@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.
- 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
|
@@ -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
|
+
}
|