@nifrajs/web 2.2.0 → 2.4.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 +4 -4
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +20 -74
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +64 -42
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +100 -78
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +1 -1
- package/dist/bun-dev-entry.js +1 -1
- 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 +201 -29
- 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-diagnostics.d.ts +20 -0
- package/dist/dev-diagnostics.d.ts.map +1 -0
- package/dist/dev-diagnostics.js +34 -0
- package/dist/dev-diagnostics.js.map +1 -0
- package/dist/dev-error.d.ts +10 -13
- package/dist/dev-error.d.ts.map +1 -1
- package/dist/dev-error.js +51 -37
- package/dist/dev-error.js.map +1 -1
- package/dist/dev-port.d.ts +3 -3
- package/dist/dev-port.d.ts.map +1 -1
- package/dist/dev-port.js +10 -4
- package/dist/dev-port.js.map +1 -1
- package/dist/dev.d.ts +4 -4
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +28 -12
- package/dist/dev.js.map +1 -1
- package/dist/diagnostic.d.ts +93 -0
- package/dist/diagnostic.d.ts.map +1 -0
- package/dist/diagnostic.js +205 -0
- package/dist/diagnostic.js.map +1 -0
- package/dist/draft.d.ts +12 -12
- package/dist/draft.js +11 -11
- 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 +1 -1
- package/dist/fs.js +1 -1
- package/dist/index.d.ts +151 -70
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +317 -139
- 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 +1 -1
- package/dist/internal/timing-safe-equal.js +1 -1
- 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.js +7 -7
- package/dist/manifest.d.ts +73 -35
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +10 -16
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +5 -5
- package/dist/module-graph.js +4 -4
- 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.js +0 -0
- 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 +1 -1
- package/dist/plugins/vite-leak-guard.js +1 -1
- 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/query.d.ts +10 -10
- package/dist/query.js +15 -15
- package/dist/route-manifest.d.ts +1 -1
- package/dist/route-manifest.js +2 -2
- package/dist/router.d.ts +30 -21
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +62 -32
- 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 +16 -14
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +51 -24
- package/dist/vite.js.map +1 -1
- package/package.json +38 -4
- package/server-manifest.d.ts +31 -0
- package/src/build-vite.ts +24 -77
- package/src/build.ts +137 -105
- package/src/bun-dev-entry.ts +1 -1
- package/src/chunk-names.ts +1 -1
- package/src/client.ts +215 -32
- package/src/conformance.ts +3 -3
- package/src/deferred.ts +17 -17
- package/src/dev-diagnostics.ts +47 -0
- package/src/dev-error.ts +64 -33
- package/src/dev-port.ts +17 -5
- package/src/dev.ts +36 -20
- package/src/diagnostic.ts +284 -0
- package/src/draft.ts +15 -15
- 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 +2 -2
- package/src/index.ts +460 -181
- 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 +1 -1
- package/src/internal/vite-import.ts +92 -0
- package/src/islands.ts +7 -7
- package/src/isr.ts +17 -17
- package/src/manifest.ts +83 -42
- package/src/module-graph.ts +6 -6
- 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 +2 -2
- 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/query.ts +22 -22
- package/src/route-manifest.ts +3 -3
- package/src/router.ts +98 -45
- package/src/search.ts +297 -0
- package/src/server-only.ts +7 -7
- package/src/service-worker.ts +165 -0
- package/src/vite.ts +61 -32
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,32 +11,40 @@ 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
18
|
import { createMatcher, DATA_HEADER, NAV_FROM_HEADER, PRERENDERED_GLOBAL, REDIRECT_HEADER, RETAIN_HEADER, REVALIDATE_HEADER, STATUS_HEADER, } from "./router.js";
|
|
17
|
-
|
|
19
|
+
import { searchOf, searchOfChain } from "./search.js";
|
|
20
|
+
// Draft / preview mode - a signed cookie that flips `ctx.draft` for loaders + bypasses ISR for editors.
|
|
18
21
|
export { DRAFT_COOKIE, disableDraft, enableDraft, isDraftEnabled, previewEndpoint, } from "./draft.js";
|
|
19
|
-
// Font optimization
|
|
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
|
-
// public/
|
|
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.
|
|
30
33
|
export { resolvePublicPath, servePublicDir, } from "./public-dir.js";
|
|
31
|
-
// Keyed query-cache (agnostic)
|
|
34
|
+
// Keyed query-cache (agnostic) - a `query(key, fn)` primitive (dedup + staleness + invalidation + GC)
|
|
32
35
|
// consumed by the per-adapter `useQuery`/`createQuery` bindings.
|
|
33
36
|
export { createMutation, createQueryClient, hashQueryKey, } from "./query.js";
|
|
34
|
-
// Agnostic client-side router core (pure + DOM-free)
|
|
37
|
+
// Agnostic client-side router core (pure + DOM-free) - consumed by per-adapter Router bindings.
|
|
35
38
|
// `DATA_HEADER` marks a navigation's data-only GET; `createWebApp` answers it with loader JSON.
|
|
36
39
|
export { createClientRouter, createMatcher, DATA_HEADER, NAV_FROM_HEADER, REDIRECT_HEADER, RETAIN_HEADER, REVALIDATE_HEADER, STATUS_HEADER, } from "./router.js";
|
|
37
|
-
// Deferred loader data (`defer()` + the `Deferred<T>` type)
|
|
40
|
+
// Deferred loader data (`defer()` + the `Deferred<T>` type) - consumed by the adapter's `<Await>`.
|
|
38
41
|
export { defer };
|
|
39
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";
|
|
40
48
|
/** Global the server serializes loader data into; the client reads it to hydrate. */
|
|
41
49
|
export const DATA_GLOBAL = "__NIFRA_DATA__";
|
|
42
50
|
/** Per-layout loader data for hydration. Emitted ONLY when some layout in the chain has a loader, so
|
|
@@ -48,12 +56,24 @@ export const ROUTE_GLOBAL = "__NIFRA_ROUTE__";
|
|
|
48
56
|
* reads it so hydration after a native form POST matches the server-rendered markup. */
|
|
49
57
|
export const ACTION_GLOBAL = "__NIFRA_ACTION__";
|
|
50
58
|
/**
|
|
51
|
-
*
|
|
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
|
|
52
72
|
* first paint and the island bundle taking over). It neutralizes the one real hydration footgun: a
|
|
53
73
|
* JS-only form (a hand-wired `onSubmit` with no native fallback) submitting *natively* before its
|
|
54
74
|
* handler is attached, which navigates the browser to a broken `?field=…` GET of the current page.
|
|
55
75
|
*
|
|
56
|
-
* It blocks ONLY that shape
|
|
76
|
+
* It blocks ONLY that shape - an effective-GET form whose action targets the current document - and
|
|
57
77
|
* only until hydration commits (`data-nifra-hydrated`, set by the client entry). Method-`post` forms
|
|
58
78
|
* (progressive enhancement: the native POST hits the route `action`) and GET forms with a real action
|
|
59
79
|
* (a search box → `/search`) pass through untouched. Opt out per-form with `data-native`. Static text
|
|
@@ -69,11 +89,11 @@ export const PRE_HYDRATION_GUARD = "(function(){addEventListener('submit',functi
|
|
|
69
89
|
/**
|
|
70
90
|
* The single default port for the dev server (`@nifrajs/web/dev`, `@nifrajs/web/vite`) **and**
|
|
71
91
|
* `nifra start`. Deliberately uncommon: `3000`/`5173`/`8080` collide with whatever else is running
|
|
72
|
-
* (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`
|
|
73
93
|
* and `nifra start` means a project's URL doesn't change between commands. Override per-run with
|
|
74
94
|
* `--port <n>` or the `PORT` env var. */
|
|
75
95
|
export const DEFAULT_DEV_PORT = 4321;
|
|
76
|
-
// Shared, stateless
|
|
96
|
+
// Shared, stateless - allocated once at module load, not per render/stream.
|
|
77
97
|
const TEXT_ENCODER = new TextEncoder();
|
|
78
98
|
// XSS-safe `<script>` JSON escaping in a SINGLE pass: `<`/`>` would break out of the
|
|
79
99
|
// script element; U+2028/U+2029 are valid JSON but historically break JS string literals. Built via
|
|
@@ -91,8 +111,8 @@ const SCRIPT_ESCAPE_MAP = {
|
|
|
91
111
|
const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body");
|
|
92
112
|
const RESPONSE_RESULT = Symbol.for("nifra.response.result");
|
|
93
113
|
/**
|
|
94
|
-
* Server: render a full HTML document for a page
|
|
95
|
-
* 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`.
|
|
96
116
|
* The shell (`<head>` + the open container) flushes first, the adapter's app stream follows,
|
|
97
117
|
* then the tail (data globals + client entry). Pure Web Standards, so it returns straight from
|
|
98
118
|
* a nifra route handler and streams on any fetch runtime (Bun/Node/Deno/Workers).
|
|
@@ -102,16 +122,20 @@ export function renderPage(options) {
|
|
|
102
122
|
return page instanceof Promise ? page.then((p) => p.toResponse()) : page.toResponse();
|
|
103
123
|
}
|
|
104
124
|
export function renderPageResult(options) {
|
|
105
|
-
const { adapter, chain, data, actionData, clientEntry, preload = [], styles = [], prerenderedPaths = [], revalidate, routeId, status = 200, title = "nifra", head, rootId = "root", hydrate = true, islandScripts = [], headers: extraHeaders, } = 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)}"`;
|
|
106
130
|
const route = routeId === undefined ? "" : `window.${ROUTE_GLOBAL}=${serializeData(routeId)};`;
|
|
107
|
-
// 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
|
|
108
132
|
// `_data.json` on soft-nav into a prerendered route instead of hitting the worker. Empty ⇒ omitted.
|
|
109
133
|
const prerendered = prerenderedPaths.length === 0
|
|
110
134
|
? ""
|
|
111
135
|
: `window.${PRERENDERED_GLOBAL}=${serializeData(prerenderedPaths)};`;
|
|
112
136
|
// Split deferred values: the component sees markers (id + promise) to `<Await>`; the serialized
|
|
113
137
|
// data carries `{__nifra_deferred: id}` placeholders (promises don't serialize). `actionData` may
|
|
114
|
-
// also `defer()`
|
|
138
|
+
// also `defer()` - split it too, continuing the id space so a single registry settles both. The
|
|
115
139
|
// inline registry runtime is emitted only when something defers, so non-deferred output is unchanged.
|
|
116
140
|
const { forComponent, forClient, deferred } = prepareDeferred(data);
|
|
117
141
|
// Each layout's data is split in turn, each continuing the id space, so a `defer()` in a layout
|
|
@@ -130,7 +154,7 @@ export function renderPageResult(options) {
|
|
|
130
154
|
? undefined
|
|
131
155
|
: prepareDeferred(actionData, deferred.length + layoutDeferred.length);
|
|
132
156
|
const allDeferred = [...deferred, ...layoutDeferred, ...(actionSplit ? actionSplit.deferred : [])];
|
|
133
|
-
// Omitted entirely when no layout has a loader
|
|
157
|
+
// Omitted entirely when no layout has a loader - a page-only app emits exactly what it did before.
|
|
134
158
|
const layoutTail = options.layoutData === undefined
|
|
135
159
|
? ""
|
|
136
160
|
: `window.${LAYOUT_DATA_GLOBAL}=${serializeData(layoutSplits.map((split) => split.forClient))};`;
|
|
@@ -138,9 +162,9 @@ export function renderPageResult(options) {
|
|
|
138
162
|
const action = actionSplit === undefined
|
|
139
163
|
? ""
|
|
140
164
|
: `window.${ACTION_GLOBAL}=${serializeData(actionSplit.forClient)};`;
|
|
141
|
-
const deferredRuntime = allDeferred.length > 0 ? `<script>${DEFERRED_RUNTIME}</script>` : "";
|
|
165
|
+
const deferredRuntime = allDeferred.length > 0 ? `<script${nonceAttr}>${DEFERRED_RUNTIME}</script>` : "";
|
|
142
166
|
// Matched-route chunk preloads, concatenated directly (skip the `filter().map()`
|
|
143
|
-
// intermediate arrays
|
|
167
|
+
// intermediate arrays - and the whole loop - on the common no-extra-preload render). De-duped
|
|
144
168
|
// against the entry, which is preloaded separately below.
|
|
145
169
|
let preloadLinks = "";
|
|
146
170
|
if (preload.length > 0) {
|
|
@@ -149,7 +173,7 @@ export function renderPageResult(options) {
|
|
|
149
173
|
preloadLinks += `<link rel="modulepreload" href="${escapeAttr(url)}">`;
|
|
150
174
|
}
|
|
151
175
|
}
|
|
152
|
-
// The matched route's stylesheets
|
|
176
|
+
// The matched route's stylesheets - `<link rel="stylesheet">` in `<head>` so CSS arrives with the
|
|
153
177
|
// first paint (no FOUC). Render-blocking by design, and emitted regardless of `hydrate` (a static or
|
|
154
178
|
// `_error` page still wants its styles). In dev (Vite) CSS is injected client-side instead, so
|
|
155
179
|
// `styles` is empty there.
|
|
@@ -158,43 +182,50 @@ export function renderPageResult(options) {
|
|
|
158
182
|
styleLinks += `<link rel="stylesheet" href="${escapeAttr(url)}">`;
|
|
159
183
|
// Island bundles are referenced only by a `<script type="module">` at the END of `<body>` (below), so
|
|
160
184
|
// the browser doesn't discover them until the whole page is parsed. `modulepreload` them in `<head>`
|
|
161
|
-
// 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
|
|
162
186
|
// is typically hydrate:false). Without this, a heavy island bundle on a cold first load leaves its
|
|
163
187
|
// server-rendered placeholder visible until that late, un-prefetched fetch lands ("stuck loading").
|
|
164
188
|
let islandPreloads = "";
|
|
165
189
|
for (const src of islandScripts)
|
|
166
190
|
islandPreloads += `<link rel="modulepreload" href="${escapeAttr(src)}">`;
|
|
167
|
-
// Shell
|
|
168
|
-
// open container. `modulepreload` of the client entry
|
|
169
|
-
// (`preloadLinks`)
|
|
170
|
-
// 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.
|
|
171
195
|
// A non-hydrated page (e.g. an `_error` boundary) omits the client-entry preload, route-chunk
|
|
172
|
-
// preloads, and deferred runtime
|
|
196
|
+
// preloads, and deferred runtime - there's no client takeover to feed.
|
|
173
197
|
const hydrationLinks = hydrate
|
|
174
|
-
? `<link rel="modulepreload" href="${escapeAttr(clientEntry)}">${preloadLinks}${deferredRuntime}`
|
|
198
|
+
? `<link rel="modulepreload" href="${escapeAttr(clientEntry ?? "")}">${preloadLinks}${deferredRuntime}`
|
|
175
199
|
: "";
|
|
176
200
|
// Runs in the first-paint→hydration window to swallow a JS-only form's broken native submit. Only on a
|
|
177
201
|
// hydrating page (a static/_error page has no client handlers, so no footgun).
|
|
178
|
-
const hydrationGuard = hydrate ? `<script>${PRE_HYDRATION_GUARD}</script>` : "";
|
|
202
|
+
const hydrationGuard = hydrate ? `<script${nonceAttr}>${PRE_HYDRATION_GUARD}</script>` : "";
|
|
179
203
|
// `<html>` attributes. `lang` defaults to `"en"` (so an app that never sets it is byte-identical to
|
|
180
|
-
// before); `dir` is omitted entirely when unset, which IS HTML's `ltr` default
|
|
204
|
+
// before); `dir` is omitted entirely when unset, which IS HTML's `ltr` default - emitting it
|
|
181
205
|
// unconditionally would change every existing app's output for no gain. Both attribute-escaped.
|
|
182
206
|
// `client.ts`'s `applyHead` mirrors this exact defaulting on soft-nav, so a hard load and a client
|
|
183
207
|
// navigation to the same URL produce the same `<html>` (no drift on a multilingual site).
|
|
184
208
|
const htmlAttrs = ` lang="${escapeAttr(head?.lang ?? "en")}"${head?.dir === undefined ? "" : ` dir="${escapeAttr(head.dir)}"`}`;
|
|
185
|
-
|
|
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}>`;
|
|
186
217
|
// Closes the hydration container; deferred resolve scripts go AFTER it (outside `#root`) so they
|
|
187
218
|
// aren't part of the adapter's hydrated tree (an inline script inside it breaks hydration).
|
|
188
219
|
const closeRootHtml = "</div>";
|
|
189
|
-
// Tail
|
|
220
|
+
// Tail - the loader-data globals + the client module. Module scripts defer (run after parse), so
|
|
190
221
|
// the data global + every streamed deferred resolution are set before the entry hydrates.
|
|
191
|
-
// Island bundles load regardless of `hydrate`
|
|
222
|
+
// Island bundles load regardless of `hydrate` - a static page (hydrate:false) ships no framework
|
|
192
223
|
// client but can still mount no-framework islands (`@nifrajs/web/islands`).
|
|
193
224
|
let islandTags = "";
|
|
194
225
|
for (const src of islandScripts)
|
|
195
|
-
islandTags += `<script type="module" src="${escapeAttr(src)}"></script>`;
|
|
226
|
+
islandTags += `<script type="module" src="${escapeAttr(src)}"${nonceAttr}></script>`;
|
|
196
227
|
const tailHtml = `${hydrate
|
|
197
|
-
? `<script>${route}${action}${prerendered}${layoutTail}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>`
|
|
198
229
|
: ""}${islandTags}</body></html>`;
|
|
199
230
|
const headers = { "content-type": "text/html; charset=utf-8" };
|
|
200
231
|
// Caller-supplied headers (a terminal status page's `cache-control`, say). Applied before the
|
|
@@ -215,10 +246,13 @@ export function renderPageResult(options) {
|
|
|
215
246
|
data: forComponent,
|
|
216
247
|
actionData: actionSplit?.forComponent,
|
|
217
248
|
// `params`/`path` thread the matched route + URL to the adapter's `useParams`/`useLocation` so they
|
|
218
|
-
// render SSR-correct. Spread only when supplied (exactOptionalPropertyTypes)
|
|
249
|
+
// render SSR-correct. Spread only when supplied (exactOptionalPropertyTypes) - an adapter with no
|
|
219
250
|
// router bindings simply never reads them.
|
|
220
251
|
...(options.params !== undefined ? { params: options.params } : {}),
|
|
221
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 } : {}),
|
|
222
256
|
// Spread only when present, so a page-only render produces exactly the props it did before.
|
|
223
257
|
...(options.layoutData !== undefined
|
|
224
258
|
? { layoutData: layoutSplits.map((split) => split.forComponent) }
|
|
@@ -236,10 +270,10 @@ export function renderPageResult(options) {
|
|
|
236
270
|
? new BufferedRenderedPage(shellHtml + out + closeRootHtml + tailHtml, status, headers)
|
|
237
271
|
: out.then((bodyHtml) => new BufferedRenderedPage(shellHtml + bodyHtml + closeRootHtml + tailHtml, status, headers));
|
|
238
272
|
}
|
|
239
|
-
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));
|
|
240
274
|
}
|
|
241
|
-
async function renderStreamedPage(adapter, chain, renderProps, shellHtml, closeRootHtml, allDeferred, tailHtml, status, headers) {
|
|
242
|
-
// 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
|
|
243
277
|
// that doesn't implement `renderToString`. Awaiting `renderToStream` resolves on shell-readiness
|
|
244
278
|
// (React: on-shell-ready; Solid: synchronously), so a shell-render throw surfaces before any byte is
|
|
245
279
|
// sent. A *mid*-stream failure errors the body instead.
|
|
@@ -248,7 +282,7 @@ async function renderStreamedPage(adapter, chain, renderProps, shellHtml, closeR
|
|
|
248
282
|
const closeRoot = enc.encode(closeRootHtml);
|
|
249
283
|
const tail = enc.encode(tailHtml);
|
|
250
284
|
const appStream = await adapter.renderToStream(chain, renderProps);
|
|
251
|
-
const body = streamDocument(shell, appStream, closeRoot, allDeferred, tail, enc);
|
|
285
|
+
const body = streamDocument(shell, appStream, closeRoot, allDeferred, tail, enc, nonceAttr);
|
|
252
286
|
return new Response(body, { status, headers });
|
|
253
287
|
}
|
|
254
288
|
class BufferedRenderedPage {
|
|
@@ -289,11 +323,11 @@ function htmlResponse(body, init) {
|
|
|
289
323
|
/**
|
|
290
324
|
* Assemble the document stream: `shell` → the app `stream` (forwarded chunk-by-chunk, so a streaming
|
|
291
325
|
* renderer's progressive flushing is preserved) → `closeRoot` → one `__nifraResolve`/`__nifraReject`
|
|
292
|
-
* script per deferred value (emitted once its promise settles
|
|
293
|
-
* 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
|
|
294
328
|
* the result (the body breaks) rather than silently truncating a 200.
|
|
295
329
|
*/
|
|
296
|
-
function streamDocument(shell, stream, closeRoot, deferred, tail, enc) {
|
|
330
|
+
function streamDocument(shell, stream, closeRoot, deferred, tail, enc, nonceAttr) {
|
|
297
331
|
return new ReadableStream({
|
|
298
332
|
async start(controller) {
|
|
299
333
|
const reader = stream.getReader();
|
|
@@ -306,19 +340,19 @@ function streamDocument(shell, stream, closeRoot, deferred, tail, enc) {
|
|
|
306
340
|
controller.enqueue(value);
|
|
307
341
|
}
|
|
308
342
|
controller.enqueue(closeRoot);
|
|
309
|
-
// Stream each resolution as ITS OWN promise settles
|
|
343
|
+
// Stream each resolution as ITS OWN promise settles - NOT in array order. A slow
|
|
310
344
|
// defer() must not block a faster one; each script self-addresses by id, so order is irrelevant.
|
|
311
345
|
await Promise.all(deferred.map(async (d) => {
|
|
312
346
|
try {
|
|
313
347
|
const value = serializeData(await d.promise);
|
|
314
|
-
controller.enqueue(enc.encode(`<script>window.__nifraResolve(${d.id},${value})</script>`));
|
|
348
|
+
controller.enqueue(enc.encode(`<script${nonceAttr}>window.__nifraResolve(${d.id},${value})</script>`));
|
|
315
349
|
}
|
|
316
350
|
catch (err) {
|
|
317
|
-
// A rejected deferred streams __nifraReject (the client `<Await>` surfaces it)
|
|
351
|
+
// A rejected deferred streams __nifraReject (the client `<Await>` surfaces it) - it must
|
|
318
352
|
// not break the whole body. Redact: stream a stable opaque code, never the raw error
|
|
319
353
|
// text; log the real reason server-side.
|
|
320
354
|
console.error("[nifra/web] deferred value rejected:", err);
|
|
321
|
-
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>`));
|
|
322
356
|
}
|
|
323
357
|
}));
|
|
324
358
|
controller.enqueue(tail);
|
|
@@ -340,12 +374,12 @@ function isSameOriginPath(location) {
|
|
|
340
374
|
return location.startsWith("/") && !location.startsWith("//");
|
|
341
375
|
}
|
|
342
376
|
/**
|
|
343
|
-
* Build a redirect `Response`
|
|
377
|
+
* Build a redirect `Response` - return it from a route `action` for the Post/Redirect/Get
|
|
344
378
|
* pattern (POST mutates, 303 sends the browser to a fresh GET, so a reload doesn't re-submit).
|
|
345
379
|
* Defaults to 303 (See Other); pass `{ status: 307 }` or `{ status: 308 }` to preserve the method.
|
|
346
380
|
*
|
|
347
381
|
* **Secure by default:** `location` must be a same-origin path (begins with `/`, not `//`). An
|
|
348
|
-
* off-origin/absolute destination throws unless you pass `{ external: true }`
|
|
382
|
+
* off-origin/absolute destination throws unless you pass `{ external: true }` - this closes the
|
|
349
383
|
* open-redirect footgun of `return redirect(formData.get("next"))` on the no-JS (native-form) path,
|
|
350
384
|
* which returns the action's `Response` verbatim.
|
|
351
385
|
*
|
|
@@ -355,12 +389,12 @@ export function redirect(location, options = {}) {
|
|
|
355
389
|
if (options.external !== true && !isSameOriginPath(location)) {
|
|
356
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.`);
|
|
357
391
|
}
|
|
358
|
-
// Reject CR/LF in the Location explicitly
|
|
392
|
+
// Reject CR/LF in the Location explicitly - defense-in-depth (response splitting / header
|
|
359
393
|
// injection). Spec-correct runtimes' Headers setter throws on CR/LF, but `external: true` lets
|
|
360
394
|
// unvalidated input reach this sink, so we don't rely on the runtime. Same posture as
|
|
361
395
|
// serializeCookie / the SSE frame formatter, which strip CRLF at their sinks.
|
|
362
396
|
if (/[\r\n]/.test(location)) {
|
|
363
|
-
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.`);
|
|
364
398
|
}
|
|
365
399
|
return new Response(null, { status: options.status ?? 303, headers: { location } });
|
|
366
400
|
}
|
|
@@ -504,7 +538,7 @@ export function statusPage(status, options = {}) {
|
|
|
504
538
|
/**
|
|
505
539
|
* Return this from an action to declare which routes the mutation changed (alongside the action's
|
|
506
540
|
* `data`). `createWebApp` sets the `X-Nifra-Revalidate` response header; after the submit the client
|
|
507
|
-
* marks those cached routes stale
|
|
541
|
+
* marks those cached routes stale - refetching the active one and any mounted fetcher showing them -
|
|
508
542
|
* so a mutation can refresh views beyond the one that was submitted. `data` is still surfaced to the
|
|
509
543
|
* component as `actionData` (the wrapper is transparent to `ActionData<typeof action>`).
|
|
510
544
|
*/
|
|
@@ -526,10 +560,10 @@ function escapeHtml(value) {
|
|
|
526
560
|
/**
|
|
527
561
|
* Escape a `<script>` element's text content so it cannot break out of the element. Per the HTML spec,
|
|
528
562
|
* a script's text may contain any character EXCEPT the sequences that the parser treats as element
|
|
529
|
-
* boundaries: `</` (which would begin the end tag
|
|
563
|
+
* boundaries: `</` (which would begin the end tag - `</script>` is the obvious XSS vector, but ANY `</`
|
|
530
564
|
* starts script-data-end-tag parsing), and the comment/CDATA edges `<!--` / `]]>`. We escape the `<`
|
|
531
565
|
* (and the `>` of `]]>`) to its JS unicode escape: inside a JS/JSON string literal `<` is the
|
|
532
|
-
* 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
|
|
533
567
|
* HTML tokenizer scans for is gone. This is the JSON-LD-in-HTML rule (content is JSON, never raw HTML),
|
|
534
568
|
* mirroring {@link serializeData}'s posture for the data-global script. Idempotent on already-safe text.
|
|
535
569
|
*/
|
|
@@ -546,28 +580,13 @@ function escapeAttr(value) {
|
|
|
546
580
|
.replaceAll("<", "<")
|
|
547
581
|
.replaceAll(">", ">");
|
|
548
582
|
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
// `data-*`. A hardcoded list would silently drop valid attrs (the bug this guards against: `hreflang`
|
|
555
|
-
// /`crossorigin` getting filtered out). What it rejects is name-shape abuse only — a name with a space,
|
|
556
|
-
// `=`, `>`, quote, or a leading digit can't break out of the tag. Attribute VALUES are escaped
|
|
557
|
-
// separately (they may carry loader data → XSS), so a widened name set never widens the injection
|
|
558
|
-
// surface. Names that fail the shape check are dropped.
|
|
559
|
-
const SAFE_ATTR_NAME = /^[a-zA-Z][a-zA-Z0-9-]*$/;
|
|
560
|
-
/** Serialize a meta/link tag's attributes: validate the name shape (see {@link SAFE_ATTR_NAME}) +
|
|
561
|
-
* escape the value against XSS. Invalid attribute names are dropped. Values follow the HTML attribute
|
|
562
|
-
* conventions {@link LinkDescriptor} types: a string renders `name="escaped"`; `true` renders the bare
|
|
563
|
-
* boolean attribute (`disabled`); `false` and `undefined` are skipped (a conditionally-absent attr). */
|
|
564
|
-
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;
|
|
565
588
|
let out = "";
|
|
566
|
-
for (const [name, value] of
|
|
567
|
-
if (value === undefined || value === false)
|
|
568
|
-
continue; // omitted / absent boolean attribute
|
|
569
|
-
if (!SAFE_ATTR_NAME.test(name))
|
|
570
|
-
continue; // name-shape abuse — dropped (see SAFE_ATTR_NAME)
|
|
589
|
+
for (const [name, value] of trusted) {
|
|
571
590
|
if (out !== "")
|
|
572
591
|
out += " ";
|
|
573
592
|
out += value === true ? name : `${name}="${escapeAttr(value)}"`;
|
|
@@ -580,11 +599,16 @@ function tagAttrs(attrs) {
|
|
|
580
599
|
// builds a fresh object each request (new identity) → miss → recompute, exactly as needed (its
|
|
581
600
|
// content can vary). WeakMap so a route module that's GC'd takes its entry with it.
|
|
582
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
|
+
}
|
|
583
607
|
/** Render a route's `meta`/`link`/`script` as managed (`data-nifra`) head tags. Title is set
|
|
584
|
-
* separately. XSS-safe by construction: every attribute flows through
|
|
585
|
-
*
|
|
586
|
-
* (breakout-escaped)
|
|
587
|
-
* 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
|
|
588
612
|
* the already concat-based preloadLinks/styleLinks/islandPreloads loops; byte-identical output. Result
|
|
589
613
|
* is memoized per resolved-`Meta` identity (static meta → serialized once per route). */
|
|
590
614
|
function headTags(head) {
|
|
@@ -595,17 +619,42 @@ function headTags(head) {
|
|
|
595
619
|
return cached;
|
|
596
620
|
let out = "";
|
|
597
621
|
if (head.meta !== undefined)
|
|
598
|
-
for (const m of head.meta)
|
|
599
|
-
|
|
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
|
+
}
|
|
600
627
|
if (head.link !== undefined)
|
|
601
|
-
for (const l of head.link)
|
|
602
|
-
|
|
603
|
-
|
|
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
|
|
604
634
|
// breakout-escaped (`escapeScriptContent`) so a `</script>` (or `<!--`/`]]>`) payload can't close the
|
|
605
635
|
// element early. Managed (`data-nifra`) so a soft-nav's `applyHead` cleanly replaces it.
|
|
606
636
|
if (head.script !== undefined)
|
|
607
|
-
for (const s of head.script)
|
|
608
|
-
|
|
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
|
+
}
|
|
609
658
|
headTagsCache.set(head, out);
|
|
610
659
|
return out;
|
|
611
660
|
}
|
|
@@ -644,7 +693,7 @@ export function openGraph(input) {
|
|
|
644
693
|
add("og:description", input.description);
|
|
645
694
|
add("og:image", input.image);
|
|
646
695
|
add("og:url", input.url);
|
|
647
|
-
// `og:type` always present (the spec's required property)
|
|
696
|
+
// `og:type` always present (the spec's required property) - default "website" when unset.
|
|
648
697
|
tags.push({ property: "og:type", content: input.type ?? "website" });
|
|
649
698
|
return tags;
|
|
650
699
|
}
|
|
@@ -662,6 +711,26 @@ export function openGraph(input) {
|
|
|
662
711
|
export function jsonLd(data) {
|
|
663
712
|
return { type: "application/ld+json", content: JSON.stringify(data) };
|
|
664
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
|
+
}
|
|
665
734
|
/**
|
|
666
735
|
* Merge a route's `<head>` contributions from its layout chain + the page into one {@link Meta}.
|
|
667
736
|
*
|
|
@@ -669,20 +738,20 @@ export function jsonLd(data) {
|
|
|
669
738
|
* `meta` to put sitewide tags (`hreflang`, `preconnect`, a default `<title>`, …) on every page below
|
|
670
739
|
* it. `heads` is passed **outermost layout → … → innermost layout → page** (the same order as the
|
|
671
740
|
* render chain), and merges:
|
|
672
|
-
* - **`title`**
|
|
741
|
+
* - **`title`** - *nearest-wins*: the last defined value across the list, so the page overrides an
|
|
673
742
|
* inner layout, which overrides an outer one. A layout `title` is the section default; an undefined
|
|
674
743
|
* page `title` keeps it.
|
|
675
|
-
* - **`meta` / `link`**
|
|
744
|
+
* - **`meta` / `link`** - *concatenated* in list order (outermost first, page last). Duplicate-tag
|
|
676
745
|
* de-duplication is the caller's concern; the framework emits exactly what's declared so a layout
|
|
677
746
|
* can ship N `<link rel="alternate" hreflang>` tags and a page can add its own canonical.
|
|
678
747
|
*
|
|
679
748
|
* Returns a fresh object whose identity is stable per `heads` *content* only when every entry is a
|
|
680
|
-
* 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
|
|
681
750
|
* fine: {@link headTags}'s memo is keyed on identity, so a per-request merge simply recomputes (its
|
|
682
751
|
* content can vary with loader data anyway).
|
|
683
752
|
*/
|
|
684
753
|
export function mergeHeads(heads) {
|
|
685
|
-
// 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
|
|
686
755
|
// resolved object by reference so headTags' identity-keyed memo hits across requests for static meta.
|
|
687
756
|
if (heads.length === 1)
|
|
688
757
|
return heads[0];
|
|
@@ -692,6 +761,7 @@ export function mergeHeads(heads) {
|
|
|
692
761
|
const meta = [];
|
|
693
762
|
const link = [];
|
|
694
763
|
const script = [];
|
|
764
|
+
const unsafeScript = [];
|
|
695
765
|
for (const h of heads) {
|
|
696
766
|
if (h.title !== undefined)
|
|
697
767
|
title = h.title; // nearest-wins: later (more specific) overrides
|
|
@@ -705,8 +775,10 @@ export function mergeHeads(heads) {
|
|
|
705
775
|
link.push(...h.link);
|
|
706
776
|
if (h.script !== undefined)
|
|
707
777
|
script.push(...h.script); // concatenated like meta/link (outermost first)
|
|
778
|
+
if (h.unsafeScript !== undefined)
|
|
779
|
+
unsafeScript.push(...h.unsafeScript);
|
|
708
780
|
}
|
|
709
|
-
// 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`/
|
|
710
782
|
// `script` array would otherwise be a spurious (if harmless) key. A mutable local; the cast to `Meta`
|
|
711
783
|
// is sound because a key is assigned only when defined (so `exactOptionalPropertyTypes` never sees
|
|
712
784
|
// `undefined`).
|
|
@@ -719,6 +791,8 @@ export function mergeHeads(heads) {
|
|
|
719
791
|
merged.link = link;
|
|
720
792
|
if (script.length > 0)
|
|
721
793
|
merged.script = script;
|
|
794
|
+
if (unsafeScript.length > 0)
|
|
795
|
+
merged.unsafeScript = unsafeScript;
|
|
722
796
|
if (lang !== undefined)
|
|
723
797
|
merged.lang = lang;
|
|
724
798
|
if (dir !== undefined)
|
|
@@ -760,15 +834,15 @@ function backendMountOf(api) {
|
|
|
760
834
|
/**
|
|
761
835
|
* Build a nifra app from a route manifest: every route SSRs its layout chain via `renderPage`,
|
|
762
836
|
* and a wildcard catch-all renders `_404` (or a plain 404). Reuses @nifrajs/core's router +
|
|
763
|
-
* 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
|
|
764
838
|
* manifest from `discoverRoutes` (`@nifrajs/web/fs`) at startup, so the served app stays portable.
|
|
765
839
|
*
|
|
766
|
-
* **Typed platform bindings.** Pass `Env`
|
|
840
|
+
* **Typed platform bindings.** Pass `Env` - `createWebApp<Env>({ … })` - to declare the app's Workers
|
|
767
841
|
* bindings ONCE. It seeds the returned `Server`'s context with `{ env: Env }` (exactly as the backend's
|
|
768
842
|
* `server<Env>()` does), so `app.fetch(req, { env })` / `toFetchHandler(app)` type-check the `env`
|
|
769
|
-
* argument against the declared shape
|
|
843
|
+
* argument against the declared shape - no per-binding cast at the edge entry. Per-route loaders/actions
|
|
770
844
|
* stay typed independently of this call: annotate them with `@nifrajs/client`'s `LoaderArgs<Api, Env>`
|
|
771
|
-
* (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
|
|
772
846
|
* secure default; validate at the trust boundary before use.
|
|
773
847
|
*/
|
|
774
848
|
export function createWebApp(options) {
|
|
@@ -785,7 +859,7 @@ export function createWebApp(options) {
|
|
|
785
859
|
return chunks ? { preload: chunks } : {};
|
|
786
860
|
};
|
|
787
861
|
// The matched route's stylesheet links (spread into renderPage). Per-route when the build mapped it
|
|
788
|
-
// (`routeStyles[id]`
|
|
862
|
+
// (`routeStyles[id]` - only the chain's CSS; an empty array ⇒ no `<link>`), else the aggregate
|
|
789
863
|
// `styles`. Omitted entirely when CSS-free (for exactOptionalPropertyTypes).
|
|
790
864
|
const stylesOf = (id) => {
|
|
791
865
|
const perRoute = options.routeStyles?.[id];
|
|
@@ -799,7 +873,7 @@ export function createWebApp(options) {
|
|
|
799
873
|
const app = server();
|
|
800
874
|
// Auto-mount the in-process backend over HTTP at `apiPrefix` (default `/api`), BEFORE page routing.
|
|
801
875
|
// `onRequest` runs on the raw request ahead of the router, so this wins over the page wildcard `/*`
|
|
802
|
-
// 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`)
|
|
803
877
|
// surfaces as the backend's own response, never the page 404. We dispatch the SAME `Request` object
|
|
804
878
|
// (its body unread) so streamed/large bodies pass through untouched; the backend defines its routes
|
|
805
879
|
// at the full prefixed path (`server().post("/api/sync", …)`), matching the `inProcessClient` call
|
|
@@ -822,7 +896,7 @@ export function createWebApp(options) {
|
|
|
822
896
|
continue;
|
|
823
897
|
return mount.app.fetch(mount.stripPrefix === true ? stripMountPrefix(req, mount.path) : req);
|
|
824
898
|
}
|
|
825
|
-
// Exactly the prefix (`/api`) or a sub-path (`/api/…`)
|
|
899
|
+
// Exactly the prefix (`/api`) or a sub-path (`/api/…`) - NOT a sibling like `/apixyz` that merely
|
|
826
900
|
// shares the prefix as a string head. Dispatch the original `req` (body intact) to the backend.
|
|
827
901
|
if (mountedApi !== undefined && apiPrefix !== "" && underPrefix(pathname, apiPrefix)) {
|
|
828
902
|
return mountedApi(apiStrip ? stripMountPrefix(req, apiPrefix) : req, platform);
|
|
@@ -838,7 +912,7 @@ export function createWebApp(options) {
|
|
|
838
912
|
const routeById = new Map(manifest.routes.map((route) => [route.id, route]));
|
|
839
913
|
// Load a route's layout modules (outermost layout → innermost), keeping each module whole so the
|
|
840
914
|
// render path can take both its `default` (the component chain) AND its `meta` (the sitewide head it
|
|
841
|
-
// contributes). Loaded lazily
|
|
915
|
+
// contributes). Loaded lazily - the data-only and 405 branches return before any layout is needed.
|
|
842
916
|
const loadLayoutModules = async (route) => {
|
|
843
917
|
const modules = await Promise.all(
|
|
844
918
|
// layoutIds only reference layouts present in the manifest (buildManifest invariant).
|
|
@@ -849,7 +923,7 @@ export function createWebApp(options) {
|
|
|
849
923
|
if (modules[i].action === undefined)
|
|
850
924
|
continue;
|
|
851
925
|
const id = route.layoutIds[i];
|
|
852
|
-
throw new Error(`[nifra/web] "${manifest.layouts[id].file}" exports an \`action\`, but layouts do not run one
|
|
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.`);
|
|
853
927
|
}
|
|
854
928
|
return modules;
|
|
855
929
|
};
|
|
@@ -937,6 +1011,13 @@ export function createWebApp(options) {
|
|
|
937
1011
|
}
|
|
938
1012
|
return valid;
|
|
939
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);
|
|
940
1021
|
const runLayoutChain = async (route, ctx, retain = new Set()) => {
|
|
941
1022
|
const modules = await loadLayoutModules(route);
|
|
942
1023
|
if (!modules.some((m) => m.loader !== undefined)) {
|
|
@@ -1042,11 +1123,11 @@ export function createWebApp(options) {
|
|
|
1042
1123
|
}
|
|
1043
1124
|
return undefined;
|
|
1044
1125
|
};
|
|
1045
|
-
// The request's origin (scheme + host + port, NO trailing slash)
|
|
1126
|
+
// The request's origin (scheme + host + port, NO trailing slash) - the one server fact `meta()` needs
|
|
1046
1127
|
// for absolute `canonical`/`og:url`/`og:image` but can't otherwise see (it also runs on the client).
|
|
1047
1128
|
// `URL.origin` is exactly that shape; it matches the browser's `location.origin` for the same URL, so
|
|
1048
1129
|
// a soft-nav's `applyHead` resolves an identical `<head>` (no hydration drift). A malformed `req.url`
|
|
1049
|
-
// (shouldn't happen on a real request) degrades to `""`
|
|
1130
|
+
// (shouldn't happen on a real request) degrades to `""` - the documented unknown-origin default -
|
|
1050
1131
|
// rather than throwing during render.
|
|
1051
1132
|
const originOf = (req) => {
|
|
1052
1133
|
try {
|
|
@@ -1056,7 +1137,7 @@ export function createWebApp(options) {
|
|
|
1056
1137
|
return "";
|
|
1057
1138
|
}
|
|
1058
1139
|
};
|
|
1059
|
-
// The request's `pathname + search`
|
|
1140
|
+
// The request's `pathname + search` - threaded into `renderPage` so an adapter's `useLocation`/
|
|
1060
1141
|
// `useSearchParams` render the current URL server-side and hydrate against the client's initial state
|
|
1061
1142
|
// (which the client entry seeds from `location.pathname + location.search`). No hash: it never reaches
|
|
1062
1143
|
// the server. A malformed `req.url` degrades to "/" rather than throwing during render.
|
|
@@ -1071,11 +1152,11 @@ export function createWebApp(options) {
|
|
|
1071
1152
|
};
|
|
1072
1153
|
/**
|
|
1073
1154
|
* Render the **nearest `_error` boundary** when a route's loader throws (the agnostic, server-side
|
|
1074
|
-
* half of error UI
|
|
1155
|
+
* half of error UI - works on every adapter, no client takeover). The boundary renders in place of
|
|
1075
1156
|
* the page, wrapped by the layouts **at or above** its segment (deeper layouts are dropped). It's
|
|
1076
1157
|
* served **non-hydrated** at status 500: a terminal page, and rendering a boundary (not the page the
|
|
1077
1158
|
* client maps this route to) would otherwise hydrate-mismatch. The component receives `{ name,
|
|
1078
|
-
* message }`
|
|
1159
|
+
* message }` - never the stack (no internals leak into HTML).
|
|
1079
1160
|
*/
|
|
1080
1161
|
const renderError = async (route, errorId, err) => {
|
|
1081
1162
|
const errDir = dirOfId(errorId, "_error");
|
|
@@ -1097,7 +1178,7 @@ export function createWebApp(options) {
|
|
|
1097
1178
|
...titleOption,
|
|
1098
1179
|
});
|
|
1099
1180
|
};
|
|
1100
|
-
// The 404 response
|
|
1181
|
+
// The 404 response - the `_404` page (status 404) or a plain-text fallback. Shared by the wildcard
|
|
1101
1182
|
// catch-all (unmatched paths) and the `fallback: "404"` enforcement (unlisted paths under a route
|
|
1102
1183
|
// that opted out of on-demand SSR).
|
|
1103
1184
|
const renderNotFound = (path) => renderTerminalStatus(404, path);
|
|
@@ -1121,13 +1202,22 @@ export function createWebApp(options) {
|
|
|
1121
1202
|
// The routeId decides which preload/style bundle and which client component this maps to, so it
|
|
1122
1203
|
// must name the page actually rendered - `_410` when `_410.tsx` exists, `_404` when it fell back.
|
|
1123
1204
|
const routeId = manifest.statusPages?.[String(status)] !== undefined ? `_${status}` : "_404";
|
|
1124
|
-
const
|
|
1205
|
+
const loaded = (await page.load());
|
|
1206
|
+
const component = loaded.default;
|
|
1125
1207
|
return renderPageResult({
|
|
1126
1208
|
adapter,
|
|
1127
1209
|
chain: [component],
|
|
1128
1210
|
data: null,
|
|
1129
1211
|
clientEntry,
|
|
1130
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
|
+
: {}),
|
|
1131
1221
|
// The page hydrates, so its SSR `path` must equal the client's initial
|
|
1132
1222
|
// `location.pathname+search` (an unmatched path yields no params → `{}`), or a `useLocation`
|
|
1133
1223
|
// on it would drift.
|
|
@@ -1160,7 +1250,7 @@ export function createWebApp(options) {
|
|
|
1160
1250
|
return renderTerminalStatus(status, pathOf(req), headers);
|
|
1161
1251
|
};
|
|
1162
1252
|
for (const route of manifest.routes) {
|
|
1163
|
-
// A dynamic route whose `getStaticPaths` declared `fallback: "404"`
|
|
1253
|
+
// A dynamic route whose `getStaticPaths` declared `fallback: "404"` - only its prerendered paths
|
|
1164
1254
|
// exist; anything else 404s (computed once per route, not per request).
|
|
1165
1255
|
const is404Fallback = options.staticFallbacks?.[route.pattern] === "404";
|
|
1166
1256
|
app.register("GET", route.pattern, undefined, async (c) => {
|
|
@@ -1184,6 +1274,7 @@ export function createWebApp(options) {
|
|
|
1184
1274
|
api,
|
|
1185
1275
|
env: c.env,
|
|
1186
1276
|
draft,
|
|
1277
|
+
search: loaderSearch(mod.searchSchema, c.req),
|
|
1187
1278
|
};
|
|
1188
1279
|
// Layout loaders run for BOTH the document and the data-only request. A gate that only ran on
|
|
1189
1280
|
// the document path would be bypassed by adding the data header, which is exactly the request
|
|
@@ -1195,8 +1286,12 @@ export function createWebApp(options) {
|
|
|
1195
1286
|
const run = await runLayoutChain(route, ctx, validatedRetainedIndices(c.req, route, c.params));
|
|
1196
1287
|
layoutModules = run.modules;
|
|
1197
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`.
|
|
1198
1291
|
const [pageData] = await Promise.all([
|
|
1199
|
-
mod.loader
|
|
1292
|
+
mod.loader
|
|
1293
|
+
? mod.loader({ ...ctx, search: searchChainOf(run.modules, mod, c.req) })
|
|
1294
|
+
: null,
|
|
1200
1295
|
run.pending, // a non-gate layout loader that rejects must still surface, not go unhandled
|
|
1201
1296
|
]);
|
|
1202
1297
|
data = pageData;
|
|
@@ -1209,7 +1304,7 @@ export function createWebApp(options) {
|
|
|
1209
1304
|
if (isStatusSignal(err))
|
|
1210
1305
|
return renderStatusSignal(c.req, err);
|
|
1211
1306
|
// A thrown `Response` is a control-flow signal (a guard's `redirect(...)`, an explicit error
|
|
1212
|
-
// response)
|
|
1307
|
+
// response) - let it propagate to core, which returns it as-is. Real errors render the nearest
|
|
1213
1308
|
// `_error` boundary, if any; with none, rethrow (unchanged 500 behavior).
|
|
1214
1309
|
if (err instanceof Response)
|
|
1215
1310
|
throw err;
|
|
@@ -1225,7 +1320,7 @@ export function createWebApp(options) {
|
|
|
1225
1320
|
const errorId = boundaryFor(route, err);
|
|
1226
1321
|
if (errorId === undefined)
|
|
1227
1322
|
throw err;
|
|
1228
|
-
// 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
|
|
1229
1324
|
// navigation, which lands here as a document and renders the `_error` page.
|
|
1230
1325
|
if (c.req.headers.get(DATA_HEADER) !== null) {
|
|
1231
1326
|
return new Response("Internal Server Error", {
|
|
@@ -1235,10 +1330,10 @@ export function createWebApp(options) {
|
|
|
1235
1330
|
}
|
|
1236
1331
|
return renderError(route, errorId, withDuplicateInstanceHint(err));
|
|
1237
1332
|
}
|
|
1238
|
-
// 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
|
|
1239
1334
|
// document, no layout chain. A route with deferred data streams NDJSON (critical data first,
|
|
1240
1335
|
// then each deferred value as it settles); otherwise one JSON (the fast path). Same loader,
|
|
1241
|
-
// same auth
|
|
1336
|
+
// same auth - only the transport differs.
|
|
1242
1337
|
if (c.req.headers.get(DATA_HEADER) !== null) {
|
|
1243
1338
|
const pageSplit = prepareDeferred(data);
|
|
1244
1339
|
const layoutSplits = [];
|
|
@@ -1257,7 +1352,7 @@ export function createWebApp(options) {
|
|
|
1257
1352
|
layoutData: layoutSplits.map((split) => split.forClient),
|
|
1258
1353
|
retained: layoutRetained,
|
|
1259
1354
|
};
|
|
1260
|
-
// Bare value when there is no layout data
|
|
1355
|
+
// Bare value when there is no layout data - the pre-envelope shape, so a client running older
|
|
1261
1356
|
// code against a newer server keeps working.
|
|
1262
1357
|
if (deferred.length === 0) {
|
|
1263
1358
|
return Response.json(payload ?? null);
|
|
@@ -1274,7 +1369,7 @@ export function createWebApp(options) {
|
|
|
1274
1369
|
const hydrateRoute = mod.hydrate !== false;
|
|
1275
1370
|
try {
|
|
1276
1371
|
// `await` so a shell-render throw (renderToStream rejects before any byte) is caught here and
|
|
1277
|
-
// can render the `_error` page
|
|
1372
|
+
// can render the `_error` page - not just a loader throw. Mid-stream (post-shell) throws can't
|
|
1278
1373
|
// be recovered to a full page; the per-adapter client boundary catches client render errors.
|
|
1279
1374
|
return await renderPageResult({
|
|
1280
1375
|
adapter,
|
|
@@ -1285,6 +1380,10 @@ export function createWebApp(options) {
|
|
|
1285
1380
|
routeId: route.id,
|
|
1286
1381
|
params: c.params,
|
|
1287
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),
|
|
1288
1387
|
hydrate: hydrateRoute,
|
|
1289
1388
|
...(layoutData !== undefined ? { layoutData } : {}),
|
|
1290
1389
|
...preloadOf(route.id),
|
|
@@ -1296,7 +1395,7 @@ export function createWebApp(options) {
|
|
|
1296
1395
|
});
|
|
1297
1396
|
}
|
|
1298
1397
|
catch (err) {
|
|
1299
|
-
// Same precedence as the loader catch above
|
|
1398
|
+
// Same precedence as the loader catch above - a `meta()` may signal too.
|
|
1300
1399
|
if (isStatusSignal(err))
|
|
1301
1400
|
return renderStatusSignal(c.req, err);
|
|
1302
1401
|
if (err instanceof Response)
|
|
@@ -1318,7 +1417,7 @@ export function createWebApp(options) {
|
|
|
1318
1417
|
});
|
|
1319
1418
|
// POST runs the route's `action` (mutation). A `Response` return (e.g. a `redirect(...)`)
|
|
1320
1419
|
// passes straight through; a data return re-renders the page (the loader re-runs for fresh
|
|
1321
|
-
// 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.
|
|
1322
1421
|
app.register("POST", route.pattern, undefined, async (c) => {
|
|
1323
1422
|
const mod = await route.load();
|
|
1324
1423
|
const draft = await draftFlag(c.req);
|
|
@@ -1335,6 +1434,7 @@ export function createWebApp(options) {
|
|
|
1335
1434
|
api,
|
|
1336
1435
|
env: c.env,
|
|
1337
1436
|
draft,
|
|
1437
|
+
search: loaderSearch(mod.searchSchema, c.req),
|
|
1338
1438
|
};
|
|
1339
1439
|
// A gate is an authorization boundary for everything beneath its layout, mutations included.
|
|
1340
1440
|
// Run only gates before the action; ordinary layout data loaders run after a native mutation.
|
|
@@ -1343,7 +1443,7 @@ export function createWebApp(options) {
|
|
|
1343
1443
|
const isDataRequest = c.req.headers.get(DATA_HEADER) !== null;
|
|
1344
1444
|
// An action may wrap its data in `revalidate(paths, data)` to declare which routes it changed.
|
|
1345
1445
|
// Unwrap to the inner data; the paths ride the `X-Nifra-Revalidate` header on the data-mode
|
|
1346
|
-
// responses (the client acts on them
|
|
1446
|
+
// responses (the client acts on them - a full-page POST re-runs loaders inline, so no header).
|
|
1347
1447
|
const isRevalidate = result !== null && typeof result === "object" && "__nifraRevalidate" in result;
|
|
1348
1448
|
const actionResult = isRevalidate ? result.data : result;
|
|
1349
1449
|
const revalidateHeader = isRevalidate
|
|
@@ -1361,7 +1461,7 @@ export function createWebApp(options) {
|
|
|
1361
1461
|
// Client submit wants just the action's data (it revalidates the loader itself); a native
|
|
1362
1462
|
// form POST re-renders the full page (loader re-runs) with the action data.
|
|
1363
1463
|
if (isDataRequest) {
|
|
1364
|
-
// 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
|
|
1365
1465
|
// each deferred as it settles) exactly like a loader; a non-deferred action returns one JSON.
|
|
1366
1466
|
const { forClient, deferred } = prepareDeferred(actionResult);
|
|
1367
1467
|
if (deferred.length === 0)
|
|
@@ -1370,8 +1470,20 @@ export function createWebApp(options) {
|
|
|
1370
1470
|
headers: { "content-type": "application/x-ndjson; charset=utf-8", ...revalidateHeader },
|
|
1371
1471
|
});
|
|
1372
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);
|
|
1373
1477
|
const data = mod.loader
|
|
1374
|
-
? 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
|
+
})
|
|
1375
1487
|
: null;
|
|
1376
1488
|
const { chain, head } = resolveChainAndHead(layoutModules, mod, {
|
|
1377
1489
|
data,
|
|
@@ -1379,7 +1491,7 @@ export function createWebApp(options) {
|
|
|
1379
1491
|
origin: originOf(c.req),
|
|
1380
1492
|
});
|
|
1381
1493
|
const hydrateRoute = mod.hydrate !== false;
|
|
1382
|
-
// 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 -
|
|
1383
1495
|
// `renderPage` splits `actionData` like loader data (works with JS off; hydrates after).
|
|
1384
1496
|
return renderPageResult({
|
|
1385
1497
|
adapter,
|
|
@@ -1391,6 +1503,7 @@ export function createWebApp(options) {
|
|
|
1391
1503
|
routeId: route.id,
|
|
1392
1504
|
params: c.params,
|
|
1393
1505
|
path: pathOf(c.req),
|
|
1506
|
+
search,
|
|
1394
1507
|
hydrate: hydrateRoute,
|
|
1395
1508
|
...preloadOf(route.id),
|
|
1396
1509
|
...stylesOf(route.id),
|
|
@@ -1431,7 +1544,7 @@ export function generateClientEntry(manifest, options) {
|
|
|
1431
1544
|
const loaderRows = [];
|
|
1432
1545
|
const patternRows = [];
|
|
1433
1546
|
const statusRoutes = {};
|
|
1434
|
-
// Routes whose loader appends a nearest `_error` module (LAST)
|
|
1547
|
+
// Routes whose loader appends a nearest `_error` module (LAST) - the client wraps the page in the
|
|
1435
1548
|
// adapter's `errorBoundary(fallback)` for these, so a client render error shows the `_error` UI.
|
|
1436
1549
|
const errorRouteIds = [];
|
|
1437
1550
|
// Lazy loader returns the raw modules (for both the component chain + the page's `meta` export).
|
|
@@ -1468,7 +1581,7 @@ export function generateClientEntry(manifest, options) {
|
|
|
1468
1581
|
'import { createClientRouter, createMatcher, mergeHeads, resolveMeta } from "@nifrajs/web"',
|
|
1469
1582
|
'import { applyHead, installForms, installHistory, signalHydrated } from "@nifrajs/web/client"',
|
|
1470
1583
|
// Namespace import: `errorBoundary` is optional (an adapter may not export it). A namespace member
|
|
1471
|
-
// access yields `undefined` if absent
|
|
1584
|
+
// access yields `undefined` if absent - unlike a named import, which would be a link error.
|
|
1472
1585
|
`import * as __adapter from ${JSON.stringify(clientModule)}`,
|
|
1473
1586
|
"const { mountRouter } = __adapter",
|
|
1474
1587
|
// The `clientModule` contract is a string specifier, so nothing type-checks that the module it
|
|
@@ -1476,7 +1589,7 @@ export function generateClientEntry(manifest, options) {
|
|
|
1476
1589
|
// "mountRouter is not a function" from inside a bundled chunk, at first paint, naming neither the
|
|
1477
1590
|
// module nor the requirement - a self-executing entry passes every build and fails only here.
|
|
1478
1591
|
`if (typeof mountRouter !== "function") throw new Error(${JSON.stringify(`[nifra/web] clientModule ${JSON.stringify(clientModule)} does not export \`mountRouter\`. ` +
|
|
1479
|
-
"A client module must export `mountRouter({ router, routes, container })`
|
|
1592
|
+
"A client module must export `mountRouter({ router, routes, container })` - it is called by " +
|
|
1480
1593
|
"the generated bootstrap, so a self-executing entry will not work. Use your adapter's " +
|
|
1481
1594
|
'`/client` entry (e.g. "@nifrajs/web-react/client"), or re-export mountRouter from it.')})`,
|
|
1482
1595
|
"const errorBoundary = __adapter.errorBoundary",
|
|
@@ -1489,6 +1602,12 @@ export function generateClientEntry(manifest, options) {
|
|
|
1489
1602
|
"}",
|
|
1490
1603
|
"const chains = {}",
|
|
1491
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 = {}",
|
|
1492
1611
|
"const loadModule = async (id) => {",
|
|
1493
1612
|
" if (chains[id]) return",
|
|
1494
1613
|
" const mods = await loaders[id]()",
|
|
@@ -1497,7 +1616,7 @@ export function generateClientEntry(manifest, options) {
|
|
|
1497
1616
|
// hydrated tree matches the SSR markup (which has no boundary). Falls back to the plain chain when
|
|
1498
1617
|
// the adapter has no `errorBoundary`.
|
|
1499
1618
|
// `metas[id]` is the chain's `meta` exports in head order (outermost layout → … → page), so a
|
|
1500
|
-
// 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
|
|
1501
1620
|
// layout tags persist across client navigation, no flash of page-only head). `_error` carries no
|
|
1502
1621
|
// head (a terminal boundary), so it's excluded from the meta list for error routes.
|
|
1503
1622
|
" if (errorBoundary && errorRouteIds.has(id)) {",
|
|
@@ -1506,9 +1625,16 @@ export function generateClientEntry(manifest, options) {
|
|
|
1506
1625
|
" const layouts = mods.slice(0, mods.length - 2).map((m) => m.default)",
|
|
1507
1626
|
" chains[id] = [...layouts, errorBoundary(fallback), page]",
|
|
1508
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 ?? []",
|
|
1509
1632
|
" } else {",
|
|
1510
1633
|
" chains[id] = mods.map((m) => m.default)",
|
|
1511
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 ?? []",
|
|
1512
1638
|
" }",
|
|
1513
1639
|
"}",
|
|
1514
1640
|
"const patterns = [",
|
|
@@ -1537,23 +1663,34 @@ export function generateClientEntry(manifest, options) {
|
|
|
1537
1663
|
" pending: false,",
|
|
1538
1664
|
"}",
|
|
1539
1665
|
`const statusRoutes = ${JSON.stringify(statusRoutes)}`,
|
|
1540
|
-
"const router = createClientRouter({ patterns, initial, loadModule, statusRoutes })",
|
|
1666
|
+
"const router = createClientRouter({ patterns, initial, loadModule, statusRoutes, searchClientKeys })",
|
|
1541
1667
|
"installHistory(router)",
|
|
1542
1668
|
"installForms(router)",
|
|
1543
|
-
|
|
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.')})`,
|
|
1544
1681
|
// Load the initial route's chunk, then hydrate the Router (chain is cached). The initial head
|
|
1545
1682
|
// is server-rendered; subsequent navigations update it from the matched route's meta + data.
|
|
1546
|
-
"
|
|
1547
|
-
" mountRouter({ router, routes: chains, container: root })",
|
|
1683
|
+
"loadModule(initial.routeId).then(() => {",
|
|
1684
|
+
" mountRouter({ router, routes: chains, searchSchemas, container: root })",
|
|
1548
1685
|
// Next frame: the adapter has committed its initial hydration, so handlers are attached. Flip the
|
|
1549
1686
|
// document to interactive (`data-nifra-hydrated` + `nifra:hydrated`) for apps to gate pre-hydration UI.
|
|
1550
1687
|
" requestAnimationFrame(signalHydrated)",
|
|
1551
1688
|
" router.subscribe(() => {",
|
|
1552
1689
|
" const s = router.snapshot()",
|
|
1553
|
-
// 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.
|
|
1554
1691
|
" if (!s.pending) {",
|
|
1555
1692
|
// `origin: location.origin` matches the SSR `originOf(req)` (both are `URL.origin` for the same
|
|
1556
|
-
// 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.
|
|
1557
1694
|
" const args = { data: s.data, params: s.params, origin: location.origin }",
|
|
1558
1695
|
" applyHead(mergeHeads((metas[s.routeId] ?? [undefined]).map((m) => resolveMeta(m, args))))",
|
|
1559
1696
|
" }",
|
|
@@ -1561,12 +1698,42 @@ export function generateClientEntry(manifest, options) {
|
|
|
1561
1698
|
"})",
|
|
1562
1699
|
].join("\n")}\n`;
|
|
1563
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
|
+
}
|
|
1564
1731
|
/**
|
|
1565
1732
|
* Codegen: emit a **server manifest** module (as source) for disk-less edge runtimes (Cloudflare
|
|
1566
|
-
* Workers, …)
|
|
1567
|
-
* and dynamic-imports each route by a *runtime* path
|
|
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
|
|
1568
1735
|
* **statically-analyzable** imports of every route/layout/`_error`/terminal status page (so the bundler includes them) and
|
|
1569
|
-
* rebuilds the manifest with `buildManifest`
|
|
1736
|
+
* rebuilds the manifest with `buildManifest` - the SAME pure logic `discoverRoutes` feeds, so patterns
|
|
1570
1737
|
* + layout chains are identical. Eager (`import * as`) by default; `lazy` emits `() => import(...)` so
|
|
1571
1738
|
* a code-splitting bundler chunks per route. The emitted module exports `manifest` (consumed by
|
|
1572
1739
|
* `createWebApp`, unchanged) + `clientEntry` (baked). Write it to a file and bundle it into the worker
|
|
@@ -1585,7 +1752,7 @@ export function generateServerManifest(manifest, options) {
|
|
|
1585
1752
|
]),
|
|
1586
1753
|
].sort();
|
|
1587
1754
|
const header = [
|
|
1588
|
-
"// GENERATED by @nifrajs/web generateServerManifest
|
|
1755
|
+
"// GENERATED by @nifrajs/web generateServerManifest - route manifest for the disk-less edge",
|
|
1589
1756
|
"// (no filesystem; route imports are static specifiers the bundler resolves). buildManifest is",
|
|
1590
1757
|
"// the same pure logic discoverRoutes feeds, so patterns + layout chains match exactly.",
|
|
1591
1758
|
'import { buildManifest } from "@nifrajs/web"',
|
|
@@ -1627,4 +1794,15 @@ export function generateServerManifest(manifest, options) {
|
|
|
1627
1794
|
"export const manifest = buildManifest(Object.keys(modules), (file) => () => Promise.resolve(modules[file]))",
|
|
1628
1795
|
].join("\n")}\n`;
|
|
1629
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";
|
|
1630
1808
|
//# sourceMappingURL=index.js.map
|