@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.
- package/dist/build-plan.d.ts +228 -0
- package/dist/build-plan.d.ts.map +1 -0
- package/dist/build-plan.js +514 -0
- package/dist/build-plan.js.map +1 -0
- package/dist/build-vite.d.ts +2 -1
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +2 -197
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +61 -502
- package/dist/build.js.map +1 -1
- package/dist/chunk-names.d.ts.map +1 -1
- package/dist/chunk-names.js +3 -2
- package/dist/chunk-names.js.map +1 -1
- package/dist/dev-ssr-graph.d.ts.map +1 -1
- package/dist/dev-ssr-graph.js +63 -28
- package/dist/dev-ssr-graph.js.map +1 -1
- package/dist/diagnostic.d.ts.map +1 -1
- package/dist/diagnostic.js +9 -2
- package/dist/diagnostic.js.map +1 -1
- package/dist/fonts-google.d.ts.map +1 -1
- package/dist/fonts-google.js +3 -1
- package/dist/fonts-google.js.map +1 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +2 -1
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +15 -609
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -1935
- package/dist/index.js.map +1 -1
- package/dist/internal/codegen.d.ts +68 -0
- package/dist/internal/codegen.d.ts.map +1 -0
- package/dist/internal/codegen.js +317 -0
- package/dist/internal/codegen.js.map +1 -0
- package/dist/internal/head-merge.d.ts +2 -0
- package/dist/internal/head-merge.d.ts.map +1 -1
- package/dist/internal/head-merge.js +17 -1
- package/dist/internal/head-merge.js.map +1 -1
- package/dist/internal/parity.d.ts +7 -0
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +212 -30
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/render-document.d.ts +372 -0
- package/dist/internal/render-document.d.ts.map +1 -0
- package/dist/internal/render-document.js +744 -0
- package/dist/internal/render-document.js.map +1 -0
- package/dist/internal/runtime-contract.d.ts +48 -0
- package/dist/internal/runtime-contract.d.ts.map +1 -0
- package/dist/internal/runtime-contract.js +27 -0
- package/dist/internal/runtime-contract.js.map +1 -0
- package/dist/internal/web-app.d.ts +171 -0
- package/dist/internal/web-app.d.ts.map +1 -0
- package/dist/internal/web-app.js +844 -0
- package/dist/internal/web-app.js.map +1 -0
- package/dist/islands.d.ts +48 -0
- package/dist/islands.d.ts.map +1 -1
- package/dist/islands.js +79 -0
- package/dist/islands.js.map +1 -1
- package/dist/module-graph.d.ts.map +1 -1
- package/dist/module-graph.js +22 -9
- package/dist/module-graph.js.map +1 -1
- package/dist/nano.d.ts +125 -0
- package/dist/nano.d.ts.map +1 -0
- package/dist/nano.js +190 -0
- package/dist/nano.js.map +1 -0
- package/dist/plugins/css-modules.js +2 -2
- package/dist/plugins/css-modules.js.map +1 -1
- package/dist/plugins/kit.d.ts +12 -0
- package/dist/plugins/kit.d.ts.map +1 -1
- package/dist/plugins/kit.js +62 -12
- package/dist/plugins/kit.js.map +1 -1
- package/dist/plugins/postcss.js +2 -2
- package/dist/plugins/postcss.js.map +1 -1
- package/dist/plugins/scss.js +2 -2
- package/dist/plugins/scss.js.map +1 -1
- package/dist/plugins/stylex.d.ts +70 -0
- package/dist/plugins/stylex.d.ts.map +1 -0
- package/dist/plugins/stylex.js +166 -0
- package/dist/plugins/stylex.js.map +1 -0
- package/dist/plugins/svg.js +2 -2
- package/dist/plugins/svg.js.map +1 -1
- package/dist/router.d.ts +1 -1
- package/dist/router.js +23 -23
- package/dist/router.js.map +1 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +26 -10
- package/dist/vite.js.map +1 -1
- package/package.json +39 -3
- package/src/build-plan.ts +741 -0
- package/src/build-vite.ts +1 -4
- package/src/build.ts +84 -716
- package/src/chunk-names.ts +3 -2
- package/src/dev-ssr-graph.ts +63 -28
- package/src/diagnostic.ts +14 -2
- package/src/fonts-google.ts +3 -1
- package/src/fs.ts +2 -1
- package/src/index.ts +73 -2705
- package/src/internal/codegen.ts +400 -0
- package/src/internal/head-merge.ts +19 -1
- package/src/internal/parity.ts +215 -35
- package/src/internal/render-document.ts +1116 -0
- package/src/internal/runtime-contract.ts +54 -0
- package/src/internal/web-app.ts +1114 -0
- package/src/islands.ts +85 -0
- package/src/module-graph.ts +23 -9
- package/src/nano.ts +246 -0
- package/src/plugins/css-modules.ts +2 -2
- package/src/plugins/kit.ts +65 -12
- package/src/plugins/postcss.ts +2 -2
- package/src/plugins/scss.ts +2 -2
- package/src/plugins/stylex.ts +298 -0
- package/src/plugins/svg.ts +2 -2
- package/src/router.ts +24 -24
- 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
|
-
|
|
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
|
/**
|