@ilha/router 0.11.3 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1140 @@
1
+ import { C as runWithIslandRequest, a as authorizeFrameRequest, b as setFrameAuth, c as frameScopedUrl, f as isSafeFramePath, h as parseFrameProps, n as FrameError, s as frameEnvelope, u as getFrameGuard, x as setFrameGuard, y as renderServerIslandResult } from "./ssr-Dvq_qXs4.js";
2
+ import { existsSync, readFileSync, statSync, watch } from "node:fs";
3
+ import path from "node:path";
4
+ import * as Result from "effect/Result";
5
+ import { createUnplugin } from "unplugin";
6
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
7
+ import { createHash } from "node:crypto";
8
+
9
+ //#region src/server-islands.ts
10
+ /**
11
+ * Build-time support for server-defined islands. The plugin scans
12
+ * `*.server.ts(x)` modules for island exports (`export const X = ilha…`),
13
+ * generates a client virtual module per file that re-creates those exports as
14
+ * proxies wired to tacho stubs, and rewrites client-graph import sites so
15
+ * island bindings resolve to the proxy while everything else keeps flowing
16
+ * through oxidejs's tacho stub replacement.
17
+ */
18
+ /** Oxide RPC client key — basename without `.server.*` (oxide indexes by module name). */
19
+ const serverModuleRpcKey = (spec) => path.basename(spec).replace(/\.server\.(?:[jt]sx?)$/iu, "");
20
+ /** Client repaint group key — full normalized path so same-basename files stay distinct. */
21
+ const serverModuleRepaintKey = (spec) => spec.replaceAll("\\", "/").replace(/\.server\.(?:[jt]sx?)$/iu, "");
22
+ /**
23
+ * Replace identity `action(` wrappers of exported server actions with the
24
+ * capture-aware shim on ALREADY-COMPILED module code. Must run inside the
25
+ * SSR transform so upstream JSX/TS output is preserved.
26
+ */
27
+ const rewriteServerActions = (code, rpcActions) => {
28
+ const names = Object.keys(rpcActions);
29
+ if (names.length === 0) return code;
30
+ const re = new RegExp(`(?<head>export\\s+(?:const|let|var)\\s+(?<name>${names.join("|")})\\b\\s*=\\s*)action\\s*\\(`, "gu");
31
+ return code.replace(re, (_match, ...args) => {
32
+ const groups = args.at(-1);
33
+ const name = groups?.name;
34
+ const head = groups?.head;
35
+ if (name === void 0 || head === void 0) return _match;
36
+ const key = rpcActions[name];
37
+ if (key === void 0) return _match;
38
+ return `${head}__ilhaServerAction(${JSON.stringify(key)}, `;
39
+ });
40
+ };
41
+ const EXPORT_RE = /(?:^|\n)\s*export\s+(?:declare\s+)?(?:async\s+)?(?:function\s*\*?|const|let|var|class)\s+(?<name>[A-Za-z_$][\w$]*)/gu;
42
+ const ISLAND_EXPORT_RE = /(?:^|\n)\s*export\s+(?:const|let|var)\s+(?<name>[A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?function(?:\s*\*)?/gu;
43
+ const DEFAULT_ISLAND_RE = /export\s+default\s+(?:async\s+)?function(?:\s*\*)?/u;
44
+ const EXPORT_LIST_RE = /export\s*\{(?<body>[^}]*)\}/gu;
45
+ const ACTION_EXPORT_RE = /export\s+(?:const|let|var)\s+(?<name>[A-Za-z_$][\w$]*)\s*=\s*action\s*\(/gu;
46
+ const JSX_TAG_RE = /<(?<tag>[A-Z][\w$]*)\b/gu;
47
+ const IMPORT_STMT_RE = /(?:^|\n)\s*import\s+(?!type\b)(?<clause>[^'"\n]+?)\s+from\s+["'](?<spec>[^"']+)["']/gu;
48
+ const NAMED_BINDING_RE = /^(?<imported>[A-Za-z_$][\w$]*)(?:\s+as\s+(?<local>[A-Za-z_$][\w$]*))?$/u;
49
+ const FROM_ASYNC_RE = /fromAsyncIterable\(\s*(?<target>[A-Za-z_$][\w$]*)\s*\(/gu;
50
+ const CALL_IDENT_RE = /(?<ident>[A-Za-z_$][\w$]*)\s*\(/gu;
51
+ const clientRefPublicId = (spec, imported) => createHash("sha256").update(`${spec}#${imported}`).digest("base64url");
52
+ const collectNamedImportRefs = (clause, spec, used, refs) => {
53
+ const brace = clause.match(/\{(?<inner>[^}]+)\}/u)?.groups?.inner;
54
+ if (!brace) return;
55
+ for (const part of brace.split(",")) {
56
+ const binding = part.trim().match(NAMED_BINDING_RE);
57
+ const imported = binding?.groups?.imported;
58
+ if (imported === void 0) continue;
59
+ const local = binding?.groups?.local ?? imported;
60
+ if (used.has(local)) refs.push({
61
+ id: clientRefPublicId(spec, imported),
62
+ imported,
63
+ local,
64
+ spec
65
+ });
66
+ }
67
+ };
68
+ const collectDefaultImportRef = (clause, spec, used, refs) => {
69
+ const [defaultPart] = clause.split(",", 1);
70
+ const defaultLocal = defaultPart?.trim() ?? "";
71
+ if (/^[A-Za-z_$][\w$]*$/u.test(defaultLocal) && used.has(defaultLocal)) refs.push({
72
+ id: clientRefPublicId(spec, "default"),
73
+ imported: "default",
74
+ local: defaultLocal,
75
+ spec
76
+ });
77
+ };
78
+ const scanClientRefs = (source) => {
79
+ const used = /* @__PURE__ */ new Set();
80
+ for (const match of source.matchAll(JSX_TAG_RE)) {
81
+ const tag = match.groups?.tag;
82
+ if (tag !== void 0) used.add(tag);
83
+ }
84
+ const refs = [];
85
+ for (const match of source.matchAll(IMPORT_STMT_RE)) {
86
+ const clause = match.groups?.clause?.trim();
87
+ const spec = match.groups?.spec;
88
+ if (clause === void 0 || spec === void 0) continue;
89
+ collectNamedImportRefs(clause, spec, used, refs);
90
+ collectDefaultImportRef(clause, spec, used, refs);
91
+ }
92
+ return refs;
93
+ };
94
+ /** Extract the balanced-paren argument list starting at the "(" following
95
+ * `from` index. String literals are skipped so parens inside them don't count.
96
+ * Returns the inner text, or null when unbalanced within `limit` chars. */
97
+ const extractCallArgs = (source, openParen, limit = 4e3) => {
98
+ let depth = 0;
99
+ let quote = null;
100
+ for (let i = openParen; i < Math.min(source.length, openParen + limit); i += 1) {
101
+ const ch = source[i];
102
+ if (ch === void 0) break;
103
+ if (quote !== null) {
104
+ if (ch === "\\") i += 1;
105
+ else if (ch === quote) quote = null;
106
+ continue;
107
+ }
108
+ if (ch === "\"" || ch === "'" || ch === "`") {
109
+ quote = ch;
110
+ continue;
111
+ }
112
+ if (ch === "(") depth += 1;
113
+ else if (ch === ")") {
114
+ depth -= 1;
115
+ if (depth === 0) return source.slice(openParen + 1, i);
116
+ }
117
+ }
118
+ console.warn("[ilha-router] scanServerIslands: argument list exceeded the scan limit — call skipped.");
119
+ return null;
120
+ };
121
+ const referencedExports = (body, candidates) => {
122
+ for (const match of body.matchAll(CALL_IDENT_RE)) {
123
+ const ident = match.groups?.ident;
124
+ if (ident !== void 0 && candidates.has(ident)) return ident;
125
+ }
126
+ };
127
+ const collectIslandWiring = (source, name, start, sliceEnd, candidates, islands) => {
128
+ const slice = source.slice(start, sliceEnd);
129
+ const as = "div";
130
+ const streams = {};
131
+ const actions = {};
132
+ let actionOrder = 0;
133
+ let streamOrder = 0;
134
+ for (const match of slice.matchAll(/[A-Za-z0-9_$]*[Aa]ction\s*\(/gu)) {
135
+ if (match.index === void 0) continue;
136
+ const openParen = match.index + match[0].indexOf("(");
137
+ const args = extractCallArgs(slice, openParen);
138
+ if (!args) continue;
139
+ const target = referencedExports(args, candidates);
140
+ actions[`a${actionOrder}`] = target ?? "";
141
+ actionOrder += 1;
142
+ }
143
+ for (const match of slice.matchAll(FROM_ASYNC_RE)) {
144
+ const target = match.groups?.target;
145
+ if (target !== void 0 && candidates.has(target) && !Object.values(streams).includes(target)) {
146
+ streams[`d${streamOrder}`] = target;
147
+ streamOrder += 1;
148
+ }
149
+ }
150
+ islands.push({
151
+ actions,
152
+ as,
153
+ name,
154
+ streams
155
+ });
156
+ };
157
+ const collectModuleExports = (source) => {
158
+ const exports = [];
159
+ for (const match of source.matchAll(EXPORT_RE)) {
160
+ const name = match.groups?.name;
161
+ if (name !== void 0) exports.push(name);
162
+ }
163
+ for (const match of source.matchAll(EXPORT_LIST_RE)) {
164
+ const body = match.groups?.body;
165
+ if (body === void 0) continue;
166
+ for (const part of body.split(",")) {
167
+ const parts2 = part.trim().split(/\s+as\s+/u);
168
+ const [first, second] = parts2;
169
+ const name = (parts2.length === 2 ? second : first)?.trim();
170
+ if (name && /^[A-Za-z_$][\w$]*$/u.test(name) && !exports.includes(name)) exports.push(name);
171
+ }
172
+ }
173
+ return exports;
174
+ };
175
+ const collectRpcActions = (source) => {
176
+ const rpcActions = {};
177
+ for (const match of source.matchAll(ACTION_EXPORT_RE)) {
178
+ const name = match.groups?.name;
179
+ if (name !== void 0) rpcActions[name] = `x:${name}`;
180
+ }
181
+ return rpcActions;
182
+ };
183
+ const collectNamedIslands = (source, candidates, islands) => {
184
+ for (const match of source.matchAll(ISLAND_EXPORT_RE)) {
185
+ const name = match.groups?.name;
186
+ if (name === void 0 || match.index === void 0) continue;
187
+ const rest = source.slice(match.index + match[0].length);
188
+ const nextExport = rest.search(/\nexport\b/u);
189
+ collectIslandWiring(source, name, match.index, match.index + match[0].length + (nextExport === -1 ? rest.length : nextExport), candidates, islands);
190
+ }
191
+ };
192
+ const collectDefaultIsland = (source, candidates, islands) => {
193
+ const defaultMatch = source.match(DEFAULT_ISLAND_RE);
194
+ if (!defaultMatch || defaultMatch.index === void 0) return;
195
+ const rest = source.slice(defaultMatch.index + defaultMatch[0].length);
196
+ const nextExport = rest.search(/\nexport\b/u);
197
+ collectIslandWiring(source, "default", defaultMatch.index, defaultMatch.index + defaultMatch[0].length + (nextExport === -1 ? rest.length : nextExport), candidates, islands);
198
+ };
199
+ /** Scan a `*.server.ts(x)` module source for island exports and their
200
+ * declarative wiring. Islands are `export const Name = [async] function[*]`
201
+ * (and the `export default` form); stream/action transports are discovered
202
+ * from calls inside each island body. */
203
+ const scanServerIslands = (source) => {
204
+ const exports = collectModuleExports(source);
205
+ const candidates = new Set(exports);
206
+ const rpcActions = collectRpcActions(source);
207
+ const islands = [];
208
+ collectNamedIslands(source, candidates, islands);
209
+ collectDefaultIsland(source, candidates, islands);
210
+ return {
211
+ clientRefs: scanClientRefs(source),
212
+ exports,
213
+ islands,
214
+ rpcActions
215
+ };
216
+ };
217
+ const loadServerModuleScan = (filePath) => scanServerIslands(readFileSync(filePath, "utf-8"));
218
+ /** Virtual-module id prefix for generated client proxies of server islands.
219
+ * The file path rides base64url-encoded: a raw suffix like
220
+ * `\0…:…/tasks.server.tsx` would end in `.server.*` and oxidejs's client-stub
221
+ * loader would claim the virtual module before us. */
222
+ const SERVER_ISLAND_PREFIX = "\0ilha:server-island:";
223
+ /** Virtual-module specifier serving the client proxy for one server island file. */
224
+ const serverIslandVirtualSpec = (file) => SERVER_ISLAND_PREFIX + Buffer.from(file).toString("base64url");
225
+ /** Emit the client virtual module for one scanned server file. Plain JS —
226
+ * `\0` virtual modules bypass Vite's built-in TS transform, so type-only
227
+ * constructs here would reach the browser unparsed. Editor types are
228
+ * unaffected: TS resolves the ORIGINAL specifier (the real server module);
229
+ * this module exists only inside the client bundle. Frames are fetched from
230
+ * the plugin's `/__ilha/frame` dev middleware. */
231
+ const serverIslandPublicId = (spec, name) => createHash("sha256").update(`${spec}#${name}`).digest("base64url");
232
+ const generateServerIslandModule = (spec, scan) => {
233
+ const rpcKey = serverModuleRpcKey(spec);
234
+ const repaintKey = serverModuleRepaintKey(spec);
235
+ const lines = [
236
+ `import { client as $$rpc } from "virtual:oxide/client";`,
237
+ `import { __ilhaApplyHead, __ilhaServerIsland } from "@ilha/router/server-island";`,
238
+ `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(rpcKey)}][method](...args.slice(0, -1), opts) : $$rpc[${JSON.stringify(rpcKey)}][method](...args); };`,
239
+ ...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)};`)
240
+ ];
241
+ 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);`);
242
+ for (const island of scan.islands) {
243
+ const wiring = [];
244
+ const streams = Object.entries(island.streams).map(([key, target]) => `${JSON.stringify(key)}: (signal) => $$call(${JSON.stringify(target)}, [{ signal }])`);
245
+ const rpcEntries = Object.entries(scan.rpcActions).map(([name, key]) => [key, name]);
246
+ const actions = Object.entries({
247
+ ...island.actions,
248
+ ...Object.fromEntries(rpcEntries)
249
+ }).map(([key, target]) => `${JSON.stringify(key)}: (...args) => $$call(${JSON.stringify(target)}, args)`);
250
+ if (streams.length) wiring.push(`streams: { ${streams.join(", ")} }`);
251
+ if (actions.length) wiring.push(`actions: { ${actions.join(", ")} }`);
252
+ if (scan.clientRefs.length) wiring.push(`children: { ${scan.clientRefs.map((ref, index) => `${JSON.stringify(ref.id)}: $$child${index}`).join(", ")} }`);
253
+ const id = serverIslandPublicId(spec, island.name);
254
+ wiring.push(`frame: (props) => fetch("/__ilha/frame", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id: ${JSON.stringify(id)}, path: location.pathname + location.search, props }) }).then((r) => { if (!r.ok) throw new Error("frame failed"); return r.json(); }).then((j) => { if (j.redirect) { try { const u = new URL(j.redirect, location.href); if (u.origin === location.origin) location.assign(u.pathname + u.search + u.hash); } catch {} throw new Error("frame redirected"); } __ilhaApplyHead(j.head); return j.html; })`);
255
+ const call = `__ilhaServerIsland(${JSON.stringify(id)}, ${JSON.stringify(island.as)}, { ${wiring.join(", ")} }, ${JSON.stringify(repaintKey)})`;
256
+ if (island.name === "default") lines.push(`export default ${call};`);
257
+ else lines.push(`export const ${island.name} = ${call};`);
258
+ }
259
+ return lines.join("\n");
260
+ };
261
+ const parseImportClause = (clause) => {
262
+ const result = { named: [] };
263
+ let rest = clause.trim();
264
+ const nsMatch = rest.match(/\*\s+as\s+(?<ns>[A-Za-z_$][\w$]*)/u);
265
+ if (nsMatch) {
266
+ const [full] = nsMatch;
267
+ result.namespace = nsMatch.groups?.ns;
268
+ rest = rest.replace(full, "").replaceAll(",", "").trim();
269
+ }
270
+ const braceStart = rest.indexOf("{");
271
+ if (braceStart !== -1) {
272
+ const before = rest.slice(0, braceStart).replaceAll(",", "").trim();
273
+ if (before) result.defaultLocal = before;
274
+ const inner = rest.slice(braceStart + 1, rest.lastIndexOf("}"));
275
+ for (const item of inner.split(",")) {
276
+ const trimmed = item.trim();
277
+ if (!trimmed || trimmed.startsWith("type ")) continue;
278
+ const asMatch = trimmed.match(NAMED_BINDING_RE);
279
+ const imported = asMatch?.groups?.imported;
280
+ if (imported !== void 0) result.named.push({
281
+ imported,
282
+ local: asMatch?.groups?.local ?? imported
283
+ });
284
+ }
285
+ } else if (rest && !result.namespace) result.defaultLocal = rest.replaceAll(",", "").trim();
286
+ return result;
287
+ };
288
+ /**
289
+ * Rewrite import sites whose specifier targets a server module containing
290
+ * island exports. Island bindings move to the virtual proxy module; all other
291
+ * bindings stay on the original specifier (oxidejs replaces them with tacho
292
+ * stubs). Returns null when no statement needed rewriting.
293
+ */
294
+ const splitServerImports = (code, ctx) => {
295
+ const IMPORT_RE = /(?<lead>^|\n)import\s+(?!type\b)(?<clause>[^'"\n]+?)\s*from\s*(?<quote>["'])(?<spec>[^"'\n]+)\k<quote>;?/gu;
296
+ let changed = false;
297
+ const out = code.replace(IMPORT_RE, (statement, ...args) => {
298
+ const groups = args.at(-1);
299
+ const lead = groups?.lead ?? "";
300
+ const clause = groups?.clause;
301
+ const spec = groups?.spec;
302
+ if (clause === void 0 || spec === void 0) return statement;
303
+ const info = ctx.islandNamesFor(spec);
304
+ if (!info) return statement;
305
+ const parsed = parseImportClause(clause);
306
+ const routed = [];
307
+ const kept = [];
308
+ for (const binding of parsed.named) routed.push(binding);
309
+ const routeDefault = parsed.defaultLocal !== void 0 && info.hasDefault;
310
+ if (routed.length === 0 && !routeDefault) return statement;
311
+ changed = true;
312
+ const parts = [];
313
+ const keptBits = [];
314
+ if (!routeDefault && parsed.defaultLocal) keptBits.push(parsed.defaultLocal);
315
+ if (kept.length) keptBits.push(`{ ${kept.map((b) => b.local === b.imported ? b.imported : `${b.imported} as ${b.local}`).join(", ")} }`);
316
+ if (keptBits.length) parts.push(`import ${keptBits.join(", ")} from ${JSON.stringify(spec)};`);
317
+ if (parsed.namespace) parts.push(`import * as ${parsed.namespace} from ${JSON.stringify(spec)};`);
318
+ const routedBits = [];
319
+ if (routeDefault && parsed.defaultLocal !== void 0) routedBits.push(parsed.defaultLocal);
320
+ if (routed.length) routedBits.push(`{ ${routed.map((b) => b.local === b.imported ? b.imported : `${b.imported} as ${b.local}`).join(", ")} }`);
321
+ parts.push(`import ${routedBits.join(", ")} from ${JSON.stringify(ctx.virtualSpecFor(spec))};`);
322
+ return lead + parts.join("\n");
323
+ });
324
+ return changed ? out : null;
325
+ };
326
+
327
+ //#endregion
328
+ //#region src/codegen.ts
329
+ const toPosix = (p) => p.replaceAll("\\", "/");
330
+ /** Files that should never be treated as pages even if they match the ts/tsx extension. */
331
+ const EXCLUDED_RE = /\.(?:test|spec|d)\.(?:ts|tsx)$/u;
332
+ /** Server pages: `foo.server.tsx` routes `/foo`, rendered through the frame protocol. */
333
+ const SERVER_PAGE_RE = /\.server\.(?:ts|tsx)$/u;
334
+ const PAGE_EXT_RE = /\.(?:ts|tsx)$/u;
335
+ const fileToSegment = (name) => {
336
+ if (name.startsWith("[...") && name.endsWith("]")) return `**:${name.slice(4, -1)}`;
337
+ if (name.startsWith("[") && name.endsWith("]")) return `:${name.slice(1, -1)}`;
338
+ return name;
339
+ };
340
+ /** Route-group directories like "(auth)" are transparent to the URL. */
341
+ const dirToSegment = (name) => {
342
+ if (name.startsWith("(") && name.endsWith(")")) return "";
343
+ return fileToSegment(name);
344
+ };
345
+ const fileToPattern = (pagesDir, file) => {
346
+ const rel = toPosix(path.relative(pagesDir, file));
347
+ let noExt = rel.slice(0, -path.extname(rel).length);
348
+ if (SERVER_PAGE_RE.test(rel)) noExt = noExt.replace(/\.server$/u, "");
349
+ const parts = noExt.split("/");
350
+ const leaf = parts.at(-1);
351
+ const segments = [...parts.slice(0, -1).map(dirToSegment), fileToSegment(leaf ?? "")];
352
+ if (segments.at(-1) === "index") segments.pop();
353
+ const joined = segments.filter(Boolean).join("/");
354
+ return joined === "" ? "/" : `/${joined}`;
355
+ };
356
+ const patternToName = (pattern) => {
357
+ if (pattern === "/") return "index";
358
+ return pattern.replace(/^\//u, "").replaceAll(/\*\*:[^/]*/gu, (m) => m.length > 3 ? m.slice(3) : "wildcard").replaceAll(":", "").replaceAll("**", "wildcard").replaceAll("/", "-").replaceAll(/[^a-zA-Z0-9-]/gu, "") || "page";
359
+ };
360
+ const specificityScore = (pattern) => {
361
+ if (pattern === "/") return 3;
362
+ if (pattern.includes("**")) return 0;
363
+ if (pattern.includes(":")) return 1;
364
+ return 2;
365
+ };
366
+ /** Deterministic sort: by specificity desc, then by segment count desc, then alphabetical. */
367
+ const sortEntries = (entries) => [...entries].toSorted((a, b) => {
368
+ const specDiff = specificityScore(b.pattern) - specificityScore(a.pattern);
369
+ if (specDiff !== 0) return specDiff;
370
+ const segDiff = b.pattern.split("/").length - a.pattern.split("/").length;
371
+ if (segDiff !== 0) return segDiff;
372
+ return a.pattern.localeCompare(b.pattern);
373
+ });
374
+ const chainForFile = (pagesDir, file, all, sentinel) => {
375
+ const relDir = toPosix(path.relative(pagesDir, path.dirname(file)));
376
+ const parts = relDir === "" ? [] : relDir.split("/");
377
+ const dirs = [pagesDir, ...parts.map((_, i) => path.join(pagesDir, ...parts.slice(0, i + 1)))];
378
+ const candidatesFor = (dir) => {
379
+ const tsx = `${path.join(dir, sentinel)}.tsx`;
380
+ if (all.has(tsx)) return [tsx];
381
+ const ts = `${path.join(dir, sentinel)}.ts`;
382
+ if (all.has(ts)) return [ts];
383
+ return [];
384
+ };
385
+ return dirs.flatMap(candidatesFor);
386
+ };
387
+ const MAX_SCAN_DEPTH = 20;
388
+ const collectFiles = async (dir, depth = 0) => {
389
+ if (depth > MAX_SCAN_DEPTH) {
390
+ console.warn(`[ilha:pages] Max scan depth (${MAX_SCAN_DEPTH}) reached at ${dir} — skipping`);
391
+ return [];
392
+ }
393
+ let entries;
394
+ try {
395
+ entries = await readdir(dir, { withFileTypes: true });
396
+ } catch (error) {
397
+ const { code } = error;
398
+ if (code === "ENOENT") return [];
399
+ throw error;
400
+ }
401
+ return (await Promise.all(entries.map((entry) => {
402
+ const full = path.join(dir, entry.name);
403
+ if (entry.isDirectory()) return collectFiles(full, depth + 1);
404
+ if (entry.isFile() && PAGE_EXT_RE.test(entry.name) && !EXCLUDED_RE.test(entry.name)) return [full];
405
+ return [];
406
+ }))).flat();
407
+ };
408
+ const scanPages = async (pagesDir) => {
409
+ const all = await collectFiles(pagesDir);
410
+ const allSet = new Set(all);
411
+ return all.filter((f) => !path.basename(f).startsWith("+")).map((file) => {
412
+ const pattern = fileToPattern(pagesDir, file);
413
+ return {
414
+ errors: chainForFile(pagesDir, file, allSet, "+error"),
415
+ file,
416
+ layouts: chainForFile(pagesDir, file, allSet, "+layout"),
417
+ name: patternToName(pattern),
418
+ pattern,
419
+ server: SERVER_PAGE_RE.test(path.basename(file))
420
+ };
421
+ });
422
+ };
423
+ const validateEntries = (entries, pagesDir, strict) => {
424
+ if (entries.length === 0) {
425
+ console.warn(`[ilha:pages] No pages found in ${pagesDir}`);
426
+ return;
427
+ }
428
+ const seenPatterns = /* @__PURE__ */ new Map();
429
+ const seenNames = /* @__PURE__ */ new Map();
430
+ const problems = [];
431
+ for (const entry of entries) {
432
+ if (entry.server) try {
433
+ const scan = loadServerModuleScan(entry.file);
434
+ if (!scan.islands.some((island) => island.name === "default") && !scan.exports.includes("default")) throw new Error(`[ilha:pages] Server page ${entry.file} has no default export.`);
435
+ } catch (error) {
436
+ if (error instanceof Error && error.message.includes("[ilha:pages]")) throw error;
437
+ throw new Error(`[ilha:pages] Server page ${entry.file} could not be scanned for island exports.`, { cause: error });
438
+ }
439
+ const existingPattern = seenPatterns.get(entry.pattern);
440
+ 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.`);
441
+ else seenPatterns.set(entry.pattern, entry.file);
442
+ const existingName = seenNames.get(entry.name);
443
+ if (existingName) problems.push(`Registry name collision: "${entry.name}" is used by both\n ${existingName}\n ${entry.file}\n Hydration may not work correctly for one of these routes.`);
444
+ else seenNames.set(entry.name, entry.file);
445
+ }
446
+ if (problems.length === 0) return;
447
+ if (strict) throw new Error(`[ilha:pages] Route validation failed:\n\n${problems.join("\n\n")}`);
448
+ for (const p of problems) console.warn(`[ilha:pages] ${p}`);
449
+ };
450
+ const resolveGeneratedPaths = (outDir) => ({
451
+ clientFile: path.join(outDir, "pages.client.ts"),
452
+ serverFile: path.join(outDir, "pages.server.ts")
453
+ });
454
+ const relFrom = (fromFile, abs) => {
455
+ const r = toPosix(path.relative(path.dirname(fromFile), abs));
456
+ return r.startsWith(".") ? r : `./${r}`;
457
+ };
458
+ const wrapIslandExpr = (pageId, layoutCount, errorCount, index) => {
459
+ let expr = pageId;
460
+ for (let j = errorCount - 1; j >= 0; j -= 1) expr = `wrapError(_error${index}_${j}, ${expr})`;
461
+ for (let j = layoutCount - 1; j >= 0; j -= 1) expr = `wrapLayout(_layout${index}_${j}, ${expr})`;
462
+ return expr;
463
+ };
464
+ const pushLayoutErrorImports = (imports, entry, index, importPath) => {
465
+ for (const [j, l] of entry.layouts.entries()) imports.push(`import { default as _layout${index}_${j} } from ${JSON.stringify(importPath(l))};`);
466
+ for (const [j, e] of entry.errors.entries()) imports.push(`import { default as _error${index}_${j} } from ${JSON.stringify(importPath(e))};`);
467
+ };
468
+ const pushRegistryAndRoutes = (entry, index, entriesLength, wrappedId, registryLines, routeLines, includeRoutes) => {
469
+ registryLines.push(` ${JSON.stringify(entry.name)}: ${wrappedId}${index < entriesLength - 1 ? "," : ""}`);
470
+ if (!includeRoutes) return;
471
+ routeLines.push(` .route(${JSON.stringify(entry.pattern)}, ${wrappedId})`);
472
+ if (entry.errors.length > 0) routeLines.push(` .errorBoundary(${JSON.stringify(entry.pattern)}, _error${index}_${entry.errors.length - 1})`);
473
+ };
474
+ const buildServerFile = (entries, serverFile) => {
475
+ const rel = (abs) => relFrom(serverFile, abs);
476
+ const imports = [`import { router, wrapLayout, wrapError } from "@ilha/router";`, `import type { Island } from "ilha";`];
477
+ const wrappedIslandLines = [];
478
+ const registryLines = [];
479
+ const routeLines = [];
480
+ for (const [i, entry] of entries.entries()) {
481
+ imports.push(`import { default as _page${i} } from ${JSON.stringify(rel(entry.file))};`);
482
+ pushLayoutErrorImports(imports, entry, i, rel);
483
+ const expr = wrapIslandExpr(`_page${i}`, entry.layouts.length, entry.errors.length, i);
484
+ const wrappedId = `_wrapped${i}`;
485
+ wrappedIslandLines.push(`const ${wrappedId} = ${expr};`);
486
+ pushRegistryAndRoutes(entry, i, entries.length, wrappedId, registryLines, routeLines, true);
487
+ }
488
+ return [
489
+ `// @generated by @ilha/router — do not edit`,
490
+ `// Server module. Use for SSR and SSG/prerender.`,
491
+ `// Import via: import { pageRouter, registry } from "ilha:pages/server";`,
492
+ ``,
493
+ ...imports,
494
+ ``,
495
+ ...wrappedIslandLines,
496
+ ``,
497
+ `export const registry: Record<string, Island<any>> = {`,
498
+ ...registryLines,
499
+ `};`,
500
+ ``,
501
+ `export const pageRouter = router()`,
502
+ ...routeLines,
503
+ ` ;`
504
+ ].join("\n");
505
+ };
506
+ const emitClientEntry = (entry, index, entriesLength, isStatic, clientImport, imports, wrappedIslandLines, registryLines, routeLines) => {
507
+ const pageImport = entry.server ? `import { default as _page${index} } from ${JSON.stringify(serverIslandVirtualSpec(entry.file))};` : `import { default as _page${index} } from ${JSON.stringify(clientImport(entry.file))};`;
508
+ imports.push(pageImport);
509
+ pushLayoutErrorImports(imports, entry, index, clientImport);
510
+ const expr = wrapIslandExpr(`_page${index}`, entry.layouts.length, entry.errors.length, index);
511
+ const wrappedId = `_wrapped${index}`;
512
+ wrappedIslandLines.push(`const ${wrappedId} = ${expr};`);
513
+ pushRegistryAndRoutes(entry, index, entriesLength, wrappedId, registryLines, routeLines, !isStatic);
514
+ };
515
+ const buildClientFile = (entries, clientFile, opts) => {
516
+ const { isStatic, interceptLinks } = opts;
517
+ const rel = (abs) => relFrom(clientFile, abs);
518
+ const clientImport = (abs) => `${rel(abs)}?client`;
519
+ const imports = isStatic ? [`import { router as _router, wrapLayout, wrapError } from "@ilha/router";`, `import type { Island } from "ilha";`] : [`import { router, wrapLayout, wrapError } from "@ilha/router";`, `import type { Island } from "ilha";`];
520
+ const wrappedIslandLines = [];
521
+ const registryLines = [];
522
+ const routeLines = [];
523
+ for (const [i, entry] of entries.entries()) emitClientEntry(entry, i, entries.length, isStatic, clientImport, imports, wrappedIslandLines, registryLines, routeLines);
524
+ const routerExpr = isStatic ? `_router({ mode: "static" })` : `router(${interceptLinks === false ? `{ interceptLinks: false }` : ""})`;
525
+ const lines = [
526
+ `// @generated by @ilha/router — do not edit`,
527
+ `// Client module. Use for browser hydration.`,
528
+ `// Import via: import { pageRouter, registry } from "ilha:pages/client";`,
529
+ ``,
530
+ ...imports,
531
+ ``,
532
+ ...wrappedIslandLines,
533
+ ``,
534
+ `export const registry: Record<string, Island<any>> = {`,
535
+ ...registryLines,
536
+ `};`,
537
+ ``
538
+ ];
539
+ if (isStatic) lines.push(`export const pageRouter = ${routerExpr};`);
540
+ else lines.push(`export const pageRouter = ${routerExpr}`, ...routeLines, ` ;`);
541
+ return lines.join("\n");
542
+ };
543
+ const writeIfChanged = async (file, content) => {
544
+ try {
545
+ if (await readFile(file, "utf-8") === content) return false;
546
+ } catch {}
547
+ await writeFile(file, content, "utf-8");
548
+ return true;
549
+ };
550
+ const generateTypes = async (outDir) => {
551
+ const dtsFile = path.join(outDir, "pages.d.ts");
552
+ const types = [
553
+ `// @generated by @ilha/router — do not edit`,
554
+ ``,
555
+ `declare module "ilha:pages/server" {`,
556
+ ` import type { RouterBuilder } from "@ilha/router";`,
557
+ ` import type { Island } from "ilha";`,
558
+ ` export const pageRouter: RouterBuilder;`,
559
+ ` export const registry: Record<string, Island<any>>;`,
560
+ `}`,
561
+ ``,
562
+ `declare module "ilha:pages/client" {`,
563
+ ` import type { RouterBuilder } from "@ilha/router";`,
564
+ ` import type { Island } from "ilha";`,
565
+ ` export const pageRouter: RouterBuilder;`,
566
+ ` export const registry: Record<string, Island<any>>;`,
567
+ `}`,
568
+ ``,
569
+ `declare module "ilha:loaders" {`,
570
+ ` // Side-effect-only module. oxidejs imports it alongside @ilha/router/ssr.`,
571
+ `}`,
572
+ ``
573
+ ].join("\n");
574
+ await writeIfChanged(dtsFile, types);
575
+ };
576
+ const generate = async (pagesDir, outDir, options = {}) => {
577
+ const mode = options.mode ?? "spa";
578
+ const { interceptLinks } = options;
579
+ const isStatic = mode === "static";
580
+ const raw = await scanPages(pagesDir);
581
+ const entries = sortEntries(raw);
582
+ validateEntries(entries, pagesDir, options.strict === true);
583
+ await mkdir(outDir, { recursive: true });
584
+ const { serverFile, clientFile } = resolveGeneratedPaths(outDir);
585
+ const serverCode = buildServerFile(entries, serverFile);
586
+ const serverChanged = await writeIfChanged(serverFile, serverCode);
587
+ const clientCode = buildClientFile(entries, clientFile, {
588
+ interceptLinks,
589
+ isStatic
590
+ });
591
+ const clientChanged = await writeIfChanged(clientFile, clientCode);
592
+ if (serverChanged || clientChanged) await generateTypes(outDir);
593
+ };
594
+
595
+ //#endregion
596
+ //#region src/plugin.ts
597
+ const objectTag = (value) => Object.prototype.toString.call(value);
598
+ const isString = (value) => objectTag(value) === "[object String]";
599
+ const isNumber = (value) => objectTag(value) === "[object Number]";
600
+ const hostnameFromHost = (host) => {
601
+ if (host === true || host === void 0 || host === "") return "localhost";
602
+ if (host === false) return "localhost";
603
+ return String(host);
604
+ };
605
+ const portFromConfig = (port) => {
606
+ if (isNumber(port)) return port;
607
+ if (port) return Number(port);
608
+ return 5173;
609
+ };
610
+ const devServerOrigin = (server) => {
611
+ const configured = server.config.server.origin;
612
+ if (configured) {
613
+ const value = isString(configured) ? configured : String(configured);
614
+ return new URL(value).origin;
615
+ }
616
+ const local = server.resolvedUrls?.local?.[0];
617
+ if (local) return new URL(local).origin;
618
+ const { host, port, https } = server.config.server;
619
+ const hostname = hostnameFromHost(host);
620
+ return `${https ? "https" : "http"}://${hostname}:${portFromConfig(port)}`;
621
+ };
622
+ const VIRTUAL_PAGES_SERVER = "ilha:pages/server";
623
+ const VIRTUAL_PAGES_CLIENT = "ilha:pages/client";
624
+ const VIRTUAL_LOADERS = "ilha:loaders";
625
+ const RESOLVED_PAGES_SERVER = "\0ilha:pages/server";
626
+ const RESOLVED_PAGES_CLIENT = "\0ilha:pages/client";
627
+ const RESOLVED_LOADERS = "\0ilha:loaders";
628
+ const RESOLVED_VIRTUAL_IDS = [
629
+ RESOLVED_PAGES_SERVER,
630
+ RESOLVED_PAGES_CLIENT,
631
+ RESOLVED_LOADERS
632
+ ];
633
+ /** Query suffix used on page/layout imports in the client file. */
634
+ const CLIENT_QUERY = "?client";
635
+ const decodeServerIslandId = (id) => {
636
+ if (!id.startsWith("\0ilha:server-island:")) return null;
637
+ try {
638
+ return Buffer.from(id.slice(SERVER_ISLAND_PREFIX.length), "base64url").toString();
639
+ } catch {
640
+ return null;
641
+ }
642
+ };
643
+ const SERVER_FILE_RE = /\.server\.(?:ts|tsx|js|jsx)$/u;
644
+ /** Cheap prefilter — most modules never mention a server import. Extension
645
+ * optional: aliases like `$lib/tasks.server` resolve to `.server.tsx` later. */
646
+ const SERVER_SPEC_HINT_RE = /["'][^"']*\.server(?:\.[cm]?[jt]sx?)?["']/u;
647
+ /** mtime-keyed scan cache so repeated transforms don't re-read/re-parse. */
648
+ const scanCache = /* @__PURE__ */ new Map();
649
+ const scanFor = (filePath) => {
650
+ try {
651
+ const { mtimeMs } = statSync(filePath);
652
+ const cached = scanCache.get(filePath);
653
+ if (cached && cached.mtimeMs === mtimeMs) return cached.scan;
654
+ const scan = loadServerModuleScan(filePath);
655
+ if (scan.islands.length === 0) return null;
656
+ scanCache.set(filePath, {
657
+ mtimeMs,
658
+ scan
659
+ });
660
+ return scan;
661
+ } catch {
662
+ return null;
663
+ }
664
+ };
665
+ /** Read & parse a package.json, returning null on any error. */
666
+ const readJson = (filePath) => {
667
+ try {
668
+ return JSON.parse(readFileSync(filePath, "utf-8"));
669
+ } catch {
670
+ return null;
671
+ }
672
+ };
673
+ /** Resolve a dependency's package.json by walking up node_modules from `root`. */
674
+ const readDepPackageJson = (root, name) => {
675
+ let dir = root;
676
+ for (;;) {
677
+ const candidate = path.join(dir, "node_modules", name, "package.json");
678
+ if (existsSync(candidate)) return readJson(candidate);
679
+ const parent = path.dirname(dir);
680
+ if (parent === dir) return null;
681
+ dir = parent;
682
+ }
683
+ };
684
+ /**
685
+ * Find app dependencies that bridge ilha primitives — i.e. declare `ilha` as a
686
+ * peer or dependency (e.g. a UI library like `areia`). They render islands with
687
+ * `bind:*`/slot directives, so they MUST share the app's single ilha instance.
688
+ * Returned here so the plugin can give them the same `dedupe` + `ssr.noExternal`
689
+ * treatment as the framework singletons; otherwise SSR externalizes them with
690
+ * their own ilha copy (a second renderCtxStack) and hydration silently breaks.
691
+ * Keeps app vite configs minimal — no manual `noExternal: ["areia"]` needed.
692
+ */
693
+ const detectIlhaConsumers = (root) => {
694
+ const appPkg = readJson(path.join(root, "package.json"));
695
+ if (!appPkg) return [];
696
+ const deps = {
697
+ ...appPkg.dependencies,
698
+ ...appPkg.devDependencies
699
+ };
700
+ const found = [];
701
+ for (const name of Object.keys(deps)) {
702
+ if (name === "ilha") continue;
703
+ const pkg = readDepPackageJson(root, name);
704
+ if (!pkg) continue;
705
+ const peers = pkg.peerDependencies ?? {};
706
+ const directDeps = pkg.dependencies ?? {};
707
+ if ("ilha" in peers || "ilha" in directDeps) found.push(name);
708
+ }
709
+ return found;
710
+ };
711
+ const resolvePluginPaths = (root, options) => {
712
+ const pagesDir = path.resolve(root, options.dir ?? "src/pages");
713
+ const outDir = path.resolve(root, options.outDir ?? ".ilha");
714
+ const { serverFile, clientFile } = resolveGeneratedPaths(outDir);
715
+ return {
716
+ clientFile,
717
+ outDir,
718
+ pagesDir,
719
+ serverFile
720
+ };
721
+ };
722
+ const createPagesPluginState = (options) => {
723
+ let pagesDir = "";
724
+ let outDir = "";
725
+ let serverFile = "";
726
+ let clientFile = "";
727
+ const setPaths = (root) => {
728
+ ({pagesDir, outDir, serverFile, clientFile} = resolvePluginPaths(root, options));
729
+ };
730
+ const regen = async () => {
731
+ try {
732
+ await generate(pagesDir, outDir, {
733
+ interceptLinks: options.interceptLinks,
734
+ mode: options.mode,
735
+ strict: options.strict
736
+ });
737
+ } catch (error) {
738
+ console.error("[ilha:pages] codegen failed:", error);
739
+ if (options.strict) throw error;
740
+ }
741
+ };
742
+ const isUnderPagesDir = (file) => file === pagesDir || file.startsWith(pagesDir + path.sep);
743
+ const shouldRegenOnChange = (file) => {
744
+ if (!isUnderPagesDir(file)) return false;
745
+ const base = path.basename(file);
746
+ return base.startsWith("+") || /\.(?:ts|tsx)$/u.test(base);
747
+ };
748
+ return {
749
+ get clientFile() {
750
+ return clientFile;
751
+ },
752
+ isUnderPagesDir,
753
+ get outDir() {
754
+ return outDir;
755
+ },
756
+ get pagesDir() {
757
+ return pagesDir;
758
+ },
759
+ regen,
760
+ get serverFile() {
761
+ return serverFile;
762
+ },
763
+ setPaths,
764
+ shouldRegenOnChange
765
+ };
766
+ };
767
+ const regenFromPagesChange = async (state, file, shouldRegen) => {
768
+ if (!shouldRegen(file)) return;
769
+ await state.regen();
770
+ };
771
+ const resolvePagesId = (state, id, importer) => {
772
+ if (id === "ilha:pages/server") return RESOLVED_PAGES_SERVER;
773
+ if (id === "ilha:pages/client") return RESOLVED_PAGES_CLIENT;
774
+ if (id === "ilha:loaders") return RESOLVED_LOADERS;
775
+ for (const query of [CLIENT_QUERY]) {
776
+ if (!id.endsWith(query)) continue;
777
+ const bare = id.slice(0, -query.length);
778
+ const resolved = importer ? path.resolve(importer.replace(/\?.*$/u, ""), "..", bare) : path.resolve(bare);
779
+ if (!state.pagesDir || !state.isUnderPagesDir(resolved)) return;
780
+ return resolved + query;
781
+ }
782
+ };
783
+ const loadPagesModule = (state, id) => {
784
+ if (id === "\0ilha:pages/server") {
785
+ const spec = state.serverFile.replace(/\.tsx?$/u, "");
786
+ return `export { pageRouter, registry } from ${JSON.stringify(spec)};`;
787
+ }
788
+ if (id === "\0ilha:pages/client") {
789
+ const spec = state.clientFile.replace(/\.tsx?$/u, "");
790
+ return `export { pageRouter, registry } from ${JSON.stringify(spec)};`;
791
+ }
792
+ if (id === "\0ilha:loaders") return `// @generated by @ilha/router — do not edit\nexport {};\n`;
793
+ if (id.endsWith("?client")) {
794
+ const bare = id.slice(0, -7);
795
+ return `export { default } from ${JSON.stringify(bare)};`;
796
+ }
797
+ };
798
+ const createStructuralInvalidate = (state, invalidate) => async (file) => {
799
+ if (!state.isUnderPagesDir(file)) return;
800
+ await state.regen();
801
+ await invalidate();
802
+ };
803
+ const setupRspackPagesWatcher = (state, structuralInvalidate) => {
804
+ let watcher = null;
805
+ let poll = null;
806
+ let closed = false;
807
+ const attach = () => {
808
+ watcher = watch(state.pagesDir, { recursive: true }, (_event, filename) => {
809
+ if (!filename) return;
810
+ structuralInvalidate(path.join(state.pagesDir, filename));
811
+ });
812
+ };
813
+ if (existsSync(state.pagesDir)) attach();
814
+ else {
815
+ poll = setInterval(() => {
816
+ if (closed || !existsSync(state.pagesDir)) return;
817
+ if (poll !== null) clearInterval(poll);
818
+ poll = null;
819
+ attach();
820
+ structuralInvalidate(path.join(state.pagesDir, "."));
821
+ }, 1e3);
822
+ poll.unref?.();
823
+ }
824
+ return () => {
825
+ closed = true;
826
+ if (poll) clearInterval(poll);
827
+ watcher?.close();
828
+ };
829
+ };
830
+ const normalizeNoExternal = (existingNoExternal, singletonPeers) => {
831
+ if (existingNoExternal === true) return true;
832
+ let existing;
833
+ if (Array.isArray(existingNoExternal)) existing = existingNoExternal;
834
+ else if (existingNoExternal === void 0 || existingNoExternal === null) existing = [];
835
+ else existing = [existingNoExternal];
836
+ return [.../* @__PURE__ */ new Set([...existing, ...singletonPeers])];
837
+ };
838
+ const writeFrameEnvelope = (res, env) => {
839
+ res.statusCode = env.status;
840
+ for (const [k, v] of Object.entries(env.headers)) res.setHeader(k, v);
841
+ res.end(env.body);
842
+ };
843
+ const readFrameBody = async (req, res) => {
844
+ const chunks = [];
845
+ let size = 0;
846
+ for await (const chunk of req) {
847
+ const buf = isString(chunk) ? Buffer.from(chunk) : chunk;
848
+ size += buf.length;
849
+ if (size > 16384) {
850
+ res.statusCode = 413;
851
+ res.end();
852
+ return null;
853
+ }
854
+ chunks.push(buf);
855
+ }
856
+ return Buffer.concat(chunks);
857
+ };
858
+ const rejectNonFrame = (req, res, next) => {
859
+ if ((req.url ?? "").split("?")[0] !== "/__ilha/frame") {
860
+ next();
861
+ return true;
862
+ }
863
+ if (req.method !== "POST") {
864
+ res.statusCode = 405;
865
+ res.end();
866
+ return true;
867
+ }
868
+ const contentTypeHeader = req.headers["content-type"];
869
+ if (!(Array.isArray(contentTypeHeader) ? contentTypeHeader[0] ?? "" : contentTypeHeader ?? "").startsWith("application/json")) {
870
+ res.statusCode = 415;
871
+ res.end();
872
+ return true;
873
+ }
874
+ return false;
875
+ };
876
+ const renderAuthorizedFrame = async (req, res, ctx, raw, serverOrigin, identityHeaders) => {
877
+ const body = JSON.parse(raw.toString("utf-8"));
878
+ const incomingProps = parseFrameProps(body.props);
879
+ const target = ctx.serverIslands.get(body.id ?? "");
880
+ if (!target) throw new Error("unknown island");
881
+ let framePath = "/";
882
+ if (isString(body.path)) {
883
+ if (!isSafeFramePath(body.path)) {
884
+ writeFrameEnvelope(res, frameEnvelope(400, { error: "frame failed" }));
885
+ return;
886
+ }
887
+ framePath = body.path;
888
+ }
889
+ await ctx.server.ssrLoadModule(VIRTUAL_PAGES_SERVER);
890
+ await ctx.server.ssrLoadModule(target.file);
891
+ const request = new Request(frameScopedUrl(req.url ?? "/", framePath, serverOrigin), {
892
+ headers: identityHeaders,
893
+ method: "POST"
894
+ });
895
+ const result = await renderServerIslandResult(body.id ?? "", request, (r, fn) => runWithIslandRequest(r, fn), incomingProps);
896
+ if (Result.isFailure(result)) {
897
+ const err = result.failure;
898
+ if (err.redirect) {
899
+ writeFrameEnvelope(res, frameEnvelope(err.status, { redirect: err.redirect }));
900
+ return;
901
+ }
902
+ const { status } = err;
903
+ if (status >= 500) console.error("[ilha-router] frame render failed:", err);
904
+ writeFrameEnvelope(res, frameEnvelope(status, { error: "frame failed" }));
905
+ return;
906
+ }
907
+ writeFrameEnvelope(res, frameEnvelope(200, { html: result.success }));
908
+ };
909
+ const handleDevFrame = async (req, res, next, ctx) => {
910
+ if (rejectNonFrame(req, res, next)) return;
911
+ const serverOrigin = devServerOrigin(ctx.server);
912
+ const frameUrl = `${serverOrigin}${req.url ?? "/"}`;
913
+ const authorized = await authorizeFrameRequest(new Request(frameUrl, {
914
+ headers: new Headers(req.headers),
915
+ method: req.method
916
+ }), {
917
+ defaultAction: "open",
918
+ onGuardError: () => {}
919
+ });
920
+ if (!authorized.ok) {
921
+ 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.`);
922
+ res.statusCode = authorized.status;
923
+ res.end();
924
+ return;
925
+ }
926
+ const raw = await readFrameBody(req, res);
927
+ if (raw === null) return;
928
+ try {
929
+ await renderAuthorizedFrame(req, res, ctx, raw, serverOrigin, authorized.identityHeaders);
930
+ } catch (error) {
931
+ if (error instanceof FrameError && error.redirect) {
932
+ writeFrameEnvelope(res, frameEnvelope(error.status, { redirect: error.redirect }));
933
+ return;
934
+ }
935
+ const status = error instanceof FrameError ? error.status : 400;
936
+ if (!(error instanceof FrameError) || error.status >= 500) console.error("[ilha-router] frame render failed:", error);
937
+ writeFrameEnvelope(res, frameEnvelope(status, { error: "frame failed" }));
938
+ }
939
+ };
940
+ const transformSsrServerFile = (code, file) => {
941
+ const scan = scanFor(file);
942
+ const lines = [];
943
+ 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)};`);
944
+ if (scan && scan.islands.length > 0 && !code.startsWith("// oxidejs:client-stub")) {
945
+ if (Object.keys(scan.rpcActions).length > 0) lines.unshift(`import { __ilhaServerAction } from "@ilha/router/ssr";`);
946
+ lines.unshift(`import { registerServerIsland } from "@ilha/router/ssr";`, `import * as __ilhaSelf from ${JSON.stringify(file)};`);
947
+ for (const island of scan.islands) lines.push(`registerServerIsland(${JSON.stringify(serverIslandPublicId(file, island.name))}, () => __ilhaSelf[${JSON.stringify(island.name)}]);`);
948
+ }
949
+ const base = rewriteServerActions(code, scan?.rpcActions ?? {});
950
+ if (base === code && lines.length === 0) return null;
951
+ if (lines.length === 0) return base;
952
+ return `${base}\n${lines.join("\n")}`;
953
+ };
954
+ const transformClientServerImports = async (code, id, resolve) => {
955
+ const SPEC_RE = /(?:^|\n)\s*import\s+(?:type\s+)?[^'"\n]+?\s*from\s*["'](?<spec>[^"']+)["']/gu;
956
+ const specs = /* @__PURE__ */ new Set();
957
+ for (const match of code.matchAll(SPEC_RE)) {
958
+ const spec = match.groups?.spec;
959
+ if (spec !== void 0) specs.add(spec);
960
+ }
961
+ if (specs.size === 0) return null;
962
+ const scanned = /* @__PURE__ */ new Map();
963
+ const resolvedEntries = await Promise.all([...specs].map(async (spec) => {
964
+ let resolvedFile;
965
+ try {
966
+ const resolved = await resolve(spec, id);
967
+ resolvedFile = resolved?.path ?? resolved?.id;
968
+ } catch {
969
+ resolvedFile = void 0;
970
+ }
971
+ if (!resolvedFile && spec.startsWith("/")) resolvedFile = spec;
972
+ return {
973
+ resolvedFile,
974
+ spec
975
+ };
976
+ }));
977
+ for (const { spec, resolvedFile } of resolvedEntries) {
978
+ if (!resolvedFile || !SERVER_FILE_RE.test(resolvedFile.replace(/\?.*$/u, ""))) continue;
979
+ const scan = scanFor(resolvedFile);
980
+ if (!scan) continue;
981
+ scanned.set(spec, {
982
+ hasDefault: scan.islands.some((i) => i.name === "default"),
983
+ islands: new Set(scan.islands.filter((i) => i.name !== "default").map((i) => i.name)),
984
+ resolvedFile
985
+ });
986
+ }
987
+ if (scanned.size === 0) return null;
988
+ return splitServerImports(code, {
989
+ islandNamesFor: (spec) => {
990
+ const entry = scanned.get(spec);
991
+ return entry ? {
992
+ hasDefault: entry.hasDefault,
993
+ islands: entry.islands
994
+ } : null;
995
+ },
996
+ virtualSpecFor: (spec) => {
997
+ const entry = scanned.get(spec);
998
+ if (entry === void 0) return serverIslandVirtualSpec(spec);
999
+ return serverIslandVirtualSpec(entry.resolvedFile);
1000
+ }
1001
+ });
1002
+ };
1003
+ const pagesFactory = (options = {}) => {
1004
+ const state = createPagesPluginState(options);
1005
+ const serverIslands = /* @__PURE__ */ new Map();
1006
+ return {
1007
+ async buildStart() {
1008
+ if (!state.pagesDir) state.setPaths(process.cwd());
1009
+ this.addWatchFile?.(state.pagesDir);
1010
+ await state.regen();
1011
+ },
1012
+ load(id) {
1013
+ const islandFile = decodeServerIslandId(id);
1014
+ if (islandFile !== null) {
1015
+ const scan = loadServerModuleScan(islandFile);
1016
+ for (const island of scan.islands) serverIslands.set(serverIslandPublicId(islandFile, island.name), {
1017
+ file: islandFile,
1018
+ name: island.name
1019
+ });
1020
+ return generateServerIslandModule(islandFile, scan);
1021
+ }
1022
+ return loadPagesModule(state, id);
1023
+ },
1024
+ name: "ilha:pages",
1025
+ resolveId(id, importer) {
1026
+ if (id.startsWith("\0ilha:server-island:")) return id;
1027
+ return resolvePagesId(state, id, importer);
1028
+ },
1029
+ rspack(compiler) {
1030
+ state.setPaths(compiler.options.context ?? process.cwd());
1031
+ const structuralInvalidate = createStructuralInvalidate(state, () => {
1032
+ if (!compiler.watching) return Promise.resolve();
1033
+ compiler.invalidate();
1034
+ return Promise.resolve();
1035
+ });
1036
+ let closeWatcher;
1037
+ compiler.hooks.watchRun.tap("ilha:pages", () => {
1038
+ closeWatcher?.();
1039
+ closeWatcher = setupRspackPagesWatcher(state, structuralInvalidate);
1040
+ });
1041
+ compiler.hooks.shutdown.tap("ilha:pages", () => closeWatcher?.());
1042
+ },
1043
+ vite: {
1044
+ config(userConfig) {
1045
+ const root = userConfig.root ? path.resolve(userConfig.root) : process.cwd();
1046
+ const singletonPeers = [
1047
+ "ilha",
1048
+ "@ilha/router",
1049
+ ...detectIlhaConsumers(root)
1050
+ ];
1051
+ const noExternal = normalizeNoExternal(userConfig.ssr?.noExternal, singletonPeers);
1052
+ return {
1053
+ optimizeDeps: {
1054
+ ...userConfig.optimizeDeps,
1055
+ include: [.../* @__PURE__ */ new Set([
1056
+ ...userConfig.optimizeDeps?.include ?? [],
1057
+ "ilha",
1058
+ "ilha/jsx-runtime",
1059
+ "ilha/jsx-dev-runtime"
1060
+ ])]
1061
+ },
1062
+ resolve: { dedupe: [.../* @__PURE__ */ new Set([...userConfig.resolve?.dedupe ?? [], ...singletonPeers])] },
1063
+ ssr: { noExternal }
1064
+ };
1065
+ },
1066
+ configResolved(config) {
1067
+ state.setPaths(config.root);
1068
+ },
1069
+ configureServer(server) {
1070
+ server.watcher.add(state.pagesDir);
1071
+ if (options.frameGuard) setFrameGuard(options.frameGuard);
1072
+ setFrameAuth({
1073
+ csrf: options.csrf,
1074
+ defaultAction: "open",
1075
+ trustedOrigins: options.trustedOrigins
1076
+ });
1077
+ server.middlewares.use((req, res, next) => {
1078
+ const run = async () => {
1079
+ try {
1080
+ await handleDevFrame(req, res, next, {
1081
+ server,
1082
+ serverIslands
1083
+ });
1084
+ } catch (error) {
1085
+ return next(error instanceof Error ? error : new Error(String(error)));
1086
+ }
1087
+ };
1088
+ run();
1089
+ });
1090
+ const structuralInvalidate = createStructuralInvalidate(state, () => {
1091
+ for (const resolvedId of RESOLVED_VIRTUAL_IDS) {
1092
+ const mod = server.moduleGraph.getModuleById(resolvedId);
1093
+ if (mod) server.moduleGraph.invalidateModule(mod);
1094
+ }
1095
+ server.hot.send({ type: "full-reload" });
1096
+ return Promise.resolve();
1097
+ });
1098
+ server.watcher.on("add", structuralInvalidate);
1099
+ server.watcher.on("addDir", structuralInvalidate);
1100
+ server.watcher.on("unlink", structuralInvalidate);
1101
+ server.watcher.on("change", async (changedFile) => {
1102
+ if (state.shouldRegenOnChange(changedFile)) await structuralInvalidate(changedFile);
1103
+ if (!SERVER_FILE_RE.test(changedFile.replace(/\?.*$/u, ""))) return;
1104
+ scanCache.delete(changedFile);
1105
+ const graph = server.moduleGraph;
1106
+ let touched = false;
1107
+ graph.forEachModule?.((mod) => {
1108
+ if (mod.id.startsWith("\0ilha:server-island:")) {
1109
+ const m = server.moduleGraph.getModuleById(mod.id);
1110
+ if (m) {
1111
+ server.moduleGraph.invalidateModule(m);
1112
+ touched = true;
1113
+ }
1114
+ }
1115
+ });
1116
+ if (touched) server.hot.send({ type: "full-reload" });
1117
+ });
1118
+ },
1119
+ transform(code, id, opts) {
1120
+ const file = id.replace(/\?.*$/u, "");
1121
+ const isServerFile = SERVER_FILE_RE.test(file);
1122
+ if (opts?.ssr === true) {
1123
+ if (!isServerFile) return null;
1124
+ return transformSsrServerFile(code, file);
1125
+ }
1126
+ if (id.startsWith("\0") || id.includes("node_modules")) return null;
1127
+ if (isServerFile) return null;
1128
+ if (!SERVER_SPEC_HINT_RE.test(code)) return null;
1129
+ return transformClientServerImports(code, id, (spec, importer) => Promise.resolve(this.resolve?.(spec, importer)));
1130
+ }
1131
+ },
1132
+ async watchChange(file) {
1133
+ await regenFromPagesChange(state, file, (f) => state.shouldRegenOnChange(f));
1134
+ }
1135
+ };
1136
+ };
1137
+ const ilhaPages = createUnplugin(pagesFactory);
1138
+
1139
+ //#endregion
1140
+ export { ilhaPages as t };