@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
package/src/index.ts
CHANGED
|
@@ -8,83 +8,6 @@
|
|
|
8
8
|
* of DOM types: it is pure server orchestration + string building.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
12
|
-
type BackendMount,
|
|
13
|
-
type BackendMountHandler,
|
|
14
|
-
NIFRA_BACKEND_MOUNT,
|
|
15
|
-
} from "@nifrajs/core/mount"
|
|
16
|
-
import {
|
|
17
|
-
isSameOriginPath,
|
|
18
|
-
type ResponseResult,
|
|
19
|
-
type ServerOptions,
|
|
20
|
-
server,
|
|
21
|
-
status as statusResult,
|
|
22
|
-
type UrlParts,
|
|
23
|
-
urlPartsOf,
|
|
24
|
-
} from "@nifrajs/core/server"
|
|
25
|
-
import type { BoundaryRegistration } from "./boundary.ts"
|
|
26
|
-
import {
|
|
27
|
-
type BoundaryRequestCtx,
|
|
28
|
-
type BoundaryStates,
|
|
29
|
-
boundaryDescriptors,
|
|
30
|
-
resolveStaticBoundaries,
|
|
31
|
-
startDynamicBoundaries,
|
|
32
|
-
} from "./boundary.ts"
|
|
33
|
-
import {
|
|
34
|
-
DEFERRED_ERROR_CODE,
|
|
35
|
-
DEFERRED_RUNTIME,
|
|
36
|
-
type Deferred,
|
|
37
|
-
defer,
|
|
38
|
-
MAP_DEFERRED_SOURCE,
|
|
39
|
-
ndjsonStream,
|
|
40
|
-
prepareDeferred,
|
|
41
|
-
} from "./deferred.ts"
|
|
42
|
-
import { isDraftEnabled } from "./draft.ts"
|
|
43
|
-
import { trustedHeadAttributes } from "./internal/head-attributes.ts"
|
|
44
|
-
// Head resolution/merging. Defined in an internal module so `@nifrajs/web/client` can export the same
|
|
45
|
-
// two functions to the generated client entry without dragging THIS module (and its server code) into
|
|
46
|
-
// the browser graph - see internal/head-merge.ts. Re-exported here so the public API is unchanged.
|
|
47
|
-
import { mergeHeads, resolveMeta } from "./internal/head-merge.ts"
|
|
48
|
-
import { jsStringLiteral } from "./internal/js-string.ts"
|
|
49
|
-
import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./internal/script-types.ts"
|
|
50
|
-
import { ISR_REVALIDATE_HEADER, ISR_REVALIDATE_TAGS_HEADER, serializeISRTags } from "./isr.ts"
|
|
51
|
-
import { generateLlmsTxt } from "./llms-txt.ts"
|
|
52
|
-
import type {
|
|
53
|
-
LayoutEntry,
|
|
54
|
-
Loader as LayoutLoader,
|
|
55
|
-
LinkDescriptor,
|
|
56
|
-
LoaderContext,
|
|
57
|
-
Manifest,
|
|
58
|
-
Meta,
|
|
59
|
-
MetaArgs,
|
|
60
|
-
MetaDescriptor,
|
|
61
|
-
MetaInput,
|
|
62
|
-
RouteEntry,
|
|
63
|
-
RouteModule,
|
|
64
|
-
ScriptDescriptor,
|
|
65
|
-
UnsafeScriptDescriptor,
|
|
66
|
-
} from "./manifest.ts"
|
|
67
|
-
import type { RenderAdapter, RenderProps } from "./render-seam.ts"
|
|
68
|
-
import {
|
|
69
|
-
ACTION_GLOBAL,
|
|
70
|
-
BOUNDARY_GLOBAL,
|
|
71
|
-
DATA_GLOBAL,
|
|
72
|
-
LAYOUT_DATA_GLOBAL,
|
|
73
|
-
ROOT_ATTRIBUTE,
|
|
74
|
-
ROUTE_GLOBAL,
|
|
75
|
-
} from "./render-seam.ts"
|
|
76
|
-
import {
|
|
77
|
-
createMatcher,
|
|
78
|
-
DATA_HEADER,
|
|
79
|
-
NAV_FROM_HEADER,
|
|
80
|
-
PRERENDERED_GLOBAL,
|
|
81
|
-
REDIRECT_HEADER,
|
|
82
|
-
RETAIN_HEADER,
|
|
83
|
-
REVALIDATE_HEADER,
|
|
84
|
-
STATUS_HEADER,
|
|
85
|
-
} from "./router.ts"
|
|
86
|
-
import { searchOf, searchOfChain } from "./search.ts"
|
|
87
|
-
|
|
88
11
|
// Draft / preview mode - a signed cookie that flips `ctx.draft` for loaders + bypasses ISR for editors.
|
|
89
12
|
export {
|
|
90
13
|
assertStaticBoundaryImports,
|
|
@@ -113,6 +36,13 @@ export {
|
|
|
113
36
|
type StaticCtx,
|
|
114
37
|
startDynamicBoundaries,
|
|
115
38
|
} from "./boundary.ts"
|
|
39
|
+
export {
|
|
40
|
+
assertRenderAdapterConformance,
|
|
41
|
+
RenderAdapterConformanceError,
|
|
42
|
+
type RenderAdapterConformanceFixture,
|
|
43
|
+
} from "./conformance.ts"
|
|
44
|
+
// Deferred loader data (`defer()` + the `Deferred<T>` type) - consumed by the adapter's `<Await>`.
|
|
45
|
+
export { type Deferred, defer } from "./deferred.ts"
|
|
116
46
|
export {
|
|
117
47
|
DRAFT_COOKIE,
|
|
118
48
|
type DraftCookieControls,
|
|
@@ -132,6 +62,59 @@ export {
|
|
|
132
62
|
fontFace,
|
|
133
63
|
fontPreload,
|
|
134
64
|
} from "./fonts.ts"
|
|
65
|
+
export {
|
|
66
|
+
type GenerateClientEntryOptions,
|
|
67
|
+
type GenerateRouteSearchTypesOptions,
|
|
68
|
+
type GenerateServerManifestOptions,
|
|
69
|
+
generateClientEntry,
|
|
70
|
+
generateRouteSearchTypes,
|
|
71
|
+
generateServerManifest,
|
|
72
|
+
} from "./internal/codegen.ts"
|
|
73
|
+
// Generated client entries intentionally import the browser router from the client subpath:
|
|
74
|
+
// `import { createClientRouter, createMatcher, mergeHeads, resolveMeta } from "@nifrajs/web/client"`.
|
|
75
|
+
export { mergeHeads, resolveMeta } from "./internal/head-merge.ts"
|
|
76
|
+
export {
|
|
77
|
+
canonical,
|
|
78
|
+
gone,
|
|
79
|
+
jsonLd,
|
|
80
|
+
notFound,
|
|
81
|
+
type OpenGraphInput,
|
|
82
|
+
openGraph,
|
|
83
|
+
type RedirectOptions,
|
|
84
|
+
type RenderAssemblyCache,
|
|
85
|
+
type RenderedPage,
|
|
86
|
+
type RenderPageInput,
|
|
87
|
+
type RenderPageOptions,
|
|
88
|
+
type RevalidateResult,
|
|
89
|
+
redirect,
|
|
90
|
+
renderPage,
|
|
91
|
+
renderPageResult,
|
|
92
|
+
revalidate,
|
|
93
|
+
type StatusPageOptions,
|
|
94
|
+
serializeData,
|
|
95
|
+
statusPage,
|
|
96
|
+
unsafeInlineScript,
|
|
97
|
+
} from "./internal/render-document.ts"
|
|
98
|
+
export {
|
|
99
|
+
DEFAULT_DEV_PORT,
|
|
100
|
+
PRE_HYDRATION_GUARD,
|
|
101
|
+
type ServerOnly,
|
|
102
|
+
} from "./internal/runtime-contract.ts"
|
|
103
|
+
/**
|
|
104
|
+
* The two file-name conventions that decide what never reaches a browser: a `*.server` module is
|
|
105
|
+
* EMPTIED in the client build, a `*.fn` module is REPLACED with client stubs.
|
|
106
|
+
*
|
|
107
|
+
* Exported because more than the bundlers need them. `nifra dev --bun` cannot transform (Bun's dev
|
|
108
|
+
* bundler takes no plugins) so it must REFUSE instead, and a refusal driven by its own hand-written
|
|
109
|
+
* glob drifts from the transform - which is how `.fn.mts` came to be stubbed by both build pipelines
|
|
110
|
+
* and waved through by the guard that exists to stop it leaking. Anything deciding "is this module
|
|
111
|
+
* server-only" should import the matcher rather than re-encode it.
|
|
112
|
+
*/
|
|
113
|
+
export { SERVER_FN_MODULE, SERVER_ONLY_MODULE } from "./internal/server-boundary.ts"
|
|
114
|
+
export {
|
|
115
|
+
type CreateWebAppOptions,
|
|
116
|
+
createWebApp,
|
|
117
|
+
} from "./internal/web-app.ts"
|
|
135
118
|
// ISR (incremental static regeneration): a pluggable cache store + the `withISR` stale-while-revalidate
|
|
136
119
|
// wrapper for rendered SSR responses.
|
|
137
120
|
export {
|
|
@@ -234,6 +217,19 @@ export {
|
|
|
234
217
|
type QueryState,
|
|
235
218
|
type QueryStatus,
|
|
236
219
|
} from "./query.ts"
|
|
220
|
+
export {
|
|
221
|
+
ACTION_GLOBAL,
|
|
222
|
+
BOUNDARY_GLOBAL,
|
|
223
|
+
DATA_GLOBAL,
|
|
224
|
+
LAYOUT_DATA_GLOBAL,
|
|
225
|
+
type RenderAdapter,
|
|
226
|
+
type RenderProps,
|
|
227
|
+
ROOT_ATTRIBUTE,
|
|
228
|
+
ROUTE_GLOBAL,
|
|
229
|
+
type SsrModuleLoader,
|
|
230
|
+
setSsrModuleLoader,
|
|
231
|
+
ssrModuleLoader,
|
|
232
|
+
} from "./render-seam.ts"
|
|
237
233
|
// Agnostic client-side router core (pure + DOM-free) - consumed by per-adapter Router bindings.
|
|
238
234
|
// `DATA_HEADER` marks a navigation's data-only GET; `createWebApp` answers it with loader JSON.
|
|
239
235
|
export {
|
|
@@ -257,2636 +253,8 @@ export {
|
|
|
257
253
|
type Submission,
|
|
258
254
|
type SubmitOptions,
|
|
259
255
|
} from "./router.ts"
|
|
260
|
-
// Deferred loader data (`defer()` + the `Deferred<T>` type) - consumed by the adapter's `<Await>`.
|
|
261
|
-
export { type Deferred, defer, mergeHeads, resolveMeta }
|
|
262
|
-
|
|
263
|
-
/** Phantom brand key for {@link ServerOnly}. A unique symbol so the brand can't be forged by a plain
|
|
264
|
-
* object literal; `unique symbol` keys never appear in the emitted JS, so the brand is purely
|
|
265
|
-
* type-level (zero runtime cost). The property is optional + `never`-typed, so a `ServerOnly<T>`
|
|
266
|
-
* stays assignment-compatible with `T` - it documents intent without obstructing real use. */
|
|
267
|
-
declare const SERVER_ONLY_BRAND: unique symbol
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Type-level intent marker for a value that must only exist on the server - a secret, a DB handle, a
|
|
271
|
-
* server-only client. `ServerOnly<T>` is structurally `T` (the brand is an optional phantom field, so
|
|
272
|
-
* existing code keeps type-checking), but it advertises to readers + the compiler that the value is
|
|
273
|
-
* not meant to cross to the browser. It is **purely type-level** and erases at build - it does NOT,
|
|
274
|
-
* by itself, keep the value out of the client bundle.
|
|
275
|
-
*
|
|
276
|
-
* The enforcement is two runtime conventions:
|
|
277
|
-
* - add the side-effect import `import "@nifrajs/web/server-only"` at the top of the module - the
|
|
278
|
-
* client build ({@link buildClient}) fails loud, with the import chain, if it reaches a browser
|
|
279
|
-
* chunk (the poison-import marker);
|
|
280
|
-
* - or name the file `*.server.ts` - the `.server` convention empties it in the client build.
|
|
281
|
-
*
|
|
282
|
-
* Use this brand to express the intent in the types; pair it with one of those runtime markers so a
|
|
283
|
-
* leak is caught at build time rather than shipping a secret to the client.
|
|
284
|
-
*
|
|
285
|
-
* @example
|
|
286
|
-
* import "@nifrajs/web/server-only"
|
|
287
|
-
* import type { ServerOnly } from "@nifrajs/web"
|
|
288
|
-
* export const apiKey: ServerOnly<string> = process.env.SECRET_API_KEY!
|
|
289
|
-
*/
|
|
290
|
-
export type ServerOnly<T> = T & { readonly [SERVER_ONLY_BRAND]?: never }
|
|
291
|
-
|
|
292
|
-
export {
|
|
293
|
-
assertRenderAdapterConformance,
|
|
294
|
-
RenderAdapterConformanceError,
|
|
295
|
-
type RenderAdapterConformanceFixture,
|
|
296
|
-
} from "./conformance.ts"
|
|
297
|
-
export {
|
|
298
|
-
ACTION_GLOBAL,
|
|
299
|
-
BOUNDARY_GLOBAL,
|
|
300
|
-
DATA_GLOBAL,
|
|
301
|
-
LAYOUT_DATA_GLOBAL,
|
|
302
|
-
type RenderAdapter,
|
|
303
|
-
type RenderProps,
|
|
304
|
-
ROOT_ATTRIBUTE,
|
|
305
|
-
ROUTE_GLOBAL,
|
|
306
|
-
type SsrModuleLoader,
|
|
307
|
-
setSsrModuleLoader,
|
|
308
|
-
ssrModuleLoader,
|
|
309
|
-
} from "./render-seam.ts"
|
|
310
|
-
|
|
311
256
|
// The search-derivation primitive: parse a URL query, validate it against a route's `searchSchema`
|
|
312
257
|
// (fail-closed to defaults), or return the raw parsed query when there is none. Both the server (loader
|
|
313
258
|
// ctx + `renderPage`) and a client adapter mount call this with the same URL + schema, so the two sides
|
|
314
259
|
// produce the identical value by construction. An adapter's `useSearch` binding reads its result.
|
|
315
260
|
export { type SearchOf, searchOf, searchOfChain, serializeSearch } from "./search.ts"
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Pre-hydration form guard - a tiny inline script flushed in `<head>` (it runs in the window between
|
|
319
|
-
* first paint and the island bundle taking over). It neutralizes the one real hydration footgun: a
|
|
320
|
-
* JS-only form (a hand-wired `onSubmit` with no native fallback) submitting *natively* before its
|
|
321
|
-
* handler is attached, which navigates the browser to a broken `?field=…` GET of the current page.
|
|
322
|
-
*
|
|
323
|
-
* It blocks ONLY that shape - an effective-GET form whose action targets the current document - and
|
|
324
|
-
* only until hydration commits (`data-nifra-hydrated`, set by the client entry). Method-`post` forms
|
|
325
|
-
* (progressive enhancement: the native POST hits the route `action`) and GET forms with a real action
|
|
326
|
-
* (a search box → `/search`) pass through untouched. Opt out per-form with `data-native`. Static text
|
|
327
|
-
* (no interpolation), so it can't carry injected markup. See the Hydration guide.
|
|
328
|
-
*/
|
|
329
|
-
export const PRE_HYDRATION_GUARD =
|
|
330
|
-
"(function(){addEventListener('submit',function(e){var f=e.target;" +
|
|
331
|
-
"if(!f||f.tagName!=='FORM'||f.hasAttribute('data-native'))return;" +
|
|
332
|
-
"if(document.documentElement.hasAttribute('data-nifra-hydrated'))return;" +
|
|
333
|
-
"var m=(f.getAttribute('method')||'get').toLowerCase();if(m==='post')return;" +
|
|
334
|
-
"var a=f.getAttribute('action');" +
|
|
335
|
-
"if(!a||a===''||a==='#'||a===location.pathname||a===location.href)e.preventDefault()" +
|
|
336
|
-
"},true)})()"
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* The single default port for the dev server (`@nifrajs/web/dev`, `@nifrajs/web/vite`) **and**
|
|
340
|
-
* `nifra start`. Deliberately uncommon: `3000`/`5173`/`8080` collide with whatever else is running
|
|
341
|
-
* (Next, Vite, a stray Node API). `4321` rarely is - and being the *same* constant across `nifra dev`
|
|
342
|
-
* and `nifra start` means a project's URL doesn't change between commands. Override per-run with
|
|
343
|
-
* `--port <n>` or the `PORT` env var. */
|
|
344
|
-
export const DEFAULT_DEV_PORT = 4321
|
|
345
|
-
|
|
346
|
-
// Shared, stateless - allocated once at module load, not per render/stream.
|
|
347
|
-
const TEXT_ENCODER = new TextEncoder()
|
|
348
|
-
|
|
349
|
-
type MaybePromise<T> = T | Promise<T>
|
|
350
|
-
|
|
351
|
-
// XSS-safe `<script>` JSON escaping in a SINGLE pass: `<`/`>` would break out of the
|
|
352
|
-
// script element; U+2028/U+2029 are valid JSON but historically break JS string literals. Built via
|
|
353
|
-
// fromCharCode so no raw separator chars live in this source. One regex + one output string replaces
|
|
354
|
-
// the prior four sequential `replaceAll` full-string passes.
|
|
355
|
-
const LINE_SEP = String.fromCharCode(0x2028)
|
|
356
|
-
const PARA_SEP = String.fromCharCode(0x2029)
|
|
357
|
-
const SCRIPT_ESCAPE = new RegExp(`[<>${LINE_SEP}${PARA_SEP}]`, "g")
|
|
358
|
-
const SCRIPT_ESCAPE_MAP: Readonly<Record<string, string>> = {
|
|
359
|
-
"<": "\\u003c",
|
|
360
|
-
">": "\\u003e",
|
|
361
|
-
[LINE_SEP]: "\\u2028",
|
|
362
|
-
[PARA_SEP]: "\\u2029",
|
|
363
|
-
}
|
|
364
|
-
const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body")
|
|
365
|
-
const RESPONSE_RESULT = Symbol.for("nifra.response.result")
|
|
366
|
-
|
|
367
|
-
/**
|
|
368
|
-
* A control-flow value that renders as plain data - what `redirect()` returns, and what core's
|
|
369
|
-
* `status(...)` returns. Recognized here rather than imported: core keeps its own predicate internal,
|
|
370
|
-
* and the registry symbol is the contract between them (two copies of core must still agree).
|
|
371
|
-
*/
|
|
372
|
-
const isResponseResult = (value: unknown): value is ResponseResult =>
|
|
373
|
-
typeof value === "object" &&
|
|
374
|
-
value !== null &&
|
|
375
|
-
(value as { readonly [RESPONSE_RESULT]?: unknown })[RESPONSE_RESULT] === true &&
|
|
376
|
-
typeof (value as { readonly toResponse?: unknown }).toResponse === "function"
|
|
377
|
-
|
|
378
|
-
/** A returned/thrown value that ends the request as-is: either shape of control-flow signal. */
|
|
379
|
-
const isControlFlow = (value: unknown): value is Response | ResponseResult =>
|
|
380
|
-
value instanceof Response || isResponseResult(value)
|
|
381
|
-
|
|
382
|
-
export interface RenderedPage {
|
|
383
|
-
readonly [RESPONSE_RESULT]: true
|
|
384
|
-
toResponse(): Response
|
|
385
|
-
toNodeBody?(): {
|
|
386
|
-
readonly status: number
|
|
387
|
-
readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
|
|
388
|
-
readonly body: string | Uint8Array
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
export interface RenderPageOptions {
|
|
393
|
-
readonly adapter: RenderAdapter
|
|
394
|
-
/** The layout chain to render - outermost layout → page (opaque; the adapter renders it). */
|
|
395
|
-
readonly chain: readonly unknown[]
|
|
396
|
-
/** The loader output for this request. */
|
|
397
|
-
readonly data: unknown
|
|
398
|
-
/** An action's data return (POST only) - surfaced to the page as `actionData` + serialized
|
|
399
|
-
* for the client so post-POST hydration matches. Omit on GETs. */
|
|
400
|
-
readonly actionData?: unknown
|
|
401
|
-
/**
|
|
402
|
-
* URL of the built client entry (loaded as a module script).
|
|
403
|
-
*
|
|
404
|
-
* Optional ONLY on a `hydrate: false` page, where there is no client takeover to feed and this value
|
|
405
|
-
* reaches no output at all. The union below is what enforces that: omitting it on a hydrating page
|
|
406
|
-
* stays a compile error rather than rendering `src=""`.
|
|
407
|
-
*/
|
|
408
|
-
readonly clientEntry?: string
|
|
409
|
-
/** Chunk URLs for the **matched** route (its layout chain + own chunk) to `modulepreload` in the
|
|
410
|
-
* shell - so the route code downloads in parallel with the entry instead of after it
|
|
411
|
-
* (`buildClient`'s per-route map). Empty/omitted ⇒ only the entry is preloaded (unchanged). */
|
|
412
|
-
readonly preload?: readonly string[]
|
|
413
|
-
/** Stylesheet URLs for the **matched** route (its layout chain + own CSS, from `buildClient`'s
|
|
414
|
-
* `BuildManifest.css`) - injected as `<link rel="stylesheet">` in `<head>` so styles arrive with the
|
|
415
|
-
* first paint (no FOUC). Rendered even on non-hydrated pages. Empty/omitted ⇒ none (unchanged). */
|
|
416
|
-
readonly styles?: readonly string[]
|
|
417
|
-
/** SSG: the prerendered-path set, serialized to `window.__NIFRA_PRERENDERED__` so the client fetches
|
|
418
|
-
* a static `_data.json` on soft-nav into a prerendered route. Empty/omitted ⇒ not injected. */
|
|
419
|
-
readonly prerenderedPaths?: readonly string[]
|
|
420
|
-
/** ISR: route freshness in seconds, emitted as the `x-nifra-isr-revalidate` header for a `withISR`
|
|
421
|
-
* wrapper to read. Omit ⇒ no header (the wrapper's default TTL applies). */
|
|
422
|
-
readonly revalidate?: number
|
|
423
|
-
/** ISR invalidation tags emitted as a bounded `x-nifra-isr-tags` header. */
|
|
424
|
-
readonly revalidateTags?: readonly string[]
|
|
425
|
-
/** Matched route id; written to `window.__NIFRA_ROUTE__` so the client hydrates this chain. */
|
|
426
|
-
readonly routeId?: string
|
|
427
|
-
/** The matched route's decoded path params - surfaced to the page as `params` (via {@link RenderProps})
|
|
428
|
-
* so an adapter's `useParams` is SSR-correct. Omit ⇒ `{}` (a route with no dynamic segments, or an
|
|
429
|
-
* error/404 render). */
|
|
430
|
-
readonly params?: Readonly<Record<string, string>>
|
|
431
|
-
/** The request's `pathname + search` - surfaced as `path` (via {@link RenderProps}) so an adapter's
|
|
432
|
-
* `useLocation`/`useSearchParams` render the right URL server-side and hydrate without drift. Omit ⇒
|
|
433
|
-
* `""`. */
|
|
434
|
-
readonly path?: string
|
|
435
|
-
/** The route's validated search (`searchOf(mod.searchSchema, url.search)`, the same value handed to
|
|
436
|
-
* the loader as `ctx.search`), surfaced as `search` (via {@link RenderProps}) so an adapter's
|
|
437
|
-
* `useSearch` is SSR-correct. Omit ⇒ `{}` (a render with no search context). */
|
|
438
|
-
readonly search?: Record<string, unknown>
|
|
439
|
-
/** Per-layout loader data, forwarded to the adapter as `RenderProps.layoutData`. Aligned with the
|
|
440
|
-
* chain's layout prefix; omitted when no layout in the chain has a loader. */
|
|
441
|
-
readonly layoutData?: readonly unknown[]
|
|
442
|
-
/** Dynamic-boundary states, forwarded to the adapter and serialized for hydration when present. */
|
|
443
|
-
readonly boundaries?: BoundaryStates
|
|
444
|
-
/** HTTP status for the response (default 200; e.g. 404 for a not-found page). */
|
|
445
|
-
readonly status?: number
|
|
446
|
-
/** Extra response headers - e.g. the `cache-control` a terminal status page wants. `content-type`
|
|
447
|
-
* is ignored (the document is always HTML) and the ISR freshness header is applied after these,
|
|
448
|
-
* so neither can be overridden from here. */
|
|
449
|
-
readonly headers?: HeadersLike
|
|
450
|
-
/** Document title (fallback when `head.title` is unset). */
|
|
451
|
-
readonly title?: string
|
|
452
|
-
/** Resolved route head - `title` overrides `title` above; `meta`/`link` render as managed
|
|
453
|
-
* (`data-nifra`) tags the client updates on navigation.
|
|
454
|
-
*
|
|
455
|
-
* **Head contract (the layout chain contributes).** `createWebApp` resolves this via
|
|
456
|
-
* {@link mergeHeads}: a route's head is its **layout chain's** `meta`/`head` exports merged with the
|
|
457
|
-
* page's. A `_layout.tsx` may `export const meta` (or `export function meta(args)`) - its tags land
|
|
458
|
-
* on every page below it (the home for `hreflang`, `preconnect`, a section-default `<title>`). The
|
|
459
|
-
* merge is **nearest-wins for scalars** (the page's `title` overrides an inner layout's, which
|
|
460
|
-
* overrides an outer one; an undefined page title keeps the layout's) and **concatenated for the
|
|
461
|
-
* `meta`/`link` arrays** (outermost layout first, page last). `<meta>`/`<link>` attributes pass a
|
|
462
|
-
* shared tag-specific allowlist (including inert `data-*` metadata) and values are escaped. */
|
|
463
|
-
readonly head?: Meta
|
|
464
|
-
/** Id of the container wrapping the app markup (default `"root"`). A non-default id also gets
|
|
465
|
-
* {@link ROOT_ATTRIBUTE}, which is how the generated client entry finds the container to hydrate -
|
|
466
|
-
* the entry is built once and cannot know what a given render chose. */
|
|
467
|
-
readonly rootId?: string
|
|
468
|
-
/** When `false`, emit a complete but **non-hydrated** document - no client entry script, data
|
|
469
|
-
* globals, or modulepreloads. Used for server-rendered `_error` pages: a terminal state that needs no
|
|
470
|
-
* client takeover, and it sidesteps an SSR/hydrate mismatch (the server rendered the boundary, not
|
|
471
|
-
* the page the client manifest maps this route id to). Default `true`. */
|
|
472
|
-
readonly hydrate?: boolean
|
|
473
|
-
/** Island client bundles (`@nifrajs/web/islands`) to load as `<script type="module">` in the document
|
|
474
|
-
* tail - emitted **regardless of `hydrate`**, so a static (`hydrate: false`) page can still mount
|
|
475
|
-
* no-framework islands. URLs are attribute-escaped. Empty/omitted ⇒ none (unchanged output). */
|
|
476
|
-
readonly islandScripts?: readonly string[]
|
|
477
|
-
/** CSP nonce applied to every framework-owned executable script in this document. */
|
|
478
|
-
readonly nonce?: string
|
|
479
|
-
/**
|
|
480
|
-
* Advanced: a per-route slot the renderer fills with the request-invariant document pieces (shell
|
|
481
|
-
* prefix/suffix, tail statics) on the first render and reuses afterwards, skipping their re-assembly.
|
|
482
|
-
*
|
|
483
|
-
* Only pass a cache when every shell-shaping input is IDENTICAL across the requests sharing it:
|
|
484
|
-
* same `head` content (a static meta chain - never a `meta(data)` function), `title`, `styles`,
|
|
485
|
-
* `preload`, `islandScripts`, `clientEntry`, `rootId`, `hydrate`, and `prerenderedPaths`. Per-request
|
|
486
|
-
* values (`data`, `params`, `search`, `actionData`, `layoutData`, deferred state) are always assembled
|
|
487
|
-
* fresh and safe. A per-request `nonce` disables the cache automatically. `createWebApp` wires this
|
|
488
|
-
* per route, gated on the route + layout metas being static.
|
|
489
|
-
*/
|
|
490
|
-
readonly assemblyCache?: RenderAssemblyCache
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
/** The mutable per-route slot {@link RenderPageOptions.assemblyCache} fills. Opaque - create as `{}`. */
|
|
494
|
-
export interface RenderAssemblyCache {
|
|
495
|
-
/** Shell up to (not including) the deferred-runtime insertion point. */
|
|
496
|
-
shellPre?: string
|
|
497
|
-
/** Shell from after the deferred-runtime insertion point through the open `#root` container. */
|
|
498
|
-
shellPost?: string
|
|
499
|
-
/** Tail opener: the inline script open tag + the route-id global (before the action global). */
|
|
500
|
-
tailPre?: string
|
|
501
|
-
/** Tail mid: the prerendered-paths global (between the action and layout-data globals). */
|
|
502
|
-
tailMid?: string
|
|
503
|
-
/** Tail data prefix: the `window.<data global>=` assignment head. */
|
|
504
|
-
tailData?: string
|
|
505
|
-
/** Tail from after the serialized data through `</html>`. */
|
|
506
|
-
tailPost?: string
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/**
|
|
510
|
-
* `renderPage` input. A hydrating page (the default) must supply `clientEntry`, because the document
|
|
511
|
-
* loads it as a module script; a `hydrate: false` page may omit it, because nothing in the emitted
|
|
512
|
-
* document references it. Expressed as a union so the compiler enforces the pairing rather than the
|
|
513
|
-
* renderer discovering an empty `src` at runtime.
|
|
514
|
-
*/
|
|
515
|
-
export type RenderPageInput = RenderPageOptions &
|
|
516
|
-
({ readonly hydrate?: true; readonly clientEntry: string } | { readonly hydrate: false })
|
|
517
|
-
|
|
518
|
-
/**
|
|
519
|
-
* Server: render a full HTML document for a page - the adapter's hydration head + the SSR
|
|
520
|
-
* markup (**streamed**) + the serialized loader data + the client module - as a `Response`.
|
|
521
|
-
* The shell (`<head>` + the open container) flushes first, the adapter's app stream follows,
|
|
522
|
-
* then the tail (data globals + client entry). Pure Web Standards, so it returns straight from
|
|
523
|
-
* a nifra route handler and streams on any fetch runtime (Bun/Node/Deno/Workers).
|
|
524
|
-
*/
|
|
525
|
-
export function renderPage(options: RenderPageInput): MaybePromise<Response> {
|
|
526
|
-
const page = renderPageResult(options)
|
|
527
|
-
return page instanceof Promise ? page.then((p) => p.toResponse()) : page.toResponse()
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
export function renderPageResult(options: RenderPageInput): MaybePromise<RenderedPage> {
|
|
531
|
-
const {
|
|
532
|
-
adapter,
|
|
533
|
-
chain,
|
|
534
|
-
data,
|
|
535
|
-
actionData,
|
|
536
|
-
clientEntry,
|
|
537
|
-
preload = [],
|
|
538
|
-
styles = [],
|
|
539
|
-
prerenderedPaths = [],
|
|
540
|
-
revalidate,
|
|
541
|
-
revalidateTags = [],
|
|
542
|
-
routeId,
|
|
543
|
-
status = 200,
|
|
544
|
-
title = "nifra",
|
|
545
|
-
head,
|
|
546
|
-
rootId = "root",
|
|
547
|
-
hydrate = true,
|
|
548
|
-
islandScripts = [],
|
|
549
|
-
nonce,
|
|
550
|
-
boundaries,
|
|
551
|
-
headers: extraHeaders,
|
|
552
|
-
} = options
|
|
553
|
-
if (nonce !== undefined && nonce.trim() === "") {
|
|
554
|
-
throw new TypeError("[nifra/web] renderPage nonce must be non-empty when provided")
|
|
555
|
-
}
|
|
556
|
-
const nonceAttr = nonce === undefined ? "" : ` nonce="${escapeAttr(nonce)}"`
|
|
557
|
-
const route = routeId === undefined ? "" : `window.${ROUTE_GLOBAL}=${serializeData(routeId)};`
|
|
558
|
-
// The SSG prerendered-path set (when an app declares it) - the client reads it to fetch a static
|
|
559
|
-
// `_data.json` on soft-nav into a prerendered route instead of hitting the worker. Empty ⇒ omitted.
|
|
560
|
-
const prerendered =
|
|
561
|
-
prerenderedPaths.length === 0
|
|
562
|
-
? ""
|
|
563
|
-
: `window.${PRERENDERED_GLOBAL}=${serializeData(prerenderedPaths)};`
|
|
564
|
-
// Split deferred values: the component sees markers (id + promise) to `<Await>`; the serialized
|
|
565
|
-
// data carries `{__nifra_deferred: id}` placeholders (promises don't serialize). `actionData` may
|
|
566
|
-
// also `defer()` - split it too, continuing the id space so a single registry settles both. The
|
|
567
|
-
// inline registry runtime is emitted only when something defers, so non-deferred output is unchanged.
|
|
568
|
-
const { forComponent, forClient, deferred } = prepareDeferred(data)
|
|
569
|
-
// Each layout's data is split in turn, each continuing the id space, so a `defer()` in a layout
|
|
570
|
-
// settles through the SAME registry as the page's rather than colliding on ids.
|
|
571
|
-
const layoutSplits: Array<ReturnType<typeof prepareDeferred>> = []
|
|
572
|
-
if (options.layoutData !== undefined) {
|
|
573
|
-
let offset = deferred.length
|
|
574
|
-
for (const entry of options.layoutData) {
|
|
575
|
-
const split = prepareDeferred(entry, offset)
|
|
576
|
-
offset += split.deferred.length
|
|
577
|
-
layoutSplits.push(split)
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
const layoutDeferred = layoutSplits.flatMap((split) => split.deferred)
|
|
581
|
-
const actionSplit =
|
|
582
|
-
actionData === undefined
|
|
583
|
-
? undefined
|
|
584
|
-
: prepareDeferred(actionData, deferred.length + layoutDeferred.length)
|
|
585
|
-
const boundarySplit =
|
|
586
|
-
boundaries === undefined
|
|
587
|
-
? undefined
|
|
588
|
-
: prepareDeferred(
|
|
589
|
-
boundaries,
|
|
590
|
-
deferred.length + layoutDeferred.length + (actionSplit?.deferred.length ?? 0),
|
|
591
|
-
)
|
|
592
|
-
// On the common page-only path (no layout loader, no action) reuse `deferred` directly instead of
|
|
593
|
-
// allocating a fresh spread array every request.
|
|
594
|
-
const allDeferred =
|
|
595
|
-
layoutDeferred.length === 0 && actionSplit === undefined && boundarySplit === undefined
|
|
596
|
-
? deferred
|
|
597
|
-
: [
|
|
598
|
-
...deferred,
|
|
599
|
-
...layoutDeferred,
|
|
600
|
-
...(actionSplit ? actionSplit.deferred : []),
|
|
601
|
-
...(boundarySplit ? boundarySplit.deferred : []),
|
|
602
|
-
]
|
|
603
|
-
// Omitted entirely when no layout has a loader - a page-only app emits exactly what it did before.
|
|
604
|
-
const layoutTail =
|
|
605
|
-
options.layoutData === undefined
|
|
606
|
-
? ""
|
|
607
|
-
: `window.${LAYOUT_DATA_GLOBAL}=${serializeData(layoutSplits.map((split) => split.forClient))};`
|
|
608
|
-
|
|
609
|
-
// Only emit the action global when an action actually ran, so plain GET output is unchanged.
|
|
610
|
-
const action =
|
|
611
|
-
actionSplit === undefined
|
|
612
|
-
? ""
|
|
613
|
-
: `window.${ACTION_GLOBAL}=${serializeData(actionSplit.forClient)};`
|
|
614
|
-
const boundaryTail =
|
|
615
|
-
boundarySplit === undefined
|
|
616
|
-
? ""
|
|
617
|
-
: `window.${BOUNDARY_GLOBAL}=${serializeData(boundarySplit.forClient)};`
|
|
618
|
-
const deferredRuntime =
|
|
619
|
-
allDeferred.length > 0 ? `<script${nonceAttr}>${DEFERRED_RUNTIME}</script>` : ""
|
|
620
|
-
// The regex only injects the CSP nonce into the (constant) hydration head; with no nonce it's a
|
|
621
|
-
// no-op that still scans the whole script every request. Skip it on the common no-nonce path.
|
|
622
|
-
// The request-invariant document pieces. With a caller-supplied per-route cache (and no per-request
|
|
623
|
-
// nonce, which would bake into them) they're built once and reused; the per-request seams - the
|
|
624
|
-
// deferred runtime in the shell, the action/layout-data globals and the serialized loader data in
|
|
625
|
-
// the tail - are always assembled fresh. Byte-identical to building the whole document inline.
|
|
626
|
-
const slot: RenderAssemblyCache =
|
|
627
|
-
nonce === undefined && options.assemblyCache !== undefined ? options.assemblyCache : {}
|
|
628
|
-
if (slot.shellPre === undefined) {
|
|
629
|
-
// Matched-route chunk preloads, concatenated directly. De-duped against the entry, which is
|
|
630
|
-
// preloaded separately below.
|
|
631
|
-
let preloadLinks = ""
|
|
632
|
-
for (const url of preload) {
|
|
633
|
-
if (url !== clientEntry)
|
|
634
|
-
preloadLinks += `<link rel="modulepreload" href="${escapeAttr(url)}">`
|
|
635
|
-
}
|
|
636
|
-
// The matched route's stylesheets - `<link rel="stylesheet">` in `<head>` so CSS arrives with the
|
|
637
|
-
// first paint (no FOUC). Render-blocking by design, and emitted regardless of `hydrate` (a static
|
|
638
|
-
// or `_error` page still wants its styles). In dev (Vite) CSS is injected client-side instead.
|
|
639
|
-
let styleLinks = ""
|
|
640
|
-
for (const url of styles) styleLinks += `<link rel="stylesheet" href="${escapeAttr(url)}">`
|
|
641
|
-
// Island bundles are referenced only by a `<script type="module">` at the END of `<body>`, so the
|
|
642
|
-
// browser doesn't discover them until the whole page is parsed. `modulepreload` them in `<head>`
|
|
643
|
-
// so the fetch starts immediately, in parallel with parsing - regardless of `hydrate`.
|
|
644
|
-
let islandPreloads = ""
|
|
645
|
-
for (const src of islandScripts)
|
|
646
|
-
islandPreloads += `<link rel="modulepreload" href="${escapeAttr(src)}">`
|
|
647
|
-
// Runs in the first-paint→hydration window to swallow a JS-only form's broken native submit. Only
|
|
648
|
-
// on a hydrating page (a static/_error page has no client handlers, so no footgun).
|
|
649
|
-
const hydrationGuard = hydrate ? `<script${nonceAttr}>${PRE_HYDRATION_GUARD}</script>` : ""
|
|
650
|
-
// `<html>` attributes. `lang` defaults to `"en"`; `dir` is omitted entirely when unset, which IS
|
|
651
|
-
// HTML's `ltr` default. Both attribute-escaped. `client.ts`'s `applyHead` mirrors this exact
|
|
652
|
-
// defaulting on soft-nav, so a hard load and a client navigation produce the same `<html>`.
|
|
653
|
-
const htmlAttrs = ` lang="${escapeAttr(head?.lang ?? "en")}"${head?.dir === undefined ? "" : ` dir="${escapeAttr(head.dir)}"`}`
|
|
654
|
-
// Marks the container for the client entry when the id is not the one the entry falls back to.
|
|
655
|
-
const rootMarker = rootId === "root" ? "" : ` ${ROOT_ATTRIBUTE}`
|
|
656
|
-
// A non-hydrated page omits the adapter's hydration bootstrap entirely (Solid's `_$HY` registry
|
|
657
|
-
// script, etc.) - there is no client takeover to feed, so it's dead bytes on a static document.
|
|
658
|
-
// The nonce rewrite only runs when a nonce is set; with none it was a whole-string no-op scan.
|
|
659
|
-
const rawHydrationHead = hydrate ? adapter.hydrationHead(nonce) : ""
|
|
660
|
-
const hydrationHead =
|
|
661
|
-
nonce === undefined
|
|
662
|
-
? rawHydrationHead
|
|
663
|
-
: rawHydrationHead.replace(/<script(?![^>]*\bnonce=)(?=[\s>])/g, `<script${nonceAttr}`)
|
|
664
|
-
// Shell up to the deferred-runtime seam: on a hydrating page that seam sits after the entry +
|
|
665
|
-
// route-chunk preloads; a non-hydrating page has no runtime insertion at all.
|
|
666
|
-
const entryPreloads = hydrate
|
|
667
|
-
? `<link rel="modulepreload" href="${escapeAttr(clientEntry ?? "")}">${preloadLinks}`
|
|
668
|
-
: ""
|
|
669
|
-
slot.shellPre = `<!doctype html><html${htmlAttrs}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${hydrationGuard}<title>${escapeHtml(head?.title ?? title)}</title>${headTags(head)}${styleLinks}${entryPreloads}`
|
|
670
|
-
slot.shellPost = `${islandPreloads}${hydrationHead}</head><body><div id="${escapeAttr(rootId)}"${rootMarker}>`
|
|
671
|
-
// Island bundles load regardless of `hydrate` - a static page (hydrate:false) ships no framework
|
|
672
|
-
// client but can still mount no-framework islands (`@nifrajs/web/islands`).
|
|
673
|
-
let islandTags = ""
|
|
674
|
-
for (const src of islandScripts)
|
|
675
|
-
islandTags += `<script type="module" src="${escapeAttr(src)}"${nonceAttr}></script>`
|
|
676
|
-
if (hydrate) {
|
|
677
|
-
slot.tailPre = `<script${nonceAttr}>${route}`
|
|
678
|
-
slot.tailMid = prerendered
|
|
679
|
-
slot.tailData = `window.${DATA_GLOBAL}=`
|
|
680
|
-
slot.tailPost = `</script><script type="module" src="${escapeAttr(clientEntry ?? "")}"${nonceAttr}></script>${islandTags}</body></html>`
|
|
681
|
-
} else {
|
|
682
|
-
slot.tailPre = ""
|
|
683
|
-
slot.tailMid = ""
|
|
684
|
-
slot.tailData = ""
|
|
685
|
-
slot.tailPost = `${islandTags}</body></html>`
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
// The deferred runtime rides only on a hydrating page (a static page has no client takeover), and
|
|
689
|
-
// only when something actually deferred - matching the pre-cache emission exactly.
|
|
690
|
-
const runtimeSeam = hydrate && allDeferred.length > 0 ? deferredRuntime : ""
|
|
691
|
-
const shellHtml = `${slot.shellPre}${runtimeSeam}${slot.shellPost}`
|
|
692
|
-
// Closes the hydration container; deferred resolve scripts go AFTER it (outside `#root`) so they
|
|
693
|
-
// aren't part of the adapter's hydrated tree (an inline script inside it breaks hydration).
|
|
694
|
-
const closeRootHtml = "</div>"
|
|
695
|
-
// Tail - the loader-data globals + the client module. Module scripts defer (run after parse), so
|
|
696
|
-
// the data global + every streamed deferred resolution are set before the entry hydrates.
|
|
697
|
-
const tailHtml = hydrate
|
|
698
|
-
? `${slot.tailPre}${action}${slot.tailMid}${layoutTail}${boundaryTail}${slot.tailData}${serializeData(forClient)}${slot.tailPost}`
|
|
699
|
-
: (slot.tailPost as string)
|
|
700
|
-
const headers: Record<string, string> = { "content-type": "text/html; charset=utf-8" }
|
|
701
|
-
// Caller-supplied headers (a terminal status page's `cache-control`, say). Applied before the
|
|
702
|
-
// framework's own below, so `content-type` and the ISR channel stay authoritative - a caller must
|
|
703
|
-
// not be able to mislabel the document or forge the freshness header by passing them here.
|
|
704
|
-
if (extraHeaders !== undefined) {
|
|
705
|
-
for (const [name, value] of new Headers(extraHeaders)) {
|
|
706
|
-
if (name !== "content-type") headers[name] = value
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
// ISR: advertise the route's freshness so a `withISR` wrapper can set this page's cache TTL. A
|
|
710
|
-
// dedicated header (not the action-revalidation `x-nifra-revalidate`) so the TTL channel never aliases
|
|
711
|
-
// the client's path-list channel.
|
|
712
|
-
if (revalidate !== undefined) headers[ISR_REVALIDATE_HEADER] = String(revalidate)
|
|
713
|
-
const serializedTags = serializeISRTags(revalidateTags)
|
|
714
|
-
if (serializedTags !== undefined) headers[ISR_REVALIDATE_TAGS_HEADER] = serializedTags
|
|
715
|
-
const renderProps: RenderProps = {
|
|
716
|
-
data: forComponent,
|
|
717
|
-
actionData: actionSplit?.forComponent,
|
|
718
|
-
// `params`/`path` thread the matched route + URL to the adapter's `useParams`/`useLocation` so they
|
|
719
|
-
// render SSR-correct. Spread only when supplied (exactOptionalPropertyTypes) - an adapter with no
|
|
720
|
-
// router bindings simply never reads them.
|
|
721
|
-
...(options.params !== undefined ? { params: options.params } : {}),
|
|
722
|
-
...(options.path !== undefined ? { path: options.path } : {}),
|
|
723
|
-
// Same rule for `search`: the validated query the adapter's `useSearch` reads, spread only when the
|
|
724
|
-
// caller supplied it (a non-router render never has it).
|
|
725
|
-
...(options.search !== undefined ? { search: options.search } : {}),
|
|
726
|
-
// Spread only when present, so a page-only render produces exactly the props it did before.
|
|
727
|
-
...(options.layoutData !== undefined
|
|
728
|
-
? { layoutData: layoutSplits.map((split) => split.forComponent) }
|
|
729
|
-
: {}),
|
|
730
|
-
...(boundarySplit !== undefined
|
|
731
|
-
? { boundaries: boundarySplit.forComponent as BoundaryStates }
|
|
732
|
-
: {}),
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
// Fast path: nothing `defer()`s and the adapter can render synchronously to a string → buffer the
|
|
736
|
-
// whole document in one pass. Progressive streaming only benefits pages with deferred boundaries
|
|
737
|
-
// (those take the streaming path below); for a plain page the streaming pipeline + the framework's
|
|
738
|
-
// streaming renderer are pure overhead vs a single sync render + concat. A buffered string body also
|
|
739
|
-
// gets an automatic Content-Length. A render throw surfaces here exactly as the streaming path's
|
|
740
|
-
// shell-readiness `await` does, so the `_error` boundary still maps it to a status.
|
|
741
|
-
if (allDeferred.length === 0 && adapter.renderToString !== undefined) {
|
|
742
|
-
const out = adapter.renderToString(chain, renderProps)
|
|
743
|
-
return typeof out === "string"
|
|
744
|
-
? new BufferedRenderedPage(shellHtml + out + closeRootHtml + tailHtml, status, headers)
|
|
745
|
-
: out.then(
|
|
746
|
-
(bodyHtml) =>
|
|
747
|
-
new BufferedRenderedPage(
|
|
748
|
-
shellHtml + bodyHtml + closeRootHtml + tailHtml,
|
|
749
|
-
status,
|
|
750
|
-
headers,
|
|
751
|
-
),
|
|
752
|
-
)
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
return renderStreamedPage(
|
|
756
|
-
adapter,
|
|
757
|
-
chain,
|
|
758
|
-
renderProps,
|
|
759
|
-
shellHtml,
|
|
760
|
-
closeRootHtml,
|
|
761
|
-
allDeferred,
|
|
762
|
-
tailHtml,
|
|
763
|
-
status,
|
|
764
|
-
headers,
|
|
765
|
-
nonceAttr,
|
|
766
|
-
).then((response) => new ResponseRenderedPage(response))
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
async function renderStreamedPage(
|
|
770
|
-
adapter: RenderAdapter,
|
|
771
|
-
chain: readonly unknown[],
|
|
772
|
-
renderProps: RenderProps,
|
|
773
|
-
shellHtml: string,
|
|
774
|
-
closeRootHtml: string,
|
|
775
|
-
allDeferred: ReadonlyArray<{ readonly id: number; readonly promise: Promise<unknown> }>,
|
|
776
|
-
tailHtml: string,
|
|
777
|
-
status: number,
|
|
778
|
-
headers: Record<string, string>,
|
|
779
|
-
nonceAttr: string,
|
|
780
|
-
): Promise<Response> {
|
|
781
|
-
// Streaming path - required for `defer()` (progressive `<Await>` resolution) and used by any adapter
|
|
782
|
-
// that doesn't implement `renderToString`. Awaiting `renderToStream` resolves on shell-readiness
|
|
783
|
-
// (React: on-shell-ready; Solid: synchronously), so a shell-render throw surfaces before any byte is
|
|
784
|
-
// sent. A *mid*-stream failure errors the body instead.
|
|
785
|
-
const enc = TEXT_ENCODER
|
|
786
|
-
const shell = enc.encode(shellHtml)
|
|
787
|
-
const closeRoot = enc.encode(closeRootHtml)
|
|
788
|
-
const tail = enc.encode(tailHtml)
|
|
789
|
-
const appStream = await adapter.renderToStream(chain, renderProps)
|
|
790
|
-
const body = streamDocument(shell, appStream, closeRoot, allDeferred, tail, enc, nonceAttr)
|
|
791
|
-
return new Response(body, { status, headers })
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
class BufferedRenderedPage implements RenderedPage {
|
|
795
|
-
readonly [RESPONSE_RESULT] = true
|
|
796
|
-
private readonly body: string
|
|
797
|
-
private readonly status: number
|
|
798
|
-
private readonly headers: Readonly<Record<string, string>>
|
|
799
|
-
|
|
800
|
-
constructor(body: string, status: number, headers: Readonly<Record<string, string>>) {
|
|
801
|
-
this.body = body
|
|
802
|
-
this.status = status
|
|
803
|
-
this.headers = headers
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
toResponse(): Response {
|
|
807
|
-
return htmlResponse(this.body, { status: this.status, headers: this.headers })
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
toNodeBody(): {
|
|
811
|
-
readonly status: number
|
|
812
|
-
readonly headers: Readonly<Record<string, string | readonly string[]>>
|
|
813
|
-
readonly body: string
|
|
814
|
-
} {
|
|
815
|
-
return { status: this.status, headers: this.headers, body: this.body }
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
class ResponseRenderedPage implements RenderedPage {
|
|
820
|
-
readonly [RESPONSE_RESULT] = true
|
|
821
|
-
private readonly response: Response
|
|
822
|
-
|
|
823
|
-
constructor(response: Response) {
|
|
824
|
-
this.response = response
|
|
825
|
-
}
|
|
826
|
-
|
|
827
|
-
toResponse(): Response {
|
|
828
|
-
return this.response
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
function htmlResponse(body: string, init: ResponseInit): Response {
|
|
833
|
-
const response = new Response(body, init)
|
|
834
|
-
// @nifrajs/node can write buffered HTML straight to ServerResponse with `end(body)`, avoiding a Web
|
|
835
|
-
// Response stream drain on Node. Non-enumerable + Symbol.for keeps this invisible to Web runtimes and
|
|
836
|
-
// cross-package without adding a runtime dependency from @nifrajs/node to @nifrajs/web.
|
|
837
|
-
Object.defineProperty(response, NODE_RESPONSE_BODY, { value: body })
|
|
838
|
-
return response
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
/**
|
|
842
|
-
* Assemble the document stream: `shell` → the app `stream` (forwarded chunk-by-chunk, so a streaming
|
|
843
|
-
* renderer's progressive flushing is preserved) → `closeRoot` → one `__nifraResolve`/`__nifraReject`
|
|
844
|
-
* script per deferred value (emitted once its promise settles - by now the app stream has awaited
|
|
845
|
-
* the same Suspense boundaries - and placed OUTSIDE `#root`) → `tail`. A mid-stream app error errors
|
|
846
|
-
* the result (the body breaks) rather than silently truncating a 200.
|
|
847
|
-
*/
|
|
848
|
-
function streamDocument(
|
|
849
|
-
shell: Uint8Array,
|
|
850
|
-
stream: ReadableStream<Uint8Array>,
|
|
851
|
-
closeRoot: Uint8Array,
|
|
852
|
-
deferred: ReadonlyArray<{ readonly id: number; readonly promise: Promise<unknown> }>,
|
|
853
|
-
tail: Uint8Array,
|
|
854
|
-
enc: TextEncoder,
|
|
855
|
-
nonceAttr: string,
|
|
856
|
-
): ReadableStream<Uint8Array> {
|
|
857
|
-
return new ReadableStream({
|
|
858
|
-
async start(controller) {
|
|
859
|
-
const reader = stream.getReader()
|
|
860
|
-
try {
|
|
861
|
-
controller.enqueue(shell)
|
|
862
|
-
for (;;) {
|
|
863
|
-
const { done, value } = await reader.read()
|
|
864
|
-
if (done) break
|
|
865
|
-
controller.enqueue(value)
|
|
866
|
-
}
|
|
867
|
-
controller.enqueue(closeRoot)
|
|
868
|
-
// Stream each resolution as ITS OWN promise settles - NOT in array order. A slow
|
|
869
|
-
// defer() must not block a faster one; each script self-addresses by id, so order is irrelevant.
|
|
870
|
-
await Promise.all(
|
|
871
|
-
deferred.map(async (d) => {
|
|
872
|
-
try {
|
|
873
|
-
const value = serializeData(await d.promise)
|
|
874
|
-
controller.enqueue(
|
|
875
|
-
enc.encode(`<script${nonceAttr}>window.__nifraResolve(${d.id},${value})</script>`),
|
|
876
|
-
)
|
|
877
|
-
} catch (err) {
|
|
878
|
-
// A rejected deferred streams __nifraReject (the client `<Await>` surfaces it) - it must
|
|
879
|
-
// not break the whole body. Redact: stream a stable opaque code, never the raw error
|
|
880
|
-
// text; log the real reason server-side.
|
|
881
|
-
console.error("[nifra/web] deferred value rejected:", err)
|
|
882
|
-
controller.enqueue(
|
|
883
|
-
enc.encode(
|
|
884
|
-
`<script${nonceAttr}>window.__nifraReject(${d.id},${serializeData(DEFERRED_ERROR_CODE)})</script>`,
|
|
885
|
-
),
|
|
886
|
-
)
|
|
887
|
-
}
|
|
888
|
-
}),
|
|
889
|
-
)
|
|
890
|
-
controller.enqueue(tail)
|
|
891
|
-
controller.close()
|
|
892
|
-
} catch (err) {
|
|
893
|
-
controller.error(err)
|
|
894
|
-
} finally {
|
|
895
|
-
reader.releaseLock()
|
|
896
|
-
}
|
|
897
|
-
},
|
|
898
|
-
})
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
/** Options for {@link redirect}. */
|
|
902
|
-
export interface RedirectOptions {
|
|
903
|
-
/** HTTP status (default 303 See Other; pass 307/308 to preserve the method). */
|
|
904
|
-
readonly status?: number
|
|
905
|
-
/** Allow an off-origin / absolute destination. Off by default: only a same-origin path (a single
|
|
906
|
-
* leading `/`) is permitted, so an action can't be turned into an open redirect by passing
|
|
907
|
-
* attacker-controlled input straight through. Set `true` for a deliberate external redirect. */
|
|
908
|
-
readonly external?: boolean
|
|
909
|
-
/** Extra response headers. A redirect is no longer a `Response`, so there is no `.headers` to
|
|
910
|
-
* mutate after the fact - name them here. Cookies still ride `c.set`, as on any other response. */
|
|
911
|
-
readonly headers?: Readonly<Record<string, string>>
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
/** A same-origin destination is an absolute path: one leading `/`, but NOT `//` (protocol-relative →
|
|
915
|
-
* another origin), and nothing a URL parser resolves off-origin. Everything else (absolute URL with
|
|
916
|
-
* a scheme, `//host`, `javascript:`, a bare relative `foo`) requires `external: true`. The predicate
|
|
917
|
-
* lives in the kernel - see `isSameOriginPath` - so this gate and the auth guards' `redirectTo`
|
|
918
|
-
* cannot drift apart. */
|
|
919
|
-
|
|
920
|
-
/**
|
|
921
|
-
* Build a redirect - return it from a route `action` for the Post/Redirect/Get pattern (POST
|
|
922
|
-
* mutates, 303 sends the browser to a fresh GET, so a reload doesn't re-submit). Defaults to 303
|
|
923
|
-
* (See Other); pass `{ status: 307 }` or `{ status: 308 }` to preserve the method.
|
|
924
|
-
*
|
|
925
|
-
* **Secure by default:** `location` must be a same-origin path (begins with `/`, not `//`). An
|
|
926
|
-
* off-origin/absolute destination throws unless you pass `{ external: true }` - this closes the
|
|
927
|
-
* open-redirect footgun of `return redirect(formData.get("next"))` on the no-JS (native-form) path,
|
|
928
|
-
* which serves the action's control-flow value verbatim.
|
|
929
|
-
*
|
|
930
|
-
* Returns a plain render, not a `Response`. A redirect is a status line and one header - the most
|
|
931
|
-
* body-less response there is - and building a `Response` for it costs the whole Web object plus, on
|
|
932
|
-
* Node, a stream drained back out. As data it renders on the same lane a handler's return takes.
|
|
933
|
-
* `redirect(...)` is still returned or thrown from exactly the same places; only `.status` /
|
|
934
|
-
* `.headers` are gone from the value, replaced by `options.headers` and the request's `c.set`.
|
|
935
|
-
*
|
|
936
|
-
* @param options redirect status, extra headers, and whether an off-origin destination is intentional.
|
|
937
|
-
*/
|
|
938
|
-
export function redirect(location: string, options: RedirectOptions = {}): ResponseResult {
|
|
939
|
-
if (options.external !== true && !isSameOriginPath(location)) {
|
|
940
|
-
throw new Error(
|
|
941
|
-
`[nifra/web] redirect(${JSON.stringify(location)}) is not a same-origin path. Use a path beginning with "/" (not "//", no backslash or control character), or redirect(location, { external: true }) for a deliberate off-origin redirect. This guards against open redirects from unvalidated input.`,
|
|
942
|
-
)
|
|
943
|
-
}
|
|
944
|
-
// Reject CR/LF in the Location explicitly - defense-in-depth (response splitting / header
|
|
945
|
-
// injection). Spec-correct runtimes' Headers setter throws on CR/LF, but `external: true` lets
|
|
946
|
-
// unvalidated input reach this sink, so we don't rely on the runtime. Same posture as
|
|
947
|
-
// serializeCookie / the SSE frame formatter, which strip CRLF at their sinks.
|
|
948
|
-
if (/[\r\n]/.test(location)) {
|
|
949
|
-
throw new Error(
|
|
950
|
-
`[nifra/web] redirect location contains a CR/LF character - refusing to emit a header-injecting redirect.`,
|
|
951
|
-
)
|
|
952
|
-
}
|
|
953
|
-
return statusResult(options.status ?? 303, undefined, {
|
|
954
|
-
headers: options.headers === undefined ? { location } : { ...options.headers, location },
|
|
955
|
-
})
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
/**
|
|
959
|
-
* Brand marking a `Response` as a **terminal-status signal** rather than a response to serve verbatim.
|
|
960
|
-
*
|
|
961
|
-
* A loader that throws a bare `Response` has always been passed straight through - that is how
|
|
962
|
-
* `throw redirect(...)` works, and apps rely on it. So "render the `_404` boundary at 404" cannot be
|
|
963
|
-
* expressed by throwing a plain `Response`: the framework would have no way to tell it apart from a
|
|
964
|
-
* hand-rolled body the app wants served exactly as written. The brand is that distinction, and
|
|
965
|
-
* checking it *before* the pass-through is what keeps every existing throw working unchanged.
|
|
966
|
-
*
|
|
967
|
-
* `Symbol.for` matches `RESPONSE_RESULT` above: a registry symbol survives two copies of this module,
|
|
968
|
-
* which a `unique symbol` would not - and a duplicate-install turning a 404 into a raw 404 body would
|
|
969
|
-
* be a maddening bug to trace.
|
|
970
|
-
*/
|
|
971
|
-
/** Derived from the `Headers` constructor rather than the DOM lib's `HeadersInit`: `@nifrajs/web`
|
|
972
|
-
* is consumed from DOM-free programs (the root tsconfig runs `types: ["bun"]`), and naming the DOM
|
|
973
|
-
* type here would break their build. Same workaround as `SSEInit.headers` in core. */
|
|
974
|
-
type HeadersLike = ConstructorParameters<typeof Headers>[0]
|
|
975
|
-
|
|
976
|
-
/**
|
|
977
|
-
* The signatures a **duplicate module instance** produces during SSR, across engines.
|
|
978
|
-
*
|
|
979
|
-
* Two copies of React (or of `@nifrajs/core`) at the SAME version still fail, because module identity
|
|
980
|
-
* is path-based: hooks read a dispatcher off the copy that rendered, and the component imported the
|
|
981
|
-
* other one. The error that surfaces names a React internal, so it reads as a React bug and the actual
|
|
982
|
-
* cause - two directories - is a long inference away. Naming the cause at the point of failure is the
|
|
983
|
-
* difference between that and a five-second read.
|
|
984
|
-
*/
|
|
985
|
-
const DUPLICATE_INSTANCE_SIGNATURES: readonly RegExp[] = [
|
|
986
|
-
/resolveDispatcher\(\)/,
|
|
987
|
-
/Invalid hook call/,
|
|
988
|
-
/Cannot read propert(?:y|ies) of null \(reading '(?:use[A-Z]\w*)'\)/,
|
|
989
|
-
/null is not an object \(evaluating '.*\.use[A-Z]\w*'\)/,
|
|
990
|
-
]
|
|
991
|
-
|
|
992
|
-
/**
|
|
993
|
-
* Append the likely cause to an SSR error that carries a duplicate-instance signature.
|
|
994
|
-
*
|
|
995
|
-
* Deliberately does NOT claim the resolved paths: `@nifrajs/web` is framework-agnostic and does not
|
|
996
|
-
* depend on React, so it cannot resolve the copies without guessing. It names the condition and the
|
|
997
|
-
* command that reports the paths, which is the actionable part. Returns the error untouched when the
|
|
998
|
-
* signature does not match, so an ordinary render error reads exactly as before.
|
|
999
|
-
*/
|
|
1000
|
-
function withDuplicateInstanceHint(err: unknown): unknown {
|
|
1001
|
-
if (!(err instanceof Error)) return err
|
|
1002
|
-
if (!DUPLICATE_INSTANCE_SIGNATURES.some((rx) => rx.test(err.message))) return err
|
|
1003
|
-
const augmented = new Error(
|
|
1004
|
-
`${err.message}\n\n[nifra/web] This signature usually means TWO COPIES of an identity-sensitive package (react, react-dom, or @nifrajs/core) are installed at different paths. Module identity is path-based, so matching versions do NOT fix it - the copies must resolve to the same directory. Run \`nifra check\` to list the paths.`,
|
|
1005
|
-
{ cause: err },
|
|
1006
|
-
)
|
|
1007
|
-
augmented.name = err.name
|
|
1008
|
-
// `stack` is optional under exactOptionalPropertyTypes; assigning `undefined` would replace a real
|
|
1009
|
-
// stack with nothing on engines that always populate it.
|
|
1010
|
-
if (err.stack !== undefined) augmented.stack = err.stack
|
|
1011
|
-
return augmented
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
/**
|
|
1015
|
-
* An action's control-flow value passes straight through - except a redirect on a client-submit data
|
|
1016
|
-
* request: fetch would follow the 3xx into HTML the client can't use, so the redirect rides the
|
|
1017
|
-
* X-Nifra-Redirect header on a 204 and the client navigates. One conversion shared by the returned-
|
|
1018
|
-
* and thrown- paths, so `return redirect()` and `throw redirect()` agree.
|
|
1019
|
-
*
|
|
1020
|
-
* Takes either shape: `redirect()` is a plain render, while a hand-rolled `new Response(...)` from an
|
|
1021
|
-
* action still arrives as a `Response`. The rewrite stays on the lane its input was on - a plain
|
|
1022
|
-
* redirect converts to a plain 204, and never materializes the `Response` it is replacing.
|
|
1023
|
-
*/
|
|
1024
|
-
function actionResponse(
|
|
1025
|
-
result: Response | ResponseResult,
|
|
1026
|
-
isDataRequest: boolean,
|
|
1027
|
-
): Response | ResponseResult {
|
|
1028
|
-
if (!isDataRequest) return result
|
|
1029
|
-
if (isResponseResult(result)) {
|
|
1030
|
-
const plain = result.plain
|
|
1031
|
-
// No `plain` means a carrier that only knows how to build a `Response` (not one of ours) - fall
|
|
1032
|
-
// back rather than guess at its status.
|
|
1033
|
-
if (plain === undefined) return actionResponse(result.toResponse(), isDataRequest)
|
|
1034
|
-
if (plain.status < 300 || plain.status >= 400) return result
|
|
1035
|
-
const location = plain.headers?.location ?? "/"
|
|
1036
|
-
return statusResult(204, undefined, { headers: { [REDIRECT_HEADER]: location } })
|
|
1037
|
-
}
|
|
1038
|
-
if (result.status >= 300 && result.status < 400) {
|
|
1039
|
-
const location = result.headers.get("location") ?? "/"
|
|
1040
|
-
return statusResult(204, undefined, { headers: { [REDIRECT_HEADER]: location } })
|
|
1041
|
-
}
|
|
1042
|
-
return result
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
/** A loaded layout module. `loader`/`gate` are the layout-loader surface; `meta` predates it. */
|
|
1046
|
-
type LoadedLayoutModules = ReadonlyArray<{
|
|
1047
|
-
default: unknown
|
|
1048
|
-
meta?: MetaInput
|
|
1049
|
-
loader?: LayoutLoader
|
|
1050
|
-
action?: unknown
|
|
1051
|
-
gate?: boolean
|
|
1052
|
-
// A layout may declare its own `searchSchema`; the route's effective search merges the layout chain's
|
|
1053
|
-
// schemas with the page's (page-wins). Present on the raw module already - typed here so it is readable.
|
|
1054
|
-
searchSchema?: RouteModule["searchSchema"]
|
|
1055
|
-
boundaries?: readonly BoundaryRegistration[]
|
|
1056
|
-
}>
|
|
1057
|
-
|
|
1058
|
-
/**
|
|
1059
|
-
* Narrow the route's params to the ones a layout owns.
|
|
1060
|
-
*
|
|
1061
|
-
* A layout wraps a URL prefix, so anything deeper belongs to a route beneath it. Handing it the full
|
|
1062
|
-
* set would let a layout read a param it does not own - which reads fine until the layout is reused
|
|
1063
|
-
* under a route that has no such param and the value silently becomes `undefined`.
|
|
1064
|
-
*/
|
|
1065
|
-
function scopeParams(
|
|
1066
|
-
params: Record<string, string>,
|
|
1067
|
-
owned: readonly string[] | undefined,
|
|
1068
|
-
): Record<string, string> {
|
|
1069
|
-
if (owned === undefined) return params // hand-built manifest with no scope info: unchanged behaviour
|
|
1070
|
-
if (owned.length === 0) return EMPTY_LAYOUT_PARAMS
|
|
1071
|
-
const scoped: Record<string, string> = {}
|
|
1072
|
-
for (const name of owned) {
|
|
1073
|
-
const value = params[name]
|
|
1074
|
-
if (value !== undefined) scoped[name] = value
|
|
1075
|
-
}
|
|
1076
|
-
return scoped
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
const EMPTY_LAYOUT_PARAMS: Record<string, string> = Object.freeze({})
|
|
1080
|
-
|
|
1081
|
-
const EMPTY_RETAIN: ReadonlySet<number> = new Set<number>()
|
|
1082
|
-
|
|
1083
|
-
/**
|
|
1084
|
-
* Marks an error as coming from a LAYOUT loader, carrying that layout's id.
|
|
1085
|
-
*
|
|
1086
|
-
* The boundary that catches it must be at or above the failing layout's own segment. The route's
|
|
1087
|
-
* innermost `_error` may live BELOW the layout that failed, and rendering there would wrap the
|
|
1088
|
-
* boundary in a layout whose data never arrived - so the page would render inside a component that
|
|
1089
|
-
* threw. A registry symbol, matching the other cross-module brands here.
|
|
1090
|
-
*/
|
|
1091
|
-
const LAYOUT_ERROR_ID = Symbol.for("nifra.web.layout-error-id")
|
|
1092
|
-
|
|
1093
|
-
const tagLayoutError = (err: unknown, layoutId: string): unknown => {
|
|
1094
|
-
if (typeof err === "object" && err !== null && !(LAYOUT_ERROR_ID in err)) {
|
|
1095
|
-
Object.defineProperty(err, LAYOUT_ERROR_ID, { value: layoutId, enumerable: false })
|
|
1096
|
-
}
|
|
1097
|
-
return err
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
const layoutErrorId = (err: unknown): string | undefined =>
|
|
1101
|
-
typeof err === "object" && err !== null
|
|
1102
|
-
? ((err as Record<symbol, unknown>)[LAYOUT_ERROR_ID] as string | undefined)
|
|
1103
|
-
: undefined
|
|
1104
|
-
|
|
1105
|
-
const STATUS_SIGNAL = Symbol.for("nifra.web.status-signal")
|
|
1106
|
-
|
|
1107
|
-
/** Reason phrases for the plain-text fallback, used only when the app authored no `_<status>` and no
|
|
1108
|
-
* `_404` page. Deliberately not exhaustive - anything unlisted falls back to "Error", which is more
|
|
1109
|
-
* useful than shipping a table of every RFC status for a body almost nobody will see. */
|
|
1110
|
-
const STATUS_TEXT: Readonly<Record<number, string>> = {
|
|
1111
|
-
400: "Bad Request",
|
|
1112
|
-
401: "Unauthorized",
|
|
1113
|
-
402: "Payment Required",
|
|
1114
|
-
403: "Forbidden",
|
|
1115
|
-
404: "Not Found",
|
|
1116
|
-
410: "Gone",
|
|
1117
|
-
451: "Unavailable For Legal Reasons",
|
|
1118
|
-
500: "Internal Server Error",
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
interface StatusSignal extends Response {
|
|
1122
|
-
readonly [STATUS_SIGNAL]: { readonly status: number; readonly headers?: HeadersLike }
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
function isStatusSignal(value: unknown): value is StatusSignal {
|
|
1126
|
-
return value instanceof Response && STATUS_SIGNAL in value
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
function statusSignal(status: number, options: StatusPageOptions): never {
|
|
1130
|
-
if (!Number.isInteger(status) || status < 400 || status > 599) {
|
|
1131
|
-
throw new Error(
|
|
1132
|
-
`[nifra/web] statusPage(${JSON.stringify(status)}) must be an integer 4xx or 5xx status. Use redirect() for 3xx, and return data for a successful render.`,
|
|
1133
|
-
)
|
|
1134
|
-
}
|
|
1135
|
-
const response = new Response(null, { status })
|
|
1136
|
-
// Non-enumerable so the brand never lands in a structured clone or a JSON log of the response.
|
|
1137
|
-
Object.defineProperty(response, STATUS_SIGNAL, {
|
|
1138
|
-
value: { status, ...(options.headers !== undefined ? { headers: options.headers } : {}) },
|
|
1139
|
-
enumerable: false,
|
|
1140
|
-
})
|
|
1141
|
-
throw response
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
/** Options shared by {@link notFound}, {@link gone}, and {@link statusPage}. */
|
|
1145
|
-
export interface StatusPageOptions {
|
|
1146
|
-
/**
|
|
1147
|
-
* Extra headers for the rendered response - `cache-control` above all.
|
|
1148
|
-
*
|
|
1149
|
-
* The defaults differ by status for a reason: a 404 may be a race with publication, so it wants a
|
|
1150
|
-
* short TTL, while a 410 is a promise that the URL is permanently gone and can be cached hard.
|
|
1151
|
-
* Getting this wrong is the difference between a crawler dropping a dead URL and re-fetching it
|
|
1152
|
-
* for weeks.
|
|
1153
|
-
*/
|
|
1154
|
-
readonly headers?: HeadersLike
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
/**
|
|
1158
|
-
* Render the nearest `_404` page at status **404**. `throw` it from a loader when the record does not
|
|
1159
|
-
* exist.
|
|
1160
|
-
*
|
|
1161
|
-
* This is the fix for the soft 404: a matched route whose loader finds nothing has otherwise no way to
|
|
1162
|
-
* answer 404, so the path of least resistance is to return empty data and render "not found" inside a
|
|
1163
|
-
* **200**. That looks correct in a browser and is invisible in review, which is why it ships and stays
|
|
1164
|
-
* shipped - while search engines penalise it and keep the dead URL indexed.
|
|
1165
|
-
*
|
|
1166
|
-
* Returns `never`: these throw, so a loader narrows without a redundant `return`, and the type states
|
|
1167
|
-
* the thing the "loaders `throw` redirect, actions `return` it" rule already trips people on.
|
|
1168
|
-
*/
|
|
1169
|
-
export function notFound(options: StatusPageOptions = {}): never {
|
|
1170
|
-
return statusSignal(404, options)
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
/**
|
|
1174
|
-
* Render a terminal page at status **410 Gone**. `throw` it from a loader for a record that existed and
|
|
1175
|
-
* was deliberately removed - a withdrawn listing, a deleted post.
|
|
1176
|
-
*
|
|
1177
|
-
* 410 is not a pedantic 404: it tells a crawler to **drop** the URL rather than re-fetch it for weeks
|
|
1178
|
-
* on the assumption the 404 was transient. Uses `_410.tsx` if the app has one, otherwise `_404`.
|
|
1179
|
-
*/
|
|
1180
|
-
export function gone(options: StatusPageOptions = {}): never {
|
|
1181
|
-
return statusSignal(410, options)
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
/**
|
|
1185
|
-
* Render a terminal page at any 4xx/5xx status - the escape hatch behind {@link notFound} and
|
|
1186
|
-
* {@link gone} (402, 451, …). Uses `_<status>.tsx` if present, otherwise `_404`.
|
|
1187
|
-
*/
|
|
1188
|
-
export function statusPage(status: number, options: StatusPageOptions = {}): never {
|
|
1189
|
-
return statusSignal(status, options)
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
/** The wrapper `revalidate()` returns: the action's `data` plus the paths it changed. A plain tagged
|
|
1193
|
-
* shape (not a class) so `@nifrajs/client`'s `ActionData` can unwrap it structurally without importing
|
|
1194
|
-
* from `@nifrajs/web`. `createWebApp` strips the wrapper - the client receives `data` as the body and
|
|
1195
|
-
* the paths via the `X-Nifra-Revalidate` header. */
|
|
1196
|
-
export interface RevalidateResult<T> {
|
|
1197
|
-
readonly __nifraRevalidate: readonly string[]
|
|
1198
|
-
readonly data: T
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
/**
|
|
1202
|
-
* Return this from an action to declare which routes the mutation changed (alongside the action's
|
|
1203
|
-
* `data`). `createWebApp` sets the `X-Nifra-Revalidate` response header; after the submit the client
|
|
1204
|
-
* marks those cached routes stale - refetching the active one and any mounted fetcher showing them -
|
|
1205
|
-
* so a mutation can refresh views beyond the one that was submitted. `data` is still surfaced to the
|
|
1206
|
-
* component as `actionData` (the wrapper is transparent to `ActionData<typeof action>`).
|
|
1207
|
-
*/
|
|
1208
|
-
export function revalidate<T>(paths: readonly string[], data: T): RevalidateResult<T> {
|
|
1209
|
-
return { __nifraRevalidate: paths, data }
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
/**
|
|
1213
|
-
* Serialize loader data for embedding inside an inline `<script>`. `JSON.stringify` alone
|
|
1214
|
-
* is NOT safe there: a string containing `</script>` or `<!--` would break out of the
|
|
1215
|
-
* script element (an XSS vector). Escape `<`/`>` to `\uXXXX`, plus the U+2028/U+2029
|
|
1216
|
-
* separators.
|
|
1217
|
-
*/
|
|
1218
|
-
export function serializeData(data: unknown): string {
|
|
1219
|
-
const serialized = JSON.stringify(data ?? null)
|
|
1220
|
-
// Most loader payloads contain no script-sensitive characters. Guard with chained `indexOf`
|
|
1221
|
-
// rather than a regex `.test`: each probe is a native memchr-style scan, measured ~7x faster than
|
|
1222
|
-
// the regex scan on V8 for a multi-KB payload (and the engine rejects the two >0xFF separator
|
|
1223
|
-
// probes in O(1) when the string is internally one-byte, which serialized JSON almost always is).
|
|
1224
|
-
// The same four characters are probed as the SCRIPT_ESCAPE class, so any payload the regex would
|
|
1225
|
-
// escape still takes the escape path - this is only a cheaper detector, never a security
|
|
1226
|
-
// relaxation.
|
|
1227
|
-
const needsEscape =
|
|
1228
|
-
serialized.indexOf("<") !== -1 ||
|
|
1229
|
-
serialized.indexOf(">") !== -1 ||
|
|
1230
|
-
serialized.indexOf(LINE_SEP) !== -1 ||
|
|
1231
|
-
serialized.indexOf(PARA_SEP) !== -1
|
|
1232
|
-
return needsEscape
|
|
1233
|
-
? serialized.replace(SCRIPT_ESCAPE, (ch) => SCRIPT_ESCAPE_MAP[ch] ?? ch)
|
|
1234
|
-
: serialized
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
function escapeHtml(value: string): string {
|
|
1238
|
-
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
/**
|
|
1242
|
-
* Escape a `<script>` element's text content so it cannot break out of the element. Per the HTML spec,
|
|
1243
|
-
* a script's text may contain any character EXCEPT the sequences that the parser treats as element
|
|
1244
|
-
* boundaries: `</` (which would begin the end tag - `</script>` is the obvious XSS vector, but ANY `</`
|
|
1245
|
-
* starts script-data-end-tag parsing), and the comment/CDATA edges `<!--` / `]]>`. We escape the `<`
|
|
1246
|
-
* (and the `>` of `]]>`) to its JS unicode escape: inside a JS/JSON string literal `<` is the
|
|
1247
|
-
* identical character, so a JSON-LD payload is byte-equivalent after parsing - but the raw `<`/`>` the
|
|
1248
|
-
* HTML tokenizer scans for is gone. This is the JSON-LD-in-HTML rule (content is JSON, never raw HTML),
|
|
1249
|
-
* mirroring {@link serializeData}'s posture for the data-global script. Idempotent on already-safe text.
|
|
1250
|
-
*/
|
|
1251
|
-
function escapeScriptContent(content: string): string {
|
|
1252
|
-
// Order matters: rewrite `]]>` first (its `>` becomes `>`), then every `<` (covers `</` and
|
|
1253
|
-
// `<!--` in one pass). `<` → `<`; the lone `>` of `]]>` → `>`. A bare `>` elsewhere is
|
|
1254
|
-
// harmless in script data, so only the `]]>` close is targeted for `>`.
|
|
1255
|
-
return content.replaceAll("]]>", "]]\\u003e").replaceAll("<", "\\u003c")
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
function escapeAttr(value: string): string {
|
|
1259
|
-
return value
|
|
1260
|
-
.replaceAll("&", "&")
|
|
1261
|
-
.replaceAll('"', """)
|
|
1262
|
-
.replaceAll("<", "<")
|
|
1263
|
-
.replaceAll(">", ">")
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
/** Serialize attributes that passed the shared SSR/client head trust policy. */
|
|
1267
|
-
function tagAttrs(tag: "meta" | "link", attrs: Readonly<object>): string | null {
|
|
1268
|
-
const trusted = trustedHeadAttributes(tag, attrs)
|
|
1269
|
-
if (trusted === null) return null
|
|
1270
|
-
let out = ""
|
|
1271
|
-
for (const [name, value] of trusted) {
|
|
1272
|
-
if (out !== "") out += " "
|
|
1273
|
-
out += value === true ? name : `${name}="${escapeAttr(value)}"`
|
|
1274
|
-
}
|
|
1275
|
-
return out
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
// Memoize the serialized head-tag string by the resolved `Meta` object's identity. A STATIC route
|
|
1279
|
-
// `meta` is returned by-reference from resolveMeta, so the same object recurs every request → cache
|
|
1280
|
-
// hit, and the invariant string is serialized once per route, not per request. A FUNCTION `meta`
|
|
1281
|
-
// builds a fresh object each request (new identity) → miss → recompute, exactly as needed (its
|
|
1282
|
-
// content can vary). WeakMap so a route module that's GC'd takes its entry with it.
|
|
1283
|
-
const headTagsCache = new WeakMap<Meta, string>()
|
|
1284
|
-
|
|
1285
|
-
function assertExecutableScriptType(type: string): void {
|
|
1286
|
-
if (!EXECUTABLE_SCRIPT_TYPES.has(type)) {
|
|
1287
|
-
throw new TypeError(
|
|
1288
|
-
`[nifra/web] executable inline scripts accept ${[...EXECUTABLE_SCRIPT_TYPES].map((t) => JSON.stringify(t)).join(" or ")}; received ${JSON.stringify(type)}`,
|
|
1289
|
-
)
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
/** Render a route's `meta`/`link`/`script` as managed (`data-nifra`) head tags. Title is set
|
|
1294
|
-
* separately. XSS-safe by construction: every attribute flows through the shared tag-specific trust
|
|
1295
|
-
* policy then value escaping, and each `script[].content` through `escapeScriptContent`
|
|
1296
|
-
* (breakout-escaped) - so loader-derived strings (LLM-authored `og:*`, user content) can't inject markup
|
|
1297
|
-
* or close the tag early. String concatenation (no intermediate `.map()` arrays + spread) - parity with
|
|
1298
|
-
* the already concat-based preloadLinks/styleLinks/islandPreloads loops; byte-identical output. Result
|
|
1299
|
-
* is memoized per resolved-`Meta` identity (static meta → serialized once per route). */
|
|
1300
|
-
function headTags(head: Meta | undefined): string {
|
|
1301
|
-
if (head === undefined) return ""
|
|
1302
|
-
const cached = headTagsCache.get(head)
|
|
1303
|
-
if (cached !== undefined) return cached
|
|
1304
|
-
let out = ""
|
|
1305
|
-
if (head.meta !== undefined)
|
|
1306
|
-
for (const m of head.meta) {
|
|
1307
|
-
const attrs = tagAttrs("meta", m)
|
|
1308
|
-
if (attrs !== null) out += `<meta${attrs === "" ? "" : ` ${attrs}`} data-nifra>`
|
|
1309
|
-
}
|
|
1310
|
-
if (head.link !== undefined)
|
|
1311
|
-
for (const l of head.link) {
|
|
1312
|
-
const attrs = tagAttrs("link", l)
|
|
1313
|
-
if (attrs !== null) out += `<link${attrs === "" ? "" : ` ${attrs}`} data-nifra>`
|
|
1314
|
-
}
|
|
1315
|
-
// `<script>` slot - JSON-LD + other inert head scripts. `type` is attribute-escaped; `content` is
|
|
1316
|
-
// breakout-escaped (`escapeScriptContent`) so a `</script>` (or `<!--`/`]]>`) payload can't close the
|
|
1317
|
-
// element early. Managed (`data-nifra`) so a soft-nav's `applyHead` cleanly replaces it.
|
|
1318
|
-
if (head.script !== undefined)
|
|
1319
|
-
for (const s of head.script) {
|
|
1320
|
-
const type = s.type ?? "application/ld+json"
|
|
1321
|
-
if (!INERT_SCRIPT_TYPES.has(type)) {
|
|
1322
|
-
throw new TypeError(
|
|
1323
|
-
`[nifra/web] head.script only accepts inert JSON script types; received ${JSON.stringify(type)}. Use unsafeInlineScript() with a CSP nonce for executable code.`,
|
|
1324
|
-
)
|
|
1325
|
-
}
|
|
1326
|
-
out += `<script type="${type}" data-nifra>${escapeScriptContent(s.content)}</script>`
|
|
1327
|
-
}
|
|
1328
|
-
if (head.unsafeScript !== undefined)
|
|
1329
|
-
for (const s of head.unsafeScript) {
|
|
1330
|
-
if (s.unsafe !== true || s.nonce.trim() === "") {
|
|
1331
|
-
throw new TypeError("[nifra/web] executable inline scripts require a non-empty CSP nonce")
|
|
1332
|
-
}
|
|
1333
|
-
// `type` is validated, not escaped, for the same reason as the inert slot above: an unknown
|
|
1334
|
-
// value here is a bug, and escaping one would emit a nonsense script type rather than say so.
|
|
1335
|
-
// It was interpolated raw while the `nonce` beside it was escaped, so a descriptor built by hand
|
|
1336
|
-
// - `unsafeScript` is a public field, the helper is only a convenience - could close the
|
|
1337
|
-
// attribute and inject markup. Measured: `type: '"><img src=x onerror=alert(1)>'` reached the
|
|
1338
|
-
// document. An injection in the slot whose whole purpose is to make script emission trustworthy.
|
|
1339
|
-
assertExecutableScriptType(s.type)
|
|
1340
|
-
out += `<script type="${s.type}" nonce="${escapeAttr(s.nonce)}" data-nifra>${escapeScriptContent(s.content)}</script>`
|
|
1341
|
-
}
|
|
1342
|
-
headTagsCache.set(head, out)
|
|
1343
|
-
return out
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
/**
|
|
1347
|
-
* A `<link rel="canonical">` descriptor for a route's `meta.link`. The canonical URL tells search
|
|
1348
|
-
* engines which URL is authoritative for a page (deduping query-string / tracking variants).
|
|
1349
|
-
*
|
|
1350
|
-
* ```ts
|
|
1351
|
-
* export const meta = (a) => ({ link: [canonical(`https://site.com/posts/${a.params.slug}`)] })
|
|
1352
|
-
* ```
|
|
1353
|
-
*/
|
|
1354
|
-
export function canonical(href: string): LinkDescriptor {
|
|
1355
|
-
return { rel: "canonical", href }
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
/** Inputs for {@link openGraph} - the common Open Graph properties. All optional; only the provided
|
|
1359
|
-
* ones become tags. `type` defaults to `"website"`. */
|
|
1360
|
-
export interface OpenGraphInput {
|
|
1361
|
-
readonly title?: string
|
|
1362
|
-
readonly description?: string
|
|
1363
|
-
/** Absolute URL of the share image (`og:image`). */
|
|
1364
|
-
readonly image?: string
|
|
1365
|
-
/** Canonical URL of the page (`og:url`). */
|
|
1366
|
-
readonly url?: string
|
|
1367
|
-
/** Object type (`og:type`) - `"website"`, `"article"`, … Default `"website"`. */
|
|
1368
|
-
readonly type?: string
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
/**
|
|
1372
|
-
* Build the Open Graph `<meta property="og:*">` entries for a route's `meta.meta`. Returns only the
|
|
1373
|
-
* properties you supplied (plus `og:type`, defaulting to `"website"`), so it composes with other meta.
|
|
1374
|
-
*
|
|
1375
|
-
* ```ts
|
|
1376
|
-
* export const meta = { meta: [...openGraph({ title: "Nifra", image: "https://site.com/og.png" })] }
|
|
1377
|
-
* ```
|
|
1378
|
-
*/
|
|
1379
|
-
export function openGraph(input: OpenGraphInput): MetaDescriptor[] {
|
|
1380
|
-
const tags: MetaDescriptor[] = []
|
|
1381
|
-
const add = (property: string, content: string | undefined): void => {
|
|
1382
|
-
if (content !== undefined) tags.push({ property, content })
|
|
1383
|
-
}
|
|
1384
|
-
add("og:title", input.title)
|
|
1385
|
-
add("og:description", input.description)
|
|
1386
|
-
add("og:image", input.image)
|
|
1387
|
-
add("og:url", input.url)
|
|
1388
|
-
// `og:type` always present (the spec's required property) - default "website" when unset.
|
|
1389
|
-
tags.push({ property: "og:type", content: input.type ?? "website" })
|
|
1390
|
-
return tags
|
|
1391
|
-
}
|
|
1392
|
-
|
|
1393
|
-
/**
|
|
1394
|
-
* Build a JSON-LD `<script type="application/ld+json">` entry for a route's `meta.script` from a plain
|
|
1395
|
-
* object. `JSON.stringify` produces the body; the head renderer breakout-escapes it (see
|
|
1396
|
-
* `escapeScriptContent`), so a string field containing `</script>` is embedded safely.
|
|
1397
|
-
*
|
|
1398
|
-
* ```ts
|
|
1399
|
-
* export const meta = {
|
|
1400
|
-
* script: [jsonLd({ "@context": "https://schema.org", "@type": "Article", headline: "Hi" })],
|
|
1401
|
-
* }
|
|
1402
|
-
* ```
|
|
1403
|
-
*/
|
|
1404
|
-
export function jsonLd(data: Record<string, unknown>): ScriptDescriptor {
|
|
1405
|
-
return { type: "application/ld+json", content: JSON.stringify(data) }
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
/**
|
|
1409
|
-
* Deliberately unsafe escape hatch for executable inline code. The required nonce keeps the result
|
|
1410
|
-
* compatible with a strict CSP and makes the security-sensitive choice visible at the call site.
|
|
1411
|
-
*/
|
|
1412
|
-
export function unsafeInlineScript(
|
|
1413
|
-
content: string,
|
|
1414
|
-
options: { readonly nonce: string; readonly type?: "module" | "text/javascript" },
|
|
1415
|
-
): UnsafeScriptDescriptor {
|
|
1416
|
-
if (options.nonce.trim() === "") {
|
|
1417
|
-
throw new TypeError("[nifra/web] unsafeInlineScript requires a non-empty CSP nonce")
|
|
1418
|
-
}
|
|
1419
|
-
const type = options.type ?? "module"
|
|
1420
|
-
// Checked here as well as at emit: the type union is compile-time only, and this helper is the one
|
|
1421
|
-
// place a caller is told what it may pass. Failing at the call site names the argument; failing at
|
|
1422
|
-
// render names a document.
|
|
1423
|
-
assertExecutableScriptType(type)
|
|
1424
|
-
return {
|
|
1425
|
-
unsafe: true,
|
|
1426
|
-
type,
|
|
1427
|
-
nonce: options.nonce,
|
|
1428
|
-
content,
|
|
1429
|
-
}
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
export interface CreateWebAppOptions<Env = unknown> {
|
|
1433
|
-
readonly adapter: RenderAdapter
|
|
1434
|
-
readonly manifest: Manifest
|
|
1435
|
-
/** URL of the built client entry (module script) injected into every page. */
|
|
1436
|
-
readonly clientEntry: string
|
|
1437
|
-
/** Default document title for all pages. */
|
|
1438
|
-
readonly title?: string
|
|
1439
|
-
/**
|
|
1440
|
-
* Options for the underlying `server()` - `requestTimeoutMs`, `admission`, `gracefulSignals`, and
|
|
1441
|
-
* the rest of {@link ServerOptions}.
|
|
1442
|
-
*
|
|
1443
|
-
* Needed because the `Server` is constructed in here, so a caller has no other way to reach its
|
|
1444
|
-
* constructor: a page app could set no request timeout and no capacity gate, which are exactly the
|
|
1445
|
-
* two knobs a production readiness check looks for. An SSR app wants them MORE than a backend does -
|
|
1446
|
-
* a render is slow and allocation-heavy, so a burst that a JSON API would absorb is the one that
|
|
1447
|
-
* exhausts the pod.
|
|
1448
|
-
*
|
|
1449
|
-
* Applies to the whole app, {@link mounts} and the {@link api} auto-mount included: the capacity gate
|
|
1450
|
-
* sits at the fetch entry, ahead of the request hooks those are dispatched from.
|
|
1451
|
-
*/
|
|
1452
|
-
readonly server?: ServerOptions
|
|
1453
|
-
/**
|
|
1454
|
-
* Runs against the app BEFORE any page route is registered - the seam for `securityHeaders()`,
|
|
1455
|
-
* `requestId()`, `logger()`, a rate limit, or anything else applied with `use`.
|
|
1456
|
-
*
|
|
1457
|
-
* ```ts
|
|
1458
|
-
* createWebApp({
|
|
1459
|
-
* …,
|
|
1460
|
-
* use: (app) => {
|
|
1461
|
-
* app.use(securityHeaders())
|
|
1462
|
-
* app.use(requestId())
|
|
1463
|
-
* },
|
|
1464
|
-
* })
|
|
1465
|
-
* ```
|
|
1466
|
-
*
|
|
1467
|
-
* A callback rather than an array because `use` is overloaded - `Middleware`, `IdentityPlugin`,
|
|
1468
|
-
* `ContextPlugin` - and only the real call site instantiates the right overload. The return value
|
|
1469
|
-
* is ignored: this is for cross-cutting concerns, not for declaring routes (declare those on the
|
|
1470
|
-
* returned app, where they get their types).
|
|
1471
|
-
*
|
|
1472
|
-
* **Why the timing matters.** `beforeHandle`, `afterHandle`, `around`, `derive`/`decorate`, and
|
|
1473
|
-
* `onError` are snapshotted into each route AS IT IS DECLARED, and `createWebApp` declares every
|
|
1474
|
-
* page (plus the `/*` catch-all) before it returns - so a caller's `app.use(…)` afterwards binds
|
|
1475
|
-
* them to nothing, silently. `requestId()` is exactly that case: it is a `derive`, so applied late
|
|
1476
|
-
* it leaves every page without `c.requestId`. Route assurance evidence is order-scoped the same
|
|
1477
|
-
* way, so a late `securityHeaders()` leaves the pages unable to PROVE the header to `nifra assure`
|
|
1478
|
-
* even though it does still set it.
|
|
1479
|
-
*
|
|
1480
|
-
* `onRequest`/`onResponse` (and the response header/body hooks) are app-global arrays read at
|
|
1481
|
-
* request time, so those alone DO work when added late. Using this seam for everything avoids
|
|
1482
|
-
* having to remember which is which.
|
|
1483
|
-
*
|
|
1484
|
-
* Runs ahead of the {@link mounts} / {@link api} request hooks, so an `onRequest` middleware guards
|
|
1485
|
-
* a mounted auth handler too.
|
|
1486
|
-
*
|
|
1487
|
-
* A `ContextPlugin` applied here takes runtime effect but cannot widen the declared return type of
|
|
1488
|
-
* this function, so `c.requestId` is not typed on routes you declare afterwards. Named plugins are
|
|
1489
|
-
* idempotent, so `app.use(requestId())` on the returned app recovers the TYPE without applying the
|
|
1490
|
-
* plugin a second time.
|
|
1491
|
-
*/
|
|
1492
|
-
readonly use?: (app: ReturnType<typeof server<Env>>) => void
|
|
1493
|
-
/** Injected into each loader's `ctx.api` - typically an `inProcessClient(app)` (typed
|
|
1494
|
-
* per-route via `@nifrajs/client`'s `createRoutes`). Opaque to the core.
|
|
1495
|
-
*
|
|
1496
|
-
* **Auto-mount.** Every `inProcessClient(backend)` exposes the symbol-keyed platform-aware backend
|
|
1497
|
-
* mount interface from `@nifrajs/core/mount`; `createWebApp` also serves that backend over HTTP at
|
|
1498
|
-
* {@link apiPrefix} (default `/api`): a request whose pathname starts with the prefix is dispatched
|
|
1499
|
-
* before page routing with the same `env`/`waitUntil` platform context, and the backend's `Response`
|
|
1500
|
-
* is returned untouched. The mount runs in `nifra dev` too. Pass `apiPrefix: ""` to disable it. */
|
|
1501
|
-
readonly api?: unknown
|
|
1502
|
-
/** HTTP path prefix the {@link api} backend is auto-mounted at (default `"/api"`). A request whose
|
|
1503
|
-
* pathname is exactly the prefix or starts with `prefix + "/"` is dispatched to the backend before
|
|
1504
|
-
* page routing; the backend therefore defines its routes at the **full** path (`server().post("/api/
|
|
1505
|
-
* sync", …)`), matching the in-process `inProcessClient` call sites. Set to `""` to disable the
|
|
1506
|
-
* auto-mount entirely (the app serves pages only and `api` stays a loader-only `ctx.api`). Mounting
|
|
1507
|
-
* is also a no-op when `api` does not expose the symbol mount. */
|
|
1508
|
-
readonly apiPrefix?: string
|
|
1509
|
-
/**
|
|
1510
|
-
* Strip {@link apiPrefix} from the pathname before dispatching to `api` (default `false`).
|
|
1511
|
-
*
|
|
1512
|
-
* The default suits a backend that declares FULL paths (`server().post("/api/sync", …)`), which is
|
|
1513
|
-
* right when it is only ever mounted here. Set this when the backend declares its routes WITHOUT the
|
|
1514
|
-
* prefix because it also runs standalone behind its own shell, so its paths cannot carry a prefix that
|
|
1515
|
-
* only exists when it is mounted. Without it every request 404s inside the backend, and the workaround
|
|
1516
|
-
* is a `Proxy` that rewrites each URL.
|
|
1517
|
-
*/
|
|
1518
|
-
readonly apiStrip?: boolean
|
|
1519
|
-
/**
|
|
1520
|
-
* Sub-apps mounted ahead of page routing - an auth handler, a webhook receiver, a stack's routes.
|
|
1521
|
-
*
|
|
1522
|
-
* Structural on purpose: anything with `{ path, app: { fetch } }` fits, so a library that exposes its
|
|
1523
|
-
* routes as such a list mounts as `mounts: theirRoutes` without `@nifrajs/web` taking a dependency on
|
|
1524
|
-
* it. `better-auth` is the motivating case - it is not a `backend` route, so `/api/auth/*` used to 404
|
|
1525
|
-
* silently.
|
|
1526
|
-
*
|
|
1527
|
-
* Tried longest-path-first and BEFORE the `api` mount, so a mount at `/api/auth` wins over a backend
|
|
1528
|
-
* at `/api` no matter which was declared first. `stripPrefix` is the per-mount form of {@link apiStrip}:
|
|
1529
|
-
* leave it off to pass the full path through.
|
|
1530
|
-
*/
|
|
1531
|
-
readonly mounts?: ReadonlyArray<{
|
|
1532
|
-
readonly path: string
|
|
1533
|
-
readonly app: { fetch(request: Request): Response | Promise<Response> }
|
|
1534
|
-
readonly stripPrefix?: boolean
|
|
1535
|
-
}>
|
|
1536
|
-
/** Secret for **draft / preview mode** (see `enableDraft`). When set, a request carrying a valid
|
|
1537
|
-
* signed `__nifra_draft` cookie gets `ctx.draft === true` in loaders/actions (else always `false`).
|
|
1538
|
-
* Pair with `withISR({ draftSecret })` so editors bypass the cache. Omit to disable draft mode. */
|
|
1539
|
-
readonly draftSecret?: string
|
|
1540
|
-
/** Per-route chunk URLs (`buildClient`'s `BuildManifest.routes`) - `routeId → [layout chunks…, own
|
|
1541
|
-
* chunk]`. When present, each page `modulepreload`s its matched route's chunks alongside the entry,
|
|
1542
|
-
* so the route code downloads in parallel (no entry→route-chunk waterfall). Omit ⇒ entry-only. */
|
|
1543
|
-
readonly routePreload?: Readonly<Record<string, readonly string[]>>
|
|
1544
|
-
/** The app's bundled stylesheet URLs (`buildClient`'s `BuildManifest.css`) - the aggregate, injected
|
|
1545
|
-
* as `<link rel="stylesheet">` in a page's `<head>`. Used as the fallback for any route absent from
|
|
1546
|
-
* {@link routeStyles}. Omit ⇒ no links (dev, where Vite injects CSS, or a CSS-free app). */
|
|
1547
|
-
readonly styles?: readonly string[]
|
|
1548
|
-
/** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) - `routeId → [chain CSS]`.
|
|
1549
|
-
* When a matched route has an entry here, only those (its layout chain + own CSS) are linked instead
|
|
1550
|
-
* of the aggregate `styles`, so a page ships only the CSS it uses. An empty array ⇒ no `<link>` (the
|
|
1551
|
-
* page imports no CSS). Routes absent here fall back to `styles`. Omit ⇒ always use `styles`. */
|
|
1552
|
-
readonly routeStyles?: Readonly<Record<string, readonly string[]>>
|
|
1553
|
-
/** SSG: the prerendered-path set (e.g. `enumerateStaticRoutes(routes).paths` or the build's
|
|
1554
|
-
* `prerendered.json`). Injected as `window.__NIFRA_PRERENDERED__` on every page so a client soft-nav
|
|
1555
|
-
* into a prerendered route fetches its static `_data.json` instead of hitting the worker. */
|
|
1556
|
-
readonly prerenderedPaths?: readonly string[]
|
|
1557
|
-
/** Publish the project's `AGENTS.md` inside `/llms.txt` and `/llms-full.txt`. **Off by default**:
|
|
1558
|
-
* those endpoints are public and unauthenticated, while `AGENTS.md` is a repo file written for the
|
|
1559
|
-
* team - unreleased feature names, internal hostnames, and "don't touch X yet" notes live there
|
|
1560
|
-
* routinely. Turn it on only for a repo whose guidelines you would publish as a page. */
|
|
1561
|
-
readonly publishLocalGuidelines?: boolean
|
|
1562
|
-
/** SSG: per dynamic route pattern, its `getStaticPaths` `fallback` (from `enumerateStaticRoutes` or
|
|
1563
|
-
* the build's `prerendered.json`). A route mapped to `"404"` rejects any path NOT in
|
|
1564
|
-
* `prerenderedPaths` with the 404 page - the unlisted path simply doesn't exist. `"ssr"` (the
|
|
1565
|
-
* default for unmapped routes) renders unlisted paths on-demand. */
|
|
1566
|
-
readonly staticFallbacks?: Readonly<Record<string, "ssr" | "404">>
|
|
1567
|
-
/** In-memory reference cache for explicitly `static` boundary values. It never persists payloads
|
|
1568
|
-
* across processes; operated/durable cache implementations stay outside the public framework. */
|
|
1569
|
-
readonly staticBoundaryCache?: import("./boundary.ts").StaticBoundaryCache
|
|
1570
|
-
/** Observe every loader/action failure - for error-reporting plugins (Sentry-style). Called for
|
|
1571
|
-
* real throws (not control-flow `Response`s like `redirect`), **before** the nearest `_error`
|
|
1572
|
-
* boundary renders / a soft-nav 500 / a rethrow - so it sees errors that the boundary would
|
|
1573
|
-
* otherwise hide. Observation only; its own throws are swallowed so a faulty reporter can't break
|
|
1574
|
-
* rendering. (`beforeLoader` is intentionally omitted - the core HTTP hooks already cover
|
|
1575
|
-
* pre-request work.) */
|
|
1576
|
-
readonly onLoaderError?: (
|
|
1577
|
-
error: unknown,
|
|
1578
|
-
ctx: {
|
|
1579
|
-
readonly request: Request
|
|
1580
|
-
readonly params: Readonly<Record<string, string>>
|
|
1581
|
-
readonly route: string
|
|
1582
|
-
},
|
|
1583
|
-
) => void
|
|
1584
|
-
}
|
|
1585
|
-
|
|
1586
|
-
/** The handler context fields createWebApp uses - a structural subset of nifra's `Context`. */
|
|
1587
|
-
interface RouteContext {
|
|
1588
|
-
readonly params: Record<string, string>
|
|
1589
|
-
readonly req: Request
|
|
1590
|
-
/** Platform bindings (Workers env), forwarded to each route's loader/action as `args.env`. */
|
|
1591
|
-
readonly env: unknown
|
|
1592
|
-
}
|
|
1593
|
-
|
|
1594
|
-
/**
|
|
1595
|
-
* Re-issue `request` with `prefix` removed from its pathname.
|
|
1596
|
-
*
|
|
1597
|
-
* Needed because two conventions exist and both are reasonable. `createWebApp`'s auto-mount assumes a
|
|
1598
|
-
* backend that declares FULL paths (`server().post("/api/sync")`), which is right when the backend is
|
|
1599
|
-
* only ever mounted here. A backend that also runs standalone declares paths WITHOUT the prefix and
|
|
1600
|
-
* lets its own shell supply it - and mounting one of those here used to require the caller to wrap it
|
|
1601
|
-
* in a `Proxy` that rewrote every URL. Two apps wrote that same wrapper independently, which is the
|
|
1602
|
-
* signal that it belonged in the framework.
|
|
1603
|
-
*
|
|
1604
|
-
* The `Request` is rebuilt rather than mutated (`url` is read-only), preserving method, headers, body,
|
|
1605
|
-
* and duplex streaming - the body is passed through unread, so a large or streamed upload is untouched.
|
|
1606
|
-
*/
|
|
1607
|
-
function stripMountPrefix(request: Request, prefix: string): Request {
|
|
1608
|
-
const url = new URL(request.url)
|
|
1609
|
-
const rest = url.pathname.slice(prefix.length)
|
|
1610
|
-
url.pathname = rest === "" ? "/" : rest
|
|
1611
|
-
// `url.href`, not the `URL`: this type set only declares the string and Request overloads.
|
|
1612
|
-
return new Request(url.href, request)
|
|
1613
|
-
}
|
|
1614
|
-
|
|
1615
|
-
/**
|
|
1616
|
-
* Resolve the explicit symbol-keyed backend mount interface. The symbol seam forwards platform
|
|
1617
|
-
* context without making web depend on client.
|
|
1618
|
-
*/
|
|
1619
|
-
function backendMountOf(api: unknown): BackendMountHandler | undefined {
|
|
1620
|
-
if ((typeof api !== "object" && typeof api !== "function") || api === null) return undefined
|
|
1621
|
-
const explicit = (api as Partial<BackendMount>)[NIFRA_BACKEND_MOUNT]
|
|
1622
|
-
if (typeof explicit !== "function") return undefined
|
|
1623
|
-
return (request, platform) => explicit.call(api, request, platform)
|
|
1624
|
-
}
|
|
1625
|
-
|
|
1626
|
-
// `createWebApp` reaches the same Request object through the mount hook and the page route. Cache the
|
|
1627
|
-
// allocation-light core split once so API mounting, path hydration, search parsing, and SSG fallback
|
|
1628
|
-
// checks do not each rescan the absolute URL. WeakMap keeps this bounded to requests that are alive.
|
|
1629
|
-
const REQUEST_URL_PARTS = new WeakMap<Request, UrlParts>()
|
|
1630
|
-
const urlPartsFor = (request: Request): UrlParts => {
|
|
1631
|
-
const cached = REQUEST_URL_PARTS.get(request)
|
|
1632
|
-
if (cached !== undefined) return cached
|
|
1633
|
-
const parts = urlPartsOf(request.url)
|
|
1634
|
-
REQUEST_URL_PARTS.set(request, parts)
|
|
1635
|
-
return parts
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
|
-
/**
|
|
1639
|
-
* Build a nifra app from a route manifest: every route SSRs its layout chain via `renderPage`,
|
|
1640
|
-
* and a wildcard catch-all renders `_404` (or a plain 404). Reuses @nifrajs/core's router +
|
|
1641
|
-
* lifecycle, so matching, params, and precedence are battle-tested. fs-free - feed it a
|
|
1642
|
-
* manifest from `discoverRoutes` (`@nifrajs/web/fs`) at startup, so the served app stays portable.
|
|
1643
|
-
*
|
|
1644
|
-
* **Typed platform bindings.** Pass `Env` - `createWebApp<Env>({ … })` - to declare the app's Workers
|
|
1645
|
-
* bindings ONCE. It seeds the returned `Server`'s context with `{ env: Env }` (exactly as the backend's
|
|
1646
|
-
* `server<Env>()` does), so `app.fetch(req, { env })` / `toFetchHandler(app)` type-check the `env`
|
|
1647
|
-
* argument against the declared shape - no per-binding cast at the edge entry. Per-route loaders/actions
|
|
1648
|
-
* stay typed independently of this call: annotate them with `@nifrajs/client`'s `LoaderArgs<Api, Env>`
|
|
1649
|
-
* (same `Env`) so `ctx.env.MY_KV` is typed there too. Omit the parameter and `Env` is `unknown` - the
|
|
1650
|
-
* secure default; validate at the trust boundary before use.
|
|
1651
|
-
*/
|
|
1652
|
-
export function createWebApp<Env = unknown>(
|
|
1653
|
-
options: CreateWebAppOptions<Env>,
|
|
1654
|
-
): ReturnType<typeof server<Env>> {
|
|
1655
|
-
const { adapter, manifest, clientEntry, title, api } = options
|
|
1656
|
-
const titleOption = title === undefined ? {} : { title }
|
|
1657
|
-
// Draft/preview: when a `draftSecret` is configured, each request's signed `__nifra_draft` cookie is
|
|
1658
|
-
// verified once and surfaced to loaders/actions as `ctx.draft`. No secret ⇒ always `false` (sync, free).
|
|
1659
|
-
const draftFlag = (req: Request): Promise<boolean> =>
|
|
1660
|
-
options.draftSecret === undefined
|
|
1661
|
-
? Promise.resolve(false)
|
|
1662
|
-
: isDraftEnabled(req, options.draftSecret)
|
|
1663
|
-
// Per-route preload chunks (spread into renderPage; omitted when unmapped, for exactOptionalPropertyTypes).
|
|
1664
|
-
const preloadOf = (id: string): { preload?: readonly string[] } => {
|
|
1665
|
-
const chunks = options.routePreload?.[id]
|
|
1666
|
-
return chunks ? { preload: chunks } : {}
|
|
1667
|
-
}
|
|
1668
|
-
// The matched route's stylesheet links (spread into renderPage). Per-route when the build mapped it
|
|
1669
|
-
// (`routeStyles[id]` - only the chain's CSS; an empty array ⇒ no `<link>`), else the aggregate
|
|
1670
|
-
// `styles`. Omitted entirely when CSS-free (for exactOptionalPropertyTypes).
|
|
1671
|
-
const stylesOf = (id: string): { styles?: readonly string[] } => {
|
|
1672
|
-
const perRoute = options.routeStyles?.[id]
|
|
1673
|
-
if (perRoute !== undefined) return { styles: perRoute }
|
|
1674
|
-
return options.styles && options.styles.length > 0 ? { styles: options.styles } : {}
|
|
1675
|
-
}
|
|
1676
|
-
// Seed the context with the declared `Env` so `app.fetch(req, { env })` / `toFetchHandler(app)` type
|
|
1677
|
-
// the platform bindings (see the `createWebApp` doc). The runtime `env` still arrives per-request via
|
|
1678
|
-
// `app.fetch(req, { env })`; this is a compile-time-only seed (`server<Env>()` casts, doesn't store).
|
|
1679
|
-
const app = server<Env>(options.server)
|
|
1680
|
-
// Before the mount hooks and before every page route, so the order-scoped hooks (`derive`,
|
|
1681
|
-
// `beforeHandle`, assurance evidence) actually bind to them - see the `use` option. A caller
|
|
1682
|
-
// cannot do this after the fact: by the time this function returns, the routes are declared.
|
|
1683
|
-
options.use?.(app)
|
|
1684
|
-
// Auto-mount the in-process backend over HTTP at `apiPrefix` (default `/api`), BEFORE page routing.
|
|
1685
|
-
// `onRequest` runs on the raw request ahead of the router, so this wins over the page wildcard `/*`
|
|
1686
|
-
// for every method (POST/GET/PUT/…) - not just the GET page routes - and a backend 404 (`/api/none`)
|
|
1687
|
-
// surfaces as the backend's own response, never the page 404. We dispatch the SAME `Request` object
|
|
1688
|
-
// (its body unread) so streamed/large bodies pass through untouched; the backend defines its routes
|
|
1689
|
-
// at the full prefixed path (`server().post("/api/sync", …)`), matching the `inProcessClient` call
|
|
1690
|
-
// sites. `apiPrefix: ""` opts out; a non-mountable `api` (no symbol mount) is left pages-only. The hook
|
|
1691
|
-
// is registered ONLY when both hold, so a pages-only app keeps core's synchronous no-hook fast path.
|
|
1692
|
-
const apiPrefix = options.apiPrefix ?? "/api"
|
|
1693
|
-
const apiStrip = options.apiStrip === true
|
|
1694
|
-
const mountedApi = backendMountOf(api)
|
|
1695
|
-
// Longest path first, so a more specific mount (`/api/auth`) is tried before a broader one (`/api`)
|
|
1696
|
-
// regardless of the order they were declared in.
|
|
1697
|
-
const mounts = [...(options.mounts ?? [])].sort((a, b) => b.path.length - a.path.length)
|
|
1698
|
-
const underPrefix = (pathname: string, prefix: string): boolean =>
|
|
1699
|
-
pathname === prefix || pathname.startsWith(`${prefix}/`)
|
|
1700
|
-
|
|
1701
|
-
if (mounts.length > 0 || (apiPrefix !== "" && mountedApi !== undefined)) {
|
|
1702
|
-
app.onRequest((req, platform) => {
|
|
1703
|
-
const { pathname } = urlPartsFor(req)
|
|
1704
|
-
// Sub-app mounts win over the backend prefix: they are the more specific declaration, and an
|
|
1705
|
-
// auth handler mounted at `/api/auth` must not be swallowed by the backend mounted at `/api`.
|
|
1706
|
-
for (const mount of mounts) {
|
|
1707
|
-
if (!underPrefix(pathname, mount.path)) continue
|
|
1708
|
-
return mount.app.fetch(mount.stripPrefix === true ? stripMountPrefix(req, mount.path) : req)
|
|
1709
|
-
}
|
|
1710
|
-
// Exactly the prefix (`/api`) or a sub-path (`/api/…`) - NOT a sibling like `/apixyz` that merely
|
|
1711
|
-
// shares the prefix as a string head. Dispatch the original `req` (body intact) to the backend.
|
|
1712
|
-
if (mountedApi !== undefined && apiPrefix !== "" && underPrefix(pathname, apiPrefix)) {
|
|
1713
|
-
return mountedApi(apiStrip ? stripMountPrefix(req, apiPrefix) : req, platform)
|
|
1714
|
-
}
|
|
1715
|
-
return undefined // not an API path → continue to page routing
|
|
1716
|
-
})
|
|
1717
|
-
}
|
|
1718
|
-
// SSG `fallback: "404"`: the set of concrete paths that actually exist for those routes. An unlisted
|
|
1719
|
-
// path under a `"404"` route is rejected (it isn't a static file, and the route declared it shouldn't
|
|
1720
|
-
// SSR on demand). O(1) membership via a Set.
|
|
1721
|
-
const prerenderedSet = new Set(options.prerenderedPaths ?? [])
|
|
1722
|
-
const matchManifestRoute = createMatcher(
|
|
1723
|
-
manifest.routes.map((route) => ({ routeId: route.id, pattern: route.pattern })),
|
|
1724
|
-
)
|
|
1725
|
-
const routeById = new Map(manifest.routes.map((route) => [route.id, route]))
|
|
1726
|
-
|
|
1727
|
-
// Load a route's layout modules (outermost layout → innermost), keeping each module whole so the
|
|
1728
|
-
// render path can take both its `default` (the component chain) AND its `meta` (the sitewide head it
|
|
1729
|
-
// contributes). Loaded lazily - the data-only and 405 branches return before any layout is needed.
|
|
1730
|
-
const loadLayoutModules = async (route: RouteEntry): Promise<LoadedLayoutModules> => {
|
|
1731
|
-
const modules = await Promise.all(
|
|
1732
|
-
// layoutIds only reference layouts present in the manifest (buildManifest invariant).
|
|
1733
|
-
route.layoutIds.map((id) => (manifest.layouts[id] as LayoutEntry).load()),
|
|
1734
|
-
)
|
|
1735
|
-
// A layout is not a form target, so an `action` there is a mistake with no meaning to give it.
|
|
1736
|
-
// A `loader`, by contrast, now runs (see `runLayoutChain`).
|
|
1737
|
-
for (let i = 0; i < modules.length; i++) {
|
|
1738
|
-
if ((modules[i] as { action?: unknown }).action === undefined) continue
|
|
1739
|
-
const id = route.layoutIds[i] as string
|
|
1740
|
-
throw new Error(
|
|
1741
|
-
`[nifra/web] "${(manifest.layouts[id] as LayoutEntry).file}" exports an \`action\`, but layouts do not run one - only route files do. Move the mutation into the route that submits to it.`,
|
|
1742
|
-
)
|
|
1743
|
-
}
|
|
1744
|
-
return modules
|
|
1745
|
-
}
|
|
1746
|
-
|
|
1747
|
-
/**
|
|
1748
|
-
* Run a route's layout-chain loaders, honouring declared gates.
|
|
1749
|
-
*
|
|
1750
|
-
* Returns once every **gate** has resolved, with the non-gate loaders still in flight, so the caller
|
|
1751
|
-
* can start the page loader at exactly the right moment: after anything that might reject the
|
|
1752
|
-
* request, alongside anything that merely fetches.
|
|
1753
|
-
*
|
|
1754
|
-
* The ordering is the security-relevant part. Parallel loaders are NOT an authorization boundary -
|
|
1755
|
-
* a page loader running concurrently with a guard has already queried by the time the guard says no.
|
|
1756
|
-
* Remix documents that as a footgun; here it is a declaration instead. `export const gate = true`
|
|
1757
|
-
* makes a layout blocking for everything beneath it, and nothing beneath a rejected gate runs.
|
|
1758
|
-
*
|
|
1759
|
-
* Each layout receives only the params it OWNS ({@link RouteEntry.layoutParams}), not the route's
|
|
1760
|
-
* full set, so a layout at `orgs/[org]/` cannot read a param belonging to a route below it.
|
|
1761
|
-
*/
|
|
1762
|
-
/**
|
|
1763
|
-
* Layout indices a client navigation asked to keep, from {@link RETAIN_HEADER}.
|
|
1764
|
-
*
|
|
1765
|
-
* The CLIENT decides retainability: it knows each layout's scoped params (shipped in the bootstrap)
|
|
1766
|
-
* and both the old and new match, so it can tell whether a layout's own prefix changed - without the
|
|
1767
|
-
* server re-matching a path the client already matched.
|
|
1768
|
-
*
|
|
1769
|
-
* It is a HINT, not an instruction. The client is asking to keep data it already holds, which is
|
|
1770
|
-
* harmless for data and unacceptable for a guard, so `runLayoutChain` refuses to skip a gate no
|
|
1771
|
-
* matter what arrives here. Anything unparseable is ignored rather than rejected: the worst case of
|
|
1772
|
-
* a bad hint is a loader that runs when it need not.
|
|
1773
|
-
*/
|
|
1774
|
-
const retainedIndices = (header: string | null): ReadonlySet<number> => {
|
|
1775
|
-
if (header === null || header === "") return EMPTY_RETAIN
|
|
1776
|
-
const out = new Set<number>()
|
|
1777
|
-
for (const part of header.split(",")) {
|
|
1778
|
-
// Digits only. `Number("")` is 0 and `Number(" ")` is 0, so a stray comma would otherwise read
|
|
1779
|
-
// as "retain index 0" and silently skip the outermost layout.
|
|
1780
|
-
if (!/^\d+$/.test(part)) continue
|
|
1781
|
-
out.add(Number(part))
|
|
1782
|
-
}
|
|
1783
|
-
return out
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
|
-
/**
|
|
1787
|
-
* Validate a data-navigation's retain request against the manifest and its declared source path.
|
|
1788
|
-
* A client may ask to retain every slot it currently has; the server only honours indices whose
|
|
1789
|
-
* layout identity and owned params are unchanged. Hard document GETs never retain anything.
|
|
1790
|
-
*/
|
|
1791
|
-
const validatedRetainedIndices = (
|
|
1792
|
-
req: Request,
|
|
1793
|
-
route: RouteEntry,
|
|
1794
|
-
params: Readonly<Record<string, string>>,
|
|
1795
|
-
): ReadonlySet<number> => {
|
|
1796
|
-
if (req.headers.get(DATA_HEADER) === null) return EMPTY_RETAIN
|
|
1797
|
-
const requested = retainedIndices(req.headers.get(RETAIN_HEADER))
|
|
1798
|
-
const from = req.headers.get(NAV_FROM_HEADER)
|
|
1799
|
-
if (requested.size === 0 || from === null) return EMPTY_RETAIN
|
|
1800
|
-
let fromPath: string
|
|
1801
|
-
try {
|
|
1802
|
-
const current = new URL(req.url)
|
|
1803
|
-
const source = new URL(from, current)
|
|
1804
|
-
if (source.origin !== current.origin) return EMPTY_RETAIN
|
|
1805
|
-
fromPath = source.pathname + source.search
|
|
1806
|
-
} catch {
|
|
1807
|
-
return EMPTY_RETAIN
|
|
1808
|
-
}
|
|
1809
|
-
const previousMatch = matchManifestRoute(fromPath)
|
|
1810
|
-
if (previousMatch === null) return EMPTY_RETAIN
|
|
1811
|
-
const previous = routeById.get(previousMatch.routeId)
|
|
1812
|
-
if (previous === undefined) return EMPTY_RETAIN
|
|
1813
|
-
const valid = new Set<number>()
|
|
1814
|
-
for (const index of requested) {
|
|
1815
|
-
if (route.layoutIds[index] !== previous.layoutIds[index]) continue
|
|
1816
|
-
const owned = route.layoutParams?.[index] ?? []
|
|
1817
|
-
const previouslyOwned = previous.layoutParams?.[index] ?? []
|
|
1818
|
-
if (
|
|
1819
|
-
owned.length !== previouslyOwned.length ||
|
|
1820
|
-
owned.some((name, i) => name !== previouslyOwned[i]) ||
|
|
1821
|
-
owned.some((name) => params[name] !== previousMatch.params[name])
|
|
1822
|
-
) {
|
|
1823
|
-
continue
|
|
1824
|
-
}
|
|
1825
|
-
valid.add(index)
|
|
1826
|
-
}
|
|
1827
|
-
return valid
|
|
1828
|
-
}
|
|
1829
|
-
|
|
1830
|
-
// The validated (or raw-parsed) search a route's loader context receives; `searchOf` fails closed to
|
|
1831
|
-
// the schema's defaults on hostile input, so this never throws on an attacker-picked query.
|
|
1832
|
-
const loaderSearch = (searchSchema: RouteModule["searchSchema"], request: Request) =>
|
|
1833
|
-
searchOf(searchSchema, urlPartsFor(request).search)
|
|
1834
|
-
|
|
1835
|
-
// The route's EFFECTIVE search once its layout chain is loaded: the layout schemas merged with the
|
|
1836
|
-
// page's (page-wins), the same chain the client mount builds for `useSearch`. Used for the page loader's
|
|
1837
|
-
// `ctx.search` and the render's `RenderProps.search`, so both agree with the client and with each other.
|
|
1838
|
-
const searchChainOf = (
|
|
1839
|
-
layoutMods: LoadedLayoutModules,
|
|
1840
|
-
mod: RouteModule,
|
|
1841
|
-
request: Request,
|
|
1842
|
-
): Record<string, unknown> =>
|
|
1843
|
-
searchOfChain(
|
|
1844
|
-
[...layoutMods.map((m) => m.searchSchema), mod.searchSchema],
|
|
1845
|
-
urlPartsFor(request).search,
|
|
1846
|
-
)
|
|
1847
|
-
|
|
1848
|
-
const runLayoutChain = async (
|
|
1849
|
-
route: RouteEntry,
|
|
1850
|
-
ctx: LoaderContext,
|
|
1851
|
-
retain: ReadonlySet<number> = new Set(),
|
|
1852
|
-
): Promise<{
|
|
1853
|
-
readonly modules: LoadedLayoutModules
|
|
1854
|
-
readonly layoutData: readonly unknown[] | undefined
|
|
1855
|
-
readonly retained: readonly number[]
|
|
1856
|
-
readonly pending: Promise<unknown>
|
|
1857
|
-
}> => {
|
|
1858
|
-
const modules = await loadLayoutModules(route)
|
|
1859
|
-
if (!modules.some((m) => m.loader !== undefined)) {
|
|
1860
|
-
// Nothing to run: no array is produced, so a page-only app serializes nothing extra and its
|
|
1861
|
-
// HTML is byte-identical to before this feature.
|
|
1862
|
-
return { modules, layoutData: undefined, retained: [], pending: Promise.resolve() }
|
|
1863
|
-
}
|
|
1864
|
-
const results: unknown[] = new Array(modules.length).fill(null)
|
|
1865
|
-
const retained: number[] = []
|
|
1866
|
-
const pending: Array<Promise<void>> = []
|
|
1867
|
-
for (let i = 0; i < modules.length; i++) {
|
|
1868
|
-
const loader = modules[i]?.loader
|
|
1869
|
-
if (loader === undefined) continue
|
|
1870
|
-
// Unchanged scope: the client keeps what it has. A GATE is never skipped - it is a guard, and a
|
|
1871
|
-
// guard that only runs when the URL prefix changed is not a guard.
|
|
1872
|
-
if (retain.has(i) && modules[i]?.gate !== true) {
|
|
1873
|
-
retained.push(i)
|
|
1874
|
-
continue
|
|
1875
|
-
}
|
|
1876
|
-
const scoped: LoaderContext = {
|
|
1877
|
-
...ctx,
|
|
1878
|
-
params: scopeParams(ctx.params, route.layoutParams?.[i]),
|
|
1879
|
-
}
|
|
1880
|
-
if (modules[i]?.gate === true) {
|
|
1881
|
-
// Blocking: awaited here, so a throw propagates before anything deeper is started.
|
|
1882
|
-
try {
|
|
1883
|
-
results[i] = await loader(scoped)
|
|
1884
|
-
} catch (err) {
|
|
1885
|
-
throw tagLayoutError(err, route.layoutIds[i] as string)
|
|
1886
|
-
}
|
|
1887
|
-
continue
|
|
1888
|
-
}
|
|
1889
|
-
const layoutId = route.layoutIds[i] as string
|
|
1890
|
-
const index = i
|
|
1891
|
-
// The loader is invoked INSIDE the async function, not before it. `Promise.resolve(loader(x))`
|
|
1892
|
-
// would let a synchronously-throwing loader escape past the rejection handler entirely - the
|
|
1893
|
-
// exception happens while evaluating the argument, before any promise exists to reject.
|
|
1894
|
-
pending.push(
|
|
1895
|
-
(async () => {
|
|
1896
|
-
try {
|
|
1897
|
-
results[index] = await loader(scoped)
|
|
1898
|
-
} catch (err) {
|
|
1899
|
-
throw tagLayoutError(err, layoutId)
|
|
1900
|
-
}
|
|
1901
|
-
})(),
|
|
1902
|
-
)
|
|
1903
|
-
}
|
|
1904
|
-
return { modules, layoutData: results, retained, pending: Promise.all(pending) }
|
|
1905
|
-
}
|
|
1906
|
-
|
|
1907
|
-
/** Run only blocking layout gates before a route action. Non-gate loaders fetch render data and
|
|
1908
|
-
* therefore run after the mutation when a native POST needs a fresh document. */
|
|
1909
|
-
const runLayoutGates = async (
|
|
1910
|
-
route: RouteEntry,
|
|
1911
|
-
ctx: LoaderContext,
|
|
1912
|
-
): Promise<LoadedLayoutModules> => {
|
|
1913
|
-
const modules = await loadLayoutModules(route)
|
|
1914
|
-
for (let i = 0; i < modules.length; i++) {
|
|
1915
|
-
const mod = modules[i]
|
|
1916
|
-
if (mod?.gate !== true || mod.loader === undefined) continue
|
|
1917
|
-
try {
|
|
1918
|
-
await mod.loader({
|
|
1919
|
-
...ctx,
|
|
1920
|
-
params: scopeParams(ctx.params, route.layoutParams?.[i]),
|
|
1921
|
-
})
|
|
1922
|
-
} catch (err) {
|
|
1923
|
-
throw tagLayoutError(err, route.layoutIds[i] as string)
|
|
1924
|
-
}
|
|
1925
|
-
}
|
|
1926
|
-
return modules
|
|
1927
|
-
}
|
|
1928
|
-
|
|
1929
|
-
// Build a route's render chain + its merged `<head>` from the already-loaded layout modules + the
|
|
1930
|
-
// page module. The chain is `[…layout components, page]`; the head merges each layout's `meta`
|
|
1931
|
-
// export (outermost→innermost) with the page's (last), so a `head`/`meta` on `_layout.tsx`
|
|
1932
|
-
// contributes sitewide tags. `metaArgs` (loader data + params) feed function-form `meta`s.
|
|
1933
|
-
const resolveChainAndHead = (
|
|
1934
|
-
layoutModules: LoadedLayoutModules,
|
|
1935
|
-
page: RouteModule,
|
|
1936
|
-
metaArgs: MetaArgs,
|
|
1937
|
-
): { chain: unknown[]; head: Meta } => {
|
|
1938
|
-
const chain = [...layoutModules.map((m) => m.default), page.default]
|
|
1939
|
-
const heads = [
|
|
1940
|
-
...layoutModules.map((m) => resolveMeta(m.meta, metaArgs)),
|
|
1941
|
-
resolveMeta(page.meta, metaArgs),
|
|
1942
|
-
]
|
|
1943
|
-
return { chain, head: mergeHeads(heads) }
|
|
1944
|
-
}
|
|
1945
|
-
|
|
1946
|
-
// Dir a special-file id lives in: `_error`→"" , `a/b/_error`→"a/b" (and likewise for `_layout`).
|
|
1947
|
-
const dirOfId = (id: string, suffix: string): string =>
|
|
1948
|
-
id === suffix ? "" : id.slice(0, id.length - suffix.length - 1)
|
|
1949
|
-
|
|
1950
|
-
/**
|
|
1951
|
-
* Choose the `_error` boundary for a failure, respecting where a LAYOUT loader failed.
|
|
1952
|
-
*
|
|
1953
|
-
* A route error uses the nearest boundary, as it always has. A layout error must not: the nearest
|
|
1954
|
-
* boundary can sit BELOW the layout that threw, and rendering there would wrap the boundary in that
|
|
1955
|
-
* very layout - the one whose loader just failed and whose data therefore never arrived.
|
|
1956
|
-
*/
|
|
1957
|
-
const boundaryFor = (route: RouteEntry, err: unknown): string | undefined => {
|
|
1958
|
-
const ids = route.errorIds ?? []
|
|
1959
|
-
const failing = layoutErrorId(err)
|
|
1960
|
-
if (failing === undefined) return ids.at(-1)
|
|
1961
|
-
const layoutDir = dirOfId(failing, "_layout")
|
|
1962
|
-
// Deepest boundary at or ABOVE the failing layout's directory.
|
|
1963
|
-
for (let i = ids.length - 1; i >= 0; i--) {
|
|
1964
|
-
const errDir = dirOfId(ids[i] as string, "_error")
|
|
1965
|
-
if (errDir === "" || errDir === layoutDir || layoutDir.startsWith(`${errDir}/`)) return ids[i]
|
|
1966
|
-
}
|
|
1967
|
-
return undefined
|
|
1968
|
-
}
|
|
1969
|
-
|
|
1970
|
-
// The request's origin (scheme + host + port, NO trailing slash) - the one server fact `meta()` needs
|
|
1971
|
-
// for absolute `canonical`/`og:url`/`og:image` but can't otherwise see (it also runs on the client).
|
|
1972
|
-
// `URL.origin` is exactly that shape; it matches the browser's `location.origin` for the same URL, so
|
|
1973
|
-
// a soft-nav's `applyHead` resolves an identical `<head>` (no hydration drift). A malformed `req.url`
|
|
1974
|
-
// (shouldn't happen on a real request) degrades to `""` - the documented unknown-origin default -
|
|
1975
|
-
// rather than throwing during render.
|
|
1976
|
-
const originOf = (req: Request): string => {
|
|
1977
|
-
try {
|
|
1978
|
-
return new URL(req.url).origin
|
|
1979
|
-
} catch {
|
|
1980
|
-
return ""
|
|
1981
|
-
}
|
|
1982
|
-
}
|
|
1983
|
-
|
|
1984
|
-
// The request's `pathname + search` - threaded into `renderPage` so an adapter's `useLocation`/
|
|
1985
|
-
// `useSearchParams` render the current URL server-side and hydrate against the client's initial state
|
|
1986
|
-
// (which the client entry seeds from `location.pathname + location.search`). No hash: it never reaches
|
|
1987
|
-
// the server. A malformed `req.url` degrades to "/" rather than throwing during render.
|
|
1988
|
-
const pathOf = (req: Request): string => {
|
|
1989
|
-
try {
|
|
1990
|
-
const parts = urlPartsFor(req)
|
|
1991
|
-
return parts.pathname + parts.search
|
|
1992
|
-
} catch {
|
|
1993
|
-
return "/"
|
|
1994
|
-
}
|
|
1995
|
-
}
|
|
1996
|
-
|
|
1997
|
-
/**
|
|
1998
|
-
* Render the **nearest `_error` boundary** when a route's loader throws (the agnostic, server-side
|
|
1999
|
-
* half of error UI - works on every adapter, no client takeover). The boundary renders in place of
|
|
2000
|
-
* the page, wrapped by the layouts **at or above** its segment (deeper layouts are dropped). It's
|
|
2001
|
-
* served **non-hydrated** at status 500: a terminal page, and rendering a boundary (not the page the
|
|
2002
|
-
* client maps this route to) would otherwise hydrate-mismatch. The component receives `{ name,
|
|
2003
|
-
* message }` - never the stack (no internals leak into HTML).
|
|
2004
|
-
*/
|
|
2005
|
-
const renderError = async (
|
|
2006
|
-
route: RouteEntry,
|
|
2007
|
-
errorId: string,
|
|
2008
|
-
err: unknown,
|
|
2009
|
-
): Promise<Response | RenderedPage> => {
|
|
2010
|
-
const errDir = dirOfId(errorId, "_error")
|
|
2011
|
-
const keptLayoutIds = route.layoutIds.filter((id) => {
|
|
2012
|
-
const ld = dirOfId(id, "_layout")
|
|
2013
|
-
return ld === "" || ld === errDir || errDir.startsWith(`${ld}/`)
|
|
2014
|
-
})
|
|
2015
|
-
const layouts = await Promise.all(
|
|
2016
|
-
keptLayoutIds.map((id) =>
|
|
2017
|
-
(manifest.layouts[id] as LayoutEntry).load().then((m) => m.default),
|
|
2018
|
-
),
|
|
2019
|
-
)
|
|
2020
|
-
const { default: errComp } = await (manifest.errors?.[errorId] as LayoutEntry).load()
|
|
2021
|
-
const e = err instanceof Error ? err : new Error(String(err))
|
|
2022
|
-
return renderPageResult({
|
|
2023
|
-
adapter,
|
|
2024
|
-
chain: [...layouts, errComp],
|
|
2025
|
-
data: { name: e.name, message: e.message },
|
|
2026
|
-
clientEntry,
|
|
2027
|
-
routeId: errorId,
|
|
2028
|
-
status: 500,
|
|
2029
|
-
hydrate: false,
|
|
2030
|
-
...titleOption,
|
|
2031
|
-
})
|
|
2032
|
-
}
|
|
2033
|
-
|
|
2034
|
-
// The 404 response - the `_404` page (status 404) or a plain-text fallback. Shared by the wildcard
|
|
2035
|
-
// catch-all (unmatched paths) and the `fallback: "404"` enforcement (unlisted paths under a route
|
|
2036
|
-
// that opted out of on-demand SSR).
|
|
2037
|
-
const renderNotFound = (path?: string): Promise<Response | RenderedPage> =>
|
|
2038
|
-
renderTerminalStatus(404, path)
|
|
2039
|
-
|
|
2040
|
-
/**
|
|
2041
|
-
* Render a terminal-status page: the `_<status>` page if the app authored one, else `_404`, else
|
|
2042
|
-
* plain text. Shared by the wildcard catch-all, `fallback: "404"` enforcement, and a loader's
|
|
2043
|
-
* `notFound()` / `gone()` / `statusPage()`, so all of them resolve the page and set the status
|
|
2044
|
-
* through one path rather than forking.
|
|
2045
|
-
*
|
|
2046
|
-
* Hydrates, following the `_404` precedent rather than `_error`'s non-hydrated 500: these are
|
|
2047
|
-
* ordinary pages the client router can own, and a 404 arrived at by clicking a link should behave
|
|
2048
|
-
* like any other route.
|
|
2049
|
-
*/
|
|
2050
|
-
const renderTerminalStatus = async (
|
|
2051
|
-
status: number,
|
|
2052
|
-
path?: string,
|
|
2053
|
-
extraHeaders?: HeadersLike,
|
|
2054
|
-
): Promise<Response | RenderedPage> => {
|
|
2055
|
-
const page = manifest.statusPages?.[String(status)] ?? manifest.notFound
|
|
2056
|
-
if (page === undefined) {
|
|
2057
|
-
const headers = new Headers(extraHeaders)
|
|
2058
|
-
headers.set("content-type", "text/plain; charset=utf-8")
|
|
2059
|
-
return new Response(STATUS_TEXT[status] ?? "Error", { status, headers })
|
|
2060
|
-
}
|
|
2061
|
-
// The routeId decides which preload/style bundle and which client component this maps to, so it
|
|
2062
|
-
// must name the page actually rendered - `_410` when `_410.tsx` exists, `_404` when it fell back.
|
|
2063
|
-
const routeId = manifest.statusPages?.[String(status)] !== undefined ? `_${status}` : "_404"
|
|
2064
|
-
const loaded = (await page.load()) as {
|
|
2065
|
-
readonly default: unknown
|
|
2066
|
-
readonly searchSchema?: RouteModule["searchSchema"]
|
|
2067
|
-
}
|
|
2068
|
-
const component = loaded.default
|
|
2069
|
-
return renderPageResult({
|
|
2070
|
-
adapter,
|
|
2071
|
-
chain: [component],
|
|
2072
|
-
data: null,
|
|
2073
|
-
clientEntry,
|
|
2074
|
-
routeId,
|
|
2075
|
-
// The page hydrates, so besides `path` its SSR `search` must equal the client mount's derivation for
|
|
2076
|
-
// this routeId (`searchOf(searchSchema, url.search)`), or a status page reading `useSearch` drifts.
|
|
2077
|
-
// Only when `path` is present (a real URL); a schema-less status page then gets the raw parsed query.
|
|
2078
|
-
...(path !== undefined
|
|
2079
|
-
? {
|
|
2080
|
-
search: searchOf(
|
|
2081
|
-
loaded.searchSchema,
|
|
2082
|
-
path.includes("?") ? path.slice(path.indexOf("?")) : "",
|
|
2083
|
-
),
|
|
2084
|
-
}
|
|
2085
|
-
: {}),
|
|
2086
|
-
// The page hydrates, so its SSR `path` must equal the client's initial
|
|
2087
|
-
// `location.pathname+search` (an unmatched path yields no params → `{}`), or a `useLocation`
|
|
2088
|
-
// on it would drift.
|
|
2089
|
-
...(path !== undefined ? { path } : {}),
|
|
2090
|
-
status,
|
|
2091
|
-
...(extraHeaders !== undefined ? { headers: extraHeaders } : {}),
|
|
2092
|
-
...preloadOf(routeId),
|
|
2093
|
-
...stylesOf(routeId),
|
|
2094
|
-
prerenderedPaths: options.prerenderedPaths ?? [],
|
|
2095
|
-
...titleOption,
|
|
2096
|
-
})
|
|
2097
|
-
}
|
|
2098
|
-
|
|
2099
|
-
/**
|
|
2100
|
-
* Answer a loader's terminal-status signal, in whichever form the caller asked for.
|
|
2101
|
-
*
|
|
2102
|
-
* A hard navigation wants the document, so it gets the rendered boundary. A client-side navigation
|
|
2103
|
-
* fetched DATA, not a document, so it gets an empty body plus the status on `X-Nifra-Status`, and
|
|
2104
|
-
* the client router renders the same boundary from its own manifest. Sending the HTML back to a
|
|
2105
|
-
* soft-nav would be answering a different question than the one asked - and skipping the header
|
|
2106
|
-
* entirely is what leaves a crawler seeing a correct 404 while a user who clicked a link sees a
|
|
2107
|
-
* blank page.
|
|
2108
|
-
*/
|
|
2109
|
-
const renderStatusSignal = (
|
|
2110
|
-
req: Request,
|
|
2111
|
-
signal: StatusSignal,
|
|
2112
|
-
): Promise<Response | RenderedPage> | Response => {
|
|
2113
|
-
const { status, headers } = signal[STATUS_SIGNAL]
|
|
2114
|
-
if (req.headers.get(DATA_HEADER) !== null) {
|
|
2115
|
-
const responseHeaders = new Headers(headers)
|
|
2116
|
-
responseHeaders.set(STATUS_HEADER, String(status))
|
|
2117
|
-
return new Response(null, { status, headers: responseHeaders })
|
|
2118
|
-
}
|
|
2119
|
-
return renderTerminalStatus(status, pathOf(req), headers)
|
|
2120
|
-
}
|
|
2121
|
-
|
|
2122
|
-
for (const route of manifest.routes) {
|
|
2123
|
-
// A dynamic route whose `getStaticPaths` declared `fallback: "404"` - only its prerendered paths
|
|
2124
|
-
// exist; anything else 404s (computed once per route, not per request).
|
|
2125
|
-
const is404Fallback = options.staticFallbacks?.[route.pattern] === "404"
|
|
2126
|
-
// Per-route document-assembly cache (see RenderAssemblyCache): filled on the first GET render and
|
|
2127
|
-
// reused while the route + layout MODULE IDENTITIES are unchanged. Attached only when every meta
|
|
2128
|
-
// in the chain is static - a `meta(data)` function makes the head per-request, so those routes
|
|
2129
|
-
// always assemble fresh. A module reload (dev HMR) yields new module objects, dropping the slot.
|
|
2130
|
-
let assemblySlot: RenderAssemblyCache | undefined
|
|
2131
|
-
let assemblySlotMod: unknown
|
|
2132
|
-
let assemblySlotLayouts: LoadedLayoutModules | undefined
|
|
2133
|
-
const assemblyCacheFor = (
|
|
2134
|
-
mod: RouteModule,
|
|
2135
|
-
lms: LoadedLayoutModules,
|
|
2136
|
-
): { assemblyCache: RenderAssemblyCache } | Record<string, never> => {
|
|
2137
|
-
if (typeof mod.meta === "function") return {}
|
|
2138
|
-
for (const m of lms) if (typeof m.meta === "function") return {}
|
|
2139
|
-
const layoutsUnchanged =
|
|
2140
|
-
assemblySlotLayouts !== undefined &&
|
|
2141
|
-
assemblySlotLayouts.length === lms.length &&
|
|
2142
|
-
assemblySlotLayouts.every((m, i) => m === lms[i])
|
|
2143
|
-
if (assemblySlotMod !== mod || !layoutsUnchanged) {
|
|
2144
|
-
assemblySlot = {}
|
|
2145
|
-
assemblySlotMod = mod
|
|
2146
|
-
assemblySlotLayouts = lms
|
|
2147
|
-
}
|
|
2148
|
-
return { assemblyCache: assemblySlot as RenderAssemblyCache }
|
|
2149
|
-
}
|
|
2150
|
-
app.register("GET", route.pattern, undefined, async (c: RouteContext) => {
|
|
2151
|
-
// Enforce `fallback: "404"` before any work: an unlisted path under this route doesn't exist.
|
|
2152
|
-
// Covers hard navigation directly; a client soft-nav's data fetch gets the 404, throws, and the
|
|
2153
|
-
// history layer falls back to a full-page navigation (which lands here again, as a document).
|
|
2154
|
-
if (is404Fallback && !prerenderedSet.has(urlPartsFor(c.req).pathname)) {
|
|
2155
|
-
return renderNotFound(pathOf(c.req))
|
|
2156
|
-
}
|
|
2157
|
-
const mod = await route.load()
|
|
2158
|
-
const draft = await draftFlag(c.req)
|
|
2159
|
-
let data: unknown
|
|
2160
|
-
let layoutData: readonly unknown[] | undefined
|
|
2161
|
-
let boundaryStates: BoundaryStates | undefined
|
|
2162
|
-
let layoutModules: LoadedLayoutModules | undefined
|
|
2163
|
-
let layoutRetained: readonly number[] = []
|
|
2164
|
-
try {
|
|
2165
|
-
const ctx = {
|
|
2166
|
-
params: c.params,
|
|
2167
|
-
request: c.req,
|
|
2168
|
-
req: c.req,
|
|
2169
|
-
api,
|
|
2170
|
-
env: c.env,
|
|
2171
|
-
draft,
|
|
2172
|
-
search: loaderSearch(mod.searchSchema, c.req),
|
|
2173
|
-
}
|
|
2174
|
-
// Layout loaders run for BOTH the document and the data-only request. A gate that only ran on
|
|
2175
|
-
// the document path would be bypassed by adding the data header, which is exactly the request
|
|
2176
|
-
// a client navigation makes - a guard has to hold on the path an attacker can pick.
|
|
2177
|
-
//
|
|
2178
|
-
// `runLayoutChain` returns once every GATE has resolved, with the non-gate loaders still in
|
|
2179
|
-
// flight. So the page loader starts as late as correctness requires and as early as speed
|
|
2180
|
-
// allows, and both are awaited together below.
|
|
2181
|
-
const run = await runLayoutChain(
|
|
2182
|
-
route,
|
|
2183
|
-
ctx,
|
|
2184
|
-
validatedRetainedIndices(c.req, route, c.params),
|
|
2185
|
-
)
|
|
2186
|
-
layoutModules = run.modules
|
|
2187
|
-
layoutRetained = run.retained
|
|
2188
|
-
const boundaryDefinitions = [
|
|
2189
|
-
...run.modules.flatMap((layout) => layout.boundaries ?? []),
|
|
2190
|
-
...(mod.boundaries ?? []),
|
|
2191
|
-
]
|
|
2192
|
-
if (boundaryDefinitions.length > 0) boundaryDescriptors(boundaryDefinitions)
|
|
2193
|
-
const effectiveSearch = searchChainOf(run.modules, mod, c.req)
|
|
2194
|
-
const boundaryBatch =
|
|
2195
|
-
boundaryDefinitions.length === 0
|
|
2196
|
-
? undefined
|
|
2197
|
-
: startDynamicBoundaries(boundaryDefinitions, {
|
|
2198
|
-
request: c.req,
|
|
2199
|
-
params: c.params,
|
|
2200
|
-
api,
|
|
2201
|
-
env: c.env,
|
|
2202
|
-
draft,
|
|
2203
|
-
search: effectiveSearch,
|
|
2204
|
-
signal: c.req.signal,
|
|
2205
|
-
} satisfies BoundaryRequestCtx)
|
|
2206
|
-
const staticBoundaryPromise =
|
|
2207
|
-
boundaryDefinitions.length === 0
|
|
2208
|
-
? Promise.resolve(undefined)
|
|
2209
|
-
: resolveStaticBoundaries(
|
|
2210
|
-
boundaryDefinitions,
|
|
2211
|
-
{ phase: "build", origin: originOf(c.req) },
|
|
2212
|
-
options.staticBoundaryCache,
|
|
2213
|
-
)
|
|
2214
|
-
// Boundary data is attached as one Deferred marker per slot. The shell can render
|
|
2215
|
-
// immediately with each slot pending, while the existing deferred protocol emits the first
|
|
2216
|
-
// completed sibling independently instead of waiting for the slowest boundary.
|
|
2217
|
-
// The page loader gets the EFFECTIVE search (layout chain + page, page-wins), now that the layout
|
|
2218
|
-
// modules are loaded - so `ctx.search` includes a layout's shared keys (`?org`), matching `useSearch`.
|
|
2219
|
-
const [pageData, , staticBoundaries] = await Promise.all([
|
|
2220
|
-
mod.loader ? mod.loader({ ...ctx, search: effectiveSearch }) : null,
|
|
2221
|
-
run.pending, // a non-gate layout loader that rejects must still surface, not go unhandled
|
|
2222
|
-
staticBoundaryPromise,
|
|
2223
|
-
])
|
|
2224
|
-
data = pageData
|
|
2225
|
-
layoutData = run.layoutData
|
|
2226
|
-
if (boundaryBatch !== undefined) {
|
|
2227
|
-
const streamed = { ...boundaryBatch.initial, ...(staticBoundaries ?? {}) }
|
|
2228
|
-
for (const entry of boundaryBatch.pending) {
|
|
2229
|
-
const initial = streamed[entry.name]
|
|
2230
|
-
if (initial !== undefined)
|
|
2231
|
-
streamed[entry.name] = { ...initial, data: defer(entry.promise) }
|
|
2232
|
-
}
|
|
2233
|
-
boundaryStates = streamed
|
|
2234
|
-
} else if (staticBoundaries !== undefined) {
|
|
2235
|
-
boundaryStates = staticBoundaries
|
|
2236
|
-
}
|
|
2237
|
-
} catch (err) {
|
|
2238
|
-
// A terminal-status signal (`notFound()` / `gone()` / `statusPage(n)`) renders a boundary at
|
|
2239
|
-
// that status. Checked BEFORE the pass-through below: a signal IS a `Response`, so the order
|
|
2240
|
-
// is what keeps a hand-rolled `throw new Response(...)` served verbatim, as it always was.
|
|
2241
|
-
if (isStatusSignal(err)) return renderStatusSignal(c.req, err)
|
|
2242
|
-
// A thrown control-flow signal (a guard's `redirect(...)`, an explicit error response) - let
|
|
2243
|
-
// it propagate to core, which renders it as-is, whether it is a plain render or a
|
|
2244
|
-
// hand-rolled `Response`. Real errors render the nearest `_error` boundary, if any; with
|
|
2245
|
-
// none, rethrow (unchanged 500 behavior).
|
|
2246
|
-
if (isControlFlow(err)) throw err
|
|
2247
|
-
// Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
|
|
2248
|
-
if (options.onLoaderError !== undefined) {
|
|
2249
|
-
try {
|
|
2250
|
-
options.onLoaderError(err, { request: c.req, params: c.params, route: route.pattern })
|
|
2251
|
-
} catch {
|
|
2252
|
-
// A faulty reporter must never break error rendering.
|
|
2253
|
-
}
|
|
2254
|
-
}
|
|
2255
|
-
const errorId = boundaryFor(route, err)
|
|
2256
|
-
if (errorId === undefined) throw err
|
|
2257
|
-
// A soft-nav data fetch can't render a boundary - 500 so the client falls back to a full-page
|
|
2258
|
-
// navigation, which lands here as a document and renders the `_error` page.
|
|
2259
|
-
if (c.req.headers.get(DATA_HEADER) !== null) {
|
|
2260
|
-
return new Response("Internal Server Error", {
|
|
2261
|
-
status: 500,
|
|
2262
|
-
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
2263
|
-
})
|
|
2264
|
-
}
|
|
2265
|
-
return renderError(route, errorId, withDuplicateInstanceHint(err))
|
|
2266
|
-
}
|
|
2267
|
-
// A loader may RETURN its control-flow signal instead of throwing it - `return redirect(...)`
|
|
2268
|
-
// reads naturally and must not silently serialize the signal as loader data. Mirror the catch
|
|
2269
|
-
// above exactly: a status signal renders its boundary, any other signal (a redirect, a
|
|
2270
|
-
// hand-rolled response) passes through to core verbatim - so return and throw are
|
|
2271
|
-
// interchangeable.
|
|
2272
|
-
if (isStatusSignal(data)) return renderStatusSignal(c.req, data)
|
|
2273
|
-
if (isControlFlow(data)) return data
|
|
2274
|
-
// Client-side navigation asks (via the X-Nifra-Data header) for just the loader data - no full
|
|
2275
|
-
// document, no layout chain. A route with deferred data streams NDJSON (critical data first,
|
|
2276
|
-
// then each deferred value as it settles); otherwise one JSON (the fast path). Same loader,
|
|
2277
|
-
// same auth - only the transport differs.
|
|
2278
|
-
if (c.req.headers.get(DATA_HEADER) !== null) {
|
|
2279
|
-
const pageSplit = prepareDeferred(data)
|
|
2280
|
-
const layoutSplits: Array<ReturnType<typeof prepareDeferred>> = []
|
|
2281
|
-
let offset = pageSplit.deferred.length
|
|
2282
|
-
for (const entry of layoutData ?? []) {
|
|
2283
|
-
const split = prepareDeferred(entry, offset)
|
|
2284
|
-
layoutSplits.push(split)
|
|
2285
|
-
offset += split.deferred.length
|
|
2286
|
-
}
|
|
2287
|
-
const deferred = [...pageSplit.deferred, ...layoutSplits.flatMap((split) => split.deferred)]
|
|
2288
|
-
const boundarySplit =
|
|
2289
|
-
boundaryStates === undefined
|
|
2290
|
-
? undefined
|
|
2291
|
-
: prepareDeferred(boundaryStates, deferred.length)
|
|
2292
|
-
const allDeferred = [
|
|
2293
|
-
...deferred,
|
|
2294
|
-
...(boundarySplit === undefined ? [] : boundarySplit.deferred),
|
|
2295
|
-
]
|
|
2296
|
-
const payload =
|
|
2297
|
-
layoutData === undefined && boundarySplit === undefined
|
|
2298
|
-
? pageSplit.forClient
|
|
2299
|
-
: {
|
|
2300
|
-
v: 1 as const,
|
|
2301
|
-
data: pageSplit.forClient,
|
|
2302
|
-
layoutData: layoutSplits.map((split) => split.forClient),
|
|
2303
|
-
retained: layoutRetained,
|
|
2304
|
-
...(boundarySplit === undefined ? {} : { boundaries: boundarySplit.forClient }),
|
|
2305
|
-
}
|
|
2306
|
-
// Bare value when there is no layout data - the pre-envelope shape, so a client running older
|
|
2307
|
-
// code against a newer server keeps working.
|
|
2308
|
-
if (allDeferred.length === 0) {
|
|
2309
|
-
return Response.json(payload ?? null)
|
|
2310
|
-
}
|
|
2311
|
-
return new Response(ndjsonStream(payload, allDeferred), {
|
|
2312
|
-
headers: { "content-type": "application/x-ndjson; charset=utf-8" },
|
|
2313
|
-
})
|
|
2314
|
-
}
|
|
2315
|
-
const chainLayoutModules = layoutModules ?? (await loadLayoutModules(route))
|
|
2316
|
-
const { chain, head } = resolveChainAndHead(chainLayoutModules, mod, {
|
|
2317
|
-
data,
|
|
2318
|
-
params: c.params,
|
|
2319
|
-
origin: originOf(c.req),
|
|
2320
|
-
})
|
|
2321
|
-
const hydrateRoute = mod.hydrate !== false
|
|
2322
|
-
try {
|
|
2323
|
-
// `await` so a shell-render throw (renderToStream rejects before any byte) is caught here and
|
|
2324
|
-
// can render the `_error` page - not just a loader throw. Mid-stream (post-shell) throws can't
|
|
2325
|
-
// be recovered to a full page; the per-adapter client boundary catches client render errors.
|
|
2326
|
-
return await renderPageResult({
|
|
2327
|
-
adapter,
|
|
2328
|
-
chain,
|
|
2329
|
-
data,
|
|
2330
|
-
head,
|
|
2331
|
-
clientEntry,
|
|
2332
|
-
routeId: route.id,
|
|
2333
|
-
params: c.params,
|
|
2334
|
-
path: pathOf(c.req),
|
|
2335
|
-
// The effective (layout chain + page) search, matching the page loader's `ctx.search` above and
|
|
2336
|
-
// the client mount - so `useSearch` renders SSR-correct and hydrates with no drift. `layoutModules`
|
|
2337
|
-
// is loaded by now (the loader try succeeded to reach here).
|
|
2338
|
-
search: searchChainOf(layoutModules ?? [], mod, c.req),
|
|
2339
|
-
hydrate: hydrateRoute,
|
|
2340
|
-
...(layoutData !== undefined ? { layoutData } : {}),
|
|
2341
|
-
...(boundaryStates !== undefined ? { boundaries: boundaryStates } : {}),
|
|
2342
|
-
...preloadOf(route.id),
|
|
2343
|
-
...stylesOf(route.id),
|
|
2344
|
-
prerenderedPaths: options.prerenderedPaths ?? [],
|
|
2345
|
-
...(mod.revalidate !== undefined ? { revalidate: mod.revalidate } : {}),
|
|
2346
|
-
...(mod.revalidateTags !== undefined ? { revalidateTags: mod.revalidateTags } : {}),
|
|
2347
|
-
...(mod.islandScripts !== undefined ? { islandScripts: mod.islandScripts } : {}),
|
|
2348
|
-
...titleOption,
|
|
2349
|
-
...assemblyCacheFor(mod, chainLayoutModules),
|
|
2350
|
-
})
|
|
2351
|
-
} catch (err) {
|
|
2352
|
-
// Same precedence as the loader catch above - a `meta()` may signal too.
|
|
2353
|
-
if (isStatusSignal(err)) return renderStatusSignal(c.req, err)
|
|
2354
|
-
if (isControlFlow(err)) throw err
|
|
2355
|
-
// Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
|
|
2356
|
-
if (options.onLoaderError !== undefined) {
|
|
2357
|
-
try {
|
|
2358
|
-
options.onLoaderError(err, { request: c.req, params: c.params, route: route.pattern })
|
|
2359
|
-
} catch {
|
|
2360
|
-
// A faulty reporter must never break error rendering.
|
|
2361
|
-
}
|
|
2362
|
-
}
|
|
2363
|
-
const errorId = boundaryFor(route, err)
|
|
2364
|
-
if (errorId === undefined) throw err
|
|
2365
|
-
return renderError(route, errorId, withDuplicateInstanceHint(err))
|
|
2366
|
-
}
|
|
2367
|
-
})
|
|
2368
|
-
|
|
2369
|
-
// POST runs the route's `action` (mutation). A control-flow return (e.g. a `redirect(...)`)
|
|
2370
|
-
// passes straight through; a data return re-renders the page (the loader re-runs for fresh
|
|
2371
|
-
// data) with `actionData`. Routes without an action reject POST with 405 - not a stray 404.
|
|
2372
|
-
app.register("POST", route.pattern, undefined, async (c: RouteContext) => {
|
|
2373
|
-
const mod = await route.load()
|
|
2374
|
-
const draft = await draftFlag(c.req)
|
|
2375
|
-
if (mod.action === undefined) {
|
|
2376
|
-
return new Response("Method Not Allowed", {
|
|
2377
|
-
status: 405,
|
|
2378
|
-
headers: { allow: "GET", "content-type": "text/plain; charset=utf-8" },
|
|
2379
|
-
})
|
|
2380
|
-
}
|
|
2381
|
-
const actionContext = {
|
|
2382
|
-
params: c.params,
|
|
2383
|
-
request: c.req,
|
|
2384
|
-
req: c.req,
|
|
2385
|
-
api,
|
|
2386
|
-
env: c.env,
|
|
2387
|
-
draft,
|
|
2388
|
-
search: loaderSearch(mod.searchSchema, c.req),
|
|
2389
|
-
}
|
|
2390
|
-
const isDataRequest = c.req.headers.get(DATA_HEADER) !== null
|
|
2391
|
-
let layoutModules: LoadedLayoutModules
|
|
2392
|
-
let result: unknown
|
|
2393
|
-
try {
|
|
2394
|
-
// A gate is an authorization boundary for everything beneath its layout, mutations included.
|
|
2395
|
-
// Run only gates before the action; ordinary layout data loaders run after a native mutation.
|
|
2396
|
-
layoutModules = await runLayoutGates(route, actionContext)
|
|
2397
|
-
result = await mod.action(actionContext)
|
|
2398
|
-
} catch (err) {
|
|
2399
|
-
// A THROWN signal is the same control flow as a returned one (`throw redirect()` in a gate
|
|
2400
|
-
// or an action) - route it through the same conversion as the returned branch below, so
|
|
2401
|
-
// throw and return are interchangeable on the mutation path too.
|
|
2402
|
-
if (isControlFlow(err)) return actionResponse(err, isDataRequest)
|
|
2403
|
-
throw err
|
|
2404
|
-
}
|
|
2405
|
-
// An action may wrap its data in `revalidate(paths, data)` to declare which routes it changed.
|
|
2406
|
-
// Unwrap to the inner data; the paths ride the `X-Nifra-Revalidate` header on the data-mode
|
|
2407
|
-
// responses (the client acts on them - a full-page POST re-runs loaders inline, so no header).
|
|
2408
|
-
const isRevalidate =
|
|
2409
|
-
result !== null && typeof result === "object" && "__nifraRevalidate" in result
|
|
2410
|
-
const actionResult = isRevalidate ? (result as RevalidateResult<unknown>).data : result
|
|
2411
|
-
const revalidateHeader: Record<string, string> = isRevalidate
|
|
2412
|
-
? { [REVALIDATE_HEADER]: (result as RevalidateResult<unknown>).__nifraRevalidate.join(",") }
|
|
2413
|
-
: {}
|
|
2414
|
-
if (isControlFlow(actionResult)) return actionResponse(actionResult, isDataRequest)
|
|
2415
|
-
// Client submit wants just the action's data (it revalidates the loader itself); a native
|
|
2416
|
-
// form POST re-renders the full page (loader re-runs) with the action data.
|
|
2417
|
-
if (isDataRequest) {
|
|
2418
|
-
// An action may `defer()` slow parts of its result - stream them (critical data first, then
|
|
2419
|
-
// each deferred as it settles) exactly like a loader; a non-deferred action returns one JSON.
|
|
2420
|
-
const { forClient, deferred } = prepareDeferred(actionResult)
|
|
2421
|
-
if (deferred.length === 0)
|
|
2422
|
-
return Response.json(actionResult ?? null, { headers: revalidateHeader })
|
|
2423
|
-
return new Response(ndjsonStream(forClient, deferred), {
|
|
2424
|
-
headers: { "content-type": "application/x-ndjson; charset=utf-8", ...revalidateHeader },
|
|
2425
|
-
})
|
|
2426
|
-
}
|
|
2427
|
-
// Derived once and shared: the loader's `ctx.search` and the render's `RenderProps.search` are the
|
|
2428
|
-
// SAME effective (layout chain + page) value, and the client mount rebuilds it identically. The
|
|
2429
|
-
// layout modules are already loaded (the action ran through their gates above).
|
|
2430
|
-
const search = searchChainOf(layoutModules, mod, c.req)
|
|
2431
|
-
const data = mod.loader
|
|
2432
|
-
? await mod.loader({
|
|
2433
|
-
params: c.params,
|
|
2434
|
-
request: c.req,
|
|
2435
|
-
req: c.req,
|
|
2436
|
-
api,
|
|
2437
|
-
env: c.env,
|
|
2438
|
-
draft,
|
|
2439
|
-
search,
|
|
2440
|
-
})
|
|
2441
|
-
: null
|
|
2442
|
-
const { chain, head } = resolveChainAndHead(layoutModules, mod, {
|
|
2443
|
-
data,
|
|
2444
|
-
params: c.params,
|
|
2445
|
-
origin: originOf(c.req),
|
|
2446
|
-
})
|
|
2447
|
-
const hydrateRoute = mod.hydrate !== false
|
|
2448
|
-
// A full-page POST streams the action's `defer()`'d parts mid-document behind `<Await>` too -
|
|
2449
|
-
// `renderPage` splits `actionData` like loader data (works with JS off; hydrates after).
|
|
2450
|
-
return renderPageResult({
|
|
2451
|
-
adapter,
|
|
2452
|
-
chain,
|
|
2453
|
-
data,
|
|
2454
|
-
actionData: actionResult,
|
|
2455
|
-
head,
|
|
2456
|
-
clientEntry,
|
|
2457
|
-
routeId: route.id,
|
|
2458
|
-
params: c.params,
|
|
2459
|
-
path: pathOf(c.req),
|
|
2460
|
-
search,
|
|
2461
|
-
hydrate: hydrateRoute,
|
|
2462
|
-
...preloadOf(route.id),
|
|
2463
|
-
...stylesOf(route.id),
|
|
2464
|
-
prerenderedPaths: options.prerenderedPaths ?? [],
|
|
2465
|
-
...titleOption,
|
|
2466
|
-
})
|
|
2467
|
-
})
|
|
2468
|
-
}
|
|
2469
|
-
|
|
2470
|
-
// Register llms.txt & llms-full.txt
|
|
2471
|
-
const llmsOptions = { includeLocalGuidelines: options.publishLocalGuidelines === true }
|
|
2472
|
-
app.register("GET", "/llms.txt", undefined, async () => {
|
|
2473
|
-
const text = await generateLlmsTxt(false, manifest.routes, api, llmsOptions)
|
|
2474
|
-
return new Response(text, {
|
|
2475
|
-
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
2476
|
-
})
|
|
2477
|
-
})
|
|
2478
|
-
|
|
2479
|
-
app.register("GET", "/llms-full.txt", undefined, async () => {
|
|
2480
|
-
const text = await generateLlmsTxt(true, manifest.routes, api, llmsOptions)
|
|
2481
|
-
return new Response(text, {
|
|
2482
|
-
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
2483
|
-
})
|
|
2484
|
-
})
|
|
2485
|
-
|
|
2486
|
-
// Wildcard catch-all: unmatched paths render `_404` (404), or a plain text 404 if absent.
|
|
2487
|
-
app.register("GET", "/*", undefined, (c: RouteContext) => renderNotFound(pathOf(c.req)))
|
|
2488
|
-
|
|
2489
|
-
return app
|
|
2490
|
-
}
|
|
2491
|
-
|
|
2492
|
-
export interface GenerateClientEntryOptions {
|
|
2493
|
-
/**
|
|
2494
|
-
* Module specifier for the adapter's client runtime, e.g. `"@nifrajs/web-solid/client"`.
|
|
2495
|
-
*
|
|
2496
|
-
* **Contract:** the module MUST export `mountRouter({ router, routes, container })`. The generated
|
|
2497
|
-
* bootstrap imports the specifier and calls it - so a self-executing entry that mounts on import
|
|
2498
|
-
* builds cleanly and then does nothing, which is the trap this contract exists to name. The type is
|
|
2499
|
-
* a bare `string` because a specifier is resolved by the bundler, not the type system, so the
|
|
2500
|
-
* requirement is enforced by the bootstrap instead: it throws immediately, naming this module and
|
|
2501
|
-
* the missing export, rather than failing later as `mountRouter is not a function`.
|
|
2502
|
-
*/
|
|
2503
|
-
readonly clientModule: string
|
|
2504
|
-
/** Turn a route/layout source file (relative to the routes dir) into an import specifier. */
|
|
2505
|
-
readonly resolve: (file: string) => string
|
|
2506
|
-
}
|
|
2507
|
-
|
|
2508
|
-
/**
|
|
2509
|
-
* Codegen: emit a client-entry module (as source) that lazily imports each route's layout chain
|
|
2510
|
-
* (so `Bun.build` with `splitting` code-splits one chunk per route), builds a `patterns` list,
|
|
2511
|
-
* then creates the agnostic router store (with a `loadModule` hook), installs history + form
|
|
2512
|
-
* interception, loads the initial route's chunk, and hydrates the adapter's stateful Router. The
|
|
2513
|
-
* initial route is derived from the URL (falling back to the server-injected route id, e.g.
|
|
2514
|
-
* `_404`), so after hydration the Router owns navigation and swaps routes without full reloads.
|
|
2515
|
-
* Bun has no `import.meta.glob`, so file-based routing needs this. Write the result to a file and
|
|
2516
|
-
* bundle it with `buildClient` / `Bun.build` (+ the adapter's transform).
|
|
2517
|
-
*/
|
|
2518
|
-
export function generateClientEntry(
|
|
2519
|
-
manifest: Manifest,
|
|
2520
|
-
options: GenerateClientEntryOptions,
|
|
2521
|
-
): string {
|
|
2522
|
-
const { clientModule, resolve } = options
|
|
2523
|
-
|
|
2524
|
-
const loaderRows: string[] = []
|
|
2525
|
-
const patternRows: string[] = []
|
|
2526
|
-
const statusRoutes: Record<number, string> = {}
|
|
2527
|
-
// Routes whose loader appends a nearest `_error` module (LAST) - the client wraps the page in the
|
|
2528
|
-
// adapter's `errorBoundary(fallback)` for these, so a client render error shows the `_error` UI.
|
|
2529
|
-
const errorRouteIds: string[] = []
|
|
2530
|
-
// Lazy loader returns the raw modules (for both the component chain + the page's `meta` export).
|
|
2531
|
-
const lazyLoader = (files: readonly string[]): string => {
|
|
2532
|
-
const imports = files.map((f) => `import(${jsStringLiteral(resolve(f))})`).join(", ")
|
|
2533
|
-
return `() => Promise.all([${imports}])`
|
|
2534
|
-
}
|
|
2535
|
-
for (const route of manifest.routes) {
|
|
2536
|
-
// layoutIds only reference layouts present in the manifest (buildManifest invariant).
|
|
2537
|
-
const files = [
|
|
2538
|
-
...route.layoutIds.map((id) => (manifest.layouts[id] as LayoutEntry).file),
|
|
2539
|
-
route.file,
|
|
2540
|
-
]
|
|
2541
|
-
// Append the nearest `_error` file LAST, so loadModule can pull it off the tail and wrap the page.
|
|
2542
|
-
const nearestErrorId = route.errorIds?.at(-1)
|
|
2543
|
-
const errorFile =
|
|
2544
|
-
nearestErrorId === undefined ? undefined : manifest.errors?.[nearestErrorId]?.file
|
|
2545
|
-
if (errorFile !== undefined) {
|
|
2546
|
-
files.push(errorFile)
|
|
2547
|
-
errorRouteIds.push(route.id)
|
|
2548
|
-
}
|
|
2549
|
-
loaderRows.push(` ${JSON.stringify(route.id)}: ${lazyLoader(files)},`)
|
|
2550
|
-
patternRows.push(
|
|
2551
|
-
` { routeId: ${JSON.stringify(route.id)}, pattern: ${JSON.stringify(route.pattern)} },`,
|
|
2552
|
-
)
|
|
2553
|
-
}
|
|
2554
|
-
if (manifest.notFound !== undefined) {
|
|
2555
|
-
loaderRows.push(` "_404": ${lazyLoader([manifest.notFound.file])},`)
|
|
2556
|
-
statusRoutes[404] = "_404"
|
|
2557
|
-
}
|
|
2558
|
-
for (const [status, entry] of Object.entries(manifest.statusPages ?? {})) {
|
|
2559
|
-
const routeId = `_${status}`
|
|
2560
|
-
loaderRows.push(` ${JSON.stringify(routeId)}: ${lazyLoader([entry.file])},`)
|
|
2561
|
-
statusRoutes[Number(status)] = routeId
|
|
2562
|
-
}
|
|
2563
|
-
|
|
2564
|
-
return `${[
|
|
2565
|
-
// `/client`, never the root: the root's graph carries the server (renderPage, the static-file
|
|
2566
|
-
// server), and Vite's dev server evaluates what it is given instead of tree-shaking it.
|
|
2567
|
-
'import { createClientRouter, createMatcher, mergeHeads, resolveMeta } from "@nifrajs/web/client"',
|
|
2568
|
-
'import { applyHead, installForms, installHistory, signalHydrated } from "@nifrajs/web/client"',
|
|
2569
|
-
// Namespace import: `errorBoundary` is optional (an adapter may not export it). A namespace member
|
|
2570
|
-
// access yields `undefined` if absent - unlike a named import, which would be a link error.
|
|
2571
|
-
`import * as __adapter from ${JSON.stringify(clientModule)}`,
|
|
2572
|
-
"const { mountRouter } = __adapter",
|
|
2573
|
-
// The assurance hook is optional: ordinary client entries pay only for the namespace lookup, while
|
|
2574
|
-
// hydration runners get a runtime identity from the exact adapter module that mounted the page. The
|
|
2575
|
-
// registry is token-only and lives on a Symbol.for key so it cannot collide with app data.
|
|
2576
|
-
"const __hydrationHook = __adapter.hydrationAssuranceHook",
|
|
2577
|
-
'if (__hydrationHook && typeof __hydrationHook.runtimeIdentity === "function") {',
|
|
2578
|
-
' const __runtimeKey = Symbol.for("nifra.hydration.runtime")',
|
|
2579
|
-
" const __runtimeState = globalThis[__runtimeKey]",
|
|
2580
|
-
" const __identities = __runtimeState && Array.isArray(__runtimeState.identities) ? __runtimeState.identities : []",
|
|
2581
|
-
" globalThis[__runtimeKey] = { framework: __hydrationHook.framework, identities: [...__identities, __hydrationHook.runtimeIdentity()] }",
|
|
2582
|
-
"}",
|
|
2583
|
-
// The `clientModule` contract is a string specifier, so nothing type-checks that the module it
|
|
2584
|
-
// names actually exports `mountRouter`. Without this the miss surfaces as
|
|
2585
|
-
// "mountRouter is not a function" from inside a bundled chunk, at first paint, naming neither the
|
|
2586
|
-
// module nor the requirement - a self-executing entry passes every build and fails only here.
|
|
2587
|
-
`if (typeof mountRouter !== "function") throw new Error(${JSON.stringify(
|
|
2588
|
-
`[nifra/web] clientModule ${JSON.stringify(clientModule)} does not export \`mountRouter\`. ` +
|
|
2589
|
-
"A client module must export `mountRouter({ router, routes, container })` - it is called by " +
|
|
2590
|
-
"the generated bootstrap, so a self-executing entry will not work. Use your adapter's " +
|
|
2591
|
-
'`/client` entry (e.g. "@nifrajs/web-react/client"), or re-export mountRouter from it.',
|
|
2592
|
-
)})`,
|
|
2593
|
-
"const errorBoundary = __adapter.errorBoundary",
|
|
2594
|
-
// A hot update that the framework cannot actually apply must end in a reload, not in nothing.
|
|
2595
|
-
//
|
|
2596
|
-
// Bun's dev server applies React Fast Refresh to JSX modules. For React that patches the component
|
|
2597
|
-
// and preserves state. For Preact it does not - the module still LOOKS Fast-Refresh-able, so the
|
|
2598
|
-
// update is accepted and then swallowed: `bun:beforeUpdate`/`bun:afterUpdate` both fire, the server
|
|
2599
|
-
// logs a rebuild, and the page silently keeps rendering the old code. An edit that appears to do
|
|
2600
|
-
// nothing is the worst failure mode in a dev loop - worse than a reload, because there is no signal
|
|
2601
|
-
// to act on. An adapter that knows its framework is in that position says so, and the entry turns
|
|
2602
|
-
// the swallowed update into an honest full reload.
|
|
2603
|
-
'if (import.meta.hot && __adapter.hotUpdateNeedsReload) import.meta.hot.on("bun:afterUpdate", () => location.reload())',
|
|
2604
|
-
`const errorRouteIds = new Set(${JSON.stringify(errorRouteIds)})`,
|
|
2605
|
-
// Each route is a lazy loader: dynamic imports → Bun.build (splitting) emits one chunk per
|
|
2606
|
-
// route, shared layouts/deps deduped into shared chunks, so a route's code loads only when
|
|
2607
|
-
// visited. loadModule caches the [layouts…, page] component chain + the chain's meta list per id.
|
|
2608
|
-
"const loaders = {",
|
|
2609
|
-
...loaderRows,
|
|
2610
|
-
"}",
|
|
2611
|
-
"const chains = {}",
|
|
2612
|
-
"const metas = {}",
|
|
2613
|
-
// routeId → the page module's `searchSchema` export (or undefined). Populated lazily with
|
|
2614
|
-
// chains/metas so the mount can derive this route's typed `search` from the URL, matching the SSR.
|
|
2615
|
-
"const searchSchemas = {}",
|
|
2616
|
-
// routeId → the page's client-only search keys (`searchClientKeys`, or `[]`). The router reads this
|
|
2617
|
-
// by reference to decide when a same-route search change can skip the loader fetch (re-render only).
|
|
2618
|
-
"const searchClientKeys = {}",
|
|
2619
|
-
// routeId → the page's optional post-hydration client loader/action hooks. Hooks are populated only
|
|
2620
|
-
// after the route chunk loads, so routes that do not declare them remain ordinary route modules.
|
|
2621
|
-
"const routeHooks = {}",
|
|
2622
|
-
"const loadModule = async (id) => {",
|
|
2623
|
-
" if (chains[id]) return",
|
|
2624
|
-
" const mods = await loaders[id]()",
|
|
2625
|
-
// For an error route the `_error` module is appended LAST: wrap the page (now second-to-last) in
|
|
2626
|
-
// the adapter's boundary so a client render error renders the `_error` UI. DOM-transparent, so the
|
|
2627
|
-
// hydrated tree matches the SSR markup (which has no boundary). Falls back to the plain chain when
|
|
2628
|
-
// the adapter has no `errorBoundary`.
|
|
2629
|
-
// `metas[id]` is the chain's `meta` exports in head order (outermost layout → … → page), so a
|
|
2630
|
-
// soft-nav merges the layout chain's head with the page's - matching the SSR `<head>` (sitewide
|
|
2631
|
-
// layout tags persist across client navigation, no flash of page-only head). `_error` carries no
|
|
2632
|
-
// head (a terminal boundary), so it's excluded from the meta list for error routes.
|
|
2633
|
-
" if (errorBoundary && errorRouteIds.has(id)) {",
|
|
2634
|
-
" const fallback = mods[mods.length - 1].default",
|
|
2635
|
-
" const page = mods[mods.length - 2].default",
|
|
2636
|
-
" const layouts = mods.slice(0, mods.length - 2).map((m) => m.default)",
|
|
2637
|
-
" chains[id] = [...layouts, errorBoundary(fallback), page]",
|
|
2638
|
-
" metas[id] = mods.slice(0, mods.length - 1).map((m) => m.meta)",
|
|
2639
|
-
// The search-schema CHAIN is the layout modules + page (all but the appended `_error`), so a layout's
|
|
2640
|
-
// `searchSchema` merges with the page's. Client keys come from the page (second-to-last).
|
|
2641
|
-
" searchSchemas[id] = mods.slice(0, mods.length - 1).map((m) => m.searchSchema)",
|
|
2642
|
-
" searchClientKeys[id] = mods[mods.length - 2].searchClientKeys ?? []",
|
|
2643
|
-
" } else {",
|
|
2644
|
-
" chains[id] = mods.map((m) => m.default)",
|
|
2645
|
-
" metas[id] = mods.map((m) => m.meta)",
|
|
2646
|
-
// The chain is every module (layouts + page); client keys come from the page (the last module).
|
|
2647
|
-
" searchSchemas[id] = mods.map((m) => m.searchSchema)",
|
|
2648
|
-
" searchClientKeys[id] = mods[mods.length - 1].searchClientKeys ?? []",
|
|
2649
|
-
" }",
|
|
2650
|
-
" const page = errorRouteIds.has(id) ? mods[mods.length - 2] : mods[mods.length - 1]",
|
|
2651
|
-
// Keep interception client-safe: only neutral name/mode metadata crosses into the router. The
|
|
2652
|
-
// server-side boundary load function (which may close over secrets) is never stored in the client
|
|
2653
|
-
// hook table or serialized into a browser bundle by this wiring.
|
|
2654
|
-
" const boundaryMods = errorRouteIds.has(id) ? mods.slice(0, -1) : mods",
|
|
2655
|
-
" 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 }) })))",
|
|
2656
|
-
" routeHooks[id] = { clientLoader: page.clientLoader, clientAction: page.clientAction, boundaries }",
|
|
2657
|
-
"}",
|
|
2658
|
-
"const patterns = [",
|
|
2659
|
-
...patternRows,
|
|
2660
|
-
"]",
|
|
2661
|
-
// Derive the initial route from the URL (correct on refresh/deep-link); fall back to the
|
|
2662
|
-
// server-injected route id for non-pattern routes (e.g. _404, which matches nothing).
|
|
2663
|
-
"const matched = createMatcher(patterns)(location.pathname)",
|
|
2664
|
-
// Map any `{__nifra_deferred: id}` placeholder in the SSR data to the registry's promise, so the
|
|
2665
|
-
// component receives real promises to `<Await>` (a no-op when a page has no deferred data).
|
|
2666
|
-
MAP_DEFERRED_SOURCE,
|
|
2667
|
-
"const initial = {",
|
|
2668
|
-
` routeId: matched ? matched.routeId : (window.${ROUTE_GLOBAL} ?? ""),`,
|
|
2669
|
-
" params: matched ? matched.params : {},",
|
|
2670
|
-
// pathname + search (NOT just pathname): the SSR render threads `pathname+search` into
|
|
2671
|
-
// `useLocation`/`useSearchParams`, so the hydrating initial state must carry the query too or a
|
|
2672
|
-
// page reading the search string would hydrate-mismatch. The #hash is client-only (never SSR'd).
|
|
2673
|
-
" path: location.pathname + location.search,",
|
|
2674
|
-
` data: mapDeferred(window.${DATA_GLOBAL}),`,
|
|
2675
|
-
// Undefined on a page-only app, which is exactly what the router treats as "no layout data".
|
|
2676
|
-
` layoutData: window.${LAYOUT_DATA_GLOBAL} && window.${LAYOUT_DATA_GLOBAL}.map(mapDeferred),`,
|
|
2677
|
-
` boundaries: mapDeferred(window.${BOUNDARY_GLOBAL}),`,
|
|
2678
|
-
// actionData (only set after a form POST) is in the initial state so the binding hydrates
|
|
2679
|
-
// consistently with the server-rendered markup; mapped through `mapDeferred` too so a deferred
|
|
2680
|
-
// action's placeholders become registry markers (a no-op when the action didn't defer).
|
|
2681
|
-
` actionData: mapDeferred(window.${ACTION_GLOBAL}),`,
|
|
2682
|
-
" pending: false,",
|
|
2683
|
-
"}",
|
|
2684
|
-
`const statusRoutes = ${JSON.stringify(statusRoutes)}`,
|
|
2685
|
-
"const router = createClientRouter({ patterns, initial, loadModule, statusRoutes, searchClientKeys, routeHooks })",
|
|
2686
|
-
"installHistory(router)",
|
|
2687
|
-
"installForms(router)",
|
|
2688
|
-
// The container is found in the DOM, not baked in. `rootId` is a per-render option and this entry
|
|
2689
|
-
// is a per-build artifact, so a hardcoded id is a guess: `renderPage({ rootId: "app" })` produced a
|
|
2690
|
-
// perfect document that hydrated NOTHING, with no diagnostic, because the missing `#root` was
|
|
2691
|
-
// swallowed by an `if (root)`. `renderPage` marks a custom container with `ROOT_ATTRIBUTE`; the
|
|
2692
|
-
// default `#root` it leaves unmarked, which is what the fallback is for.
|
|
2693
|
-
`const root = document.querySelector("body > div[${ROOT_ATTRIBUTE}]") ?? document.getElementById("root")`,
|
|
2694
|
-
// Loud, because there is no recoverable case: this entry ships only in a document `renderPage`
|
|
2695
|
-
// opened a container in, so reaching here means the document is not the one it was built for.
|
|
2696
|
-
`if (!root) throw new Error(${JSON.stringify(
|
|
2697
|
-
"[nifra/web] no hydration container found. The generated bootstrap mounts into the element " +
|
|
2698
|
-
`renderPage opened the app in - the one marked \`${ROOT_ATTRIBUTE}\` when a custom \`rootId\` ` +
|
|
2699
|
-
"is set, `#root` otherwise - and this document has neither. If you emit the document yourself, " +
|
|
2700
|
-
'keep `<div id="root">` around the SSR markup.',
|
|
2701
|
-
)})`,
|
|
2702
|
-
// Load the initial route's chunk, then hydrate the Router (chain is cached). The initial head
|
|
2703
|
-
// is server-rendered; subsequent navigations update it from the matched route's meta + data.
|
|
2704
|
-
"loadModule(initial.routeId).then(() => {",
|
|
2705
|
-
" mountRouter({ router, routes: chains, searchSchemas, container: root })",
|
|
2706
|
-
// Run the optional client loader only after the adapter has mounted the SSR tree. The initial
|
|
2707
|
-
// server data is already in `window.__NIFRA_DATA__`, so `serverLoader()` reuses it and cannot
|
|
2708
|
-
// duplicate the first request.
|
|
2709
|
-
" router.hydrate().catch((error) => console.error('[nifra/web] clientLoader failed:', error))",
|
|
2710
|
-
// Next frame: the adapter has committed its initial hydration, so handlers are attached. Flip the
|
|
2711
|
-
// document to interactive (`data-nifra-hydrated` + `nifra:hydrated`) for apps to gate pre-hydration UI.
|
|
2712
|
-
" requestAnimationFrame(signalHydrated)",
|
|
2713
|
-
" router.subscribe(() => {",
|
|
2714
|
-
" const s = router.snapshot()",
|
|
2715
|
-
// Merge the matched route's chain meta (layouts→page) into one head - same contract as SSR.
|
|
2716
|
-
" if (!s.pending) {",
|
|
2717
|
-
// `origin: location.origin` matches the SSR `originOf(req)` (both are `URL.origin` for the same
|
|
2718
|
-
// page URL), so a soft-nav re-resolves the SAME absolute canonical/og:url - no head drift.
|
|
2719
|
-
" const args = { data: s.data, params: s.params, origin: location.origin }",
|
|
2720
|
-
" applyHead(mergeHeads((metas[s.routeId] ?? [undefined]).map((m) => resolveMeta(m, args))))",
|
|
2721
|
-
" }",
|
|
2722
|
-
" })",
|
|
2723
|
-
"})",
|
|
2724
|
-
].join("\n")}\n`
|
|
2725
|
-
}
|
|
2726
|
-
|
|
2727
|
-
export interface GenerateRouteSearchTypesOptions {
|
|
2728
|
-
/** Turn a route source file into a module specifier for a `typeof import(...)` type - the same contract
|
|
2729
|
-
* as `generateClientEntry`'s `resolve`, but WITHOUT a file extension (a `.d.ts` imports a module, not a
|
|
2730
|
-
* path). Relative to wherever the emitted `.d.ts` is written. */
|
|
2731
|
-
readonly resolve: (file: string) => string
|
|
2732
|
-
/** The module whose `RouteSearch` interface is augmented (default `"@nifrajs/web"`). Every adapter's
|
|
2733
|
-
* `NavigateTarget` reads `@nifrajs/web`'s `RouteSearch`, so the default types `navigate` on all of them. */
|
|
2734
|
-
readonly module?: string
|
|
2735
|
-
}
|
|
2736
|
-
|
|
2737
|
-
/**
|
|
2738
|
-
* Codegen: emit a `.d.ts` that types cross-route `navigate({ to, search })` against each route's
|
|
2739
|
-
* `searchSchema`. It augments the {@link RouteSearch} map with `"<pattern>": SearchOf<typeof import(...)>`
|
|
2740
|
-
* for every **static** route (one with no `:param`/`*` segment - a concrete `to` a caller can pass; a
|
|
2741
|
-
* dynamic route's concrete path can't match its pattern key, so those keep the loose object form). Written
|
|
2742
|
-
* by `nifra sync-routes` next to the route tree and included in the app's `tsconfig`; because it is
|
|
2743
|
-
* regenerated from the route files, a `navigate` whose `search` no longer matches the route's schema
|
|
2744
|
-
* becomes a `tsc` error - the same drift guarantee as nifra's typed client, no hand-maintained map.
|
|
2745
|
-
*/
|
|
2746
|
-
export function generateRouteSearchTypes(
|
|
2747
|
-
manifest: Manifest,
|
|
2748
|
-
options: GenerateRouteSearchTypesOptions,
|
|
2749
|
-
): string {
|
|
2750
|
-
const { resolve, module = "@nifrajs/web" } = options
|
|
2751
|
-
// Static routes only (no `:param` / `*` catch-all): their pattern IS a concrete `to`. Sorted for stable
|
|
2752
|
-
// output; a dynamic route falls through to the loose `{ to: string; search?: Record<...> }` form.
|
|
2753
|
-
const entries = manifest.routes
|
|
2754
|
-
.filter((route) => !/[:*]/.test(route.pattern))
|
|
2755
|
-
.map((route) => [route.pattern, resolve(route.file)] as const)
|
|
2756
|
-
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
|
2757
|
-
.map(
|
|
2758
|
-
([pattern, spec]) =>
|
|
2759
|
-
` ${JSON.stringify(pattern)}: SearchOf<typeof import(${JSON.stringify(spec)})>`,
|
|
2760
|
-
)
|
|
2761
|
-
return `${[
|
|
2762
|
-
"// GENERATED by @nifrajs/web generateRouteSearchTypes - typed cross-route navigate({ to, search }).",
|
|
2763
|
-
"// Regenerated from the route files; a stale navigate search shape becomes a tsc error. Do not edit.",
|
|
2764
|
-
'import type { SearchOf } from "@nifrajs/web"',
|
|
2765
|
-
`declare module ${JSON.stringify(module)} {`,
|
|
2766
|
-
" interface RouteSearch {",
|
|
2767
|
-
...entries,
|
|
2768
|
-
" }",
|
|
2769
|
-
"}",
|
|
2770
|
-
"export {}",
|
|
2771
|
-
].join("\n")}\n`
|
|
2772
|
-
}
|
|
2773
|
-
|
|
2774
|
-
export interface GenerateServerManifestOptions {
|
|
2775
|
-
/** Turn a route/layout source file (relative to the routes dir) into an import specifier -
|
|
2776
|
-
* same contract as `generateClientEntry`'s `resolve`. */
|
|
2777
|
-
readonly resolve: (file: string) => string
|
|
2778
|
-
/** The content-hashed client entry URL (from `buildClient`'s manifest), **baked** into the emitted
|
|
2779
|
-
* module - a disk-less worker can't read `manifest.json` at runtime. */
|
|
2780
|
-
readonly clientEntry: string
|
|
2781
|
-
/** The app's aggregate stylesheet URLs (`buildClient`'s `BuildManifest.css`) - baked as
|
|
2782
|
-
* `export const styles` so the server entry can hand them to `createWebApp` (→ `<link rel="stylesheet">`
|
|
2783
|
-
* in `<head>`). Without this the built SSR page ships no CSS link and renders unstyled. */
|
|
2784
|
-
readonly styles?: readonly string[] | undefined
|
|
2785
|
-
/** Per-route stylesheet URLs (`buildClient`'s `BuildManifest.routeStyles`) - baked as
|
|
2786
|
-
* `export const routeStyles` so a page links only the CSS its route chain uses. */
|
|
2787
|
-
readonly routeStyles?: Readonly<Record<string, readonly string[]>> | undefined
|
|
2788
|
-
/** Emit **lazy** per-route loaders (`() => import("./routes/x")`, a static specifier) instead of
|
|
2789
|
-
* eager `import * as`, so a bundler with code-splitting emits one chunk per route - loaded on the
|
|
2790
|
-
* first request to it, not all at boot (smaller cold-start parse). Default `false` (eager). Both
|
|
2791
|
-
* modes are fs-free with statically-analyzable specifiers; only the *when* differs. */
|
|
2792
|
-
readonly lazy?: boolean
|
|
2793
|
-
}
|
|
2794
|
-
|
|
2795
|
-
/**
|
|
2796
|
-
* Codegen: emit a **server manifest** module (as source) for disk-less edge runtimes (Cloudflare
|
|
2797
|
-
* Workers, …) - and, with a `target`, any portable server bundle. `discoverRoutes` scans `node:fs`
|
|
2798
|
-
* and dynamic-imports each route by a *runtime* path - neither exists on workerd. This instead emits
|
|
2799
|
-
* **statically-analyzable** imports of every route/layout/`_error`/terminal status page (so the bundler includes them) and
|
|
2800
|
-
* rebuilds the manifest with `buildManifest` - the SAME pure logic `discoverRoutes` feeds, so patterns
|
|
2801
|
-
* + layout chains are identical. Eager (`import * as`) by default; `lazy` emits `() => import(...)` so
|
|
2802
|
-
* a code-splitting bundler chunks per route. The emitted module exports `manifest` (consumed by
|
|
2803
|
-
* `createWebApp`, unchanged) + `clientEntry` (baked). Write it to a file and bundle it into the worker
|
|
2804
|
-
* entry (see `buildServer` in `@nifrajs/web/build`).
|
|
2805
|
-
*/
|
|
2806
|
-
// Route source files are emitted as EXTENSIONLESS import specifiers. An explicit `.tsx`/`.ts` in an
|
|
2807
|
-
// import is a hard error under a plain `tsc` (TS5097) unless `allowImportingTsExtensions` is set - which
|
|
2808
|
-
// only the nifra template tsconfig does - so the generated manifest failed a bare `tsc` with one error
|
|
2809
|
-
// per route. Every bundler and `moduleResolution` still resolves the extensionless form to the source
|
|
2810
|
-
// file, so dropping the extension costs nothing at build time and makes the output portable. Only a
|
|
2811
|
-
// known TS/JS source extension is stripped; the map KEY keeps its extension (it is the route identifier
|
|
2812
|
-
// `buildManifest` matches, not an import path).
|
|
2813
|
-
const SOURCE_EXTENSION = /\.(?:[mc]?tsx?|[mc]?jsx?)$/
|
|
2814
|
-
function moduleSpecifier(resolved: string): string {
|
|
2815
|
-
return resolved.replace(SOURCE_EXTENSION, "")
|
|
2816
|
-
}
|
|
2817
|
-
|
|
2818
|
-
export function generateServerManifest(
|
|
2819
|
-
manifest: Manifest,
|
|
2820
|
-
options: GenerateServerManifestOptions,
|
|
2821
|
-
): string {
|
|
2822
|
-
const { resolve, clientEntry, styles, routeStyles, lazy = false } = options
|
|
2823
|
-
// Every unique source file in the manifest (routes + layouts + error/status pages), sorted for stable output.
|
|
2824
|
-
const files = [
|
|
2825
|
-
...new Set([
|
|
2826
|
-
...manifest.routes.map((r) => r.file),
|
|
2827
|
-
...Object.values(manifest.layouts).map((l) => l.file),
|
|
2828
|
-
...Object.values(manifest.errors ?? {}).map((e) => e.file),
|
|
2829
|
-
...(manifest.notFound ? [manifest.notFound.file] : []),
|
|
2830
|
-
...Object.values(manifest.statusPages ?? {}).map((page) => page.file),
|
|
2831
|
-
]),
|
|
2832
|
-
].sort()
|
|
2833
|
-
const header = [
|
|
2834
|
-
"// GENERATED by @nifrajs/web generateServerManifest - route manifest for the disk-less edge",
|
|
2835
|
-
"// (no filesystem; route imports are static specifiers the bundler resolves). buildManifest is",
|
|
2836
|
-
"// the same pure logic discoverRoutes feeds, so patterns + layout chains match exactly.",
|
|
2837
|
-
'import { buildManifest, type RouteModule } from "@nifrajs/web"',
|
|
2838
|
-
]
|
|
2839
|
-
const clientEntryLine = `export const clientEntry = ${JSON.stringify(clientEntry)}`
|
|
2840
|
-
// Baked stylesheet URLs so the server entry can pass them to createWebApp → <link rel="stylesheet">.
|
|
2841
|
-
// Always emitted (default empty) so consumers can `import { styles, routeStyles }` unconditionally.
|
|
2842
|
-
const stylesLine = `export const styles = ${JSON.stringify(styles ?? [])}`
|
|
2843
|
-
const routeStylesLine = `export const routeStyles = ${JSON.stringify(routeStyles ?? {})}`
|
|
2844
|
-
if (lazy) {
|
|
2845
|
-
// Lazy: `() => import("./routes/x")` per route (static specifier → one chunk per route under a
|
|
2846
|
-
// code-splitting bundler, loaded on first request). The map keys are the route-relative paths
|
|
2847
|
-
// `buildManifest` expects; the importer it builds calls the per-file loader.
|
|
2848
|
-
const loaders = files.map(
|
|
2849
|
-
(file) =>
|
|
2850
|
-
` ${jsStringLiteral(file)}: () => import(${jsStringLiteral(moduleSpecifier(resolve(file)))}),`,
|
|
2851
|
-
)
|
|
2852
|
-
return `${[
|
|
2853
|
-
...header,
|
|
2854
|
-
"const loaders: Record<string, () => Promise<RouteModule>> = {",
|
|
2855
|
-
...loaders,
|
|
2856
|
-
"}",
|
|
2857
|
-
clientEntryLine,
|
|
2858
|
-
stylesLine,
|
|
2859
|
-
routeStylesLine,
|
|
2860
|
-
"export const manifest = buildManifest(Object.keys(loaders), (file) => () => loaders[file]())",
|
|
2861
|
-
].join("\n")}\n`
|
|
2862
|
-
}
|
|
2863
|
-
// Eager: `import * as` per route (all bundled into the entry, parsed at boot). Index-based
|
|
2864
|
-
// identifiers are collision-proof regardless of filename.
|
|
2865
|
-
const imports = files.map(
|
|
2866
|
-
(file, i) => `import * as m${i} from ${JSON.stringify(moduleSpecifier(resolve(file)))}`,
|
|
2867
|
-
)
|
|
2868
|
-
const entries = files.map((file, i) => ` ${JSON.stringify(file)}: m${i},`)
|
|
2869
|
-
return `${[
|
|
2870
|
-
...header,
|
|
2871
|
-
...imports,
|
|
2872
|
-
"const modules: Record<string, RouteModule> = {",
|
|
2873
|
-
...entries,
|
|
2874
|
-
"}",
|
|
2875
|
-
clientEntryLine,
|
|
2876
|
-
stylesLine,
|
|
2877
|
-
routeStylesLine,
|
|
2878
|
-
"export const manifest = buildManifest(Object.keys(modules), (file) => () => Promise.resolve(modules[file]))",
|
|
2879
|
-
].join("\n")}\n`
|
|
2880
|
-
}
|
|
2881
|
-
|
|
2882
|
-
/**
|
|
2883
|
-
* The two file-name conventions that decide what never reaches a browser: a `*.server` module is
|
|
2884
|
-
* EMPTIED in the client build, a `*.fn` module is REPLACED with client stubs.
|
|
2885
|
-
*
|
|
2886
|
-
* Exported because more than the bundlers need them. `nifra dev --bun` cannot transform (Bun's dev
|
|
2887
|
-
* bundler takes no plugins) so it must REFUSE instead, and a refusal driven by its own hand-written
|
|
2888
|
-
* glob drifts from the transform - which is how `.fn.mts` came to be stubbed by both build pipelines
|
|
2889
|
-
* and waved through by the guard that exists to stop it leaking. Anything deciding "is this module
|
|
2890
|
-
* server-only" should import the matcher rather than re-encode it.
|
|
2891
|
-
*/
|
|
2892
|
-
export { SERVER_FN_MODULE, SERVER_ONLY_MODULE } from "./internal/server-boundary.ts"
|