@nifrajs/web 3.1.0 → 3.3.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 (114) hide show
  1. package/dist/build-plan.d.ts +228 -0
  2. package/dist/build-plan.d.ts.map +1 -0
  3. package/dist/build-plan.js +514 -0
  4. package/dist/build-plan.js.map +1 -0
  5. package/dist/build-vite.d.ts +2 -1
  6. package/dist/build-vite.d.ts.map +1 -1
  7. package/dist/build-vite.js.map +1 -1
  8. package/dist/build.d.ts +2 -197
  9. package/dist/build.d.ts.map +1 -1
  10. package/dist/build.js +61 -502
  11. package/dist/build.js.map +1 -1
  12. package/dist/chunk-names.d.ts.map +1 -1
  13. package/dist/chunk-names.js +3 -2
  14. package/dist/chunk-names.js.map +1 -1
  15. package/dist/dev-ssr-graph.d.ts.map +1 -1
  16. package/dist/dev-ssr-graph.js +63 -28
  17. package/dist/dev-ssr-graph.js.map +1 -1
  18. package/dist/diagnostic.d.ts.map +1 -1
  19. package/dist/diagnostic.js +9 -2
  20. package/dist/diagnostic.js.map +1 -1
  21. package/dist/fonts-google.d.ts.map +1 -1
  22. package/dist/fonts-google.js +3 -1
  23. package/dist/fonts-google.js.map +1 -1
  24. package/dist/fs.d.ts.map +1 -1
  25. package/dist/fs.js +2 -1
  26. package/dist/fs.js.map +1 -1
  27. package/dist/index.d.ts +15 -609
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +22 -1935
  30. package/dist/index.js.map +1 -1
  31. package/dist/internal/codegen.d.ts +68 -0
  32. package/dist/internal/codegen.d.ts.map +1 -0
  33. package/dist/internal/codegen.js +317 -0
  34. package/dist/internal/codegen.js.map +1 -0
  35. package/dist/internal/head-merge.d.ts +2 -0
  36. package/dist/internal/head-merge.d.ts.map +1 -1
  37. package/dist/internal/head-merge.js +17 -1
  38. package/dist/internal/head-merge.js.map +1 -1
  39. package/dist/internal/parity.d.ts +7 -0
  40. package/dist/internal/parity.d.ts.map +1 -1
  41. package/dist/internal/parity.js +212 -30
  42. package/dist/internal/parity.js.map +1 -1
  43. package/dist/internal/render-document.d.ts +372 -0
  44. package/dist/internal/render-document.d.ts.map +1 -0
  45. package/dist/internal/render-document.js +744 -0
  46. package/dist/internal/render-document.js.map +1 -0
  47. package/dist/internal/runtime-contract.d.ts +48 -0
  48. package/dist/internal/runtime-contract.d.ts.map +1 -0
  49. package/dist/internal/runtime-contract.js +27 -0
  50. package/dist/internal/runtime-contract.js.map +1 -0
  51. package/dist/internal/web-app.d.ts +171 -0
  52. package/dist/internal/web-app.d.ts.map +1 -0
  53. package/dist/internal/web-app.js +844 -0
  54. package/dist/internal/web-app.js.map +1 -0
  55. package/dist/islands.d.ts +48 -0
  56. package/dist/islands.d.ts.map +1 -1
  57. package/dist/islands.js +79 -0
  58. package/dist/islands.js.map +1 -1
  59. package/dist/module-graph.d.ts.map +1 -1
  60. package/dist/module-graph.js +22 -9
  61. package/dist/module-graph.js.map +1 -1
  62. package/dist/nano.d.ts +125 -0
  63. package/dist/nano.d.ts.map +1 -0
  64. package/dist/nano.js +190 -0
  65. package/dist/nano.js.map +1 -0
  66. package/dist/plugins/css-modules.js +2 -2
  67. package/dist/plugins/css-modules.js.map +1 -1
  68. package/dist/plugins/kit.d.ts +12 -0
  69. package/dist/plugins/kit.d.ts.map +1 -1
  70. package/dist/plugins/kit.js +62 -12
  71. package/dist/plugins/kit.js.map +1 -1
  72. package/dist/plugins/postcss.js +2 -2
  73. package/dist/plugins/postcss.js.map +1 -1
  74. package/dist/plugins/scss.js +2 -2
  75. package/dist/plugins/scss.js.map +1 -1
  76. package/dist/plugins/stylex.d.ts +70 -0
  77. package/dist/plugins/stylex.d.ts.map +1 -0
  78. package/dist/plugins/stylex.js +166 -0
  79. package/dist/plugins/stylex.js.map +1 -0
  80. package/dist/plugins/svg.js +2 -2
  81. package/dist/plugins/svg.js.map +1 -1
  82. package/dist/router.d.ts +1 -1
  83. package/dist/router.js +23 -23
  84. package/dist/router.js.map +1 -1
  85. package/dist/vite.d.ts.map +1 -1
  86. package/dist/vite.js +26 -10
  87. package/dist/vite.js.map +1 -1
  88. package/package.json +39 -3
  89. package/src/build-plan.ts +741 -0
  90. package/src/build-vite.ts +1 -4
  91. package/src/build.ts +84 -716
  92. package/src/chunk-names.ts +3 -2
  93. package/src/dev-ssr-graph.ts +63 -28
  94. package/src/diagnostic.ts +14 -2
  95. package/src/fonts-google.ts +3 -1
  96. package/src/fs.ts +2 -1
  97. package/src/index.ts +73 -2705
  98. package/src/internal/codegen.ts +400 -0
  99. package/src/internal/head-merge.ts +19 -1
  100. package/src/internal/parity.ts +215 -35
  101. package/src/internal/render-document.ts +1116 -0
  102. package/src/internal/runtime-contract.ts +54 -0
  103. package/src/internal/web-app.ts +1114 -0
  104. package/src/islands.ts +85 -0
  105. package/src/module-graph.ts +23 -9
  106. package/src/nano.ts +246 -0
  107. package/src/plugins/css-modules.ts +2 -2
  108. package/src/plugins/kit.ts +65 -12
  109. package/src/plugins/postcss.ts +2 -2
  110. package/src/plugins/scss.ts +2 -2
  111. package/src/plugins/stylex.ts +298 -0
  112. package/src/plugins/svg.ts +2 -2
  113. package/src/router.ts +24 -24
  114. package/src/vite.ts +33 -9
