@nifrajs/web 3.3.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-plan.d.ts +17 -0
- package/dist/build-plan.d.ts.map +1 -1
- package/dist/build-plan.js +7 -1
- package/dist/build-plan.js.map +1 -1
- package/dist/build-vite.d.ts +14 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +29 -4
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +15 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +57 -15
- package/dist/build.js.map +1 -1
- package/dist/client.d.ts +18 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +115 -0
- package/dist/client.js.map +1 -1
- package/dist/css-contract.d.ts +21 -0
- package/dist/css-contract.d.ts.map +1 -0
- package/dist/css-contract.js +28 -0
- package/dist/css-contract.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/codegen.d.ts +3 -0
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +11 -6
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/page-execution.d.ts +57 -0
- package/dist/internal/page-execution.d.ts.map +1 -0
- package/dist/internal/page-execution.js +555 -0
- package/dist/internal/page-execution.js.map +1 -0
- package/dist/internal/render-document.d.ts +12 -2
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +28 -5
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/request-url.d.ts +3 -0
- package/dist/internal/request-url.d.ts.map +1 -0
- package/dist/internal/request-url.js +14 -0
- package/dist/internal/request-url.js.map +1 -0
- package/dist/internal/web-app.d.ts +20 -1
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +37 -734
- package/dist/internal/web-app.js.map +1 -1
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +39 -4
- package/dist/isr.js.map +1 -1
- package/dist/llms-txt.d.ts +3 -0
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +6 -3
- package/dist/llms-txt.js.map +1 -1
- package/dist/manifest.d.ts +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +69 -44
- package/dist/public-dir.js.map +1 -1
- package/dist/trusted-html.d.ts +34 -0
- package/dist/trusted-html.d.ts.map +1 -0
- package/dist/trusted-html.js +35 -0
- package/dist/trusted-html.js.map +1 -0
- package/package.json +3 -3
- package/src/build-plan.ts +23 -1
- package/src/build-vite.ts +47 -4
- package/src/build.ts +69 -6
- package/src/client.ts +140 -0
- package/src/css-contract.ts +44 -0
- package/src/index.ts +15 -0
- package/src/internal/codegen.ts +20 -6
- package/src/internal/page-execution.ts +761 -0
- package/src/internal/render-document.ts +39 -6
- package/src/internal/request-url.ts +14 -0
- package/src/internal/web-app.ts +68 -835
- package/src/isr.ts +42 -3
- package/src/llms-txt.ts +16 -4
- package/src/manifest.ts +1 -1
- package/src/public-dir.ts +70 -45
- package/src/trusted-html.ts +44 -0
package/src/internal/web-app.ts
CHANGED
|
@@ -3,56 +3,16 @@ import {
|
|
|
3
3
|
type BackendMountHandler,
|
|
4
4
|
NIFRA_BACKEND_MOUNT,
|
|
5
5
|
} from "@nifrajs/core/mount"
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
type BoundaryStates,
|
|
10
|
-
boundaryDescriptors,
|
|
11
|
-
resolveStaticBoundaries,
|
|
12
|
-
startDynamicBoundaries,
|
|
13
|
-
} from "../boundary.ts"
|
|
14
|
-
import { defer, ndjsonStream, prepareDeferred } from "../deferred.ts"
|
|
15
|
-
import { isDraftEnabled } from "../draft.ts"
|
|
6
|
+
import type { Platform } from "@nifrajs/core/server"
|
|
7
|
+
import { type ServerOptions, server } from "@nifrajs/core/server"
|
|
8
|
+
import type { CssLoadingMode } from "../css-contract.ts"
|
|
16
9
|
import { generateLlmsTxt } from "../llms-txt.ts"
|
|
17
|
-
import type {
|
|
18
|
-
LayoutEntry,
|
|
19
|
-
LoaderContext,
|
|
20
|
-
Manifest,
|
|
21
|
-
Meta,
|
|
22
|
-
MetaArgs,
|
|
23
|
-
RouteEntry,
|
|
24
|
-
RouteModule,
|
|
25
|
-
} from "../manifest.ts"
|
|
10
|
+
import type { Manifest } from "../manifest.ts"
|
|
26
11
|
import type { RenderAdapter } from "../render-seam.ts"
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
RETAIN_HEADER,
|
|
32
|
-
REVALIDATE_HEADER,
|
|
33
|
-
STATUS_HEADER,
|
|
34
|
-
} from "../router.ts"
|
|
35
|
-
import { searchOf, searchOfChain } from "../search.ts"
|
|
36
|
-
import { mergeHeads, resolveMeta } from "./head-merge.ts"
|
|
37
|
-
import {
|
|
38
|
-
actionResponse,
|
|
39
|
-
EMPTY_RETAIN,
|
|
40
|
-
type HeadersLike,
|
|
41
|
-
isControlFlow,
|
|
42
|
-
isStatusSignal,
|
|
43
|
-
type LoadedLayoutModules,
|
|
44
|
-
layoutErrorId,
|
|
45
|
-
type RenderAssemblyCache,
|
|
46
|
-
type RenderedPage,
|
|
47
|
-
type RevalidateResult,
|
|
48
|
-
renderPageResult,
|
|
49
|
-
STATUS_SIGNAL,
|
|
50
|
-
STATUS_TEXT,
|
|
51
|
-
type StatusSignal,
|
|
52
|
-
scopeParams,
|
|
53
|
-
tagLayoutError,
|
|
54
|
-
withDuplicateInstanceHint,
|
|
55
|
-
} from "./render-document.ts"
|
|
12
|
+
import { createPageRequestExecutor, type NonceResolver } from "./page-execution.ts"
|
|
13
|
+
import { urlPartsFor } from "./request-url.ts"
|
|
14
|
+
|
|
15
|
+
export type { NonceResolver } from "./page-execution.ts"
|
|
56
16
|
export interface CreateWebAppOptions<Env = unknown> {
|
|
57
17
|
readonly adapter: RenderAdapter
|
|
58
18
|
readonly manifest: Manifest
|
|
@@ -60,6 +20,14 @@ export interface CreateWebAppOptions<Env = unknown> {
|
|
|
60
20
|
readonly clientEntry: string
|
|
61
21
|
/** Default document title for all pages. */
|
|
62
22
|
readonly title?: string
|
|
23
|
+
/**
|
|
24
|
+
* Resolve a fresh CSP nonce for each document request. The value is applied to every
|
|
25
|
+
* framework-owned executable script; return `undefined` to keep the default nonce-free output.
|
|
26
|
+
* Nonce-bearing documents are marked `private, no-store` so the request-specific value is not
|
|
27
|
+
* replayed by browser, CDN, or ISR caches. The resolver does not declare a CSP header because the
|
|
28
|
+
* policy's allowed sources are app-specific.
|
|
29
|
+
*/
|
|
30
|
+
readonly nonce?: NonceResolver<Env>
|
|
63
31
|
/**
|
|
64
32
|
* Options for the underlying `server()` - `requestTimeoutMs`, `admission`, `gracefulSignals`, and
|
|
65
33
|
* the rest of {@link ServerOptions}.
|
|
@@ -154,7 +122,9 @@ export interface CreateWebAppOptions<Env = unknown> {
|
|
|
154
122
|
*/
|
|
155
123
|
readonly mounts?: ReadonlyArray<{
|
|
156
124
|
readonly path: string
|
|
157
|
-
readonly app: {
|
|
125
|
+
readonly app: {
|
|
126
|
+
fetch(request: Request, platform?: Platform<Env>): Response | Promise<Response>
|
|
127
|
+
}
|
|
158
128
|
readonly stripPrefix?: boolean
|
|
159
129
|
}>
|
|
160
130
|
/** Secret for **draft / preview mode** (see `enableDraft`). When set, a request carrying a valid
|
|
@@ -174,6 +144,13 @@ export interface CreateWebAppOptions<Env = unknown> {
|
|
|
174
144
|
* of the aggregate `styles`, so a page ships only the CSS it uses. An empty array ⇒ no `<link>` (the
|
|
175
145
|
* page imports no CSS). Routes absent here fall back to `styles`. Omit ⇒ always use `styles`. */
|
|
176
146
|
readonly routeStyles?: Readonly<Record<string, readonly string[]>>
|
|
147
|
+
/**
|
|
148
|
+
* Activation policy for framework-owned stylesheet links (default `"blocking"`). `"deferred"`
|
|
149
|
+
* emits `media="print"` links; the generated client waits for them and switches them to `all` before
|
|
150
|
+
* mounting. Pair with an aggregate Vite stylesheet (`cssCodeSplit: false`) to prevent lazy-prefetch
|
|
151
|
+
* CSS insertion. Non-hydrated pages always keep their stylesheet links blocking.
|
|
152
|
+
*/
|
|
153
|
+
readonly cssLoading?: CssLoadingMode
|
|
177
154
|
/** SSG: the prerendered-path set (e.g. `enumerateStaticRoutes(routes).paths` or the build's
|
|
178
155
|
* `prerendered.json`). Injected as `window.__NIFRA_PRERENDERED__` on every page so a client soft-nav
|
|
179
156
|
* into a prerendered route fetches its static `_data.json` instead of hitting the worker. */
|
|
@@ -208,11 +185,11 @@ export interface CreateWebAppOptions<Env = unknown> {
|
|
|
208
185
|
}
|
|
209
186
|
|
|
210
187
|
/** The handler context fields createWebApp uses - a structural subset of nifra's `Context`. */
|
|
211
|
-
interface RouteContext {
|
|
188
|
+
interface RouteContext<Env = unknown> {
|
|
212
189
|
readonly params: Record<string, string>
|
|
213
190
|
readonly req: Request
|
|
214
191
|
/** Platform bindings (Workers env), forwarded to each route's loader/action as `args.env`. */
|
|
215
|
-
readonly env:
|
|
192
|
+
readonly env: Env
|
|
216
193
|
}
|
|
217
194
|
|
|
218
195
|
/**
|
|
@@ -240,23 +217,20 @@ function stripMountPrefix(request: Request, prefix: string): Request {
|
|
|
240
217
|
* Resolve the explicit symbol-keyed backend mount interface. The symbol seam forwards platform
|
|
241
218
|
* context without making web depend on client.
|
|
242
219
|
*/
|
|
243
|
-
function backendMountOf(api: unknown): BackendMountHandler | undefined {
|
|
220
|
+
function backendMountOf<Env>(api: unknown): BackendMountHandler<Env> | undefined {
|
|
244
221
|
if ((typeof api !== "object" && typeof api !== "function") || api === null) return undefined
|
|
245
|
-
const explicit = (api as Partial<BackendMount
|
|
222
|
+
const explicit = (api as Partial<BackendMount<Env>>)[NIFRA_BACKEND_MOUNT]
|
|
246
223
|
if (typeof explicit !== "function") return undefined
|
|
247
224
|
return (request, platform) => explicit.call(api, request, platform)
|
|
248
225
|
}
|
|
249
226
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const parts = urlPartsOf(request.url)
|
|
258
|
-
REQUEST_URL_PARTS.set(request, parts)
|
|
259
|
-
return parts
|
|
227
|
+
function requestPathOf(request: Request): string {
|
|
228
|
+
try {
|
|
229
|
+
const parts = urlPartsFor(request)
|
|
230
|
+
return parts.pathname + parts.search
|
|
231
|
+
} catch {
|
|
232
|
+
return "/"
|
|
233
|
+
}
|
|
260
234
|
}
|
|
261
235
|
|
|
262
236
|
/**
|
|
@@ -277,26 +251,6 @@ export function createWebApp<Env = unknown>(
|
|
|
277
251
|
options: CreateWebAppOptions<Env>,
|
|
278
252
|
): ReturnType<typeof server<Env>> {
|
|
279
253
|
const { adapter, manifest, clientEntry, title, api } = options
|
|
280
|
-
const titleOption = title === undefined ? {} : { title }
|
|
281
|
-
// Draft/preview: when a `draftSecret` is configured, each request's signed `__nifra_draft` cookie is
|
|
282
|
-
// verified once and surfaced to loaders/actions as `ctx.draft`. No secret ⇒ always `false` (sync, free).
|
|
283
|
-
const draftFlag = (req: Request): Promise<boolean> =>
|
|
284
|
-
options.draftSecret === undefined
|
|
285
|
-
? Promise.resolve(false)
|
|
286
|
-
: isDraftEnabled(req, options.draftSecret)
|
|
287
|
-
// Per-route preload chunks (spread into renderPage; omitted when unmapped, for exactOptionalPropertyTypes).
|
|
288
|
-
const preloadOf = (id: string): { preload?: readonly string[] } => {
|
|
289
|
-
const chunks = options.routePreload?.[id]
|
|
290
|
-
return chunks ? { preload: chunks } : {}
|
|
291
|
-
}
|
|
292
|
-
// The matched route's stylesheet links (spread into renderPage). Per-route when the build mapped it
|
|
293
|
-
// (`routeStyles[id]` - only the chain's CSS; an empty array ⇒ no `<link>`), else the aggregate
|
|
294
|
-
// `styles`. Omitted entirely when CSS-free (for exactOptionalPropertyTypes).
|
|
295
|
-
const stylesOf = (id: string): { styles?: readonly string[] } => {
|
|
296
|
-
const perRoute = options.routeStyles?.[id]
|
|
297
|
-
if (perRoute !== undefined) return { styles: perRoute }
|
|
298
|
-
return options.styles && options.styles.length > 0 ? { styles: options.styles } : {}
|
|
299
|
-
}
|
|
300
254
|
// Seed the context with the declared `Env` so `app.fetch(req, { env })` / `toFetchHandler(app)` type
|
|
301
255
|
// the platform bindings (see the `createWebApp` doc). The runtime `env` still arrives per-request via
|
|
302
256
|
// `app.fetch(req, { env })`; this is a compile-time-only seed (`server<Env>()` casts, doesn't store).
|
|
@@ -315,7 +269,7 @@ export function createWebApp<Env = unknown>(
|
|
|
315
269
|
// is registered ONLY when both hold, so a pages-only app keeps core's synchronous no-hook fast path.
|
|
316
270
|
const apiPrefix = options.apiPrefix ?? "/api"
|
|
317
271
|
const apiStrip = options.apiStrip === true
|
|
318
|
-
const mountedApi = backendMountOf(api)
|
|
272
|
+
const mountedApi = backendMountOf<Env>(api)
|
|
319
273
|
// Longest path first, so a more specific mount (`/api/auth`) is tried before a broader one (`/api`)
|
|
320
274
|
// regardless of the order they were declared in.
|
|
321
275
|
const mounts = [...(options.mounts ?? [])].sort((a, b) => b.path.length - a.path.length)
|
|
@@ -329,7 +283,10 @@ export function createWebApp<Env = unknown>(
|
|
|
329
283
|
// auth handler mounted at `/api/auth` must not be swallowed by the backend mounted at `/api`.
|
|
330
284
|
for (const mount of mounts) {
|
|
331
285
|
if (!underPrefix(pathname, mount.path)) continue
|
|
332
|
-
return mount.app.fetch(
|
|
286
|
+
return mount.app.fetch(
|
|
287
|
+
mount.stripPrefix === true ? stripMountPrefix(req, mount.path) : req,
|
|
288
|
+
platform,
|
|
289
|
+
)
|
|
333
290
|
}
|
|
334
291
|
// Exactly the prefix (`/api`) or a sub-path (`/api/…`) - NOT a sibling like `/apixyz` that merely
|
|
335
292
|
// shares the prefix as a string head. Dispatch the original `req` (body intact) to the backend.
|
|
@@ -339,758 +296,32 @@ export function createWebApp<Env = unknown>(
|
|
|
339
296
|
return undefined // not an API path → continue to page routing
|
|
340
297
|
})
|
|
341
298
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const id = route.layoutIds[i] as string
|
|
364
|
-
throw new Error(
|
|
365
|
-
`[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.`,
|
|
366
|
-
)
|
|
367
|
-
}
|
|
368
|
-
return modules
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/**
|
|
372
|
-
* Run a route's layout-chain loaders, honouring declared gates.
|
|
373
|
-
*
|
|
374
|
-
* Returns once every **gate** has resolved, with the non-gate loaders still in flight, so the caller
|
|
375
|
-
* can start the page loader at exactly the right moment: after anything that might reject the
|
|
376
|
-
* request, alongside anything that merely fetches.
|
|
377
|
-
*
|
|
378
|
-
* The ordering is the security-relevant part. Parallel loaders are NOT an authorization boundary -
|
|
379
|
-
* a page loader running concurrently with a guard has already queried by the time the guard says no.
|
|
380
|
-
* Remix documents that as a footgun; here it is a declaration instead. `export const gate = true`
|
|
381
|
-
* makes a layout blocking for everything beneath it, and nothing beneath a rejected gate runs.
|
|
382
|
-
*
|
|
383
|
-
* Each layout receives only the params it OWNS ({@link RouteEntry.layoutParams}), not the route's
|
|
384
|
-
* full set, so a layout at `orgs/[org]/` cannot read a param belonging to a route below it.
|
|
385
|
-
*/
|
|
386
|
-
/**
|
|
387
|
-
* Layout indices a client navigation asked to keep, from {@link RETAIN_HEADER}.
|
|
388
|
-
*
|
|
389
|
-
* The CLIENT decides retainability: it knows each layout's scoped params (shipped in the bootstrap)
|
|
390
|
-
* and both the old and new match, so it can tell whether a layout's own prefix changed - without the
|
|
391
|
-
* server re-matching a path the client already matched.
|
|
392
|
-
*
|
|
393
|
-
* It is a HINT, not an instruction. The client is asking to keep data it already holds, which is
|
|
394
|
-
* harmless for data and unacceptable for a guard, so `runLayoutChain` refuses to skip a gate no
|
|
395
|
-
* matter what arrives here. Anything unparseable is ignored rather than rejected: the worst case of
|
|
396
|
-
* a bad hint is a loader that runs when it need not.
|
|
397
|
-
*/
|
|
398
|
-
const retainedIndices = (header: string | null): ReadonlySet<number> => {
|
|
399
|
-
if (header === null || header === "") return EMPTY_RETAIN
|
|
400
|
-
const out = new Set<number>()
|
|
401
|
-
for (const part of header.split(",")) {
|
|
402
|
-
// Digits only. `Number("")` is 0 and `Number(" ")` is 0, so a stray comma would otherwise read
|
|
403
|
-
// as "retain index 0" and silently skip the outermost layout.
|
|
404
|
-
if (!/^\d+$/.test(part)) continue
|
|
405
|
-
out.add(Number(part))
|
|
406
|
-
}
|
|
407
|
-
return out
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/**
|
|
411
|
-
* Validate a data-navigation's retain request against the manifest and its declared source path.
|
|
412
|
-
* A client may ask to retain every slot it currently has; the server only honours indices whose
|
|
413
|
-
* layout identity and owned params are unchanged. Hard document GETs never retain anything.
|
|
414
|
-
*/
|
|
415
|
-
const validatedRetainedIndices = (
|
|
416
|
-
req: Request,
|
|
417
|
-
route: RouteEntry,
|
|
418
|
-
params: Readonly<Record<string, string>>,
|
|
419
|
-
): ReadonlySet<number> => {
|
|
420
|
-
if (req.headers.get(DATA_HEADER) === null) return EMPTY_RETAIN
|
|
421
|
-
const requested = retainedIndices(req.headers.get(RETAIN_HEADER))
|
|
422
|
-
const from = req.headers.get(NAV_FROM_HEADER)
|
|
423
|
-
if (requested.size === 0 || from === null) return EMPTY_RETAIN
|
|
424
|
-
let fromPath: string
|
|
425
|
-
try {
|
|
426
|
-
const current = new URL(req.url)
|
|
427
|
-
const source = new URL(from, current)
|
|
428
|
-
if (source.origin !== current.origin) return EMPTY_RETAIN
|
|
429
|
-
fromPath = source.pathname + source.search
|
|
430
|
-
} catch {
|
|
431
|
-
return EMPTY_RETAIN
|
|
432
|
-
}
|
|
433
|
-
const previousMatch = matchManifestRoute(fromPath)
|
|
434
|
-
if (previousMatch === null) return EMPTY_RETAIN
|
|
435
|
-
const previous = routeById.get(previousMatch.routeId)
|
|
436
|
-
if (previous === undefined) return EMPTY_RETAIN
|
|
437
|
-
const valid = new Set<number>()
|
|
438
|
-
for (const index of requested) {
|
|
439
|
-
if (route.layoutIds[index] !== previous.layoutIds[index]) continue
|
|
440
|
-
const owned = route.layoutParams?.[index] ?? []
|
|
441
|
-
const previouslyOwned = previous.layoutParams?.[index] ?? []
|
|
442
|
-
if (
|
|
443
|
-
owned.length !== previouslyOwned.length ||
|
|
444
|
-
owned.some((name, i) => name !== previouslyOwned[i]) ||
|
|
445
|
-
owned.some((name) => params[name] !== previousMatch.params[name])
|
|
446
|
-
) {
|
|
447
|
-
continue
|
|
448
|
-
}
|
|
449
|
-
valid.add(index)
|
|
450
|
-
}
|
|
451
|
-
return valid
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// The validated (or raw-parsed) search a route's loader context receives; `searchOf` fails closed to
|
|
455
|
-
// the schema's defaults on hostile input, so this never throws on an attacker-picked query.
|
|
456
|
-
const loaderSearch = (searchSchema: RouteModule["searchSchema"], request: Request) =>
|
|
457
|
-
searchOf(searchSchema, urlPartsFor(request).search)
|
|
458
|
-
|
|
459
|
-
// The route's EFFECTIVE search once its layout chain is loaded: the layout schemas merged with the
|
|
460
|
-
// page's (page-wins), the same chain the client mount builds for `useSearch`. Used for the page loader's
|
|
461
|
-
// `ctx.search` and the render's `RenderProps.search`, so both agree with the client and with each other.
|
|
462
|
-
const searchChainOf = (
|
|
463
|
-
layoutMods: LoadedLayoutModules,
|
|
464
|
-
mod: RouteModule,
|
|
465
|
-
request: Request,
|
|
466
|
-
): Record<string, unknown> =>
|
|
467
|
-
searchOfChain(
|
|
468
|
-
[...layoutMods.map((m) => m.searchSchema), mod.searchSchema],
|
|
469
|
-
urlPartsFor(request).search,
|
|
470
|
-
)
|
|
471
|
-
|
|
472
|
-
const runLayoutChain = async (
|
|
473
|
-
route: RouteEntry,
|
|
474
|
-
ctx: LoaderContext,
|
|
475
|
-
retain: ReadonlySet<number> = new Set(),
|
|
476
|
-
): Promise<{
|
|
477
|
-
readonly modules: LoadedLayoutModules
|
|
478
|
-
readonly layoutData: readonly unknown[] | undefined
|
|
479
|
-
readonly retained: readonly number[]
|
|
480
|
-
readonly pending: Promise<unknown>
|
|
481
|
-
}> => {
|
|
482
|
-
const modules = await loadLayoutModules(route)
|
|
483
|
-
if (!modules.some((m) => m.loader !== undefined)) {
|
|
484
|
-
// Nothing to run: no array is produced, so a page-only app serializes nothing extra and its
|
|
485
|
-
// HTML is byte-identical to before this feature.
|
|
486
|
-
return { modules, layoutData: undefined, retained: [], pending: Promise.resolve() }
|
|
487
|
-
}
|
|
488
|
-
const results: unknown[] = new Array(modules.length).fill(null)
|
|
489
|
-
const retained: number[] = []
|
|
490
|
-
const pending: Array<Promise<void>> = []
|
|
491
|
-
for (let i = 0; i < modules.length; i++) {
|
|
492
|
-
const loader = modules[i]?.loader
|
|
493
|
-
if (loader === undefined) continue
|
|
494
|
-
// Unchanged scope: the client keeps what it has. A GATE is never skipped - it is a guard, and a
|
|
495
|
-
// guard that only runs when the URL prefix changed is not a guard.
|
|
496
|
-
if (retain.has(i) && modules[i]?.gate !== true) {
|
|
497
|
-
retained.push(i)
|
|
498
|
-
continue
|
|
499
|
-
}
|
|
500
|
-
const scoped: LoaderContext = {
|
|
501
|
-
...ctx,
|
|
502
|
-
params: scopeParams(ctx.params, route.layoutParams?.[i]),
|
|
503
|
-
}
|
|
504
|
-
if (modules[i]?.gate === true) {
|
|
505
|
-
// Blocking: awaited here, so a throw propagates before anything deeper is started.
|
|
506
|
-
try {
|
|
507
|
-
results[i] = await loader(scoped)
|
|
508
|
-
} catch (err) {
|
|
509
|
-
throw tagLayoutError(err, route.layoutIds[i] as string)
|
|
510
|
-
}
|
|
511
|
-
continue
|
|
512
|
-
}
|
|
513
|
-
const layoutId = route.layoutIds[i] as string
|
|
514
|
-
const index = i
|
|
515
|
-
// The loader is invoked INSIDE the async function, not before it. `Promise.resolve(loader(x))`
|
|
516
|
-
// would let a synchronously-throwing loader escape past the rejection handler entirely - the
|
|
517
|
-
// exception happens while evaluating the argument, before any promise exists to reject.
|
|
518
|
-
pending.push(
|
|
519
|
-
(async () => {
|
|
520
|
-
try {
|
|
521
|
-
results[index] = await loader(scoped)
|
|
522
|
-
} catch (err) {
|
|
523
|
-
throw tagLayoutError(err, layoutId)
|
|
524
|
-
}
|
|
525
|
-
})(),
|
|
526
|
-
)
|
|
527
|
-
}
|
|
528
|
-
return { modules, layoutData: results, retained, pending: Promise.all(pending) }
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
/** Run only blocking layout gates before a route action. Non-gate loaders fetch render data and
|
|
532
|
-
* therefore run after the mutation when a native POST needs a fresh document. */
|
|
533
|
-
const runLayoutGates = async (
|
|
534
|
-
route: RouteEntry,
|
|
535
|
-
ctx: LoaderContext,
|
|
536
|
-
): Promise<LoadedLayoutModules> => {
|
|
537
|
-
const modules = await loadLayoutModules(route)
|
|
538
|
-
for (let i = 0; i < modules.length; i++) {
|
|
539
|
-
const mod = modules[i]
|
|
540
|
-
if (mod?.gate !== true || mod.loader === undefined) continue
|
|
541
|
-
try {
|
|
542
|
-
await mod.loader({
|
|
543
|
-
...ctx,
|
|
544
|
-
params: scopeParams(ctx.params, route.layoutParams?.[i]),
|
|
545
|
-
})
|
|
546
|
-
} catch (err) {
|
|
547
|
-
throw tagLayoutError(err, route.layoutIds[i] as string)
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
return modules
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// Build a route's render chain + its merged `<head>` from the already-loaded layout modules + the
|
|
554
|
-
// page module. The chain is `[…layout components, page]`; the head merges each layout's `meta`
|
|
555
|
-
// export (outermost→innermost) with the page's (last), so a `head`/`meta` on `_layout.tsx`
|
|
556
|
-
// contributes sitewide tags. `metaArgs` (loader data + params) feed function-form `meta`s.
|
|
557
|
-
const resolveChainAndHead = (
|
|
558
|
-
layoutModules: LoadedLayoutModules,
|
|
559
|
-
page: RouteModule,
|
|
560
|
-
metaArgs: MetaArgs,
|
|
561
|
-
): { chain: unknown[]; head: Meta } => {
|
|
562
|
-
const chain = [...layoutModules.map((m) => m.default), page.default]
|
|
563
|
-
const heads = [
|
|
564
|
-
...layoutModules.map((m) => resolveMeta(m.meta, metaArgs)),
|
|
565
|
-
resolveMeta(page.meta, metaArgs),
|
|
566
|
-
]
|
|
567
|
-
return { chain, head: mergeHeads(heads) }
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
// Dir a special-file id lives in: `_error`→"" , `a/b/_error`→"a/b" (and likewise for `_layout`).
|
|
571
|
-
const dirOfId = (id: string, suffix: string): string =>
|
|
572
|
-
id === suffix ? "" : id.slice(0, id.length - suffix.length - 1)
|
|
573
|
-
|
|
574
|
-
/**
|
|
575
|
-
* Choose the `_error` boundary for a failure, respecting where a LAYOUT loader failed.
|
|
576
|
-
*
|
|
577
|
-
* A route error uses the nearest boundary, as it always has. A layout error must not: the nearest
|
|
578
|
-
* boundary can sit BELOW the layout that threw, and rendering there would wrap the boundary in that
|
|
579
|
-
* very layout - the one whose loader just failed and whose data therefore never arrived.
|
|
580
|
-
*/
|
|
581
|
-
const boundaryFor = (route: RouteEntry, err: unknown): string | undefined => {
|
|
582
|
-
const ids = route.errorIds ?? []
|
|
583
|
-
const failing = layoutErrorId(err)
|
|
584
|
-
if (failing === undefined) return ids.at(-1)
|
|
585
|
-
const layoutDir = dirOfId(failing, "_layout")
|
|
586
|
-
// Deepest boundary at or ABOVE the failing layout's directory.
|
|
587
|
-
for (let i = ids.length - 1; i >= 0; i--) {
|
|
588
|
-
const errDir = dirOfId(ids[i] as string, "_error")
|
|
589
|
-
if (errDir === "" || errDir === layoutDir || layoutDir.startsWith(`${errDir}/`)) return ids[i]
|
|
590
|
-
}
|
|
591
|
-
return undefined
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
// The request's origin (scheme + host + port, NO trailing slash) - the one server fact `meta()` needs
|
|
595
|
-
// for absolute `canonical`/`og:url`/`og:image` but can't otherwise see (it also runs on the client).
|
|
596
|
-
// `URL.origin` is exactly that shape; it matches the browser's `location.origin` for the same URL, so
|
|
597
|
-
// a soft-nav's `applyHead` resolves an identical `<head>` (no hydration drift). A malformed `req.url`
|
|
598
|
-
// (shouldn't happen on a real request) degrades to `""` - the documented unknown-origin default -
|
|
599
|
-
// rather than throwing during render.
|
|
600
|
-
const originOf = (req: Request): string => {
|
|
601
|
-
try {
|
|
602
|
-
return new URL(req.url).origin
|
|
603
|
-
} catch {
|
|
604
|
-
return ""
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// The request's `pathname + search` - threaded into `renderPage` so an adapter's `useLocation`/
|
|
609
|
-
// `useSearchParams` render the current URL server-side and hydrate against the client's initial state
|
|
610
|
-
// (which the client entry seeds from `location.pathname + location.search`). No hash: it never reaches
|
|
611
|
-
// the server. A malformed `req.url` degrades to "/" rather than throwing during render.
|
|
612
|
-
const pathOf = (req: Request): string => {
|
|
613
|
-
try {
|
|
614
|
-
const parts = urlPartsFor(req)
|
|
615
|
-
return parts.pathname + parts.search
|
|
616
|
-
} catch {
|
|
617
|
-
return "/"
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
/**
|
|
622
|
-
* Render the **nearest `_error` boundary** when a route's loader throws (the agnostic, server-side
|
|
623
|
-
* half of error UI - works on every adapter, no client takeover). The boundary renders in place of
|
|
624
|
-
* the page, wrapped by the layouts **at or above** its segment (deeper layouts are dropped). It's
|
|
625
|
-
* served **non-hydrated** at status 500: a terminal page, and rendering a boundary (not the page the
|
|
626
|
-
* client maps this route to) would otherwise hydrate-mismatch. The component receives `{ name,
|
|
627
|
-
* message }` - never the stack (no internals leak into HTML).
|
|
628
|
-
*/
|
|
629
|
-
const renderError = async (
|
|
630
|
-
route: RouteEntry,
|
|
631
|
-
errorId: string,
|
|
632
|
-
err: unknown,
|
|
633
|
-
): Promise<Response | RenderedPage> => {
|
|
634
|
-
const errDir = dirOfId(errorId, "_error")
|
|
635
|
-
const keptLayoutIds = route.layoutIds.filter((id) => {
|
|
636
|
-
const ld = dirOfId(id, "_layout")
|
|
637
|
-
return ld === "" || ld === errDir || errDir.startsWith(`${ld}/`)
|
|
638
|
-
})
|
|
639
|
-
const layouts = await Promise.all(
|
|
640
|
-
keptLayoutIds.map((id) =>
|
|
641
|
-
(manifest.layouts[id] as LayoutEntry).load().then((m) => m.default),
|
|
642
|
-
),
|
|
643
|
-
)
|
|
644
|
-
const { default: errComp } = await (manifest.errors?.[errorId] as LayoutEntry).load()
|
|
645
|
-
const e = err instanceof Error ? err : new Error(String(err))
|
|
646
|
-
return renderPageResult({
|
|
647
|
-
adapter,
|
|
648
|
-
chain: [...layouts, errComp],
|
|
649
|
-
data: { name: e.name, message: e.message },
|
|
650
|
-
clientEntry,
|
|
651
|
-
routeId: errorId,
|
|
652
|
-
status: 500,
|
|
653
|
-
hydrate: false,
|
|
654
|
-
...titleOption,
|
|
655
|
-
})
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
// The 404 response - the `_404` page (status 404) or a plain-text fallback. Shared by the wildcard
|
|
659
|
-
// catch-all (unmatched paths) and the `fallback: "404"` enforcement (unlisted paths under a route
|
|
660
|
-
// that opted out of on-demand SSR).
|
|
661
|
-
const renderNotFound = (path?: string): Promise<Response | RenderedPage> =>
|
|
662
|
-
renderTerminalStatus(404, path)
|
|
663
|
-
|
|
664
|
-
/**
|
|
665
|
-
* Render a terminal-status page: the `_<status>` page if the app authored one, else `_404`, else
|
|
666
|
-
* plain text. Shared by the wildcard catch-all, `fallback: "404"` enforcement, and a loader's
|
|
667
|
-
* `notFound()` / `gone()` / `statusPage()`, so all of them resolve the page and set the status
|
|
668
|
-
* through one path rather than forking.
|
|
669
|
-
*
|
|
670
|
-
* Hydrates, following the `_404` precedent rather than `_error`'s non-hydrated 500: these are
|
|
671
|
-
* ordinary pages the client router can own, and a 404 arrived at by clicking a link should behave
|
|
672
|
-
* like any other route.
|
|
673
|
-
*/
|
|
674
|
-
const renderTerminalStatus = async (
|
|
675
|
-
status: number,
|
|
676
|
-
path?: string,
|
|
677
|
-
extraHeaders?: HeadersLike,
|
|
678
|
-
): Promise<Response | RenderedPage> => {
|
|
679
|
-
const page = manifest.statusPages?.[String(status)] ?? manifest.notFound
|
|
680
|
-
if (page === undefined) {
|
|
681
|
-
const headers = new Headers(extraHeaders)
|
|
682
|
-
headers.set("content-type", "text/plain; charset=utf-8")
|
|
683
|
-
return new Response(STATUS_TEXT[status] ?? "Error", { status, headers })
|
|
684
|
-
}
|
|
685
|
-
// The routeId decides which preload/style bundle and which client component this maps to, so it
|
|
686
|
-
// must name the page actually rendered - `_410` when `_410.tsx` exists, `_404` when it fell back.
|
|
687
|
-
const routeId = manifest.statusPages?.[String(status)] !== undefined ? `_${status}` : "_404"
|
|
688
|
-
const loaded = (await page.load()) as {
|
|
689
|
-
readonly default: unknown
|
|
690
|
-
readonly searchSchema?: RouteModule["searchSchema"]
|
|
691
|
-
}
|
|
692
|
-
const component = loaded.default
|
|
693
|
-
return renderPageResult({
|
|
694
|
-
adapter,
|
|
695
|
-
chain: [component],
|
|
696
|
-
data: null,
|
|
697
|
-
clientEntry,
|
|
698
|
-
routeId,
|
|
699
|
-
// The page hydrates, so besides `path` its SSR `search` must equal the client mount's derivation for
|
|
700
|
-
// this routeId (`searchOf(searchSchema, url.search)`), or a status page reading `useSearch` drifts.
|
|
701
|
-
// Only when `path` is present (a real URL); a schema-less status page then gets the raw parsed query.
|
|
702
|
-
...(path !== undefined
|
|
703
|
-
? {
|
|
704
|
-
search: searchOf(
|
|
705
|
-
loaded.searchSchema,
|
|
706
|
-
path.includes("?") ? path.slice(path.indexOf("?")) : "",
|
|
707
|
-
),
|
|
708
|
-
}
|
|
709
|
-
: {}),
|
|
710
|
-
// The page hydrates, so its SSR `path` must equal the client's initial
|
|
711
|
-
// `location.pathname+search` (an unmatched path yields no params → `{}`), or a `useLocation`
|
|
712
|
-
// on it would drift.
|
|
713
|
-
...(path !== undefined ? { path } : {}),
|
|
714
|
-
status,
|
|
715
|
-
...(extraHeaders !== undefined ? { headers: extraHeaders } : {}),
|
|
716
|
-
...preloadOf(routeId),
|
|
717
|
-
...stylesOf(routeId),
|
|
718
|
-
prerenderedPaths: options.prerenderedPaths ?? [],
|
|
719
|
-
...titleOption,
|
|
720
|
-
})
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
/**
|
|
724
|
-
* Answer a loader's terminal-status signal, in whichever form the caller asked for.
|
|
725
|
-
*
|
|
726
|
-
* A hard navigation wants the document, so it gets the rendered boundary. A client-side navigation
|
|
727
|
-
* fetched DATA, not a document, so it gets an empty body plus the status on `X-Nifra-Status`, and
|
|
728
|
-
* the client router renders the same boundary from its own manifest. Sending the HTML back to a
|
|
729
|
-
* soft-nav would be answering a different question than the one asked - and skipping the header
|
|
730
|
-
* entirely is what leaves a crawler seeing a correct 404 while a user who clicked a link sees a
|
|
731
|
-
* blank page.
|
|
732
|
-
*/
|
|
733
|
-
const renderStatusSignal = (
|
|
734
|
-
req: Request,
|
|
735
|
-
signal: StatusSignal,
|
|
736
|
-
): Promise<Response | RenderedPage> | Response => {
|
|
737
|
-
const { status, headers } = signal[STATUS_SIGNAL]
|
|
738
|
-
if (req.headers.get(DATA_HEADER) !== null) {
|
|
739
|
-
const responseHeaders = new Headers(headers)
|
|
740
|
-
responseHeaders.set(STATUS_HEADER, String(status))
|
|
741
|
-
return new Response(null, { status, headers: responseHeaders })
|
|
742
|
-
}
|
|
743
|
-
return renderTerminalStatus(status, pathOf(req), headers)
|
|
744
|
-
}
|
|
299
|
+
const pageExecutor = createPageRequestExecutor<Env>({
|
|
300
|
+
adapter,
|
|
301
|
+
manifest,
|
|
302
|
+
clientEntry,
|
|
303
|
+
...(title === undefined ? {} : { title }),
|
|
304
|
+
...(api === undefined ? {} : { api }),
|
|
305
|
+
...(options.draftSecret === undefined ? {} : { draftSecret: options.draftSecret }),
|
|
306
|
+
...(options.routePreload === undefined ? {} : { routePreload: options.routePreload }),
|
|
307
|
+
...(options.styles === undefined ? {} : { styles: options.styles }),
|
|
308
|
+
...(options.routeStyles === undefined ? {} : { routeStyles: options.routeStyles }),
|
|
309
|
+
...(options.cssLoading === undefined ? {} : { cssLoading: options.cssLoading }),
|
|
310
|
+
...(options.prerenderedPaths === undefined
|
|
311
|
+
? {}
|
|
312
|
+
: { prerenderedPaths: options.prerenderedPaths }),
|
|
313
|
+
...(options.staticFallbacks === undefined ? {} : { staticFallbacks: options.staticFallbacks }),
|
|
314
|
+
...(options.staticBoundaryCache === undefined
|
|
315
|
+
? {}
|
|
316
|
+
: { staticBoundaryCache: options.staticBoundaryCache }),
|
|
317
|
+
...(options.nonce === undefined ? {} : { nonce: options.nonce }),
|
|
318
|
+
...(options.onLoaderError === undefined ? {} : { onLoaderError: options.onLoaderError }),
|
|
319
|
+
})
|
|
745
320
|
|
|
746
321
|
for (const route of manifest.routes) {
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
const is404Fallback = options.staticFallbacks?.[route.pattern] === "404"
|
|
750
|
-
// Per-route document-assembly cache (see RenderAssemblyCache): filled on the first GET render and
|
|
751
|
-
// reused while the route + layout MODULE IDENTITIES are unchanged. Attached only when every meta
|
|
752
|
-
// in the chain is static - a `meta(data)` function makes the head per-request, so those routes
|
|
753
|
-
// always assemble fresh. A module reload (dev HMR) yields new module objects, dropping the slot.
|
|
754
|
-
let assemblySlot: RenderAssemblyCache | undefined
|
|
755
|
-
let assemblySlotMod: unknown
|
|
756
|
-
let assemblySlotLayouts: LoadedLayoutModules | undefined
|
|
757
|
-
const assemblyCacheFor = (
|
|
758
|
-
mod: RouteModule,
|
|
759
|
-
lms: LoadedLayoutModules,
|
|
760
|
-
): { assemblyCache: RenderAssemblyCache } | Record<string, never> => {
|
|
761
|
-
if (typeof mod.meta === "function") return {}
|
|
762
|
-
for (const m of lms) if (typeof m.meta === "function") return {}
|
|
763
|
-
const layoutsUnchanged =
|
|
764
|
-
assemblySlotLayouts !== undefined &&
|
|
765
|
-
assemblySlotLayouts.length === lms.length &&
|
|
766
|
-
assemblySlotLayouts.every((m, i) => m === lms[i])
|
|
767
|
-
if (assemblySlotMod !== mod || !layoutsUnchanged) {
|
|
768
|
-
assemblySlot = {}
|
|
769
|
-
assemblySlotMod = mod
|
|
770
|
-
assemblySlotLayouts = lms
|
|
771
|
-
}
|
|
772
|
-
return { assemblyCache: assemblySlot as RenderAssemblyCache }
|
|
773
|
-
}
|
|
774
|
-
app.register("GET", route.pattern, undefined, async (c: RouteContext) => {
|
|
775
|
-
// Enforce `fallback: "404"` before any work: an unlisted path under this route doesn't exist.
|
|
776
|
-
// Covers hard navigation directly; a client soft-nav's data fetch gets the 404, throws, and the
|
|
777
|
-
// history layer falls back to a full-page navigation (which lands here again, as a document).
|
|
778
|
-
if (is404Fallback && !prerenderedSet.has(urlPartsFor(c.req).pathname)) {
|
|
779
|
-
return renderNotFound(pathOf(c.req))
|
|
780
|
-
}
|
|
781
|
-
const mod = await route.load()
|
|
782
|
-
const draft = await draftFlag(c.req)
|
|
783
|
-
let data: unknown
|
|
784
|
-
let layoutData: readonly unknown[] | undefined
|
|
785
|
-
let boundaryStates: BoundaryStates | undefined
|
|
786
|
-
let layoutModules: LoadedLayoutModules | undefined
|
|
787
|
-
let layoutRetained: readonly number[] = []
|
|
788
|
-
try {
|
|
789
|
-
const ctx = {
|
|
790
|
-
params: c.params,
|
|
791
|
-
request: c.req,
|
|
792
|
-
req: c.req,
|
|
793
|
-
api,
|
|
794
|
-
env: c.env,
|
|
795
|
-
draft,
|
|
796
|
-
search: loaderSearch(mod.searchSchema, c.req),
|
|
797
|
-
}
|
|
798
|
-
// Layout loaders run for BOTH the document and the data-only request. A gate that only ran on
|
|
799
|
-
// the document path would be bypassed by adding the data header, which is exactly the request
|
|
800
|
-
// a client navigation makes - a guard has to hold on the path an attacker can pick.
|
|
801
|
-
//
|
|
802
|
-
// `runLayoutChain` returns once every GATE has resolved, with the non-gate loaders still in
|
|
803
|
-
// flight. So the page loader starts as late as correctness requires and as early as speed
|
|
804
|
-
// allows, and both are awaited together below.
|
|
805
|
-
const run = await runLayoutChain(
|
|
806
|
-
route,
|
|
807
|
-
ctx,
|
|
808
|
-
validatedRetainedIndices(c.req, route, c.params),
|
|
809
|
-
)
|
|
810
|
-
layoutModules = run.modules
|
|
811
|
-
layoutRetained = run.retained
|
|
812
|
-
const boundaryDefinitions = [
|
|
813
|
-
...run.modules.flatMap((layout) => layout.boundaries ?? []),
|
|
814
|
-
...(mod.boundaries ?? []),
|
|
815
|
-
]
|
|
816
|
-
if (boundaryDefinitions.length > 0) boundaryDescriptors(boundaryDefinitions)
|
|
817
|
-
const effectiveSearch = searchChainOf(run.modules, mod, c.req)
|
|
818
|
-
const boundaryBatch =
|
|
819
|
-
boundaryDefinitions.length === 0
|
|
820
|
-
? undefined
|
|
821
|
-
: startDynamicBoundaries(boundaryDefinitions, {
|
|
822
|
-
request: c.req,
|
|
823
|
-
params: c.params,
|
|
824
|
-
api,
|
|
825
|
-
env: c.env,
|
|
826
|
-
draft,
|
|
827
|
-
search: effectiveSearch,
|
|
828
|
-
signal: c.req.signal,
|
|
829
|
-
} satisfies BoundaryRequestCtx)
|
|
830
|
-
const staticBoundaryPromise =
|
|
831
|
-
boundaryDefinitions.length === 0
|
|
832
|
-
? Promise.resolve(undefined)
|
|
833
|
-
: resolveStaticBoundaries(
|
|
834
|
-
boundaryDefinitions,
|
|
835
|
-
{ phase: "build", origin: originOf(c.req) },
|
|
836
|
-
options.staticBoundaryCache,
|
|
837
|
-
)
|
|
838
|
-
// Boundary data is attached as one Deferred marker per slot. The shell can render
|
|
839
|
-
// immediately with each slot pending, while the existing deferred protocol emits the first
|
|
840
|
-
// completed sibling independently instead of waiting for the slowest boundary.
|
|
841
|
-
// The page loader gets the EFFECTIVE search (layout chain + page, page-wins), now that the layout
|
|
842
|
-
// modules are loaded - so `ctx.search` includes a layout's shared keys (`?org`), matching `useSearch`.
|
|
843
|
-
const [pageData, , staticBoundaries] = await Promise.all([
|
|
844
|
-
mod.loader ? mod.loader({ ...ctx, search: effectiveSearch }) : null,
|
|
845
|
-
run.pending, // a non-gate layout loader that rejects must still surface, not go unhandled
|
|
846
|
-
staticBoundaryPromise,
|
|
847
|
-
])
|
|
848
|
-
data = pageData
|
|
849
|
-
layoutData = run.layoutData
|
|
850
|
-
if (boundaryBatch !== undefined) {
|
|
851
|
-
const streamed = { ...boundaryBatch.initial, ...(staticBoundaries ?? {}) }
|
|
852
|
-
for (const entry of boundaryBatch.pending) {
|
|
853
|
-
const initial = streamed[entry.name]
|
|
854
|
-
if (initial !== undefined)
|
|
855
|
-
streamed[entry.name] = { ...initial, data: defer(entry.promise) }
|
|
856
|
-
}
|
|
857
|
-
boundaryStates = streamed
|
|
858
|
-
} else if (staticBoundaries !== undefined) {
|
|
859
|
-
boundaryStates = staticBoundaries
|
|
860
|
-
}
|
|
861
|
-
} catch (err) {
|
|
862
|
-
// A terminal-status signal (`notFound()` / `gone()` / `statusPage(n)`) renders a boundary at
|
|
863
|
-
// that status. Checked BEFORE the pass-through below: a signal IS a `Response`, so the order
|
|
864
|
-
// is what keeps a hand-rolled `throw new Response(...)` served verbatim, as it always was.
|
|
865
|
-
if (isStatusSignal(err)) return renderStatusSignal(c.req, err)
|
|
866
|
-
// A thrown control-flow signal (a guard's `redirect(...)`, an explicit error response) - let
|
|
867
|
-
// it propagate to core, which renders it as-is, whether it is a plain render or a
|
|
868
|
-
// hand-rolled `Response`. Real errors render the nearest `_error` boundary, if any; with
|
|
869
|
-
// none, rethrow (unchanged 500 behavior).
|
|
870
|
-
if (isControlFlow(err)) throw err
|
|
871
|
-
// Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
|
|
872
|
-
if (options.onLoaderError !== undefined) {
|
|
873
|
-
try {
|
|
874
|
-
options.onLoaderError(err, { request: c.req, params: c.params, route: route.pattern })
|
|
875
|
-
} catch {
|
|
876
|
-
// A faulty reporter must never break error rendering.
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
const errorId = boundaryFor(route, err)
|
|
880
|
-
if (errorId === undefined) throw err
|
|
881
|
-
// A soft-nav data fetch can't render a boundary - 500 so the client falls back to a full-page
|
|
882
|
-
// navigation, which lands here as a document and renders the `_error` page.
|
|
883
|
-
if (c.req.headers.get(DATA_HEADER) !== null) {
|
|
884
|
-
return new Response("Internal Server Error", {
|
|
885
|
-
status: 500,
|
|
886
|
-
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
887
|
-
})
|
|
888
|
-
}
|
|
889
|
-
return renderError(route, errorId, withDuplicateInstanceHint(err))
|
|
890
|
-
}
|
|
891
|
-
// A loader may RETURN its control-flow signal instead of throwing it - `return redirect(...)`
|
|
892
|
-
// reads naturally and must not silently serialize the signal as loader data. Mirror the catch
|
|
893
|
-
// above exactly: a status signal renders its boundary, any other signal (a redirect, a
|
|
894
|
-
// hand-rolled response) passes through to core verbatim - so return and throw are
|
|
895
|
-
// interchangeable.
|
|
896
|
-
if (isStatusSignal(data)) return renderStatusSignal(c.req, data)
|
|
897
|
-
if (isControlFlow(data)) return data
|
|
898
|
-
// Client-side navigation asks (via the X-Nifra-Data header) for just the loader data - no full
|
|
899
|
-
// document, no layout chain. A route with deferred data streams NDJSON (critical data first,
|
|
900
|
-
// then each deferred value as it settles); otherwise one JSON (the fast path). Same loader,
|
|
901
|
-
// same auth - only the transport differs.
|
|
902
|
-
if (c.req.headers.get(DATA_HEADER) !== null) {
|
|
903
|
-
const pageSplit = prepareDeferred(data)
|
|
904
|
-
const layoutSplits: Array<ReturnType<typeof prepareDeferred>> = []
|
|
905
|
-
let offset = pageSplit.deferred.length
|
|
906
|
-
for (const entry of layoutData ?? []) {
|
|
907
|
-
const split = prepareDeferred(entry, offset)
|
|
908
|
-
layoutSplits.push(split)
|
|
909
|
-
offset += split.deferred.length
|
|
910
|
-
}
|
|
911
|
-
const deferred = [...pageSplit.deferred, ...layoutSplits.flatMap((split) => split.deferred)]
|
|
912
|
-
const boundarySplit =
|
|
913
|
-
boundaryStates === undefined
|
|
914
|
-
? undefined
|
|
915
|
-
: prepareDeferred(boundaryStates, deferred.length)
|
|
916
|
-
const allDeferred = [
|
|
917
|
-
...deferred,
|
|
918
|
-
...(boundarySplit === undefined ? [] : boundarySplit.deferred),
|
|
919
|
-
]
|
|
920
|
-
const payload =
|
|
921
|
-
layoutData === undefined && boundarySplit === undefined
|
|
922
|
-
? pageSplit.forClient
|
|
923
|
-
: {
|
|
924
|
-
v: 1 as const,
|
|
925
|
-
data: pageSplit.forClient,
|
|
926
|
-
layoutData: layoutSplits.map((split) => split.forClient),
|
|
927
|
-
retained: layoutRetained,
|
|
928
|
-
...(boundarySplit === undefined ? {} : { boundaries: boundarySplit.forClient }),
|
|
929
|
-
}
|
|
930
|
-
// Bare value when there is no layout data - the pre-envelope shape, so a client running older
|
|
931
|
-
// code against a newer server keeps working.
|
|
932
|
-
if (allDeferred.length === 0) {
|
|
933
|
-
return Response.json(payload ?? null)
|
|
934
|
-
}
|
|
935
|
-
return new Response(ndjsonStream(payload, allDeferred), {
|
|
936
|
-
headers: { "content-type": "application/x-ndjson; charset=utf-8" },
|
|
937
|
-
})
|
|
938
|
-
}
|
|
939
|
-
const chainLayoutModules = layoutModules ?? (await loadLayoutModules(route))
|
|
940
|
-
const { chain, head } = resolveChainAndHead(chainLayoutModules, mod, {
|
|
941
|
-
data,
|
|
942
|
-
params: c.params,
|
|
943
|
-
origin: originOf(c.req),
|
|
944
|
-
})
|
|
945
|
-
const hydrateRoute = mod.hydrate !== false
|
|
946
|
-
try {
|
|
947
|
-
// `await` so a shell-render throw (renderToStream rejects before any byte) is caught here and
|
|
948
|
-
// can render the `_error` page - not just a loader throw. Mid-stream (post-shell) throws can't
|
|
949
|
-
// be recovered to a full page; the per-adapter client boundary catches client render errors.
|
|
950
|
-
return await renderPageResult({
|
|
951
|
-
adapter,
|
|
952
|
-
chain,
|
|
953
|
-
data,
|
|
954
|
-
head,
|
|
955
|
-
clientEntry,
|
|
956
|
-
routeId: route.id,
|
|
957
|
-
params: c.params,
|
|
958
|
-
path: pathOf(c.req),
|
|
959
|
-
// The effective (layout chain + page) search, matching the page loader's `ctx.search` above and
|
|
960
|
-
// the client mount - so `useSearch` renders SSR-correct and hydrates with no drift. `layoutModules`
|
|
961
|
-
// is loaded by now (the loader try succeeded to reach here).
|
|
962
|
-
search: searchChainOf(layoutModules ?? [], mod, c.req),
|
|
963
|
-
hydrate: hydrateRoute,
|
|
964
|
-
...(layoutData !== undefined ? { layoutData } : {}),
|
|
965
|
-
...(boundaryStates !== undefined ? { boundaries: boundaryStates } : {}),
|
|
966
|
-
...preloadOf(route.id),
|
|
967
|
-
...stylesOf(route.id),
|
|
968
|
-
prerenderedPaths: options.prerenderedPaths ?? [],
|
|
969
|
-
...(mod.revalidate !== undefined ? { revalidate: mod.revalidate } : {}),
|
|
970
|
-
...(mod.revalidateTags !== undefined ? { revalidateTags: mod.revalidateTags } : {}),
|
|
971
|
-
...(mod.islandScripts !== undefined ? { islandScripts: mod.islandScripts } : {}),
|
|
972
|
-
...titleOption,
|
|
973
|
-
...assemblyCacheFor(mod, chainLayoutModules),
|
|
974
|
-
})
|
|
975
|
-
} catch (err) {
|
|
976
|
-
// Same precedence as the loader catch above - a `meta()` may signal too.
|
|
977
|
-
if (isStatusSignal(err)) return renderStatusSignal(c.req, err)
|
|
978
|
-
if (isControlFlow(err)) throw err
|
|
979
|
-
// Let reporting plugins observe the data-layer failure before it's rendered/rethrown/500'd.
|
|
980
|
-
if (options.onLoaderError !== undefined) {
|
|
981
|
-
try {
|
|
982
|
-
options.onLoaderError(err, { request: c.req, params: c.params, route: route.pattern })
|
|
983
|
-
} catch {
|
|
984
|
-
// A faulty reporter must never break error rendering.
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
const errorId = boundaryFor(route, err)
|
|
988
|
-
if (errorId === undefined) throw err
|
|
989
|
-
return renderError(route, errorId, withDuplicateInstanceHint(err))
|
|
990
|
-
}
|
|
991
|
-
})
|
|
992
|
-
|
|
993
|
-
// POST runs the route's `action` (mutation). A control-flow return (e.g. a `redirect(...)`)
|
|
994
|
-
// passes straight through; a data return re-renders the page (the loader re-runs for fresh
|
|
995
|
-
// data) with `actionData`. Routes without an action reject POST with 405 - not a stray 404.
|
|
996
|
-
app.register("POST", route.pattern, undefined, async (c: RouteContext) => {
|
|
997
|
-
const mod = await route.load()
|
|
998
|
-
const draft = await draftFlag(c.req)
|
|
999
|
-
if (mod.action === undefined) {
|
|
1000
|
-
return new Response("Method Not Allowed", {
|
|
1001
|
-
status: 405,
|
|
1002
|
-
headers: { allow: "GET", "content-type": "text/plain; charset=utf-8" },
|
|
1003
|
-
})
|
|
1004
|
-
}
|
|
1005
|
-
const actionContext = {
|
|
1006
|
-
params: c.params,
|
|
1007
|
-
request: c.req,
|
|
1008
|
-
req: c.req,
|
|
1009
|
-
api,
|
|
1010
|
-
env: c.env,
|
|
1011
|
-
draft,
|
|
1012
|
-
search: loaderSearch(mod.searchSchema, c.req),
|
|
1013
|
-
}
|
|
1014
|
-
const isDataRequest = c.req.headers.get(DATA_HEADER) !== null
|
|
1015
|
-
let layoutModules: LoadedLayoutModules
|
|
1016
|
-
let result: unknown
|
|
1017
|
-
try {
|
|
1018
|
-
// A gate is an authorization boundary for everything beneath its layout, mutations included.
|
|
1019
|
-
// Run only gates before the action; ordinary layout data loaders run after a native mutation.
|
|
1020
|
-
layoutModules = await runLayoutGates(route, actionContext)
|
|
1021
|
-
result = await mod.action(actionContext)
|
|
1022
|
-
} catch (err) {
|
|
1023
|
-
// A THROWN signal is the same control flow as a returned one (`throw redirect()` in a gate
|
|
1024
|
-
// or an action) - route it through the same conversion as the returned branch below, so
|
|
1025
|
-
// throw and return are interchangeable on the mutation path too.
|
|
1026
|
-
if (isControlFlow(err)) return actionResponse(err, isDataRequest)
|
|
1027
|
-
throw err
|
|
1028
|
-
}
|
|
1029
|
-
// An action may wrap its data in `revalidate(paths, data)` to declare which routes it changed.
|
|
1030
|
-
// Unwrap to the inner data; the paths ride the `X-Nifra-Revalidate` header on the data-mode
|
|
1031
|
-
// responses (the client acts on them - a full-page POST re-runs loaders inline, so no header).
|
|
1032
|
-
const isRevalidate =
|
|
1033
|
-
result !== null && typeof result === "object" && "__nifraRevalidate" in result
|
|
1034
|
-
const actionResult = isRevalidate ? (result as RevalidateResult<unknown>).data : result
|
|
1035
|
-
const revalidateHeader: Record<string, string> = isRevalidate
|
|
1036
|
-
? { [REVALIDATE_HEADER]: (result as RevalidateResult<unknown>).__nifraRevalidate.join(",") }
|
|
1037
|
-
: {}
|
|
1038
|
-
if (isControlFlow(actionResult)) return actionResponse(actionResult, isDataRequest)
|
|
1039
|
-
// Client submit wants just the action's data (it revalidates the loader itself); a native
|
|
1040
|
-
// form POST re-renders the full page (loader re-runs) with the action data.
|
|
1041
|
-
if (isDataRequest) {
|
|
1042
|
-
// An action may `defer()` slow parts of its result - stream them (critical data first, then
|
|
1043
|
-
// each deferred as it settles) exactly like a loader; a non-deferred action returns one JSON.
|
|
1044
|
-
const { forClient, deferred } = prepareDeferred(actionResult)
|
|
1045
|
-
if (deferred.length === 0)
|
|
1046
|
-
return Response.json(actionResult ?? null, { headers: revalidateHeader })
|
|
1047
|
-
return new Response(ndjsonStream(forClient, deferred), {
|
|
1048
|
-
headers: { "content-type": "application/x-ndjson; charset=utf-8", ...revalidateHeader },
|
|
1049
|
-
})
|
|
1050
|
-
}
|
|
1051
|
-
// Derived once and shared: the loader's `ctx.search` and the render's `RenderProps.search` are the
|
|
1052
|
-
// SAME effective (layout chain + page) value, and the client mount rebuilds it identically. The
|
|
1053
|
-
// layout modules are already loaded (the action ran through their gates above).
|
|
1054
|
-
const search = searchChainOf(layoutModules, mod, c.req)
|
|
1055
|
-
const data = mod.loader
|
|
1056
|
-
? await mod.loader({
|
|
1057
|
-
params: c.params,
|
|
1058
|
-
request: c.req,
|
|
1059
|
-
req: c.req,
|
|
1060
|
-
api,
|
|
1061
|
-
env: c.env,
|
|
1062
|
-
draft,
|
|
1063
|
-
search,
|
|
1064
|
-
})
|
|
1065
|
-
: null
|
|
1066
|
-
const { chain, head } = resolveChainAndHead(layoutModules, mod, {
|
|
1067
|
-
data,
|
|
1068
|
-
params: c.params,
|
|
1069
|
-
origin: originOf(c.req),
|
|
1070
|
-
})
|
|
1071
|
-
const hydrateRoute = mod.hydrate !== false
|
|
1072
|
-
// A full-page POST streams the action's `defer()`'d parts mid-document behind `<Await>` too -
|
|
1073
|
-
// `renderPage` splits `actionData` like loader data (works with JS off; hydrates after).
|
|
1074
|
-
return renderPageResult({
|
|
1075
|
-
adapter,
|
|
1076
|
-
chain,
|
|
1077
|
-
data,
|
|
1078
|
-
actionData: actionResult,
|
|
1079
|
-
head,
|
|
1080
|
-
clientEntry,
|
|
1081
|
-
routeId: route.id,
|
|
1082
|
-
params: c.params,
|
|
1083
|
-
path: pathOf(c.req),
|
|
1084
|
-
search,
|
|
1085
|
-
hydrate: hydrateRoute,
|
|
1086
|
-
...preloadOf(route.id),
|
|
1087
|
-
...stylesOf(route.id),
|
|
1088
|
-
prerenderedPaths: options.prerenderedPaths ?? [],
|
|
1089
|
-
...titleOption,
|
|
1090
|
-
})
|
|
1091
|
-
})
|
|
322
|
+
app.register("GET", route.pattern, undefined, pageExecutor.get(route))
|
|
323
|
+
app.register("POST", route.pattern, undefined, pageExecutor.post(route))
|
|
1092
324
|
}
|
|
1093
|
-
|
|
1094
325
|
// Register llms.txt & llms-full.txt
|
|
1095
326
|
const llmsOptions = { includeLocalGuidelines: options.publishLocalGuidelines === true }
|
|
1096
327
|
app.register("GET", "/llms.txt", undefined, async () => {
|
|
@@ -1108,7 +339,9 @@ export function createWebApp<Env = unknown>(
|
|
|
1108
339
|
})
|
|
1109
340
|
|
|
1110
341
|
// Wildcard catch-all: unmatched paths render `_404` (404), or a plain text 404 if absent.
|
|
1111
|
-
app.register("GET", "/*", undefined, (c: RouteContext) =>
|
|
342
|
+
app.register("GET", "/*", undefined, (c: RouteContext<Env>) =>
|
|
343
|
+
pageExecutor.notFound(c.req, c.env, requestPathOf(c.req)),
|
|
344
|
+
)
|
|
1112
345
|
|
|
1113
346
|
return app
|
|
1114
347
|
}
|