@nifrajs/web 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/dist/build-plan.d.ts +228 -0
  2. package/dist/build-plan.d.ts.map +1 -0
  3. package/dist/build-plan.js +514 -0
  4. package/dist/build-plan.js.map +1 -0
  5. package/dist/build-vite.d.ts +2 -1
  6. package/dist/build-vite.d.ts.map +1 -1
  7. package/dist/build-vite.js.map +1 -1
  8. package/dist/build.d.ts +2 -197
  9. package/dist/build.d.ts.map +1 -1
  10. package/dist/build.js +61 -502
  11. package/dist/build.js.map +1 -1
  12. package/dist/chunk-names.d.ts.map +1 -1
  13. package/dist/chunk-names.js +3 -2
  14. package/dist/chunk-names.js.map +1 -1
  15. package/dist/dev-ssr-graph.d.ts.map +1 -1
  16. package/dist/dev-ssr-graph.js +63 -28
  17. package/dist/dev-ssr-graph.js.map +1 -1
  18. package/dist/diagnostic.d.ts.map +1 -1
  19. package/dist/diagnostic.js +9 -2
  20. package/dist/diagnostic.js.map +1 -1
  21. package/dist/fonts-google.d.ts.map +1 -1
  22. package/dist/fonts-google.js +3 -1
  23. package/dist/fonts-google.js.map +1 -1
  24. package/dist/fs.d.ts.map +1 -1
  25. package/dist/fs.js +2 -1
  26. package/dist/fs.js.map +1 -1
  27. package/dist/index.d.ts +15 -609
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +22 -1935
  30. package/dist/index.js.map +1 -1
  31. package/dist/internal/codegen.d.ts +68 -0
  32. package/dist/internal/codegen.d.ts.map +1 -0
  33. package/dist/internal/codegen.js +317 -0
  34. package/dist/internal/codegen.js.map +1 -0
  35. package/dist/internal/head-merge.d.ts +2 -0
  36. package/dist/internal/head-merge.d.ts.map +1 -1
  37. package/dist/internal/head-merge.js +17 -1
  38. package/dist/internal/head-merge.js.map +1 -1
  39. package/dist/internal/parity.d.ts +7 -0
  40. package/dist/internal/parity.d.ts.map +1 -1
  41. package/dist/internal/parity.js +212 -30
  42. package/dist/internal/parity.js.map +1 -1
  43. package/dist/internal/render-document.d.ts +372 -0
  44. package/dist/internal/render-document.d.ts.map +1 -0
  45. package/dist/internal/render-document.js +744 -0
  46. package/dist/internal/render-document.js.map +1 -0
  47. package/dist/internal/runtime-contract.d.ts +48 -0
  48. package/dist/internal/runtime-contract.d.ts.map +1 -0
  49. package/dist/internal/runtime-contract.js +27 -0
  50. package/dist/internal/runtime-contract.js.map +1 -0
  51. package/dist/internal/web-app.d.ts +171 -0
  52. package/dist/internal/web-app.d.ts.map +1 -0
  53. package/dist/internal/web-app.js +844 -0
  54. package/dist/internal/web-app.js.map +1 -0
  55. package/dist/islands.d.ts +48 -0
  56. package/dist/islands.d.ts.map +1 -1
  57. package/dist/islands.js +79 -0
  58. package/dist/islands.js.map +1 -1
  59. package/dist/module-graph.d.ts.map +1 -1
  60. package/dist/module-graph.js +22 -9
  61. package/dist/module-graph.js.map +1 -1
  62. package/dist/nano.d.ts +125 -0
  63. package/dist/nano.d.ts.map +1 -0
  64. package/dist/nano.js +190 -0
  65. package/dist/nano.js.map +1 -0
  66. package/dist/plugins/css-modules.js +2 -2
  67. package/dist/plugins/css-modules.js.map +1 -1
  68. package/dist/plugins/kit.d.ts +12 -0
  69. package/dist/plugins/kit.d.ts.map +1 -1
  70. package/dist/plugins/kit.js +62 -12
  71. package/dist/plugins/kit.js.map +1 -1
  72. package/dist/plugins/postcss.js +2 -2
  73. package/dist/plugins/postcss.js.map +1 -1
  74. package/dist/plugins/scss.js +2 -2
  75. package/dist/plugins/scss.js.map +1 -1
  76. package/dist/plugins/stylex.d.ts +70 -0
  77. package/dist/plugins/stylex.d.ts.map +1 -0
  78. package/dist/plugins/stylex.js +166 -0
  79. package/dist/plugins/stylex.js.map +1 -0
  80. package/dist/plugins/svg.js +2 -2
  81. package/dist/plugins/svg.js.map +1 -1
  82. package/dist/router.d.ts +1 -1
  83. package/dist/router.js +23 -23
  84. package/dist/router.js.map +1 -1
  85. package/dist/vite.d.ts.map +1 -1
  86. package/dist/vite.js +26 -10
  87. package/dist/vite.js.map +1 -1
  88. package/package.json +39 -3
  89. package/src/build-plan.ts +741 -0
  90. package/src/build-vite.ts +1 -4
  91. package/src/build.ts +84 -716
  92. package/src/chunk-names.ts +3 -2
  93. package/src/dev-ssr-graph.ts +63 -28
  94. package/src/diagnostic.ts +14 -2
  95. package/src/fonts-google.ts +3 -1
  96. package/src/fs.ts +2 -1
  97. package/src/index.ts +73 -2705
  98. package/src/internal/codegen.ts +400 -0
  99. package/src/internal/head-merge.ts +19 -1
  100. package/src/internal/parity.ts +215 -35
  101. package/src/internal/render-document.ts +1116 -0
  102. package/src/internal/runtime-contract.ts +54 -0
  103. package/src/internal/web-app.ts +1114 -0
  104. package/src/islands.ts +85 -0
  105. package/src/module-graph.ts +23 -9
  106. package/src/nano.ts +246 -0
  107. package/src/plugins/css-modules.ts +2 -2
  108. package/src/plugins/kit.ts +65 -12
  109. package/src/plugins/postcss.ts +2 -2
  110. package/src/plugins/scss.ts +2 -2
  111. package/src/plugins/stylex.ts +298 -0
  112. package/src/plugins/svg.ts +2 -2
  113. package/src/router.ts +24 -24
  114. package/src/vite.ts +33 -9
