@nifrajs/web 2.0.0 → 2.2.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.
Files changed (102) hide show
  1. package/dist/build-vite.d.ts +68 -0
  2. package/dist/build-vite.d.ts.map +1 -0
  3. package/dist/build-vite.js +487 -0
  4. package/dist/build-vite.js.map +1 -0
  5. package/dist/build.d.ts +112 -25
  6. package/dist/build.d.ts.map +1 -1
  7. package/dist/build.js +283 -64
  8. package/dist/build.js.map +1 -1
  9. package/dist/bun-dev-entry.d.ts +83 -0
  10. package/dist/bun-dev-entry.d.ts.map +1 -0
  11. package/dist/bun-dev-entry.js +136 -0
  12. package/dist/bun-dev-entry.js.map +1 -0
  13. package/dist/client.d.ts.map +1 -1
  14. package/dist/client.js +10 -0
  15. package/dist/client.js.map +1 -1
  16. package/dist/deferred.d.ts +3 -2
  17. package/dist/deferred.d.ts.map +1 -1
  18. package/dist/deferred.js +11 -8
  19. package/dist/deferred.js.map +1 -1
  20. package/dist/dev-port.d.ts +36 -0
  21. package/dist/dev-port.d.ts.map +1 -0
  22. package/dist/dev-port.js +48 -0
  23. package/dist/dev-port.js.map +1 -0
  24. package/dist/dev.d.ts +61 -4
  25. package/dist/dev.d.ts.map +1 -1
  26. package/dist/dev.js +419 -60
  27. package/dist/dev.js.map +1 -1
  28. package/dist/draft.d.ts +37 -0
  29. package/dist/draft.d.ts.map +1 -1
  30. package/dist/draft.js +87 -4
  31. package/dist/draft.js.map +1 -1
  32. package/dist/fs.d.ts +14 -1
  33. package/dist/fs.d.ts.map +1 -1
  34. package/dist/fs.js +7 -1
  35. package/dist/fs.js.map +1 -1
  36. package/dist/index.d.ts +116 -5
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +565 -53
  39. package/dist/index.js.map +1 -1
  40. package/dist/internal/timing-safe-equal.d.ts +16 -0
  41. package/dist/internal/timing-safe-equal.d.ts.map +1 -0
  42. package/dist/internal/timing-safe-equal.js +26 -0
  43. package/dist/internal/timing-safe-equal.js.map +1 -0
  44. package/dist/isr.d.ts.map +1 -1
  45. package/dist/isr.js +1 -14
  46. package/dist/isr.js.map +1 -1
  47. package/dist/llms-txt.d.ts.map +1 -1
  48. package/dist/llms-txt.js +12 -6
  49. package/dist/llms-txt.js.map +1 -1
  50. package/dist/manifest.d.ts +51 -8
  51. package/dist/manifest.d.ts.map +1 -1
  52. package/dist/manifest.js +130 -14
  53. package/dist/manifest.js.map +1 -1
  54. package/dist/module-graph.d.ts +91 -0
  55. package/dist/module-graph.d.ts.map +1 -0
  56. package/dist/module-graph.js +92 -0
  57. package/dist/module-graph.js.map +1 -0
  58. package/dist/plugins/css-modules.d.ts.map +1 -1
  59. package/dist/plugins/css-modules.js +0 -0
  60. package/dist/plugins/css-modules.js.map +1 -1
  61. package/dist/plugins/vite-leak-guard.d.ts +45 -0
  62. package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
  63. package/dist/plugins/vite-leak-guard.js +67 -0
  64. package/dist/plugins/vite-leak-guard.js.map +1 -0
  65. package/dist/public-dir.d.ts +36 -0
  66. package/dist/public-dir.d.ts.map +1 -0
  67. package/dist/public-dir.js +89 -0
  68. package/dist/public-dir.js.map +1 -0
  69. package/dist/route-manifest.d.ts +91 -0
  70. package/dist/route-manifest.d.ts.map +1 -0
  71. package/dist/route-manifest.js +128 -0
  72. package/dist/route-manifest.js.map +1 -0
  73. package/dist/router.d.ts +64 -1
  74. package/dist/router.d.ts.map +1 -1
  75. package/dist/router.js +114 -31
  76. package/dist/router.js.map +1 -1
  77. package/dist/vite.d.ts +10 -4
  78. package/dist/vite.d.ts.map +1 -1
  79. package/dist/vite.js +44 -12
  80. package/dist/vite.js.map +1 -1
  81. package/package.json +17 -2
  82. package/src/build-vite.ts +606 -0
  83. package/src/build.ts +432 -73
  84. package/src/bun-dev-entry.ts +184 -0
  85. package/src/client.ts +8 -0
  86. package/src/deferred.ts +13 -6
  87. package/src/dev-port.ts +63 -0
  88. package/src/dev.ts +467 -65
  89. package/src/draft.ts +116 -4
  90. package/src/fs.ts +22 -4
  91. package/src/index.ts +727 -57
  92. package/src/internal/timing-safe-equal.ts +24 -0
  93. package/src/isr.ts +1 -13
  94. package/src/llms-txt.ts +13 -5
  95. package/src/manifest.ts +188 -13
  96. package/src/module-graph.ts +146 -0
  97. package/src/plugins/css-modules.ts +0 -0
  98. package/src/plugins/vite-leak-guard.ts +110 -0
  99. package/src/public-dir.ts +108 -0
  100. package/src/route-manifest.ts +240 -0
  101. package/src/router.ts +198 -34
  102. package/src/vite.ts +62 -20
