@ilha/router 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,4 @@
1
- import { t as runWithIslandRequest } from "./request-scope-C4reU4v0.js";
2
- import { FrameError, getFrameAuth, getFrameGuard, isTrustedOrigin, renderServerIsland, setFrameAuth, setFrameGuard, setLoaderGuard } from "./server-island-registry.js";
1
+ import { C as setLoaderGuard, T as runWithIslandRequest, b as setFrameAuth, c as frameEnvelope, m as isSafeFramePath, n as FrameError, o as authorizeFrameRequest, u as getFrameGuard, x as setFrameGuard, y as renderServerIsland } from "./ssr-BxrcUYy5.js";
3
2
  import { existsSync, readFileSync, statSync, watch } from "node:fs";
4
3
  import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
5
4
  import { createUnplugin } from "unplugin";
@@ -7,10 +6,24 @@ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
7
6
  import { createHash } from "node:crypto";
8
7
 
9
8
  //#region src/server-islands.ts
9
+ /**
10
+ * Replace identity `action(` wrappers of exported server actions with the
11
+ * capture-aware shim on ALREADY-COMPILED module code. Must run inside the
12
+ * SSR transform so upstream JSX/TS output is preserved.
13
+ */
14
+ function rewriteServerActions(code, rpcActions) {
15
+ const names = Object.keys(rpcActions);
16
+ if (names.length === 0) return code;
17
+ const re = new RegExp(`(export\\s+(?:const|let|var)\\s+(${names.join("|")})\\b\\s*=\\s*)action\\s*\\(`, "g");
18
+ return code.replace(re, (_match, head, name) => {
19
+ const key = rpcActions[name];
20
+ return `${head}__ilhaServerAction(${JSON.stringify(key)}, `;
21
+ });
22
+ }
10
23
  const EXPORT_RE = /(?:^|\n)\s*export\s+(?:declare\s+)?(?:async\s+)?(?:function\s*\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/g;
11
24
  const ISLAND_EXPORT_RE = /(?:^|\n)\s*export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*ilha\b/g;
12
25
  const DEFAULT_ISLAND_RE = /export\s+default\s+ilha\b/;
13
- const AS_RE = /\.as\(\s*["'`]([a-z][a-z0-9-]*)["'`]\s*\)/;
26
+ const AS_RES = [/\{\s*as:\s*["'`]([a-z][a-z0-9-]*)["'`]\s*[,}]?/];
14
27
  function clientRefPublicId(spec, imported) {
15
28
  return createHash("sha256").update(`${spec}#${imported}`).digest("base64url");
16
29
  }
@@ -69,35 +82,12 @@ function extractCallArgs(source, openParen, limit = 4e3) {
69
82
  console.warn("[ilha-router] scanServerIslands: argument list exceeded the scan limit — call skipped.");
70
83
  return null;
71
84
  }
72
- /** First callback body inside an args list: everything after the first top-level
73
- * comma. Used to scan which module exports a stream/action closure references. */
74
- function callbackBody(args) {
75
- let depth = 0;
76
- let quote = null;
77
- for (let i = 0; i < args.length; i++) {
78
- const ch = args[i];
79
- if (quote !== null) {
80
- if (ch === "\\") i++;
81
- else if (ch === quote) quote = null;
82
- continue;
83
- }
84
- if (ch === "\"" || ch === "'" || ch === "`") {
85
- quote = ch;
86
- continue;
87
- }
88
- if (ch === "(" || ch === "{" || ch === "[") depth++;
89
- else if (ch === ")" || ch === "}" || ch === "]") depth--;
90
- else if (ch === "," && depth === 0) return args.slice(i + 1);
91
- }
92
- return "";
93
- }
94
- /** Identifiers in `body` that are members of `candidates`, excluding keywords. */
95
85
  function referencedExports(body, candidates) {
96
86
  for (const match of body.matchAll(/([A-Za-z_$][\w$]*)\s*\(/g)) if (candidates.has(match[1])) return match[1];
97
87
  }
98
88
  /** Scan a `*.server.ts(x)` module source for island exports and their
99
89
  * declarative wiring. Convention: islands start with `ilha` — both builder
100
- * chains (`ilha.state()…render()`) and direct factories (`ilha(() => …)`). */
90
+ * function components (`ilha(() => …)`) and `ilha(schema, component)`. */
101
91
  function scanServerIslands(source) {
102
92
  const exports = [];
103
93
  for (const match of source.matchAll(EXPORT_RE)) exports.push(match[1]);
@@ -107,20 +97,36 @@ function scanServerIslands(source) {
107
97
  if (name && /^[A-Za-z_$][\w$]*$/.test(name) && !exports.includes(name)) exports.push(name);
108
98
  }
109
99
  const candidates = new Set(exports);
100
+ const rpcActions = {};
101
+ for (const match of source.matchAll(/export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*action\s*\(/g)) rpcActions[match[1]] = `x:${match[1]}`;
110
102
  const islands = [];
111
103
  const collect = (name, start, sliceEnd) => {
112
104
  const slice = source.slice(start, sliceEnd);
113
- const as = slice.match(AS_RE)?.[1] ?? "div";
105
+ let as = "div";
106
+ for (const re of AS_RES) {
107
+ const hit = slice.match(re)?.[1];
108
+ if (hit) {
109
+ as = hit;
110
+ break;
111
+ }
112
+ }
114
113
  const streams = {};
115
114
  const actions = {};
116
- for (const kind of ["stream", "action"]) {
117
- const re = new RegExp(`\\.${kind}\\s*\\(\\s*["'\`](\\w+)["'\`]\\s*,`, "g");
115
+ let actionOrder = 0;
116
+ let streamOrder = 0;
117
+ for (const kind of ["action", "derived"]) {
118
+ const re = new RegExp(kind === "action" ? "[A-Za-z0-9_$]*[Aa]ction\\s*\\(" : "\\bderived\\s*\\(", "g");
118
119
  for (const match of slice.matchAll(re)) {
119
- const key = match[1];
120
- const args = extractCallArgs(slice, (match.index ?? 0) + match[0].indexOf("("));
120
+ const args = extractCallArgs(slice, match.index + match[0].indexOf("("));
121
121
  if (!args) continue;
122
- const target = referencedExports(callbackBody(args), candidates);
123
- if (target) (kind === "stream" ? streams : actions)[key] = target;
122
+ if (kind === "derived") {
123
+ if (!/(async\s+function\s*\*|yield\b|for\s+await)/.test(args)) continue;
124
+ const target = referencedExports(args, candidates);
125
+ if (target) streams[`d${streamOrder++}`] = target;
126
+ continue;
127
+ }
128
+ const target = referencedExports(args, candidates);
129
+ actions[`a${actionOrder++}`] = target ?? "";
124
130
  }
125
131
  }
126
132
  islands.push({
@@ -145,6 +151,7 @@ function scanServerIslands(source) {
145
151
  return {
146
152
  islands,
147
153
  exports,
154
+ rpcActions,
148
155
  clientRefs: scanClientRefs(source),
149
156
  clientLoader: /(^|\n)\s*export\s+(?:const|let|var)\s+load\b\s*=\s*loader\.client\b/.test(source)
150
157
  };
@@ -174,7 +181,7 @@ function generateServerIslandModule(spec, scan) {
174
181
  const moduleKey = basename(spec).replace(/\.server\.(?:[jt]sx?)$/i, "");
175
182
  const lines = [
176
183
  `import { client as $$rpc } from "virtual:oxide/client";`,
177
- `import { __ilhaServerIsland } from "@ilha/router/server-island";`,
184
+ `import { __ilhaApplyHead, __ilhaServerIsland } from "@ilha/router/server-island";`,
178
185
  `const $$call = (method, args) => { const opts = args.at(-1); return opts && typeof opts === "object" && opts.signal instanceof AbortSignal && Object.keys(opts).length === 1 ? $$rpc[${JSON.stringify(moduleKey)}][method](args.slice(0, -1), opts) : $$rpc[${JSON.stringify(moduleKey)}][method](args); };`,
179
186
  ...scan.clientRefs.map((ref, index) => ref.imported === "default" ? `import $$child${index} from ${JSON.stringify(ref.spec)};` : `import { ${ref.imported} as $$child${index} } from ${JSON.stringify(ref.spec)};`)
180
187
  ];
@@ -182,12 +189,16 @@ function generateServerIslandModule(spec, scan) {
182
189
  for (const island of scan.islands) {
183
190
  const wiring = [];
184
191
  const streams = Object.entries(island.streams).map(([key, target]) => `${JSON.stringify(key)}: (signal) => $$call(${JSON.stringify(target)}, [{ signal }])`);
185
- const actions = Object.entries(island.actions).map(([key, target]) => `${JSON.stringify(key)}: (...args) => $$call(${JSON.stringify(target)}, args)`);
192
+ const rpcEntries = Object.entries(scan.rpcActions).map(([name, key]) => [key, name]);
193
+ const actions = Object.entries({
194
+ ...island.actions,
195
+ ...Object.fromEntries(rpcEntries)
196
+ }).map(([key, target]) => `${JSON.stringify(key)}: (...args) => $$call(${JSON.stringify(target)}, args)`);
186
197
  if (streams.length) wiring.push(`streams: { ${streams.join(", ")} }`);
187
198
  if (actions.length) wiring.push(`actions: { ${actions.join(", ")} }`);
188
199
  if (scan.clientRefs.length) wiring.push(`children: { ${scan.clientRefs.map((ref, index) => `${JSON.stringify(ref.id)}: $$child${index}`).join(", ")} }`);
189
200
  const id = serverIslandPublicId(spec, island.name);
190
- wiring.push(`frame: () => fetch("/__ilha/frame", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id: ${JSON.stringify(id)}, path: location.pathname + location.search }) }).then((r) => { if (!r.ok) throw new Error("frame failed"); return r.json(); }).then((j) => { if (j.redirect) { location.assign(j.redirect); throw new Error("frame redirected"); } return j.html; })`);
201
+ wiring.push(`frame: () => fetch("/__ilha/frame", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id: ${JSON.stringify(id)}, path: location.pathname + location.search }) }).then((r) => { if (!r.ok) throw new Error("frame failed"); return r.json(); }).then((j) => { if (j.redirect) { location.assign(j.redirect); throw new Error("frame redirected"); } __ilhaApplyHead(j.head); return j.html; })`);
191
202
  if (scan.clientLoader) wiring.push(`clientLoader: () => $$call("load", [])`);
192
203
  const call = `__ilhaServerIsland(${JSON.stringify(id)}, ${JSON.stringify(island.as)}, { ${wiring.join(", ")} })`;
193
204
  if (island.name === "default") lines.push(`export default ${call};`);
@@ -472,11 +483,11 @@ function buildServerFile(entries, serverFile) {
472
483
  `// Import via: import { pageRouter, registry } from "ilha:pages/server";`,
473
484
  ``,
474
485
  ...imports,
475
- ...entries.some((e) => e.hasLoader || e.loaderLayouts.length > 0) ? [`import { setFrameLoaderRunner } from "@ilha/router/server-island-registry";`, `setFrameLoaderRunner((path, request) => pageRouter.runLoader(path, request));`] : [],
486
+ ...entries.some((e) => e.hasLoader || e.loaderLayouts.length > 0) ? [`import { setFrameLoaderRunner } from "@ilha/router/ssr";`, `setFrameLoaderRunner((path, request) => pageRouter.runLoader(path, request));`] : [],
476
487
  ``,
477
488
  ...wrappedIslandLines,
478
489
  ``,
479
- `export const registry: Record<string, Island<any, any>> = {`,
490
+ `export const registry: Record<string, Island<any>> = {`,
480
491
  ...registryLines,
481
492
  `};`,
482
493
  ``,
@@ -556,7 +567,7 @@ function buildClientFile(entries, clientFile, opts) {
556
567
  ``,
557
568
  ...wrappedIslandLines,
558
569
  ``,
559
- `export const registry: Record<string, Island<any, any>> = {`,
570
+ `export const registry: Record<string, Island<any>> = {`,
560
571
  ...registryLines,
561
572
  `};`,
562
573
  ``
@@ -626,14 +637,14 @@ async function generateTypes(outDir) {
626
637
  ` import type { RouterBuilder } from "@ilha/router";`,
627
638
  ` import type { Island } from "ilha";`,
628
639
  ` export const pageRouter: RouterBuilder;`,
629
- ` export const registry: Record<string, Island<any, any>>;`,
640
+ ` export const registry: Record<string, Island<any>>;`,
630
641
  `}`,
631
642
  ``,
632
643
  `declare module "ilha:pages/client" {`,
633
644
  ` import type { RouterBuilder } from "@ilha/router";`,
634
645
  ` import type { Island } from "ilha";`,
635
646
  ` export const pageRouter: RouterBuilder;`,
636
- ` export const registry: Record<string, Island<any, any>>;`,
647
+ ` export const registry: Record<string, Island<any>>;`,
637
648
  `}`,
638
649
  ``,
639
650
  `declare module "ilha:loaders" {`,
@@ -722,8 +733,8 @@ function detectIlhaConsumers(root) {
722
733
  const appPkg = readJson(join(root, "package.json"));
723
734
  if (!appPkg) return [];
724
735
  const deps = {
725
- ...appPkg.dependencies ?? {},
726
- ...appPkg.devDependencies ?? {}
736
+ ...appPkg.dependencies,
737
+ ...appPkg.devDependencies
727
738
  };
728
739
  const found = [];
729
740
  for (const name of Object.keys(deps)) {
@@ -902,7 +913,6 @@ const pagesFactory = (options = {}) => {
902
913
  config(userConfig) {
903
914
  const singletonPeers = [
904
915
  "ilha",
905
- "@ilha/store",
906
916
  "@ilha/router",
907
917
  "alien-signals",
908
918
  ...detectIlhaConsumers(userConfig.root ? resolve(userConfig.root) : process.cwd())
@@ -919,7 +929,6 @@ const pagesFactory = (options = {}) => {
919
929
  "ilha",
920
930
  "ilha/jsx-runtime",
921
931
  "ilha/jsx-dev-runtime",
922
- "@ilha/store",
923
932
  "alien-signals"
924
933
  ])]
925
934
  }
@@ -937,16 +946,19 @@ const pagesFactory = (options = {}) => {
937
946
  const lines = [];
938
947
  for (const ref of scan?.clientRefs ?? []) lines.push(`if (${ref.local}?.[Symbol.for("ilha.island")]) ${ref.local}[Symbol.for("ilha.clientRef")] = ${JSON.stringify(ref.id)};`);
939
948
  if (scan && scan.islands.length > 0 && !code.startsWith("// oxidejs:client-stub")) {
949
+ if (Object.keys(scan.rpcActions).length > 0) lines.unshift(`import { __ilhaServerAction } from "@ilha/router/ssr";`);
940
950
  lines.unshift(`import * as __ilhaSelf from ${JSON.stringify(file)};`);
941
- lines.unshift(`import { registerServerIsland } from "@ilha/router/server-island-registry";`);
951
+ lines.unshift(`import { registerServerIsland } from "@ilha/router/ssr";`);
942
952
  for (const island of scan.islands) {
943
953
  const id2 = serverIslandPublicId(file, island.name);
944
954
  const isServerPage = SERVER_PAGE_RE.test(file) && state.isUnderPagesDir(file) && scan.exports.includes("load") ? `, { load: __ilhaSelf.load, pattern: ${JSON.stringify(fileToPattern(state.pagesDir, file))} }` : "";
945
955
  lines.push(`registerServerIsland(${JSON.stringify(id2)}, () => __ilhaSelf[${JSON.stringify(island.name)}]?.[Symbol.for("ilha.renderState")]${isServerPage});`);
946
956
  }
947
957
  }
948
- if (lines.length === 0) return null;
949
- return `${code}\n${lines.join("\n")}`;
958
+ const base = rewriteServerActions(code, scan?.rpcActions ?? {});
959
+ if (base === code && lines.length === 0) return null;
960
+ if (lines.length === 0) return base;
961
+ return `${base}\n${lines.join("\n")}`;
950
962
  }
951
963
  if (id.startsWith("\0") || id.includes("node_modules")) return null;
952
964
  if (serverFile) return null;
@@ -990,7 +1002,7 @@ const pagesFactory = (options = {}) => {
990
1002
  server.watcher.add(state.pagesDir);
991
1003
  if (options.frameGuard) setFrameGuard(options.frameGuard);
992
1004
  if (options.loaderGuard) setLoaderGuard(options.loaderGuard);
993
- if (options.trustedOrigins || options.csrf) setFrameAuth({
1005
+ setFrameAuth({
994
1006
  trustedOrigins: options.trustedOrigins,
995
1007
  csrf: options.csrf,
996
1008
  defaultAction: "open"
@@ -1007,48 +1019,17 @@ const pagesFactory = (options = {}) => {
1007
1019
  res.end();
1008
1020
  return;
1009
1021
  }
1010
- const guardHeaders = new Headers();
1011
- if (req.headers.origin) guardHeaders.set("origin", String(req.headers.origin));
1012
- if (req.headers.host) guardHeaders.set("host", String(req.headers.host));
1013
- if (!isTrustedOrigin(new Request(`http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`, { headers: guardHeaders }), getFrameAuth())) {
1014
- console.warn(`[ilha-router] dev frame request rejected: Origin ${String(req.headers.origin)} is not trusted (host: ${String(req.headers.host ?? "localhost")}). Configure trustedOrigins via IlhaPagesOptions if this origin is expected.`);
1015
- res.statusCode = 403;
1016
- res.end();
1017
- return;
1018
- }
1019
- const identityHeaders = new Headers();
1020
- for (const name of ["cookie", "authorization"]) {
1021
- const v = req.headers[name];
1022
- if (typeof v === "string") identityHeaders.set(name, v);
1023
- }
1024
- try {
1025
- const denied = await getFrameGuard()?.(new Request(`http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`, {
1026
- method: req.method,
1027
- headers: identityHeaders
1028
- }));
1029
- if (denied) {
1030
- res.statusCode = denied.status;
1031
- res.setHeader("cache-control", "no-store");
1032
- res.end();
1033
- return;
1034
- }
1035
- } catch {
1036
- res.statusCode = 403;
1037
- res.end();
1038
- return;
1039
- }
1040
- const csrf = getFrameAuth()?.csrf;
1041
- if (csrf) try {
1042
- if (!await csrf(new Request(`http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`, {
1043
- method: req.method,
1044
- headers: identityHeaders
1045
- }))) {
1046
- res.statusCode = 403;
1047
- res.end();
1048
- return;
1049
- }
1050
- } catch {
1051
- res.statusCode = 403;
1022
+ const frameUrl = `http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`;
1023
+ const authorized = await authorizeFrameRequest(new Request(frameUrl, {
1024
+ method: req.method,
1025
+ headers: new Headers(req.headers)
1026
+ }), {
1027
+ defaultAction: "open",
1028
+ onGuardError: () => {}
1029
+ });
1030
+ if (!authorized.ok) {
1031
+ if (authorized.status === 403 && !getFrameGuard()) console.warn(`[ilha-router] dev frame request rejected: Origin ${String(req.headers.origin)} is not trusted (host: ${String(req.headers.host ?? "localhost")}). Configure trustedOrigins via IlhaPagesOptions if this origin is expected.`);
1032
+ res.statusCode = authorized.status;
1052
1033
  res.end();
1053
1034
  return;
1054
1035
  }
@@ -1067,42 +1048,48 @@ const pagesFactory = (options = {}) => {
1067
1048
  const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
1068
1049
  const target = serverIslands.get(body.id ?? "");
1069
1050
  if (!target) throw new Error("unknown island");
1070
- let framePath = "/__ilha/frame";
1071
- if (typeof body.path === "string" && body.path.startsWith("/") && !body.path.includes("//") && !body.path.includes("\\") && body.path.length <= 2048) framePath = body.path;
1051
+ let framePath = "/";
1052
+ if (typeof body.path === "string") {
1053
+ if (!isSafeFramePath(body.path)) {
1054
+ const env = frameEnvelope(400, { error: "frame failed" });
1055
+ res.statusCode = env.status;
1056
+ for (const [k, v] of Object.entries(env.headers)) res.setHeader(k, v);
1057
+ res.end(env.body);
1058
+ return;
1059
+ }
1060
+ framePath = body.path;
1061
+ }
1072
1062
  await server.ssrLoadModule(VIRTUAL_PAGES_SERVER);
1073
1063
  if (typeof (await server.ssrLoadModule(target.file))[target.name]?.[Symbol.for("ilha.renderState")] !== "function") throw new Error("unknown island");
1074
- const headers = new Headers();
1075
- for (const name of [
1076
- "cookie",
1077
- "authorization",
1078
- "user-agent"
1079
- ]) {
1080
- const value = req.headers[name];
1081
- if (typeof value === "string") headers.set(name, value);
1082
- }
1064
+ const headers = authorized.identityHeaders;
1083
1065
  const requestOrigin = `http://${req.headers.host ?? "localhost"}`;
1084
1066
  const request = new Request(new URL(framePath, requestOrigin), {
1085
1067
  method: "POST",
1086
1068
  headers
1087
1069
  });
1088
- const html = await renderServerIsland(body.id ?? "", request, (r, fn) => runWithIslandRequest(r, fn));
1089
- res.setHeader("cache-control", "no-store");
1090
- res.setHeader("content-type", "application/json;charset=utf-8");
1091
- res.end(JSON.stringify({ html: String(html) }));
1070
+ let head;
1071
+ const html = await renderServerIsland(body.id ?? "", request, (r, fn) => runWithIslandRequest(r, fn), (entries) => head = entries);
1072
+ const env = frameEnvelope(200, {
1073
+ html: String(html),
1074
+ head
1075
+ });
1076
+ res.statusCode = env.status;
1077
+ for (const [k, v] of Object.entries(env.headers)) res.setHeader(k, v);
1078
+ res.end(env.body);
1092
1079
  } catch (err) {
1093
1080
  if (err instanceof FrameError && err.redirect) {
1094
- res.statusCode = err.status;
1095
- res.setHeader("cache-control", "no-store");
1096
- res.setHeader("content-type", "application/json;charset=utf-8");
1097
- res.end(JSON.stringify({ redirect: err.redirect }));
1081
+ const env = frameEnvelope(err.status, { redirect: err.redirect });
1082
+ res.statusCode = env.status;
1083
+ for (const [k, v] of Object.entries(env.headers)) res.setHeader(k, v);
1084
+ res.end(env.body);
1098
1085
  return;
1099
1086
  }
1100
1087
  const status = err instanceof FrameError ? err.status : 400;
1101
1088
  if (!(err instanceof FrameError) || err.status >= 500) console.error("[ilha-router] frame render failed:", err);
1102
- res.statusCode = status;
1103
- res.setHeader("cache-control", "no-store");
1104
- res.setHeader("content-type", "application/json;charset=utf-8");
1105
- res.end(JSON.stringify({ error: "frame failed" }));
1089
+ const env = frameEnvelope(status, { error: "frame failed" });
1090
+ res.statusCode = env.status;
1091
+ for (const [k, v] of Object.entries(env.headers)) res.setHeader(k, v);
1092
+ res.end(env.body);
1106
1093
  }
1107
1094
  });
1108
1095
  const structuralInvalidate = createStructuralInvalidate(state, async () => {
package/dist/plugin.d.ts CHANGED
@@ -33,7 +33,7 @@ export interface IlhaPagesOptions {
33
33
  * Return a `Response` to reject; return nothing to allow. Island state is
34
34
  * world-readable through frames unless gated — install a session check here
35
35
  * when islands serve private data. Production equivalents register via
36
- * `setFrameGuard()` from `@ilha/router/server-island-registry`.
36
+ * `setFrameGuard()` from `@ilha/router/ssr`.
37
37
  */
38
38
  frameGuard?: (request: Request) => Response | void | Promise<Response | void>;
39
39
  /**
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Shared, decoded-aware route pattern matching used by both the router
3
- * (`index.ts`) and the server-frame path (`server-island-registry.ts`) so the
3
+ * (`index.ts`) and the server-frame path (`ssr.ts`) so the
4
4
  * two never drift on segment semantics or parameter decoding.
5
5
  *
6
6
  * Patterns support `:name` segments, a bare mid-pattern `*` (one segment), and
@@ -1,6 +1,6 @@
1
1
  export type { LayoutHandler, ErrorHandler, RouteSnapshot, AppError } from "./index";
2
2
  export { ilhaPages, type IlhaPagesOptions } from "./plugin";
3
3
  import { type IlhaPagesOptions } from "./plugin";
4
- /** Rspack plugin — use via `@ilha/router/rspack`. */
5
- export declare function pages(options?: IlhaPagesOptions): RspackPluginInstance;
4
+ /** Rsbuild plugin — use via `@ilha/router/rsbuild`. */
5
+ export declare function pages(options?: IlhaPagesOptions): any;
6
6
  export default pages;
@@ -0,0 +1,10 @@
1
+ import { t as ilhaPages } from "./plugin-CmI3Brr2.js";
2
+
3
+ //#region src/rsbuild.ts
4
+ /** Rsbuild plugin — use via `@ilha/router/rsbuild`. */
5
+ function pages(options = {}) {
6
+ return ilhaPages.rsbuild(options);
7
+ }
8
+
9
+ //#endregion
10
+ export { pages as default, pages, ilhaPages };
@@ -14,6 +14,7 @@
14
14
  * `[data-ilha-on]` event sentinels to named actions using the
15
15
  * `data-ilha-actions` manifest emitted by `hydratable()`.
16
16
  */
17
+ declare const ISLAND_CALL: unique symbol;
17
18
  export type ServerStreamFn = (signal: AbortSignal) => AsyncGenerator<unknown> | Generator<unknown>;
18
19
  export interface ServerIslandWiring {
19
20
  /** Stream key → client transport. The plugin wires these to tacho stubs. */
@@ -33,12 +34,26 @@ export interface ServerIslandHandle {
33
34
  unmount: () => void;
34
35
  updateProps: (props?: Record<string, unknown>) => void;
35
36
  }
37
+ /** @internal Apply head entries returned with a server-page frame. */
38
+ export declare function __ilhaApplyHead(entries: unknown): void;
36
39
  /**
37
40
  * Create a client proxy island for a server-defined island. Called by
38
41
  * generated virtual modules — not by application code.
39
42
  *
40
43
  * @param id - Stable identity (`<relative-path>#<export>`), for diagnostics.
41
- * @param as - Slot tag declared by the server island's `.as()` (default div).
44
+ * @param as - Slot tag declared by the server island's `{ as }` option (default div).
42
45
  * @param wiring - Stream/action transports wired to tacho stubs by codegen.
43
46
  */
44
- export declare function __ilhaServerIsland(id: string, as: string, wiring?: ServerIslandWiring): unknown;
47
+ interface IslandCallShape {
48
+ [ISLAND_CALL]: true;
49
+ island: ServerIslandCallable;
50
+ props?: Record<string, unknown>;
51
+ key: string;
52
+ }
53
+ export type ServerIslandCallable = Record<symbol, unknown> & ((props?: Record<string, unknown>) => string) & {
54
+ mount: (host: Element) => () => void;
55
+ toString: () => string;
56
+ key: (slotKey: string) => (props?: Record<string, unknown>) => IslandCallShape;
57
+ };
58
+ export declare function __ilhaServerIsland(id: string, as: string, wiring?: ServerIslandWiring): ServerIslandCallable;
59
+ export {};
@@ -1,3 +1,4 @@
1
+ import { n as applyHeadEntriesToDocument, t as parseSnapshotAttr } from "./snapshot-CsEaY6h_.js";
1
2
  import { morph } from "ilha";
2
3
 
3
4
  //#region src/server-island.ts
@@ -20,23 +21,18 @@ import { morph } from "ilha";
20
21
  /** Symbol.for keeps brands stable across duplicate ilha copies in one realm. */
21
22
  const ISLAND = Symbol.for("ilha.island");
22
23
  const ISLAND_SLOT_TAG = Symbol.for("ilha.islandSlotTag");
23
- const ISLAND_MOUNT_INTERNAL$1 = Symbol.for("ilha.islandMountInternal");
24
+ const ISLAND_MOUNT_INTERNAL = Symbol.for("ilha.islandMountInternal");
24
25
  const STATE_ATTR = "data-ilha-state";
25
26
  const EVENT_SENTINEL_ATTR = "data-ilha-on";
26
27
  const ACTIONS_ATTR = "data-ilha-actions";
27
28
  const PROPS_ATTR = "data-ilha-props";
28
29
  const CLIENT_REF_ATTR = "data-ilha-client-ref";
29
- /** Defensive snapshot parse mirrors core's guards in miniature. */
30
- function parseSnapshot(raw) {
31
- if (raw.length > 262144) return void 0;
32
- try {
33
- const parsed = JSON.parse(raw);
34
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
35
- return parsed;
36
- } catch {
37
- return;
38
- }
30
+ /** @internal Apply head entries returned with a server-page frame. */
31
+ function __ilhaApplyHead(entries) {
32
+ if (Array.isArray(entries)) applyHeadEntriesToDocument(entries);
39
33
  }
34
+ /** Defensive snapshot parse — reuses the shared guarded parser (size cap,
35
+ * plain-object check, depth cap, prototype-key stripping). */
40
36
  function assertValidTag(tag) {
41
37
  const trimmed = tag.trim();
42
38
  if (/^[a-z][a-z0-9-]*$/i.test(trimmed)) return trimmed.toLowerCase();
@@ -59,7 +55,7 @@ function hydrateServerIsland(host, id, wiring) {
59
55
  const state = {};
60
56
  const rawState = host.getAttribute(STATE_ATTR);
61
57
  if (rawState) {
62
- const parsed = parseSnapshot(rawState);
58
+ const parsed = parseSnapshotAttr(rawState);
63
59
  if (parsed) {
64
60
  for (const [key, value] of Object.entries(parsed)) if (!key.startsWith("_")) state[key] = value;
65
61
  }
@@ -82,7 +78,7 @@ function hydrateServerIsland(host, id, wiring) {
82
78
  const attached = [];
83
79
  const readManifest = () => {
84
80
  const raw = Array.from(host.children).find((c) => c.matches(`template[${ACTIONS_ATTR}]`))?.getAttribute(ACTIONS_ATTR) ?? host.getAttribute(ACTIONS_ATTR) ?? null;
85
- return raw ? parseSnapshot(raw) : void 0;
81
+ return raw ? parseSnapshotAttr(raw) : void 0;
86
82
  };
87
83
  const wireEvents = () => {
88
84
  for (const { el, type, listener } of attached) el.removeEventListener(type, listener);
@@ -148,13 +144,13 @@ function hydrateServerIsland(host, id, wiring) {
148
144
  }
149
145
  for (const el of host.querySelectorAll(`[${CLIENT_REF_ATTR}]`)) {
150
146
  if (!belongsToHost(host, el)) continue;
151
- const props = reviveChildProps(parseSnapshot(el.getAttribute(PROPS_ATTR) ?? "") ?? void 0);
147
+ const props = reviveChildProps(parseSnapshotAttr(el.getAttribute(PROPS_ATTR) ?? "") ?? void 0);
152
148
  const mounted = mountedChildren.get(el);
153
149
  if (mounted) {
154
150
  mounted.updateProps(props);
155
151
  continue;
156
152
  }
157
- const mount = (wiring.children?.[el.getAttribute(CLIENT_REF_ATTR) ?? ""])?.[ISLAND_MOUNT_INTERNAL$1];
153
+ const mount = (wiring.children?.[el.getAttribute(CLIENT_REF_ATTR) ?? ""])?.[ISLAND_MOUNT_INTERNAL];
158
154
  if (mount) mountedChildren.set(el, mount(el, props));
159
155
  }
160
156
  };
@@ -194,14 +190,6 @@ function hydrateServerIsland(host, id, wiring) {
194
190
  updateProps: () => {}
195
191
  };
196
192
  }
197
- /**
198
- * Create a client proxy island for a server-defined island. Called by
199
- * generated virtual modules — not by application code.
200
- *
201
- * @param id - Stable identity (`<relative-path>#<export>`), for diagnostics.
202
- * @param as - Slot tag declared by the server island's `.as()` (default div).
203
- * @param wiring - Stream/action transports wired to tacho stubs by codegen.
204
- */
205
193
  function __ilhaServerIsland(id, as, wiring = {}) {
206
194
  const slotTag = assertValidTag(as);
207
195
  const island = ((props) => {
@@ -220,10 +208,10 @@ function __ilhaServerIsland(id, as, wiring = {}) {
220
208
  key: slotKey
221
209
  });
222
210
  };
223
- island[ISLAND_MOUNT_INTERNAL$1] = (host) => hydrateServerIsland(host, id, wiring);
211
+ island[ISLAND_MOUNT_INTERNAL] = (host) => hydrateServerIsland(host, id, wiring);
224
212
  island.mount = (host) => hydrateServerIsland(host, id, wiring).unmount;
225
213
  return island;
226
214
  }
227
215
 
228
216
  //#endregion
229
- export { __ilhaServerIsland };
217
+ export { __ilhaApplyHead, __ilhaServerIsland };
@@ -9,7 +9,7 @@
9
9
  export interface ScannedServerIsland {
10
10
  /** Export binding name, or `"default"` for `export default ilha…`. */
11
11
  name: string;
12
- /** Slot tag from `.as()` — must match what SSR emits. */
12
+ /** Slot tag from the `{ as }` option — must match what SSR emits. */
13
13
  as: string;
14
14
  /** Stream key → referenced module export used as its transport. */
15
15
  streams: Record<string, string>;
@@ -22,10 +22,24 @@ export interface ClientIslandRef {
22
22
  imported: string;
23
23
  spec: string;
24
24
  }
25
+ /**
26
+ * Replace identity `action(` wrappers of exported server actions with the
27
+ * capture-aware shim on ALREADY-COMPILED module code. Must run inside the
28
+ * SSR transform so upstream JSX/TS output is preserved.
29
+ */
30
+ export declare function rewriteServerActions(code: string, rpcActions: Record<string, string>): string;
25
31
  export interface ServerModuleScan {
26
32
  islands: ScannedServerIsland[];
27
33
  /** All value-export names of the module (transport candidates). */
28
34
  exports: string[];
35
+ /**
36
+ * Exported server actions rewritten to capture-aware shims: name → the
37
+ * `x:<name>` manifest key the client proxy wires an RPC transport for.
38
+ * Event closures may call these directly without wrapping in ilha's
39
+ * action() — during hydration-manifest rendering the call is recorded,
40
+ * not executed.
41
+ */
42
+ rpcActions: Record<string, string>;
29
43
  /** Imported JSX components that must hydrate inside the server island. */
30
44
  clientRefs: ClientIslandRef[];
31
45
  /** True when the module declares `export const load = loader.client(…)` —
@@ -35,7 +49,7 @@ export interface ServerModuleScan {
35
49
  export declare function clientRefPublicId(spec: string, imported: string): string;
36
50
  /** Scan a `*.server.ts(x)` module source for island exports and their
37
51
  * declarative wiring. Convention: islands start with `ilha` — both builder
38
- * chains (`ilha.state()…render()`) and direct factories (`ilha(() => …)`). */
52
+ * function components (`ilha(() => …)`) and `ilha(schema, component)`. */
39
53
  export declare function scanServerIslands(source: string): ServerModuleScan;
40
54
  export declare function loadServerModuleScan(path: string): ServerModuleScan;
41
55
  /** Virtual-module id prefix for generated client proxies of server islands.