@nifrajs/web 2.14.1 → 3.1.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 (62) hide show
  1. package/dist/boundary.d.ts +158 -0
  2. package/dist/boundary.d.ts.map +1 -0
  3. package/dist/boundary.js +237 -0
  4. package/dist/boundary.js.map +1 -0
  5. package/dist/build-vite.d.ts.map +1 -1
  6. package/dist/build-vite.js +5 -3
  7. package/dist/build-vite.js.map +1 -1
  8. package/dist/build.d.ts.map +1 -1
  9. package/dist/build.js +31 -13
  10. package/dist/build.js.map +1 -1
  11. package/dist/index.d.ts +30 -9
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +163 -51
  14. package/dist/index.js.map +1 -1
  15. package/dist/internal/fetcher-runtime.d.ts +19 -0
  16. package/dist/internal/fetcher-runtime.d.ts.map +1 -0
  17. package/dist/internal/fetcher-runtime.js +26 -0
  18. package/dist/internal/fetcher-runtime.js.map +1 -0
  19. package/dist/internal/identity-policy.d.ts +51 -0
  20. package/dist/internal/identity-policy.d.ts.map +1 -0
  21. package/dist/internal/identity-policy.js +67 -0
  22. package/dist/internal/identity-policy.js.map +1 -0
  23. package/dist/internal/parity.d.ts +97 -8
  24. package/dist/internal/parity.d.ts.map +1 -1
  25. package/dist/internal/parity.js +281 -47
  26. package/dist/internal/parity.js.map +1 -1
  27. package/dist/internal/query-runtime.d.ts +8 -0
  28. package/dist/internal/query-runtime.d.ts.map +1 -0
  29. package/dist/internal/query-runtime.js +29 -0
  30. package/dist/internal/query-runtime.js.map +1 -0
  31. package/dist/islands.d.ts +2 -2
  32. package/dist/islands.d.ts.map +1 -1
  33. package/dist/islands.js +17 -36
  34. package/dist/islands.js.map +1 -1
  35. package/dist/manifest.d.ts +41 -6
  36. package/dist/manifest.d.ts.map +1 -1
  37. package/dist/manifest.js.map +1 -1
  38. package/dist/render-seam.d.ts +5 -0
  39. package/dist/render-seam.d.ts.map +1 -1
  40. package/dist/render-seam.js +10 -0
  41. package/dist/render-seam.js.map +1 -1
  42. package/dist/router.d.ts +10 -0
  43. package/dist/router.d.ts.map +1 -1
  44. package/dist/router.js +233 -26
  45. package/dist/router.js.map +1 -1
  46. package/dist/vite.d.ts.map +1 -1
  47. package/dist/vite.js +8 -6
  48. package/dist/vite.js.map +1 -1
  49. package/package.json +13 -2
  50. package/src/boundary.ts +407 -0
  51. package/src/build-vite.ts +5 -3
  52. package/src/build.ts +36 -14
  53. package/src/index.ts +235 -49
  54. package/src/internal/fetcher-runtime.ts +36 -0
  55. package/src/internal/identity-policy.ts +94 -0
  56. package/src/internal/parity.ts +409 -57
  57. package/src/internal/query-runtime.ts +31 -0
  58. package/src/islands.ts +22 -41
  59. package/src/manifest.ts +50 -3
  60. package/src/render-seam.ts +6 -0
  61. package/src/router.ts +271 -29
  62. package/src/vite.ts +8 -6
package/dist/index.js CHANGED
@@ -8,7 +8,8 @@
8
8
  * of DOM types: it is pure server orchestration + string building.
9
9
  */
10
10
  import { NIFRA_BACKEND_MOUNT, } from "@nifrajs/core/mount";
11
- import { server, urlPartsOf } from "@nifrajs/core/server";
11
+ import { isSameOriginPath, server, status as statusResult, urlPartsOf, } from "@nifrajs/core/server";
12
+ import { boundaryDescriptors, resolveStaticBoundaries, startDynamicBoundaries, } from "./boundary.js";
12
13
  import { DEFERRED_ERROR_CODE, DEFERRED_RUNTIME, defer, MAP_DEFERRED_SOURCE, ndjsonStream, prepareDeferred, } from "./deferred.js";
13
14
  import { isDraftEnabled } from "./draft.js";
14
15
  import { trustedHeadAttributes } from "./internal/head-attributes.js";
