@nifrajs/web 2.2.0 → 2.4.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 (179) hide show
  1. package/README.md +3 -3
  2. package/dist/build-vite.d.ts +4 -4
  3. package/dist/build-vite.d.ts.map +1 -1
  4. package/dist/build-vite.js +20 -74
  5. package/dist/build-vite.js.map +1 -1
  6. package/dist/build.d.ts +64 -42
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/build.js +100 -78
  9. package/dist/build.js.map +1 -1
  10. package/dist/bun-dev-entry.d.ts +1 -1
  11. package/dist/bun-dev-entry.js +1 -1
  12. package/dist/chunk-names.js +1 -1
  13. package/dist/client.d.ts +3 -9
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +201 -29
  16. package/dist/client.js.map +1 -1
  17. package/dist/conformance.d.ts +2 -2
  18. package/dist/conformance.js +3 -3
  19. package/dist/deferred.d.ts +11 -11
  20. package/dist/deferred.d.ts.map +1 -1
  21. package/dist/deferred.js +16 -16
  22. package/dist/dev-diagnostics.d.ts +20 -0
  23. package/dist/dev-diagnostics.d.ts.map +1 -0
  24. package/dist/dev-diagnostics.js +34 -0
  25. package/dist/dev-diagnostics.js.map +1 -0
  26. package/dist/dev-error.d.ts +10 -13
  27. package/dist/dev-error.d.ts.map +1 -1
  28. package/dist/dev-error.js +51 -37
  29. package/dist/dev-error.js.map +1 -1
  30. package/dist/dev-port.d.ts +3 -3
  31. package/dist/dev-port.d.ts.map +1 -1
  32. package/dist/dev-port.js +10 -4
  33. package/dist/dev-port.js.map +1 -1
  34. package/dist/dev.d.ts +4 -4
  35. package/dist/dev.d.ts.map +1 -1
  36. package/dist/dev.js +28 -12
  37. package/dist/dev.js.map +1 -1
  38. package/dist/diagnostic.d.ts +93 -0
  39. package/dist/diagnostic.d.ts.map +1 -0
  40. package/dist/diagnostic.js +205 -0
  41. package/dist/diagnostic.js.map +1 -0
  42. package/dist/draft.d.ts +12 -12
  43. package/dist/draft.js +11 -11
  44. package/dist/fn-state.d.ts +56 -0
  45. package/dist/fn-state.d.ts.map +1 -0
  46. package/dist/fn-state.js +77 -0
  47. package/dist/fn-state.js.map +1 -0
  48. package/dist/fn.d.ts +111 -0
  49. package/dist/fn.d.ts.map +1 -0
  50. package/dist/fn.js +125 -0
  51. package/dist/fn.js.map +1 -0
  52. package/dist/fonts-google.d.ts +9 -9
  53. package/dist/fonts-google.js +10 -10
  54. package/dist/fonts.d.ts +7 -7
  55. package/dist/fonts.js +1 -1
  56. package/dist/forms.d.ts +8 -8
  57. package/dist/fs.d.ts +1 -1
  58. package/dist/fs.js +1 -1
  59. package/dist/index.d.ts +151 -70
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +317 -139
  62. package/dist/index.js.map +1 -1
  63. package/dist/internal/head-attributes.d.ts +15 -0
  64. package/dist/internal/head-attributes.d.ts.map +1 -0
  65. package/dist/internal/head-attributes.js +99 -0
  66. package/dist/internal/head-attributes.js.map +1 -0
  67. package/dist/internal/script-types.d.ts +14 -0
  68. package/dist/internal/script-types.d.ts.map +1 -0
  69. package/dist/internal/script-types.js +17 -0
  70. package/dist/internal/script-types.js.map +1 -0
  71. package/dist/internal/server-boundary.d.ts +60 -0
  72. package/dist/internal/server-boundary.d.ts.map +1 -0
  73. package/dist/internal/server-boundary.js +174 -0
  74. package/dist/internal/server-boundary.js.map +1 -0
  75. package/dist/internal/timing-safe-equal.d.ts +1 -1
  76. package/dist/internal/timing-safe-equal.js +1 -1
  77. package/dist/internal/vite-import.d.ts +63 -0
  78. package/dist/internal/vite-import.d.ts.map +1 -0
  79. package/dist/internal/vite-import.js +94 -0
  80. package/dist/internal/vite-import.js.map +1 -0
  81. package/dist/islands.d.ts +5 -5
  82. package/dist/islands.js +5 -5
  83. package/dist/isr.d.ts +15 -15
  84. package/dist/isr.js +7 -7
  85. package/dist/manifest.d.ts +73 -35
  86. package/dist/manifest.d.ts.map +1 -1
  87. package/dist/manifest.js +10 -16
  88. package/dist/manifest.js.map +1 -1
  89. package/dist/module-graph.d.ts +5 -5
  90. package/dist/module-graph.js +4 -4
  91. package/dist/navigation.d.ts +120 -5
  92. package/dist/navigation.d.ts.map +1 -1
  93. package/dist/navigation.js +37 -5
  94. package/dist/navigation.js.map +1 -1
  95. package/dist/plugins/css-modules.d.ts +3 -3
  96. package/dist/plugins/css-modules.js +0 -0
  97. package/dist/plugins/kit.d.ts +8 -8
  98. package/dist/plugins/kit.js +9 -9
  99. package/dist/plugins/scss.js +3 -3
  100. package/dist/plugins/vite-leak-guard.d.ts +1 -1
  101. package/dist/plugins/vite-leak-guard.js +1 -1
  102. package/dist/plugins/vite-server-fn.d.ts +23 -0
  103. package/dist/plugins/vite-server-fn.d.ts.map +1 -0
  104. package/dist/plugins/vite-server-fn.js +51 -0
  105. package/dist/plugins/vite-server-fn.js.map +1 -0
  106. package/dist/plugins/vite-server-only.d.ts +49 -0
  107. package/dist/plugins/vite-server-only.d.ts.map +1 -0
  108. package/dist/plugins/vite-server-only.js +57 -0
  109. package/dist/plugins/vite-server-only.js.map +1 -0
  110. package/dist/prerender.d.ts +5 -5
  111. package/dist/prerender.js +7 -7
  112. package/dist/query.d.ts +10 -10
  113. package/dist/query.js +15 -15
  114. package/dist/route-manifest.d.ts +1 -1
  115. package/dist/route-manifest.js +2 -2
  116. package/dist/router.d.ts +30 -21
  117. package/dist/router.d.ts.map +1 -1
  118. package/dist/router.js +62 -32
  119. package/dist/router.js.map +1 -1
  120. package/dist/search.d.ts +94 -0
  121. package/dist/search.d.ts.map +1 -0
  122. package/dist/search.js +232 -0
  123. package/dist/search.js.map +1 -0
  124. package/dist/server-only.d.ts +6 -6
  125. package/dist/server-only.js +7 -7
  126. package/dist/service-worker.d.ts +56 -0
  127. package/dist/service-worker.d.ts.map +1 -0
  128. package/dist/service-worker.js +127 -0
  129. package/dist/service-worker.js.map +1 -0
  130. package/dist/vite.d.ts +16 -14
  131. package/dist/vite.d.ts.map +1 -1
  132. package/dist/vite.js +51 -24
  133. package/dist/vite.js.map +1 -1
  134. package/package.json +38 -4
  135. package/server-manifest.d.ts +31 -0
  136. package/src/build-vite.ts +24 -77
  137. package/src/build.ts +137 -105
  138. package/src/bun-dev-entry.ts +1 -1
  139. package/src/chunk-names.ts +1 -1
  140. package/src/client.ts +215 -32
  141. package/src/conformance.ts +3 -3
  142. package/src/deferred.ts +17 -17
  143. package/src/dev-diagnostics.ts +47 -0
  144. package/src/dev-error.ts +64 -33
  145. package/src/dev-port.ts +17 -5
  146. package/src/dev.ts +36 -20
  147. package/src/diagnostic.ts +284 -0
  148. package/src/draft.ts +15 -15
  149. package/src/fn-state.ts +107 -0
  150. package/src/fn.ts +199 -0
  151. package/src/fonts-google.ts +13 -13
  152. package/src/fonts.ts +7 -7
  153. package/src/forms.ts +8 -8
  154. package/src/fs.ts +2 -2
  155. package/src/index.ts +460 -181
  156. package/src/internal/head-attributes.ts +109 -0
  157. package/src/internal/script-types.ts +18 -0
  158. package/src/internal/server-boundary.ts +187 -0
  159. package/src/internal/timing-safe-equal.ts +1 -1
  160. package/src/internal/vite-import.ts +92 -0
  161. package/src/islands.ts +7 -7
  162. package/src/isr.ts +17 -17
  163. package/src/manifest.ts +83 -42
  164. package/src/module-graph.ts +6 -6
  165. package/src/navigation.ts +151 -5
  166. package/src/plugins/css-modules.ts +0 -0
  167. package/src/plugins/kit.ts +11 -11
  168. package/src/plugins/scss.ts +3 -3
  169. package/src/plugins/vite-leak-guard.ts +2 -2
  170. package/src/plugins/vite-server-fn.ts +67 -0
  171. package/src/plugins/vite-server-only.ts +75 -0
  172. package/src/prerender.ts +10 -10
  173. package/src/query.ts +22 -22
  174. package/src/route-manifest.ts +3 -3
  175. package/src/router.ts +98 -45
  176. package/src/search.ts +297 -0
  177. package/src/server-only.ts +7 -7
  178. package/src/service-worker.ts +165 -0
  179. package/src/vite.ts +61 -32
package/src/index.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
- * @nifrajs/web — the framework-agnostic SSR core. It owns the render *seam* and the HTML
2
+ * @nifrajs/web - the framework-agnostic SSR core. It owns the render *seam* and the HTML
3
3
  * document orchestration, and knows nothing about any specific UI framework: a render
4
4
  * adapter (@nifrajs/web-solid, @nifrajs/web-react, …) supplies the actual render + hydrate.
5
5
  *
6
- * The core treats a "component" and a hydration "container" as opaque `unknown` — only
6
+ * The core treats a "component" and a hydration "container" as opaque `unknown` - only
7
7
  * the adapter interprets them. That keeps this package both framework-agnostic and free
8
8
  * of DOM types: it is pure server orchestration + string building.
9
9
  */
@@ -24,6 +24,8 @@ import {
24
24
  prepareDeferred,
25
25
  } from "./deferred.ts"
26
26
  import { isDraftEnabled } from "./draft.ts"
27
+ import { trustedHeadAttributes } from "./internal/head-attributes.ts"
28
+ import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./internal/script-types.ts"
27
29
  import { ISR_REVALIDATE_HEADER } from "./isr.ts"
28
30
  import { generateLlmsTxt } from "./llms-txt.ts"
