@nifrajs/web 2.13.0 → 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 +9 -6
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +57 -31
- package/dist/build.js.map +1 -1
- package/dist/fn.d.ts +2 -2
- package/dist/fn.d.ts.map +1 -1
- package/dist/fn.js +2 -2
- package/dist/fn.js.map +1 -1
- package/dist/index.d.ts +30 -20
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +179 -56
- 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 +105 -7
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +303 -56
- 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 +8 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +51 -13
- 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 +61 -31
- package/src/fn.ts +6 -6
- package/src/index.ts +255 -54
- package/src/internal/fetcher-runtime.ts +36 -0
- package/src/internal/identity-policy.ts +94 -0
- package/src/internal/parity.ts +447 -70
- 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 +75 -13
package/src/index.ts
CHANGED
|
@@ -13,7 +13,23 @@ import {
|
|
|
13
13
|
type BackendMountHandler,
|
|
14
14
|
NIFRA_BACKEND_MOUNT,
|
|
15
15
|
} from "@nifrajs/core/mount"
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
isSameOriginPath,
|
|
18
|
+
type ResponseResult,
|
|
19
|
+
type ServerOptions,
|
|
20
|
+
server,
|
|
21
|
+
status as statusResult,
|
|
22
|
+
type UrlParts,
|
|
23
|
+
urlPartsOf,
|
|
24
|
+
} from "@nifrajs/core/server"
|
|
25
|
+
import type { BoundaryRegistration } from "./boundary.ts"
|
|
26
|
+
import {
|
|
27
|
+
type BoundaryRequestCtx,
|
|
28
|
+
type BoundaryStates,
|
|
29
|
+
boundaryDescriptors,
|
|
30
|
+
resolveStaticBoundaries,
|
|
31
|
+
startDynamicBoundaries,
|
|
32
|
+
} from "./boundary.ts"
|
|
17
33
|
import {
|
|
18
34
|
DEFERRED_ERROR_CODE,
|
|
19
35
|
DEFERRED_RUNTIME,
|
|
@@ -51,6 +67,7 @@ import type {
|
|
|
51
67
|
import type { RenderAdapter, RenderProps } from "./render-seam.ts"
|
|
52
68
|
import {
|
|
53
69
|
ACTION_GLOBAL,
|
|
70
|
+
BOUNDARY_GLOBAL,
|
|
54
71
|
DATA_GLOBAL,
|
|
55
72
|
LAYOUT_DATA_GLOBAL,
|
|
56
73
|
ROOT_ATTRIBUTE,
|
|
@@ -69,6 +86,33 @@ import {
|
|
|
69
86
|
import { searchOf, searchOfChain } from "./search.ts"
|
|
70
87
|
|
|
71
88
|
// Draft / preview mode - a signed cookie that flips `ctx.draft` for loaders + bypasses ISR for editors.
|
|
89
|
+
export {
|
|
90
|
+
assertStaticBoundaryImports,
|
|
91
|
+
type Boundary,
|
|
92
|
+
type BoundaryDescriptor,
|
|
93
|
+
type BoundaryError,
|
|
94
|
+
type BoundaryMode,
|
|
95
|
+
type BoundaryRegistration,
|
|
96
|
+
type BoundaryRequestCtx,
|
|
97
|
+
type BoundaryState,
|
|
98
|
+
type BoundaryStates,
|
|
99
|
+
type BoundaryStatus,
|
|
100
|
+
boundaryDescriptors,
|
|
101
|
+
boundaryModeKey,
|
|
102
|
+
type DynamicBoundary,
|
|
103
|
+
type DynamicBoundaryBatch,
|
|
104
|
+
type InterceptBoundary,
|
|
105
|
+
MemoryStaticBoundaryCache,
|
|
106
|
+
type PendingBoundary,
|
|
107
|
+
resolveDynamicBoundaries,
|
|
108
|
+
resolveStaticBoundaries,
|
|
109
|
+
type StaticBoundary,
|
|
110
|
+
type StaticBoundaryCache,
|
|
111
|
+
type StaticBoundaryImportEdge,
|
|
112
|
+
type StaticBoundaryRoot,
|
|
113
|
+
type StaticCtx,
|
|
114
|
+
startDynamicBoundaries,
|
|
115
|
+
} from "./boundary.ts"
|
|
72
116
|
export {
|
|
73
117
|
DRAFT_COOKIE,
|
|
74
118
|
type DraftCookieControls,
|
|
@@ -112,6 +156,13 @@ export {
|
|
|
112
156
|
export {
|
|
113
157
|
type Action,
|
|
114
158
|
buildManifest,
|
|
159
|
+
type ClientAction,
|
|
160
|
+
type ClientActionArgs,
|
|
161
|
+
type ClientActionResult,
|
|
162
|
+
type ClientLoader,
|
|
163
|
+
type ClientLoaderArgs,
|
|
164
|
+
type ClientRequestBody,
|
|
165
|
+
type ClientRouteHooks,
|
|
115
166
|
enumerateStaticRoutes,
|
|
116
167
|
filePathToPattern,
|
|
117
168
|
filePathToPatterns,
|
|
@@ -245,6 +296,7 @@ export {
|
|
|
245
296
|
} from "./conformance.ts"
|
|
246
297
|
export {
|
|
247
298
|
ACTION_GLOBAL,
|
|
299
|
+
BOUNDARY_GLOBAL,
|
|
248
300
|
DATA_GLOBAL,
|
|
249
301
|
LAYOUT_DATA_GLOBAL,
|
|
250
302
|
type RenderAdapter,
|
|
@@ -312,6 +364,21 @@ const SCRIPT_ESCAPE_MAP: Readonly<Record<string, string>> = {
|
|
|
312
364
|
const NODE_RESPONSE_BODY = Symbol.for("nifra.response.body")
|
|
313
365
|
const RESPONSE_RESULT = Symbol.for("nifra.response.result")
|
|
314
366
|
|
|
367
|
+
/**
|
|
368
|
+
* A control-flow value that renders as plain data - what `redirect()` returns, and what core's
|
|
369
|
+
* `status(...)` returns. Recognized here rather than imported: core keeps its own predicate internal,
|
|
370
|
+
* and the registry symbol is the contract between them (two copies of core must still agree).
|
|
371
|
+
*/
|
|
372
|
+
const isResponseResult = (value: unknown): value is ResponseResult =>
|
|
373
|
+
typeof value === "object" &&
|
|
374
|
+
value !== null &&
|
|
375
|
+
(value as { readonly [RESPONSE_RESULT]?: unknown })[RESPONSE_RESULT] === true &&
|
|
376
|
+
typeof (value as { readonly toResponse?: unknown }).toResponse === "function"
|
|
377
|
+
|
|
378
|
+
/** A returned/thrown value that ends the request as-is: either shape of control-flow signal. */
|
|
379
|
+
const isControlFlow = (value: unknown): value is Response | ResponseResult =>
|
|
380
|
+
value instanceof Response || isResponseResult(value)
|
|
381
|
+
|
|
315
382
|
export interface RenderedPage {
|
|
316
383
|
readonly [RESPONSE_RESULT]: true
|
|
317
384
|
toResponse(): Response
|
|
@@ -372,6 +439,8 @@ export interface RenderPageOptions {
|
|
|
372
439
|
/** Per-layout loader data, forwarded to the adapter as `RenderProps.layoutData`. Aligned with the
|
|
373
440
|
* chain's layout prefix; omitted when no layout in the chain has a loader. */
|
|
374
441
|
readonly layoutData?: readonly unknown[]
|
|
442
|
+
/** Dynamic-boundary states, forwarded to the adapter and serialized for hydration when present. */
|
|
443
|
+
readonly boundaries?: BoundaryStates
|
|
375
444
|
/** HTTP status for the response (default 200; e.g. 404 for a not-found page). */
|
|
376
445
|
readonly status?: number
|
|
377
446
|
/** Extra response headers - e.g. the `cache-control` a terminal status page wants. `content-type`
|
|
@@ -478,6 +547,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
478
547
|
hydrate = true,
|
|
479
548
|
islandScripts = [],
|
|
480
549
|
nonce,
|
|
550
|
+
boundaries,
|
|
481
551
|
headers: extraHeaders,
|
|
482
552
|
} = options
|
|
483
553
|
if (nonce !== undefined && nonce.trim() === "") {
|
|
@@ -512,12 +582,24 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
512
582
|
actionData === undefined
|
|
513
583
|
? undefined
|
|
514
584
|
: prepareDeferred(actionData, deferred.length + layoutDeferred.length)
|
|
585
|
+
const boundarySplit =
|
|
586
|
+
boundaries === undefined
|
|
587
|
+
? undefined
|
|
588
|
+
: prepareDeferred(
|
|
589
|
+
boundaries,
|
|
590
|
+
deferred.length + layoutDeferred.length + (actionSplit?.deferred.length ?? 0),
|
|
591
|
+
)
|
|
515
592
|
// On the common page-only path (no layout loader, no action) reuse `deferred` directly instead of
|
|
516
593
|
// allocating a fresh spread array every request.
|
|
517
594
|
const allDeferred =
|
|
518
|
-
layoutDeferred.length === 0 && actionSplit === undefined
|
|
595
|
+
layoutDeferred.length === 0 && actionSplit === undefined && boundarySplit === undefined
|
|
519
596
|
? deferred
|
|
520
|
-
: [
|
|
597
|
+
: [
|
|
598
|
+
...deferred,
|
|
599
|
+
...layoutDeferred,
|
|
600
|
+
...(actionSplit ? actionSplit.deferred : []),
|
|
601
|
+
...(boundarySplit ? boundarySplit.deferred : []),
|
|
602
|
+
]
|
|
521
603
|
// Omitted entirely when no layout has a loader - a page-only app emits exactly what it did before.
|
|
522
604
|
const layoutTail =
|
|
523
605
|
options.layoutData === undefined
|
|
@@ -529,6 +611,10 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
529
611
|
actionSplit === undefined
|
|
530
612
|
? ""
|
|
531
613
|
: `window.${ACTION_GLOBAL}=${serializeData(actionSplit.forClient)};`
|
|
614
|
+
const boundaryTail =
|
|
615
|
+
boundarySplit === undefined
|
|
616
|
+
? ""
|
|
617
|
+
: `window.${BOUNDARY_GLOBAL}=${serializeData(boundarySplit.forClient)};`
|
|
532
618
|
const deferredRuntime =
|
|
533
619
|
allDeferred.length > 0 ? `<script${nonceAttr}>${DEFERRED_RUNTIME}</script>` : ""
|
|
534
620
|
// The regex only injects the CSP nonce into the (constant) hydration head; with no nonce it's a
|
|
@@ -609,7 +695,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
609
695
|
// Tail - the loader-data globals + the client module. Module scripts defer (run after parse), so
|
|
610
696
|
// the data global + every streamed deferred resolution are set before the entry hydrates.
|
|
611
697
|
const tailHtml = hydrate
|
|
612
|
-
? `${slot.tailPre}${action}${slot.tailMid}${layoutTail}${slot.tailData}${serializeData(forClient)}${slot.tailPost}`
|
|
698
|
+
? `${slot.tailPre}${action}${slot.tailMid}${layoutTail}${boundaryTail}${slot.tailData}${serializeData(forClient)}${slot.tailPost}`
|
|
613
699
|
: (slot.tailPost as string)
|
|
614
700
|
const headers: Record<string, string> = { "content-type": "text/html; charset=utf-8" }
|
|
615
701
|
// Caller-supplied headers (a terminal status page's `cache-control`, say). Applied before the
|
|
@@ -641,6 +727,9 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
641
727
|
...(options.layoutData !== undefined
|
|
642
728
|
? { layoutData: layoutSplits.map((split) => split.forComponent) }
|
|
643
729
|
: {}),
|
|
730
|
+
...(boundarySplit !== undefined
|
|
731
|
+
? { boundaries: boundarySplit.forComponent as BoundaryStates }
|
|
732
|
+
: {}),
|
|
644
733
|
}
|
|
645
734
|
|
|
646
735
|
// Fast path: nothing `defer()`s and the adapter can render synchronously to a string → buffer the
|
|
@@ -817,31 +906,39 @@ export interface RedirectOptions {
|
|
|
817
906
|
* leading `/`) is permitted, so an action can't be turned into an open redirect by passing
|
|
818
907
|
* attacker-controlled input straight through. Set `true` for a deliberate external redirect. */
|
|
819
908
|
readonly external?: boolean
|
|
909
|
+
/** Extra response headers. A redirect is no longer a `Response`, so there is no `.headers` to
|
|
910
|
+
* mutate after the fact - name them here. Cookies still ride `c.set`, as on any other response. */
|
|
911
|
+
readonly headers?: Readonly<Record<string, string>>
|
|
820
912
|
}
|
|
821
913
|
|
|
822
914
|
/** A same-origin destination is an absolute path: one leading `/`, but NOT `//` (protocol-relative →
|
|
823
|
-
* another origin). Everything else (absolute URL with
|
|
824
|
-
* relative `foo`)
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
}
|
|
915
|
+
* another origin), and nothing a URL parser resolves off-origin. Everything else (absolute URL with
|
|
916
|
+
* a scheme, `//host`, `javascript:`, a bare relative `foo`) requires `external: true`. The predicate
|
|
917
|
+
* lives in the kernel - see `isSameOriginPath` - so this gate and the auth guards' `redirectTo`
|
|
918
|
+
* cannot drift apart. */
|
|
828
919
|
|
|
829
920
|
/**
|
|
830
|
-
* Build a redirect
|
|
831
|
-
*
|
|
832
|
-
*
|
|
921
|
+
* Build a redirect - return it from a route `action` for the Post/Redirect/Get pattern (POST
|
|
922
|
+
* mutates, 303 sends the browser to a fresh GET, so a reload doesn't re-submit). Defaults to 303
|
|
923
|
+
* (See Other); pass `{ status: 307 }` or `{ status: 308 }` to preserve the method.
|
|
833
924
|
*
|
|
834
925
|
* **Secure by default:** `location` must be a same-origin path (begins with `/`, not `//`). An
|
|
835
926
|
* off-origin/absolute destination throws unless you pass `{ external: true }` - this closes the
|
|
836
927
|
* open-redirect footgun of `return redirect(formData.get("next"))` on the no-JS (native-form) path,
|
|
837
|
-
* which
|
|
928
|
+
* which serves the action's control-flow value verbatim.
|
|
838
929
|
*
|
|
839
|
-
*
|
|
930
|
+
* Returns a plain render, not a `Response`. A redirect is a status line and one header - the most
|
|
931
|
+
* body-less response there is - and building a `Response` for it costs the whole Web object plus, on
|
|
932
|
+
* Node, a stream drained back out. As data it renders on the same lane a handler's return takes.
|
|
933
|
+
* `redirect(...)` is still returned or thrown from exactly the same places; only `.status` /
|
|
934
|
+
* `.headers` are gone from the value, replaced by `options.headers` and the request's `c.set`.
|
|
935
|
+
*
|
|
936
|
+
* @param options redirect status, extra headers, and whether an off-origin destination is intentional.
|
|
840
937
|
*/
|
|
841
|
-
export function redirect(location: string, options: RedirectOptions = {}):
|
|
938
|
+
export function redirect(location: string, options: RedirectOptions = {}): ResponseResult {
|
|
842
939
|
if (options.external !== true && !isSameOriginPath(location)) {
|
|
843
940
|
throw new Error(
|
|
844
|
-
`[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.`,
|
|
941
|
+
`[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.`,
|
|
845
942
|
)
|
|
846
943
|
}
|
|
847
944
|
// Reject CR/LF in the Location explicitly - defense-in-depth (response splitting / header
|
|
@@ -853,7 +950,9 @@ export function redirect(location: string, options: RedirectOptions = {}): Respo
|
|
|
853
950
|
`[nifra/web] redirect location contains a CR/LF character - refusing to emit a header-injecting redirect.`,
|
|
854
951
|
)
|
|
855
952
|
}
|
|
856
|
-
return
|
|
953
|
+
return statusResult(options.status ?? 303, undefined, {
|
|
954
|
+
headers: options.headers === undefined ? { location } : { ...options.headers, location },
|
|
955
|
+
})
|
|
857
956
|
}
|
|
858
957
|
|
|
859
958
|
/**
|
|
@@ -913,17 +1012,34 @@ function withDuplicateInstanceHint(err: unknown): unknown {
|
|
|
913
1012
|
}
|
|
914
1013
|
|
|
915
1014
|
/**
|
|
916
|
-
* An action's
|
|
1015
|
+
* An action's control-flow value passes straight through - except a redirect on a client-submit data
|
|
917
1016
|
* request: fetch would follow the 3xx into HTML the client can't use, so the redirect rides the
|
|
918
|
-
* X-Nifra-Redirect header on a 204 and the client navigates. One conversion shared by the
|
|
919
|
-
*
|
|
1017
|
+
* X-Nifra-Redirect header on a 204 and the client navigates. One conversion shared by the returned-
|
|
1018
|
+
* and thrown- paths, so `return redirect()` and `throw redirect()` agree.
|
|
1019
|
+
*
|
|
1020
|
+
* Takes either shape: `redirect()` is a plain render, while a hand-rolled `new Response(...)` from an
|
|
1021
|
+
* action still arrives as a `Response`. The rewrite stays on the lane its input was on - a plain
|
|
1022
|
+
* redirect converts to a plain 204, and never materializes the `Response` it is replacing.
|
|
920
1023
|
*/
|
|
921
|
-
function actionResponse(
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
1024
|
+
function actionResponse(
|
|
1025
|
+
result: Response | ResponseResult,
|
|
1026
|
+
isDataRequest: boolean,
|
|
1027
|
+
): Response | ResponseResult {
|
|
1028
|
+
if (!isDataRequest) return result
|
|
1029
|
+
if (isResponseResult(result)) {
|
|
1030
|
+
const plain = result.plain
|
|
1031
|
+
// No `plain` means a carrier that only knows how to build a `Response` (not one of ours) - fall
|
|
1032
|
+
// back rather than guess at its status.
|
|
1033
|
+
if (plain === undefined) return actionResponse(result.toResponse(), isDataRequest)
|
|
1034
|
+
if (plain.status < 300 || plain.status >= 400) return result
|
|
1035
|
+
const location = plain.headers?.location ?? "/"
|
|
1036
|
+
return statusResult(204, undefined, { headers: { [REDIRECT_HEADER]: location } })
|
|
925
1037
|
}
|
|
926
|
-
|
|
1038
|
+
if (result.status >= 300 && result.status < 400) {
|
|
1039
|
+
const location = result.headers.get("location") ?? "/"
|
|
1040
|
+
return statusResult(204, undefined, { headers: { [REDIRECT_HEADER]: location } })
|
|
1041
|
+
}
|
|
1042
|
+
return result
|
|
927
1043
|
}
|
|
928
1044
|
|
|
929
1045
|
/** A loaded layout module. `loader`/`gate` are the layout-loader surface; `meta` predates it. */
|
|
@@ -936,6 +1052,7 @@ type LoadedLayoutModules = ReadonlyArray<{
|
|
|
936
1052
|
// A layout may declare its own `searchSchema`; the route's effective search merges the layout chain's
|
|
937
1053
|
// schemas with the page's (page-wins). Present on the raw module already - typed here so it is readable.
|
|
938
1054
|
searchSchema?: RouteModule["searchSchema"]
|
|
1055
|
+
boundaries?: readonly BoundaryRegistration[]
|
|
939
1056
|
}>
|
|
940
1057
|
|
|
941
1058
|
/**
|
|
@@ -1447,6 +1564,9 @@ export interface CreateWebAppOptions<Env = unknown> {
|
|
|
1447
1564
|
* `prerenderedPaths` with the 404 page - the unlisted path simply doesn't exist. `"ssr"` (the
|
|
1448
1565
|
* default for unmapped routes) renders unlisted paths on-demand. */
|
|
1449
1566
|
readonly staticFallbacks?: Readonly<Record<string, "ssr" | "404">>
|
|
1567
|
+
/** In-memory reference cache for explicitly `static` boundary values. It never persists payloads
|
|
1568
|
+
* across processes; operated/durable cache implementations stay outside the public framework. */
|
|
1569
|
+
readonly staticBoundaryCache?: import("./boundary.ts").StaticBoundaryCache
|
|
1450
1570
|
/** Observe every loader/action failure - for error-reporting plugins (Sentry-style). Called for
|
|
1451
1571
|
* real throws (not control-flow `Response`s like `redirect`), **before** the nearest `_error`
|
|
1452
1572
|
* boundary renders / a soft-nav 500 / a rethrow - so it sees errors that the boundary would
|
|
@@ -2038,6 +2158,7 @@ export function createWebApp<Env = unknown>(
|
|
|
2038
2158
|
const draft = await draftFlag(c.req)
|
|
2039
2159
|
let data: unknown
|
|
2040
2160
|
let layoutData: readonly unknown[] | undefined
|
|
2161
|
+
let boundaryStates: BoundaryStates | undefined
|
|
2041
2162
|
let layoutModules: LoadedLayoutModules | undefined
|
|
2042
2163
|
let layoutRetained: readonly number[] = []
|
|
2043
2164
|
try {
|
|
@@ -2064,25 +2185,65 @@ export function createWebApp<Env = unknown>(
|
|
|
2064
2185
|
)
|
|
2065
2186
|
layoutModules = run.modules
|
|
2066
2187
|
layoutRetained = run.retained
|
|
2188
|
+
const boundaryDefinitions = [
|
|
2189
|
+
...run.modules.flatMap((layout) => layout.boundaries ?? []),
|
|
2190
|
+
...(mod.boundaries ?? []),
|
|
2191
|
+
]
|
|
2192
|
+
if (boundaryDefinitions.length > 0) boundaryDescriptors(boundaryDefinitions)
|
|
2193
|
+
const effectiveSearch = searchChainOf(run.modules, mod, c.req)
|
|
2194
|
+
const boundaryBatch =
|
|
2195
|
+
boundaryDefinitions.length === 0
|
|
2196
|
+
? undefined
|
|
2197
|
+
: startDynamicBoundaries(boundaryDefinitions, {
|
|
2198
|
+
request: c.req,
|
|
2199
|
+
params: c.params,
|
|
2200
|
+
api,
|
|
2201
|
+
env: c.env,
|
|
2202
|
+
draft,
|
|
2203
|
+
search: effectiveSearch,
|
|
2204
|
+
signal: c.req.signal,
|
|
2205
|
+
} satisfies BoundaryRequestCtx)
|
|
2206
|
+
const staticBoundaryPromise =
|
|
2207
|
+
boundaryDefinitions.length === 0
|
|
2208
|
+
? Promise.resolve(undefined)
|
|
2209
|
+
: resolveStaticBoundaries(
|
|
2210
|
+
boundaryDefinitions,
|
|
2211
|
+
{ phase: "build", origin: originOf(c.req) },
|
|
2212
|
+
options.staticBoundaryCache,
|
|
2213
|
+
)
|
|
2214
|
+
// Boundary data is attached as one Deferred marker per slot. The shell can render
|
|
2215
|
+
// immediately with each slot pending, while the existing deferred protocol emits the first
|
|
2216
|
+
// completed sibling independently instead of waiting for the slowest boundary.
|
|
2067
2217
|
// The page loader gets the EFFECTIVE search (layout chain + page, page-wins), now that the layout
|
|
2068
2218
|
// modules are loaded - so `ctx.search` includes a layout's shared keys (`?org`), matching `useSearch`.
|
|
2069
|
-
const [pageData] = await Promise.all([
|
|
2070
|
-
mod.loader
|
|
2071
|
-
? mod.loader({ ...ctx, search: searchChainOf(run.modules, mod, c.req) })
|
|
2072
|
-
: null,
|
|
2219
|
+
const [pageData, , staticBoundaries] = await Promise.all([
|
|
2220
|
+
mod.loader ? mod.loader({ ...ctx, search: effectiveSearch }) : null,
|
|
2073
2221
|
run.pending, // a non-gate layout loader that rejects must still surface, not go unhandled
|
|
2222
|
+
staticBoundaryPromise,
|
|
2074
2223
|
])
|
|
2075
2224
|
data = pageData
|
|
2076
2225
|
layoutData = run.layoutData
|
|
2226
|
+
if (boundaryBatch !== undefined) {
|
|
2227
|
+
const streamed = { ...boundaryBatch.initial, ...(staticBoundaries ?? {}) }
|
|
2228
|
+
for (const entry of boundaryBatch.pending) {
|
|
2229
|
+
const initial = streamed[entry.name]
|
|
2230
|
+
if (initial !== undefined)
|
|
2231
|
+
streamed[entry.name] = { ...initial, data: defer(entry.promise) }
|
|
2232
|
+
}
|
|
2233
|
+
boundaryStates = streamed
|
|
2234
|
+
} else if (staticBoundaries !== undefined) {
|
|
2235
|
+
boundaryStates = staticBoundaries
|
|
2236
|
+
}
|
|
2077
2237
|
} catch (err) {
|
|
2078
2238
|
// A terminal-status signal (`notFound()` / `gone()` / `statusPage(n)`) renders a boundary at
|
|
2079
2239
|
// that status. Checked BEFORE the pass-through below: a signal IS a `Response`, so the order
|
|
2080
2240
|
// is what keeps a hand-rolled `throw new Response(...)` served verbatim, as it always was.
|
|
2081
2241
|
if (isStatusSignal(err)) return renderStatusSignal(c.req, err)
|
|
2082
|
-
// A thrown
|
|
2083
|
-
//
|
|
2084
|
-
// `_error` boundary, if any; with
|
|
2085
|
-
|
|
2242
|
+
// A thrown control-flow signal (a guard's `redirect(...)`, an explicit error response) - let
|
|
2243
|
+
// it propagate to core, which renders it as-is, whether it is a plain render or a
|
|
2244
|
+
// hand-rolled `Response`. Real errors render the nearest `_error` boundary, if any; with
|
|
2245
|
+
// none, rethrow (unchanged 500 behavior).
|
|
2246
|
+
if (isControlFlow(err)) throw err
|
|
2086
2247
|
// Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
|
|
2087
2248
|
if (options.onLoaderError !== undefined) {
|
|
2088
2249
|
try {
|
|
@@ -2104,12 +2265,12 @@ export function createWebApp<Env = unknown>(
|
|
|
2104
2265
|
return renderError(route, errorId, withDuplicateInstanceHint(err))
|
|
2105
2266
|
}
|
|
2106
2267
|
// A loader may RETURN its control-flow signal instead of throwing it - `return redirect(...)`
|
|
2107
|
-
// reads naturally and must not silently serialize the
|
|
2108
|
-
//
|
|
2109
|
-
//
|
|
2110
|
-
//
|
|
2268
|
+
// reads naturally and must not silently serialize the signal as loader data. Mirror the catch
|
|
2269
|
+
// above exactly: a status signal renders its boundary, any other signal (a redirect, a
|
|
2270
|
+
// hand-rolled response) passes through to core verbatim - so return and throw are
|
|
2271
|
+
// interchangeable.
|
|
2111
2272
|
if (isStatusSignal(data)) return renderStatusSignal(c.req, data)
|
|
2112
|
-
if (data
|
|
2273
|
+
if (isControlFlow(data)) return data
|
|
2113
2274
|
// Client-side navigation asks (via the X-Nifra-Data header) for just the loader data - no full
|
|
2114
2275
|
// document, no layout chain. A route with deferred data streams NDJSON (critical data first,
|
|
2115
2276
|
// then each deferred value as it settles); otherwise one JSON (the fast path). Same loader,
|
|
@@ -2124,21 +2285,30 @@ export function createWebApp<Env = unknown>(
|
|
|
2124
2285
|
offset += split.deferred.length
|
|
2125
2286
|
}
|
|
2126
2287
|
const deferred = [...pageSplit.deferred, ...layoutSplits.flatMap((split) => split.deferred)]
|
|
2288
|
+
const boundarySplit =
|
|
2289
|
+
boundaryStates === undefined
|
|
2290
|
+
? undefined
|
|
2291
|
+
: prepareDeferred(boundaryStates, deferred.length)
|
|
2292
|
+
const allDeferred = [
|
|
2293
|
+
...deferred,
|
|
2294
|
+
...(boundarySplit === undefined ? [] : boundarySplit.deferred),
|
|
2295
|
+
]
|
|
2127
2296
|
const payload =
|
|
2128
|
-
layoutData === undefined
|
|
2297
|
+
layoutData === undefined && boundarySplit === undefined
|
|
2129
2298
|
? pageSplit.forClient
|
|
2130
2299
|
: {
|
|
2131
2300
|
v: 1 as const,
|
|
2132
2301
|
data: pageSplit.forClient,
|
|
2133
2302
|
layoutData: layoutSplits.map((split) => split.forClient),
|
|
2134
2303
|
retained: layoutRetained,
|
|
2304
|
+
...(boundarySplit === undefined ? {} : { boundaries: boundarySplit.forClient }),
|
|
2135
2305
|
}
|
|
2136
2306
|
// Bare value when there is no layout data - the pre-envelope shape, so a client running older
|
|
2137
2307
|
// code against a newer server keeps working.
|
|
2138
|
-
if (
|
|
2308
|
+
if (allDeferred.length === 0) {
|
|
2139
2309
|
return Response.json(payload ?? null)
|
|
2140
2310
|
}
|
|
2141
|
-
return new Response(ndjsonStream(payload,
|
|
2311
|
+
return new Response(ndjsonStream(payload, allDeferred), {
|
|
2142
2312
|
headers: { "content-type": "application/x-ndjson; charset=utf-8" },
|
|
2143
2313
|
})
|
|
2144
2314
|
}
|
|
@@ -2168,6 +2338,7 @@ export function createWebApp<Env = unknown>(
|
|
|
2168
2338
|
search: searchChainOf(layoutModules ?? [], mod, c.req),
|
|
2169
2339
|
hydrate: hydrateRoute,
|
|
2170
2340
|
...(layoutData !== undefined ? { layoutData } : {}),
|
|
2341
|
+
...(boundaryStates !== undefined ? { boundaries: boundaryStates } : {}),
|
|
2171
2342
|
...preloadOf(route.id),
|
|
2172
2343
|
...stylesOf(route.id),
|
|
2173
2344
|
prerenderedPaths: options.prerenderedPaths ?? [],
|
|
@@ -2180,7 +2351,7 @@ export function createWebApp<Env = unknown>(
|
|
|
2180
2351
|
} catch (err) {
|
|
2181
2352
|
// Same precedence as the loader catch above - a `meta()` may signal too.
|
|
2182
2353
|
if (isStatusSignal(err)) return renderStatusSignal(c.req, err)
|
|
2183
|
-
if (err
|
|
2354
|
+
if (isControlFlow(err)) throw err
|
|
2184
2355
|
// Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
|
|
2185
2356
|
if (options.onLoaderError !== undefined) {
|
|
2186
2357
|
try {
|
|
@@ -2195,7 +2366,7 @@ export function createWebApp<Env = unknown>(
|
|
|
2195
2366
|
}
|
|
2196
2367
|
})
|
|
2197
2368
|
|
|
2198
|
-
// POST runs the route's `action` (mutation). A
|
|
2369
|
+
// POST runs the route's `action` (mutation). A control-flow return (e.g. a `redirect(...)`)
|
|
2199
2370
|
// passes straight through; a data return re-renders the page (the loader re-runs for fresh
|
|
2200
2371
|
// data) with `actionData`. Routes without an action reject POST with 405 - not a stray 404.
|
|
2201
2372
|
app.register("POST", route.pattern, undefined, async (c: RouteContext) => {
|
|
@@ -2225,10 +2396,10 @@ export function createWebApp<Env = unknown>(
|
|
|
2225
2396
|
layoutModules = await runLayoutGates(route, actionContext)
|
|
2226
2397
|
result = await mod.action(actionContext)
|
|
2227
2398
|
} catch (err) {
|
|
2228
|
-
// A THROWN
|
|
2229
|
-
//
|
|
2230
|
-
//
|
|
2231
|
-
if (err
|
|
2399
|
+
// A THROWN signal is the same control flow as a returned one (`throw redirect()` in a gate
|
|
2400
|
+
// or an action) - route it through the same conversion as the returned branch below, so
|
|
2401
|
+
// throw and return are interchangeable on the mutation path too.
|
|
2402
|
+
if (isControlFlow(err)) return actionResponse(err, isDataRequest)
|
|
2232
2403
|
throw err
|
|
2233
2404
|
}
|
|
2234
2405
|
// An action may wrap its data in `revalidate(paths, data)` to declare which routes it changed.
|
|
@@ -2240,7 +2411,7 @@ export function createWebApp<Env = unknown>(
|
|
|
2240
2411
|
const revalidateHeader: Record<string, string> = isRevalidate
|
|
2241
2412
|
? { [REVALIDATE_HEADER]: (result as RevalidateResult<unknown>).__nifraRevalidate.join(",") }
|
|
2242
2413
|
: {}
|
|
2243
|
-
if (actionResult
|
|
2414
|
+
if (isControlFlow(actionResult)) return actionResponse(actionResult, isDataRequest)
|
|
2244
2415
|
// Client submit wants just the action's data (it revalidates the loader itself); a native
|
|
2245
2416
|
// form POST re-renders the full page (loader re-runs) with the action data.
|
|
2246
2417
|
if (isDataRequest) {
|
|
@@ -2445,6 +2616,9 @@ export function generateClientEntry(
|
|
|
2445
2616
|
// routeId → the page's client-only search keys (`searchClientKeys`, or `[]`). The router reads this
|
|
2446
2617
|
// by reference to decide when a same-route search change can skip the loader fetch (re-render only).
|
|
2447
2618
|
"const searchClientKeys = {}",
|
|
2619
|
+
// routeId → the page's optional post-hydration client loader/action hooks. Hooks are populated only
|
|
2620
|
+
// after the route chunk loads, so routes that do not declare them remain ordinary route modules.
|
|
2621
|
+
"const routeHooks = {}",
|
|
2448
2622
|
"const loadModule = async (id) => {",
|
|
2449
2623
|
" if (chains[id]) return",
|
|
2450
2624
|
" const mods = await loaders[id]()",
|
|
@@ -2473,6 +2647,13 @@ export function generateClientEntry(
|
|
|
2473
2647
|
" searchSchemas[id] = mods.map((m) => m.searchSchema)",
|
|
2474
2648
|
" searchClientKeys[id] = mods[mods.length - 1].searchClientKeys ?? []",
|
|
2475
2649
|
" }",
|
|
2650
|
+
" const page = errorRouteIds.has(id) ? mods[mods.length - 2] : mods[mods.length - 1]",
|
|
2651
|
+
// Keep interception client-safe: only neutral name/mode metadata crosses into the router. The
|
|
2652
|
+
// server-side boundary load function (which may close over secrets) is never stored in the client
|
|
2653
|
+
// hook table or serialized into a browser bundle by this wiring.
|
|
2654
|
+
" const boundaryMods = errorRouteIds.has(id) ? mods.slice(0, -1) : mods",
|
|
2655
|
+
" 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 }) })))",
|
|
2656
|
+
" routeHooks[id] = { clientLoader: page.clientLoader, clientAction: page.clientAction, boundaries }",
|
|
2476
2657
|
"}",
|
|
2477
2658
|
"const patterns = [",
|
|
2478
2659
|
...patternRows,
|
|
@@ -2493,6 +2674,7 @@ export function generateClientEntry(
|
|
|
2493
2674
|
` data: mapDeferred(window.${DATA_GLOBAL}),`,
|
|
2494
2675
|
// Undefined on a page-only app, which is exactly what the router treats as "no layout data".
|
|
2495
2676
|
` layoutData: window.${LAYOUT_DATA_GLOBAL} && window.${LAYOUT_DATA_GLOBAL}.map(mapDeferred),`,
|
|
2677
|
+
` boundaries: mapDeferred(window.${BOUNDARY_GLOBAL}),`,
|
|
2496
2678
|
// actionData (only set after a form POST) is in the initial state so the binding hydrates
|
|
2497
2679
|
// consistently with the server-rendered markup; mapped through `mapDeferred` too so a deferred
|
|
2498
2680
|
// action's placeholders become registry markers (a no-op when the action didn't defer).
|
|
@@ -2500,7 +2682,7 @@ export function generateClientEntry(
|
|
|
2500
2682
|
" pending: false,",
|
|
2501
2683
|
"}",
|
|
2502
2684
|
`const statusRoutes = ${JSON.stringify(statusRoutes)}`,
|
|
2503
|
-
"const router = createClientRouter({ patterns, initial, loadModule, statusRoutes, searchClientKeys })",
|
|
2685
|
+
"const router = createClientRouter({ patterns, initial, loadModule, statusRoutes, searchClientKeys, routeHooks })",
|
|
2504
2686
|
"installHistory(router)",
|
|
2505
2687
|
"installForms(router)",
|
|
2506
2688
|
// The container is found in the DOM, not baked in. `rootId` is a per-render option and this entry
|
|
@@ -2521,6 +2703,10 @@ export function generateClientEntry(
|
|
|
2521
2703
|
// is server-rendered; subsequent navigations update it from the matched route's meta + data.
|
|
2522
2704
|
"loadModule(initial.routeId).then(() => {",
|
|
2523
2705
|
" mountRouter({ router, routes: chains, searchSchemas, container: root })",
|
|
2706
|
+
// Run the optional client loader only after the adapter has mounted the SSR tree. The initial
|
|
2707
|
+
// server data is already in `window.__NIFRA_DATA__`, so `serverLoader()` reuses it and cannot
|
|
2708
|
+
// duplicate the first request.
|
|
2709
|
+
" router.hydrate().catch((error) => console.error('[nifra/web] clientLoader failed:', error))",
|
|
2524
2710
|
// Next frame: the adapter has committed its initial hydration, so handlers are attached. Flip the
|
|
2525
2711
|
// document to interactive (`data-nifra-hydrated` + `nifra:hydrated`) for apps to gate pre-hydration UI.
|
|
2526
2712
|
" requestAnimationFrame(signalHydrated)",
|
|
@@ -2617,6 +2803,18 @@ export interface GenerateServerManifestOptions {
|
|
|
2617
2803
|
* `createWebApp`, unchanged) + `clientEntry` (baked). Write it to a file and bundle it into the worker
|
|
2618
2804
|
* entry (see `buildServer` in `@nifrajs/web/build`).
|
|
2619
2805
|
*/
|
|
2806
|
+
// Route source files are emitted as EXTENSIONLESS import specifiers. An explicit `.tsx`/`.ts` in an
|
|
2807
|
+
// import is a hard error under a plain `tsc` (TS5097) unless `allowImportingTsExtensions` is set - which
|
|
2808
|
+
// only the nifra template tsconfig does - so the generated manifest failed a bare `tsc` with one error
|
|
2809
|
+
// per route. Every bundler and `moduleResolution` still resolves the extensionless form to the source
|
|
2810
|
+
// file, so dropping the extension costs nothing at build time and makes the output portable. Only a
|
|
2811
|
+
// known TS/JS source extension is stripped; the map KEY keeps its extension (it is the route identifier
|
|
2812
|
+
// `buildManifest` matches, not an import path).
|
|
2813
|
+
const SOURCE_EXTENSION = /\.(?:[mc]?tsx?|[mc]?jsx?)$/
|
|
2814
|
+
function moduleSpecifier(resolved: string): string {
|
|
2815
|
+
return resolved.replace(SOURCE_EXTENSION, "")
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2620
2818
|
export function generateServerManifest(
|
|
2621
2819
|
manifest: Manifest,
|
|
2622
2820
|
options: GenerateServerManifestOptions,
|
|
@@ -2636,7 +2834,7 @@ export function generateServerManifest(
|
|
|
2636
2834
|
"// GENERATED by @nifrajs/web generateServerManifest - route manifest for the disk-less edge",
|
|
2637
2835
|
"// (no filesystem; route imports are static specifiers the bundler resolves). buildManifest is",
|
|
2638
2836
|
"// the same pure logic discoverRoutes feeds, so patterns + layout chains match exactly.",
|
|
2639
|
-
'import { buildManifest } from "@nifrajs/web"',
|
|
2837
|
+
'import { buildManifest, type RouteModule } from "@nifrajs/web"',
|
|
2640
2838
|
]
|
|
2641
2839
|
const clientEntryLine = `export const clientEntry = ${JSON.stringify(clientEntry)}`
|
|
2642
2840
|
// Baked stylesheet URLs so the server entry can pass them to createWebApp → <link rel="stylesheet">.
|
|
@@ -2648,11 +2846,12 @@ export function generateServerManifest(
|
|
|
2648
2846
|
// code-splitting bundler, loaded on first request). The map keys are the route-relative paths
|
|
2649
2847
|
// `buildManifest` expects; the importer it builds calls the per-file loader.
|
|
2650
2848
|
const loaders = files.map(
|
|
2651
|
-
(file) =>
|
|
2849
|
+
(file) =>
|
|
2850
|
+
` ${jsStringLiteral(file)}: () => import(${jsStringLiteral(moduleSpecifier(resolve(file)))}),`,
|
|
2652
2851
|
)
|
|
2653
2852
|
return `${[
|
|
2654
2853
|
...header,
|
|
2655
|
-
"const loaders = {",
|
|
2854
|
+
"const loaders: Record<string, () => Promise<RouteModule>> = {",
|
|
2656
2855
|
...loaders,
|
|
2657
2856
|
"}",
|
|
2658
2857
|
clientEntryLine,
|
|
@@ -2663,12 +2862,14 @@ export function generateServerManifest(
|
|
|
2663
2862
|
}
|
|
2664
2863
|
// Eager: `import * as` per route (all bundled into the entry, parsed at boot). Index-based
|
|
2665
2864
|
// identifiers are collision-proof regardless of filename.
|
|
2666
|
-
const imports = files.map(
|
|
2865
|
+
const imports = files.map(
|
|
2866
|
+
(file, i) => `import * as m${i} from ${JSON.stringify(moduleSpecifier(resolve(file)))}`,
|
|
2867
|
+
)
|
|
2667
2868
|
const entries = files.map((file, i) => ` ${JSON.stringify(file)}: m${i},`)
|
|
2668
2869
|
return `${[
|
|
2669
2870
|
...header,
|
|
2670
2871
|
...imports,
|
|
2671
|
-
"const modules = {",
|
|
2872
|
+
"const modules: Record<string, RouteModule> = {",
|
|
2672
2873
|
...entries,
|
|
2673
2874
|
"}",
|
|
2674
2875
|
clientEntryLine,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ClientRouter, Fetcher, FetcherState } from "../router.ts"
|
|
2
|
+
|
|
3
|
+
/** Stable idle state shared by every framework fetcher binding. */
|
|
4
|
+
export const IDLE_FETCHER_STATE: FetcherState = Object.freeze({
|
|
5
|
+
pending: false,
|
|
6
|
+
data: undefined,
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
/** Stable empty collection used before a router is mounted. */
|
|
10
|
+
export const NO_FETCHERS: readonly Fetcher[] = Object.freeze([])
|
|
11
|
+
|
|
12
|
+
/** No-op subscription used by SSR and pre-mount snapshots. */
|
|
13
|
+
export const noopSubscribe = (): (() => void) => () => {}
|
|
14
|
+
|
|
15
|
+
/** No-op fetcher action used before a router is mounted. */
|
|
16
|
+
export const noopAsync = async (): Promise<void> => {}
|
|
17
|
+
|
|
18
|
+
/** Stable idle fetcher snapshot for external-store bindings. */
|
|
19
|
+
export const idleFetcherSnapshot = (): FetcherState => IDLE_FETCHER_STATE
|
|
20
|
+
|
|
21
|
+
/** Stable empty fetcher collection snapshot for external-store bindings. */
|
|
22
|
+
export const noFetchers = (): readonly Fetcher[] => NO_FETCHERS
|
|
23
|
+
|
|
24
|
+
/** Keep mounted-router state local to each adapter package. */
|
|
25
|
+
export function createMountedRouterRef(): {
|
|
26
|
+
readonly get: () => ClientRouter | undefined
|
|
27
|
+
readonly set: (router: ClientRouter | undefined) => void
|
|
28
|
+
} {
|
|
29
|
+
let router: ClientRouter | undefined
|
|
30
|
+
return {
|
|
31
|
+
get: () => router,
|
|
32
|
+
set: (next) => {
|
|
33
|
+
router = next
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
}
|