@@ -0,0 +1,1116 @@
1
+ import { isSameOriginPath, type ResponseResult, status as statusResult } from "@nifrajs/core/server"
2
+ import type { BoundaryRegistration, BoundaryStates } from "../boundary.ts"
3
+ import { DEFERRED_ERROR_CODE, DEFERRED_RUNTIME, prepareDeferred } from "../deferred.ts"
4
+ import { ISR_REVALIDATE_HEADER, ISR_REVALIDATE_TAGS_HEADER, serializeISRTags } from "../isr.ts"
5
+ import type {
6
+ Loader as LayoutLoader,
7
+ LinkDescriptor,
8
+ Meta,
9
+ MetaDescriptor,
10
+ MetaInput,
11
+ RouteModule,
12
+ ScriptDescriptor,
13
+ UnsafeScriptDescriptor,
14
+ } from "../manifest.ts"
15
+ import type { RenderAdapter, RenderProps } from "../render-seam.ts"
16
+ import {
17
+ ACTION_GLOBAL,
18
+ BOUNDARY_GLOBAL,
19
+ DATA_GLOBAL,
20
+ LAYOUT_DATA_GLOBAL,
21
+ ROOT_ATTRIBUTE,
22
+ ROUTE_GLOBAL,
23
+ } from "../render-seam.ts"
24
+ import { PRERENDERED_GLOBAL, REDIRECT_HEADER } from "../router.ts"
25
+ import { trustedHeadAttributes } from "./head-attributes.ts"
26
+ import { isStaticMeta, mergeHeads } from "./head-merge.ts"
27
+ import { PRE_HYDRATION_GUARD } from "./runtime-contract.ts"
28
+ import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./script-types.ts"
29
+
30
+ const TEXT_ENCODER = new TextEncoder()
31
+
32
+ type MaybePromise<T> = T | Promise<T>
33
+
34
+ // XSS-safe `<script>` JSON escaping in a SINGLE pass: `<`/`>` would break out of the
35
+ // script element; U+2028/U+2029 are valid JSON but historically break JS string literals. Built via
36
+ // fromCharCode so no raw separator chars live in this source. One regex + one output string replaces
37
+ // the prior four sequential `replaceAll` full-string passes.
38
+ const LINE_SEP = String.fromCharCode(0x2028)
39
+ const PARA_SEP = String.fromCharCode(0x2029)
40
+ const SCRIPT_ESCAPE = new RegExp(`[<>${LINE_SEP}${PARA_SEP}]`, "g")
41
+ const SCRIPT_ESCAPE_MAP: Readonly<Record<string, string>> = {
42
+ "<": "\\u003c",
43
+ ">": "\\u003e",
44
+ [LINE_SEP]: "\\u2028",
45
+ [PARA_SEP]: "\\u2029",
46
+ }
47
+ const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body")
48
+ const RESPONSE_RESULT = Symbol.for("nifra.response.result")
49
+
50
+ /**
51
+ * A control-flow value that renders as plain data - what `redirect()` returns, and what core's
52
+ * `status(...)` returns. Recognized here rather than imported: core keeps its own predicate internal,
53
+ * and the registry symbol is the contract between them (two copies of core must still agree).
54
+ */
55
+ const isResponseResult = (value: unknown): value is ResponseResult =>
56
+ typeof value === "object" &&
57
+ value !== null &&
58
+ (value as { readonly [RESPONSE_RESULT]?: unknown })[RESPONSE_RESULT] === true &&
59
+ typeof (value as { readonly toResponse?: unknown }).toResponse === "function"
60
+
61
+ /** A returned/thrown value that ends the request as-is: either shape of control-flow signal. */
62
+ export const isControlFlow = (value: unknown): value is Response | ResponseResult =>
63
+ value instanceof Response || isResponseResult(value)
64
+
65
+ export interface RenderedPage {
66
+ readonly [RESPONSE_RESULT]: true
67
+ toResponse(): Response
68
+ toNodeBody?(): {
69
+ readonly status: number
70
+ readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
71
+ readonly body: string | Uint8Array
72
+ }
73
+ }
74
+
75
+ export interface RenderPageOptions {
76
+ readonly adapter: RenderAdapter
77
+ /** The layout chain to render - outermost layout → page (opaque; the adapter renders it). */
78
+ readonly chain: readonly unknown[]
79
+ /** The loader output for this request. */
80
+ readonly data: unknown
81
+ /** An action's data return (POST only) - surfaced to the page as `actionData` + serialized
82
+ * for the client so post-POST hydration matches. Omit on GETs. */
83
+ readonly actionData?: unknown
84
+ /**
85
+ * URL of the built client entry (loaded as a module script).
86
+ *
87
+ * Optional ONLY on a `hydrate: false` page, where there is no client takeover to feed and this value
88
+ * reaches no output at all. The union below is what enforces that: omitting it on a hydrating page
89
+ * stays a compile error rather than rendering `src=""`.
90
+ */
91
+ readonly clientEntry?: string
92
+ /** Chunk URLs for the **matched** route (its layout chain + own chunk) to `modulepreload` in the
93
+ * shell - so the route code downloads in parallel with the entry instead of after it
94
+ * (`buildClient`'s per-route map). Empty/omitted ⇒ only the entry is preloaded (unchanged). */
95
+ readonly preload?: readonly string[]
96
+ /** Stylesheet URLs for the **matched** route (its layout chain + own CSS, from `buildClient`'s
97
+ * `BuildManifest.css`) - injected as `<link rel="stylesheet">` in `<head>` so styles arrive with the
98
+ * first paint (no FOUC). Rendered even on non-hydrated pages. Empty/omitted ⇒ none (unchanged). */
99
+ readonly styles?: readonly string[]
100
+ /** SSG: the prerendered-path set, serialized to `window.__NIFRA_PRERENDERED__` so the client fetches
101
+ * a static `_data.json` on soft-nav into a prerendered route. Empty/omitted ⇒ not injected. */
102
+ readonly prerenderedPaths?: readonly string[]
103
+ /** ISR: route freshness in seconds, emitted as the `x-nifra-isr-revalidate` header for a `withISR`
104
+ * wrapper to read. Omit ⇒ no header (the wrapper's default TTL applies). */
105
+ readonly revalidate?: number
106
+ /** ISR invalidation tags emitted as a bounded `x-nifra-isr-tags` header. */
107
+ readonly revalidateTags?: readonly string[]
108
+ /** Matched route id; written to `window.__NIFRA_ROUTE__` so the client hydrates this chain. */
109
+ readonly routeId?: string
110
+ /** The matched route's decoded path params - surfaced to the page as `params` (via {@link RenderProps})
111
+ * so an adapter's `useParams` is SSR-correct. Omit ⇒ `{}` (a route with no dynamic segments, or an
112
+ * error/404 render). */
113
+ readonly params?: Readonly<Record<string, string>>
114
+ /** The request's `pathname + search` - surfaced as `path` (via {@link RenderProps}) so an adapter's
115
+ * `useLocation`/`useSearchParams` render the right URL server-side and hydrate without drift. Omit ⇒
116
+ * `""`. */
117
+ readonly path?: string
118
+ /** The route's validated search (`searchOf(mod.searchSchema, url.search)`, the same value handed to
119
+ * the loader as `ctx.search`), surfaced as `search` (via {@link RenderProps}) so an adapter's
120
+ * `useSearch` is SSR-correct. Omit ⇒ `{}` (a render with no search context). */
121
+ readonly search?: Record<string, unknown>
122
+ /** Per-layout loader data, forwarded to the adapter as `RenderProps.layoutData`. Aligned with the
123
+ * chain's layout prefix; omitted when no layout in the chain has a loader. */
124
+ readonly layoutData?: readonly unknown[]
125
+ /** Dynamic-boundary states, forwarded to the adapter and serialized for hydration when present. */
126
+ readonly boundaries?: BoundaryStates
127
+ /** HTTP status for the response (default 200; e.g. 404 for a not-found page). */
128
+ readonly status?: number
129
+ /** Extra response headers - e.g. the `cache-control` a terminal status page wants. `content-type`
130
+ * is ignored (the document is always HTML) and the ISR freshness header is applied after these,
131
+ * so neither can be overridden from here. */
132
+ readonly headers?: HeadersLike
133
+ /** Document title (fallback when `head.title` is unset). */
134
+ readonly title?: string
135
+ /** Resolved route head - `title` overrides `title` above; `meta`/`link` render as managed
136
+ * (`data-nifra`) tags the client updates on navigation.
137
+ *
138
+ * **Head contract (the layout chain contributes).** `createWebApp` resolves this via
139
+ * {@link mergeHeads}: a route's head is its **layout chain's** `meta`/`head` exports merged with the
140
+ * page's. A `_layout.tsx` may `export const meta` (or `export function meta(args)`) - its tags land
141
+ * on every page below it (the home for `hreflang`, `preconnect`, a section-default `<title>`). The
142
+ * merge is **nearest-wins for scalars** (the page's `title` overrides an inner layout's, which
143
+ * overrides an outer one; an undefined page title keeps the layout's) and **concatenated for the
144
+ * `meta`/`link` arrays** (outermost layout first, page last). `<meta>`/`<link>` attributes pass a
145
+ * shared tag-specific allowlist (including inert `data-*` metadata) and values are escaped. */
146
+ readonly head?: Meta
147
+ /** Id of the container wrapping the app markup (default `"root"`). A non-default id also gets
148
+ * {@link ROOT_ATTRIBUTE}, which is how the generated client entry finds the container to hydrate -
149
+ * the entry is built once and cannot know what a given render chose. */
150
+ readonly rootId?: string
151
+ /** When `false`, emit a complete but **non-hydrated** document - no client entry script, data
152
+ * globals, or modulepreloads. Used for server-rendered `_error` pages: a terminal state that needs no
153
+ * client takeover, and it sidesteps an SSR/hydrate mismatch (the server rendered the boundary, not
154
+ * the page the client manifest maps this route id to). Default `true`. */
155
+ readonly hydrate?: boolean
156
+ /** Island client bundles (`@nifrajs/web/islands`) to load as `<script type="module">` in the document
157
+ * tail - emitted **regardless of `hydrate`**, so a static (`hydrate: false`) page can still mount
158
+ * no-framework islands. URLs are attribute-escaped. Empty/omitted ⇒ none (unchanged output). */
159
+ readonly islandScripts?: readonly string[]
160
+ /** CSP nonce applied to every framework-owned executable script in this document. */
161
+ readonly nonce?: string
162
+ /**
163
+ * Advanced: a per-route slot the renderer fills with the request-invariant document pieces (shell
164
+ * prefix/suffix, tail statics) on the first render and reuses afterwards, skipping their re-assembly.
165
+ *
166
+ * Only pass a cache when every shell-shaping input is IDENTICAL across the requests sharing it:
167
+ * same `head` content (a static meta chain - never a `meta(data)` function), `title`, `styles`,
168
+ * `preload`, `islandScripts`, `clientEntry`, `rootId`, `hydrate`, and `prerenderedPaths`. Per-request
169
+ * values (`data`, `params`, `search`, `actionData`, `layoutData`, deferred state) are always assembled
170
+ * fresh and safe. A per-request `nonce` disables the cache automatically. `createWebApp` wires this
171
+ * per route, gated on the route + layout metas being static.
172
+ */
173
+ readonly assemblyCache?: RenderAssemblyCache
174
+ }
175
+
176
+ /** The mutable per-route slot {@link RenderPageOptions.assemblyCache} fills. Opaque - create as `{}`. */
177
+ export interface RenderAssemblyCache {
178
+ /** Shell up to (not including) the deferred-runtime insertion point. */
179
+ shellPre?: string
180
+ /** Shell from after the deferred-runtime insertion point through the open `#root` container. */
181
+ shellPost?: string
182
+ /** Tail opener: the inline script open tag + the route-id global (before the action global). */
183
+ tailPre?: string
184
+ /** Tail mid: the prerendered-paths global (between the action and layout-data globals). */
185
+ tailMid?: string
186
+ /** Tail data prefix: the `window.<data global>=` assignment head. */
187
+ tailData?: string
188
+ /** Tail from after the serialized data through `</html>`. */
189
+ tailPost?: string
190
+ }
191
+
192
+ /**
193
+ * `renderPage` input. A hydrating page (the default) must supply `clientEntry`, because the document
194
+ * loads it as a module script; a `hydrate: false` page may omit it, because nothing in the emitted
195
+ * document references it. Expressed as a union so the compiler enforces the pairing rather than the
196
+ * renderer discovering an empty `src` at runtime.
197
+ */
198
+ export type RenderPageInput = RenderPageOptions &
199
+ ({ readonly hydrate?: true; readonly clientEntry: string } | { readonly hydrate: false })
200
+
201
+ /**
202
+ * Server: render a full HTML document for a page - the adapter's hydration head + the SSR
203
+ * markup (**streamed**) + the serialized loader data + the client module - as a `Response`.
204
+ * The shell (`<head>` + the open container) flushes first, the adapter's app stream follows,
205
+ * then the tail (data globals + client entry). Pure Web Standards, so it returns straight from
206
+ * a nifra route handler and streams on any fetch runtime (Bun/Node/Deno/Workers).
207
+ */
208
+ export function renderPage(options: RenderPageInput): MaybePromise<Response> {
209
+ const page = renderPageResult(options)
210
+ return page instanceof Promise ? page.then((p) => p.toResponse()) : page.toResponse()
211
+ }
212
+
213
+ export function renderPageResult(options: RenderPageInput): MaybePromise<RenderedPage> {
214
+ const {
215
+ adapter,
216
+ chain,
217
+ data,
218
+ actionData,
219
+ clientEntry,
220
+ preload = [],
221
+ styles = [],
222
+ prerenderedPaths = [],
223
+ revalidate,
224
+ revalidateTags = [],
225
+ routeId,
226
+ status = 200,
227
+ title = "nifra",
228
+ head,
229
+ rootId = "root",
230
+ hydrate = true,
231
+ islandScripts = [],
232
+ nonce,
233
+ boundaries,
234
+ headers: extraHeaders,
235
+ } = options
236
+ if (nonce !== undefined && nonce.trim() === "") {
237
+ throw new TypeError("[nifra/web] renderPage nonce must be non-empty when provided")
238
+ }
239
+ const nonceAttr = nonce === undefined ? "" : ` nonce="${escapeAttr(nonce)}"`
240
+ const route = routeId === undefined ? "" : `window.${ROUTE_GLOBAL}=${serializeData(routeId)};`
241
+ // The SSG prerendered-path set (when an app declares it) - the client reads it to fetch a static
242
+ // `_data.json` on soft-nav into a prerendered route instead of hitting the worker. Empty ⇒ omitted.
243
+ const prerendered =
244
+ prerenderedPaths.length === 0
245
+ ? ""
246
+ : `window.${PRERENDERED_GLOBAL}=${serializeData(prerenderedPaths)};`
247
+ // Split deferred values: the component sees markers (id + promise) to `<Await>`; the serialized
248
+ // data carries `{__nifra_deferred: id}` placeholders (promises don't serialize). `actionData` may
249
+ // also `defer()` - split it too, continuing the id space so a single registry settles both. The
250
+ // inline registry runtime is emitted only when something defers, so non-deferred output is unchanged.
251
+ const { forComponent, forClient, deferred } = prepareDeferred(data)
252
+ // Each layout's data is split in turn, each continuing the id space, so a `defer()` in a layout
253
+ // settles through the SAME registry as the page's rather than colliding on ids.
254
+ const layoutSplits: Array<ReturnType<typeof prepareDeferred>> = []
255
+ if (options.layoutData !== undefined) {
256
+ let offset = deferred.length
257
+ for (const entry of options.layoutData) {
258
+ const split = prepareDeferred(entry, offset)
259
+ offset += split.deferred.length
260
+ layoutSplits.push(split)
261
+ }
262
+ }
263
+ const layoutDeferred = layoutSplits.flatMap((split) => split.deferred)
264
+ const actionSplit =
265
+ actionData === undefined
266
+ ? undefined
267
+ : prepareDeferred(actionData, deferred.length + layoutDeferred.length)
268
+ const boundarySplit =
269
+ boundaries === undefined
270
+ ? undefined
271
+ : prepareDeferred(
272
+ boundaries,
273
+ deferred.length + layoutDeferred.length + (actionSplit?.deferred.length ?? 0),
274
+ )
275
+ // On the common page-only path (no layout loader, no action) reuse `deferred` directly instead of
276
+ // allocating a fresh spread array every request.
277
+ const allDeferred =
278
+ layoutDeferred.length === 0 && actionSplit === undefined && boundarySplit === undefined
279
+ ? deferred
280
+ : [
281
+ ...deferred,
282
+ ...layoutDeferred,
283
+ ...(actionSplit ? actionSplit.deferred : []),
284
+ ...(boundarySplit ? boundarySplit.deferred : []),
285
+ ]
286
+ // Omitted entirely when no layout has a loader - a page-only app emits exactly what it did before.
287
+ const layoutTail =
288
+ options.layoutData === undefined
289
+ ? ""
290
+ : `window.${LAYOUT_DATA_GLOBAL}=${serializeData(layoutSplits.map((split) => split.forClient))};`
291
+
292
+ // Only emit the action global when an action actually ran, so plain GET output is unchanged.
293
+ const action =
294
+ actionSplit === undefined
295
+ ? ""
296
+ : `window.${ACTION_GLOBAL}=${serializeData(actionSplit.forClient)};`
297
+ const boundaryTail =
298
+ boundarySplit === undefined
299
+ ? ""
300
+ : `window.${BOUNDARY_GLOBAL}=${serializeData(boundarySplit.forClient)};`
301
+ const deferredRuntime =
302
+ allDeferred.length > 0 ? `<script${nonceAttr}>${DEFERRED_RUNTIME}</script>` : ""
303
+ // The regex only injects the CSP nonce into the (constant) hydration head; with no nonce it's a
304
+ // no-op that still scans the whole script every request. Skip it on the common no-nonce path.
305
+ // The request-invariant document pieces. With a caller-supplied per-route cache (and no per-request
306
+ // nonce, which would bake into them) they're built once and reused; the per-request seams - the
307
+ // deferred runtime in the shell, the action/layout-data globals and the serialized loader data in
308
+ // the tail - are always assembled fresh. Byte-identical to building the whole document inline.
309
+ const slot: RenderAssemblyCache =
310
+ nonce === undefined && options.assemblyCache !== undefined ? options.assemblyCache : {}
311
+ if (slot.shellPre === undefined) {
312
+ // Matched-route chunk preloads, concatenated directly. De-duped against the entry, which is
313
+ // preloaded separately below.
314
+ let preloadLinks = ""
315
+ for (const url of preload) {
316
+ if (url !== clientEntry)
317
+ preloadLinks += `<link rel="modulepreload" href="${escapeAttr(url)}">`
318
+ }
319
+ // The matched route's stylesheets - `<link rel="stylesheet">` in `<head>` so CSS arrives with the
320
+ // first paint (no FOUC). Render-blocking by design, and emitted regardless of `hydrate` (a static
321
+ // or `_error` page still wants its styles). In dev (Vite) CSS is injected client-side instead.
322
+ let styleLinks = ""
323
+ for (const url of styles) styleLinks += `<link rel="stylesheet" href="${escapeAttr(url)}">`
324
+ // Island bundles are referenced only by a `<script type="module">` at the END of `<body>`, so the
325
+ // browser doesn't discover them until the whole page is parsed. `modulepreload` them in `<head>`
326
+ // so the fetch starts immediately, in parallel with parsing - regardless of `hydrate`.
327
+ let islandPreloads = ""
328
+ for (const src of islandScripts)
329
+ islandPreloads += `<link rel="modulepreload" href="${escapeAttr(src)}">`
330
+ // Runs in the first-paint→hydration window to swallow a JS-only form's broken native submit. Only
331
+ // on a hydrating page (a static/_error page has no client handlers, so no footgun).
332
+ const hydrationGuard = hydrate ? `<script${nonceAttr}>${PRE_HYDRATION_GUARD}</script>` : ""
333
+ // `<html>` attributes. `lang` defaults to `"en"`; `dir` is omitted entirely when unset, which IS
334
+ // HTML's `ltr` default. Both attribute-escaped. `client.ts`'s `applyHead` mirrors this exact
335
+ // defaulting on soft-nav, so a hard load and a client navigation produce the same `<html>`.
336
+ const htmlAttrs = ` lang="${escapeAttr(head?.lang ?? "en")}"${head?.dir === undefined ? "" : ` dir="${escapeAttr(head.dir)}"`}`
337
+ // Marks the container for the client entry when the id is not the one the entry falls back to.
338
+ const rootMarker = rootId === "root" ? "" : ` ${ROOT_ATTRIBUTE}`
339
+ // A non-hydrated page omits the adapter's hydration bootstrap entirely (Solid's `_$HY` registry
340
+ // script, etc.) - there is no client takeover to feed, so it's dead bytes on a static document.
341
+ // The nonce rewrite only runs when a nonce is set; with none it was a whole-string no-op scan.
342
+ const rawHydrationHead = hydrate ? adapter.hydrationHead(nonce) : ""
343
+ const hydrationHead =
344
+ nonce === undefined
345
+ ? rawHydrationHead
346
+ : rawHydrationHead.replace(/<script(?![^>]*\bnonce=)(?=[\s>])/g, `<script${nonceAttr}`)
347
+ // Shell up to the deferred-runtime seam: on a hydrating page that seam sits after the entry +
348
+ // route-chunk preloads; a non-hydrating page has no runtime insertion at all.
349
+ const entryPreloads = hydrate
350
+ ? `<link rel="modulepreload" href="${escapeAttr(clientEntry ?? "")}">${preloadLinks}`
351
+ : ""
352
+ slot.shellPre = `<!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}${entryPreloads}`
353
+ slot.shellPost = `${islandPreloads}${hydrationHead}</head><body><div id="${escapeAttr(rootId)}"${rootMarker}>`
354
+ // Island bundles load regardless of `hydrate` - a static page (hydrate:false) ships no framework
355
+ // client but can still mount no-framework islands (`@nifrajs/web/islands`).
356
+ let islandTags = ""
357
+ for (const src of islandScripts)
358
+ islandTags += `<script type="module" src="${escapeAttr(src)}"${nonceAttr}></script>`
359
+ if (hydrate) {
360
+ slot.tailPre = `<script${nonceAttr}>${route}`
361
+ slot.tailMid = prerendered
362
+ slot.tailData = `window.${DATA_GLOBAL}=`
363
+ slot.tailPost = `</script><script type="module" src="${escapeAttr(clientEntry ?? "")}"${nonceAttr}></script>${islandTags}</body></html>`
364
+ } else {
365
+ slot.tailPre = ""
366
+ slot.tailMid = ""
367
+ slot.tailData = ""
368
+ slot.tailPost = `${islandTags}</body></html>`
369
+ }
370
+ }
371
+ // The deferred runtime rides only on a hydrating page (a static page has no client takeover), and
372
+ // only when something actually deferred - matching the pre-cache emission exactly.
373
+ const runtimeSeam = hydrate && allDeferred.length > 0 ? deferredRuntime : ""
374
+ const shellHtml = `${slot.shellPre}${runtimeSeam}${slot.shellPost}`
375
+ // Closes the hydration container; deferred resolve scripts go AFTER it (outside `#root`) so they
376
+ // aren't part of the adapter's hydrated tree (an inline script inside it breaks hydration).
377
+ const closeRootHtml = "</div>"
378
+ // Tail - the loader-data globals + the client module. Module scripts defer (run after parse), so
379
+ // the data global + every streamed deferred resolution are set before the entry hydrates.
380
+ const tailHtml = hydrate
381
+ ? `${slot.tailPre}${action}${slot.tailMid}${layoutTail}${boundaryTail}${slot.tailData}${serializeData(forClient)}${slot.tailPost}`
382
+ : (slot.tailPost as string)
383
+ const headers: Record<string, string> = { "content-type": "text/html; charset=utf-8" }
384
+ // Caller-supplied headers (a terminal status page's `cache-control`, say). Applied before the
385
+ // framework's own below, so `content-type` and the ISR channel stay authoritative - a caller must
386
+ // not be able to mislabel the document or forge the freshness header by passing them here.
387
+ if (extraHeaders !== undefined) {
388
+ for (const [name, value] of new Headers(extraHeaders)) {
389
+ if (name !== "content-type") headers[name] = value
390
+ }
391
+ }
392
+ // ISR: advertise the route's freshness so a `withISR` wrapper can set this page's cache TTL. A
393
+ // dedicated header (not the action-revalidation `x-nifra-revalidate`) so the TTL channel never aliases
394
+ // the client's path-list channel.
395
+ if (revalidate !== undefined) headers[ISR_REVALIDATE_HEADER] = String(revalidate)
396
+ const serializedTags = serializeISRTags(revalidateTags)
397
+ if (serializedTags !== undefined) headers[ISR_REVALIDATE_TAGS_HEADER] = serializedTags
398
+ const renderProps: RenderProps = {
399
+ data: forComponent,
400
+ actionData: actionSplit?.forComponent,
401
+ // `params`/`path` thread the matched route + URL to the adapter's `useParams`/`useLocation` so they
402
+ // render SSR-correct. Spread only when supplied (exactOptionalPropertyTypes) - an adapter with no
403
+ // router bindings simply never reads them.
404
+ ...(options.params !== undefined ? { params: options.params } : {}),
405
+ ...(options.path !== undefined ? { path: options.path } : {}),
406
+ // Same rule for `search`: the validated query the adapter's `useSearch` reads, spread only when the
407
+ // caller supplied it (a non-router render never has it).
408
+ ...(options.search !== undefined ? { search: options.search } : {}),
409
+ // Spread only when present, so a page-only render produces exactly the props it did before.
410
+ ...(options.layoutData !== undefined
411
+ ? { layoutData: layoutSplits.map((split) => split.forComponent) }
412
+ : {}),
413
+ ...(boundarySplit !== undefined
414
+ ? { boundaries: boundarySplit.forComponent as BoundaryStates }
415
+ : {}),
416
+ }
417
+
418
+ // Fast path: nothing `defer()`s and the adapter can render synchronously to a string → buffer the
419
+ // whole document in one pass. Progressive streaming only benefits pages with deferred boundaries
420
+ // (those take the streaming path below); for a plain page the streaming pipeline + the framework's
421
+ // streaming renderer are pure overhead vs a single sync render + concat. A buffered string body also
422
+ // gets an automatic Content-Length. A render throw surfaces here exactly as the streaming path's
423
+ // shell-readiness `await` does, so the `_error` boundary still maps it to a status.
424
+ if (allDeferred.length === 0 && adapter.renderToString !== undefined) {
425
+ const out = adapter.renderToString(chain, renderProps)
426
+ return typeof out === "string"
427
+ ? new BufferedRenderedPage(shellHtml + out + closeRootHtml + tailHtml, status, headers)
428
+ : out.then(
429
+ (bodyHtml) =>
430
+ new BufferedRenderedPage(
431
+ shellHtml + bodyHtml + closeRootHtml + tailHtml,
432
+ status,
433
+ headers,
434
+ ),
435
+ )
436
+ }
437
+
438
+ return renderStreamedPage(
439
+ adapter,
440
+ chain,
441
+ renderProps,
442
+ shellHtml,
443
+ closeRootHtml,
444
+ allDeferred,
445
+ tailHtml,
446
+ status,
447
+ headers,
448
+ nonceAttr,
449
+ ).then((response) => new ResponseRenderedPage(response))
450
+ }
451
+
452
+ async function renderStreamedPage(
453
+ adapter: RenderAdapter,
454
+ chain: readonly unknown[],
455
+ renderProps: RenderProps,
456
+ shellHtml: string,
457
+ closeRootHtml: string,
458
+ allDeferred: ReadonlyArray<{ readonly id: number; readonly promise: Promise<unknown> }>,
459
+ tailHtml: string,
460
+ status: number,
461
+ headers: Record<string, string>,
462
+ nonceAttr: string,
463
+ ): Promise<Response> {
464
+ // Streaming path - required for `defer()` (progressive `<Await>` resolution) and used by any adapter
465
+ // that doesn't implement `renderToString`. Awaiting `renderToStream` resolves on shell-readiness
466
+ // (React: on-shell-ready; Solid: synchronously), so a shell-render throw surfaces before any byte is
467
+ // sent. A *mid*-stream failure errors the body instead.
468
+ const enc = TEXT_ENCODER
469
+ const shell = enc.encode(shellHtml)
470
+ const closeRoot = enc.encode(closeRootHtml)
471
+ const tail = enc.encode(tailHtml)
472
+ const appStream = await adapter.renderToStream(chain, renderProps)
473
+ const body = streamDocument(shell, appStream, closeRoot, allDeferred, tail, enc, nonceAttr)
474
+ return new Response(body, { status, headers })
475
+ }
476
+
477
+ class BufferedRenderedPage implements RenderedPage {
478
+ readonly [RESPONSE_RESULT] = true
479
+ private readonly body: string
480
+ private readonly status: number
481
+ private readonly headers: Readonly<Record<string, string>>
482
+
483
+ constructor(body: string, status: number, headers: Readonly<Record<string, string>>) {
484
+ this.body = body
485
+ this.status = status
486
+ this.headers = headers
487
+ }
488
+
489
+ toResponse(): Response {
490
+ return htmlResponse(this.body, { status: this.status, headers: this.headers })
491
+ }
492
+
493
+ toNodeBody(): {
494
+ readonly status: number
495
+ readonly headers: Readonly<Record<string, string | readonly string[]>>
496
+ readonly body: string
497
+ } {
498
+ return { status: this.status, headers: this.headers, body: this.body }
499
+ }
500
+ }
501
+
502
+ class ResponseRenderedPage implements RenderedPage {
503
+ readonly [RESPONSE_RESULT] = true
504
+ private readonly response: Response
505
+
506
+ constructor(response: Response) {
507
+ this.response = response
508
+ }
509
+
510
+ toResponse(): Response {
511
+ return this.response
512
+ }
513
+ }
514
+
515
+ function htmlResponse(body: string, init: ResponseInit): Response {
516
+ const response = new Response(body, init)
517
+ // @nifrajs/node can write buffered HTML straight to ServerResponse with `end(body)`, avoiding a Web
518
+ // Response stream drain on Node. Non-enumerable + Symbol.for keeps this invisible to Web runtimes and
519
+ // cross-package without adding a runtime dependency from @nifrajs/node to @nifrajs/web.
520
+ Object.defineProperty(response, NODE_RESPONSE_BODY, { value: body })
521
+ return response
522
+ }
523
+
524
+ /**
525
+ * Assemble the document stream: `shell` → the app `stream` (forwarded chunk-by-chunk, so a streaming
526
+ * renderer's progressive flushing is preserved) → `closeRoot` → one `__nifraResolve`/`__nifraReject`
527
+ * script per deferred value (emitted once its promise settles - by now the app stream has awaited
528
+ * the same Suspense boundaries - and placed OUTSIDE `#root`) → `tail`. A mid-stream app error errors
529
+ * the result (the body breaks) rather than silently truncating a 200.
530
+ */
531
+ function streamDocument(
532
+ shell: Uint8Array,
533
+ stream: ReadableStream<Uint8Array>,
534
+ closeRoot: Uint8Array,
535
+ deferred: ReadonlyArray<{ readonly id: number; readonly promise: Promise<unknown> }>,
536
+ tail: Uint8Array,
537
+ enc: TextEncoder,
538
+ nonceAttr: string,
539
+ ): ReadableStream<Uint8Array> {
540
+ return new ReadableStream({
541
+ async start(controller) {
542
+ const reader = stream.getReader()
543
+ try {
544
+ controller.enqueue(shell)
545
+ for (;;) {
546
+ const { done, value } = await reader.read()
547
+ if (done) break
548
+ controller.enqueue(value)
549
+ }
550
+ controller.enqueue(closeRoot)
551
+ // Stream each resolution as ITS OWN promise settles - NOT in array order. A slow
552
+ // defer() must not block a faster one; each script self-addresses by id, so order is irrelevant.
553
+ await Promise.all(
554
+ deferred.map(async (d) => {
555
+ try {
556
+ const value = serializeData(await d.promise)
557
+ controller.enqueue(
558
+ enc.encode(`<script${nonceAttr}>window.__nifraResolve(${d.id},${value})</script>`),
559
+ )
560
+ } catch (err) {
561
+ // A rejected deferred streams __nifraReject (the client `<Await>` surfaces it) - it must
562
+ // not break the whole body. Redact: stream a stable opaque code, never the raw error
563
+ // text; log the real reason server-side.
564
+ console.error("[nifra/web] deferred value rejected:", err)
565
+ controller.enqueue(
566
+ enc.encode(
567
+ `<script${nonceAttr}>window.__nifraReject(${d.id},${serializeData(DEFERRED_ERROR_CODE)})</script>`,
568
+ ),
569
+ )
570
+ }
571
+ }),
572
+ )
573
+ controller.enqueue(tail)
574
+ controller.close()
575
+ } catch (err) {
576
+ controller.error(err)
577
+ } finally {
578
+ reader.releaseLock()
579
+ }
580
+ },
581
+ })
582
+ }
583
+
584
+ /** Options for {@link redirect}. */
585
+ export interface RedirectOptions {
586
+ /** HTTP status (default 303 See Other; pass 307/308 to preserve the method). */
587
+ readonly status?: number
588
+ /** Allow an off-origin / absolute destination. Off by default: only a same-origin path (a single
589
+ * leading `/`) is permitted, so an action can't be turned into an open redirect by passing
590
+ * attacker-controlled input straight through. Set `true` for a deliberate external redirect. */
591
+ readonly external?: boolean
592
+ /** Extra response headers. A redirect is no longer a `Response`, so there is no `.headers` to
593
+ * mutate after the fact - name them here. Cookies still ride `c.set`, as on any other response. */
594
+ readonly headers?: Readonly<Record<string, string>>
595
+ }
596
+
597
+ /** A same-origin destination is an absolute path: one leading `/`, but NOT `//` (protocol-relative →
598
+ * another origin), and nothing a URL parser resolves off-origin. Everything else (absolute URL with
599
+ * a scheme, `//host`, `javascript:`, a bare relative `foo`) requires `external: true`. The predicate
600
+ * lives in the kernel - see `isSameOriginPath` - so this gate and the auth guards' `redirectTo`
601
+ * cannot drift apart. */
602
+
603
+ /**
604
+ * Build a redirect - return it from a route `action` for the Post/Redirect/Get pattern (POST
605
+ * mutates, 303 sends the browser to a fresh GET, so a reload doesn't re-submit). Defaults to 303
606
+ * (See Other); pass `{ status: 307 }` or `{ status: 308 }` to preserve the method.
607
+ *
608
+ * **Secure by default:** `location` must be a same-origin path (begins with `/`, not `//`). An
609
+ * off-origin/absolute destination throws unless you pass `{ external: true }` - this closes the
610
+ * open-redirect footgun of `return redirect(formData.get("next"))` on the no-JS (native-form) path,
611
+ * which serves the action's control-flow value verbatim.
612
+ *
613
+ * Returns a plain render, not a `Response`. A redirect is a status line and one header - the most
614
+ * body-less response there is - and building a `Response` for it costs the whole Web object plus, on
615
+ * Node, a stream drained back out. As data it renders on the same lane a handler's return takes.
616
+ * `redirect(...)` is still returned or thrown from exactly the same places; only `.status` /
617
+ * `.headers` are gone from the value, replaced by `options.headers` and the request's `c.set`.
618
+ *
619
+ * @param options redirect status, extra headers, and whether an off-origin destination is intentional.
620
+ */
621
+ export function redirect(location: string, options: RedirectOptions = {}): ResponseResult {
622
+ if (options.external !== true && !isSameOriginPath(location)) {
623
+ throw new Error(
624
+ `[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.`,
625
+ )
626
+ }
627
+ // Reject CR/LF in the Location explicitly - defense-in-depth (response splitting / header
628
+ // injection). Spec-correct runtimes' Headers setter throws on CR/LF, but `external: true` lets
629
+ // unvalidated input reach this sink, so we don't rely on the runtime. Same posture as
630
+ // serializeCookie / the SSE frame formatter, which strip CRLF at their sinks.
631
+ if (/[\r\n]/.test(location)) {
632
+ throw new Error(
633
+ `[nifra/web] redirect location contains a CR/LF character - refusing to emit a header-injecting redirect.`,
634
+ )
635
+ }
636
+ return statusResult(options.status ?? 303, undefined, {
637
+ headers: options.headers === undefined ? { location } : { ...options.headers, location },
638
+ })
639
+ }
640
+
641
+ /**
642
+ * Brand marking a `Response` as a **terminal-status signal** rather than a response to serve verbatim.
643
+ *
644
+ * A loader that throws a bare `Response` has always been passed straight through - that is how
645
+ * `throw redirect(...)` works, and apps rely on it. So "render the `_404` boundary at 404" cannot be
646
+ * expressed by throwing a plain `Response`: the framework would have no way to tell it apart from a
647
+ * hand-rolled body the app wants served exactly as written. The brand is that distinction, and
648
+ * checking it *before* the pass-through is what keeps every existing throw working unchanged.
649
+ *
650
+ * `Symbol.for` matches `RESPONSE_RESULT` above: a registry symbol survives two copies of this module,
651
+ * which a `unique symbol` would not - and a duplicate-install turning a 404 into a raw 404 body would
652
+ * be a maddening bug to trace.
653
+ */
654
+ /** Derived from the `Headers` constructor rather than the DOM lib's `HeadersInit`: `@nifrajs/web`
655
+ * is consumed from DOM-free programs (the root tsconfig runs `types: ["bun"]`), and naming the DOM
656
+ * type here would break their build. Same workaround as `SSEInit.headers` in core. */
657
+ export type HeadersLike = ConstructorParameters<typeof Headers>[0]
658
+
659
+ /**
660
+ * The signatures a **duplicate module instance** produces during SSR, across engines.
661
+ *
662
+ * Two copies of React (or of `@nifrajs/core`) at the SAME version still fail, because module identity
663
+ * is path-based: hooks read a dispatcher off the copy that rendered, and the component imported the
664
+ * other one. The error that surfaces names a React internal, so it reads as a React bug and the actual
665
+ * cause - two directories - is a long inference away. Naming the cause at the point of failure is the
666
+ * difference between that and a five-second read.
667
+ */
668
+ const DUPLICATE_INSTANCE_SIGNATURES: readonly RegExp[] = [
669
+ /resolveDispatcher\(\)/,
670
+ /Invalid hook call/,
671
+ /Cannot read propert(?:y|ies) of null \(reading '(?:use[A-Z]\w*)'\)/,
672
+ /null is not an object \(evaluating '.*\.use[A-Z]\w*'\)/,
673
+ ]
674
+
675
+ /**
676
+ * Append the likely cause to an SSR error that carries a duplicate-instance signature.
677
+ *
678
+ * Deliberately does NOT claim the resolved paths: `@nifrajs/web` is framework-agnostic and does not
679
+ * depend on React, so it cannot resolve the copies without guessing. It names the condition and the
680
+ * command that reports the paths, which is the actionable part. Returns the error untouched when the
681
+ * signature does not match, so an ordinary render error reads exactly as before.
682
+ */
683
+ export function withDuplicateInstanceHint(err: unknown): unknown {
684
+ if (!(err instanceof Error)) return err
685
+ if (!DUPLICATE_INSTANCE_SIGNATURES.some((rx) => rx.test(err.message))) return err
686
+ const augmented = new Error(
687
+ `${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.`,
688
+ { cause: err },
689
+ )
690
+ augmented.name = err.name
691
+ // `stack` is optional under exactOptionalPropertyTypes; assigning `undefined` would replace a real
692
+ // stack with nothing on engines that always populate it.
693
+ if (err.stack !== undefined) augmented.stack = err.stack
694
+ return augmented
695
+ }
696
+
697
+ /**
698
+ * An action's control-flow value passes straight through - except a redirect on a client-submit data
699
+ * request: fetch would follow the 3xx into HTML the client can't use, so the redirect rides the
700
+ * X-Nifra-Redirect header on a 204 and the client navigates. One conversion shared by the returned-
701
+ * and thrown- paths, so `return redirect()` and `throw redirect()` agree.
702
+ *
703
+ * Takes either shape: `redirect()` is a plain render, while a hand-rolled `new Response(...)` from an
704
+ * action still arrives as a `Response`. The rewrite stays on the lane its input was on - a plain
705
+ * redirect converts to a plain 204, and never materializes the `Response` it is replacing.
706
+ */
707
+ export function actionResponse(
708
+ result: Response | ResponseResult,
709
+ isDataRequest: boolean,
710
+ ): Response | ResponseResult {
711
+ if (!isDataRequest) return result
712
+ if (isResponseResult(result)) {
713
+ const plain = result.plain
714
+ // No `plain` means a carrier that only knows how to build a `Response` (not one of ours) - fall
715
+ // back rather than guess at its status.
716
+ if (plain === undefined) return actionResponse(result.toResponse(), isDataRequest)
717
+ if (plain.status < 300 || plain.status >= 400) return result
718
+ const location = plain.headers?.location ?? "/"
719
+ return statusResult(204, undefined, { headers: { [REDIRECT_HEADER]: location } })
720
+ }
721
+ if (result.status >= 300 && result.status < 400) {
722
+ const location = result.headers.get("location") ?? "/"
723
+ return statusResult(204, undefined, { headers: { [REDIRECT_HEADER]: location } })
724
+ }
725
+ return result
726
+ }
727
+
728
+ /** A loaded layout module. `loader`/`gate` are the layout-loader surface; `meta` predates it. */
729
+ export type LoadedLayoutModules = ReadonlyArray<{
730
+ default: unknown
731
+ meta?: MetaInput
732
+ loader?: LayoutLoader
733
+ action?: unknown
734
+ gate?: boolean
735
+ // A layout may declare its own `searchSchema`; the route's effective search merges the layout chain's
736
+ // schemas with the page's (page-wins). Present on the raw module already - typed here so it is readable.
737
+ searchSchema?: RouteModule["searchSchema"]
738
+ boundaries?: readonly BoundaryRegistration[]
739
+ }>
740
+
741
+ /**
742
+ * Narrow the route's params to the ones a layout owns.
743
+ *
744
+ * A layout wraps a URL prefix, so anything deeper belongs to a route beneath it. Handing it the full
745
+ * set would let a layout read a param it does not own - which reads fine until the layout is reused
746
+ * under a route that has no such param and the value silently becomes `undefined`.
747
+ */
748
+ export function scopeParams(
749
+ params: Record<string, string>,
750
+ owned: readonly string[] | undefined,
751
+ ): Record<string, string> {
752
+ if (owned === undefined) return params // hand-built manifest with no scope info: unchanged behaviour
753
+ if (owned.length === 0) return EMPTY_LAYOUT_PARAMS
754
+ const scoped: Record<string, string> = {}
755
+ for (const name of owned) {
756
+ const value = params[name]
757
+ if (value !== undefined) scoped[name] = value
758
+ }
759
+ return scoped
760
+ }
761
+
762
+ const EMPTY_LAYOUT_PARAMS: Record<string, string> = Object.freeze({})
763
+
764
+ export const EMPTY_RETAIN: ReadonlySet<number> = new Set<number>()
765
+
766
+ /**
767
+ * Marks an error as coming from a LAYOUT loader, carrying that layout's id.
768
+ *
769
+ * The boundary that catches it must be at or above the failing layout's own segment. The route's
770
+ * innermost `_error` may live BELOW the layout that failed, and rendering there would wrap the
771
+ * boundary in a layout whose data never arrived - so the page would render inside a component that
772
+ * threw. A registry symbol, matching the other cross-module brands here.
773
+ */
774
+ const LAYOUT_ERROR_ID = Symbol.for("nifra.web.layout-error-id")
775
+
776
+ export const tagLayoutError = (err: unknown, layoutId: string): unknown => {
777
+ if (typeof err === "object" && err !== null && !(LAYOUT_ERROR_ID in err)) {
778
+ Object.defineProperty(err, LAYOUT_ERROR_ID, { value: layoutId, enumerable: false })
779
+ }
780
+ return err
781
+ }
782
+
783
+ export const layoutErrorId = (err: unknown): string | undefined =>
784
+ typeof err === "object" && err !== null
785
+ ? ((err as Record<symbol, unknown>)[LAYOUT_ERROR_ID] as string | undefined)
786
+ : undefined
787
+
788
+ export const STATUS_SIGNAL = Symbol.for("nifra.web.status-signal")
789
+
790
+ /** Reason phrases for the plain-text fallback, used only when the app authored no `_<status>` and no
791
+ * `_404` page. Deliberately not exhaustive - anything unlisted falls back to "Error", which is more
792
+ * useful than shipping a table of every RFC status for a body almost nobody will see. */
793
+ export const STATUS_TEXT: Readonly<Record<number, string>> = {
794
+ 400: "Bad Request",
795
+ 401: "Unauthorized",
796
+ 402: "Payment Required",
797
+ 403: "Forbidden",
798
+ 404: "Not Found",
799
+ 410: "Gone",
800
+ 451: "Unavailable For Legal Reasons",
801
+ 500: "Internal Server Error",
802
+ }
803
+
804
+ export interface StatusSignal extends Response {
805
+ readonly [STATUS_SIGNAL]: { readonly status: number; readonly headers?: HeadersLike }
806
+ }
807
+
808
+ export function isStatusSignal(value: unknown): value is StatusSignal {
809
+ return value instanceof Response && STATUS_SIGNAL in value
810
+ }
811
+
812
+ function statusSignal(status: number, options: StatusPageOptions): never {
813
+ if (!Number.isInteger(status) || status < 400 || status > 599) {
814
+ throw new Error(
815
+ `[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.`,
816
+ )
817
+ }
818
+ const response = new Response(null, { status })
819
+ // Non-enumerable so the brand never lands in a structured clone or a JSON log of the response.
820
+ Object.defineProperty(response, STATUS_SIGNAL, {
821
+ value: { status, ...(options.headers !== undefined ? { headers: options.headers } : {}) },
822
+ enumerable: false,
823
+ })
824
+ throw response
825
+ }
826
+
827
+ /** Options shared by {@link notFound}, {@link gone}, and {@link statusPage}. */
828
+ export interface StatusPageOptions {
829
+ /**
830
+ * Extra headers for the rendered response - `cache-control` above all.
831
+ *
832
+ * The defaults differ by status for a reason: a 404 may be a race with publication, so it wants a
833
+ * short TTL, while a 410 is a promise that the URL is permanently gone and can be cached hard.
834
+ * Getting this wrong is the difference between a crawler dropping a dead URL and re-fetching it
835
+ * for weeks.
836
+ */
837
+ readonly headers?: HeadersLike
838
+ }
839
+
840
+ /**
841
+ * Render the nearest `_404` page at status **404**. `throw` it from a loader when the record does not
842
+ * exist.
843
+ *
844
+ * This is the fix for the soft 404: a matched route whose loader finds nothing has otherwise no way to
845
+ * answer 404, so the path of least resistance is to return empty data and render "not found" inside a
846
+ * **200**. That looks correct in a browser and is invisible in review, which is why it ships and stays
847
+ * shipped - while search engines penalise it and keep the dead URL indexed.
848
+ *
849
+ * Returns `never`: these throw, so a loader narrows without a redundant `return`, and the type states
850
+ * the thing the "loaders `throw` redirect, actions `return` it" rule already trips people on.
851
+ */
852
+ export function notFound(options: StatusPageOptions = {}): never {
853
+ return statusSignal(404, options)
854
+ }
855
+
856
+ /**
857
+ * Render a terminal page at status **410 Gone**. `throw` it from a loader for a record that existed and
858
+ * was deliberately removed - a withdrawn listing, a deleted post.
859
+ *
860
+ * 410 is not a pedantic 404: it tells a crawler to **drop** the URL rather than re-fetch it for weeks
861
+ * on the assumption the 404 was transient. Uses `_410.tsx` if the app has one, otherwise `_404`.
862
+ */
863
+ export function gone(options: StatusPageOptions = {}): never {
864
+ return statusSignal(410, options)
865
+ }
866
+
867
+ /**
868
+ * Render a terminal page at any 4xx/5xx status - the escape hatch behind {@link notFound} and
869
+ * {@link gone} (402, 451, …). Uses `_<status>.tsx` if present, otherwise `_404`.
870
+ */
871
+ export function statusPage(status: number, options: StatusPageOptions = {}): never {
872
+ return statusSignal(status, options)
873
+ }
874
+
875
+ /** The wrapper `revalidate()` returns: the action's `data` plus the paths it changed. A plain tagged
876
+ * shape (not a class) so `@nifrajs/client`'s `ActionData` can unwrap it structurally without importing
877
+ * from `@nifrajs/web`. `createWebApp` strips the wrapper - the client receives `data` as the body and
878
+ * the paths via the `X-Nifra-Revalidate` header. */
879
+ export interface RevalidateResult<T> {
880
+ readonly __nifraRevalidate: readonly string[]
881
+ readonly data: T
882
+ }
883
+
884
+ /**
885
+ * Return this from an action to declare which routes the mutation changed (alongside the action's
886
+ * `data`). `createWebApp` sets the `X-Nifra-Revalidate` response header; after the submit the client
887
+ * marks those cached routes stale - refetching the active one and any mounted fetcher showing them -
888
+ * so a mutation can refresh views beyond the one that was submitted. `data` is still surfaced to the
889
+ * component as `actionData` (the wrapper is transparent to `ActionData<typeof action>`).
890
+ */
891
+ export function revalidate<T>(paths: readonly string[], data: T): RevalidateResult<T> {
892
+ return { __nifraRevalidate: paths, data }
893
+ }
894
+
895
+ /**
896
+ * Serialize loader data for embedding inside an inline `<script>`. `JSON.stringify` alone
897
+ * is NOT safe there: a string containing `</script>` or `<!--` would break out of the
898
+ * script element (an XSS vector). Escape `<`/`>` to `\uXXXX`, plus the U+2028/U+2029
899
+ * separators.
900
+ */
901
+ export function serializeData(data: unknown): string {
902
+ const serialized = JSON.stringify(data ?? null)
903
+ // Most loader payloads contain no script-sensitive characters. Guard with chained `indexOf`
904
+ // rather than a regex `.test`: each probe is a native memchr-style scan, measured ~7x faster than
905
+ // the regex scan on V8 for a multi-KB payload (and the engine rejects the two >0xFF separator
906
+ // probes in O(1) when the string is internally one-byte, which serialized JSON almost always is).
907
+ // The same four characters are probed as the SCRIPT_ESCAPE class, so any payload the regex would
908
+ // escape still takes the escape path - this is only a cheaper detector, never a security
909
+ // relaxation.
910
+ const needsEscape =
911
+ serialized.indexOf("<") !== -1 ||
912
+ serialized.indexOf(">") !== -1 ||
913
+ serialized.indexOf(LINE_SEP) !== -1 ||
914
+ serialized.indexOf(PARA_SEP) !== -1
915
+ return needsEscape
916
+ ? serialized.replace(SCRIPT_ESCAPE, (ch) => SCRIPT_ESCAPE_MAP[ch] ?? ch)
917
+ : serialized
918
+ }
919
+
920
+ function escapeHtml(value: string): string {
921
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
922
+ }
923
+
924
+ /**
925
+ * Escape a `<script>` element's text content so it cannot break out of the element. Per the HTML spec,
926
+ * a script's text may contain any character EXCEPT the sequences that the parser treats as element
927
+ * boundaries: `</` (which would begin the end tag - `</script>` is the obvious XSS vector, but ANY `</`
928
+ * starts script-data-end-tag parsing), and the comment/CDATA edges `<!--` / `]]>`. We escape the `<`
929
+ * (and the `>` of `]]>`) to its JS unicode escape: inside a JS/JSON string literal `<` is the
930
+ * identical character, so a JSON-LD payload is byte-equivalent after parsing - but the raw `<`/`>` the
931
+ * HTML tokenizer scans for is gone. This is the JSON-LD-in-HTML rule (content is JSON, never raw HTML),
932
+ * mirroring {@link serializeData}'s posture for the data-global script. Idempotent on already-safe text.
933
+ */
934
+ function escapeScriptContent(content: string): string {
935
+ // Order matters: rewrite `]]>` first (its `>` becomes `>`), then every `<` (covers `</` and
936
+ // `<!--` in one pass). `<` → `<`; the lone `>` of `]]>` → `>`. A bare `>` elsewhere is
937
+ // harmless in script data, so only the `]]>` close is targeted for `>`.
938
+ return content.replaceAll("]]>", "]]\\u003e").replaceAll("<", "\\u003c")
939
+ }
940
+
941
+ function escapeAttr(value: string): string {
942
+ return value
943
+ .replaceAll("&", "&amp;")
944
+ .replaceAll('"', "&quot;")
945
+ .replaceAll("<", "&lt;")
946
+ .replaceAll(">", "&gt;")
947
+ }
948
+
949
+ /** Serialize attributes that passed the shared SSR/client head trust policy. */
950
+ function tagAttrs(tag: "meta" | "link", attrs: Readonly<object>): string | null {
951
+ const trusted = trustedHeadAttributes(tag, attrs)
952
+ if (trusted === null) return null
953
+ let out = ""
954
+ for (const [name, value] of trusted) {
955
+ if (out !== "") out += " "
956
+ out += value === true ? name : `${name}="${escapeAttr(value)}"`
957
+ }
958
+ return out
959
+ }
960
+
961
+ // Memoize the serialized head-tag string by the resolved `Meta` object's identity. A STATIC route
962
+ // `meta` is returned by-reference from resolveMeta, so the same object recurs every request → cache
963
+ // hit, and the invariant string is serialized once per route, not per request. Function results are
964
+ // never cached, even when a function deliberately reuses one mutable object across requests. WeakMap
965
+ // so a route module that's GC'd takes its entry with it.
966
+ const headTagsCache = new WeakMap<Meta, string>()
967
+
968
+ function assertExecutableScriptType(type: string): void {
969
+ if (!EXECUTABLE_SCRIPT_TYPES.has(type)) {
970
+ throw new TypeError(
971
+ `[nifra/web] executable inline scripts accept ${[...EXECUTABLE_SCRIPT_TYPES].map((t) => JSON.stringify(t)).join(" or ")}; received ${JSON.stringify(type)}`,
972
+ )
973
+ }
974
+ }
975
+
976
+ /** Render a route's `meta`/`link`/`script` as managed (`data-nifra`) head tags. Title is set
977
+ * separately. XSS-safe by construction: every attribute flows through the shared tag-specific trust
978
+ * policy then value escaping, and each `script[].content` through `escapeScriptContent`
979
+ * (breakout-escaped) - so loader-derived strings (LLM-authored `og:*`, user content) can't inject markup
980
+ * or close the tag early. String concatenation (no intermediate `.map()` arrays + spread) - parity with
981
+ * the already concat-based preloadLinks/styleLinks/islandPreloads loops; byte-identical output. Result
982
+ * is memoized only for objects observed as static meta exports (serialized once per route). */
983
+ function headTags(head: Meta | undefined): string {
984
+ if (head === undefined) return ""
985
+ const cacheable = isStaticMeta(head)
986
+ if (cacheable) {
987
+ const cached = headTagsCache.get(head)
988
+ if (cached !== undefined) return cached
989
+ }
990
+ let out = ""
991
+ if (head.meta !== undefined)
992
+ for (const m of head.meta) {
993
+ const attrs = tagAttrs("meta", m)
994
+ if (attrs !== null) out += `<meta${attrs === "" ? "" : ` ${attrs}`} data-nifra>`
995
+ }
996
+ if (head.link !== undefined)
997
+ for (const l of head.link) {
998
+ const attrs = tagAttrs("link", l)
999
+ if (attrs !== null) out += `<link${attrs === "" ? "" : ` ${attrs}`} data-nifra>`
1000
+ }
1001
+ // `<script>` slot - JSON-LD + other inert head scripts. `type` is attribute-escaped; `content` is
1002
+ // breakout-escaped (`escapeScriptContent`) so a `</script>` (or `<!--`/`]]>`) payload can't close the
1003
+ // element early. Managed (`data-nifra`) so a soft-nav's `applyHead` cleanly replaces it.
1004
+ if (head.script !== undefined)
1005
+ for (const s of head.script) {
1006
+ const type = s.type ?? "application/ld+json"
1007
+ if (!INERT_SCRIPT_TYPES.has(type)) {
1008
+ throw new TypeError(
1009
+ `[nifra/web] head.script only accepts inert JSON script types; received ${JSON.stringify(type)}. Use unsafeInlineScript() with a CSP nonce for executable code.`,
1010
+ )
1011
+ }
1012
+ out += `<script type="${type}" data-nifra>${escapeScriptContent(s.content)}</script>`
1013
+ }
1014
+ if (head.unsafeScript !== undefined)
1015
+ for (const s of head.unsafeScript) {
1016
+ if (s.unsafe !== true || s.nonce.trim() === "") {
1017
+ throw new TypeError("[nifra/web] executable inline scripts require a non-empty CSP nonce")
1018
+ }
1019
+ // `type` is validated, not escaped, for the same reason as the inert slot above: an unknown
1020
+ // value here is a bug, and escaping one would emit a nonsense script type rather than say so.
1021
+ // It was interpolated raw while the `nonce` beside it was escaped, so a descriptor built by hand
1022
+ // - `unsafeScript` is a public field, the helper is only a convenience - could close the
1023
+ // attribute and inject markup. Measured: `type: '"><img src=x onerror=alert(1)>'` reached the
1024
+ // document. An injection in the slot whose whole purpose is to make script emission trustworthy.
1025
+ assertExecutableScriptType(s.type)
1026
+ out += `<script type="${s.type}" nonce="${escapeAttr(s.nonce)}" data-nifra>${escapeScriptContent(s.content)}</script>`
1027
+ }
1028
+ if (cacheable) headTagsCache.set(head, out)
1029
+ return out
1030
+ }
1031
+
1032
+ /**
1033
+ * A `<link rel="canonical">` descriptor for a route's `meta.link`. The canonical URL tells search
1034
+ * engines which URL is authoritative for a page (deduping query-string / tracking variants).
1035
+ *
1036
+ * ```ts
1037
+ * export const meta = (a) => ({ link: [canonical(`https://site.com/posts/${a.params.slug}`)] })
1038
+ * ```
1039
+ */
1040
+ export function canonical(href: string): LinkDescriptor {
1041
+ return { rel: "canonical", href }
1042
+ }
1043
+
1044
+ /** Inputs for {@link openGraph} - the common Open Graph properties. All optional; only the provided
1045
+ * ones become tags. `type` defaults to `"website"`. */
1046
+ export interface OpenGraphInput {
1047
+ readonly title?: string
1048
+ readonly description?: string
1049
+ /** Absolute URL of the share image (`og:image`). */
1050
+ readonly image?: string
1051
+ /** Canonical URL of the page (`og:url`). */
1052
+ readonly url?: string
1053
+ /** Object type (`og:type`) - `"website"`, `"article"`, … Default `"website"`. */
1054
+ readonly type?: string
1055
+ }
1056
+
1057
+ /**
1058
+ * Build the Open Graph `<meta property="og:*">` entries for a route's `meta.meta`. Returns only the
1059
+ * properties you supplied (plus `og:type`, defaulting to `"website"`), so it composes with other meta.
1060
+ *
1061
+ * ```ts
1062
+ * export const meta = { meta: [...openGraph({ title: "Nifra", image: "https://site.com/og.png" })] }
1063
+ * ```
1064
+ */
1065
+ export function openGraph(input: OpenGraphInput): MetaDescriptor[] {
1066
+ const tags: MetaDescriptor[] = []
1067
+ const add = (property: string, content: string | undefined): void => {
1068
+ if (content !== undefined) tags.push({ property, content })
1069
+ }
1070
+ add("og:title", input.title)
1071
+ add("og:description", input.description)
1072
+ add("og:image", input.image)
1073
+ add("og:url", input.url)
1074
+ // `og:type` always present (the spec's required property) - default "website" when unset.
1075
+ tags.push({ property: "og:type", content: input.type ?? "website" })
1076
+ return tags
1077
+ }
1078
+
1079
+ /**
1080
+ * Build a JSON-LD `<script type="application/ld+json">` entry for a route's `meta.script` from a plain
1081
+ * object. `JSON.stringify` produces the body; the head renderer breakout-escapes it (see
1082
+ * `escapeScriptContent`), so a string field containing `</script>` is embedded safely.
1083
+ *
1084
+ * ```ts
1085
+ * export const meta = {
1086
+ * script: [jsonLd({ "@context": "https://schema.org", "@type": "Article", headline: "Hi" })],
1087
+ * }
1088
+ * ```
1089
+ */
1090
+ export function jsonLd(data: Record<string, unknown>): ScriptDescriptor {
1091
+ return { type: "application/ld+json", content: JSON.stringify(data) }
1092
+ }
1093
+
1094
+ /**
1095
+ * Deliberately unsafe escape hatch for executable inline code. The required nonce keeps the result
1096
+ * compatible with a strict CSP and makes the security-sensitive choice visible at the call site.
1097
+ */
1098
+ export function unsafeInlineScript(
1099
+ content: string,
1100
+ options: { readonly nonce: string; readonly type?: "module" | "text/javascript" },
1101
+ ): UnsafeScriptDescriptor {
1102
+ if (options.nonce.trim() === "") {
1103
+ throw new TypeError("[nifra/web] unsafeInlineScript requires a non-empty CSP nonce")
1104
+ }
1105
+ const type = options.type ?? "module"
1106
+ // Checked here as well as at emit: the type union is compile-time only, and this helper is the one
1107
+ // place a caller is told what it may pass. Failing at the call site names the argument; failing at
1108
+ // render names a document.
1109
+ assertExecutableScriptType(type)
1110
+ return {
1111
+ unsafe: true,
1112
+ type,
1113
+ nonce: options.nonce,
1114
+ content,
1115
+ }
1116
+ }