@@ -20,10 +21,11 @@ import { jsStringLiteral } from "./internal/js-string.js";
20
21
  import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./internal/script-types.js";
21
22
  import { ISR_REVALIDATE_HEADER, ISR_REVALIDATE_TAGS_HEADER, serializeISRTags } from "./isr.js";
22
23
  import { generateLlmsTxt } from "./llms-txt.js";
23
- import { ACTION_GLOBAL, DATA_GLOBAL, LAYOUT_DATA_GLOBAL, ROOT_ATTRIBUTE, ROUTE_GLOBAL, } from "./render-seam.js";
24
+ import { ACTION_GLOBAL, BOUNDARY_GLOBAL, DATA_GLOBAL, LAYOUT_DATA_GLOBAL, ROOT_ATTRIBUTE, ROUTE_GLOBAL, } from "./render-seam.js";
24
25
  import { createMatcher, DATA_HEADER, NAV_FROM_HEADER, PRERENDERED_GLOBAL, REDIRECT_HEADER, RETAIN_HEADER, REVALIDATE_HEADER, STATUS_HEADER, } from "./router.js";
25
26
  import { searchOf, searchOfChain } from "./search.js";
26
27
  // Draft / preview mode - a signed cookie that flips `ctx.draft` for loaders + bypasses ISR for editors.
28
+ export { assertStaticBoundaryImports, boundaryDescriptors, boundaryModeKey, MemoryStaticBoundaryCache, resolveDynamicBoundaries, resolveStaticBoundaries, startDynamicBoundaries, } from "./boundary.js";
27
29
  export { DRAFT_COOKIE, disableDraft, enableDraft, isDraftEnabled, previewEndpoint, } from "./draft.js";
28
30
  // Font optimization - a CLS-safe `@font-face` generator + a preload `<link>` for self-hosted fonts.
29
31
  export { fontFace, fontPreload, } from "./fonts.js";