package/dist/index.js CHANGED
@@ -13,9 +13,9 @@ import { DEFERRED_ERROR_CODE, DEFERRED_RUNTIME, defer, MAP_DEFERRED_SOURCE, ndjs
13
13
  import { isDraftEnabled } from "./draft.js";
14
14
  import { ISR_REVALIDATE_HEADER } from "./isr.js";
15
15
  import { generateLlmsTxt } from "./llms-txt.js";
16
- import { DATA_HEADER, PRERENDERED_GLOBAL, REDIRECT_HEADER, REVALIDATE_HEADER, } from "./router.js";
16
+ import { createMatcher, DATA_HEADER, NAV_FROM_HEADER, PRERENDERED_GLOBAL, REDIRECT_HEADER, RETAIN_HEADER, REVALIDATE_HEADER, STATUS_HEADER, } from "./router.js";
17
17
  // Draft / preview mode — a signed cookie that flips `ctx.draft` for loaders + bypasses ISR for editors.
18
- export { DRAFT_COOKIE, disableDraft, enableDraft, isDraftEnabled, } from "./draft.js";
18
+ export { DRAFT_COOKIE, disableDraft, enableDraft, isDraftEnabled, previewEndpoint, } from "./draft.js";
19
19
  // Font optimization — a CLS-safe `@font-face` generator + a preload `<link>` for self-hosted fonts.
20
20
  export { fontFace, fontPreload, } from "./fonts.js";
21
21
  // ISR (incremental static regeneration): a pluggable cache store + the `withISR` stale-while-revalidate
@@ -26,17 +26,22 @@ export { buildManifest, enumerateStaticRoutes, filePathToPattern, filePathToPatt
26
26
  // Navigation bridge — a DOM-free seam the browser layer (`installHistory`) populates so an adapter's
27
27
  // `useNavigate` (a route component, importing only this agnostic entry) reaches history-aware nav.
28
28
  export { getBrowserNavigate, setBrowserNavigate, } from "./navigation.js";
29
+ // public/ — user-authored static files, served identically in dev and production by one handler.
30
+ export { resolvePublicPath, servePublicDir, } from "./public-dir.js";
29
31
  // Keyed query-cache (agnostic) — a `query(key, fn)` primitive (dedup + staleness + invalidation + GC)
30
32
  // consumed by the per-adapter `useQuery`/`createQuery` bindings.
31
33
  export { createMutation, createQueryClient, hashQueryKey, } from "./query.js";
32
34
  // Agnostic client-side router core (pure + DOM-free) — consumed by per-adapter Router bindings.
33
35
  // `DATA_HEADER` marks a navigation's data-only GET; `createWebApp` answers it with loader JSON.
34
- export { createClientRouter, createMatcher, DATA_HEADER, REDIRECT_HEADER, REVALIDATE_HEADER, } from "./router.js";
36
+ export { createClientRouter, createMatcher, DATA_HEADER, NAV_FROM_HEADER, REDIRECT_HEADER, RETAIN_HEADER, REVALIDATE_HEADER, STATUS_HEADER, } from "./router.js";
35
37
  // Deferred loader data (`defer()` + the `Deferred<T>` type) — consumed by the adapter's `<Await>`.
36
38
  export { defer };
37
39
  export { assertRenderAdapterConformance, RenderAdapterConformanceError, } from "./conformance.js";
38
40
  /** Global the server serializes loader data into; the client reads it to hydrate. */
39
41
  export const DATA_GLOBAL = "__NIFRA_DATA__";
42
+ /** Per-layout loader data for hydration. Emitted ONLY when some layout in the chain has a loader, so
43
+ * a page-only app's HTML is byte-identical to before layout loaders existed. */
44
+ export const LAYOUT_DATA_GLOBAL = "__NIFRA_LAYOUT_DATA__";
40
45
  /** Global the server writes the matched route id into; the client uses it to pick the chain. */
41
46
  export const ROUTE_GLOBAL = "__NIFRA_ROUTE__";
42
47
  /** Global the server serializes an action's data return into (absent on GETs); the client
@@ -97,7 +102,7 @@ export function renderPage(options) {
97
102
  return page instanceof Promise ? page.then((p) => p.toResponse()) : page.toResponse();
98
103
  }
99
104
  export function renderPageResult(options) {
100
- const { adapter, chain, data, actionData, clientEntry, preload = [], styles = [], prerenderedPaths = [], revalidate, routeId, status = 200, title = "nifra", head, rootId = "root", hydrate = true, islandScripts = [], } = options;
105
+ const { adapter, chain, data, actionData, clientEntry, preload = [], styles = [], prerenderedPaths = [], revalidate, routeId, status = 200, title = "nifra", head, rootId = "root", hydrate = true, islandScripts = [], headers: extraHeaders, } = options;
101
106
  const route = routeId === undefined ? "" : `window.${ROUTE_GLOBAL}=${serializeData(routeId)};`;
102
107
  // The SSG prerendered-path set (when an app declares it) — the client reads it to fetch a static
103
108
  // `_data.json` on soft-nav into a prerendered route instead of hitting the worker. Empty ⇒ omitted.
@@ -109,8 +114,26 @@ export function renderPageResult(options) {
109
114
  // also `defer()` — split it too, continuing the id space so a single registry settles both. The
110
115
  // inline registry runtime is emitted only when something defers, so non-deferred output is unchanged.
111
116
  const { forComponent, forClient, deferred } = prepareDeferred(data);
112
- const actionSplit = actionData === undefined ? undefined : prepareDeferred(actionData, deferred.length);
113
- const allDeferred = actionSplit ? [...deferred, ...actionSplit.deferred] : deferred;
117
+ // Each layout's data is split in turn, each continuing the id space, so a `defer()` in a layout
118
+ // settles through the SAME registry as the page's rather than colliding on ids.
119
+ const layoutSplits = [];
120
+ if (options.layoutData !== undefined) {
121
+ let offset = deferred.length;
122
+ for (const entry of options.layoutData) {
123
+ const split = prepareDeferred(entry, offset);
124
+ offset += split.deferred.length;
125
+ layoutSplits.push(split);
126
+ }
127
+ }
128
+ const layoutDeferred = layoutSplits.flatMap((split) => split.deferred);
129
+ const actionSplit = actionData === undefined
130
+ ? undefined
131
+ : prepareDeferred(actionData, deferred.length + layoutDeferred.length);
132
+ const allDeferred = [...deferred, ...layoutDeferred, ...(actionSplit ? actionSplit.deferred : [])];
133
+ // Omitted entirely when no layout has a loader — a page-only app emits exactly what it did before.
134
+ const layoutTail = options.layoutData === undefined
135
+ ? ""
136
+ : `window.${LAYOUT_DATA_GLOBAL}=${serializeData(layoutSplits.map((split) => split.forClient))};`;
114
137
  // Only emit the action global when an action actually ran, so plain GET output is unchanged.
115
138
  const action = actionSplit === undefined
116
139
  ? ""
@@ -153,7 +176,13 @@ export function renderPageResult(options) {
153
176
  // Runs in the first-paint→hydration window to swallow a JS-only form's broken native submit. Only on a
154
177
  // hydrating page (a static/_error page has no client handlers, so no footgun).
155
178
  const hydrationGuard = hydrate ? `<script>${PRE_HYDRATION_GUARD}</script>` : "";
156
- const shellHtml = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${hydrationGuard}<title>${escapeHtml(head?.title ?? title)}</title>${headTags(head)}${styleLinks}${hydrationLinks}${islandPreloads}${adapter.hydrationHead()}</head><body><div id="${escapeAttr(rootId)}">`;
179
+ // `<html>` attributes. `lang` defaults to `"en"` (so an app that never sets it is byte-identical to
180
+ // before); `dir` is omitted entirely when unset, which IS HTML's `ltr` default — emitting it
181
+ // unconditionally would change every existing app's output for no gain. Both attribute-escaped.
182
+ // `client.ts`'s `applyHead` mirrors this exact defaulting on soft-nav, so a hard load and a client
183
+ // navigation to the same URL produce the same `<html>` (no drift on a multilingual site).
184
+ const htmlAttrs = ` lang="${escapeAttr(head?.lang ?? "en")}"${head?.dir === undefined ? "" : ` dir="${escapeAttr(head.dir)}"`}`;
185
+ const shellHtml = `<!doctype html><html${htmlAttrs}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${hydrationGuard}<title>${escapeHtml(head?.title ?? title)}</title>${headTags(head)}${styleLinks}${hydrationLinks}${islandPreloads}${adapter.hydrationHead()}</head><body><div id="${escapeAttr(rootId)}">`;
157
186
  // Closes the hydration container; deferred resolve scripts go AFTER it (outside `#root`) so they
158
187
  // aren't part of the adapter's hydrated tree (an inline script inside it breaks hydration).
159
188
  const closeRootHtml = "</div>";
@@ -165,9 +194,18 @@ export function renderPageResult(options) {
165
194
  for (const src of islandScripts)
166
195
  islandTags += `<script type="module" src="${escapeAttr(src)}"></script>`;
167
196
  const tailHtml = `${hydrate
168
- ? `<script>${route}${action}${prerendered}window.${DATA_GLOBAL}=${serializeData(forClient)}</script><script type="module" src="${escapeAttr(clientEntry)}"></script>`
197
+ ? `<script>${route}${action}${prerendered}${layoutTail}window.${DATA_GLOBAL}=${serializeData(forClient)}</script><script type="module" src="${escapeAttr(clientEntry)}"></script>`
169
198
  : ""}${islandTags}</body></html>`;
170
199
  const headers = { "content-type": "text/html; charset=utf-8" };
200
+ // Caller-supplied headers (a terminal status page's `cache-control`, say). Applied before the
201
+ // framework's own below, so `content-type` and the ISR channel stay authoritative - a caller must
202
+ // not be able to mislabel the document or forge the freshness header by passing them here.
203
+ if (extraHeaders !== undefined) {
204
+ for (const [name, value] of new Headers(extraHeaders)) {
205
+ if (name !== "content-type")
206
+ headers[name] = value;
207
+ }
208
+ }
171
209
  // ISR: advertise the route's freshness so a `withISR` wrapper can set this page's cache TTL. A
172
210
  // dedicated header (not the action-revalidation `x-nifra-revalidate`) so the TTL channel never aliases
173
211
  // the client's path-list channel.
@@ -181,6 +219,10 @@ export function renderPageResult(options) {
181
219
  // router bindings simply never reads them.
182
220
  ...(options.params !== undefined ? { params: options.params } : {}),
183
221
  ...(options.path !== undefined ? { path: options.path } : {}),
222
+ // Spread only when present, so a page-only render produces exactly the props it did before.
223
+ ...(options.layoutData !== undefined
224
+ ? { layoutData: layoutSplits.map((split) => split.forComponent) }
225
+ : {}),
184
226
  };
185
227
  // Fast path: nothing `defer()`s and the adapter can render synchronously to a string → buffer the
186
228
  // whole document in one pass. Progressive streaming only benefits pages with deferred boundaries
@@ -322,6 +364,143 @@ export function redirect(location, options = {}) {
322
364
  }
323
365
  return new Response(null, { status: options.status ?? 303, headers: { location } });
324
366
  }
367
+ /**
368
+ * The signatures a **duplicate module instance** produces during SSR, across engines.
369
+ *
370
+ * Two copies of React (or of `@nifrajs/core`) at the SAME version still fail, because module identity
371
+ * is path-based: hooks read a dispatcher off the copy that rendered, and the component imported the
372
+ * other one. The error that surfaces names a React internal, so it reads as a React bug and the actual
373
+ * cause - two directories - is a long inference away. Naming the cause at the point of failure is the
374
+ * difference between that and a five-second read.
375
+ */
376
+ const DUPLICATE_INSTANCE_SIGNATURES = [
377
+ /resolveDispatcher\(\)/,
378
+ /Invalid hook call/,
379
+ /Cannot read propert(?:y|ies) of null \(reading '(?:use[A-Z]\w*)'\)/,
380
+ /null is not an object \(evaluating '.*\.use[A-Z]\w*'\)/,
381
+ ];
382
+ /**
383
+ * Append the likely cause to an SSR error that carries a duplicate-instance signature.
384
+ *
385
+ * Deliberately does NOT claim the resolved paths: `@nifrajs/web` is framework-agnostic and does not
386
+ * depend on React, so it cannot resolve the copies without guessing. It names the condition and the
387
+ * command that reports the paths, which is the actionable part. Returns the error untouched when the
388
+ * signature does not match, so an ordinary render error reads exactly as before.
389
+ */
390
+ function withDuplicateInstanceHint(err) {
391
+ if (!(err instanceof Error))
392
+ return err;
393
+ if (!DUPLICATE_INSTANCE_SIGNATURES.some((rx) => rx.test(err.message)))
394
+ return err;
395
+ const augmented = new Error(`${err.message}\n\n[nifra/web] This signature usually means TWO COPIES of an identity-sensitive package (react, react-dom, or @nifrajs/core) are installed at different paths. Module identity is path-based, so matching versions do NOT fix it - the copies must resolve to the same directory. Run \`nifra check\` to list the paths.`, { cause: err });
396
+ augmented.name = err.name;
397
+ // `stack` is optional under exactOptionalPropertyTypes; assigning `undefined` would replace a real
398
+ // stack with nothing on engines that always populate it.
399
+ if (err.stack !== undefined)
400
+ augmented.stack = err.stack;
401
+ return augmented;
402
+ }
403
+ /**
404
+ * Narrow the route's params to the ones a layout owns.
405
+ *
406
+ * A layout wraps a URL prefix, so anything deeper belongs to a route beneath it. Handing it the full
407
+ * set would let a layout read a param it does not own - which reads fine until the layout is reused
408
+ * under a route that has no such param and the value silently becomes `undefined`.
409
+ */
410
+ function scopeParams(params, owned) {
411
+ if (owned === undefined)
412
+ return params; // hand-built manifest with no scope info: unchanged behaviour
413
+ if (owned.length === 0)
414
+ return EMPTY_LAYOUT_PARAMS;
415
+ const scoped = {};
416
+ for (const name of owned) {
417
+ const value = params[name];
418
+ if (value !== undefined)
419
+ scoped[name] = value;
420
+ }
421
+ return scoped;
422
+ }
423
+ const EMPTY_LAYOUT_PARAMS = Object.freeze({});
424
+ const EMPTY_RETAIN = new Set();
425
+ /**
426
+ * Marks an error as coming from a LAYOUT loader, carrying that layout's id.
427
+ *
428
+ * The boundary that catches it must be at or above the failing layout's own segment. The route's
429
+ * innermost `_error` may live BELOW the layout that failed, and rendering there would wrap the
430
+ * boundary in a layout whose data never arrived - so the page would render inside a component that
431
+ * threw. A registry symbol, matching the other cross-module brands here.
432
+ */
433
+ const LAYOUT_ERROR_ID = Symbol.for("nifra.web.layout-error-id");
434
+ const tagLayoutError = (err, layoutId) => {
435
+ if (typeof err === "object" && err !== null && !(LAYOUT_ERROR_ID in err)) {
436
+ Object.defineProperty(err, LAYOUT_ERROR_ID, { value: layoutId, enumerable: false });
437
+ }
438
+ return err;
439
+ };
440
+ const layoutErrorId = (err) => typeof err === "object" && err !== null
441
+ ? err[LAYOUT_ERROR_ID]
442
+ : undefined;
443
+ const STATUS_SIGNAL = Symbol.for("nifra.web.status-signal");
444
+ /** Reason phrases for the plain-text fallback, used only when the app authored no `_<status>` and no
445
+ * `_404` page. Deliberately not exhaustive - anything unlisted falls back to "Error", which is more
446
+ * useful than shipping a table of every RFC status for a body almost nobody will see. */
447
+ const STATUS_TEXT = {
448
+ 400: "Bad Request",
449
+ 401: "Unauthorized",
450
+ 402: "Payment Required",
451
+ 403: "Forbidden",
452
+ 404: "Not Found",
453
+ 410: "Gone",
454
+ 451: "Unavailable For Legal Reasons",
455
+ 500: "Internal Server Error",
456
+ };
457
+ function isStatusSignal(value) {
458
+ return value instanceof Response && STATUS_SIGNAL in value;
459
+ }
460
+ function statusSignal(status, options) {
461
+ if (!Number.isInteger(status) || status < 400 || status > 599) {
462
+ throw new Error(`[nifra/web] statusPage(${JSON.stringify(status)}) must be an integer 4xx or 5xx status. Use redirect() for 3xx, and return data for a successful render.`);
463
+ }
464
+ const response = new Response(null, { status });
465
+ // Non-enumerable so the brand never lands in a structured clone or a JSON log of the response.
466
+ Object.defineProperty(response, STATUS_SIGNAL, {
467
+ value: { status, ...(options.headers !== undefined ? { headers: options.headers } : {}) },
468
+ enumerable: false,
469
+ });
470
+ throw response;
471
+ }
472
+ /**
473
+ * Render the nearest `_404` page at status **404**. `throw` it from a loader when the record does not
474
+ * exist.
475
+ *
476
+ * This is the fix for the soft 404: a matched route whose loader finds nothing has otherwise no way to
477
+ * answer 404, so the path of least resistance is to return empty data and render "not found" inside a
478
+ * **200**. That looks correct in a browser and is invisible in review, which is why it ships and stays
479
+ * shipped - while search engines penalise it and keep the dead URL indexed.
480
+ *
481
+ * Returns `never`: these throw, so a loader narrows without a redundant `return`, and the type states
482
+ * the thing the "loaders `throw` redirect, actions `return` it" rule already trips people on.
483
+ */
484
+ export function notFound(options = {}) {
485
+ return statusSignal(404, options);
486
+ }
487
+ /**
488
+ * Render a terminal page at status **410 Gone**. `throw` it from a loader for a record that existed and
489
+ * was deliberately removed - a withdrawn listing, a deleted post.
490
+ *
491
+ * 410 is not a pedantic 404: it tells a crawler to **drop** the URL rather than re-fetch it for weeks
492
+ * on the assumption the 404 was transient. Uses `_410.tsx` if the app has one, otherwise `_404`.
493
+ */
494
+ export function gone(options = {}) {
495
+ return statusSignal(410, options);
496
+ }
497
+ /**
498
+ * Render a terminal page at any 4xx/5xx status - the escape hatch behind {@link notFound} and
499
+ * {@link gone} (402, 451, …). Uses `_<status>.tsx` if present, otherwise `_404`.
500
+ */
501
+ export function statusPage(status, options = {}) {
502
+ return statusSignal(status, options);
503
+ }
325
504
  /**
326
505
  * Return this from an action to declare which routes the mutation changed (alongside the action's
327
506
  * `data`). `createWebApp` sets the `X-Nifra-Revalidate` response header; after the submit the client
@@ -508,12 +687,18 @@ export function mergeHeads(heads) {
508
687
  if (heads.length === 1)
509
688
  return heads[0];
510
689
  let title;
690
+ let lang;
691
+ let dir;
511
692
  const meta = [];
512
693
  const link = [];
513
694
  const script = [];
514
695
  for (const h of heads) {
515
696
  if (h.title !== undefined)
516
697
  title = h.title; // nearest-wins: later (more specific) overrides
698
+ if (h.lang !== undefined)
699
+ lang = h.lang; // nearest-wins, like title
700
+ if (h.dir !== undefined)
701
+ dir = h.dir;
517
702
  if (h.meta !== undefined)
518
703
  meta.push(...h.meta);
519
704
  if (h.link !== undefined)
@@ -534,8 +719,32 @@ export function mergeHeads(heads) {
534
719
  merged.link = link;
535
720
  if (script.length > 0)
536
721
  merged.script = script;
722
+ if (lang !== undefined)
723
+ merged.lang = lang;
724
+ if (dir !== undefined)
725
+ merged.dir = dir;
537
726
  return merged;
538
727
  }
728
+ /**
729
+ * Re-issue `request` with `prefix` removed from its pathname.
730
+ *
731
+ * Needed because two conventions exist and both are reasonable. `createWebApp`'s auto-mount assumes a
732
+ * backend that declares FULL paths (`server().post("/api/sync")`), which is right when the backend is
733
+ * only ever mounted here. A backend that also runs standalone declares paths WITHOUT the prefix and
734
+ * lets its own shell supply it - and mounting one of those here used to require the caller to wrap it
735
+ * in a `Proxy` that rewrote every URL. Two apps wrote that same wrapper independently, which is the
736
+ * signal that it belonged in the framework.
737
+ *
738
+ * The `Request` is rebuilt rather than mutated (`url` is read-only), preserving method, headers, body,
739
+ * and duplex streaming - the body is passed through unread, so a large or streamed upload is untouched.
740
+ */
741
+ function stripMountPrefix(request, prefix) {
742
+ const url = new URL(request.url);
743
+ const rest = url.pathname.slice(prefix.length);
744
+ url.pathname = rest === "" ? "/" : rest;
745
+ // `url.href`, not the `URL`: this type set only declares the string and Request overloads.
746
+ return new Request(url.href, request);
747
+ }
539
748
  /**
540
749
  * Resolve the explicit symbol-keyed backend mount interface. The symbol seam forwards platform
541
750
  * context without making web depend on client.
@@ -597,14 +806,26 @@ export function createWebApp(options) {
597
806
  // sites. `apiPrefix: ""` opts out; a non-mountable `api` (no symbol mount) is left pages-only. The hook
598
807
  // is registered ONLY when both hold, so a pages-only app keeps core's synchronous no-hook fast path.
599
808
  const apiPrefix = options.apiPrefix ?? "/api";
809
+ const apiStrip = options.apiStrip === true;
600
810
  const mountedApi = backendMountOf(api);
601
- if (apiPrefix !== "" && mountedApi !== undefined) {
811
+ // Longest path first, so a more specific mount (`/api/auth`) is tried before a broader one (`/api`)
812
+ // regardless of the order they were declared in.
813
+ const mounts = [...(options.mounts ?? [])].sort((a, b) => b.path.length - a.path.length);
814
+ const underPrefix = (pathname, prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`);
815
+ if (mounts.length > 0 || (apiPrefix !== "" && mountedApi !== undefined)) {
602
816
  app.onRequest((req, platform) => {
603
817
  const { pathname } = new URL(req.url);
818
+ // Sub-app mounts win over the backend prefix: they are the more specific declaration, and an
819
+ // auth handler mounted at `/api/auth` must not be swallowed by the backend mounted at `/api`.
820
+ for (const mount of mounts) {
821
+ if (!underPrefix(pathname, mount.path))
822
+ continue;
823
+ return mount.app.fetch(mount.stripPrefix === true ? stripMountPrefix(req, mount.path) : req);
824
+ }
604
825
  // Exactly the prefix (`/api`) or a sub-path (`/api/…`) — NOT a sibling like `/apixyz` that merely
605
826
  // shares the prefix as a string head. Dispatch the original `req` (body intact) to the backend.
606
- if (pathname === apiPrefix || pathname.startsWith(`${apiPrefix}/`)) {
607
- return mountedApi(req, platform);
827
+ if (mountedApi !== undefined && apiPrefix !== "" && underPrefix(pathname, apiPrefix)) {
828
+ return mountedApi(apiStrip ? stripMountPrefix(req, apiPrefix) : req, platform);
608
829
  }
609
830
  return undefined; // not an API path → continue to page routing
610
831
  });
@@ -613,12 +834,179 @@ export function createWebApp(options) {
613
834
  // path under a `"404"` route is rejected (it isn't a static file, and the route declared it shouldn't
614
835
  // SSR on demand). O(1) membership via a Set.
615
836
  const prerenderedSet = new Set(options.prerenderedPaths ?? []);
837
+ const matchManifestRoute = createMatcher(manifest.routes.map((route) => ({ routeId: route.id, pattern: route.pattern })));
838
+ const routeById = new Map(manifest.routes.map((route) => [route.id, route]));
616
839
  // Load a route's layout modules (outermost layout → innermost), keeping each module whole so the
617
840
  // render path can take both its `default` (the component chain) AND its `meta` (the sitewide head it
618
841
  // contributes). Loaded lazily — the data-only and 405 branches return before any layout is needed.
619
- const loadLayoutModules = (route) => Promise.all(
620
- // layoutIds only reference layouts present in the manifest (buildManifest invariant).
621
- route.layoutIds.map((id) => manifest.layouts[id].load()));
842
+ const loadLayoutModules = async (route) => {
843
+ const modules = await Promise.all(
844
+ // layoutIds only reference layouts present in the manifest (buildManifest invariant).
845
+ route.layoutIds.map((id) => manifest.layouts[id].load()));
846
+ // A layout is not a form target, so an `action` there is a mistake with no meaning to give it.
847
+ // A `loader`, by contrast, now runs (see `runLayoutChain`).
848
+ for (let i = 0; i < modules.length; i++) {
849
+ if (modules[i].action === undefined)
850
+ continue;
851
+ const id = route.layoutIds[i];
852
+ throw new Error(`[nifra/web] "${manifest.layouts[id].file}" exports an \`action\`, but layouts do not run one — only route files do. Move the mutation into the route that submits to it.`);
853
+ }
854
+ return modules;
855
+ };
856
+ /**
857
+ * Run a route's layout-chain loaders, honouring declared gates.
858
+ *
859
+ * Returns once every **gate** has resolved, with the non-gate loaders still in flight, so the caller
860
+ * can start the page loader at exactly the right moment: after anything that might reject the
861
+ * request, alongside anything that merely fetches.
862
+ *
863
+ * The ordering is the security-relevant part. Parallel loaders are NOT an authorization boundary -
864
+ * a page loader running concurrently with a guard has already queried by the time the guard says no.
865
+ * Remix documents that as a footgun; here it is a declaration instead. `export const gate = true`
866
+ * makes a layout blocking for everything beneath it, and nothing beneath a rejected gate runs.
867
+ *
868
+ * Each layout receives only the params it OWNS ({@link RouteEntry.layoutParams}), not the route's
869
+ * full set, so a layout at `orgs/[org]/` cannot read a param belonging to a route below it.
870
+ */
871
+ /**
872
+ * Layout indices a client navigation asked to keep, from {@link RETAIN_HEADER}.
873
+ *
874
+ * The CLIENT decides retainability: it knows each layout's scoped params (shipped in the bootstrap)
875
+ * and both the old and new match, so it can tell whether a layout's own prefix changed - without the
876
+ * server re-matching a path the client already matched.
877
+ *
878
+ * It is a HINT, not an instruction. The client is asking to keep data it already holds, which is
879
+ * harmless for data and unacceptable for a guard, so `runLayoutChain` refuses to skip a gate no
880
+ * matter what arrives here. Anything unparseable is ignored rather than rejected: the worst case of
881
+ * a bad hint is a loader that runs when it need not.
882
+ */
883
+ const retainedIndices = (header) => {
884
+ if (header === null || header === "")
885
+ return EMPTY_RETAIN;
886
+ const out = new Set();
887
+ for (const part of header.split(",")) {
888
+ // Digits only. `Number("")` is 0 and `Number(" ")` is 0, so a stray comma would otherwise read
889
+ // as "retain index 0" and silently skip the outermost layout.
890
+ if (!/^\d+$/.test(part))
891
+ continue;
892
+ out.add(Number(part));
893
+ }
894
+ return out;
895
+ };
896
+ /**
897
+ * Validate a data-navigation's retain request against the manifest and its declared source path.
898
+ * A client may ask to retain every slot it currently has; the server only honours indices whose
899
+ * layout identity and owned params are unchanged. Hard document GETs never retain anything.
900
+ */
901
+ const validatedRetainedIndices = (req, route, params) => {
902
+ if (req.headers.get(DATA_HEADER) === null)
903
+ return EMPTY_RETAIN;
904
+ const requested = retainedIndices(req.headers.get(RETAIN_HEADER));
905
+ const from = req.headers.get(NAV_FROM_HEADER);
906
+ if (requested.size === 0 || from === null)
907
+ return EMPTY_RETAIN;
908
+ let fromPath;
909
+ try {
910
+ const current = new URL(req.url);
911
+ const source = new URL(from, current);
912
+ if (source.origin !== current.origin)
913
+ return EMPTY_RETAIN;
914
+ fromPath = source.pathname + source.search;
915
+ }
916
+ catch {
917
+ return EMPTY_RETAIN;
918
+ }
919
+ const previousMatch = matchManifestRoute(fromPath);
920
+ if (previousMatch === null)
921
+ return EMPTY_RETAIN;
922
+ const previous = routeById.get(previousMatch.routeId);
923
+ if (previous === undefined)
924
+ return EMPTY_RETAIN;
925
+ const valid = new Set();
926
+ for (const index of requested) {
927
+ if (route.layoutIds[index] !== previous.layoutIds[index])
928
+ continue;
929
+ const owned = route.layoutParams?.[index] ?? [];
930
+ const previouslyOwned = previous.layoutParams?.[index] ?? [];
931
+ if (owned.length !== previouslyOwned.length ||
932
+ owned.some((name, i) => name !== previouslyOwned[i]) ||
933
+ owned.some((name) => params[name] !== previousMatch.params[name])) {
934
+ continue;
935
+ }
936
+ valid.add(index);
937
+ }
938
+ return valid;
939
+ };
940
+ const runLayoutChain = async (route, ctx, retain = new Set()) => {
941
+ const modules = await loadLayoutModules(route);
942
+ if (!modules.some((m) => m.loader !== undefined)) {
943
+ // Nothing to run: no array is produced, so a page-only app serializes nothing extra and its
944
+ // HTML is byte-identical to before this feature.
945
+ return { modules, layoutData: undefined, retained: [], pending: Promise.resolve() };
946
+ }
947
+ const results = new Array(modules.length).fill(null);
948
+ const retained = [];
949
+ const pending = [];
950
+ for (let i = 0; i < modules.length; i++) {
951
+ const loader = modules[i]?.loader;
952
+ if (loader === undefined)
953
+ continue;
954
+ // Unchanged scope: the client keeps what it has. A GATE is never skipped - it is a guard, and a
955
+ // guard that only runs when the URL prefix changed is not a guard.
956
+ if (retain.has(i) && modules[i]?.gate !== true) {
957
+ retained.push(i);
958
+ continue;
959
+ }
960
+ const scoped = {
961
+ ...ctx,
962
+ params: scopeParams(ctx.params, route.layoutParams?.[i]),
963
+ };
964
+ if (modules[i]?.gate === true) {
965
+ // Blocking: awaited here, so a throw propagates before anything deeper is started.
966
+ try {
967
+ results[i] = await loader(scoped);
968
+ }
969
+ catch (err) {
970
+ throw tagLayoutError(err, route.layoutIds[i]);
971
+ }
972
+ continue;
973
+ }
974
+ const layoutId = route.layoutIds[i];
975
+ const index = i;
976
+ // The loader is invoked INSIDE the async function, not before it. `Promise.resolve(loader(x))`
977
+ // would let a synchronously-throwing loader escape past the rejection handler entirely - the
978
+ // exception happens while evaluating the argument, before any promise exists to reject.
979
+ pending.push((async () => {
980
+ try {
981
+ results[index] = await loader(scoped);
982
+ }
983
+ catch (err) {
984
+ throw tagLayoutError(err, layoutId);
985
+ }
986
+ })());
987
+ }
988
+ return { modules, layoutData: results, retained, pending: Promise.all(pending) };
989
+ };
990
+ /** Run only blocking layout gates before a route action. Non-gate loaders fetch render data and
991
+ * therefore run after the mutation when a native POST needs a fresh document. */
992
+ const runLayoutGates = async (route, ctx) => {
993
+ const modules = await loadLayoutModules(route);
994
+ for (let i = 0; i < modules.length; i++) {
995
+ const mod = modules[i];
996
+ if (mod?.gate !== true || mod.loader === undefined)
997
+ continue;
998
+ try {
999
+ await mod.loader({
1000
+ ...ctx,
1001
+ params: scopeParams(ctx.params, route.layoutParams?.[i]),
1002
+ });
1003
+ }
1004
+ catch (err) {
1005
+ throw tagLayoutError(err, route.layoutIds[i]);
1006
+ }
1007
+ }
1008
+ return modules;
1009
+ };
622
1010
  // Build a route's render chain + its merged `<head>` from the already-loaded layout modules + the
623
1011
  // page module. The chain is `[…layout components, page]`; the head merges each layout's `meta`
624
1012
  // export (outermost→innermost) with the page's (last), so a `head`/`meta` on `_layout.tsx`
@@ -633,6 +1021,27 @@ export function createWebApp(options) {
633
1021
  };
634
1022
  // Dir a special-file id lives in: `_error`→"" , `a/b/_error`→"a/b" (and likewise for `_layout`).
635
1023
  const dirOfId = (id, suffix) => id === suffix ? "" : id.slice(0, id.length - suffix.length - 1);
1024
+ /**
1025
+ * Choose the `_error` boundary for a failure, respecting where a LAYOUT loader failed.
1026
+ *
1027
+ * A route error uses the nearest boundary, as it always has. A layout error must not: the nearest
1028
+ * boundary can sit BELOW the layout that threw, and rendering there would wrap the boundary in that
1029
+ * very layout - the one whose loader just failed and whose data therefore never arrived.
1030
+ */
1031
+ const boundaryFor = (route, err) => {
1032
+ const ids = route.errorIds ?? [];
1033
+ const failing = layoutErrorId(err);
1034
+ if (failing === undefined)
1035
+ return ids.at(-1);
1036
+ const layoutDir = dirOfId(failing, "_layout");
1037
+ // Deepest boundary at or ABOVE the failing layout's directory.
1038
+ for (let i = ids.length - 1; i >= 0; i--) {
1039
+ const errDir = dirOfId(ids[i], "_error");
1040
+ if (errDir === "" || errDir === layoutDir || layoutDir.startsWith(`${errDir}/`))
1041
+ return ids[i];
1042
+ }
1043
+ return undefined;
1044
+ };
636
1045
  // The request's origin (scheme + host + port, NO trailing slash) — the one server fact `meta()` needs
637
1046
  // for absolute `canonical`/`og:url`/`og:image` but can't otherwise see (it also runs on the client).
638
1047
  // `URL.origin` is exactly that shape; it matches the browser's `location.origin` for the same URL, so
@@ -691,30 +1100,65 @@ export function createWebApp(options) {
691
1100
  // The 404 response — the `_404` page (status 404) or a plain-text fallback. Shared by the wildcard
692
1101
  // catch-all (unmatched paths) and the `fallback: "404"` enforcement (unlisted paths under a route
693
1102
  // that opted out of on-demand SSR).
694
- const renderNotFound = async (path) => {
695
- if (manifest.notFound === undefined) {
696
- return new Response("Not Found", {
697
- status: 404,
698
- headers: { "content-type": "text/plain; charset=utf-8" },
699
- });
1103
+ const renderNotFound = (path) => renderTerminalStatus(404, path);
1104
+ /**
1105
+ * Render a terminal-status page: the `_<status>` page if the app authored one, else `_404`, else
1106
+ * plain text. Shared by the wildcard catch-all, `fallback: "404"` enforcement, and a loader's
1107
+ * `notFound()` / `gone()` / `statusPage()`, so all of them resolve the page and set the status
1108
+ * through one path rather than forking.
1109
+ *
1110
+ * Hydrates, following the `_404` precedent rather than `_error`'s non-hydrated 500: these are
1111
+ * ordinary pages the client router can own, and a 404 arrived at by clicking a link should behave
1112
+ * like any other route.
1113
+ */
1114
+ const renderTerminalStatus = async (status, path, extraHeaders) => {
1115
+ const page = manifest.statusPages?.[String(status)] ?? manifest.notFound;
1116
+ if (page === undefined) {
1117
+ const headers = new Headers(extraHeaders);
1118
+ headers.set("content-type", "text/plain; charset=utf-8");
1119
+ return new Response(STATUS_TEXT[status] ?? "Error", { status, headers });
700
1120
  }
701
- const { default: notFound } = await manifest.notFound.load();
1121
+ // The routeId decides which preload/style bundle and which client component this maps to, so it
1122
+ // must name the page actually rendered - `_410` when `_410.tsx` exists, `_404` when it fell back.
1123
+ const routeId = manifest.statusPages?.[String(status)] !== undefined ? `_${status}` : "_404";
1124
+ const { default: component } = await page.load();
702
1125
  return renderPageResult({
703
1126
  adapter,
704
- chain: [notFound],
1127
+ chain: [component],
705
1128
  data: null,
706
1129
  clientEntry,
707
- routeId: "_404",
708
- // `_404` hydrates, so its SSR `path` must equal the client's initial `location.pathname+search`
709
- // (an unmatched path yields no params → `{}`), or a `useLocation` on the 404 page would drift.
1130
+ routeId,
1131
+ // The page hydrates, so its SSR `path` must equal the client's initial
1132
+ // `location.pathname+search` (an unmatched path yields no params → `{}`), or a `useLocation`
1133
+ // on it would drift.
710
1134
  ...(path !== undefined ? { path } : {}),
711
- status: 404,
712
- ...preloadOf("_404"),
713
- ...stylesOf("_404"),
1135
+ status,
1136
+ ...(extraHeaders !== undefined ? { headers: extraHeaders } : {}),
1137
+ ...preloadOf(routeId),
1138
+ ...stylesOf(routeId),
714
1139
  prerenderedPaths: options.prerenderedPaths ?? [],
715
1140
  ...titleOption,
716
1141
  });
717
1142
  };
1143
+ /**
1144
+ * Answer a loader's terminal-status signal, in whichever form the caller asked for.
1145
+ *
1146
+ * A hard navigation wants the document, so it gets the rendered boundary. A client-side navigation
1147
+ * fetched DATA, not a document, so it gets an empty body plus the status on `X-Nifra-Status`, and
1148
+ * the client router renders the same boundary from its own manifest. Sending the HTML back to a
1149
+ * soft-nav would be answering a different question than the one asked - and skipping the header
1150
+ * entirely is what leaves a crawler seeing a correct 404 while a user who clicked a link sees a
1151
+ * blank page.
1152
+ */
1153
+ const renderStatusSignal = (req, signal) => {
1154
+ const { status, headers } = signal[STATUS_SIGNAL];
1155
+ if (req.headers.get(DATA_HEADER) !== null) {
1156
+ const responseHeaders = new Headers(headers);
1157
+ responseHeaders.set(STATUS_HEADER, String(status));
1158
+ return new Response(null, { status, headers: responseHeaders });
1159
+ }
1160
+ return renderTerminalStatus(status, pathOf(req), headers);
1161
+ };
718
1162
  for (const route of manifest.routes) {
719
1163
  // A dynamic route whose `getStaticPaths` declared `fallback: "404"` — only its prerendered paths
720
1164
  // exist; anything else 404s (computed once per route, not per request).
@@ -729,19 +1173,41 @@ export function createWebApp(options) {
729
1173
  const mod = await route.load();
730
1174
  const draft = await draftFlag(c.req);
731
1175
  let data;
1176
+ let layoutData;
1177
+ let layoutModules;
1178
+ let layoutRetained = [];
732
1179
  try {
733
- data = mod.loader
734
- ? await mod.loader({
735
- params: c.params,
736
- request: c.req,
737
- req: c.req,
738
- api,
739
- env: c.env,
740
- draft,
741
- })
742
- : null;
1180
+ const ctx = {
1181
+ params: c.params,
1182
+ request: c.req,
1183
+ req: c.req,
1184
+ api,
1185
+ env: c.env,
1186
+ draft,
1187
+ };
1188
+ // Layout loaders run for BOTH the document and the data-only request. A gate that only ran on
1189
+ // the document path would be bypassed by adding the data header, which is exactly the request
1190
+ // a client navigation makes - a guard has to hold on the path an attacker can pick.
1191
+ //
1192
+ // `runLayoutChain` returns once every GATE has resolved, with the non-gate loaders still in
1193
+ // flight. So the page loader starts as late as correctness requires and as early as speed
1194
+ // allows, and both are awaited together below.
1195
+ const run = await runLayoutChain(route, ctx, validatedRetainedIndices(c.req, route, c.params));
1196
+ layoutModules = run.modules;
1197
+ layoutRetained = run.retained;
1198
+ const [pageData] = await Promise.all([
1199
+ mod.loader ? mod.loader(ctx) : null,
1200
+ run.pending, // a non-gate layout loader that rejects must still surface, not go unhandled
1201
+ ]);
1202
+ data = pageData;
1203
+ layoutData = run.layoutData;
743
1204
  }
744
1205
  catch (err) {
1206
+ // A terminal-status signal (`notFound()` / `gone()` / `statusPage(n)`) renders a boundary at
1207
+ // that status. Checked BEFORE the pass-through below: a signal IS a `Response`, so the order
1208
+ // is what keeps a hand-rolled `throw new Response(...)` served verbatim, as it always was.
1209
+ if (isStatusSignal(err))
1210
+ return renderStatusSignal(c.req, err);
745
1211
  // A thrown `Response` is a control-flow signal (a guard's `redirect(...)`, an explicit error
746
1212
  // response) — let it propagate to core, which returns it as-is. Real errors render the nearest
747
1213
  // `_error` boundary, if any; with none, rethrow (unchanged 500 behavior).
@@ -756,7 +1222,7 @@ export function createWebApp(options) {
756
1222
  // A faulty reporter must never break error rendering.
757
1223
  }
758
1224
  }
759
- const errorId = route.errorIds?.at(-1);
1225
+ const errorId = boundaryFor(route, err);
760
1226
  if (errorId === undefined)
761
1227
  throw err;
762
1228
  // A soft-nav data fetch can't render a boundary — 500 so the client falls back to a full-page
@@ -767,21 +1233,40 @@ export function createWebApp(options) {
767
1233
  headers: { "content-type": "text/plain; charset=utf-8" },
768
1234
  });
769
1235
  }
770
- return renderError(route, errorId, err);
1236
+ return renderError(route, errorId, withDuplicateInstanceHint(err));
771
1237
  }
772
1238
  // Client-side navigation asks (via the X-Nifra-Data header) for just the loader data — no full
773
1239
  // document, no layout chain. A route with deferred data streams NDJSON (critical data first,
774
1240
  // then each deferred value as it settles); otherwise one JSON (the fast path). Same loader,
775
1241
  // same auth — only the transport differs.
776
1242
  if (c.req.headers.get(DATA_HEADER) !== null) {
777
- const { forClient, deferred } = prepareDeferred(data);
778
- if (deferred.length === 0)
779
- return Response.json(data ?? null);
780
- return new Response(ndjsonStream(forClient, deferred), {
1243
+ const pageSplit = prepareDeferred(data);
1244
+ const layoutSplits = [];
1245
+ let offset = pageSplit.deferred.length;
1246
+ for (const entry of layoutData ?? []) {
1247
+ const split = prepareDeferred(entry, offset);
1248
+ layoutSplits.push(split);
1249
+ offset += split.deferred.length;
1250
+ }
1251
+ const deferred = [...pageSplit.deferred, ...layoutSplits.flatMap((split) => split.deferred)];
1252
+ const payload = layoutData === undefined
1253
+ ? pageSplit.forClient
1254
+ : {
1255
+ v: 1,
1256
+ data: pageSplit.forClient,
1257
+ layoutData: layoutSplits.map((split) => split.forClient),
1258
+ retained: layoutRetained,
1259
+ };
1260
+ // Bare value when there is no layout data — the pre-envelope shape, so a client running older
1261
+ // code against a newer server keeps working.
1262
+ if (deferred.length === 0) {
1263
+ return Response.json(payload ?? null);
1264
+ }
1265
+ return new Response(ndjsonStream(payload, deferred), {
781
1266
  headers: { "content-type": "application/x-ndjson; charset=utf-8" },
782
1267
  });
783
1268
  }
784
- const { chain, head } = resolveChainAndHead(await loadLayoutModules(route), mod, {
1269
+ const { chain, head } = resolveChainAndHead(layoutModules ?? (await loadLayoutModules(route)), mod, {
785
1270
  data,
786
1271
  params: c.params,
787
1272
  origin: originOf(c.req),
@@ -801,6 +1286,7 @@ export function createWebApp(options) {
801
1286
  params: c.params,
802
1287
  path: pathOf(c.req),
803
1288
  hydrate: hydrateRoute,
1289
+ ...(layoutData !== undefined ? { layoutData } : {}),
804
1290
  ...preloadOf(route.id),
805
1291
  ...stylesOf(route.id),
806
1292
  prerenderedPaths: options.prerenderedPaths ?? [],
@@ -810,6 +1296,9 @@ export function createWebApp(options) {
810
1296
  });
811
1297
  }
812
1298
  catch (err) {
1299
+ // Same precedence as the loader catch above — a `meta()` may signal too.
1300
+ if (isStatusSignal(err))
1301
+ return renderStatusSignal(c.req, err);
813
1302
  if (err instanceof Response)
814
1303
  throw err;
815
1304
  // Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
@@ -821,10 +1310,10 @@ export function createWebApp(options) {
821
1310
  // A faulty reporter must never break error rendering.
822
1311
  }
823
1312
  }
824
- const errorId = route.errorIds?.at(-1);
1313
+ const errorId = boundaryFor(route, err);
825
1314
  if (errorId === undefined)
826
1315
  throw err;
827
- return renderError(route, errorId, err);
1316
+ return renderError(route, errorId, withDuplicateInstanceHint(err));
828
1317
  }
829
1318
  });
830
1319
  // POST runs the route's `action` (mutation). A `Response` return (e.g. a `redirect(...)`)
@@ -839,14 +1328,18 @@ export function createWebApp(options) {
839
1328
  headers: { allow: "GET", "content-type": "text/plain; charset=utf-8" },
840
1329
  });
841
1330
  }
842
- const result = await mod.action({
1331
+ const actionContext = {
843
1332
  params: c.params,
844
1333
  request: c.req,
845
1334
  req: c.req,
846
1335
  api,
847
1336
  env: c.env,
848
1337
  draft,
849
- });
1338
+ };
1339
+ // A gate is an authorization boundary for everything beneath its layout, mutations included.
1340
+ // Run only gates before the action; ordinary layout data loaders run after a native mutation.
1341
+ const layoutModules = await runLayoutGates(route, actionContext);
1342
+ const result = await mod.action(actionContext);
850
1343
  const isDataRequest = c.req.headers.get(DATA_HEADER) !== null;
851
1344
  // An action may wrap its data in `revalidate(paths, data)` to declare which routes it changed.
852
1345
  // Unwrap to the inner data; the paths ride the `X-Nifra-Revalidate` header on the data-mode
@@ -880,7 +1373,7 @@ export function createWebApp(options) {
880
1373
  const data = mod.loader
881
1374
  ? await mod.loader({ params: c.params, request: c.req, req: c.req, api, env: c.env, draft })
882
1375
  : null;
883
- const { chain, head } = resolveChainAndHead(await loadLayoutModules(route), mod, {
1376
+ const { chain, head } = resolveChainAndHead(layoutModules, mod, {
884
1377
  data,
885
1378
  params: c.params,
886
1379
  origin: originOf(c.req),
@@ -937,6 +1430,7 @@ export function generateClientEntry(manifest, options) {
937
1430
  const { clientModule, resolve } = options;
938
1431
  const loaderRows = [];
939
1432
  const patternRows = [];
1433
+ const statusRoutes = {};
940
1434
  // Routes whose loader appends a nearest `_error` module (LAST) — the client wraps the page in the
941
1435
  // adapter's `errorBoundary(fallback)` for these, so a client render error shows the `_error` UI.
942
1436
  const errorRouteIds = [];
@@ -963,6 +1457,12 @@ export function generateClientEntry(manifest, options) {
963
1457
  }
964
1458
  if (manifest.notFound !== undefined) {
965
1459
  loaderRows.push(` "_404": ${lazyLoader([manifest.notFound.file])},`);
1460
+ statusRoutes[404] = "_404";
1461
+ }
1462
+ for (const [status, entry] of Object.entries(manifest.statusPages ?? {})) {
1463
+ const routeId = `_${status}`;
1464
+ loaderRows.push(` ${JSON.stringify(routeId)}: ${lazyLoader([entry.file])},`);
1465
+ statusRoutes[Number(status)] = routeId;
966
1466
  }
967
1467
  return `${[
968
1468
  'import { createClientRouter, createMatcher, mergeHeads, resolveMeta } from "@nifrajs/web"',
@@ -971,6 +1471,14 @@ export function generateClientEntry(manifest, options) {
971
1471
  // access yields `undefined` if absent — unlike a named import, which would be a link error.
972
1472
  `import * as __adapter from ${JSON.stringify(clientModule)}`,
973
1473
  "const { mountRouter } = __adapter",
1474
+ // The `clientModule` contract is a string specifier, so nothing type-checks that the module it
1475
+ // names actually exports `mountRouter`. Without this the miss surfaces as
1476
+ // "mountRouter is not a function" from inside a bundled chunk, at first paint, naming neither the
1477
+ // module nor the requirement - a self-executing entry passes every build and fails only here.
1478
+ `if (typeof mountRouter !== "function") throw new Error(${JSON.stringify(`[nifra/web] clientModule ${JSON.stringify(clientModule)} does not export \`mountRouter\`. ` +
1479
+ "A client module must export `mountRouter({ router, routes, container })` — it is called by " +
1480
+ "the generated bootstrap, so a self-executing entry will not work. Use your adapter's " +
1481
+ '`/client` entry (e.g. "@nifrajs/web-react/client"), or re-export mountRouter from it.')})`,
974
1482
  "const errorBoundary = __adapter.errorBoundary",
975
1483
  `const errorRouteIds = new Set(${JSON.stringify(errorRouteIds)})`,
976
1484
  // Each route is a lazy loader: dynamic imports → Bun.build (splitting) emits one chunk per
@@ -1020,13 +1528,16 @@ export function generateClientEntry(manifest, options) {
1020
1528
  // page reading the search string would hydrate-mismatch. The #hash is client-only (never SSR'd).
1021
1529
  " path: location.pathname + location.search,",
1022
1530
  ` data: mapDeferred(window.${DATA_GLOBAL}),`,
1531
+ // Undefined on a page-only app, which is exactly what the router treats as "no layout data".
1532
+ ` layoutData: window.${LAYOUT_DATA_GLOBAL} && window.${LAYOUT_DATA_GLOBAL}.map(mapDeferred),`,
1023
1533
  // actionData (only set after a form POST) is in the initial state so the binding hydrates
1024
1534
  // consistently with the server-rendered markup; mapped through `mapDeferred` too so a deferred
1025
1535
  // action's placeholders become registry markers (a no-op when the action didn't defer).
1026
1536
  ` actionData: mapDeferred(window.${ACTION_GLOBAL}),`,
1027
1537
  " pending: false,",
1028
1538
  "}",
1029
- "const router = createClientRouter({ patterns, initial, loadModule })",
1539
+ `const statusRoutes = ${JSON.stringify(statusRoutes)}`,
1540
+ "const router = createClientRouter({ patterns, initial, loadModule, statusRoutes })",
1030
1541
  "installHistory(router)",
1031
1542
  "installForms(router)",
1032
1543
  'const root = document.getElementById("root")',
@@ -1054,7 +1565,7 @@ export function generateClientEntry(manifest, options) {
1054
1565
  * Codegen: emit a **server manifest** module (as source) for disk-less edge runtimes (Cloudflare
1055
1566
  * Workers, …) — and, with a `target`, any portable server bundle. `discoverRoutes` scans `node:fs`
1056
1567
  * and dynamic-imports each route by a *runtime* path — neither exists on workerd. This instead emits
1057
- * **statically-analyzable** imports of every route/layout/`_error`/`_404` (so the bundler includes them) and
1568
+ * **statically-analyzable** imports of every route/layout/`_error`/terminal status page (so the bundler includes them) and
1058
1569
  * rebuilds the manifest with `buildManifest` — the SAME pure logic `discoverRoutes` feeds, so patterns
1059
1570
  * + layout chains are identical. Eager (`import * as`) by default; `lazy` emits `() => import(...)` so
1060
1571
  * a code-splitting bundler chunks per route. The emitted module exports `manifest` (consumed by
@@ -1063,13 +1574,14 @@ export function generateClientEntry(manifest, options) {
1063
1574
  */
1064
1575
  export function generateServerManifest(manifest, options) {
1065
1576
  const { resolve, clientEntry, styles, routeStyles, lazy = false } = options;
1066
- // Every unique source file in the manifest (routes + layouts + `_error` + `_404`), sorted for stable output.
1577
+ // Every unique source file in the manifest (routes + layouts + error/status pages), sorted for stable output.
1067
1578
  const files = [
1068
1579
  ...new Set([
1069
1580
  ...manifest.routes.map((r) => r.file),
1070
1581
  ...Object.values(manifest.layouts).map((l) => l.file),
1071
1582
  ...Object.values(manifest.errors ?? {}).map((e) => e.file),
1072
1583
  ...(manifest.notFound ? [manifest.notFound.file] : []),
1584
+ ...Object.values(manifest.statusPages ?? {}).map((page) => page.file),
1073
1585
  ]),
1074
1586
  ].sort();
1075
1587
  const header = [