@nifrajs/web 3.2.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.
Files changed (77) hide show
  1. package/dist/build-plan.d.ts +17 -0
  2. package/dist/build-plan.d.ts.map +1 -1
  3. package/dist/build-plan.js +7 -1
  4. package/dist/build-plan.js.map +1 -1
  5. package/dist/build-vite.d.ts +14 -0
  6. package/dist/build-vite.d.ts.map +1 -1
  7. package/dist/build-vite.js +29 -4
  8. package/dist/build-vite.js.map +1 -1
  9. package/dist/build.d.ts +15 -0
  10. package/dist/build.d.ts.map +1 -1
  11. package/dist/build.js +57 -15
  12. package/dist/build.js.map +1 -1
  13. package/dist/client.d.ts +18 -0
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +115 -0
  16. package/dist/client.js.map +1 -1
  17. package/dist/css-contract.d.ts +21 -0
  18. package/dist/css-contract.d.ts.map +1 -0
  19. package/dist/css-contract.js +28 -0
  20. package/dist/css-contract.js.map +1 -0
  21. package/dist/index.d.ts +3 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/internal/codegen.d.ts +3 -0
  26. package/dist/internal/codegen.d.ts.map +1 -1
  27. package/dist/internal/codegen.js +11 -6
  28. package/dist/internal/codegen.js.map +1 -1
  29. package/dist/internal/page-execution.d.ts +57 -0
  30. package/dist/internal/page-execution.d.ts.map +1 -0
  31. package/dist/internal/page-execution.js +555 -0
  32. package/dist/internal/page-execution.js.map +1 -0
  33. package/dist/internal/render-document.d.ts +12 -2
  34. package/dist/internal/render-document.d.ts.map +1 -1
  35. package/dist/internal/render-document.js +28 -5
  36. package/dist/internal/render-document.js.map +1 -1
  37. package/dist/internal/request-url.d.ts +3 -0
  38. package/dist/internal/request-url.d.ts.map +1 -0
  39. package/dist/internal/request-url.js +14 -0
  40. package/dist/internal/request-url.js.map +1 -0
  41. package/dist/internal/web-app.d.ts +20 -1
  42. package/dist/internal/web-app.d.ts.map +1 -1
  43. package/dist/internal/web-app.js +37 -734
  44. package/dist/internal/web-app.js.map +1 -1
  45. package/dist/isr.d.ts.map +1 -1
  46. package/dist/isr.js +39 -4
  47. package/dist/isr.js.map +1 -1
  48. package/dist/llms-txt.d.ts +3 -0
  49. package/dist/llms-txt.d.ts.map +1 -1
  50. package/dist/llms-txt.js +6 -3
  51. package/dist/llms-txt.js.map +1 -1
  52. package/dist/manifest.d.ts +1 -1
  53. package/dist/manifest.d.ts.map +1 -1
  54. package/dist/public-dir.d.ts.map +1 -1
  55. package/dist/public-dir.js +69 -44
  56. package/dist/public-dir.js.map +1 -1
  57. package/dist/trusted-html.d.ts +34 -0
  58. package/dist/trusted-html.d.ts.map +1 -0
  59. package/dist/trusted-html.js +35 -0
  60. package/dist/trusted-html.js.map +1 -0
  61. package/package.json +3 -3
  62. package/src/build-plan.ts +23 -1
  63. package/src/build-vite.ts +47 -4
  64. package/src/build.ts +69 -6
  65. package/src/client.ts +140 -0
  66. package/src/css-contract.ts +44 -0
  67. package/src/index.ts +15 -0
  68. package/src/internal/codegen.ts +20 -6
  69. package/src/internal/page-execution.ts +761 -0
  70. package/src/internal/render-document.ts +39 -6
  71. package/src/internal/request-url.ts +14 -0
  72. package/src/internal/web-app.ts +68 -835
  73. package/src/isr.ts +42 -3
  74. package/src/llms-txt.ts +16 -4
  75. package/src/manifest.ts +1 -1
  76. package/src/public-dir.ts +70 -45
  77. package/src/trusted-html.ts +44 -0
