@ilha/router 0.8.13 → 0.9.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,14 +1,268 @@
1
- import { existsSync, readFileSync, watch } from "node:fs";
1
+ import { t as runWithIslandRequest } from "./request-scope-D6_4rqMb.js";
2
+ import { FrameError, getFrameGuard, renderServerIsland, setFrameGuard } from "./server-island-registry.js";
3
+ import { existsSync, readFileSync, statSync, watch } from "node:fs";
2
4
  import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
3
5
  import { createUnplugin } from "unplugin";
4
6
  import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
7
+ import { createHash } from "node:crypto";
5
8
 
9
+ //#region src/server-islands.ts
10
+ const EXPORT_RE = /(?:^|\n)\s*export\s+(?:declare\s+)?(?:async\s+)?(?:function\s*\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/g;
11
+ const ISLAND_EXPORT_RE = /(?:^|\n)\s*export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*ilha\b/g;
12
+ const DEFAULT_ISLAND_RE = /export\s+default\s+ilha\b/;
13
+ const AS_RE = /\.as\(\s*["'`]([a-z][a-z0-9-]*)["'`]\s*\)/;
14
+ function clientRefPublicId(spec, imported) {
15
+ return createHash("sha256").update(`${spec}#${imported}`).digest("base64url");
16
+ }
17
+ function scanClientRefs(source) {
18
+ const used = new Set(Array.from(source.matchAll(/<([A-Z][\w$]*)\b/g), (match) => match[1]));
19
+ const refs = [];
20
+ for (const match of source.matchAll(/(?:^|\n)\s*import\s+(?!type\b)([^'"\n]+?)\s+from\s+["']([^"']+)["']/g)) {
21
+ const clause = match[1].trim();
22
+ const spec = match[2];
23
+ const brace = clause.match(/\{([^}]+)\}/)?.[1];
24
+ if (brace) for (const part of brace.split(",")) {
25
+ const binding = part.trim().match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
26
+ if (!binding) continue;
27
+ const imported = binding[1];
28
+ const local = binding[2] ?? imported;
29
+ if (used.has(local)) refs.push({
30
+ id: clientRefPublicId(spec, imported),
31
+ local,
32
+ imported,
33
+ spec
34
+ });
35
+ }
36
+ const defaultLocal = clause.split(",", 1)[0].trim();
37
+ if (/^[A-Za-z_$][\w$]*$/.test(defaultLocal) && used.has(defaultLocal)) refs.push({
38
+ id: clientRefPublicId(spec, "default"),
39
+ local: defaultLocal,
40
+ imported: "default",
41
+ spec
42
+ });
43
+ }
44
+ return refs;
45
+ }
46
+ /** Extract the balanced-paren argument list starting at the "(" following
47
+ * `from` index. String literals are skipped so parens inside them don't count.
48
+ * Returns the inner text, or null when unbalanced within `limit` chars. */
49
+ function extractCallArgs(source, openParen, limit = 4e3) {
50
+ let depth = 0;
51
+ let quote = null;
52
+ for (let i = openParen; i < Math.min(source.length, openParen + limit); i++) {
53
+ const ch = source[i];
54
+ if (quote !== null) {
55
+ if (ch === "\\") i++;
56
+ else if (ch === quote) quote = null;
57
+ continue;
58
+ }
59
+ if (ch === "\"" || ch === "'" || ch === "`") {
60
+ quote = ch;
61
+ continue;
62
+ }
63
+ if (ch === "(") depth++;
64
+ else if (ch === ")") {
65
+ depth--;
66
+ if (depth === 0) return source.slice(openParen + 1, i);
67
+ }
68
+ }
69
+ console.warn("[ilha-router] scanServerIslands: argument list exceeded the scan limit — call skipped.");
70
+ return null;
71
+ }
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
+ function referencedExports(body, candidates) {
96
+ for (const match of body.matchAll(/([A-Za-z_$][\w$]*)\s*\(/g)) if (candidates.has(match[1])) return match[1];
97
+ }
98
+ /** Scan a `*.server.ts(x)` module source for island exports and their
99
+ * declarative wiring. Convention: islands start with `ilha` — both builder
100
+ * chains (`ilha.state()…render()`) and direct factories (`ilha(() => …)`). */
101
+ function scanServerIslands(source) {
102
+ const exports = [];
103
+ for (const match of source.matchAll(EXPORT_RE)) exports.push(match[1]);
104
+ for (const match of source.matchAll(/export\s*\{([^}]*)\}/g)) for (const part of match[1].split(",")) {
105
+ const parts2 = part.trim().split(/\s+as\s+/);
106
+ const name = (parts2.length === 2 ? parts2[1] : parts2[0])?.trim();
107
+ if (name && /^[A-Za-z_$][\w$]*$/.test(name) && !exports.includes(name)) exports.push(name);
108
+ }
109
+ const candidates = new Set(exports);
110
+ const islands = [];
111
+ const collect = (name, start, sliceEnd) => {
112
+ const slice = source.slice(start, sliceEnd);
113
+ const as = slice.match(AS_RE)?.[1] ?? "div";
114
+ const streams = {};
115
+ const actions = {};
116
+ for (const kind of ["stream", "action"]) {
117
+ const re = new RegExp(`\\.${kind}\\s*\\(\\s*["'\`](\\w+)["'\`]\\s*,`, "g");
118
+ for (const match of slice.matchAll(re)) {
119
+ const key = match[1];
120
+ const args = extractCallArgs(slice, (match.index ?? 0) + match[0].indexOf("("));
121
+ if (!args) continue;
122
+ const target = referencedExports(callbackBody(args), candidates);
123
+ if (target) (kind === "stream" ? streams : actions)[key] = target;
124
+ }
125
+ }
126
+ islands.push({
127
+ name,
128
+ as,
129
+ streams,
130
+ actions
131
+ });
132
+ };
133
+ for (const match of source.matchAll(ISLAND_EXPORT_RE)) {
134
+ const name = match[1];
135
+ const rest = source.slice((match.index ?? 0) + match[0].length);
136
+ const nextExport = rest.search(/\nexport\b/);
137
+ collect(name, match.index ?? 0, (match.index ?? 0) + match[0].length + (nextExport === -1 ? rest.length : nextExport));
138
+ }
139
+ const defaultMatch = source.match(DEFAULT_ISLAND_RE);
140
+ if (defaultMatch && defaultMatch.index !== void 0) {
141
+ const rest = source.slice(defaultMatch.index + defaultMatch[0].length);
142
+ const nextExport = rest.search(/\nexport\b/);
143
+ collect("default", defaultMatch.index, defaultMatch.index + defaultMatch[0].length + (nextExport === -1 ? rest.length : nextExport));
144
+ }
145
+ return {
146
+ islands,
147
+ exports,
148
+ clientRefs: scanClientRefs(source),
149
+ clientLoader: /(^|\n)\s*export\s+(?:const|let|var)\s+load\b\s*=\s*loader\.client\b/.test(source)
150
+ };
151
+ }
152
+ function loadServerModuleScan(path) {
153
+ return scanServerIslands(readFileSync(path, "utf8"));
154
+ }
155
+ /** Virtual-module id prefix for generated client proxies of server islands.
156
+ * The file path rides base64url-encoded: a raw suffix like
157
+ * `\0…:…/tasks.server.tsx` would end in `.server.*` and oxidejs's client-stub
158
+ * loader would claim the virtual module before us. */
159
+ const SERVER_ISLAND_PREFIX = "\0ilha:server-island:";
160
+ /** Virtual-module specifier serving the client proxy for one server island file. */
161
+ function serverIslandVirtualSpec(file) {
162
+ return SERVER_ISLAND_PREFIX + Buffer.from(file).toString("base64url");
163
+ }
164
+ /** Emit the client virtual module for one scanned server file. Plain JS —
165
+ * `\0` virtual modules bypass Vite's built-in TS transform, so type-only
166
+ * constructs here would reach the browser unparsed. Editor types are
167
+ * unaffected: TS resolves the ORIGINAL specifier (the real server module);
168
+ * this module exists only inside the client bundle. Frames are fetched from
169
+ * the plugin's `/__ilha/frame` dev middleware. */
170
+ function serverIslandPublicId(spec, name) {
171
+ return createHash("sha256").update(`${spec}#${name}`).digest("base64url");
172
+ }
173
+ function generateServerIslandModule(spec, scan) {
174
+ const moduleKey = basename(spec).replace(/\.server\.(?:[jt]sx?)$/i, "");
175
+ const lines = [
176
+ `import { client as $$rpc } from "virtual:oxide/client";`,
177
+ `import { __ilhaServerIsland } from "@ilha/router/server-island";`,
178
+ `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
+ ...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
+ ];
181
+ for (const name of scan.exports) if (!scan.islands.some((island) => island.name === name)) lines.push(`export const ${name} = (...args) => $$call(${JSON.stringify(name)}, args);`);
182
+ for (const island of scan.islands) {
183
+ const wiring = [];
184
+ 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)`);
186
+ if (streams.length) wiring.push(`streams: { ${streams.join(", ")} }`);
187
+ if (actions.length) wiring.push(`actions: { ${actions.join(", ")} }`);
188
+ if (scan.clientRefs.length) wiring.push(`children: { ${scan.clientRefs.map((ref, index) => `${JSON.stringify(ref.id)}: $$child${index}`).join(", ")} }`);
189
+ 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; })`);
191
+ if (scan.clientLoader) wiring.push(`clientLoader: () => $$call("load", [])`);
192
+ const call = `__ilhaServerIsland(${JSON.stringify(id)}, ${JSON.stringify(island.as)}, { ${wiring.join(", ")} })`;
193
+ if (island.name === "default") lines.push(`export default ${call};`);
194
+ else lines.push(`export const ${island.name} = ${call};`);
195
+ }
196
+ return lines.join("\n");
197
+ }
198
+ function parseImportClause(clause) {
199
+ const result = { named: [] };
200
+ let rest = clause.trim();
201
+ const nsMatch = rest.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/);
202
+ if (nsMatch) {
203
+ result.namespace = nsMatch[1];
204
+ rest = rest.replace(nsMatch[0], "").replace(/,/g, "").trim();
205
+ }
206
+ const braceStart = rest.indexOf("{");
207
+ if (braceStart !== -1) {
208
+ const before = rest.slice(0, braceStart).replace(/,/g, "").trim();
209
+ if (before) result.defaultLocal = before;
210
+ const inner = rest.slice(braceStart + 1, rest.lastIndexOf("}"));
211
+ for (const item of inner.split(",")) {
212
+ const trimmed = item.trim();
213
+ if (!trimmed || trimmed.startsWith("type ")) continue;
214
+ const asMatch = trimmed.match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
215
+ if (asMatch) result.named.push({
216
+ imported: asMatch[1],
217
+ local: asMatch[2] ?? asMatch[1]
218
+ });
219
+ }
220
+ } else if (rest && !result.namespace) result.defaultLocal = rest.replace(/,/g, "").trim();
221
+ return result;
222
+ }
223
+ /**
224
+ * Rewrite import sites whose specifier targets a server module containing
225
+ * island exports. Island bindings move to the virtual proxy module; all other
226
+ * bindings stay on the original specifier (oxidejs replaces them with tacho
227
+ * stubs). Returns null when no statement needed rewriting.
228
+ */
229
+ function splitServerImports(code, ctx) {
230
+ const IMPORT_RE = /(^|\n)import\s+(?!type\b)([^'"\n]+?)\s*from\s*(["'])([^"'\n]+)\3;?/g;
231
+ let changed = false;
232
+ const out = code.replace(IMPORT_RE, (statement, lead, clause, _q, spec) => {
233
+ const info = ctx.islandNamesFor(spec);
234
+ if (!info) return statement;
235
+ const parsed = parseImportClause(clause);
236
+ const routed = [];
237
+ const kept = [];
238
+ for (const binding of parsed.named) routed.push(binding);
239
+ const routeDefault = parsed.defaultLocal !== void 0 && info.hasDefault;
240
+ if (routed.length === 0 && !routeDefault) return statement;
241
+ changed = true;
242
+ const parts = [];
243
+ const keptBits = [];
244
+ if (!routeDefault && parsed.defaultLocal) keptBits.push(parsed.defaultLocal);
245
+ if (kept.length) keptBits.push(`{ ${kept.map((b) => b.local === b.imported ? b.imported : `${b.imported} as ${b.local}`).join(", ")} }`);
246
+ if (keptBits.length) parts.push(`import ${keptBits.join(", ")} from ${JSON.stringify(spec)};`);
247
+ if (parsed.namespace) parts.push(`import * as ${parsed.namespace} from ${JSON.stringify(spec)};`);
248
+ const routedBits = [];
249
+ if (routeDefault) routedBits.push(parsed.defaultLocal);
250
+ if (routed.length) routedBits.push(`{ ${routed.map((b) => b.local === b.imported ? b.imported : `${b.imported} as ${b.local}`).join(", ")} }`);
251
+ parts.push(`import ${routedBits.join(", ")} from ${JSON.stringify(ctx.virtualSpecFor(spec))};`);
252
+ return lead + parts.join("\n");
253
+ });
254
+ return changed ? out : null;
255
+ }
256
+
257
+ //#endregion
6
258
  //#region src/codegen.ts
7
259
  function toPosix(p) {
8
260
  return p.replace(/\\/g, "/");
9
261
  }
10
262
  /** Files that should never be treated as pages even if they match the ts/tsx extension. */
11
263
  const EXCLUDED_RE = /\.(test|spec|d)\.(ts|tsx)$/;
264
+ /** Server pages: `foo.server.tsx` routes `/foo`, rendered through the frame protocol. */
265
+ const SERVER_PAGE_RE = /\.server\.(ts|tsx)$/;
12
266
  /**
13
267
  * Match a top-of-statement `export const load`, `export let load`,
14
268
  * `export function load`, or `export async function load`. Intentionally
@@ -16,20 +270,22 @@ const EXCLUDED_RE = /\.(test|spec|d)\.(ts|tsx)$/;
16
270
  * in v1. Declare `load` directly in the file to be picked up.
17
271
  */
18
272
  const LOADER_EXPORT_RE = /^\s*export\s+(?:const|let|var|async\s+function|function)\s+load\b/m;
19
- /** Same shape for `clientLoad`a loader executed in the browser on client navigations. */
20
- const CLIENT_LOADER_EXPORT_RE = /^\s*export\s+(?:const|let|var|async\s+function|function)\s+clientLoad\b/m;
273
+ /** `export const load = loader.client(…)` — the only client-loader form. */
274
+ /** `export const load = loader.client()` — client loader under the server name. */
275
+ const LOAD_CLIENT_EXPORT_RE = /(^|\n)\s*export\s+(?:const|let|var)\s+load\b\s*=\s*loader\.client\b/m;
21
276
  async function detectLoaderExports(file) {
22
277
  try {
23
278
  const stripped = (await readFile(file, "utf8")).replace(/^\s*\/\/.*$/gm, "");
279
+ const isClientLoader = LOAD_CLIENT_EXPORT_RE.test(stripped);
24
280
  return {
25
- load: LOADER_EXPORT_RE.test(stripped),
26
- clientLoad: CLIENT_LOADER_EXPORT_RE.test(stripped)
281
+ load: LOADER_EXPORT_RE.test(stripped) && !isClientLoader,
282
+ isClientLoader
27
283
  };
28
284
  } catch (err) {
29
285
  if (err?.code !== "ENOENT") console.warn(`[ilha-router] failed to read ${file} while detecting loader exports:`, err);
30
286
  return {
31
287
  load: false,
32
- clientLoad: false
288
+ isClientLoader: false
33
289
  };
34
290
  }
35
291
  }
@@ -45,7 +301,9 @@ function dirToSegment(name) {
45
301
  }
46
302
  function fileToPattern(pagesDir, file) {
47
303
  const rel = toPosix(relative(pagesDir, file));
48
- const parts = rel.slice(0, -extname(rel).length).split("/");
304
+ let noExt = rel.slice(0, -extname(rel).length);
305
+ if (SERVER_PAGE_RE.test(rel)) noExt = noExt.replace(/\.server$/, "");
306
+ const parts = noExt.split("/");
49
307
  const segments = [...parts.slice(0, -1).map(dirToSegment), fileToSegment(parts.at(-1))];
50
308
  if (segments.at(-1) === "index") segments.pop();
51
309
  return "/" + segments.filter(Boolean).join("/") || "/";
@@ -116,12 +374,13 @@ async function scanPages(pagesDir) {
116
374
  return cached;
117
375
  };
118
376
  return Promise.all(pages.map(async (file) => {
377
+ const server = SERVER_PAGE_RE.test(basename(file));
119
378
  const pattern = fileToPattern(pagesDir, file);
120
379
  const layouts = chainForFile(pagesDir, file, allSet, "+layout");
121
380
  const errors = chainForFile(pagesDir, file, allSet, "+error");
122
381
  const [pageExports, ...layoutExports] = await Promise.all([detectLoaderExports(file), ...layouts.map(getLayoutExports)]);
123
382
  const loaderLayouts = layouts.filter((_, i) => layoutExports[i].load);
124
- const clientLoaderLayouts = layouts.filter((_, i) => layoutExports[i].clientLoad);
383
+ const clientLoaderLayouts = layouts.filter((_, i) => layoutExports[i].isClientLoader);
125
384
  return {
126
385
  file,
127
386
  pattern,
@@ -130,8 +389,9 @@ async function scanPages(pagesDir) {
130
389
  errors,
131
390
  hasLoader: pageExports.load,
132
391
  loaderLayouts,
133
- hasClientLoader: pageExports.clientLoad,
134
- clientLoaderLayouts
392
+ hasClientLoader: pageExports.isClientLoader,
393
+ clientLoaderLayouts,
394
+ server
135
395
  };
136
396
  }));
137
397
  }
@@ -144,6 +404,12 @@ function validateEntries(entries, pagesDir, strict) {
144
404
  const seenNames = /* @__PURE__ */ new Map();
145
405
  const problems = [];
146
406
  for (const entry of entries) {
407
+ if (entry.server) try {
408
+ if (!loadServerModuleScan(entry.file).islands.some((island) => island.name === "default")) throw new Error(`[ilha:pages] Server page ${entry.file} has no default island export.\n A .server page must "export default ilha…" so it can be rendered server-side.`);
409
+ } catch (err) {
410
+ if (err instanceof Error && err.message.includes("[ilha:pages]")) throw err;
411
+ throw new Error(`[ilha:pages] Server page ${entry.file} could not be scanned for island exports.`);
412
+ }
147
413
  const existingPattern = seenPatterns.get(entry.pattern);
148
414
  if (existingPattern) problems.push(`Duplicate route pattern "${entry.pattern}"\n first: ${existingPattern}\n second: ${entry.file}\n The first match wins — the second page will never be reached.`);
149
415
  else seenPatterns.set(entry.pattern, entry.file);
@@ -206,6 +472,7 @@ function buildServerFile(entries, serverFile) {
206
472
  `// Import via: import { pageRouter, registry } from "ilha:pages/server";`,
207
473
  ``,
208
474
  ...imports,
475
+ ...entries.some((e) => e.hasLoader || e.loaderLayouts.length > 0) ? [`import { setFrameLoaderRunner } from "@ilha/router/server-island-registry";`, `setFrameLoaderRunner((path) => pageRouter.runLoader(path));`] : [],
209
476
  ``,
210
477
  ...wrappedIslandLines,
211
478
  ``,
@@ -232,6 +499,22 @@ function buildClientFile(entries, clientFile, opts) {
232
499
  const registryLines = [];
233
500
  const routeLines = [];
234
501
  for (const [i, entry] of entries.entries()) {
502
+ if (entry.server) {
503
+ imports.push(`import { default as _page${i} } from ${JSON.stringify(serverIslandVirtualSpec(entry.file))};`);
504
+ for (const [j, l] of entry.layouts.entries()) imports.push(`import { default as _layout${i}_${j} } from ${JSON.stringify(clientImport(l))};`);
505
+ for (const [j, e] of entry.errors.entries()) imports.push(`import { default as _error${i}_${j} } from ${JSON.stringify(clientImport(e))};`);
506
+ let serverExpr = `_page${i}`;
507
+ for (let j = entry.errors.length - 1; j >= 0; j--) serverExpr = `wrapError(_error${i}_${j}, ${serverExpr})`;
508
+ for (let j = entry.layouts.length - 1; j >= 0; j--) serverExpr = `wrapLayout(_layout${i}_${j}, ${serverExpr})`;
509
+ const wrappedServerId = `_wrapped${i}`;
510
+ wrappedIslandLines.push(`const ${wrappedServerId} = ${serverExpr};`);
511
+ registryLines.push(` ${JSON.stringify(entry.name)}: ${wrappedServerId}` + (i < entries.length - 1 ? "," : ""));
512
+ if (!isStatic) {
513
+ routeLines.push(` .route(${JSON.stringify(entry.pattern)}, ${wrappedServerId})`);
514
+ if (entry.errors.length > 0) routeLines.push(` .errorBoundary(${JSON.stringify(entry.pattern)}, _error${i}_${entry.errors.length - 1})`);
515
+ }
516
+ continue;
517
+ }
235
518
  imports.push(`import { default as _page${i} } from ${JSON.stringify(clientImport(entry.file))};`);
236
519
  for (const [j, l] of entry.layouts.entries()) imports.push(`import { default as _layout${i}_${j} } from ${JSON.stringify(clientImport(l))};`);
237
520
  for (const [j, e] of entry.errors.entries()) imports.push(`import { default as _error${i}_${j} } from ${JSON.stringify(clientImport(e))};`);
@@ -246,12 +529,12 @@ function buildClientFile(entries, clientFile, opts) {
246
529
  const clientLoaderIds = [];
247
530
  for (const [j, layout] of entry.clientLoaderLayouts.entries()) {
248
531
  const id = `_cl${i}_l${j}`;
249
- imports.push(`import { clientLoad as ${id} } from ${JSON.stringify(clientLoaderImport(layout))};`);
532
+ imports.push(`import { load as ${id} } from ${JSON.stringify(clientLoaderImport(layout))};`);
250
533
  clientLoaderIds.push(id);
251
534
  }
252
535
  if (entry.hasClientLoader) {
253
536
  const id = `_cl${i}`;
254
- imports.push(`import { clientLoad as ${id} } from ${JSON.stringify(clientLoaderImport(entry.file))};`);
537
+ imports.push(`import { load as ${id} } from ${JSON.stringify(clientLoaderImport(entry.file))};`);
255
538
  clientLoaderIds.push(id);
256
539
  }
257
540
  if (clientLoaderIds.length > 0) {
@@ -375,8 +658,38 @@ const RESOLVED_VIRTUAL_IDS = [
375
658
  ];
376
659
  /** Query suffix used on page/layout imports in the client file. */
377
660
  const CLIENT_QUERY = "?client";
378
- /** Query suffix that re-exports a page/layout's `clientLoad` for the browser bundle. */
661
+ /** Query suffix that re-exports a page/layout's `load` (loader.client) for the browser bundle. */
379
662
  const CLIENT_LOADER_QUERY = "?client-loader";
663
+ function decodeServerIslandId(id) {
664
+ if (!id.startsWith("\0ilha:server-island:")) return null;
665
+ try {
666
+ return Buffer.from(id.slice(SERVER_ISLAND_PREFIX.length), "base64url").toString();
667
+ } catch {
668
+ return null;
669
+ }
670
+ }
671
+ const SERVER_FILE_RE = /\.server\.(ts|tsx|js|jsx)$/;
672
+ /** Cheap prefilter — most modules never mention a server import. Extension
673
+ * optional: aliases like `$lib/tasks.server` resolve to `.server.tsx` later. */
674
+ const SERVER_SPEC_HINT_RE = /["'][^"']*\.server(\.[cm]?[jt]sx?)?["']/;
675
+ /** mtime-keyed scan cache so repeated transforms don't re-read/re-parse. */
676
+ const scanCache = /* @__PURE__ */ new Map();
677
+ function scanFor(path) {
678
+ try {
679
+ const mtimeMs = statSync(path).mtimeMs;
680
+ const cached = scanCache.get(path);
681
+ if (cached && cached.mtimeMs === mtimeMs) return cached.scan;
682
+ const scan = loadServerModuleScan(path);
683
+ if (scan.islands.length === 0) return null;
684
+ scanCache.set(path, {
685
+ mtimeMs,
686
+ scan
687
+ });
688
+ return scan;
689
+ } catch {
690
+ return null;
691
+ }
692
+ }
380
693
  /** Read & parse a package.json, returning null on any error. */
381
694
  function readJson(path) {
382
695
  try {
@@ -515,7 +828,7 @@ function loadPagesModule(state, id) {
515
828
  }
516
829
  if (id.endsWith("?client-loader")) {
517
830
  const bare = id.slice(0, -14);
518
- return `export { clientLoad } from ${JSON.stringify(bare)};`;
831
+ return `export { load } from ${JSON.stringify(bare)};`;
519
832
  }
520
833
  if (id.endsWith("?client")) {
521
834
  const bare = id.slice(0, -7);
@@ -558,6 +871,7 @@ function setupRspackPagesWatcher(state, structuralInvalidate) {
558
871
  }
559
872
  const pagesFactory = (options = {}) => {
560
873
  const state = createPagesPluginState(options);
874
+ const serverIslands = /* @__PURE__ */ new Map();
561
875
  return {
562
876
  name: "ilha:pages",
563
877
  async buildStart() {
@@ -569,9 +883,19 @@ const pagesFactory = (options = {}) => {
569
883
  await regenFromPagesChange(state, file, (f) => state.shouldRegenOnChange(f));
570
884
  },
571
885
  resolveId(id, importer) {
886
+ if (id.startsWith("\0ilha:server-island:")) return id;
572
887
  return resolvePagesId(state, id, importer);
573
888
  },
574
889
  load(id) {
890
+ const islandFile = decodeServerIslandId(id);
891
+ if (islandFile !== null) {
892
+ const scan = loadServerModuleScan(islandFile);
893
+ for (const island of scan.islands) serverIslands.set(serverIslandPublicId(islandFile, island.name), {
894
+ file: islandFile,
895
+ name: island.name
896
+ });
897
+ return generateServerIslandModule(islandFile, scan);
898
+ }
575
899
  return loadPagesModule(state, id);
576
900
  },
577
901
  vite: {
@@ -584,7 +908,7 @@ const pagesFactory = (options = {}) => {
584
908
  ...detectIlhaConsumers(userConfig.root ? resolve(userConfig.root) : process.cwd())
585
909
  ];
586
910
  const existingNoExternal = userConfig.ssr?.noExternal;
587
- const noExternal = existingNoExternal === true ? true : [.../* @__PURE__ */ new Set([...Array.isArray(existingNoExternal) ? existingNoExternal : existingNoExternal != null ? [existingNoExternal] : [], ...singletonPeers])];
911
+ const noExternal = existingNoExternal === true ? true : [.../* @__PURE__ */ new Set([...Array.isArray(existingNoExternal) ? existingNoExternal : existingNoExternal == null ? [] : [existingNoExternal], ...singletonPeers])];
588
912
  return {
589
913
  resolve: { dedupe: [.../* @__PURE__ */ new Set([...userConfig.resolve?.dedupe ?? [], ...singletonPeers])] },
590
914
  ssr: { noExternal },
@@ -604,8 +928,165 @@ const pagesFactory = (options = {}) => {
604
928
  configResolved(config) {
605
929
  state.setPaths(config.root);
606
930
  },
931
+ async transform(code, id, opts) {
932
+ const file = id.replace(/\?.*$/, "");
933
+ const serverFile = SERVER_FILE_RE.test(file);
934
+ if (opts?.ssr) {
935
+ if (!serverFile) return null;
936
+ const scan = scanFor(file);
937
+ const lines = [];
938
+ 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
+ if (scan && scan.islands.length > 0 && !code.startsWith("// oxidejs:client-stub")) {
940
+ lines.unshift(`import * as __ilhaSelf from ${JSON.stringify(file)};`);
941
+ lines.unshift(`import { registerServerIsland } from "@ilha/router/server-island-registry";`);
942
+ for (const island of scan.islands) {
943
+ const id2 = serverIslandPublicId(file, island.name);
944
+ 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
+ lines.push(`registerServerIsland(${JSON.stringify(id2)}, () => __ilhaSelf[${JSON.stringify(island.name)}]?.[Symbol.for("ilha.renderState")]${isServerPage});`);
946
+ }
947
+ }
948
+ if (lines.length === 0) return null;
949
+ return `${code}\n${lines.join("\n")}`;
950
+ }
951
+ if (id.startsWith("\0") || id.includes("node_modules")) return null;
952
+ if (serverFile) return null;
953
+ if (!SERVER_SPEC_HINT_RE.test(code)) return null;
954
+ const SPEC_RE = /(?:^|\n)\s*import\s+(?:type\s+)?[^'"\n]+?\s*from\s*["']([^"']+)["']/g;
955
+ const specs = /* @__PURE__ */ new Set();
956
+ for (const match of code.matchAll(SPEC_RE)) specs.add(match[1]);
957
+ if (specs.size === 0) return null;
958
+ const scanned = /* @__PURE__ */ new Map();
959
+ for (const spec of specs) {
960
+ let file;
961
+ try {
962
+ const resolved = await this.resolve?.(spec, id);
963
+ file = resolved?.path ?? resolved?.id;
964
+ } catch {
965
+ file = void 0;
966
+ }
967
+ if (!file && spec.startsWith("/")) file = spec;
968
+ if (!file || !SERVER_FILE_RE.test(file.replace(/\?.*$/, ""))) continue;
969
+ const scan = scanFor(file);
970
+ if (!scan) continue;
971
+ scanned.set(spec, {
972
+ file,
973
+ islands: new Set(scan.islands.filter((i) => i.name !== "default").map((i) => i.name)),
974
+ hasDefault: scan.islands.some((i) => i.name === "default")
975
+ });
976
+ }
977
+ if (scanned.size === 0) return null;
978
+ return splitServerImports(code, {
979
+ islandNamesFor: (spec) => {
980
+ const entry = scanned.get(spec);
981
+ return entry ? {
982
+ islands: entry.islands,
983
+ hasDefault: entry.hasDefault
984
+ } : null;
985
+ },
986
+ virtualSpecFor: (spec) => serverIslandVirtualSpec(scanned.get(spec).file)
987
+ });
988
+ },
607
989
  configureServer(server) {
608
990
  server.watcher.add(state.pagesDir);
991
+ if (options.frameGuard) setFrameGuard(options.frameGuard);
992
+ server.middlewares.use(async (req, res, next) => {
993
+ if ((req.url ?? "").split("?")[0] !== "/__ilha/frame") return next();
994
+ if (req.method !== "POST") {
995
+ res.statusCode = 405;
996
+ res.end();
997
+ return;
998
+ }
999
+ if (!(req.headers["content-type"] ?? "").startsWith("application/json")) {
1000
+ res.statusCode = 415;
1001
+ res.end();
1002
+ return;
1003
+ }
1004
+ try {
1005
+ const guardHeaders = new Headers();
1006
+ for (const name of [
1007
+ "cookie",
1008
+ "authorization",
1009
+ "x-forwarded-for"
1010
+ ]) {
1011
+ const v = req.headers[name];
1012
+ if (typeof v === "string") guardHeaders.set(name, v);
1013
+ }
1014
+ const denied = await getFrameGuard()?.(new Request(`http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`, {
1015
+ method: req.method,
1016
+ headers: guardHeaders
1017
+ }));
1018
+ if (denied) {
1019
+ res.statusCode = denied.status;
1020
+ res.setHeader("cache-control", "no-store");
1021
+ res.end();
1022
+ return;
1023
+ }
1024
+ } catch {
1025
+ res.statusCode = 403;
1026
+ res.end();
1027
+ return;
1028
+ }
1029
+ const origin = req.headers.origin;
1030
+ const host = req.headers.host;
1031
+ if (origin && origin !== `http://${host}` && origin !== `https://${host}`) {
1032
+ res.statusCode = 403;
1033
+ res.end();
1034
+ return;
1035
+ }
1036
+ const chunks = [];
1037
+ let size = 0;
1038
+ for await (const chunk of req) {
1039
+ size += chunk.length;
1040
+ if (size > 16384) {
1041
+ res.statusCode = 413;
1042
+ res.end();
1043
+ return;
1044
+ }
1045
+ chunks.push(chunk);
1046
+ }
1047
+ try {
1048
+ const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
1049
+ const target = serverIslands.get(body.id ?? "");
1050
+ if (!target) throw new Error("unknown island");
1051
+ let framePath = "/__ilha/frame";
1052
+ if (typeof body.path === "string" && body.path.startsWith("/") && !body.path.includes("//") && body.path.length <= 2048) framePath = body.path;
1053
+ await server.ssrLoadModule(VIRTUAL_PAGES_SERVER);
1054
+ if (typeof (await server.ssrLoadModule(target.file))[target.name]?.[Symbol.for("ilha.renderState")] !== "function") throw new Error("unknown island");
1055
+ const headers = new Headers();
1056
+ for (const name of [
1057
+ "cookie",
1058
+ "authorization",
1059
+ "user-agent",
1060
+ "x-forwarded-for"
1061
+ ]) {
1062
+ const value = req.headers[name];
1063
+ if (typeof value === "string") headers.set(name, value);
1064
+ }
1065
+ const requestOrigin = `http://${req.headers.host ?? "localhost"}`;
1066
+ const request = new Request(new URL(framePath, requestOrigin), {
1067
+ method: "POST",
1068
+ headers
1069
+ });
1070
+ const html = await renderServerIsland(body.id ?? "", request, (r, fn) => runWithIslandRequest(r, fn));
1071
+ res.setHeader("cache-control", "no-store");
1072
+ res.setHeader("content-type", "application/json;charset=utf-8");
1073
+ res.end(JSON.stringify({ html: String(html) }));
1074
+ } catch (err) {
1075
+ if (err instanceof FrameError && err.redirect) {
1076
+ res.statusCode = err.status;
1077
+ res.setHeader("cache-control", "no-store");
1078
+ res.setHeader("content-type", "application/json;charset=utf-8");
1079
+ res.end(JSON.stringify({ redirect: err.redirect }));
1080
+ return;
1081
+ }
1082
+ const status = err instanceof FrameError ? err.status : 400;
1083
+ if (!(err instanceof FrameError) || err.status >= 500) console.error("[ilha-router] frame render failed:", err);
1084
+ res.statusCode = status;
1085
+ res.setHeader("cache-control", "no-store");
1086
+ res.setHeader("content-type", "application/json;charset=utf-8");
1087
+ res.end(JSON.stringify({ error: "frame failed" }));
1088
+ }
1089
+ });
609
1090
  const structuralInvalidate = createStructuralInvalidate(state, async () => {
610
1091
  for (const id of RESOLVED_VIRTUAL_IDS) {
611
1092
  const mod = server.moduleGraph.getModuleById(id);
@@ -618,6 +1099,20 @@ const pagesFactory = (options = {}) => {
618
1099
  server.watcher.on("unlink", structuralInvalidate);
619
1100
  server.watcher.on("change", async (file) => {
620
1101
  if (state.shouldRegenOnChange(file)) await structuralInvalidate(file);
1102
+ if (!SERVER_FILE_RE.test(file.replace(/\?.*$/, ""))) return;
1103
+ scanCache.delete(file);
1104
+ const graph = server.moduleGraph;
1105
+ let touched = false;
1106
+ graph.forEachModule?.((mod) => {
1107
+ if (mod.id.startsWith("\0ilha:server-island:")) {
1108
+ const m = server.moduleGraph.getModuleById(mod.id);
1109
+ if (m) {
1110
+ server.moduleGraph.invalidateModule(m);
1111
+ touched = true;
1112
+ }
1113
+ }
1114
+ });
1115
+ if (touched) server.hot.send({ type: "full-reload" });
621
1116
  });
622
1117
  }
623
1118
  },