@ilha/router 0.11.7 → 0.11.9

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,151 @@
1
+ /**
2
+ * Shared server-frame helpers used by the Vite/Rsbuild pages plugin and by
3
+ * `@ilha/router/ssr`. Kept free of the optional `oxidejs` peer so
4
+ * `@ilha/router/vite` can load without oxide installed.
5
+ *
6
+ * Server-frame state (renderers registry, frame/loader guards, auth policy)
7
+ * lives on `globalThis` so every module copy (plugin bundle, SSR graph, frame
8
+ * entry) shares one instance — same pattern as `request-scope.ts`.
9
+ */
10
+ import * as Effect from "effect/Effect";
11
+ import type * as Result from "effect/Result";
12
+ import type { SnapshotObject } from "./snapshot";
13
+ /** JSON payload for frame envelopes. */
14
+ export type FrameJsonValue = string | number | boolean | null | FrameJsonValue[] | FrameJsonObject;
15
+ export interface FrameJsonObject {
16
+ readonly [key: string]: FrameJsonValue | undefined;
17
+ }
18
+ /** A frame render: optionally preceded by running the page's `load`. */
19
+ export interface ServerIslandEntry {
20
+ /** Returns the renderState fn (`Symbol.for("ilha.renderState")` getter). */
21
+ render: () => ServerIslandRenderFn;
22
+ }
23
+ export type ServerIslandRenderFn = (props?: SnapshotObject) => string | Promise<string> | object;
24
+ export type FrameGuard = (request: Request) => Response | undefined | Promise<Response | undefined>;
25
+ /**
26
+ * Install a guard consulted by every `/__ilha/frame` request (dev middleware
27
+ * and the production `@ilha/router/ssr` handler share this slot — both read
28
+ * it from `globalThis`). Return a `Response` to reject; return nothing to
29
+ * allow. Island state is world-readable through frames unless you gate them,
30
+ * so apps serving private data should install a session check here.
31
+ */
32
+ export declare const setFrameGuard: (guard: FrameGuard) => void;
33
+ export declare const getFrameGuard: () => FrameGuard | undefined;
34
+ /** Frame-authorization policy, installed via {@link setFrameAuth}. */
35
+ export interface FrameAuthPolicy {
36
+ /**
37
+ * Action taken when no frame guard is registered. `"deny"` (default in the
38
+ * production handler) rejects every `/__ilha/frame` request with 403;
39
+ * `"open"` preserves the legacy unauthenticated behavior. The dev
40
+ * middleware stays permissive unless a guard is registered.
41
+ */
42
+ defaultAction?: "open" | "deny";
43
+ /**
44
+ * Explicit trusted origins (e.g. `"https://app.example.com"`). When set,
45
+ * origin checks accept only these; otherwise the check compares the `Origin`
46
+ * header against `https://{host}` / `http://{host}`.
47
+ */
48
+ trustedOrigins?: string[];
49
+ /**
50
+ * Optional CSRF verifier for the state-changing frame POST. Receives the
51
+ * original `Request`; returning falsy rejects the request. Use this for
52
+ * server-to-server frame callers that have no browser `Origin`.
53
+ */
54
+ csrf?: (request: Request) => boolean | Promise<boolean>;
55
+ }
56
+ /**
57
+ * Install the frame-authorization policy consumed by the production
58
+ * `@ilha/router/ssr` handler. `trustedOrigins` and `csrf` are also applied by
59
+ * the dev middleware (via `IlhaPagesOptions`).
60
+ */
61
+ export declare const setFrameAuth: (policy: FrameAuthPolicy) => void;
62
+ export declare const getFrameAuth: () => FrameAuthPolicy | undefined;
63
+ /**
64
+ * Same-origin check for frame/loader requests. Browsers always send `Origin`
65
+ * on cross-origin and same-origin `POST`; its absence implies a non-browser
66
+ * caller (allowed — gate those via a guard or `csrf`). When `Origin` is
67
+ * present it must match the configured trusted origins, else the request's
68
+ * own `Host`.
69
+ */
70
+ export declare const isTrustedOrigin: (request: Request, policy: FrameAuthPolicy | undefined) => boolean;
71
+ /**
72
+ * Path-only route context for frame/loader scoped requests. Leading slash,
73
+ * no `//` or backslash (WHATWG URLs treat `\` as `/` for http(s), so a
74
+ * `\evil.com` prefix would smuggle a foreign authority past a plain `//`
75
+ * check), bounded length. `false` for anything else.
76
+ */
77
+ export declare const isSafeFramePath: (framePath: string) => boolean;
78
+ /**
79
+ * Build the scoped frame render URL from the incoming request URL and frame
80
+ * path. Absolute `incomingUrl` values supply the origin. Relative values (for
81
+ * example Vite's `req.url`) require an explicit trusted `serverOrigin` — never
82
+ * a client `Host` header.
83
+ */
84
+ export declare const frameScopedUrl: (incomingUrl: string, framePath: string, serverOrigin?: string) => string;
85
+ type HeaderSource = Headers | Readonly<Record<string, string | string[] | undefined>>;
86
+ /**
87
+ * Copy identity headers (cookie, authorization, user-agent) onto a fresh
88
+ * `Headers`. Accepts a `Headers` or a Node `IncomingHttpHeaders`-style plain
89
+ * object. Client-supplied `x-forwarded-for` is deliberately NOT forwarded —
90
+ * it is spoofable and must not be trusted by loaders for IP checks.
91
+ */
92
+ export declare const forwardIdentityHeaders: (source: HeaderSource) => Headers;
93
+ /** No-store JSON envelope shared by dev and production frame handlers. */
94
+ export interface FrameEnvelope {
95
+ status: number;
96
+ headers: Record<string, string>;
97
+ body: string;
98
+ }
99
+ export declare const frameEnvelope: (status: number, body: FrameJsonObject) => FrameEnvelope;
100
+ export declare const registerServerIsland: (id: string, render: () => ServerIslandRenderFn) => void;
101
+ export declare const getServerIslandEntry: (id: string) => ServerIslandEntry | undefined;
102
+ declare const FrameError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
103
+ readonly _tag: "FrameError";
104
+ } & Readonly<A>;
105
+ /** Client-facing frame failure. `redirect` carries a same-origin redirect target. */
106
+ export declare class FrameError extends FrameError_base<{
107
+ status: number;
108
+ message?: string;
109
+ redirect?: string;
110
+ }> {
111
+ }
112
+ /**
113
+ * Render a registered server island. Typed error channel: the only failure is
114
+ * `FrameError`; everything else is a defect surfaced as a 400 to the client.
115
+ */
116
+ export declare const renderServerIsland: (id: string, request: Request, runWithScope: <T>(request: Request, fn: () => T) => T | Promise<T>, incomingProps?: SnapshotObject) => Effect.Effect<string, FrameError>;
117
+ /** Convenience for non-Effect callers: run the render and resolve to a Result. */
118
+ export declare const renderServerIslandResult: (id: string, request: Request, runWithScope: <T>(request: Request, fn: () => T) => T | Promise<T>, incomingProps?: SnapshotObject) => Promise<Result.Result<string, FrameError>>;
119
+ export declare const FRAME_ENDPOINT = "/__ilha/frame";
120
+ /** Max request body size — matches the dev middleware cap. */
121
+ export declare const MAX_BODY: number;
122
+ /** Parent-island props on a frame POST. Missing is fine; anything else is 400. */
123
+ export declare const parseFrameProps: <T>(value?: T) => SnapshotObject | undefined;
124
+ export declare const json: (status: number, body: FrameJsonObject) => Response;
125
+ /**
126
+ * Read a request body as UTF-8, streaming it with a hard byte cap. Returns
127
+ * `null` when the body exceeds `maxBytes` (the reader is cancelled before the
128
+ * cap is far exceeded) or when decoding fails.
129
+ */
130
+ export declare const readBodyBounded: (request: Request, maxBytes: number) => Promise<string | null>;
131
+ /**
132
+ * Shared frame-request authorization used by both the production handler
133
+ * below and the Vite/Rsbuild dev middleware: same-origin check against the
134
+ * frame-auth policy, the registered frame guard, and the optional CSRF
135
+ * verifier. `defaultAction` selects the deny-by-default production posture or
136
+ * the permissive development one.
137
+ *
138
+ * Returns the forwarded identity headers on success so callers render frames
139
+ * with cookie/auth/UA context, or the HTTP status to reject with.
140
+ */
141
+ export declare const authorizeFrameRequest: (request: Request, options: {
142
+ defaultAction: "open" | "deny";
143
+ onGuardError?: <E>(error: E) => void;
144
+ }) => Promise<{
145
+ ok: true;
146
+ identityHeaders: Headers;
147
+ } | {
148
+ ok: false;
149
+ status: number;
150
+ }>;
151
+ export {};
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Oxide-branded server-action wrapper.
3
+ *
4
+ * Lives in its own module so `@ilha/router/vite` / the pages plugin can load
5
+ * frame helpers without resolving the optional `oxidejs` peer.
6
+ */
7
+ import type { SnapshotValue } from "./snapshot";
8
+ /** Brand an exported server action with its generated RPC transport key. */
9
+ export declare const __ilhaServerAction: <A extends SnapshotValue[], R extends SnapshotValue>(key: string, fn: (...args: A) => R | Promise<R>) => import("oxidejs").ServerActionHandle<A, Awaited<Awaited<R>>>;
@@ -1,4 +1,4 @@
1
- import { C as runWithIslandRequest, a as authorizeFrameRequest, b as setFrameAuth, c as frameScopedUrl, f as isSafeFramePath, h as parseFrameProps, n as FrameError, s as frameEnvelope, u as getFrameGuard, x as setFrameGuard, y as renderServerIslandResult } from "./ssr-D9q5C-OV.js";
1
+ import { a as authorizeFrameRequest, b as setFrameAuth, c as frameScopedUrl, f as isSafeFramePath, h as parseFrameProps, r as FrameError, s as frameEnvelope, t as runWithIslandRequest, u as getFrameGuard, x as setFrameGuard, y as renderServerIslandResult } from "./request-scope-CI9BJVS-.js";
2
2
  import { existsSync, readFileSync, statSync, watch } from "node:fs";