@@ -0,0 +1,400 @@
1
+ import { MAP_DEFERRED_SOURCE } from "../deferred.ts"
2
+ import type { LayoutEntry, Manifest } from "../manifest.ts"
3
+ import {
4
+ ACTION_GLOBAL,
5
+ BOUNDARY_GLOBAL,
6
+ DATA_GLOBAL,
7
+ LAYOUT_DATA_GLOBAL,
8
+ ROOT_ATTRIBUTE,
9
+ ROUTE_GLOBAL,
10
+ } from "../render-seam.ts"
11
+ import { jsStringLiteral } from "./js-string.ts"
12
+ export interface GenerateClientEntryOptions {
13
+ /**
14
+ * Module specifier for the adapter's client runtime, e.g. `"@nifrajs/web-solid/client"`.
15
+ *
16
+ * **Contract:** the module MUST export `mountRouter({ router, routes, container })`. The generated
17
+ * bootstrap imports the specifier and calls it - so a self-executing entry that mounts on import
18
+ * builds cleanly and then does nothing, which is the trap this contract exists to name. The type is
19
+ * a bare `string` because a specifier is resolved by the bundler, not the type system, so the
20
+ * requirement is enforced by the bootstrap instead: it throws immediately, naming this module and
21
+ * the missing export, rather than failing later as `mountRouter is not a function`.
22
+ */
23
+ readonly clientModule: string
24
+ /** Turn a route/layout source file (relative to the routes dir) into an import specifier. */
25
+ readonly resolve: (file: string) => string
26
+ }
27
+
28
+ /**
29
+ * Codegen: emit a client-entry module (as source) that lazily imports each route's layout chain
30
+ * (so `Bun.build` with `splitting` code-splits one chunk per route), builds a `patterns` list,
31
+ * then creates the agnostic router store (with a `loadModule` hook), installs history + form
32
+ * interception, loads the initial route's chunk, and hydrates the adapter's stateful Router. The
33
+ * initial route is derived from the URL (falling back to the server-injected route id, e.g.
34
+ * `_404`), so after hydration the Router owns navigation and swaps routes without full reloads.
35
+ * Bun has no `import.meta.glob`, so file-based routing needs this. Write the result to a file and
36
+ * bundle it with `buildClient` / `Bun.build` (+ the adapter's transform).
37
+ */
38
+ export function generateClientEntry(
39
+ manifest: Manifest,
40
+ options: GenerateClientEntryOptions,
41
+ ): string {
42
+ const { clientModule, resolve } = options
43
+
44
+ const loaderRows: string[] = []
45
+ const patternRows: string[] = []
46
+ const statusRoutes: Record<number, string> = {}
47
+ // Routes whose loader appends a nearest `_error` module (LAST) - the client wraps the page in the
48
+ // adapter's `errorBoundary(fallback)` for these, so a client render error shows the `_error` UI.
49
+ const errorRouteIds: string[] = []
50
+ // Lazy loader returns the raw modules (for both the component chain + the page's `meta` export).
51
+ const lazyLoader = (files: readonly string[]): string => {
52
+ const imports = files.map((f) => `import(${jsStringLiteral(resolve(f))})`).join(", ")
53
+ return `() => Promise.all([${imports}])`
54
+ }
55
+ for (const route of manifest.routes) {
56
+ // layoutIds only reference layouts present in the manifest (buildManifest invariant).
57
+ const files = [
58
+ ...route.layoutIds.map((id) => (manifest.layouts[id] as LayoutEntry).file),
59
+ route.file,
60
+ ]
61
+ // Append the nearest `_error` file LAST, so loadModule can pull it off the tail and wrap the page.
62
+ const nearestErrorId = route.errorIds?.at(-1)
63
+ const errorFile =
64
+ nearestErrorId === undefined ? undefined : manifest.errors?.[nearestErrorId]?.file
65
+ if (errorFile !== undefined) {
66
+ files.push(errorFile)
67
+ errorRouteIds.push(route.id)
68
+ }
69
+ loaderRows.push(` ${JSON.stringify(route.id)}: ${lazyLoader(files)},`)
70
+ patternRows.push(
71
+ ` { routeId: ${JSON.stringify(route.id)}, pattern: ${JSON.stringify(route.pattern)} },`,
72
+ )
73
+ }
74
+ if (manifest.notFound !== undefined) {
75
+ loaderRows.push(` "_404": ${lazyLoader([manifest.notFound.file])},`)
76
+ statusRoutes[404] = "_404"
77
+ }
78
+ for (const [status, entry] of Object.entries(manifest.statusPages ?? {})) {
79
+ const routeId = `_${status}`
80
+ loaderRows.push(` ${JSON.stringify(routeId)}: ${lazyLoader([entry.file])},`)
81
+ statusRoutes[Number(status)] = routeId
82
+ }
83
+
84
+ return `${[
85
+ // `/client`, never the root: the root's graph carries the server (renderPage, the static-file
86
+ // server), and Vite's dev server evaluates what it is given instead of tree-shaking it.
87
+ 'import { createClientRouter, createMatcher, mergeHeads, resolveMeta } from "@nifrajs/web/client"',
88
+ 'import { applyHead, installForms, installHistory, signalHydrated } from "@nifrajs/web/client"',
89
+ // Namespace import: `errorBoundary` is optional (an adapter may not export it). A namespace member
90
+ // access yields `undefined` if absent - unlike a named import, which would be a link error.
91
+ `import * as __adapter from ${JSON.stringify(clientModule)}`,
92
+ "const { mountRouter } = __adapter",
93
+ // The assurance hook is optional: ordinary client entries pay only for the namespace lookup, while
94
+ // hydration runners get a runtime identity from the exact adapter module that mounted the page. The
95
+ // registry is token-only and lives on a Symbol.for key so it cannot collide with app data.
96
+ "const __hydrationHook = __adapter.hydrationAssuranceHook",
97
+ 'if (__hydrationHook && typeof __hydrationHook.runtimeIdentity === "function") {',
98
+ ' const __runtimeKey = Symbol.for("nifra.hydration.runtime")',
99
+ " const __runtimeState = globalThis[__runtimeKey]",
100
+ " const __identities = __runtimeState && Array.isArray(__runtimeState.identities) ? __runtimeState.identities : []",
101
+ " globalThis[__runtimeKey] = { framework: __hydrationHook.framework, identities: [...__identities, __hydrationHook.runtimeIdentity()] }",
102
+ "}",
103
+ // The `clientModule` contract is a string specifier, so nothing type-checks that the module it
104
+ // names actually exports `mountRouter`. Without this the miss surfaces as
105
+ // "mountRouter is not a function" from inside a bundled chunk, at first paint, naming neither the
106
+ // module nor the requirement - a self-executing entry passes every build and fails only here.
107
+ `if (typeof mountRouter !== "function") throw new Error(${JSON.stringify(
108
+ `[nifra/web] clientModule ${JSON.stringify(clientModule)} does not export \`mountRouter\`. ` +
109
+ "A client module must export `mountRouter({ router, routes, container })` - it is called by " +
110
+ "the generated bootstrap, so a self-executing entry will not work. Use your adapter's " +
111
+ '`/client` entry (e.g. "@nifrajs/web-react/client"), or re-export mountRouter from it.',
112
+ )})`,
113
+ "const errorBoundary = __adapter.errorBoundary",
114
+ // A hot update that the framework cannot actually apply must end in a reload, not in nothing.
115
+ //
116
+ // Bun's dev server applies React Fast Refresh to JSX modules. For React that patches the component
117
+ // and preserves state. For Preact it does not - the module still LOOKS Fast-Refresh-able, so the
118
+ // update is accepted and then swallowed: `bun:beforeUpdate`/`bun:afterUpdate` both fire, the server
119
+ // logs a rebuild, and the page silently keeps rendering the old code. An edit that appears to do
120
+ // nothing is the worst failure mode in a dev loop - worse than a reload, because there is no signal
121
+ // to act on. An adapter that knows its framework is in that position says so, and the entry turns
122
+ // the swallowed update into an honest full reload.
123
+ 'if (import.meta.hot && __adapter.hotUpdateNeedsReload) import.meta.hot.on("bun:afterUpdate", () => location.reload())',
124
+ `const errorRouteIds = new Set(${JSON.stringify(errorRouteIds)})`,
125
+ // Each route is a lazy loader: dynamic imports → Bun.build (splitting) emits one chunk per
126
+ // route, shared layouts/deps deduped into shared chunks, so a route's code loads only when
127
+ // visited. loadModule caches the [layouts…, page] component chain + the chain's meta list per id.
128
+ "const loaders = {",
129
+ ...loaderRows,
130
+ "}",
131
+ "const chains = {}",
132
+ "const metas = {}",
133
+ // routeId → the page module's `searchSchema` export (or undefined). Populated lazily with
134
+ // chains/metas so the mount can derive this route's typed `search` from the URL, matching the SSR.
135
+ "const searchSchemas = {}",
136
+ // routeId → the page's client-only search keys (`searchClientKeys`, or `[]`). The router reads this
137
+ // by reference to decide when a same-route search change can skip the loader fetch (re-render only).
138
+ "const searchClientKeys = {}",
139
+ // routeId → the page's optional post-hydration client loader/action hooks. Hooks are populated only
140
+ // after the route chunk loads, so routes that do not declare them remain ordinary route modules.
141
+ "const routeHooks = {}",
142
+ "const loadModule = async (id) => {",
143
+ " if (chains[id]) return",
144
+ " const mods = await loaders[id]()",
145
+ // For an error route the `_error` module is appended LAST: wrap the page (now second-to-last) in
146
+ // the adapter's boundary so a client render error renders the `_error` UI. DOM-transparent, so the
147
+ // hydrated tree matches the SSR markup (which has no boundary). Falls back to the plain chain when
148
+ // the adapter has no `errorBoundary`.
149
+ // `metas[id]` is the chain's `meta` exports in head order (outermost layout → … → page), so a
150
+ // soft-nav merges the layout chain's head with the page's - matching the SSR `<head>` (sitewide
151
+ // layout tags persist across client navigation, no flash of page-only head). `_error` carries no
152
+ // head (a terminal boundary), so it's excluded from the meta list for error routes.
153
+ " if (errorBoundary && errorRouteIds.has(id)) {",
154
+ " const fallback = mods[mods.length - 1].default",
155
+ " const page = mods[mods.length - 2].default",
156
+ " const layouts = mods.slice(0, mods.length - 2).map((m) => m.default)",
157
+ " chains[id] = [...layouts, errorBoundary(fallback), page]",
158
+ " metas[id] = mods.slice(0, mods.length - 1).map((m) => m.meta)",
159
+ // The search-schema CHAIN is the layout modules + page (all but the appended `_error`), so a layout's
160
+ // `searchSchema` merges with the page's. Client keys come from the page (second-to-last).
161
+ " searchSchemas[id] = mods.slice(0, mods.length - 1).map((m) => m.searchSchema)",
162
+ " searchClientKeys[id] = mods[mods.length - 2].searchClientKeys ?? []",
163
+ " } else {",
164
+ " chains[id] = mods.map((m) => m.default)",
165
+ " metas[id] = mods.map((m) => m.meta)",
166
+ // The chain is every module (layouts + page); client keys come from the page (the last module).
167
+ " searchSchemas[id] = mods.map((m) => m.searchSchema)",
168
+ " searchClientKeys[id] = mods[mods.length - 1].searchClientKeys ?? []",
169
+ " }",
170
+ " const page = errorRouteIds.has(id) ? mods[mods.length - 2] : mods[mods.length - 1]",
171
+ // Keep interception client-safe: only neutral name/mode metadata crosses into the router. The
172
+ // server-side boundary load function (which may close over secrets) is never stored in the client
173
+ // hook table or serialized into a browser bundle by this wiring.
174
+ " const boundaryMods = errorRouteIds.has(id) ? mods.slice(0, -1) : mods",
175
+ " const boundaries = boundaryMods.flatMap((m) => (m.boundaries ?? []).map((b) => ({ name: b.name, mode: b.mode, hasLoad: b.load !== undefined, ...(b.errorId === undefined ? {} : { errorId: b.errorId }) })))",
176
+ " routeHooks[id] = { clientLoader: page.clientLoader, clientAction: page.clientAction, boundaries }",
177
+ "}",
178
+ "const patterns = [",
179
+ ...patternRows,
180
+ "]",
181
+ // Derive the initial route from the URL (correct on refresh/deep-link); fall back to the
182
+ // server-injected route id for non-pattern routes (e.g. _404, which matches nothing).
183
+ "const matched = createMatcher(patterns)(location.pathname)",
184
+ // Map any `{__nifra_deferred: id}` placeholder in the SSR data to the registry's promise, so the
185
+ // component receives real promises to `<Await>` (a no-op when a page has no deferred data).
186
+ MAP_DEFERRED_SOURCE,
187
+ "const initial = {",
188
+ ` routeId: matched ? matched.routeId : (window.${ROUTE_GLOBAL} ?? ""),`,
189
+ " params: matched ? matched.params : {},",
190
+ // pathname + search (NOT just pathname): the SSR render threads `pathname+search` into
191
+ // `useLocation`/`useSearchParams`, so the hydrating initial state must carry the query too or a
192
+ // page reading the search string would hydrate-mismatch. The #hash is client-only (never SSR'd).
193
+ " path: location.pathname + location.search,",
194
+ ` data: mapDeferred(window.${DATA_GLOBAL}),`,
195
+ // Undefined on a page-only app, which is exactly what the router treats as "no layout data".
196
+ ` layoutData: window.${LAYOUT_DATA_GLOBAL} && window.${LAYOUT_DATA_GLOBAL}.map(mapDeferred),`,
197
+ ` boundaries: mapDeferred(window.${BOUNDARY_GLOBAL}),`,
198
+ // actionData (only set after a form POST) is in the initial state so the binding hydrates
199
+ // consistently with the server-rendered markup; mapped through `mapDeferred` too so a deferred
200
+ // action's placeholders become registry markers (a no-op when the action didn't defer).
201
+ ` actionData: mapDeferred(window.${ACTION_GLOBAL}),`,
202
+ " pending: false,",
203
+ "}",
204
+ `const statusRoutes = ${JSON.stringify(statusRoutes)}`,
205
+ "const router = createClientRouter({ patterns, initial, loadModule, statusRoutes, searchClientKeys, routeHooks })",
206
+ "installHistory(router)",
207
+ "installForms(router)",
208
+ // The container is found in the DOM, not baked in. `rootId` is a per-render option and this entry
209
+ // is a per-build artifact, so a hardcoded id is a guess: `renderPage({ rootId: "app" })` produced a
210
+ // perfect document that hydrated NOTHING, with no diagnostic, because the missing `#root` was
211
+ // swallowed by an `if (root)`. `renderPage` marks a custom container with `ROOT_ATTRIBUTE`; the
212
+ // default `#root` it leaves unmarked, which is what the fallback is for.
213
+ `const root = document.querySelector("body > div[${ROOT_ATTRIBUTE}]") ?? document.getElementById("root")`,
214
+ // Loud, because there is no recoverable case: this entry ships only in a document `renderPage`
215
+ // opened a container in, so reaching here means the document is not the one it was built for.
216
+ `if (!root) throw new Error(${JSON.stringify(
217
+ "[nifra/web] no hydration container found. The generated bootstrap mounts into the element " +
218
+ `renderPage opened the app in - the one marked \`${ROOT_ATTRIBUTE}\` when a custom \`rootId\` ` +
219
+ "is set, `#root` otherwise - and this document has neither. If you emit the document yourself, " +
220
+ 'keep `<div id="root">` around the SSR markup.',
221
+ )})`,
222
+ // Load the initial route's chunk, then hydrate the Router (chain is cached). The initial head
223
+ // is server-rendered; subsequent navigations update it from the matched route's meta + data.
224
+ "loadModule(initial.routeId).then(() => {",
225
+ " mountRouter({ router, routes: chains, searchSchemas, container: root })",
226
+ // Run the optional client loader only after the adapter has mounted the SSR tree. The initial
227
+ // server data is already in `window.__NIFRA_DATA__`, so `serverLoader()` reuses it and cannot
228
+ // duplicate the first request.
229
+ " router.hydrate().catch((error) => console.error('[nifra/web] clientLoader failed:', error))",
230
+ // Next frame: the adapter has committed its initial hydration, so handlers are attached. Flip the
231
+ // document to interactive (`data-nifra-hydrated` + `nifra:hydrated`) for apps to gate pre-hydration UI.
232
+ " requestAnimationFrame(signalHydrated)",
233
+ " router.subscribe(() => {",
234
+ " const s = router.snapshot()",
235
+ // Merge the matched route's chain meta (layouts→page) into one head - same contract as SSR.
236
+ " if (!s.pending) {",
237
+ // `origin: location.origin` matches the SSR `originOf(req)` (both are `URL.origin` for the same
238
+ // page URL), so a soft-nav re-resolves the SAME absolute canonical/og:url - no head drift.
239
+ " const args = { data: s.data, params: s.params, origin: location.origin }",
240
+ " applyHead(mergeHeads((metas[s.routeId] ?? [undefined]).map((m) => resolveMeta(m, args))))",
241
+ " }",
242
+ " })",
243
+ "})",
244
+ ].join("\n")}\n`
245
+ }
246
+
247
+ export interface GenerateRouteSearchTypesOptions {
248
+ /** Turn a route source file into a module specifier for a `typeof import(...)` type - the same contract
249
+ * as `generateClientEntry`'s `resolve`, but WITHOUT a file extension (a `.d.ts` imports a module, not a
250
+ * path). Relative to wherever the emitted `.d.ts` is written. */
251
+ readonly resolve: (file: string) => string
252
+ /** The module whose `RouteSearch` interface is augmented (default `"@nifrajs/web"`). Every adapter's
253
+ * `NavigateTarget` reads `@nifrajs/web`'s `RouteSearch`, so the default types `navigate` on all of them. */
254
+ readonly module?: string
255
+ }
256
+
257
+ /**
258
+ * Codegen: emit a `.d.ts` that types cross-route `navigate({ to, search })` against each route's
259
+ * `searchSchema`. It augments the {@link RouteSearch} map with `"<pattern>": SearchOf<typeof import(...)>`
260
+ * for every **static** route (one with no `:param`/`*` segment - a concrete `to` a caller can pass; a
261
+ * dynamic route's concrete path can't match its pattern key, so those keep the loose object form). Written
262
+ * by `nifra sync-routes` next to the route tree and included in the app's `tsconfig`; because it is
263
+ * regenerated from the route files, a `navigate` whose `search` no longer matches the route's schema
264
+ * becomes a `tsc` error - the same drift guarantee as nifra's typed client, no hand-maintained map.
265
+ */
266
+ export function generateRouteSearchTypes(
267
+ manifest: Manifest,
268
+ options: GenerateRouteSearchTypesOptions,
269
+ ): string {
270
+ const { resolve, module = "@nifrajs/web" } = options
271
+ // Static routes only (no `:param` / `*` catch-all): their pattern IS a concrete `to`. Sorted for stable
272
+ // output; a dynamic route falls through to the loose `{ to: string; search?: Record<...> }` form.
273
+ const entries = manifest.routes
274
+ .filter((route) => !/[:*]/.test(route.pattern))
275
+ .map((route) => [route.pattern, resolve(route.file)] as const)
276
+ .sort((a, b) => (a[0] < b[0] ? -1 : 1))
277
+ .map(
278
+ ([pattern, spec]) =>
279
+ ` ${JSON.stringify(pattern)}: SearchOf<typeof import(${JSON.stringify(spec)})>`,
280
+ )
281
+ return `${[
282
+ "// GENERATED by @nifrajs/web generateRouteSearchTypes - typed cross-route navigate({ to, search }).",
283
+ "// Regenerated from the route files; a stale navigate search shape becomes a tsc error. Do not edit.",
284
+ 'import type { SearchOf } from "@nifrajs/web"',
285
+ `declare module ${JSON.stringify(module)} {`,
286
+ " interface RouteSearch {",
287
+ ...entries,
288
+ " }",
289
+ "}",
290
+ "export {}",
291
+ ].join("\n")}\n`
292
+ }
293
+
294
+ export interface GenerateServerManifestOptions {
295
+ /** Turn a route/layout source file (relative to the routes dir) into an import specifier -
296
+ * same contract as `generateClientEntry`'s `resolve`. */
297
+ readonly resolve: (file: string) => string
298
+ /** The content-hashed client entry URL (from `buildClient`'s manifest), **baked** into the emitted
299
+ * module - a disk-less worker can't read `manifest.json` at runtime. */
300
+ readonly clientEntry: string
301
+ /** The app's aggregate stylesheet URLs (`buildClient`'s `BuildManifest.css`) - baked as
302
+ * `export const styles` so the server entry can hand them to `createWebApp` (→ `<link rel="stylesheet">`
303
+ * in `<head>`). Without this the built SSR page ships no CSS link and renders unstyled. */
304
+ readonly styles?: readonly string[] | undefined
305
+ /** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) - baked as
306
+ * `export const routeStyles` so a page links only the CSS its route chain uses. */
307
+ readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
308
+ /** Emit **lazy** per-route loaders (`() => import("./routes/x")`, a static specifier) instead of
309
+ * eager `import * as`, so a bundler with code-splitting emits one chunk per route - loaded on the
310
+ * first request to it, not all at boot (smaller cold-start parse). Default `false` (eager). Both
311
+ * modes are fs-free with statically-analyzable specifiers; only the *when* differs. */
312
+ readonly lazy?: boolean
313
+ }
314
+
315
+ /**
316
+ * Codegen: emit a **server manifest** module (as source) for disk-less edge runtimes (Cloudflare
317
+ * Workers, …) - and, with a `target`, any portable server bundle. `discoverRoutes` scans `node:fs`
318
+ * and dynamic-imports each route by a *runtime* path - neither exists on workerd. This instead emits
319
+ * **statically-analyzable** imports of every route/layout/`_error`/terminal status page (so the bundler includes them) and
320
+ * rebuilds the manifest with `buildManifest` - the SAME pure logic `discoverRoutes` feeds, so patterns
321
+ * + layout chains are identical. Eager (`import * as`) by default; `lazy` emits `() => import(...)` so
322
+ * a code-splitting bundler chunks per route. The emitted module exports `manifest` (consumed by
323
+ * `createWebApp`, unchanged) + `clientEntry` (baked). Write it to a file and bundle it into the worker
324
+ * entry (see `buildServer` in `@nifrajs/web/build`).
325
+ */
326
+ // Route source files are emitted as EXTENSIONLESS import specifiers. An explicit `.tsx`/`.ts` in an
327
+ // import is a hard error under a plain `tsc` (TS5097) unless `allowImportingTsExtensions` is set - which
328
+ // only the nifra template tsconfig does - so the generated manifest failed a bare `tsc` with one error
329
+ // per route. Every bundler and `moduleResolution` still resolves the extensionless form to the source
330
+ // file, so dropping the extension costs nothing at build time and makes the output portable. Only a
331
+ // known TS/JS source extension is stripped; the map KEY keeps its extension (it is the route identifier
332
+ // `buildManifest` matches, not an import path).
333
+ const SOURCE_EXTENSION = /\.(?:[mc]?tsx?|[mc]?jsx?)$/
334
+ function moduleSpecifier(resolved: string): string {
335
+ return resolved.replace(SOURCE_EXTENSION, "")
336
+ }
337
+
338
+ export function generateServerManifest(
339
+ manifest: Manifest,
340
+ options: GenerateServerManifestOptions,
341
+ ): string {
342
+ const { resolve, clientEntry, styles, routeStyles, lazy = false } = options
343
+ // Every unique source file in the manifest (routes + layouts + error/status pages), sorted for stable output.
344
+ const files = [
345
+ ...new Set([
346
+ ...manifest.routes.map((r) => r.file),
347
+ ...Object.values(manifest.layouts).map((l) => l.file),
348
+ ...Object.values(manifest.errors ?? {}).map((e) => e.file),
349
+ ...(manifest.notFound ? [manifest.notFound.file] : []),
350
+ ...Object.values(manifest.statusPages ?? {}).map((page) => page.file),
351
+ ]),
352
+ ].sort()
353
+ const header = [
354
+ "// GENERATED by @nifrajs/web generateServerManifest - route manifest for the disk-less edge",
355
+ "// (no filesystem; route imports are static specifiers the bundler resolves). buildManifest is",
356
+ "// the same pure logic discoverRoutes feeds, so patterns + layout chains match exactly.",
357
+ 'import { buildManifest, type RouteModule } from "@nifrajs/web"',
358
+ ]
359
+ const clientEntryLine = `export const clientEntry = ${JSON.stringify(clientEntry)}`
360
+ // Baked stylesheet URLs so the server entry can pass them to createWebApp → <link rel="stylesheet">.
361
+ // Always emitted (default empty) so consumers can `import { styles, routeStyles }` unconditionally.
362
+ const stylesLine = `export const styles = ${JSON.stringify(styles ?? [])}`
363
+ const routeStylesLine = `export const routeStyles = ${JSON.stringify(routeStyles ?? {})}`
364
+ if (lazy) {
365
+ // Lazy: `() => import("./routes/x")` per route (static specifier → one chunk per route under a
366
+ // code-splitting bundler, loaded on first request). The map keys are the route-relative paths
367
+ // `buildManifest` expects; the importer it builds calls the per-file loader.
368
+ const loaders = files.map(
369
+ (file) =>
370
+ ` ${jsStringLiteral(file)}: () => import(${jsStringLiteral(moduleSpecifier(resolve(file)))}),`,
371
+ )
372
+ return `${[
373
+ ...header,
374
+ "const loaders: Record<string, () => Promise<RouteModule>> = {",
375
+ ...loaders,
376
+ "}",
377
+ clientEntryLine,
378
+ stylesLine,
379
+ routeStylesLine,
380
+ "export const manifest = buildManifest(Object.keys(loaders), (file) => () => loaders[file]())",
381
+ ].join("\n")}\n`
382
+ }
383
+ // Eager: `import * as` per route (all bundled into the entry, parsed at boot). Index-based
384
+ // identifiers are collision-proof regardless of filename.
385
+ const imports = files.map(
386
+ (file, i) => `import * as m${i} from ${JSON.stringify(moduleSpecifier(resolve(file)))}`,
387
+ )
388
+ const entries = files.map((file, i) => ` ${JSON.stringify(file)}: m${i},`)
389
+ return `${[
390
+ ...header,
391
+ ...imports,
392
+ "const modules: Record<string, RouteModule> = {",
393
+ ...entries,
394
+ "}",
395
+ clientEntryLine,
396
+ stylesLine,
397
+ routeStylesLine,
398
+ "export const manifest = buildManifest(Object.keys(modules), (file) => () => Promise.resolve(modules[file]))",
399
+ ].join("\n")}\n`
400
+ }
@@ -21,10 +21,28 @@ import type {
21
21
  UnsafeScriptDescriptor,
22
22
  } from "../manifest.ts"
23
23
 
24
+ // Server head serialization may memoize only objects that came exclusively from a static meta export.
25
+ // A dynamic function is allowed to reuse and mutate one object, including an object another route also
26
+ // exports statically, so identity alone does not prove cacheability. Once observed dynamically, an
27
+ // object stays uncacheable: losing a small static optimization is safer than cross-request stale data.
28
+ const STATIC_META = new WeakSet<Meta>()
29
+ const DYNAMIC_META = new WeakSet<Meta>()
30
+
31
+ /** @internal Whether `resolveMeta` observed this object as a static export. */
32
+ export function isStaticMeta(meta: Meta): boolean {
33
+ return STATIC_META.has(meta) && !DYNAMIC_META.has(meta)
34
+ }
35
+
24
36
  /** Resolve a route's `meta` export - a static object, or a function of the route's data/params. */
25
37
  export function resolveMeta(meta: MetaInput | undefined, args: MetaArgs): Meta {
26
38
  if (meta === undefined) return {}
27
- return typeof meta === "function" ? meta(args) : meta
39
+ if (typeof meta === "function") {
40
+ const resolved = meta(args)
41
+ DYNAMIC_META.add(resolved)
42
+ return resolved
43
+ }
44
+ STATIC_META.add(meta)
45
+ return meta
28
46
  }
29
47
 
30
48
  /**