@nifrajs/web 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-vite.d.ts +68 -0
- package/dist/build-vite.d.ts.map +1 -0
- package/dist/build-vite.js +487 -0
- package/dist/build-vite.js.map +1 -0
- package/dist/build.d.ts +112 -25
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +283 -64
- package/dist/build.js.map +1 -1
- package/dist/bun-dev-entry.d.ts +83 -0
- package/dist/bun-dev-entry.d.ts.map +1 -0
- package/dist/bun-dev-entry.js +136 -0
- package/dist/bun-dev-entry.js.map +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +10 -0
- package/dist/client.js.map +1 -1
- package/dist/dev-port.d.ts +36 -0
- package/dist/dev-port.d.ts.map +1 -0
- package/dist/dev-port.js +48 -0
- package/dist/dev-port.js.map +1 -0
- package/dist/dev.d.ts +61 -4
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +419 -60
- package/dist/dev.js.map +1 -1
- package/dist/draft.d.ts +37 -0
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +87 -4
- package/dist/draft.js.map +1 -1
- package/dist/fs.d.ts +14 -1
- package/dist/fs.d.ts.map +1 -1
- package/dist/fs.js +7 -1
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +116 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +565 -53
- package/dist/index.js.map +1 -1
- package/dist/internal/timing-safe-equal.d.ts +16 -0
- package/dist/internal/timing-safe-equal.d.ts.map +1 -0
- package/dist/internal/timing-safe-equal.js +26 -0
- package/dist/internal/timing-safe-equal.js.map +1 -0
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +1 -14
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +12 -6
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +51 -8
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +130 -14
- package/dist/manifest.js.map +1 -1
- package/dist/module-graph.d.ts +91 -0
- package/dist/module-graph.d.ts.map +1 -0
- package/dist/module-graph.js +92 -0
- package/dist/module-graph.js.map +1 -0
- package/dist/plugins/css-modules.d.ts.map +1 -1
- package/dist/plugins/css-modules.js +0 -0
- package/dist/plugins/css-modules.js.map +1 -1
- package/dist/plugins/vite-leak-guard.d.ts +45 -0
- package/dist/plugins/vite-leak-guard.d.ts.map +1 -0
- package/dist/plugins/vite-leak-guard.js +67 -0
- package/dist/plugins/vite-leak-guard.js.map +1 -0
- package/dist/public-dir.d.ts +36 -0
- package/dist/public-dir.d.ts.map +1 -0
- package/dist/public-dir.js +89 -0
- package/dist/public-dir.js.map +1 -0
- package/dist/route-manifest.d.ts +91 -0
- package/dist/route-manifest.d.ts.map +1 -0
- package/dist/route-manifest.js +128 -0
- package/dist/route-manifest.js.map +1 -0
- package/dist/router.d.ts +64 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +114 -31
- package/dist/router.js.map +1 -1
- package/dist/vite.d.ts +10 -4
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +44 -12
- package/dist/vite.js.map +1 -1
- package/package.json +17 -2
- package/src/build-vite.ts +606 -0
- package/src/build.ts +432 -73
- package/src/bun-dev-entry.ts +184 -0
- package/src/client.ts +8 -0
- package/src/dev-port.ts +63 -0
- package/src/dev.ts +467 -65
- package/src/draft.ts +116 -4
- package/src/fs.ts +22 -4
- package/src/index.ts +727 -57
- package/src/internal/timing-safe-equal.ts +24 -0
- package/src/isr.ts +1 -13
- package/src/llms-txt.ts +13 -5
- package/src/manifest.ts +188 -13
- package/src/module-graph.ts +146 -0
- package/src/plugins/css-modules.ts +0 -0
- package/src/plugins/vite-leak-guard.ts +110 -0
- package/src/public-dir.ts +108 -0
- package/src/route-manifest.ts +240 -0
- package/src/router.ts +198 -34
- package/src/vite.ts +62 -20
package/src/index.ts
CHANGED
|
@@ -28,7 +28,9 @@ import { ISR_REVALIDATE_HEADER } from "./isr.ts"
|
|
|
28
28
|
import { generateLlmsTxt } from "./llms-txt.ts"
|
|
29
29
|
import type {
|
|
30
30
|
LayoutEntry,
|
|
31
|
+
Loader as LayoutLoader,
|
|
31
32
|
LinkDescriptor,
|
|
33
|
+
LoaderContext,
|
|
32
34
|
Manifest,
|
|
33
35
|
Meta,
|
|
34
36
|
MetaArgs,
|
|
@@ -38,10 +40,14 @@ import type {
|
|
|
38
40
|
ScriptDescriptor,
|
|
39
41
|
} from "./manifest.ts"
|
|
40
42
|
import {
|
|
43
|
+
createMatcher,
|
|
41
44
|
DATA_HEADER,
|
|
45
|
+
NAV_FROM_HEADER,
|
|
42
46
|
PRERENDERED_GLOBAL,
|
|
43
47
|
REDIRECT_HEADER,
|
|
48
|
+
RETAIN_HEADER,
|
|
44
49
|
REVALIDATE_HEADER,
|
|
50
|
+
STATUS_HEADER,
|
|
45
51
|
type Submission,
|
|
46
52
|
} from "./router.ts"
|
|
47
53
|
|
|
@@ -53,6 +59,8 @@ export {
|
|
|
53
59
|
type EnableDraftOptions,
|
|
54
60
|
enableDraft,
|
|
55
61
|
isDraftEnabled,
|
|
62
|
+
type PreviewEndpointOptions,
|
|
63
|
+
previewEndpoint,
|
|
56
64
|
} from "./draft.ts"
|
|
57
65
|
// Font optimization — a CLS-safe `@font-face` generator + a preload `<link>` for self-hosted fonts.
|
|
58
66
|
export {
|
|
@@ -82,7 +90,6 @@ export {
|
|
|
82
90
|
revalidateEndpoint,
|
|
83
91
|
withISR,
|
|
84
92
|
} from "./isr.ts"
|
|
85
|
-
|
|
86
93
|
// File-based routing manifest — pure + fs-free. `discoverRoutes` (fs) lives in `@nifrajs/web/fs`.
|
|
87
94
|
export {
|
|
88
95
|
type Action,
|
|
@@ -114,6 +121,13 @@ export {
|
|
|
114
121
|
type NavigateOptions,
|
|
115
122
|
setBrowserNavigate,
|
|
116
123
|
} from "./navigation.ts"
|
|
124
|
+
// public/ — user-authored static files, served identically in dev and production by one handler.
|
|
125
|
+
export {
|
|
126
|
+
type PublicDirCache,
|
|
127
|
+
resolvePublicPath,
|
|
128
|
+
type ServePublicDirOptions,
|
|
129
|
+
servePublicDir,
|
|
130
|
+
} from "./public-dir.ts"
|
|
117
131
|
// Keyed query-cache (agnostic) — a `query(key, fn)` primitive (dedup + staleness + invalidation + GC)
|
|
118
132
|
// consumed by the per-adapter `useQuery`/`createQuery` bindings.
|
|
119
133
|
export {
|
|
@@ -147,11 +161,14 @@ export {
|
|
|
147
161
|
type FetcherState,
|
|
148
162
|
type FetchRouteData,
|
|
149
163
|
type MountRouterOptions,
|
|
164
|
+
NAV_FROM_HEADER,
|
|
150
165
|
REDIRECT_HEADER,
|
|
166
|
+
RETAIN_HEADER,
|
|
151
167
|
REVALIDATE_HEADER,
|
|
152
168
|
type RouteMatch,
|
|
153
169
|
type RoutePattern,
|
|
154
170
|
type RouterState,
|
|
171
|
+
STATUS_HEADER,
|
|
155
172
|
type Submission,
|
|
156
173
|
type SubmitOptions,
|
|
157
174
|
} from "./router.ts"
|
|
@@ -198,6 +215,14 @@ export {
|
|
|
198
215
|
* (absent on SSR): they drive **optimistic UI** — render from `submission.formData` while `pending`. */
|
|
199
216
|
export interface RenderProps {
|
|
200
217
|
readonly data: unknown
|
|
218
|
+
/**
|
|
219
|
+
* Per-layout loader data, aligned index-for-index with the layout prefix of `chain`.
|
|
220
|
+
*
|
|
221
|
+
* Absent when no layout in the chain exports a `loader`, so a page-only app is byte-identical to
|
|
222
|
+
* before. An adapter that does not read it renders exactly as it did; one that does passes
|
|
223
|
+
* `layoutData[i]` to `chain[i]`.
|
|
224
|
+
*/
|
|
225
|
+
readonly layoutData?: readonly unknown[]
|
|
201
226
|
readonly actionData?: unknown
|
|
202
227
|
/** True while a client navigation or submit is in flight (client-only; absent/false on SSR). */
|
|
203
228
|
readonly pending?: boolean
|
|
@@ -255,6 +280,9 @@ export interface RenderAdapter {
|
|
|
255
280
|
|
|
256
281
|
/** Global the server serializes loader data into; the client reads it to hydrate. */
|
|
257
282
|
export const DATA_GLOBAL = "__NIFRA_DATA__"
|
|
283
|
+
/** Per-layout loader data for hydration. Emitted ONLY when some layout in the chain has a loader, so
|
|
284
|
+
* a page-only app's HTML is byte-identical to before layout loaders existed. */
|
|
285
|
+
export const LAYOUT_DATA_GLOBAL = "__NIFRA_LAYOUT_DATA__"
|
|
258
286
|
|
|
259
287
|
/** Global the server writes the matched route id into; the client uses it to pick the chain. */
|
|
260
288
|
export const ROUTE_GLOBAL = "__NIFRA_ROUTE__"
|
|
@@ -358,8 +386,15 @@ export interface RenderPageOptions {
|
|
|
358
386
|
* `useLocation`/`useSearchParams` render the right URL server-side and hydrate without drift. Omit ⇒
|
|
359
387
|
* `""`. */
|
|
360
388
|
readonly path?: string
|
|
389
|
+
/** Per-layout loader data, forwarded to the adapter as `RenderProps.layoutData`. Aligned with the
|
|
390
|
+
* chain's layout prefix; omitted when no layout in the chain has a loader. */
|
|
391
|
+
readonly layoutData?: readonly unknown[]
|
|
361
392
|
/** HTTP status for the response (default 200; e.g. 404 for a not-found page). */
|
|
362
393
|
readonly status?: number
|
|
394
|
+
/** Extra response headers - e.g. the `cache-control` a terminal status page wants. `content-type`
|
|
395
|
+
* is ignored (the document is always HTML) and the ISR freshness header is applied after these,
|
|
396
|
+
* so neither can be overridden from here. */
|
|
397
|
+
readonly headers?: HeadersLike
|
|
363
398
|
/** Document title (fallback when `head.title` is unset). */
|
|
364
399
|
readonly title?: string
|
|
365
400
|
/** Resolved route head — `title` overrides `title` above; `meta`/`link` render as managed
|
|
@@ -418,6 +453,7 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
|
|
|
418
453
|
rootId = "root",
|
|
419
454
|
hydrate = true,
|
|
420
455
|
islandScripts = [],
|
|
456
|
+
headers: extraHeaders,
|
|
421
457
|
} = options
|
|
422
458
|
const route = routeId === undefined ? "" : `window.${ROUTE_GLOBAL}=${serializeData(routeId)};`
|
|
423
459
|
// The SSG prerendered-path set (when an app declares it) — the client reads it to fetch a static
|
|
@@ -431,9 +467,29 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
|
|
|
431
467
|
// also `defer()` — split it too, continuing the id space so a single registry settles both. The
|
|
432
468
|
// inline registry runtime is emitted only when something defers, so non-deferred output is unchanged.
|
|
433
469
|
const { forComponent, forClient, deferred } = prepareDeferred(data)
|
|
470
|
+
// Each layout's data is split in turn, each continuing the id space, so a `defer()` in a layout
|
|
471
|
+
// settles through the SAME registry as the page's rather than colliding on ids.
|
|
472
|
+
const layoutSplits: Array<ReturnType<typeof prepareDeferred>> = []
|
|
473
|
+
if (options.layoutData !== undefined) {
|
|
474
|
+
let offset = deferred.length
|
|
475
|
+
for (const entry of options.layoutData) {
|
|
476
|
+
const split = prepareDeferred(entry, offset)
|
|
477
|
+
offset += split.deferred.length
|
|
478
|
+
layoutSplits.push(split)
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const layoutDeferred = layoutSplits.flatMap((split) => split.deferred)
|
|
434
482
|
const actionSplit =
|
|
435
|
-
actionData === undefined
|
|
436
|
-
|
|
483
|
+
actionData === undefined
|
|
484
|
+
? undefined
|
|
485
|
+
: prepareDeferred(actionData, deferred.length + layoutDeferred.length)
|
|
486
|
+
const allDeferred = [...deferred, ...layoutDeferred, ...(actionSplit ? actionSplit.deferred : [])]
|
|
487
|
+
// Omitted entirely when no layout has a loader — a page-only app emits exactly what it did before.
|
|
488
|
+
const layoutTail =
|
|
489
|
+
options.layoutData === undefined
|
|
490
|
+
? ""
|
|
491
|
+
: `window.${LAYOUT_DATA_GLOBAL}=${serializeData(layoutSplits.map((split) => split.forClient))};`
|
|
492
|
+
|
|
437
493
|
// Only emit the action global when an action actually ran, so plain GET output is unchanged.
|
|
438
494
|
const action =
|
|
439
495
|
actionSplit === undefined
|
|
@@ -476,7 +532,13 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
|
|
|
476
532
|
// Runs in the first-paint→hydration window to swallow a JS-only form's broken native submit. Only on a
|
|
477
533
|
// hydrating page (a static/_error page has no client handlers, so no footgun).
|
|
478
534
|
const hydrationGuard = hydrate ? `<script>${PRE_HYDRATION_GUARD}</script>` : ""
|
|
479
|
-
|
|
535
|
+
// `<html>` attributes. `lang` defaults to `"en"` (so an app that never sets it is byte-identical to
|
|
536
|
+
// before); `dir` is omitted entirely when unset, which IS HTML's `ltr` default — emitting it
|
|
537
|
+
// unconditionally would change every existing app's output for no gain. Both attribute-escaped.
|
|
538
|
+
// `client.ts`'s `applyHead` mirrors this exact defaulting on soft-nav, so a hard load and a client
|
|
539
|
+
// navigation to the same URL produce the same `<html>` (no drift on a multilingual site).
|
|
540
|
+
const htmlAttrs = ` lang="${escapeAttr(head?.lang ?? "en")}"${head?.dir === undefined ? "" : ` dir="${escapeAttr(head.dir)}"`}`
|
|
541
|
+
const shellHtml = `<!doctype html><html${htmlAttrs}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${hydrationGuard}<title>${escapeHtml(head?.title ?? title)}</title>${headTags(head)}${styleLinks}${hydrationLinks}${islandPreloads}${adapter.hydrationHead()}</head><body><div id="${escapeAttr(rootId)}">`
|
|
480
542
|
// Closes the hydration container; deferred resolve scripts go AFTER it (outside `#root`) so they
|
|
481
543
|
// aren't part of the adapter's hydrated tree (an inline script inside it breaks hydration).
|
|
482
544
|
const closeRootHtml = "</div>"
|
|
@@ -489,10 +551,18 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
|
|
|
489
551
|
islandTags += `<script type="module" src="${escapeAttr(src)}"></script>`
|
|
490
552
|
const tailHtml = `${
|
|
491
553
|
hydrate
|
|
492
|
-
? `<script>${route}${action}${prerendered}window.${DATA_GLOBAL}=${serializeData(forClient)}</script><script type="module" src="${escapeAttr(clientEntry)}"></script>`
|
|
554
|
+
? `<script>${route}${action}${prerendered}${layoutTail}window.${DATA_GLOBAL}=${serializeData(forClient)}</script><script type="module" src="${escapeAttr(clientEntry)}"></script>`
|
|
493
555
|
: ""
|
|
494
556
|
}${islandTags}</body></html>`
|
|
495
557
|
const headers: Record<string, string> = { "content-type": "text/html; charset=utf-8" }
|
|
558
|
+
// Caller-supplied headers (a terminal status page's `cache-control`, say). Applied before the
|
|
559
|
+
// framework's own below, so `content-type` and the ISR channel stay authoritative - a caller must
|
|
560
|
+
// not be able to mislabel the document or forge the freshness header by passing them here.
|
|
561
|
+
if (extraHeaders !== undefined) {
|
|
562
|
+
for (const [name, value] of new Headers(extraHeaders)) {
|
|
563
|
+
if (name !== "content-type") headers[name] = value
|
|
564
|
+
}
|
|
565
|
+
}
|
|
496
566
|
// ISR: advertise the route's freshness so a `withISR` wrapper can set this page's cache TTL. A
|
|
497
567
|
// dedicated header (not the action-revalidation `x-nifra-revalidate`) so the TTL channel never aliases
|
|
498
568
|
// the client's path-list channel.
|
|
@@ -505,6 +575,10 @@ export function renderPageResult(options: RenderPageOptions): MaybePromise<Rende
|
|
|
505
575
|
// router bindings simply never reads them.
|
|
506
576
|
...(options.params !== undefined ? { params: options.params } : {}),
|
|
507
577
|
...(options.path !== undefined ? { path: options.path } : {}),
|
|
578
|
+
// Spread only when present, so a page-only render produces exactly the props it did before.
|
|
579
|
+
...(options.layoutData !== undefined
|
|
580
|
+
? { layoutData: layoutSplits.map((split) => split.forComponent) }
|
|
581
|
+
: {}),
|
|
508
582
|
}
|
|
509
583
|
|
|
510
584
|
// Fast path: nothing `defer()`s and the adapter can render synchronously to a string → buffer the
|
|
@@ -717,6 +791,205 @@ export function redirect(location: string, options: RedirectOptions = {}): Respo
|
|
|
717
791
|
return new Response(null, { status: options.status ?? 303, headers: { location } })
|
|
718
792
|
}
|
|
719
793
|
|
|
794
|
+
/**
|
|
795
|
+
* Brand marking a `Response` as a **terminal-status signal** rather than a response to serve verbatim.
|
|
796
|
+
*
|
|
797
|
+
* A loader that throws a bare `Response` has always been passed straight through - that is how
|
|
798
|
+
* `throw redirect(...)` works, and apps rely on it. So "render the `_404` boundary at 404" cannot be
|
|
799
|
+
* expressed by throwing a plain `Response`: the framework would have no way to tell it apart from a
|
|
800
|
+
* hand-rolled body the app wants served exactly as written. The brand is that distinction, and
|
|
801
|
+
* checking it *before* the pass-through is what keeps every existing throw working unchanged.
|
|
802
|
+
*
|
|
803
|
+
* `Symbol.for` matches `RESPONSE_RESULT` above: a registry symbol survives two copies of this module,
|
|
804
|
+
* which a `unique symbol` would not - and a duplicate-install turning a 404 into a raw 404 body would
|
|
805
|
+
* be a maddening bug to trace.
|
|
806
|
+
*/
|
|
807
|
+
/** Derived from the `Headers` constructor rather than the DOM lib's `HeadersInit`: `@nifrajs/web`
|
|
808
|
+
* is consumed from DOM-free programs (the root tsconfig runs `types: ["bun"]`), and naming the DOM
|
|
809
|
+
* type here would break their build. Same workaround as `SSEInit.headers` in core. */
|
|
810
|
+
type HeadersLike = ConstructorParameters<typeof Headers>[0]
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* The signatures a **duplicate module instance** produces during SSR, across engines.
|
|
814
|
+
*
|
|
815
|
+
* Two copies of React (or of `@nifrajs/core`) at the SAME version still fail, because module identity
|
|
816
|
+
* is path-based: hooks read a dispatcher off the copy that rendered, and the component imported the
|
|
817
|
+
* other one. The error that surfaces names a React internal, so it reads as a React bug and the actual
|
|
818
|
+
* cause - two directories - is a long inference away. Naming the cause at the point of failure is the
|
|
819
|
+
* difference between that and a five-second read.
|
|
820
|
+
*/
|
|
821
|
+
const DUPLICATE_INSTANCE_SIGNATURES: readonly RegExp[] = [
|
|
822
|
+
/resolveDispatcher\(\)/,
|
|
823
|
+
/Invalid hook call/,
|
|
824
|
+
/Cannot read propert(?:y|ies) of null \(reading '(?:use[A-Z]\w*)'\)/,
|
|
825
|
+
/null is not an object \(evaluating '.*\.use[A-Z]\w*'\)/,
|
|
826
|
+
]
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Append the likely cause to an SSR error that carries a duplicate-instance signature.
|
|
830
|
+
*
|
|
831
|
+
* Deliberately does NOT claim the resolved paths: `@nifrajs/web` is framework-agnostic and does not
|
|
832
|
+
* depend on React, so it cannot resolve the copies without guessing. It names the condition and the
|
|
833
|
+
* command that reports the paths, which is the actionable part. Returns the error untouched when the
|
|
834
|
+
* signature does not match, so an ordinary render error reads exactly as before.
|
|
835
|
+
*/
|
|
836
|
+
function withDuplicateInstanceHint(err: unknown): unknown {
|
|
837
|
+
if (!(err instanceof Error)) return err
|
|
838
|
+
if (!DUPLICATE_INSTANCE_SIGNATURES.some((rx) => rx.test(err.message))) return err
|
|
839
|
+
const augmented = new Error(
|
|
840
|
+
`${err.message}\n\n[nifra/web] This signature usually means TWO COPIES of an identity-sensitive package (react, react-dom, or @nifrajs/core) are installed at different paths. Module identity is path-based, so matching versions do NOT fix it - the copies must resolve to the same directory. Run \`nifra check\` to list the paths.`,
|
|
841
|
+
{ cause: err },
|
|
842
|
+
)
|
|
843
|
+
augmented.name = err.name
|
|
844
|
+
// `stack` is optional under exactOptionalPropertyTypes; assigning `undefined` would replace a real
|
|
845
|
+
// stack with nothing on engines that always populate it.
|
|
846
|
+
if (err.stack !== undefined) augmented.stack = err.stack
|
|
847
|
+
return augmented
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** A loaded layout module. `loader`/`gate` are the layout-loader surface; `meta` predates it. */
|
|
851
|
+
type LoadedLayoutModules = ReadonlyArray<{
|
|
852
|
+
default: unknown
|
|
853
|
+
meta?: MetaInput
|
|
854
|
+
loader?: LayoutLoader
|
|
855
|
+
action?: unknown
|
|
856
|
+
gate?: boolean
|
|
857
|
+
}>
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Narrow the route's params to the ones a layout owns.
|
|
861
|
+
*
|
|
862
|
+
* A layout wraps a URL prefix, so anything deeper belongs to a route beneath it. Handing it the full
|
|
863
|
+
* set would let a layout read a param it does not own - which reads fine until the layout is reused
|
|
864
|
+
* under a route that has no such param and the value silently becomes `undefined`.
|
|
865
|
+
*/
|
|
866
|
+
function scopeParams(
|
|
867
|
+
params: Record<string, string>,
|
|
868
|
+
owned: readonly string[] | undefined,
|
|
869
|
+
): Record<string, string> {
|
|
870
|
+
if (owned === undefined) return params // hand-built manifest with no scope info: unchanged behaviour
|
|
871
|
+
if (owned.length === 0) return EMPTY_LAYOUT_PARAMS
|
|
872
|
+
const scoped: Record<string, string> = {}
|
|
873
|
+
for (const name of owned) {
|
|
874
|
+
const value = params[name]
|
|
875
|
+
if (value !== undefined) scoped[name] = value
|
|
876
|
+
}
|
|
877
|
+
return scoped
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const EMPTY_LAYOUT_PARAMS: Record<string, string> = Object.freeze({})
|
|
881
|
+
|
|
882
|
+
const EMPTY_RETAIN: ReadonlySet<number> = new Set<number>()
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Marks an error as coming from a LAYOUT loader, carrying that layout's id.
|
|
886
|
+
*
|
|
887
|
+
* The boundary that catches it must be at or above the failing layout's own segment. The route's
|
|
888
|
+
* innermost `_error` may live BELOW the layout that failed, and rendering there would wrap the
|
|
889
|
+
* boundary in a layout whose data never arrived - so the page would render inside a component that
|
|
890
|
+
* threw. A registry symbol, matching the other cross-module brands here.
|
|
891
|
+
*/
|
|
892
|
+
const LAYOUT_ERROR_ID = Symbol.for("nifra.web.layout-error-id")
|
|
893
|
+
|
|
894
|
+
const tagLayoutError = (err: unknown, layoutId: string): unknown => {
|
|
895
|
+
if (typeof err === "object" && err !== null && !(LAYOUT_ERROR_ID in err)) {
|
|
896
|
+
Object.defineProperty(err, LAYOUT_ERROR_ID, { value: layoutId, enumerable: false })
|
|
897
|
+
}
|
|
898
|
+
return err
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const layoutErrorId = (err: unknown): string | undefined =>
|
|
902
|
+
typeof err === "object" && err !== null
|
|
903
|
+
? ((err as Record<symbol, unknown>)[LAYOUT_ERROR_ID] as string | undefined)
|
|
904
|
+
: undefined
|
|
905
|
+
|
|
906
|
+
const STATUS_SIGNAL = Symbol.for("nifra.web.status-signal")
|
|
907
|
+
|
|
908
|
+
/** Reason phrases for the plain-text fallback, used only when the app authored no `_<status>` and no
|
|
909
|
+
* `_404` page. Deliberately not exhaustive - anything unlisted falls back to "Error", which is more
|
|
910
|
+
* useful than shipping a table of every RFC status for a body almost nobody will see. */
|
|
911
|
+
const STATUS_TEXT: Readonly<Record<number, string>> = {
|
|
912
|
+
400: "Bad Request",
|
|
913
|
+
401: "Unauthorized",
|
|
914
|
+
402: "Payment Required",
|
|
915
|
+
403: "Forbidden",
|
|
916
|
+
404: "Not Found",
|
|
917
|
+
410: "Gone",
|
|
918
|
+
451: "Unavailable For Legal Reasons",
|
|
919
|
+
500: "Internal Server Error",
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
interface StatusSignal extends Response {
|
|
923
|
+
readonly [STATUS_SIGNAL]: { readonly status: number; readonly headers?: HeadersLike }
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function isStatusSignal(value: unknown): value is StatusSignal {
|
|
927
|
+
return value instanceof Response && STATUS_SIGNAL in value
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function statusSignal(status: number, options: StatusPageOptions): never {
|
|
931
|
+
if (!Number.isInteger(status) || status < 400 || status > 599) {
|
|
932
|
+
throw new Error(
|
|
933
|
+
`[nifra/web] statusPage(${JSON.stringify(status)}) must be an integer 4xx or 5xx status. Use redirect() for 3xx, and return data for a successful render.`,
|
|
934
|
+
)
|
|
935
|
+
}
|
|
936
|
+
const response = new Response(null, { status })
|
|
937
|
+
// Non-enumerable so the brand never lands in a structured clone or a JSON log of the response.
|
|
938
|
+
Object.defineProperty(response, STATUS_SIGNAL, {
|
|
939
|
+
value: { status, ...(options.headers !== undefined ? { headers: options.headers } : {}) },
|
|
940
|
+
enumerable: false,
|
|
941
|
+
})
|
|
942
|
+
throw response
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** Options shared by {@link notFound}, {@link gone}, and {@link statusPage}. */
|
|
946
|
+
export interface StatusPageOptions {
|
|
947
|
+
/**
|
|
948
|
+
* Extra headers for the rendered response - `cache-control` above all.
|
|
949
|
+
*
|
|
950
|
+
* The defaults differ by status for a reason: a 404 may be a race with publication, so it wants a
|
|
951
|
+
* short TTL, while a 410 is a promise that the URL is permanently gone and can be cached hard.
|
|
952
|
+
* Getting this wrong is the difference between a crawler dropping a dead URL and re-fetching it
|
|
953
|
+
* for weeks.
|
|
954
|
+
*/
|
|
955
|
+
readonly headers?: HeadersLike
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Render the nearest `_404` page at status **404**. `throw` it from a loader when the record does not
|
|
960
|
+
* exist.
|
|
961
|
+
*
|
|
962
|
+
* This is the fix for the soft 404: a matched route whose loader finds nothing has otherwise no way to
|
|
963
|
+
* answer 404, so the path of least resistance is to return empty data and render "not found" inside a
|
|
964
|
+
* **200**. That looks correct in a browser and is invisible in review, which is why it ships and stays
|
|
965
|
+
* shipped - while search engines penalise it and keep the dead URL indexed.
|
|
966
|
+
*
|
|
967
|
+
* Returns `never`: these throw, so a loader narrows without a redundant `return`, and the type states
|
|
968
|
+
* the thing the "loaders `throw` redirect, actions `return` it" rule already trips people on.
|
|
969
|
+
*/
|
|
970
|
+
export function notFound(options: StatusPageOptions = {}): never {
|
|
971
|
+
return statusSignal(404, options)
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Render a terminal page at status **410 Gone**. `throw` it from a loader for a record that existed and
|
|
976
|
+
* was deliberately removed - a withdrawn listing, a deleted post.
|
|
977
|
+
*
|
|
978
|
+
* 410 is not a pedantic 404: it tells a crawler to **drop** the URL rather than re-fetch it for weeks
|
|
979
|
+
* on the assumption the 404 was transient. Uses `_410.tsx` if the app has one, otherwise `_404`.
|
|
980
|
+
*/
|
|
981
|
+
export function gone(options: StatusPageOptions = {}): never {
|
|
982
|
+
return statusSignal(410, options)
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Render a terminal page at any 4xx/5xx status - the escape hatch behind {@link notFound} and
|
|
987
|
+
* {@link gone} (402, 451, …). Uses `_<status>.tsx` if present, otherwise `_404`.
|
|
988
|
+
*/
|
|
989
|
+
export function statusPage(status: number, options: StatusPageOptions = {}): never {
|
|
990
|
+
return statusSignal(status, options)
|
|
991
|
+
}
|
|
992
|
+
|
|
720
993
|
/** The wrapper `revalidate()` returns: the action's `data` plus the paths it changed. A plain tagged
|
|
721
994
|
* shape (not a class) so `@nifrajs/client`'s `ActionData` can unwrap it structurally without importing
|
|
722
995
|
* from `@nifrajs/web`. `createWebApp` strips the wrapper — the client receives `data` as the body and
|
|
@@ -926,11 +1199,15 @@ export function mergeHeads(heads: readonly Meta[]): Meta {
|
|
|
926
1199
|
// resolved object by reference so headTags' identity-keyed memo hits across requests for static meta.
|
|
927
1200
|
if (heads.length === 1) return heads[0] as Meta
|
|
928
1201
|
let title: string | undefined
|
|
1202
|
+
let lang: string | undefined
|
|
1203
|
+
let dir: Meta["dir"]
|
|
929
1204
|
const meta: Array<Record<string, string>> = []
|
|
930
1205
|
const link: LinkDescriptor[] = []
|
|
931
1206
|
const script: ScriptDescriptor[] = []
|
|
932
1207
|
for (const h of heads) {
|
|
933
1208
|
if (h.title !== undefined) title = h.title // nearest-wins: later (more specific) overrides
|
|
1209
|
+
if (h.lang !== undefined) lang = h.lang // nearest-wins, like title
|
|
1210
|
+
if (h.dir !== undefined) dir = h.dir
|
|
934
1211
|
if (h.meta !== undefined) meta.push(...h.meta)
|
|
935
1212
|
if (h.link !== undefined) link.push(...h.link)
|
|
936
1213
|
if (h.script !== undefined) script.push(...h.script) // concatenated like meta/link (outermost first)
|
|
@@ -944,11 +1221,15 @@ export function mergeHeads(heads: readonly Meta[]): Meta {
|
|
|
944
1221
|
meta?: Meta["meta"]
|
|
945
1222
|
link?: Meta["link"]
|
|
946
1223
|
script?: Meta["script"]
|
|
1224
|
+
lang?: string
|
|
1225
|
+
dir?: Meta["dir"]
|
|
947
1226
|
} = {}
|
|
948
1227
|
if (title !== undefined) merged.title = title
|
|
949
1228
|
if (meta.length > 0) merged.meta = meta
|
|
950
1229
|
if (link.length > 0) merged.link = link
|
|
951
1230
|
if (script.length > 0) merged.script = script
|
|
1231
|
+
if (lang !== undefined) merged.lang = lang
|
|
1232
|
+
if (dir !== undefined) merged.dir = dir
|
|
952
1233
|
return merged as Meta
|
|
953
1234
|
}
|
|
954
1235
|
|
|
@@ -975,6 +1256,33 @@ export interface CreateWebAppOptions {
|
|
|
975
1256
|
* auto-mount entirely (the app serves pages only and `api` stays a loader-only `ctx.api`). Mounting
|
|
976
1257
|
* is also a no-op when `api` does not expose the symbol mount. */
|
|
977
1258
|
readonly apiPrefix?: string
|
|
1259
|
+
/**
|
|
1260
|
+
* Strip {@link apiPrefix} from the pathname before dispatching to `api` (default `false`).
|
|
1261
|
+
*
|
|
1262
|
+
* The default suits a backend that declares FULL paths (`server().post("/api/sync", …)`), which is
|
|
1263
|
+
* right when it is only ever mounted here. Set this when the backend declares its routes WITHOUT the
|
|
1264
|
+
* prefix because it also runs standalone behind its own shell, so its paths cannot carry a prefix that
|
|
1265
|
+
* only exists when it is mounted. Without it every request 404s inside the backend, and the workaround
|
|
1266
|
+
* is a `Proxy` that rewrites each URL.
|
|
1267
|
+
*/
|
|
1268
|
+
readonly apiStrip?: boolean
|
|
1269
|
+
/**
|
|
1270
|
+
* Sub-apps mounted ahead of page routing - an auth handler, a webhook receiver, a stack's routes.
|
|
1271
|
+
*
|
|
1272
|
+
* Structural on purpose: anything with `{ path, app: { fetch } }` fits, so a library that exposes its
|
|
1273
|
+
* routes as such a list mounts as `mounts: theirRoutes` without `@nifrajs/web` taking a dependency on
|
|
1274
|
+
* it. `better-auth` is the motivating case - it is not a `backend` route, so `/api/auth/*` used to 404
|
|
1275
|
+
* silently.
|
|
1276
|
+
*
|
|
1277
|
+
* Tried longest-path-first and BEFORE the `api` mount, so a mount at `/api/auth` wins over a backend
|
|
1278
|
+
* at `/api` no matter which was declared first. `stripPrefix` is the per-mount form of {@link apiStrip}:
|
|
1279
|
+
* leave it off to pass the full path through.
|
|
1280
|
+
*/
|
|
1281
|
+
readonly mounts?: ReadonlyArray<{
|
|
1282
|
+
readonly path: string
|
|
1283
|
+
readonly app: { fetch(request: Request): Response | Promise<Response> }
|
|
1284
|
+
readonly stripPrefix?: boolean
|
|
1285
|
+
}>
|
|
978
1286
|
/** Secret for **draft / preview mode** (see `enableDraft`). When set, a request carrying a valid
|
|
979
1287
|
* signed `__nifra_draft` cookie gets `ctx.draft === true` in loaders/actions (else always `false`).
|
|
980
1288
|
* Pair with `withISR({ draftSecret })` so editors bypass the cache. Omit to disable draft mode. */
|
|
@@ -1025,6 +1333,27 @@ interface RouteContext {
|
|
|
1025
1333
|
readonly env: unknown
|
|
1026
1334
|
}
|
|
1027
1335
|
|
|
1336
|
+
/**
|
|
1337
|
+
* Re-issue `request` with `prefix` removed from its pathname.
|
|
1338
|
+
*
|
|
1339
|
+
* Needed because two conventions exist and both are reasonable. `createWebApp`'s auto-mount assumes a
|
|
1340
|
+
* backend that declares FULL paths (`server().post("/api/sync")`), which is right when the backend is
|
|
1341
|
+
* only ever mounted here. A backend that also runs standalone declares paths WITHOUT the prefix and
|
|
1342
|
+
* lets its own shell supply it - and mounting one of those here used to require the caller to wrap it
|
|
1343
|
+
* in a `Proxy` that rewrote every URL. Two apps wrote that same wrapper independently, which is the
|
|
1344
|
+
* signal that it belonged in the framework.
|
|
1345
|
+
*
|
|
1346
|
+
* The `Request` is rebuilt rather than mutated (`url` is read-only), preserving method, headers, body,
|
|
1347
|
+
* and duplex streaming - the body is passed through unread, so a large or streamed upload is untouched.
|
|
1348
|
+
*/
|
|
1349
|
+
function stripMountPrefix(request: Request, prefix: string): Request {
|
|
1350
|
+
const url = new URL(request.url)
|
|
1351
|
+
const rest = url.pathname.slice(prefix.length)
|
|
1352
|
+
url.pathname = rest === "" ? "/" : rest
|
|
1353
|
+
// `url.href`, not the `URL`: this type set only declares the string and Request overloads.
|
|
1354
|
+
return new Request(url.href, request)
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1028
1357
|
/**
|
|
1029
1358
|
* Resolve the explicit symbol-keyed backend mount interface. The symbol seam forwards platform
|
|
1030
1359
|
* context without making web depend on client.
|
|
@@ -1087,14 +1416,27 @@ export function createWebApp<Env = unknown>(
|
|
|
1087
1416
|
// sites. `apiPrefix: ""` opts out; a non-mountable `api` (no symbol mount) is left pages-only. The hook
|
|
1088
1417
|
// is registered ONLY when both hold, so a pages-only app keeps core's synchronous no-hook fast path.
|
|
1089
1418
|
const apiPrefix = options.apiPrefix ?? "/api"
|
|
1419
|
+
const apiStrip = options.apiStrip === true
|
|
1090
1420
|
const mountedApi = backendMountOf(api)
|
|
1091
|
-
|
|
1421
|
+
// Longest path first, so a more specific mount (`/api/auth`) is tried before a broader one (`/api`)
|
|
1422
|
+
// regardless of the order they were declared in.
|
|
1423
|
+
const mounts = [...(options.mounts ?? [])].sort((a, b) => b.path.length - a.path.length)
|
|
1424
|
+
const underPrefix = (pathname: string, prefix: string): boolean =>
|
|
1425
|
+
pathname === prefix || pathname.startsWith(`${prefix}/`)
|
|
1426
|
+
|
|
1427
|
+
if (mounts.length > 0 || (apiPrefix !== "" && mountedApi !== undefined)) {
|
|
1092
1428
|
app.onRequest((req, platform) => {
|
|
1093
1429
|
const { pathname } = new URL(req.url)
|
|
1430
|
+
// Sub-app mounts win over the backend prefix: they are the more specific declaration, and an
|
|
1431
|
+
// auth handler mounted at `/api/auth` must not be swallowed by the backend mounted at `/api`.
|
|
1432
|
+
for (const mount of mounts) {
|
|
1433
|
+
if (!underPrefix(pathname, mount.path)) continue
|
|
1434
|
+
return mount.app.fetch(mount.stripPrefix === true ? stripMountPrefix(req, mount.path) : req)
|
|
1435
|
+
}
|
|
1094
1436
|
// Exactly the prefix (`/api`) or a sub-path (`/api/…`) — NOT a sibling like `/apixyz` that merely
|
|
1095
1437
|
// shares the prefix as a string head. Dispatch the original `req` (body intact) to the backend.
|
|
1096
|
-
if (
|
|
1097
|
-
return mountedApi(req, platform)
|
|
1438
|
+
if (mountedApi !== undefined && apiPrefix !== "" && underPrefix(pathname, apiPrefix)) {
|
|
1439
|
+
return mountedApi(apiStrip ? stripMountPrefix(req, apiPrefix) : req, platform)
|
|
1098
1440
|
}
|
|
1099
1441
|
return undefined // not an API path → continue to page routing
|
|
1100
1442
|
})
|
|
@@ -1103,24 +1445,201 @@ export function createWebApp<Env = unknown>(
|
|
|
1103
1445
|
// path under a `"404"` route is rejected (it isn't a static file, and the route declared it shouldn't
|
|
1104
1446
|
// SSR on demand). O(1) membership via a Set.
|
|
1105
1447
|
const prerenderedSet = new Set(options.prerenderedPaths ?? [])
|
|
1448
|
+
const matchManifestRoute = createMatcher(
|
|
1449
|
+
manifest.routes.map((route) => ({ routeId: route.id, pattern: route.pattern })),
|
|
1450
|
+
)
|
|
1451
|
+
const routeById = new Map(manifest.routes.map((route) => [route.id, route]))
|
|
1106
1452
|
|
|
1107
1453
|
// Load a route's layout modules (outermost layout → innermost), keeping each module whole so the
|
|
1108
1454
|
// render path can take both its `default` (the component chain) AND its `meta` (the sitewide head it
|
|
1109
1455
|
// contributes). Loaded lazily — the data-only and 405 branches return before any layout is needed.
|
|
1110
|
-
const loadLayoutModules = (
|
|
1111
|
-
|
|
1112
|
-
): Promise<ReadonlyArray<{ default: unknown; meta?: MetaInput }>> =>
|
|
1113
|
-
Promise.all(
|
|
1456
|
+
const loadLayoutModules = async (route: RouteEntry): Promise<LoadedLayoutModules> => {
|
|
1457
|
+
const modules = await Promise.all(
|
|
1114
1458
|
// layoutIds only reference layouts present in the manifest (buildManifest invariant).
|
|
1115
1459
|
route.layoutIds.map((id) => (manifest.layouts[id] as LayoutEntry).load()),
|
|
1116
1460
|
)
|
|
1461
|
+
// A layout is not a form target, so an `action` there is a mistake with no meaning to give it.
|
|
1462
|
+
// A `loader`, by contrast, now runs (see `runLayoutChain`).
|
|
1463
|
+
for (let i = 0; i < modules.length; i++) {
|
|
1464
|
+
if ((modules[i] as { action?: unknown }).action === undefined) continue
|
|
1465
|
+
const id = route.layoutIds[i] as string
|
|
1466
|
+
throw new Error(
|
|
1467
|
+
`[nifra/web] "${(manifest.layouts[id] as LayoutEntry).file}" exports an \`action\`, but layouts do not run one — only route files do. Move the mutation into the route that submits to it.`,
|
|
1468
|
+
)
|
|
1469
|
+
}
|
|
1470
|
+
return modules
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/**
|
|
1474
|
+
* Run a route's layout-chain loaders, honouring declared gates.
|
|
1475
|
+
*
|
|
1476
|
+
* Returns once every **gate** has resolved, with the non-gate loaders still in flight, so the caller
|
|
1477
|
+
* can start the page loader at exactly the right moment: after anything that might reject the
|
|
1478
|
+
* request, alongside anything that merely fetches.
|
|
1479
|
+
*
|
|
1480
|
+
* The ordering is the security-relevant part. Parallel loaders are NOT an authorization boundary -
|
|
1481
|
+
* a page loader running concurrently with a guard has already queried by the time the guard says no.
|
|
1482
|
+
* Remix documents that as a footgun; here it is a declaration instead. `export const gate = true`
|
|
1483
|
+
* makes a layout blocking for everything beneath it, and nothing beneath a rejected gate runs.
|
|
1484
|
+
*
|
|
1485
|
+
* Each layout receives only the params it OWNS ({@link RouteEntry.layoutParams}), not the route's
|
|
1486
|
+
* full set, so a layout at `orgs/[org]/` cannot read a param belonging to a route below it.
|
|
1487
|
+
*/
|
|
1488
|
+
/**
|
|
1489
|
+
* Layout indices a client navigation asked to keep, from {@link RETAIN_HEADER}.
|
|
1490
|
+
*
|
|
1491
|
+
* The CLIENT decides retainability: it knows each layout's scoped params (shipped in the bootstrap)
|
|
1492
|
+
* and both the old and new match, so it can tell whether a layout's own prefix changed - without the
|
|
1493
|
+
* server re-matching a path the client already matched.
|
|
1494
|
+
*
|
|
1495
|
+
* It is a HINT, not an instruction. The client is asking to keep data it already holds, which is
|
|
1496
|
+
* harmless for data and unacceptable for a guard, so `runLayoutChain` refuses to skip a gate no
|
|
1497
|
+
* matter what arrives here. Anything unparseable is ignored rather than rejected: the worst case of
|
|
1498
|
+
* a bad hint is a loader that runs when it need not.
|
|
1499
|
+
*/
|
|
1500
|
+
const retainedIndices = (header: string | null): ReadonlySet<number> => {
|
|
1501
|
+
if (header === null || header === "") return EMPTY_RETAIN
|
|
1502
|
+
const out = new Set<number>()
|
|
1503
|
+
for (const part of header.split(",")) {
|
|
1504
|
+
// Digits only. `Number("")` is 0 and `Number(" ")` is 0, so a stray comma would otherwise read
|
|
1505
|
+
// as "retain index 0" and silently skip the outermost layout.
|
|
1506
|
+
if (!/^\d+$/.test(part)) continue
|
|
1507
|
+
out.add(Number(part))
|
|
1508
|
+
}
|
|
1509
|
+
return out
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/**
|
|
1513
|
+
* Validate a data-navigation's retain request against the manifest and its declared source path.
|
|
1514
|
+
* A client may ask to retain every slot it currently has; the server only honours indices whose
|
|
1515
|
+
* layout identity and owned params are unchanged. Hard document GETs never retain anything.
|
|
1516
|
+
*/
|
|
1517
|
+
const validatedRetainedIndices = (
|
|
1518
|
+
req: Request,
|
|
1519
|
+
route: RouteEntry,
|
|
1520
|
+
params: Readonly<Record<string, string>>,
|
|
1521
|
+
): ReadonlySet<number> => {
|
|
1522
|
+
if (req.headers.get(DATA_HEADER) === null) return EMPTY_RETAIN
|
|
1523
|
+
const requested = retainedIndices(req.headers.get(RETAIN_HEADER))
|
|
1524
|
+
const from = req.headers.get(NAV_FROM_HEADER)
|
|
1525
|
+
if (requested.size === 0 || from === null) return EMPTY_RETAIN
|
|
1526
|
+
let fromPath: string
|
|
1527
|
+
try {
|
|
1528
|
+
const current = new URL(req.url)
|
|
1529
|
+
const source = new URL(from, current)
|
|
1530
|
+
if (source.origin !== current.origin) return EMPTY_RETAIN
|
|
1531
|
+
fromPath = source.pathname + source.search
|
|
1532
|
+
} catch {
|
|
1533
|
+
return EMPTY_RETAIN
|
|
1534
|
+
}
|
|
1535
|
+
const previousMatch = matchManifestRoute(fromPath)
|
|
1536
|
+
if (previousMatch === null) return EMPTY_RETAIN
|
|
1537
|
+
const previous = routeById.get(previousMatch.routeId)
|
|
1538
|
+
if (previous === undefined) return EMPTY_RETAIN
|
|
1539
|
+
const valid = new Set<number>()
|
|
1540
|
+
for (const index of requested) {
|
|
1541
|
+
if (route.layoutIds[index] !== previous.layoutIds[index]) continue
|
|
1542
|
+
const owned = route.layoutParams?.[index] ?? []
|
|
1543
|
+
const previouslyOwned = previous.layoutParams?.[index] ?? []
|
|
1544
|
+
if (
|
|
1545
|
+
owned.length !== previouslyOwned.length ||
|
|
1546
|
+
owned.some((name, i) => name !== previouslyOwned[i]) ||
|
|
1547
|
+
owned.some((name) => params[name] !== previousMatch.params[name])
|
|
1548
|
+
) {
|
|
1549
|
+
continue
|
|
1550
|
+
}
|
|
1551
|
+
valid.add(index)
|
|
1552
|
+
}
|
|
1553
|
+
return valid
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
const runLayoutChain = async (
|
|
1557
|
+
route: RouteEntry,
|
|
1558
|
+
ctx: LoaderContext,
|
|
1559
|
+
retain: ReadonlySet<number> = new Set(),
|
|
1560
|
+
): Promise<{
|
|
1561
|
+
readonly modules: LoadedLayoutModules
|
|
1562
|
+
readonly layoutData: readonly unknown[] | undefined
|
|
1563
|
+
readonly retained: readonly number[]
|
|
1564
|
+
readonly pending: Promise<unknown>
|
|
1565
|
+
}> => {
|
|
1566
|
+
const modules = await loadLayoutModules(route)
|
|
1567
|
+
if (!modules.some((m) => m.loader !== undefined)) {
|
|
1568
|
+
// Nothing to run: no array is produced, so a page-only app serializes nothing extra and its
|
|
1569
|
+
// HTML is byte-identical to before this feature.
|
|
1570
|
+
return { modules, layoutData: undefined, retained: [], pending: Promise.resolve() }
|
|
1571
|
+
}
|
|
1572
|
+
const results: unknown[] = new Array(modules.length).fill(null)
|
|
1573
|
+
const retained: number[] = []
|
|
1574
|
+
const pending: Array<Promise<void>> = []
|
|
1575
|
+
for (let i = 0; i < modules.length; i++) {
|
|
1576
|
+
const loader = modules[i]?.loader
|
|
1577
|
+
if (loader === undefined) continue
|
|
1578
|
+
// Unchanged scope: the client keeps what it has. A GATE is never skipped - it is a guard, and a
|
|
1579
|
+
// guard that only runs when the URL prefix changed is not a guard.
|
|
1580
|
+
if (retain.has(i) && modules[i]?.gate !== true) {
|
|
1581
|
+
retained.push(i)
|
|
1582
|
+
continue
|
|
1583
|
+
}
|
|
1584
|
+
const scoped: LoaderContext = {
|
|
1585
|
+
...ctx,
|
|
1586
|
+
params: scopeParams(ctx.params, route.layoutParams?.[i]),
|
|
1587
|
+
}
|
|
1588
|
+
if (modules[i]?.gate === true) {
|
|
1589
|
+
// Blocking: awaited here, so a throw propagates before anything deeper is started.
|
|
1590
|
+
try {
|
|
1591
|
+
results[i] = await loader(scoped)
|
|
1592
|
+
} catch (err) {
|
|
1593
|
+
throw tagLayoutError(err, route.layoutIds[i] as string)
|
|
1594
|
+
}
|
|
1595
|
+
continue
|
|
1596
|
+
}
|
|
1597
|
+
const layoutId = route.layoutIds[i] as string
|
|
1598
|
+
const index = i
|
|
1599
|
+
// The loader is invoked INSIDE the async function, not before it. `Promise.resolve(loader(x))`
|
|
1600
|
+
// would let a synchronously-throwing loader escape past the rejection handler entirely - the
|
|
1601
|
+
// exception happens while evaluating the argument, before any promise exists to reject.
|
|
1602
|
+
pending.push(
|
|
1603
|
+
(async () => {
|
|
1604
|
+
try {
|
|
1605
|
+
results[index] = await loader(scoped)
|
|
1606
|
+
} catch (err) {
|
|
1607
|
+
throw tagLayoutError(err, layoutId)
|
|
1608
|
+
}
|
|
1609
|
+
})(),
|
|
1610
|
+
)
|
|
1611
|
+
}
|
|
1612
|
+
return { modules, layoutData: results, retained, pending: Promise.all(pending) }
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
/** Run only blocking layout gates before a route action. Non-gate loaders fetch render data and
|
|
1616
|
+
* therefore run after the mutation when a native POST needs a fresh document. */
|
|
1617
|
+
const runLayoutGates = async (
|
|
1618
|
+
route: RouteEntry,
|
|
1619
|
+
ctx: LoaderContext,
|
|
1620
|
+
): Promise<LoadedLayoutModules> => {
|
|
1621
|
+
const modules = await loadLayoutModules(route)
|
|
1622
|
+
for (let i = 0; i < modules.length; i++) {
|
|
1623
|
+
const mod = modules[i]
|
|
1624
|
+
if (mod?.gate !== true || mod.loader === undefined) continue
|
|
1625
|
+
try {
|
|
1626
|
+
await mod.loader({
|
|
1627
|
+
...ctx,
|
|
1628
|
+
params: scopeParams(ctx.params, route.layoutParams?.[i]),
|
|
1629
|
+
})
|
|
1630
|
+
} catch (err) {
|
|
1631
|
+
throw tagLayoutError(err, route.layoutIds[i] as string)
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
return modules
|
|
1635
|
+
}
|
|
1117
1636
|
|
|
1118
1637
|
// Build a route's render chain + its merged `<head>` from the already-loaded layout modules + the
|
|
1119
1638
|
// page module. The chain is `[…layout components, page]`; the head merges each layout's `meta`
|
|
1120
1639
|
// export (outermost→innermost) with the page's (last), so a `head`/`meta` on `_layout.tsx`
|
|
1121
1640
|
// contributes sitewide tags. `metaArgs` (loader data + params) feed function-form `meta`s.
|
|
1122
1641
|
const resolveChainAndHead = (
|
|
1123
|
-
layoutModules:
|
|
1642
|
+
layoutModules: LoadedLayoutModules,
|
|
1124
1643
|
page: RouteModule,
|
|
1125
1644
|
metaArgs: MetaArgs,
|
|
1126
1645
|
): { chain: unknown[]; head: Meta } => {
|
|
@@ -1136,6 +1655,26 @@ export function createWebApp<Env = unknown>(
|
|
|
1136
1655
|
const dirOfId = (id: string, suffix: string): string =>
|
|
1137
1656
|
id === suffix ? "" : id.slice(0, id.length - suffix.length - 1)
|
|
1138
1657
|
|
|
1658
|
+
/**
|
|
1659
|
+
* Choose the `_error` boundary for a failure, respecting where a LAYOUT loader failed.
|
|
1660
|
+
*
|
|
1661
|
+
* A route error uses the nearest boundary, as it always has. A layout error must not: the nearest
|
|
1662
|
+
* boundary can sit BELOW the layout that threw, and rendering there would wrap the boundary in that
|
|
1663
|
+
* very layout - the one whose loader just failed and whose data therefore never arrived.
|
|
1664
|
+
*/
|
|
1665
|
+
const boundaryFor = (route: RouteEntry, err: unknown): string | undefined => {
|
|
1666
|
+
const ids = route.errorIds ?? []
|
|
1667
|
+
const failing = layoutErrorId(err)
|
|
1668
|
+
if (failing === undefined) return ids.at(-1)
|
|
1669
|
+
const layoutDir = dirOfId(failing, "_layout")
|
|
1670
|
+
// Deepest boundary at or ABOVE the failing layout's directory.
|
|
1671
|
+
for (let i = ids.length - 1; i >= 0; i--) {
|
|
1672
|
+
const errDir = dirOfId(ids[i] as string, "_error")
|
|
1673
|
+
if (errDir === "" || errDir === layoutDir || layoutDir.startsWith(`${errDir}/`)) return ids[i]
|
|
1674
|
+
}
|
|
1675
|
+
return undefined
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1139
1678
|
// The request's origin (scheme + host + port, NO trailing slash) — the one server fact `meta()` needs
|
|
1140
1679
|
// for absolute `canonical`/`og:url`/`og:image` but can't otherwise see (it also runs on the client).
|
|
1141
1680
|
// `URL.origin` is exactly that shape; it matches the browser's `location.origin` for the same URL, so
|
|
@@ -1203,31 +1742,76 @@ export function createWebApp<Env = unknown>(
|
|
|
1203
1742
|
// The 404 response — the `_404` page (status 404) or a plain-text fallback. Shared by the wildcard
|
|
1204
1743
|
// catch-all (unmatched paths) and the `fallback: "404"` enforcement (unlisted paths under a route
|
|
1205
1744
|
// that opted out of on-demand SSR).
|
|
1206
|
-
const renderNotFound =
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1745
|
+
const renderNotFound = (path?: string): Promise<Response | RenderedPage> =>
|
|
1746
|
+
renderTerminalStatus(404, path)
|
|
1747
|
+
|
|
1748
|
+
/**
|
|
1749
|
+
* Render a terminal-status page: the `_<status>` page if the app authored one, else `_404`, else
|
|
1750
|
+
* plain text. Shared by the wildcard catch-all, `fallback: "404"` enforcement, and a loader's
|
|
1751
|
+
* `notFound()` / `gone()` / `statusPage()`, so all of them resolve the page and set the status
|
|
1752
|
+
* through one path rather than forking.
|
|
1753
|
+
*
|
|
1754
|
+
* Hydrates, following the `_404` precedent rather than `_error`'s non-hydrated 500: these are
|
|
1755
|
+
* ordinary pages the client router can own, and a 404 arrived at by clicking a link should behave
|
|
1756
|
+
* like any other route.
|
|
1757
|
+
*/
|
|
1758
|
+
const renderTerminalStatus = async (
|
|
1759
|
+
status: number,
|
|
1760
|
+
path?: string,
|
|
1761
|
+
extraHeaders?: HeadersLike,
|
|
1762
|
+
): Promise<Response | RenderedPage> => {
|
|
1763
|
+
const page = manifest.statusPages?.[String(status)] ?? manifest.notFound
|
|
1764
|
+
if (page === undefined) {
|
|
1765
|
+
const headers = new Headers(extraHeaders)
|
|
1766
|
+
headers.set("content-type", "text/plain; charset=utf-8")
|
|
1767
|
+
return new Response(STATUS_TEXT[status] ?? "Error", { status, headers })
|
|
1212
1768
|
}
|
|
1213
|
-
|
|
1769
|
+
// The routeId decides which preload/style bundle and which client component this maps to, so it
|
|
1770
|
+
// must name the page actually rendered - `_410` when `_410.tsx` exists, `_404` when it fell back.
|
|
1771
|
+
const routeId = manifest.statusPages?.[String(status)] !== undefined ? `_${status}` : "_404"
|
|
1772
|
+
const { default: component } = await page.load()
|
|
1214
1773
|
return renderPageResult({
|
|
1215
1774
|
adapter,
|
|
1216
|
-
chain: [
|
|
1775
|
+
chain: [component],
|
|
1217
1776
|
data: null,
|
|
1218
1777
|
clientEntry,
|
|
1219
|
-
routeId
|
|
1220
|
-
//
|
|
1221
|
-
// (an unmatched path yields no params → `{}`), or a `useLocation`
|
|
1778
|
+
routeId,
|
|
1779
|
+
// The page hydrates, so its SSR `path` must equal the client's initial
|
|
1780
|
+
// `location.pathname+search` (an unmatched path yields no params → `{}`), or a `useLocation`
|
|
1781
|
+
// on it would drift.
|
|
1222
1782
|
...(path !== undefined ? { path } : {}),
|
|
1223
|
-
status
|
|
1224
|
-
...
|
|
1225
|
-
...
|
|
1783
|
+
status,
|
|
1784
|
+
...(extraHeaders !== undefined ? { headers: extraHeaders } : {}),
|
|
1785
|
+
...preloadOf(routeId),
|
|
1786
|
+
...stylesOf(routeId),
|
|
1226
1787
|
prerenderedPaths: options.prerenderedPaths ?? [],
|
|
1227
1788
|
...titleOption,
|
|
1228
1789
|
})
|
|
1229
1790
|
}
|
|
1230
1791
|
|
|
1792
|
+
/**
|
|
1793
|
+
* Answer a loader's terminal-status signal, in whichever form the caller asked for.
|
|
1794
|
+
*
|
|
1795
|
+
* A hard navigation wants the document, so it gets the rendered boundary. A client-side navigation
|
|
1796
|
+
* fetched DATA, not a document, so it gets an empty body plus the status on `X-Nifra-Status`, and
|
|
1797
|
+
* the client router renders the same boundary from its own manifest. Sending the HTML back to a
|
|
1798
|
+
* soft-nav would be answering a different question than the one asked - and skipping the header
|
|
1799
|
+
* entirely is what leaves a crawler seeing a correct 404 while a user who clicked a link sees a
|
|
1800
|
+
* blank page.
|
|
1801
|
+
*/
|
|
1802
|
+
const renderStatusSignal = (
|
|
1803
|
+
req: Request,
|
|
1804
|
+
signal: StatusSignal,
|
|
1805
|
+
): Promise<Response | RenderedPage> | Response => {
|
|
1806
|
+
const { status, headers } = signal[STATUS_SIGNAL]
|
|
1807
|
+
if (req.headers.get(DATA_HEADER) !== null) {
|
|
1808
|
+
const responseHeaders = new Headers(headers)
|
|
1809
|
+
responseHeaders.set(STATUS_HEADER, String(status))
|
|
1810
|
+
return new Response(null, { status, headers: responseHeaders })
|
|
1811
|
+
}
|
|
1812
|
+
return renderTerminalStatus(status, pathOf(req), headers)
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1231
1815
|
for (const route of manifest.routes) {
|
|
1232
1816
|
// A dynamic route whose `getStaticPaths` declared `fallback: "404"` — only its prerendered paths
|
|
1233
1817
|
// exist; anything else 404s (computed once per route, not per request).
|
|
@@ -1242,18 +1826,43 @@ export function createWebApp<Env = unknown>(
|
|
|
1242
1826
|
const mod = await route.load()
|
|
1243
1827
|
const draft = await draftFlag(c.req)
|
|
1244
1828
|
let data: unknown
|
|
1829
|
+
let layoutData: readonly unknown[] | undefined
|
|
1830
|
+
let layoutModules: LoadedLayoutModules | undefined
|
|
1831
|
+
let layoutRetained: readonly number[] = []
|
|
1245
1832
|
try {
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1833
|
+
const ctx = {
|
|
1834
|
+
params: c.params,
|
|
1835
|
+
request: c.req,
|
|
1836
|
+
req: c.req,
|
|
1837
|
+
api,
|
|
1838
|
+
env: c.env,
|
|
1839
|
+
draft,
|
|
1840
|
+
}
|
|
1841
|
+
// Layout loaders run for BOTH the document and the data-only request. A gate that only ran on
|
|
1842
|
+
// the document path would be bypassed by adding the data header, which is exactly the request
|
|
1843
|
+
// a client navigation makes - a guard has to hold on the path an attacker can pick.
|
|
1844
|
+
//
|
|
1845
|
+
// `runLayoutChain` returns once every GATE has resolved, with the non-gate loaders still in
|
|
1846
|
+
// flight. So the page loader starts as late as correctness requires and as early as speed
|
|
1847
|
+
// allows, and both are awaited together below.
|
|
1848
|
+
const run = await runLayoutChain(
|
|
1849
|
+
route,
|
|
1850
|
+
ctx,
|
|
1851
|
+
validatedRetainedIndices(c.req, route, c.params),
|
|
1852
|
+
)
|
|
1853
|
+
layoutModules = run.modules
|
|
1854
|
+
layoutRetained = run.retained
|
|
1855
|
+
const [pageData] = await Promise.all([
|
|
1856
|
+
mod.loader ? mod.loader(ctx) : null,
|
|
1857
|
+
run.pending, // a non-gate layout loader that rejects must still surface, not go unhandled
|
|
1858
|
+
])
|
|
1859
|
+
data = pageData
|
|
1860
|
+
layoutData = run.layoutData
|
|
1256
1861
|
} catch (err) {
|
|
1862
|
+
// A terminal-status signal (`notFound()` / `gone()` / `statusPage(n)`) renders a boundary at
|
|
1863
|
+
// that status. Checked BEFORE the pass-through below: a signal IS a `Response`, so the order
|
|
1864
|
+
// is what keeps a hand-rolled `throw new Response(...)` served verbatim, as it always was.
|
|
1865
|
+
if (isStatusSignal(err)) return renderStatusSignal(c.req, err)
|
|
1257
1866
|
// A thrown `Response` is a control-flow signal (a guard's `redirect(...)`, an explicit error
|
|
1258
1867
|
// response) — let it propagate to core, which returns it as-is. Real errors render the nearest
|
|
1259
1868
|
// `_error` boundary, if any; with none, rethrow (unchanged 500 behavior).
|
|
@@ -1266,7 +1875,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1266
1875
|
// A faulty reporter must never break error rendering.
|
|
1267
1876
|
}
|
|
1268
1877
|
}
|
|
1269
|
-
const errorId = route
|
|
1878
|
+
const errorId = boundaryFor(route, err)
|
|
1270
1879
|
if (errorId === undefined) throw err
|
|
1271
1880
|
// A soft-nav data fetch can't render a boundary — 500 so the client falls back to a full-page
|
|
1272
1881
|
// navigation, which lands here as a document and renders the `_error` page.
|
|
@@ -1276,24 +1885,49 @@ export function createWebApp<Env = unknown>(
|
|
|
1276
1885
|
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
1277
1886
|
})
|
|
1278
1887
|
}
|
|
1279
|
-
return renderError(route, errorId, err)
|
|
1888
|
+
return renderError(route, errorId, withDuplicateInstanceHint(err))
|
|
1280
1889
|
}
|
|
1281
1890
|
// Client-side navigation asks (via the X-Nifra-Data header) for just the loader data — no full
|
|
1282
1891
|
// document, no layout chain. A route with deferred data streams NDJSON (critical data first,
|
|
1283
1892
|
// then each deferred value as it settles); otherwise one JSON (the fast path). Same loader,
|
|
1284
1893
|
// same auth — only the transport differs.
|
|
1285
1894
|
if (c.req.headers.get(DATA_HEADER) !== null) {
|
|
1286
|
-
const
|
|
1287
|
-
|
|
1288
|
-
|
|
1895
|
+
const pageSplit = prepareDeferred(data)
|
|
1896
|
+
const layoutSplits: Array<ReturnType<typeof prepareDeferred>> = []
|
|
1897
|
+
let offset = pageSplit.deferred.length
|
|
1898
|
+
for (const entry of layoutData ?? []) {
|
|
1899
|
+
const split = prepareDeferred(entry, offset)
|
|
1900
|
+
layoutSplits.push(split)
|
|
1901
|
+
offset += split.deferred.length
|
|
1902
|
+
}
|
|
1903
|
+
const deferred = [...pageSplit.deferred, ...layoutSplits.flatMap((split) => split.deferred)]
|
|
1904
|
+
const payload =
|
|
1905
|
+
layoutData === undefined
|
|
1906
|
+
? pageSplit.forClient
|
|
1907
|
+
: {
|
|
1908
|
+
v: 1 as const,
|
|
1909
|
+
data: pageSplit.forClient,
|
|
1910
|
+
layoutData: layoutSplits.map((split) => split.forClient),
|
|
1911
|
+
retained: layoutRetained,
|
|
1912
|
+
}
|
|
1913
|
+
// Bare value when there is no layout data — the pre-envelope shape, so a client running older
|
|
1914
|
+
// code against a newer server keeps working.
|
|
1915
|
+
if (deferred.length === 0) {
|
|
1916
|
+
return Response.json(payload ?? null)
|
|
1917
|
+
}
|
|
1918
|
+
return new Response(ndjsonStream(payload, deferred), {
|
|
1289
1919
|
headers: { "content-type": "application/x-ndjson; charset=utf-8" },
|
|
1290
1920
|
})
|
|
1291
1921
|
}
|
|
1292
|
-
const { chain, head } = resolveChainAndHead(
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1922
|
+
const { chain, head } = resolveChainAndHead(
|
|
1923
|
+
layoutModules ?? (await loadLayoutModules(route)),
|
|
1924
|
+
mod,
|
|
1925
|
+
{
|
|
1926
|
+
data,
|
|
1927
|
+
params: c.params,
|
|
1928
|
+
origin: originOf(c.req),
|
|
1929
|
+
},
|
|
1930
|
+
)
|
|
1297
1931
|
const hydrateRoute = mod.hydrate !== false
|
|
1298
1932
|
try {
|
|
1299
1933
|
// `await` so a shell-render throw (renderToStream rejects before any byte) is caught here and
|
|
@@ -1309,6 +1943,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1309
1943
|
params: c.params,
|
|
1310
1944
|
path: pathOf(c.req),
|
|
1311
1945
|
hydrate: hydrateRoute,
|
|
1946
|
+
...(layoutData !== undefined ? { layoutData } : {}),
|
|
1312
1947
|
...preloadOf(route.id),
|
|
1313
1948
|
...stylesOf(route.id),
|
|
1314
1949
|
prerenderedPaths: options.prerenderedPaths ?? [],
|
|
@@ -1317,6 +1952,8 @@ export function createWebApp<Env = unknown>(
|
|
|
1317
1952
|
...titleOption,
|
|
1318
1953
|
})
|
|
1319
1954
|
} catch (err) {
|
|
1955
|
+
// Same precedence as the loader catch above — a `meta()` may signal too.
|
|
1956
|
+
if (isStatusSignal(err)) return renderStatusSignal(c.req, err)
|
|
1320
1957
|
if (err instanceof Response) throw err
|
|
1321
1958
|
// Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
|
|
1322
1959
|
if (options.onLoaderError !== undefined) {
|
|
@@ -1326,9 +1963,9 @@ export function createWebApp<Env = unknown>(
|
|
|
1326
1963
|
// A faulty reporter must never break error rendering.
|
|
1327
1964
|
}
|
|
1328
1965
|
}
|
|
1329
|
-
const errorId = route
|
|
1966
|
+
const errorId = boundaryFor(route, err)
|
|
1330
1967
|
if (errorId === undefined) throw err
|
|
1331
|
-
return renderError(route, errorId, err)
|
|
1968
|
+
return renderError(route, errorId, withDuplicateInstanceHint(err))
|
|
1332
1969
|
}
|
|
1333
1970
|
})
|
|
1334
1971
|
|
|
@@ -1344,14 +1981,18 @@ export function createWebApp<Env = unknown>(
|
|
|
1344
1981
|
headers: { allow: "GET", "content-type": "text/plain; charset=utf-8" },
|
|
1345
1982
|
})
|
|
1346
1983
|
}
|
|
1347
|
-
const
|
|
1984
|
+
const actionContext = {
|
|
1348
1985
|
params: c.params,
|
|
1349
1986
|
request: c.req,
|
|
1350
1987
|
req: c.req,
|
|
1351
1988
|
api,
|
|
1352
1989
|
env: c.env,
|
|
1353
1990
|
draft,
|
|
1354
|
-
}
|
|
1991
|
+
}
|
|
1992
|
+
// A gate is an authorization boundary for everything beneath its layout, mutations included.
|
|
1993
|
+
// Run only gates before the action; ordinary layout data loaders run after a native mutation.
|
|
1994
|
+
const layoutModules = await runLayoutGates(route, actionContext)
|
|
1995
|
+
const result = await mod.action(actionContext)
|
|
1355
1996
|
const isDataRequest = c.req.headers.get(DATA_HEADER) !== null
|
|
1356
1997
|
// An action may wrap its data in `revalidate(paths, data)` to declare which routes it changed.
|
|
1357
1998
|
// Unwrap to the inner data; the paths ride the `X-Nifra-Revalidate` header on the data-mode
|
|
@@ -1386,7 +2027,7 @@ export function createWebApp<Env = unknown>(
|
|
|
1386
2027
|
const data = mod.loader
|
|
1387
2028
|
? await mod.loader({ params: c.params, request: c.req, req: c.req, api, env: c.env, draft })
|
|
1388
2029
|
: null
|
|
1389
|
-
const { chain, head } = resolveChainAndHead(
|
|
2030
|
+
const { chain, head } = resolveChainAndHead(layoutModules, mod, {
|
|
1390
2031
|
data,
|
|
1391
2032
|
params: c.params,
|
|
1392
2033
|
origin: originOf(c.req),
|
|
@@ -1435,8 +2076,16 @@ export function createWebApp<Env = unknown>(
|
|
|
1435
2076
|
}
|
|
1436
2077
|
|
|
1437
2078
|
export interface GenerateClientEntryOptions {
|
|
1438
|
-
/**
|
|
1439
|
-
* `"@nifrajs/web-solid/client"`.
|
|
2079
|
+
/**
|
|
2080
|
+
* Module specifier for the adapter's client runtime, e.g. `"@nifrajs/web-solid/client"`.
|
|
2081
|
+
*
|
|
2082
|
+
* **Contract:** the module MUST export `mountRouter({ router, routes, container })`. The generated
|
|
2083
|
+
* bootstrap imports the specifier and calls it — so a self-executing entry that mounts on import
|
|
2084
|
+
* builds cleanly and then does nothing, which is the trap this contract exists to name. The type is
|
|
2085
|
+
* a bare `string` because a specifier is resolved by the bundler, not the type system, so the
|
|
2086
|
+
* requirement is enforced by the bootstrap instead: it throws immediately, naming this module and
|
|
2087
|
+
* the missing export, rather than failing later as `mountRouter is not a function`.
|
|
2088
|
+
*/
|
|
1440
2089
|
readonly clientModule: string
|
|
1441
2090
|
/** Turn a route/layout source file (relative to the routes dir) into an import specifier. */
|
|
1442
2091
|
readonly resolve: (file: string) => string
|
|
@@ -1460,6 +2109,7 @@ export function generateClientEntry(
|
|
|
1460
2109
|
|
|
1461
2110
|
const loaderRows: string[] = []
|
|
1462
2111
|
const patternRows: string[] = []
|
|
2112
|
+
const statusRoutes: Record<number, string> = {}
|
|
1463
2113
|
// Routes whose loader appends a nearest `_error` module (LAST) — the client wraps the page in the
|
|
1464
2114
|
// adapter's `errorBoundary(fallback)` for these, so a client render error shows the `_error` UI.
|
|
1465
2115
|
const errorRouteIds: string[] = []
|
|
@@ -1489,6 +2139,12 @@ export function generateClientEntry(
|
|
|
1489
2139
|
}
|
|
1490
2140
|
if (manifest.notFound !== undefined) {
|
|
1491
2141
|
loaderRows.push(` "_404": ${lazyLoader([manifest.notFound.file])},`)
|
|
2142
|
+
statusRoutes[404] = "_404"
|
|
2143
|
+
}
|
|
2144
|
+
for (const [status, entry] of Object.entries(manifest.statusPages ?? {})) {
|
|
2145
|
+
const routeId = `_${status}`
|
|
2146
|
+
loaderRows.push(` ${JSON.stringify(routeId)}: ${lazyLoader([entry.file])},`)
|
|
2147
|
+
statusRoutes[Number(status)] = routeId
|
|
1492
2148
|
}
|
|
1493
2149
|
|
|
1494
2150
|
return `${[
|
|
@@ -1498,6 +2154,16 @@ export function generateClientEntry(
|
|
|
1498
2154
|
// access yields `undefined` if absent — unlike a named import, which would be a link error.
|
|
1499
2155
|
`import * as __adapter from ${JSON.stringify(clientModule)}`,
|
|
1500
2156
|
"const { mountRouter } = __adapter",
|
|
2157
|
+
// The `clientModule` contract is a string specifier, so nothing type-checks that the module it
|
|
2158
|
+
// names actually exports `mountRouter`. Without this the miss surfaces as
|
|
2159
|
+
// "mountRouter is not a function" from inside a bundled chunk, at first paint, naming neither the
|
|
2160
|
+
// module nor the requirement - a self-executing entry passes every build and fails only here.
|
|
2161
|
+
`if (typeof mountRouter !== "function") throw new Error(${JSON.stringify(
|
|
2162
|
+
`[nifra/web] clientModule ${JSON.stringify(clientModule)} does not export \`mountRouter\`. ` +
|
|
2163
|
+
"A client module must export `mountRouter({ router, routes, container })` — it is called by " +
|
|
2164
|
+
"the generated bootstrap, so a self-executing entry will not work. Use your adapter's " +
|
|
2165
|
+
'`/client` entry (e.g. "@nifrajs/web-react/client"), or re-export mountRouter from it.',
|
|
2166
|
+
)})`,
|
|
1501
2167
|
"const errorBoundary = __adapter.errorBoundary",
|
|
1502
2168
|
`const errorRouteIds = new Set(${JSON.stringify(errorRouteIds)})`,
|
|
1503
2169
|
// Each route is a lazy loader: dynamic imports → Bun.build (splitting) emits one chunk per
|
|
@@ -1547,13 +2213,16 @@ export function generateClientEntry(
|
|
|
1547
2213
|
// page reading the search string would hydrate-mismatch. The #hash is client-only (never SSR'd).
|
|
1548
2214
|
" path: location.pathname + location.search,",
|
|
1549
2215
|
` data: mapDeferred(window.${DATA_GLOBAL}),`,
|
|
2216
|
+
// Undefined on a page-only app, which is exactly what the router treats as "no layout data".
|
|
2217
|
+
` layoutData: window.${LAYOUT_DATA_GLOBAL} && window.${LAYOUT_DATA_GLOBAL}.map(mapDeferred),`,
|
|
1550
2218
|
// actionData (only set after a form POST) is in the initial state so the binding hydrates
|
|
1551
2219
|
// consistently with the server-rendered markup; mapped through `mapDeferred` too so a deferred
|
|
1552
2220
|
// action's placeholders become registry markers (a no-op when the action didn't defer).
|
|
1553
2221
|
` actionData: mapDeferred(window.${ACTION_GLOBAL}),`,
|
|
1554
2222
|
" pending: false,",
|
|
1555
2223
|
"}",
|
|
1556
|
-
|
|
2224
|
+
`const statusRoutes = ${JSON.stringify(statusRoutes)}`,
|
|
2225
|
+
"const router = createClientRouter({ patterns, initial, loadModule, statusRoutes })",
|
|
1557
2226
|
"installHistory(router)",
|
|
1558
2227
|
"installForms(router)",
|
|
1559
2228
|
'const root = document.getElementById("root")',
|
|
@@ -1603,7 +2272,7 @@ export interface GenerateServerManifestOptions {
|
|
|
1603
2272
|
* Codegen: emit a **server manifest** module (as source) for disk-less edge runtimes (Cloudflare
|
|
1604
2273
|
* Workers, …) — and, with a `target`, any portable server bundle. `discoverRoutes` scans `node:fs`
|
|
1605
2274
|
* and dynamic-imports each route by a *runtime* path — neither exists on workerd. This instead emits
|
|
1606
|
-
* **statically-analyzable** imports of every route/layout/`_error
|
|
2275
|
+
* **statically-analyzable** imports of every route/layout/`_error`/terminal status page (so the bundler includes them) and
|
|
1607
2276
|
* rebuilds the manifest with `buildManifest` — the SAME pure logic `discoverRoutes` feeds, so patterns
|
|
1608
2277
|
* + layout chains are identical. Eager (`import * as`) by default; `lazy` emits `() => import(...)` so
|
|
1609
2278
|
* a code-splitting bundler chunks per route. The emitted module exports `manifest` (consumed by
|
|
@@ -1615,13 +2284,14 @@ export function generateServerManifest(
|
|
|
1615
2284
|
options: GenerateServerManifestOptions,
|
|
1616
2285
|
): string {
|
|
1617
2286
|
const { resolve, clientEntry, styles, routeStyles, lazy = false } = options
|
|
1618
|
-
// Every unique source file in the manifest (routes + layouts +
|
|
2287
|
+
// Every unique source file in the manifest (routes + layouts + error/status pages), sorted for stable output.
|
|
1619
2288
|
const files = [
|
|
1620
2289
|
...new Set([
|
|
1621
2290
|
...manifest.routes.map((r) => r.file),
|
|
1622
2291
|
...Object.values(manifest.layouts).map((l) => l.file),
|
|
1623
2292
|
...Object.values(manifest.errors ?? {}).map((e) => e.file),
|
|
1624
2293
|
...(manifest.notFound ? [manifest.notFound.file] : []),
|
|
2294
|
+
...Object.values(manifest.statusPages ?? {}).map((page) => page.file),
|
|
1625
2295
|
]),
|
|
1626
2296
|
].sort()
|
|
1627
2297
|
const header = [
|