@@ -0,0 +1,761 @@
1
+ import type { ResponseResult } from "@nifrajs/core/server"
2
+ import {
3
+ type BoundaryRequestCtx,
4
+ type BoundaryStates,
5
+ boundaryDescriptors,
6
+ resolveStaticBoundaries,
7
+ type StaticBoundaryCache,
8
+ startDynamicBoundaries,
9
+ } from "../boundary.ts"
10
+ import { type CssLoadingMode, DEFAULT_CSS_LOADING, normalizeCssLoading } from "../css-contract.ts"
11
+ import { defer, ndjsonStream, prepareDeferred } from "../deferred.ts"
12
+ import { isDraftEnabled } from "../draft.ts"
13
+ import type {
14
+ LayoutEntry,
15
+ LoaderContext,
16
+ Manifest,
17
+ Meta,
18
+ MetaArgs,
19
+ RouteEntry,
20
+ RouteModule,
21
+ } from "../manifest.ts"
22
+ import type { RenderAdapter } from "../render-seam.ts"
23
+ import {
24
+ createMatcher,
25
+ DATA_HEADER,
26
+ NAV_FROM_HEADER,
27
+ RETAIN_HEADER,
28
+ REVALIDATE_HEADER,
29
+ STATUS_HEADER,
30
+ } from "../router.ts"
31
+ import { searchOf, searchOfChain } from "../search.ts"
32
+ import { mergeHeads, resolveMeta } from "./head-merge.ts"
33
+ import {
34
+ actionResponse,
35
+ EMPTY_RETAIN,
36
+ type HeadersLike,
37
+ isControlFlow,
38
+ isStatusSignal,
39
+ type LoadedLayoutModules,
40
+ layoutErrorId,
41
+ type RenderAssemblyCache,
42
+ type RenderedPage,
43
+ type RevalidateResult,
44
+ renderPageResult,
45
+ STATUS_SIGNAL,
46
+ STATUS_TEXT,
47
+ type StatusSignal,
48
+ scopeParams,
49
+ tagLayoutError,
50
+ withDuplicateInstanceHint,
51
+ } from "./render-document.ts"
52
+ import { urlPartsFor } from "./request-url.ts"
53
+
54
+ /** Resolve the CSP nonce for one document request. Return `undefined` to omit nonce attributes. */
55
+ export type NonceResolver<Env = unknown> = (ctx: {
56
+ readonly request: Request
57
+ readonly env: Env
58
+ }) => string | undefined | Promise<string | undefined>
59
+
60
+ /** Structural context supplied by the core server to one page route handler. */
61
+ export interface PageRouteContext<Env = unknown> {
62
+ readonly params: Record<string, string>
63
+ readonly req: Request
64
+ readonly env: Env
65
+ }
66
+
67
+ export interface PageExecutionOptions<Env = unknown> {
68
+ readonly adapter: RenderAdapter
69
+ readonly manifest: Manifest
70
+ readonly clientEntry: string
71
+ readonly title?: string
72
+ readonly api?: unknown
73
+ readonly draftSecret?: string
74
+ readonly routePreload?: Readonly<Record<string, readonly string[]>>
75
+ readonly styles?: readonly string[]
76
+ readonly routeStyles?: Readonly<Record<string, readonly string[]>>
77
+ readonly cssLoading?: CssLoadingMode
78
+ readonly prerenderedPaths?: readonly string[]
79
+ readonly staticFallbacks?: Readonly<Record<string, "ssr" | "404">>
80
+ readonly staticBoundaryCache?: StaticBoundaryCache
81
+ /** Per-request CSP nonce for framework-owned executable document scripts. */
82
+ readonly nonce?: NonceResolver<Env>
83
+ readonly onLoaderError?: (
84
+ error: unknown,
85
+ ctx: {
86
+ readonly request: Request
87
+ readonly params: Readonly<Record<string, string>>
88
+ readonly route: string
89
+ },
90
+ ) => void
91
+ }
92
+
93
+ type PageResponse = Response | ResponseResult | RenderedPage
94
+
95
+ export interface PageRequestExecutor<Env = unknown> {
96
+ get(route: RouteEntry): (ctx: PageRouteContext<Env>) => Promise<PageResponse>
97
+ post(route: RouteEntry): (ctx: PageRouteContext<Env>) => Promise<PageResponse>
98
+ notFound(request: Request, env: Env, path?: string): Promise<PageResponse>
99
+ }
100
+
101
+ /**
102
+ * Own the page request program behind one private seam.
103
+ *
104
+ * `createWebApp` remains responsible for app construction, mount ordering, and route registration.
105
+ * This module owns the page-specific contract: context, layout execution, control-flow precedence,
106
+ * data/document assembly, and error/status rendering. GET and POST retain their distinct ordering
107
+ * (ordinary layout data may run after an action; a gate must run before it) while sharing the same
108
+ * outcome and render decisions.
109
+ */
110
+ export function createPageRequestExecutor<Env = unknown>(
111
+ options: PageExecutionOptions<Env>,
112
+ ): PageRequestExecutor<Env> {
113
+ const { adapter, manifest, clientEntry, api } = options
114
+ const cssLoading = normalizeCssLoading(options.cssLoading ?? DEFAULT_CSS_LOADING)
115
+ const titleOption = options.title === undefined ? {} : { title: options.title }
116
+ const prerenderedSet = new Set(options.prerenderedPaths ?? [])
117
+ const matchManifestRoute = createMatcher(
118
+ manifest.routes.map((route) => ({ routeId: route.id, pattern: route.pattern })),
119
+ )
120
+ const routeById = new Map(manifest.routes.map((route) => [route.id, route]))
121
+
122
+ const draftFlag = (req: Request): Promise<boolean> =>
123
+ options.draftSecret === undefined
124
+ ? Promise.resolve(false)
125
+ : isDraftEnabled(req, options.draftSecret)
126
+
127
+ const preloadOf = (id: string): { preload?: readonly string[] } => {
128
+ const chunks = options.routePreload?.[id]
129
+ return chunks ? { preload: chunks } : {}
130
+ }
131
+
132
+ const resolveNonce = (
133
+ request: Request,
134
+ env: Env,
135
+ ): string | undefined | Promise<string | undefined> =>
136
+ options.nonce === undefined ? undefined : options.nonce({ request, env })
137
+
138
+ const stylesOf = (id: string): { styles?: readonly string[]; cssLoading?: CssLoadingMode } => {
139
+ const perRoute = options.routeStyles?.[id]
140
+ const styles = perRoute ?? options.styles
141
+ if (styles === undefined || styles.length === 0) return {}
142
+ return {
143
+ styles,
144
+ ...(cssLoading === "deferred" ? { cssLoading } : {}),
145
+ }
146
+ }
147
+
148
+ // Load a route's layout modules once for the current execution. The render path needs each whole
149
+ // module for its component + meta, while the data path also needs loader/gate/boundary declarations.
150
+ const loadLayoutModules = async (route: RouteEntry): Promise<LoadedLayoutModules> => {
151
+ const modules = await Promise.all(
152
+ route.layoutIds.map((id) => (manifest.layouts[id] as LayoutEntry).load()),
153
+ )
154
+ for (let i = 0; i < modules.length; i++) {
155
+ if ((modules[i] as { action?: unknown }).action === undefined) continue
156
+ const id = route.layoutIds[i] as string
157
+ throw new Error(
158
+ `[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.`,
159
+ )
160
+ }
161
+ return modules
162
+ }
163
+
164
+ /** Run layout loaders, awaiting gates before starting the page loader and retaining only safe data. */
165
+ const runLayoutChain = async (
166
+ route: RouteEntry,
167
+ ctx: LoaderContext,
168
+ retain: ReadonlySet<number> = new Set(),
169
+ ): Promise<{
170
+ readonly modules: LoadedLayoutModules
171
+ readonly layoutData: readonly unknown[] | undefined
172
+ readonly retained: readonly number[]
173
+ readonly pending: Promise<unknown>
174
+ }> => {
175
+ const modules = await loadLayoutModules(route)
176
+ if (!modules.some((m) => m.loader !== undefined)) {
177
+ return { modules, layoutData: undefined, retained: [], pending: Promise.resolve() }
178
+ }
179
+ const results: unknown[] = new Array(modules.length).fill(null)
180
+ const retained: number[] = []
181
+ const pending: Array<Promise<void>> = []
182
+ for (let i = 0; i < modules.length; i++) {
183
+ const loader = modules[i]?.loader
184
+ if (loader === undefined) continue
185
+ if (retain.has(i) && modules[i]?.gate !== true) {
186
+ retained.push(i)
187
+ continue
188
+ }
189
+ const scoped: LoaderContext = {
190
+ ...ctx,
191
+ params: scopeParams(ctx.params, route.layoutParams?.[i]),
192
+ }
193
+ if (modules[i]?.gate === true) {
194
+ try {
195
+ results[i] = await loader(scoped)
196
+ } catch (err) {
197
+ throw tagLayoutError(err, route.layoutIds[i] as string)
198
+ }
199
+ continue
200
+ }
201
+ const layoutId = route.layoutIds[i] as string
202
+ const index = i
203
+ pending.push(
204
+ (async () => {
205
+ try {
206
+ results[index] = await loader(scoped)
207
+ } catch (err) {
208
+ throw tagLayoutError(err, layoutId)
209
+ }
210
+ })(),
211
+ )
212
+ }
213
+ return { modules, layoutData: results, retained, pending: Promise.all(pending) }
214
+ }
215
+
216
+ /** Run only authorization gates before an action; ordinary layout data runs after a native action. */
217
+ const runLayoutGates = async (
218
+ route: RouteEntry,
219
+ ctx: LoaderContext,
220
+ ): Promise<LoadedLayoutModules> => {
221
+ const modules = await loadLayoutModules(route)
222
+ for (let i = 0; i < modules.length; i++) {
223
+ const mod = modules[i]
224
+ if (mod?.gate !== true || mod.loader === undefined) continue
225
+ try {
226
+ await mod.loader({
227
+ ...ctx,
228
+ params: scopeParams(ctx.params, route.layoutParams?.[i]),
229
+ })
230
+ } catch (err) {
231
+ throw tagLayoutError(err, route.layoutIds[i] as string)
232
+ }
233
+ }
234
+ return modules
235
+ }
236
+
237
+ const retainedIndices = (header: string | null): ReadonlySet<number> => {
238
+ if (header === null || header === "") return EMPTY_RETAIN
239
+ const out = new Set<number>()
240
+ for (const part of header.split(",")) {
241
+ if (!/^\d+$/.test(part)) continue
242
+ out.add(Number(part))
243
+ }
244
+ return out
245
+ }
246
+
247
+ const validatedRetainedIndices = (
248
+ req: Request,
249
+ route: RouteEntry,
250
+ params: Readonly<Record<string, string>>,
251
+ ): ReadonlySet<number> => {
252
+ if (req.headers.get(DATA_HEADER) === null) return EMPTY_RETAIN
253
+ const requested = retainedIndices(req.headers.get(RETAIN_HEADER))
254
+ const from = req.headers.get(NAV_FROM_HEADER)
255
+ if (requested.size === 0 || from === null) return EMPTY_RETAIN
256
+ let fromPath: string
257
+ try {
258
+ const current = new URL(req.url)
259
+ const source = new URL(from, current)
260
+ if (source.origin !== current.origin) return EMPTY_RETAIN
261
+ fromPath = source.pathname + source.search
262
+ } catch {
263
+ return EMPTY_RETAIN
264
+ }
265
+ const previousMatch = matchManifestRoute(fromPath)
266
+ if (previousMatch === null) return EMPTY_RETAIN
267
+ const previous = routeById.get(previousMatch.routeId)
268
+ if (previous === undefined) return EMPTY_RETAIN
269
+ const valid = new Set<number>()
270
+ for (const index of requested) {
271
+ if (route.layoutIds[index] !== previous.layoutIds[index]) continue
272
+ const owned = route.layoutParams?.[index] ?? []
273
+ const previouslyOwned = previous.layoutParams?.[index] ?? []
274
+ if (
275
+ owned.length !== previouslyOwned.length ||
276
+ owned.some((name, i) => name !== previouslyOwned[i]) ||
277
+ owned.some((name) => params[name] !== previousMatch.params[name])
278
+ ) {
279
+ continue
280
+ }
281
+ valid.add(index)
282
+ }
283
+ return valid
284
+ }
285
+
286
+ const loaderSearch = (searchSchema: RouteModule["searchSchema"], request: Request) =>
287
+ searchOf(searchSchema, urlPartsFor(request).search)
288
+
289
+ const searchChainOf = (
290
+ layoutMods: LoadedLayoutModules,
291
+ mod: RouteModule,
292
+ request: Request,
293
+ ): Record<string, unknown> =>
294
+ searchOfChain(
295
+ [...layoutMods.map((m) => m.searchSchema), mod.searchSchema],
296
+ urlPartsFor(request).search,
297
+ )
298
+
299
+ const resolveChainAndHead = (
300
+ layoutModules: LoadedLayoutModules,
301
+ page: RouteModule,
302
+ metaArgs: MetaArgs,
303
+ ): { chain: unknown[]; head: Meta } => {
304
+ const chain = [...layoutModules.map((m) => m.default), page.default]
305
+ const heads = [
306
+ ...layoutModules.map((m) => resolveMeta(m.meta, metaArgs)),
307
+ resolveMeta(page.meta, metaArgs),
308
+ ]
309
+ return { chain, head: mergeHeads(heads) }
310
+ }
311
+
312
+ const dirOfId = (id: string, suffix: string): string =>
313
+ id === suffix ? "" : id.slice(0, id.length - suffix.length - 1)
314
+
315
+ const boundaryFor = (route: RouteEntry, err: unknown): string | undefined => {
316
+ const ids = route.errorIds ?? []
317
+ const failing = layoutErrorId(err)
318
+ if (failing === undefined) return ids.at(-1)
319
+ const layoutDir = dirOfId(failing, "_layout")
320
+ for (let i = ids.length - 1; i >= 0; i--) {
321
+ const errDir = dirOfId(ids[i] as string, "_error")
322
+ if (errDir === "" || errDir === layoutDir || layoutDir.startsWith(`${errDir}/`)) return ids[i]
323
+ }
324
+ return undefined
325
+ }
326
+
327
+ const originOf = (req: Request): string => {
328
+ try {
329
+ return new URL(req.url).origin
330
+ } catch {
331
+ return ""
332
+ }
333
+ }
334
+
335
+ const pathOf = (req: Request): string => {
336
+ try {
337
+ const parts = urlPartsFor(req)
338
+ return parts.pathname + parts.search
339
+ } catch {
340
+ return "/"
341
+ }
342
+ }
343
+
344
+ const renderError = async (
345
+ route: RouteEntry,
346
+ errorId: string,
347
+ err: unknown,
348
+ nonce?: string,
349
+ ): Promise<PageResponse> => {
350
+ const errDir = dirOfId(errorId, "_error")
351
+ const keptLayoutIds = route.layoutIds.filter((id) => {
352
+ const ld = dirOfId(id, "_layout")
353
+ return ld === "" || ld === errDir || errDir.startsWith(`${ld}/`)
354
+ })
355
+ const layouts = await Promise.all(
356
+ keptLayoutIds.map((id) =>
357
+ (manifest.layouts[id] as LayoutEntry).load().then((m) => m.default),
358
+ ),
359
+ )
360
+ const { default: errComp } = await (manifest.errors?.[errorId] as LayoutEntry).load()
361
+ const e = err instanceof Error ? err : new Error(String(err))
362
+ return renderPageResult({
363
+ adapter,
364
+ chain: [...layouts, errComp],
365
+ data: { name: e.name, message: e.message },
366
+ clientEntry,
367
+ routeId: errorId,
368
+ status: 500,
369
+ hydrate: false,
370
+ ...(nonce === undefined ? {} : { nonce }),
371
+ ...titleOption,
372
+ })
373
+ }
374
+
375
+ const renderTerminalStatus = async (
376
+ status: number,
377
+ path?: string,
378
+ extraHeaders?: HeadersLike,
379
+ nonce?: string,
380
+ ): Promise<PageResponse> => {
381
+ const page = manifest.statusPages?.[String(status)] ?? manifest.notFound
382
+ if (page === undefined) {
383
+ const headers = new Headers(extraHeaders)
384
+ headers.set("content-type", "text/plain; charset=utf-8")
385
+ return new Response(STATUS_TEXT[status] ?? "Error", { status, headers })
386
+ }
387
+ const routeId = manifest.statusPages?.[String(status)] !== undefined ? `_${status}` : "_404"
388
+ const loaded = (await page.load()) as {
389
+ readonly default: unknown
390
+ readonly searchSchema?: RouteModule["searchSchema"]
391
+ }
392
+ return renderPageResult({
393
+ adapter,
394
+ chain: [loaded.default],
395
+ data: null,
396
+ clientEntry,
397
+ routeId,
398
+ ...(path !== undefined
399
+ ? {
400
+ search: searchOf(
401
+ loaded.searchSchema,
402
+ path.includes("?") ? path.slice(path.indexOf("?")) : "",
403
+ ),
404
+ }
405
+ : {}),
406
+ ...(path !== undefined ? { path } : {}),
407
+ status,
408
+ ...(extraHeaders !== undefined ? { headers: extraHeaders } : {}),
409
+ ...(nonce === undefined ? {} : { nonce }),
410
+ ...preloadOf(routeId),
411
+ ...stylesOf(routeId),
412
+ prerenderedPaths: options.prerenderedPaths ?? [],
413
+ ...titleOption,
414
+ })
415
+ }
416
+
417
+ const renderStatusSignal = async (
418
+ req: Request,
419
+ env: Env,
420
+ signal: StatusSignal,
421
+ ): Promise<PageResponse> => {
422
+ const { status, headers } = signal[STATUS_SIGNAL]
423
+ if (req.headers.get(DATA_HEADER) !== null) {
424
+ const responseHeaders = new Headers(headers)
425
+ responseHeaders.set(STATUS_HEADER, String(status))
426
+ return new Response(null, { status, headers: responseHeaders })
427
+ }
428
+ return renderTerminalStatus(status, pathOf(req), headers, await resolveNonce(req, env))
429
+ }
430
+
431
+ const reportLoaderError = (
432
+ route: RouteEntry,
433
+ req: Request,
434
+ params: Record<string, string>,
435
+ err: unknown,
436
+ ): void => {
437
+ if (options.onLoaderError === undefined) return
438
+ try {
439
+ options.onLoaderError(err, { request: req, params, route: route.pattern })
440
+ } catch {
441
+ // A faulty reporter must never break error rendering.
442
+ }
443
+ }
444
+
445
+ const handleDataOrDocument = async (
446
+ req: Request,
447
+ env: Env,
448
+ route: RouteEntry,
449
+ params: Readonly<Record<string, string>>,
450
+ mod: RouteModule,
451
+ data: unknown,
452
+ layoutData: readonly unknown[] | undefined,
453
+ layoutModules: LoadedLayoutModules,
454
+ layoutRetained: readonly number[],
455
+ boundaryStates: BoundaryStates | undefined,
456
+ assemblyCache: RenderAssemblyCache | undefined,
457
+ ): Promise<PageResponse> => {
458
+ if (isStatusSignal(data)) return renderStatusSignal(req, env, data)
459
+ if (isControlFlow(data)) return data
460
+ if (req.headers.get(DATA_HEADER) !== null) {
461
+ const pageSplit = prepareDeferred(data)
462
+ const layoutSplits: Array<ReturnType<typeof prepareDeferred>> = []
463
+ let offset = pageSplit.deferred.length
464
+ for (const entry of layoutData ?? []) {
465
+ const split = prepareDeferred(entry, offset)
466
+ layoutSplits.push(split)
467
+ offset += split.deferred.length
468
+ }
469
+ const deferred = [...pageSplit.deferred, ...layoutSplits.flatMap((split) => split.deferred)]
470
+ const boundarySplit =
471
+ boundaryStates === undefined ? undefined : prepareDeferred(boundaryStates, deferred.length)
472
+ const allDeferred = [
473
+ ...deferred,
474
+ ...(boundarySplit === undefined ? [] : boundarySplit.deferred),
475
+ ]
476
+ const payload =
477
+ layoutData === undefined && boundarySplit === undefined
478
+ ? pageSplit.forClient
479
+ : {
480
+ v: 1 as const,
481
+ data: pageSplit.forClient,
482
+ layoutData: layoutSplits.map((split) => split.forClient),
483
+ retained: layoutRetained,
484
+ ...(boundarySplit === undefined ? {} : { boundaries: boundarySplit.forClient }),
485
+ }
486
+ if (allDeferred.length === 0) return Response.json(payload ?? null)
487
+ return new Response(ndjsonStream(payload, allDeferred), {
488
+ headers: { "content-type": "application/x-ndjson; charset=utf-8" },
489
+ })
490
+ }
491
+
492
+ const { chain, head } = resolveChainAndHead(layoutModules, mod, {
493
+ data,
494
+ params,
495
+ origin: originOf(req),
496
+ })
497
+ const nonce = options.nonce === undefined ? undefined : await resolveNonce(req, env)
498
+ try {
499
+ return await renderPageResult({
500
+ adapter,
501
+ chain,
502
+ data,
503
+ head,
504
+ clientEntry,
505
+ routeId: route.id,
506
+ params,
507
+ path: pathOf(req),
508
+ search: searchChainOf(layoutModules, mod, req),
509
+ hydrate: mod.hydrate !== false,
510
+ ...(layoutData !== undefined ? { layoutData } : {}),
511
+ ...(boundaryStates !== undefined ? { boundaries: boundaryStates } : {}),
512
+ ...preloadOf(route.id),
513
+ ...stylesOf(route.id),
514
+ prerenderedPaths: options.prerenderedPaths ?? [],
515
+ ...(mod.revalidate !== undefined ? { revalidate: mod.revalidate } : {}),
516
+ ...(mod.revalidateTags !== undefined ? { revalidateTags: mod.revalidateTags } : {}),
517
+ ...(mod.islandScripts !== undefined ? { islandScripts: mod.islandScripts } : {}),
518
+ ...(nonce === undefined ? {} : { nonce }),
519
+ ...titleOption,
520
+ ...(assemblyCache === undefined ? {} : { assemblyCache }),
521
+ })
522
+ } catch (err) {
523
+ if (isStatusSignal(err)) return renderStatusSignal(req, env, err)
524
+ if (isControlFlow(err)) throw err
525
+ reportLoaderError(route, req, { ...params }, err)
526
+ const errorId = boundaryFor(route, err)
527
+ if (errorId === undefined) throw err
528
+ return renderError(route, errorId, withDuplicateInstanceHint(err), nonce)
529
+ }
530
+ }
531
+
532
+ const renderNotFound = async (
533
+ request: Request,
534
+ env: Env,
535
+ path?: string,
536
+ ): Promise<PageResponse> => {
537
+ const page = manifest.statusPages?.["404"] ?? manifest.notFound
538
+ if (page === undefined) return renderTerminalStatus(404, path)
539
+ return renderTerminalStatus(
540
+ 404,
541
+ path,
542
+ undefined,
543
+ options.nonce === undefined ? undefined : await resolveNonce(request, env),
544
+ )
545
+ }
546
+
547
+ return {
548
+ get: (route) => {
549
+ const is404Fallback = options.staticFallbacks?.[route.pattern] === "404"
550
+ let assemblySlot: RenderAssemblyCache | undefined
551
+ let assemblySlotMod: unknown
552
+ let assemblySlotLayouts: LoadedLayoutModules | undefined
553
+ const assemblyCacheFor = (
554
+ mod: RouteModule,
555
+ layouts: LoadedLayoutModules,
556
+ ): RenderAssemblyCache | undefined => {
557
+ if (typeof mod.meta === "function") return undefined
558
+ for (const layout of layouts) if (typeof layout.meta === "function") return undefined
559
+ const layoutsUnchanged =
560
+ assemblySlotLayouts !== undefined &&
561
+ assemblySlotLayouts.length === layouts.length &&
562
+ assemblySlotLayouts.every((layout, index) => layout === layouts[index])
563
+ if (assemblySlotMod !== mod || !layoutsUnchanged) {
564
+ assemblySlot = {}
565
+ assemblySlotMod = mod
566
+ assemblySlotLayouts = layouts
567
+ }
568
+ return assemblySlot
569
+ }
570
+
571
+ return async (c: PageRouteContext<Env>) => {
572
+ if (is404Fallback && !prerenderedSet.has(urlPartsFor(c.req).pathname)) {
573
+ return renderNotFound(c.req, c.env, pathOf(c.req))
574
+ }
575
+ const mod = await route.load()
576
+ const draft = await draftFlag(c.req)
577
+ let data: unknown
578
+ let layoutData: readonly unknown[] | undefined
579
+ let boundaryStates: BoundaryStates | undefined
580
+ let layoutModules: LoadedLayoutModules | undefined
581
+ let layoutRetained: readonly number[] = []
582
+ try {
583
+ const ctx: LoaderContext = {
584
+ params: c.params,
585
+ request: c.req,
586
+ req: c.req,
587
+ api,
588
+ env: c.env,
589
+ draft,
590
+ search: loaderSearch(mod.searchSchema, c.req),
591
+ }
592
+ const run = await runLayoutChain(
593
+ route,
594
+ ctx,
595
+ validatedRetainedIndices(c.req, route, c.params),
596
+ )
597
+ layoutModules = run.modules
598
+ layoutRetained = run.retained
599
+ const boundaryDefinitions = [
600
+ ...run.modules.flatMap((layout) => layout.boundaries ?? []),
601
+ ...(mod.boundaries ?? []),
602
+ ]
603
+ if (boundaryDefinitions.length > 0) boundaryDescriptors(boundaryDefinitions)
604
+ const effectiveSearch = searchChainOf(run.modules, mod, c.req)
605
+ const boundaryBatch =
606
+ boundaryDefinitions.length === 0
607
+ ? undefined
608
+ : startDynamicBoundaries(boundaryDefinitions, {
609
+ request: c.req,
610
+ params: c.params,
611
+ api,
612
+ env: c.env,
613
+ draft,
614
+ search: effectiveSearch,
615
+ signal: c.req.signal,
616
+ } satisfies BoundaryRequestCtx)
617
+ const staticBoundaryPromise =
618
+ boundaryDefinitions.length === 0
619
+ ? Promise.resolve(undefined)
620
+ : resolveStaticBoundaries(
621
+ boundaryDefinitions,
622
+ { phase: "build", origin: originOf(c.req) },
623
+ options.staticBoundaryCache,
624
+ )
625
+ const [pageData, , staticBoundaries] = await Promise.all([
626
+ mod.loader ? mod.loader({ ...ctx, search: effectiveSearch }) : null,
627
+ run.pending,
628
+ staticBoundaryPromise,
629
+ ])
630
+ data = pageData
631
+ layoutData = run.layoutData
632
+ if (boundaryBatch !== undefined) {
633
+ const streamed = { ...boundaryBatch.initial, ...(staticBoundaries ?? {}) }
634
+ for (const entry of boundaryBatch.pending) {
635
+ const initial = streamed[entry.name]
636
+ if (initial !== undefined)
637
+ streamed[entry.name] = { ...initial, data: defer(entry.promise) }
638
+ }
639
+ boundaryStates = streamed
640
+ } else if (staticBoundaries !== undefined) {
641
+ boundaryStates = staticBoundaries
642
+ }
643
+ } catch (err) {
644
+ if (isStatusSignal(err)) return renderStatusSignal(c.req, c.env, err)
645
+ if (isControlFlow(err)) throw err
646
+ reportLoaderError(route, c.req, c.params, err)
647
+ const errorId = boundaryFor(route, err)
648
+ if (errorId === undefined) throw err
649
+ if (c.req.headers.get(DATA_HEADER) !== null) {
650
+ return new Response("Internal Server Error", {
651
+ status: 500,
652
+ headers: { "content-type": "text/plain; charset=utf-8" },
653
+ })
654
+ }
655
+ return renderError(
656
+ route,
657
+ errorId,
658
+ withDuplicateInstanceHint(err),
659
+ options.nonce === undefined ? undefined : await resolveNonce(c.req, c.env),
660
+ )
661
+ }
662
+ return handleDataOrDocument(
663
+ c.req,
664
+ c.env,
665
+ route,
666
+ c.params,
667
+ mod,
668
+ data,
669
+ layoutData,
670
+ layoutModules ?? (await loadLayoutModules(route)),
671
+ layoutRetained,
672
+ boundaryStates,
673
+ layoutModules === undefined ? undefined : assemblyCacheFor(mod, layoutModules),
674
+ )
675
+ }
676
+ },
677
+
678
+ post: (route) => async (c) => {
679
+ const mod = await route.load()
680
+ const draft = await draftFlag(c.req)
681
+ if (mod.action === undefined) {
682
+ return new Response("Method Not Allowed", {
683
+ status: 405,
684
+ headers: { allow: "GET", "content-type": "text/plain; charset=utf-8" },
685
+ })
686
+ }
687
+ const actionContext: LoaderContext = {
688
+ params: c.params,
689
+ request: c.req,
690
+ req: c.req,
691
+ api,
692
+ env: c.env,
693
+ draft,
694
+ search: loaderSearch(mod.searchSchema, c.req),
695
+ }
696
+ const isDataRequest = c.req.headers.get(DATA_HEADER) !== null
697
+ let layoutModules: LoadedLayoutModules
698
+ let result: unknown
699
+ try {
700
+ layoutModules = await runLayoutGates(route, actionContext)
701
+ result = await mod.action(actionContext)
702
+ } catch (err) {
703
+ if (isControlFlow(err)) return actionResponse(err, isDataRequest)
704
+ throw err
705
+ }
706
+ const isRevalidate =
707
+ result !== null && typeof result === "object" && "__nifraRevalidate" in result
708
+ const actionResult = isRevalidate ? (result as RevalidateResult<unknown>).data : result
709
+ const revalidateHeader: Record<string, string> = isRevalidate
710
+ ? { [REVALIDATE_HEADER]: (result as RevalidateResult<unknown>).__nifraRevalidate.join(",") }
711
+ : {}
712
+ if (isControlFlow(actionResult)) return actionResponse(actionResult, isDataRequest)
713
+ if (isDataRequest) {
714
+ const { forClient, deferred } = prepareDeferred(actionResult)
715
+ if (deferred.length === 0)
716
+ return Response.json(actionResult ?? null, { headers: revalidateHeader })
717
+ return new Response(ndjsonStream(forClient, deferred), {
718
+ headers: { "content-type": "application/x-ndjson; charset=utf-8", ...revalidateHeader },
719
+ })
720
+ }
721
+ const search = searchChainOf(layoutModules, mod, c.req)
722
+ const data = mod.loader
723
+ ? await mod.loader({
724
+ params: c.params,
725
+ request: c.req,
726
+ req: c.req,
727
+ api,
728
+ env: c.env,
729
+ draft,
730
+ search,
731
+ })
732
+ : null
733
+ const { chain, head } = resolveChainAndHead(layoutModules, mod, {
734
+ data,
735
+ params: c.params,
736
+ origin: originOf(c.req),
737
+ })
738
+ const nonce = options.nonce === undefined ? undefined : await resolveNonce(c.req, c.env)
739
+ return renderPageResult({
740
+ adapter,
741
+ chain,
742
+ data,
743
+ actionData: actionResult,
744
+ head,
745
+ clientEntry,
746
+ routeId: route.id,
747
+ params: c.params,
748
+ path: pathOf(c.req),
749
+ search,
750
+ hydrate: mod.hydrate !== false,
751
+ ...preloadOf(route.id),
752
+ ...stylesOf(route.id),
753
+ prerenderedPaths: options.prerenderedPaths ?? [],
754
+ ...(nonce === undefined ? {} : { nonce }),
755
+ ...titleOption,
756
+ })
757
+ },
758
+
759
+ notFound: renderNotFound,
760
+ }
761
+ }