3
3
  import path from "node:path";
4
4
  import * as Result from "effect/Result";
@@ -2,158 +2,19 @@ import { t as REQUEST_ALS_KEY } from "./als-key-CVxbuM3z.js";
2
2
  import { a as withHeadStore } from "./head-WG_AtbdX.js";
3
3
  import { n as sanitizeSnapshotObject } from "./snapshot-XpAkSV8s.js";
4
4
  import { renderToString } from "ilha";
5
- import * as Result from "effect/Result";
6
- import { AsyncLocalStorage } from "node:async_hooks";
7
5
  import * as Data from "effect/Data";
8
6
  import * as Effect from "effect/Effect";
9
- import { action, brandServerAction } from "oxidejs";
10
-
11
- //#region src/webcontainer.ts
12
- /**
13
- * StackBlitz WebContainer detection for AsyncLocalStorage fallbacks.
14
- * Safe to import from browser-shared modules: on the client this always
15
- * returns false (no `process.versions.webcontainer`).
16
- */
17
- let webcontainerOverride = null;
18
- /** StackBlitz sets `process.versions.webcontainer` inside WebContainers. */
19
- const inWebcontainer = () => {
20
- if (webcontainerOverride !== null) return webcontainerOverride;
21
- try {
22
- const versions = globalThis.process?.versions;
23
- return Boolean(versions?.webcontainer);
24
- } catch {
25
- return false;
26
- }
27
- };
28
-
29
- //#endregion
30
- //#region src/request-scope.ts
31
- /**
32
- * Request scope for server-owned island rendering.
33
- *
34
- * A `.server.tsx` island's render function always executes on the server —
35
- * page SSR through the router, or streamed frames through the plugin's
36
- * `/__ilha/frame` endpoint. Both seed this scope with the originating
37
- * `Request`, so render functions can read request data (URL, headers,
38
- * cookies) through `useContext().request` or a host integration such as Oxide's `useRequest()`.
39
- *
40
- * The storage lives on `globalThis` under `ilha.requestAls` so every module
41
- * copy (plugin bundle, SSR graph) shares one instance. The public accessor
42
- * is `useContext()` from the main `@ilha/router` entry, which reads the
43
- * storage without importing `node:async_hooks`; this node-only module is the
44
- * sole place that constructs it.
45
- *
46
- * StackBlitz WebContainers lose AsyncLocalStorage across `async/await`. A
47
- * module sync fallback keeps `useContext().request` readable, and unrelated
48
- * fallback entries are serialized so concurrent renders cannot stomp each
49
- * other. Nested calls reenter only while owned by the active entry (sync nest
50
- * or ALS owner) so a suspended outer cannot admit unrelated concurrent work.
51
- */
52
- /** Installed by oxidejs when its module loads. Lets `useRequest()` resolve
53
- * inside island renders and frames, not just `/__oxide/action`. */
54
- const OXIDE_RUN_WITH_REQUEST = Symbol.for("oxidejs.runWithRequest");
55
- const innerAls = new AsyncLocalStorage();
56
- /** Tracks the active fallback lock owner across awaits where ALS survives. */
57
- const entryOwnerAls = new AsyncLocalStorage();
58
- let syncStore = null;
59
- /** When true, getStore ignores ALS so tests exercise the sync fallback path. */
60
- let alsBypassForTests = false;
61
- /** Serialize unrelated WebContainer fallback entries (set syncStore → run → clear). */
62
- let fallbackTail = Promise.resolve(null);
63
- /** Lock holder identity — nested reentry requires matching ALS owner (or sync nest). */
64
- let activeOwner = null;
65
- /**
66
- * Sync-only nest depth while `runScoped` is invoking `fn`. Lets nested
67
- * `runWithIslandRequest` reenter before `fn` awaits; cleared before awaiting
68
- * so a suspended entry does not admit unrelated concurrent requests.
69
- */
70
- let syncNestDepth = 0;
71
- const withFallbackEntry = async (fn) => {
72
- const owner = alsBypassForTests ? void 0 : entryOwnerAls.getStore();
73
- if (syncNestDepth > 0 || owner !== void 0 && owner === activeOwner) return await fn();
74
- const previous = fallbackTail;
75
- const next = Promise.withResolvers();
76
- fallbackTail = next.promise;
77
- await previous;
78
- const myOwner = {};
79
- activeOwner = myOwner;
80
- try {
81
- return await entryOwnerAls.run(myOwner, fn);
82
- } finally {
83
- activeOwner = null;
84
- next.resolve(null);
85
- }
86
- };
87
- const ensureScope = () => {
88
- const g = globalThis;
89
- const existing = g[REQUEST_ALS_KEY];
90
- if (existing && "getStore" in existing) return existing;
91
- const scope = { getStore: () => (alsBypassForTests ? void 0 : innerAls.getStore()) ?? syncStore ?? void 0 };
92
- g[REQUEST_ALS_KEY] = scope;
93
- return scope;
94
- };
95
- const runScoped = (request, oxide, fn) => innerAls.run(request, () => oxide ? oxide(request, fn) : fn());
96
- /**
97
- * Run `fn` with `request` available to `useContext().request`. When oxidejs
98
- * is loaded, its action scope is entered too, so `useRequest()` works in
99
- * island renders and streamed frames.
100
- *
101
- * On WebContainer the return is always a `Promise` (fallback lock). Elsewhere
102
- * the return matches `fn` (sync or async).
103
- */
104
- const runWithIslandRequest = (request, fn) => {
105
- ensureScope();
106
- const oxideSlot = globalThis[OXIDE_RUN_WITH_REQUEST];
107
- const oxide = oxideSlot === void 0 ? void 0 : oxideSlot;
108
- if (!inWebcontainer()) return runScoped(request, oxide, fn);
109
- return withFallbackEntry(async () => {
110
- const previous = syncStore;
111
- syncStore = request;
112
- try {
113
- syncNestDepth += 1;
114
- let result;
115
- try {
116
- result = runScoped(request, oxide, fn);
117
- } finally {
118
- syncNestDepth -= 1;
119
- }
120
- return await Promise.resolve(result);
121
- } finally {
122
- syncStore = previous;
123
- }
124
- });
125
- };
7
+ import { AsyncLocalStorage } from "node:async_hooks";
126
8
 
