@ilha/router 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,498 @@
1
+ import { M as parsePattern, N as safeDecode, b as resolveRedirectTarget, j as matchSegments } from "./src-B5dHU24f.js";
2
+ import { setServerManifestSerializer } from "ilha/internal";
3
+ import { AsyncLocalStorage } from "node:async_hooks";
4
+
5
+ //#region src/request-scope.ts
6
+ /**
7
+ * Request scope for server-owned island rendering.
8
+ *
9
+ * A `.server.tsx` island's render function always executes on the server —
10
+ * page SSR through the router, or streamed frames through the plugin's
11
+ * `/__ilha/frame` endpoint. Both seed this scope with the originating
12
+ * `Request`, so render functions can read request data (URL, headers,
13
+ * cookies) through `useContext().request` or a host integration such as Oxide's `useRequest()`.
14
+ *
15
+ * The storage lives on `globalThis` under `ilha.requestAls` so every module
16
+ * copy (plugin bundle, SSR graph) shares one instance. The public accessor
17
+ * is `useContext()` from the main `@ilha/router` entry, which reads the
18
+ * storage without importing `node:async_hooks`; this node-only module is the
19
+ * sole place that constructs it.
20
+ */
21
+ const REQUEST_ALS_KEY = Symbol.for("ilha.requestAls");
22
+ /** Installed by oxidejs when its module loads. Lets `useRequest()` resolve
23
+ * inside island renders and frames, not just `/__oxide/action`. */
24
+ const OXIDE_RUN_WITH_REQUEST = Symbol.for("oxidejs.runWithRequest");
25
+ /** Run `fn` with `request` available to `useContext().request`. When oxidejs
26
+ * is loaded, its action scope is entered too, so `useRequest()` works in
27
+ * island renders and streamed frames. */
28
+ function runWithIslandRequest(request, fn) {
29
+ const g = globalThis;
30
+ const als = g[REQUEST_ALS_KEY] ??= new AsyncLocalStorage();
31
+ const oxide = g[OXIDE_RUN_WITH_REQUEST];
32
+ return als.run(request, () => oxide ? oxide(request, fn) : fn());
33
+ }
34
+
35
+ //#endregion
36
+ //#region src/ssr.ts
37
+ /**
38
+ * Production SSR endpoints for server-owned islands and regular-page loads.
39
+ *
40
+ * Default export is an oxidejs-style fetch middleware:
41
+ * `(request) => Response | undefined`. Returns `undefined` for any request it
42
+ * does not own, so hosts can chain it ahead of their own handler:
43
+ *
44
+ * ```ts
45
+ * oxide({ middleware: ["@ilha/router/ssr"] });
46
+ * ```
47
+ *
48
+ * Serves:
49
+ * - `POST /__ilha/frame` — re-renders a server island (JSON `{ id, path }` in,
50
+ * `{ html }` out). Renderers come from the process-global registry
51
+ * populated by self-registration code appended to `.server` modules.
52
+ * - `GET /__ilha/loader?path=…` — regular-page server loads via the loader
53
+ * runner (`setFrameLoaderRunner`, wired by the generated server module).
54
+ */
55
+ const REGISTRY_KEY = Symbol.for("ilha.serverIslandRenderers");
56
+ function registry() {
57
+ const g = globalThis;
58
+ let map = g[REGISTRY_KEY];
59
+ if (!map) {
60
+ map = /* @__PURE__ */ new Map();
61
+ g[REGISTRY_KEY] = map;
62
+ }
63
+ return map;
64
+ }
65
+ const GUARD_KEY = Symbol.for("ilha.frameGuard");
66
+ /**
67
+ * Install a guard consulted by every `/__ilha/frame` request (dev middleware
68
+ * and the production `@ilha/router/ssr` handler share this slot — both read
69
+ * it from `globalThis`). Return a `Response` to reject; return nothing to
70
+ * allow. Island state is world-readable through frames unless you gate them,
71
+ * so apps serving private data should install a session check here.
72
+ */
73
+ function setFrameGuard(guard) {
74
+ const g = globalThis;
75
+ g[GUARD_KEY] = guard;
76
+ }
77
+ function getFrameGuard() {
78
+ return globalThis[GUARD_KEY];
79
+ }
80
+ const LOADER_GUARD_KEY = Symbol.for("ilha.loaderGuard");
81
+ /**
82
+ * Install a guard consulted only by `GET /__ilha/loader`. When absent, the
83
+ * loader endpoint falls back to `getFrameGuard()` for backwards compatibility.
84
+ * Prefer a dedicated loader guard so gating the loader endpoint is independent
85
+ * of frame rendering.
86
+ */
87
+ function setLoaderGuard(guard) {
88
+ const g = globalThis;
89
+ g[LOADER_GUARD_KEY] = guard;
90
+ }
91
+ function getLoaderGuard() {
92
+ return globalThis[LOADER_GUARD_KEY];
93
+ }
94
+ const AUTH_KEY = Symbol.for("ilha.frameAuth");
95
+ /**
96
+ * Install the frame-authorization policy consumed by the production
97
+ * `@ilha/router/ssr` handler. `trustedOrigins` and `csrf` are also applied by
98
+ * the dev middleware (via `IlhaPagesOptions`).
99
+ */
100
+ function setFrameAuth(policy) {
101
+ const g = globalThis;
102
+ g[AUTH_KEY] = policy;
103
+ }
104
+ function getFrameAuth() {
105
+ return globalThis[AUTH_KEY];
106
+ }
107
+ function normalizeOrigin(value) {
108
+ try {
109
+ return new URL(value).origin;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+ /**
115
+ * Same-origin check for frame/loader requests. Browsers always send `Origin`
116
+ * on cross-origin and same-origin `POST`; its absence implies a non-browser
117
+ * caller (allowed — gate those via a guard or `csrf`). When `Origin` is
118
+ * present it must match the configured trusted origins, else the request's
119
+ * own `Host`.
120
+ */
121
+ function isTrustedOrigin(request, policy) {
122
+ const originHeader = request.headers.get("origin");
123
+ if (originHeader === null) return true;
124
+ const origin = normalizeOrigin(originHeader);
125
+ if (origin === null) return false;
126
+ const trusted = policy?.trustedOrigins ?? [];
127
+ if (trusted.length > 0) return trusted.some((o) => normalizeOrigin(o) === origin);
128
+ const host = request.headers.get("host");
129
+ if (!host) return false;
130
+ return origin === `https://${host}` || origin === `http://${host}`;
131
+ }
132
+ /**
133
+ * Path-only route context for frame/loader scoped requests. Leading slash,
134
+ * no `//` or backslash (WHATWG URLs treat `\` as `/` for http(s), so a
135
+ * `\evil.com` prefix would smuggle a foreign authority past a plain `//`
136
+ * check), bounded length. `false` for anything else.
137
+ */
138
+ function isSafeFramePath(path) {
139
+ return path.startsWith("/") && !path.includes("//") && !path.includes("\\") && path.length <= 2048;
140
+ }
141
+ /** Identity headers forwarded onto scoped render/loader requests. */
142
+ const FORWARD_IDENTITY_HEADERS = [
143
+ "cookie",
144
+ "authorization",
145
+ "user-agent"
146
+ ];
147
+ /**
148
+ * Copy identity headers (cookie, authorization, user-agent) onto a fresh
149
+ * `Headers`. Accepts a `Headers` or a Node `IncomingHttpHeaders`-style plain
150
+ * object. Client-supplied `x-forwarded-for` is deliberately NOT forwarded —
151
+ * it is spoofable and must not be trusted by loaders for IP checks.
152
+ */
153
+ function forwardIdentityHeaders(source) {
154
+ const out = new Headers();
155
+ const read = (name) => {
156
+ const s = source;
157
+ if (typeof s.get === "function") return s.get(name);
158
+ const v = source[name];
159
+ return Array.isArray(v) ? v[0] : v;
160
+ };
161
+ for (const name of FORWARD_IDENTITY_HEADERS) {
162
+ const v = read(name);
163
+ if (v !== null && v !== void 0) out.set(name, v);
164
+ }
165
+ return out;
166
+ }
167
+ function frameEnvelope(status, body) {
168
+ return {
169
+ status,
170
+ headers: {
171
+ "cache-control": "no-store",
172
+ "content-type": "application/json;charset=utf-8"
173
+ },
174
+ body: JSON.stringify(body)
175
+ };
176
+ }
177
+ const LOADER_RUNNER_KEY = Symbol.for("ilha.frameLoaderRunner");
178
+ /**
179
+ * Install the handler backing `GET /__ilha/loader` in production. The
180
+ * generated `pages.server.ts` wires this to `pageRouter.runLoader`, so
181
+ * regular-page server loads get full route matching, layout chains, and
182
+ * redirect/error semantics. Dev and prod handlers share the slot.
183
+ */
184
+ function setFrameLoaderRunner(runner) {
185
+ const g = globalThis;
186
+ g[LOADER_RUNNER_KEY] = runner;
187
+ }
188
+ function getFrameLoaderRunner() {
189
+ return globalThis[LOADER_RUNNER_KEY];
190
+ }
191
+ setServerManifestSerializer({ template(manifest) {
192
+ return `<template data-ilha-actions='${JSON.stringify(Object.fromEntries(manifest)).replace(/&/g, "&amp;").replace(/'/g, "&#39;").replace(/</g, "&lt;")}'></template>`;
193
+ } });
194
+ /** Register `id` → entry. Later registrations win (id encodes file + name). */
195
+ /**
196
+ * Wrap an exported server action with a named transport key. Ilha never
197
+ * executes event-handler closures during server rendering (fail closed), so
198
+ * this is a transparent passthrough kept for API compatibility; the `key`
199
+ * documents the RPC transport name used by tooling.
200
+ */
201
+ function __ilhaServerAction(key, fn) {
202
+ return typeof fn === "function" ? fn : () => fn;
203
+ }
204
+ function registerServerIsland(id, render, options) {
205
+ registry().set(id, {
206
+ render,
207
+ load: options?.load,
208
+ pattern: options?.pattern
209
+ });
210
+ }
211
+ function getServerIslandEntry(id) {
212
+ return registry().get(id);
213
+ }
214
+ /** Client-facing frame failure. `redirect` carries a loader redirect target. */
215
+ var FrameError = class extends Error {
216
+ status;
217
+ redirect;
218
+ constructor(status, message, redirect) {
219
+ super(message);
220
+ this.status = status;
221
+ this.redirect = redirect;
222
+ }
223
+ };
224
+ /** Match a route pattern (`/user/:id`, `/docs/**:slug`) against a pathname.
225
+ * Returns decoded params, or null when the path doesn't match. Shares the
226
+ * router's matcher semantics via `route-match.ts`. */
227
+ function matchPatternParams(pattern, pathname) {
228
+ const raw = matchSegments(parsePattern(pattern).segments, pathname);
229
+ if (!raw) return null;
230
+ const params = {};
231
+ for (const [k, v] of Object.entries(raw)) params[k] = safeDecode(v);
232
+ return params;
233
+ }
234
+ /**
235
+ * Shared tail of every frame request: run the page's `load` when registered
236
+ * (params matched from the frame path), then invoke the renderer inside the
237
+ * caller's scope. Throws `FrameError` with an HTTP status for client-facing
238
+ * failures; loader redirects surface via `FrameError.redirect`.
239
+ */
240
+ async function renderServerIsland(id, request, runWithScope, onHead) {
241
+ const entry = registry().get(id);
242
+ if (!entry) throw new FrameError(400, "unknown island");
243
+ let props;
244
+ if (entry.load) {
245
+ let url;
246
+ try {
247
+ url = new URL(request.url);
248
+ } catch {
249
+ throw new FrameError(400, "frame failed");
250
+ }
251
+ const params = entry.pattern ? matchPatternParams(entry.pattern, url.pathname) : {};
252
+ if (!params) throw new FrameError(400, "frame failed");
253
+ try {
254
+ const headEntries = [];
255
+ const result = await entry.load({
256
+ params,
257
+ request,
258
+ url,
259
+ signal: request.signal,
260
+ head: (input) => headEntries.push(input)
261
+ });
262
+ if (headEntries.length > 0) onHead?.(headEntries);
263
+ props = { load: {
264
+ loading: false,
265
+ value: result ?? {},
266
+ error: void 0
267
+ } };
268
+ } catch (error) {
269
+ const marker = error;
270
+ if (marker.__ilhaRedirect === true) {
271
+ const r = error;
272
+ const safe = resolveRedirectTarget(r.to, url, false);
273
+ if (!safe.ok) throw new FrameError(500, "unsafe redirect target");
274
+ throw new FrameError(r.status || 302, "frame failed", safe.to);
275
+ }
276
+ if (marker.__ilhaLoaderError === true) throw new FrameError(error.status || 500, "frame failed");
277
+ throw error;
278
+ }
279
+ }
280
+ const render = entry.render();
281
+ if (typeof render !== "function") throw new FrameError(400, "unknown island");
282
+ const html = await runWithScope(request, () => render(props));
283
+ return String(html);
284
+ }
285
+ const FRAME_ENDPOINT = "/__ilha/frame";
286
+ /** Regular-page server loads: served through the loader-runner slot. */
287
+ const LOADER_ENDPOINT = "/__ilha/loader";
288
+ /** Max request body size — matches the dev middleware cap. */
289
+ const MAX_BODY = 16384;
290
+ function json(status, body) {
291
+ const env = frameEnvelope(status, body);
292
+ return new Response(env.body, {
293
+ status: env.status,
294
+ headers: env.headers
295
+ });
296
+ }
297
+ /**
298
+ * Read a request body as UTF-8, streaming it with a hard byte cap. Returns
299
+ * `null` when the body exceeds `maxBytes` (the reader is cancelled before the
300
+ * cap is far exceeded) or when decoding fails.
301
+ */
302
+ async function readBodyBounded(request, maxBytes) {
303
+ const contentLength = request.headers.get("content-length");
304
+ if (contentLength !== null && Number(contentLength) > maxBytes) return null;
305
+ const reader = request.body?.getReader();
306
+ if (!reader) return "";
307
+ const chunks = [];
308
+ let size = 0;
309
+ for (;;) {
310
+ const { done, value } = await reader.read();
311
+ if (done) break;
312
+ size += value?.byteLength ?? 0;
313
+ if (size > maxBytes) {
314
+ await reader.cancel().catch(() => {});
315
+ return null;
316
+ }
317
+ chunks.push(value);
318
+ }
319
+ const decoder = new TextDecoder();
320
+ return chunks.map((c) => decoder.decode(c, { stream: true })).join("") + decoder.decode();
321
+ }
322
+ /**
323
+ * Shared frame-request authorization used by both the production handler
324
+ * below and the Vite/Rsbuild dev middleware: same-origin check against the
325
+ * frame-auth policy, the registered frame guard, and the optional CSRF
326
+ * verifier. `defaultAction` selects the deny-by-default production posture or
327
+ * the permissive development one.
328
+ *
329
+ * Returns the forwarded identity headers on success so callers render frames
330
+ * with cookie/auth/UA context, or the HTTP status to reject with.
331
+ */
332
+ async function authorizeFrameRequest(request, options) {
333
+ const auth = getFrameAuth();
334
+ if (!isTrustedOrigin(request, auth)) return {
335
+ ok: false,
336
+ status: 403
337
+ };
338
+ const guard = getFrameGuard();
339
+ if (!guard && (auth?.defaultAction ?? "deny") === "deny") return {
340
+ ok: false,
341
+ status: 403
342
+ };
343
+ try {
344
+ const denied = await guard?.(request);
345
+ if (denied) return {
346
+ ok: false,
347
+ status: denied.status
348
+ };
349
+ } catch (error) {
350
+ options.onGuardError?.(error);
351
+ return {
352
+ ok: false,
353
+ status: 403
354
+ };
355
+ }
356
+ if (auth?.csrf) try {
357
+ if (!await auth.csrf(request)) return {
358
+ ok: false,
359
+ status: 403
360
+ };
361
+ } catch {
362
+ return {
363
+ ok: false,
364
+ status: 403
365
+ };
366
+ }
367
+ return {
368
+ ok: true,
369
+ identityHeaders: forwardIdentityHeaders(request.headers)
370
+ };
371
+ }
372
+ async function ssr(request) {
373
+ let pathname;
374
+ try {
375
+ pathname = new URL(request.url).pathname;
376
+ } catch {
377
+ return json(400, { error: "frame failed" });
378
+ }
379
+ if (pathname !== "/__ilha/frame" && pathname !== "/__ilha/loader") return;
380
+ const auth = getFrameAuth();
381
+ if (!isTrustedOrigin(request, auth)) return json(403, { error: "frame failed" });
382
+ if (pathname === "/__ilha/loader") {
383
+ if (request.method !== "GET") return json(405, { error: "method not allowed" });
384
+ const guard = getLoaderGuard() ?? getFrameGuard();
385
+ if (!guard && (auth?.defaultAction ?? "deny") === "deny") return json(403, { error: "loader failed" });
386
+ try {
387
+ const denied = await guard?.(request);
388
+ if (denied) return denied;
389
+ } catch {
390
+ return json(403, { error: "loader failed" });
391
+ }
392
+ const runner = getFrameLoaderRunner();
393
+ if (!runner) return json(404, {
394
+ kind: "error",
395
+ status: 404,
396
+ message: "not found"
397
+ });
398
+ const cl = request.headers.get("content-length");
399
+ if (cl && Number(cl) > 16384) return json(413, { error: "frame failed" });
400
+ let target = "/";
401
+ try {
402
+ target = new URL(request.url).searchParams.get("path") ?? "/";
403
+ } catch {
404
+ return json(400, {
405
+ kind: "error",
406
+ status: 400,
407
+ message: "bad request"
408
+ });
409
+ }
410
+ if (!isSafeFramePath(target)) return json(400, {
411
+ kind: "error",
412
+ status: 400,
413
+ message: "bad request"
414
+ });
415
+ try {
416
+ const result = await runWithIslandRequest(request, () => runner(target, request));
417
+ if (result.kind === "redirect") return json(result.status || 302, {
418
+ kind: "redirect",
419
+ to: result.to,
420
+ status: result.status
421
+ });
422
+ if (result.kind !== "data") {
423
+ const status = result.status || 500;
424
+ return json(status, {
425
+ kind: result.kind,
426
+ status,
427
+ message: result.message
428
+ });
429
+ }
430
+ return json(200, result);
431
+ } catch (error) {
432
+ console.error("[ilha-router] loader endpoint failed:", error);
433
+ return json(500, {
434
+ kind: "error",
435
+ status: 500,
436
+ message: "loader failed"
437
+ });
438
+ }
439
+ }
440
+ if (request.method !== "POST") return json(405, { error: "frame failed" });
441
+ if (!(request.headers.get("content-type") ?? "").startsWith("application/json")) return json(415, { error: "frame failed" });
442
+ const authorized = await authorizeFrameRequest(request, {
443
+ defaultAction: auth?.defaultAction ?? "deny",
444
+ onGuardError: (error) => console.error("[ilha-router] frame guard failed:", error)
445
+ });
446
+ if (!authorized.ok) return json(authorized.status, { error: "frame failed" });
447
+ let id;
448
+ let path = "/";
449
+ try {
450
+ const text = await readBodyBounded(request, MAX_BODY);
451
+ if (text === null) return json(413, { error: "frame failed" });
452
+ const body = JSON.parse(text);
453
+ id = String(body.id ?? "");
454
+ if (typeof body.path === "string") {
455
+ if (!isSafeFramePath(body.path)) return json(400, { error: "frame failed" });
456
+ path = body.path;
457
+ }
458
+ } catch {
459
+ return json(400, { error: "frame failed" });
460
+ }
461
+ try {
462
+ let origin;
463
+ try {
464
+ origin = new URL(request.url).origin;
465
+ } catch {
466
+ return json(400, { error: "frame failed" });
467
+ }
468
+ const headers = forwardIdentityHeaders(request.headers);
469
+ const scoped = new Request(new URL(path, origin), {
470
+ method: "POST",
471
+ headers
472
+ });
473
+ for (const sym of Object.getOwnPropertySymbols(request)) {
474
+ if (Symbol.keyFor(sym) === void 0) continue;
475
+ try {
476
+ scoped[sym] = request[sym];
477
+ } catch {}
478
+ }
479
+ let head;
480
+ return json(200, {
481
+ html: await renderServerIsland(id, scoped, (scopedRequest, fn) => Promise.resolve(runWithIslandRequest(scopedRequest, fn)), (entries) => head = entries),
482
+ head
483
+ });
484
+ } catch (error) {
485
+ if (error instanceof FrameError) {
486
+ if (error.redirect) return json(error.status, { redirect: error.redirect });
487
+ if (error.status >= 500) console.error("[ilha-router] frame render failed:", error);
488
+ return json(error.status, { error: "frame failed" });
489
+ }
490
+ console.error("[ilha-router] frame render failed:", error);
491
+ return json(400, { error: "frame failed" });
492
+ }
493
+ }
494
+ /** Side-effect imports required alongside this handler. */
495
+ ssr.imports = ["ilha:pages/server", "ilha:loaders"];
496
+
497
+ //#endregion
498
+ export { setLoaderGuard as C, setFrameLoaderRunner as S, runWithIslandRequest as T, readBodyBounded as _, __ilhaServerAction as a, setFrameAuth as b, frameEnvelope as c, getFrameLoaderRunner as d, getLoaderGuard as f, json as g, isTrustedOrigin as h, MAX_BODY as i, getFrameAuth as l, isSafeFramePath as m, FrameError as n, authorizeFrameRequest as o, getServerIslandEntry as p, LOADER_ENDPOINT as r, forwardIdentityHeaders as s, FRAME_ENDPOINT as t, getFrameGuard as u, registerServerIsland as v, ssr as w, setFrameGuard as x, renderServerIsland as y };
package/dist/ssr.d.ts CHANGED
@@ -16,6 +16,159 @@
16
16
  * - `GET /__ilha/loader?path=…` — regular-page server loads via the loader
17
17
  * runner (`setFrameLoaderRunner`, wired by the generated server module).
18
18
  */
19
+ import type { HeadInput } from "./head";
20
+ /**
21
+ * Server-frame state shared by the dev middleware and the production
22
+ * `@ilha/router/ssr` handler: the renderers registry (keyed by the public
23
+ * island id, `sha256(file#name)`, see `serverIslandPublicId`), frame/loader
24
+ * guards and auth policy, and the loader runner. Lives on `globalThis` so
25
+ * every module copy (plugin bundle, SSR graph, frame entry) shares one
26
+ * instance — same pattern as `request-scope.ts`.
27
+ *
28
+ * `.server` modules self-register when the plugin appends registration code
29
+ * to their server-graph copy; the `/__ilha/frame` handler below consumes the
30
+ * registry to re-render an island from a client state snapshot. Server pages
31
+ * additionally register their `load` and route pattern so frame handlers can
32
+ * run the loader with matched params.
33
+ */
34
+ /** Loader context for server-page `load` — mirrors the router's shape. */
35
+ export interface FrameLoaderContext {
36
+ params: Record<string, string>;
37
+ request: Request;
38
+ url: URL;
39
+ signal: AbortSignal;
40
+ /** Contribute `<head>` data for this route. Safe to call multiple times. */
41
+ head: (input: HeadInput) => void;
42
+ }
43
+ export type ServerPageLoader = (ctx: FrameLoaderContext) => unknown;
44
+ /** A frame render: optionally preceded by running the page's `load`. */
45
+ export interface ServerIslandEntry {
46
+ /** Returns the renderState fn (`Symbol.for("ilha.renderState")` getter). */
47
+ render: () => unknown;
48
+ /** The module's `load` export — runs at frame time; its return value
49
+ * becomes the island's render props. */
50
+ load?: ServerPageLoader;
51
+ /** Route pattern for the page (`/user/:id`) — matches params for `load`. */
52
+ pattern?: string;
53
+ }
54
+ export type FrameGuard = (request: Request) => Response | void | Promise<Response | void>;
55
+ /**
56
+ * Install a guard consulted by every `/__ilha/frame` request (dev middleware
57
+ * and the production `@ilha/router/ssr` handler share this slot — both read
58
+ * it from `globalThis`). Return a `Response` to reject; return nothing to
59
+ * allow. Island state is world-readable through frames unless you gate them,
60
+ * so apps serving private data should install a session check here.
61
+ */
62
+ export declare function setFrameGuard(guard: FrameGuard): void;
63
+ export declare function getFrameGuard(): FrameGuard | undefined;
64
+ /**
65
+ * Install a guard consulted only by `GET /__ilha/loader`. When absent, the
66
+ * loader endpoint falls back to `getFrameGuard()` for backwards compatibility.
67
+ * Prefer a dedicated loader guard so gating the loader endpoint is independent
68
+ * of frame rendering.
69
+ */
70
+ export declare function setLoaderGuard(guard: FrameGuard): void;
71
+ export declare function getLoaderGuard(): FrameGuard | undefined;
72
+ /** Frame-authorization policy, installed via {@link setFrameAuth}. */
73
+ export interface FrameAuthPolicy {
74
+ /**
75
+ * Action taken when no frame guard is registered. `"deny"` (default in the
76
+ * production handler) rejects every `/__ilha/frame` request with 403;
77
+ * `"open"` preserves the legacy unauthenticated behavior. The dev
78
+ * middleware stays permissive unless a guard is registered.
79
+ */
80
+ defaultAction?: "open" | "deny";
81
+ /**
82
+ * Explicit trusted origins (e.g. `"https://app.example.com"`). When set,
83
+ * origin checks accept only these; otherwise the check compares the `Origin`
84
+ * header against `https://{host}` / `http://{host}`.
85
+ */
86
+ trustedOrigins?: string[];
87
+ /**
88
+ * Optional CSRF verifier for the state-changing frame POST. Receives the
89
+ * original `Request`; returning falsy rejects the request. Use this for
90
+ * server-to-server frame callers that have no browser `Origin`.
91
+ */
92
+ csrf?: (request: Request) => boolean | Promise<boolean>;
93
+ }
94
+ /**
95
+ * Install the frame-authorization policy consumed by the production
96
+ * `@ilha/router/ssr` handler. `trustedOrigins` and `csrf` are also applied by
97
+ * the dev middleware (via `IlhaPagesOptions`).
98
+ */
99
+ export declare function setFrameAuth(policy: FrameAuthPolicy): void;
100
+ export declare function getFrameAuth(): FrameAuthPolicy | undefined;
101
+ /**
102
+ * Same-origin check for frame/loader requests. Browsers always send `Origin`
103
+ * on cross-origin and same-origin `POST`; its absence implies a non-browser
104
+ * caller (allowed — gate those via a guard or `csrf`). When `Origin` is
105
+ * present it must match the configured trusted origins, else the request's
106
+ * own `Host`.
107
+ */
108
+ export declare function isTrustedOrigin(request: Request, policy: FrameAuthPolicy | undefined): boolean;
109
+ /**
110
+ * Path-only route context for frame/loader scoped requests. Leading slash,
111
+ * no `//` or backslash (WHATWG URLs treat `\` as `/` for http(s), so a
112
+ * `\evil.com` prefix would smuggle a foreign authority past a plain `//`
113
+ * check), bounded length. `false` for anything else.
114
+ */
115
+ export declare function isSafeFramePath(path: string): boolean;
116
+ /**
117
+ * Copy identity headers (cookie, authorization, user-agent) onto a fresh
118
+ * `Headers`. Accepts a `Headers` or a Node `IncomingHttpHeaders`-style plain
119
+ * object. Client-supplied `x-forwarded-for` is deliberately NOT forwarded —
120
+ * it is spoofable and must not be trusted by loaders for IP checks.
121
+ */
122
+ export declare function forwardIdentityHeaders(source: Headers | Record<string, string | string[] | undefined>): Headers;
123
+ /** No-store JSON envelope shared by dev and production frame handlers. */
124
+ export interface FrameEnvelope {
125
+ status: number;
126
+ headers: Record<string, string>;
127
+ body: string;
128
+ }
129
+ export declare function frameEnvelope(status: number, body: Record<string, unknown>): FrameEnvelope;
130
+ export type FrameLoaderRunner = (path: string, request?: Request) => Promise<{
131
+ kind: string;
132
+ data?: unknown;
133
+ headEntries?: unknown;
134
+ status?: number;
135
+ to?: string;
136
+ message?: string;
137
+ }>;
138
+ /**
139
+ * Install the handler backing `GET /__ilha/loader` in production. The
140
+ * generated `pages.server.ts` wires this to `pageRouter.runLoader`, so
141
+ * regular-page server loads get full route matching, layout chains, and
142
+ * redirect/error semantics. Dev and prod handlers share the slot.
143
+ */
144
+ export declare function setFrameLoaderRunner(runner: FrameLoaderRunner): void;
145
+ export declare function getFrameLoaderRunner(): FrameLoaderRunner | undefined;
146
+ /** Register `id` → entry. Later registrations win (id encodes file + name). */
147
+ /**
148
+ * Wrap an exported server action with a named transport key. Ilha never
149
+ * executes event-handler closures during server rendering (fail closed), so
150
+ * this is a transparent passthrough kept for API compatibility; the `key`
151
+ * documents the RPC transport name used by tooling.
152
+ */
153
+ export declare function __ilhaServerAction<A extends unknown[], R>(key: string, fn: (...args: A) => R): (...args: A) => R | undefined;
154
+ export declare function registerServerIsland(id: string, render: () => unknown, options?: {
155
+ load?: ServerPageLoader;
156
+ pattern?: string;
157
+ }): void;
158
+ export declare function getServerIslandEntry(id: string): ServerIslandEntry | undefined;
159
+ /** Client-facing frame failure. `redirect` carries a loader redirect target. */
160
+ export declare class FrameError extends Error {
161
+ status: number;
162
+ redirect?: string;
163
+ constructor(status: number, message: string, redirect?: string);
164
+ }
165
+ /**
166
+ * Shared tail of every frame request: run the page's `load` when registered
167
+ * (params matched from the frame path), then invoke the renderer inside the
168
+ * caller's scope. Throws `FrameError` with an HTTP status for client-facing
169
+ * failures; loader redirects surface via `FrameError.redirect`.
170
+ */
171
+ export declare function renderServerIsland(id: string, request: Request, runWithScope: <T>(request: Request, fn: () => T) => T | Promise<T>, onHead?: (entries: HeadInput[]) => void): Promise<string>;
19
172
  export declare const FRAME_ENDPOINT = "/__ilha/frame";
20
173
  /** Regular-page server loads: served through the loader-runner slot. */
21
174
  export declare const LOADER_ENDPOINT = "/__ilha/loader";
@@ -28,5 +181,25 @@ export declare function json(status: number, body: Record<string, unknown>): Res
28
181
  * cap is far exceeded) or when decoding fails.
29
182
  */
30
183
  export declare function readBodyBounded(request: Request, maxBytes: number): Promise<string | null>;
184
+ /**
185
+ * Shared frame-request authorization used by both the production handler
186
+ * below and the Vite/Rsbuild dev middleware: same-origin check against the
187
+ * frame-auth policy, the registered frame guard, and the optional CSRF
188
+ * verifier. `defaultAction` selects the deny-by-default production posture or
189
+ * the permissive development one.
190
+ *
191
+ * Returns the forwarded identity headers on success so callers render frames
192
+ * with cookie/auth/UA context, or the HTTP status to reject with.
193
+ */
194
+ export declare function authorizeFrameRequest(request: Request, options: {
195
+ defaultAction: "open" | "deny";
196
+ onGuardError?: (error: unknown) => void;
197
+ }): Promise<{
198
+ ok: true;
199
+ identityHeaders: Headers;
200
+ } | {
201
+ ok: false;
202
+ status: number;
203
+ }>;
31
204
  declare function ssr(request: Request): Promise<Response | undefined>;
32
205
  export default ssr;