@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.
- package/LICENSE +21 -0
- package/README.md +18 -0
- package/css-modules.d.ts +34 -0
- package/dist/build.d.ts +110 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +238 -0
- package/dist/build.js.map +1 -0
- package/dist/client.d.ts +32 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +204 -0
- package/dist/client.js.map +1 -0
- package/dist/deferred.d.ts +90 -0
- package/dist/deferred.d.ts.map +1 -0
- package/dist/deferred.js +310 -0
- package/dist/deferred.js.map +1 -0
- package/dist/dev-error.d.ts +17 -0
- package/dist/dev-error.d.ts.map +1 -0
- package/dist/dev-error.js +59 -0
- package/dist/dev-error.js.map +1 -0
- package/dist/dev.d.ts +25 -0
- package/dist/dev.d.ts.map +1 -0
- package/dist/dev.js +83 -0
- package/dist/dev.js.map +1 -0
- package/dist/draft.d.ts +58 -0
- package/dist/draft.d.ts.map +1 -0
- package/dist/draft.js +55 -0
- package/dist/draft.js.map +1 -0
- package/dist/fonts-google.d.ts +109 -0
- package/dist/fonts-google.d.ts.map +1 -0
- package/dist/fonts-google.js +291 -0
- package/dist/fonts-google.js.map +1 -0
- package/dist/fonts.d.ts +67 -0
- package/dist/fonts.d.ts.map +1 -0
- package/dist/fonts.js +103 -0
- package/dist/fonts.js.map +1 -0
- package/dist/fs.d.ts +10 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +24 -0
- package/dist/fs.js.map +1 -0
- package/dist/index.d.ts +280 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +784 -0
- package/dist/index.js.map +1 -0
- package/dist/islands.d.ts +37 -0
- package/dist/islands.d.ts.map +1 -0
- package/dist/islands.js +107 -0
- package/dist/islands.js.map +1 -0
- package/dist/isr.d.ts +154 -0
- package/dist/isr.d.ts.map +1 -0
- package/dist/isr.js +331 -0
- package/dist/isr.js.map +1 -0
- package/dist/manifest.d.ts +178 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +213 -0
- package/dist/manifest.js.map +1 -0
- package/dist/plugins/css-modules.d.ts +40 -0
- package/dist/plugins/css-modules.d.ts.map +1 -0
- package/dist/plugins/css-modules.js +0 -0
- package/dist/plugins/css-modules.js.map +1 -0
- package/dist/plugins/kit.d.ts +44 -0
- package/dist/plugins/kit.d.ts.map +1 -0
- package/dist/plugins/kit.js +105 -0
- package/dist/plugins/kit.js.map +1 -0
- package/dist/plugins/scss.d.ts +27 -0
- package/dist/plugins/scss.d.ts.map +1 -0
- package/dist/plugins/scss.js +78 -0
- package/dist/plugins/scss.js.map +1 -0
- package/dist/prerender.d.ts +77 -0
- package/dist/prerender.d.ts.map +1 -0
- package/dist/prerender.js +139 -0
- package/dist/prerender.js.map +1 -0
- package/dist/query.d.ts +62 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +184 -0
- package/dist/query.js.map +1 -0
- package/dist/router.d.ts +169 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +531 -0
- package/dist/router.js.map +1 -0
- package/dist/vite.d.ts +49 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +144 -0
- package/dist/vite.js.map +1 -0
- package/package.json +127 -0
- package/src/build.ts +375 -0
- package/src/client.ts +218 -0
- package/src/deferred.ts +339 -0
- package/src/dev-error.ts +62 -0
- package/src/dev.ts +108 -0
- package/src/draft.ts +82 -0
- package/src/fonts-google.ts +397 -0
- package/src/fonts.ts +147 -0
- package/src/fs.ts +26 -0
- package/src/index.ts +1202 -0
- package/src/islands.ts +121 -0
- package/src/isr.ts +461 -0
- package/src/manifest.ts +366 -0
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/kit.ts +123 -0
- package/src/plugins/scss.ts +108 -0
- package/src/prerender.ts +209 -0
- package/src/query.ts +250 -0
- package/src/router.ts +676 -0
- 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("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function escapeAttr(value: string): string {
|
|
616
|
+
return value
|
|
617
|
+
.replaceAll("&", "&")
|
|
618
|
+
.replaceAll('"', """)
|
|
619
|
+
.replaceAll("<", "<")
|
|
620
|
+
.replaceAll(">", ">")
|
|
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
|
+
}
|