127
- //#endregion
128
- //#region src/ssr.ts
129
- /**
130
- * Production SSR endpoint for server-owned islands.
131
- *
132
- * Default export is an oxidejs-style fetch middleware:
133
- * `(request) => Response | undefined`. Returns `undefined` for any request it
134
- * does not own, so hosts can chain it ahead of their own handler:
135
- *
136
- * ```ts
137
- * oxide({ middleware: ["@ilha/router/ssr"] });
138
- * ```
139
- *
140
- * Serves `POST /__ilha/frame` — re-renders a server island (JSON `{ id, path }`
141
- * in, `{ html }` out). Renderers come from the process-global registry
142
- * populated by self-registration code appended to `.server` modules.
143
- */
9
+ //#region src/frame.ts
144
10
  /**
145
- * Server-frame state shared by the dev middleware and the production
146
- * `@ilha/router/ssr` handler: the renderers registry (keyed by the public
147
- * island id, `sha256(file#name)`, see `serverIslandPublicId`), frame/loader
148
- * guards and auth policy, and the loader runner. Lives on `globalThis` so
149
- * every module copy (plugin bundle, SSR graph, frame entry) shares one
150
- * instance — same pattern as `request-scope.ts`.
11
+ * Shared server-frame helpers used by the Vite/Rsbuild pages plugin and by
12
+ * `@ilha/router/ssr`. Kept free of the optional `oxidejs` peer so
13
+ * `@ilha/router/vite` can load without oxide installed.
151
14
  *
152
- * `.server` modules self-register when the plugin appends registration code
153
- * to their server-graph copy; the `/__ilha/frame` handler below consumes the
154
- * registry to re-render an island from a client state snapshot. Server pages
155
- * additionally register their `load` and route pattern so frame handlers can
156
- * run the loader with matched params.
15
+ * Server-frame state (renderers registry, frame/loader guards, auth policy)
16
+ * lives on `globalThis` so every module copy (plugin bundle, SSR graph, frame
17
+ * entry) shares one instance — same pattern as `request-scope.ts`.
157
18
  */
