@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/src/index.ts CHANGED
@@ -13,7 +13,23 @@ import {
13
13
  type BackendMountHandler,
14
14
  NIFRA_BACKEND_MOUNT,
15
15
  } from "@nifrajs/core/mount"
16
- import { type ServerOptions, server, type UrlParts, urlPartsOf } from "@nifrajs/core/server"
16
+ import {
17
+ isSameOriginPath,
18
+ type ResponseResult,
19
+ type ServerOptions,
20
+ server,
21
+ status as statusResult,
22
+ type UrlParts,
23
+ urlPartsOf,
24
+ } from "@nifrajs/core/server"
25
+ import type { BoundaryRegistration } from "./boundary.ts"
26
+ import {
27
+ type BoundaryRequestCtx,
28
+ type BoundaryStates,
29
+ boundaryDescriptors,
30
+ resolveStaticBoundaries,
31
+ startDynamicBoundaries,
32
+ } from "./boundary.ts"
17
33
  import {
18
34
  DEFERRED_ERROR_CODE,
19
35
  DEFERRED_RUNTIME,
@@ -51,6 +67,7 @@ import type {
51
67
  import type { RenderAdapter, RenderProps } from "./render-seam.ts"
52
68
  import {
53
69
  ACTION_GLOBAL,
70
+ BOUNDARY_GLOBAL,
54
71
  DATA_GLOBAL,
55
72
  LAYOUT_DATA_GLOBAL,
56
73
  ROOT_ATTRIBUTE,
@@ -69,6 +86,33 @@ import {
69
86
  import { searchOf, searchOfChain } from "./search.ts"
70
87
 
71
88
  // Draft / preview mode - a signed cookie that flips `ctx.draft` for loaders + bypasses ISR for editors.
89
+ export {
90
+ assertStaticBoundaryImports,
91
+ type Boundary,
92
+ type BoundaryDescriptor,
93
+ type BoundaryError,
94
+ type BoundaryMode,
95
+ type BoundaryRegistration,
96
+ type BoundaryRequestCtx,
97
+ type BoundaryState,
98
+ type BoundaryStates,
99
+ type BoundaryStatus,
100
+ boundaryDescriptors,
101
+ boundaryModeKey,
102
+ type DynamicBoundary,
103
+ type DynamicBoundaryBatch,
104
+ type InterceptBoundary,
105
+ MemoryStaticBoundaryCache,
106
+ type PendingBoundary,
107
+ resolveDynamicBoundaries,
108
+ resolveStaticBoundaries,
109
+ type StaticBoundary,
110
+ type StaticBoundaryCache,
111
+ type StaticBoundaryImportEdge,
112
+ type StaticBoundaryRoot,
113
+ type StaticCtx,
114
+ startDynamicBoundaries,
115
+ } from "./boundary.ts"
72
116
  export {
73
117
  DRAFT_COOKIE,
74
118
  type DraftCookieControls,
@@ -112,6 +156,13 @@ export {
112
156
  export {
113
157
  type Action,
114
158
  buildManifest,
159
+ type ClientAction,
160
+ type ClientActionArgs,
161
+ type ClientActionResult,
162
+ type ClientLoader,
163
+ type ClientLoaderArgs,
164
+ type ClientRequestBody,
165
+ type ClientRouteHooks,
115
166
  enumerateStaticRoutes,
116
167
  filePathToPattern,
117
168
  filePathToPatterns,
@@ -245,6 +296,7 @@ export {
245
296
  } from "./conformance.ts"
246
297
  export {
247
298
  ACTION_GLOBAL,
299
+ BOUNDARY_GLOBAL,
248
300
  DATA_GLOBAL,
249
301
  LAYOUT_DATA_GLOBAL,
250
302
  type RenderAdapter,
@@ -312,6 +364,21 @@ const SCRIPT_ESCAPE_MAP: Readonly<Record<string, string>> = {
312
364
  const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body")
313
365
  const RESPONSE_RESULT = Symbol.for("nifra.response.result")
314
366
 
367
+ /**
368
+ * A control-flow value that renders as plain data - what `redirect()` returns, and what core's
369
+ * `status(...)` returns. Recognized here rather than imported: core keeps its own predicate internal,
370
+ * and the registry symbol is the contract between them (two copies of core must still agree).
371
+ */
372
+ const isResponseResult = (value: unknown): value is ResponseResult =>
373
+ typeof value === "object" &&
374
+ value !== null &&
375
+ (value as { readonly [RESPONSE_RESULT]?: unknown })[RESPONSE_RESULT] === true &&
376
+ typeof (value as { readonly toResponse?: unknown }).toResponse === "function"
377
+
378
+ /** A returned/thrown value that ends the request as-is: either shape of control-flow signal. */
379
+ const isControlFlow = (value: unknown): value is Response | ResponseResult =>
380
+ value instanceof Response || isResponseResult(value)
381
+
315
382
  export interface RenderedPage {
316
383
  readonly [RESPONSE_RESULT]: true
317
384
  toResponse(): Response
@@ -372,6 +439,8 @@ export interface RenderPageOptions {
372
439
  /** Per-layout loader data, forwarded to the adapter as `RenderProps.layoutData`. Aligned with the
373
440
  * chain's layout prefix; omitted when no layout in the chain has a loader. */
374
441
  readonly layoutData?: readonly unknown[]
442
+ /** Dynamic-boundary states, forwarded to the adapter and serialized for hydration when present. */
443
+ readonly boundaries?: BoundaryStates
375
444
  /** HTTP status for the response (default 200; e.g. 404 for a not-found page). */
376
445
  readonly status?: number
377
446
  /** Extra response headers - e.g. the `cache-control` a terminal status page wants. `content-type`
@@ -478,6 +547,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
478
547
  hydrate = true,
479
548
  islandScripts = [],
480
549
  nonce,
550
+ boundaries,
481
551
  headers: extraHeaders,
482
552
  } = options
483
553
  if (nonce !== undefined && nonce.trim() === "") {
@@ -512,12 +582,24 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
512
582
  actionData === undefined
513
583
  ? undefined
514
584
  : prepareDeferred(actionData, deferred.length + layoutDeferred.length)
585
+ const boundarySplit =
586
+ boundaries === undefined
587
+ ? undefined
588
+ : prepareDeferred(
589
+ boundaries,
590
+ deferred.length + layoutDeferred.length + (actionSplit?.deferred.length ?? 0),
591
+ )
515
592
  // On the common page-only path (no layout loader, no action) reuse `deferred` directly instead of
516
593
  // allocating a fresh spread array every request.
517
594
  const allDeferred =
518
- layoutDeferred.length === 0 && actionSplit === undefined
595
+ layoutDeferred.length === 0 && actionSplit === undefined && boundarySplit === undefined
519
596
  ? deferred
520
- : [...deferred, ...layoutDeferred, ...(actionSplit ? actionSplit.deferred : [])]
597
+ : [
598
+ ...deferred,
599
+ ...layoutDeferred,
600
+ ...(actionSplit ? actionSplit.deferred : []),
601
+ ...(boundarySplit ? boundarySplit.deferred : []),
602
+ ]
521
603
  // Omitted entirely when no layout has a loader - a page-only app emits exactly what it did before.
522
604
  const layoutTail =
523
605
  options.layoutData === undefined
@@ -529,6 +611,10 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
529
611
  actionSplit === undefined
530
612
  ? ""
531
613
  : `window.${ACTION_GLOBAL}=${serializeData(actionSplit.forClient)};`
614
+ const boundaryTail =
615
+ boundarySplit === undefined
616
+ ? ""
617
+ : `window.${BOUNDARY_GLOBAL}=${serializeData(boundarySplit.forClient)};`
532
618
  const deferredRuntime =
533
619
  allDeferred.length > 0 ? `<script${nonceAttr}>${DEFERRED_RUNTIME}</script>` : ""
534
620
  // The regex only injects the CSP nonce into the (constant) hydration head; with no nonce it's a
@@ -609,7 +695,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
609
695
  // Tail - the loader-data globals + the client module. Module scripts defer (run after parse), so
610
696
  // the data global + every streamed deferred resolution are set before the entry hydrates.
611
697
  const tailHtml = hydrate
612
- ? `${slot.tailPre}${action}${slot.tailMid}${layoutTail}${slot.tailData}${serializeData(forClient)}${slot.tailPost}`
698
+ ? `${slot.tailPre}${action}${slot.tailMid}${layoutTail}${boundaryTail}${slot.tailData}${serializeData(forClient)}${slot.tailPost}`
613
699
  : (slot.tailPost as string)
614
700
  const headers: Record<string, string> = { "content-type": "text/html; charset=utf-8" }
615
701
  // Caller-supplied headers (a terminal status page's `cache-control`, say). Applied before the
@@ -641,6 +727,9 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
641
727
  ...(options.layoutData !== undefined
642
728
  ? { layoutData: layoutSplits.map((split) => split.forComponent) }
643
729
  : {}),
730
+ ...(boundarySplit !== undefined
731
+ ? { boundaries: boundarySplit.forComponent as BoundaryStates }
732
+ : {}),
644
733
  }
645
734
 
646
735
  // Fast path: nothing `defer()`s and the adapter can render synchronously to a string → buffer the
@@ -817,31 +906,39 @@ export interface RedirectOptions {
817
906
  * leading `/`) is permitted, so an action can't be turned into an open redirect by passing
818
907
  * attacker-controlled input straight through. Set `true` for a deliberate external redirect. */
819
908
  readonly external?: boolean
909
+ /** Extra response headers. A redirect is no longer a `Response`, so there is no `.headers` to
910
+ * mutate after the fact - name them here. Cookies still ride `c.set`, as on any other response. */
911
+ readonly headers?: Readonly<Record<string, string>>
820
912
  }
821
913
 
822
914
  /** A same-origin destination is an absolute path: one leading `/`, but NOT `//` (protocol-relative →
823
- * another origin). Everything else (absolute URL with a scheme, `//host`, `javascript:`, a bare
824
- * relative `foo`) is treated as off-origin and requires `external: true`. */
825
- function isSameOriginPath(location: string): boolean {
826
- return location.startsWith("/") && !location.startsWith("//")
827
- }
915
+ * another origin), and nothing a URL parser resolves off-origin. Everything else (absolute URL with
916
+ * a scheme, `//host`, `javascript:`, a bare relative `foo`) requires `external: true`. The predicate
917
+ * lives in the kernel - see `isSameOriginPath` - so this gate and the auth guards' `redirectTo`
918
+ * cannot drift apart. */
828
919
 
829
920
  /**
830
- * Build a redirect `Response` - return it from a route `action` for the Post/Redirect/Get
831
- * pattern (POST mutates, 303 sends the browser to a fresh GET, so a reload doesn't re-submit).
832
- * Defaults to 303 (See Other); pass `{ status: 307 }` or `{ status: 308 }` to preserve the method.
921
+ * Build a redirect - return it from a route `action` for the Post/Redirect/Get pattern (POST
922
+ * mutates, 303 sends the browser to a fresh GET, so a reload doesn't re-submit). Defaults to 303
923
+ * (See Other); pass `{ status: 307 }` or `{ status: 308 }` to preserve the method.
833
924
  *
834
925
  * **Secure by default:** `location` must be a same-origin path (begins with `/`, not `//`). An
835
926
  * off-origin/absolute destination throws unless you pass `{ external: true }` - this closes the
836
927
  * open-redirect footgun of `return redirect(formData.get("next"))` on the no-JS (native-form) path,
837
- * which returns the action's `Response` verbatim.
928
+ * which serves the action's control-flow value verbatim.
929
+ *
930
+ * Returns a plain render, not a `Response`. A redirect is a status line and one header - the most
931
+ * body-less response there is - and building a `Response` for it costs the whole Web object plus, on
932
+ * Node, a stream drained back out. As data it renders on the same lane a handler's return takes.
933
+ * `redirect(...)` is still returned or thrown from exactly the same places; only `.status` /
934
+ * `.headers` are gone from the value, replaced by `options.headers` and the request's `c.set`.
838
935
  *
839
- * @param options redirect status and whether an off-origin destination is intentional.
936
+ * @param options redirect status, extra headers, and whether an off-origin destination is intentional.
840
937
  */
841
- export function redirect(location: string, options: RedirectOptions = {}): Response {
938
+ export function redirect(location: string, options: RedirectOptions = {}): ResponseResult {
842
939
  if (options.external !== true && !isSameOriginPath(location)) {
843
940
  throw new Error(
844
- `[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.`,
941
+ `[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.`,
845
942
  )
846
943
  }
847
944
  // Reject CR/LF in the Location explicitly - defense-in-depth (response splitting / header
@@ -853,7 +950,9 @@ export function redirect(location: string, options: RedirectOptions = {}): Respo
853
950
  `[nifra/web] redirect location contains a CR/LF character - refusing to emit a header-injecting redirect.`,
854
951
  )
855
952
  }
856
- return new Response(null, { status: options.status ?? 303, headers: { location } })
953
+ return statusResult(options.status ?? 303, undefined, {
954
+ headers: options.headers === undefined ? { location } : { ...options.headers, location },
955
+ })
857
956
  }
858
957
 
859
958
  /**
@@ -913,17 +1012,34 @@ function withDuplicateInstanceHint(err: unknown): unknown {
913
1012
  }
914
1013
 
915
1014
  /**
916
- * An action's `Response` passes straight through - except a redirect on a client-submit data
1015
+ * An action's control-flow value passes straight through - except a redirect on a client-submit data
917
1016
  * request: fetch would follow the 3xx into HTML the client can't use, so the redirect rides the
918
- * X-Nifra-Redirect header on a 204 and the client navigates. One conversion shared by the
919
- * returned- and thrown-Response paths, so `return redirect()` and `throw redirect()` agree.
1017
+ * X-Nifra-Redirect header on a 204 and the client navigates. One conversion shared by the returned-
1018
+ * and thrown- paths, so `return redirect()` and `throw redirect()` agree.
1019
+ *
1020
+ * Takes either shape: `redirect()` is a plain render, while a hand-rolled `new Response(...)` from an
1021
+ * action still arrives as a `Response`. The rewrite stays on the lane its input was on - a plain
1022
+ * redirect converts to a plain 204, and never materializes the `Response` it is replacing.
920
1023
  */
921
- function actionResponse(response: Response, isDataRequest: boolean): Response {
922
- if (isDataRequest && response.status >= 300 && response.status < 400) {
923
- const location = response.headers.get("location") ?? "/"
924
- return new Response(null, { status: 204, headers: { [REDIRECT_HEADER]: location } })
1024
+ function actionResponse(
1025
+ result: Response | ResponseResult,
1026
+ isDataRequest: boolean,
1027
+ ): Response | ResponseResult {
1028
+ if (!isDataRequest) return result
1029
+ if (isResponseResult(result)) {
1030
+ const plain = result.plain
1031
+ // No `plain` means a carrier that only knows how to build a `Response` (not one of ours) - fall
1032
+ // back rather than guess at its status.
1033
+ if (plain === undefined) return actionResponse(result.toResponse(), isDataRequest)
1034
+ if (plain.status < 300 || plain.status >= 400) return result
1035
+ const location = plain.headers?.location ?? "/"
1036
+ return statusResult(204, undefined, { headers: { [REDIRECT_HEADER]: location } })
925
1037
  }
926
- return response
1038
+ if (result.status >= 300 && result.status < 400) {
1039
+ const location = result.headers.get("location") ?? "/"
1040
+ return statusResult(204, undefined, { headers: { [REDIRECT_HEADER]: location } })
1041
+ }
1042
+ return result
927
1043
  }
928
1044
 
929
1045
  /** A loaded layout module. `loader`/`gate` are the layout-loader surface; `meta` predates it. */
@@ -936,6 +1052,7 @@ type LoadedLayoutModules = ReadonlyArray<{
936
1052
  // A layout may declare its own `searchSchema`; the route's effective search merges the layout chain's
937
1053
  // schemas with the page's (page-wins). Present on the raw module already - typed here so it is readable.
938
1054
  searchSchema?: RouteModule["searchSchema"]
1055
+ boundaries?: readonly BoundaryRegistration[]
939
1056
  }>
940
1057
 
941
1058
  /**
@@ -1447,6 +1564,9 @@ export interface CreateWebAppOptions<Env = unknown> {
1447
1564
  * `prerenderedPaths` with the 404 page - the unlisted path simply doesn't exist. `"ssr"` (the
1448
1565
  * default for unmapped routes) renders unlisted paths on-demand. */
1449
1566
  readonly staticFallbacks?: Readonly<Record<string, "ssr" | "404">>
1567
+ /** In-memory reference cache for explicitly `static` boundary values. It never persists payloads
1568
+ * across processes; operated/durable cache implementations stay outside the public framework. */
1569
+ readonly staticBoundaryCache?: import("./boundary.ts").StaticBoundaryCache
1450
1570
  /** Observe every loader/action failure - for error-reporting plugins (Sentry-style). Called for
1451
1571
  * real throws (not control-flow `Response`s like `redirect`), **before** the nearest `_error`
1452
1572
  * boundary renders / a soft-nav 500 / a rethrow - so it sees errors that the boundary would
@@ -2038,6 +2158,7 @@ export function createWebApp<Env = unknown>(
2038
2158
  const draft = await draftFlag(c.req)
2039
2159
  let data: unknown
2040
2160
  let layoutData: readonly unknown[] | undefined
2161
+ let boundaryStates: BoundaryStates | undefined
2041
2162
  let layoutModules: LoadedLayoutModules | undefined
2042
2163
  let layoutRetained: readonly number[] = []
2043
2164
  try {
@@ -2064,25 +2185,65 @@ export function createWebApp<Env = unknown>(
2064
2185
  )
2065
2186
  layoutModules = run.modules
2066
2187
  layoutRetained = run.retained
2188
+ const boundaryDefinitions = [
2189
+ ...run.modules.flatMap((layout) => layout.boundaries ?? []),
2190
+ ...(mod.boundaries ?? []),
2191
+ ]
2192
+ if (boundaryDefinitions.length > 0) boundaryDescriptors(boundaryDefinitions)
2193
+ const effectiveSearch = searchChainOf(run.modules, mod, c.req)
2194
+ const boundaryBatch =
2195
+ boundaryDefinitions.length === 0
2196
+ ? undefined
2197
+ : startDynamicBoundaries(boundaryDefinitions, {
2198
+ request: c.req,
2199
+ params: c.params,
2200
+ api,
2201
+ env: c.env,
2202
+ draft,
2203
+ search: effectiveSearch,
2204
+ signal: c.req.signal,
2205
+ } satisfies BoundaryRequestCtx)
2206
+ const staticBoundaryPromise =
2207
+ boundaryDefinitions.length === 0
2208
+ ? Promise.resolve(undefined)
2209
+ : resolveStaticBoundaries(
2210
+ boundaryDefinitions,
2211
+ { phase: "build", origin: originOf(c.req) },
2212
+ options.staticBoundaryCache,
2213
+ )
2214
+ // Boundary data is attached as one Deferred marker per slot. The shell can render
2215
+ // immediately with each slot pending, while the existing deferred protocol emits the first
2216
+ // completed sibling independently instead of waiting for the slowest boundary.
2067
2217
  // The page loader gets the EFFECTIVE search (layout chain + page, page-wins), now that the layout
2068
2218
  // modules are loaded - so `ctx.search` includes a layout's shared keys (`?org`), matching `useSearch`.
2069
- const [pageData] = await Promise.all([
2070
- mod.loader
2071
- ? mod.loader({ ...ctx, search: searchChainOf(run.modules, mod, c.req) })
2072
- : null,
2219
+ const [pageData, , staticBoundaries] = await Promise.all([
2220
+ mod.loader ? mod.loader({ ...ctx, search: effectiveSearch }) : null,
2073
2221
  run.pending, // a non-gate layout loader that rejects must still surface, not go unhandled
2222
+ staticBoundaryPromise,
2074
2223
  ])
2075
2224
  data = pageData
2076
2225
  layoutData = run.layoutData
2226
+ if (boundaryBatch !== undefined) {
2227
+ const streamed = { ...boundaryBatch.initial, ...(staticBoundaries ?? {}) }
2228
+ for (const entry of boundaryBatch.pending) {
2229
+ const initial = streamed[entry.name]
2230
+ if (initial !== undefined)
2231
+ streamed[entry.name] = { ...initial, data: defer(entry.promise) }
2232
+ }
2233
+ boundaryStates = streamed
2234
+ } else if (staticBoundaries !== undefined) {
2235
+ boundaryStates = staticBoundaries
2236
+ }
2077
2237
  } catch (err) {
2078
2238
  // A terminal-status signal (`notFound()` / `gone()` / `statusPage(n)`) renders a boundary at
2079
2239
  // that status. Checked BEFORE the pass-through below: a signal IS a `Response`, so the order
2080
2240
  // is what keeps a hand-rolled `throw new Response(...)` served verbatim, as it always was.
2081
2241
  if (isStatusSignal(err)) return renderStatusSignal(c.req, err)
2082
- // A thrown `Response` is a control-flow signal (a guard's `redirect(...)`, an explicit error
2083
- // response) - let it propagate to core, which returns it as-is. Real errors render the nearest
2084
- // `_error` boundary, if any; with none, rethrow (unchanged 500 behavior).
2085
- if (err instanceof Response) throw err
2242
+ // A thrown control-flow signal (a guard's `redirect(...)`, an explicit error response) - let
2243
+ // it propagate to core, which renders it as-is, whether it is a plain render or a
2244
+ // hand-rolled `Response`. Real errors render the nearest `_error` boundary, if any; with
2245
+ // none, rethrow (unchanged 500 behavior).
2246
+ if (isControlFlow(err)) throw err
2086
2247
  // Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
2087
2248
  if (options.onLoaderError !== undefined) {
2088
2249
  try {
@@ -2104,12 +2265,12 @@ export function createWebApp<Env = unknown>(
2104
2265
  return renderError(route, errorId, withDuplicateInstanceHint(err))
2105
2266
  }
2106
2267
  // A loader may RETURN its control-flow signal instead of throwing it - `return redirect(...)`
2107
- // reads naturally and must not silently serialize the `Response` as loader data. Mirror the
2108
- // catch above exactly: a status signal renders its boundary, any other `Response` (a
2109
- // redirect, a hand-rolled response) passes through to core verbatim - so return and throw
2110
- // are interchangeable.
2268
+ // reads naturally and must not silently serialize the signal as loader data. Mirror the catch
2269
+ // above exactly: a status signal renders its boundary, any other signal (a redirect, a
2270
+ // hand-rolled response) passes through to core verbatim - so return and throw are
2271
+ // interchangeable.
2111
2272
  if (isStatusSignal(data)) return renderStatusSignal(c.req, data)
2112
- if (data instanceof Response) return data
2273
+ if (isControlFlow(data)) return data
2113
2274
  // Client-side navigation asks (via the X-Nifra-Data header) for just the loader data - no full
2114
2275
  // document, no layout chain. A route with deferred data streams NDJSON (critical data first,
2115
2276
  // then each deferred value as it settles); otherwise one JSON (the fast path). Same loader,
@@ -2124,21 +2285,30 @@ export function createWebApp<Env = unknown>(
2124
2285
  offset += split.deferred.length
2125
2286
  }
2126
2287
  const deferred = [...pageSplit.deferred, ...layoutSplits.flatMap((split) => split.deferred)]
2288
+ const boundarySplit =
2289
+ boundaryStates === undefined
2290
+ ? undefined
2291
+ : prepareDeferred(boundaryStates, deferred.length)
2292
+ const allDeferred = [
2293
+ ...deferred,
2294
+ ...(boundarySplit === undefined ? [] : boundarySplit.deferred),
2295
+ ]
2127
2296
  const payload =
2128
- layoutData === undefined
2297
+ layoutData === undefined && boundarySplit === undefined
2129
2298
  ? pageSplit.forClient
2130
2299
  : {
2131
2300
  v: 1 as const,
2132
2301
  data: pageSplit.forClient,
2133
2302
  layoutData: layoutSplits.map((split) => split.forClient),
2134
2303
  retained: layoutRetained,
2304
+ ...(boundarySplit === undefined ? {} : { boundaries: boundarySplit.forClient }),
2135
2305
  }
2136
2306
  // Bare value when there is no layout data - the pre-envelope shape, so a client running older
2137
2307
  // code against a newer server keeps working.
2138
- if (deferred.length === 0) {
2308
+ if (allDeferred.length === 0) {
2139
2309
  return Response.json(payload ?? null)
2140
2310
  }
2141
- return new Response(ndjsonStream(payload, deferred), {
2311
+ return new Response(ndjsonStream(payload, allDeferred), {
2142
2312
  headers: { "content-type": "application/x-ndjson; charset=utf-8" },
2143
2313
  })
2144
2314
  }
@@ -2168,6 +2338,7 @@ export function createWebApp<Env = unknown>(
2168
2338
  search: searchChainOf(layoutModules ?? [], mod, c.req),
2169
2339
  hydrate: hydrateRoute,
2170
2340
  ...(layoutData !== undefined ? { layoutData } : {}),
2341
+ ...(boundaryStates !== undefined ? { boundaries: boundaryStates } : {}),
2171
2342
  ...preloadOf(route.id),
2172
2343
  ...stylesOf(route.id),
2173
2344
  prerenderedPaths: options.prerenderedPaths ?? [],
@@ -2180,7 +2351,7 @@ export function createWebApp<Env = unknown>(
2180
2351
  } catch (err) {
2181
2352
  // Same precedence as the loader catch above - a `meta()` may signal too.
2182
2353
  if (isStatusSignal(err)) return renderStatusSignal(c.req, err)
2183
- if (err instanceof Response) throw err
2354
+ if (isControlFlow(err)) throw err
2184
2355
  // Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
2185
2356
  if (options.onLoaderError !== undefined) {
2186
2357
  try {
@@ -2195,7 +2366,7 @@ export function createWebApp<Env = unknown>(
2195
2366
  }
2196
2367
  })
2197
2368
 
2198
- // POST runs the route's `action` (mutation). A `Response` return (e.g. a `redirect(...)`)
2369
+ // POST runs the route's `action` (mutation). A control-flow return (e.g. a `redirect(...)`)
2199
2370
  // passes straight through; a data return re-renders the page (the loader re-runs for fresh
2200
2371
  // data) with `actionData`. Routes without an action reject POST with 405 - not a stray 404.
2201
2372
  app.register("POST", route.pattern, undefined, async (c: RouteContext) => {
@@ -2225,10 +2396,10 @@ export function createWebApp<Env = unknown>(
2225
2396
  layoutModules = await runLayoutGates(route, actionContext)
2226
2397
  result = await mod.action(actionContext)
2227
2398
  } catch (err) {
2228
- // A THROWN Response is the same control-flow signal as a returned one (`throw redirect()`
2229
- // in a gate or an action) - route it through the same conversion as the returned-Response
2230
- // branch below, so throw and return are interchangeable on the mutation path too.
2231
- if (err instanceof Response) return actionResponse(err, isDataRequest)
2399
+ // A THROWN signal is the same control flow as a returned one (`throw redirect()` in a gate
2400
+ // or an action) - route it through the same conversion as the returned branch below, so
2401
+ // throw and return are interchangeable on the mutation path too.
2402
+ if (isControlFlow(err)) return actionResponse(err, isDataRequest)
2232
2403
  throw err
2233
2404
  }
2234
2405
  // An action may wrap its data in `revalidate(paths, data)` to declare which routes it changed.
@@ -2240,7 +2411,7 @@ export function createWebApp<Env = unknown>(
2240
2411
  const revalidateHeader: Record<string, string> = isRevalidate
2241
2412
  ? { [REVALIDATE_HEADER]: (result as RevalidateResult<unknown>).__nifraRevalidate.join(",") }
2242
2413
  : {}
2243
- if (actionResult instanceof Response) return actionResponse(actionResult, isDataRequest)
2414
+ if (isControlFlow(actionResult)) return actionResponse(actionResult, isDataRequest)
2244
2415
  // Client submit wants just the action's data (it revalidates the loader itself); a native
2245
2416
  // form POST re-renders the full page (loader re-runs) with the action data.
2246
2417
  if (isDataRequest) {
@@ -2445,6 +2616,9 @@ export function generateClientEntry(
2445
2616
  // routeId → the page's client-only search keys (`searchClientKeys`, or `[]`). The router reads this
2446
2617
  // by reference to decide when a same-route search change can skip the loader fetch (re-render only).
2447
2618
  "const searchClientKeys = {}",
2619
+ // routeId → the page's optional post-hydration client loader/action hooks. Hooks are populated only
2620
+ // after the route chunk loads, so routes that do not declare them remain ordinary route modules.
2621
+ "const routeHooks = {}",
2448
2622
  "const loadModule = async (id) => {",
2449
2623
  " if (chains[id]) return",
2450
2624
  " const mods = await loaders[id]()",
@@ -2473,6 +2647,13 @@ export function generateClientEntry(
2473
2647
  " searchSchemas[id] = mods.map((m) => m.searchSchema)",
2474
2648
  " searchClientKeys[id] = mods[mods.length - 1].searchClientKeys ?? []",
2475
2649
  " }",
2650
+ " const page = errorRouteIds.has(id) ? mods[mods.length - 2] : mods[mods.length - 1]",
2651
+ // Keep interception client-safe: only neutral name/mode metadata crosses into the router. The
2652
+ // server-side boundary load function (which may close over secrets) is never stored in the client
2653
+ // hook table or serialized into a browser bundle by this wiring.
2654
+ " const boundaryMods = errorRouteIds.has(id) ? mods.slice(0, -1) : mods",
2655
+ " 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 }) })))",
2656
+ " routeHooks[id] = { clientLoader: page.clientLoader, clientAction: page.clientAction, boundaries }",
2476
2657
  "}",
2477
2658
  "const patterns = [",
2478
2659
  ...patternRows,
@@ -2493,6 +2674,7 @@ export function generateClientEntry(
2493
2674
  ` data: mapDeferred(window.${DATA_GLOBAL}),`,
2494
2675
  // Undefined on a page-only app, which is exactly what the router treats as "no layout data".
2495
2676
  ` layoutData: window.${LAYOUT_DATA_GLOBAL} && window.${LAYOUT_DATA_GLOBAL}.map(mapDeferred),`,
2677
+ ` boundaries: mapDeferred(window.${BOUNDARY_GLOBAL}),`,
2496
2678
  // actionData (only set after a form POST) is in the initial state so the binding hydrates
2497
2679
  // consistently with the server-rendered markup; mapped through `mapDeferred` too so a deferred
2498
2680
  // action's placeholders become registry markers (a no-op when the action didn't defer).
@@ -2500,7 +2682,7 @@ export function generateClientEntry(
2500
2682
  " pending: false,",
2501
2683
  "}",
2502
2684
  `const statusRoutes = ${JSON.stringify(statusRoutes)}`,
2503
- "const router = createClientRouter({ patterns, initial, loadModule, statusRoutes, searchClientKeys })",
2685
+ "const router = createClientRouter({ patterns, initial, loadModule, statusRoutes, searchClientKeys, routeHooks })",
2504
2686
  "installHistory(router)",
2505
2687
  "installForms(router)",
2506
2688
  // The container is found in the DOM, not baked in. `rootId` is a per-render option and this entry
@@ -2521,6 +2703,10 @@ export function generateClientEntry(
2521
2703
  // is server-rendered; subsequent navigations update it from the matched route's meta + data.
2522
2704
  "loadModule(initial.routeId).then(() => {",
2523
2705
  " mountRouter({ router, routes: chains, searchSchemas, container: root })",
2706
+ // Run the optional client loader only after the adapter has mounted the SSR tree. The initial
2707
+ // server data is already in `window.__NIFRA_DATA__`, so `serverLoader()` reuses it and cannot
2708
+ // duplicate the first request.
2709
+ " router.hydrate().catch((error) => console.error('[nifra/web] clientLoader failed:', error))",
2524
2710
  // Next frame: the adapter has committed its initial hydration, so handlers are attached. Flip the
2525
2711
  // document to interactive (`data-nifra-hydrated` + `nifra:hydrated`) for apps to gate pre-hydration UI.
2526
2712
  " requestAnimationFrame(signalHydrated)",
@@ -0,0 +1,36 @@
1
+ import type { ClientRouter, Fetcher, FetcherState } from "../router.ts"
2
+
3
+ /** Stable idle state shared by every framework fetcher binding. */
4
+ export const IDLE_FETCHER_STATE: FetcherState = Object.freeze({
5
+ pending: false,
6
+ data: undefined,
7
+ })
8
+
9
+ /** Stable empty collection used before a router is mounted. */
10
+ export const NO_FETCHERS: readonly Fetcher[] = Object.freeze([])
11
+
12
+ /** No-op subscription used by SSR and pre-mount snapshots. */
13
+ export const noopSubscribe = (): (() => void) => () => {}
14
+
15
+ /** No-op fetcher action used before a router is mounted. */
16
+ export const noopAsync = async (): Promise<void> => {}
17
+
18
+ /** Stable idle fetcher snapshot for external-store bindings. */
19
+ export const idleFetcherSnapshot = (): FetcherState => IDLE_FETCHER_STATE
20
+
21
+ /** Stable empty fetcher collection snapshot for external-store bindings. */
22
+ export const noFetchers = (): readonly Fetcher[] => NO_FETCHERS
23
+
24
+ /** Keep mounted-router state local to each adapter package. */
25
+ export function createMountedRouterRef(): {
26
+ readonly get: () => ClientRouter | undefined
27
+ readonly set: (router: ClientRouter | undefined) => void
28
+ } {
29
+ let router: ClientRouter | undefined
30
+ return {
31
+ get: () => router,
32
+ set: (next) => {
33
+ router = next
34
+ },
35
+ }
36
+ }