@nifrajs/web 0.1.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +18 -0
- package/css-modules.d.ts +34 -0
- package/dist/build.d.ts +110 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +238 -0
- package/dist/build.js.map +1 -0
- package/dist/client.d.ts +32 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +204 -0
- package/dist/client.js.map +1 -0
- package/dist/deferred.d.ts +90 -0
- package/dist/deferred.d.ts.map +1 -0
- package/dist/deferred.js +310 -0
- package/dist/deferred.js.map +1 -0
- package/dist/dev-error.d.ts +17 -0
- package/dist/dev-error.d.ts.map +1 -0
- package/dist/dev-error.js +59 -0
- package/dist/dev-error.js.map +1 -0
- package/dist/dev.d.ts +25 -0
- package/dist/dev.d.ts.map +1 -0
- package/dist/dev.js +83 -0
- package/dist/dev.js.map +1 -0
- package/dist/draft.d.ts +58 -0
- package/dist/draft.d.ts.map +1 -0
- package/dist/draft.js +55 -0
- package/dist/draft.js.map +1 -0
- package/dist/fonts-google.d.ts +109 -0
- package/dist/fonts-google.d.ts.map +1 -0
- package/dist/fonts-google.js +291 -0
- package/dist/fonts-google.js.map +1 -0
- package/dist/fonts.d.ts +67 -0
- package/dist/fonts.d.ts.map +1 -0
- package/dist/fonts.js +103 -0
- package/dist/fonts.js.map +1 -0
- package/dist/fs.d.ts +10 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +24 -0
- package/dist/fs.js.map +1 -0
- package/dist/index.d.ts +280 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +784 -0
- package/dist/index.js.map +1 -0
- package/dist/islands.d.ts +37 -0
- package/dist/islands.d.ts.map +1 -0
- package/dist/islands.js +107 -0
- package/dist/islands.js.map +1 -0
- package/dist/isr.d.ts +154 -0
- package/dist/isr.d.ts.map +1 -0
- package/dist/isr.js +331 -0
- package/dist/isr.js.map +1 -0
- package/dist/manifest.d.ts +178 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +213 -0
- package/dist/manifest.js.map +1 -0
- package/dist/plugins/css-modules.d.ts +40 -0
- package/dist/plugins/css-modules.d.ts.map +1 -0
- package/dist/plugins/css-modules.js +0 -0
- package/dist/plugins/css-modules.js.map +1 -0
- package/dist/plugins/kit.d.ts +44 -0
- package/dist/plugins/kit.d.ts.map +1 -0
- package/dist/plugins/kit.js +105 -0
- package/dist/plugins/kit.js.map +1 -0
- package/dist/plugins/scss.d.ts +27 -0
- package/dist/plugins/scss.d.ts.map +1 -0
- package/dist/plugins/scss.js +78 -0
- package/dist/plugins/scss.js.map +1 -0
- package/dist/prerender.d.ts +77 -0
- package/dist/prerender.d.ts.map +1 -0
- package/dist/prerender.js +139 -0
- package/dist/prerender.js.map +1 -0
- package/dist/query.d.ts +62 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +184 -0
- package/dist/query.js.map +1 -0
- package/dist/router.d.ts +169 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +531 -0
- package/dist/router.js.map +1 -0
- package/dist/vite.d.ts +49 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +144 -0
- package/dist/vite.js.map +1 -0
- package/package.json +127 -0
- package/src/build.ts +375 -0
- package/src/client.ts +218 -0
- package/src/deferred.ts +339 -0
- package/src/dev-error.ts +62 -0
- package/src/dev.ts +108 -0
- package/src/draft.ts +82 -0
- package/src/fonts-google.ts +397 -0
- package/src/fonts.ts +147 -0
- package/src/fs.ts +26 -0
- package/src/index.ts +1202 -0
- package/src/islands.ts +121 -0
- package/src/isr.ts +461 -0
- package/src/manifest.ts +366 -0
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/kit.ts +123 -0
- package/src/plugins/scss.ts +108 -0
- package/src/prerender.ts +209 -0
- package/src/query.ts +250 -0
- package/src/router.ts +676 -0
- package/src/vite.ts +212 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nifrajs/web — the framework-agnostic SSR core. It owns the render *seam* and the HTML
|
|
3
|
+
* document orchestration, and knows nothing about any specific UI framework: a render
|
|
4
|
+
* adapter (@nifrajs/web-solid, @nifrajs/web-react, …) supplies the actual render + hydrate.
|
|
5
|
+
*
|
|
6
|
+
* The core treats a "component" and a hydration "container" as opaque `unknown` — only
|
|
7
|
+
* the adapter interprets them. That keeps this package both framework-agnostic and free
|
|
8
|
+
* of DOM types: it is pure server orchestration + string building.
|
|
9
|
+
*/
|
|
10
|
+
import { server } from "@nifrajs/core";
|
|
11
|
+
import { DEFERRED_ERROR_CODE, DEFERRED_RUNTIME, defer, MAP_DEFERRED_SOURCE, ndjsonStream, prepareDeferred, } from "./deferred.js";
|
|
12
|
+
import { isDraftEnabled } from "./draft.js";
|
|
13
|
+
import { ISR_REVALIDATE_HEADER } from "./isr.js";
|
|
14
|
+
import { DATA_HEADER, PRERENDERED_GLOBAL, REDIRECT_HEADER, REVALIDATE_HEADER, } from "./router.js";
|
|
15
|
+
// Draft / preview mode — a signed cookie that flips `ctx.draft` for loaders + bypasses ISR for editors.
|
|
16
|
+
export { DRAFT_COOKIE, disableDraft, enableDraft, isDraftEnabled, } from "./draft.js";
|
|
17
|
+
// Font optimization — a CLS-safe `@font-face` generator + a preload `<link>` for self-hosted fonts.
|
|
18
|
+
export { fontFace, fontPreload, } from "./fonts.js";
|
|
19
|
+
// ISR (incremental static regeneration): a pluggable cache store + the `withISR` stale-while-revalidate
|
|
20
|
+
// wrapper for rendered SSR responses.
|
|
21
|
+
export { ISR_REVALIDATE_HEADER, ISR_STATUS_HEADER, KVCacheStore, MemoryCacheStore, revalidateEndpoint, withISR, } from "./isr.js";
|
|
22
|
+
// File-based routing manifest — pure + fs-free. `discoverRoutes` (fs) lives in `@nifrajs/web/fs`.
|
|
23
|
+
export { buildManifest, enumeratePrerenderedPaths, enumerateStaticRoutes, filePathToPattern, filePathToPatterns, } from "./manifest.js";
|
|
24
|
+
// Keyed query-cache (agnostic) — a `query(key, fn)` primitive (dedup + staleness + invalidation + GC)
|
|
25
|
+
// consumed by the per-adapter `useQuery`/`createQuery` bindings.
|
|
26
|
+
export { createQueryClient, hashQueryKey, } from "./query.js";
|
|
27
|
+
// Agnostic client-side router core (pure + DOM-free) — consumed by per-adapter Router bindings.
|
|
28
|
+
// `DATA_HEADER` marks a navigation's data-only GET; `createWebApp` answers it with loader JSON.
|
|
29
|
+
export { createClientRouter, createMatcher, DATA_HEADER, REDIRECT_HEADER, REVALIDATE_HEADER, } from "./router.js";
|
|
30
|
+
// Deferred loader data (`defer()` + the `Deferred<T>` type) — consumed by the adapter's `<Await>`.
|
|
31
|
+
export { defer };
|
|
32
|
+
/** Global the server serializes loader data into; the client reads it to hydrate. */
|
|
33
|
+
export const DATA_GLOBAL = "__NIFRA_DATA__";
|
|
34
|
+
/** Global the server writes the matched route id into; the client uses it to pick the chain. */
|
|
35
|
+
export const ROUTE_GLOBAL = "__NIFRA_ROUTE__";
|
|
36
|
+
/** Global the server serializes an action's data return into (absent on GETs); the client
|
|
37
|
+
* reads it so hydration after a native form POST matches the server-rendered markup. */
|
|
38
|
+
export const ACTION_GLOBAL = "__NIFRA_ACTION__";
|
|
39
|
+
// Shared, stateless — allocated once at module load, not per render/stream.
|
|
40
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
41
|
+
// XSS-safe `<script>` JSON escaping in a SINGLE pass: `<`/`>` would break out of the
|
|
42
|
+
// script element; U+2028/U+2029 are valid JSON but historically break JS string literals. Built via
|
|
43
|
+
// fromCharCode so no raw separator chars live in this source. One regex + one output string replaces
|
|
44
|
+
// the prior four sequential `replaceAll` full-string passes.
|
|
45
|
+
const LINE_SEP = String.fromCharCode(0x2028);
|
|
46
|
+
const PARA_SEP = String.fromCharCode(0x2029);
|
|
47
|
+
const SCRIPT_ESCAPE = new RegExp(`[<>${LINE_SEP}${PARA_SEP}]`, "g");
|
|
48
|
+
const SCRIPT_ESCAPE_MAP = {
|
|
49
|
+
"<": "\\u003c",
|
|
50
|
+
">": "\\u003e",
|
|
51
|
+
[LINE_SEP]: "\\u2028",
|
|
52
|
+
[PARA_SEP]: "\\u2029",
|
|
53
|
+
};
|
|
54
|
+
const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body");
|
|
55
|
+
const RESPONSE_RESULT = Symbol.for("nifra.response.result");
|
|
56
|
+
/**
|
|
57
|
+
* Server: render a full HTML document for a page — the adapter's hydration head + the SSR
|
|
58
|
+
* markup (**streamed**) + the serialized loader data + the client module — as a `Response`.
|
|
59
|
+
* The shell (`<head>` + the open container) flushes first, the adapter's app stream follows,
|
|
60
|
+
* then the tail (data globals + client entry). Pure Web Standards, so it returns straight from
|
|
61
|
+
* a nifra route handler and streams on any fetch runtime (Bun/Node/Deno/Workers).
|
|
62
|
+
*/
|
|
63
|
+
export function renderPage(options) {
|
|
64
|
+
const page = renderPageResult(options);
|
|
65
|
+
return page instanceof Promise ? page.then((p) => p.toResponse()) : page.toResponse();
|
|
66
|
+
}
|
|
67
|
+
export function renderPageResult(options) {
|
|
68
|
+
const { adapter, chain, data, actionData, clientEntry, preload = [], styles = [], prerenderedPaths = [], revalidate, routeId, status = 200, title = "nifra", head, rootId = "root", hydrate = true, islandScripts = [], } = options;
|
|
69
|
+
const route = routeId === undefined ? "" : `window.${ROUTE_GLOBAL}=${serializeData(routeId)};`;
|
|
70
|
+
// The SSG prerendered-path set (when an app declares it) — the client reads it to fetch a static
|
|
71
|
+
// `_data.json` on soft-nav into a prerendered route instead of hitting the worker. Empty ⇒ omitted.
|
|
72
|
+
const prerendered = prerenderedPaths.length === 0
|
|
73
|
+
? ""
|
|
74
|
+
: `window.${PRERENDERED_GLOBAL}=${serializeData(prerenderedPaths)};`;
|
|
75
|
+
// Split deferred values: the component sees markers (id + promise) to `<Await>`; the serialized
|
|
76
|
+
// data carries `{__nifra_deferred: id}` placeholders (promises don't serialize). `actionData` may
|
|
77
|
+
// also `defer()` — split it too, continuing the id space so a single registry settles both. The
|
|
78
|
+
// inline registry runtime is emitted only when something defers, so non-deferred output is unchanged.
|
|
79
|
+
const { forComponent, forClient, deferred } = prepareDeferred(data);
|
|
80
|
+
const actionSplit = actionData === undefined ? undefined : prepareDeferred(actionData, deferred.length);
|
|
81
|
+
const allDeferred = actionSplit ? [...deferred, ...actionSplit.deferred] : deferred;
|
|
82
|
+
// Only emit the action global when an action actually ran, so plain GET output is unchanged.
|
|
83
|
+
const action = actionSplit === undefined
|
|
84
|
+
? ""
|
|
85
|
+
: `window.${ACTION_GLOBAL}=${serializeData(actionSplit.forClient)};`;
|
|
86
|
+
const deferredRuntime = allDeferred.length > 0 ? `<script>${DEFERRED_RUNTIME}</script>` : "";
|
|
87
|
+
// Matched-route chunk preloads, concatenated directly (skip the `filter().map()`
|
|
88
|
+
// intermediate arrays — and the whole loop — on the common no-extra-preload render). De-duped
|
|
89
|
+
// against the entry, which is preloaded separately below.
|
|
90
|
+
let preloadLinks = "";
|
|
91
|
+
if (preload.length > 0) {
|
|
92
|
+
for (const url of preload) {
|
|
93
|
+
if (url !== clientEntry)
|
|
94
|
+
preloadLinks += `<link rel="modulepreload" href="${escapeAttr(url)}">`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// The matched route's stylesheets — `<link rel="stylesheet">` in `<head>` so CSS arrives with the
|
|
98
|
+
// first paint (no FOUC). Render-blocking by design, and emitted regardless of `hydrate` (a static or
|
|
99
|
+
// `_error` page still wants its styles). In dev (Vite) CSS is injected client-side instead, so
|
|
100
|
+
// `styles` is empty there.
|
|
101
|
+
let styleLinks = "";
|
|
102
|
+
for (const url of styles)
|
|
103
|
+
styleLinks += `<link rel="stylesheet" href="${escapeAttr(url)}">`;
|
|
104
|
+
// Island bundles are referenced only by a `<script type="module">` at the END of `<body>` (below), so
|
|
105
|
+
// the browser doesn't discover them until the whole page is parsed. `modulepreload` them in `<head>`
|
|
106
|
+
// so the fetch starts immediately, in parallel with parsing — regardless of `hydrate` (an island page
|
|
107
|
+
// is typically hydrate:false). Without this, a heavy island bundle on a cold first load leaves its
|
|
108
|
+
// server-rendered placeholder visible until that late, un-prefetched fetch lands ("stuck loading").
|
|
109
|
+
let islandPreloads = "";
|
|
110
|
+
for (const src of islandScripts)
|
|
111
|
+
islandPreloads += `<link rel="modulepreload" href="${escapeAttr(src)}">`;
|
|
112
|
+
// Shell — flushed before the app finishes rendering: `<head>` (title, meta, hydration head) + the
|
|
113
|
+
// open container. `modulepreload` of the client entry — plus the matched route's own chunks
|
|
114
|
+
// (`preloadLinks`) — lets the JS download while the body streams. One template literal (no
|
|
115
|
+
// intermediate array + `join`) — byte-identical to the prior output.
|
|
116
|
+
// A non-hydrated page (e.g. an `_error` boundary) omits the client-entry preload, route-chunk
|
|
117
|
+
// preloads, and deferred runtime — there's no client takeover to feed.
|
|
118
|
+
const hydrationLinks = hydrate
|
|
119
|
+
? `<link rel="modulepreload" href="${escapeAttr(clientEntry)}">${preloadLinks}${deferredRuntime}`
|
|
120
|
+
: "";
|
|
121
|
+
const shellHtml = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(head?.title ?? title)}</title>${headTags(head)}${styleLinks}${hydrationLinks}${islandPreloads}${adapter.hydrationHead()}</head><body><div id="${escapeAttr(rootId)}">`;
|
|
122
|
+
// Closes the hydration container; deferred resolve scripts go AFTER it (outside `#root`) so they
|
|
123
|
+
// aren't part of the adapter's hydrated tree (an inline script inside it breaks hydration).
|
|
124
|
+
const closeRootHtml = "</div>";
|
|
125
|
+
// Tail — the loader-data globals + the client module. Module scripts defer (run after parse), so
|
|
126
|
+
// the data global + every streamed deferred resolution are set before the entry hydrates.
|
|
127
|
+
// Island bundles load regardless of `hydrate` — a static page (hydrate:false) ships no framework
|
|
128
|
+
// client but can still mount no-framework islands (`@nifrajs/web/islands`).
|
|
129
|
+
let islandTags = "";
|
|
130
|
+
for (const src of islandScripts)
|
|
131
|
+
islandTags += `<script type="module" src="${escapeAttr(src)}"></script>`;
|
|
132
|
+
const tailHtml = `${hydrate
|
|
133
|
+
? `<script>${route}${action}${prerendered}window.${DATA_GLOBAL}=${serializeData(forClient)}</script><script type="module" src="${escapeAttr(clientEntry)}"></script>`
|
|
134
|
+
: ""}${islandTags}</body></html>`;
|
|
135
|
+
const headers = { "content-type": "text/html; charset=utf-8" };
|
|
136
|
+
// ISR: advertise the route's freshness so a `withISR` wrapper can set this page's cache TTL. A
|
|
137
|
+
// dedicated header (not the action-revalidation `x-nifra-revalidate`) so the TTL channel never aliases
|
|
138
|
+
// the client's path-list channel.
|
|
139
|
+
if (revalidate !== undefined)
|
|
140
|
+
headers[ISR_REVALIDATE_HEADER] = String(revalidate);
|
|
141
|
+
const renderProps = { data: forComponent, actionData: actionSplit?.forComponent };
|
|
142
|
+
// Fast path: nothing `defer()`s and the adapter can render synchronously to a string → buffer the
|
|
143
|
+
// whole document in one pass. Progressive streaming only benefits pages with deferred boundaries
|
|
144
|
+
// (those take the streaming path below); for a plain page the streaming pipeline + the framework's
|
|
145
|
+
// streaming renderer are pure overhead vs a single sync render + concat. A buffered string body also
|
|
146
|
+
// gets an automatic Content-Length. A render throw surfaces here exactly as the streaming path's
|
|
147
|
+
// shell-readiness `await` does, so the `_error` boundary still maps it to a status.
|
|
148
|
+
if (allDeferred.length === 0 && adapter.renderToString !== undefined) {
|
|
149
|
+
const out = adapter.renderToString(chain, renderProps);
|
|
150
|
+
return typeof out === "string"
|
|
151
|
+
? new BufferedRenderedPage(shellHtml + out + closeRootHtml + tailHtml, status, headers)
|
|
152
|
+
: out.then((bodyHtml) => new BufferedRenderedPage(shellHtml + bodyHtml + closeRootHtml + tailHtml, status, headers));
|
|
153
|
+
}
|
|
154
|
+
return renderStreamedPage(adapter, chain, renderProps, shellHtml, closeRootHtml, allDeferred, tailHtml, status, headers).then((response) => new ResponseRenderedPage(response));
|
|
155
|
+
}
|
|
156
|
+
async function renderStreamedPage(adapter, chain, renderProps, shellHtml, closeRootHtml, allDeferred, tailHtml, status, headers) {
|
|
157
|
+
// Streaming path — required for `defer()` (progressive `<Await>` resolution) and used by any adapter
|
|
158
|
+
// that doesn't implement `renderToString`. Awaiting `renderToStream` resolves on shell-readiness
|
|
159
|
+
// (React: on-shell-ready; Solid: synchronously), so a shell-render throw surfaces before any byte is
|
|
160
|
+
// sent. A *mid*-stream failure errors the body instead.
|
|
161
|
+
const enc = TEXT_ENCODER;
|
|
162
|
+
const shell = enc.encode(shellHtml);
|
|
163
|
+
const closeRoot = enc.encode(closeRootHtml);
|
|
164
|
+
const tail = enc.encode(tailHtml);
|
|
165
|
+
const appStream = await adapter.renderToStream(chain, renderProps);
|
|
166
|
+
const body = streamDocument(shell, appStream, closeRoot, allDeferred, tail, enc);
|
|
167
|
+
return new Response(body, { status, headers });
|
|
168
|
+
}
|
|
169
|
+
class BufferedRenderedPage {
|
|
170
|
+
[RESPONSE_RESULT] = true;
|
|
171
|
+
body;
|
|
172
|
+
status;
|
|
173
|
+
headers;
|
|
174
|
+
constructor(body, status, headers) {
|
|
175
|
+
this.body = body;
|
|
176
|
+
this.status = status;
|
|
177
|
+
this.headers = headers;
|
|
178
|
+
}
|
|
179
|
+
toResponse() {
|
|
180
|
+
return htmlResponse(this.body, { status: this.status, headers: this.headers });
|
|
181
|
+
}
|
|
182
|
+
toNodeBody() {
|
|
183
|
+
return { status: this.status, headers: this.headers, body: this.body };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
class ResponseRenderedPage {
|
|
187
|
+
[RESPONSE_RESULT] = true;
|
|
188
|
+
response;
|
|
189
|
+
constructor(response) {
|
|
190
|
+
this.response = response;
|
|
191
|
+
}
|
|
192
|
+
toResponse() {
|
|
193
|
+
return this.response;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function htmlResponse(body, init) {
|
|
197
|
+
const response = new Response(body, init);
|
|
198
|
+
// @nifrajs/node can write buffered HTML straight to ServerResponse with `end(body)`, avoiding a Web
|
|
199
|
+
// Response stream drain on Node. Non-enumerable + Symbol.for keeps this invisible to Web runtimes and
|
|
200
|
+
// cross-package without adding a runtime dependency from @nifrajs/node to @nifrajs/web.
|
|
201
|
+
Object.defineProperty(response, NODE_RESPONSE_BODY, { value: body });
|
|
202
|
+
return response;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Assemble the document stream: `shell` → the app `stream` (forwarded chunk-by-chunk, so a streaming
|
|
206
|
+
* renderer's progressive flushing is preserved) → `closeRoot` → one `__nifraResolve`/`__nifraReject`
|
|
207
|
+
* script per deferred value (emitted once its promise settles — by now the app stream has awaited
|
|
208
|
+
* the same Suspense boundaries — and placed OUTSIDE `#root`) → `tail`. A mid-stream app error errors
|
|
209
|
+
* the result (the body breaks) rather than silently truncating a 200.
|
|
210
|
+
*/
|
|
211
|
+
function streamDocument(shell, stream, closeRoot, deferred, tail, enc) {
|
|
212
|
+
return new ReadableStream({
|
|
213
|
+
async start(controller) {
|
|
214
|
+
const reader = stream.getReader();
|
|
215
|
+
try {
|
|
216
|
+
controller.enqueue(shell);
|
|
217
|
+
for (;;) {
|
|
218
|
+
const { done, value } = await reader.read();
|
|
219
|
+
if (done)
|
|
220
|
+
break;
|
|
221
|
+
controller.enqueue(value);
|
|
222
|
+
}
|
|
223
|
+
controller.enqueue(closeRoot);
|
|
224
|
+
// Stream each resolution as ITS OWN promise settles — NOT in array order. A slow
|
|
225
|
+
// defer() must not block a faster one; each script self-addresses by id, so order is irrelevant.
|
|
226
|
+
await Promise.all(deferred.map(async (d) => {
|
|
227
|
+
try {
|
|
228
|
+
const value = serializeData(await d.promise);
|
|
229
|
+
controller.enqueue(enc.encode(`<script>window.__nifraResolve(${d.id},${value})</script>`));
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
// A rejected deferred streams __nifraReject (the client `<Await>` surfaces it) — it must
|
|
233
|
+
// not break the whole body. Redact: stream a stable opaque code, never the raw error
|
|
234
|
+
// text; log the real reason server-side.
|
|
235
|
+
console.error("[nifra/web] deferred value rejected:", err);
|
|
236
|
+
controller.enqueue(enc.encode(`<script>window.__nifraReject(${d.id},${serializeData(DEFERRED_ERROR_CODE)})</script>`));
|
|
237
|
+
}
|
|
238
|
+
}));
|
|
239
|
+
controller.enqueue(tail);
|
|
240
|
+
controller.close();
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
controller.error(err);
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
reader.releaseLock();
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
/** A same-origin destination is an absolute path: one leading `/`, but NOT `//` (protocol-relative →
|
|
252
|
+
* another origin). Everything else (absolute URL with a scheme, `//host`, `javascript:`, a bare
|
|
253
|
+
* relative `foo`) is treated as off-origin and requires `external: true`. */
|
|
254
|
+
function isSameOriginPath(location) {
|
|
255
|
+
return location.startsWith("/") && !location.startsWith("//");
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Build a redirect `Response` — return it from a route `action` for the Post/Redirect/Get
|
|
259
|
+
* pattern (POST mutates, 303 sends the browser to a fresh GET, so a reload doesn't re-submit).
|
|
260
|
+
* Defaults to 303 (See Other); pass `307`/`308` (or `{ status }`) to preserve the method.
|
|
261
|
+
*
|
|
262
|
+
* **Secure by default:** `location` must be a same-origin path (begins with `/`, not `//`). An
|
|
263
|
+
* off-origin/absolute destination throws unless you pass `{ external: true }` — this closes the
|
|
264
|
+
* open-redirect footgun of `return redirect(formData.get("next"))` on the no-JS (native-form) path,
|
|
265
|
+
* which returns the action's `Response` verbatim.
|
|
266
|
+
*
|
|
267
|
+
* @param statusOrOptions a status number (back-compat) or `{ status?, external? }`.
|
|
268
|
+
*/
|
|
269
|
+
export function redirect(location, statusOrOptions = 303) {
|
|
270
|
+
const opts = typeof statusOrOptions === "number" ? { status: statusOrOptions } : statusOrOptions;
|
|
271
|
+
if (opts.external !== true && !isSameOriginPath(location)) {
|
|
272
|
+
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.`);
|
|
273
|
+
}
|
|
274
|
+
// Reject CR/LF in the Location explicitly — defense-in-depth (response splitting / header
|
|
275
|
+
// injection). Spec-correct runtimes' Headers setter throws on CR/LF, but `external: true` lets
|
|
276
|
+
// unvalidated input reach this sink, so we don't rely on the runtime. Same posture as
|
|
277
|
+
// serializeCookie / the SSE frame formatter, which strip CRLF at their sinks.
|
|
278
|
+
if (/[\r\n]/.test(location)) {
|
|
279
|
+
throw new Error(`[nifra/web] redirect location contains a CR/LF character — refusing to emit a header-injecting redirect.`);
|
|
280
|
+
}
|
|
281
|
+
return new Response(null, { status: opts.status ?? 303, headers: { location } });
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Return this from an action to declare which routes the mutation changed (alongside the action's
|
|
285
|
+
* `data`). `createWebApp` sets the `X-Nifra-Revalidate` response header; after the submit the client
|
|
286
|
+
* marks those cached routes stale — refetching the active one and any mounted fetcher showing them —
|
|
287
|
+
* so a mutation can refresh views beyond the one that was submitted. `data` is still surfaced to the
|
|
288
|
+
* component as `actionData` (the wrapper is transparent to `ActionData<typeof action>`).
|
|
289
|
+
*/
|
|
290
|
+
export function revalidate(paths, data) {
|
|
291
|
+
return { __nifraRevalidate: paths, data };
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Serialize loader data for embedding inside an inline `<script>`. `JSON.stringify` alone
|
|
295
|
+
* is NOT safe there: a string containing `</script>` or `<!--` would break out of the
|
|
296
|
+
* script element (an XSS vector). Escape `<`/`>` to `\uXXXX`, plus the U+2028/U+2029
|
|
297
|
+
* separators.
|
|
298
|
+
*/
|
|
299
|
+
export function serializeData(data) {
|
|
300
|
+
return JSON.stringify(data ?? null).replace(SCRIPT_ESCAPE, (ch) => SCRIPT_ESCAPE_MAP[ch] ?? ch);
|
|
301
|
+
}
|
|
302
|
+
function escapeHtml(value) {
|
|
303
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
304
|
+
}
|
|
305
|
+
function escapeAttr(value) {
|
|
306
|
+
return value
|
|
307
|
+
.replaceAll("&", "&")
|
|
308
|
+
.replaceAll('"', """)
|
|
309
|
+
.replaceAll("<", "<")
|
|
310
|
+
.replaceAll(">", ">");
|
|
311
|
+
}
|
|
312
|
+
const SAFE_ATTR_NAME = /^[a-zA-Z][a-zA-Z0-9-]*$/;
|
|
313
|
+
/** Serialize a meta/link tag's attributes: validate the name (dev-authored) + escape the value
|
|
314
|
+
* (may carry loader data → XSS). Invalid attribute names are dropped. */
|
|
315
|
+
function tagAttrs(attrs) {
|
|
316
|
+
return Object.entries(attrs)
|
|
317
|
+
.filter(([name]) => SAFE_ATTR_NAME.test(name))
|
|
318
|
+
.map(([name, value]) => `${name}="${escapeAttr(value)}"`)
|
|
319
|
+
.join(" ");
|
|
320
|
+
}
|
|
321
|
+
// Memoize the serialized head-tag string by the resolved `Meta` object's identity. A STATIC route
|
|
322
|
+
// `meta` is returned by-reference from resolveMeta, so the same object recurs every request → cache
|
|
323
|
+
// hit, and the invariant string is serialized once per route, not per request. A FUNCTION `meta`
|
|
324
|
+
// builds a fresh object each request (new identity) → miss → recompute, exactly as needed (its
|
|
325
|
+
// content can vary). WeakMap so a route module that's GC'd takes its entry with it.
|
|
326
|
+
const headTagsCache = new WeakMap();
|
|
327
|
+
/** Render a route's `meta`/`link` as managed (`data-nifra`) head tags. Title is set separately.
|
|
328
|
+
* String concatenation (no intermediate `.map()` arrays + spread) — parity with the already
|
|
329
|
+
* concat-based preloadLinks/styleLinks/islandPreloads loops; byte-identical output.
|
|
330
|
+
* Result is memoized per resolved-`Meta` identity (static meta → serialized once per route). */
|
|
331
|
+
function headTags(head) {
|
|
332
|
+
if (head === undefined)
|
|
333
|
+
return "";
|
|
334
|
+
const cached = headTagsCache.get(head);
|
|
335
|
+
if (cached !== undefined)
|
|
336
|
+
return cached;
|
|
337
|
+
let out = "";
|
|
338
|
+
if (head.meta !== undefined)
|
|
339
|
+
for (const m of head.meta)
|
|
340
|
+
out += `<meta ${tagAttrs(m)} data-nifra>`;
|
|
341
|
+
if (head.link !== undefined)
|
|
342
|
+
for (const l of head.link)
|
|
343
|
+
out += `<link ${tagAttrs(l)} data-nifra>`;
|
|
344
|
+
headTagsCache.set(head, out);
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
/** Resolve a route's `meta` (static or a function of the loader data + params) to a {@link Meta}. */
|
|
348
|
+
export function resolveMeta(meta, args) {
|
|
349
|
+
if (meta === undefined)
|
|
350
|
+
return {};
|
|
351
|
+
return typeof meta === "function" ? meta(args) : meta;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Build a nifra app from a route manifest: every route SSRs its layout chain via `renderPage`,
|
|
355
|
+
* and a wildcard catch-all renders `_404` (or a plain 404). Reuses @nifrajs/core's router +
|
|
356
|
+
* lifecycle, so matching, params, and precedence are battle-tested. fs-free — feed it a
|
|
357
|
+
* manifest from `discoverRoutes` (`@nifrajs/web/fs`) at startup, so the served app stays portable.
|
|
358
|
+
*/
|
|
359
|
+
export function createWebApp(options) {
|
|
360
|
+
const { adapter, manifest, clientEntry, title, api } = options;
|
|
361
|
+
const titleOption = title === undefined ? {} : { title };
|
|
362
|
+
// Draft/preview: when a `draftSecret` is configured, each request's signed `__nifra_draft` cookie is
|
|
363
|
+
// verified once and surfaced to loaders/actions as `ctx.draft`. No secret ⇒ always `false` (sync, free).
|
|
364
|
+
const draftFlag = (req) => options.draftSecret === undefined
|
|
365
|
+
? Promise.resolve(false)
|
|
366
|
+
: isDraftEnabled(req, options.draftSecret);
|
|
367
|
+
// Per-route preload chunks (spread into renderPage; omitted when unmapped, for exactOptionalPropertyTypes).
|
|
368
|
+
const preloadOf = (id) => {
|
|
369
|
+
const chunks = options.routePreload?.[id];
|
|
370
|
+
return chunks ? { preload: chunks } : {};
|
|
371
|
+
};
|
|
372
|
+
// The matched route's stylesheet links (spread into renderPage). Per-route when the build mapped it
|
|
373
|
+
// (`routeStyles[id]` — only the chain's CSS; an empty array ⇒ no `<link>`), else the aggregate
|
|
374
|
+
// `styles`. Omitted entirely when CSS-free (for exactOptionalPropertyTypes).
|
|
375
|
+
const stylesOf = (id) => {
|
|
376
|
+
const perRoute = options.routeStyles?.[id];
|
|
377
|
+
if (perRoute !== undefined)
|
|
378
|
+
return { styles: perRoute };
|
|
379
|
+
return options.styles && options.styles.length > 0 ? { styles: options.styles } : {};
|
|
380
|
+
};
|
|
381
|
+
const app = server();
|
|
382
|
+
// SSG `fallback: "404"`: the set of concrete paths that actually exist for those routes. An unlisted
|
|
383
|
+
// path under a `"404"` route is rejected (it isn't a static file, and the route declared it shouldn't
|
|
384
|
+
// SSR on demand). O(1) membership via a Set.
|
|
385
|
+
const prerenderedSet = new Set(options.prerenderedPaths ?? []);
|
|
386
|
+
// Resolve a route's layout chain (outermost layout → page). Loaded lazily — the data-only and
|
|
387
|
+
// 405 branches return before any layout is needed.
|
|
388
|
+
const loadLayouts = (route) => Promise.all(
|
|
389
|
+
// layoutIds only reference layouts present in the manifest (buildManifest invariant).
|
|
390
|
+
route.layoutIds.map((id) => manifest.layouts[id].load().then((m) => m.default)));
|
|
391
|
+
// Dir a special-file id lives in: `_error`→"" , `a/b/_error`→"a/b" (and likewise for `_layout`).
|
|
392
|
+
const dirOfId = (id, suffix) => id === suffix ? "" : id.slice(0, id.length - suffix.length - 1);
|
|
393
|
+
/**
|
|
394
|
+
* Render the **nearest `_error` boundary** when a route's loader throws (the agnostic, server-side
|
|
395
|
+
* half of error UI — works on every adapter, no client takeover). The boundary renders in place of
|
|
396
|
+
* the page, wrapped by the layouts **at or above** its segment (deeper layouts are dropped). It's
|
|
397
|
+
* served **non-hydrated** at status 500: a terminal page, and rendering a boundary (not the page the
|
|
398
|
+
* client maps this route to) would otherwise hydrate-mismatch. The component receives `{ name,
|
|
399
|
+
* message }` — never the stack (no internals leak into HTML).
|
|
400
|
+
*/
|
|
401
|
+
const renderError = async (route, errorId, err) => {
|
|
402
|
+
const errDir = dirOfId(errorId, "_error");
|
|
403
|
+
const keptLayoutIds = route.layoutIds.filter((id) => {
|
|
404
|
+
const ld = dirOfId(id, "_layout");
|
|
405
|
+
return ld === "" || ld === errDir || errDir.startsWith(`${ld}/`);
|
|
406
|
+
});
|
|
407
|
+
const layouts = await Promise.all(keptLayoutIds.map((id) => manifest.layouts[id].load().then((m) => m.default)));
|
|
408
|
+
const { default: errComp } = await (manifest.errors?.[errorId]).load();
|
|
409
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
410
|
+
return renderPageResult({
|
|
411
|
+
adapter,
|
|
412
|
+
chain: [...layouts, errComp],
|
|
413
|
+
data: { name: e.name, message: e.message },
|
|
414
|
+
clientEntry,
|
|
415
|
+
routeId: errorId,
|
|
416
|
+
status: 500,
|
|
417
|
+
hydrate: false,
|
|
418
|
+
...titleOption,
|
|
419
|
+
});
|
|
420
|
+
};
|
|
421
|
+
// The 404 response — the `_404` page (status 404) or a plain-text fallback. Shared by the wildcard
|
|
422
|
+
// catch-all (unmatched paths) and the `fallback: "404"` enforcement (unlisted paths under a route
|
|
423
|
+
// that opted out of on-demand SSR).
|
|
424
|
+
const renderNotFound = async () => {
|
|
425
|
+
if (manifest.notFound === undefined) {
|
|
426
|
+
return new Response("Not Found", {
|
|
427
|
+
status: 404,
|
|
428
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
const { default: notFound } = await manifest.notFound.load();
|
|
432
|
+
return renderPageResult({
|
|
433
|
+
adapter,
|
|
434
|
+
chain: [notFound],
|
|
435
|
+
data: null,
|
|
436
|
+
clientEntry,
|
|
437
|
+
routeId: "_404",
|
|
438
|
+
status: 404,
|
|
439
|
+
...preloadOf("_404"),
|
|
440
|
+
...stylesOf("_404"),
|
|
441
|
+
prerenderedPaths: options.prerenderedPaths ?? [],
|
|
442
|
+
...titleOption,
|
|
443
|
+
});
|
|
444
|
+
};
|
|
445
|
+
for (const route of manifest.routes) {
|
|
446
|
+
// A dynamic route whose `getStaticPaths` declared `fallback: "404"` — only its prerendered paths
|
|
447
|
+
// exist; anything else 404s (computed once per route, not per request).
|
|
448
|
+
const is404Fallback = options.staticFallbacks?.[route.pattern] === "404";
|
|
449
|
+
app.register("GET", route.pattern, undefined, async (c) => {
|
|
450
|
+
// Enforce `fallback: "404"` before any work: an unlisted path under this route doesn't exist.
|
|
451
|
+
// Covers hard navigation directly; a client soft-nav's data fetch gets the 404, throws, and the
|
|
452
|
+
// history layer falls back to a full-page navigation (which lands here again, as a document).
|
|
453
|
+
if (is404Fallback && !prerenderedSet.has(new URL(c.req.url).pathname)) {
|
|
454
|
+
return renderNotFound();
|
|
455
|
+
}
|
|
456
|
+
const mod = await route.load();
|
|
457
|
+
const draft = await draftFlag(c.req);
|
|
458
|
+
let data;
|
|
459
|
+
try {
|
|
460
|
+
data = mod.loader
|
|
461
|
+
? await mod.loader({ params: c.params, request: c.req, api, env: c.env, draft })
|
|
462
|
+
: null;
|
|
463
|
+
}
|
|
464
|
+
catch (err) {
|
|
465
|
+
// A thrown `Response` is a control-flow signal (a guard's `redirect(...)`, an explicit error
|
|
466
|
+
// response) — let it propagate to core, which returns it as-is. Real errors render the nearest
|
|
467
|
+
// `_error` boundary, if any; with none, rethrow (unchanged 500 behavior).
|
|
468
|
+
if (err instanceof Response)
|
|
469
|
+
throw err;
|
|
470
|
+
// Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
|
|
471
|
+
if (options.onLoaderError !== undefined) {
|
|
472
|
+
try {
|
|
473
|
+
options.onLoaderError(err, { request: c.req, params: c.params, route: route.pattern });
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
// A faulty reporter must never break error rendering.
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const errorId = route.errorIds?.at(-1);
|
|
480
|
+
if (errorId === undefined)
|
|
481
|
+
throw err;
|
|
482
|
+
// A soft-nav data fetch can't render a boundary — 500 so the client falls back to a full-page
|
|
483
|
+
// navigation, which lands here as a document and renders the `_error` page.
|
|
484
|
+
if (c.req.headers.get(DATA_HEADER) !== null) {
|
|
485
|
+
return new Response("Internal Server Error", {
|
|
486
|
+
status: 500,
|
|
487
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
return renderError(route, errorId, err);
|
|
491
|
+
}
|
|
492
|
+
// Client-side navigation asks (via the X-Nifra-Data header) for just the loader data — no full
|
|
493
|
+
// document, no layout chain. A route with deferred data streams NDJSON (critical data first,
|
|
494
|
+
// then each deferred value as it settles); otherwise one JSON (the fast path). Same loader,
|
|
495
|
+
// same auth — only the transport differs.
|
|
496
|
+
if (c.req.headers.get(DATA_HEADER) !== null) {
|
|
497
|
+
const { forClient, deferred } = prepareDeferred(data);
|
|
498
|
+
if (deferred.length === 0)
|
|
499
|
+
return Response.json(data ?? null);
|
|
500
|
+
return new Response(ndjsonStream(forClient, deferred), {
|
|
501
|
+
headers: { "content-type": "application/x-ndjson; charset=utf-8" },
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
const chain = [...(await loadLayouts(route)), mod.default];
|
|
505
|
+
const head = resolveMeta(mod.meta, { data, params: c.params });
|
|
506
|
+
const hydrateRoute = mod.hydrate !== false;
|
|
507
|
+
try {
|
|
508
|
+
// `await` so a shell-render throw (renderToStream rejects before any byte) is caught here and
|
|
509
|
+
// can render the `_error` page — not just a loader throw. Mid-stream (post-shell) throws can't
|
|
510
|
+
// be recovered to a full page; the per-adapter client boundary catches client render errors.
|
|
511
|
+
return await renderPageResult({
|
|
512
|
+
adapter,
|
|
513
|
+
chain,
|
|
514
|
+
data,
|
|
515
|
+
head,
|
|
516
|
+
clientEntry,
|
|
517
|
+
routeId: route.id,
|
|
518
|
+
hydrate: hydrateRoute,
|
|
519
|
+
...preloadOf(route.id),
|
|
520
|
+
...stylesOf(route.id),
|
|
521
|
+
prerenderedPaths: options.prerenderedPaths ?? [],
|
|
522
|
+
...(mod.revalidate !== undefined ? { revalidate: mod.revalidate } : {}),
|
|
523
|
+
...(mod.islandScripts !== undefined ? { islandScripts: mod.islandScripts } : {}),
|
|
524
|
+
...titleOption,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
if (err instanceof Response)
|
|
529
|
+
throw err;
|
|
530
|
+
// Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
|
|
531
|
+
if (options.onLoaderError !== undefined) {
|
|
532
|
+
try {
|
|
533
|
+
options.onLoaderError(err, { request: c.req, params: c.params, route: route.pattern });
|
|
534
|
+
}
|
|
535
|
+
catch {
|
|
536
|
+
// A faulty reporter must never break error rendering.
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const errorId = route.errorIds?.at(-1);
|
|
540
|
+
if (errorId === undefined)
|
|
541
|
+
throw err;
|
|
542
|
+
return renderError(route, errorId, err);
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
// POST runs the route's `action` (mutation). A `Response` return (e.g. a `redirect(...)`)
|
|
546
|
+
// passes straight through; a data return re-renders the page (the loader re-runs for fresh
|
|
547
|
+
// data) with `actionData`. Routes without an action reject POST with 405 — not a stray 404.
|
|
548
|
+
app.register("POST", route.pattern, undefined, async (c) => {
|
|
549
|
+
const mod = await route.load();
|
|
550
|
+
const draft = await draftFlag(c.req);
|
|
551
|
+
if (mod.action === undefined) {
|
|
552
|
+
return new Response("Method Not Allowed", {
|
|
553
|
+
status: 405,
|
|
554
|
+
headers: { allow: "GET", "content-type": "text/plain; charset=utf-8" },
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
const result = await mod.action({ params: c.params, request: c.req, api, env: c.env, draft });
|
|
558
|
+
const isDataRequest = c.req.headers.get(DATA_HEADER) !== null;
|
|
559
|
+
// An action may wrap its data in `revalidate(paths, data)` to declare which routes it changed.
|
|
560
|
+
// Unwrap to the inner data; the paths ride the `X-Nifra-Revalidate` header on the data-mode
|
|
561
|
+
// responses (the client acts on them — a full-page POST re-runs loaders inline, so no header).
|
|
562
|
+
const isRevalidate = result !== null && typeof result === "object" && "__nifraRevalidate" in result;
|
|
563
|
+
const actionResult = isRevalidate ? result.data : result;
|
|
564
|
+
const revalidateHeader = isRevalidate
|
|
565
|
+
? { [REVALIDATE_HEADER]: result.__nifraRevalidate.join(",") }
|
|
566
|
+
: {};
|
|
567
|
+
if (actionResult instanceof Response) {
|
|
568
|
+
// Client submit can't read a 3xx Location (fetch follows it to HTML), so convey the
|
|
569
|
+
// redirect via a header on a 204 and let the client navigate. Native forms get the 3xx.
|
|
570
|
+
if (isDataRequest && actionResult.status >= 300 && actionResult.status < 400) {
|
|
571
|
+
const location = actionResult.headers.get("location") ?? "/";
|
|
572
|
+
return new Response(null, { status: 204, headers: { [REDIRECT_HEADER]: location } });
|
|
573
|
+
}
|
|
574
|
+
return actionResult;
|
|
575
|
+
}
|
|
576
|
+
// Client submit wants just the action's data (it revalidates the loader itself); a native
|
|
577
|
+
// form POST re-renders the full page (loader re-runs) with the action data.
|
|
578
|
+
if (isDataRequest) {
|
|
579
|
+
// An action may `defer()` slow parts of its result — stream them (critical data first, then
|
|
580
|
+
// each deferred as it settles) exactly like a loader; a non-deferred action returns one JSON.
|
|
581
|
+
const { forClient, deferred } = prepareDeferred(actionResult);
|
|
582
|
+
if (deferred.length === 0)
|
|
583
|
+
return Response.json(actionResult ?? null, { headers: revalidateHeader });
|
|
584
|
+
return new Response(ndjsonStream(forClient, deferred), {
|
|
585
|
+
headers: { "content-type": "application/x-ndjson; charset=utf-8", ...revalidateHeader },
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
const data = mod.loader
|
|
589
|
+
? await mod.loader({ params: c.params, request: c.req, api, env: c.env, draft })
|
|
590
|
+
: null;
|
|
591
|
+
const chain = [...(await loadLayouts(route)), mod.default];
|
|
592
|
+
const head = resolveMeta(mod.meta, { data, params: c.params });
|
|
593
|
+
const hydrateRoute = mod.hydrate !== false;
|
|
594
|
+
// A full-page POST streams the action's `defer()`'d parts mid-document behind `<Await>` too —
|
|
595
|
+
// `renderPage` splits `actionData` like loader data (works with JS off; hydrates after).
|
|
596
|
+
return renderPageResult({
|
|
597
|
+
adapter,
|
|
598
|
+
chain,
|
|
599
|
+
data,
|
|
600
|
+
actionData: actionResult,
|
|
601
|
+
head,
|
|
602
|
+
clientEntry,
|
|
603
|
+
routeId: route.id,
|
|
604
|
+
hydrate: hydrateRoute,
|
|
605
|
+
...preloadOf(route.id),
|
|
606
|
+
...stylesOf(route.id),
|
|
607
|
+
prerenderedPaths: options.prerenderedPaths ?? [],
|
|
608
|
+
...titleOption,
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
// Wildcard catch-all: unmatched paths render `_404` (404), or a plain text 404 if absent.
|
|
613
|
+
app.register("GET", "/*", undefined, () => renderNotFound());
|
|
614
|
+
return app;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Codegen: emit a client-entry module (as source) that lazily imports each route's layout chain
|
|
618
|
+
* (so `Bun.build` with `splitting` code-splits one chunk per route), builds a `patterns` list,
|
|
619
|
+
* then creates the agnostic router store (with a `loadModule` hook), installs history + form
|
|
620
|
+
* interception, loads the initial route's chunk, and hydrates the adapter's stateful Router. The
|
|
621
|
+
* initial route is derived from the URL (falling back to the server-injected route id, e.g.
|
|
622
|
+
* `_404`), so after hydration the Router owns navigation and swaps routes without full reloads.
|
|
623
|
+
* Bun has no `import.meta.glob`, so file-based routing needs this. Write the result to a file and
|
|
624
|
+
* bundle it with `buildClient` / `Bun.build` (+ the adapter's transform).
|
|
625
|
+
*/
|
|
626
|
+
export function generateClientEntry(manifest, options) {
|
|
627
|
+
const { clientModule, resolve } = options;
|
|
628
|
+
const loaderRows = [];
|
|
629
|
+
const patternRows = [];
|
|
630
|
+
// Routes whose loader appends a nearest `_error` module (LAST) — the client wraps the page in the
|
|
631
|
+
// adapter's `errorBoundary(fallback)` for these, so a client render error shows the `_error` UI.
|
|
632
|
+
const errorRouteIds = [];
|
|
633
|
+
// Lazy loader returns the raw modules (for both the component chain + the page's `meta` export).
|
|
634
|
+
const lazyLoader = (files) => {
|
|
635
|
+
const imports = files.map((f) => `import(${JSON.stringify(resolve(f))})`).join(", ");
|
|
636
|
+
return `() => Promise.all([${imports}])`;
|
|
637
|
+
};
|
|
638
|
+
for (const route of manifest.routes) {
|
|
639
|
+
// layoutIds only reference layouts present in the manifest (buildManifest invariant).
|
|
640
|
+
const files = [
|
|
641
|
+
...route.layoutIds.map((id) => manifest.layouts[id].file),
|
|
642
|
+
route.file,
|
|
643
|
+
];
|
|
644
|
+
// Append the nearest `_error` file LAST, so loadModule can pull it off the tail and wrap the page.
|
|
645
|
+
const nearestErrorId = route.errorIds?.at(-1);
|
|
646
|
+
const errorFile = nearestErrorId === undefined ? undefined : manifest.errors?.[nearestErrorId]?.file;
|
|
647
|
+
if (errorFile !== undefined) {
|
|
648
|
+
files.push(errorFile);
|
|
649
|
+
errorRouteIds.push(route.id);
|
|
650
|
+
}
|
|
651
|
+
loaderRows.push(` ${JSON.stringify(route.id)}: ${lazyLoader(files)},`);
|
|
652
|
+
patternRows.push(` { routeId: ${JSON.stringify(route.id)}, pattern: ${JSON.stringify(route.pattern)} },`);
|
|
653
|
+
}
|
|
654
|
+
if (manifest.notFound !== undefined) {
|
|
655
|
+
loaderRows.push(` "_404": ${lazyLoader([manifest.notFound.file])},`);
|
|
656
|
+
}
|
|
657
|
+
return `${[
|
|
658
|
+
'import { createClientRouter, createMatcher, resolveMeta } from "@nifrajs/web"',
|
|
659
|
+
'import { applyHead, installForms, installHistory } from "@nifrajs/web/client"',
|
|
660
|
+
// Namespace import: `errorBoundary` is optional (an adapter may not export it). A namespace member
|
|
661
|
+
// access yields `undefined` if absent — unlike a named import, which would be a link error.
|
|
662
|
+
`import * as __adapter from ${JSON.stringify(clientModule)}`,
|
|
663
|
+
"const { mountRouter } = __adapter",
|
|
664
|
+
"const errorBoundary = __adapter.errorBoundary",
|
|
665
|
+
`const errorRouteIds = new Set(${JSON.stringify(errorRouteIds)})`,
|
|
666
|
+
// Each route is a lazy loader: dynamic imports → Bun.build (splitting) emits one chunk per
|
|
667
|
+
// route, shared layouts/deps deduped into shared chunks, so a route's code loads only when
|
|
668
|
+
// visited. loadModule caches the [layouts…, page] component chain + the page's meta per id.
|
|
669
|
+
"const loaders = {",
|
|
670
|
+
...loaderRows,
|
|
671
|
+
"}",
|
|
672
|
+
"const chains = {}",
|
|
673
|
+
"const metas = {}",
|
|
674
|
+
"const loadModule = async (id) => {",
|
|
675
|
+
" if (chains[id]) return",
|
|
676
|
+
" const mods = await loaders[id]()",
|
|
677
|
+
// For an error route the `_error` module is appended LAST: wrap the page (now second-to-last) in
|
|
678
|
+
// the adapter's boundary so a client render error renders the `_error` UI. DOM-transparent, so the
|
|
679
|
+
// hydrated tree matches the SSR markup (which has no boundary). Falls back to the plain chain when
|
|
680
|
+
// the adapter has no `errorBoundary`.
|
|
681
|
+
" if (errorBoundary && errorRouteIds.has(id)) {",
|
|
682
|
+
" const fallback = mods[mods.length - 1].default",
|
|
683
|
+
" const page = mods[mods.length - 2].default",
|
|
684
|
+
" const layouts = mods.slice(0, mods.length - 2).map((m) => m.default)",
|
|
685
|
+
" chains[id] = [...layouts, errorBoundary(fallback), page]",
|
|
686
|
+
" metas[id] = mods[mods.length - 2].meta",
|
|
687
|
+
" } else {",
|
|
688
|
+
" chains[id] = mods.map((m) => m.default)",
|
|
689
|
+
" metas[id] = mods[mods.length - 1].meta",
|
|
690
|
+
" }",
|
|
691
|
+
"}",
|
|
692
|
+
"const patterns = [",
|
|
693
|
+
...patternRows,
|
|
694
|
+
"]",
|
|
695
|
+
// Derive the initial route from the URL (correct on refresh/deep-link); fall back to the
|
|
696
|
+
// server-injected route id for non-pattern routes (e.g. _404, which matches nothing).
|
|
697
|
+
"const matched = createMatcher(patterns)(location.pathname)",
|
|
698
|
+
// Map any `{__nifra_deferred: id}` placeholder in the SSR data to the registry's promise, so the
|
|
699
|
+
// component receives real promises to `<Await>` (a no-op when a page has no deferred data).
|
|
700
|
+
MAP_DEFERRED_SOURCE,
|
|
701
|
+
"const initial = {",
|
|
702
|
+
` routeId: matched ? matched.routeId : (window.${ROUTE_GLOBAL} ?? ""),`,
|
|
703
|
+
" params: matched ? matched.params : {},",
|
|
704
|
+
" path: location.pathname,",
|
|
705
|
+
` data: mapDeferred(window.${DATA_GLOBAL}),`,
|
|
706
|
+
// actionData (only set after a form POST) is in the initial state so the binding hydrates
|
|
707
|
+
// consistently with the server-rendered markup; mapped through `mapDeferred` too so a deferred
|
|
708
|
+
// action's placeholders become registry markers (a no-op when the action didn't defer).
|
|
709
|
+
` actionData: mapDeferred(window.${ACTION_GLOBAL}),`,
|
|
710
|
+
" pending: false,",
|
|
711
|
+
"}",
|
|
712
|
+
"const router = createClientRouter({ patterns, initial, loadModule })",
|
|
713
|
+
"installHistory(router)",
|
|
714
|
+
"installForms(router)",
|
|
715
|
+
'const root = document.getElementById("root")',
|
|
716
|
+
// Load the initial route's chunk, then hydrate the Router (chain is cached). The initial head
|
|
717
|
+
// is server-rendered; subsequent navigations update it from the matched route's meta + data.
|
|
718
|
+
"if (root) loadModule(initial.routeId).then(() => {",
|
|
719
|
+
" mountRouter({ router, routes: chains, container: root })",
|
|
720
|
+
" router.subscribe(() => {",
|
|
721
|
+
" const s = router.snapshot()",
|
|
722
|
+
" if (!s.pending) applyHead(resolveMeta(metas[s.routeId], { data: s.data, params: s.params }))",
|
|
723
|
+
" })",
|
|
724
|
+
"})",
|
|
725
|
+
].join("\n")}\n`;
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Codegen: emit a **server manifest** module (as source) for disk-less edge runtimes (Cloudflare
|
|
729
|
+
* Workers, …) — and, with a `target`, any portable server bundle. `discoverRoutes` scans `node:fs`
|
|
730
|
+
* and dynamic-imports each route by a *runtime* path — neither exists on workerd. This instead emits
|
|
731
|
+
* **statically-analyzable** imports of every route/layout/`_error`/`_404` (so the bundler includes them) and
|
|
732
|
+
* rebuilds the manifest with `buildManifest` — the SAME pure logic `discoverRoutes` feeds, so patterns
|
|
733
|
+
* + layout chains are identical. Eager (`import * as`) by default; `lazy` emits `() => import(...)` so
|
|
734
|
+
* a code-splitting bundler chunks per route. The emitted module exports `manifest` (consumed by
|
|
735
|
+
* `createWebApp`, unchanged) + `clientEntry` (baked). Write it to a file and bundle it into the worker
|
|
736
|
+
* entry (see `buildServer` in `@nifrajs/web/build`).
|
|
737
|
+
*/
|
|
738
|
+
export function generateServerManifest(manifest, options) {
|
|
739
|
+
const { resolve, clientEntry, lazy = false } = options;
|
|
740
|
+
// Every unique source file in the manifest (routes + layouts + `_error` + `_404`), sorted for stable output.
|
|
741
|
+
const files = [
|
|
742
|
+
...new Set([
|
|
743
|
+
...manifest.routes.map((r) => r.file),
|
|
744
|
+
...Object.values(manifest.layouts).map((l) => l.file),
|
|
745
|
+
...Object.values(manifest.errors ?? {}).map((e) => e.file),
|
|
746
|
+
...(manifest.notFound ? [manifest.notFound.file] : []),
|
|
747
|
+
]),
|
|
748
|
+
].sort();
|
|
749
|
+
const header = [
|
|
750
|
+
"// GENERATED by @nifrajs/web generateServerManifest — route manifest for the disk-less edge",
|
|
751
|
+
"// (no filesystem; route imports are static specifiers the bundler resolves). buildManifest is",
|
|
752
|
+
"// the same pure logic discoverRoutes feeds, so patterns + layout chains match exactly.",
|
|
753
|
+
'import { buildManifest } from "@nifrajs/web"',
|
|
754
|
+
];
|
|
755
|
+
const clientEntryLine = `export const clientEntry = ${JSON.stringify(clientEntry)}`;
|
|
756
|
+
if (lazy) {
|
|
757
|
+
// Lazy: `() => import("./routes/x")` per route (static specifier → one chunk per route under a
|
|
758
|
+
// code-splitting bundler, loaded on first request). The map keys are the route-relative paths
|
|
759
|
+
// `buildManifest` expects; the importer it builds calls the per-file loader.
|
|
760
|
+
const loaders = files.map((file) => ` ${JSON.stringify(file)}: () => import(${JSON.stringify(resolve(file))}),`);
|
|
761
|
+
return `${[
|
|
762
|
+
...header,
|
|
763
|
+
"const loaders = {",
|
|
764
|
+
...loaders,
|
|
765
|
+
"}",
|
|
766
|
+
clientEntryLine,
|
|
767
|
+
"export const manifest = buildManifest(Object.keys(loaders), (file) => () => loaders[file]())",
|
|
768
|
+
].join("\n")}\n`;
|
|
769
|
+
}
|
|
770
|
+
// Eager: `import * as` per route (all bundled into the entry, parsed at boot). Index-based
|
|
771
|
+
// identifiers are collision-proof regardless of filename.
|
|
772
|
+
const imports = files.map((file, i) => `import * as m${i} from ${JSON.stringify(resolve(file))}`);
|
|
773
|
+
const entries = files.map((file, i) => ` ${JSON.stringify(file)}: m${i},`);
|
|
774
|
+
return `${[
|
|
775
|
+
...header,
|
|
776
|
+
...imports,
|
|
777
|
+
"const modules = {",
|
|
778
|
+
...entries,
|
|
779
|
+
"}",
|
|
780
|
+
clientEntryLine,
|
|
781
|
+
"export const manifest = buildManifest(Object.keys(modules), (file) => () => Promise.resolve(modules[file]))",
|
|
782
|
+
].join("\n")}\n`;
|
|
783
|
+
}
|
|
784
|
+
//# sourceMappingURL=index.js.map
|