@nifrajs/web 2.1.0 → 2.3.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/README.md +3 -3
- package/dist/build-vite.d.ts +68 -0
- package/dist/build-vite.d.ts.map +1 -0
- package/dist/build-vite.js +433 -0
- package/dist/build-vite.js.map +1 -0
- package/dist/build.d.ts +174 -65
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +376 -135
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +83 -0
- package/dist/bun-dev-entry.d.ts.map +1 -0
- package/dist/bun-dev-entry.js +136 -0
- package/dist/bun-dev-entry.js.map +1 -0
- package/dist/chunk-names.js +1 -1
- package/dist/client.d.ts +3 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +209 -27
- package/dist/client.js.map +1 -1
- package/dist/conformance.d.ts +2 -2
- package/dist/conformance.js +3 -3
- package/dist/deferred.d.ts +11 -11
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +16 -16
- package/dist/dev-error.d.ts +2 -2
- package/dist/dev-error.js +3 -3
- package/dist/dev-port.d.ts +36 -0
- package/dist/dev-port.d.ts.map +1 -0
- package/dist/dev-port.js +48 -0
- package/dist/dev-port.js.map +1 -0
- package/dist/dev.d.ts +62 -5
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +419 -60
- package/dist/dev.js.map +1 -1
- package/dist/draft.d.ts +44 -7
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +92 -9
- package/dist/draft.js.map +1 -1
- package/dist/fn-state.d.ts +56 -0
- package/dist/fn-state.d.ts.map +1 -0
- package/dist/fn-state.js +77 -0
- package/dist/fn-state.js.map +1 -0
- package/dist/fn.d.ts +111 -0
- package/dist/fn.d.ts.map +1 -0
- package/dist/fn.js +125 -0
- package/dist/fn.js.map +1 -0
- package/dist/fonts-google.d.ts +9 -9
- package/dist/fonts-google.js +10 -10
- package/dist/fonts.d.ts +7 -7
- package/dist/fonts.js +1 -1
- package/dist/forms.d.ts +8 -8
- package/dist/fs.d.ts +15 -2
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +8 -2
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +266 -74
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +869 -179
- package/dist/index.js.map +1 -1
- package/dist/internal/head-attributes.d.ts +15 -0
- package/dist/internal/head-attributes.d.ts.map +1 -0
- package/dist/internal/head-attributes.js +99 -0
- package/dist/internal/head-attributes.js.map +1 -0
- package/dist/internal/script-types.d.ts +14 -0
- package/dist/internal/script-types.d.ts.map +1 -0
- package/dist/internal/script-types.js +17 -0
- package/dist/internal/script-types.js.map +1 -0
- package/dist/internal/server-boundary.d.ts +60 -0
- package/dist/internal/server-boundary.d.ts.map +1 -0
- package/dist/internal/server-boundary.js +174 -0
- package/dist/internal/server-boundary.js.map +1 -0
- package/dist/internal/timing-safe-equal.d.ts +16 -0
- package/dist/internal/timing-safe-equal.d.ts.map +1 -0
- package/dist/internal/timing-safe-equal.js +26 -0
- package/dist/internal/timing-safe-equal.js.map +1 -0
- package/dist/internal/vite-import.d.ts +63 -0
- package/dist/internal/vite-import.d.ts.map +1 -0
- package/dist/internal/vite-import.js +94 -0
- package/dist/internal/vite-import.js.map +1 -0
- package/dist/islands.d.ts +5 -5
- package/dist/islands.js +5 -5
- package/dist/isr.d.ts +15 -15
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +8 -21
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +12 -6
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +121 -40
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +136 -26
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +91 -0
- package/dist/module-graph.d.ts.map +1 -0
- package/dist/module-graph.js +92 -0
- package/dist/module-graph.js.map +1 -0
- package/dist/navigation.d.ts +120 -5
- package/dist/navigation.d.ts.map +1 -1
- package/dist/navigation.js +37 -5
- package/dist/navigation.js.map +1 -1
- package/dist/plugins/css-modules.d.ts +3 -3
- package/dist/plugins/css-modules.d.ts.map +1 -1
- package/dist/plugins/css-modules.js +0 -0
- package/dist/plugins/css-modules.js.map +1 -1
- package/dist/plugins/kit.d.ts +8 -8
- package/dist/plugins/kit.js +9 -9
- package/dist/plugins/scss.js +3 -3
- package/dist/plugins/vite-leak-guard.d.ts +45 -0
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
- package/dist/plugins/vite-leak-guard.js +67 -0
- package/dist/plugins/vite-leak-guard.js.map +1 -0
- package/dist/plugins/vite-server-fn.d.ts +23 -0
- package/dist/plugins/vite-server-fn.d.ts.map +1 -0
- package/dist/plugins/vite-server-fn.js +51 -0
- package/dist/plugins/vite-server-fn.js.map +1 -0
- package/dist/plugins/vite-server-only.d.ts +49 -0
- package/dist/plugins/vite-server-only.d.ts.map +1 -0
- package/dist/plugins/vite-server-only.js +57 -0
- package/dist/plugins/vite-server-only.js.map +1 -0
- package/dist/prerender.d.ts +5 -5
- package/dist/prerender.js +7 -7
- package/dist/public-dir.d.ts +36 -0
- package/dist/public-dir.d.ts.map +1 -0
- package/dist/public-dir.js +89 -0
- package/dist/public-dir.js.map +1 -0
- package/dist/query.d.ts +10 -10
- package/dist/query.js +15 -15
- package/dist/route-manifest.d.ts +91 -0
- package/dist/route-manifest.d.ts.map +1 -0
- package/dist/route-manifest.js +128 -0
- package/dist/route-manifest.js.map +1 -0
- package/dist/router.d.ts +94 -22
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +170 -58
- package/dist/router.js.map +1 -1
- package/dist/search.d.ts +94 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +232 -0
- package/dist/search.js.map +1 -0
- package/dist/server-only.d.ts +6 -6
- package/dist/server-only.js +7 -7
- package/dist/service-worker.d.ts +56 -0
- package/dist/service-worker.d.ts.map +1 -0
- package/dist/service-worker.js +127 -0
- package/dist/service-worker.js.map +1 -0
- package/dist/vite.d.ts +25 -17
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +75 -33
- package/dist/vite.js.map +1 -1
- package/package.json +48 -4
- package/server-manifest.d.ts +31 -0
- package/src/build-vite.ts +553 -0
- package/src/build.ts +563 -172
- package/src/bun-dev-entry.ts +184 -0
- package/src/chunk-names.ts +1 -1
- package/src/client.ts +221 -30
- package/src/conformance.ts +3 -3
- package/src/deferred.ts +17 -17
- package/src/dev-error.ts +3 -3
- package/src/dev-port.ts +63 -0
- package/src/dev.ts +468 -66
- package/src/draft.ts +124 -12
- package/src/fn-state.ts +107 -0
- package/src/fn.ts +199 -0
- package/src/fonts-google.ts +13 -13
- package/src/fonts.ts +7 -7
- package/src/forms.ts +8 -8
- package/src/fs.ts +24 -6
- package/src/index.ts +1174 -225
- package/src/internal/head-attributes.ts +109 -0
- package/src/internal/script-types.ts +18 -0
- package/src/internal/server-boundary.ts +187 -0
- package/src/internal/timing-safe-equal.ts +24 -0
- package/src/internal/vite-import.ts +92 -0
- package/src/islands.ts +7 -7
- package/src/isr.ts +18 -30
- package/src/llms-txt.ts +13 -5
- package/src/manifest.ts +264 -48
- package/src/module-graph.ts +146 -0
- package/src/navigation.ts +151 -5
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/kit.ts +11 -11
- package/src/plugins/scss.ts +3 -3
- package/src/plugins/vite-leak-guard.ts +110 -0
- package/src/plugins/vite-server-fn.ts +67 -0
- package/src/plugins/vite-server-only.ts +75 -0
- package/src/prerender.ts +10 -10
- package/src/public-dir.ts +108 -0
- package/src/query.ts +22 -22
- package/src/route-manifest.ts +240 -0
- package/src/router.ts +288 -74
- package/src/search.ts +297 -0
- package/src/server-only.ts +7 -7
- package/src/service-worker.ts +165 -0
- package/src/vite.ts +103 -49
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @nifrajs/web
|
|
2
|
+
* @nifrajs/web - the framework-agnostic SSR core. It owns the render *seam* and the HTML
|
|
3
3
|
* document orchestration, and knows nothing about any specific UI framework: a render
|
|
4
4
|
* adapter (@nifrajs/web-solid, @nifrajs/web-react, …) supplies the actual render + hydrate.
|
|
5
5
|
*
|
|
6
|
-
* The core treats a "component" and a hydration "container" as opaque `unknown`
|
|
6
|
+
* The core treats a "component" and a hydration "container" as opaque `unknown` - only
|
|
7
7
|
* the adapter interprets them. That keeps this package both framework-agnostic and free
|
|
8
8
|
* of DOM types: it is pure server orchestration + string building.
|
|
9
9
|
*/
|
|
@@ -11,44 +11,69 @@ import { NIFRA_BACKEND_MOUNT, } from "@nifrajs/core/mount";
|
|
|
11
11
|
import { server } from "@nifrajs/core/server";
|
|
12
12
|
import { DEFERRED_ERROR_CODE, DEFERRED_RUNTIME, defer, MAP_DEFERRED_SOURCE, ndjsonStream, prepareDeferred, } from "./deferred.js";
|
|
13
13
|
import { isDraftEnabled } from "./draft.js";
|
|
14
|
+
import { trustedHeadAttributes } from "./internal/head-attributes.js";
|
|
15
|
+
import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./internal/script-types.js";
|
|
14
16
|
import { ISR_REVALIDATE_HEADER } from "./isr.js";
|
|
15
17
|
import { generateLlmsTxt } from "./llms-txt.js";
|
|
16
|
-
import { DATA_HEADER, PRERENDERED_GLOBAL, REDIRECT_HEADER, REVALIDATE_HEADER, } from "./router.js";
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
import { createMatcher, DATA_HEADER, NAV_FROM_HEADER, PRERENDERED_GLOBAL, REDIRECT_HEADER, RETAIN_HEADER, REVALIDATE_HEADER, STATUS_HEADER, } from "./router.js";
|
|
19
|
+
import { searchOf, searchOfChain } from "./search.js";
|
|
20
|
+
// Draft / preview mode - a signed cookie that flips `ctx.draft` for loaders + bypasses ISR for editors.
|
|
21
|
+
export { DRAFT_COOKIE, disableDraft, enableDraft, isDraftEnabled, previewEndpoint, } from "./draft.js";
|
|
22
|
+
// Font optimization - a CLS-safe `@font-face` generator + a preload `<link>` for self-hosted fonts.
|
|
20
23
|
export { fontFace, fontPreload, } from "./fonts.js";
|
|
21
24
|
// ISR (incremental static regeneration): a pluggable cache store + the `withISR` stale-while-revalidate
|
|
22
25
|
// wrapper for rendered SSR responses.
|
|
23
26
|
export { ISR_REVALIDATE_HEADER, ISR_STATUS_HEADER, KVCacheStore, MemoryCacheStore, revalidateEndpoint, withISR, } from "./isr.js";
|
|
24
|
-
// File-based routing manifest
|
|
27
|
+
// File-based routing manifest - pure + fs-free. `discoverRoutes` (fs) lives in `@nifrajs/web/fs`.
|
|
25
28
|
export { buildManifest, enumerateStaticRoutes, filePathToPattern, filePathToPatterns, } from "./manifest.js";
|
|
26
|
-
// Navigation bridge
|
|
29
|
+
// Navigation bridge - a DOM-free seam the browser layer (`installHistory`) populates so an adapter's
|
|
27
30
|
// `useNavigate` (a route component, importing only this agnostic entry) reaches history-aware nav.
|
|
28
|
-
export { getBrowserNavigate, setBrowserNavigate, } from "./navigation.js";
|
|
29
|
-
//
|
|
31
|
+
export { getBrowserNavigate, IDLE_BLOCKER, registerBlocker, resolveNavigate, setBlockerController, setBrowserNavigate, } from "./navigation.js";
|
|
32
|
+
// public/ - user-authored static files, served identically in dev and production by one handler.
|
|
33
|
+
export { resolvePublicPath, servePublicDir, } from "./public-dir.js";
|
|
34
|
+
// Keyed query-cache (agnostic) - a `query(key, fn)` primitive (dedup + staleness + invalidation + GC)
|
|
30
35
|
// consumed by the per-adapter `useQuery`/`createQuery` bindings.
|
|
31
36
|
export { createMutation, createQueryClient, hashQueryKey, } from "./query.js";
|
|
32
|
-
// Agnostic client-side router core (pure + DOM-free)
|
|
37
|
+
// Agnostic client-side router core (pure + DOM-free) - consumed by per-adapter Router bindings.
|
|
33
38
|
// `DATA_HEADER` marks a navigation's data-only GET; `createWebApp` answers it with loader JSON.
|
|
34
|
-
export { createClientRouter, createMatcher, DATA_HEADER, REDIRECT_HEADER, REVALIDATE_HEADER, } from "./router.js";
|
|
35
|
-
// Deferred loader data (`defer()` + the `Deferred<T>` type)
|
|
39
|
+
export { createClientRouter, createMatcher, DATA_HEADER, NAV_FROM_HEADER, REDIRECT_HEADER, RETAIN_HEADER, REVALIDATE_HEADER, STATUS_HEADER, } from "./router.js";
|
|
40
|
+
// Deferred loader data (`defer()` + the `Deferred<T>` type) - consumed by the adapter's `<Await>`.
|
|
36
41
|
export { defer };
|
|
37
42
|
export { assertRenderAdapterConformance, RenderAdapterConformanceError, } from "./conformance.js";
|
|
43
|
+
// The search-derivation primitive: parse a URL query, validate it against a route's `searchSchema`
|
|
44
|
+
// (fail-closed to defaults), or return the raw parsed query when there is none. Both the server (loader
|
|
45
|
+
// ctx + `renderPage`) and a client adapter mount call this with the same URL + schema, so the two sides
|
|
46
|
+
// produce the identical value by construction. An adapter's `useSearch` binding reads its result.
|
|
47
|
+
export { searchOf, searchOfChain, serializeSearch } from "./search.js";
|
|
38
48
|
/** Global the server serializes loader data into; the client reads it to hydrate. */
|
|
39
49
|
export const DATA_GLOBAL = "__NIFRA_DATA__";
|
|
50
|
+
/** Per-layout loader data for hydration. Emitted ONLY when some layout in the chain has a loader, so
|
|
51
|
+
* a page-only app's HTML is byte-identical to before layout loaders existed. */
|
|
52
|
+
export const LAYOUT_DATA_GLOBAL = "__NIFRA_LAYOUT_DATA__";
|
|
40
53
|
/** Global the server writes the matched route id into; the client uses it to pick the chain. */
|
|
41
54
|
export const ROUTE_GLOBAL = "__NIFRA_ROUTE__";
|
|
42
55
|
/** Global the server serializes an action's data return into (absent on GETs); the client
|
|
43
56
|
* reads it so hydration after a native form POST matches the server-rendered markup. */
|
|
44
57
|
export const ACTION_GLOBAL = "__NIFRA_ACTION__";
|
|
45
58
|
/**
|
|
46
|
-
*
|
|
59
|
+
* Marker attribute on the app container, emitted ONLY when `rootId` is not the default.
|
|
60
|
+
*
|
|
61
|
+
* `rootId` is a per-RENDER option; the client entry is a per-BUILD artifact, so it cannot be
|
|
62
|
+
* generated knowing which id a given render will use. The container therefore announces itself, and
|
|
63
|
+
* the entry finds it by this attribute before falling back to `#root`. It has to be the DOM rather
|
|
64
|
+
* than another `window.__NIFRA_*` global: a second copy of the id can drift from the markup, while
|
|
65
|
+
* this is written by the same expression that writes the id and cannot.
|
|
66
|
+
*
|
|
67
|
+
* Absent on a default render, so an app that never sets `rootId` emits the same bytes it always has.
|
|
68
|
+
*/
|
|
69
|
+
export const ROOT_ATTRIBUTE = "data-nifra-root";
|
|
70
|
+
/**
|
|
71
|
+
* Pre-hydration form guard - a tiny inline script flushed in `<head>` (it runs in the window between
|
|
47
72
|
* first paint and the island bundle taking over). It neutralizes the one real hydration footgun: a
|
|
48
73
|
* JS-only form (a hand-wired `onSubmit` with no native fallback) submitting *natively* before its
|
|
49
74
|
* handler is attached, which navigates the browser to a broken `?field=…` GET of the current page.
|
|
50
75
|
*
|
|
51
|
-
* It blocks ONLY that shape
|
|
76
|
+
* It blocks ONLY that shape - an effective-GET form whose action targets the current document - and
|
|
52
77
|
* only until hydration commits (`data-nifra-hydrated`, set by the client entry). Method-`post` forms
|
|
53
78
|
* (progressive enhancement: the native POST hits the route `action`) and GET forms with a real action
|
|
54
79
|
* (a search box → `/search`) pass through untouched. Opt out per-form with `data-native`. Static text
|
|
@@ -64,11 +89,11 @@ export const PRE_HYDRATION_GUARD = "(function(){addEventListener('submit',functi
|
|
|
64
89
|
/**
|
|
65
90
|
* The single default port for the dev server (`@nifrajs/web/dev`, `@nifrajs/web/vite`) **and**
|
|
66
91
|
* `nifra start`. Deliberately uncommon: `3000`/`5173`/`8080` collide with whatever else is running
|
|
67
|
-
* (Next, Vite, a stray Node API). `4321` rarely is
|
|
92
|
+
* (Next, Vite, a stray Node API). `4321` rarely is - and being the *same* constant across `nifra dev`
|
|
68
93
|
* and `nifra start` means a project's URL doesn't change between commands. Override per-run with
|
|
69
94
|
* `--port <n>` or the `PORT` env var. */
|
|
70
95
|
export const DEFAULT_DEV_PORT = 4321;
|
|
71
|
-
// Shared, stateless
|
|
96
|
+
// Shared, stateless - allocated once at module load, not per render/stream.
|
|
72
97
|
const TEXT_ENCODER = new TextEncoder();
|
|
73
98
|
// XSS-safe `<script>` JSON escaping in a SINGLE pass: `<`/`>` would break out of the
|
|
74
99
|
// script element; U+2028/U+2029 are valid JSON but historically break JS string literals. Built via
|
|
@@ -86,8 +111,8 @@ const SCRIPT_ESCAPE_MAP = {
|
|
|
86
111
|
const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body");
|
|
87
112
|
const RESPONSE_RESULT = Symbol.for("nifra.response.result");
|
|
88
113
|
/**
|
|
89
|
-
* Server: render a full HTML document for a page
|
|
90
|
-
* markup (**streamed**) + the serialized loader data + the client module
|
|
114
|
+
* Server: render a full HTML document for a page - the adapter's hydration head + the SSR
|
|
115
|
+
* markup (**streamed**) + the serialized loader data + the client module - as a `Response`.
|
|
91
116
|
* The shell (`<head>` + the open container) flushes first, the adapter's app stream follows,
|
|
92
117
|
* then the tail (data globals + client entry). Pure Web Standards, so it returns straight from
|
|
93
118
|
* a nifra route handler and streams on any fetch runtime (Bun/Node/Deno/Workers).
|
|
@@ -97,27 +122,49 @@ export function renderPage(options) {
|
|
|
97
122
|
return page instanceof Promise ? page.then((p) => p.toResponse()) : page.toResponse();
|
|
98
123
|
}
|
|
99
124
|
export function renderPageResult(options) {
|
|
100
|
-
const { adapter, chain, data, actionData, clientEntry, preload = [], styles = [], prerenderedPaths = [], revalidate, routeId, status = 200, title = "nifra", head, rootId = "root", hydrate = true, islandScripts = [], } = options;
|
|
125
|
+
const { adapter, chain, data, actionData, clientEntry, preload = [], styles = [], prerenderedPaths = [], revalidate, routeId, status = 200, title = "nifra", head, rootId = "root", hydrate = true, islandScripts = [], nonce, headers: extraHeaders, } = options;
|
|
126
|
+
if (nonce !== undefined && nonce.trim() === "") {
|
|
127
|
+
throw new TypeError("[nifra/web] renderPage nonce must be non-empty when provided");
|
|
128
|
+
}
|
|
129
|
+
const nonceAttr = nonce === undefined ? "" : ` nonce="${escapeAttr(nonce)}"`;
|
|
101
130
|
const route = routeId === undefined ? "" : `window.${ROUTE_GLOBAL}=${serializeData(routeId)};`;
|
|
102
|
-
// The SSG prerendered-path set (when an app declares it)
|
|
131
|
+
// The SSG prerendered-path set (when an app declares it) - the client reads it to fetch a static
|
|
103
132
|
// `_data.json` on soft-nav into a prerendered route instead of hitting the worker. Empty ⇒ omitted.
|
|
104
133
|
const prerendered = prerenderedPaths.length === 0
|
|
105
134
|
? ""
|
|
106
135
|
: `window.${PRERENDERED_GLOBAL}=${serializeData(prerenderedPaths)};`;
|
|
107
136
|
// Split deferred values: the component sees markers (id + promise) to `<Await>`; the serialized
|
|
108
137
|
// data carries `{__nifra_deferred: id}` placeholders (promises don't serialize). `actionData` may
|
|
109
|
-
// also `defer()`
|
|
138
|
+
// also `defer()` - split it too, continuing the id space so a single registry settles both. The
|
|
110
139
|
// inline registry runtime is emitted only when something defers, so non-deferred output is unchanged.
|
|
111
140
|
const { forComponent, forClient, deferred } = prepareDeferred(data);
|
|
112
|
-
|
|
113
|
-
|
|
141
|
+
// Each layout's data is split in turn, each continuing the id space, so a `defer()` in a layout
|
|
142
|
+
// settles through the SAME registry as the page's rather than colliding on ids.
|
|
143
|
+
const layoutSplits = [];
|
|
144
|
+
if (options.layoutData !== undefined) {
|
|
145
|
+
let offset = deferred.length;
|
|
146
|
+
for (const entry of options.layoutData) {
|
|
147
|
+
const split = prepareDeferred(entry, offset);
|
|
148
|
+
offset += split.deferred.length;
|
|
149
|
+
layoutSplits.push(split);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const layoutDeferred = layoutSplits.flatMap((split) => split.deferred);
|
|
153
|
+
const actionSplit = actionData === undefined
|
|
154
|
+
? undefined
|
|
155
|
+
: prepareDeferred(actionData, deferred.length + layoutDeferred.length);
|
|
156
|
+
const allDeferred = [...deferred, ...layoutDeferred, ...(actionSplit ? actionSplit.deferred : [])];
|
|
157
|
+
// Omitted entirely when no layout has a loader - a page-only app emits exactly what it did before.
|
|
158
|
+
const layoutTail = options.layoutData === undefined
|
|
159
|
+
? ""
|
|
160
|
+
: `window.${LAYOUT_DATA_GLOBAL}=${serializeData(layoutSplits.map((split) => split.forClient))};`;
|
|
114
161
|
// Only emit the action global when an action actually ran, so plain GET output is unchanged.
|
|
115
162
|
const action = actionSplit === undefined
|
|
116
163
|
? ""
|
|
117
164
|
: `window.${ACTION_GLOBAL}=${serializeData(actionSplit.forClient)};`;
|
|
118
|
-
const deferredRuntime = allDeferred.length > 0 ? `<script>${DEFERRED_RUNTIME}</script>` : "";
|
|
165
|
+
const deferredRuntime = allDeferred.length > 0 ? `<script${nonceAttr}>${DEFERRED_RUNTIME}</script>` : "";
|
|
119
166
|
// Matched-route chunk preloads, concatenated directly (skip the `filter().map()`
|
|
120
|
-
// intermediate arrays
|
|
167
|
+
// intermediate arrays - and the whole loop - on the common no-extra-preload render). De-duped
|
|
121
168
|
// against the entry, which is preloaded separately below.
|
|
122
169
|
let preloadLinks = "";
|
|
123
170
|
if (preload.length > 0) {
|
|
@@ -126,7 +173,7 @@ export function renderPageResult(options) {
|
|
|
126
173
|
preloadLinks += `<link rel="modulepreload" href="${escapeAttr(url)}">`;
|
|
127
174
|
}
|
|
128
175
|
}
|
|
129
|
-
// The matched route's stylesheets
|
|
176
|
+
// The matched route's stylesheets - `<link rel="stylesheet">` in `<head>` so CSS arrives with the
|
|
130
177
|
// first paint (no FOUC). Render-blocking by design, and emitted regardless of `hydrate` (a static or
|
|
131
178
|
// `_error` page still wants its styles). In dev (Vite) CSS is injected client-side instead, so
|
|
132
179
|
// `styles` is empty there.
|
|
@@ -135,39 +182,61 @@ export function renderPageResult(options) {
|
|
|
135
182
|
styleLinks += `<link rel="stylesheet" href="${escapeAttr(url)}">`;
|
|
136
183
|
// Island bundles are referenced only by a `<script type="module">` at the END of `<body>` (below), so
|
|
137
184
|
// the browser doesn't discover them until the whole page is parsed. `modulepreload` them in `<head>`
|
|
138
|
-
// so the fetch starts immediately, in parallel with parsing
|
|
185
|
+
// so the fetch starts immediately, in parallel with parsing - regardless of `hydrate` (an island page
|
|
139
186
|
// is typically hydrate:false). Without this, a heavy island bundle on a cold first load leaves its
|
|
140
187
|
// server-rendered placeholder visible until that late, un-prefetched fetch lands ("stuck loading").
|
|
141
188
|
let islandPreloads = "";
|
|
142
189
|
for (const src of islandScripts)
|
|
143
190
|
islandPreloads += `<link rel="modulepreload" href="${escapeAttr(src)}">`;
|
|
144
|
-
// Shell
|
|
145
|
-
// open container. `modulepreload` of the client entry
|
|
146
|
-
// (`preloadLinks`)
|
|
147
|
-
// intermediate array + `join`)
|
|
191
|
+
// Shell - flushed before the app finishes rendering: `<head>` (title, meta, hydration head) + the
|
|
192
|
+
// open container. `modulepreload` of the client entry - plus the matched route's own chunks
|
|
193
|
+
// (`preloadLinks`) - lets the JS download while the body streams. One template literal (no
|
|
194
|
+
// intermediate array + `join`) - byte-identical to the prior output.
|
|
148
195
|
// A non-hydrated page (e.g. an `_error` boundary) omits the client-entry preload, route-chunk
|
|
149
|
-
// preloads, and deferred runtime
|
|
196
|
+
// preloads, and deferred runtime - there's no client takeover to feed.
|
|
150
197
|
const hydrationLinks = hydrate
|
|
151
|
-
? `<link rel="modulepreload" href="${escapeAttr(clientEntry)}">${preloadLinks}${deferredRuntime}`
|
|
198
|
+
? `<link rel="modulepreload" href="${escapeAttr(clientEntry ?? "")}">${preloadLinks}${deferredRuntime}`
|
|
152
199
|
: "";
|
|
153
200
|
// Runs in the first-paint→hydration window to swallow a JS-only form's broken native submit. Only on a
|
|
154
201
|
// hydrating page (a static/_error page has no client handlers, so no footgun).
|
|
155
|
-
const hydrationGuard = hydrate ? `<script>${PRE_HYDRATION_GUARD}</script>` : "";
|
|
156
|
-
|
|
202
|
+
const hydrationGuard = hydrate ? `<script${nonceAttr}>${PRE_HYDRATION_GUARD}</script>` : "";
|
|
203
|
+
// `<html>` attributes. `lang` defaults to `"en"` (so an app that never sets it is byte-identical to
|
|
204
|
+
// before); `dir` is omitted entirely when unset, which IS HTML's `ltr` default - emitting it
|
|
205
|
+
// unconditionally would change every existing app's output for no gain. Both attribute-escaped.
|
|
206
|
+
// `client.ts`'s `applyHead` mirrors this exact defaulting on soft-nav, so a hard load and a client
|
|
207
|
+
// navigation to the same URL produce the same `<html>` (no drift on a multilingual site).
|
|
208
|
+
const htmlAttrs = ` lang="${escapeAttr(head?.lang ?? "en")}"${head?.dir === undefined ? "" : ` dir="${escapeAttr(head.dir)}"`}`;
|
|
209
|
+
// Marks the container for the client entry when the id is not the one the entry falls back to. A
|
|
210
|
+
// static attribute name with no value, so `rootId` never reaches the markup twice and there is
|
|
211
|
+
// nothing here to escape. Omitted on the default so existing output is unchanged.
|
|
212
|
+
const rootMarker = rootId === "root" ? "" : ` ${ROOT_ATTRIBUTE}`;
|
|
213
|
+
const hydrationHead = adapter
|
|
214
|
+
.hydrationHead(nonce)
|
|
215
|
+
.replace(/<script(?![^>]*\bnonce=)(?=[\s>])/g, `<script${nonceAttr}`);
|
|
216
|
+
const shellHtml = `<!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}${hydrationLinks}${islandPreloads}${hydrationHead}</head><body><div id="${escapeAttr(rootId)}"${rootMarker}>`;
|
|
157
217
|
// Closes the hydration container; deferred resolve scripts go AFTER it (outside `#root`) so they
|
|
158
218
|
// aren't part of the adapter's hydrated tree (an inline script inside it breaks hydration).
|
|
159
219
|
const closeRootHtml = "</div>";
|
|
160
|
-
// Tail
|
|
220
|
+
// Tail - the loader-data globals + the client module. Module scripts defer (run after parse), so
|
|
161
221
|
// the data global + every streamed deferred resolution are set before the entry hydrates.
|
|
162
|
-
// Island bundles load regardless of `hydrate`
|
|
222
|
+
// Island bundles load regardless of `hydrate` - a static page (hydrate:false) ships no framework
|
|
163
223
|
// client but can still mount no-framework islands (`@nifrajs/web/islands`).
|
|
164
224
|
let islandTags = "";
|
|
165
225
|
for (const src of islandScripts)
|
|
166
|
-
islandTags += `<script type="module" src="${escapeAttr(src)}"></script>`;
|
|
226
|
+
islandTags += `<script type="module" src="${escapeAttr(src)}"${nonceAttr}></script>`;
|
|
167
227
|
const tailHtml = `${hydrate
|
|
168
|
-
? `<script>${route}${action}${prerendered}window.${DATA_GLOBAL}=${serializeData(forClient)}</script><script type="module" src="${escapeAttr(clientEntry)}"></script>`
|
|
228
|
+
? `<script${nonceAttr}>${route}${action}${prerendered}${layoutTail}window.${DATA_GLOBAL}=${serializeData(forClient)}</script><script type="module" src="${escapeAttr(clientEntry ?? "")}"${nonceAttr}></script>`
|
|
169
229
|
: ""}${islandTags}</body></html>`;
|
|
170
230
|
const headers = { "content-type": "text/html; charset=utf-8" };
|
|
231
|
+
// Caller-supplied headers (a terminal status page's `cache-control`, say). Applied before the
|
|
232
|
+
// framework's own below, so `content-type` and the ISR channel stay authoritative - a caller must
|
|
233
|
+
// not be able to mislabel the document or forge the freshness header by passing them here.
|
|
234
|
+
if (extraHeaders !== undefined) {
|
|
235
|
+
for (const [name, value] of new Headers(extraHeaders)) {
|
|
236
|
+
if (name !== "content-type")
|
|
237
|
+
headers[name] = value;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
171
240
|
// ISR: advertise the route's freshness so a `withISR` wrapper can set this page's cache TTL. A
|
|
172
241
|
// dedicated header (not the action-revalidation `x-nifra-revalidate`) so the TTL channel never aliases
|
|
173
242
|
// the client's path-list channel.
|
|
@@ -177,10 +246,17 @@ export function renderPageResult(options) {
|
|
|
177
246
|
data: forComponent,
|
|
178
247
|
actionData: actionSplit?.forComponent,
|
|
179
248
|
// `params`/`path` thread the matched route + URL to the adapter's `useParams`/`useLocation` so they
|
|
180
|
-
// render SSR-correct. Spread only when supplied (exactOptionalPropertyTypes)
|
|
249
|
+
// render SSR-correct. Spread only when supplied (exactOptionalPropertyTypes) - an adapter with no
|
|
181
250
|
// router bindings simply never reads them.
|
|
182
251
|
...(options.params !== undefined ? { params: options.params } : {}),
|
|
183
252
|
...(options.path !== undefined ? { path: options.path } : {}),
|
|
253
|
+
// Same rule for `search`: the validated query the adapter's `useSearch` reads, spread only when the
|
|
254
|
+
// caller supplied it (a non-router render never has it).
|
|
255
|
+
...(options.search !== undefined ? { search: options.search } : {}),
|
|
256
|
+
// Spread only when present, so a page-only render produces exactly the props it did before.
|
|
257
|
+
...(options.layoutData !== undefined
|
|
258
|
+
? { layoutData: layoutSplits.map((split) => split.forComponent) }
|
|
259
|
+
: {}),
|
|
184
260
|
};
|
|
185
261
|
// Fast path: nothing `defer()`s and the adapter can render synchronously to a string → buffer the
|
|
186
262
|
// whole document in one pass. Progressive streaming only benefits pages with deferred boundaries
|
|
@@ -194,10 +270,10 @@ export function renderPageResult(options) {
|
|
|
194
270
|
? new BufferedRenderedPage(shellHtml + out + closeRootHtml + tailHtml, status, headers)
|
|
195
271
|
: out.then((bodyHtml) => new BufferedRenderedPage(shellHtml + bodyHtml + closeRootHtml + tailHtml, status, headers));
|
|
196
272
|
}
|
|
197
|
-
return renderStreamedPage(adapter, chain, renderProps, shellHtml, closeRootHtml, allDeferred, tailHtml, status, headers).then((response) => new ResponseRenderedPage(response));
|
|
273
|
+
return renderStreamedPage(adapter, chain, renderProps, shellHtml, closeRootHtml, allDeferred, tailHtml, status, headers, nonceAttr).then((response) => new ResponseRenderedPage(response));
|
|
198
274
|
}
|
|
199
|
-
async function renderStreamedPage(adapter, chain, renderProps, shellHtml, closeRootHtml, allDeferred, tailHtml, status, headers) {
|
|
200
|
-
// Streaming path
|
|
275
|
+
async function renderStreamedPage(adapter, chain, renderProps, shellHtml, closeRootHtml, allDeferred, tailHtml, status, headers, nonceAttr) {
|
|
276
|
+
// Streaming path - required for `defer()` (progressive `<Await>` resolution) and used by any adapter
|
|
201
277
|
// that doesn't implement `renderToString`. Awaiting `renderToStream` resolves on shell-readiness
|
|
202
278
|
// (React: on-shell-ready; Solid: synchronously), so a shell-render throw surfaces before any byte is
|
|
203
279
|
// sent. A *mid*-stream failure errors the body instead.
|
|
@@ -206,7 +282,7 @@ async function renderStreamedPage(adapter, chain, renderProps, shellHtml, closeR
|
|
|
206
282
|
const closeRoot = enc.encode(closeRootHtml);
|
|
207
283
|
const tail = enc.encode(tailHtml);
|
|
208
284
|
const appStream = await adapter.renderToStream(chain, renderProps);
|
|
209
|
-
const body = streamDocument(shell, appStream, closeRoot, allDeferred, tail, enc);
|
|
285
|
+
const body = streamDocument(shell, appStream, closeRoot, allDeferred, tail, enc, nonceAttr);
|
|
210
286
|
return new Response(body, { status, headers });
|
|
211
287
|
}
|
|
212
288
|
class BufferedRenderedPage {
|
|
@@ -247,11 +323,11 @@ function htmlResponse(body, init) {
|
|
|
247
323
|
/**
|
|
248
324
|
* Assemble the document stream: `shell` → the app `stream` (forwarded chunk-by-chunk, so a streaming
|
|
249
325
|
* renderer's progressive flushing is preserved) → `closeRoot` → one `__nifraResolve`/`__nifraReject`
|
|
250
|
-
* script per deferred value (emitted once its promise settles
|
|
251
|
-
* the same Suspense boundaries
|
|
326
|
+
* script per deferred value (emitted once its promise settles - by now the app stream has awaited
|
|
327
|
+
* the same Suspense boundaries - and placed OUTSIDE `#root`) → `tail`. A mid-stream app error errors
|
|
252
328
|
* the result (the body breaks) rather than silently truncating a 200.
|
|
253
329
|
*/
|
|
254
|
-
function streamDocument(shell, stream, closeRoot, deferred, tail, enc) {
|
|
330
|
+
function streamDocument(shell, stream, closeRoot, deferred, tail, enc, nonceAttr) {
|
|
255
331
|
return new ReadableStream({
|
|
256
332
|
async start(controller) {
|
|
257
333
|
const reader = stream.getReader();
|
|
@@ -264,19 +340,19 @@ function streamDocument(shell, stream, closeRoot, deferred, tail, enc) {
|
|
|
264
340
|
controller.enqueue(value);
|
|
265
341
|
}
|
|
266
342
|
controller.enqueue(closeRoot);
|
|
267
|
-
// Stream each resolution as ITS OWN promise settles
|
|
343
|
+
// Stream each resolution as ITS OWN promise settles - NOT in array order. A slow
|
|
268
344
|
// defer() must not block a faster one; each script self-addresses by id, so order is irrelevant.
|
|
269
345
|
await Promise.all(deferred.map(async (d) => {
|
|
270
346
|
try {
|
|
271
347
|
const value = serializeData(await d.promise);
|
|
272
|
-
controller.enqueue(enc.encode(`<script>window.__nifraResolve(${d.id},${value})</script>`));
|
|
348
|
+
controller.enqueue(enc.encode(`<script${nonceAttr}>window.__nifraResolve(${d.id},${value})</script>`));
|
|
273
349
|
}
|
|
274
350
|
catch (err) {
|
|
275
|
-
// A rejected deferred streams __nifraReject (the client `<Await>` surfaces it)
|
|
351
|
+
// A rejected deferred streams __nifraReject (the client `<Await>` surfaces it) - it must
|
|
276
352
|
// not break the whole body. Redact: stream a stable opaque code, never the raw error
|
|
277
353
|
// text; log the real reason server-side.
|
|
278
354
|
console.error("[nifra/web] deferred value rejected:", err);
|
|
279
|
-
controller.enqueue(enc.encode(`<script>window.__nifraReject(${d.id},${serializeData(DEFERRED_ERROR_CODE)})</script>`));
|
|
355
|
+
controller.enqueue(enc.encode(`<script${nonceAttr}>window.__nifraReject(${d.id},${serializeData(DEFERRED_ERROR_CODE)})</script>`));
|
|
280
356
|
}
|
|
281
357
|
}));
|
|
282
358
|
controller.enqueue(tail);
|
|
@@ -298,12 +374,12 @@ function isSameOriginPath(location) {
|
|
|
298
374
|
return location.startsWith("/") && !location.startsWith("//");
|
|
299
375
|
}
|
|
300
376
|
/**
|
|
301
|
-
* Build a redirect `Response`
|
|
377
|
+
* Build a redirect `Response` - return it from a route `action` for the Post/Redirect/Get
|
|
302
378
|
* pattern (POST mutates, 303 sends the browser to a fresh GET, so a reload doesn't re-submit).
|
|
303
379
|
* Defaults to 303 (See Other); pass `{ status: 307 }` or `{ status: 308 }` to preserve the method.
|
|
304
380
|
*
|
|
305
381
|
* **Secure by default:** `location` must be a same-origin path (begins with `/`, not `//`). An
|
|
306
|
-
* off-origin/absolute destination throws unless you pass `{ external: true }`
|
|
382
|
+
* off-origin/absolute destination throws unless you pass `{ external: true }` - this closes the
|
|
307
383
|
* open-redirect footgun of `return redirect(formData.get("next"))` on the no-JS (native-form) path,
|
|
308
384
|
* which returns the action's `Response` verbatim.
|
|
309
385
|
*
|
|
@@ -313,19 +389,156 @@ export function redirect(location, options = {}) {
|
|
|
313
389
|
if (options.external !== true && !isSameOriginPath(location)) {
|
|
314
390
|
throw new Error(`[nifra/web] redirect(${JSON.stringify(location)}) is not a same-origin path. Use a path beginning with "/" (not "//"), or redirect(location, { external: true }) for a deliberate off-origin redirect. This guards against open redirects from unvalidated input.`);
|
|
315
391
|
}
|
|
316
|
-
// Reject CR/LF in the Location explicitly
|
|
392
|
+
// Reject CR/LF in the Location explicitly - defense-in-depth (response splitting / header
|
|
317
393
|
// injection). Spec-correct runtimes' Headers setter throws on CR/LF, but `external: true` lets
|
|
318
394
|
// unvalidated input reach this sink, so we don't rely on the runtime. Same posture as
|
|
319
395
|
// serializeCookie / the SSE frame formatter, which strip CRLF at their sinks.
|
|
320
396
|
if (/[\r\n]/.test(location)) {
|
|
321
|
-
throw new Error(`[nifra/web] redirect location contains a CR/LF character
|
|
397
|
+
throw new Error(`[nifra/web] redirect location contains a CR/LF character - refusing to emit a header-injecting redirect.`);
|
|
322
398
|
}
|
|
323
399
|
return new Response(null, { status: options.status ?? 303, headers: { location } });
|
|
324
400
|
}
|
|
401
|
+
/**
|
|
402
|
+
* The signatures a **duplicate module instance** produces during SSR, across engines.
|
|
403
|
+
*
|
|
404
|
+
* Two copies of React (or of `@nifrajs/core`) at the SAME version still fail, because module identity
|
|
405
|
+
* is path-based: hooks read a dispatcher off the copy that rendered, and the component imported the
|
|
406
|
+
* other one. The error that surfaces names a React internal, so it reads as a React bug and the actual
|
|
407
|
+
* cause - two directories - is a long inference away. Naming the cause at the point of failure is the
|
|
408
|
+
* difference between that and a five-second read.
|
|
409
|
+
*/
|
|
410
|
+
const DUPLICATE_INSTANCE_SIGNATURES = [
|
|
411
|
+
/resolveDispatcher\(\)/,
|
|
412
|
+
/Invalid hook call/,
|
|
413
|
+
/Cannot read propert(?:y|ies) of null \(reading '(?:use[A-Z]\w*)'\)/,
|
|
414
|
+
/null is not an object \(evaluating '.*\.use[A-Z]\w*'\)/,
|
|
415
|
+
];
|
|
416
|
+
/**
|
|
417
|
+
* Append the likely cause to an SSR error that carries a duplicate-instance signature.
|
|
418
|
+
*
|
|
419
|
+
* Deliberately does NOT claim the resolved paths: `@nifrajs/web` is framework-agnostic and does not
|
|
420
|
+
* depend on React, so it cannot resolve the copies without guessing. It names the condition and the
|
|
421
|
+
* command that reports the paths, which is the actionable part. Returns the error untouched when the
|
|
422
|
+
* signature does not match, so an ordinary render error reads exactly as before.
|
|
423
|
+
*/
|
|
424
|
+
function withDuplicateInstanceHint(err) {
|
|
425
|
+
if (!(err instanceof Error))
|
|
426
|
+
return err;
|
|
427
|
+
if (!DUPLICATE_INSTANCE_SIGNATURES.some((rx) => rx.test(err.message)))
|
|
428
|
+
return err;
|
|
429
|
+
const augmented = new Error(`${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.`, { cause: err });
|
|
430
|
+
augmented.name = err.name;
|
|
431
|
+
// `stack` is optional under exactOptionalPropertyTypes; assigning `undefined` would replace a real
|
|
432
|
+
// stack with nothing on engines that always populate it.
|
|
433
|
+
if (err.stack !== undefined)
|
|
434
|
+
augmented.stack = err.stack;
|
|
435
|
+
return augmented;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Narrow the route's params to the ones a layout owns.
|
|
439
|
+
*
|
|
440
|
+
* A layout wraps a URL prefix, so anything deeper belongs to a route beneath it. Handing it the full
|
|
441
|
+
* set would let a layout read a param it does not own - which reads fine until the layout is reused
|
|
442
|
+
* under a route that has no such param and the value silently becomes `undefined`.
|
|
443
|
+
*/
|
|
444
|
+
function scopeParams(params, owned) {
|
|
445
|
+
if (owned === undefined)
|
|
446
|
+
return params; // hand-built manifest with no scope info: unchanged behaviour
|
|
447
|
+
if (owned.length === 0)
|
|
448
|
+
return EMPTY_LAYOUT_PARAMS;
|
|
449
|
+
const scoped = {};
|
|
450
|
+
for (const name of owned) {
|
|
451
|
+
const value = params[name];
|
|
452
|
+
if (value !== undefined)
|
|
453
|
+
scoped[name] = value;
|
|
454
|
+
}
|
|
455
|
+
return scoped;
|
|
456
|
+
}
|
|
457
|
+
const EMPTY_LAYOUT_PARAMS = Object.freeze({});
|
|
458
|
+
const EMPTY_RETAIN = new Set();
|
|
459
|
+
/**
|
|
460
|
+
* Marks an error as coming from a LAYOUT loader, carrying that layout's id.
|
|
461
|
+
*
|
|
462
|
+
* The boundary that catches it must be at or above the failing layout's own segment. The route's
|
|
463
|
+
* innermost `_error` may live BELOW the layout that failed, and rendering there would wrap the
|
|
464
|
+
* boundary in a layout whose data never arrived - so the page would render inside a component that
|
|
465
|
+
* threw. A registry symbol, matching the other cross-module brands here.
|
|
466
|
+
*/
|
|
467
|
+
const LAYOUT_ERROR_ID = Symbol.for("nifra.web.layout-error-id");
|
|
468
|
+
const tagLayoutError = (err, layoutId) => {
|
|
469
|
+
if (typeof err === "object" && err !== null && !(LAYOUT_ERROR_ID in err)) {
|
|
470
|
+
Object.defineProperty(err, LAYOUT_ERROR_ID, { value: layoutId, enumerable: false });
|
|
471
|
+
}
|
|
472
|
+
return err;
|
|
473
|
+
};
|
|
474
|
+
const layoutErrorId = (err) => typeof err === "object" && err !== null
|
|
475
|
+
? err[LAYOUT_ERROR_ID]
|
|
476
|
+
: undefined;
|
|
477
|
+
const STATUS_SIGNAL = Symbol.for("nifra.web.status-signal");
|
|
478
|
+
/** Reason phrases for the plain-text fallback, used only when the app authored no `_<status>` and no
|
|
479
|
+
* `_404` page. Deliberately not exhaustive - anything unlisted falls back to "Error", which is more
|
|
480
|
+
* useful than shipping a table of every RFC status for a body almost nobody will see. */
|
|
481
|
+
const STATUS_TEXT = {
|
|
482
|
+
400: "Bad Request",
|
|
483
|
+
401: "Unauthorized",
|
|
484
|
+
402: "Payment Required",
|
|
485
|
+
403: "Forbidden",
|
|
486
|
+
404: "Not Found",
|
|
487
|
+
410: "Gone",
|
|
488
|
+
451: "Unavailable For Legal Reasons",
|
|
489
|
+
500: "Internal Server Error",
|
|
490
|
+
};
|
|
491
|
+
function isStatusSignal(value) {
|
|
492
|
+
return value instanceof Response && STATUS_SIGNAL in value;
|
|
493
|
+
}
|
|
494
|
+
function statusSignal(status, options) {
|
|
495
|
+
if (!Number.isInteger(status) || status < 400 || status > 599) {
|
|
496
|
+
throw new Error(`[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.`);
|
|
497
|
+
}
|
|
498
|
+
const response = new Response(null, { status });
|
|
499
|
+
// Non-enumerable so the brand never lands in a structured clone or a JSON log of the response.
|
|
500
|
+
Object.defineProperty(response, STATUS_SIGNAL, {
|
|
501
|
+
value: { status, ...(options.headers !== undefined ? { headers: options.headers } : {}) },
|
|
502
|
+
enumerable: false,
|
|
503
|
+
});
|
|
504
|
+
throw response;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Render the nearest `_404` page at status **404**. `throw` it from a loader when the record does not
|
|
508
|
+
* exist.
|
|
509
|
+
*
|
|
510
|
+
* This is the fix for the soft 404: a matched route whose loader finds nothing has otherwise no way to
|
|
511
|
+
* answer 404, so the path of least resistance is to return empty data and render "not found" inside a
|
|
512
|
+
* **200**. That looks correct in a browser and is invisible in review, which is why it ships and stays
|
|
513
|
+
* shipped - while search engines penalise it and keep the dead URL indexed.
|
|
514
|
+
*
|
|
515
|
+
* Returns `never`: these throw, so a loader narrows without a redundant `return`, and the type states
|
|
516
|
+
* the thing the "loaders `throw` redirect, actions `return` it" rule already trips people on.
|
|
517
|
+
*/
|
|
518
|
+
export function notFound(options = {}) {
|
|
519
|
+
return statusSignal(404, options);
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Render a terminal page at status **410 Gone**. `throw` it from a loader for a record that existed and
|
|
523
|
+
* was deliberately removed - a withdrawn listing, a deleted post.
|
|
524
|
+
*
|
|
525
|
+
* 410 is not a pedantic 404: it tells a crawler to **drop** the URL rather than re-fetch it for weeks
|
|
526
|
+
* on the assumption the 404 was transient. Uses `_410.tsx` if the app has one, otherwise `_404`.
|
|
527
|
+
*/
|
|
528
|
+
export function gone(options = {}) {
|
|
529
|
+
return statusSignal(410, options);
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* Render a terminal page at any 4xx/5xx status - the escape hatch behind {@link notFound} and
|
|
533
|
+
* {@link gone} (402, 451, …). Uses `_<status>.tsx` if present, otherwise `_404`.
|
|
534
|
+
*/
|
|
535
|
+
export function statusPage(status, options = {}) {
|
|
536
|
+
return statusSignal(status, options);
|
|
537
|
+
}
|
|
325
538
|
/**
|
|
326
539
|
* Return this from an action to declare which routes the mutation changed (alongside the action's
|
|
327
540
|
* `data`). `createWebApp` sets the `X-Nifra-Revalidate` response header; after the submit the client
|
|
328
|
-
* marks those cached routes stale
|
|
541
|
+
* marks those cached routes stale - refetching the active one and any mounted fetcher showing them -
|
|
329
542
|
* so a mutation can refresh views beyond the one that was submitted. `data` is still surfaced to the
|
|
330
543
|
* component as `actionData` (the wrapper is transparent to `ActionData<typeof action>`).
|
|
331
544
|
*/
|
|
@@ -347,10 +560,10 @@ function escapeHtml(value) {
|
|
|
347
560
|
/**
|
|
348
561
|
* Escape a `<script>` element's text content so it cannot break out of the element. Per the HTML spec,
|
|
349
562
|
* a script's text may contain any character EXCEPT the sequences that the parser treats as element
|
|
350
|
-
* boundaries: `</` (which would begin the end tag
|
|
563
|
+
* boundaries: `</` (which would begin the end tag - `</script>` is the obvious XSS vector, but ANY `</`
|
|
351
564
|
* starts script-data-end-tag parsing), and the comment/CDATA edges `<!--` / `]]>`. We escape the `<`
|
|
352
565
|
* (and the `>` of `]]>`) to its JS unicode escape: inside a JS/JSON string literal `<` is the
|
|
353
|
-
* identical character, so a JSON-LD payload is byte-equivalent after parsing
|
|
566
|
+
* identical character, so a JSON-LD payload is byte-equivalent after parsing - but the raw `<`/`>` the
|
|
354
567
|
* HTML tokenizer scans for is gone. This is the JSON-LD-in-HTML rule (content is JSON, never raw HTML),
|
|
355
568
|
* mirroring {@link serializeData}'s posture for the data-global script. Idempotent on already-safe text.
|
|
356
569
|
*/
|
|
@@ -367,28 +580,13 @@ function escapeAttr(value) {
|
|
|
367
580
|
.replaceAll("<", "<")
|
|
368
581
|
.replaceAll(">", ">");
|
|
369
582
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
// `data-*`. A hardcoded list would silently drop valid attrs (the bug this guards against: `hreflang`
|
|
376
|
-
// /`crossorigin` getting filtered out). What it rejects is name-shape abuse only — a name with a space,
|
|
377
|
-
// `=`, `>`, quote, or a leading digit can't break out of the tag. Attribute VALUES are escaped
|
|
378
|
-
// separately (they may carry loader data → XSS), so a widened name set never widens the injection
|
|
379
|
-
// surface. Names that fail the shape check are dropped.
|
|
380
|
-
const SAFE_ATTR_NAME = /^[a-zA-Z][a-zA-Z0-9-]*$/;
|
|
381
|
-
/** Serialize a meta/link tag's attributes: validate the name shape (see {@link SAFE_ATTR_NAME}) +
|
|
382
|
-
* escape the value against XSS. Invalid attribute names are dropped. Values follow the HTML attribute
|
|
383
|
-
* conventions {@link LinkDescriptor} types: a string renders `name="escaped"`; `true` renders the bare
|
|
384
|
-
* boolean attribute (`disabled`); `false` and `undefined` are skipped (a conditionally-absent attr). */
|
|
385
|
-
function tagAttrs(attrs) {
|
|
583
|
+
/** Serialize attributes that passed the shared SSR/client head trust policy. */
|
|
584
|
+
function tagAttrs(tag, attrs) {
|
|
585
|
+
const trusted = trustedHeadAttributes(tag, attrs);
|
|
586
|
+
if (trusted === null)
|
|
587
|
+
return null;
|
|
386
588
|
let out = "";
|
|
387
|
-
for (const [name, value] of
|
|
388
|
-
if (value === undefined || value === false)
|
|
389
|
-
continue; // omitted / absent boolean attribute
|
|
390
|
-
if (!SAFE_ATTR_NAME.test(name))
|
|
391
|
-
continue; // name-shape abuse — dropped (see SAFE_ATTR_NAME)
|
|
589
|
+
for (const [name, value] of trusted) {
|
|
392
590
|
if (out !== "")
|
|
393
591
|
out += " ";
|
|
394
592
|
out += value === true ? name : `${name}="${escapeAttr(value)}"`;
|
|
@@ -401,11 +599,16 @@ function tagAttrs(attrs) {
|
|
|
401
599
|
// builds a fresh object each request (new identity) → miss → recompute, exactly as needed (its
|
|
402
600
|
// content can vary). WeakMap so a route module that's GC'd takes its entry with it.
|
|
403
601
|
const headTagsCache = new WeakMap();
|
|
602
|
+
function assertExecutableScriptType(type) {
|
|
603
|
+
if (!EXECUTABLE_SCRIPT_TYPES.has(type)) {
|
|
604
|
+
throw new TypeError(`[nifra/web] executable inline scripts accept ${[...EXECUTABLE_SCRIPT_TYPES].map((t) => JSON.stringify(t)).join(" or ")}; received ${JSON.stringify(type)}`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
404
607
|
/** Render a route's `meta`/`link`/`script` as managed (`data-nifra`) head tags. Title is set
|
|
405
|
-
* separately. XSS-safe by construction: every attribute flows through
|
|
406
|
-
*
|
|
407
|
-
* (breakout-escaped)
|
|
408
|
-
* or close the tag early. String concatenation (no intermediate `.map()` arrays + spread)
|
|
608
|
+
* separately. XSS-safe by construction: every attribute flows through the shared tag-specific trust
|
|
609
|
+
* policy then value escaping, and each `script[].content` through `escapeScriptContent`
|
|
610
|
+
* (breakout-escaped) - so loader-derived strings (LLM-authored `og:*`, user content) can't inject markup
|
|
611
|
+
* or close the tag early. String concatenation (no intermediate `.map()` arrays + spread) - parity with
|
|
409
612
|
* the already concat-based preloadLinks/styleLinks/islandPreloads loops; byte-identical output. Result
|
|
410
613
|
* is memoized per resolved-`Meta` identity (static meta → serialized once per route). */
|
|
411
614
|
function headTags(head) {
|
|
@@ -416,17 +619,42 @@ function headTags(head) {
|
|
|
416
619
|
return cached;
|
|
417
620
|
let out = "";
|
|
418
621
|
if (head.meta !== undefined)
|
|
419
|
-
for (const m of head.meta)
|
|
420
|
-
|
|
622
|
+
for (const m of head.meta) {
|
|
623
|
+
const attrs = tagAttrs("meta", m);
|
|
624
|
+
if (attrs !== null)
|
|
625
|
+
out += `<meta${attrs === "" ? "" : ` ${attrs}`} data-nifra>`;
|
|
626
|
+
}
|
|
421
627
|
if (head.link !== undefined)
|
|
422
|
-
for (const l of head.link)
|
|
423
|
-
|
|
424
|
-
|
|
628
|
+
for (const l of head.link) {
|
|
629
|
+
const attrs = tagAttrs("link", l);
|
|
630
|
+
if (attrs !== null)
|
|
631
|
+
out += `<link${attrs === "" ? "" : ` ${attrs}`} data-nifra>`;
|
|
632
|
+
}
|
|
633
|
+
// `<script>` slot - JSON-LD + other inert head scripts. `type` is attribute-escaped; `content` is
|
|
425
634
|
// breakout-escaped (`escapeScriptContent`) so a `</script>` (or `<!--`/`]]>`) payload can't close the
|
|
426
635
|
// element early. Managed (`data-nifra`) so a soft-nav's `applyHead` cleanly replaces it.
|
|
427
636
|
if (head.script !== undefined)
|
|
428
|
-
for (const s of head.script)
|
|
429
|
-
|
|
637
|
+
for (const s of head.script) {
|
|
638
|
+
const type = s.type ?? "application/ld+json";
|
|
639
|
+
if (!INERT_SCRIPT_TYPES.has(type)) {
|
|
640
|
+
throw new TypeError(`[nifra/web] head.script only accepts inert JSON script types; received ${JSON.stringify(type)}. Use unsafeInlineScript() with a CSP nonce for executable code.`);
|
|
641
|
+
}
|
|
642
|
+
out += `<script type="${type}" data-nifra>${escapeScriptContent(s.content)}</script>`;
|
|
643
|
+
}
|
|
644
|
+
if (head.unsafeScript !== undefined)
|
|
645
|
+
for (const s of head.unsafeScript) {
|
|
646
|
+
if (s.unsafe !== true || s.nonce.trim() === "") {
|
|
647
|
+
throw new TypeError("[nifra/web] executable inline scripts require a non-empty CSP nonce");
|
|
648
|
+
}
|
|
649
|
+
// `type` is validated, not escaped, for the same reason as the inert slot above: an unknown
|
|
650
|
+
// value here is a bug, and escaping one would emit a nonsense script type rather than say so.
|
|
651
|
+
// It was interpolated raw while the `nonce` beside it was escaped, so a descriptor built by hand
|
|
652
|
+
// - `unsafeScript` is a public field, the helper is only a convenience - could close the
|
|
653
|
+
// attribute and inject markup. Measured: `type: '"><img src=x onerror=alert(1)>'` reached the
|
|
654
|
+
// document. An injection in the slot whose whole purpose is to make script emission trustworthy.
|
|
655
|
+
assertExecutableScriptType(s.type);
|
|
656
|
+
out += `<script type="${s.type}" nonce="${escapeAttr(s.nonce)}" data-nifra>${escapeScriptContent(s.content)}</script>`;
|
|
657
|
+
}
|
|
430
658
|
headTagsCache.set(head, out);
|
|
431
659
|
return out;
|
|
432
660
|
}
|
|
@@ -465,7 +693,7 @@ export function openGraph(input) {
|
|
|
465
693
|
add("og:description", input.description);
|
|
466
694
|
add("og:image", input.image);
|
|
467
695
|
add("og:url", input.url);
|
|
468
|
-
// `og:type` always present (the spec's required property)
|
|
696
|
+
// `og:type` always present (the spec's required property) - default "website" when unset.
|
|
469
697
|
tags.push({ property: "og:type", content: input.type ?? "website" });
|
|
470
698
|
return tags;
|
|
471
699
|
}
|
|
@@ -483,6 +711,26 @@ export function openGraph(input) {
|
|
|
483
711
|
export function jsonLd(data) {
|
|
484
712
|
return { type: "application/ld+json", content: JSON.stringify(data) };
|
|
485
713
|
}
|
|
714
|
+
/**
|
|
715
|
+
* Deliberately unsafe escape hatch for executable inline code. The required nonce keeps the result
|
|
716
|
+
* compatible with a strict CSP and makes the security-sensitive choice visible at the call site.
|
|
717
|
+
*/
|
|
718
|
+
export function unsafeInlineScript(content, options) {
|
|
719
|
+
if (options.nonce.trim() === "") {
|
|
720
|
+
throw new TypeError("[nifra/web] unsafeInlineScript requires a non-empty CSP nonce");
|
|
721
|
+
}
|
|
722
|
+
const type = options.type ?? "module";
|
|
723
|
+
// Checked here as well as at emit: the type union is compile-time only, and this helper is the one
|
|
724
|
+
// place a caller is told what it may pass. Failing at the call site names the argument; failing at
|
|
725
|
+
// render names a document.
|
|
726
|
+
assertExecutableScriptType(type);
|
|
727
|
+
return {
|
|
728
|
+
unsafe: true,
|
|
729
|
+
type,
|
|
730
|
+
nonce: options.nonce,
|
|
731
|
+
content,
|
|
732
|
+
};
|
|
733
|
+
}
|
|
486
734
|
/**
|
|
487
735
|
* Merge a route's `<head>` contributions from its layout chain + the page into one {@link Meta}.
|
|
488
736
|
*
|
|
@@ -490,38 +738,47 @@ export function jsonLd(data) {
|
|
|
490
738
|
* `meta` to put sitewide tags (`hreflang`, `preconnect`, a default `<title>`, …) on every page below
|
|
491
739
|
* it. `heads` is passed **outermost layout → … → innermost layout → page** (the same order as the
|
|
492
740
|
* render chain), and merges:
|
|
493
|
-
* - **`title`**
|
|
741
|
+
* - **`title`** - *nearest-wins*: the last defined value across the list, so the page overrides an
|
|
494
742
|
* inner layout, which overrides an outer one. A layout `title` is the section default; an undefined
|
|
495
743
|
* page `title` keeps it.
|
|
496
|
-
* - **`meta` / `link`**
|
|
744
|
+
* - **`meta` / `link`** - *concatenated* in list order (outermost first, page last). Duplicate-tag
|
|
497
745
|
* de-duplication is the caller's concern; the framework emits exactly what's declared so a layout
|
|
498
746
|
* can ship N `<link rel="alternate" hreflang>` tags and a page can add its own canonical.
|
|
499
747
|
*
|
|
500
748
|
* Returns a fresh object whose identity is stable per `heads` *content* only when every entry is a
|
|
501
|
-
* static (by-reference) `Meta` and there is exactly one
|
|
749
|
+
* static (by-reference) `Meta` and there is exactly one - otherwise a new object each call. That is
|
|
502
750
|
* fine: {@link headTags}'s memo is keyed on identity, so a per-request merge simply recomputes (its
|
|
503
751
|
* content can vary with loader data anyway).
|
|
504
752
|
*/
|
|
505
753
|
export function mergeHeads(heads) {
|
|
506
|
-
// Single-head fast path (a route with no layout `meta`, by far the common case)
|
|
754
|
+
// Single-head fast path (a route with no layout `meta`, by far the common case) - return the
|
|
507
755
|
// resolved object by reference so headTags' identity-keyed memo hits across requests for static meta.
|
|
508
756
|
if (heads.length === 1)
|
|
509
757
|
return heads[0];
|
|
510
758
|
let title;
|
|
759
|
+
let lang;
|
|
760
|
+
let dir;
|
|
511
761
|
const meta = [];
|
|
512
762
|
const link = [];
|
|
513
763
|
const script = [];
|
|
764
|
+
const unsafeScript = [];
|
|
514
765
|
for (const h of heads) {
|
|
515
766
|
if (h.title !== undefined)
|
|
516
767
|
title = h.title; // nearest-wins: later (more specific) overrides
|
|
768
|
+
if (h.lang !== undefined)
|
|
769
|
+
lang = h.lang; // nearest-wins, like title
|
|
770
|
+
if (h.dir !== undefined)
|
|
771
|
+
dir = h.dir;
|
|
517
772
|
if (h.meta !== undefined)
|
|
518
773
|
meta.push(...h.meta);
|
|
519
774
|
if (h.link !== undefined)
|
|
520
775
|
link.push(...h.link);
|
|
521
776
|
if (h.script !== undefined)
|
|
522
777
|
script.push(...h.script); // concatenated like meta/link (outermost first)
|
|
778
|
+
if (h.unsafeScript !== undefined)
|
|
779
|
+
unsafeScript.push(...h.unsafeScript);
|
|
523
780
|
}
|
|
524
|
-
// Build the result with only the fields that were actually contributed
|
|
781
|
+
// Build the result with only the fields that were actually contributed - an empty `meta`/`link`/
|
|
525
782
|
// `script` array would otherwise be a spurious (if harmless) key. A mutable local; the cast to `Meta`
|
|
526
783
|
// is sound because a key is assigned only when defined (so `exactOptionalPropertyTypes` never sees
|
|
527
784
|
// `undefined`).
|
|
@@ -534,8 +791,34 @@ export function mergeHeads(heads) {
|
|
|
534
791
|
merged.link = link;
|
|
535
792
|
if (script.length > 0)
|
|
536
793
|
merged.script = script;
|
|
794
|
+
if (unsafeScript.length > 0)
|
|
795
|
+
merged.unsafeScript = unsafeScript;
|
|
796
|
+
if (lang !== undefined)
|
|
797
|
+
merged.lang = lang;
|
|
798
|
+
if (dir !== undefined)
|
|
799
|
+
merged.dir = dir;
|
|
537
800
|
return merged;
|
|
538
801
|
}
|
|
802
|
+
/**
|
|
803
|
+
* Re-issue `request` with `prefix` removed from its pathname.
|
|
804
|
+
*
|
|
805
|
+
* Needed because two conventions exist and both are reasonable. `createWebApp`'s auto-mount assumes a
|
|
806
|
+
* backend that declares FULL paths (`server().post("/api/sync")`), which is right when the backend is
|
|
807
|
+
* only ever mounted here. A backend that also runs standalone declares paths WITHOUT the prefix and
|
|
808
|
+
* lets its own shell supply it - and mounting one of those here used to require the caller to wrap it
|
|
809
|
+
* in a `Proxy` that rewrote every URL. Two apps wrote that same wrapper independently, which is the
|
|
810
|
+
* signal that it belonged in the framework.
|
|
811
|
+
*
|
|
812
|
+
* The `Request` is rebuilt rather than mutated (`url` is read-only), preserving method, headers, body,
|
|
813
|
+
* and duplex streaming - the body is passed through unread, so a large or streamed upload is untouched.
|
|
814
|
+
*/
|
|
815
|
+
function stripMountPrefix(request, prefix) {
|
|
816
|
+
const url = new URL(request.url);
|
|
817
|
+
const rest = url.pathname.slice(prefix.length);
|
|
818
|
+
url.pathname = rest === "" ? "/" : rest;
|
|
819
|
+
// `url.href`, not the `URL`: this type set only declares the string and Request overloads.
|
|
820
|
+
return new Request(url.href, request);
|
|
821
|
+
}
|
|
539
822
|
/**
|
|
540
823
|
* Resolve the explicit symbol-keyed backend mount interface. The symbol seam forwards platform
|
|
541
824
|
* context without making web depend on client.
|
|
@@ -551,15 +834,15 @@ function backendMountOf(api) {
|
|
|
551
834
|
/**
|
|
552
835
|
* Build a nifra app from a route manifest: every route SSRs its layout chain via `renderPage`,
|
|
553
836
|
* and a wildcard catch-all renders `_404` (or a plain 404). Reuses @nifrajs/core's router +
|
|
554
|
-
* lifecycle, so matching, params, and precedence are battle-tested. fs-free
|
|
837
|
+
* lifecycle, so matching, params, and precedence are battle-tested. fs-free - feed it a
|
|
555
838
|
* manifest from `discoverRoutes` (`@nifrajs/web/fs`) at startup, so the served app stays portable.
|
|
556
839
|
*
|
|
557
|
-
* **Typed platform bindings.** Pass `Env`
|
|
840
|
+
* **Typed platform bindings.** Pass `Env` - `createWebApp<Env>({ … })` - to declare the app's Workers
|
|
558
841
|
* bindings ONCE. It seeds the returned `Server`'s context with `{ env: Env }` (exactly as the backend's
|
|
559
842
|
* `server<Env>()` does), so `app.fetch(req, { env })` / `toFetchHandler(app)` type-check the `env`
|
|
560
|
-
* argument against the declared shape
|
|
843
|
+
* argument against the declared shape - no per-binding cast at the edge entry. Per-route loaders/actions
|
|
561
844
|
* stay typed independently of this call: annotate them with `@nifrajs/client`'s `LoaderArgs<Api, Env>`
|
|
562
|
-
* (same `Env`) so `ctx.env.MY_KV` is typed there too. Omit the parameter and `Env` is `unknown`
|
|
845
|
+
* (same `Env`) so `ctx.env.MY_KV` is typed there too. Omit the parameter and `Env` is `unknown` - the
|
|
563
846
|
* secure default; validate at the trust boundary before use.
|
|
564
847
|
*/
|
|
565
848
|
export function createWebApp(options) {
|
|
@@ -576,7 +859,7 @@ export function createWebApp(options) {
|
|
|
576
859
|
return chunks ? { preload: chunks } : {};
|
|
577
860
|
};
|
|
578
861
|
// The matched route's stylesheet links (spread into renderPage). Per-route when the build mapped it
|
|
579
|
-
// (`routeStyles[id]`
|
|
862
|
+
// (`routeStyles[id]` - only the chain's CSS; an empty array ⇒ no `<link>`), else the aggregate
|
|
580
863
|
// `styles`. Omitted entirely when CSS-free (for exactOptionalPropertyTypes).
|
|
581
864
|
const stylesOf = (id) => {
|
|
582
865
|
const perRoute = options.routeStyles?.[id];
|
|
@@ -590,21 +873,33 @@ export function createWebApp(options) {
|
|
|
590
873
|
const app = server();
|
|
591
874
|
// Auto-mount the in-process backend over HTTP at `apiPrefix` (default `/api`), BEFORE page routing.
|
|
592
875
|
// `onRequest` runs on the raw request ahead of the router, so this wins over the page wildcard `/*`
|
|
593
|
-
// for every method (POST/GET/PUT/…)
|
|
876
|
+
// for every method (POST/GET/PUT/…) - not just the GET page routes - and a backend 404 (`/api/none`)
|
|
594
877
|
// surfaces as the backend's own response, never the page 404. We dispatch the SAME `Request` object
|
|
595
878
|
// (its body unread) so streamed/large bodies pass through untouched; the backend defines its routes
|
|
596
879
|
// at the full prefixed path (`server().post("/api/sync", …)`), matching the `inProcessClient` call
|
|
597
880
|
// sites. `apiPrefix: ""` opts out; a non-mountable `api` (no symbol mount) is left pages-only. The hook
|
|
598
881
|
// is registered ONLY when both hold, so a pages-only app keeps core's synchronous no-hook fast path.
|
|
599
882
|
const apiPrefix = options.apiPrefix ?? "/api";
|
|
883
|
+
const apiStrip = options.apiStrip === true;
|
|
600
884
|
const mountedApi = backendMountOf(api);
|
|
601
|
-
|
|
885
|
+
// Longest path first, so a more specific mount (`/api/auth`) is tried before a broader one (`/api`)
|
|
886
|
+
// regardless of the order they were declared in.
|
|
887
|
+
const mounts = [...(options.mounts ?? [])].sort((a, b) => b.path.length - a.path.length);
|
|
888
|
+
const underPrefix = (pathname, prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`);
|
|
889
|
+
if (mounts.length > 0 || (apiPrefix !== "" && mountedApi !== undefined)) {
|
|
602
890
|
app.onRequest((req, platform) => {
|
|
603
891
|
const { pathname } = new URL(req.url);
|
|
604
|
-
//
|
|
892
|
+
// Sub-app mounts win over the backend prefix: they are the more specific declaration, and an
|
|
893
|
+
// auth handler mounted at `/api/auth` must not be swallowed by the backend mounted at `/api`.
|
|
894
|
+
for (const mount of mounts) {
|
|
895
|
+
if (!underPrefix(pathname, mount.path))
|
|
896
|
+
continue;
|
|
897
|
+
return mount.app.fetch(mount.stripPrefix === true ? stripMountPrefix(req, mount.path) : req);
|
|
898
|
+
}
|
|
899
|
+
// Exactly the prefix (`/api`) or a sub-path (`/api/…`) - NOT a sibling like `/apixyz` that merely
|
|
605
900
|
// shares the prefix as a string head. Dispatch the original `req` (body intact) to the backend.
|
|
606
|
-
if (
|
|
607
|
-
return mountedApi(req, platform);
|
|
901
|
+
if (mountedApi !== undefined && apiPrefix !== "" && underPrefix(pathname, apiPrefix)) {
|
|
902
|
+
return mountedApi(apiStrip ? stripMountPrefix(req, apiPrefix) : req, platform);
|
|
608
903
|
}
|
|
609
904
|
return undefined; // not an API path → continue to page routing
|
|
610
905
|
});
|
|
@@ -613,12 +908,186 @@ export function createWebApp(options) {
|
|
|
613
908
|
// path under a `"404"` route is rejected (it isn't a static file, and the route declared it shouldn't
|
|
614
909
|
// SSR on demand). O(1) membership via a Set.
|
|
615
910
|
const prerenderedSet = new Set(options.prerenderedPaths ?? []);
|
|
911
|
+
const matchManifestRoute = createMatcher(manifest.routes.map((route) => ({ routeId: route.id, pattern: route.pattern })));
|
|
912
|
+
const routeById = new Map(manifest.routes.map((route) => [route.id, route]));
|
|
616
913
|
// Load a route's layout modules (outermost layout → innermost), keeping each module whole so the
|
|
617
914
|
// render path can take both its `default` (the component chain) AND its `meta` (the sitewide head it
|
|
618
|
-
// contributes). Loaded lazily
|
|
619
|
-
const loadLayoutModules = (route) =>
|
|
620
|
-
|
|
621
|
-
|
|
915
|
+
// contributes). Loaded lazily - the data-only and 405 branches return before any layout is needed.
|
|
916
|
+
const loadLayoutModules = async (route) => {
|
|
917
|
+
const modules = await Promise.all(
|
|
918
|
+
// layoutIds only reference layouts present in the manifest (buildManifest invariant).
|
|
919
|
+
route.layoutIds.map((id) => manifest.layouts[id].load()));
|
|
920
|
+
// A layout is not a form target, so an `action` there is a mistake with no meaning to give it.
|
|
921
|
+
// A `loader`, by contrast, now runs (see `runLayoutChain`).
|
|
922
|
+
for (let i = 0; i < modules.length; i++) {
|
|
923
|
+
if (modules[i].action === undefined)
|
|
924
|
+
continue;
|
|
925
|
+
const id = route.layoutIds[i];
|
|
926
|
+
throw new Error(`[nifra/web] "${manifest.layouts[id].file}" exports an \`action\`, but layouts do not run one - only route files do. Move the mutation into the route that submits to it.`);
|
|
927
|
+
}
|
|
928
|
+
return modules;
|
|
929
|
+
};
|
|
930
|
+
/**
|
|
931
|
+
* Run a route's layout-chain loaders, honouring declared gates.
|
|
932
|
+
*
|
|
933
|
+
* Returns once every **gate** has resolved, with the non-gate loaders still in flight, so the caller
|
|
934
|
+
* can start the page loader at exactly the right moment: after anything that might reject the
|
|
935
|
+
* request, alongside anything that merely fetches.
|
|
936
|
+
*
|
|
937
|
+
* The ordering is the security-relevant part. Parallel loaders are NOT an authorization boundary -
|
|
938
|
+
* a page loader running concurrently with a guard has already queried by the time the guard says no.
|
|
939
|
+
* Remix documents that as a footgun; here it is a declaration instead. `export const gate = true`
|
|
940
|
+
* makes a layout blocking for everything beneath it, and nothing beneath a rejected gate runs.
|
|
941
|
+
*
|
|
942
|
+
* Each layout receives only the params it OWNS ({@link RouteEntry.layoutParams}), not the route's
|
|
943
|
+
* full set, so a layout at `orgs/[org]/` cannot read a param belonging to a route below it.
|
|
944
|
+
*/
|
|
945
|
+
/**
|
|
946
|
+
* Layout indices a client navigation asked to keep, from {@link RETAIN_HEADER}.
|
|
947
|
+
*
|
|
948
|
+
* The CLIENT decides retainability: it knows each layout's scoped params (shipped in the bootstrap)
|
|
949
|
+
* and both the old and new match, so it can tell whether a layout's own prefix changed - without the
|
|
950
|
+
* server re-matching a path the client already matched.
|
|
951
|
+
*
|
|
952
|
+
* It is a HINT, not an instruction. The client is asking to keep data it already holds, which is
|
|
953
|
+
* harmless for data and unacceptable for a guard, so `runLayoutChain` refuses to skip a gate no
|
|
954
|
+
* matter what arrives here. Anything unparseable is ignored rather than rejected: the worst case of
|
|
955
|
+
* a bad hint is a loader that runs when it need not.
|
|
956
|
+
*/
|
|
957
|
+
const retainedIndices = (header) => {
|
|
958
|
+
if (header === null || header === "")
|
|
959
|
+
return EMPTY_RETAIN;
|
|
960
|
+
const out = new Set();
|
|
961
|
+
for (const part of header.split(",")) {
|
|
962
|
+
// Digits only. `Number("")` is 0 and `Number(" ")` is 0, so a stray comma would otherwise read
|
|
963
|
+
// as "retain index 0" and silently skip the outermost layout.
|
|
964
|
+
if (!/^\d+$/.test(part))
|
|
965
|
+
continue;
|
|
966
|
+
out.add(Number(part));
|
|
967
|
+
}
|
|
968
|
+
return out;
|
|
969
|
+
};
|
|
970
|
+
/**
|
|
971
|
+
* Validate a data-navigation's retain request against the manifest and its declared source path.
|
|
972
|
+
* A client may ask to retain every slot it currently has; the server only honours indices whose
|
|
973
|
+
* layout identity and owned params are unchanged. Hard document GETs never retain anything.
|
|
974
|
+
*/
|
|
975
|
+
const validatedRetainedIndices = (req, route, params) => {
|
|
976
|
+
if (req.headers.get(DATA_HEADER) === null)
|
|
977
|
+
return EMPTY_RETAIN;
|
|
978
|
+
const requested = retainedIndices(req.headers.get(RETAIN_HEADER));
|
|
979
|
+
const from = req.headers.get(NAV_FROM_HEADER);
|
|
980
|
+
if (requested.size === 0 || from === null)
|
|
981
|
+
return EMPTY_RETAIN;
|
|
982
|
+
let fromPath;
|
|
983
|
+
try {
|
|
984
|
+
const current = new URL(req.url);
|
|
985
|
+
const source = new URL(from, current);
|
|
986
|
+
if (source.origin !== current.origin)
|
|
987
|
+
return EMPTY_RETAIN;
|
|
988
|
+
fromPath = source.pathname + source.search;
|
|
989
|
+
}
|
|
990
|
+
catch {
|
|
991
|
+
return EMPTY_RETAIN;
|
|
992
|
+
}
|
|
993
|
+
const previousMatch = matchManifestRoute(fromPath);
|
|
994
|
+
if (previousMatch === null)
|
|
995
|
+
return EMPTY_RETAIN;
|
|
996
|
+
const previous = routeById.get(previousMatch.routeId);
|
|
997
|
+
if (previous === undefined)
|
|
998
|
+
return EMPTY_RETAIN;
|
|
999
|
+
const valid = new Set();
|
|
1000
|
+
for (const index of requested) {
|
|
1001
|
+
if (route.layoutIds[index] !== previous.layoutIds[index])
|
|
1002
|
+
continue;
|
|
1003
|
+
const owned = route.layoutParams?.[index] ?? [];
|
|
1004
|
+
const previouslyOwned = previous.layoutParams?.[index] ?? [];
|
|
1005
|
+
if (owned.length !== previouslyOwned.length ||
|
|
1006
|
+
owned.some((name, i) => name !== previouslyOwned[i]) ||
|
|
1007
|
+
owned.some((name) => params[name] !== previousMatch.params[name])) {
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
valid.add(index);
|
|
1011
|
+
}
|
|
1012
|
+
return valid;
|
|
1013
|
+
};
|
|
1014
|
+
// The validated (or raw-parsed) search a route's loader context receives; `searchOf` fails closed to
|
|
1015
|
+
// the schema's defaults on hostile input, so this never throws on an attacker-picked query.
|
|
1016
|
+
const loaderSearch = (searchSchema, request) => searchOf(searchSchema, new URL(request.url).search);
|
|
1017
|
+
// The route's EFFECTIVE search once its layout chain is loaded: the layout schemas merged with the
|
|
1018
|
+
// page's (page-wins), the same chain the client mount builds for `useSearch`. Used for the page loader's
|
|
1019
|
+
// `ctx.search` and the render's `RenderProps.search`, so both agree with the client and with each other.
|
|
1020
|
+
const searchChainOf = (layoutMods, mod, request) => searchOfChain([...layoutMods.map((m) => m.searchSchema), mod.searchSchema], new URL(request.url).search);
|
|
1021
|
+
const runLayoutChain = async (route, ctx, retain = new Set()) => {
|
|
1022
|
+
const modules = await loadLayoutModules(route);
|
|
1023
|
+
if (!modules.some((m) => m.loader !== undefined)) {
|
|
1024
|
+
// Nothing to run: no array is produced, so a page-only app serializes nothing extra and its
|
|
1025
|
+
// HTML is byte-identical to before this feature.
|
|
1026
|
+
return { modules, layoutData: undefined, retained: [], pending: Promise.resolve() };
|
|
1027
|
+
}
|
|
1028
|
+
const results = new Array(modules.length).fill(null);
|
|
1029
|
+
const retained = [];
|
|
1030
|
+
const pending = [];
|
|
1031
|
+
for (let i = 0; i < modules.length; i++) {
|
|
1032
|
+
const loader = modules[i]?.loader;
|
|
1033
|
+
if (loader === undefined)
|
|
1034
|
+
continue;
|
|
1035
|
+
// Unchanged scope: the client keeps what it has. A GATE is never skipped - it is a guard, and a
|
|
1036
|
+
// guard that only runs when the URL prefix changed is not a guard.
|
|
1037
|
+
if (retain.has(i) && modules[i]?.gate !== true) {
|
|
1038
|
+
retained.push(i);
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
const scoped = {
|
|
1042
|
+
...ctx,
|
|
1043
|
+
params: scopeParams(ctx.params, route.layoutParams?.[i]),
|
|
1044
|
+
};
|
|
1045
|
+
if (modules[i]?.gate === true) {
|
|
1046
|
+
// Blocking: awaited here, so a throw propagates before anything deeper is started.
|
|
1047
|
+
try {
|
|
1048
|
+
results[i] = await loader(scoped);
|
|
1049
|
+
}
|
|
1050
|
+
catch (err) {
|
|
1051
|
+
throw tagLayoutError(err, route.layoutIds[i]);
|
|
1052
|
+
}
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
const layoutId = route.layoutIds[i];
|
|
1056
|
+
const index = i;
|
|
1057
|
+
// The loader is invoked INSIDE the async function, not before it. `Promise.resolve(loader(x))`
|
|
1058
|
+
// would let a synchronously-throwing loader escape past the rejection handler entirely - the
|
|
1059
|
+
// exception happens while evaluating the argument, before any promise exists to reject.
|
|
1060
|
+
pending.push((async () => {
|
|
1061
|
+
try {
|
|
1062
|
+
results[index] = await loader(scoped);
|
|
1063
|
+
}
|
|
1064
|
+
catch (err) {
|
|
1065
|
+
throw tagLayoutError(err, layoutId);
|
|
1066
|
+
}
|
|
1067
|
+
})());
|
|
1068
|
+
}
|
|
1069
|
+
return { modules, layoutData: results, retained, pending: Promise.all(pending) };
|
|
1070
|
+
};
|
|
1071
|
+
/** Run only blocking layout gates before a route action. Non-gate loaders fetch render data and
|
|
1072
|
+
* therefore run after the mutation when a native POST needs a fresh document. */
|
|
1073
|
+
const runLayoutGates = async (route, ctx) => {
|
|
1074
|
+
const modules = await loadLayoutModules(route);
|
|
1075
|
+
for (let i = 0; i < modules.length; i++) {
|
|
1076
|
+
const mod = modules[i];
|
|
1077
|
+
if (mod?.gate !== true || mod.loader === undefined)
|
|
1078
|
+
continue;
|
|
1079
|
+
try {
|
|
1080
|
+
await mod.loader({
|
|
1081
|
+
...ctx,
|
|
1082
|
+
params: scopeParams(ctx.params, route.layoutParams?.[i]),
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
catch (err) {
|
|
1086
|
+
throw tagLayoutError(err, route.layoutIds[i]);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
return modules;
|
|
1090
|
+
};
|
|
622
1091
|
// Build a route's render chain + its merged `<head>` from the already-loaded layout modules + the
|
|
623
1092
|
// page module. The chain is `[…layout components, page]`; the head merges each layout's `meta`
|
|
624
1093
|
// export (outermost→innermost) with the page's (last), so a `head`/`meta` on `_layout.tsx`
|
|
@@ -633,11 +1102,32 @@ export function createWebApp(options) {
|
|
|
633
1102
|
};
|
|
634
1103
|
// Dir a special-file id lives in: `_error`→"" , `a/b/_error`→"a/b" (and likewise for `_layout`).
|
|
635
1104
|
const dirOfId = (id, suffix) => id === suffix ? "" : id.slice(0, id.length - suffix.length - 1);
|
|
636
|
-
|
|
1105
|
+
/**
|
|
1106
|
+
* Choose the `_error` boundary for a failure, respecting where a LAYOUT loader failed.
|
|
1107
|
+
*
|
|
1108
|
+
* A route error uses the nearest boundary, as it always has. A layout error must not: the nearest
|
|
1109
|
+
* boundary can sit BELOW the layout that threw, and rendering there would wrap the boundary in that
|
|
1110
|
+
* very layout - the one whose loader just failed and whose data therefore never arrived.
|
|
1111
|
+
*/
|
|
1112
|
+
const boundaryFor = (route, err) => {
|
|
1113
|
+
const ids = route.errorIds ?? [];
|
|
1114
|
+
const failing = layoutErrorId(err);
|
|
1115
|
+
if (failing === undefined)
|
|
1116
|
+
return ids.at(-1);
|
|
1117
|
+
const layoutDir = dirOfId(failing, "_layout");
|
|
1118
|
+
// Deepest boundary at or ABOVE the failing layout's directory.
|
|
1119
|
+
for (let i = ids.length - 1; i >= 0; i--) {
|
|
1120
|
+
const errDir = dirOfId(ids[i], "_error");
|
|
1121
|
+
if (errDir === "" || errDir === layoutDir || layoutDir.startsWith(`${errDir}/`))
|
|
1122
|
+
return ids[i];
|
|
1123
|
+
}
|
|
1124
|
+
return undefined;
|
|
1125
|
+
};
|
|
1126
|
+
// The request's origin (scheme + host + port, NO trailing slash) - the one server fact `meta()` needs
|
|
637
1127
|
// for absolute `canonical`/`og:url`/`og:image` but can't otherwise see (it also runs on the client).
|
|
638
1128
|
// `URL.origin` is exactly that shape; it matches the browser's `location.origin` for the same URL, so
|
|
639
1129
|
// a soft-nav's `applyHead` resolves an identical `<head>` (no hydration drift). A malformed `req.url`
|
|
640
|
-
// (shouldn't happen on a real request) degrades to `""`
|
|
1130
|
+
// (shouldn't happen on a real request) degrades to `""` - the documented unknown-origin default -
|
|
641
1131
|
// rather than throwing during render.
|
|
642
1132
|
const originOf = (req) => {
|
|
643
1133
|
try {
|
|
@@ -647,7 +1137,7 @@ export function createWebApp(options) {
|
|
|
647
1137
|
return "";
|
|
648
1138
|
}
|
|
649
1139
|
};
|
|
650
|
-
// The request's `pathname + search`
|
|
1140
|
+
// The request's `pathname + search` - threaded into `renderPage` so an adapter's `useLocation`/
|
|
651
1141
|
// `useSearchParams` render the current URL server-side and hydrate against the client's initial state
|
|
652
1142
|
// (which the client entry seeds from `location.pathname + location.search`). No hash: it never reaches
|
|
653
1143
|
// the server. A malformed `req.url` degrades to "/" rather than throwing during render.
|
|
@@ -662,11 +1152,11 @@ export function createWebApp(options) {
|
|
|
662
1152
|
};
|
|
663
1153
|
/**
|
|
664
1154
|
* Render the **nearest `_error` boundary** when a route's loader throws (the agnostic, server-side
|
|
665
|
-
* half of error UI
|
|
1155
|
+
* half of error UI - works on every adapter, no client takeover). The boundary renders in place of
|
|
666
1156
|
* the page, wrapped by the layouts **at or above** its segment (deeper layouts are dropped). It's
|
|
667
1157
|
* served **non-hydrated** at status 500: a terminal page, and rendering a boundary (not the page the
|
|
668
1158
|
* client maps this route to) would otherwise hydrate-mismatch. The component receives `{ name,
|
|
669
|
-
* message }`
|
|
1159
|
+
* message }` - never the stack (no internals leak into HTML).
|
|
670
1160
|
*/
|
|
671
1161
|
const renderError = async (route, errorId, err) => {
|
|
672
1162
|
const errDir = dirOfId(errorId, "_error");
|
|
@@ -688,35 +1178,79 @@ export function createWebApp(options) {
|
|
|
688
1178
|
...titleOption,
|
|
689
1179
|
});
|
|
690
1180
|
};
|
|
691
|
-
// The 404 response
|
|
1181
|
+
// The 404 response - the `_404` page (status 404) or a plain-text fallback. Shared by the wildcard
|
|
692
1182
|
// catch-all (unmatched paths) and the `fallback: "404"` enforcement (unlisted paths under a route
|
|
693
1183
|
// that opted out of on-demand SSR).
|
|
694
|
-
const renderNotFound =
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
1184
|
+
const renderNotFound = (path) => renderTerminalStatus(404, path);
|
|
1185
|
+
/**
|
|
1186
|
+
* Render a terminal-status page: the `_<status>` page if the app authored one, else `_404`, else
|
|
1187
|
+
* plain text. Shared by the wildcard catch-all, `fallback: "404"` enforcement, and a loader's
|
|
1188
|
+
* `notFound()` / `gone()` / `statusPage()`, so all of them resolve the page and set the status
|
|
1189
|
+
* through one path rather than forking.
|
|
1190
|
+
*
|
|
1191
|
+
* Hydrates, following the `_404` precedent rather than `_error`'s non-hydrated 500: these are
|
|
1192
|
+
* ordinary pages the client router can own, and a 404 arrived at by clicking a link should behave
|
|
1193
|
+
* like any other route.
|
|
1194
|
+
*/
|
|
1195
|
+
const renderTerminalStatus = async (status, path, extraHeaders) => {
|
|
1196
|
+
const page = manifest.statusPages?.[String(status)] ?? manifest.notFound;
|
|
1197
|
+
if (page === undefined) {
|
|
1198
|
+
const headers = new Headers(extraHeaders);
|
|
1199
|
+
headers.set("content-type", "text/plain; charset=utf-8");
|
|
1200
|
+
return new Response(STATUS_TEXT[status] ?? "Error", { status, headers });
|
|
700
1201
|
}
|
|
701
|
-
|
|
1202
|
+
// The routeId decides which preload/style bundle and which client component this maps to, so it
|
|
1203
|
+
// must name the page actually rendered - `_410` when `_410.tsx` exists, `_404` when it fell back.
|
|
1204
|
+
const routeId = manifest.statusPages?.[String(status)] !== undefined ? `_${status}` : "_404";
|
|
1205
|
+
const loaded = (await page.load());
|
|
1206
|
+
const component = loaded.default;
|
|
702
1207
|
return renderPageResult({
|
|
703
1208
|
adapter,
|
|
704
|
-
chain: [
|
|
1209
|
+
chain: [component],
|
|
705
1210
|
data: null,
|
|
706
1211
|
clientEntry,
|
|
707
|
-
routeId
|
|
708
|
-
//
|
|
709
|
-
//
|
|
1212
|
+
routeId,
|
|
1213
|
+
// The page hydrates, so besides `path` its SSR `search` must equal the client mount's derivation for
|
|
1214
|
+
// this routeId (`searchOf(searchSchema, url.search)`), or a status page reading `useSearch` drifts.
|
|
1215
|
+
// Only when `path` is present (a real URL); a schema-less status page then gets the raw parsed query.
|
|
1216
|
+
...(path !== undefined
|
|
1217
|
+
? {
|
|
1218
|
+
search: searchOf(loaded.searchSchema, path.includes("?") ? path.slice(path.indexOf("?")) : ""),
|
|
1219
|
+
}
|
|
1220
|
+
: {}),
|
|
1221
|
+
// The page hydrates, so its SSR `path` must equal the client's initial
|
|
1222
|
+
// `location.pathname+search` (an unmatched path yields no params → `{}`), or a `useLocation`
|
|
1223
|
+
// on it would drift.
|
|
710
1224
|
...(path !== undefined ? { path } : {}),
|
|
711
|
-
status
|
|
712
|
-
...
|
|
713
|
-
...
|
|
1225
|
+
status,
|
|
1226
|
+
...(extraHeaders !== undefined ? { headers: extraHeaders } : {}),
|
|
1227
|
+
...preloadOf(routeId),
|
|
1228
|
+
...stylesOf(routeId),
|
|
714
1229
|
prerenderedPaths: options.prerenderedPaths ?? [],
|
|
715
1230
|
...titleOption,
|
|
716
1231
|
});
|
|
717
1232
|
};
|
|
1233
|
+
/**
|
|
1234
|
+
* Answer a loader's terminal-status signal, in whichever form the caller asked for.
|
|
1235
|
+
*
|
|
1236
|
+
* A hard navigation wants the document, so it gets the rendered boundary. A client-side navigation
|
|
1237
|
+
* fetched DATA, not a document, so it gets an empty body plus the status on `X-Nifra-Status`, and
|
|
1238
|
+
* the client router renders the same boundary from its own manifest. Sending the HTML back to a
|
|
1239
|
+
* soft-nav would be answering a different question than the one asked - and skipping the header
|
|
1240
|
+
* entirely is what leaves a crawler seeing a correct 404 while a user who clicked a link sees a
|
|
1241
|
+
* blank page.
|
|
1242
|
+
*/
|
|
1243
|
+
const renderStatusSignal = (req, signal) => {
|
|
1244
|
+
const { status, headers } = signal[STATUS_SIGNAL];
|
|
1245
|
+
if (req.headers.get(DATA_HEADER) !== null) {
|
|
1246
|
+
const responseHeaders = new Headers(headers);
|
|
1247
|
+
responseHeaders.set(STATUS_HEADER, String(status));
|
|
1248
|
+
return new Response(null, { status, headers: responseHeaders });
|
|
1249
|
+
}
|
|
1250
|
+
return renderTerminalStatus(status, pathOf(req), headers);
|
|
1251
|
+
};
|
|
718
1252
|
for (const route of manifest.routes) {
|
|
719
|
-
// A dynamic route whose `getStaticPaths` declared `fallback: "404"`
|
|
1253
|
+
// A dynamic route whose `getStaticPaths` declared `fallback: "404"` - only its prerendered paths
|
|
720
1254
|
// exist; anything else 404s (computed once per route, not per request).
|
|
721
1255
|
const is404Fallback = options.staticFallbacks?.[route.pattern] === "404";
|
|
722
1256
|
app.register("GET", route.pattern, undefined, async (c) => {
|
|
@@ -729,21 +1263,48 @@ export function createWebApp(options) {
|
|
|
729
1263
|
const mod = await route.load();
|
|
730
1264
|
const draft = await draftFlag(c.req);
|
|
731
1265
|
let data;
|
|
1266
|
+
let layoutData;
|
|
1267
|
+
let layoutModules;
|
|
1268
|
+
let layoutRetained = [];
|
|
732
1269
|
try {
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
1270
|
+
const ctx = {
|
|
1271
|
+
params: c.params,
|
|
1272
|
+
request: c.req,
|
|
1273
|
+
req: c.req,
|
|
1274
|
+
api,
|
|
1275
|
+
env: c.env,
|
|
1276
|
+
draft,
|
|
1277
|
+
search: loaderSearch(mod.searchSchema, c.req),
|
|
1278
|
+
};
|
|
1279
|
+
// Layout loaders run for BOTH the document and the data-only request. A gate that only ran on
|
|
1280
|
+
// the document path would be bypassed by adding the data header, which is exactly the request
|
|
1281
|
+
// a client navigation makes - a guard has to hold on the path an attacker can pick.
|
|
1282
|
+
//
|
|
1283
|
+
// `runLayoutChain` returns once every GATE has resolved, with the non-gate loaders still in
|
|
1284
|
+
// flight. So the page loader starts as late as correctness requires and as early as speed
|
|
1285
|
+
// allows, and both are awaited together below.
|
|
1286
|
+
const run = await runLayoutChain(route, ctx, validatedRetainedIndices(c.req, route, c.params));
|
|
1287
|
+
layoutModules = run.modules;
|
|
1288
|
+
layoutRetained = run.retained;
|
|
1289
|
+
// The page loader gets the EFFECTIVE search (layout chain + page, page-wins), now that the layout
|
|
1290
|
+
// modules are loaded - so `ctx.search` includes a layout's shared keys (`?org`), matching `useSearch`.
|
|
1291
|
+
const [pageData] = await Promise.all([
|
|
1292
|
+
mod.loader
|
|
1293
|
+
? mod.loader({ ...ctx, search: searchChainOf(run.modules, mod, c.req) })
|
|
1294
|
+
: null,
|
|
1295
|
+
run.pending, // a non-gate layout loader that rejects must still surface, not go unhandled
|
|
1296
|
+
]);
|
|
1297
|
+
data = pageData;
|
|
1298
|
+
layoutData = run.layoutData;
|
|
743
1299
|
}
|
|
744
1300
|
catch (err) {
|
|
1301
|
+
// A terminal-status signal (`notFound()` / `gone()` / `statusPage(n)`) renders a boundary at
|
|
1302
|
+
// that status. Checked BEFORE the pass-through below: a signal IS a `Response`, so the order
|
|
1303
|
+
// is what keeps a hand-rolled `throw new Response(...)` served verbatim, as it always was.
|
|
1304
|
+
if (isStatusSignal(err))
|
|
1305
|
+
return renderStatusSignal(c.req, err);
|
|
745
1306
|
// A thrown `Response` is a control-flow signal (a guard's `redirect(...)`, an explicit error
|
|
746
|
-
// response)
|
|
1307
|
+
// response) - let it propagate to core, which returns it as-is. Real errors render the nearest
|
|
747
1308
|
// `_error` boundary, if any; with none, rethrow (unchanged 500 behavior).
|
|
748
1309
|
if (err instanceof Response)
|
|
749
1310
|
throw err;
|
|
@@ -756,10 +1317,10 @@ export function createWebApp(options) {
|
|
|
756
1317
|
// A faulty reporter must never break error rendering.
|
|
757
1318
|
}
|
|
758
1319
|
}
|
|
759
|
-
const errorId = route
|
|
1320
|
+
const errorId = boundaryFor(route, err);
|
|
760
1321
|
if (errorId === undefined)
|
|
761
1322
|
throw err;
|
|
762
|
-
// A soft-nav data fetch can't render a boundary
|
|
1323
|
+
// A soft-nav data fetch can't render a boundary - 500 so the client falls back to a full-page
|
|
763
1324
|
// navigation, which lands here as a document and renders the `_error` page.
|
|
764
1325
|
if (c.req.headers.get(DATA_HEADER) !== null) {
|
|
765
1326
|
return new Response("Internal Server Error", {
|
|
@@ -767,21 +1328,40 @@ export function createWebApp(options) {
|
|
|
767
1328
|
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
768
1329
|
});
|
|
769
1330
|
}
|
|
770
|
-
return renderError(route, errorId, err);
|
|
1331
|
+
return renderError(route, errorId, withDuplicateInstanceHint(err));
|
|
771
1332
|
}
|
|
772
|
-
// Client-side navigation asks (via the X-Nifra-Data header) for just the loader data
|
|
1333
|
+
// Client-side navigation asks (via the X-Nifra-Data header) for just the loader data - no full
|
|
773
1334
|
// document, no layout chain. A route with deferred data streams NDJSON (critical data first,
|
|
774
1335
|
// then each deferred value as it settles); otherwise one JSON (the fast path). Same loader,
|
|
775
|
-
// same auth
|
|
1336
|
+
// same auth - only the transport differs.
|
|
776
1337
|
if (c.req.headers.get(DATA_HEADER) !== null) {
|
|
777
|
-
const
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
1338
|
+
const pageSplit = prepareDeferred(data);
|
|
1339
|
+
const layoutSplits = [];
|
|
1340
|
+
let offset = pageSplit.deferred.length;
|
|
1341
|
+
for (const entry of layoutData ?? []) {
|
|
1342
|
+
const split = prepareDeferred(entry, offset);
|
|
1343
|
+
layoutSplits.push(split);
|
|
1344
|
+
offset += split.deferred.length;
|
|
1345
|
+
}
|
|
1346
|
+
const deferred = [...pageSplit.deferred, ...layoutSplits.flatMap((split) => split.deferred)];
|
|
1347
|
+
const payload = layoutData === undefined
|
|
1348
|
+
? pageSplit.forClient
|
|
1349
|
+
: {
|
|
1350
|
+
v: 1,
|
|
1351
|
+
data: pageSplit.forClient,
|
|
1352
|
+
layoutData: layoutSplits.map((split) => split.forClient),
|
|
1353
|
+
retained: layoutRetained,
|
|
1354
|
+
};
|
|
1355
|
+
// Bare value when there is no layout data - the pre-envelope shape, so a client running older
|
|
1356
|
+
// code against a newer server keeps working.
|
|
1357
|
+
if (deferred.length === 0) {
|
|
1358
|
+
return Response.json(payload ?? null);
|
|
1359
|
+
}
|
|
1360
|
+
return new Response(ndjsonStream(payload, deferred), {
|
|
781
1361
|
headers: { "content-type": "application/x-ndjson; charset=utf-8" },
|
|
782
1362
|
});
|
|
783
1363
|
}
|
|
784
|
-
const { chain, head } = resolveChainAndHead(await loadLayoutModules(route), mod, {
|
|
1364
|
+
const { chain, head } = resolveChainAndHead(layoutModules ?? (await loadLayoutModules(route)), mod, {
|
|
785
1365
|
data,
|
|
786
1366
|
params: c.params,
|
|
787
1367
|
origin: originOf(c.req),
|
|
@@ -789,7 +1369,7 @@ export function createWebApp(options) {
|
|
|
789
1369
|
const hydrateRoute = mod.hydrate !== false;
|
|
790
1370
|
try {
|
|
791
1371
|
// `await` so a shell-render throw (renderToStream rejects before any byte) is caught here and
|
|
792
|
-
// can render the `_error` page
|
|
1372
|
+
// can render the `_error` page - not just a loader throw. Mid-stream (post-shell) throws can't
|
|
793
1373
|
// be recovered to a full page; the per-adapter client boundary catches client render errors.
|
|
794
1374
|
return await renderPageResult({
|
|
795
1375
|
adapter,
|
|
@@ -800,7 +1380,12 @@ export function createWebApp(options) {
|
|
|
800
1380
|
routeId: route.id,
|
|
801
1381
|
params: c.params,
|
|
802
1382
|
path: pathOf(c.req),
|
|
1383
|
+
// The effective (layout chain + page) search, matching the page loader's `ctx.search` above and
|
|
1384
|
+
// the client mount - so `useSearch` renders SSR-correct and hydrates with no drift. `layoutModules`
|
|
1385
|
+
// is loaded by now (the loader try succeeded to reach here).
|
|
1386
|
+
search: searchChainOf(layoutModules ?? [], mod, c.req),
|
|
803
1387
|
hydrate: hydrateRoute,
|
|
1388
|
+
...(layoutData !== undefined ? { layoutData } : {}),
|
|
804
1389
|
...preloadOf(route.id),
|
|
805
1390
|
...stylesOf(route.id),
|
|
806
1391
|
prerenderedPaths: options.prerenderedPaths ?? [],
|
|
@@ -810,6 +1395,9 @@ export function createWebApp(options) {
|
|
|
810
1395
|
});
|
|
811
1396
|
}
|
|
812
1397
|
catch (err) {
|
|
1398
|
+
// Same precedence as the loader catch above - a `meta()` may signal too.
|
|
1399
|
+
if (isStatusSignal(err))
|
|
1400
|
+
return renderStatusSignal(c.req, err);
|
|
813
1401
|
if (err instanceof Response)
|
|
814
1402
|
throw err;
|
|
815
1403
|
// Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
|
|
@@ -821,15 +1409,15 @@ export function createWebApp(options) {
|
|
|
821
1409
|
// A faulty reporter must never break error rendering.
|
|
822
1410
|
}
|
|
823
1411
|
}
|
|
824
|
-
const errorId = route
|
|
1412
|
+
const errorId = boundaryFor(route, err);
|
|
825
1413
|
if (errorId === undefined)
|
|
826
1414
|
throw err;
|
|
827
|
-
return renderError(route, errorId, err);
|
|
1415
|
+
return renderError(route, errorId, withDuplicateInstanceHint(err));
|
|
828
1416
|
}
|
|
829
1417
|
});
|
|
830
1418
|
// POST runs the route's `action` (mutation). A `Response` return (e.g. a `redirect(...)`)
|
|
831
1419
|
// passes straight through; a data return re-renders the page (the loader re-runs for fresh
|
|
832
|
-
// data) with `actionData`. Routes without an action reject POST with 405
|
|
1420
|
+
// data) with `actionData`. Routes without an action reject POST with 405 - not a stray 404.
|
|
833
1421
|
app.register("POST", route.pattern, undefined, async (c) => {
|
|
834
1422
|
const mod = await route.load();
|
|
835
1423
|
const draft = await draftFlag(c.req);
|
|
@@ -839,18 +1427,23 @@ export function createWebApp(options) {
|
|
|
839
1427
|
headers: { allow: "GET", "content-type": "text/plain; charset=utf-8" },
|
|
840
1428
|
});
|
|
841
1429
|
}
|
|
842
|
-
const
|
|
1430
|
+
const actionContext = {
|
|
843
1431
|
params: c.params,
|
|
844
1432
|
request: c.req,
|
|
845
1433
|
req: c.req,
|
|
846
1434
|
api,
|
|
847
1435
|
env: c.env,
|
|
848
1436
|
draft,
|
|
849
|
-
|
|
1437
|
+
search: loaderSearch(mod.searchSchema, c.req),
|
|
1438
|
+
};
|
|
1439
|
+
// A gate is an authorization boundary for everything beneath its layout, mutations included.
|
|
1440
|
+
// Run only gates before the action; ordinary layout data loaders run after a native mutation.
|
|
1441
|
+
const layoutModules = await runLayoutGates(route, actionContext);
|
|
1442
|
+
const result = await mod.action(actionContext);
|
|
850
1443
|
const isDataRequest = c.req.headers.get(DATA_HEADER) !== null;
|
|
851
1444
|
// An action may wrap its data in `revalidate(paths, data)` to declare which routes it changed.
|
|
852
1445
|
// Unwrap to the inner data; the paths ride the `X-Nifra-Revalidate` header on the data-mode
|
|
853
|
-
// responses (the client acts on them
|
|
1446
|
+
// responses (the client acts on them - a full-page POST re-runs loaders inline, so no header).
|
|
854
1447
|
const isRevalidate = result !== null && typeof result === "object" && "__nifraRevalidate" in result;
|
|
855
1448
|
const actionResult = isRevalidate ? result.data : result;
|
|
856
1449
|
const revalidateHeader = isRevalidate
|
|
@@ -868,7 +1461,7 @@ export function createWebApp(options) {
|
|
|
868
1461
|
// Client submit wants just the action's data (it revalidates the loader itself); a native
|
|
869
1462
|
// form POST re-renders the full page (loader re-runs) with the action data.
|
|
870
1463
|
if (isDataRequest) {
|
|
871
|
-
// An action may `defer()` slow parts of its result
|
|
1464
|
+
// An action may `defer()` slow parts of its result - stream them (critical data first, then
|
|
872
1465
|
// each deferred as it settles) exactly like a loader; a non-deferred action returns one JSON.
|
|
873
1466
|
const { forClient, deferred } = prepareDeferred(actionResult);
|
|
874
1467
|
if (deferred.length === 0)
|
|
@@ -877,16 +1470,28 @@ export function createWebApp(options) {
|
|
|
877
1470
|
headers: { "content-type": "application/x-ndjson; charset=utf-8", ...revalidateHeader },
|
|
878
1471
|
});
|
|
879
1472
|
}
|
|
1473
|
+
// Derived once and shared: the loader's `ctx.search` and the render's `RenderProps.search` are the
|
|
1474
|
+
// SAME effective (layout chain + page) value, and the client mount rebuilds it identically. The
|
|
1475
|
+
// layout modules are already loaded (the action ran through their gates above).
|
|
1476
|
+
const search = searchChainOf(layoutModules, mod, c.req);
|
|
880
1477
|
const data = mod.loader
|
|
881
|
-
? await mod.loader({
|
|
1478
|
+
? await mod.loader({
|
|
1479
|
+
params: c.params,
|
|
1480
|
+
request: c.req,
|
|
1481
|
+
req: c.req,
|
|
1482
|
+
api,
|
|
1483
|
+
env: c.env,
|
|
1484
|
+
draft,
|
|
1485
|
+
search,
|
|
1486
|
+
})
|
|
882
1487
|
: null;
|
|
883
|
-
const { chain, head } = resolveChainAndHead(
|
|
1488
|
+
const { chain, head } = resolveChainAndHead(layoutModules, mod, {
|
|
884
1489
|
data,
|
|
885
1490
|
params: c.params,
|
|
886
1491
|
origin: originOf(c.req),
|
|
887
1492
|
});
|
|
888
1493
|
const hydrateRoute = mod.hydrate !== false;
|
|
889
|
-
// A full-page POST streams the action's `defer()`'d parts mid-document behind `<Await>` too
|
|
1494
|
+
// A full-page POST streams the action's `defer()`'d parts mid-document behind `<Await>` too -
|
|
890
1495
|
// `renderPage` splits `actionData` like loader data (works with JS off; hydrates after).
|
|
891
1496
|
return renderPageResult({
|
|
892
1497
|
adapter,
|
|
@@ -898,6 +1503,7 @@ export function createWebApp(options) {
|
|
|
898
1503
|
routeId: route.id,
|
|
899
1504
|
params: c.params,
|
|
900
1505
|
path: pathOf(c.req),
|
|
1506
|
+
search,
|
|
901
1507
|
hydrate: hydrateRoute,
|
|
902
1508
|
...preloadOf(route.id),
|
|
903
1509
|
...stylesOf(route.id),
|
|
@@ -937,7 +1543,8 @@ export function generateClientEntry(manifest, options) {
|
|
|
937
1543
|
const { clientModule, resolve } = options;
|
|
938
1544
|
const loaderRows = [];
|
|
939
1545
|
const patternRows = [];
|
|
940
|
-
|
|
1546
|
+
const statusRoutes = {};
|
|
1547
|
+
// Routes whose loader appends a nearest `_error` module (LAST) - the client wraps the page in the
|
|
941
1548
|
// adapter's `errorBoundary(fallback)` for these, so a client render error shows the `_error` UI.
|
|
942
1549
|
const errorRouteIds = [];
|
|
943
1550
|
// Lazy loader returns the raw modules (for both the component chain + the page's `meta` export).
|
|
@@ -963,14 +1570,28 @@ export function generateClientEntry(manifest, options) {
|
|
|
963
1570
|
}
|
|
964
1571
|
if (manifest.notFound !== undefined) {
|
|
965
1572
|
loaderRows.push(` "_404": ${lazyLoader([manifest.notFound.file])},`);
|
|
1573
|
+
statusRoutes[404] = "_404";
|
|
1574
|
+
}
|
|
1575
|
+
for (const [status, entry] of Object.entries(manifest.statusPages ?? {})) {
|
|
1576
|
+
const routeId = `_${status}`;
|
|
1577
|
+
loaderRows.push(` ${JSON.stringify(routeId)}: ${lazyLoader([entry.file])},`);
|
|
1578
|
+
statusRoutes[Number(status)] = routeId;
|
|
966
1579
|
}
|
|
967
1580
|
return `${[
|
|
968
1581
|
'import { createClientRouter, createMatcher, mergeHeads, resolveMeta } from "@nifrajs/web"',
|
|
969
1582
|
'import { applyHead, installForms, installHistory, signalHydrated } from "@nifrajs/web/client"',
|
|
970
1583
|
// Namespace import: `errorBoundary` is optional (an adapter may not export it). A namespace member
|
|
971
|
-
// access yields `undefined` if absent
|
|
1584
|
+
// access yields `undefined` if absent - unlike a named import, which would be a link error.
|
|
972
1585
|
`import * as __adapter from ${JSON.stringify(clientModule)}`,
|
|
973
1586
|
"const { mountRouter } = __adapter",
|
|
1587
|
+
// The `clientModule` contract is a string specifier, so nothing type-checks that the module it
|
|
1588
|
+
// names actually exports `mountRouter`. Without this the miss surfaces as
|
|
1589
|
+
// "mountRouter is not a function" from inside a bundled chunk, at first paint, naming neither the
|
|
1590
|
+
// module nor the requirement - a self-executing entry passes every build and fails only here.
|
|
1591
|
+
`if (typeof mountRouter !== "function") throw new Error(${JSON.stringify(`[nifra/web] clientModule ${JSON.stringify(clientModule)} does not export \`mountRouter\`. ` +
|
|
1592
|
+
"A client module must export `mountRouter({ router, routes, container })` - it is called by " +
|
|
1593
|
+
"the generated bootstrap, so a self-executing entry will not work. Use your adapter's " +
|
|
1594
|
+
'`/client` entry (e.g. "@nifrajs/web-react/client"), or re-export mountRouter from it.')})`,
|
|
974
1595
|
"const errorBoundary = __adapter.errorBoundary",
|
|
975
1596
|
`const errorRouteIds = new Set(${JSON.stringify(errorRouteIds)})`,
|
|
976
1597
|
// Each route is a lazy loader: dynamic imports → Bun.build (splitting) emits one chunk per
|
|
@@ -981,6 +1602,12 @@ export function generateClientEntry(manifest, options) {
|
|
|
981
1602
|
"}",
|
|
982
1603
|
"const chains = {}",
|
|
983
1604
|
"const metas = {}",
|
|
1605
|
+
// routeId → the page module's `searchSchema` export (or undefined). Populated lazily with
|
|
1606
|
+
// chains/metas so the mount can derive this route's typed `search` from the URL, matching the SSR.
|
|
1607
|
+
"const searchSchemas = {}",
|
|
1608
|
+
// routeId → the page's client-only search keys (`searchClientKeys`, or `[]`). The router reads this
|
|
1609
|
+
// by reference to decide when a same-route search change can skip the loader fetch (re-render only).
|
|
1610
|
+
"const searchClientKeys = {}",
|
|
984
1611
|
"const loadModule = async (id) => {",
|
|
985
1612
|
" if (chains[id]) return",
|
|
986
1613
|
" const mods = await loaders[id]()",
|
|
@@ -989,7 +1616,7 @@ export function generateClientEntry(manifest, options) {
|
|
|
989
1616
|
// hydrated tree matches the SSR markup (which has no boundary). Falls back to the plain chain when
|
|
990
1617
|
// the adapter has no `errorBoundary`.
|
|
991
1618
|
// `metas[id]` is the chain's `meta` exports in head order (outermost layout → … → page), so a
|
|
992
|
-
// soft-nav merges the layout chain's head with the page's
|
|
1619
|
+
// soft-nav merges the layout chain's head with the page's - matching the SSR `<head>` (sitewide
|
|
993
1620
|
// layout tags persist across client navigation, no flash of page-only head). `_error` carries no
|
|
994
1621
|
// head (a terminal boundary), so it's excluded from the meta list for error routes.
|
|
995
1622
|
" if (errorBoundary && errorRouteIds.has(id)) {",
|
|
@@ -998,9 +1625,16 @@ export function generateClientEntry(manifest, options) {
|
|
|
998
1625
|
" const layouts = mods.slice(0, mods.length - 2).map((m) => m.default)",
|
|
999
1626
|
" chains[id] = [...layouts, errorBoundary(fallback), page]",
|
|
1000
1627
|
" metas[id] = mods.slice(0, mods.length - 1).map((m) => m.meta)",
|
|
1628
|
+
// The search-schema CHAIN is the layout modules + page (all but the appended `_error`), so a layout's
|
|
1629
|
+
// `searchSchema` merges with the page's. Client keys come from the page (second-to-last).
|
|
1630
|
+
" searchSchemas[id] = mods.slice(0, mods.length - 1).map((m) => m.searchSchema)",
|
|
1631
|
+
" searchClientKeys[id] = mods[mods.length - 2].searchClientKeys ?? []",
|
|
1001
1632
|
" } else {",
|
|
1002
1633
|
" chains[id] = mods.map((m) => m.default)",
|
|
1003
1634
|
" metas[id] = mods.map((m) => m.meta)",
|
|
1635
|
+
// The chain is every module (layouts + page); client keys come from the page (the last module).
|
|
1636
|
+
" searchSchemas[id] = mods.map((m) => m.searchSchema)",
|
|
1637
|
+
" searchClientKeys[id] = mods[mods.length - 1].searchClientKeys ?? []",
|
|
1004
1638
|
" }",
|
|
1005
1639
|
"}",
|
|
1006
1640
|
"const patterns = [",
|
|
@@ -1020,29 +1654,43 @@ export function generateClientEntry(manifest, options) {
|
|
|
1020
1654
|
// page reading the search string would hydrate-mismatch. The #hash is client-only (never SSR'd).
|
|
1021
1655
|
" path: location.pathname + location.search,",
|
|
1022
1656
|
` data: mapDeferred(window.${DATA_GLOBAL}),`,
|
|
1657
|
+
// Undefined on a page-only app, which is exactly what the router treats as "no layout data".
|
|
1658
|
+
` layoutData: window.${LAYOUT_DATA_GLOBAL} && window.${LAYOUT_DATA_GLOBAL}.map(mapDeferred),`,
|
|
1023
1659
|
// actionData (only set after a form POST) is in the initial state so the binding hydrates
|
|
1024
1660
|
// consistently with the server-rendered markup; mapped through `mapDeferred` too so a deferred
|
|
1025
1661
|
// action's placeholders become registry markers (a no-op when the action didn't defer).
|
|
1026
1662
|
` actionData: mapDeferred(window.${ACTION_GLOBAL}),`,
|
|
1027
1663
|
" pending: false,",
|
|
1028
1664
|
"}",
|
|
1029
|
-
|
|
1665
|
+
`const statusRoutes = ${JSON.stringify(statusRoutes)}`,
|
|
1666
|
+
"const router = createClientRouter({ patterns, initial, loadModule, statusRoutes, searchClientKeys })",
|
|
1030
1667
|
"installHistory(router)",
|
|
1031
1668
|
"installForms(router)",
|
|
1032
|
-
|
|
1669
|
+
// The container is found in the DOM, not baked in. `rootId` is a per-render option and this entry
|
|
1670
|
+
// is a per-build artifact, so a hardcoded id is a guess: `renderPage({ rootId: "app" })` produced a
|
|
1671
|
+
// perfect document that hydrated NOTHING, with no diagnostic, because the missing `#root` was
|
|
1672
|
+
// swallowed by an `if (root)`. `renderPage` marks a custom container with `ROOT_ATTRIBUTE`; the
|
|
1673
|
+
// default `#root` it leaves unmarked, which is what the fallback is for.
|
|
1674
|
+
`const root = document.querySelector("body > div[${ROOT_ATTRIBUTE}]") ?? document.getElementById("root")`,
|
|
1675
|
+
// Loud, because there is no recoverable case: this entry ships only in a document `renderPage`
|
|
1676
|
+
// opened a container in, so reaching here means the document is not the one it was built for.
|
|
1677
|
+
`if (!root) throw new Error(${JSON.stringify("[nifra/web] no hydration container found. The generated bootstrap mounts into the element " +
|
|
1678
|
+
`renderPage opened the app in - the one marked \`${ROOT_ATTRIBUTE}\` when a custom \`rootId\` ` +
|
|
1679
|
+
"is set, `#root` otherwise - and this document has neither. If you emit the document yourself, " +
|
|
1680
|
+
'keep `<div id="root">` around the SSR markup.')})`,
|
|
1033
1681
|
// Load the initial route's chunk, then hydrate the Router (chain is cached). The initial head
|
|
1034
1682
|
// is server-rendered; subsequent navigations update it from the matched route's meta + data.
|
|
1035
|
-
"
|
|
1036
|
-
" mountRouter({ router, routes: chains, container: root })",
|
|
1683
|
+
"loadModule(initial.routeId).then(() => {",
|
|
1684
|
+
" mountRouter({ router, routes: chains, searchSchemas, container: root })",
|
|
1037
1685
|
// Next frame: the adapter has committed its initial hydration, so handlers are attached. Flip the
|
|
1038
1686
|
// document to interactive (`data-nifra-hydrated` + `nifra:hydrated`) for apps to gate pre-hydration UI.
|
|
1039
1687
|
" requestAnimationFrame(signalHydrated)",
|
|
1040
1688
|
" router.subscribe(() => {",
|
|
1041
1689
|
" const s = router.snapshot()",
|
|
1042
|
-
// Merge the matched route's chain meta (layouts→page) into one head
|
|
1690
|
+
// Merge the matched route's chain meta (layouts→page) into one head - same contract as SSR.
|
|
1043
1691
|
" if (!s.pending) {",
|
|
1044
1692
|
// `origin: location.origin` matches the SSR `originOf(req)` (both are `URL.origin` for the same
|
|
1045
|
-
// page URL), so a soft-nav re-resolves the SAME absolute canonical/og:url
|
|
1693
|
+
// page URL), so a soft-nav re-resolves the SAME absolute canonical/og:url - no head drift.
|
|
1046
1694
|
" const args = { data: s.data, params: s.params, origin: location.origin }",
|
|
1047
1695
|
" applyHead(mergeHeads((metas[s.routeId] ?? [undefined]).map((m) => resolveMeta(m, args))))",
|
|
1048
1696
|
" }",
|
|
@@ -1050,12 +1698,42 @@ export function generateClientEntry(manifest, options) {
|
|
|
1050
1698
|
"})",
|
|
1051
1699
|
].join("\n")}\n`;
|
|
1052
1700
|
}
|
|
1701
|
+
/**
|
|
1702
|
+
* Codegen: emit a `.d.ts` that types cross-route `navigate({ to, search })` against each route's
|
|
1703
|
+
* `searchSchema`. It augments the {@link RouteSearch} map with `"<pattern>": SearchOf<typeof import(...)>`
|
|
1704
|
+
* for every **static** route (one with no `:param`/`*` segment - a concrete `to` a caller can pass; a
|
|
1705
|
+
* dynamic route's concrete path can't match its pattern key, so those keep the loose object form). Written
|
|
1706
|
+
* by `nifra sync-routes` next to the route tree and included in the app's `tsconfig`; because it is
|
|
1707
|
+
* regenerated from the route files, a `navigate` whose `search` no longer matches the route's schema
|
|
1708
|
+
* becomes a `tsc` error - the same drift guarantee as nifra's typed client, no hand-maintained map.
|
|
1709
|
+
*/
|
|
1710
|
+
export function generateRouteSearchTypes(manifest, options) {
|
|
1711
|
+
const { resolve, module = "@nifrajs/web" } = options;
|
|
1712
|
+
// Static routes only (no `:param` / `*` catch-all): their pattern IS a concrete `to`. Sorted for stable
|
|
1713
|
+
// output; a dynamic route falls through to the loose `{ to: string; search?: Record<...> }` form.
|
|
1714
|
+
const entries = manifest.routes
|
|
1715
|
+
.filter((route) => !/[:*]/.test(route.pattern))
|
|
1716
|
+
.map((route) => [route.pattern, resolve(route.file)])
|
|
1717
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
|
1718
|
+
.map(([pattern, spec]) => ` ${JSON.stringify(pattern)}: SearchOf<typeof import(${JSON.stringify(spec)})>`);
|
|
1719
|
+
return `${[
|
|
1720
|
+
"// GENERATED by @nifrajs/web generateRouteSearchTypes - typed cross-route navigate({ to, search }).",
|
|
1721
|
+
"// Regenerated from the route files; a stale navigate search shape becomes a tsc error. Do not edit.",
|
|
1722
|
+
'import type { SearchOf } from "@nifrajs/web"',
|
|
1723
|
+
`declare module ${JSON.stringify(module)} {`,
|
|
1724
|
+
" interface RouteSearch {",
|
|
1725
|
+
...entries,
|
|
1726
|
+
" }",
|
|
1727
|
+
"}",
|
|
1728
|
+
"export {}",
|
|
1729
|
+
].join("\n")}\n`;
|
|
1730
|
+
}
|
|
1053
1731
|
/**
|
|
1054
1732
|
* Codegen: emit a **server manifest** module (as source) for disk-less edge runtimes (Cloudflare
|
|
1055
|
-
* Workers, …)
|
|
1056
|
-
* and dynamic-imports each route by a *runtime* path
|
|
1057
|
-
* **statically-analyzable** imports of every route/layout/`_error
|
|
1058
|
-
* rebuilds the manifest with `buildManifest`
|
|
1733
|
+
* Workers, …) - and, with a `target`, any portable server bundle. `discoverRoutes` scans `node:fs`
|
|
1734
|
+
* and dynamic-imports each route by a *runtime* path - neither exists on workerd. This instead emits
|
|
1735
|
+
* **statically-analyzable** imports of every route/layout/`_error`/terminal status page (so the bundler includes them) and
|
|
1736
|
+
* rebuilds the manifest with `buildManifest` - the SAME pure logic `discoverRoutes` feeds, so patterns
|
|
1059
1737
|
* + layout chains are identical. Eager (`import * as`) by default; `lazy` emits `() => import(...)` so
|
|
1060
1738
|
* a code-splitting bundler chunks per route. The emitted module exports `manifest` (consumed by
|
|
1061
1739
|
* `createWebApp`, unchanged) + `clientEntry` (baked). Write it to a file and bundle it into the worker
|
|
@@ -1063,17 +1741,18 @@ export function generateClientEntry(manifest, options) {
|
|
|
1063
1741
|
*/
|
|
1064
1742
|
export function generateServerManifest(manifest, options) {
|
|
1065
1743
|
const { resolve, clientEntry, styles, routeStyles, lazy = false } = options;
|
|
1066
|
-
// Every unique source file in the manifest (routes + layouts +
|
|
1744
|
+
// Every unique source file in the manifest (routes + layouts + error/status pages), sorted for stable output.
|
|
1067
1745
|
const files = [
|
|
1068
1746
|
...new Set([
|
|
1069
1747
|
...manifest.routes.map((r) => r.file),
|
|
1070
1748
|
...Object.values(manifest.layouts).map((l) => l.file),
|
|
1071
1749
|
...Object.values(manifest.errors ?? {}).map((e) => e.file),
|
|
1072
1750
|
...(manifest.notFound ? [manifest.notFound.file] : []),
|
|
1751
|
+
...Object.values(manifest.statusPages ?? {}).map((page) => page.file),
|
|
1073
1752
|
]),
|
|
1074
1753
|
].sort();
|
|
1075
1754
|
const header = [
|
|
1076
|
-
"// GENERATED by @nifrajs/web generateServerManifest
|
|
1755
|
+
"// GENERATED by @nifrajs/web generateServerManifest - route manifest for the disk-less edge",
|
|
1077
1756
|
"// (no filesystem; route imports are static specifiers the bundler resolves). buildManifest is",
|
|
1078
1757
|
"// the same pure logic discoverRoutes feeds, so patterns + layout chains match exactly.",
|
|
1079
1758
|
'import { buildManifest } from "@nifrajs/web"',
|
|
@@ -1115,4 +1794,15 @@ export function generateServerManifest(manifest, options) {
|
|
|
1115
1794
|
"export const manifest = buildManifest(Object.keys(modules), (file) => () => Promise.resolve(modules[file]))",
|
|
1116
1795
|
].join("\n")}\n`;
|
|
1117
1796
|
}
|
|
1797
|
+
/**
|
|
1798
|
+
* The two file-name conventions that decide what never reaches a browser: a `*.server` module is
|
|
1799
|
+
* EMPTIED in the client build, a `*.fn` module is REPLACED with client stubs.
|
|
1800
|
+
*
|
|
1801
|
+
* Exported because more than the bundlers need them. `nifra dev --bun` cannot transform (Bun's dev
|
|
1802
|
+
* bundler takes no plugins) so it must REFUSE instead, and a refusal driven by its own hand-written
|
|
1803
|
+
* glob drifts from the transform - which is how `.fn.mts` came to be stubbed by both build pipelines
|
|
1804
|
+
* and waved through by the guard that exists to stop it leaking. Anything deciding "is this module
|
|
1805
|
+
* server-only" should import the matcher rather than re-encode it.
|
|
1806
|
+
*/
|
|
1807
|
+
export { SERVER_FN_MODULE, SERVER_ONLY_MODULE } from "./internal/server-boundary.js";
|
|
1118
1808
|
//# sourceMappingURL=index.js.map
|