@nifrajs/web 0.1.0-alpha.1

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 (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -0
  3. package/css-modules.d.ts +34 -0
  4. package/dist/build.d.ts +110 -0
  5. package/dist/build.d.ts.map +1 -0
  6. package/dist/build.js +238 -0
  7. package/dist/build.js.map +1 -0
  8. package/dist/client.d.ts +32 -0
  9. package/dist/client.d.ts.map +1 -0
  10. package/dist/client.js +204 -0
  11. package/dist/client.js.map +1 -0
  12. package/dist/deferred.d.ts +90 -0
  13. package/dist/deferred.d.ts.map +1 -0
  14. package/dist/deferred.js +310 -0
  15. package/dist/deferred.js.map +1 -0
  16. package/dist/dev-error.d.ts +17 -0
  17. package/dist/dev-error.d.ts.map +1 -0
  18. package/dist/dev-error.js +59 -0
  19. package/dist/dev-error.js.map +1 -0
  20. package/dist/dev.d.ts +25 -0
  21. package/dist/dev.d.ts.map +1 -0
  22. package/dist/dev.js +83 -0
  23. package/dist/dev.js.map +1 -0
  24. package/dist/draft.d.ts +58 -0
  25. package/dist/draft.d.ts.map +1 -0
  26. package/dist/draft.js +55 -0
  27. package/dist/draft.js.map +1 -0
  28. package/dist/fonts-google.d.ts +109 -0
  29. package/dist/fonts-google.d.ts.map +1 -0
  30. package/dist/fonts-google.js +291 -0
  31. package/dist/fonts-google.js.map +1 -0
  32. package/dist/fonts.d.ts +67 -0
  33. package/dist/fonts.d.ts.map +1 -0
  34. package/dist/fonts.js +103 -0
  35. package/dist/fonts.js.map +1 -0
  36. package/dist/fs.d.ts +10 -0
  37. package/dist/fs.d.ts.map +1 -0
  38. package/dist/fs.js +24 -0
  39. package/dist/fs.js.map +1 -0
  40. package/dist/index.d.ts +280 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +784 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/islands.d.ts +37 -0
  45. package/dist/islands.d.ts.map +1 -0
  46. package/dist/islands.js +107 -0
  47. package/dist/islands.js.map +1 -0
  48. package/dist/isr.d.ts +154 -0
  49. package/dist/isr.d.ts.map +1 -0
  50. package/dist/isr.js +331 -0
  51. package/dist/isr.js.map +1 -0
  52. package/dist/manifest.d.ts +178 -0
  53. package/dist/manifest.d.ts.map +1 -0
  54. package/dist/manifest.js +213 -0
  55. package/dist/manifest.js.map +1 -0
  56. package/dist/plugins/css-modules.d.ts +40 -0
  57. package/dist/plugins/css-modules.d.ts.map +1 -0
  58. package/dist/plugins/css-modules.js +0 -0
  59. package/dist/plugins/css-modules.js.map +1 -0
  60. package/dist/plugins/kit.d.ts +44 -0
  61. package/dist/plugins/kit.d.ts.map +1 -0
  62. package/dist/plugins/kit.js +105 -0
  63. package/dist/plugins/kit.js.map +1 -0
  64. package/dist/plugins/scss.d.ts +27 -0
  65. package/dist/plugins/scss.d.ts.map +1 -0
  66. package/dist/plugins/scss.js +78 -0
  67. package/dist/plugins/scss.js.map +1 -0
  68. package/dist/prerender.d.ts +77 -0
  69. package/dist/prerender.d.ts.map +1 -0
  70. package/dist/prerender.js +139 -0
  71. package/dist/prerender.js.map +1 -0
  72. package/dist/query.d.ts +62 -0
  73. package/dist/query.d.ts.map +1 -0
  74. package/dist/query.js +184 -0
  75. package/dist/query.js.map +1 -0
  76. package/dist/router.d.ts +169 -0
  77. package/dist/router.d.ts.map +1 -0
  78. package/dist/router.js +531 -0
  79. package/dist/router.js.map +1 -0
  80. package/dist/vite.d.ts +49 -0
  81. package/dist/vite.d.ts.map +1 -0
  82. package/dist/vite.js +144 -0
  83. package/dist/vite.js.map +1 -0
  84. package/package.json +127 -0
  85. package/src/build.ts +375 -0
  86. package/src/client.ts +218 -0
  87. package/src/deferred.ts +339 -0
  88. package/src/dev-error.ts +62 -0
  89. package/src/dev.ts +108 -0
  90. package/src/draft.ts +82 -0
  91. package/src/fonts-google.ts +397 -0
  92. package/src/fonts.ts +147 -0
  93. package/src/fs.ts +26 -0
  94. package/src/index.ts +1202 -0
  95. package/src/islands.ts +121 -0
  96. package/src/isr.ts +461 -0
  97. package/src/manifest.ts +366 -0
  98. package/src/plugins/css-modules.ts +0 -0
  99. package/src/plugins/kit.ts +123 -0
  100. package/src/plugins/scss.ts +108 -0
  101. package/src/prerender.ts +209 -0
  102. package/src/query.ts +250 -0
  103. package/src/router.ts +676 -0
  104. package/src/vite.ts +212 -0
package/src/index.ts ADDED
@@ -0,0 +1,1202 @@
1
+ /**
2
+ * @nifrajs/web — the framework-agnostic SSR core. It owns the render *seam* and the HTML
3
+ * document orchestration, and knows nothing about any specific UI framework: a render
4
+ * adapter (@nifrajs/web-solid, @nifrajs/web-react, …) supplies the actual render + hydrate.
5
+ *
6
+ * The core treats a "component" and a hydration "container" as opaque `unknown` — only
7
+ * the adapter interprets them. That keeps this package both framework-agnostic and free
8
+ * of DOM types: it is pure server orchestration + string building.
9
+ */
10
+ import { type Server, server } from "@nifrajs/core"
11
+ import {
12
+ DEFERRED_ERROR_CODE,
13
+ DEFERRED_RUNTIME,
14
+ type Deferred,
15
+ defer,
16
+ MAP_DEFERRED_SOURCE,
17
+ ndjsonStream,
18
+ prepareDeferred,
19
+ } from "./deferred.ts"
20
+ import { isDraftEnabled } from "./draft.ts"
21
+ import { ISR_REVALIDATE_HEADER } from "./isr.ts"
22
+ import type { LayoutEntry, Manifest, Meta, MetaArgs, MetaInput, RouteEntry } from "./manifest.ts"
23
+ import {
24
+ DATA_HEADER,
25
+ PRERENDERED_GLOBAL,
26
+ REDIRECT_HEADER,
27
+ REVALIDATE_HEADER,
28
+ type Submission,
29
+ } from "./router.ts"
30
+
31
+ // Draft / preview mode — a signed cookie that flips `ctx.draft` for loaders + bypasses ISR for editors.
32
+ export {
33
+ DRAFT_COOKIE,
34
+ type DraftCookieControls,
35
+ disableDraft,
36
+ type EnableDraftOptions,
37
+ enableDraft,
38
+ isDraftEnabled,
39
+ } from "./draft.ts"
40
+ // Font optimization — a CLS-safe `@font-face` generator + a preload `<link>` for self-hosted fonts.
41
+ export {
42
+ type FontDisplay,
43
+ type FontFace,
44
+ type FontPreloadInput,
45
+ type FontSource,
46
+ fontFace,
47
+ fontPreload,
48
+ } from "./fonts.ts"
49
+ // ISR (incremental static regeneration): a pluggable cache store + the `withISR` stale-while-revalidate
50
+ // wrapper for rendered SSR responses.
51
+ export {
52
+ type CachedResponse,
53
+ type CacheStore,
54
+ ISR_REVALIDATE_HEADER,
55
+ ISR_STATUS_HEADER,
56
+ type ISRApp,
57
+ type ISROptions,
58
+ type ISRPlatform,
59
+ KVCacheStore,
60
+ type KVCacheStoreOptions,
61
+ type KVNamespaceLike,
62
+ MemoryCacheStore,
63
+ type MemoryCacheStoreOptions,
64
+ type RevalidateEndpointOptions,
65
+ revalidateEndpoint,
66
+ withISR,
67
+ } from "./isr.ts"
68
+
69
+ // File-based routing manifest — pure + fs-free. `discoverRoutes` (fs) lives in `@nifrajs/web/fs`.
70
+ export {
71
+ type Action,
72
+ buildManifest,
73
+ enumeratePrerenderedPaths,
74
+ enumerateStaticRoutes,
75
+ filePathToPattern,
76
+ filePathToPatterns,
77
+ type GetStaticPaths,
78
+ type LayoutEntry,
79
+ type Loader,
80
+ type LoaderContext,
81
+ type Manifest,
82
+ type Meta,
83
+ type MetaArgs,
84
+ type MetaInput,
85
+ type RouteEntry,
86
+ type RouteModule,
87
+ type StaticPath,
88
+ type StaticPaths,
89
+ type StaticRoutes,
90
+ } from "./manifest.ts"
91
+ // Keyed query-cache (agnostic) — a `query(key, fn)` primitive (dedup + staleness + invalidation + GC)
92
+ // consumed by the per-adapter `useQuery`/`createQuery` bindings.
93
+ export {
94
+ createQueryClient,
95
+ hashQueryKey,
96
+ type QueryClient,
97
+ type QueryClientOptions,
98
+ type QueryHandle,
99
+ type QueryState,
100
+ type QueryStatus,
101
+ } from "./query.ts"
102
+ // Agnostic client-side router core (pure + DOM-free) — consumed by per-adapter Router bindings.
103
+ // `DATA_HEADER` marks a navigation's data-only GET; `createWebApp` answers it with loader JSON.
104
+ export {
105
+ type ClientRouter,
106
+ type ClientRouterOptions,
107
+ createClientRouter,
108
+ createMatcher,
109
+ DATA_HEADER,
110
+ type Fetcher,
111
+ type FetcherState,
112
+ type FetchRouteData,
113
+ type MountRouterOptions,
114
+ REDIRECT_HEADER,
115
+ REVALIDATE_HEADER,
116
+ type RouteMatch,
117
+ type RoutePattern,
118
+ type RouterState,
119
+ type Submission,
120
+ type SubmitOptions,
121
+ } from "./router.ts"
122
+ // Deferred loader data (`defer()` + the `Deferred<T>` type) — consumed by the adapter's `<Await>`.
123
+ export { type Deferred, defer }
124
+
125
+ /** The data handed to a route component. Opaque to the core. `actionData` is the return of a
126
+ * route `action` after a POST (absent on plain GETs). `pending` + `submission` are client-only
127
+ * (absent on SSR): they drive **optimistic UI** — render from `submission.formData` while `pending`. */
128
+ export interface RenderProps {
129
+ readonly data: unknown
130
+ readonly actionData?: unknown
131
+ /** True while a client navigation or submit is in flight (client-only; absent/false on SSR). */
132
+ readonly pending?: boolean
133
+ /** The in-flight client submit, for optimistic UI (client-only; absent on SSR + when idle). */
134
+ readonly submission?: Submission
135
+ }
136
+
137
+ /** The seam every render adapter implements. */
138
+ export interface RenderAdapter {
139
+ /**
140
+ * Server: render a route's layout `chain` (outermost layout → page) to a **stream** of HTML
141
+ * bytes, including the framework's hydration markers. The page (innermost) receives `props`
142
+ * (the loader data); each layout wraps the child via its `children`. Returns (or resolves to)
143
+ * a Web `ReadableStream<Uint8Array>`: a streaming renderer flushes the shell first and streams
144
+ * Suspense boundaries as they resolve; a non-streaming renderer may return a one-chunk stream.
145
+ * May be async — e.g. React resolves once the shell is renderable, so a shell-render throw can
146
+ * still map to an error status before any byte is sent.
147
+ */
148
+ renderToStream(
149
+ chain: readonly unknown[],
150
+ props: RenderProps,
151
+ ): ReadableStream<Uint8Array> | Promise<ReadableStream<Uint8Array>>
152
+ /**
153
+ * Server (optional): render the chain to a complete HTML **string** in one pass, with the same
154
+ * hydration markers `renderToStream` produces. When provided, `renderPage` uses it for any page
155
+ * that does **not** `defer()` — buffering the document is faster than the streaming pipeline there,
156
+ * because the framework's synchronous renderer (React `renderToString`, Solid `renderToString`,
157
+ * Preact `render`, Vue `renderToString`, Svelte `render`) is markedly cheaper than its streaming
158
+ * renderer — most visibly on Bun, where React Fizz / Solid `renderToStream` are the heaviest. Pages
159
+ * that `defer()` keep the streaming path (progressive `<Await>` needs it). Markup MUST be
160
+ * hydration-equivalent to `renderToStream`; a throw surfaces like the streaming shell-readiness
161
+ * await (the `_error` boundary maps it to a status). Omit it to always stream.
162
+ */
163
+ renderToString?(chain: readonly unknown[], props: RenderProps): string | Promise<string>
164
+ /**
165
+ * Server: per-document bootstrap markup injected into `<head>` that the client
166
+ * `hydrate` requires (Solid: `generateHydrationScript()`). Empty string if none.
167
+ */
168
+ hydrationHead(): string
169
+ }
170
+
171
+ /** Global the server serializes loader data into; the client reads it to hydrate. */
172
+ export const DATA_GLOBAL = "__NIFRA_DATA__"
173
+
174
+ /** Global the server writes the matched route id into; the client uses it to pick the chain. */
175
+ export const ROUTE_GLOBAL = "__NIFRA_ROUTE__"
176
+
177
+ /** Global the server serializes an action's data return into (absent on GETs); the client
178
+ * reads it so hydration after a native form POST matches the server-rendered markup. */
179
+ export const ACTION_GLOBAL = "__NIFRA_ACTION__"
180
+
181
+ // Shared, stateless — allocated once at module load, not per render/stream.
182
+ const TEXT_ENCODER = new TextEncoder()
183
+
184
+ type MaybePromise<T> = T | Promise<T>
185
+
186
+ // XSS-safe `<script>` JSON escaping in a SINGLE pass: `<`/`>` would break out of the
187
+ // script element; U+2028/U+2029 are valid JSON but historically break JS string literals. Built via
188
+ // fromCharCode so no raw separator chars live in this source. One regex + one output string replaces
189
+ // the prior four sequential `replaceAll` full-string passes.
190
+ const LINE_SEP = String.fromCharCode(0x2028)
191
+ const PARA_SEP = String.fromCharCode(0x2029)
192
+ const SCRIPT_ESCAPE = new RegExp(`[<>${LINE_SEP}${PARA_SEP}]`, "g")
193
+ const SCRIPT_ESCAPE_MAP: Readonly<Record<string, string>> = {
194
+ "<": "\\u003c",
195
+ ">": "\\u003e",
196
+ [LINE_SEP]: "\\u2028",
197
+ [PARA_SEP]: "\\u2029",
198
+ }
199
+ const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body")
200
+ const RESPONSE_RESULT = Symbol.for("nifra.response.result")
201
+
202
+ export interface RenderedPage {
203
+ readonly [RESPONSE_RESULT]: true
204
+ toResponse(): Response
205
+ toNodeBody?(): {
206
+ readonly status: number
207
+ readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
208
+ readonly body: string | Uint8Array
209
+ }
210
+ }
211
+
212
+ export interface RenderPageOptions {
213
+ readonly adapter: RenderAdapter
214
+ /** The layout chain to render — outermost layout → page (opaque; the adapter renders it). */
215
+ readonly chain: readonly unknown[]
216
+ /** The loader output for this request. */
217
+ readonly data: unknown
218
+ /** An action's data return (POST only) — surfaced to the page as `actionData` + serialized
219
+ * for the client so post-POST hydration matches. Omit on GETs. */
220
+ readonly actionData?: unknown
221
+ /** URL of the built client entry (loaded as a module script). */
222
+ readonly clientEntry: string
223
+ /** Chunk URLs for the **matched** route (its layout chain + own chunk) to `modulepreload` in the
224
+ * shell — so the route code downloads in parallel with the entry instead of after it
225
+ * (`buildClient`'s per-route map). Empty/omitted ⇒ only the entry is preloaded (unchanged). */
226
+ readonly preload?: readonly string[]
227
+ /** Stylesheet URLs for the **matched** route (its layout chain + own CSS, from `buildClient`'s
228
+ * `BuildManifest.css`) — injected as `<link rel="stylesheet">` in `<head>` so styles arrive with the
229
+ * first paint (no FOUC). Rendered even on non-hydrated pages. Empty/omitted ⇒ none (unchanged). */
230
+ readonly styles?: readonly string[]
231
+ /** SSG: the prerendered-path set, serialized to `window.__NIFRA_PRERENDERED__` so the client fetches
232
+ * a static `_data.json` on soft-nav into a prerendered route. Empty/omitted ⇒ not injected. */
233
+ readonly prerenderedPaths?: readonly string[]
234
+ /** ISR: route freshness in seconds, emitted as the `x-nifra-isr-revalidate` header for a `withISR`
235
+ * wrapper to read. Omit ⇒ no header (the wrapper's default TTL applies). */
236
+ readonly revalidate?: number
237
+ /** Matched route id; written to `window.__NIFRA_ROUTE__` so the client hydrates this chain. */
238
+ readonly routeId?: string
239
+ /** HTTP status for the response (default 200; e.g. 404 for a not-found page). */
240
+ readonly status?: number
241
+ /** Document title (fallback when `head.title` is unset). */
242
+ readonly title?: string
243
+ /** Resolved route head — `title` overrides `title` above; `meta`/`link` render as managed
244
+ * (`data-nifra`) tags the client updates on navigation. */
245
+ readonly head?: Meta
246
+ /** Id of the container wrapping the app markup (default `"root"`). */
247
+ readonly rootId?: string
248
+ /** When `false`, emit a complete but **non-hydrated** document — no client entry script, data
249
+ * globals, or modulepreloads. Used for server-rendered `_error` pages: a terminal state that needs no
250
+ * client takeover, and it sidesteps an SSR/hydrate mismatch (the server rendered the boundary, not
251
+ * the page the client manifest maps this route id to). Default `true`. */
252
+ readonly hydrate?: boolean
253
+ /** Island client bundles (`@nifrajs/web/islands`) to load as `<script type="module">` in the document
254
+ * tail — emitted **regardless of `hydrate`**, so a static (`hydrate: false`) page can still mount
255
+ * no-framework islands. URLs are attribute-escaped. Empty/omitted ⇒ none (unchanged output). */
256
+ readonly islandScripts?: readonly string[]
257
+ }
258
+
259
+ /**
260
+ * Server: render a full HTML document for a page — the adapter's hydration head + the SSR
261
+ * markup (**streamed**) + the serialized loader data + the client module — as a `Response`.
262
+ * The shell (`<head>` + the open container) flushes first, the adapter's app stream follows,
263
+ * then the tail (data globals + client entry). Pure Web Standards, so it returns straight from
264
+ * a nifra route handler and streams on any fetch runtime (Bun/Node/Deno/Workers).
265
+ */
266
+ export function renderPage(options: RenderPageOptions): MaybePromise<Response> {
267
+ const page = renderPageResult(options)
268
+ return page instanceof Promise ? page.then((p) => p.toResponse()) : page.toResponse()
269
+ }
270
+
271
+ export function renderPageResult(options: RenderPageOptions): MaybePromise<RenderedPage> {
272
+ const {
273
+ adapter,
274
+ chain,
275
+ data,
276
+ actionData,
277
+ clientEntry,
278
+ preload = [],
279
+ styles = [],
280
+ prerenderedPaths = [],
281
+ revalidate,
282
+ routeId,
283
+ status = 200,
284
+ title = "nifra",
285
+ head,
286
+ rootId = "root",
287
+ hydrate = true,
288
+ islandScripts = [],
289
+ } = options
290
+ const route = routeId === undefined ? "" : `window.${ROUTE_GLOBAL}=${serializeData(routeId)};`
291
+ // The SSG prerendered-path set (when an app declares it) — the client reads it to fetch a static
292
+ // `_data.json` on soft-nav into a prerendered route instead of hitting the worker. Empty ⇒ omitted.
293
+ const prerendered =
294
+ prerenderedPaths.length === 0
295
+ ? ""
296
+ : `window.${PRERENDERED_GLOBAL}=${serializeData(prerenderedPaths)};`
297
+ // Split deferred values: the component sees markers (id + promise) to `<Await>`; the serialized
298
+ // data carries `{__nifra_deferred: id}` placeholders (promises don't serialize). `actionData` may
299
+ // also `defer()` — split it too, continuing the id space so a single registry settles both. The
300
+ // inline registry runtime is emitted only when something defers, so non-deferred output is unchanged.
301
+ const { forComponent, forClient, deferred } = prepareDeferred(data)
302
+ const actionSplit =
303
+ actionData === undefined ? undefined : prepareDeferred(actionData, deferred.length)
304
+ const allDeferred = actionSplit ? [...deferred, ...actionSplit.deferred] : deferred
305
+ // Only emit the action global when an action actually ran, so plain GET output is unchanged.
306
+ const action =
307
+ actionSplit === undefined
308
+ ? ""
309
+ : `window.${ACTION_GLOBAL}=${serializeData(actionSplit.forClient)};`
310
+ const deferredRuntime = allDeferred.length > 0 ? `<script>${DEFERRED_RUNTIME}</script>` : ""
311
+ // Matched-route chunk preloads, concatenated directly (skip the `filter().map()`
312
+ // intermediate arrays — and the whole loop — on the common no-extra-preload render). De-duped
313
+ // against the entry, which is preloaded separately below.
314
+ let preloadLinks = ""
315
+ if (preload.length > 0) {
316
+ for (const url of preload) {
317
+ if (url !== clientEntry)
318
+ preloadLinks += `<link rel="modulepreload" href="${escapeAttr(url)}">`
319
+ }
320
+ }
321
+ // The matched route's stylesheets — `<link rel="stylesheet">` in `<head>` so CSS arrives with the
322
+ // first paint (no FOUC). Render-blocking by design, and emitted regardless of `hydrate` (a static or
323
+ // `_error` page still wants its styles). In dev (Vite) CSS is injected client-side instead, so
324
+ // `styles` is empty there.
325
+ let styleLinks = ""
326
+ for (const url of styles) styleLinks += `<link rel="stylesheet" href="${escapeAttr(url)}">`
327
+ // Island bundles are referenced only by a `<script type="module">` at the END of `<body>` (below), so
328
+ // the browser doesn't discover them until the whole page is parsed. `modulepreload` them in `<head>`
329
+ // so the fetch starts immediately, in parallel with parsing — regardless of `hydrate` (an island page
330
+ // is typically hydrate:false). Without this, a heavy island bundle on a cold first load leaves its
331
+ // server-rendered placeholder visible until that late, un-prefetched fetch lands ("stuck loading").
332
+ let islandPreloads = ""
333
+ for (const src of islandScripts)
334
+ islandPreloads += `<link rel="modulepreload" href="${escapeAttr(src)}">`
335
+ // Shell — flushed before the app finishes rendering: `<head>` (title, meta, hydration head) + the
336
+ // open container. `modulepreload` of the client entry — plus the matched route's own chunks
337
+ // (`preloadLinks`) — lets the JS download while the body streams. One template literal (no
338
+ // intermediate array + `join`) — byte-identical to the prior output.
339
+ // A non-hydrated page (e.g. an `_error` boundary) omits the client-entry preload, route-chunk
340
+ // preloads, and deferred runtime — there's no client takeover to feed.
341
+ const hydrationLinks = hydrate
342
+ ? `<link rel="modulepreload" href="${escapeAttr(clientEntry)}">${preloadLinks}${deferredRuntime}`
343
+ : ""
344
+ const shellHtml = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(head?.title ?? title)}</title>${headTags(head)}${styleLinks}${hydrationLinks}${islandPreloads}${adapter.hydrationHead()}</head><body><div id="${escapeAttr(rootId)}">`
345
+ // Closes the hydration container; deferred resolve scripts go AFTER it (outside `#root`) so they
346
+ // aren't part of the adapter's hydrated tree (an inline script inside it breaks hydration).
347
+ const closeRootHtml = "</div>"
348
+ // Tail — the loader-data globals + the client module. Module scripts defer (run after parse), so
349
+ // the data global + every streamed deferred resolution are set before the entry hydrates.
350
+ // Island bundles load regardless of `hydrate` — a static page (hydrate:false) ships no framework
351
+ // client but can still mount no-framework islands (`@nifrajs/web/islands`).
352
+ let islandTags = ""
353
+ for (const src of islandScripts)
354
+ islandTags += `<script type="module" src="${escapeAttr(src)}"></script>`
355
+ const tailHtml = `${
356
+ hydrate
357
+ ? `<script>${route}${action}${prerendered}window.${DATA_GLOBAL}=${serializeData(forClient)}</script><script type="module" src="${escapeAttr(clientEntry)}"></script>`
358
+ : ""
359
+ }${islandTags}</body></html>`
360
+ const headers: Record<string, string> = { "content-type": "text/html; charset=utf-8" }
361
+ // ISR: advertise the route's freshness so a `withISR` wrapper can set this page's cache TTL. A
362
+ // dedicated header (not the action-revalidation `x-nifra-revalidate`) so the TTL channel never aliases
363
+ // the client's path-list channel.
364
+ if (revalidate !== undefined) headers[ISR_REVALIDATE_HEADER] = String(revalidate)
365
+ const renderProps: RenderProps = { data: forComponent, actionData: actionSplit?.forComponent }
366
+
367
+ // Fast path: nothing `defer()`s and the adapter can render synchronously to a string → buffer the
368
+ // whole document in one pass. Progressive streaming only benefits pages with deferred boundaries
369
+ // (those take the streaming path below); for a plain page the streaming pipeline + the framework's
370
+ // streaming renderer are pure overhead vs a single sync render + concat. A buffered string body also
371
+ // gets an automatic Content-Length. A render throw surfaces here exactly as the streaming path's
372
+ // shell-readiness `await` does, so the `_error` boundary still maps it to a status.
373
+ if (allDeferred.length === 0 && adapter.renderToString !== undefined) {
374
+ const out = adapter.renderToString(chain, renderProps)
375
+ return typeof out === "string"
376
+ ? new BufferedRenderedPage(shellHtml + out + closeRootHtml + tailHtml, status, headers)
377
+ : out.then(
378
+ (bodyHtml) =>
379
+ new BufferedRenderedPage(
380
+ shellHtml + bodyHtml + closeRootHtml + tailHtml,
381
+ status,
382
+ headers,
383
+ ),
384
+ )
385
+ }
386
+
387
+ return renderStreamedPage(
388
+ adapter,
389
+ chain,
390
+ renderProps,
391
+ shellHtml,
392
+ closeRootHtml,
393
+ allDeferred,
394
+ tailHtml,
395
+ status,
396
+ headers,
397
+ ).then((response) => new ResponseRenderedPage(response))
398
+ }
399
+
400
+ async function renderStreamedPage(
401
+ adapter: RenderAdapter,
402
+ chain: readonly unknown[],
403
+ renderProps: RenderProps,
404
+ shellHtml: string,
405
+ closeRootHtml: string,
406
+ allDeferred: ReadonlyArray<{ readonly id: number; readonly promise: Promise<unknown> }>,
407
+ tailHtml: string,
408
+ status: number,
409
+ headers: Record<string, string>,
410
+ ): Promise<Response> {
411
+ // Streaming path — required for `defer()` (progressive `<Await>` resolution) and used by any adapter
412
+ // that doesn't implement `renderToString`. Awaiting `renderToStream` resolves on shell-readiness
413
+ // (React: on-shell-ready; Solid: synchronously), so a shell-render throw surfaces before any byte is
414
+ // sent. A *mid*-stream failure errors the body instead.
415
+ const enc = TEXT_ENCODER
416
+ const shell = enc.encode(shellHtml)
417
+ const closeRoot = enc.encode(closeRootHtml)
418
+ const tail = enc.encode(tailHtml)
419
+ const appStream = await adapter.renderToStream(chain, renderProps)
420
+ const body = streamDocument(shell, appStream, closeRoot, allDeferred, tail, enc)
421
+ return new Response(body, { status, headers })
422
+ }
423
+
424
+ class BufferedRenderedPage implements RenderedPage {
425
+ readonly [RESPONSE_RESULT] = true
426
+ private readonly body: string
427
+ private readonly status: number
428
+ private readonly headers: Readonly<Record<string, string>>
429
+
430
+ constructor(body: string, status: number, headers: Readonly<Record<string, string>>) {
431
+ this.body = body
432
+ this.status = status
433
+ this.headers = headers
434
+ }
435
+
436
+ toResponse(): Response {
437
+ return htmlResponse(this.body, { status: this.status, headers: this.headers })
438
+ }
439
+
440
+ toNodeBody(): {
441
+ readonly status: number
442
+ readonly headers: Readonly<Record<string, string | readonly string[]>>
443
+ readonly body: string
444
+ } {
445
+ return { status: this.status, headers: this.headers, body: this.body }
446
+ }
447
+ }
448
+
449
+ class ResponseRenderedPage implements RenderedPage {
450
+ readonly [RESPONSE_RESULT] = true
451
+ private readonly response: Response
452
+
453
+ constructor(response: Response) {
454
+ this.response = response
455
+ }
456
+
457
+ toResponse(): Response {
458
+ return this.response
459
+ }
460
+ }
461
+
462
+ function htmlResponse(body: string, init: ResponseInit): Response {
463
+ const response = new Response(body, init)
464
+ // @nifrajs/node can write buffered HTML straight to ServerResponse with `end(body)`, avoiding a Web
465
+ // Response stream drain on Node. Non-enumerable + Symbol.for keeps this invisible to Web runtimes and
466
+ // cross-package without adding a runtime dependency from @nifrajs/node to @nifrajs/web.
467
+ Object.defineProperty(response, NODE_RESPONSE_BODY, { value: body })
468
+ return response
469
+ }
470
+
471
+ /**
472
+ * Assemble the document stream: `shell` → the app `stream` (forwarded chunk-by-chunk, so a streaming
473
+ * renderer's progressive flushing is preserved) → `closeRoot` → one `__nifraResolve`/`__nifraReject`
474
+ * script per deferred value (emitted once its promise settles — by now the app stream has awaited
475
+ * the same Suspense boundaries — and placed OUTSIDE `#root`) → `tail`. A mid-stream app error errors
476
+ * the result (the body breaks) rather than silently truncating a 200.
477
+ */
478
+ function streamDocument(
479
+ shell: Uint8Array,
480
+ stream: ReadableStream<Uint8Array>,
481
+ closeRoot: Uint8Array,
482
+ deferred: ReadonlyArray<{ readonly id: number; readonly promise: Promise<unknown> }>,
483
+ tail: Uint8Array,
484
+ enc: TextEncoder,
485
+ ): ReadableStream<Uint8Array> {
486
+ return new ReadableStream({
487
+ async start(controller) {
488
+ const reader = stream.getReader()
489
+ try {
490
+ controller.enqueue(shell)
491
+ for (;;) {
492
+ const { done, value } = await reader.read()
493
+ if (done) break
494
+ controller.enqueue(value)
495
+ }
496
+ controller.enqueue(closeRoot)
497
+ // Stream each resolution as ITS OWN promise settles — NOT in array order. A slow
498
+ // defer() must not block a faster one; each script self-addresses by id, so order is irrelevant.
499
+ await Promise.all(
500
+ deferred.map(async (d) => {
501
+ try {
502
+ const value = serializeData(await d.promise)
503
+ controller.enqueue(
504
+ enc.encode(`<script>window.__nifraResolve(${d.id},${value})</script>`),
505
+ )
506
+ } catch (err) {
507
+ // A rejected deferred streams __nifraReject (the client `<Await>` surfaces it) — it must
508
+ // not break the whole body. Redact: stream a stable opaque code, never the raw error
509
+ // text; log the real reason server-side.
510
+ console.error("[nifra/web] deferred value rejected:", err)
511
+ controller.enqueue(
512
+ enc.encode(
513
+ `<script>window.__nifraReject(${d.id},${serializeData(DEFERRED_ERROR_CODE)})</script>`,
514
+ ),
515
+ )
516
+ }
517
+ }),
518
+ )
519
+ controller.enqueue(tail)
520
+ controller.close()
521
+ } catch (err) {
522
+ controller.error(err)
523
+ } finally {
524
+ reader.releaseLock()
525
+ }
526
+ },
527
+ })
528
+ }
529
+
530
+ /** Options for {@link redirect}. */
531
+ export interface RedirectOptions {
532
+ /** HTTP status (default 303 See Other; pass 307/308 to preserve the method). */
533
+ readonly status?: number
534
+ /** Allow an off-origin / absolute destination. Off by default: only a same-origin path (a single
535
+ * leading `/`) is permitted, so an action can't be turned into an open redirect by passing
536
+ * attacker-controlled input straight through. Set `true` for a deliberate external redirect. */
537
+ readonly external?: boolean
538
+ }
539
+
540
+ /** A same-origin destination is an absolute path: one leading `/`, but NOT `//` (protocol-relative →
541
+ * another origin). Everything else (absolute URL with a scheme, `//host`, `javascript:`, a bare
542
+ * relative `foo`) is treated as off-origin and requires `external: true`. */
543
+ function isSameOriginPath(location: string): boolean {
544
+ return location.startsWith("/") && !location.startsWith("//")
545
+ }
546
+
547
+ /**
548
+ * Build a redirect `Response` — return it from a route `action` for the Post/Redirect/Get
549
+ * pattern (POST mutates, 303 sends the browser to a fresh GET, so a reload doesn't re-submit).
550
+ * Defaults to 303 (See Other); pass `307`/`308` (or `{ status }`) to preserve the method.
551
+ *
552
+ * **Secure by default:** `location` must be a same-origin path (begins with `/`, not `//`). An
553
+ * off-origin/absolute destination throws unless you pass `{ external: true }` — this closes the
554
+ * open-redirect footgun of `return redirect(formData.get("next"))` on the no-JS (native-form) path,
555
+ * which returns the action's `Response` verbatim.
556
+ *
557
+ * @param statusOrOptions a status number (back-compat) or `{ status?, external? }`.
558
+ */
559
+ export function redirect(
560
+ location: string,
561
+ statusOrOptions: number | RedirectOptions = 303,
562
+ ): Response {
563
+ const opts = typeof statusOrOptions === "number" ? { status: statusOrOptions } : statusOrOptions
564
+ if (opts.external !== true && !isSameOriginPath(location)) {
565
+ throw new Error(
566
+ `[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.`,
567
+ )
568
+ }
569
+ // Reject CR/LF in the Location explicitly — defense-in-depth (response splitting / header
570
+ // injection). Spec-correct runtimes' Headers setter throws on CR/LF, but `external: true` lets
571
+ // unvalidated input reach this sink, so we don't rely on the runtime. Same posture as
572
+ // serializeCookie / the SSE frame formatter, which strip CRLF at their sinks.
573
+ if (/[\r\n]/.test(location)) {
574
+ throw new Error(
575
+ `[nifra/web] redirect location contains a CR/LF character — refusing to emit a header-injecting redirect.`,
576
+ )
577
+ }
578
+ return new Response(null, { status: opts.status ?? 303, headers: { location } })
579
+ }
580
+
581
+ /** The wrapper `revalidate()` returns: the action's `data` plus the paths it changed. A plain tagged
582
+ * shape (not a class) so `@nifrajs/client`'s `ActionData` can unwrap it structurally without importing
583
+ * from `@nifrajs/web`. `createWebApp` strips the wrapper — the client receives `data` as the body and
584
+ * the paths via the `X-Nifra-Revalidate` header. */
585
+ export interface RevalidateResult<T> {
586
+ readonly __nifraRevalidate: readonly string[]
587
+ readonly data: T
588
+ }
589
+
590
+ /**
591
+ * Return this from an action to declare which routes the mutation changed (alongside the action's
592
+ * `data`). `createWebApp` sets the `X-Nifra-Revalidate` response header; after the submit the client
593
+ * marks those cached routes stale — refetching the active one and any mounted fetcher showing them —
594
+ * so a mutation can refresh views beyond the one that was submitted. `data` is still surfaced to the
595
+ * component as `actionData` (the wrapper is transparent to `ActionData<typeof action>`).
596
+ */
597
+ export function revalidate<T>(paths: readonly string[], data: T): RevalidateResult<T> {
598
+ return { __nifraRevalidate: paths, data }
599
+ }
600
+
601
+ /**
602
+ * Serialize loader data for embedding inside an inline `<script>`. `JSON.stringify` alone
603
+ * is NOT safe there: a string containing `</script>` or `<!--` would break out of the
604
+ * script element (an XSS vector). Escape `<`/`>` to `\uXXXX`, plus the U+2028/U+2029
605
+ * separators.
606
+ */
607
+ export function serializeData(data: unknown): string {
608
+ return JSON.stringify(data ?? null).replace(SCRIPT_ESCAPE, (ch) => SCRIPT_ESCAPE_MAP[ch] ?? ch)
609
+ }
610
+
611
+ function escapeHtml(value: string): string {
612
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
613
+ }
614
+
615
+ function escapeAttr(value: string): string {
616
+ return value
617
+ .replaceAll("&", "&amp;")
618
+ .replaceAll('"', "&quot;")
619
+ .replaceAll("<", "&lt;")
620
+ .replaceAll(">", "&gt;")
621
+ }
622
+
623
+ const SAFE_ATTR_NAME = /^[a-zA-Z][a-zA-Z0-9-]*$/
624
+
625
+ /** Serialize a meta/link tag's attributes: validate the name (dev-authored) + escape the value
626
+ * (may carry loader data → XSS). Invalid attribute names are dropped. */
627
+ function tagAttrs(attrs: Record<string, string>): string {
628
+ return Object.entries(attrs)
629
+ .filter(([name]) => SAFE_ATTR_NAME.test(name))
630
+ .map(([name, value]) => `${name}="${escapeAttr(value)}"`)
631
+ .join(" ")
632
+ }
633
+
634
+ // Memoize the serialized head-tag string by the resolved `Meta` object's identity. A STATIC route
635
+ // `meta` is returned by-reference from resolveMeta, so the same object recurs every request → cache
636
+ // hit, and the invariant string is serialized once per route, not per request. A FUNCTION `meta`
637
+ // builds a fresh object each request (new identity) → miss → recompute, exactly as needed (its
638
+ // content can vary). WeakMap so a route module that's GC'd takes its entry with it.
639
+ const headTagsCache = new WeakMap<Meta, string>()
640
+
641
+ /** Render a route's `meta`/`link` as managed (`data-nifra`) head tags. Title is set separately.
642
+ * String concatenation (no intermediate `.map()` arrays + spread) — parity with the already
643
+ * concat-based preloadLinks/styleLinks/islandPreloads loops; byte-identical output.
644
+ * Result is memoized per resolved-`Meta` identity (static meta → serialized once per route). */
645
+ function headTags(head: Meta | undefined): string {
646
+ if (head === undefined) return ""
647
+ const cached = headTagsCache.get(head)
648
+ if (cached !== undefined) return cached
649
+ let out = ""
650
+ if (head.meta !== undefined) for (const m of head.meta) out += `<meta ${tagAttrs(m)} data-nifra>`
651
+ if (head.link !== undefined) for (const l of head.link) out += `<link ${tagAttrs(l)} data-nifra>`
652
+ headTagsCache.set(head, out)
653
+ return out
654
+ }
655
+
656
+ /** Resolve a route's `meta` (static or a function of the loader data + params) to a {@link Meta}. */
657
+ export function resolveMeta(meta: MetaInput | undefined, args: MetaArgs): Meta {
658
+ if (meta === undefined) return {}
659
+ return typeof meta === "function" ? meta(args) : meta
660
+ }
661
+
662
+ export interface CreateWebAppOptions {
663
+ readonly adapter: RenderAdapter
664
+ readonly manifest: Manifest
665
+ /** URL of the built client entry (module script) injected into every page. */
666
+ readonly clientEntry: string
667
+ /** Default document title for all pages. */
668
+ readonly title?: string
669
+ /** Injected into each loader's `ctx.api` — typically an `inProcessClient(app)` (typed
670
+ * per-route via `@nifrajs/client`'s `createRoutes`). Opaque to the core. */
671
+ readonly api?: unknown
672
+ /** Secret for **draft / preview mode** (see `enableDraft`). When set, a request carrying a valid
673
+ * signed `__nifra_draft` cookie gets `ctx.draft === true` in loaders/actions (else always `false`).
674
+ * Pair with `withISR({ draftSecret })` so editors bypass the cache. Omit to disable draft mode. */
675
+ readonly draftSecret?: string
676
+ /** Per-route chunk URLs (`buildClient`'s `BuildManifest.routes`) — `routeId → [layout chunks…, own
677
+ * chunk]`. When present, each page `modulepreload`s its matched route's chunks alongside the entry,
678
+ * so the route code downloads in parallel (no entry→route-chunk waterfall). Omit ⇒ entry-only. */
679
+ readonly routePreload?: Readonly<Record<string, readonly string[]>>
680
+ /** The app's bundled stylesheet URLs (`buildClient`'s `BuildManifest.css`) — the aggregate, injected
681
+ * as `<link rel="stylesheet">` in a page's `<head>`. Used as the fallback for any route absent from
682
+ * {@link routeStyles}. Omit ⇒ no links (dev, where Vite injects CSS, or a CSS-free app). */
683
+ readonly styles?: readonly string[]
684
+ /** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) — `routeId → [chain CSS]`.
685
+ * When a matched route has an entry here, only those (its layout chain + own CSS) are linked instead
686
+ * of the aggregate `styles`, so a page ships only the CSS it uses. An empty array ⇒ no `<link>` (the
687
+ * page imports no CSS). Routes absent here fall back to `styles`. Omit ⇒ always use `styles`. */
688
+ readonly routeStyles?: Readonly<Record<string, readonly string[]>>
689
+ /** SSG: the prerendered-path set (e.g. from `enumeratePrerenderedPaths` or the build's
690
+ * `prerendered.json`). Injected as `window.__NIFRA_PRERENDERED__` on every page so a client soft-nav
691
+ * into a prerendered route fetches its static `_data.json` instead of hitting the worker. */
692
+ readonly prerenderedPaths?: readonly string[]
693
+ /** SSG: per dynamic route pattern, its `getStaticPaths` `fallback` (from `enumerateStaticRoutes` or
694
+ * the build's `prerendered.json`). A route mapped to `"404"` rejects any path NOT in
695
+ * `prerenderedPaths` with the 404 page — the unlisted path simply doesn't exist. `"ssr"` (the
696
+ * default for unmapped routes) renders unlisted paths on-demand. */
697
+ readonly staticFallbacks?: Readonly<Record<string, "ssr" | "404">>
698
+ /** Observe every loader/action failure — for error-reporting plugins (Sentry-style). Called for
699
+ * real throws (not control-flow `Response`s like `redirect`), **before** the nearest `_error`
700
+ * boundary renders / a soft-nav 500 / a rethrow — so it sees errors that the boundary would
701
+ * otherwise hide. Observation only; its own throws are swallowed so a faulty reporter can't break
702
+ * rendering. (`beforeLoader` is intentionally omitted — the core HTTP hooks already cover
703
+ * pre-request work.) */
704
+ readonly onLoaderError?: (
705
+ error: unknown,
706
+ ctx: {
707
+ readonly request: Request
708
+ readonly params: Readonly<Record<string, string>>
709
+ readonly route: string
710
+ },
711
+ ) => void
712
+ }
713
+
714
+ /** The handler context fields createWebApp uses — a structural subset of nifra's `Context`. */
715
+ interface RouteContext {
716
+ readonly params: Record<string, string>
717
+ readonly req: Request
718
+ /** Platform bindings (Workers env), forwarded to each route's loader/action as `args.env`. */
719
+ readonly env: unknown
720
+ }
721
+
722
+ /**
723
+ * Build a nifra app from a route manifest: every route SSRs its layout chain via `renderPage`,
724
+ * and a wildcard catch-all renders `_404` (or a plain 404). Reuses @nifrajs/core's router +
725
+ * lifecycle, so matching, params, and precedence are battle-tested. fs-free — feed it a
726
+ * manifest from `discoverRoutes` (`@nifrajs/web/fs`) at startup, so the served app stays portable.
727
+ */
728
+ export function createWebApp(options: CreateWebAppOptions): Server {
729
+ const { adapter, manifest, clientEntry, title, api } = options
730
+ const titleOption = title === undefined ? {} : { title }
731
+ // Draft/preview: when a `draftSecret` is configured, each request's signed `__nifra_draft` cookie is
732
+ // verified once and surfaced to loaders/actions as `ctx.draft`. No secret ⇒ always `false` (sync, free).
733
+ const draftFlag = (req: Request): Promise<boolean> =>
734
+ options.draftSecret === undefined
735
+ ? Promise.resolve(false)
736
+ : isDraftEnabled(req, options.draftSecret)
737
+ // Per-route preload chunks (spread into renderPage; omitted when unmapped, for exactOptionalPropertyTypes).
738
+ const preloadOf = (id: string): { preload?: readonly string[] } => {
739
+ const chunks = options.routePreload?.[id]
740
+ return chunks ? { preload: chunks } : {}
741
+ }
742
+ // The matched route's stylesheet links (spread into renderPage). Per-route when the build mapped it
743
+ // (`routeStyles[id]` — only the chain's CSS; an empty array ⇒ no `<link>`), else the aggregate
744
+ // `styles`. Omitted entirely when CSS-free (for exactOptionalPropertyTypes).
745
+ const stylesOf = (id: string): { styles?: readonly string[] } => {
746
+ const perRoute = options.routeStyles?.[id]
747
+ if (perRoute !== undefined) return { styles: perRoute }
748
+ return options.styles && options.styles.length > 0 ? { styles: options.styles } : {}
749
+ }
750
+ const app = server()
751
+ // SSG `fallback: "404"`: the set of concrete paths that actually exist for those routes. An unlisted
752
+ // path under a `"404"` route is rejected (it isn't a static file, and the route declared it shouldn't
753
+ // SSR on demand). O(1) membership via a Set.
754
+ const prerenderedSet = new Set(options.prerenderedPaths ?? [])
755
+
756
+ // Resolve a route's layout chain (outermost layout → page). Loaded lazily — the data-only and
757
+ // 405 branches return before any layout is needed.
758
+ const loadLayouts = (route: RouteEntry): Promise<unknown[]> =>
759
+ Promise.all(
760
+ // layoutIds only reference layouts present in the manifest (buildManifest invariant).
761
+ route.layoutIds.map((id) =>
762
+ (manifest.layouts[id] as LayoutEntry).load().then((m) => m.default),
763
+ ),
764
+ )
765
+
766
+ // Dir a special-file id lives in: `_error`→"" , `a/b/_error`→"a/b" (and likewise for `_layout`).
767
+ const dirOfId = (id: string, suffix: string): string =>
768
+ id === suffix ? "" : id.slice(0, id.length - suffix.length - 1)
769
+
770
+ /**
771
+ * Render the **nearest `_error` boundary** when a route's loader throws (the agnostic, server-side
772
+ * half of error UI — works on every adapter, no client takeover). The boundary renders in place of
773
+ * the page, wrapped by the layouts **at or above** its segment (deeper layouts are dropped). It's
774
+ * served **non-hydrated** at status 500: a terminal page, and rendering a boundary (not the page the
775
+ * client maps this route to) would otherwise hydrate-mismatch. The component receives `{ name,
776
+ * message }` — never the stack (no internals leak into HTML).
777
+ */
778
+ const renderError = async (
779
+ route: RouteEntry,
780
+ errorId: string,
781
+ err: unknown,
782
+ ): Promise<Response | RenderedPage> => {
783
+ const errDir = dirOfId(errorId, "_error")
784
+ const keptLayoutIds = route.layoutIds.filter((id) => {
785
+ const ld = dirOfId(id, "_layout")
786
+ return ld === "" || ld === errDir || errDir.startsWith(`${ld}/`)
787
+ })
788
+ const layouts = await Promise.all(
789
+ keptLayoutIds.map((id) =>
790
+ (manifest.layouts[id] as LayoutEntry).load().then((m) => m.default),
791
+ ),
792
+ )
793
+ const { default: errComp } = await (manifest.errors?.[errorId] as LayoutEntry).load()
794
+ const e = err instanceof Error ? err : new Error(String(err))
795
+ return renderPageResult({
796
+ adapter,
797
+ chain: [...layouts, errComp],
798
+ data: { name: e.name, message: e.message },
799
+ clientEntry,
800
+ routeId: errorId,
801
+ status: 500,
802
+ hydrate: false,
803
+ ...titleOption,
804
+ })
805
+ }
806
+
807
+ // The 404 response — the `_404` page (status 404) or a plain-text fallback. Shared by the wildcard
808
+ // catch-all (unmatched paths) and the `fallback: "404"` enforcement (unlisted paths under a route
809
+ // that opted out of on-demand SSR).
810
+ const renderNotFound = async (): Promise<Response | RenderedPage> => {
811
+ if (manifest.notFound === undefined) {
812
+ return new Response("Not Found", {
813
+ status: 404,
814
+ headers: { "content-type": "text/plain; charset=utf-8" },
815
+ })
816
+ }
817
+ const { default: notFound } = await manifest.notFound.load()
818
+ return renderPageResult({
819
+ adapter,
820
+ chain: [notFound],
821
+ data: null,
822
+ clientEntry,
823
+ routeId: "_404",
824
+ status: 404,
825
+ ...preloadOf("_404"),
826
+ ...stylesOf("_404"),
827
+ prerenderedPaths: options.prerenderedPaths ?? [],
828
+ ...titleOption,
829
+ })
830
+ }
831
+
832
+ for (const route of manifest.routes) {
833
+ // A dynamic route whose `getStaticPaths` declared `fallback: "404"` — only its prerendered paths
834
+ // exist; anything else 404s (computed once per route, not per request).
835
+ const is404Fallback = options.staticFallbacks?.[route.pattern] === "404"
836
+ app.register("GET", route.pattern, undefined, async (c: RouteContext) => {
837
+ // Enforce `fallback: "404"` before any work: an unlisted path under this route doesn't exist.
838
+ // Covers hard navigation directly; a client soft-nav's data fetch gets the 404, throws, and the
839
+ // history layer falls back to a full-page navigation (which lands here again, as a document).
840
+ if (is404Fallback && !prerenderedSet.has(new URL(c.req.url).pathname)) {
841
+ return renderNotFound()
842
+ }
843
+ const mod = await route.load()
844
+ const draft = await draftFlag(c.req)
845
+ let data: unknown
846
+ try {
847
+ data = mod.loader
848
+ ? await mod.loader({ params: c.params, request: c.req, api, env: c.env, draft })
849
+ : null
850
+ } catch (err) {
851
+ // A thrown `Response` is a control-flow signal (a guard's `redirect(...)`, an explicit error
852
+ // response) — let it propagate to core, which returns it as-is. Real errors render the nearest
853
+ // `_error` boundary, if any; with none, rethrow (unchanged 500 behavior).
854
+ if (err instanceof Response) throw err
855
+ // Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
856
+ if (options.onLoaderError !== undefined) {
857
+ try {
858
+ options.onLoaderError(err, { request: c.req, params: c.params, route: route.pattern })
859
+ } catch {
860
+ // A faulty reporter must never break error rendering.
861
+ }
862
+ }
863
+ const errorId = route.errorIds?.at(-1)
864
+ if (errorId === undefined) throw err
865
+ // A soft-nav data fetch can't render a boundary — 500 so the client falls back to a full-page
866
+ // navigation, which lands here as a document and renders the `_error` page.
867
+ if (c.req.headers.get(DATA_HEADER) !== null) {
868
+ return new Response("Internal Server Error", {
869
+ status: 500,
870
+ headers: { "content-type": "text/plain; charset=utf-8" },
871
+ })
872
+ }
873
+ return renderError(route, errorId, err)
874
+ }
875
+ // Client-side navigation asks (via the X-Nifra-Data header) for just the loader data — no full
876
+ // document, no layout chain. A route with deferred data streams NDJSON (critical data first,
877
+ // then each deferred value as it settles); otherwise one JSON (the fast path). Same loader,
878
+ // same auth — only the transport differs.
879
+ if (c.req.headers.get(DATA_HEADER) !== null) {
880
+ const { forClient, deferred } = prepareDeferred(data)
881
+ if (deferred.length === 0) return Response.json(data ?? null)
882
+ return new Response(ndjsonStream(forClient, deferred), {
883
+ headers: { "content-type": "application/x-ndjson; charset=utf-8" },
884
+ })
885
+ }
886
+ const chain = [...(await loadLayouts(route)), mod.default]
887
+ const head = resolveMeta(mod.meta, { data, params: c.params })
888
+ const hydrateRoute = mod.hydrate !== false
889
+ try {
890
+ // `await` so a shell-render throw (renderToStream rejects before any byte) is caught here and
891
+ // can render the `_error` page — not just a loader throw. Mid-stream (post-shell) throws can't
892
+ // be recovered to a full page; the per-adapter client boundary catches client render errors.
893
+ return await renderPageResult({
894
+ adapter,
895
+ chain,
896
+ data,
897
+ head,
898
+ clientEntry,
899
+ routeId: route.id,
900
+ hydrate: hydrateRoute,
901
+ ...preloadOf(route.id),
902
+ ...stylesOf(route.id),
903
+ prerenderedPaths: options.prerenderedPaths ?? [],
904
+ ...(mod.revalidate !== undefined ? { revalidate: mod.revalidate } : {}),
905
+ ...(mod.islandScripts !== undefined ? { islandScripts: mod.islandScripts } : {}),
906
+ ...titleOption,
907
+ })
908
+ } catch (err) {
909
+ if (err instanceof Response) throw err
910
+ // Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
911
+ if (options.onLoaderError !== undefined) {
912
+ try {
913
+ options.onLoaderError(err, { request: c.req, params: c.params, route: route.pattern })
914
+ } catch {
915
+ // A faulty reporter must never break error rendering.
916
+ }
917
+ }
918
+ const errorId = route.errorIds?.at(-1)
919
+ if (errorId === undefined) throw err
920
+ return renderError(route, errorId, err)
921
+ }
922
+ })
923
+
924
+ // POST runs the route's `action` (mutation). A `Response` return (e.g. a `redirect(...)`)
925
+ // passes straight through; a data return re-renders the page (the loader re-runs for fresh
926
+ // data) with `actionData`. Routes without an action reject POST with 405 — not a stray 404.
927
+ app.register("POST", route.pattern, undefined, async (c: RouteContext) => {
928
+ const mod = await route.load()
929
+ const draft = await draftFlag(c.req)
930
+ if (mod.action === undefined) {
931
+ return new Response("Method Not Allowed", {
932
+ status: 405,
933
+ headers: { allow: "GET", "content-type": "text/plain; charset=utf-8" },
934
+ })
935
+ }
936
+ const result = await mod.action({ params: c.params, request: c.req, api, env: c.env, draft })
937
+ const isDataRequest = c.req.headers.get(DATA_HEADER) !== null
938
+ // An action may wrap its data in `revalidate(paths, data)` to declare which routes it changed.
939
+ // Unwrap to the inner data; the paths ride the `X-Nifra-Revalidate` header on the data-mode
940
+ // responses (the client acts on them — a full-page POST re-runs loaders inline, so no header).
941
+ const isRevalidate =
942
+ result !== null && typeof result === "object" && "__nifraRevalidate" in result
943
+ const actionResult = isRevalidate ? (result as RevalidateResult<unknown>).data : result
944
+ const revalidateHeader: Record<string, string> = isRevalidate
945
+ ? { [REVALIDATE_HEADER]: (result as RevalidateResult<unknown>).__nifraRevalidate.join(",") }
946
+ : {}
947
+ if (actionResult instanceof Response) {
948
+ // Client submit can't read a 3xx Location (fetch follows it to HTML), so convey the
949
+ // redirect via a header on a 204 and let the client navigate. Native forms get the 3xx.
950
+ if (isDataRequest && actionResult.status >= 300 && actionResult.status < 400) {
951
+ const location = actionResult.headers.get("location") ?? "/"
952
+ return new Response(null, { status: 204, headers: { [REDIRECT_HEADER]: location } })
953
+ }
954
+ return actionResult
955
+ }
956
+ // Client submit wants just the action's data (it revalidates the loader itself); a native
957
+ // form POST re-renders the full page (loader re-runs) with the action data.
958
+ if (isDataRequest) {
959
+ // An action may `defer()` slow parts of its result — stream them (critical data first, then
960
+ // each deferred as it settles) exactly like a loader; a non-deferred action returns one JSON.
961
+ const { forClient, deferred } = prepareDeferred(actionResult)
962
+ if (deferred.length === 0)
963
+ return Response.json(actionResult ?? null, { headers: revalidateHeader })
964
+ return new Response(ndjsonStream(forClient, deferred), {
965
+ headers: { "content-type": "application/x-ndjson; charset=utf-8", ...revalidateHeader },
966
+ })
967
+ }
968
+ const data = mod.loader
969
+ ? await mod.loader({ params: c.params, request: c.req, api, env: c.env, draft })
970
+ : null
971
+ const chain = [...(await loadLayouts(route)), mod.default]
972
+ const head = resolveMeta(mod.meta, { data, params: c.params })
973
+ const hydrateRoute = mod.hydrate !== false
974
+ // A full-page POST streams the action's `defer()`'d parts mid-document behind `<Await>` too —
975
+ // `renderPage` splits `actionData` like loader data (works with JS off; hydrates after).
976
+ return renderPageResult({
977
+ adapter,
978
+ chain,
979
+ data,
980
+ actionData: actionResult,
981
+ head,
982
+ clientEntry,
983
+ routeId: route.id,
984
+ hydrate: hydrateRoute,
985
+ ...preloadOf(route.id),
986
+ ...stylesOf(route.id),
987
+ prerenderedPaths: options.prerenderedPaths ?? [],
988
+ ...titleOption,
989
+ })
990
+ })
991
+ }
992
+
993
+ // Wildcard catch-all: unmatched paths render `_404` (404), or a plain text 404 if absent.
994
+ app.register("GET", "/*", undefined, () => renderNotFound())
995
+
996
+ return app
997
+ }
998
+
999
+ export interface GenerateClientEntryOptions {
1000
+ /** Module specifier for the adapter's client runtime (exports `mountRouter`), e.g.
1001
+ * `"@nifrajs/web-solid/client"`. */
1002
+ readonly clientModule: string
1003
+ /** Turn a route/layout source file (relative to the routes dir) into an import specifier. */
1004
+ readonly resolve: (file: string) => string
1005
+ }
1006
+
1007
+ /**
1008
+ * Codegen: emit a client-entry module (as source) that lazily imports each route's layout chain
1009
+ * (so `Bun.build` with `splitting` code-splits one chunk per route), builds a `patterns` list,
1010
+ * then creates the agnostic router store (with a `loadModule` hook), installs history + form
1011
+ * interception, loads the initial route's chunk, and hydrates the adapter's stateful Router. The
1012
+ * initial route is derived from the URL (falling back to the server-injected route id, e.g.
1013
+ * `_404`), so after hydration the Router owns navigation and swaps routes without full reloads.
1014
+ * Bun has no `import.meta.glob`, so file-based routing needs this. Write the result to a file and
1015
+ * bundle it with `buildClient` / `Bun.build` (+ the adapter's transform).
1016
+ */
1017
+ export function generateClientEntry(
1018
+ manifest: Manifest,
1019
+ options: GenerateClientEntryOptions,
1020
+ ): string {
1021
+ const { clientModule, resolve } = options
1022
+
1023
+ const loaderRows: string[] = []
1024
+ const patternRows: string[] = []
1025
+ // Routes whose loader appends a nearest `_error` module (LAST) — the client wraps the page in the
1026
+ // adapter's `errorBoundary(fallback)` for these, so a client render error shows the `_error` UI.
1027
+ const errorRouteIds: string[] = []
1028
+ // Lazy loader returns the raw modules (for both the component chain + the page's `meta` export).
1029
+ const lazyLoader = (files: readonly string[]): string => {
1030
+ const imports = files.map((f) => `import(${JSON.stringify(resolve(f))})`).join(", ")
1031
+ return `() => Promise.all([${imports}])`
1032
+ }
1033
+ for (const route of manifest.routes) {
1034
+ // layoutIds only reference layouts present in the manifest (buildManifest invariant).
1035
+ const files = [
1036
+ ...route.layoutIds.map((id) => (manifest.layouts[id] as LayoutEntry).file),
1037
+ route.file,
1038
+ ]
1039
+ // Append the nearest `_error` file LAST, so loadModule can pull it off the tail and wrap the page.
1040
+ const nearestErrorId = route.errorIds?.at(-1)
1041
+ const errorFile =
1042
+ nearestErrorId === undefined ? undefined : manifest.errors?.[nearestErrorId]?.file
1043
+ if (errorFile !== undefined) {
1044
+ files.push(errorFile)
1045
+ errorRouteIds.push(route.id)
1046
+ }
1047
+ loaderRows.push(` ${JSON.stringify(route.id)}: ${lazyLoader(files)},`)
1048
+ patternRows.push(
1049
+ ` { routeId: ${JSON.stringify(route.id)}, pattern: ${JSON.stringify(route.pattern)} },`,
1050
+ )
1051
+ }
1052
+ if (manifest.notFound !== undefined) {
1053
+ loaderRows.push(` "_404": ${lazyLoader([manifest.notFound.file])},`)
1054
+ }
1055
+
1056
+ return `${[
1057
+ 'import { createClientRouter, createMatcher, resolveMeta } from "@nifrajs/web"',
1058
+ 'import { applyHead, installForms, installHistory } from "@nifrajs/web/client"',
1059
+ // Namespace import: `errorBoundary` is optional (an adapter may not export it). A namespace member
1060
+ // access yields `undefined` if absent — unlike a named import, which would be a link error.
1061
+ `import * as __adapter from ${JSON.stringify(clientModule)}`,
1062
+ "const { mountRouter } = __adapter",
1063
+ "const errorBoundary = __adapter.errorBoundary",
1064
+ `const errorRouteIds = new Set(${JSON.stringify(errorRouteIds)})`,
1065
+ // Each route is a lazy loader: dynamic imports → Bun.build (splitting) emits one chunk per
1066
+ // route, shared layouts/deps deduped into shared chunks, so a route's code loads only when
1067
+ // visited. loadModule caches the [layouts…, page] component chain + the page's meta per id.
1068
+ "const loaders = {",
1069
+ ...loaderRows,
1070
+ "}",
1071
+ "const chains = {}",
1072
+ "const metas = {}",
1073
+ "const loadModule = async (id) => {",
1074
+ " if (chains[id]) return",
1075
+ " const mods = await loaders[id]()",
1076
+ // For an error route the `_error` module is appended LAST: wrap the page (now second-to-last) in
1077
+ // the adapter's boundary so a client render error renders the `_error` UI. DOM-transparent, so the
1078
+ // hydrated tree matches the SSR markup (which has no boundary). Falls back to the plain chain when
1079
+ // the adapter has no `errorBoundary`.
1080
+ " if (errorBoundary && errorRouteIds.has(id)) {",
1081
+ " const fallback = mods[mods.length - 1].default",
1082
+ " const page = mods[mods.length - 2].default",
1083
+ " const layouts = mods.slice(0, mods.length - 2).map((m) => m.default)",
1084
+ " chains[id] = [...layouts, errorBoundary(fallback), page]",
1085
+ " metas[id] = mods[mods.length - 2].meta",
1086
+ " } else {",
1087
+ " chains[id] = mods.map((m) => m.default)",
1088
+ " metas[id] = mods[mods.length - 1].meta",
1089
+ " }",
1090
+ "}",
1091
+ "const patterns = [",
1092
+ ...patternRows,
1093
+ "]",
1094
+ // Derive the initial route from the URL (correct on refresh/deep-link); fall back to the
1095
+ // server-injected route id for non-pattern routes (e.g. _404, which matches nothing).
1096
+ "const matched = createMatcher(patterns)(location.pathname)",
1097
+ // Map any `{__nifra_deferred: id}` placeholder in the SSR data to the registry's promise, so the
1098
+ // component receives real promises to `<Await>` (a no-op when a page has no deferred data).
1099
+ MAP_DEFERRED_SOURCE,
1100
+ "const initial = {",
1101
+ ` routeId: matched ? matched.routeId : (window.${ROUTE_GLOBAL} ?? ""),`,
1102
+ " params: matched ? matched.params : {},",
1103
+ " path: location.pathname,",
1104
+ ` data: mapDeferred(window.${DATA_GLOBAL}),`,
1105
+ // actionData (only set after a form POST) is in the initial state so the binding hydrates
1106
+ // consistently with the server-rendered markup; mapped through `mapDeferred` too so a deferred
1107
+ // action's placeholders become registry markers (a no-op when the action didn't defer).
1108
+ ` actionData: mapDeferred(window.${ACTION_GLOBAL}),`,
1109
+ " pending: false,",
1110
+ "}",
1111
+ "const router = createClientRouter({ patterns, initial, loadModule })",
1112
+ "installHistory(router)",
1113
+ "installForms(router)",
1114
+ 'const root = document.getElementById("root")',
1115
+ // Load the initial route's chunk, then hydrate the Router (chain is cached). The initial head
1116
+ // is server-rendered; subsequent navigations update it from the matched route's meta + data.
1117
+ "if (root) loadModule(initial.routeId).then(() => {",
1118
+ " mountRouter({ router, routes: chains, container: root })",
1119
+ " router.subscribe(() => {",
1120
+ " const s = router.snapshot()",
1121
+ " if (!s.pending) applyHead(resolveMeta(metas[s.routeId], { data: s.data, params: s.params }))",
1122
+ " })",
1123
+ "})",
1124
+ ].join("\n")}\n`
1125
+ }
1126
+
1127
+ export interface GenerateServerManifestOptions {
1128
+ /** Turn a route/layout source file (relative to the routes dir) into an import specifier —
1129
+ * same contract as `generateClientEntry`'s `resolve`. */
1130
+ readonly resolve: (file: string) => string
1131
+ /** The content-hashed client entry URL (from `buildClient`'s manifest), **baked** into the emitted
1132
+ * module — a disk-less worker can't read `manifest.json` at runtime. */
1133
+ readonly clientEntry: string
1134
+ /** Emit **lazy** per-route loaders (`() => import("./routes/x")`, a static specifier) instead of
1135
+ * eager `import * as`, so a bundler with code-splitting emits one chunk per route — loaded on the
1136
+ * first request to it, not all at boot (smaller cold-start parse). Default `false` (eager). Both
1137
+ * modes are fs-free with statically-analyzable specifiers; only the *when* differs. */
1138
+ readonly lazy?: boolean
1139
+ }
1140
+
1141
+ /**
1142
+ * Codegen: emit a **server manifest** module (as source) for disk-less edge runtimes (Cloudflare
1143
+ * Workers, …) — and, with a `target`, any portable server bundle. `discoverRoutes` scans `node:fs`
1144
+ * and dynamic-imports each route by a *runtime* path — neither exists on workerd. This instead emits
1145
+ * **statically-analyzable** imports of every route/layout/`_error`/`_404` (so the bundler includes them) and
1146
+ * rebuilds the manifest with `buildManifest` — the SAME pure logic `discoverRoutes` feeds, so patterns
1147
+ * + layout chains are identical. Eager (`import * as`) by default; `lazy` emits `() => import(...)` so
1148
+ * a code-splitting bundler chunks per route. The emitted module exports `manifest` (consumed by
1149
+ * `createWebApp`, unchanged) + `clientEntry` (baked). Write it to a file and bundle it into the worker
1150
+ * entry (see `buildServer` in `@nifrajs/web/build`).
1151
+ */
1152
+ export function generateServerManifest(
1153
+ manifest: Manifest,
1154
+ options: GenerateServerManifestOptions,
1155
+ ): string {
1156
+ const { resolve, clientEntry, lazy = false } = options
1157
+ // Every unique source file in the manifest (routes + layouts + `_error` + `_404`), sorted for stable output.
1158
+ const files = [
1159
+ ...new Set([
1160
+ ...manifest.routes.map((r) => r.file),
1161
+ ...Object.values(manifest.layouts).map((l) => l.file),
1162
+ ...Object.values(manifest.errors ?? {}).map((e) => e.file),
1163
+ ...(manifest.notFound ? [manifest.notFound.file] : []),
1164
+ ]),
1165
+ ].sort()
1166
+ const header = [
1167
+ "// GENERATED by @nifrajs/web generateServerManifest — route manifest for the disk-less edge",
1168
+ "// (no filesystem; route imports are static specifiers the bundler resolves). buildManifest is",
1169
+ "// the same pure logic discoverRoutes feeds, so patterns + layout chains match exactly.",
1170
+ 'import { buildManifest } from "@nifrajs/web"',
1171
+ ]
1172
+ const clientEntryLine = `export const clientEntry = ${JSON.stringify(clientEntry)}`
1173
+ if (lazy) {
1174
+ // Lazy: `() => import("./routes/x")` per route (static specifier → one chunk per route under a
1175
+ // code-splitting bundler, loaded on first request). The map keys are the route-relative paths
1176
+ // `buildManifest` expects; the importer it builds calls the per-file loader.
1177
+ const loaders = files.map(
1178
+ (file) => ` ${JSON.stringify(file)}: () => import(${JSON.stringify(resolve(file))}),`,
1179
+ )
1180
+ return `${[
1181
+ ...header,
1182
+ "const loaders = {",
1183
+ ...loaders,
1184
+ "}",
1185
+ clientEntryLine,
1186
+ "export const manifest = buildManifest(Object.keys(loaders), (file) => () => loaders[file]())",
1187
+ ].join("\n")}\n`
1188
+ }
1189
+ // Eager: `import * as` per route (all bundled into the entry, parsed at boot). Index-based
1190
+ // identifiers are collision-proof regardless of filename.
1191
+ const imports = files.map((file, i) => `import * as m${i} from ${JSON.stringify(resolve(file))}`)
1192
+ const entries = files.map((file, i) => ` ${JSON.stringify(file)}: m${i},`)
1193
+ return `${[
1194
+ ...header,
1195
+ ...imports,
1196
+ "const modules = {",
1197
+ ...entries,
1198
+ "}",
1199
+ clientEntryLine,
1200
+ "export const manifest = buildManifest(Object.keys(modules), (file) => () => Promise.resolve(modules[file]))",
1201
+ ].join("\n")}\n`
1202
+ }