@nifrajs/web 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-plan.d.ts +228 -0
- package/dist/build-plan.d.ts.map +1 -0
- package/dist/build-plan.js +514 -0
- package/dist/build-plan.js.map +1 -0
- package/dist/build-vite.d.ts +2 -1
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +2 -197
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +61 -502
- package/dist/build.js.map +1 -1
- package/dist/chunk-names.d.ts.map +1 -1
- package/dist/chunk-names.js +3 -2
- package/dist/chunk-names.js.map +1 -1
- package/dist/dev-ssr-graph.d.ts.map +1 -1
- package/dist/dev-ssr-graph.js +63 -28
- package/dist/dev-ssr-graph.js.map +1 -1
- package/dist/diagnostic.d.ts.map +1 -1
- package/dist/diagnostic.js +9 -2
- package/dist/diagnostic.js.map +1 -1
- package/dist/fonts-google.d.ts.map +1 -1
- package/dist/fonts-google.js +3 -1
- package/dist/fonts-google.js.map +1 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +2 -1
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +15 -609
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -1935
- package/dist/index.js.map +1 -1
- package/dist/internal/codegen.d.ts +68 -0
- package/dist/internal/codegen.d.ts.map +1 -0
- package/dist/internal/codegen.js +317 -0
- package/dist/internal/codegen.js.map +1 -0
- package/dist/internal/head-merge.d.ts +2 -0
- package/dist/internal/head-merge.d.ts.map +1 -1
- package/dist/internal/head-merge.js +17 -1
- package/dist/internal/head-merge.js.map +1 -1
- package/dist/internal/parity.d.ts +7 -0
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +212 -30
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/render-document.d.ts +372 -0
- package/dist/internal/render-document.d.ts.map +1 -0
- package/dist/internal/render-document.js +744 -0
- package/dist/internal/render-document.js.map +1 -0
- package/dist/internal/runtime-contract.d.ts +48 -0
- package/dist/internal/runtime-contract.d.ts.map +1 -0
- package/dist/internal/runtime-contract.js +27 -0
- package/dist/internal/runtime-contract.js.map +1 -0
- package/dist/internal/web-app.d.ts +171 -0
- package/dist/internal/web-app.d.ts.map +1 -0
- package/dist/internal/web-app.js +844 -0
- package/dist/internal/web-app.js.map +1 -0
- package/dist/islands.d.ts +48 -0
- package/dist/islands.d.ts.map +1 -1
- package/dist/islands.js +79 -0
- package/dist/islands.js.map +1 -1
- package/dist/module-graph.d.ts.map +1 -1
- package/dist/module-graph.js +22 -9
- package/dist/module-graph.js.map +1 -1
- package/dist/nano.d.ts +125 -0
- package/dist/nano.d.ts.map +1 -0
- package/dist/nano.js +190 -0
- package/dist/nano.js.map +1 -0
- package/dist/plugins/css-modules.js +2 -2
- package/dist/plugins/css-modules.js.map +1 -1
- package/dist/plugins/kit.d.ts +12 -0
- package/dist/plugins/kit.d.ts.map +1 -1
- package/dist/plugins/kit.js +62 -12
- package/dist/plugins/kit.js.map +1 -1
- package/dist/plugins/postcss.js +2 -2
- package/dist/plugins/postcss.js.map +1 -1
- package/dist/plugins/scss.js +2 -2
- package/dist/plugins/scss.js.map +1 -1
- package/dist/plugins/stylex.d.ts +70 -0
- package/dist/plugins/stylex.d.ts.map +1 -0
- package/dist/plugins/stylex.js +166 -0
- package/dist/plugins/stylex.js.map +1 -0
- package/dist/plugins/svg.js +2 -2
- package/dist/plugins/svg.js.map +1 -1
- package/dist/router.d.ts +1 -1
- package/dist/router.js +23 -23
- package/dist/router.js.map +1 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +26 -10
- package/dist/vite.js.map +1 -1
- package/package.json +39 -3
- package/src/build-plan.ts +741 -0
- package/src/build-vite.ts +1 -4
- package/src/build.ts +84 -716
- package/src/chunk-names.ts +3 -2
- package/src/dev-ssr-graph.ts +63 -28
- package/src/diagnostic.ts +14 -2
- package/src/fonts-google.ts +3 -1
- package/src/fs.ts +2 -1
- package/src/index.ts +73 -2705
- package/src/internal/codegen.ts +400 -0
- package/src/internal/head-merge.ts +19 -1
- package/src/internal/parity.ts +215 -35
- package/src/internal/render-document.ts +1116 -0
- package/src/internal/runtime-contract.ts +54 -0
- package/src/internal/web-app.ts +1114 -0
- package/src/islands.ts +85 -0
- package/src/module-graph.ts +23 -9
- package/src/nano.ts +246 -0
- package/src/plugins/css-modules.ts +2 -2
- package/src/plugins/kit.ts +65 -12
- package/src/plugins/postcss.ts +2 -2
- package/src/plugins/scss.ts +2 -2
- package/src/plugins/stylex.ts +298 -0
- package/src/plugins/svg.ts +2 -2
- package/src/router.ts +24 -24
- package/src/vite.ts +33 -9
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
import { isSameOriginPath, status as statusResult } from "@nifrajs/core/server";
|
|
2
|
+
import { DEFERRED_ERROR_CODE, DEFERRED_RUNTIME, prepareDeferred } from "../deferred.js";
|
|
3
|
+
import { ISR_REVALIDATE_HEADER, ISR_REVALIDATE_TAGS_HEADER, serializeISRTags } from "../isr.js";
|
|
4
|
+
import { ACTION_GLOBAL, BOUNDARY_GLOBAL, DATA_GLOBAL, LAYOUT_DATA_GLOBAL, ROOT_ATTRIBUTE, ROUTE_GLOBAL, } from "../render-seam.js";
|
|
5
|
+
import { PRERENDERED_GLOBAL, REDIRECT_HEADER } from "../router.js";
|
|
6
|
+
import { trustedHeadAttributes } from "./head-attributes.js";
|
|
7
|
+
import { isStaticMeta, mergeHeads } from "./head-merge.js";
|
|
8
|
+
import { PRE_HYDRATION_GUARD } from "./runtime-contract.js";
|
|
9
|
+
import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./script-types.js";
|
|
10
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
11
|
+
// XSS-safe `<script>` JSON escaping in a SINGLE pass: `<`/`>` would break out of the
|
|
12
|
+
// script element; U+2028/U+2029 are valid JSON but historically break JS string literals. Built via
|
|
13
|
+
// fromCharCode so no raw separator chars live in this source. One regex + one output string replaces
|
|
14
|
+
// the prior four sequential `replaceAll` full-string passes.
|
|
15
|
+
const LINE_SEP = String.fromCharCode(0x2028);
|
|
16
|
+
const PARA_SEP = String.fromCharCode(0x2029);
|
|
17
|
+
const SCRIPT_ESCAPE = new RegExp(`[<>${LINE_SEP}${PARA_SEP}]`, "g");
|
|
18
|
+
const SCRIPT_ESCAPE_MAP = {
|
|
19
|
+
"<": "\\u003c",
|
|
20
|
+
">": "\\u003e",
|
|
21
|
+
[LINE_SEP]: "\\u2028",
|
|
22
|
+
[PARA_SEP]: "\\u2029",
|
|
23
|
+
};
|
|
24
|
+
const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body");
|
|
25
|
+
const RESPONSE_RESULT = Symbol.for("nifra.response.result");
|
|
26
|
+
/**
|
|
27
|
+
* A control-flow value that renders as plain data - what `redirect()` returns, and what core's
|
|
28
|
+
* `status(...)` returns. Recognized here rather than imported: core keeps its own predicate internal,
|
|
29
|
+
* and the registry symbol is the contract between them (two copies of core must still agree).
|
|
30
|
+
*/
|
|
31
|
+
const isResponseResult = (value) => typeof value === "object" &&
|
|
32
|
+
value !== null &&
|
|
33
|
+
value[RESPONSE_RESULT] === true &&
|
|
34
|
+
typeof value.toResponse === "function";
|
|
35
|
+
/** A returned/thrown value that ends the request as-is: either shape of control-flow signal. */
|
|
36
|
+
export const isControlFlow = (value) => value instanceof Response || isResponseResult(value);
|
|
37
|
+
/**
|
|
38
|
+
* Server: render a full HTML document for a page - the adapter's hydration head + the SSR
|
|
39
|
+
* markup (**streamed**) + the serialized loader data + the client module - as a `Response`.
|
|
40
|
+
* The shell (`<head>` + the open container) flushes first, the adapter's app stream follows,
|
|
41
|
+
* then the tail (data globals + client entry). Pure Web Standards, so it returns straight from
|
|
42
|
+
* a nifra route handler and streams on any fetch runtime (Bun/Node/Deno/Workers).
|
|
43
|
+
*/
|
|
44
|
+
export function renderPage(options) {
|
|
45
|
+
const page = renderPageResult(options);
|
|
46
|
+
return page instanceof Promise ? page.then((p) => p.toResponse()) : page.toResponse();
|
|
47
|
+
}
|
|
48
|
+
export function renderPageResult(options) {
|
|
49
|
+
const { adapter, chain, data, actionData, clientEntry, preload = [], styles = [], prerenderedPaths = [], revalidate, revalidateTags = [], routeId, status = 200, title = "nifra", head, rootId = "root", hydrate = true, islandScripts = [], nonce, boundaries, headers: extraHeaders, } = options;
|
|
50
|
+
if (nonce !== undefined && nonce.trim() === "") {
|
|
51
|
+
throw new TypeError("[nifra/web] renderPage nonce must be non-empty when provided");
|
|
52
|
+
}
|
|
53
|
+
const nonceAttr = nonce === undefined ? "" : ` nonce="${escapeAttr(nonce)}"`;
|
|
54
|
+
const route = routeId === undefined ? "" : `window.${ROUTE_GLOBAL}=${serializeData(routeId)};`;
|
|
55
|
+
// The SSG prerendered-path set (when an app declares it) - the client reads it to fetch a static
|
|
56
|
+
// `_data.json` on soft-nav into a prerendered route instead of hitting the worker. Empty ⇒ omitted.
|
|
57
|
+
const prerendered = prerenderedPaths.length === 0
|
|
58
|
+
? ""
|
|
59
|
+
: `window.${PRERENDERED_GLOBAL}=${serializeData(prerenderedPaths)};`;
|
|
60
|
+
// Split deferred values: the component sees markers (id + promise) to `<Await>`; the serialized
|
|
61
|
+
// data carries `{__nifra_deferred: id}` placeholders (promises don't serialize). `actionData` may
|
|
62
|
+
// also `defer()` - split it too, continuing the id space so a single registry settles both. The
|
|
63
|
+
// inline registry runtime is emitted only when something defers, so non-deferred output is unchanged.
|
|
64
|
+
const { forComponent, forClient, deferred } = prepareDeferred(data);
|
|
65
|
+
// Each layout's data is split in turn, each continuing the id space, so a `defer()` in a layout
|
|
66
|
+
// settles through the SAME registry as the page's rather than colliding on ids.
|
|
67
|
+
const layoutSplits = [];
|
|
68
|
+
if (options.layoutData !== undefined) {
|
|
69
|
+
let offset = deferred.length;
|
|
70
|
+
for (const entry of options.layoutData) {
|
|
71
|
+
const split = prepareDeferred(entry, offset);
|
|
72
|
+
offset += split.deferred.length;
|
|
73
|
+
layoutSplits.push(split);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const layoutDeferred = layoutSplits.flatMap((split) => split.deferred);
|
|
77
|
+
const actionSplit = actionData === undefined
|
|
78
|
+
? undefined
|
|
79
|
+
: prepareDeferred(actionData, deferred.length + layoutDeferred.length);
|
|
80
|
+
const boundarySplit = boundaries === undefined
|
|
81
|
+
? undefined
|
|
82
|
+
: prepareDeferred(boundaries, deferred.length + layoutDeferred.length + (actionSplit?.deferred.length ?? 0));
|
|
83
|
+
// On the common page-only path (no layout loader, no action) reuse `deferred` directly instead of
|
|
84
|
+
// allocating a fresh spread array every request.
|
|
85
|
+
const allDeferred = layoutDeferred.length === 0 && actionSplit === undefined && boundarySplit === undefined
|
|
86
|
+
? deferred
|
|
87
|
+
: [
|
|
88
|
+
...deferred,
|
|
89
|
+
...layoutDeferred,
|
|
90
|
+
...(actionSplit ? actionSplit.deferred : []),
|
|
91
|
+
...(boundarySplit ? boundarySplit.deferred : []),
|
|
92
|
+
];
|
|
93
|
+
// Omitted entirely when no layout has a loader - a page-only app emits exactly what it did before.
|
|
94
|
+
const layoutTail = options.layoutData === undefined
|
|
95
|
+
? ""
|
|
96
|
+
: `window.${LAYOUT_DATA_GLOBAL}=${serializeData(layoutSplits.map((split) => split.forClient))};`;
|
|
97
|
+
// Only emit the action global when an action actually ran, so plain GET output is unchanged.
|
|
98
|
+
const action = actionSplit === undefined
|
|
99
|
+
? ""
|
|
100
|
+
: `window.${ACTION_GLOBAL}=${serializeData(actionSplit.forClient)};`;
|
|
101
|
+
const boundaryTail = boundarySplit === undefined
|
|
102
|
+
? ""
|
|
103
|
+
: `window.${BOUNDARY_GLOBAL}=${serializeData(boundarySplit.forClient)};`;
|
|
104
|
+
const deferredRuntime = allDeferred.length > 0 ? `<script${nonceAttr}>${DEFERRED_RUNTIME}</script>` : "";
|
|
105
|
+
// The regex only injects the CSP nonce into the (constant) hydration head; with no nonce it's a
|
|
106
|
+
// no-op that still scans the whole script every request. Skip it on the common no-nonce path.
|
|
107
|
+
// The request-invariant document pieces. With a caller-supplied per-route cache (and no per-request
|
|
108
|
+
// nonce, which would bake into them) they're built once and reused; the per-request seams - the
|
|
109
|
+
// deferred runtime in the shell, the action/layout-data globals and the serialized loader data in
|
|
110
|
+
// the tail - are always assembled fresh. Byte-identical to building the whole document inline.
|
|
111
|
+
const slot = nonce === undefined && options.assemblyCache !== undefined ? options.assemblyCache : {};
|
|
112
|
+
if (slot.shellPre === undefined) {
|
|
113
|
+
// Matched-route chunk preloads, concatenated directly. De-duped against the entry, which is
|
|
114
|
+
// preloaded separately below.
|
|
115
|
+
let preloadLinks = "";
|
|
116
|
+
for (const url of preload) {
|
|
117
|
+
if (url !== clientEntry)
|
|
118
|
+
preloadLinks += `<link rel="modulepreload" href="${escapeAttr(url)}">`;
|
|
119
|
+
}
|
|
120
|
+
// The matched route's stylesheets - `<link rel="stylesheet">` in `<head>` so CSS arrives with the
|
|
121
|
+
// first paint (no FOUC). Render-blocking by design, and emitted regardless of `hydrate` (a static
|
|
122
|
+
// or `_error` page still wants its styles). In dev (Vite) CSS is injected client-side instead.
|
|
123
|
+
let styleLinks = "";
|
|
124
|
+
for (const url of styles)
|
|
125
|
+
styleLinks += `<link rel="stylesheet" href="${escapeAttr(url)}">`;
|
|
126
|
+
// Island bundles are referenced only by a `<script type="module">` at the END of `<body>`, so the
|
|
127
|
+
// browser doesn't discover them until the whole page is parsed. `modulepreload` them in `<head>`
|
|
128
|
+
// so the fetch starts immediately, in parallel with parsing - regardless of `hydrate`.
|
|
129
|
+
let islandPreloads = "";
|
|
130
|
+
for (const src of islandScripts)
|
|
131
|
+
islandPreloads += `<link rel="modulepreload" href="${escapeAttr(src)}">`;
|
|
132
|
+
// Runs in the first-paint→hydration window to swallow a JS-only form's broken native submit. Only
|
|
133
|
+
// on a hydrating page (a static/_error page has no client handlers, so no footgun).
|
|
134
|
+
const hydrationGuard = hydrate ? `<script${nonceAttr}>${PRE_HYDRATION_GUARD}</script>` : "";
|
|
135
|
+
// `<html>` attributes. `lang` defaults to `"en"`; `dir` is omitted entirely when unset, which IS
|
|
136
|
+
// HTML's `ltr` default. Both attribute-escaped. `client.ts`'s `applyHead` mirrors this exact
|
|
137
|
+
// defaulting on soft-nav, so a hard load and a client navigation produce the same `<html>`.
|
|
138
|
+
const htmlAttrs = ` lang="${escapeAttr(head?.lang ?? "en")}"${head?.dir === undefined ? "" : ` dir="${escapeAttr(head.dir)}"`}`;
|
|
139
|
+
// Marks the container for the client entry when the id is not the one the entry falls back to.
|
|
140
|
+
const rootMarker = rootId === "root" ? "" : ` ${ROOT_ATTRIBUTE}`;
|
|
141
|
+
// A non-hydrated page omits the adapter's hydration bootstrap entirely (Solid's `_$HY` registry
|
|
142
|
+
// script, etc.) - there is no client takeover to feed, so it's dead bytes on a static document.
|
|
143
|
+
// The nonce rewrite only runs when a nonce is set; with none it was a whole-string no-op scan.
|
|
144
|
+
const rawHydrationHead = hydrate ? adapter.hydrationHead(nonce) : "";
|
|
145
|
+
const hydrationHead = nonce === undefined
|
|
146
|
+
? rawHydrationHead
|
|
147
|
+
: rawHydrationHead.replace(/<script(?![^>]*\bnonce=)(?=[\s>])/g, `<script${nonceAttr}`);
|
|
148
|
+
// Shell up to the deferred-runtime seam: on a hydrating page that seam sits after the entry +
|
|
149
|
+
// route-chunk preloads; a non-hydrating page has no runtime insertion at all.
|
|
150
|
+
const entryPreloads = hydrate
|
|
151
|
+
? `<link rel="modulepreload" href="${escapeAttr(clientEntry ?? "")}">${preloadLinks}`
|
|
152
|
+
: "";
|
|
153
|
+
slot.shellPre = `<!doctype html><html${htmlAttrs}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${hydrationGuard}<title>${escapeHtml(head?.title ?? title)}</title>${headTags(head)}${styleLinks}${entryPreloads}`;
|
|
154
|
+
slot.shellPost = `${islandPreloads}${hydrationHead}</head><body><div id="${escapeAttr(rootId)}"${rootMarker}>`;
|
|
155
|
+
// Island bundles load regardless of `hydrate` - a static page (hydrate:false) ships no framework
|
|
156
|
+
// client but can still mount no-framework islands (`@nifrajs/web/islands`).
|
|
157
|
+
let islandTags = "";
|
|
158
|
+
for (const src of islandScripts)
|
|
159
|
+
islandTags += `<script type="module" src="${escapeAttr(src)}"${nonceAttr}></script>`;
|
|
160
|
+
if (hydrate) {
|
|
161
|
+
slot.tailPre = `<script${nonceAttr}>${route}`;
|
|
162
|
+
slot.tailMid = prerendered;
|
|
163
|
+
slot.tailData = `window.${DATA_GLOBAL}=`;
|
|
164
|
+
slot.tailPost = `</script><script type="module" src="${escapeAttr(clientEntry ?? "")}"${nonceAttr}></script>${islandTags}</body></html>`;
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
slot.tailPre = "";
|
|
168
|
+
slot.tailMid = "";
|
|
169
|
+
slot.tailData = "";
|
|
170
|
+
slot.tailPost = `${islandTags}</body></html>`;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// The deferred runtime rides only on a hydrating page (a static page has no client takeover), and
|
|
174
|
+
// only when something actually deferred - matching the pre-cache emission exactly.
|
|
175
|
+
const runtimeSeam = hydrate && allDeferred.length > 0 ? deferredRuntime : "";
|
|
176
|
+
const shellHtml = `${slot.shellPre}${runtimeSeam}${slot.shellPost}`;
|
|
177
|
+
// Closes the hydration container; deferred resolve scripts go AFTER it (outside `#root`) so they
|
|
178
|
+
// aren't part of the adapter's hydrated tree (an inline script inside it breaks hydration).
|
|
179
|
+
const closeRootHtml = "</div>";
|
|
180
|
+
// Tail - the loader-data globals + the client module. Module scripts defer (run after parse), so
|
|
181
|
+
// the data global + every streamed deferred resolution are set before the entry hydrates.
|
|
182
|
+
const tailHtml = hydrate
|
|
183
|
+
? `${slot.tailPre}${action}${slot.tailMid}${layoutTail}${boundaryTail}${slot.tailData}${serializeData(forClient)}${slot.tailPost}`
|
|
184
|
+
: slot.tailPost;
|
|
185
|
+
const headers = { "content-type": "text/html; charset=utf-8" };
|
|
186
|
+
// Caller-supplied headers (a terminal status page's `cache-control`, say). Applied before the
|
|
187
|
+
// framework's own below, so `content-type` and the ISR channel stay authoritative - a caller must
|
|
188
|
+
// not be able to mislabel the document or forge the freshness header by passing them here.
|
|
189
|
+
if (extraHeaders !== undefined) {
|
|
190
|
+
for (const [name, value] of new Headers(extraHeaders)) {
|
|
191
|
+
if (name !== "content-type")
|
|
192
|
+
headers[name] = value;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// ISR: advertise the route's freshness so a `withISR` wrapper can set this page's cache TTL. A
|
|
196
|
+
// dedicated header (not the action-revalidation `x-nifra-revalidate`) so the TTL channel never aliases
|
|
197
|
+
// the client's path-list channel.
|
|
198
|
+
if (revalidate !== undefined)
|
|
199
|
+
headers[ISR_REVALIDATE_HEADER] = String(revalidate);
|
|
200
|
+
const serializedTags = serializeISRTags(revalidateTags);
|
|
201
|
+
if (serializedTags !== undefined)
|
|
202
|
+
headers[ISR_REVALIDATE_TAGS_HEADER] = serializedTags;
|
|
203
|
+
const renderProps = {
|
|
204
|
+
data: forComponent,
|
|
205
|
+
actionData: actionSplit?.forComponent,
|
|
206
|
+
// `params`/`path` thread the matched route + URL to the adapter's `useParams`/`useLocation` so they
|
|
207
|
+
// render SSR-correct. Spread only when supplied (exactOptionalPropertyTypes) - an adapter with no
|
|
208
|
+
// router bindings simply never reads them.
|
|
209
|
+
...(options.params !== undefined ? { params: options.params } : {}),
|
|
210
|
+
...(options.path !== undefined ? { path: options.path } : {}),
|
|
211
|
+
// Same rule for `search`: the validated query the adapter's `useSearch` reads, spread only when the
|
|
212
|
+
// caller supplied it (a non-router render never has it).
|
|
213
|
+
...(options.search !== undefined ? { search: options.search } : {}),
|
|
214
|
+
// Spread only when present, so a page-only render produces exactly the props it did before.
|
|
215
|
+
...(options.layoutData !== undefined
|
|
216
|
+
? { layoutData: layoutSplits.map((split) => split.forComponent) }
|
|
217
|
+
: {}),
|
|
218
|
+
...(boundarySplit !== undefined
|
|
219
|
+
? { boundaries: boundarySplit.forComponent }
|
|
220
|
+
: {}),
|
|
221
|
+
};
|
|
222
|
+
// Fast path: nothing `defer()`s and the adapter can render synchronously to a string → buffer the
|
|
223
|
+
// whole document in one pass. Progressive streaming only benefits pages with deferred boundaries
|
|
224
|
+
// (those take the streaming path below); for a plain page the streaming pipeline + the framework's
|
|
225
|
+
// streaming renderer are pure overhead vs a single sync render + concat. A buffered string body also
|
|
226
|
+
// gets an automatic Content-Length. A render throw surfaces here exactly as the streaming path's
|
|
227
|
+
// shell-readiness `await` does, so the `_error` boundary still maps it to a status.
|
|
228
|
+
if (allDeferred.length === 0 && adapter.renderToString !== undefined) {
|
|
229
|
+
const out = adapter.renderToString(chain, renderProps);
|
|
230
|
+
return typeof out === "string"
|
|
231
|
+
? new BufferedRenderedPage(shellHtml + out + closeRootHtml + tailHtml, status, headers)
|
|
232
|
+
: out.then((bodyHtml) => new BufferedRenderedPage(shellHtml + bodyHtml + closeRootHtml + tailHtml, status, headers));
|
|
233
|
+
}
|
|
234
|
+
return renderStreamedPage(adapter, chain, renderProps, shellHtml, closeRootHtml, allDeferred, tailHtml, status, headers, nonceAttr).then((response) => new ResponseRenderedPage(response));
|
|
235
|
+
}
|
|
236
|
+
async function renderStreamedPage(adapter, chain, renderProps, shellHtml, closeRootHtml, allDeferred, tailHtml, status, headers, nonceAttr) {
|
|
237
|
+
// Streaming path - required for `defer()` (progressive `<Await>` resolution) and used by any adapter
|
|
238
|
+
// that doesn't implement `renderToString`. Awaiting `renderToStream` resolves on shell-readiness
|
|
239
|
+
// (React: on-shell-ready; Solid: synchronously), so a shell-render throw surfaces before any byte is
|
|
240
|
+
// sent. A *mid*-stream failure errors the body instead.
|
|
241
|
+
const enc = TEXT_ENCODER;
|
|
242
|
+
const shell = enc.encode(shellHtml);
|
|
243
|
+
const closeRoot = enc.encode(closeRootHtml);
|
|
244
|
+
const tail = enc.encode(tailHtml);
|
|
245
|
+
const appStream = await adapter.renderToStream(chain, renderProps);
|
|
246
|
+
const body = streamDocument(shell, appStream, closeRoot, allDeferred, tail, enc, nonceAttr);
|
|
247
|
+
return new Response(body, { status, headers });
|
|
248
|
+
}
|
|
249
|
+
class BufferedRenderedPage {
|
|
250
|
+
[RESPONSE_RESULT] = true;
|
|
251
|
+
body;
|
|
252
|
+
status;
|
|
253
|
+
headers;
|
|
254
|
+
constructor(body, status, headers) {
|
|
255
|
+
this.body = body;
|
|
256
|
+
this.status = status;
|
|
257
|
+
this.headers = headers;
|
|
258
|
+
}
|
|
259
|
+
toResponse() {
|
|
260
|
+
return htmlResponse(this.body, { status: this.status, headers: this.headers });
|
|
261
|
+
}
|
|
262
|
+
toNodeBody() {
|
|
263
|
+
return { status: this.status, headers: this.headers, body: this.body };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
class ResponseRenderedPage {
|
|
267
|
+
[RESPONSE_RESULT] = true;
|
|
268
|
+
response;
|
|
269
|
+
constructor(response) {
|
|
270
|
+
this.response = response;
|
|
271
|
+
}
|
|
272
|
+
toResponse() {
|
|
273
|
+
return this.response;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function htmlResponse(body, init) {
|
|
277
|
+
const response = new Response(body, init);
|
|
278
|
+
// @nifrajs/node can write buffered HTML straight to ServerResponse with `end(body)`, avoiding a Web
|
|
279
|
+
// Response stream drain on Node. Non-enumerable + Symbol.for keeps this invisible to Web runtimes and
|
|
280
|
+
// cross-package without adding a runtime dependency from @nifrajs/node to @nifrajs/web.
|
|
281
|
+
Object.defineProperty(response, NODE_RESPONSE_BODY, { value: body });
|
|
282
|
+
return response;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Assemble the document stream: `shell` → the app `stream` (forwarded chunk-by-chunk, so a streaming
|
|
286
|
+
* renderer's progressive flushing is preserved) → `closeRoot` → one `__nifraResolve`/`__nifraReject`
|
|
287
|
+
* script per deferred value (emitted once its promise settles - by now the app stream has awaited
|
|
288
|
+
* the same Suspense boundaries - and placed OUTSIDE `#root`) → `tail`. A mid-stream app error errors
|
|
289
|
+
* the result (the body breaks) rather than silently truncating a 200.
|
|
290
|
+
*/
|
|
291
|
+
function streamDocument(shell, stream, closeRoot, deferred, tail, enc, nonceAttr) {
|
|
292
|
+
return new ReadableStream({
|
|
293
|
+
async start(controller) {
|
|
294
|
+
const reader = stream.getReader();
|
|
295
|
+
try {
|
|
296
|
+
controller.enqueue(shell);
|
|
297
|
+
for (;;) {
|
|
298
|
+
const { done, value } = await reader.read();
|
|
299
|
+
if (done)
|
|
300
|
+
break;
|
|
301
|
+
controller.enqueue(value);
|
|
302
|
+
}
|
|
303
|
+
controller.enqueue(closeRoot);
|
|
304
|
+
// Stream each resolution as ITS OWN promise settles - NOT in array order. A slow
|
|
305
|
+
// defer() must not block a faster one; each script self-addresses by id, so order is irrelevant.
|
|
306
|
+
await Promise.all(deferred.map(async (d) => {
|
|
307
|
+
try {
|
|
308
|
+
const value = serializeData(await d.promise);
|
|
309
|
+
controller.enqueue(enc.encode(`<script${nonceAttr}>window.__nifraResolve(${d.id},${value})</script>`));
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
// A rejected deferred streams __nifraReject (the client `<Await>` surfaces it) - it must
|
|
313
|
+
// not break the whole body. Redact: stream a stable opaque code, never the raw error
|
|
314
|
+
// text; log the real reason server-side.
|
|
315
|
+
console.error("[nifra/web] deferred value rejected:", err);
|
|
316
|
+
controller.enqueue(enc.encode(`<script${nonceAttr}>window.__nifraReject(${d.id},${serializeData(DEFERRED_ERROR_CODE)})</script>`));
|
|
317
|
+
}
|
|
318
|
+
}));
|
|
319
|
+
controller.enqueue(tail);
|
|
320
|
+
controller.close();
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
controller.error(err);
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
reader.releaseLock();
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
/** A same-origin destination is an absolute path: one leading `/`, but NOT `//` (protocol-relative →
|
|
332
|
+
* another origin), and nothing a URL parser resolves off-origin. Everything else (absolute URL with
|
|
333
|
+
* a scheme, `//host`, `javascript:`, a bare relative `foo`) requires `external: true`. The predicate
|
|
334
|
+
* lives in the kernel - see `isSameOriginPath` - so this gate and the auth guards' `redirectTo`
|
|
335
|
+
* cannot drift apart. */
|
|
336
|
+
/**
|
|
337
|
+
* Build a redirect - return it from a route `action` for the Post/Redirect/Get pattern (POST
|
|
338
|
+
* mutates, 303 sends the browser to a fresh GET, so a reload doesn't re-submit). Defaults to 303
|
|
339
|
+
* (See Other); pass `{ status: 307 }` or `{ status: 308 }` to preserve the method.
|
|
340
|
+
*
|
|
341
|
+
* **Secure by default:** `location` must be a same-origin path (begins with `/`, not `//`). An
|
|
342
|
+
* off-origin/absolute destination throws unless you pass `{ external: true }` - this closes the
|
|
343
|
+
* open-redirect footgun of `return redirect(formData.get("next"))` on the no-JS (native-form) path,
|
|
344
|
+
* which serves the action's control-flow value verbatim.
|
|
345
|
+
*
|
|
346
|
+
* Returns a plain render, not a `Response`. A redirect is a status line and one header - the most
|
|
347
|
+
* body-less response there is - and building a `Response` for it costs the whole Web object plus, on
|
|
348
|
+
* Node, a stream drained back out. As data it renders on the same lane a handler's return takes.
|
|
349
|
+
* `redirect(...)` is still returned or thrown from exactly the same places; only `.status` /
|
|
350
|
+
* `.headers` are gone from the value, replaced by `options.headers` and the request's `c.set`.
|
|
351
|
+
*
|
|
352
|
+
* @param options redirect status, extra headers, and whether an off-origin destination is intentional.
|
|
353
|
+
*/
|
|
354
|
+
export function redirect(location, options = {}) {
|
|
355
|
+
if (options.external !== true && !isSameOriginPath(location)) {
|
|
356
|
+
throw new Error(`[nifra/web] redirect(${JSON.stringify(location)}) is not a same-origin path. Use a path beginning with "/" (not "//", no backslash or control character), or redirect(location, { external: true }) for a deliberate off-origin redirect. This guards against open redirects from unvalidated input.`);
|
|
357
|
+
}
|
|
358
|
+
// Reject CR/LF in the Location explicitly - defense-in-depth (response splitting / header
|
|
359
|
+
// injection). Spec-correct runtimes' Headers setter throws on CR/LF, but `external: true` lets
|
|
360
|
+
// unvalidated input reach this sink, so we don't rely on the runtime. Same posture as
|
|
361
|
+
// serializeCookie / the SSE frame formatter, which strip CRLF at their sinks.
|
|
362
|
+
if (/[\r\n]/.test(location)) {
|
|
363
|
+
throw new Error(`[nifra/web] redirect location contains a CR/LF character - refusing to emit a header-injecting redirect.`);
|
|
364
|
+
}
|
|
365
|
+
return statusResult(options.status ?? 303, undefined, {
|
|
366
|
+
headers: options.headers === undefined ? { location } : { ...options.headers, location },
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* The signatures a **duplicate module instance** produces during SSR, across engines.
|
|
371
|
+
*
|
|
372
|
+
* Two copies of React (or of `@nifrajs/core`) at the SAME version still fail, because module identity
|
|
373
|
+
* is path-based: hooks read a dispatcher off the copy that rendered, and the component imported the
|
|
374
|
+
* other one. The error that surfaces names a React internal, so it reads as a React bug and the actual
|
|
375
|
+
* cause - two directories - is a long inference away. Naming the cause at the point of failure is the
|
|
376
|
+
* difference between that and a five-second read.
|
|
377
|
+
*/
|
|
378
|
+
const DUPLICATE_INSTANCE_SIGNATURES = [
|
|
379
|
+
/resolveDispatcher\(\)/,
|
|
380
|
+
/Invalid hook call/,
|
|
381
|
+
/Cannot read propert(?:y|ies) of null \(reading '(?:use[A-Z]\w*)'\)/,
|
|
382
|
+
/null is not an object \(evaluating '.*\.use[A-Z]\w*'\)/,
|
|
383
|
+
];
|
|
384
|
+
/**
|
|
385
|
+
* Append the likely cause to an SSR error that carries a duplicate-instance signature.
|
|
386
|
+
*
|
|
387
|
+
* Deliberately does NOT claim the resolved paths: `@nifrajs/web` is framework-agnostic and does not
|
|
388
|
+
* depend on React, so it cannot resolve the copies without guessing. It names the condition and the
|
|
389
|
+
* command that reports the paths, which is the actionable part. Returns the error untouched when the
|
|
390
|
+
* signature does not match, so an ordinary render error reads exactly as before.
|
|
391
|
+
*/
|
|
392
|
+
export function withDuplicateInstanceHint(err) {
|
|
393
|
+
if (!(err instanceof Error))
|
|
394
|
+
return err;
|
|
395
|
+
if (!DUPLICATE_INSTANCE_SIGNATURES.some((rx) => rx.test(err.message)))
|
|
396
|
+
return err;
|
|
397
|
+
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 });
|
|
398
|
+
augmented.name = err.name;
|
|
399
|
+
// `stack` is optional under exactOptionalPropertyTypes; assigning `undefined` would replace a real
|
|
400
|
+
// stack with nothing on engines that always populate it.
|
|
401
|
+
if (err.stack !== undefined)
|
|
402
|
+
augmented.stack = err.stack;
|
|
403
|
+
return augmented;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* An action's control-flow value passes straight through - except a redirect on a client-submit data
|
|
407
|
+
* request: fetch would follow the 3xx into HTML the client can't use, so the redirect rides the
|
|
408
|
+
* X-Nifra-Redirect header on a 204 and the client navigates. One conversion shared by the returned-
|
|
409
|
+
* and thrown- paths, so `return redirect()` and `throw redirect()` agree.
|
|
410
|
+
*
|
|
411
|
+
* Takes either shape: `redirect()` is a plain render, while a hand-rolled `new Response(...)` from an
|
|
412
|
+
* action still arrives as a `Response`. The rewrite stays on the lane its input was on - a plain
|
|
413
|
+
* redirect converts to a plain 204, and never materializes the `Response` it is replacing.
|
|
414
|
+
*/
|
|
415
|
+
export function actionResponse(result, isDataRequest) {
|
|
416
|
+
if (!isDataRequest)
|
|
417
|
+
return result;
|
|
418
|
+
if (isResponseResult(result)) {
|
|
419
|
+
const plain = result.plain;
|
|
420
|
+
// No `plain` means a carrier that only knows how to build a `Response` (not one of ours) - fall
|
|
421
|
+
// back rather than guess at its status.
|
|
422
|
+
if (plain === undefined)
|
|
423
|
+
return actionResponse(result.toResponse(), isDataRequest);
|
|
424
|
+
if (plain.status < 300 || plain.status >= 400)
|
|
425
|
+
return result;
|
|
426
|
+
const location = plain.headers?.location ?? "/";
|
|
427
|
+
return statusResult(204, undefined, { headers: { [REDIRECT_HEADER]: location } });
|
|
428
|
+
}
|
|
429
|
+
if (result.status >= 300 && result.status < 400) {
|
|
430
|
+
const location = result.headers.get("location") ?? "/";
|
|
431
|
+
return statusResult(204, undefined, { headers: { [REDIRECT_HEADER]: location } });
|
|
432
|
+
}
|
|
433
|
+
return result;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Narrow the route's params to the ones a layout owns.
|
|
437
|
+
*
|
|
438
|
+
* A layout wraps a URL prefix, so anything deeper belongs to a route beneath it. Handing it the full
|
|
439
|
+
* set would let a layout read a param it does not own - which reads fine until the layout is reused
|
|
440
|
+
* under a route that has no such param and the value silently becomes `undefined`.
|
|
441
|
+
*/
|
|
442
|
+
export function scopeParams(params, owned) {
|
|
443
|
+
if (owned === undefined)
|
|
444
|
+
return params; // hand-built manifest with no scope info: unchanged behaviour
|
|
445
|
+
if (owned.length === 0)
|
|
446
|
+
return EMPTY_LAYOUT_PARAMS;
|
|
447
|
+
const scoped = {};
|
|
448
|
+
for (const name of owned) {
|
|
449
|
+
const value = params[name];
|
|
450
|
+
if (value !== undefined)
|
|
451
|
+
scoped[name] = value;
|
|
452
|
+
}
|
|
453
|
+
return scoped;
|
|
454
|
+
}
|
|
455
|
+
const EMPTY_LAYOUT_PARAMS = Object.freeze({});
|
|
456
|
+
export const EMPTY_RETAIN = new Set();
|
|
457
|
+
/**
|
|
458
|
+
* Marks an error as coming from a LAYOUT loader, carrying that layout's id.
|
|
459
|
+
*
|
|
460
|
+
* The boundary that catches it must be at or above the failing layout's own segment. The route's
|
|
461
|
+
* innermost `_error` may live BELOW the layout that failed, and rendering there would wrap the
|
|
462
|
+
* boundary in a layout whose data never arrived - so the page would render inside a component that
|
|
463
|
+
* threw. A registry symbol, matching the other cross-module brands here.
|
|
464
|
+
*/
|
|
465
|
+
const LAYOUT_ERROR_ID = Symbol.for("nifra.web.layout-error-id");
|
|
466
|
+
export const tagLayoutError = (err, layoutId) => {
|
|
467
|
+
if (typeof err === "object" && err !== null && !(LAYOUT_ERROR_ID in err)) {
|
|
468
|
+
Object.defineProperty(err, LAYOUT_ERROR_ID, { value: layoutId, enumerable: false });
|
|
469
|
+
}
|
|
470
|
+
return err;
|
|
471
|
+
};
|
|
472
|
+
export const layoutErrorId = (err) => typeof err === "object" && err !== null
|
|
473
|
+
? err[LAYOUT_ERROR_ID]
|
|
474
|
+
: undefined;
|
|
475
|
+
export const STATUS_SIGNAL = Symbol.for("nifra.web.status-signal");
|
|
476
|
+
/** Reason phrases for the plain-text fallback, used only when the app authored no `_<status>` and no
|
|
477
|
+
* `_404` page. Deliberately not exhaustive - anything unlisted falls back to "Error", which is more
|
|
478
|
+
* useful than shipping a table of every RFC status for a body almost nobody will see. */
|
|
479
|
+
export const STATUS_TEXT = {
|
|
480
|
+
400: "Bad Request",
|
|
481
|
+
401: "Unauthorized",
|
|
482
|
+
402: "Payment Required",
|
|
483
|
+
403: "Forbidden",
|
|
484
|
+
404: "Not Found",
|
|
485
|
+
410: "Gone",
|
|
486
|
+
451: "Unavailable For Legal Reasons",
|
|
487
|
+
500: "Internal Server Error",
|
|
488
|
+
};
|
|
489
|
+
export function isStatusSignal(value) {
|
|
490
|
+
return value instanceof Response && STATUS_SIGNAL in value;
|
|
491
|
+
}
|
|
492
|
+
function statusSignal(status, options) {
|
|
493
|
+
if (!Number.isInteger(status) || status < 400 || status > 599) {
|
|
494
|
+
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.`);
|
|
495
|
+
}
|
|
496
|
+
const response = new Response(null, { status });
|
|
497
|
+
// Non-enumerable so the brand never lands in a structured clone or a JSON log of the response.
|
|
498
|
+
Object.defineProperty(response, STATUS_SIGNAL, {
|
|
499
|
+
value: { status, ...(options.headers !== undefined ? { headers: options.headers } : {}) },
|
|
500
|
+
enumerable: false,
|
|
501
|
+
});
|
|
502
|
+
throw response;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Render the nearest `_404` page at status **404**. `throw` it from a loader when the record does not
|
|
506
|
+
* exist.
|
|
507
|
+
*
|
|
508
|
+
* This is the fix for the soft 404: a matched route whose loader finds nothing has otherwise no way to
|
|
509
|
+
* answer 404, so the path of least resistance is to return empty data and render "not found" inside a
|
|
510
|
+
* **200**. That looks correct in a browser and is invisible in review, which is why it ships and stays
|
|
511
|
+
* shipped - while search engines penalise it and keep the dead URL indexed.
|
|
512
|
+
*
|
|
513
|
+
* Returns `never`: these throw, so a loader narrows without a redundant `return`, and the type states
|
|
514
|
+
* the thing the "loaders `throw` redirect, actions `return` it" rule already trips people on.
|
|
515
|
+
*/
|
|
516
|
+
export function notFound(options = {}) {
|
|
517
|
+
return statusSignal(404, options);
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Render a terminal page at status **410 Gone**. `throw` it from a loader for a record that existed and
|
|
521
|
+
* was deliberately removed - a withdrawn listing, a deleted post.
|
|
522
|
+
*
|
|
523
|
+
* 410 is not a pedantic 404: it tells a crawler to **drop** the URL rather than re-fetch it for weeks
|
|
524
|
+
* on the assumption the 404 was transient. Uses `_410.tsx` if the app has one, otherwise `_404`.
|
|
525
|
+
*/
|
|
526
|
+
export function gone(options = {}) {
|
|
527
|
+
return statusSignal(410, options);
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Render a terminal page at any 4xx/5xx status - the escape hatch behind {@link notFound} and
|
|
531
|
+
* {@link gone} (402, 451, …). Uses `_<status>.tsx` if present, otherwise `_404`.
|
|
532
|
+
*/
|
|
533
|
+
export function statusPage(status, options = {}) {
|
|
534
|
+
return statusSignal(status, options);
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Return this from an action to declare which routes the mutation changed (alongside the action's
|
|
538
|
+
* `data`). `createWebApp` sets the `X-Nifra-Revalidate` response header; after the submit the client
|
|
539
|
+
* marks those cached routes stale - refetching the active one and any mounted fetcher showing them -
|
|
540
|
+
* so a mutation can refresh views beyond the one that was submitted. `data` is still surfaced to the
|
|
541
|
+
* component as `actionData` (the wrapper is transparent to `ActionData<typeof action>`).
|
|
542
|
+
*/
|
|
543
|
+
export function revalidate(paths, data) {
|
|
544
|
+
return { __nifraRevalidate: paths, data };
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Serialize loader data for embedding inside an inline `<script>`. `JSON.stringify` alone
|
|
548
|
+
* is NOT safe there: a string containing `</script>` or `<!--` would break out of the
|
|
549
|
+
* script element (an XSS vector). Escape `<`/`>` to `\uXXXX`, plus the U+2028/U+2029
|
|
550
|
+
* separators.
|
|
551
|
+
*/
|
|
552
|
+
export function serializeData(data) {
|
|
553
|
+
const serialized = JSON.stringify(data ?? null);
|
|
554
|
+
// Most loader payloads contain no script-sensitive characters. Guard with chained `indexOf`
|
|
555
|
+
// rather than a regex `.test`: each probe is a native memchr-style scan, measured ~7x faster than
|
|
556
|
+
// the regex scan on V8 for a multi-KB payload (and the engine rejects the two >0xFF separator
|
|
557
|
+
// probes in O(1) when the string is internally one-byte, which serialized JSON almost always is).
|
|
558
|
+
// The same four characters are probed as the SCRIPT_ESCAPE class, so any payload the regex would
|
|
559
|
+
// escape still takes the escape path - this is only a cheaper detector, never a security
|
|
560
|
+
// relaxation.
|
|
561
|
+
const needsEscape = serialized.indexOf("<") !== -1 ||
|
|
562
|
+
serialized.indexOf(">") !== -1 ||
|
|
563
|
+
serialized.indexOf(LINE_SEP) !== -1 ||
|
|
564
|
+
serialized.indexOf(PARA_SEP) !== -1;
|
|
565
|
+
return needsEscape
|
|
566
|
+
? serialized.replace(SCRIPT_ESCAPE, (ch) => SCRIPT_ESCAPE_MAP[ch] ?? ch)
|
|
567
|
+
: serialized;
|
|
568
|
+
}
|
|
569
|
+
function escapeHtml(value) {
|
|
570
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Escape a `<script>` element's text content so it cannot break out of the element. Per the HTML spec,
|
|
574
|
+
* a script's text may contain any character EXCEPT the sequences that the parser treats as element
|
|
575
|
+
* boundaries: `</` (which would begin the end tag - `</script>` is the obvious XSS vector, but ANY `</`
|
|
576
|
+
* starts script-data-end-tag parsing), and the comment/CDATA edges `<!--` / `]]>`. We escape the `<`
|
|
577
|
+
* (and the `>` of `]]>`) to its JS unicode escape: inside a JS/JSON string literal `<` is the
|
|
578
|
+
* identical character, so a JSON-LD payload is byte-equivalent after parsing - but the raw `<`/`>` the
|
|
579
|
+
* HTML tokenizer scans for is gone. This is the JSON-LD-in-HTML rule (content is JSON, never raw HTML),
|
|
580
|
+
* mirroring {@link serializeData}'s posture for the data-global script. Idempotent on already-safe text.
|
|
581
|
+
*/
|
|
582
|
+
function escapeScriptContent(content) {
|
|
583
|
+
// Order matters: rewrite `]]>` first (its `>` becomes `>`), then every `<` (covers `</` and
|
|
584
|
+
// `<!--` in one pass). `<` → `<`; the lone `>` of `]]>` → `>`. A bare `>` elsewhere is
|
|
585
|
+
// harmless in script data, so only the `]]>` close is targeted for `>`.
|
|
586
|
+
return content.replaceAll("]]>", "]]\\u003e").replaceAll("<", "\\u003c");
|
|
587
|
+
}
|
|
588
|
+
function escapeAttr(value) {
|
|
589
|
+
return value
|
|
590
|
+
.replaceAll("&", "&")
|
|
591
|
+
.replaceAll('"', """)
|
|
592
|
+
.replaceAll("<", "<")
|
|
593
|
+
.replaceAll(">", ">");
|
|
594
|
+
}
|
|
595
|
+
/** Serialize attributes that passed the shared SSR/client head trust policy. */
|
|
596
|
+
function tagAttrs(tag, attrs) {
|
|
597
|
+
const trusted = trustedHeadAttributes(tag, attrs);
|
|
598
|
+
if (trusted === null)
|
|
599
|
+
return null;
|
|
600
|
+
let out = "";
|
|
601
|
+
for (const [name, value] of trusted) {
|
|
602
|
+
if (out !== "")
|
|
603
|
+
out += " ";
|
|
604
|
+
out += value === true ? name : `${name}="${escapeAttr(value)}"`;
|
|
605
|
+
}
|
|
606
|
+
return out;
|
|
607
|
+
}
|
|
608
|
+
// Memoize the serialized head-tag string by the resolved `Meta` object's identity. A STATIC route
|
|
609
|
+
// `meta` is returned by-reference from resolveMeta, so the same object recurs every request → cache
|
|
610
|
+
// hit, and the invariant string is serialized once per route, not per request. Function results are
|
|
611
|
+
// never cached, even when a function deliberately reuses one mutable object across requests. WeakMap
|
|
612
|
+
// so a route module that's GC'd takes its entry with it.
|
|
613
|
+
const headTagsCache = new WeakMap();
|
|
614
|
+
function assertExecutableScriptType(type) {
|
|
615
|
+
if (!EXECUTABLE_SCRIPT_TYPES.has(type)) {
|
|
616
|
+
throw new TypeError(`[nifra/web] executable inline scripts accept ${[...EXECUTABLE_SCRIPT_TYPES].map((t) => JSON.stringify(t)).join(" or ")}; received ${JSON.stringify(type)}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/** Render a route's `meta`/`link`/`script` as managed (`data-nifra`) head tags. Title is set
|
|
620
|
+
* separately. XSS-safe by construction: every attribute flows through the shared tag-specific trust
|
|
621
|
+
* policy then value escaping, and each `script[].content` through `escapeScriptContent`
|
|
622
|
+
* (breakout-escaped) - so loader-derived strings (LLM-authored `og:*`, user content) can't inject markup
|
|
623
|
+
* or close the tag early. String concatenation (no intermediate `.map()` arrays + spread) - parity with
|
|
624
|
+
* the already concat-based preloadLinks/styleLinks/islandPreloads loops; byte-identical output. Result
|
|
625
|
+
* is memoized only for objects observed as static meta exports (serialized once per route). */
|
|
626
|
+
function headTags(head) {
|
|
627
|
+
if (head === undefined)
|
|
628
|
+
return "";
|
|
629
|
+
const cacheable = isStaticMeta(head);
|
|
630
|
+
if (cacheable) {
|
|
631
|
+
const cached = headTagsCache.get(head);
|
|
632
|
+
if (cached !== undefined)
|
|
633
|
+
return cached;
|
|
634
|
+
}
|
|
635
|
+
let out = "";
|
|
636
|
+
if (head.meta !== undefined)
|
|
637
|
+
for (const m of head.meta) {
|
|
638
|
+
const attrs = tagAttrs("meta", m);
|
|
639
|
+
if (attrs !== null)
|
|
640
|
+
out += `<meta${attrs === "" ? "" : ` ${attrs}`} data-nifra>`;
|
|
641
|
+
}
|
|
642
|
+
if (head.link !== undefined)
|
|
643
|
+
for (const l of head.link) {
|
|
644
|
+
const attrs = tagAttrs("link", l);
|
|
645
|
+
if (attrs !== null)
|
|
646
|
+
out += `<link${attrs === "" ? "" : ` ${attrs}`} data-nifra>`;
|
|
647
|
+
}
|
|
648
|
+
// `<script>` slot - JSON-LD + other inert head scripts. `type` is attribute-escaped; `content` is
|
|
649
|
+
// breakout-escaped (`escapeScriptContent`) so a `</script>` (or `<!--`/`]]>`) payload can't close the
|
|
650
|
+
// element early. Managed (`data-nifra`) so a soft-nav's `applyHead` cleanly replaces it.
|
|
651
|
+
if (head.script !== undefined)
|
|
652
|
+
for (const s of head.script) {
|
|
653
|
+
const type = s.type ?? "application/ld+json";
|
|
654
|
+
if (!INERT_SCRIPT_TYPES.has(type)) {
|
|
655
|
+
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.`);
|
|
656
|
+
}
|
|
657
|
+
out += `<script type="${type}" data-nifra>${escapeScriptContent(s.content)}</script>`;
|
|
658
|
+
}
|
|
659
|
+
if (head.unsafeScript !== undefined)
|
|
660
|
+
for (const s of head.unsafeScript) {
|
|
661
|
+
if (s.unsafe !== true || s.nonce.trim() === "") {
|
|
662
|
+
throw new TypeError("[nifra/web] executable inline scripts require a non-empty CSP nonce");
|
|
663
|
+
}
|
|
664
|
+
// `type` is validated, not escaped, for the same reason as the inert slot above: an unknown
|
|
665
|
+
// value here is a bug, and escaping one would emit a nonsense script type rather than say so.
|
|
666
|
+
// It was interpolated raw while the `nonce` beside it was escaped, so a descriptor built by hand
|
|
667
|
+
// - `unsafeScript` is a public field, the helper is only a convenience - could close the
|
|
668
|
+
// attribute and inject markup. Measured: `type: '"><img src=x onerror=alert(1)>'` reached the
|
|
669
|
+
// document. An injection in the slot whose whole purpose is to make script emission trustworthy.
|
|
670
|
+
assertExecutableScriptType(s.type);
|
|
671
|
+
out += `<script type="${s.type}" nonce="${escapeAttr(s.nonce)}" data-nifra>${escapeScriptContent(s.content)}</script>`;
|
|
672
|
+
}
|
|
673
|
+
if (cacheable)
|
|
674
|
+
headTagsCache.set(head, out);
|
|
675
|
+
return out;
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* A `<link rel="canonical">` descriptor for a route's `meta.link`. The canonical URL tells search
|
|
679
|
+
* engines which URL is authoritative for a page (deduping query-string / tracking variants).
|
|
680
|
+
*
|
|
681
|
+
* ```ts
|
|
682
|
+
* export const meta = (a) => ({ link: [canonical(`https://site.com/posts/${a.params.slug}`)] })
|
|
683
|
+
* ```
|
|
684
|
+
*/
|
|
685
|
+
export function canonical(href) {
|
|
686
|
+
return { rel: "canonical", href };
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Build the Open Graph `<meta property="og:*">` entries for a route's `meta.meta`. Returns only the
|
|
690
|
+
* properties you supplied (plus `og:type`, defaulting to `"website"`), so it composes with other meta.
|
|
691
|
+
*
|
|
692
|
+
* ```ts
|
|
693
|
+
* export const meta = { meta: [...openGraph({ title: "Nifra", image: "https://site.com/og.png" })] }
|
|
694
|
+
* ```
|
|
695
|
+
*/
|
|
696
|
+
export function openGraph(input) {
|
|
697
|
+
const tags = [];
|
|
698
|
+
const add = (property, content) => {
|
|
699
|
+
if (content !== undefined)
|
|
700
|
+
tags.push({ property, content });
|
|
701
|
+
};
|
|
702
|
+
add("og:title", input.title);
|
|
703
|
+
add("og:description", input.description);
|
|
704
|
+
add("og:image", input.image);
|
|
705
|
+
add("og:url", input.url);
|
|
706
|
+
// `og:type` always present (the spec's required property) - default "website" when unset.
|
|
707
|
+
tags.push({ property: "og:type", content: input.type ?? "website" });
|
|
708
|
+
return tags;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Build a JSON-LD `<script type="application/ld+json">` entry for a route's `meta.script` from a plain
|
|
712
|
+
* object. `JSON.stringify` produces the body; the head renderer breakout-escapes it (see
|
|
713
|
+
* `escapeScriptContent`), so a string field containing `</script>` is embedded safely.
|
|
714
|
+
*
|
|
715
|
+
* ```ts
|
|
716
|
+
* export const meta = {
|
|
717
|
+
* script: [jsonLd({ "@context": "https://schema.org", "@type": "Article", headline: "Hi" })],
|
|
718
|
+
* }
|
|
719
|
+
* ```
|
|
720
|
+
*/
|
|
721
|
+
export function jsonLd(data) {
|
|
722
|
+
return { type: "application/ld+json", content: JSON.stringify(data) };
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Deliberately unsafe escape hatch for executable inline code. The required nonce keeps the result
|
|
726
|
+
* compatible with a strict CSP and makes the security-sensitive choice visible at the call site.
|
|
727
|
+
*/
|
|
728
|
+
export function unsafeInlineScript(content, options) {
|
|
729
|
+
if (options.nonce.trim() === "") {
|
|
730
|
+
throw new TypeError("[nifra/web] unsafeInlineScript requires a non-empty CSP nonce");
|
|
731
|
+
}
|
|
732
|
+
const type = options.type ?? "module";
|
|
733
|
+
// Checked here as well as at emit: the type union is compile-time only, and this helper is the one
|
|
734
|
+
// place a caller is told what it may pass. Failing at the call site names the argument; failing at
|
|
735
|
+
// render names a document.
|
|
736
|
+
assertExecutableScriptType(type);
|
|
737
|
+
return {
|
|
738
|
+
unsafe: true,
|
|
739
|
+
type,
|
|
740
|
+
nonce: options.nonce,
|
|
741
|
+
content,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
//# sourceMappingURL=render-document.js.map
|