@ilha/router 0.10.4 → 0.11.1

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,6 +1,7 @@
1
- import { E as runWithIslandRequest, S as setFrameGuard, _ as parseFrameProps, b as renderServerIsland, c as frameEnvelope, m as isSafeFramePath, n as FrameError, o as authorizeFrameRequest, u as getFrameGuard, w as setLoaderGuard, x as setFrameAuth } from "./ssr-BTmqfAp9.js";
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-WA3bSsCo.js";
2
2
  import { existsSync, readFileSync, statSync, watch } from "node:fs";
3
3
  import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
4
+ import * as Result from "effect/Result";
4
5
  import { createUnplugin } from "unplugin";
5
6
  import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
6
7
  import { createHash } from "node:crypto";
@@ -21,9 +22,8 @@ function rewriteServerActions(code, rpcActions) {
21
22
  });
22
23
  }
23
24
  const EXPORT_RE = /(?:^|\n)\s*export\s+(?:declare\s+)?(?:async\s+)?(?:function\s*\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/g;
24
- const ISLAND_EXPORT_RE = /(?:^|\n)\s*export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*ilha\b/g;
25
- const DEFAULT_ISLAND_RE = /export\s+default\s+ilha\b/;
26
- const AS_RES = [/\{\s*as:\s*["'`]([a-z][a-z0-9-]*)["'`]\s*[,}]?/];
25
+ const ISLAND_EXPORT_RE = /(?:^|\n)\s*export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+function|function\s*\*)/g;
26
+ const DEFAULT_ISLAND_RE = /export\s+default\s+(?:async\s+function|function\s*\*|function\b)/;
27
27
  function clientRefPublicId(spec, imported) {
28
28
  return createHash("sha256").update(`${spec}#${imported}`).digest("base64url");
29
29
  }
@@ -102,32 +102,20 @@ function scanServerIslands(source) {
102
102
  const islands = [];
103
103
  const collect = (name, start, sliceEnd) => {
104
104
  const slice = source.slice(start, sliceEnd);
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
- }
105
+ const as = "div";
113
106
  const streams = {};
114
107
  const actions = {};
115
108
  let actionOrder = 0;
116
109
  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");
119
- for (const match of slice.matchAll(re)) {
120
- const args = extractCallArgs(slice, match.index + match[0].indexOf("("));
121
- if (!args) continue;
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 ?? "";
130
- }
110
+ for (const match of slice.matchAll(/[A-Za-z0-9_$]*[Aa]ction\s*\(/g)) {
111
+ const args = extractCallArgs(slice, match.index + match[0].indexOf("("));
112
+ if (!args) continue;
113
+ const target = referencedExports(args, candidates);
114
+ actions[`a${actionOrder++}`] = target ?? "";
115
+ }
116
+ for (const match of slice.matchAll(/fromAsyncIterable\(\s*([A-Za-z_$][\w$]*)\s*\(/g)) {
117
+ const target = match[1];
118
+ if (candidates.has(target) && !Object.values(streams).includes(target)) streams[`d${streamOrder++}`] = target;
131
119
  }
132
120
  islands.push({
133
121
  name,
@@ -152,8 +140,7 @@ function scanServerIslands(source) {
152
140
  islands,
153
141
  exports,
154
142
  rpcActions,
155
- clientRefs: scanClientRefs(source),
156
- clientLoader: /(^|\n)\s*export\s+(?:const|let|var)\s+load\b\s*=\s*loader\.client\b/.test(source)
143
+ clientRefs: scanClientRefs(source)
157
144
  };
158
145
  }
159
146
  function loadServerModuleScan(path) {
@@ -198,8 +185,7 @@ function generateServerIslandModule(spec, scan) {
198
185
  if (actions.length) wiring.push(`actions: { ${actions.join(", ")} }`);
199
186
  if (scan.clientRefs.length) wiring.push(`children: { ${scan.clientRefs.map((ref, index) => `${JSON.stringify(ref.id)}: $$child${index}`).join(", ")} }`);
200
187
  const id = serverIslandPublicId(spec, island.name);
201
- 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) { location.assign(j.redirect); throw new Error("frame redirected"); } __ilhaApplyHead(j.head); return j.html; })`);
202
- if (scan.clientLoader) wiring.push(`clientLoader: () => $$call("load", [])`);
188
+ 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; })`);
203
189
  const call = `__ilhaServerIsland(${JSON.stringify(id)}, ${JSON.stringify(island.as)}, { ${wiring.join(", ")} })`;
204
190
  if (island.name === "default") lines.push(`export default ${call};`);
205
191
  else lines.push(`export const ${island.name} = ${call};`);
@@ -274,32 +260,6 @@ function toPosix(p) {
274
260
  const EXCLUDED_RE = /\.(test|spec|d)\.(ts|tsx)$/;
275
261
  /** Server pages: `foo.server.tsx` routes `/foo`, rendered through the frame protocol. */
276
262
  const SERVER_PAGE_RE = /\.server\.(ts|tsx)$/;
277
- /**
278
- * Match a top-of-statement `export const load`, `export let load`,
279
- * `export function load`, or `export async function load`. Intentionally
280
- * conservative: `export { load } from "./x"` re-exports are NOT detected
281
- * in v1. Declare `load` directly in the file to be picked up.
282
- */
283
- const LOADER_EXPORT_RE = /^\s*export\s+(?:const|let|var|async\s+function|function)\s+load\b/m;
284
- /** `export const load = loader.client(…)` — the only client-loader form. */
285
- /** `export const load = loader.client(…)` — client loader under the server name. */
286
- const LOAD_CLIENT_EXPORT_RE = /(^|\n)\s*export\s+(?:const|let|var)\s+load\b\s*=\s*loader\.client\b/m;
287
- async function detectLoaderExports(file) {
288
- try {
289
- const stripped = (await readFile(file, "utf8")).replace(/^\s*\/\/.*$/gm, "");
290
- const isClientLoader = LOAD_CLIENT_EXPORT_RE.test(stripped);
291
- return {
292
- load: LOADER_EXPORT_RE.test(stripped) && !isClientLoader,
293
- isClientLoader
294
- };
295
- } catch (err) {
296
- if (err?.code !== "ENOENT") console.warn(`[ilha-router] failed to read ${file} while detecting loader exports:`, err);
297
- return {
298
- load: false,
299
- isClientLoader: false
300
- };
301
- }
302
- }
303
263
  function fileToSegment(name) {
304
264
  if (name.startsWith("[...") && name.endsWith("]")) return `**:${name.slice(4, -1)}`;
305
265
  if (name.startsWith("[") && name.endsWith("]")) return `:${name.slice(1, -1)}`;
@@ -374,37 +334,17 @@ async function collectFiles(dir, depth = 0) {
374
334
  async function scanPages(pagesDir) {
375
335
  const all = await collectFiles(pagesDir);
376
336
  const allSet = new Set(all);
377
- const pages = all.filter((f) => !basename(f).startsWith("+"));
378
- const layoutLoaderCache = /* @__PURE__ */ new Map();
379
- const getLayoutExports = (file) => {
380
- let cached = layoutLoaderCache.get(file);
381
- if (!cached) {
382
- cached = detectLoaderExports(file);
383
- layoutLoaderCache.set(file, cached);
384
- }
385
- return cached;
386
- };
387
- return Promise.all(pages.map(async (file) => {
388
- const server = SERVER_PAGE_RE.test(basename(file));
337
+ return all.filter((f) => !basename(f).startsWith("+")).map((file) => {
389
338
  const pattern = fileToPattern(pagesDir, file);
390
- const layouts = chainForFile(pagesDir, file, allSet, "+layout");
391
- const errors = chainForFile(pagesDir, file, allSet, "+error");
392
- const [pageExports, ...layoutExports] = await Promise.all([detectLoaderExports(file), ...layouts.map(getLayoutExports)]);
393
- const loaderLayouts = layouts.filter((_, i) => layoutExports[i].load);
394
- const clientLoaderLayouts = layouts.filter((_, i) => layoutExports[i].isClientLoader);
395
339
  return {
396
340
  file,
397
341
  pattern,
398
342
  name: patternToName(pattern),
399
- layouts,
400
- errors,
401
- hasLoader: pageExports.load,
402
- loaderLayouts,
403
- hasClientLoader: pageExports.isClientLoader,
404
- clientLoaderLayouts,
405
- server
343
+ layouts: chainForFile(pagesDir, file, allSet, "+layout"),
344
+ errors: chainForFile(pagesDir, file, allSet, "+error"),
345
+ server: SERVER_PAGE_RE.test(basename(file))
406
346
  };
407
- }));
347
+ });
408
348
  }
409
349
  function validateEntries(entries, pagesDir, strict) {
410
350
  if (entries.length === 0) {
@@ -416,7 +356,8 @@ function validateEntries(entries, pagesDir, strict) {
416
356
  const problems = [];
417
357
  for (const entry of entries) {
418
358
  if (entry.server) try {
419
- 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.`);
359
+ const scan = loadServerModuleScan(entry.file);
360
+ 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.`);
420
361
  } catch (err) {
421
362
  if (err instanceof Error && err.message.includes("[ilha:pages]")) throw err;
422
363
  throw new Error(`[ilha:pages] Server page ${entry.file} could not be scanned for island exports.`);
@@ -435,8 +376,7 @@ function validateEntries(entries, pagesDir, strict) {
435
376
  function resolveGeneratedPaths(outDir) {
436
377
  return {
437
378
  serverFile: join(outDir, "pages.server.ts"),
438
- clientFile: join(outDir, "pages.client.ts"),
439
- loadersFile: join(outDir, "loaders.ts")
379
+ clientFile: join(outDir, "pages.client.ts")
440
380
  };
441
381
  }
442
382
  async function generate(pagesDir, outDir, options = {}) {
@@ -446,13 +386,13 @@ async function generate(pagesDir, outDir, options = {}) {
446
386
  const entries = sortEntries(await scanPages(pagesDir));
447
387
  validateEntries(entries, pagesDir, options.strict === true);
448
388
  await mkdir(outDir, { recursive: true });
449
- const { serverFile, clientFile, loadersFile } = resolveGeneratedPaths(outDir);
389
+ const { serverFile, clientFile } = resolveGeneratedPaths(outDir);
450
390
  const serverChanged = await writeIfChanged(serverFile, buildServerFile(entries, serverFile));
451
391
  const clientChanged = await writeIfChanged(clientFile, buildClientFile(entries, clientFile, {
452
392
  isStatic,
453
393
  interceptLinks
454
394
  }));
455
- if (!isStatic) await writeIfChanged(loadersFile, buildLoadersFile(entries, loadersFile, serverFile));
395
+ if (!isStatic) {}
456
396
  if (serverChanged || clientChanged) await generateTypes(outDir);
457
397
  }
458
398
  function buildServerFile(entries, serverFile) {
@@ -474,7 +414,7 @@ function buildServerFile(entries, serverFile) {
474
414
  const wrappedId = `_wrapped${i}`;
475
415
  wrappedIslandLines.push(`const ${wrappedId} = ${expr};`);
476
416
  registryLines.push(` ${JSON.stringify(entry.name)}: ${wrappedId}` + (i < entries.length - 1 ? "," : ""));
477
- routeLines.push(` .route(${JSON.stringify(entry.pattern)}, ${wrappedId})` + (entry.hasLoader || entry.loaderLayouts.length > 0 ? `.markLoader(${JSON.stringify(entry.pattern)})` : ""));
417
+ routeLines.push(` .route(${JSON.stringify(entry.pattern)}, ${wrappedId})`);
478
418
  if (entry.errors.length > 0) routeLines.push(` .errorBoundary(${JSON.stringify(entry.pattern)}, _error${i}_${entry.errors.length - 1})`);
479
419
  }
480
420
  return [
@@ -483,7 +423,6 @@ function buildServerFile(entries, serverFile) {
483
423
  `// Import via: import { pageRouter, registry } from "ilha:pages/server";`,
484
424
  ``,
485
425
  ...imports,
486
- ...entries.some((e) => e.hasLoader || e.loaderLayouts.length > 0) ? [`import { setFrameLoaderRunner } from "@ilha/router/ssr";`, `setFrameLoaderRunner((path, request) => pageRouter.runLoader(path, request));`] : [],
487
426
  ``,
488
427
  ...wrappedIslandLines,
489
428
  ``,
@@ -503,8 +442,6 @@ function buildClientFile(entries, clientFile, opts) {
503
442
  return r.startsWith(".") ? r : `./${r}`;
504
443
  };
505
444
  const clientImport = (abs) => `${rel(abs)}?client`;
506
- const clientLoaderImport = (abs) => `${rel(abs)}?client-loader`;
507
- let needsComposeLoaders = false;
508
445
  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";`];
509
446
  const wrappedIslandLines = [];
510
447
  const registryLines = [];
@@ -536,27 +473,10 @@ function buildClientFile(entries, clientFile, opts) {
536
473
  wrappedIslandLines.push(`const ${wrappedId} = ${expr};`);
537
474
  registryLines.push(` ${JSON.stringify(entry.name)}: ${wrappedId}` + (i < entries.length - 1 ? "," : ""));
538
475
  if (!isStatic) {
539
- routeLines.push(` .route(${JSON.stringify(entry.pattern)}, ${wrappedId})` + (entry.hasLoader || entry.loaderLayouts.length > 0 ? `.markLoader(${JSON.stringify(entry.pattern)})` : ""));
540
- const clientLoaderIds = [];
541
- for (const [j, layout] of entry.clientLoaderLayouts.entries()) {
542
- const id = `_cl${i}_l${j}`;
543
- imports.push(`import { load as ${id} } from ${JSON.stringify(clientLoaderImport(layout))};`);
544
- clientLoaderIds.push(id);
545
- }
546
- if (entry.hasClientLoader) {
547
- const id = `_cl${i}`;
548
- imports.push(`import { load as ${id} } from ${JSON.stringify(clientLoaderImport(entry.file))};`);
549
- clientLoaderIds.push(id);
550
- }
551
- if (clientLoaderIds.length > 0) {
552
- const expr = clientLoaderIds.length === 1 ? clientLoaderIds[0] : `composeLoaders([${clientLoaderIds.join(", ")}])`;
553
- if (clientLoaderIds.length > 1) needsComposeLoaders = true;
554
- routeLines.push(` .clientLoader(${JSON.stringify(entry.pattern)}, ${expr})`);
555
- }
476
+ routeLines.push(` .route(${JSON.stringify(entry.pattern)}, ${wrappedId})`);
556
477
  if (entry.errors.length > 0) routeLines.push(` .errorBoundary(${JSON.stringify(entry.pattern)}, _error${i}_${entry.errors.length - 1})`);
557
478
  }
558
479
  }
559
- if (needsComposeLoaders) imports[0] = imports[0].replace("{ router", "{ composeLoaders, router");
560
480
  const routerExpr = isStatic ? `_router({ mode: "static" })` : `router(${interceptLinks === false ? `{ interceptLinks: false }` : ""})`;
561
481
  const lines = [
562
482
  `// @generated by @ilha/router — do not edit`,
@@ -576,52 +496,6 @@ function buildClientFile(entries, clientFile, opts) {
576
496
  else lines.push(`export const pageRouter = ${routerExpr}`, ...routeLines, ` ;`);
577
497
  return lines.join("\n");
578
498
  }
579
- function buildLoadersFile(entries, loadersFile, serverFile) {
580
- const relFromLoaders = (abs) => {
581
- const r = toPosix(relative(dirname(loadersFile), abs));
582
- return r.startsWith(".") ? r : `./${r}`;
583
- };
584
- const withLoaders = entries.filter((e) => e.hasLoader || e.loaderLayouts.length > 0);
585
- if (withLoaders.length === 0) return [
586
- `// @generated by @ilha/router — do not edit`,
587
- `// This project has no loader exports; this file is intentionally empty.`,
588
- ``,
589
- `export {};`,
590
- ``
591
- ].join("\n");
592
- const serverRel = relFromLoaders(serverFile).replace(/\.tsx?$/, "");
593
- const imports = [`import { pageRouter } from ${JSON.stringify(serverRel)};`];
594
- let needsComposeLoaders = false;
595
- const attachLines = [];
596
- for (const [i, entry] of withLoaders.entries()) {
597
- const loaderIds = [];
598
- for (const [j, layout] of entry.loaderLayouts.entries()) {
599
- const id = `_p${i}_l${j}`;
600
- imports.push(`import { load as ${id} } from ${JSON.stringify(relFromLoaders(layout))};`);
601
- loaderIds.push(id);
602
- }
603
- if (entry.hasLoader) {
604
- const id = `_p${i}`;
605
- imports.push(`import { load as ${id} } from ${JSON.stringify(relFromLoaders(entry.file))};`);
606
- loaderIds.push(id);
607
- }
608
- const loadersExpr = loaderIds.length === 1 ? loaderIds[0] : `composeLoaders([${loaderIds.join(", ")}])`;
609
- if (loaderIds.length > 1) needsComposeLoaders = true;
610
- attachLines.push(`pageRouter.attachLoader(${JSON.stringify(entry.pattern)}, ${loadersExpr});`);
611
- }
612
- if (needsComposeLoaders) imports.unshift(`import { composeLoaders } from "@ilha/router";`);
613
- return [
614
- `// @generated by @ilha/router — do not edit`,
615
- `// Server-only. Import this module from your SSR entry to wire loaders`,
616
- `// onto pageRouter. Importing it from the client is a no-op but wastes`,
617
- `// bundle size — rely on the default build pipeline to keep it out.`,
618
- ``,
619
- ...imports,
620
- ``,
621
- ...attachLines,
622
- ``
623
- ].join("\n");
624
- }
625
499
  async function writeIfChanged(file, content) {
626
500
  try {
627
501
  if (await readFile(file, "utf8") === content) return false;
@@ -648,7 +522,7 @@ async function generateTypes(outDir) {
648
522
  `}`,
649
523
  ``,
650
524
  `declare module "ilha:loaders" {`,
651
- ` // Side-effect-only module. Importing it attaches loaders to pageRouter.`,
525
+ ` // Side-effect-only module. oxidejs imports it alongside @ilha/router/ssr.`,
652
526
  `}`,
653
527
  ``
654
528
  ].join("\n"));
@@ -656,6 +530,14 @@ async function generateTypes(outDir) {
656
530
 
657
531
  //#endregion
658
532
  //#region src/plugin.ts
533
+ function devServerOrigin(server) {
534
+ const configured = server.config.server.origin;
535
+ if (configured) return new URL(typeof configured === "string" ? configured : String(configured)).origin;
536
+ const local = server.resolvedUrls?.local?.[0];
537
+ if (local) return new URL(local).origin;
538
+ const { host, port, https } = server.config.server;
539
+ return `${https ? "https" : "http"}://${host === true || host === void 0 || host === "" ? "localhost" : host === false ? "localhost" : String(host)}:${typeof port === "number" ? port : port ? Number(port) : 5173}`;
540
+ }
659
541
  const VIRTUAL_PAGES_SERVER = "ilha:pages/server";
660
542
  const VIRTUAL_PAGES_CLIENT = "ilha:pages/client";
661
543
  const VIRTUAL_LOADERS = "ilha:loaders";
@@ -669,8 +551,6 @@ const RESOLVED_VIRTUAL_IDS = [
669
551
  ];
670
552
  /** Query suffix used on page/layout imports in the client file. */
671
553
  const CLIENT_QUERY = "?client";
672
- /** Query suffix that re-exports a page/layout's `load` (loader.client) for the browser bundle. */
673
- const CLIENT_LOADER_QUERY = "?client-loader";
674
554
  function decodeServerIslandId(id) {
675
555
  if (!id.startsWith("\0ilha:server-island:")) return null;
676
556
  try {
@@ -750,13 +630,12 @@ function detectIlhaConsumers(root) {
750
630
  function resolvePluginPaths(root, options) {
751
631
  const pagesDir = resolve(root, options.dir ?? "src/pages");
752
632
  const outDir = resolve(root, options.outDir ?? ".ilha");
753
- const { serverFile, clientFile, loadersFile } = resolveGeneratedPaths(outDir);
633
+ const { serverFile, clientFile } = resolveGeneratedPaths(outDir);
754
634
  return {
755
635
  pagesDir,
756
636
  outDir,
757
637
  serverFile,
758
- clientFile,
759
- loadersFile
638
+ clientFile
760
639
  };
761
640
  }
762
641
  function createPagesPluginState(options) {
@@ -764,9 +643,8 @@ function createPagesPluginState(options) {
764
643
  let outDir;
765
644
  let serverFile;
766
645
  let clientFile;
767
- let loadersFile;
768
646
  const setPaths = (root) => {
769
- ({pagesDir, outDir, serverFile, clientFile, loadersFile} = resolvePluginPaths(root, options));
647
+ ({pagesDir, outDir, serverFile, clientFile} = resolvePluginPaths(root, options));
770
648
  };
771
649
  const regen = async () => {
772
650
  try {
@@ -799,9 +677,6 @@ function createPagesPluginState(options) {
799
677
  get clientFile() {
800
678
  return clientFile;
801
679
  },
802
- get loadersFile() {
803
- return loadersFile;
804
- },
805
680
  setPaths,
806
681
  regen,
807
682
  shouldRegenOnChange,
@@ -816,7 +691,7 @@ function resolvePagesId(state, id, importer) {
816
691
  if (id === "ilha:pages/server") return RESOLVED_PAGES_SERVER;
817
692
  if (id === "ilha:pages/client") return RESOLVED_PAGES_CLIENT;
818
693
  if (id === "ilha:loaders") return RESOLVED_LOADERS;
819
- for (const query of [CLIENT_LOADER_QUERY, CLIENT_QUERY]) {
694
+ for (const query of [CLIENT_QUERY]) {
820
695
  if (!id.endsWith(query)) continue;
821
696
  const bare = id.slice(0, -query.length);
822
697
  const resolved = importer ? resolve(importer.replace(/\?.*$/, ""), "..", bare) : resolve(bare);
@@ -833,14 +708,7 @@ function loadPagesModule(state, id) {
833
708
  const spec = state.clientFile.replace(/\.tsx?$/, "");
834
709
  return `export { pageRouter, registry } from ${JSON.stringify(spec)};`;
835
710
  }
836
- if (id === "\0ilha:loaders") {
837
- const spec = state.loadersFile.replace(/\.tsx?$/, "");
838
- return `import ${JSON.stringify(spec)};`;
839
- }
840
- if (id.endsWith("?client-loader")) {
841
- const bare = id.slice(0, -14);
842
- return `export { load } from ${JSON.stringify(bare)};`;
843
- }
711
+ if (id === "\0ilha:loaders") return `// @generated by @ilha/router — do not edit\nexport {};\n`;
844
712
  if (id.endsWith("?client")) {
845
713
  const bare = id.slice(0, -7);
846
714
  return `export { default } from ${JSON.stringify(bare)};`;
@@ -914,7 +782,6 @@ const pagesFactory = (options = {}) => {
914
782
  const singletonPeers = [
915
783
  "ilha",
916
784
  "@ilha/router",
917
- "alien-signals",
918
785
  ...detectIlhaConsumers(userConfig.root ? resolve(userConfig.root) : process.cwd())
919
786
  ];
920
787
  const existingNoExternal = userConfig.ssr?.noExternal;
@@ -928,8 +795,7 @@ const pagesFactory = (options = {}) => {
928
795
  ...userConfig.optimizeDeps?.include ?? [],
929
796
  "ilha",
930
797
  "ilha/jsx-runtime",
931
- "ilha/jsx-dev-runtime",
932
- "alien-signals"
798
+ "ilha/jsx-dev-runtime"
933
799
  ])]
934
800
  }
935
801
  };
@@ -949,11 +815,7 @@ const pagesFactory = (options = {}) => {
949
815
  if (Object.keys(scan.rpcActions).length > 0) lines.unshift(`import { __ilhaServerAction } from "@ilha/router/ssr";`);
950
816
  lines.unshift(`import * as __ilhaSelf from ${JSON.stringify(file)};`);
951
817
  lines.unshift(`import { registerServerIsland } from "@ilha/router/ssr";`);
952
- for (const island of scan.islands) {
953
- const id2 = serverIslandPublicId(file, island.name);
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))} }` : "";
955
- lines.push(`registerServerIsland(${JSON.stringify(id2)}, () => __ilhaSelf[${JSON.stringify(island.name)}]?.[Symbol.for("ilha.renderState")]${isServerPage});`);
956
- }
818
+ for (const island of scan.islands) lines.push(`registerServerIsland(${JSON.stringify(serverIslandPublicId(file, island.name))}, () => __ilhaSelf[${JSON.stringify(island.name)}]);`);
957
819
  }
958
820
  const base = rewriteServerActions(code, scan?.rpcActions ?? {});
959
821
  if (base === code && lines.length === 0) return null;
@@ -1001,7 +863,6 @@ const pagesFactory = (options = {}) => {
1001
863
  configureServer(server) {
1002
864
  server.watcher.add(state.pagesDir);
1003
865
  if (options.frameGuard) setFrameGuard(options.frameGuard);
1004
- if (options.loaderGuard) setLoaderGuard(options.loaderGuard);
1005
866
  setFrameAuth({
1006
867
  trustedOrigins: options.trustedOrigins,
1007
868
  csrf: options.csrf,
@@ -1019,7 +880,8 @@ const pagesFactory = (options = {}) => {
1019
880
  res.end();
1020
881
  return;
1021
882
  }
1022
- const frameUrl = `http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`;
883
+ const serverOrigin = devServerOrigin(server);
884
+ const frameUrl = `${serverOrigin}${req.url ?? "/"}`;
1023
885
  const authorized = await authorizeFrameRequest(new Request(frameUrl, {
1024
886
  method: req.method,
1025
887
  headers: new Headers(req.headers)
@@ -1061,19 +923,31 @@ const pagesFactory = (options = {}) => {
1061
923
  framePath = body.path;
1062
924
  }
1063
925
  await server.ssrLoadModule(VIRTUAL_PAGES_SERVER);
1064
- if (typeof (await server.ssrLoadModule(target.file))[target.name]?.[Symbol.for("ilha.renderState")] !== "function") throw new Error("unknown island");
926
+ await server.ssrLoadModule(target.file);
1065
927
  const headers = authorized.identityHeaders;
1066
- const requestOrigin = `http://${req.headers.host ?? "localhost"}`;
1067
- const request = new Request(new URL(framePath, requestOrigin), {
928
+ const request = new Request(frameScopedUrl(req.url ?? "/", framePath, serverOrigin), {
1068
929
  method: "POST",
1069
930
  headers
1070
931
  });
1071
- let head;
1072
- const html = await renderServerIsland(body.id ?? "", request, (r, fn) => runWithIslandRequest(r, fn), (entries) => head = entries, incomingProps);
1073
- const env = frameEnvelope(200, {
1074
- html: String(html),
1075
- head
1076
- });
932
+ const result = await renderServerIslandResult(body.id ?? "", request, (r, fn) => runWithIslandRequest(r, fn), incomingProps);
933
+ if (Result.isFailure(result)) {
934
+ const err = result.failure;
935
+ if (err.redirect) {
936
+ const env = frameEnvelope(err.status, { redirect: err.redirect });
937
+ res.statusCode = env.status;
938
+ for (const [k, v] of Object.entries(env.headers)) res.setHeader(k, v);
939
+ res.end(env.body);
940
+ return;
941
+ }
942
+ const status = err.status;
943
+ if (status >= 500) console.error("[ilha-router] frame render failed:", err);
944
+ const env = frameEnvelope(status, { error: "frame failed" });
945
+ res.statusCode = env.status;
946
+ for (const [k, v] of Object.entries(env.headers)) res.setHeader(k, v);
947
+ res.end(env.body);
948
+ return;
949
+ }
950
+ const env = frameEnvelope(200, { html: result.success });
1077
951
  res.statusCode = env.status;
1078
952
  for (const [k, v] of Object.entries(env.headers)) res.setHeader(k, v);
1079
953
  res.end(env.body);
package/dist/plugin.d.ts CHANGED
@@ -8,8 +8,6 @@ export declare const RESOLVED_LOADERS = "\0ilha:loaders";
8
8
  export declare const RESOLVED_VIRTUAL_IDS: readonly ["\0ilha:pages/server", "\0ilha:pages/client", "\0ilha:loaders"];
9
9
  /** Query suffix used on page/layout imports in the client file. */
10
10
  export declare const CLIENT_QUERY = "?client";
11
- /** Query suffix that re-exports a page/layout's `load` (loader.client) for the browser bundle. */
12
- export declare const CLIENT_LOADER_QUERY = "?client-loader";
13
11
  export interface IlhaPagesOptions {
14
12
  /** Directory containing page files. Default: `src/pages` */
15
13
  dir?: string;
@@ -36,12 +34,6 @@ export interface IlhaPagesOptions {
36
34
  * `setFrameGuard()` from `@ilha/router/ssr`.
37
35
  */
38
36
  frameGuard?: (request: Request) => Response | void | Promise<Response | void>;
39
- /**
40
- * Guard consulted only by `GET /__ilha/loader` in production. When absent,
41
- * the loader endpoint falls back to the frame guard for backwards
42
- * compatibility. Mirrors `setLoaderGuard()`.
43
- */
44
- loaderGuard?: (request: Request) => Response | void | Promise<Response | void>;
45
37
  /**
46
38
  * Explicit trusted origins for frame/loader requests (e.g. a `.vercel.app`
47
39
  * or custom domain). When unset, origin checks compare the `Origin` header
@@ -64,14 +56,12 @@ export declare function resolvePluginPaths(root: string, options: IlhaPagesOptio
64
56
  outDir: string;
65
57
  serverFile: string;
66
58
  clientFile: string;
67
- loadersFile: string;
68
59
  };
69
60
  export interface PagesPluginState {
70
61
  pagesDir: string;
71
62
  outDir: string;
72
63
  serverFile: string;
73
64
  clientFile: string;
74
- loadersFile: string;
75
65
  setPaths(root: string): void;
76
66
  regen(): Promise<void>;
77
67
  shouldRegenOnChange(file: string): boolean;
@@ -13,8 +13,9 @@
13
13
  * storage without importing `node:async_hooks`; this node-only module is the
14
14
  * sole place that constructs it.
15
15
  */
16
+ import { REQUEST_ALS_KEY } from "./als-key";
16
17
  import type { IslandContext } from "./index";
17
- export declare const REQUEST_ALS_KEY: unique symbol;
18
+ export { REQUEST_ALS_KEY };
18
19
  /** Run `fn` with `request` available to `useContext().request`. When oxidejs
19
20
  * is loaded, its action scope is entered too, so `useRequest()` works in
20
21
  * island renders and streamed frames. */
package/dist/rsbuild.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as ilhaPages } from "./plugin-BQil-7B_.js";
1
+ import { t as ilhaPages } from "./plugin-C3AAI3e_.js";
2
2
 
3
3
  //#region src/rsbuild.ts
4
4
  /** Rsbuild plugin — use via `@ilha/router/rsbuild`. */
@@ -24,9 +24,6 @@ export interface ServerIslandWiring {
24
24
  actions?: Record<string, (payload?: unknown) => unknown>;
25
25
  /** Frame transport: re-renders the island with the latest parent props. */
26
26
  frame?: (props?: Record<string, unknown>) => unknown;
27
- /** RPC transport for the module's `loader.client` export — invoked once
28
- * when the view hydrates. Side-effect loader on server pages. */
29
- clientLoader?: () => unknown;
30
27
  /** Client-capable islands nested in the server render, keyed by opaque ref. */
31
28
  children?: Record<string, unknown>;
32
29
  }
@@ -1,5 +1,5 @@
1
- import { n as applyHeadEntriesToDocument, t as parseSnapshotAttr } from "./snapshot-CsEaY6h_.js";
2
- import { morph } from "ilha";
1
+ import { t as applyHeadEntriesToDocument } from "./head-DpYV3bHH.js";
2
+ import { t as parseSnapshotAttr } from "./snapshot-C0E2OGwL.js";
3
3
 
4
4
  //#region src/server-island.ts
5
5
  /**
@@ -43,7 +43,8 @@ function assertValidTag(tag) {
43
43
  function belongsToHost(host, candidate) {
44
44
  let el = candidate.parentElement;
45
45
  while (el && el !== host) {
46
- if (el.hasAttribute("data-ilha") || el.hasAttribute("data-ilha-slot")) return false;
46
+ if (el.hasAttribute("data-ilha")) return false;
47
+ if (el.hasAttribute("data-ilha-slot") && el.style.display !== "contents") return false;
47
48
  el = el.parentElement;
48
49
  }
49
50
  return el === host;
@@ -106,7 +107,8 @@ function hydrateServerIsland(host, id, wiring, props) {
106
107
  if (controller.signal.aborted || !host.isConnected) return;
107
108
  const html = await frame(sent);
108
109
  if (controller.signal.aborted || typeof html !== "string") return;
109
- morph(host, html);
110
+ const parsed = new DOMParser().parseFromString(`<div>${html}</div>`, "text/html");
111
+ host.replaceChildren(...Array.from(parsed.body.firstElementChild?.childNodes ?? []));
110
112
  syncChildren();
111
113
  wireEvents();
112
114
  }).catch((err) => {
@@ -115,7 +117,7 @@ function hydrateServerIsland(host, id, wiring, props) {
115
117
  };
116
118
  const attached = [];
117
119
  const readManifest = () => {
118
- const raw = Array.from(host.children).find((c) => c.matches(`template[${ACTIONS_ATTR}]`))?.getAttribute(ACTIONS_ATTR) ?? host.getAttribute(ACTIONS_ATTR) ?? null;
120
+ const raw = host.querySelector(`:scope > template[${ACTIONS_ATTR}], template[${ACTIONS_ATTR}]`)?.getAttribute(ACTIONS_ATTR) ?? host.getAttribute(ACTIONS_ATTR) ?? null;
119
121
  return raw ? parseSnapshotAttr(raw) : void 0;
120
122
  };
121
123
  const wireEvents = () => {
@@ -215,9 +217,6 @@ function hydrateServerIsland(host, id, wiring, props) {
215
217
  }
216
218
  })();
217
219
  if (frame && !host.hasAttribute(STATE_ATTR) && host.childNodes.length === 0) scheduleRepaint();
218
- if (wiring.clientLoader) Promise.resolve(wiring.clientLoader()).catch((err) => {
219
- if (!controller.signal.aborted) console.error(`[ilha-router] client loader failed for "${id}":`, err);
220
- });
221
220
  return {
222
221
  unmount: () => {
223
222
  for (const { el, type, listener } of attached) el.removeEventListener(type, listener);
@@ -42,9 +42,6 @@ export interface ServerModuleScan {
42
42
  rpcActions: Record<string, string>;
43
43
  /** Imported JSX components that must hydrate inside the server island. */
44
44
  clientRefs: ClientIslandRef[];
45
- /** True when the module declares `export const load = loader.client(…)` —
46
- * the proxy wires it as an RPC call invoked when the view hydrates. */
47
- clientLoader?: boolean;
48
45
  }
49
46
  export declare function clientRefPublicId(spec: string, imported: string): string;
50
47
  /** Scan a `*.server.ts(x)` module source for island exports and their
@@ -0,0 +1,55 @@
1
+ //#region src/snapshot.ts
2
+ /**
3
+ * Defensive parser for `data-ilha-state` / `data-ilha-props` snapshot
4
+ * attributes on the router side. Mirrors the core ilha guards: size cap,
5
+ * plain-object check, depth cap, and prototype-key stripping. Returns
6
+ * undefined (degrade gracefully) on any failure.
7
+ */
8
+ const MAX_SNAPSHOT_CHARS = 262144;
9
+ const MAX_SNAPSHOT_DEPTH = 32;
10
+ const UNSAFE_SNAPSHOT_KEYS = /* @__PURE__ */ new Set([
11
+ "__proto__",
12
+ "constructor",
13
+ "prototype"
14
+ ]);
15
+ function stripUnsafeKeys(value) {
16
+ if (value === null || typeof value !== "object") return;
17
+ if (Array.isArray(value)) {
18
+ for (const item of value) stripUnsafeKeys(item);
19
+ return;
20
+ }
21
+ for (const key of Object.getOwnPropertyNames(value)) if (UNSAFE_SNAPSHOT_KEYS.has(key)) delete value[key];
22
+ else stripUnsafeKeys(value[key]);
23
+ }
24
+ function exceedsDepth(value, depth) {
25
+ if (depth > MAX_SNAPSHOT_DEPTH) return true;
26
+ if (value === null || typeof value !== "object") return false;
27
+ if (Array.isArray(value)) {
28
+ for (const item of value) if (exceedsDepth(item, depth + 1)) return true;
29
+ return false;
30
+ }
31
+ for (const key in value) {
32
+ if (!Object.hasOwn(value, key)) continue;
33
+ if (exceedsDepth(value[key], depth + 1)) return true;
34
+ }
35
+ return false;
36
+ }
37
+ function sanitizeSnapshotObject(value) {
38
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return void 0;
39
+ if (exceedsDepth(value, 1)) return void 0;
40
+ stripUnsafeKeys(value);
41
+ return value;
42
+ }
43
+ function parseSnapshotAttr(raw) {
44
+ if (raw.length > MAX_SNAPSHOT_CHARS) return void 0;
45
+ let parsed;
46
+ try {
47
+ parsed = JSON.parse(raw);
48
+ } catch {
49
+ return;
50
+ }
51
+ return sanitizeSnapshotObject(parsed);
52
+ }
53
+
54
+ //#endregion
55
+ export { sanitizeSnapshotObject as n, parseSnapshotAttr as t };