29
31
  import type {
@@ -34,10 +36,12 @@ import type {
34
36
  Manifest,
35
37
  Meta,
36
38
  MetaArgs,
39
+ MetaDescriptor,
37
40
  MetaInput,
38
41
  RouteEntry,
39
42
  RouteModule,
40
43
  ScriptDescriptor,
44
+ UnsafeScriptDescriptor,
41
45
  } from "./manifest.ts"
42
46
  import {
43
47
  createMatcher,
@@ -50,8 +54,9 @@ import {
50
54
  STATUS_HEADER,
51
55
  type Submission,
52
56
  } from "./router.ts"
57
+ import { searchOf, searchOfChain } from "./search.ts"
53
58
 
54
- // Draft / preview mode — a signed cookie that flips `ctx.draft` for loaders + bypasses ISR for editors.
59
+ // Draft / preview mode - a signed cookie that flips `ctx.draft` for loaders + bypasses ISR for editors.
55
60
  export {
56
61
  DRAFT_COOKIE,
57
62
  type DraftCookieControls,
@@ -62,7 +67,7 @@ export {
62
67
  type PreviewEndpointOptions,
63
68
  previewEndpoint,
64
69
  } from "./draft.ts"
65
- // Font optimization — a CLS-safe `@font-face` generator + a preload `<link>` for self-hosted fonts.
70
+ // Font optimization - a CLS-safe `@font-face` generator + a preload `<link>` for self-hosted fonts.
66
71
  export {
67
72
  type FontDisplay,
68
73
  type FontFace,
@@ -90,7 +95,7 @@ export {
90
95
  revalidateEndpoint,
91
96
  withISR,
92
97
  } from "./isr.ts"
93
- // File-based routing manifest — pure + fs-free. `discoverRoutes` (fs) lives in `@nifrajs/web/fs`.
98
+ // File-based routing manifest - pure + fs-free. `discoverRoutes` (fs) lives in `@nifrajs/web/fs`.
94
99
  export {
95
100
  type Action,
96
101
  buildManifest,
@@ -98,6 +103,7 @@ export {
98
103
  filePathToPattern,
99
104
  filePathToPatterns,
100
105
  type GetStaticPaths,
106
+ type InertScriptType,
101
107
  type LayoutEntry,
102
108
  type LinkDescriptor,
103
109
  type Loader,
@@ -112,23 +118,37 @@ export {
112
118
  type StaticPath,
113
119
  type StaticPaths,
114
120
  type StaticRoutes,
121
+ type UnsafeScriptDescriptor,
115
122
  } from "./manifest.ts"
116
- // Navigation bridge — a DOM-free seam the browser layer (`installHistory`) populates so an adapter's
123
+ // Navigation bridge - a DOM-free seam the browser layer (`installHistory`) populates so an adapter's
117
124
  // `useNavigate` (a route component, importing only this agnostic entry) reaches history-aware nav.
118
125
  export {
126
+ type Blocker,
127
+ type BlockerController,
128
+ type BlockerFunction,
129
+ type BlockerLocation,
130
+ type BlockerState,
119
131
  type BrowserNavigate,
120
132
  getBrowserNavigate,
133
+ IDLE_BLOCKER,
134
+ type NavigateFunction,
121
135
  type NavigateOptions,
136
+ type NavigateSearchOf,
137
+ type NavigateTargetInput,
138
+ type RouteSearch,
139
+ registerBlocker,
140
+ resolveNavigate,
141
+ setBlockerController,
122
142
  setBrowserNavigate,
123
143
  } from "./navigation.ts"
124
- // public/ — user-authored static files, served identically in dev and production by one handler.
144
+ // public/ - user-authored static files, served identically in dev and production by one handler.
125
145
  export {
126
146
  type PublicDirCache,
127
147
  resolvePublicPath,
128
148
  type ServePublicDirOptions,
129
149
  servePublicDir,
130
150
  } from "./public-dir.ts"
131
- // Keyed query-cache (agnostic) — a `query(key, fn)` primitive (dedup + staleness + invalidation + GC)
151
+ // Keyed query-cache (agnostic) - a `query(key, fn)` primitive (dedup + staleness + invalidation + GC)
132
152
  // consumed by the per-adapter `useQuery`/`createQuery` bindings.
133
153
  export {
134
154
  createMutation,
@@ -149,7 +169,7 @@ export {
149
169
  type QueryState,
150
170
  type QueryStatus,
151
171
  } from "./query.ts"
152
- // Agnostic client-side router core (pure + DOM-free) — consumed by per-adapter Router bindings.
172
+ // Agnostic client-side router core (pure + DOM-free) - consumed by per-adapter Router bindings.
153
173
  // `DATA_HEADER` marks a navigation's data-only GET; `createWebApp` answers it with loader JSON.
154
174
  export {
155
175
  type ClientRouter,
@@ -172,27 +192,27 @@ export {
172
192
  type Submission,
173
193
  type SubmitOptions,
174
194
  } from "./router.ts"
175
- // Deferred loader data (`defer()` + the `Deferred<T>` type) — consumed by the adapter's `<Await>`.
195
+ // Deferred loader data (`defer()` + the `Deferred<T>` type) - consumed by the adapter's `<Await>`.
176
196
  export { type Deferred, defer }
177
197
 
178
198
  /** Phantom brand key for {@link ServerOnly}. A unique symbol so the brand can't be forged by a plain
179
199
  * object literal; `unique symbol` keys never appear in the emitted JS, so the brand is purely
180
200
  * type-level (zero runtime cost). The property is optional + `never`-typed, so a `ServerOnly<T>`
181
- * stays assignment-compatible with `T` — it documents intent without obstructing real use. */
201
+ * stays assignment-compatible with `T` - it documents intent without obstructing real use. */
182
202
  declare const SERVER_ONLY_BRAND: unique symbol
183
203
 
184
204
  /**
185
- * Type-level intent marker for a value that must only exist on the server — a secret, a DB handle, a
205
+ * Type-level intent marker for a value that must only exist on the server - a secret, a DB handle, a
186
206
  * server-only client. `ServerOnly<T>` is structurally `T` (the brand is an optional phantom field, so
187
207
  * existing code keeps type-checking), but it advertises to readers + the compiler that the value is
188
- * not meant to cross to the browser. It is **purely type-level** and erases at build — it does NOT,
208
+ * not meant to cross to the browser. It is **purely type-level** and erases at build - it does NOT,
189
209
  * by itself, keep the value out of the client bundle.
190
210
  *
191
211
  * The enforcement is two runtime conventions:
192
- * - add the side-effect import `import "@nifrajs/web/server-only"` at the top of the module — the
212
+ * - add the side-effect import `import "@nifrajs/web/server-only"` at the top of the module - the
193
213
  * client build ({@link buildClient}) fails loud, with the import chain, if it reaches a browser
194
214
  * chunk (the poison-import marker);
195
- * - or name the file `*.server.ts` — the `.server` convention empties it in the client build.
215
+ * - or name the file `*.server.ts` - the `.server` convention empties it in the client build.
196
216
  *
197
217
  * Use this brand to express the intent in the types; pair it with one of those runtime markers so a
198
218
  * leak is caught at build time rather than shipping a secret to the client.
@@ -210,9 +230,15 @@ export {
210
230
  type RenderAdapterConformanceFixture,
211
231
  } from "./conformance.ts"
212
232
 
233
+ // The search-derivation primitive: parse a URL query, validate it against a route's `searchSchema`
234
+ // (fail-closed to defaults), or return the raw parsed query when there is none. Both the server (loader
235
+ // ctx + `renderPage`) and a client adapter mount call this with the same URL + schema, so the two sides
236
+ // produce the identical value by construction. An adapter's `useSearch` binding reads its result.
237
+ export { type SearchOf, searchOf, searchOfChain, serializeSearch } from "./search.ts"
238
+
213
239
  /** The data handed to a route component. Opaque to the core. `actionData` is the return of a
214
240
  * route `action` after a POST (absent on plain GETs). `pending` + `submission` are client-only
215
- * (absent on SSR): they drive **optimistic UI** — render from `submission.formData` while `pending`. */
241
+ * (absent on SSR): they drive **optimistic UI** - render from `submission.formData` while `pending`. */
216
242
  export interface RenderProps {
217
243
  readonly data: unknown
218
244
  /**
@@ -235,10 +261,15 @@ export interface RenderProps {
235
261
  * SSR (from the request match) and on client navigation (from router state) so an adapter's
236
262
  * `useParams` reads the same value on both, with no hydration mismatch. Absent ⇒ no params (`{}`). */
237
263
  readonly params?: Readonly<Record<string, string>>
238
- /** The current URL's `pathname + search` (no hash — the hash never reaches the server). Threaded on
264
+ /** The current URL's `pathname + search` (no hash - the hash never reaches the server). Threaded on
239
265
  * SSR and client alike so an adapter's `useLocation`/`useSearchParams` hydrate consistently. Absent
240
266
  * on adapters/callers that don't supply it (treated as `""`). */
241
267
  readonly path?: string
268
+ /** The route's typed, validated search params (the value the loader also gets as `ctx.search`),
269
+ * threaded to the adapter's `useSearch`. Derived identically on SSR (from the request) and on client
270
+ * navigation (adapter mount) via the shared `searchOf`, so a component reading it hydrates with no
271
+ * drift. Absent ⇒ no search context (`{}`). */
272
+ readonly search?: Record<string, unknown>
242
273
  }
243
274
 
244
275
  /**
@@ -252,7 +283,7 @@ export interface RenderAdapter {
252
283
  * (the loader data); each layout wraps the child via its `children`. Returns (or resolves to)
253
284
  * a Web `ReadableStream<Uint8Array>`: a streaming renderer flushes the shell first and streams
254
285
  * Suspense boundaries as they resolve; a non-streaming renderer may return a one-chunk stream.
255
- * May be async — e.g. React resolves once the shell is renderable, so a shell-render throw can
286
+ * May be async - e.g. React resolves once the shell is renderable, so a shell-render throw can
256
287
  * still map to an error status before any byte is sent.
257
288
  */
258
289
  renderToStream(
@@ -262,10 +293,10 @@ export interface RenderAdapter {
262
293
  /**
263
294
  * Server (optional): render the chain to a complete HTML **string** in one pass, with the same
264
295
  * hydration markers `renderToStream` produces. When provided, `renderPage` uses it for any page
265
- * that does **not** `defer()` — buffering the document is faster than the streaming pipeline there,
296
+ * that does **not** `defer()` - buffering the document is faster than the streaming pipeline there,
266
297
  * because the framework's synchronous renderer (React `renderToString`, Solid `renderToString`,
267
298
  * Preact `render`, Vue `renderToString`, Svelte `render`) is markedly cheaper than its streaming
268
- * renderer — most visibly on Bun, where React Fizz / Solid `renderToStream` are the heaviest. Pages
299
+ * renderer - most visibly on Bun, where React Fizz / Solid `renderToStream` are the heaviest. Pages
269
300
  * that `defer()` keep the streaming path (progressive `<Await>` needs it). Markup MUST be
270
301
  * hydration-equivalent to `renderToStream`; a throw surfaces like the streaming shell-readiness
271
302
  * await (the `_error` boundary maps it to a status). Omit it to always stream.
@@ -275,7 +306,7 @@ export interface RenderAdapter {
275
306
  * Server: per-document bootstrap markup injected into `<head>` that the client
276
307
  * `hydrate` requires (Solid: `generateHydrationScript()`). Empty string if none.
277
308
  */
278
- hydrationHead(): string
309
+ hydrationHead(nonce?: string): string
279
310
  }
280
311
 
281
312
  /** Global the server serializes loader data into; the client reads it to hydrate. */
@@ -292,12 +323,25 @@ export const ROUTE_GLOBAL = "__NIFRA_ROUTE__"
292
323
  export const ACTION_GLOBAL = "__NIFRA_ACTION__"
293
324
 
294
325
  /**
295
- * Pre-hydration form guard — a tiny inline script flushed in `<head>` (it runs in the window between
326
+ * Marker attribute on the app container, emitted ONLY when `rootId` is not the default.
327
+ *
328
+ * `rootId` is a per-RENDER option; the client entry is a per-BUILD artifact, so it cannot be
329
+ * generated knowing which id a given render will use. The container therefore announces itself, and
330
+ * the entry finds it by this attribute before falling back to `#root`. It has to be the DOM rather
331
+ * than another `window.__NIFRA_*` global: a second copy of the id can drift from the markup, while
332
+ * this is written by the same expression that writes the id and cannot.
333
+ *
334
+ * Absent on a default render, so an app that never sets `rootId` emits the same bytes it always has.
335
+ */
336
+ export const ROOT_ATTRIBUTE = "data-nifra-root"
337
+
338
+ /**
339
+ * Pre-hydration form guard - a tiny inline script flushed in `<head>` (it runs in the window between
296
340
  * first paint and the island bundle taking over). It neutralizes the one real hydration footgun: a
297
341
  * JS-only form (a hand-wired `onSubmit` with no native fallback) submitting *natively* before its
298
342
  * handler is attached, which navigates the browser to a broken `?field=…` GET of the current page.
299
343
  *
300
- * It blocks ONLY that shape — an effective-GET form whose action targets the current document — and
344
+ * It blocks ONLY that shape - an effective-GET form whose action targets the current document - and
301
345
  * only until hydration commits (`data-nifra-hydrated`, set by the client entry). Method-`post` forms
302
346
  * (progressive enhancement: the native POST hits the route `action`) and GET forms with a real action
303
347
  * (a search box → `/search`) pass through untouched. Opt out per-form with `data-native`. Static text
@@ -315,12 +359,12 @@ export const PRE_HYDRATION_GUARD =
315
359
  /**
316
360
  * The single default port for the dev server (`@nifrajs/web/dev`, `@nifrajs/web/vite`) **and**
317
361
  * `nifra start`. Deliberately uncommon: `3000`/`5173`/`8080` collide with whatever else is running
318
- * (Next, Vite, a stray Node API). `4321` rarely is — and being the *same* constant across `nifra dev`
362
+ * (Next, Vite, a stray Node API). `4321` rarely is - and being the *same* constant across `nifra dev`
319
363
  * and `nifra start` means a project's URL doesn't change between commands. Override per-run with
320
364
  * `--port <n>` or the `PORT` env var. */
321
365
  export const DEFAULT_DEV_PORT = 4321
322
366
 
323
- // Shared, stateless — allocated once at module load, not per render/stream.
367
+ // Shared, stateless - allocated once at module load, not per render/stream.
324
368
  const TEXT_ENCODER = new TextEncoder()
325
369
 
326
370
  type MaybePromise<T> = T | Promise<T>
@@ -353,21 +397,27 @@ export interface RenderedPage {
353
397
 
354
398
  export interface RenderPageOptions {
355
399
  readonly adapter: RenderAdapter
356
- /** The layout chain to render — outermost layout → page (opaque; the adapter renders it). */
400
+ /** The layout chain to render - outermost layout → page (opaque; the adapter renders it). */
357
401
  readonly chain: readonly unknown[]
358
402
  /** The loader output for this request. */
359
403
  readonly data: unknown
360
- /** An action's data return (POST only) — surfaced to the page as `actionData` + serialized
404
+ /** An action's data return (POST only) - surfaced to the page as `actionData` + serialized
361
405
  * for the client so post-POST hydration matches. Omit on GETs. */
362
406
  readonly actionData?: unknown
363
- /** URL of the built client entry (loaded as a module script). */
364
- readonly clientEntry: string
407
+ /**
408
+ * URL of the built client entry (loaded as a module script).
409
+ *
410
+ * Optional ONLY on a `hydrate: false` page, where there is no client takeover to feed and this value
411
+ * reaches no output at all. The union below is what enforces that: omitting it on a hydrating page
412
+ * stays a compile error rather than rendering `src=""`.
413
+ */
414
+ readonly clientEntry?: string
365
415
  /** Chunk URLs for the **matched** route (its layout chain + own chunk) to `modulepreload` in the
366
- * shell — so the route code downloads in parallel with the entry instead of after it
416
+ * shell - so the route code downloads in parallel with the entry instead of after it
367
417
  * (`buildClient`'s per-route map). Empty/omitted ⇒ only the entry is preloaded (unchanged). */
368
418
  readonly preload?: readonly string[]
369
419
  /** Stylesheet URLs for the **matched** route (its layout chain + own CSS, from `buildClient`'s
370
- * `BuildManifest.css`) — injected as `<link rel="stylesheet">` in `<head>` so styles arrive with the
420
+ * `BuildManifest.css`) - injected as `<link rel="stylesheet">` in `<head>` so styles arrive with the
371
421
  * first paint (no FOUC). Rendered even on non-hydrated pages. Empty/omitted ⇒ none (unchanged). */
372
422
  readonly styles?: readonly string[]
373
423
  /** SSG: the prerendered-path set, serialized to `window.__NIFRA_PRERENDERED__` so the client fetches
@@ -378,14 +428,18 @@ export interface RenderPageOptions {
378
428
  readonly revalidate?: number
379
429
  /** Matched route id; written to `window.__NIFRA_ROUTE__` so the client hydrates this chain. */
380
430
  readonly routeId?: string
381
- /** The matched route's decoded path params — surfaced to the page as `params` (via {@link RenderProps})
431
+ /** The matched route's decoded path params - surfaced to the page as `params` (via {@link RenderProps})
382
432
  * so an adapter's `useParams` is SSR-correct. Omit ⇒ `{}` (a route with no dynamic segments, or an
383
433
  * error/404 render). */
384
434
  readonly params?: Readonly<Record<string, string>>
385
- /** The request's `pathname + search` — surfaced as `path` (via {@link RenderProps}) so an adapter's
435
+ /** The request's `pathname + search` - surfaced as `path` (via {@link RenderProps}) so an adapter's
386
436
  * `useLocation`/`useSearchParams` render the right URL server-side and hydrate without drift. Omit ⇒
387
437
  * `""`. */
388
438
  readonly path?: string
439
+ /** The route's validated search (`searchOf(mod.searchSchema, url.search)`, the same value handed to
440
+ * the loader as `ctx.search`), surfaced as `search` (via {@link RenderProps}) so an adapter's
441
+ * `useSearch` is SSR-correct. Omit ⇒ `{}` (a render with no search context). */
442
+ readonly search?: Record<string, unknown>
389
443
  /** Per-layout loader data, forwarded to the adapter as `RenderProps.layoutData`. Aligned with the
390
444
  * chain's layout prefix; omitted when no layout in the chain has a loader. */
391
445
  readonly layoutData?: readonly unknown[]
@@ -397,45 +451,57 @@ export interface RenderPageOptions {
397
451
  readonly headers?: HeadersLike
398
452
  /** Document title (fallback when `head.title` is unset). */
399
453
  readonly title?: string
400
- /** Resolved route head — `title` overrides `title` above; `meta`/`link` render as managed
454
+ /** Resolved route head - `title` overrides `title` above; `meta`/`link` render as managed
401
455
  * (`data-nifra`) tags the client updates on navigation.
402
456
  *
403
457
  * **Head contract (the layout chain contributes).** `createWebApp` resolves this via
404
458
  * {@link mergeHeads}: a route's head is its **layout chain's** `meta`/`head` exports merged with the
405
- * page's. A `_layout.tsx` may `export const meta` (or `export function meta(args)`) — its tags land
459
+ * page's. A `_layout.tsx` may `export const meta` (or `export function meta(args)`) - its tags land
406
460
  * on every page below it (the home for `hreflang`, `preconnect`, a section-default `<title>`). The
407
461
  * merge is **nearest-wins for scalars** (the page's `title` overrides an inner layout's, which
408
462
  * overrides an outer one; an undefined page title keeps the layout's) and **concatenated for the
409
- * `meta`/`link` arrays** (outermost layout first, page last). `<link>` attributes are name-validated
410
- * (any letter/digit/hyphen name — covers `rel`/`href`/`hreflang`/`crossorigin`/`media`/`sizes`/`as`/
411
- * `integrity`/`fetchpriority`/…) and value-escaped against XSS. */
463
+ * `meta`/`link` arrays** (outermost layout first, page last). `<meta>`/`<link>` attributes pass a
464
+ * shared tag-specific allowlist (including inert `data-*` metadata) and values are escaped. */
412
465
  readonly head?: Meta
413
- /** Id of the container wrapping the app markup (default `"root"`). */
466
+ /** Id of the container wrapping the app markup (default `"root"`). A non-default id also gets
467
+ * {@link ROOT_ATTRIBUTE}, which is how the generated client entry finds the container to hydrate -
468
+ * the entry is built once and cannot know what a given render chose. */
414
469
  readonly rootId?: string
415
- /** When `false`, emit a complete but **non-hydrated** document — no client entry script, data
470
+ /** When `false`, emit a complete but **non-hydrated** document - no client entry script, data
416
471
  * globals, or modulepreloads. Used for server-rendered `_error` pages: a terminal state that needs no
417
472
  * client takeover, and it sidesteps an SSR/hydrate mismatch (the server rendered the boundary, not
418
473
  * the page the client manifest maps this route id to). Default `true`. */
419
474
  readonly hydrate?: boolean
420
475
  /** Island client bundles (`@nifrajs/web/islands`) to load as `<script type="module">` in the document
421
- * tail — emitted **regardless of `hydrate`**, so a static (`hydrate: false`) page can still mount
476
+ * tail - emitted **regardless of `hydrate`**, so a static (`hydrate: false`) page can still mount
422
477
  * no-framework islands. URLs are attribute-escaped. Empty/omitted ⇒ none (unchanged output). */
423
478
  readonly islandScripts?: readonly string[]
479
+ /** CSP nonce applied to every framework-owned executable script in this document. */
480
+ readonly nonce?: string
424
481
  }
425
482
 
426
483
  /**
427
- * Server: render a full HTML document for a page — the adapter's hydration head + the SSR
428
- * markup (**streamed**) + the serialized loader data + the client module — as a `Response`.
484
+ * `renderPage` input. A hydrating page (the default) must supply `clientEntry`, because the document
485
+ * loads it as a module script; a `hydrate: false` page may omit it, because nothing in the emitted
486
+ * document references it. Expressed as a union so the compiler enforces the pairing rather than the
487
+ * renderer discovering an empty `src` at runtime.
488
+ */
489
+ export type RenderPageInput = RenderPageOptions &
490
+ ({ readonly hydrate?: true; readonly clientEntry: string } | { readonly hydrate: false })
491
+
492
+ /**
493
+ * Server: render a full HTML document for a page - the adapter's hydration head + the SSR
494
+ * markup (**streamed**) + the serialized loader data + the client module - as a `Response`.
429
495
  * The shell (`<head>` + the open container) flushes first, the adapter's app stream follows,
430
496
  * then the tail (data globals + client entry). Pure Web Standards, so it returns straight from
431
497
  * a nifra route handler and streams on any fetch runtime (Bun/Node/Deno/Workers).
432
498
  */
433
- export function renderPage(options: RenderPageOptions): MaybePromise<Response> {
499
+ export function renderPage(options: RenderPageInput): MaybePromise<Response> {
434
500
  const page = renderPageResult(options)
435
501
  return page instanceof Promise ? page.then((p) => p.toResponse()) : page.toResponse()
436
502
  }
437
503
 
438
- export function renderPageResult(options: RenderPageOptions): MaybePromise<RenderedPage> {
504
+ export function renderPageResult(options: RenderPageInput): MaybePromise<RenderedPage> {
439
505
  const {
440
506
  adapter,
441
507
  chain,
@@ -453,10 +519,15 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
453
519
  rootId = "root",
454
520
  hydrate = true,
455
521
  islandScripts = [],
522
+ nonce,
456
523
  headers: extraHeaders,
457
524
  } = options
525
+ if (nonce !== undefined && nonce.trim() === "") {
526
+ throw new TypeError("[nifra/web] renderPage nonce must be non-empty when provided")
527
+ }
528
+ const nonceAttr = nonce === undefined ? "" : ` nonce="${escapeAttr(nonce)}"`
458
529
  const route = routeId === undefined ? "" : `window.${ROUTE_GLOBAL}=${serializeData(routeId)};`
459
- // The SSG prerendered-path set (when an app declares it) — the client reads it to fetch a static
530
+ // The SSG prerendered-path set (when an app declares it) - the client reads it to fetch a static
460
531
  // `_data.json` on soft-nav into a prerendered route instead of hitting the worker. Empty ⇒ omitted.
461
532
  const prerendered =
462
533
  prerenderedPaths.length === 0
@@ -464,7 +535,7 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
464
535
  : `window.${PRERENDERED_GLOBAL}=${serializeData(prerenderedPaths)};`
465
536
  // Split deferred values: the component sees markers (id + promise) to `<Await>`; the serialized
466
537
  // data carries `{__nifra_deferred: id}` placeholders (promises don't serialize). `actionData` may
467
- // also `defer()` — split it too, continuing the id space so a single registry settles both. The
538
+ // also `defer()` - split it too, continuing the id space so a single registry settles both. The
468
539
  // inline registry runtime is emitted only when something defers, so non-deferred output is unchanged.
469
540
  const { forComponent, forClient, deferred } = prepareDeferred(data)
470
541
  // Each layout's data is split in turn, each continuing the id space, so a `defer()` in a layout
@@ -484,7 +555,7 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
484
555
  ? undefined
485
556
  : prepareDeferred(actionData, deferred.length + layoutDeferred.length)
486
557
  const allDeferred = [...deferred, ...layoutDeferred, ...(actionSplit ? actionSplit.deferred : [])]
487
- // Omitted entirely when no layout has a loader — a page-only app emits exactly what it did before.
558
+ // Omitted entirely when no layout has a loader - a page-only app emits exactly what it did before.
488
559
  const layoutTail =
489
560
  options.layoutData === undefined
490
561
  ? ""
@@ -495,9 +566,10 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
495
566
  actionSplit === undefined
496
567
  ? ""
497
568
  : `window.${ACTION_GLOBAL}=${serializeData(actionSplit.forClient)};`
498
- const deferredRuntime = allDeferred.length > 0 ? `<script>${DEFERRED_RUNTIME}</script>` : ""
569
+ const deferredRuntime =
570
+ allDeferred.length > 0 ? `<script${nonceAttr}>${DEFERRED_RUNTIME}</script>` : ""
499
571
  // Matched-route chunk preloads, concatenated directly (skip the `filter().map()`
500
- // intermediate arrays — and the whole loop — on the common no-extra-preload render). De-duped
572
+ // intermediate arrays - and the whole loop - on the common no-extra-preload render). De-duped
501
573
  // against the entry, which is preloaded separately below.
502
574
  let preloadLinks = ""
503
575
  if (preload.length > 0) {
@@ -506,7 +578,7 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
506
578
  preloadLinks += `<link rel="modulepreload" href="${escapeAttr(url)}">`
507
579
  }
508
580
  }
509
- // The matched route's stylesheets — `<link rel="stylesheet">` in `<head>` so CSS arrives with the
581
+ // The matched route's stylesheets - `<link rel="stylesheet">` in `<head>` so CSS arrives with the
510
582
  // first paint (no FOUC). Render-blocking by design, and emitted regardless of `hydrate` (a static or
511
583
  // `_error` page still wants its styles). In dev (Vite) CSS is injected client-side instead, so
512
584
  // `styles` is empty there.
@@ -514,44 +586,51 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
514
586
  for (const url of styles) styleLinks += `<link rel="stylesheet" href="${escapeAttr(url)}">`
515
587
  // Island bundles are referenced only by a `<script type="module">` at the END of `<body>` (below), so
516
588
  // the browser doesn't discover them until the whole page is parsed. `modulepreload` them in `<head>`
517
- // so the fetch starts immediately, in parallel with parsing — regardless of `hydrate` (an island page
589
+ // so the fetch starts immediately, in parallel with parsing - regardless of `hydrate` (an island page
518
590
  // is typically hydrate:false). Without this, a heavy island bundle on a cold first load leaves its
519
591
  // server-rendered placeholder visible until that late, un-prefetched fetch lands ("stuck loading").
520
592
  let islandPreloads = ""
521
593
  for (const src of islandScripts)
522
594
  islandPreloads += `<link rel="modulepreload" href="${escapeAttr(src)}">`
523
- // Shell — flushed before the app finishes rendering: `<head>` (title, meta, hydration head) + the
524
- // open container. `modulepreload` of the client entry — plus the matched route's own chunks
525
- // (`preloadLinks`) — lets the JS download while the body streams. One template literal (no
526
- // intermediate array + `join`) — byte-identical to the prior output.
595
+ // Shell - flushed before the app finishes rendering: `<head>` (title, meta, hydration head) + the
596
+ // open container. `modulepreload` of the client entry - plus the matched route's own chunks
597
+ // (`preloadLinks`) - lets the JS download while the body streams. One template literal (no
598
+ // intermediate array + `join`) - byte-identical to the prior output.
527
599
  // A non-hydrated page (e.g. an `_error` boundary) omits the client-entry preload, route-chunk
528
- // preloads, and deferred runtime — there's no client takeover to feed.
600
+ // preloads, and deferred runtime - there's no client takeover to feed.
529
601
  const hydrationLinks = hydrate
530
- ? `<link rel="modulepreload" href="${escapeAttr(clientEntry)}">${preloadLinks}${deferredRuntime}`
602
+ ? `<link rel="modulepreload" href="${escapeAttr(clientEntry ?? "")}">${preloadLinks}${deferredRuntime}`
531
603
  : ""
532
604
  // Runs in the first-paint→hydration window to swallow a JS-only form's broken native submit. Only on a
533
605
  // hydrating page (a static/_error page has no client handlers, so no footgun).
534
- const hydrationGuard = hydrate ? `<script>${PRE_HYDRATION_GUARD}</script>` : ""
606
+ const hydrationGuard = hydrate ? `<script${nonceAttr}>${PRE_HYDRATION_GUARD}</script>` : ""
535
607
  // `<html>` attributes. `lang` defaults to `"en"` (so an app that never sets it is byte-identical to
536
- // before); `dir` is omitted entirely when unset, which IS HTML's `ltr` default — emitting it
608
+ // before); `dir` is omitted entirely when unset, which IS HTML's `ltr` default - emitting it
537
609
  // unconditionally would change every existing app's output for no gain. Both attribute-escaped.
538
610
  // `client.ts`'s `applyHead` mirrors this exact defaulting on soft-nav, so a hard load and a client
539
611
  // navigation to the same URL produce the same `<html>` (no drift on a multilingual site).
540
612
  const htmlAttrs = ` lang="${escapeAttr(head?.lang ?? "en")}"${head?.dir === undefined ? "" : ` dir="${escapeAttr(head.dir)}"`}`
541
- const shellHtml = `<!doctype html><html${htmlAttrs}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${hydrationGuard}<title>${escapeHtml(head?.title ?? title)}</title>${headTags(head)}${styleLinks}${hydrationLinks}${islandPreloads}${adapter.hydrationHead()}</head><body><div id="${escapeAttr(rootId)}">`
613
+ // Marks the container for the client entry when the id is not the one the entry falls back to. A
614
+ // static attribute name with no value, so `rootId` never reaches the markup twice and there is
615
+ // nothing here to escape. Omitted on the default so existing output is unchanged.
616
+ const rootMarker = rootId === "root" ? "" : ` ${ROOT_ATTRIBUTE}`
617
+ const hydrationHead = adapter
618
+ .hydrationHead(nonce)
619
+ .replace(/<script(?![^>]*\bnonce=)(?=[\s>])/g, `<script${nonceAttr}`)
620
+ const shellHtml = `<!doctype html><html${htmlAttrs}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${hydrationGuard}<title>${escapeHtml(head?.title ?? title)}</title>${headTags(head)}${styleLinks}${hydrationLinks}${islandPreloads}${hydrationHead}</head><body><div id="${escapeAttr(rootId)}"${rootMarker}>`
542
621
  // Closes the hydration container; deferred resolve scripts go AFTER it (outside `#root`) so they
543
622
  // aren't part of the adapter's hydrated tree (an inline script inside it breaks hydration).
544
623
  const closeRootHtml = "</div>"
545
- // Tail — the loader-data globals + the client module. Module scripts defer (run after parse), so
624
+ // Tail - the loader-data globals + the client module. Module scripts defer (run after parse), so
546
625
  // the data global + every streamed deferred resolution are set before the entry hydrates.
547
- // Island bundles load regardless of `hydrate` — a static page (hydrate:false) ships no framework
626
+ // Island bundles load regardless of `hydrate` - a static page (hydrate:false) ships no framework
548
627
  // client but can still mount no-framework islands (`@nifrajs/web/islands`).
549
628
  let islandTags = ""
550
629
  for (const src of islandScripts)
551
- islandTags += `<script type="module" src="${escapeAttr(src)}"></script>`
630
+ islandTags += `<script type="module" src="${escapeAttr(src)}"${nonceAttr}></script>`
552
631
  const tailHtml = `${
553
632
  hydrate
554
- ? `<script>${route}${action}${prerendered}${layoutTail}window.${DATA_GLOBAL}=${serializeData(forClient)}</script><script type="module" src="${escapeAttr(clientEntry)}"></script>`
633
+ ? `<script${nonceAttr}>${route}${action}${prerendered}${layoutTail}window.${DATA_GLOBAL}=${serializeData(forClient)}</script><script type="module" src="${escapeAttr(clientEntry ?? "")}"${nonceAttr}></script>`
555
634
  : ""
556
635
  }${islandTags}</body></html>`
557
636
  const headers: Record<string, string> = { "content-type": "text/html; charset=utf-8" }
@@ -571,10 +650,13 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
571
650
  data: forComponent,
572
651
  actionData: actionSplit?.forComponent,
573
652
  // `params`/`path` thread the matched route + URL to the adapter's `useParams`/`useLocation` so they
574
- // render SSR-correct. Spread only when supplied (exactOptionalPropertyTypes) — an adapter with no
653
+ // render SSR-correct. Spread only when supplied (exactOptionalPropertyTypes) - an adapter with no
575
654
  // router bindings simply never reads them.
576
655
  ...(options.params !== undefined ? { params: options.params } : {}),
577
656
  ...(options.path !== undefined ? { path: options.path } : {}),
657
+ // Same rule for `search`: the validated query the adapter's `useSearch` reads, spread only when the
658
+ // caller supplied it (a non-router render never has it).
659
+ ...(options.search !== undefined ? { search: options.search } : {}),
578
660
  // Spread only when present, so a page-only render produces exactly the props it did before.
579
661
  ...(options.layoutData !== undefined
580
662
  ? { layoutData: layoutSplits.map((split) => split.forComponent) }
@@ -611,6 +693,7 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
611
693
  tailHtml,
612
694
  status,
613
695
  headers,
696
+ nonceAttr,
614
697
  ).then((response) => new ResponseRenderedPage(response))
615
698
  }
616
699
 
@@ -624,8 +707,9 @@ async function renderStreamedPage(
624
707
  tailHtml: string,
625
708
  status: number,
626
709
  headers: Record<string, string>,
710
+ nonceAttr: string,
627
711
  ): Promise<Response> {
628
- // Streaming path — required for `defer()` (progressive `<Await>` resolution) and used by any adapter
712
+ // Streaming path - required for `defer()` (progressive `<Await>` resolution) and used by any adapter
629
713
  // that doesn't implement `renderToString`. Awaiting `renderToStream` resolves on shell-readiness
630
714
  // (React: on-shell-ready; Solid: synchronously), so a shell-render throw surfaces before any byte is
631
715
  // sent. A *mid*-stream failure errors the body instead.
@@ -634,7 +718,7 @@ async function renderStreamedPage(
634
718
  const closeRoot = enc.encode(closeRootHtml)
635
719
  const tail = enc.encode(tailHtml)
636
720
  const appStream = await adapter.renderToStream(chain, renderProps)
637
- const body = streamDocument(shell, appStream, closeRoot, allDeferred, tail, enc)
721
+ const body = streamDocument(shell, appStream, closeRoot, allDeferred, tail, enc, nonceAttr)
638
722
  return new Response(body, { status, headers })
639
723
  }
640
724
 
@@ -688,8 +772,8 @@ function htmlResponse(body: string, init: ResponseInit): Response {
688
772
  /**
689
773
  * Assemble the document stream: `shell` → the app `stream` (forwarded chunk-by-chunk, so a streaming
690
774
  * renderer's progressive flushing is preserved) → `closeRoot` → one `__nifraResolve`/`__nifraReject`
691
- * script per deferred value (emitted once its promise settles — by now the app stream has awaited
692
- * the same Suspense boundaries — and placed OUTSIDE `#root`) → `tail`. A mid-stream app error errors
775
+ * script per deferred value (emitted once its promise settles - by now the app stream has awaited
776
+ * the same Suspense boundaries - and placed OUTSIDE `#root`) → `tail`. A mid-stream app error errors
693
777
  * the result (the body breaks) rather than silently truncating a 200.
694
778
  */
695
779
  function streamDocument(
@@ -699,6 +783,7 @@ function streamDocument(
699
783
  deferred: ReadonlyArray<{ readonly id: number; readonly promise: Promise<unknown> }>,
700
784
  tail: Uint8Array,
701
785
  enc: TextEncoder,
786
+ nonceAttr: string,
702
787
  ): ReadableStream<Uint8Array> {
703
788
  return new ReadableStream({
704
789
  async start(controller) {
@@ -711,23 +796,23 @@ function streamDocument(
711
796
  controller.enqueue(value)
712
797
  }
713
798
  controller.enqueue(closeRoot)
714
- // Stream each resolution as ITS OWN promise settles — NOT in array order. A slow
799
+ // Stream each resolution as ITS OWN promise settles - NOT in array order. A slow
715
800
  // defer() must not block a faster one; each script self-addresses by id, so order is irrelevant.
716
801
  await Promise.all(
717
802
  deferred.map(async (d) => {
718
803
  try {
719
804
  const value = serializeData(await d.promise)
720
805
  controller.enqueue(
721
- enc.encode(`<script>window.__nifraResolve(${d.id},${value})</script>`),
806
+ enc.encode(`<script${nonceAttr}>window.__nifraResolve(${d.id},${value})</script>`),
722
807
  )
723
808
  } catch (err) {
724
- // A rejected deferred streams __nifraReject (the client `<Await>` surfaces it) — it must
809
+ // A rejected deferred streams __nifraReject (the client `<Await>` surfaces it) - it must
725
810
  // not break the whole body. Redact: stream a stable opaque code, never the raw error
726
811
  // text; log the real reason server-side.
727
812
  console.error("[nifra/web] deferred value rejected:", err)
728
813
  controller.enqueue(
729
814
  enc.encode(
730
- `<script>window.__nifraReject(${d.id},${serializeData(DEFERRED_ERROR_CODE)})</script>`,
815
+ `<script${nonceAttr}>window.__nifraReject(${d.id},${serializeData(DEFERRED_ERROR_CODE)})</script>`,
731
816
  ),
732
817
  )
733
818
  }
@@ -762,12 +847,12 @@ function isSameOriginPath(location: string): boolean {
762
847
  }
763
848
 
764
849
  /**
765
- * Build a redirect `Response` — return it from a route `action` for the Post/Redirect/Get
850
+ * Build a redirect `Response` - return it from a route `action` for the Post/Redirect/Get
766
851
  * pattern (POST mutates, 303 sends the browser to a fresh GET, so a reload doesn't re-submit).
767
852
  * Defaults to 303 (See Other); pass `{ status: 307 }` or `{ status: 308 }` to preserve the method.
768
853
  *
769
854
  * **Secure by default:** `location` must be a same-origin path (begins with `/`, not `//`). An
770
- * off-origin/absolute destination throws unless you pass `{ external: true }` — this closes the
855
+ * off-origin/absolute destination throws unless you pass `{ external: true }` - this closes the
771
856
  * open-redirect footgun of `return redirect(formData.get("next"))` on the no-JS (native-form) path,
772
857
  * which returns the action's `Response` verbatim.
773
858
  *
@@ -779,13 +864,13 @@ export function redirect(location: string, options: RedirectOptions = {}): Respo
779
864
  `[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.`,
780
865
  )
781
866
  }
782
- // Reject CR/LF in the Location explicitly — defense-in-depth (response splitting / header
867
+ // Reject CR/LF in the Location explicitly - defense-in-depth (response splitting / header
783
868
  // injection). Spec-correct runtimes' Headers setter throws on CR/LF, but `external: true` lets
784
869
  // unvalidated input reach this sink, so we don't rely on the runtime. Same posture as
785
870
  // serializeCookie / the SSE frame formatter, which strip CRLF at their sinks.
786
871
  if (/[\r\n]/.test(location)) {
787
872
  throw new Error(
788
- `[nifra/web] redirect location contains a CR/LF character — refusing to emit a header-injecting redirect.`,
873
+ `[nifra/web] redirect location contains a CR/LF character - refusing to emit a header-injecting redirect.`,
789
874
  )
790
875
  }
791
876
  return new Response(null, { status: options.status ?? 303, headers: { location } })
@@ -854,6 +939,9 @@ type LoadedLayoutModules = ReadonlyArray<{
854
939
  loader?: LayoutLoader
855
940
  action?: unknown
856
941
  gate?: boolean
942
+ // A layout may declare its own `searchSchema`; the route's effective search merges the layout chain's
943
+ // schemas with the page's (page-wins). Present on the raw module already - typed here so it is readable.
944
+ searchSchema?: RouteModule["searchSchema"]
857
945
  }>
858
946
 
859
947
  /**
@@ -992,7 +1080,7 @@ export function statusPage(status: number, options: StatusPageOptions = {}): nev
992
1080
 
993
1081
  /** The wrapper `revalidate()` returns: the action's `data` plus the paths it changed. A plain tagged
994
1082
  * shape (not a class) so `@nifrajs/client`'s `ActionData` can unwrap it structurally without importing
995
- * from `@nifrajs/web`. `createWebApp` strips the wrapper — the client receives `data` as the body and
1083
+ * from `@nifrajs/web`. `createWebApp` strips the wrapper - the client receives `data` as the body and
996
1084
  * the paths via the `X-Nifra-Revalidate` header. */
997
1085
  export interface RevalidateResult<T> {
998
1086
  readonly __nifraRevalidate: readonly string[]
@@ -1002,7 +1090,7 @@ export interface RevalidateResult<T> {
1002
1090
  /**
1003
1091
  * Return this from an action to declare which routes the mutation changed (alongside the action's
1004
1092
  * `data`). `createWebApp` sets the `X-Nifra-Revalidate` response header; after the submit the client
1005
- * marks those cached routes stale — refetching the active one and any mounted fetcher showing them —
1093
+ * marks those cached routes stale - refetching the active one and any mounted fetcher showing them -
1006
1094
  * so a mutation can refresh views beyond the one that was submitted. `data` is still surfaced to the
1007
1095
  * component as `actionData` (the wrapper is transparent to `ActionData<typeof action>`).
1008
1096
  */
@@ -1027,10 +1115,10 @@ function escapeHtml(value: string): string {
1027
1115
  /**
1028
1116
  * Escape a `<script>` element's text content so it cannot break out of the element. Per the HTML spec,
1029
1117
  * a script's text may contain any character EXCEPT the sequences that the parser treats as element
1030
- * boundaries: `</` (which would begin the end tag — `</script>` is the obvious XSS vector, but ANY `</`
1118
+ * boundaries: `</` (which would begin the end tag - `</script>` is the obvious XSS vector, but ANY `</`
1031
1119
  * starts script-data-end-tag parsing), and the comment/CDATA edges `<!--` / `]]>`. We escape the `<`
1032
1120
  * (and the `>` of `]]>`) to its JS unicode escape: inside a JS/JSON string literal `<` is the
1033
- * identical character, so a JSON-LD payload is byte-equivalent after parsing — but the raw `<`/`>` the
1121
+ * identical character, so a JSON-LD payload is byte-equivalent after parsing - but the raw `<`/`>` the
1034
1122
  * HTML tokenizer scans for is gone. This is the JSON-LD-in-HTML rule (content is JSON, never raw HTML),
1035
1123
  * mirroring {@link serializeData}'s posture for the data-global script. Idempotent on already-safe text.
1036
1124
  */
@@ -1049,27 +1137,12 @@ function escapeAttr(value: string): string {
1049
1137
  .replaceAll(">", "&gt;")
1050
1138
  }
1051
1139
 
1052
- // Attribute-NAME guard for emitted `<meta>`/`<link>` tags. Intentionally a *name-shape* allowlist, not
1053
- // a hardcoded per-attribute one: any letter/digit/hyphen name beginning with a letter is permitted, so
1054
- // every standard `<link>` attribute flows through — `rel`, `href`, `hreflang`, `crossorigin`, `media`,
1055
- // `sizes`, `type`, `as`, `title`, `integrity`, `referrerpolicy`, `imagesrcset`, `imagesizes`,
1056
- // `disabled`, `color`, `fetchpriority` — alongside `<meta>`'s `name`/`property`/`content` and custom
1057
- // `data-*`. A hardcoded list would silently drop valid attrs (the bug this guards against: `hreflang`
1058
- // /`crossorigin` getting filtered out). What it rejects is name-shape abuse only — a name with a space,
1059
- // `=`, `>`, quote, or a leading digit can't break out of the tag. Attribute VALUES are escaped
1060
- // separately (they may carry loader data → XSS), so a widened name set never widens the injection
1061
- // surface. Names that fail the shape check are dropped.
1062
- const SAFE_ATTR_NAME = /^[a-zA-Z][a-zA-Z0-9-]*$/
1063
-
1064
- /** Serialize a meta/link tag's attributes: validate the name shape (see {@link SAFE_ATTR_NAME}) +
1065
- * escape the value against XSS. Invalid attribute names are dropped. Values follow the HTML attribute
1066
- * conventions {@link LinkDescriptor} types: a string renders `name="escaped"`; `true` renders the bare
1067
- * boolean attribute (`disabled`); `false` and `undefined` are skipped (a conditionally-absent attr). */
1068
- function tagAttrs(attrs: Record<string, string | boolean | undefined>): string {
1140
+ /** Serialize attributes that passed the shared SSR/client head trust policy. */
1141
+ function tagAttrs(tag: "meta" | "link", attrs: Readonly<object>): string | null {
1142
+ const trusted = trustedHeadAttributes(tag, attrs)
1143
+ if (trusted === null) return null
1069
1144
  let out = ""
1070
- for (const [name, value] of Object.entries(attrs)) {
1071
- if (value === undefined || value === false) continue // omitted / absent boolean attribute
1072
- if (!SAFE_ATTR_NAME.test(name)) continue // name-shape abuse — dropped (see SAFE_ATTR_NAME)
1145
+ for (const [name, value] of trusted) {
1073
1146
  if (out !== "") out += " "
1074
1147
  out += value === true ? name : `${name}="${escapeAttr(value)}"`
1075
1148
  }
@@ -1083,11 +1156,19 @@ function tagAttrs(attrs: Record<string, string | boolean | undefined>): string {
1083
1156
  // content can vary). WeakMap so a route module that's GC'd takes its entry with it.
1084
1157
  const headTagsCache = new WeakMap<Meta, string>()
1085
1158
 
1159
+ function assertExecutableScriptType(type: string): void {
1160
+ if (!EXECUTABLE_SCRIPT_TYPES.has(type)) {
1161
+ throw new TypeError(
1162
+ `[nifra/web] executable inline scripts accept ${[...EXECUTABLE_SCRIPT_TYPES].map((t) => JSON.stringify(t)).join(" or ")}; received ${JSON.stringify(type)}`,
1163
+ )
1164
+ }
1165
+ }
1166
+
1086
1167
  /** Render a route's `meta`/`link`/`script` as managed (`data-nifra`) head tags. Title is set
1087
- * separately. XSS-safe by construction: every attribute flows through {@link tagAttrs} (name shape-
1088
- * validated, value HTML-escaped), and each `script[].content` through `escapeScriptContent`
1089
- * (breakout-escaped) — so loader-derived strings (LLM-authored `og:*`, user content) can't inject markup
1090
- * or close the tag early. String concatenation (no intermediate `.map()` arrays + spread) — parity with
1168
+ * separately. XSS-safe by construction: every attribute flows through the shared tag-specific trust
1169
+ * policy then value escaping, and each `script[].content` through `escapeScriptContent`
1170
+ * (breakout-escaped) - so loader-derived strings (LLM-authored `og:*`, user content) can't inject markup
1171
+ * or close the tag early. String concatenation (no intermediate `.map()` arrays + spread) - parity with
1091
1172
  * the already concat-based preloadLinks/styleLinks/islandPreloads loops; byte-identical output. Result
1092
1173
  * is memoized per resolved-`Meta` identity (static meta → serialized once per route). */
1093
1174
  function headTags(head: Meta | undefined): string {
@@ -1095,14 +1176,43 @@ function headTags(head: Meta | undefined): string {
1095
1176
  const cached = headTagsCache.get(head)
1096
1177
  if (cached !== undefined) return cached
1097
1178
  let out = ""
1098
- if (head.meta !== undefined) for (const m of head.meta) out += `<meta ${tagAttrs(m)} data-nifra>`
1099
- if (head.link !== undefined) for (const l of head.link) out += `<link ${tagAttrs(l)} data-nifra>`
1100
- // `<script>` slot — JSON-LD + other inert head scripts. `type` is attribute-escaped; `content` is
1179
+ if (head.meta !== undefined)
1180
+ for (const m of head.meta) {
1181
+ const attrs = tagAttrs("meta", m)
1182
+ if (attrs !== null) out += `<meta${attrs === "" ? "" : ` ${attrs}`} data-nifra>`
1183
+ }
1184
+ if (head.link !== undefined)
1185
+ for (const l of head.link) {
1186
+ const attrs = tagAttrs("link", l)
1187
+ if (attrs !== null) out += `<link${attrs === "" ? "" : ` ${attrs}`} data-nifra>`
1188
+ }
1189
+ // `<script>` slot - JSON-LD + other inert head scripts. `type` is attribute-escaped; `content` is
1101
1190
  // breakout-escaped (`escapeScriptContent`) so a `</script>` (or `<!--`/`]]>`) payload can't close the
1102
1191
  // element early. Managed (`data-nifra`) so a soft-nav's `applyHead` cleanly replaces it.
1103
1192
  if (head.script !== undefined)
1104
- for (const s of head.script)
1105
- out += `<script type="${escapeAttr(s.type ?? "application/ld+json")}" data-nifra>${escapeScriptContent(s.content)}</script>`
1193
+ for (const s of head.script) {
1194
+ const type = s.type ?? "application/ld+json"
1195
+ if (!INERT_SCRIPT_TYPES.has(type)) {
1196
+ throw new TypeError(
1197
+ `[nifra/web] head.script only accepts inert JSON script types; received ${JSON.stringify(type)}. Use unsafeInlineScript() with a CSP nonce for executable code.`,
1198
+ )
1199
+ }
1200
+ out += `<script type="${type}" data-nifra>${escapeScriptContent(s.content)}</script>`
1201
+ }
1202
+ if (head.unsafeScript !== undefined)
1203
+ for (const s of head.unsafeScript) {
1204
+ if (s.unsafe !== true || s.nonce.trim() === "") {
1205
+ throw new TypeError("[nifra/web] executable inline scripts require a non-empty CSP nonce")
1206
+ }
1207
+ // `type` is validated, not escaped, for the same reason as the inert slot above: an unknown
1208
+ // value here is a bug, and escaping one would emit a nonsense script type rather than say so.
1209
+ // It was interpolated raw while the `nonce` beside it was escaped, so a descriptor built by hand
1210
+ // - `unsafeScript` is a public field, the helper is only a convenience - could close the
1211
+ // attribute and inject markup. Measured: `type: '"><img src=x onerror=alert(1)>'` reached the
1212
+ // document. An injection in the slot whose whole purpose is to make script emission trustworthy.
1213
+ assertExecutableScriptType(s.type)
1214
+ out += `<script type="${s.type}" nonce="${escapeAttr(s.nonce)}" data-nifra>${escapeScriptContent(s.content)}</script>`
1215
+ }
1106
1216
  headTagsCache.set(head, out)
1107
1217
  return out
1108
1218
  }
@@ -1125,7 +1235,7 @@ export function canonical(href: string): LinkDescriptor {
1125
1235
  return { rel: "canonical", href }
1126
1236
  }
1127
1237
 
1128
- /** Inputs for {@link openGraph} — the common Open Graph properties. All optional; only the provided
1238
+ /** Inputs for {@link openGraph} - the common Open Graph properties. All optional; only the provided
1129
1239
  * ones become tags. `type` defaults to `"website"`. */
1130
1240
  export interface OpenGraphInput {
1131
1241
  readonly title?: string
@@ -1134,7 +1244,7 @@ export interface OpenGraphInput {
1134
1244
  readonly image?: string
1135
1245
  /** Canonical URL of the page (`og:url`). */
1136
1246
  readonly url?: string
1137
- /** Object type (`og:type`) — `"website"`, `"article"`, … Default `"website"`. */
1247
+ /** Object type (`og:type`) - `"website"`, `"article"`, … Default `"website"`. */
1138
1248
  readonly type?: string
1139
1249
  }
1140
1250
 
@@ -1146,8 +1256,8 @@ export interface OpenGraphInput {
1146
1256
  * export const meta = { meta: [...openGraph({ title: "Nifra", image: "https://site.com/og.png" })] }
1147
1257
  * ```
1148
1258
  */
1149
- export function openGraph(input: OpenGraphInput): Array<Record<string, string>> {
1150
- const tags: Array<Record<string, string>> = []
1259
+ export function openGraph(input: OpenGraphInput): MetaDescriptor[] {
1260
+ const tags: MetaDescriptor[] = []
1151
1261
  const add = (property: string, content: string | undefined): void => {
1152
1262
  if (content !== undefined) tags.push({ property, content })
1153
1263
  }
@@ -1155,7 +1265,7 @@ export function openGraph(input: OpenGraphInput): Array<Record<string, string>>
1155
1265
  add("og:description", input.description)
1156
1266
  add("og:image", input.image)
1157
1267
  add("og:url", input.url)
1158
- // `og:type` always present (the spec's required property) — default "website" when unset.
1268
+ // `og:type` always present (the spec's required property) - default "website" when unset.
1159
1269
  tags.push({ property: "og:type", content: input.type ?? "website" })
1160
1270
  return tags
1161
1271
  }
@@ -1175,6 +1285,30 @@ export function jsonLd(data: Record<string, unknown>): ScriptDescriptor {
1175
1285
  return { type: "application/ld+json", content: JSON.stringify(data) }
1176
1286
  }
1177
1287
 
1288
+ /**
1289
+ * Deliberately unsafe escape hatch for executable inline code. The required nonce keeps the result
1290
+ * compatible with a strict CSP and makes the security-sensitive choice visible at the call site.
1291
+ */
1292
+ export function unsafeInlineScript(
1293
+ content: string,
1294
+ options: { readonly nonce: string; readonly type?: "module" | "text/javascript" },
1295
+ ): UnsafeScriptDescriptor {
1296
+ if (options.nonce.trim() === "") {
1297
+ throw new TypeError("[nifra/web] unsafeInlineScript requires a non-empty CSP nonce")
1298
+ }
1299
+ const type = options.type ?? "module"
1300
+ // Checked here as well as at emit: the type union is compile-time only, and this helper is the one
1301
+ // place a caller is told what it may pass. Failing at the call site names the argument; failing at
1302
+ // render names a document.
1303
+ assertExecutableScriptType(type)
1304
+ return {
1305
+ unsafe: true,
1306
+ type,
1307
+ nonce: options.nonce,
1308
+ content,
1309
+ }
1310
+ }
1311
+
1178
1312
  /**
1179
1313
  * Merge a route's `<head>` contributions from its layout chain + the page into one {@link Meta}.
1180
1314
  *
@@ -1182,28 +1316,29 @@ export function jsonLd(data: Record<string, unknown>): ScriptDescriptor {
1182
1316
  * `meta` to put sitewide tags (`hreflang`, `preconnect`, a default `<title>`, …) on every page below
1183
1317
  * it. `heads` is passed **outermost layout → … → innermost layout → page** (the same order as the
1184
1318
  * render chain), and merges:
1185
- * - **`title`** — *nearest-wins*: the last defined value across the list, so the page overrides an
1319
+ * - **`title`** - *nearest-wins*: the last defined value across the list, so the page overrides an
1186
1320
  * inner layout, which overrides an outer one. A layout `title` is the section default; an undefined
1187
1321
  * page `title` keeps it.
1188
- * - **`meta` / `link`** — *concatenated* in list order (outermost first, page last). Duplicate-tag
1322
+ * - **`meta` / `link`** - *concatenated* in list order (outermost first, page last). Duplicate-tag
1189
1323
  * de-duplication is the caller's concern; the framework emits exactly what's declared so a layout
1190
1324
  * can ship N `<link rel="alternate" hreflang>` tags and a page can add its own canonical.
1191
1325
  *
1192
1326
  * Returns a fresh object whose identity is stable per `heads` *content* only when every entry is a
1193
- * static (by-reference) `Meta` and there is exactly one — otherwise a new object each call. That is
1327
+ * static (by-reference) `Meta` and there is exactly one - otherwise a new object each call. That is
1194
1328
  * fine: {@link headTags}'s memo is keyed on identity, so a per-request merge simply recomputes (its
1195
1329
  * content can vary with loader data anyway).
1196
1330
  */
1197
1331
  export function mergeHeads(heads: readonly Meta[]): Meta {
1198
- // Single-head fast path (a route with no layout `meta`, by far the common case) — return the
1332
+ // Single-head fast path (a route with no layout `meta`, by far the common case) - return the
1199
1333
  // resolved object by reference so headTags' identity-keyed memo hits across requests for static meta.
1200
1334
  if (heads.length === 1) return heads[0] as Meta
1201
1335
  let title: string | undefined
1202
1336
  let lang: string | undefined
1203
1337
  let dir: Meta["dir"]
1204
- const meta: Array<Record<string, string>> = []
1338
+ const meta: MetaDescriptor[] = []
1205
1339
  const link: LinkDescriptor[] = []
1206
1340
  const script: ScriptDescriptor[] = []
1341
+ const unsafeScript: UnsafeScriptDescriptor[] = []
1207
1342
  for (const h of heads) {
1208
1343
  if (h.title !== undefined) title = h.title // nearest-wins: later (more specific) overrides
1209
1344
  if (h.lang !== undefined) lang = h.lang // nearest-wins, like title
@@ -1211,8 +1346,9 @@ export function mergeHeads(heads: readonly Meta[]): Meta {
1211
1346
  if (h.meta !== undefined) meta.push(...h.meta)
1212
1347
  if (h.link !== undefined) link.push(...h.link)
1213
1348
  if (h.script !== undefined) script.push(...h.script) // concatenated like meta/link (outermost first)
1349
+ if (h.unsafeScript !== undefined) unsafeScript.push(...h.unsafeScript)
1214
1350
  }
1215
- // Build the result with only the fields that were actually contributed — an empty `meta`/`link`/
1351
+ // Build the result with only the fields that were actually contributed - an empty `meta`/`link`/
1216
1352
  // `script` array would otherwise be a spurious (if harmless) key. A mutable local; the cast to `Meta`
1217
1353
  // is sound because a key is assigned only when defined (so `exactOptionalPropertyTypes` never sees
1218
1354
  // `undefined`).
@@ -1221,6 +1357,7 @@ export function mergeHeads(heads: readonly Meta[]): Meta {
1221
1357
  meta?: Meta["meta"]
1222
1358
  link?: Meta["link"]
1223
1359
  script?: Meta["script"]
1360
+ unsafeScript?: Meta["unsafeScript"]
1224
1361
  lang?: string
1225
1362
  dir?: Meta["dir"]
1226
1363
  } = {}
@@ -1228,6 +1365,7 @@ export function mergeHeads(heads: readonly Meta[]): Meta {
1228
1365
  if (meta.length > 0) merged.meta = meta
1229
1366
  if (link.length > 0) merged.link = link
1230
1367
  if (script.length > 0) merged.script = script
1368
+ if (unsafeScript.length > 0) merged.unsafeScript = unsafeScript
1231
1369
  if (lang !== undefined) merged.lang = lang
1232
1370
  if (dir !== undefined) merged.dir = dir
1233
1371
  return merged as Meta
@@ -1240,7 +1378,7 @@ export interface CreateWebAppOptions {
1240
1378
  readonly clientEntry: string
1241
1379
  /** Default document title for all pages. */
1242
1380
  readonly title?: string
1243
- /** Injected into each loader's `ctx.api` — typically an `inProcessClient(app)` (typed
1381
+ /** Injected into each loader's `ctx.api` - typically an `inProcessClient(app)` (typed
1244
1382
  * per-route via `@nifrajs/client`'s `createRoutes`). Opaque to the core.
1245
1383
  *
1246
1384
  * **Auto-mount.** Every `inProcessClient(backend)` exposes the symbol-keyed platform-aware backend
@@ -1287,15 +1425,15 @@ export interface CreateWebAppOptions {
1287
1425
  * signed `__nifra_draft` cookie gets `ctx.draft === true` in loaders/actions (else always `false`).
1288
1426
  * Pair with `withISR({ draftSecret })` so editors bypass the cache. Omit to disable draft mode. */
1289
1427
  readonly draftSecret?: string
1290
- /** Per-route chunk URLs (`buildClient`'s `BuildManifest.routes`) — `routeId → [layout chunks…, own
1428
+ /** Per-route chunk URLs (`buildClient`'s `BuildManifest.routes`) - `routeId → [layout chunks…, own
1291
1429
  * chunk]`. When present, each page `modulepreload`s its matched route's chunks alongside the entry,
1292
1430
  * so the route code downloads in parallel (no entry→route-chunk waterfall). Omit ⇒ entry-only. */
1293
1431
  readonly routePreload?: Readonly<Record<string, readonly string[]>>
1294
- /** The app's bundled stylesheet URLs (`buildClient`'s `BuildManifest.css`) — the aggregate, injected
1432
+ /** The app's bundled stylesheet URLs (`buildClient`'s `BuildManifest.css`) - the aggregate, injected
1295
1433
  * as `<link rel="stylesheet">` in a page's `<head>`. Used as the fallback for any route absent from
1296
1434
  * {@link routeStyles}. Omit ⇒ no links (dev, where Vite injects CSS, or a CSS-free app). */
1297
1435
  readonly styles?: readonly string[]
1298
- /** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) — `routeId → [chain CSS]`.
1436
+ /** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) - `routeId → [chain CSS]`.
1299
1437
  * When a matched route has an entry here, only those (its layout chain + own CSS) are linked instead
1300
1438
  * of the aggregate `styles`, so a page ships only the CSS it uses. An empty array ⇒ no `<link>` (the
1301
1439
  * page imports no CSS). Routes absent here fall back to `styles`. Omit ⇒ always use `styles`. */
@@ -1306,14 +1444,14 @@ export interface CreateWebAppOptions {
1306
1444
  readonly prerenderedPaths?: readonly string[]
1307
1445
  /** SSG: per dynamic route pattern, its `getStaticPaths` `fallback` (from `enumerateStaticRoutes` or
1308
1446
  * the build's `prerendered.json`). A route mapped to `"404"` rejects any path NOT in
1309
- * `prerenderedPaths` with the 404 page — the unlisted path simply doesn't exist. `"ssr"` (the
1447
+ * `prerenderedPaths` with the 404 page - the unlisted path simply doesn't exist. `"ssr"` (the
1310
1448
  * default for unmapped routes) renders unlisted paths on-demand. */
1311
1449
  readonly staticFallbacks?: Readonly<Record<string, "ssr" | "404">>
1312
- /** Observe every loader/action failure — for error-reporting plugins (Sentry-style). Called for
1450
+ /** Observe every loader/action failure - for error-reporting plugins (Sentry-style). Called for
1313
1451
  * real throws (not control-flow `Response`s like `redirect`), **before** the nearest `_error`
1314
- * boundary renders / a soft-nav 500 / a rethrow — so it sees errors that the boundary would
1452
+ * boundary renders / a soft-nav 500 / a rethrow - so it sees errors that the boundary would
1315
1453
  * otherwise hide. Observation only; its own throws are swallowed so a faulty reporter can't break
1316
- * rendering. (`beforeLoader` is intentionally omitted — the core HTTP hooks already cover
1454
+ * rendering. (`beforeLoader` is intentionally omitted - the core HTTP hooks already cover
1317
1455
  * pre-request work.) */
1318
1456
  readonly onLoaderError?: (
1319
1457
  error: unknown,
@@ -1325,7 +1463,7 @@ export interface CreateWebAppOptions {
1325
1463
  ) => void
1326
1464
  }
1327
1465
 
1328
- /** The handler context fields createWebApp uses — a structural subset of nifra's `Context`. */
1466
+ /** The handler context fields createWebApp uses - a structural subset of nifra's `Context`. */
1329
1467
  interface RouteContext {
1330
1468
  readonly params: Record<string, string>
1331
1469
  readonly req: Request
@@ -1368,15 +1506,15 @@ function backendMountOf(api: unknown): BackendMountHandler | undefined {
1368
1506
  /**
1369
1507
  * Build a nifra app from a route manifest: every route SSRs its layout chain via `renderPage`,
1370
1508
  * and a wildcard catch-all renders `_404` (or a plain 404). Reuses @nifrajs/core's router +
1371
- * lifecycle, so matching, params, and precedence are battle-tested. fs-free — feed it a
1509
+ * lifecycle, so matching, params, and precedence are battle-tested. fs-free - feed it a
1372
1510
  * manifest from `discoverRoutes` (`@nifrajs/web/fs`) at startup, so the served app stays portable.
1373
1511
  *
1374
- * **Typed platform bindings.** Pass `Env` — `createWebApp<Env>({ … })` — to declare the app's Workers
1512
+ * **Typed platform bindings.** Pass `Env` - `createWebApp<Env>({ … })` - to declare the app's Workers
1375
1513
  * bindings ONCE. It seeds the returned `Server`'s context with `{ env: Env }` (exactly as the backend's
1376
1514
  * `server<Env>()` does), so `app.fetch(req, { env })` / `toFetchHandler(app)` type-check the `env`
1377
- * argument against the declared shape — no per-binding cast at the edge entry. Per-route loaders/actions
1515
+ * argument against the declared shape - no per-binding cast at the edge entry. Per-route loaders/actions
1378
1516
  * stay typed independently of this call: annotate them with `@nifrajs/client`'s `LoaderArgs<Api, Env>`
1379
- * (same `Env`) so `ctx.env.MY_KV` is typed there too. Omit the parameter and `Env` is `unknown` — the
1517
+ * (same `Env`) so `ctx.env.MY_KV` is typed there too. Omit the parameter and `Env` is `unknown` - the
1380
1518
  * secure default; validate at the trust boundary before use.
1381
1519
  */
1382
1520
  export function createWebApp<Env = unknown>(
@@ -1396,7 +1534,7 @@ export function createWebApp<Env = unknown>(
1396
1534
  return chunks ? { preload: chunks } : {}
1397
1535
  }
1398
1536
  // The matched route's stylesheet links (spread into renderPage). Per-route when the build mapped it
1399
- // (`routeStyles[id]` — only the chain's CSS; an empty array ⇒ no `<link>`), else the aggregate
1537
+ // (`routeStyles[id]` - only the chain's CSS; an empty array ⇒ no `<link>`), else the aggregate
1400
1538
  // `styles`. Omitted entirely when CSS-free (for exactOptionalPropertyTypes).
1401
1539
  const stylesOf = (id: string): { styles?: readonly string[] } => {
1402
1540
  const perRoute = options.routeStyles?.[id]
@@ -1409,7 +1547,7 @@ export function createWebApp<Env = unknown>(
1409
1547
  const app = server<Env>()
1410
1548
  // Auto-mount the in-process backend over HTTP at `apiPrefix` (default `/api`), BEFORE page routing.
1411
1549
  // `onRequest` runs on the raw request ahead of the router, so this wins over the page wildcard `/*`
1412
- // for every method (POST/GET/PUT/…) — not just the GET page routes — and a backend 404 (`/api/none`)
1550
+ // for every method (POST/GET/PUT/…) - not just the GET page routes - and a backend 404 (`/api/none`)
1413
1551
  // surfaces as the backend's own response, never the page 404. We dispatch the SAME `Request` object
1414
1552
  // (its body unread) so streamed/large bodies pass through untouched; the backend defines its routes
1415
1553
  // at the full prefixed path (`server().post("/api/sync", …)`), matching the `inProcessClient` call
@@ -1433,7 +1571,7 @@ export function createWebApp<Env = unknown>(
1433
1571
  if (!underPrefix(pathname, mount.path)) continue
1434
1572
  return mount.app.fetch(mount.stripPrefix === true ? stripMountPrefix(req, mount.path) : req)
1435
1573
  }
1436
- // Exactly the prefix (`/api`) or a sub-path (`/api/…`) — NOT a sibling like `/apixyz` that merely
1574
+ // Exactly the prefix (`/api`) or a sub-path (`/api/…`) - NOT a sibling like `/apixyz` that merely
1437
1575
  // shares the prefix as a string head. Dispatch the original `req` (body intact) to the backend.
1438
1576
  if (mountedApi !== undefined && apiPrefix !== "" && underPrefix(pathname, apiPrefix)) {
1439
1577
  return mountedApi(apiStrip ? stripMountPrefix(req, apiPrefix) : req, platform)
@@ -1452,7 +1590,7 @@ export function createWebApp<Env = unknown>(
1452
1590
 
1453
1591
  // Load a route's layout modules (outermost layout → innermost), keeping each module whole so the
1454
1592
  // render path can take both its `default` (the component chain) AND its `meta` (the sitewide head it
1455
- // contributes). Loaded lazily — the data-only and 405 branches return before any layout is needed.
1593
+ // contributes). Loaded lazily - the data-only and 405 branches return before any layout is needed.
1456
1594
  const loadLayoutModules = async (route: RouteEntry): Promise<LoadedLayoutModules> => {
1457
1595
  const modules = await Promise.all(
1458
1596
  // layoutIds only reference layouts present in the manifest (buildManifest invariant).
@@ -1464,7 +1602,7 @@ export function createWebApp<Env = unknown>(
1464
1602
  if ((modules[i] as { action?: unknown }).action === undefined) continue
1465
1603
  const id = route.layoutIds[i] as string
1466
1604
  throw new Error(
1467
- `[nifra/web] "${(manifest.layouts[id] as LayoutEntry).file}" exports an \`action\`, but layouts do not run one — only route files do. Move the mutation into the route that submits to it.`,
1605
+ `[nifra/web] "${(manifest.layouts[id] as LayoutEntry).file}" exports an \`action\`, but layouts do not run one - only route files do. Move the mutation into the route that submits to it.`,
1468
1606
  )
1469
1607
  }
1470
1608
  return modules
@@ -1553,6 +1691,24 @@ export function createWebApp<Env = unknown>(
1553
1691
  return valid
1554
1692
  }
1555
1693
 
1694
+ // The validated (or raw-parsed) search a route's loader context receives; `searchOf` fails closed to
1695
+ // the schema's defaults on hostile input, so this never throws on an attacker-picked query.
1696
+ const loaderSearch = (searchSchema: RouteModule["searchSchema"], request: Request) =>
1697
+ searchOf(searchSchema, new URL(request.url).search)
1698
+
1699
+ // The route's EFFECTIVE search once its layout chain is loaded: the layout schemas merged with the
1700
+ // page's (page-wins), the same chain the client mount builds for `useSearch`. Used for the page loader's
1701
+ // `ctx.search` and the render's `RenderProps.search`, so both agree with the client and with each other.
1702
+ const searchChainOf = (
1703
+ layoutMods: LoadedLayoutModules,
1704
+ mod: RouteModule,
1705
+ request: Request,
1706
+ ): Record<string, unknown> =>
1707
+ searchOfChain(
1708
+ [...layoutMods.map((m) => m.searchSchema), mod.searchSchema],
1709
+ new URL(request.url).search,
1710
+ )
1711
+
1556
1712
  const runLayoutChain = async (
1557
1713
  route: RouteEntry,
1558
1714
  ctx: LoaderContext,
@@ -1675,11 +1831,11 @@ export function createWebApp<Env = unknown>(
1675
1831
  return undefined
1676
1832
  }
1677
1833
 
1678
- // The request's origin (scheme + host + port, NO trailing slash) — the one server fact `meta()` needs
1834
+ // The request's origin (scheme + host + port, NO trailing slash) - the one server fact `meta()` needs
1679
1835
  // for absolute `canonical`/`og:url`/`og:image` but can't otherwise see (it also runs on the client).
1680
1836
  // `URL.origin` is exactly that shape; it matches the browser's `location.origin` for the same URL, so
1681
1837
  // a soft-nav's `applyHead` resolves an identical `<head>` (no hydration drift). A malformed `req.url`
1682
- // (shouldn't happen on a real request) degrades to `""` — the documented unknown-origin default —
1838
+ // (shouldn't happen on a real request) degrades to `""` - the documented unknown-origin default -
1683
1839
  // rather than throwing during render.
1684
1840
  const originOf = (req: Request): string => {
1685
1841
  try {
@@ -1689,7 +1845,7 @@ export function createWebApp<Env = unknown>(
1689
1845
  }
1690
1846
  }
1691
1847
 
1692
- // The request's `pathname + search` — threaded into `renderPage` so an adapter's `useLocation`/
1848
+ // The request's `pathname + search` - threaded into `renderPage` so an adapter's `useLocation`/
1693
1849
  // `useSearchParams` render the current URL server-side and hydrate against the client's initial state
1694
1850
  // (which the client entry seeds from `location.pathname + location.search`). No hash: it never reaches
1695
1851
  // the server. A malformed `req.url` degrades to "/" rather than throwing during render.
@@ -1704,11 +1860,11 @@ export function createWebApp<Env = unknown>(
1704
1860
 
1705
1861
  /**
1706
1862
  * Render the **nearest `_error` boundary** when a route's loader throws (the agnostic, server-side
1707
- * half of error UI — works on every adapter, no client takeover). The boundary renders in place of
1863
+ * half of error UI - works on every adapter, no client takeover). The boundary renders in place of
1708
1864
  * the page, wrapped by the layouts **at or above** its segment (deeper layouts are dropped). It's
1709
1865
  * served **non-hydrated** at status 500: a terminal page, and rendering a boundary (not the page the
1710
1866
  * client maps this route to) would otherwise hydrate-mismatch. The component receives `{ name,
1711
- * message }` — never the stack (no internals leak into HTML).
1867
+ * message }` - never the stack (no internals leak into HTML).
1712
1868
  */
1713
1869
  const renderError = async (
1714
1870
  route: RouteEntry,
@@ -1739,7 +1895,7 @@ export function createWebApp<Env = unknown>(
1739
1895
  })
1740
1896
  }
1741
1897
 
1742
- // The 404 response — the `_404` page (status 404) or a plain-text fallback. Shared by the wildcard
1898
+ // The 404 response - the `_404` page (status 404) or a plain-text fallback. Shared by the wildcard
1743
1899
  // catch-all (unmatched paths) and the `fallback: "404"` enforcement (unlisted paths under a route
1744
1900
  // that opted out of on-demand SSR).
1745
1901
  const renderNotFound = (path?: string): Promise<Response | RenderedPage> =>
@@ -1769,13 +1925,28 @@ export function createWebApp<Env = unknown>(
1769
1925
  // The routeId decides which preload/style bundle and which client component this maps to, so it
1770
1926
  // must name the page actually rendered - `_410` when `_410.tsx` exists, `_404` when it fell back.
1771
1927
  const routeId = manifest.statusPages?.[String(status)] !== undefined ? `_${status}` : "_404"
1772
- const { default: component } = await page.load()
1928
+ const loaded = (await page.load()) as {
1929
+ readonly default: unknown
1930
+ readonly searchSchema?: RouteModule["searchSchema"]
1931
+ }
1932
+ const component = loaded.default
1773
1933
  return renderPageResult({
1774
1934
  adapter,
1775
1935
  chain: [component],
1776
1936
  data: null,
1777
1937
  clientEntry,
1778
1938
  routeId,
1939
+ // The page hydrates, so besides `path` its SSR `search` must equal the client mount's derivation for
1940
+ // this routeId (`searchOf(searchSchema, url.search)`), or a status page reading `useSearch` drifts.
1941
+ // Only when `path` is present (a real URL); a schema-less status page then gets the raw parsed query.
1942
+ ...(path !== undefined
1943
+ ? {
1944
+ search: searchOf(
1945
+ loaded.searchSchema,
1946
+ path.includes("?") ? path.slice(path.indexOf("?")) : "",
1947
+ ),
1948
+ }
1949
+ : {}),
1779
1950
  // The page hydrates, so its SSR `path` must equal the client's initial
1780
1951
  // `location.pathname+search` (an unmatched path yields no params → `{}`), or a `useLocation`
1781
1952
  // on it would drift.
@@ -1813,7 +1984,7 @@ export function createWebApp<Env = unknown>(
1813
1984
  }
1814
1985
 
1815
1986
  for (const route of manifest.routes) {
1816
- // A dynamic route whose `getStaticPaths` declared `fallback: "404"` — only its prerendered paths
1987
+ // A dynamic route whose `getStaticPaths` declared `fallback: "404"` - only its prerendered paths
1817
1988
  // exist; anything else 404s (computed once per route, not per request).
1818
1989
  const is404Fallback = options.staticFallbacks?.[route.pattern] === "404"
1819
1990
  app.register("GET", route.pattern, undefined, async (c: RouteContext) => {
@@ -1837,6 +2008,7 @@ export function createWebApp<Env = unknown>(
1837
2008
  api,
1838
2009
  env: c.env,
1839
2010
  draft,
2011
+ search: loaderSearch(mod.searchSchema, c.req),
1840
2012
  }
1841
2013
  // Layout loaders run for BOTH the document and the data-only request. A gate that only ran on
1842
2014
  // the document path would be bypassed by adding the data header, which is exactly the request
@@ -1852,8 +2024,12 @@ export function createWebApp<Env = unknown>(
1852
2024
  )
1853
2025
  layoutModules = run.modules
1854
2026
  layoutRetained = run.retained
2027
+ // The page loader gets the EFFECTIVE search (layout chain + page, page-wins), now that the layout
2028
+ // modules are loaded - so `ctx.search` includes a layout's shared keys (`?org`), matching `useSearch`.
1855
2029
  const [pageData] = await Promise.all([
1856
- mod.loader ? mod.loader(ctx) : null,
2030
+ mod.loader
2031
+ ? mod.loader({ ...ctx, search: searchChainOf(run.modules, mod, c.req) })
2032
+ : null,
1857
2033
  run.pending, // a non-gate layout loader that rejects must still surface, not go unhandled
1858
2034
  ])
1859
2035
  data = pageData
@@ -1864,7 +2040,7 @@ export function createWebApp<Env = unknown>(
1864
2040
  // is what keeps a hand-rolled `throw new Response(...)` served verbatim, as it always was.
1865
2041
  if (isStatusSignal(err)) return renderStatusSignal(c.req, err)
1866
2042
  // A thrown `Response` is a control-flow signal (a guard's `redirect(...)`, an explicit error
1867
- // response) — let it propagate to core, which returns it as-is. Real errors render the nearest
2043
+ // response) - let it propagate to core, which returns it as-is. Real errors render the nearest
1868
2044
  // `_error` boundary, if any; with none, rethrow (unchanged 500 behavior).
1869
2045
  if (err instanceof Response) throw err
1870
2046
  // Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
@@ -1877,7 +2053,7 @@ export function createWebApp<Env = unknown>(
1877
2053
  }
1878
2054
  const errorId = boundaryFor(route, err)
1879
2055
  if (errorId === undefined) throw err
1880
- // A soft-nav data fetch can't render a boundary — 500 so the client falls back to a full-page
2056
+ // A soft-nav data fetch can't render a boundary - 500 so the client falls back to a full-page
1881
2057
  // navigation, which lands here as a document and renders the `_error` page.
1882
2058
  if (c.req.headers.get(DATA_HEADER) !== null) {
1883
2059
  return new Response("Internal Server Error", {
@@ -1887,10 +2063,10 @@ export function createWebApp<Env = unknown>(
1887
2063
  }
1888
2064
  return renderError(route, errorId, withDuplicateInstanceHint(err))
1889
2065
  }
1890
- // Client-side navigation asks (via the X-Nifra-Data header) for just the loader data — no full
2066
+ // Client-side navigation asks (via the X-Nifra-Data header) for just the loader data - no full
1891
2067
  // document, no layout chain. A route with deferred data streams NDJSON (critical data first,
1892
2068
  // then each deferred value as it settles); otherwise one JSON (the fast path). Same loader,
1893
- // same auth — only the transport differs.
2069
+ // same auth - only the transport differs.
1894
2070
  if (c.req.headers.get(DATA_HEADER) !== null) {
1895
2071
  const pageSplit = prepareDeferred(data)
1896
2072
  const layoutSplits: Array<ReturnType<typeof prepareDeferred>> = []
@@ -1910,7 +2086,7 @@ export function createWebApp<Env = unknown>(
1910
2086
  layoutData: layoutSplits.map((split) => split.forClient),
1911
2087
  retained: layoutRetained,
1912
2088
  }
1913
- // Bare value when there is no layout data — the pre-envelope shape, so a client running older
2089
+ // Bare value when there is no layout data - the pre-envelope shape, so a client running older
1914
2090
  // code against a newer server keeps working.
1915
2091
  if (deferred.length === 0) {
1916
2092
  return Response.json(payload ?? null)
@@ -1931,7 +2107,7 @@ export function createWebApp<Env = unknown>(
1931
2107
  const hydrateRoute = mod.hydrate !== false
1932
2108
  try {
1933
2109
  // `await` so a shell-render throw (renderToStream rejects before any byte) is caught here and
1934
- // can render the `_error` page — not just a loader throw. Mid-stream (post-shell) throws can't
2110
+ // can render the `_error` page - not just a loader throw. Mid-stream (post-shell) throws can't
1935
2111
  // be recovered to a full page; the per-adapter client boundary catches client render errors.
1936
2112
  return await renderPageResult({
1937
2113
  adapter,
@@ -1942,6 +2118,10 @@ export function createWebApp<Env = unknown>(
1942
2118
  routeId: route.id,
1943
2119
  params: c.params,
1944
2120
  path: pathOf(c.req),
2121
+ // The effective (layout chain + page) search, matching the page loader's `ctx.search` above and
2122
+ // the client mount - so `useSearch` renders SSR-correct and hydrates with no drift. `layoutModules`
2123
+ // is loaded by now (the loader try succeeded to reach here).
2124
+ search: searchChainOf(layoutModules ?? [], mod, c.req),
1945
2125
  hydrate: hydrateRoute,
1946
2126
  ...(layoutData !== undefined ? { layoutData } : {}),
1947
2127
  ...preloadOf(route.id),
@@ -1952,7 +2132,7 @@ export function createWebApp<Env = unknown>(
1952
2132
  ...titleOption,
1953
2133
  })
1954
2134
  } catch (err) {
1955
- // Same precedence as the loader catch above — a `meta()` may signal too.
2135
+ // Same precedence as the loader catch above - a `meta()` may signal too.
1956
2136
  if (isStatusSignal(err)) return renderStatusSignal(c.req, err)
1957
2137
  if (err instanceof Response) throw err
1958
2138
  // Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
@@ -1971,7 +2151,7 @@ export function createWebApp<Env = unknown>(
1971
2151
 
1972
2152
  // POST runs the route's `action` (mutation). A `Response` return (e.g. a `redirect(...)`)
1973
2153
  // passes straight through; a data return re-renders the page (the loader re-runs for fresh
1974
- // data) with `actionData`. Routes without an action reject POST with 405 — not a stray 404.
2154
+ // data) with `actionData`. Routes without an action reject POST with 405 - not a stray 404.
1975
2155
  app.register("POST", route.pattern, undefined, async (c: RouteContext) => {
1976
2156
  const mod = await route.load()
1977
2157
  const draft = await draftFlag(c.req)
@@ -1988,6 +2168,7 @@ export function createWebApp<Env = unknown>(
1988
2168
  api,
1989
2169
  env: c.env,
1990
2170
  draft,
2171
+ search: loaderSearch(mod.searchSchema, c.req),
1991
2172
  }
1992
2173
  // A gate is an authorization boundary for everything beneath its layout, mutations included.
1993
2174
  // Run only gates before the action; ordinary layout data loaders run after a native mutation.
@@ -1996,7 +2177,7 @@ export function createWebApp<Env = unknown>(
1996
2177
  const isDataRequest = c.req.headers.get(DATA_HEADER) !== null
1997
2178
  // An action may wrap its data in `revalidate(paths, data)` to declare which routes it changed.
1998
2179
  // Unwrap to the inner data; the paths ride the `X-Nifra-Revalidate` header on the data-mode
1999
- // responses (the client acts on them — a full-page POST re-runs loaders inline, so no header).
2180
+ // responses (the client acts on them - a full-page POST re-runs loaders inline, so no header).
2000
2181
  const isRevalidate =
2001
2182
  result !== null && typeof result === "object" && "__nifraRevalidate" in result
2002
2183
  const actionResult = isRevalidate ? (result as RevalidateResult<unknown>).data : result
@@ -2015,7 +2196,7 @@ export function createWebApp<Env = unknown>(
2015
2196
  // Client submit wants just the action's data (it revalidates the loader itself); a native
2016
2197
  // form POST re-renders the full page (loader re-runs) with the action data.
2017
2198
  if (isDataRequest) {
2018
- // An action may `defer()` slow parts of its result — stream them (critical data first, then
2199
+ // An action may `defer()` slow parts of its result - stream them (critical data first, then
2019
2200
  // each deferred as it settles) exactly like a loader; a non-deferred action returns one JSON.
2020
2201
  const { forClient, deferred } = prepareDeferred(actionResult)
2021
2202
  if (deferred.length === 0)
@@ -2024,8 +2205,20 @@ export function createWebApp<Env = unknown>(
2024
2205
  headers: { "content-type": "application/x-ndjson; charset=utf-8", ...revalidateHeader },
2025
2206
  })
2026
2207
  }
2208
+ // Derived once and shared: the loader's `ctx.search` and the render's `RenderProps.search` are the
2209
+ // SAME effective (layout chain + page) value, and the client mount rebuilds it identically. The
2210
+ // layout modules are already loaded (the action ran through their gates above).
2211
+ const search = searchChainOf(layoutModules, mod, c.req)
2027
2212
  const data = mod.loader
2028
- ? await mod.loader({ params: c.params, request: c.req, req: c.req, api, env: c.env, draft })
2213
+ ? await mod.loader({
2214
+ params: c.params,
2215
+ request: c.req,
2216
+ req: c.req,
2217
+ api,
2218
+ env: c.env,
2219
+ draft,
2220
+ search,
2221
+ })
2029
2222
  : null
2030
2223
  const { chain, head } = resolveChainAndHead(layoutModules, mod, {
2031
2224
  data,
@@ -2033,7 +2226,7 @@ export function createWebApp<Env = unknown>(
2033
2226
  origin: originOf(c.req),
2034
2227
  })
2035
2228
  const hydrateRoute = mod.hydrate !== false
2036
- // A full-page POST streams the action's `defer()`'d parts mid-document behind `<Await>` too —
2229
+ // A full-page POST streams the action's `defer()`'d parts mid-document behind `<Await>` too -
2037
2230
  // `renderPage` splits `actionData` like loader data (works with JS off; hydrates after).
2038
2231
  return renderPageResult({
2039
2232
  adapter,
@@ -2045,6 +2238,7 @@ export function createWebApp<Env = unknown>(
2045
2238
  routeId: route.id,
2046
2239
  params: c.params,
2047
2240
  path: pathOf(c.req),
2241
+ search,
2048
2242
  hydrate: hydrateRoute,
2049
2243
  ...preloadOf(route.id),
2050
2244
  ...stylesOf(route.id),
@@ -2080,7 +2274,7 @@ export interface GenerateClientEntryOptions {
2080
2274
  * Module specifier for the adapter's client runtime, e.g. `"@nifrajs/web-solid/client"`.
2081
2275
  *
2082
2276
  * **Contract:** the module MUST export `mountRouter({ router, routes, container })`. The generated
2083
- * bootstrap imports the specifier and calls it — so a self-executing entry that mounts on import
2277
+ * bootstrap imports the specifier and calls it - so a self-executing entry that mounts on import
2084
2278
  * builds cleanly and then does nothing, which is the trap this contract exists to name. The type is
2085
2279
  * a bare `string` because a specifier is resolved by the bundler, not the type system, so the
2086
2280
  * requirement is enforced by the bootstrap instead: it throws immediately, naming this module and
@@ -2110,7 +2304,7 @@ export function generateClientEntry(
2110
2304
  const loaderRows: string[] = []
2111
2305
  const patternRows: string[] = []
2112
2306
  const statusRoutes: Record<number, string> = {}
2113
- // Routes whose loader appends a nearest `_error` module (LAST) — the client wraps the page in the
2307
+ // Routes whose loader appends a nearest `_error` module (LAST) - the client wraps the page in the
2114
2308
  // adapter's `errorBoundary(fallback)` for these, so a client render error shows the `_error` UI.
2115
2309
  const errorRouteIds: string[] = []
2116
2310
  // Lazy loader returns the raw modules (for both the component chain + the page's `meta` export).
@@ -2151,7 +2345,7 @@ export function generateClientEntry(
2151
2345
  'import { createClientRouter, createMatcher, mergeHeads, resolveMeta } from "@nifrajs/web"',
2152
2346
  'import { applyHead, installForms, installHistory, signalHydrated } from "@nifrajs/web/client"',
2153
2347
  // Namespace import: `errorBoundary` is optional (an adapter may not export it). A namespace member
2154
- // access yields `undefined` if absent — unlike a named import, which would be a link error.
2348
+ // access yields `undefined` if absent - unlike a named import, which would be a link error.
2155
2349
  `import * as __adapter from ${JSON.stringify(clientModule)}`,
2156
2350
  "const { mountRouter } = __adapter",
2157
2351
  // The `clientModule` contract is a string specifier, so nothing type-checks that the module it
@@ -2160,7 +2354,7 @@ export function generateClientEntry(
2160
2354
  // module nor the requirement - a self-executing entry passes every build and fails only here.
2161
2355
  `if (typeof mountRouter !== "function") throw new Error(${JSON.stringify(
2162
2356
  `[nifra/web] clientModule ${JSON.stringify(clientModule)} does not export \`mountRouter\`. ` +
2163
- "A client module must export `mountRouter({ router, routes, container })` — it is called by " +
2357
+ "A client module must export `mountRouter({ router, routes, container })` - it is called by " +
2164
2358
  "the generated bootstrap, so a self-executing entry will not work. Use your adapter's " +
2165
2359
  '`/client` entry (e.g. "@nifrajs/web-react/client"), or re-export mountRouter from it.',
2166
2360
  )})`,
@@ -2174,6 +2368,12 @@ export function generateClientEntry(
2174
2368
  "}",
2175
2369
  "const chains = {}",
2176
2370
  "const metas = {}",
2371
+ // routeId → the page module's `searchSchema` export (or undefined). Populated lazily with
2372
+ // chains/metas so the mount can derive this route's typed `search` from the URL, matching the SSR.
2373
+ "const searchSchemas = {}",
2374
+ // routeId → the page's client-only search keys (`searchClientKeys`, or `[]`). The router reads this
2375
+ // by reference to decide when a same-route search change can skip the loader fetch (re-render only).
2376
+ "const searchClientKeys = {}",
2177
2377
  "const loadModule = async (id) => {",
2178
2378
  " if (chains[id]) return",
2179
2379
  " const mods = await loaders[id]()",
@@ -2182,7 +2382,7 @@ export function generateClientEntry(
2182
2382
  // hydrated tree matches the SSR markup (which has no boundary). Falls back to the plain chain when
2183
2383
  // the adapter has no `errorBoundary`.
2184
2384
  // `metas[id]` is the chain's `meta` exports in head order (outermost layout → … → page), so a
2185
- // soft-nav merges the layout chain's head with the page's — matching the SSR `<head>` (sitewide
2385
+ // soft-nav merges the layout chain's head with the page's - matching the SSR `<head>` (sitewide
2186
2386
  // layout tags persist across client navigation, no flash of page-only head). `_error` carries no
2187
2387
  // head (a terminal boundary), so it's excluded from the meta list for error routes.
2188
2388
  " if (errorBoundary && errorRouteIds.has(id)) {",
@@ -2191,9 +2391,16 @@ export function generateClientEntry(
2191
2391
  " const layouts = mods.slice(0, mods.length - 2).map((m) => m.default)",
2192
2392
  " chains[id] = [...layouts, errorBoundary(fallback), page]",
2193
2393
  " metas[id] = mods.slice(0, mods.length - 1).map((m) => m.meta)",
2394
+ // The search-schema CHAIN is the layout modules + page (all but the appended `_error`), so a layout's
2395
+ // `searchSchema` merges with the page's. Client keys come from the page (second-to-last).
2396
+ " searchSchemas[id] = mods.slice(0, mods.length - 1).map((m) => m.searchSchema)",
2397
+ " searchClientKeys[id] = mods[mods.length - 2].searchClientKeys ?? []",
2194
2398
  " } else {",
2195
2399
  " chains[id] = mods.map((m) => m.default)",
2196
2400
  " metas[id] = mods.map((m) => m.meta)",
2401
+ // The chain is every module (layouts + page); client keys come from the page (the last module).
2402
+ " searchSchemas[id] = mods.map((m) => m.searchSchema)",
2403
+ " searchClientKeys[id] = mods[mods.length - 1].searchClientKeys ?? []",
2197
2404
  " }",
2198
2405
  "}",
2199
2406
  "const patterns = [",
@@ -2222,23 +2429,36 @@ export function generateClientEntry(
2222
2429
  " pending: false,",
2223
2430
  "}",
2224
2431
  `const statusRoutes = ${JSON.stringify(statusRoutes)}`,
2225
- "const router = createClientRouter({ patterns, initial, loadModule, statusRoutes })",
2432
+ "const router = createClientRouter({ patterns, initial, loadModule, statusRoutes, searchClientKeys })",
2226
2433
  "installHistory(router)",
2227
2434
  "installForms(router)",
2228
- 'const root = document.getElementById("root")',
2435
+ // The container is found in the DOM, not baked in. `rootId` is a per-render option and this entry
2436
+ // is a per-build artifact, so a hardcoded id is a guess: `renderPage({ rootId: "app" })` produced a
2437
+ // perfect document that hydrated NOTHING, with no diagnostic, because the missing `#root` was
2438
+ // swallowed by an `if (root)`. `renderPage` marks a custom container with `ROOT_ATTRIBUTE`; the
2439
+ // default `#root` it leaves unmarked, which is what the fallback is for.
2440
+ `const root = document.querySelector("body > div[${ROOT_ATTRIBUTE}]") ?? document.getElementById("root")`,
2441
+ // Loud, because there is no recoverable case: this entry ships only in a document `renderPage`
2442
+ // opened a container in, so reaching here means the document is not the one it was built for.
2443
+ `if (!root) throw new Error(${JSON.stringify(
2444
+ "[nifra/web] no hydration container found. The generated bootstrap mounts into the element " +
2445
+ `renderPage opened the app in - the one marked \`${ROOT_ATTRIBUTE}\` when a custom \`rootId\` ` +
2446
+ "is set, `#root` otherwise - and this document has neither. If you emit the document yourself, " +
2447
+ 'keep `<div id="root">` around the SSR markup.',
2448
+ )})`,
2229
2449
  // Load the initial route's chunk, then hydrate the Router (chain is cached). The initial head
2230
2450
  // is server-rendered; subsequent navigations update it from the matched route's meta + data.
2231
- "if (root) loadModule(initial.routeId).then(() => {",
2232
- " mountRouter({ router, routes: chains, container: root })",
2451
+ "loadModule(initial.routeId).then(() => {",
2452
+ " mountRouter({ router, routes: chains, searchSchemas, container: root })",
2233
2453
  // Next frame: the adapter has committed its initial hydration, so handlers are attached. Flip the
2234
2454
  // document to interactive (`data-nifra-hydrated` + `nifra:hydrated`) for apps to gate pre-hydration UI.
2235
2455
  " requestAnimationFrame(signalHydrated)",
2236
2456
  " router.subscribe(() => {",
2237
2457
  " const s = router.snapshot()",
2238
- // Merge the matched route's chain meta (layouts→page) into one head — same contract as SSR.
2458
+ // Merge the matched route's chain meta (layouts→page) into one head - same contract as SSR.
2239
2459
  " if (!s.pending) {",
2240
2460
  // `origin: location.origin` matches the SSR `originOf(req)` (both are `URL.origin` for the same
2241
- // page URL), so a soft-nav re-resolves the SAME absolute canonical/og:url — no head drift.
2461
+ // page URL), so a soft-nav re-resolves the SAME absolute canonical/og:url - no head drift.
2242
2462
  " const args = { data: s.data, params: s.params, origin: location.origin }",
2243
2463
  " applyHead(mergeHeads((metas[s.routeId] ?? [undefined]).map((m) => resolveMeta(m, args))))",
2244
2464
  " }",
@@ -2247,22 +2467,69 @@ export function generateClientEntry(
2247
2467
  ].join("\n")}\n`
2248
2468
  }
2249
2469
 
2470
+ export interface GenerateRouteSearchTypesOptions {
2471
+ /** Turn a route source file into a module specifier for a `typeof import(...)` type - the same contract
2472
+ * as `generateClientEntry`'s `resolve`, but WITHOUT a file extension (a `.d.ts` imports a module, not a
2473
+ * path). Relative to wherever the emitted `.d.ts` is written. */
2474
+ readonly resolve: (file: string) => string
2475
+ /** The module whose `RouteSearch` interface is augmented (default `"@nifrajs/web"`). Every adapter's
2476
+ * `NavigateTarget` reads `@nifrajs/web`'s `RouteSearch`, so the default types `navigate` on all of them. */
2477
+ readonly module?: string
2478
+ }
2479
+
2480
+ /**
2481
+ * Codegen: emit a `.d.ts` that types cross-route `navigate({ to, search })` against each route's
2482
+ * `searchSchema`. It augments the {@link RouteSearch} map with `"<pattern>": SearchOf<typeof import(...)>`
2483
+ * for every **static** route (one with no `:param`/`*` segment - a concrete `to` a caller can pass; a
2484
+ * dynamic route's concrete path can't match its pattern key, so those keep the loose object form). Written
2485
+ * by `nifra sync-routes` next to the route tree and included in the app's `tsconfig`; because it is
2486
+ * regenerated from the route files, a `navigate` whose `search` no longer matches the route's schema
2487
+ * becomes a `tsc` error - the same drift guarantee as nifra's typed client, no hand-maintained map.
2488
+ */
2489
+ export function generateRouteSearchTypes(
2490
+ manifest: Manifest,
2491
+ options: GenerateRouteSearchTypesOptions,
2492
+ ): string {
2493
+ const { resolve, module = "@nifrajs/web" } = options
2494
+ // Static routes only (no `:param` / `*` catch-all): their pattern IS a concrete `to`. Sorted for stable
2495
+ // output; a dynamic route falls through to the loose `{ to: string; search?: Record<...> }` form.
2496
+ const entries = manifest.routes
2497
+ .filter((route) => !/[:*]/.test(route.pattern))
2498
+ .map((route) => [route.pattern, resolve(route.file)] as const)
2499
+ .sort((a, b) => (a[0] < b[0] ? -1 : 1))
2500
+ .map(
2501
+ ([pattern, spec]) =>
2502
+ ` ${JSON.stringify(pattern)}: SearchOf<typeof import(${JSON.stringify(spec)})>`,
2503
+ )
2504
+ return `${[
2505
+ "// GENERATED by @nifrajs/web generateRouteSearchTypes - typed cross-route navigate({ to, search }).",
2506
+ "// Regenerated from the route files; a stale navigate search shape becomes a tsc error. Do not edit.",
2507
+ 'import type { SearchOf } from "@nifrajs/web"',
2508
+ `declare module ${JSON.stringify(module)} {`,
2509
+ " interface RouteSearch {",
2510
+ ...entries,
2511
+ " }",
2512
+ "}",
2513
+ "export {}",
2514
+ ].join("\n")}\n`
2515
+ }
2516
+
2250
2517
  export interface GenerateServerManifestOptions {
2251
- /** Turn a route/layout source file (relative to the routes dir) into an import specifier —
2518
+ /** Turn a route/layout source file (relative to the routes dir) into an import specifier -
2252
2519
  * same contract as `generateClientEntry`'s `resolve`. */
2253
2520
  readonly resolve: (file: string) => string
2254
2521
  /** The content-hashed client entry URL (from `buildClient`'s manifest), **baked** into the emitted
2255
- * module — a disk-less worker can't read `manifest.json` at runtime. */
2522
+ * module - a disk-less worker can't read `manifest.json` at runtime. */
2256
2523
  readonly clientEntry: string
2257
- /** The app's aggregate stylesheet URLs (`buildClient`'s `BuildManifest.css`) — baked as
2524
+ /** The app's aggregate stylesheet URLs (`buildClient`'s `BuildManifest.css`) - baked as
2258
2525
  * `export const styles` so the server entry can hand them to `createWebApp` (→ `<link rel="stylesheet">`
2259
2526
  * in `<head>`). Without this the built SSR page ships no CSS link and renders unstyled. */
2260
2527
  readonly styles?: readonly string[] | undefined
2261
- /** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) — baked as
2528
+ /** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) - baked as
2262
2529
  * `export const routeStyles` so a page links only the CSS its route chain uses. */
2263
2530
  readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
2264
2531
  /** Emit **lazy** per-route loaders (`() => import("./routes/x")`, a static specifier) instead of
2265
- * eager `import * as`, so a bundler with code-splitting emits one chunk per route — loaded on the
2532
+ * eager `import * as`, so a bundler with code-splitting emits one chunk per route - loaded on the
2266
2533
  * first request to it, not all at boot (smaller cold-start parse). Default `false` (eager). Both
2267
2534
  * modes are fs-free with statically-analyzable specifiers; only the *when* differs. */
2268
2535
  readonly lazy?: boolean
@@ -2270,10 +2537,10 @@ export interface GenerateServerManifestOptions {
2270
2537
 
2271
2538
  /**
2272
2539
  * Codegen: emit a **server manifest** module (as source) for disk-less edge runtimes (Cloudflare
2273
- * Workers, …) — and, with a `target`, any portable server bundle. `discoverRoutes` scans `node:fs`
2274
- * and dynamic-imports each route by a *runtime* path — neither exists on workerd. This instead emits
2540
+ * Workers, …) - and, with a `target`, any portable server bundle. `discoverRoutes` scans `node:fs`
2541
+ * and dynamic-imports each route by a *runtime* path - neither exists on workerd. This instead emits
2275
2542
  * **statically-analyzable** imports of every route/layout/`_error`/terminal status page (so the bundler includes them) and
2276
- * rebuilds the manifest with `buildManifest` — the SAME pure logic `discoverRoutes` feeds, so patterns
2543
+ * rebuilds the manifest with `buildManifest` - the SAME pure logic `discoverRoutes` feeds, so patterns
2277
2544
  * + layout chains are identical. Eager (`import * as`) by default; `lazy` emits `() => import(...)` so
2278
2545
  * a code-splitting bundler chunks per route. The emitted module exports `manifest` (consumed by
2279
2546
  * `createWebApp`, unchanged) + `clientEntry` (baked). Write it to a file and bundle it into the worker
@@ -2295,7 +2562,7 @@ export function generateServerManifest(
2295
2562
  ]),
2296
2563
  ].sort()
2297
2564
  const header = [
2298
- "// GENERATED by @nifrajs/web generateServerManifest — route manifest for the disk-less edge",
2565
+ "// GENERATED by @nifrajs/web generateServerManifest - route manifest for the disk-less edge",
2299
2566
  "// (no filesystem; route imports are static specifiers the bundler resolves). buildManifest is",
2300
2567
  "// the same pure logic discoverRoutes feeds, so patterns + layout chains match exactly.",
2301
2568
  'import { buildManifest } from "@nifrajs/web"',
@@ -2339,3 +2606,15 @@ export function generateServerManifest(
2339
2606
  "export const manifest = buildManifest(Object.keys(modules), (file) => () => Promise.resolve(modules[file]))",
2340
2607
  ].join("\n")}\n`
2341
2608
  }
2609
+
2610
+ /**
2611
+ * The two file-name conventions that decide what never reaches a browser: a `*.server` module is
2612
+ * EMPTIED in the client build, a `*.fn` module is REPLACED with client stubs.
2613
+ *
2614
+ * Exported because more than the bundlers need them. `nifra dev --bun` cannot transform (Bun's dev
2615
+ * bundler takes no plugins) so it must REFUSE instead, and a refusal driven by its own hand-written
2616
+ * glob drifts from the transform - which is how `.fn.mts` came to be stubbed by both build pipelines
2617
+ * and waved through by the guard that exists to stop it leaking. Anything deciding "is this module
2618
+ * server-only" should import the matcher rather than re-encode it.
2619
+ */
2620
+ export { SERVER_FN_MODULE, SERVER_ONLY_MODULE } from "./internal/server-boundary.ts"