@@ -46,7 +48,7 @@ export { createClientRouter, createMatcher, DATA_HEADER, NAV_FROM_HEADER, REDIRE
46
48
  // Deferred loader data (`defer()` + the `Deferred<T>` type) - consumed by the adapter's `<Await>`.
47
49
  export { defer, mergeHeads, resolveMeta };
48
50
  export { assertRenderAdapterConformance, RenderAdapterConformanceError, } from "./conformance.js";
49
- export { ACTION_GLOBAL, DATA_GLOBAL, LAYOUT_DATA_GLOBAL, ROOT_ATTRIBUTE, ROUTE_GLOBAL, setSsrModuleLoader, ssrModuleLoader, } from "./render-seam.js";
51
+ export { ACTION_GLOBAL, BOUNDARY_GLOBAL, DATA_GLOBAL, LAYOUT_DATA_GLOBAL, ROOT_ATTRIBUTE, ROUTE_GLOBAL, setSsrModuleLoader, ssrModuleLoader, } from "./render-seam.js";
50
52
  // The search-derivation primitive: parse a URL query, validate it against a route's `searchSchema`
51
53
  // (fail-closed to defaults), or return the raw parsed query when there is none. Both the server (loader
52
54
  // ctx + `renderPage`) and a client adapter mount call this with the same URL + schema, so the two sides
@@ -95,6 +97,17 @@ const SCRIPT_ESCAPE_MAP = {
95
97
  };
96
98
  const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body");
97
99
  const RESPONSE_RESULT = Symbol.for("nifra.response.result");
100
+ /**
101
+ * A control-flow value that renders as plain data - what `redirect()` returns, and what core's
102
+ * `status(...)` returns. Recognized here rather than imported: core keeps its own predicate internal,
103
+ * and the registry symbol is the contract between them (two copies of core must still agree).
104
+ */
105
+ const isResponseResult = (value) => typeof value === "object" &&
106
+ value !== null &&
107
+ value[RESPONSE_RESULT] === true &&
108
+ typeof value.toResponse === "function";
109
+ /** A returned/thrown value that ends the request as-is: either shape of control-flow signal. */
110
+ const isControlFlow = (value) => value instanceof Response || isResponseResult(value);
98
111
  /**
99
112
  * Server: render a full HTML document for a page - the adapter's hydration head + the SSR
100
113
  * markup (**streamed**) + the serialized loader data + the client module - as a `Response`.
@@ -107,7 +120,7 @@ export function renderPage(options) {
107
120
  return page instanceof Promise ? page.then((p) => p.toResponse()) : page.toResponse();
108
121
  }
109
122
  export function renderPageResult(options) {
110
- const { adapter, chain, data, actionData, clientEntry, preload = [], styles = [], prerenderedPaths = [], revalidate, revalidateTags = [], routeId, status = 200, title = "nifra", head, rootId = "root", hydrate = true, islandScripts = [], nonce, headers: extraHeaders, } = options;
123
+ const { adapter, chain, data, actionData, clientEntry, preload = [], styles = [], prerenderedPaths = [], revalidate, revalidateTags = [], routeId, status = 200, title = "nifra", head, rootId = "root", hydrate = true, islandScripts = [], nonce, boundaries, headers: extraHeaders, } = options;
111
124
  if (nonce !== undefined && nonce.trim() === "") {
112
125
  throw new TypeError("[nifra/web] renderPage nonce must be non-empty when provided");
113
126
  }
@@ -138,11 +151,19 @@ export function renderPageResult(options) {
138
151
  const actionSplit = actionData === undefined
139
152
  ? undefined
140
153
  : prepareDeferred(actionData, deferred.length + layoutDeferred.length);
154
+ const boundarySplit = boundaries === undefined
155
+ ? undefined
156
+ : prepareDeferred(boundaries, deferred.length + layoutDeferred.length + (actionSplit?.deferred.length ?? 0));
141
157
  // On the common page-only path (no layout loader, no action) reuse `deferred` directly instead of
142
158
  // allocating a fresh spread array every request.
143
- const allDeferred = layoutDeferred.length === 0 && actionSplit === undefined
159
+ const allDeferred = layoutDeferred.length === 0 && actionSplit === undefined && boundarySplit === undefined
144
160
  ? deferred
145
- : [...deferred, ...layoutDeferred, ...(actionSplit ? actionSplit.deferred : [])];
161
+ : [
162
+ ...deferred,
163
+ ...layoutDeferred,
164
+ ...(actionSplit ? actionSplit.deferred : []),
165
+ ...(boundarySplit ? boundarySplit.deferred : []),
166
+ ];
146
167
  // Omitted entirely when no layout has a loader - a page-only app emits exactly what it did before.
147
168
  const layoutTail = options.layoutData === undefined
148
169
  ? ""
@@ -151,6 +172,9 @@ export function renderPageResult(options) {
151
172
  const action = actionSplit === undefined
152
173
  ? ""
153
174
  : `window.${ACTION_GLOBAL}=${serializeData(actionSplit.forClient)};`;
175
+ const boundaryTail = boundarySplit === undefined
176
+ ? ""
177
+ : `window.${BOUNDARY_GLOBAL}=${serializeData(boundarySplit.forClient)};`;
154
178
  const deferredRuntime = allDeferred.length > 0 ? `<script${nonceAttr}>${DEFERRED_RUNTIME}</script>` : "";
155
179
  // The regex only injects the CSP nonce into the (constant) hydration head; with no nonce it's a
156
180
  // no-op that still scans the whole script every request. Skip it on the common no-nonce path.
@@ -230,7 +254,7 @@ export function renderPageResult(options) {
230
254
  // Tail - the loader-data globals + the client module. Module scripts defer (run after parse), so
231
255
  // the data global + every streamed deferred resolution are set before the entry hydrates.
232
256
  const tailHtml = hydrate
233
- ? `${slot.tailPre}${action}${slot.tailMid}${layoutTail}${slot.tailData}${serializeData(forClient)}${slot.tailPost}`
257
+ ? `${slot.tailPre}${action}${slot.tailMid}${layoutTail}${boundaryTail}${slot.tailData}${serializeData(forClient)}${slot.tailPost}`
234
258
  : slot.tailPost;
235
259
  const headers = { "content-type": "text/html; charset=utf-8" };
236
260
  // Caller-supplied headers (a terminal status page's `cache-control`, say). Applied before the
@@ -265,6 +289,9 @@ export function renderPageResult(options) {
265
289
  ...(options.layoutData !== undefined
266
290
  ? { layoutData: layoutSplits.map((split) => split.forComponent) }
267
291
  : {}),
292
+ ...(boundarySplit !== undefined
293
+ ? { boundaries: boundarySplit.forComponent }
294
+ : {}),
268
295
  };
269
296
  // Fast path: nothing `defer()`s and the adapter can render synchronously to a string → buffer the
270
297
  // whole document in one pass. Progressive streaming only benefits pages with deferred boundaries
@@ -376,26 +403,31 @@ function streamDocument(shell, stream, closeRoot, deferred, tail, enc, nonceAttr
376
403
  });
377
404
  }
378
405
  /** A same-origin destination is an absolute path: one leading `/`, but NOT `//` (protocol-relative →
379
- * another origin). Everything else (absolute URL with a scheme, `//host`, `javascript:`, a bare
380
- * relative `foo`) is treated as off-origin and requires `external: true`. */
381
- function isSameOriginPath(location) {
382
- return location.startsWith("/") && !location.startsWith("//");
383
- }
406
+ * another origin), and nothing a URL parser resolves off-origin. Everything else (absolute URL with
407
+ * a scheme, `//host`, `javascript:`, a bare relative `foo`) requires `external: true`. The predicate
408
+ * lives in the kernel - see `isSameOriginPath` - so this gate and the auth guards' `redirectTo`
409
+ * cannot drift apart. */
384
410
  /**
385
- * Build a redirect `Response` - return it from a route `action` for the Post/Redirect/Get
386
- * pattern (POST mutates, 303 sends the browser to a fresh GET, so a reload doesn't re-submit).
387
- * Defaults to 303 (See Other); pass `{ status: 307 }` or `{ status: 308 }` to preserve the method.
411
+ * Build a redirect - return it from a route `action` for the Post/Redirect/Get pattern (POST
412
+ * mutates, 303 sends the browser to a fresh GET, so a reload doesn't re-submit). Defaults to 303
413
+ * (See Other); pass `{ status: 307 }` or `{ status: 308 }` to preserve the method.
388
414
  *
389
415
  * **Secure by default:** `location` must be a same-origin path (begins with `/`, not `//`). An
390
416
  * off-origin/absolute destination throws unless you pass `{ external: true }` - this closes the
391
417
  * open-redirect footgun of `return redirect(formData.get("next"))` on the no-JS (native-form) path,
392
- * which returns the action's `Response` verbatim.
418
+ * which serves the action's control-flow value verbatim.
419
+ *
420
+ * Returns a plain render, not a `Response`. A redirect is a status line and one header - the most
421
+ * body-less response there is - and building a `Response` for it costs the whole Web object plus, on
422
+ * Node, a stream drained back out. As data it renders on the same lane a handler's return takes.
423
+ * `redirect(...)` is still returned or thrown from exactly the same places; only `.status` /
424
+ * `.headers` are gone from the value, replaced by `options.headers` and the request's `c.set`.
393
425
  *
394
- * @param options redirect status and whether an off-origin destination is intentional.
426
+ * @param options redirect status, extra headers, and whether an off-origin destination is intentional.
395
427
  */
