@nifrajs/web 2.14.1 → 3.1.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/boundary.d.ts +158 -0
- package/dist/boundary.d.ts.map +1 -0
- package/dist/boundary.js +237 -0
- package/dist/boundary.js.map +1 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +5 -3
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +31 -13
- package/dist/build.js.map +1 -1
- package/dist/index.d.ts +30 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +163 -51
- package/dist/index.js.map +1 -1
- package/dist/internal/fetcher-runtime.d.ts +19 -0
- package/dist/internal/fetcher-runtime.d.ts.map +1 -0
- package/dist/internal/fetcher-runtime.js +26 -0
- package/dist/internal/fetcher-runtime.js.map +1 -0
- package/dist/internal/identity-policy.d.ts +51 -0
- package/dist/internal/identity-policy.d.ts.map +1 -0
- package/dist/internal/identity-policy.js +67 -0
- package/dist/internal/identity-policy.js.map +1 -0
- package/dist/internal/parity.d.ts +97 -8
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +281 -47
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/query-runtime.d.ts +8 -0
- package/dist/internal/query-runtime.d.ts.map +1 -0
- package/dist/internal/query-runtime.js +29 -0
- package/dist/internal/query-runtime.js.map +1 -0
- package/dist/islands.d.ts +2 -2
- package/dist/islands.d.ts.map +1 -1
- package/dist/islands.js +17 -36
- package/dist/islands.js.map +1 -1
- package/dist/manifest.d.ts +41 -6
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js.map +1 -1
- package/dist/render-seam.d.ts +5 -0
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +10 -0
- package/dist/render-seam.js.map +1 -1
- package/dist/router.d.ts +10 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +233 -26
- package/dist/router.js.map +1 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +8 -6
- package/dist/vite.js.map +1 -1
- package/package.json +13 -2
- package/src/boundary.ts +407 -0
- package/src/build-vite.ts +5 -3
- package/src/build.ts +36 -14
- package/src/index.ts +235 -49
- package/src/internal/fetcher-runtime.ts +36 -0
- package/src/internal/identity-policy.ts +94 -0
- package/src/internal/parity.ts +409 -57
- package/src/internal/query-runtime.ts +31 -0
- package/src/islands.ts +22 -41
- package/src/manifest.ts +50 -3
- package/src/render-seam.ts +6 -0
- package/src/router.ts +271 -29
- package/src/vite.ts +8 -6
package/dist/index.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* of DOM types: it is pure server orchestration + string building.
|
|
9
9
|
*/
|
|
10
10
|
import { NIFRA_BACKEND_MOUNT, } from "@nifrajs/core/mount";
|
|
11
|
-
import { server, urlPartsOf } from "@nifrajs/core/server";
|
|
11
|
+
import { isSameOriginPath, server, status as statusResult, urlPartsOf, } from "@nifrajs/core/server";
|
|
12
|
+
import { boundaryDescriptors, resolveStaticBoundaries, startDynamicBoundaries, } from "./boundary.js";
|
|
12
13
|
import { DEFERRED_ERROR_CODE, DEFERRED_RUNTIME, defer, MAP_DEFERRED_SOURCE, ndjsonStream, prepareDeferred, } from "./deferred.js";
|
|
13
14
|
import { isDraftEnabled } from "./draft.js";
|
|
14
15
|
import { trustedHeadAttributes } from "./internal/head-attributes.js";
|
|
@@ -20,10 +21,11 @@ import { jsStringLiteral } from "./internal/js-string.js";
|
|
|
20
21
|
import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./internal/script-types.js";
|
|
21
22
|
import { ISR_REVALIDATE_HEADER, ISR_REVALIDATE_TAGS_HEADER, serializeISRTags } from "./isr.js";
|
|
22
23
|
import { generateLlmsTxt } from "./llms-txt.js";
|
|
23
|
-
import { ACTION_GLOBAL, DATA_GLOBAL, LAYOUT_DATA_GLOBAL, ROOT_ATTRIBUTE, ROUTE_GLOBAL, } from "./render-seam.js";
|
|
24
|
+
import { ACTION_GLOBAL, BOUNDARY_GLOBAL, DATA_GLOBAL, LAYOUT_DATA_GLOBAL, ROOT_ATTRIBUTE, ROUTE_GLOBAL, } from "./render-seam.js";
|
|
24
25
|
import { createMatcher, DATA_HEADER, NAV_FROM_HEADER, PRERENDERED_GLOBAL, REDIRECT_HEADER, RETAIN_HEADER, REVALIDATE_HEADER, STATUS_HEADER, } from "./router.js";
|
|
25
26
|
import { searchOf, searchOfChain } from "./search.js";
|
|
26
27
|
// Draft / preview mode - a signed cookie that flips `ctx.draft` for loaders + bypasses ISR for editors.
|
|
28
|
+
export { assertStaticBoundaryImports, boundaryDescriptors, boundaryModeKey, MemoryStaticBoundaryCache, resolveDynamicBoundaries, resolveStaticBoundaries, startDynamicBoundaries, } from "./boundary.js";
|
|
27
29
|
export { DRAFT_COOKIE, disableDraft, enableDraft, isDraftEnabled, previewEndpoint, } from "./draft.js";
|
|
28
30
|
// Font optimization - a CLS-safe `@font-face` generator + a preload `<link>` for self-hosted fonts.
|
|
29
31
|
export { fontFace, fontPreload, } from "./fonts.js";
|
|
@@ -46,7 +48,7 @@ export { createClientRouter, createMatcher, DATA_HEADER, NAV_FROM_HEADER, REDIRE
|
|
|
46
48
|
// Deferred loader data (`defer()` + the `Deferred<T>` type) - consumed by the adapter's `<Await>`.
|
|
47
49
|
export { defer, mergeHeads, resolveMeta };
|
|
48
50
|
export { assertRenderAdapterConformance, RenderAdapterConformanceError, } from "./conformance.js";
|
|
49
|
-
export { ACTION_GLOBAL, DATA_GLOBAL, LAYOUT_DATA_GLOBAL, ROOT_ATTRIBUTE, ROUTE_GLOBAL, setSsrModuleLoader, ssrModuleLoader, } from "./render-seam.js";
|
|
51
|
+
export { ACTION_GLOBAL, BOUNDARY_GLOBAL, DATA_GLOBAL, LAYOUT_DATA_GLOBAL, ROOT_ATTRIBUTE, ROUTE_GLOBAL, setSsrModuleLoader, ssrModuleLoader, } from "./render-seam.js";
|
|
50
52
|
// The search-derivation primitive: parse a URL query, validate it against a route's `searchSchema`
|
|
51
53
|
// (fail-closed to defaults), or return the raw parsed query when there is none. Both the server (loader
|
|
52
54
|
// ctx + `renderPage`) and a client adapter mount call this with the same URL + schema, so the two sides
|
|
@@ -95,6 +97,17 @@ const SCRIPT_ESCAPE_MAP = {
|
|
|
95
97
|
};
|
|
96
98
|
const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body");
|
|
97
99
|
const RESPONSE_RESULT = Symbol.for("nifra.response.result");
|
|
100
|
+
/**
|
|
101
|
+
* A control-flow value that renders as plain data - what `redirect()` returns, and what core's
|
|
102
|
+
* `status(...)` returns. Recognized here rather than imported: core keeps its own predicate internal,
|
|
103
|
+
* and the registry symbol is the contract between them (two copies of core must still agree).
|
|
104
|
+
*/
|
|
105
|
+
const isResponseResult = (value) => typeof value === "object" &&
|
|
106
|
+
value !== null &&
|
|
107
|
+
value[RESPONSE_RESULT] === true &&
|
|
108
|
+
typeof value.toResponse === "function";
|
|
109
|
+
/** A returned/thrown value that ends the request as-is: either shape of control-flow signal. */
|
|
110
|
+
const isControlFlow = (value) => value instanceof Response || isResponseResult(value);
|
|
98
111
|
/**
|
|
99
112
|
* Server: render a full HTML document for a page - the adapter's hydration head + the SSR
|
|
100
113
|
* markup (**streamed**) + the serialized loader data + the client module - as a `Response`.
|
|
@@ -107,7 +120,7 @@ export function renderPage(options) {
|
|
|
107
120
|
return page instanceof Promise ? page.then((p) => p.toResponse()) : page.toResponse();
|
|
108
121
|
}
|
|
109
122
|
export function renderPageResult(options) {
|
|
110
|
-
const { adapter, chain, data, actionData, clientEntry, preload = [], styles = [], prerenderedPaths = [], revalidate, revalidateTags = [], routeId, status = 200, title = "nifra", head, rootId = "root", hydrate = true, islandScripts = [], nonce, headers: extraHeaders, } = options;
|
|
123
|
+
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;
|
|
111
124
|
if (nonce !== undefined && nonce.trim() === "") {
|
|
112
125
|
throw new TypeError("[nifra/web] renderPage nonce must be non-empty when provided");
|
|
113
126
|
}
|
|
@@ -138,11 +151,19 @@ export function renderPageResult(options) {
|
|
|
138
151
|
const actionSplit = actionData === undefined
|
|
139
152
|
? undefined
|
|
140
153
|
: prepareDeferred(actionData, deferred.length + layoutDeferred.length);
|
|
154
|
+
const boundarySplit = boundaries === undefined
|
|
155
|
+
? undefined
|
|
156
|
+
: prepareDeferred(boundaries, deferred.length + layoutDeferred.length + (actionSplit?.deferred.length ?? 0));
|
|
141
157
|
// On the common page-only path (no layout loader, no action) reuse `deferred` directly instead of
|
|
142
158
|
// allocating a fresh spread array every request.
|
|
143
|
-
const allDeferred = layoutDeferred.length === 0 && actionSplit === undefined
|
|
159
|
+
const allDeferred = layoutDeferred.length === 0 && actionSplit === undefined && boundarySplit === undefined
|
|
144
160
|
? deferred
|
|
145
|
-
: [
|
|
161
|
+
: [
|
|
162
|
+
...deferred,
|
|
163
|
+
...layoutDeferred,
|
|
164
|
+
...(actionSplit ? actionSplit.deferred : []),
|
|
165
|
+
...(boundarySplit ? boundarySplit.deferred : []),
|
|
166
|
+
];
|
|
146
167
|
// Omitted entirely when no layout has a loader - a page-only app emits exactly what it did before.
|
|
147
168
|
const layoutTail = options.layoutData === undefined
|
|
148
169
|
? ""
|
|
@@ -151,6 +172,9 @@ export function renderPageResult(options) {
|
|
|
151
172
|
const action = actionSplit === undefined
|
|
152
173
|
? ""
|
|
153
174
|
: `window.${ACTION_GLOBAL}=${serializeData(actionSplit.forClient)};`;
|
|
175
|
+
const boundaryTail = boundarySplit === undefined
|
|
176
|
+
? ""
|
|
177
|
+
: `window.${BOUNDARY_GLOBAL}=${serializeData(boundarySplit.forClient)};`;
|
|
154
178
|
const deferredRuntime = allDeferred.length > 0 ? `<script${nonceAttr}>${DEFERRED_RUNTIME}</script>` : "";
|
|
155
179
|
// The regex only injects the CSP nonce into the (constant) hydration head; with no nonce it's a
|
|
156
180
|
// no-op that still scans the whole script every request. Skip it on the common no-nonce path.
|
|
@@ -230,7 +254,7 @@ export function renderPageResult(options) {
|
|
|
230
254
|
// Tail - the loader-data globals + the client module. Module scripts defer (run after parse), so
|
|
231
255
|
// the data global + every streamed deferred resolution are set before the entry hydrates.
|
|
232
256
|
const tailHtml = hydrate
|
|
233
|
-
? `${slot.tailPre}${action}${slot.tailMid}${layoutTail}${slot.tailData}${serializeData(forClient)}${slot.tailPost}`
|
|
257
|
+
? `${slot.tailPre}${action}${slot.tailMid}${layoutTail}${boundaryTail}${slot.tailData}${serializeData(forClient)}${slot.tailPost}`
|
|
234
258
|
: slot.tailPost;
|
|
235
259
|
const headers = { "content-type": "text/html; charset=utf-8" };
|
|
236
260
|
// Caller-supplied headers (a terminal status page's `cache-control`, say). Applied before the
|
|
@@ -265,6 +289,9 @@ export function renderPageResult(options) {
|
|
|
265
289
|
...(options.layoutData !== undefined
|
|
266
290
|
? { layoutData: layoutSplits.map((split) => split.forComponent) }
|
|
267
291
|
: {}),
|
|
292
|
+
...(boundarySplit !== undefined
|
|
293
|
+
? { boundaries: boundarySplit.forComponent }
|
|
294
|
+
: {}),
|
|
268
295
|
};
|
|
269
296
|
// Fast path: nothing `defer()`s and the adapter can render synchronously to a string → buffer the
|
|
270
297
|
// whole document in one pass. Progressive streaming only benefits pages with deferred boundaries
|
|
@@ -376,26 +403,31 @@ function streamDocument(shell, stream, closeRoot, deferred, tail, enc, nonceAttr
|
|
|
376
403
|
});
|
|
377
404
|
}
|
|
378
405
|
/** A same-origin destination is an absolute path: one leading `/`, but NOT `//` (protocol-relative →
|
|
379
|
-
* another origin). Everything else (absolute URL with
|
|
380
|
-
* relative `foo`)
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
}
|
|
406
|
+
* another origin), and nothing a URL parser resolves off-origin. Everything else (absolute URL with
|
|
407
|
+
* a scheme, `//host`, `javascript:`, a bare relative `foo`) requires `external: true`. The predicate
|
|
408
|
+
* lives in the kernel - see `isSameOriginPath` - so this gate and the auth guards' `redirectTo`
|
|
409
|
+
* cannot drift apart. */
|
|
384
410
|
/**
|
|
385
|
-
* Build a redirect
|
|
386
|
-
*
|
|
387
|
-
*
|
|
411
|
+
* Build a redirect - return it from a route `action` for the Post/Redirect/Get pattern (POST
|
|
412
|
+
* mutates, 303 sends the browser to a fresh GET, so a reload doesn't re-submit). Defaults to 303
|
|
413
|
+
* (See Other); pass `{ status: 307 }` or `{ status: 308 }` to preserve the method.
|
|
388
414
|
*
|
|
389
415
|
* **Secure by default:** `location` must be a same-origin path (begins with `/`, not `//`). An
|
|
390
416
|
* off-origin/absolute destination throws unless you pass `{ external: true }` - this closes the
|
|
391
417
|
* open-redirect footgun of `return redirect(formData.get("next"))` on the no-JS (native-form) path,
|
|
392
|
-
* which
|
|
418
|
+
* which serves the action's control-flow value verbatim.
|
|
419
|
+
*
|
|
420
|
+
* Returns a plain render, not a `Response`. A redirect is a status line and one header - the most
|
|
421
|
+
* body-less response there is - and building a `Response` for it costs the whole Web object plus, on
|
|
422
|
+
* Node, a stream drained back out. As data it renders on the same lane a handler's return takes.
|
|
423
|
+
* `redirect(...)` is still returned or thrown from exactly the same places; only `.status` /
|
|
424
|
+
* `.headers` are gone from the value, replaced by `options.headers` and the request's `c.set`.
|
|
393
425
|
*
|
|
394
|
-
* @param options redirect status and whether an off-origin destination is intentional.
|
|
426
|
+
* @param options redirect status, extra headers, and whether an off-origin destination is intentional.
|
|
395
427
|
*/
|
|
396
428
|
export function redirect(location, options = {}) {
|
|
397
429
|
if (options.external !== true && !isSameOriginPath(location)) {
|
|
398
|
-
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.`);
|
|
430
|
+
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.`);
|
|
399
431
|
}
|
|
400
432
|
// Reject CR/LF in the Location explicitly - defense-in-depth (response splitting / header
|
|
401
433
|
// injection). Spec-correct runtimes' Headers setter throws on CR/LF, but `external: true` lets
|
|
@@ -404,7 +436,9 @@ export function redirect(location, options = {}) {
|
|
|
404
436
|
if (/[\r\n]/.test(location)) {
|
|
405
437
|
throw new Error(`[nifra/web] redirect location contains a CR/LF character - refusing to emit a header-injecting redirect.`);
|
|
406
438
|
}
|
|
407
|
-
return
|
|
439
|
+
return statusResult(options.status ?? 303, undefined, {
|
|
440
|
+
headers: options.headers === undefined ? { location } : { ...options.headers, location },
|
|
441
|
+
});
|
|
408
442
|
}
|
|
409
443
|
/**
|
|
410
444
|
* The signatures a **duplicate module instance** produces during SSR, across engines.
|
|
@@ -443,17 +477,34 @@ function withDuplicateInstanceHint(err) {
|
|
|
443
477
|
return augmented;
|
|
444
478
|
}
|
|
445
479
|
/**
|
|
446
|
-
* An action's
|
|
480
|
+
* An action's control-flow value passes straight through - except a redirect on a client-submit data
|
|
447
481
|
* request: fetch would follow the 3xx into HTML the client can't use, so the redirect rides the
|
|
448
|
-
* X-Nifra-Redirect header on a 204 and the client navigates. One conversion shared by the
|
|
449
|
-
*
|
|
482
|
+
* X-Nifra-Redirect header on a 204 and the client navigates. One conversion shared by the returned-
|
|
483
|
+
* and thrown- paths, so `return redirect()` and `throw redirect()` agree.
|
|
484
|
+
*
|
|
485
|
+
* Takes either shape: `redirect()` is a plain render, while a hand-rolled `new Response(...)` from an
|
|
486
|
+
* action still arrives as a `Response`. The rewrite stays on the lane its input was on - a plain
|
|
487
|
+
* redirect converts to a plain 204, and never materializes the `Response` it is replacing.
|
|
450
488
|
*/
|
|
451
|
-
function actionResponse(
|
|
452
|
-
if (isDataRequest
|
|
453
|
-
|
|
454
|
-
|
|
489
|
+
function actionResponse(result, isDataRequest) {
|
|
490
|
+
if (!isDataRequest)
|
|
491
|
+
return result;
|
|
492
|
+
if (isResponseResult(result)) {
|
|
493
|
+
const plain = result.plain;
|
|
494
|
+
// No `plain` means a carrier that only knows how to build a `Response` (not one of ours) - fall
|
|
495
|
+
// back rather than guess at its status.
|
|
496
|
+
if (plain === undefined)
|
|
497
|
+
return actionResponse(result.toResponse(), isDataRequest);
|
|
498
|
+
if (plain.status < 300 || plain.status >= 400)
|
|
499
|
+
return result;
|
|
500
|
+
const location = plain.headers?.location ?? "/";
|
|
501
|
+
return statusResult(204, undefined, { headers: { [REDIRECT_HEADER]: location } });
|
|
455
502
|
}
|
|
456
|
-
|
|
503
|
+
if (result.status >= 300 && result.status < 400) {
|
|
504
|
+
const location = result.headers.get("location") ?? "/";
|
|
505
|
+
return statusResult(204, undefined, { headers: { [REDIRECT_HEADER]: location } });
|
|
506
|
+
}
|
|
507
|
+
return result;
|
|
457
508
|
}
|
|
458
509
|
/**
|
|
459
510
|
* Narrow the route's params to the ones a layout owns.
|
|
@@ -1264,6 +1315,7 @@ export function createWebApp(options) {
|
|
|
1264
1315
|
const draft = await draftFlag(c.req);
|
|
1265
1316
|
let data;
|
|
1266
1317
|
let layoutData;
|
|
1318
|
+
let boundaryStates;
|
|
1267
1319
|
let layoutModules;
|
|
1268
1320
|
let layoutRetained = [];
|
|
1269
1321
|
try {
|
|
@@ -1286,16 +1338,51 @@ export function createWebApp(options) {
|
|
|
1286
1338
|
const run = await runLayoutChain(route, ctx, validatedRetainedIndices(c.req, route, c.params));
|
|
1287
1339
|
layoutModules = run.modules;
|
|
1288
1340
|
layoutRetained = run.retained;
|
|
1341
|
+
const boundaryDefinitions = [
|
|
1342
|
+
...run.modules.flatMap((layout) => layout.boundaries ?? []),
|
|
1343
|
+
...(mod.boundaries ?? []),
|
|
1344
|
+
];
|
|
1345
|
+
if (boundaryDefinitions.length > 0)
|
|
1346
|
+
boundaryDescriptors(boundaryDefinitions);
|
|
1347
|
+
const effectiveSearch = searchChainOf(run.modules, mod, c.req);
|
|
1348
|
+
const boundaryBatch = boundaryDefinitions.length === 0
|
|
1349
|
+
? undefined
|
|
1350
|
+
: startDynamicBoundaries(boundaryDefinitions, {
|
|
1351
|
+
request: c.req,
|
|
1352
|
+
params: c.params,
|
|
1353
|
+
api,
|
|
1354
|
+
env: c.env,
|
|
1355
|
+
draft,
|
|
1356
|
+
search: effectiveSearch,
|
|
1357
|
+
signal: c.req.signal,
|
|
1358
|
+
});
|
|
1359
|
+
const staticBoundaryPromise = boundaryDefinitions.length === 0
|
|
1360
|
+
? Promise.resolve(undefined)
|
|
1361
|
+
: resolveStaticBoundaries(boundaryDefinitions, { phase: "build", origin: originOf(c.req) }, options.staticBoundaryCache);
|
|
1362
|
+
// Boundary data is attached as one Deferred marker per slot. The shell can render
|
|
1363
|
+
// immediately with each slot pending, while the existing deferred protocol emits the first
|
|
1364
|
+
// completed sibling independently instead of waiting for the slowest boundary.
|
|
1289
1365
|
// The page loader gets the EFFECTIVE search (layout chain + page, page-wins), now that the layout
|
|
1290
1366
|
// modules are loaded - so `ctx.search` includes a layout's shared keys (`?org`), matching `useSearch`.
|
|
1291
|
-
const [pageData] = await Promise.all([
|
|
1292
|
-
mod.loader
|
|
1293
|
-
? mod.loader({ ...ctx, search: searchChainOf(run.modules, mod, c.req) })
|
|
1294
|
-
: null,
|
|
1367
|
+
const [pageData, , staticBoundaries] = await Promise.all([
|
|
1368
|
+
mod.loader ? mod.loader({ ...ctx, search: effectiveSearch }) : null,
|
|
1295
1369
|
run.pending, // a non-gate layout loader that rejects must still surface, not go unhandled
|
|
1370
|
+
staticBoundaryPromise,
|
|
1296
1371
|
]);
|
|
1297
1372
|
data = pageData;
|
|
1298
1373
|
layoutData = run.layoutData;
|
|
1374
|
+
if (boundaryBatch !== undefined) {
|
|
1375
|
+
const streamed = { ...boundaryBatch.initial, ...(staticBoundaries ?? {}) };
|
|
1376
|
+
for (const entry of boundaryBatch.pending) {
|
|
1377
|
+
const initial = streamed[entry.name];
|
|
1378
|
+
if (initial !== undefined)
|
|
1379
|
+
streamed[entry.name] = { ...initial, data: defer(entry.promise) };
|
|
1380
|
+
}
|
|
1381
|
+
boundaryStates = streamed;
|
|
1382
|
+
}
|
|
1383
|
+
else if (staticBoundaries !== undefined) {
|
|
1384
|
+
boundaryStates = staticBoundaries;
|
|
1385
|
+
}
|
|
1299
1386
|
}
|
|
1300
1387
|
catch (err) {
|
|
1301
1388
|
// A terminal-status signal (`notFound()` / `gone()` / `statusPage(n)`) renders a boundary at
|
|
@@ -1303,10 +1390,11 @@ export function createWebApp(options) {
|
|
|
1303
1390
|
// is what keeps a hand-rolled `throw new Response(...)` served verbatim, as it always was.
|
|
1304
1391
|
if (isStatusSignal(err))
|
|
1305
1392
|
return renderStatusSignal(c.req, err);
|
|
1306
|
-
// A thrown
|
|
1307
|
-
//
|
|
1308
|
-
// `_error` boundary, if any; with
|
|
1309
|
-
|
|
1393
|
+
// A thrown control-flow signal (a guard's `redirect(...)`, an explicit error response) - let
|
|
1394
|
+
// it propagate to core, which renders it as-is, whether it is a plain render or a
|
|
1395
|
+
// hand-rolled `Response`. Real errors render the nearest `_error` boundary, if any; with
|
|
1396
|
+
// none, rethrow (unchanged 500 behavior).
|
|
1397
|
+
if (isControlFlow(err))
|
|
1310
1398
|
throw err;
|
|
1311
1399
|
// Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
|
|
1312
1400
|
if (options.onLoaderError !== undefined) {
|
|
@@ -1331,13 +1419,13 @@ export function createWebApp(options) {
|
|
|
1331
1419
|
return renderError(route, errorId, withDuplicateInstanceHint(err));
|
|
1332
1420
|
}
|
|
1333
1421
|
// A loader may RETURN its control-flow signal instead of throwing it - `return redirect(...)`
|
|
1334
|
-
// reads naturally and must not silently serialize the
|
|
1335
|
-
//
|
|
1336
|
-
//
|
|
1337
|
-
//
|
|
1422
|
+
// reads naturally and must not silently serialize the signal as loader data. Mirror the catch
|
|
1423
|
+
// above exactly: a status signal renders its boundary, any other signal (a redirect, a
|
|
1424
|
+
// hand-rolled response) passes through to core verbatim - so return and throw are
|
|
1425
|
+
// interchangeable.
|
|
1338
1426
|
if (isStatusSignal(data))
|
|
1339
1427
|
return renderStatusSignal(c.req, data);
|
|
1340
|
-
if (data
|
|
1428
|
+
if (isControlFlow(data))
|
|
1341
1429
|
return data;
|
|
1342
1430
|
// Client-side navigation asks (via the X-Nifra-Data header) for just the loader data - no full
|
|
1343
1431
|
// document, no layout chain. A route with deferred data streams NDJSON (critical data first,
|
|
@@ -1353,20 +1441,28 @@ export function createWebApp(options) {
|
|
|
1353
1441
|
offset += split.deferred.length;
|
|
1354
1442
|
}
|
|
1355
1443
|
const deferred = [...pageSplit.deferred, ...layoutSplits.flatMap((split) => split.deferred)];
|
|
1356
|
-
const
|
|
1444
|
+
const boundarySplit = boundaryStates === undefined
|
|
1445
|
+
? undefined
|
|
1446
|
+
: prepareDeferred(boundaryStates, deferred.length);
|
|
1447
|
+
const allDeferred = [
|
|
1448
|
+
...deferred,
|
|
1449
|
+
...(boundarySplit === undefined ? [] : boundarySplit.deferred),
|
|
1450
|
+
];
|
|
1451
|
+
const payload = layoutData === undefined && boundarySplit === undefined
|
|
1357
1452
|
? pageSplit.forClient
|
|
1358
1453
|
: {
|
|
1359
1454
|
v: 1,
|
|
1360
1455
|
data: pageSplit.forClient,
|
|
1361
1456
|
layoutData: layoutSplits.map((split) => split.forClient),
|
|
1362
1457
|
retained: layoutRetained,
|
|
1458
|
+
...(boundarySplit === undefined ? {} : { boundaries: boundarySplit.forClient }),
|
|
1363
1459
|
};
|
|
1364
1460
|
// Bare value when there is no layout data - the pre-envelope shape, so a client running older
|
|
1365
1461
|
// code against a newer server keeps working.
|
|
1366
|
-
if (
|
|
1462
|
+
if (allDeferred.length === 0) {
|
|
1367
1463
|
return Response.json(payload ?? null);
|
|
1368
1464
|
}
|
|
1369
|
-
return new Response(ndjsonStream(payload,
|
|
1465
|
+
return new Response(ndjsonStream(payload, allDeferred), {
|
|
1370
1466
|
headers: { "content-type": "application/x-ndjson; charset=utf-8" },
|
|
1371
1467
|
});
|
|
1372
1468
|
}
|
|
@@ -1396,6 +1492,7 @@ export function createWebApp(options) {
|
|
|
1396
1492
|
search: searchChainOf(layoutModules ?? [], mod, c.req),
|
|
1397
1493
|
hydrate: hydrateRoute,
|
|
1398
1494
|
...(layoutData !== undefined ? { layoutData } : {}),
|
|
1495
|
+
...(boundaryStates !== undefined ? { boundaries: boundaryStates } : {}),
|
|
1399
1496
|
...preloadOf(route.id),
|
|
1400
1497
|
...stylesOf(route.id),
|
|
1401
1498
|
prerenderedPaths: options.prerenderedPaths ?? [],
|
|
@@ -1410,7 +1507,7 @@ export function createWebApp(options) {
|
|
|
1410
1507
|
// Same precedence as the loader catch above - a `meta()` may signal too.
|
|
1411
1508
|
if (isStatusSignal(err))
|
|
1412
1509
|
return renderStatusSignal(c.req, err);
|
|
1413
|
-
if (err
|
|
1510
|
+
if (isControlFlow(err))
|
|
1414
1511
|
throw err;
|
|
1415
1512
|
// Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
|
|
1416
1513
|
if (options.onLoaderError !== undefined) {
|
|
@@ -1427,7 +1524,7 @@ export function createWebApp(options) {
|
|
|
1427
1524
|
return renderError(route, errorId, withDuplicateInstanceHint(err));
|
|
1428
1525
|
}
|
|
1429
1526
|
});
|
|
1430
|
-
// POST runs the route's `action` (mutation). A
|
|
1527
|
+
// POST runs the route's `action` (mutation). A control-flow return (e.g. a `redirect(...)`)
|
|
1431
1528
|
// passes straight through; a data return re-renders the page (the loader re-runs for fresh
|
|
1432
1529
|
// data) with `actionData`. Routes without an action reject POST with 405 - not a stray 404.
|
|
1433
1530
|
app.register("POST", route.pattern, undefined, async (c) => {
|
|
@@ -1458,10 +1555,10 @@ export function createWebApp(options) {
|
|
|
1458
1555
|
result = await mod.action(actionContext);
|
|
1459
1556
|
}
|
|
1460
1557
|
catch (err) {
|
|
1461
|
-
// A THROWN
|
|
1462
|
-
//
|
|
1463
|
-
//
|
|
1464
|
-
if (err
|
|
1558
|
+
// A THROWN signal is the same control flow as a returned one (`throw redirect()` in a gate
|
|
1559
|
+
// or an action) - route it through the same conversion as the returned branch below, so
|
|
1560
|
+
// throw and return are interchangeable on the mutation path too.
|
|
1561
|
+
if (isControlFlow(err))
|
|
1465
1562
|
return actionResponse(err, isDataRequest);
|
|
1466
1563
|
throw err;
|
|
1467
1564
|
}
|
|
@@ -1473,7 +1570,7 @@ export function createWebApp(options) {
|
|
|
1473
1570
|
const revalidateHeader = isRevalidate
|
|
1474
1571
|
? { [REVALIDATE_HEADER]: result.__nifraRevalidate.join(",") }
|
|
1475
1572
|
: {};
|
|
1476
|
-
if (actionResult
|
|
1573
|
+
if (isControlFlow(actionResult))
|
|
1477
1574
|
return actionResponse(actionResult, isDataRequest);
|
|
1478
1575
|
// Client submit wants just the action's data (it revalidates the loader itself); a native
|
|
1479
1576
|
// form POST re-renders the full page (loader re-runs) with the action data.
|
|
@@ -1648,6 +1745,9 @@ export function generateClientEntry(manifest, options) {
|
|
|
1648
1745
|
// routeId → the page's client-only search keys (`searchClientKeys`, or `[]`). The router reads this
|
|
1649
1746
|
// by reference to decide when a same-route search change can skip the loader fetch (re-render only).
|
|
1650
1747
|
"const searchClientKeys = {}",
|
|
1748
|
+
// routeId → the page's optional post-hydration client loader/action hooks. Hooks are populated only
|
|
1749
|
+
// after the route chunk loads, so routes that do not declare them remain ordinary route modules.
|
|
1750
|
+
"const routeHooks = {}",
|
|
1651
1751
|
"const loadModule = async (id) => {",
|
|
1652
1752
|
" if (chains[id]) return",
|
|
1653
1753
|
" const mods = await loaders[id]()",
|
|
@@ -1676,6 +1776,13 @@ export function generateClientEntry(manifest, options) {
|
|
|
1676
1776
|
" searchSchemas[id] = mods.map((m) => m.searchSchema)",
|
|
1677
1777
|
" searchClientKeys[id] = mods[mods.length - 1].searchClientKeys ?? []",
|
|
1678
1778
|
" }",
|
|
1779
|
+
" const page = errorRouteIds.has(id) ? mods[mods.length - 2] : mods[mods.length - 1]",
|
|
1780
|
+
// Keep interception client-safe: only neutral name/mode metadata crosses into the router. The
|
|
1781
|
+
// server-side boundary load function (which may close over secrets) is never stored in the client
|
|
1782
|
+
// hook table or serialized into a browser bundle by this wiring.
|
|
1783
|
+
" const boundaryMods = errorRouteIds.has(id) ? mods.slice(0, -1) : mods",
|
|
1784
|
+
" const boundaries = boundaryMods.flatMap((m) => (m.boundaries ?? []).map((b) => ({ name: b.name, mode: b.mode, hasLoad: b.load !== undefined, ...(b.errorId === undefined ? {} : { errorId: b.errorId }) })))",
|
|
1785
|
+
" routeHooks[id] = { clientLoader: page.clientLoader, clientAction: page.clientAction, boundaries }",
|
|
1679
1786
|
"}",
|
|
1680
1787
|
"const patterns = [",
|
|
1681
1788
|
...patternRows,
|
|
@@ -1696,6 +1803,7 @@ export function generateClientEntry(manifest, options) {
|
|
|
1696
1803
|
` data: mapDeferred(window.${DATA_GLOBAL}),`,
|
|
1697
1804
|
// Undefined on a page-only app, which is exactly what the router treats as "no layout data".
|
|
1698
1805
|
` layoutData: window.${LAYOUT_DATA_GLOBAL} && window.${LAYOUT_DATA_GLOBAL}.map(mapDeferred),`,
|
|
1806
|
+
` boundaries: mapDeferred(window.${BOUNDARY_GLOBAL}),`,
|
|
1699
1807
|
// actionData (only set after a form POST) is in the initial state so the binding hydrates
|
|
1700
1808
|
// consistently with the server-rendered markup; mapped through `mapDeferred` too so a deferred
|
|
1701
1809
|
// action's placeholders become registry markers (a no-op when the action didn't defer).
|
|
@@ -1703,7 +1811,7 @@ export function generateClientEntry(manifest, options) {
|
|
|
1703
1811
|
" pending: false,",
|
|
1704
1812
|
"}",
|
|
1705
1813
|
`const statusRoutes = ${JSON.stringify(statusRoutes)}`,
|
|
1706
|
-
"const router = createClientRouter({ patterns, initial, loadModule, statusRoutes, searchClientKeys })",
|
|
1814
|
+
"const router = createClientRouter({ patterns, initial, loadModule, statusRoutes, searchClientKeys, routeHooks })",
|
|
1707
1815
|
"installHistory(router)",
|
|
1708
1816
|
"installForms(router)",
|
|
1709
1817
|
// The container is found in the DOM, not baked in. `rootId` is a per-render option and this entry
|
|
@@ -1722,6 +1830,10 @@ export function generateClientEntry(manifest, options) {
|
|
|
1722
1830
|
// is server-rendered; subsequent navigations update it from the matched route's meta + data.
|
|
1723
1831
|
"loadModule(initial.routeId).then(() => {",
|
|
1724
1832
|
" mountRouter({ router, routes: chains, searchSchemas, container: root })",
|
|
1833
|
+
// Run the optional client loader only after the adapter has mounted the SSR tree. The initial
|
|
1834
|
+
// server data is already in `window.__NIFRA_DATA__`, so `serverLoader()` reuses it and cannot
|
|
1835
|
+
// duplicate the first request.
|
|
1836
|
+
" router.hydrate().catch((error) => console.error('[nifra/web] clientLoader failed:', error))",
|
|
1725
1837
|
// Next frame: the adapter has committed its initial hydration, so handlers are attached. Flip the
|
|
1726
1838
|
// document to interactive (`data-nifra-hydrated` + `nifra:hydrated`) for apps to gate pre-hydration UI.
|
|
1727
1839
|
" requestAnimationFrame(signalHydrated)",
|