@ilha/router 0.11.3 → 0.11.5

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.
@@ -7,9 +7,9 @@
7
7
  * through oxidejs's tacho stub replacement.
8
8
  */
9
9
  /** Oxide RPC client key — basename without `.server.*` (oxide indexes by module name). */
10
- export declare function serverModuleRpcKey(spec: string): string;
10
+ export declare const serverModuleRpcKey: (spec: string) => string;
11
11
  /** Client repaint group key — full normalized path so same-basename files stay distinct. */
12
- export declare function serverModuleRepaintKey(spec: string): string;
12
+ export declare const serverModuleRepaintKey: (spec: string) => string;
13
13
  export interface ScannedServerIsland {
14
14
  /** Export binding name, or `"default"` for `export default ilha…`. */
15
15
  name: string;
@@ -31,7 +31,7 @@ export interface ClientIslandRef {
31
31
  * capture-aware shim on ALREADY-COMPILED module code. Must run inside the
32
32
  * SSR transform so upstream JSX/TS output is preserved.
33
33
  */
34
- export declare function rewriteServerActions(code: string, rpcActions: Record<string, string>): string;
34
+ export declare const rewriteServerActions: (code: string, rpcActions: Record<string, string>) => string;
35
35
  export interface ServerModuleScan {
36
36
  islands: ScannedServerIsland[];
37
37
  /** All value-export names of the module (transport candidates). */
@@ -47,36 +47,37 @@ export interface ServerModuleScan {
47
47
  /** Imported JSX components that must hydrate inside the server island. */
48
48
  clientRefs: ClientIslandRef[];
49
49
  }
50
- export declare function clientRefPublicId(spec: string, imported: string): string;
50
+ export declare const clientRefPublicId: (spec: string, imported: string) => string;
51
51
  /** Scan a `*.server.ts(x)` module source for island exports and their
52
- * declarative wiring. Convention: islands start with `ilha` — both builder
53
- * function components (`ilha(() => …)`) and `ilha(schema, component)`. */
54
- export declare function scanServerIslands(source: string): ServerModuleScan;
55
- export declare function loadServerModuleScan(path: string): ServerModuleScan;
52
+ * declarative wiring. Islands are `export const Name = [async] function[*]`
53
+ * (and the `export default` form); stream/action transports are discovered
54
+ * from calls inside each island body. */
55
+ export declare const scanServerIslands: (source: string) => ServerModuleScan;
56
+ export declare const loadServerModuleScan: (filePath: string) => ServerModuleScan;
56
57
  /** Virtual-module id prefix for generated client proxies of server islands.
57
58
  * The file path rides base64url-encoded: a raw suffix like
58
59
  * `\0…:…/tasks.server.tsx` would end in `.server.*` and oxidejs's client-stub
59
60
  * loader would claim the virtual module before us. */
60
61
  export declare const SERVER_ISLAND_PREFIX = "\0ilha:server-island:";
61
62
  /** Virtual-module specifier serving the client proxy for one server island file. */
62
- export declare function serverIslandVirtualSpec(file: string): string;
63
+ export declare const serverIslandVirtualSpec: (file: string) => string;
63
64
  /** Emit the client virtual module for one scanned server file. Plain JS —
64
65
  * `\0` virtual modules bypass Vite's built-in TS transform, so type-only
65
66
  * constructs here would reach the browser unparsed. Editor types are
66
67
  * unaffected: TS resolves the ORIGINAL specifier (the real server module);
67
68
  * this module exists only inside the client bundle. Frames are fetched from
68
69
  * the plugin's `/__ilha/frame` dev middleware. */
69
- export declare function serverIslandPublicId(spec: string, name: string): string;
70
- export declare function generateServerIslandModule(spec: string, scan: ServerModuleScan): string;
70
+ export declare const serverIslandPublicId: (spec: string, name: string) => string;
71
+ export declare const generateServerIslandModule: (spec: string, scan: ServerModuleScan) => string;
71
72
  export interface SplitContext {
72
73
  /** Resolved absolute path of the imported specifier, when it's a scanned
73
74
  * server module carrying islands; null otherwise. */
74
- islandNamesFor(spec: string): {
75
+ islandNamesFor: (spec: string) => {
75
76
  islands: Set<string>;
76
77
  hasDefault: boolean;
77
78
  } | null;
78
79
  /** Virtual module specifier that provides the island bindings. */
79
- virtualSpecFor(spec: string): string;
80
+ virtualSpecFor: (spec: string) => string;
80
81
  }
81
82
  /**
82
83
  * Rewrite import sites whose specifier targets a server module containing
@@ -84,4 +85,4 @@ export interface SplitContext {
84
85
  * bindings stay on the original specifier (oxidejs replaces them with tacho
85
86
  * stubs). Returns null when no statement needed rewriting.
86
87
  */
87
- export declare function splitServerImports(code: string, ctx: SplitContext): string | null;
88
+ export declare const splitServerImports: (code: string, ctx: SplitContext) => string | null;
@@ -0,0 +1,57 @@
1
+ //#region src/snapshot.ts
2
+ /**
3
+ * Defensive parser for `data-ilha-state` / `data-ilha-props` snapshot
4
+ * attributes on the router side. Mirrors the core ilha guards: size cap,
5
+ * plain-object check, depth cap, and prototype-key stripping. Returns
6
+ * undefined (degrade gracefully) on any failure.
7
+ */
8
+ const MAX_SNAPSHOT_CHARS = 262144;
9
+ const MAX_SNAPSHOT_DEPTH = 32;
10
+ const objectTag = (value) => Object.prototype.toString.call(value);
11
+ const isObject = (value) => value !== null && objectTag(value) === "[object Object]";
12
+ const exceedsMaxDepth = (value, depth) => {
13
+ if (depth > MAX_SNAPSHOT_DEPTH) return true;
14
+ if (value === null || !isObject(value)) {
15
+ if (Array.isArray(value)) {
16
+ for (const item of value) if (exceedsMaxDepth(item, depth + 1)) return true;
17
+ }
18
+ return false;
19
+ }
20
+ const obj = value;
21
+ for (const key of Object.keys(obj)) if (exceedsMaxDepth(obj[key], depth + 1)) return true;
22
+ return false;
23
+ };
24
+ const deleteUnsafeKey = (obj, key) => {
25
+ if (Object.hasOwn(obj, key)) Reflect.deleteProperty(obj, key);
26
+ };
27
+ const stripUnsafeKeys = (value) => {
28
+ if (Array.isArray(value)) {
29
+ for (const item of value) stripUnsafeKeys(item);
30
+ return;
31
+ }
32
+ if (value === null || !isObject(value)) return;
33
+ const obj = value;
34
+ deleteUnsafeKey(obj, "__proto__");
35
+ deleteUnsafeKey(obj, "constructor");
36
+ deleteUnsafeKey(obj, "prototype");
37
+ for (const key of Object.getOwnPropertyNames(obj)) stripUnsafeKeys(obj[key]);
38
+ };
39
+ const sanitizeSnapshotObject = (value) => {
40
+ if (!isObject(value) || Array.isArray(value)) return;
41
+ if (exceedsMaxDepth(value, 1)) return;
42
+ stripUnsafeKeys(value);
43
+ return value;
44
+ };
45
+ const parseSnapshotAttr = (raw) => {
46
+ if (raw.length > MAX_SNAPSHOT_CHARS) return;
47
+ let parsed;
48
+ try {
49
+ parsed = JSON.parse(raw);
50
+ } catch {
51
+ return;
52
+ }
53
+ return sanitizeSnapshotObject(parsed);
54
+ };
55
+
56
+ //#endregion
57
+ export { sanitizeSnapshotObject as n, parseSnapshotAttr as t };
@@ -1,2 +1,15 @@
1
- export declare function sanitizeSnapshotObject(value: unknown): Record<string, unknown> | undefined;
2
- export declare function parseSnapshotAttr(raw: string): Record<string, unknown> | undefined;
1
+ /**
2
+ * Defensive parser for `data-ilha-state` / `data-ilha-props` snapshot
3
+ * attributes on the router side. Mirrors the core ilha guards: size cap,
4
+ * plain-object check, depth cap, and prototype-key stripping. Returns
5
+ * undefined (degrade gracefully) on any failure.
6
+ */
7
+ /** JSON leaf values accepted in SSR hydrate snapshots. */
8
+ export type SnapshotLeaf = string | number | boolean | null;
9
+ /** JSON values accepted in SSR hydrate snapshots. */
10
+ export type SnapshotValue = SnapshotLeaf | SnapshotObject | readonly SnapshotValue[];
11
+ export interface SnapshotObject {
12
+ readonly [key: string]: SnapshotValue | undefined;
13
+ }
14
+ export declare const sanitizeSnapshotObject: <T>(value: T) => SnapshotObject | undefined;
15
+ export declare const parseSnapshotAttr: (raw: string) => SnapshotObject | undefined;
@@ -1,5 +1,6 @@
1
1
  import { t as REQUEST_ALS_KEY } from "./als-key-CVxbuM3z.js";
2
- import { n as sanitizeSnapshotObject } from "./snapshot-C0E2OGwL.js";
2
+ import { a as withHeadStore } from "./head-BLtm0xtK.js";
3
+ import { n as sanitizeSnapshotObject } from "./snapshot-XpAkSV8s.js";
3
4
  import { renderToString } from "ilha";
4
5
  import * as Result from "effect/Result";
5
6
  import { AsyncLocalStorage } from "node:async_hooks";
@@ -29,12 +30,19 @@ const OXIDE_RUN_WITH_REQUEST = Symbol.for("oxidejs.runWithRequest");
29
30
  /** Run `fn` with `request` available to `useContext().request`. When oxidejs
30
31
  * is loaded, its action scope is entered too, so `useRequest()` works in
31
32
  * island renders and streamed frames. */
32
- function runWithIslandRequest(request, fn) {
33
+ const runWithIslandRequest = (request, fn) => {
33
34
  const g = globalThis;
34
- const als = g[REQUEST_ALS_KEY] ??= new AsyncLocalStorage();
35
- const oxide = g[OXIDE_RUN_WITH_REQUEST];
35
+ const existing = g[REQUEST_ALS_KEY];
36
+ let als;
37
+ if (existing instanceof AsyncLocalStorage) als = existing;
38
+ else {
39
+ als = new AsyncLocalStorage();
40
+ g[REQUEST_ALS_KEY] = als;
41
+ }
42
+ const oxideSlot = g[OXIDE_RUN_WITH_REQUEST];
43
+ const oxide = oxideSlot === void 0 ? void 0 : oxideSlot;
36
44
  return als.run(request, () => oxide ? oxide(request, fn) : fn());
37
- }
45
+ };
38
46
 
39
47
  //#endregion
40
48
  //#region src/ssr.ts
@@ -53,16 +61,36 @@ function runWithIslandRequest(request, fn) {
53
61
  * in, `{ html }` out). Renderers come from the process-global registry
54
62
  * populated by self-registration code appended to `.server` modules.
55
63
  */
64
+ /**
65
+ * Server-frame state shared by the dev middleware and the production
66
+ * `@ilha/router/ssr` handler: the renderers registry (keyed by the public
67
+ * island id, `sha256(file#name)`, see `serverIslandPublicId`), frame/loader
68
+ * guards and auth policy, and the loader runner. Lives on `globalThis` so
69
+ * every module copy (plugin bundle, SSR graph, frame entry) shares one
70
+ * instance — same pattern as `request-scope.ts`.
71
+ *
72
+ * `.server` modules self-register when the plugin appends registration code
73
+ * to their server-graph copy; the `/__ilha/frame` handler below consumes the
74
+ * registry to re-render an island from a client state snapshot. Server pages
75
+ * additionally register their `load` and route pattern so frame handlers can
76
+ * run the loader with matched params.
77
+ */
78
+ const objectTag = (value) => Object.prototype.toString.call(value);
79
+ const isString = (value) => objectTag(value) === "[object String]";
80
+ const isFunction = (value) => {
81
+ const tag = objectTag(value);
82
+ return tag === "[object Function]" || tag === "[object AsyncFunction]" || tag === "[object GeneratorFunction]";
83
+ };
84
+ const isObject = (value) => value !== null && objectTag(value) === "[object Object]";
56
85
  const REGISTRY_KEY = Symbol.for("ilha.serverIslandRenderers");
57
- function registry() {
86
+ const registry = () => {
58
87
  const g = globalThis;
59
- let map = g[REGISTRY_KEY];
60
- if (!map) {
61
- map = /* @__PURE__ */ new Map();
62
- g[REGISTRY_KEY] = map;
63
- }
88
+ const existing = g[REGISTRY_KEY];
89
+ if (existing instanceof Map) return existing;
90
+ const map = /* @__PURE__ */ new Map();
91
+ g[REGISTRY_KEY] = map;
64
92
  return map;
65
- }
93
+ };
66
94
  const GUARD_KEY = Symbol.for("ilha.frameGuard");
67
95
  /**
68
96
  * Install a guard consulted by every `/__ilha/frame` request (dev middleware
@@ -71,33 +99,36 @@ const GUARD_KEY = Symbol.for("ilha.frameGuard");
71
99
  * allow. Island state is world-readable through frames unless you gate them,
72
100
  * so apps serving private data should install a session check here.
73
101
  */
74
- function setFrameGuard(guard) {
102
+ const setFrameGuard = (guard) => {
75
103
  const g = globalThis;
76
104
  g[GUARD_KEY] = guard;
77
- }
78
- function getFrameGuard() {
79
- return globalThis[GUARD_KEY];
80
- }
105
+ };
106
+ const getFrameGuard = () => {
107
+ const slot = globalThis[GUARD_KEY];
108
+ if (!isFunction(slot)) return;
109
+ return slot;
110
+ };
81
111
  const AUTH_KEY = Symbol.for("ilha.frameAuth");
82
112
  /**
83
113
  * Install the frame-authorization policy consumed by the production
84
114
  * `@ilha/router/ssr` handler. `trustedOrigins` and `csrf` are also applied by
85
115
  * the dev middleware (via `IlhaPagesOptions`).
86
116
  */
87
- function setFrameAuth(policy) {
117
+ const setFrameAuth = (policy) => {
88
118
  const g = globalThis;
89
119
  g[AUTH_KEY] = policy;
90
- }
91
- function getFrameAuth() {
92
- return globalThis[AUTH_KEY];
93
- }
94
- function normalizeOrigin(value) {
120
+ };
121
+ const getFrameAuth = () => {
122
+ const slot = globalThis[AUTH_KEY];
123
+ return isObject(slot) ? slot : void 0;
124
+ };
125
+ const normalizeOrigin = (value) => {
95
126
  try {
96
127
  return new URL(value).origin;
97
128
  } catch {
98
129
  return null;
99
130
  }
100
- }
131
+ };
101
132
  /**
102
133
  * Same-origin check for frame/loader requests. Browsers always send `Origin`
103
134
  * on cross-origin and same-origin `POST`; its absence implies a non-browser
@@ -105,7 +136,7 @@ function normalizeOrigin(value) {
105
136
  * present it must match the configured trusted origins, else the request's
106
137
  * own `Host`.
107
138
  */
108
- function isTrustedOrigin(request, policy) {
139
+ const isTrustedOrigin = (request, policy) => {
109
140
  const originHeader = request.headers.get("origin");
110
141
  if (originHeader === null) return true;
111
142
  const origin = normalizeOrigin(originHeader);
@@ -115,40 +146,38 @@ function isTrustedOrigin(request, policy) {
115
146
  const host = request.headers.get("host");
116
147
  if (!host) return false;
117
148
  return origin === `https://${host}` || origin === `http://${host}`;
118
- }
149
+ };
119
150
  /**
120
151
  * Path-only route context for frame/loader scoped requests. Leading slash,
121
152
  * no `//` or backslash (WHATWG URLs treat `\` as `/` for http(s), so a
122
153
  * `\evil.com` prefix would smuggle a foreign authority past a plain `//`
123
154
  * check), bounded length. `false` for anything else.
124
155
  */
125
- function isSafeFramePath(path) {
126
- return path.startsWith("/") && !path.includes("//") && !path.includes("\\") && path.length <= 2048;
127
- }
156
+ const isSafeFramePath = (framePath) => framePath.startsWith("/") && !framePath.includes("//") && !framePath.includes("\\") && framePath.length <= 2048;
157
+ const parseServerOrigin = (value) => {
158
+ if (!value.includes("://")) throw new Error("frameScopedUrl: serverOrigin must be an absolute URL");
159
+ const parsed = new URL(value);
160
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("frameScopedUrl: serverOrigin must be http(s)");
161
+ return parsed.origin;
162
+ };
128
163
  /**
129
164
  * Build the scoped frame render URL from the incoming request URL and frame
130
165
  * path. Absolute `incomingUrl` values supply the origin. Relative values (for
131
166
  * example Vite's `req.url`) require an explicit trusted `serverOrigin` — never
132
167
  * a client `Host` header.
133
168
  */
134
- function frameScopedUrl(incomingUrl, framePath, serverOrigin) {
169
+ const frameScopedUrl = (incomingUrl, framePath, serverOrigin) => {
135
170
  let origin;
136
171
  try {
137
172
  const incoming = new URL(incomingUrl);
138
173
  if (incoming.protocol !== "http:" && incoming.protocol !== "https:") throw new TypeError("unsupported protocol");
139
- origin = incoming.origin;
174
+ ({origin} = incoming);
140
175
  } catch {
141
- if (serverOrigin == null) throw new Error("frameScopedUrl: relative incomingUrl requires serverOrigin");
176
+ if (serverOrigin === null || serverOrigin === void 0) throw new Error("frameScopedUrl: relative incomingUrl requires serverOrigin");
142
177
  origin = parseServerOrigin(serverOrigin);
143
178
  }
144
179
  return new URL(framePath, origin).href;
145
- }
146
- function parseServerOrigin(value) {
147
- if (!value.includes("://")) throw new Error("frameScopedUrl: serverOrigin must be an absolute URL");
148
- const parsed = new URL(value);
149
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("frameScopedUrl: serverOrigin must be http(s)");
150
- return parsed.origin;
151
- }
180
+ };
152
181
  /** Identity headers forwarded onto scoped render/loader requests. */
153
182
  const FORWARD_IDENTITY_HEADERS = [
154
183
  "cookie",
@@ -161,11 +190,10 @@ const FORWARD_IDENTITY_HEADERS = [
161
190
  * object. Client-supplied `x-forwarded-for` is deliberately NOT forwarded —
162
191
  * it is spoofable and must not be trusted by loaders for IP checks.
163
192
  */
164
- function forwardIdentityHeaders(source) {
193
+ const forwardIdentityHeaders = (source) => {
165
194
  const out = new Headers();
166
195
  const read = (name) => {
167
- const s = source;
168
- if (typeof s.get === "function") return s.get(name);
196
+ if (source instanceof Headers) return source.get(name);
169
197
  const v = source[name];
170
198
  return Array.isArray(v) ? v[0] : v;
171
199
  };
@@ -174,28 +202,24 @@ function forwardIdentityHeaders(source) {
174
202
  if (v !== null && v !== void 0) out.set(name, v);
175
203
  }
176
204
  return out;
177
- }
178
- function frameEnvelope(status, body) {
179
- return {
180
- status,
181
- headers: {
182
- "cache-control": "no-store",
183
- "content-type": "application/json;charset=utf-8"
184
- },
185
- body: JSON.stringify(body)
186
- };
187
- }
188
- /** @internal Brand an exported server action with its generated RPC transport key. */
189
- function __ilhaServerAction(key, fn) {
190
- const handle = typeof fn === "function" && fn.$$atom === 1 ? fn : action(fn);
205
+ };
206
+ const frameEnvelope = (status, body) => ({
207
+ body: JSON.stringify(body),
208
+ headers: {
209
+ "cache-control": "no-store",
210
+ "content-type": "application/json;charset=utf-8"
211
+ },
212
+ status
213
+ });
214
+ /** Brand an exported server action with its generated RPC transport key. */
215
+ const __ilhaServerAction = (key, fn) => {
216
+ const handle = fn.$$atom === 1 ? fn : action(fn);
191
217
  return brandServerAction(key, handle);
192
- }
193
- function registerServerIsland(id, render) {
218
+ };
219
+ const registerServerIsland = (id, render) => {
194
220
  registry().set(id, { render });
195
- }
196
- function getServerIslandEntry(id) {
197
- return registry().get(id);
198
- }
221
+ };
222
+ const getServerIslandEntry = (id) => registry().get(id);
199
223
  /** Client-facing frame failure. `redirect` carries a same-origin redirect target. */
200
224
  var FrameError = class extends Data.TaggedError("FrameError") {};
201
225
  /**
@@ -205,87 +229,86 @@ var FrameError = class extends Data.TaggedError("FrameError") {};
205
229
  * failures; loader redirects surface via `FrameError.redirect`.
206
230
  */
207
231
  const frameFail = (status, message) => new FrameError({
208
- status,
209
- message: message ?? "frame failed"
232
+ message: message ?? "frame failed",
233
+ status
210
234
  });
235
+ const renderToStringOf = async (render, props) => {
236
+ const out = render(props);
237
+ const view = await Promise.resolve(out);
238
+ if (isString(view)) return view;
239
+ return renderToString(() => view, {
240
+ captureActions: true,
241
+ markers: false
242
+ });
243
+ };
211
244
  /**
212
245
  * Render a registered server island. Typed error channel: the only failure is
213
246
  * `FrameError`; everything else is a defect surfaced as a 400 to the client.
214
247
  */
215
- function renderServerIsland(id, request, runWithScope, incomingProps) {
216
- return Effect.gen(function* () {
217
- const entry = registry().get(id);
218
- if (!entry) return yield* Effect.fail(frameFail(400, "unknown island"));
219
- const render = entry.render();
220
- if (typeof render !== "function") return yield* Effect.fail(frameFail(400, "unknown island"));
221
- return yield* Effect.tryPromise({
222
- try: async () => {
223
- return await runWithScope(request, () => renderToStringOf(render, incomingProps));
224
- },
225
- catch: (error) => error instanceof FrameError ? error : frameFail(400, error instanceof Error ? error.message : void 0)
226
- });
248
+ const renderServerIsland = (id, request, runWithScope, incomingProps) => Effect.gen(function* renderIsland() {
249
+ const entry = registry().get(id);
250
+ if (!entry) return yield* Effect.fail(frameFail(400, "unknown island"));
251
+ const render = entry.render();
252
+ if (!isFunction(render)) return yield* Effect.fail(frameFail(400, "unknown island"));
253
+ return yield* Effect.tryPromise({
254
+ catch: (error) => error instanceof FrameError ? error : frameFail(400, error instanceof Error ? error.message : void 0),
255
+ try: async () => await runWithScope(request, () => withHeadStore({ entries: [] }, () => renderToStringOf(render, incomingProps)))
227
256
  });
228
- }
257
+ });
229
258
  /** Convenience for non-Effect callers: run the render and resolve to a Result. */
230
- function renderServerIslandResult(id, request, runWithScope, incomingProps) {
231
- return Effect.runPromise(Effect.result(renderServerIsland(id, request, runWithScope, incomingProps)));
232
- }
233
- function renderToStringOf(render, props) {
234
- const out = render(props);
235
- return Promise.resolve(out).then((view) => typeof view === "string" ? view : renderToString(() => view, {
236
- captureActions: true,
237
- markers: false
238
- }));
239
- }
259
+ const renderServerIslandResult = (id, request, runWithScope, incomingProps) => Effect.runPromise(Effect.result(renderServerIsland(id, request, runWithScope, incomingProps)));
240
260
  const FRAME_ENDPOINT = "/__ilha/frame";
241
261
  /** Max request body size — matches the dev middleware cap. */
242
262
  const MAX_BODY = 16384;
243
263
  /** Parent-island props on a frame POST. Missing is fine; anything else is 400. */
244
- function parseFrameProps(value) {
245
- if (value == null) return void 0;
246
- if (typeof value !== "object" || Array.isArray(value)) throw new FrameError({
247
- status: 400,
248
- message: "frame failed"
264
+ const parseFrameProps = (value) => {
265
+ if (value === null || value === void 0) return;
266
+ if (!isObject(value) || Array.isArray(value)) throw new FrameError({
267
+ message: "frame failed",
268
+ status: 400
249
269
  });
250
270
  const sanitized = sanitizeSnapshotObject(value);
251
271
  if (!sanitized) throw new FrameError({
252
- status: 400,
253
- message: "frame failed"
272
+ message: "frame failed",
273
+ status: 400
254
274
  });
255
275
  return sanitized;
256
- }
257
- function json(status, body) {
276
+ };
277
+ const json = (status, body) => {
258
278
  const env = frameEnvelope(status, body);
259
279
  return new Response(env.body, {
260
- status: env.status,
261
- headers: env.headers
280
+ headers: env.headers,
281
+ status: env.status
262
282
  });
263
- }
283
+ };
284
+ const readChunksBounded = async (reader, maxBytes, chunks, size) => {
285
+ const { done, value } = await reader.read();
286
+ if (done) return chunks;
287
+ const nextSize = size + (value?.byteLength ?? 0);
288
+ if (nextSize > maxBytes) {
289
+ try {
290
+ await reader.cancel();
291
+ } catch {}
292
+ return null;
293
+ }
294
+ if (value) chunks.push(value);
295
+ return readChunksBounded(reader, maxBytes, chunks, nextSize);
296
+ };
264
297
  /**
265
298
  * Read a request body as UTF-8, streaming it with a hard byte cap. Returns
266
299
  * `null` when the body exceeds `maxBytes` (the reader is cancelled before the
267
300
  * cap is far exceeded) or when decoding fails.
268
301
  */
269
- async function readBodyBounded(request, maxBytes) {
302
+ const readBodyBounded = async (request, maxBytes) => {
270
303
  const contentLength = request.headers.get("content-length");
271
304
  if (contentLength !== null && Number(contentLength) > maxBytes) return null;
272
305
  const reader = request.body?.getReader();
273
306
  if (!reader) return "";
274
- const chunks = [];
275
- let size = 0;
276
- for (;;) {
277
- const { done, value } = await reader.read();
278
- if (done) break;
279
- size += value?.byteLength ?? 0;
280
- if (size > maxBytes) {
281
- await reader.cancel().catch(() => {});
282
- return null;
283
- }
284
- chunks.push(value);
285
- }
307
+ const chunks = await readChunksBounded(reader, maxBytes, [], 0);
308
+ if (chunks === null) return null;
286
309
  const decoder = new TextDecoder();
287
310
  return chunks.map((c) => decoder.decode(c, { stream: true })).join("") + decoder.decode();
288
- }
311
+ };
289
312
  /**
290
313
  * Shared frame-request authorization used by both the production handler
291
314
  * below and the Vite/Rsbuild dev middleware: same-origin check against the
@@ -296,7 +319,7 @@ async function readBodyBounded(request, maxBytes) {
296
319
  * Returns the forwarded identity headers on success so callers render frames
297
320
  * with cookie/auth/UA context, or the HTTP status to reject with.
298
321
  */
299
- async function authorizeFrameRequest(request, options) {
322
+ const authorizeFrameRequest = async (request, options) => {
300
323
  const auth = getFrameAuth();
301
324
  if (!isTrustedOrigin(request, auth)) return {
302
325
  ok: false,
@@ -332,65 +355,71 @@ async function authorizeFrameRequest(request, options) {
332
355
  };
333
356
  }
334
357
  return {
335
- ok: true,
336
- identityHeaders: forwardIdentityHeaders(request.headers)
358
+ identityHeaders: forwardIdentityHeaders(request.headers),
359
+ ok: true
337
360
  };
338
- }
339
- async function ssr(request) {
361
+ };
362
+ const copyFrameworkSymbols = (from, to) => {
363
+ for (const sym of Object.getOwnPropertySymbols(from)) {
364
+ if (Symbol.keyFor(sym) === void 0) continue;
365
+ const desc = Object.getOwnPropertyDescriptor(from, sym);
366
+ if (!desc) continue;
367
+ try {
368
+ Object.defineProperty(to, sym, desc);
369
+ } catch {}
370
+ }
371
+ };
372
+ const ssr = async (request) => {
340
373
  let url;
341
374
  try {
342
375
  url = new URL(request.url);
343
376
  } catch {
344
377
  return json(400, { error: "frame failed" });
345
378
  }
346
- if (url.pathname !== "/__ilha/frame") return void 0;
347
- const program = Effect.gen(function* () {
379
+ if (url.pathname !== "/__ilha/frame") return;
380
+ const program = Effect.gen(function* handleFrame() {
348
381
  const auth = getFrameAuth();
349
382
  if (!isTrustedOrigin(request, auth)) return yield* Effect.fail(frameFail(403));
350
383
  if (request.method !== "POST") return yield* Effect.fail(frameFail(405));
351
384
  if (!(request.headers.get("content-type") ?? "").startsWith("application/json")) return yield* Effect.fail(frameFail(415));
352
385
  const authorized = yield* Effect.tryPromise({
386
+ catch: () => frameFail(403),
353
387
  try: () => authorizeFrameRequest(request, {
354
388
  defaultAction: auth?.defaultAction ?? "deny",
355
389
  onGuardError: (error) => console.error("[ilha-router] frame guard failed:", error)
356
- }),
357
- catch: () => frameFail(403)
390
+ })
358
391
  });
359
392
  if (!authorized.ok) return yield* Effect.fail(frameFail(authorized.status));
360
393
  let id;
361
- let path = "/";
394
+ let framePath = "/";
362
395
  let incomingProps;
363
396
  {
364
397
  const text = yield* Effect.tryPromise({
365
- try: () => readBodyBounded(request, MAX_BODY),
366
- catch: () => frameFail(400)
398
+ catch: () => frameFail(400),
399
+ try: () => readBodyBounded(request, MAX_BODY)
367
400
  });
368
401
  if (text === null) return yield* Effect.fail(frameFail(413));
369
402
  try {
370
403
  const body = JSON.parse(text);
371
404
  id = String(body.id ?? "");
372
405
  incomingProps = parseFrameProps(body.props);
373
- if (typeof body.path === "string") {
406
+ if (isString(body.path)) {
374
407
  if (!isSafeFramePath(body.path)) return yield* Effect.fail(frameFail(400));
375
- path = body.path;
408
+ framePath = body.path;
376
409
  }
377
410
  } catch {
378
411
  return yield* Effect.fail(frameFail(400));
379
412
  }
380
413
  }
381
- const scoped = new Request(frameScopedUrl(url.href, path), {
382
- method: "POST",
383
- headers: forwardIdentityHeaders(request.headers)
414
+ const scoped = new Request(frameScopedUrl(url.href, framePath), {
415
+ headers: forwardIdentityHeaders(request.headers),
416
+ method: "POST"
384
417
  });
385
- for (const sym of Object.getOwnPropertySymbols(request)) {
386
- if (Symbol.keyFor(sym) === void 0) continue;
387
- try {
388
- scoped[sym] = request[sym];
389
- } catch {}
390
- }
391
- return json(200, { html: yield* renderServerIsland(id, scoped, (scopedRequest, fn) => Promise.resolve(runWithIslandRequest(scopedRequest, fn)), incomingProps) });
418
+ copyFrameworkSymbols(request, scoped);
419
+ const html = yield* renderServerIsland(id, scoped, (scopedRequest, fn) => Promise.resolve(runWithIslandRequest(scopedRequest, fn)), incomingProps);
420
+ return json(200, { html });
392
421
  });
393
- return Effect.runPromise(Effect.map(Effect.result(program), Result.match({
422
+ return await Effect.runPromise(Effect.map(Effect.result(program), Result.match({
394
423
  onFailure: (error) => {
395
424
  if (error.redirect) return json(error.status, { redirect: error.redirect });
396
425
  if (error.status >= 500) console.error("[ilha-router] frame render failed:", error);
@@ -398,9 +427,8 @@ async function ssr(request) {
398
427
  },
399
428
  onSuccess: (response) => response
400
429
  })));
401
- }
402
- /** Side-effect imports required alongside this handler. */
403
- ssr.imports = ["ilha:pages/server"];
430
+ };
431
+ const ssrWithImports = Object.assign(ssr, { imports: ["ilha:pages/server"] });
404
432
 
405
433
  //#endregion
406
- export { runWithIslandRequest as C, ssr 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 };
434
+ 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 };