396
428
  export function redirect(location, options = {}) {
397
429
  if (options.external !== true && !isSameOriginPath(location)) {
398
- throw new Error(`[nifra/web] redirect(${JSON.stringify(location)}) is not a same-origin path. Use a path beginning with "/" (not "//"), or redirect(location, { external: true }) for a deliberate off-origin redirect. This guards against open redirects from unvalidated input.`);
430
+ throw new Error(`[nifra/web] redirect(${JSON.stringify(location)}) is not a same-origin path. Use a path beginning with "/" (not "//", no backslash or control character), or redirect(location, { external: true }) for a deliberate off-origin redirect. This guards against open redirects from unvalidated input.`);
399
431
  }
400
432
  // Reject CR/LF in the Location explicitly - defense-in-depth (response splitting / header
401
433
  // injection). Spec-correct runtimes' Headers setter throws on CR/LF, but `external: true` lets
@@ -404,7 +436,9 @@ export function redirect(location, options = {}) {
404
436
  if (/[\r\n]/.test(location)) {
405
437
  throw new Error(`[nifra/web] redirect location contains a CR/LF character - refusing to emit a header-injecting redirect.`);
406
438
  }
407
- return new Response(null, { status: options.status ?? 303, headers: { location } });
439
+ return statusResult(options.status ?? 303, undefined, {
440
+ headers: options.headers === undefined ? { location } : { ...options.headers, location },
441
+ });
408
442
  }
409
443
  /**
410
444
  * The signatures a **duplicate module instance** produces during SSR, across engines.
@@ -443,17 +477,34 @@ function withDuplicateInstanceHint(err) {
443
477
  return augmented;
444
478
  }
445
479
  /**
446
- * An action's `Response` passes straight through - except a redirect on a client-submit data
480
+ * An action's control-flow value passes straight through - except a redirect on a client-submit data
447
481
  * request: fetch would follow the 3xx into HTML the client can't use, so the redirect rides the
448
- * X-Nifra-Redirect header on a 204 and the client navigates. One conversion shared by the
449
- * returned- and thrown-Response paths, so `return redirect()` and `throw redirect()` agree.
482
+ * X-Nifra-Redirect header on a 204 and the client navigates. One conversion shared by the returned-
483
+ * and thrown- paths, so `return redirect()` and `throw redirect()` agree.
484
+ *
485
+ * Takes either shape: `redirect()` is a plain render, while a hand-rolled `new Response(...)` from an
486
+ * action still arrives as a `Response`. The rewrite stays on the lane its input was on - a plain
487
+ * redirect converts to a plain 204, and never materializes the `Response` it is replacing.
450
488
  */
451
- function actionResponse(response, isDataRequest) {
452
- if (isDataRequest && response.status >= 300 && response.status < 400) {
453
- const location = response.headers.get("location") ?? "/";
454
- return new Response(null, { status: 204, headers: { [REDIRECT_HEADER]: location } });
489
+ function actionResponse(result, isDataRequest) {
490
+ if (!isDataRequest)
491
+ return result;
492
+ if (isResponseResult(result)) {
493
+ const plain = result.plain;
494
+ // No `plain` means a carrier that only knows how to build a `Response` (not one of ours) - fall
495
+ // back rather than guess at its status.
496
+ if (plain === undefined)
497
+ return actionResponse(result.toResponse(), isDataRequest);
498
+ if (plain.status < 300 || plain.status >= 400)
499
+ return result;
500
+ const location = plain.headers?.location ?? "/";
501
+ return statusResult(204, undefined, { headers: { [REDIRECT_HEADER]: location } });
455
502
  }
456
- return response;
503
+ if (result.status >= 300 && result.status < 400) {
504
+ const location = result.headers.get("location") ?? "/";
505
+ return statusResult(204, undefined, { headers: { [REDIRECT_HEADER]: location } });
506
+ }
507
+ return result;
457
508
  }
458
509
  /**
459
510
  * Narrow the route's params to the ones a layout owns.
@@ -1264,6 +1315,7 @@ export function createWebApp(options) {
1264
1315
  const draft = await draftFlag(c.req);
1265
1316
  let data;
1266
1317
  let layoutData;
1318
+ let boundaryStates;
1267
1319
  let layoutModules;
1268
1320
  let layoutRetained = [];
1269
1321
  try {
@@ -1286,16 +1338,51 @@ export function createWebApp(options) {
1286
1338
  const run = await runLayoutChain(route, ctx, validatedRetainedIndices(c.req, route, c.params));
1287
1339
  layoutModules = run.modules;
1288
1340
  layoutRetained = run.retained;
1341
+ const boundaryDefinitions = [
1342
+ ...run.modules.flatMap((layout) => layout.boundaries ?? []),
1343
+ ...(mod.boundaries ?? []),
1344
+ ];
1345
+ if (boundaryDefinitions.length > 0)
1346
+ boundaryDescriptors(boundaryDefinitions);
1347
+ const effectiveSearch = searchChainOf(run.modules, mod, c.req);
1348
+ const boundaryBatch = boundaryDefinitions.length === 0
1349
+ ? undefined
1350
+ : startDynamicBoundaries(boundaryDefinitions, {
1351
+ request: c.req,
1352
+ params: c.params,
1353
+ api,
1354
+ env: c.env,
1355
+ draft,
1356
+ search: effectiveSearch,
1357
+ signal: c.req.signal,
1358
+ });
1359
+ const staticBoundaryPromise = boundaryDefinitions.length === 0
1360
+ ? Promise.resolve(undefined)
1361
+ : resolveStaticBoundaries(boundaryDefinitions, { phase: "build", origin: originOf(c.req) }, options.staticBoundaryCache);
1362
+ // Boundary data is attached as one Deferred marker per slot. The shell can render
1363
+ // immediately with each slot pending, while the existing deferred protocol emits the first
1364
+ // completed sibling independently instead of waiting for the slowest boundary.
1289
1365
  // The page loader gets the EFFECTIVE search (layout chain + page, page-wins), now that the layout
1290
1366
  // modules are loaded - so `ctx.search` includes a layout's shared keys (`?org`), matching `useSearch`.
1291
- const [pageData] = await Promise.all([
1292
- mod.loader
1293
- ? mod.loader({ ...ctx, search: searchChainOf(run.modules, mod, c.req) })
1294
- : null,
1367
+ const [pageData, , staticBoundaries] = await Promise.all([
1368
+ mod.loader ? mod.loader({ ...ctx, search: effectiveSearch }) : null,
1295
1369
  run.pending, // a non-gate layout loader that rejects must still surface, not go unhandled
1370
+ staticBoundaryPromise,
1296
1371
  ]);
1297
1372
  data = pageData;
1298
1373
  layoutData = run.layoutData;
1374
+ if (boundaryBatch !== undefined) {
1375
+ const streamed = { ...boundaryBatch.initial, ...(staticBoundaries ?? {}) };
1376
+ for (const entry of boundaryBatch.pending) {
1377
+ const initial = streamed[entry.name];
1378
+ if (initial !== undefined)
1379
+ streamed[entry.name] = { ...initial, data: defer(entry.promise) };
1380
+ }
1381
+ boundaryStates = streamed;
1382
+ }
1383
+ else if (staticBoundaries !== undefined) {
1384
+ boundaryStates = staticBoundaries;
1385
+ }
1299
1386
  }
1300
1387
  catch (err) {
1301
1388
  // A terminal-status signal (`notFound()` / `gone()` / `statusPage(n)`) renders a boundary at
@@ -1303,10 +1390,11 @@ export function createWebApp(options) {
1303
1390
  // is what keeps a hand-rolled `throw new Response(...)` served verbatim, as it always was.
1304
1391
  if (isStatusSignal(err))
1305
1392
  return renderStatusSignal(c.req, err);
1306
- // A thrown `Response` is a control-flow signal (a guard's `redirect(...)`, an explicit error
1307
- // response) - let it propagate to core, which returns it as-is. Real errors render the nearest
1308
- // `_error` boundary, if any; with none, rethrow (unchanged 500 behavior).
1309
- if (err instanceof Response)
1393
+ // A thrown control-flow signal (a guard's `redirect(...)`, an explicit error response) - let
1394
+ // it propagate to core, which renders it as-is, whether it is a plain render or a
1395
+ // hand-rolled `Response`. Real errors render the nearest `_error` boundary, if any; with
1396
+ // none, rethrow (unchanged 500 behavior).
1397
+ if (isControlFlow(err))
1310
1398
  throw err;
1311
1399
  // Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
1312
1400
  if (options.onLoaderError !== undefined) {
@@ -1331,13 +1419,13 @@ export function createWebApp(options) {
1331
1419
  return renderError(route, errorId, withDuplicateInstanceHint(err));
1332
1420
  }
1333
1421
  // A loader may RETURN its control-flow signal instead of throwing it - `return redirect(...)`
1334
- // reads naturally and must not silently serialize the `Response` as loader data. Mirror the
1335
- // catch above exactly: a status signal renders its boundary, any other `Response` (a
1336
- // redirect, a hand-rolled response) passes through to core verbatim - so return and throw
1337
- // are interchangeable.
1422
+ // reads naturally and must not silently serialize the signal as loader data. Mirror the catch
1423
+ // above exactly: a status signal renders its boundary, any other signal (a redirect, a
1424
+ // hand-rolled response) passes through to core verbatim - so return and throw are
1425
+ // interchangeable.
1338
1426
  if (isStatusSignal(data))
1339
1427
  return renderStatusSignal(c.req, data);
1340
- if (data instanceof Response)
1428
+ if (isControlFlow(data))
1341
1429
  return data;
1342
1430
  // Client-side navigation asks (via the X-Nifra-Data header) for just the loader data - no full
1343
1431
  // document, no layout chain. A route with deferred data streams NDJSON (critical data first,
@@ -1353,20 +1441,28 @@ export function createWebApp(options) {
1353
1441
  offset += split.deferred.length;
1354
1442
  }
1355
1443
  const deferred = [...pageSplit.deferred, ...layoutSplits.flatMap((split) => split.deferred)];
1356
- const payload = layoutData === undefined
1444
+ const boundarySplit = boundaryStates === undefined
1445
+ ? undefined
1446
+ : prepareDeferred(boundaryStates, deferred.length);
1447
+ const allDeferred = [
1448
+ ...deferred,
1449
+ ...(boundarySplit === undefined ? [] : boundarySplit.deferred),
1450
+ ];
1451
+ const payload = layoutData === undefined && boundarySplit === undefined
1357
1452
  ? pageSplit.forClient
1358
1453
  : {
1359
1454
  v: 1,
1360
1455
  data: pageSplit.forClient,
1361
1456
  layoutData: layoutSplits.map((split) => split.forClient),
1362
1457
  retained: layoutRetained,
1458
+ ...(boundarySplit === undefined ? {} : { boundaries: boundarySplit.forClient }),
1363
1459
  };
1364
1460
  // Bare value when there is no layout data - the pre-envelope shape, so a client running older
1365
1461
  // code against a newer server keeps working.
1366
- if (deferred.length === 0) {
1462
+ if (allDeferred.length === 0) {
1367
1463
  return Response.json(payload ?? null);
1368
1464
  }
1369
- return new Response(ndjsonStream(payload, deferred), {
1465
+ return new Response(ndjsonStream(payload, allDeferred), {
1370
1466
  headers: { "content-type": "application/x-ndjson; charset=utf-8" },
1371
1467
  });
1372
1468
  }
@@ -1396,6 +1492,7 @@ export function createWebApp(options) {
1396
1492
  search: searchChainOf(layoutModules ?? [], mod, c.req),
1397
1493
  hydrate: hydrateRoute,
1398
1494
  ...(layoutData !== undefined ? { layoutData } : {}),
1495
+ ...(boundaryStates !== undefined ? { boundaries: boundaryStates } : {}),
1399
1496
  ...preloadOf(route.id),
1400
1497
  ...stylesOf(route.id),
1401
1498
  prerenderedPaths: options.prerenderedPaths ?? [],
@@ -1410,7 +1507,7 @@ export function createWebApp(options) {
1410
1507
  // Same precedence as the loader catch above - a `meta()` may signal too.
1411
1508
  if (isStatusSignal(err))
1412
1509
  return renderStatusSignal(c.req, err);
1413
- if (err instanceof Response)
1510
+ if (isControlFlow(err))
1414
1511
  throw err;
1415
1512
  // Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
1416
1513
  if (options.onLoaderError !== undefined) {
@@ -1427,7 +1524,7 @@ export function createWebApp(options) {
1427
1524
  return renderError(route, errorId, withDuplicateInstanceHint(err));
1428
1525
  }
1429
1526
  });
1430
- // POST runs the route's `action` (mutation). A `Response` return (e.g. a `redirect(...)`)
1527
+ // POST runs the route's `action` (mutation). A control-flow return (e.g. a `redirect(...)`)
1431
1528
  // passes straight through; a data return re-renders the page (the loader re-runs for fresh
1432
1529
  // data) with `actionData`. Routes without an action reject POST with 405 - not a stray 404.
1433
1530
  app.register("POST", route.pattern, undefined, async (c) => {
@@ -1458,10 +1555,10 @@ export function createWebApp(options) {
1458
1555
  result = await mod.action(actionContext);
1459
1556
  }
1460
1557
  catch (err) {
1461
- // A THROWN Response is the same control-flow signal as a returned one (`throw redirect()`
1462
- // in a gate or an action) - route it through the same conversion as the returned-Response
1463
- // branch below, so throw and return are interchangeable on the mutation path too.
1464
- if (err instanceof Response)
1558
+ // A THROWN signal is the same control flow as a returned one (`throw redirect()` in a gate
1559
+ // or an action) - route it through the same conversion as the returned branch below, so
1560
+ // throw and return are interchangeable on the mutation path too.
1561
+ if (isControlFlow(err))
1465
1562
  return actionResponse(err, isDataRequest);
1466
1563
  throw err;
1467
1564
  }
@@ -1473,7 +1570,7 @@ export function createWebApp(options) {
1473
1570
  const revalidateHeader = isRevalidate
1474
1571
  ? { [REVALIDATE_HEADER]: result.__nifraRevalidate.join(",") }
1475
1572
  : {};
1476
- if (actionResult instanceof Response)
1573
+ if (isControlFlow(actionResult))
1477
1574
  return actionResponse(actionResult, isDataRequest);
1478
1575
  // Client submit wants just the action's data (it revalidates the loader itself); a native
1479
1576
  // form POST re-renders the full page (loader re-runs) with the action data.
@@ -1648,6 +1745,9 @@ export function generateClientEntry(manifest, options) {
1648
1745
  // routeId → the page's client-only search keys (`searchClientKeys`, or `[]`). The router reads this
1649
1746
  // by reference to decide when a same-route search change can skip the loader fetch (re-render only).
1650
1747
  "const searchClientKeys = {}",
1748
+ // routeId → the page's optional post-hydration client loader/action hooks. Hooks are populated only
1749
+ // after the route chunk loads, so routes that do not declare them remain ordinary route modules.
1750
+ "const routeHooks = {}",
1651
1751
  "const loadModule = async (id) => {",
1652
1752
  " if (chains[id]) return",
1653
1753
  " const mods = await loaders[id]()",
@@ -1676,6 +1776,13 @@ export function generateClientEntry(manifest, options) {
1676
1776
  " searchSchemas[id] = mods.map((m) => m.searchSchema)",
1677
1777
  " searchClientKeys[id] = mods[mods.length - 1].searchClientKeys ?? []",
1678
1778
  " }",
1779
+ " const page = errorRouteIds.has(id) ? mods[mods.length - 2] : mods[mods.length - 1]",
1780
+ // Keep interception client-safe: only neutral name/mode metadata crosses into the router. The
1781
+ // server-side boundary load function (which may close over secrets) is never stored in the client
1782
+ // hook table or serialized into a browser bundle by this wiring.
1783
+ " const boundaryMods = errorRouteIds.has(id) ? mods.slice(0, -1) : mods",
1784
+ " const boundaries = boundaryMods.flatMap((m) => (m.boundaries ?? []).map((b) => ({ name: b.name, mode: b.mode, hasLoad: b.load !== undefined, ...(b.errorId === undefined ? {} : { errorId: b.errorId }) })))",
1785
+ " routeHooks[id] = { clientLoader: page.clientLoader, clientAction: page.clientAction, boundaries }",
1679
1786
  "}",
1680
1787
  "const patterns = [",
1681
1788
  ...patternRows,
@@ -1696,6 +1803,7 @@ export function generateClientEntry(manifest, options) {
1696
1803
  ` data: mapDeferred(window.${DATA_GLOBAL}),`,
1697
1804
  // Undefined on a page-only app, which is exactly what the router treats as "no layout data".
1698
1805
  ` layoutData: window.${LAYOUT_DATA_GLOBAL} && window.${LAYOUT_DATA_GLOBAL}.map(mapDeferred),`,
1806
+ ` boundaries: mapDeferred(window.${BOUNDARY_GLOBAL}),`,
1699
1807
  // actionData (only set after a form POST) is in the initial state so the binding hydrates
1700
1808
  // consistently with the server-rendered markup; mapped through `mapDeferred` too so a deferred
1701
1809
  // action's placeholders become registry markers (a no-op when the action didn't defer).
@@ -1703,7 +1811,7 @@ export function generateClientEntry(manifest, options) {
1703
1811
  " pending: false,",
1704
1812
  "}",
1705
1813
  `const statusRoutes = ${JSON.stringify(statusRoutes)}`,
1706
- "const router = createClientRouter({ patterns, initial, loadModule, statusRoutes, searchClientKeys })",
1814
+ "const router = createClientRouter({ patterns, initial, loadModule, statusRoutes, searchClientKeys, routeHooks })",
1707
1815
  "installHistory(router)",
1708
1816
  "installForms(router)",
1709
1817
  // The container is found in the DOM, not baked in. `rootId` is a per-render option and this entry
@@ -1722,6 +1830,10 @@ export function generateClientEntry(manifest, options) {
1722
1830
  // is server-rendered; subsequent navigations update it from the matched route's meta + data.
1723
1831
  "loadModule(initial.routeId).then(() => {",
1724
1832
  " mountRouter({ router, routes: chains, searchSchemas, container: root })",
1833
+ // Run the optional client loader only after the adapter has mounted the SSR tree. The initial
1834
+ // server data is already in `window.__NIFRA_DATA__`, so `serverLoader()` reuses it and cannot
1835
+ // duplicate the first request.
1836
+ " router.hydrate().catch((error) => console.error('[nifra/web] clientLoader failed:', error))",
1725
1837
  // Next frame: the adapter has committed its initial hydration, so handlers are attached. Flip the
1726
1838
  // document to interactive (`data-nifra-hydrated` + `nifra:hydrated`) for apps to gate pre-hydration UI.
1727
1839
  " requestAnimationFrame(signalHydrated)",