@ilha/router 0.9.2 → 0.10.1

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