158
19
  const objectTag = (value) => Object.prototype.toString.call(value);
159
20
  const isString = (value) => objectTag(value) === "[object String]";
@@ -291,11 +152,6 @@ const frameEnvelope = (status, body) => ({
291
152
  },
292
153
  status
293
154
  });
294
- /** Brand an exported server action with its generated RPC transport key. */
295
- const __ilhaServerAction = (key, fn) => {
296
- const handle = fn.$$atom === 1 ? fn : action(fn);
297
- return brandServerAction(key, handle);
298
- };
299
155
  const registerServerIsland = (id, render) => {
300
156
  registry().set(id, { render });
301
157
  };
@@ -406,7 +262,7 @@ const authorizeFrameRequest = async (request, options) => {
406
262
  status: 403
407
263
  };
408
264
  const guard = getFrameGuard();
409
- if (!guard && (auth?.defaultAction ?? "deny") === "deny") return {
265
+ if (!guard && (auth?.defaultAction ?? options.defaultAction ?? "deny") === "deny") return {
410
266
  ok: false,
411
267
  status: 403
412
268
  };
@@ -439,76 +295,123 @@ const authorizeFrameRequest = async (request, options) => {
439
295
  ok: true
440
296
  };
441
297
  };
442
- const copyFrameworkSymbols = (from, to) => {
443
- for (const sym of Object.getOwnPropertySymbols(from)) {
444
- if (Symbol.keyFor(sym) === void 0) continue;
445
- const desc = Object.getOwnPropertyDescriptor(from, sym);
446
- if (!desc) continue;
447
- try {
448
- Object.defineProperty(to, sym, desc);
449
- } catch {}
298
+
299
+ //#endregion
300
+ //#region src/webcontainer.ts
301
+ /**
302
+ * StackBlitz WebContainer detection for AsyncLocalStorage fallbacks.
303
+ * Safe to import from browser-shared modules: on the client this always
304
+ * returns false (no `process.versions.webcontainer`).
305
+ */
306
+ let webcontainerOverride = null;
307
+ /** StackBlitz sets `process.versions.webcontainer` inside WebContainers. */
308
+ const inWebcontainer = () => {
309
+ if (webcontainerOverride !== null) return webcontainerOverride;
310
+ try {
311
+ const versions = globalThis.process?.versions;
312
+ return Boolean(versions?.webcontainer);
313
+ } catch {
314
+ return false;
450
315
  }
451
316
  };
452
- const ssr = async (request) => {
453
- let url;
317
+
318
+ //#endregion
319
+ //#region src/request-scope.ts
320
+ /**
321
+ * Request scope for server-owned island rendering.
322
+ *
323
+ * A `.server.tsx` island's render function always executes on the server —
324
+ * page SSR through the router, or streamed frames through the plugin's
325
+ * `/__ilha/frame` endpoint. Both seed this scope with the originating
326
+ * `Request`, so render functions can read request data (URL, headers,
327
+ * cookies) through `useContext().request` or a host integration such as Oxide's `useRequest()`.
328
+ *
329
+ * The storage lives on `globalThis` under `ilha.requestAls` so every module
330
+ * copy (plugin bundle, SSR graph) shares one instance. The public accessor
331
+ * is `useContext()` from the main `@ilha/router` entry, which reads the
332
+ * storage without importing `node:async_hooks`; this node-only module is the
333
+ * sole place that constructs it.
334
+ *
335
+ * StackBlitz WebContainers lose AsyncLocalStorage across `async/await`. A
336
+ * module sync fallback keeps `useContext().request` readable, and unrelated
337
+ * fallback entries are serialized so concurrent renders cannot stomp each
338
+ * other. Nested calls reenter only while owned by the active entry (sync nest
339
+ * or ALS owner) so a suspended outer cannot admit unrelated concurrent work.
340
+ */
341
+ /** Installed by oxidejs when its module loads. Lets `useRequest()` resolve
342
+ * inside island renders and frames, not just `/__oxide/action`. */
343
+ const OXIDE_RUN_WITH_REQUEST = Symbol.for("oxidejs.runWithRequest");
344
+ const innerAls = new AsyncLocalStorage();
345
+ /** Tracks the active fallback lock owner across awaits where ALS survives. */
346
+ const entryOwnerAls = new AsyncLocalStorage();
347
+ let syncStore = null;
348
+ /** When true, getStore ignores ALS so tests exercise the sync fallback path. */
349
+ let alsBypassForTests = false;
350
+ /** Serialize unrelated WebContainer fallback entries (set syncStore → run → clear). */
351
+ let fallbackTail = Promise.resolve(null);
352
+ /** Lock holder identity — nested reentry requires matching ALS owner (or sync nest). */
353
+ let activeOwner = null;
354
+ /**
355
+ * Sync-only nest depth while `runScoped` is invoking `fn`. Lets nested
356
+ * `runWithIslandRequest` reenter before `fn` awaits; cleared before awaiting
357
+ * so a suspended entry does not admit unrelated concurrent requests.
358
+ */
359
+ let syncNestDepth = 0;
360
+ const withFallbackEntry = async (fn) => {
361
+ const owner = alsBypassForTests ? void 0 : entryOwnerAls.getStore();
362
+ if (syncNestDepth > 0 || owner !== void 0 && owner === activeOwner) return await fn();
363
+ const previous = fallbackTail;
364
+ const next = Promise.withResolvers();
365
+ fallbackTail = next.promise;
366
+ await previous;
367
+ const myOwner = {};
368
+ activeOwner = myOwner;
454
369
  try {
455
- url = new URL(request.url);
456
- } catch {
457
- return json(400, { error: "frame failed" });
370
+ return await entryOwnerAls.run(myOwner, fn);
371
+ } finally {
372
+ activeOwner = null;
373
+ next.resolve(null);
458
374
  }
459
- if (url.pathname !== "/__ilha/frame") return;
460
- const program = Effect.gen(function* handleFrame() {
461
- const auth = getFrameAuth();
462
- if (!isTrustedOrigin(request, auth)) return yield* Effect.fail(frameFail(403));
463
- if (request.method !== "POST") return yield* Effect.fail(frameFail(405));
464
- if (!(request.headers.get("content-type") ?? "").startsWith("application/json")) return yield* Effect.fail(frameFail(415));
465
- const authorized = yield* Effect.tryPromise({
466
- catch: () => frameFail(403),
467
- try: () => authorizeFrameRequest(request, {
468
- defaultAction: auth?.defaultAction ?? "deny",
469
- onGuardError: (error) => console.error("[ilha-router] frame guard failed:", error)
470
- })
471
- });
472
- if (!authorized.ok) return yield* Effect.fail(frameFail(authorized.status));
473
- let id;
474
- let framePath = "/";
475
- let incomingProps;
476
- {
477
- const text = yield* Effect.tryPromise({
478
- catch: () => frameFail(400),
479
- try: () => readBodyBounded(request, MAX_BODY)
480
- });
481
- if (text === null) return yield* Effect.fail(frameFail(413));
375
+ };
376
+ const ensureScope = () => {
377
+ const g = globalThis;
378
+ const existing = g[REQUEST_ALS_KEY];
379
+ if (existing && "getStore" in existing) return existing;
380
+ const scope = { getStore: () => (alsBypassForTests ? void 0 : innerAls.getStore()) ?? syncStore ?? void 0 };
381
+ g[REQUEST_ALS_KEY] = scope;
382
+ return scope;
383
+ };
384
+ const runScoped = (request, oxide, fn) => innerAls.run(request, () => oxide ? oxide(request, fn) : fn());
385
+ /**
386
+ * Run `fn` with `request` available to `useContext().request`. When oxidejs
387
+ * is loaded, its action scope is entered too, so `useRequest()` works in
388
+ * island renders and streamed frames.
389
+ *
390
+ * On WebContainer the return is always a `Promise` (fallback lock). Elsewhere
391
+ * the return matches `fn` (sync or async).
392
+ */
393
+ const runWithIslandRequest = (request, fn) => {
394
+ ensureScope();
395
+ const oxideSlot = globalThis[OXIDE_RUN_WITH_REQUEST];
396
+ const oxide = oxideSlot === void 0 ? void 0 : oxideSlot;
397
+ if (!inWebcontainer()) return runScoped(request, oxide, fn);
398
+ return withFallbackEntry(async () => {
399
+ const previous = syncStore;
400
+ syncStore = request;
401
+ try {
402
+ syncNestDepth += 1;
403
+ let result;
482
404
  try {
483
- const body = JSON.parse(text);
484
- id = String(body.id ?? "");
485
- incomingProps = parseFrameProps(body.props);
486
- if (isString(body.path)) {
487
- if (!isSafeFramePath(body.path)) return yield* Effect.fail(frameFail(400));
488
- framePath = body.path;
489
- }
490
- } catch {
491
- return yield* Effect.fail(frameFail(400));
405
+ result = runScoped(request, oxide, fn);
406
+ } finally {
407
+ syncNestDepth -= 1;
492
408
  }
409
+ return await Promise.resolve(result);
410
+ } finally {
411
+ syncStore = previous;
493
412
  }
494
- const scoped = new Request(frameScopedUrl(url.href, framePath), {
495
- headers: forwardIdentityHeaders(request.headers),
496
- method: "POST"
497
- });
498
- copyFrameworkSymbols(request, scoped);
499
- const html = yield* renderServerIsland(id, scoped, (scopedRequest, fn) => Promise.resolve(runWithIslandRequest(scopedRequest, fn)), incomingProps);
500
- return json(200, { html });
501
413
  });
502
- return await Effect.runPromise(Effect.map(Effect.result(program), Result.match({
503
- onFailure: (error) => {
504
- if (error.redirect) return json(error.status, { redirect: error.redirect });
505
- if (error.status >= 500) console.error("[ilha-router] frame render failed:", error);
506
- return json(error.status, { error: "frame failed" });
507
- },
508
- onSuccess: (response) => response
509
- })));
510
414
  };
511
- const ssrWithImports = Object.assign(ssr, { imports: ["ilha:pages/server"] });
512
415
 
513
416
  //#endregion
514
- export { runWithIslandRequest as C, ssrWithImports as S, registerServerIsland as _, authorizeFrameRequest as a, setFrameAuth as b, frameScopedUrl as c, getServerIslandEntry as d, isSafeFramePath as f, readBodyBounded as g, parseFrameProps as h, __ilhaServerAction as i, getFrameAuth as l, json as m, FrameError as n, forwardIdentityHeaders as o, isTrustedOrigin as p, MAX_BODY as r, frameEnvelope as s, FRAME_ENDPOINT as t, getFrameGuard as u, renderServerIsland as v, setFrameGuard as x, renderServerIslandResult as y };
417
+ export { registerServerIsland as _, authorizeFrameRequest as a, setFrameAuth as b, frameScopedUrl as c, getServerIslandEntry as d, isSafeFramePath as f, readBodyBounded as g, parseFrameProps as h, MAX_BODY as i, getFrameAuth as l, json as m, FRAME_ENDPOINT as n, forwardIdentityHeaders as o, isTrustedOrigin as p, FrameError as r, frameEnvelope as s, runWithIslandRequest as t, getFrameGuard as u, renderServerIsland as v, setFrameGuard as x, renderServerIslandResult as y };
package/dist/rsbuild.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as ilhaPages } from "./plugin-Ci-W2rMt.js";
1
+ import { t as ilhaPages } from "./plugin-Cn_H92L0.js";
2
2
 
3
3
  //#region src/rsbuild.ts
4
4
  /** Rsbuild plugin — use via `@ilha/router/rsbuild`. */
package/dist/ssr.d.ts CHANGED
@@ -12,150 +12,14 @@
12
12
  * Serves `POST /__ilha/frame` — re-renders a server island (JSON `{ id, path }`
13
13
  * in, `{ html }` out). Renderers come from the process-global registry
14
14
  * populated by self-registration code appended to `.server` modules.
15
- */
16
- import * as Effect from "effect/Effect";
17
- import * as Result from "effect/Result";
18
- import type { SnapshotObject, SnapshotValue } from "./snapshot";
19
- /** JSON payload for frame envelopes. */
20
- export type FrameJsonValue = string | number | boolean | null | FrameJsonValue[] | FrameJsonObject;
21
- export interface FrameJsonObject {
22
- readonly [key: string]: FrameJsonValue | undefined;
23
- }
24
- /** A frame render: optionally preceded by running the page's `load`. */
25
- export interface ServerIslandEntry {
26
- /** Returns the renderState fn (`Symbol.for("ilha.renderState")` getter). */
27
- render: () => ServerIslandRenderFn;
28
- }
29
- export type ServerIslandRenderFn = (props?: SnapshotObject) => string | Promise<string> | object;
30
- export type FrameGuard = (request: Request) => Response | undefined | Promise<Response | undefined>;
31
- /**
32
- * Install a guard consulted by every `/__ilha/frame` request (dev middleware
33
- * and the production `@ilha/router/ssr` handler share this slot — both read
34
- * it from `globalThis`). Return a `Response` to reject; return nothing to
35
- * allow. Island state is world-readable through frames unless you gate them,
36
- * so apps serving private data should install a session check here.
37
- */
38
- export declare const setFrameGuard: (guard: FrameGuard) => void;
39
- export declare const getFrameGuard: () => FrameGuard | undefined;
40
- /** Frame-authorization policy, installed via {@link setFrameAuth}. */
41
- export interface FrameAuthPolicy {
42
- /**
43
- * Action taken when no frame guard is registered. `"deny"` (default in the
44
- * production handler) rejects every `/__ilha/frame` request with 403;
45
- * `"open"` preserves the legacy unauthenticated behavior. The dev
46
- * middleware stays permissive unless a guard is registered.
47
- */
48
- defaultAction?: "open" | "deny";
49
- /**
50
- * Explicit trusted origins (e.g. `"https://app.example.com"`). When set,
51
- * origin checks accept only these; otherwise the check compares the `Origin`
52
- * header against `https://{host}` / `http://{host}`.
53
- */
54
- trustedOrigins?: string[];
55
- /**
56
- * Optional CSRF verifier for the state-changing frame POST. Receives the
57
- * original `Request`; returning falsy rejects the request. Use this for
58
- * server-to-server frame callers that have no browser `Origin`.
59
- */
60
- csrf?: (request: Request) => boolean | Promise<boolean>;
61
- }
62
- /**
63
- * Install the frame-authorization policy consumed by the production
64
- * `@ilha/router/ssr` handler. `trustedOrigins` and `csrf` are also applied by
65
- * the dev middleware (via `IlhaPagesOptions`).
66
- */
67
- export declare const setFrameAuth: (policy: FrameAuthPolicy) => void;
68
- export declare const getFrameAuth: () => FrameAuthPolicy | undefined;
69
- /**
70
- * Same-origin check for frame/loader requests. Browsers always send `Origin`
71
- * on cross-origin and same-origin `POST`; its absence implies a non-browser
72
- * caller (allowed — gate those via a guard or `csrf`). When `Origin` is
73
- * present it must match the configured trusted origins, else the request's
74
- * own `Host`.
75
- */
76
- export declare const isTrustedOrigin: (request: Request, policy: FrameAuthPolicy | undefined) => boolean;
77
- /**
78
- * Path-only route context for frame/loader scoped requests. Leading slash,
79
- * no `//` or backslash (WHATWG URLs treat `\` as `/` for http(s), so a
80
- * `\evil.com` prefix would smuggle a foreign authority past a plain `//`
81
- * check), bounded length. `false` for anything else.
82
- */
83
- export declare const isSafeFramePath: (framePath: string) => boolean;
84
- /**
85
- * Build the scoped frame render URL from the incoming request URL and frame
86
- * path. Absolute `incomingUrl` values supply the origin. Relative values (for
87
- * example Vite's `req.url`) require an explicit trusted `serverOrigin` — never
88
- * a client `Host` header.
89
- */
90
- export declare const frameScopedUrl: (incomingUrl: string, framePath: string, serverOrigin?: string) => string;
91
- type HeaderSource = Headers | Readonly<Record<string, string | string[] | undefined>>;
92
- /**
93
- * Copy identity headers (cookie, authorization, user-agent) onto a fresh
94
- * `Headers`. Accepts a `Headers` or a Node `IncomingHttpHeaders`-style plain
95
- * object. Client-supplied `x-forwarded-for` is deliberately NOT forwarded —
96
- * it is spoofable and must not be trusted by loaders for IP checks.
97
- */
98
- export declare const forwardIdentityHeaders: (source: HeaderSource) => Headers;
99
- /** No-store JSON envelope shared by dev and production frame handlers. */
100
- export interface FrameEnvelope {
101
- status: number;
102
- headers: Record<string, string>;
103
- body: string;
104
- }
105
- export declare const frameEnvelope: (status: number, body: FrameJsonObject) => FrameEnvelope;
106
- /** Brand an exported server action with its generated RPC transport key. */
107
- export declare const __ilhaServerAction: <A extends SnapshotValue[], R>(key: string, fn: (...args: A) => R | Promise<R>) => import("oxidejs").ServerActionHandle<A, Awaited<Awaited<R>>>;
108
- export declare const registerServerIsland: (id: string, render: () => ServerIslandRenderFn) => void;
109
- export declare const getServerIslandEntry: (id: string) => ServerIslandEntry | undefined;
110
- declare const FrameError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
111
- readonly _tag: "FrameError";
112
- } & Readonly<A>;
113
- /** Client-facing frame failure. `redirect` carries a same-origin redirect target. */
114
- export declare class FrameError extends FrameError_base<{
115
- status: number;
116
- message?: string;
117
- redirect?: string;
118
- }> {
119
- }
120
- /**
121
- * Render a registered server island. Typed error channel: the only failure is
122
- * `FrameError`; everything else is a defect surfaced as a 400 to the client.
123
- */
124
- export declare const renderServerIsland: (id: string, request: Request, runWithScope: <T>(request: Request, fn: () => T) => T | Promise<T>, incomingProps?: SnapshotObject) => Effect.Effect<string, FrameError>;
125
- /** Convenience for non-Effect callers: run the render and resolve to a Result. */
126
- export declare const renderServerIslandResult: (id: string, request: Request, runWithScope: <T>(request: Request, fn: () => T) => T | Promise<T>, incomingProps?: SnapshotObject) => Promise<Result.Result<string, FrameError>>;
127
- export declare const FRAME_ENDPOINT = "/__ilha/frame";
128
- /** Max request body size — matches the dev middleware cap. */
129
- export declare const MAX_BODY: number;
130
- /** Parent-island props on a frame POST. Missing is fine; anything else is 400. */
131
- export declare const parseFrameProps: <T>(value?: T) => SnapshotObject | undefined;
132
- export declare const json: (status: number, body: FrameJsonObject) => Response;
133
- /**
134
- * Read a request body as UTF-8, streaming it with a hard byte cap. Returns
135
- * `null` when the body exceeds `maxBytes` (the reader is cancelled before the
136
- * cap is far exceeded) or when decoding fails.
137
- */
138
- export declare const readBodyBounded: (request: Request, maxBytes: number) => Promise<string | null>;
139
- /**
140
- * Shared frame-request authorization used by both the production handler
141
- * below and the Vite/Rsbuild dev middleware: same-origin check against the
142
- * frame-auth policy, the registered frame guard, and the optional CSRF
143
- * verifier. `defaultAction` selects the deny-by-default production posture or
144
- * the permissive development one.
145
15
  *
146
- * Returns the forwarded identity headers on success so callers render frames
147
- * with cookie/auth/UA context, or the HTTP status to reject with.
16
+ * Frame helpers live in `./frame` (no oxidejs). `__ilhaServerAction` lives in
17
+ * `./oxide-action` so `@ilha/router/vite` can load the pages plugin without
18
+ * the optional oxide peer.
148
19
  */
149
- export declare const authorizeFrameRequest: (request: Request, options: {
150
- defaultAction: "open" | "deny";
151
- onGuardError?: <E>(error: E) => void;
152
- }) => Promise<{
153
- ok: true;
154
- identityHeaders: Headers;
155
- } | {
156
- ok: false;
157
- status: number;
158
- }>;
20
+ export { FRAME_ENDPOINT, FrameError, MAX_BODY, authorizeFrameRequest, forwardIdentityHeaders, frameEnvelope, frameScopedUrl, getFrameAuth, getFrameGuard, getServerIslandEntry, isSafeFramePath, isTrustedOrigin, json, parseFrameProps, readBodyBounded, registerServerIsland, renderServerIsland, renderServerIslandResult, setFrameAuth, setFrameGuard, } from "./frame";
21
+ export type { FrameAuthPolicy, FrameEnvelope, FrameGuard, FrameJsonObject, FrameJsonValue, ServerIslandEntry, ServerIslandRenderFn, } from "./frame";
22
+ export { __ilhaServerAction } from "./oxide-action";
159
23
  /** Side-effect imports required alongside this handler. */
160
24
  interface SsrMiddleware {
161
25
  (request: Request): Promise<Response | undefined>;
package/dist/ssr.js CHANGED
@@ -1,3 +1,118 @@
1
- import { S as ssrWithImports, _ as registerServerIsland, a as authorizeFrameRequest, b as setFrameAuth, c as frameScopedUrl, d as getServerIslandEntry, f as isSafeFramePath, g as readBodyBounded, h as parseFrameProps, i as __ilhaServerAction, l as getFrameAuth, m as json, n as FrameError, o as forwardIdentityHeaders, p as isTrustedOrigin, r as MAX_BODY, s as frameEnvelope, t as FRAME_ENDPOINT, u as getFrameGuard, v as renderServerIsland, x as setFrameGuard, y as renderServerIslandResult } from "./ssr-D9q5C-OV.js";
1
+ import { _ as registerServerIsland, a as authorizeFrameRequest, b as setFrameAuth, c as frameScopedUrl, d as getServerIslandEntry, f as isSafeFramePath, g as readBodyBounded, h as parseFrameProps, i as MAX_BODY, l as getFrameAuth, m as json, n as FRAME_ENDPOINT, o as forwardIdentityHeaders, p as isTrustedOrigin, r as FrameError, s as frameEnvelope, t as runWithIslandRequest, u as getFrameGuard, v as renderServerIsland, x as setFrameGuard, y as renderServerIslandResult } from "./request-scope-CI9BJVS-.js";
2
+ import * as Result from "effect/Result";
3
+ import * as Effect from "effect/Effect";
4
+ import { action, brandServerAction } from "oxidejs";
2
5
 
6
+ //#region src/oxide-action.ts
7
+ /**
8
+ * Oxide-branded server-action wrapper.
9
+ *
10
+ * Lives in its own module so `@ilha/router/vite` / the pages plugin can load
11
+ * frame helpers without resolving the optional `oxidejs` peer.
12
+ */
13
+ /** Brand an exported server action with its generated RPC transport key. */
14
+ const __ilhaServerAction = (key, fn) => {
15
+ const handle = fn.$$atom === 1 ? fn : action(fn);
16
+ return brandServerAction(key, handle);
17
+ };
18
+
19
+ //#endregion
20
+ //#region src/ssr.ts
21
+ /**
22
+ * Production SSR endpoint for server-owned islands.
23
+ *
24
+ * Default export is an oxidejs-style fetch middleware:
25
+ * `(request) => Response | undefined`. Returns `undefined` for any request it
26
+ * does not own, so hosts can chain it ahead of their own handler:
27
+ *
28
+ * ```ts
29
+ * oxide({ middleware: ["@ilha/router/ssr"] });
30
+ * ```
31
+ *
32
+ * Serves `POST /__ilha/frame` — re-renders a server island (JSON `{ id, path }`
33
+ * in, `{ html }` out). Renderers come from the process-global registry
34
+ * populated by self-registration code appended to `.server` modules.
35
+ *
36
+ * Frame helpers live in `./frame` (no oxidejs). `__ilhaServerAction` lives in
37
+ * `./oxide-action` so `@ilha/router/vite` can load the pages plugin without
38
+ * the optional oxide peer.
39
+ */
40
+ const objectTag = (value) => Object.prototype.toString.call(value);
41
+ const isString = (value) => objectTag(value) === "[object String]";
42
+ const copyFrameworkSymbols = (from, to) => {
43
+ for (const sym of Object.getOwnPropertySymbols(from)) {
44
+ if (Symbol.keyFor(sym) === void 0) continue;
45
+ const desc = Object.getOwnPropertyDescriptor(from, sym);
46
+ if (!desc) continue;
47
+ try {
48
+ Object.defineProperty(to, sym, desc);
49
+ } catch {}
50
+ }
51
+ };
52
+ const frameFail = (status, message) => new FrameError({
53
+ message: message ?? "frame failed",
54
+ status
55
+ });
56
+ const ssr = async (request) => {
57
+ let url;
58
+ try {
59
+ url = new URL(request.url);
60
+ } catch {
61
+ return json(400, { error: "frame failed" });
62
+ }
63
+ if (url.pathname !== "/__ilha/frame") return;
64
+ const program = Effect.gen(function* handleFrame() {
65
+ const auth = getFrameAuth();
66
+ if (!isTrustedOrigin(request, auth)) return yield* Effect.fail(frameFail(403));
67
+ if (request.method !== "POST") return yield* Effect.fail(frameFail(405));
68
+ if (!(request.headers.get("content-type") ?? "").startsWith("application/json")) return yield* Effect.fail(frameFail(415));
69
+ const authorized = yield* Effect.tryPromise({
70
+ catch: () => frameFail(403),
71
+ try: () => authorizeFrameRequest(request, {
72
+ defaultAction: auth?.defaultAction ?? "deny",
73
+ onGuardError: (error) => console.error("[ilha-router] frame guard failed:", error)
74
+ })
75
+ });
76
+ if (!authorized.ok) return yield* Effect.fail(frameFail(authorized.status));
77
+ let id;
78
+ let framePath = "/";
79
+ let incomingProps;
80
+ {
81
+ const text = yield* Effect.tryPromise({
82
+ catch: () => frameFail(400),
83
+ try: () => readBodyBounded(request, MAX_BODY)
84
+ });
85
+ if (text === null) return yield* Effect.fail(frameFail(413));
86
+ try {
87
+ const body = JSON.parse(text);
88
+ id = String(body.id ?? "");
89
+ incomingProps = parseFrameProps(body.props);
90
+ if (isString(body.path)) {
91
+ if (!isSafeFramePath(body.path)) return yield* Effect.fail(frameFail(400));
92
+ framePath = body.path;
93
+ }
94
+ } catch {
95
+ return yield* Effect.fail(frameFail(400));
96
+ }
97
+ }
98
+ const scoped = new Request(frameScopedUrl(url.href, framePath), {
99
+ headers: forwardIdentityHeaders(request.headers),
100
+ method: "POST"
101
+ });
102
+ copyFrameworkSymbols(request, scoped);
103
+ const html = yield* renderServerIsland(id, scoped, (scopedRequest, fn) => Promise.resolve(runWithIslandRequest(scopedRequest, fn)), incomingProps);
104
+ return json(200, { html });
105
+ });
106
+ return await Effect.runPromise(Effect.map(Effect.result(program), Result.match({
107
+ onFailure: (error) => {
108
+ if (error.redirect) return json(error.status, { redirect: error.redirect });
109
+ if (error.status >= 500) console.error("[ilha-router] frame render failed:", error);
110
+ return json(error.status, { error: "frame failed" });
111
+ },
112
+ onSuccess: (response) => response
113
+ })));
114
+ };
115
+ const ssrWithImports = Object.assign(ssr, { imports: ["ilha:pages/server"] });
116
+
117
+ //#endregion
3
118
  export { FRAME_ENDPOINT, FrameError, MAX_BODY, __ilhaServerAction, authorizeFrameRequest, ssrWithImports as default, forwardIdentityHeaders, frameEnvelope, frameScopedUrl, getFrameAuth, getFrameGuard, getServerIslandEntry, isSafeFramePath, isTrustedOrigin, json, parseFrameProps, readBodyBounded, registerServerIsland, renderServerIsland, renderServerIslandResult, setFrameAuth, setFrameGuard };
package/dist/vite.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as ilhaPages } from "./plugin-Ci-W2rMt.js";
1
+ import { t as ilhaPages } from "./plugin-Cn_H92L0.js";
2
2
 
3
3
  //#region src/vite.ts
4
4
  /** Vite plugin — use via `@ilha/router/vite`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ilha/router",
3
- "version": "0.11.7",
3
+ "version": "0.11.9",
4
4
  "description": "A tiny SPA router for Ilha",
5
5
  "keywords": [
6
6
  "frontend",
@@ -73,12 +73,12 @@
73
73
  "unplugin": "3.3.0"
74
74
  },
75
75
  "devDependencies": {
76
- "ilha": "^0.14.5",
76
+ "ilha": "^0.14.6",
77
77
  "oxidejs": "^0.3.2",
78
78
  "vite": "^8.2.2"
79
79
  },
80
80
  "peerDependencies": {
81
- "ilha": ">=0.14.5",
81
+ "ilha": ">=0.14.6",
82
82
  "oxidejs": ">=0.3.2"
83
83
  },
84
84
  "peerDependenciesMeta": {