@nifrajs/web 5.2.0 → 5.4.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/CHANGELOG.md +112 -0
- package/dist/build.d.ts +11 -2
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +265 -15
- package/dist/build.js.map +1 -1
- package/dist/csp.d.ts +2 -0
- package/dist/csp.d.ts.map +1 -1
- package/dist/csp.js +2 -0
- package/dist/csp.js.map +1 -1
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +23 -3
- package/dist/deferred.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/deferred-free.d.ts +3 -0
- package/dist/internal/deferred-free.d.ts.map +1 -0
- package/dist/internal/deferred-free.js +15 -0
- package/dist/internal/deferred-free.js.map +1 -0
- package/dist/internal/module-preparation.d.ts +7 -0
- package/dist/internal/module-preparation.d.ts.map +1 -0
- package/dist/internal/module-preparation.js +24 -0
- package/dist/internal/module-preparation.js.map +1 -0
- package/dist/internal/node-adapter.d.ts +19 -0
- package/dist/internal/node-adapter.d.ts.map +1 -0
- package/dist/internal/node-adapter.js +48 -0
- package/dist/internal/node-adapter.js.map +1 -0
- package/dist/internal/output-guard.d.ts +2 -0
- package/dist/internal/output-guard.d.ts.map +1 -1
- package/dist/internal/output-guard.js +51 -9
- package/dist/internal/output-guard.js.map +1 -1
- package/dist/internal/page-execution.d.ts +1 -0
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +53 -43
- package/dist/internal/page-execution.js.map +1 -1
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +3 -0
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/prerendered-pages.d.ts +42 -0
- package/dist/internal/prerendered-pages.d.ts.map +1 -0
- package/dist/internal/prerendered-pages.js +92 -0
- package/dist/internal/prerendered-pages.js.map +1 -0
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +93 -35
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/request-url.d.ts +4 -0
- package/dist/internal/request-url.d.ts.map +1 -1
- package/dist/internal/request-url.js +24 -0
- package/dist/internal/request-url.js.map +1 -1
- package/dist/internal/secret-scan.d.ts +4 -0
- package/dist/internal/secret-scan.d.ts.map +1 -1
- package/dist/internal/secret-scan.js +8 -1
- package/dist/internal/secret-scan.js.map +1 -1
- package/dist/internal/web-app.d.ts +11 -0
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +10 -1
- package/dist/internal/web-app.js.map +1 -1
- package/dist/isr.d.ts +1 -0
- package/dist/isr.d.ts.map +1 -1
- package/dist/isr.js +7 -4
- package/dist/isr.js.map +1 -1
- package/dist/prerender.d.ts +4 -0
- package/dist/prerender.d.ts.map +1 -1
- package/dist/prerender.js +9 -1
- package/dist/prerender.js.map +1 -1
- package/dist/public-dir.d.ts +19 -7
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +130 -99
- package/dist/public-dir.js.map +1 -1
- package/dist/render-seam.d.ts +5 -2
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js.map +1 -1
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +2 -0
- package/dist/search.js.map +1 -1
- package/dist/vite.d.ts +6 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +127 -54
- package/dist/vite.js.map +1 -1
- package/package.json +3 -3
- package/src/build.ts +330 -49
- package/src/csp.ts +4 -0
- package/src/deferred.ts +20 -3
- package/src/index.ts +3 -0
- package/src/internal/deferred-free.ts +15 -0
- package/src/internal/module-preparation.ts +30 -0
- package/src/internal/node-adapter.ts +57 -0
- package/src/internal/output-guard.ts +51 -9
- package/src/internal/page-execution.ts +55 -47
- package/src/internal/parity.ts +2 -0
- package/src/internal/prerendered-pages.ts +124 -0
- package/src/internal/render-document.ts +116 -52
- package/src/internal/request-url.ts +24 -0
- package/src/internal/secret-scan.ts +9 -1
- package/src/internal/web-app.ts +21 -1
- package/src/isr.ts +7 -4
- package/src/prerender.ts +13 -1
- package/src/public-dir.ts +158 -97
- package/src/render-seam.ts +5 -2
- package/src/search.ts +1 -0
- package/src/vite.ts +130 -54
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
interface ModuleEntry<T extends object> {
|
|
2
|
+
readonly load: () => Promise<T>
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/** Production imports are immutable; failed imports remain retryable. No loader data is cached. */
|
|
6
|
+
export function prepareModules(
|
|
7
|
+
enabled: boolean,
|
|
8
|
+
): <T extends object>(entry: ModuleEntry<T>) => T | Promise<T> {
|
|
9
|
+
const modules = new WeakMap<object, object | Promise<object>>()
|
|
10
|
+
return <T extends object>(entry: ModuleEntry<T>): T | Promise<T> => {
|
|
11
|
+
if (!enabled) return entry.load()
|
|
12
|
+
const known = modules.get(entry)
|
|
13
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Only this entry.load populates its slot, so the cached value retains that entry's module type.
|
|
14
|
+
if (known !== undefined) return known as T | Promise<T>
|
|
15
|
+
const pending = Promise.resolve()
|
|
16
|
+
.then(() => entry.load())
|
|
17
|
+
.then(
|
|
18
|
+
(module) => {
|
|
19
|
+
modules.set(entry, module)
|
|
20
|
+
return module
|
|
21
|
+
},
|
|
22
|
+
(error) => {
|
|
23
|
+
modules.delete(entry)
|
|
24
|
+
throw error
|
|
25
|
+
},
|
|
26
|
+
)
|
|
27
|
+
modules.set(entry, pending)
|
|
28
|
+
return pending
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { dirname, join, resolve } from "node:path"
|
|
3
|
+
|
|
4
|
+
/** A package as installed: its directory and the version its package.json declares. */
|
|
5
|
+
export interface InstalledPackage {
|
|
6
|
+
readonly dir: string
|
|
7
|
+
readonly version: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The `version` a package.json declares, or `undefined` when it declares none. */
|
|
11
|
+
function manifestVersion(path: string | URL): string | undefined {
|
|
12
|
+
const manifest: unknown = JSON.parse(readFileSync(path, "utf8"))
|
|
13
|
+
return typeof manifest === "object" &&
|
|
14
|
+
manifest !== null &&
|
|
15
|
+
"version" in manifest &&
|
|
16
|
+
typeof manifest.version === "string"
|
|
17
|
+
? manifest.version
|
|
18
|
+
: undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The copy of `name` a module in `fromDir` imports: the first `node_modules/<name>` walking up, as
|
|
23
|
+
* both bundlers resolve a bare specifier. Walked rather than `Bun.resolveSync`, which keeps its first
|
|
24
|
+
* answer for the life of the process.
|
|
25
|
+
*/
|
|
26
|
+
export function installedPackage(name: string, fromDir: string): InstalledPackage | undefined {
|
|
27
|
+
for (let dir = resolve(fromDir); ; dir = dirname(dir)) {
|
|
28
|
+
const manifest = join(dir, "node_modules", name, "package.json")
|
|
29
|
+
if (existsSync(manifest)) {
|
|
30
|
+
const version = manifestVersion(manifest)
|
|
31
|
+
return version === undefined ? undefined : { dir: dirname(manifest), version }
|
|
32
|
+
}
|
|
33
|
+
if (dirname(dir) === dir) return undefined
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Why a `--target node` build whose server entry is generated in `workDir` must stop before
|
|
39
|
+
* bundling, or `undefined`. The entry is written for the `@nifrajs/node` of this package's release and
|
|
40
|
+
* the bundle inlines whichever copy is installed: a 5.3 adapter built cleanly, then threw at startup
|
|
41
|
+
* on the static mounts.
|
|
42
|
+
*/
|
|
43
|
+
export function nodeAdapterRefusal(
|
|
44
|
+
workDir: string,
|
|
45
|
+
// src/internal/ and dist/internal/ both sit two levels below the package root.
|
|
46
|
+
webVersion = manifestVersion(new URL("../../package.json", import.meta.url)),
|
|
47
|
+
): string | undefined {
|
|
48
|
+
const adapter = installedPackage("@nifrajs/node", workDir)
|
|
49
|
+
if (adapter === undefined || webVersion === undefined) return undefined
|
|
50
|
+
if (Bun.semver.order(adapter.version, webVersion) >= 0) return undefined
|
|
51
|
+
return (
|
|
52
|
+
`[nifra/web] --target node would bundle @nifrajs/node ${adapter.version} (${adapter.dir}), older ` +
|
|
53
|
+
`than @nifrajs/web ${webVersion}. The server entry is generated for the adapter of the same ` +
|
|
54
|
+
"release, and an older one can fail when the server starts. Upgrade the nifra packages " +
|
|
55
|
+
`together: nifra upgrade ${webVersion}`
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* its own `validate` output, as zod and valibot objects do by default.
|
|
15
15
|
*/
|
|
16
16
|
import type { StandardResult, StandardSchemaV1 } from "@nifrajs/core/server"
|
|
17
|
+
import { markDeferredFree } from "./deferred-free.ts"
|
|
17
18
|
import { wireDate } from "./wire-date.ts"
|
|
18
19
|
|
|
19
20
|
// Registry symbols shared with core and `@nifrajs/schema`, so two copies of either still agree.
|
|
@@ -84,6 +85,8 @@ interface CompiledGuard {
|
|
|
84
85
|
readonly validate: StandardSchemaV1["~standard"]["validate"]
|
|
85
86
|
/** Dev only: name the keys the guard dropped, so a schema missing a field is not a silent blank. */
|
|
86
87
|
readonly warnDropped: boolean
|
|
88
|
+
/** No value this guard passes can hold a `defer()` marker (see internal/deferred-free.ts). */
|
|
89
|
+
readonly deferredFree: boolean
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
interface Deferred {
|
|
@@ -200,7 +203,10 @@ function shapeOf(
|
|
|
200
203
|
}
|
|
201
204
|
|
|
202
205
|
class Compiler {
|
|
206
|
+
/** Some node may pass a `defer()` marker on: a deferred node, or one that lets any value through. */
|
|
207
|
+
mayPassMarker = false
|
|
203
208
|
private readonly memo = new Map<Node, Project>()
|
|
209
|
+
private readonly issueFree = new WeakSet<Project>([LEAF, LEAF_ARRAY, DATE])
|
|
204
210
|
private readonly ids = new Map<string, Node>()
|
|
205
211
|
readonly sensitive: string[] = []
|
|
206
212
|
|
|
@@ -263,25 +269,33 @@ class Compiler {
|
|
|
263
269
|
case "array":
|
|
264
270
|
return this.array(node, where)
|
|
265
271
|
case "leaf":
|
|
272
|
+
// `not` (or an object `const`) without a `type` can accept an object.
|
|
273
|
+
if (node.type === undefined) this.mayPassMarker = true
|
|
266
274
|
return LEAF
|
|
267
275
|
default:
|
|
268
|
-
return
|
|
276
|
+
return this.unknown()
|
|
269
277
|
}
|
|
270
278
|
}
|
|
271
279
|
|
|
280
|
+
private unknown(): Project {
|
|
281
|
+
this.mayPassMarker = true
|
|
282
|
+
return UNKNOWN
|
|
283
|
+
}
|
|
284
|
+
|
|
272
285
|
private extra(schema: unknown, where: string): Project | undefined {
|
|
273
|
-
if (schema === true || (isNode(schema) && Object.keys(schema).length === 0))
|
|
286
|
+
if (schema === true || (isNode(schema) && Object.keys(schema).length === 0))
|
|
287
|
+
return this.unknown()
|
|
274
288
|
return isNode(schema) ? this.compile(schema, `${where}/*`) : undefined
|
|
275
289
|
}
|
|
276
290
|
|
|
277
291
|
private object(node: Node, where: string): Project {
|
|
278
|
-
const declared
|
|
292
|
+
const declared: Record<string, Project> = Object.create(null)
|
|
279
293
|
for (const [key, sub] of Object.entries(isNode(node.properties) ? node.properties : {})) {
|
|
280
294
|
if (!isNode(sub)) continue
|
|
281
295
|
if (isSensitiveFieldName(key) && typeof sub[DECLASSIFIED_KEY] !== "string") {
|
|
282
296
|
this.sensitive.push(`${where}/${key}`)
|
|
283
297
|
}
|
|
284
|
-
declared
|
|
298
|
+
declared[key] = this.compile(sub, `${where}/${key}`)
|
|
285
299
|
}
|
|
286
300
|
const patterns: Array<readonly [RegExp, Project]> = []
|
|
287
301
|
for (const [pattern, sub] of Object.entries(
|
|
@@ -302,7 +316,7 @@ class Compiler {
|
|
|
302
316
|
}
|
|
303
317
|
// A closed object drops a deferred marker's keys unless it declares them, so only an open one, or
|
|
304
318
|
// one declaring the marker key, could pass a marker on.
|
|
305
|
-
const checksMarker = open || declared.
|
|
319
|
+
const checksMarker = open || declared.__nifra_deferred !== undefined
|
|
306
320
|
const project: Project = (value, state) => {
|
|
307
321
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return value
|
|
308
322
|
if (checksMarker && isDeferred(value)) {
|
|
@@ -325,7 +339,7 @@ class Compiler {
|
|
|
325
339
|
for (const key in input) {
|
|
326
340
|
// An inherited enumerable key (a polluted prototype) is never data.
|
|
327
341
|
if (!Object.hasOwn(input, key)) continue
|
|
328
|
-
const projector = declared
|
|
342
|
+
const projector = declared[key] ?? (open ? undeclared(key) : undefined)
|
|
329
343
|
if (projector === undefined) {
|
|
330
344
|
out ??= copyBefore(input, key)
|
|
331
345
|
continue
|
|
@@ -346,6 +360,8 @@ class Compiler {
|
|
|
346
360
|
}
|
|
347
361
|
return out ?? value
|
|
348
362
|
}
|
|
363
|
+
if (!checksMarker && Object.values(declared).every((child) => this.issueFree.has(child)))
|
|
364
|
+
this.issueFree.add(project)
|
|
349
365
|
return project
|
|
350
366
|
}
|
|
351
367
|
|
|
@@ -356,13 +372,32 @@ class Compiler {
|
|
|
356
372
|
? node.items
|
|
357
373
|
: []
|
|
358
374
|
const head = prefixNodes.map((sub, i) =>
|
|
359
|
-
isNode(sub) ? this.compile(sub, `${where}/${i}`) :
|
|
375
|
+
isNode(sub) ? this.compile(sub, `${where}/${i}`) : this.unknown(),
|
|
360
376
|
)
|
|
361
377
|
const restSchema = Array.isArray(node.items) ? node.additionalItems : node.items
|
|
362
|
-
const rest = this.extra(restSchema, where) ??
|
|
378
|
+
const rest = this.extra(restSchema, where) ?? this.unknown()
|
|
363
379
|
// An array of scalars is the validator's alone: no key to drop, and walking it here would cost
|
|
364
380
|
// every `string[]` a loop. A `Date` inside one reaches the validator as it is.
|
|
365
381
|
if (rest === LEAF && head.every((projector) => projector === LEAF)) return LEAF_ARRAY
|
|
382
|
+
// A uniform subtree that cannot emit deferred-marker issues needs no per-item path bookkeeping.
|
|
383
|
+
if (head.length === 0 && this.issueFree.has(rest)) {
|
|
384
|
+
const project: Project = (value, state) => {
|
|
385
|
+
if (!Array.isArray(value)) return value
|
|
386
|
+
let out: unknown[] | undefined
|
|
387
|
+
for (let i = 0; i < value.length; i++) {
|
|
388
|
+
const before = value[i]
|
|
389
|
+
const after = rest(before, state)
|
|
390
|
+
if (out !== undefined) out[i] = after
|
|
391
|
+
else if (after !== before) {
|
|
392
|
+
out = value.slice(0, i)
|
|
393
|
+
out[i] = after
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return out ?? value
|
|
397
|
+
}
|
|
398
|
+
this.issueFree.add(project)
|
|
399
|
+
return project
|
|
400
|
+
}
|
|
366
401
|
return (value, state) => {
|
|
367
402
|
if (!Array.isArray(value)) return value
|
|
368
403
|
let out: unknown[] | undefined
|
|
@@ -424,7 +459,7 @@ class Compiler {
|
|
|
424
459
|
const branches: Node[] = []
|
|
425
460
|
this.branches(node, where, branches, new Set())
|
|
426
461
|
const kinds = branches.map(shapeOf)
|
|
427
|
-
if (kinds.includes("unknown")) return
|
|
462
|
+
if (kinds.includes("unknown")) return this.unknown()
|
|
428
463
|
const deferreds = branches.filter((_, i) => kinds[i] === "deferred")
|
|
429
464
|
if (deferreds.length > 1) {
|
|
430
465
|
throw new OutputGuardError(
|
|
@@ -463,6 +498,7 @@ class Compiler {
|
|
|
463
498
|
}
|
|
464
499
|
|
|
465
500
|
private deferred(node: Node, where: string): Project {
|
|
501
|
+
this.mayPassMarker = true
|
|
466
502
|
const validate = (node as { readonly [DEFERRED_VALIDATE]?: unknown })[DEFERRED_VALIDATE]
|
|
467
503
|
if (!isNode(node.inner) || typeof validate !== "function") {
|
|
468
504
|
throw new OutputGuardError(
|
|
@@ -473,6 +509,7 @@ class Compiler {
|
|
|
473
509
|
project: this.compile(node.inner, where),
|
|
474
510
|
validate: validate as CompiledGuard["validate"],
|
|
475
511
|
warnDropped: inDevelopment(),
|
|
512
|
+
deferredFree: false,
|
|
476
513
|
}
|
|
477
514
|
return (value, state) => {
|
|
478
515
|
if (!isDeferred(value)) return value
|
|
@@ -591,9 +628,11 @@ export function outputGuard(schema: unknown, owner: string, exportName: string):
|
|
|
591
628
|
const standard = schema as StandardSchemaV1
|
|
592
629
|
const json = jsonSchemaOf(standard)
|
|
593
630
|
let project: Project | undefined
|
|
631
|
+
let deferredFree = false
|
|
594
632
|
if (json !== undefined) {
|
|
595
633
|
const compiler = new Compiler(json)
|
|
596
634
|
project = compiler.compile(json, "")
|
|
635
|
+
deferredFree = !compiler.mayPassMarker
|
|
597
636
|
if (compiler.sensitive.length > 0) {
|
|
598
637
|
throw new OutputGuardError(
|
|
599
638
|
`${owner} declares sensitive field(s) ${compiler.sensitive.join(", ")} in ${exportName}. Remove them, or mark a field that must reach the browser with t.declassified("why it may", schema)`,
|
|
@@ -605,6 +644,7 @@ export function outputGuard(schema: unknown, owner: string, exportName: string):
|
|
|
605
644
|
project,
|
|
606
645
|
validate: standard["~standard"].validate,
|
|
607
646
|
warnDropped: inDevelopment(),
|
|
647
|
+
deferredFree,
|
|
608
648
|
}
|
|
609
649
|
compiled.set(schema, guard)
|
|
610
650
|
return guard
|
|
@@ -675,6 +715,8 @@ function settle(
|
|
|
675
715
|
// No JSON Schema to project by: the schema's own validate stripped what it does not declare.
|
|
676
716
|
if (guard.warnDropped) warnDropped(label, input, result.value)
|
|
677
717
|
}
|
|
718
|
+
// Only the projected value itself: a validator that returns a new value (a transform) may add a marker.
|
|
719
|
+
if (guard.deferredFree && result.value === input) markDeferredFree(result.value)
|
|
678
720
|
return result.value
|
|
679
721
|
}
|
|
680
722
|
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
} from "../router.ts"
|
|
47
47
|
import { searchOf, searchOfChain } from "../search.ts"
|
|
48
48
|
import { mergeHeads, resolveChainMeta } from "./head-merge.ts"
|
|
49
|
+
import { prepareModules } from "./module-preparation.ts"
|
|
49
50
|
import {
|
|
50
51
|
EMPTY_RETAIN,
|
|
51
52
|
type HeadersLike,
|
|
@@ -57,6 +58,7 @@ import {
|
|
|
57
58
|
notFound,
|
|
58
59
|
type RenderAssemblyCache,
|
|
59
60
|
type RenderedPage,
|
|
61
|
+
type RenderPageOptions,
|
|
60
62
|
type RevalidateResult,
|
|
61
63
|
redirect,
|
|
62
64
|
renderPageResult,
|
|
@@ -67,7 +69,7 @@ import {
|
|
|
67
69
|
tagLayoutError,
|
|
68
70
|
withDuplicateInstanceHint,
|
|
69
71
|
} from "./render-document.ts"
|
|
70
|
-
import { urlPartsFor } from "./request-url.ts"
|
|
72
|
+
import { originFor, urlPartsFor } from "./request-url.ts"
|
|
71
73
|
import {
|
|
72
74
|
ACTION_SCOPE,
|
|
73
75
|
type CoreResponseControls,
|
|
@@ -207,6 +209,7 @@ export interface PageExecutionOptions<Env = unknown> {
|
|
|
207
209
|
) => Promise<Response>
|
|
208
210
|
readonly adapter: RenderAdapter
|
|
209
211
|
readonly manifest: Manifest
|
|
212
|
+
readonly cacheRouteModules?: boolean
|
|
210
213
|
readonly clientEntry: string
|
|
211
214
|
readonly title?: string
|
|
212
215
|
readonly api?: unknown
|
|
@@ -296,6 +299,12 @@ type LayoutChain = {
|
|
|
296
299
|
*/
|
|
297
300
|
type LayoutChainSource = (page: RouteModule, count: number) => Promise<LayoutChain | undefined>
|
|
298
301
|
|
|
302
|
+
/** A document's render input, filled in field by field. */
|
|
303
|
+
type DocumentInput = { -readonly [K in keyof RenderPageOptions]: RenderPageOptions[K] } & {
|
|
304
|
+
readonly clientEntry: string
|
|
305
|
+
readonly hydrate: boolean
|
|
306
|
+
}
|
|
307
|
+
|
|
299
308
|
/**
|
|
300
309
|
* Own the page request program behind one private seam.
|
|
301
310
|
*
|
|
@@ -309,6 +318,11 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
309
318
|
options: PageExecutionOptions<Env>,
|
|
310
319
|
): PageRequestExecutor<Env> {
|
|
311
320
|
const { adapter, manifest, clientEntry, api } = options
|
|
321
|
+
const cacheModules =
|
|
322
|
+
options.cacheRouteModules ??
|
|
323
|
+
(typeof process !== "undefined" && process.env?.NODE_ENV === "production")
|
|
324
|
+
const loadModule = prepareModules(cacheModules)
|
|
325
|
+
const preparedLayouts = new WeakMap<RouteEntry, LoadedLayoutModules>()
|
|
312
326
|
// The action result is request-local; a browser cannot manufacture this handoff.
|
|
313
327
|
const progressiveActionData = new WeakMap<Request, unknown>()
|
|
314
328
|
// Bound once per render, so every loader, action and boundary in that render shares one view of the
|
|
@@ -326,6 +340,7 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
326
340
|
? { prerenderedPaths: options.prerenderedPaths ?? [] }
|
|
327
341
|
: { prerenderedListUrl: options.prerenderedListUrl }
|
|
328
342
|
const copyOption = options.copyChunks === undefined ? {} : { copyChunks: options.copyChunks }
|
|
343
|
+
const appDocumentOptions = { ...prerenderedHandover, ...copyOption, ...cspOption, ...titleOption }
|
|
329
344
|
const { i18n } = options
|
|
330
345
|
const pageLocales = new Map(i18n?.locales.locales.map((locale) => [locale.key, locale]))
|
|
331
346
|
/** The request's locale for a document, with the served locales the client names locales from. */
|
|
@@ -420,8 +435,10 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
420
435
|
// Load a route's layout modules once for the current execution. The render path needs each whole
|
|
421
436
|
// module for its component + meta, while the data path also needs loader/gate/boundary declarations.
|
|
422
437
|
const loadLayoutModules = async (route: RouteEntry): Promise<LoadedLayoutModules> => {
|
|
438
|
+
const known = cacheModules ? preparedLayouts.get(route) : undefined
|
|
439
|
+
if (known !== undefined) return known
|
|
423
440
|
const modules = await Promise.all(
|
|
424
|
-
route.layoutIds.map((id) => (manifest.layouts[id] as LayoutEntry)
|
|
441
|
+
route.layoutIds.map((id) => loadModule(manifest.layouts[id] as LayoutEntry)),
|
|
425
442
|
)
|
|
426
443
|
for (let i = 0; i < modules.length; i++) {
|
|
427
444
|
if ((modules[i] as { action?: unknown }).action === undefined) continue
|
|
@@ -430,6 +447,7 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
430
447
|
`[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.`,
|
|
431
448
|
)
|
|
432
449
|
}
|
|
450
|
+
if (cacheModules) preparedLayouts.set(route, modules)
|
|
433
451
|
return modules
|
|
434
452
|
}
|
|
435
453
|
|
|
@@ -616,7 +634,7 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
616
634
|
const dirTag = `${id.slice(0, id.length - "_middleware".length)}_layout`
|
|
617
635
|
let outcome: unknown
|
|
618
636
|
try {
|
|
619
|
-
const middleware = (await entry
|
|
637
|
+
const middleware = (await loadModule(entry)).default
|
|
620
638
|
// A `_layout.backend.ts` that exports no middleware is a layout data module, nothing more.
|
|
621
639
|
if (middleware === undefined) continue
|
|
622
640
|
if (typeof middleware !== "function") {
|
|
@@ -774,14 +792,6 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
774
792
|
return undefined
|
|
775
793
|
}
|
|
776
794
|
|
|
777
|
-
const originOf = (req: Request): string => {
|
|
778
|
-
try {
|
|
779
|
-
return new URL(req.url).origin
|
|
780
|
-
} catch {
|
|
781
|
-
return ""
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
|
|
785
795
|
const pathOf = (req: Request): string => {
|
|
786
796
|
try {
|
|
787
797
|
const parts = urlPartsFor(req)
|
|
@@ -794,8 +804,8 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
794
804
|
/** What a status or error page's `meta` receives: no route params, and its own data. */
|
|
795
805
|
const statusMetaArgs = (req: Request, data: unknown, nonce: string | undefined): MetaArgs =>
|
|
796
806
|
nonce === undefined
|
|
797
|
-
? { data, pageData: data, params: {}, origin:
|
|
798
|
-
: { data, pageData: data, params: {}, origin:
|
|
807
|
+
? { data, pageData: data, params: {}, origin: originFor(req) }
|
|
808
|
+
: { data, pageData: data, params: {}, origin: originFor(req), nonce }
|
|
799
809
|
|
|
800
810
|
const renderError = async (
|
|
801
811
|
req: Request,
|
|
@@ -1121,13 +1131,15 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1121
1131
|
data,
|
|
1122
1132
|
pageData: data,
|
|
1123
1133
|
params,
|
|
1124
|
-
origin:
|
|
1134
|
+
origin: originFor(req),
|
|
1125
1135
|
...(nonce === undefined ? {} : { nonce }),
|
|
1126
1136
|
},
|
|
1127
1137
|
serverLeafOf(route, mod),
|
|
1128
1138
|
)
|
|
1129
1139
|
try {
|
|
1130
|
-
|
|
1140
|
+
// Optional fields are assigned, not spread in: a conditional spread per field cost every
|
|
1141
|
+
// document request about 1us on Bun.
|
|
1142
|
+
const page: DocumentInput = {
|
|
1131
1143
|
adapter,
|
|
1132
1144
|
chain,
|
|
1133
1145
|
data,
|
|
@@ -1138,38 +1150,32 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1138
1150
|
path: pathOf(req),
|
|
1139
1151
|
search: searchChainOf(layoutModules, mod, req),
|
|
1140
1152
|
matchChain: matchChainOf(route.layoutIds, layoutModules, route.id, mod),
|
|
1141
|
-
...(serverComponents === undefined ? {} : { serverComponents }),
|
|
1142
|
-
...(progressiveActionData.has(req) ? { actionData: progressiveActionData.get(req) } : {}),
|
|
1143
1153
|
hydrate: mod.hydrate !== false,
|
|
1144
|
-
...(layoutData !== undefined ? { layoutData } : {}),
|
|
1145
|
-
...(boundaryStates !== undefined ? { boundaries: boundaryStates } : {}),
|
|
1146
1154
|
...preloadOf(route.id),
|
|
1147
1155
|
...stylesOf(route.id),
|
|
1148
|
-
...
|
|
1149
|
-
...copyOption,
|
|
1156
|
+
...appDocumentOptions,
|
|
1150
1157
|
...localeOption(req),
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
})
|
|
1158
|
+
}
|
|
1159
|
+
if (serverComponents !== undefined) page.serverComponents = serverComponents
|
|
1160
|
+
if (progressiveActionData.has(req)) page.actionData = progressiveActionData.get(req)
|
|
1161
|
+
if (layoutData !== undefined) page.layoutData = layoutData
|
|
1162
|
+
if (boundaryStates !== undefined) page.boundaries = boundaryStates
|
|
1163
|
+
if (responseHeaders !== undefined) page.headers = responseHeaders
|
|
1164
|
+
// A response that sets a cookie is one visitor's: it never advertises ISR freshness.
|
|
1165
|
+
if (!personalized && options.cacheChannel?.enabled === true) {
|
|
1166
|
+
if (mod.revalidate !== undefined) page.revalidate = mod.revalidate
|
|
1167
|
+
if (mod.revalidateTags !== undefined) {
|
|
1168
|
+
page.revalidateTags = routeTags(
|
|
1169
|
+
mod.revalidateTags,
|
|
1170
|
+
{ params, url: new URL(req.url) },
|
|
1171
|
+
route.id,
|
|
1172
|
+
)
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
if (mod.islandScripts !== undefined) page.islandScripts = mod.islandScripts
|
|
1176
|
+
if (nonce !== undefined) page.nonce = nonce
|
|
1177
|
+
if (assemblyCache !== undefined) page.assemblyCache = assemblyCache
|
|
1178
|
+
return await renderPageResult(page)
|
|
1173
1179
|
} catch (err) {
|
|
1174
1180
|
if (isStatusSignal(err)) return renderStatusSignal(req, env, err, personalized, settled)
|
|
1175
1181
|
if (isControlFlow(err)) throw err
|
|
@@ -1252,7 +1258,7 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1252
1258
|
data: null,
|
|
1253
1259
|
pageData: null,
|
|
1254
1260
|
params,
|
|
1255
|
-
origin:
|
|
1261
|
+
origin: originFor(req),
|
|
1256
1262
|
...(nonce === undefined ? {} : { nonce }),
|
|
1257
1263
|
})
|
|
1258
1264
|
let headers: Headers | undefined
|
|
@@ -1410,7 +1416,8 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1410
1416
|
if (!componentOwner && is404Fallback && !prerenderedSet.has(urlPartsFor(c.req).pathname)) {
|
|
1411
1417
|
return renderNotFound(c, controls)
|
|
1412
1418
|
}
|
|
1413
|
-
const
|
|
1419
|
+
const loaded = loadModule(route)
|
|
1420
|
+
const mod = cacheModules && !(loaded instanceof Promise) ? loaded : await loaded
|
|
1414
1421
|
const draft = await draftFlag(c.req)
|
|
1415
1422
|
// Unpublished content is the editor's alone: whatever cache-control a loader sets, no cache keeps it.
|
|
1416
1423
|
if (draft) controls.personalize()
|
|
@@ -1592,7 +1599,8 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1592
1599
|
|
|
1593
1600
|
post: (route) =>
|
|
1594
1601
|
withResponseControls(async (c, controls) => {
|
|
1595
|
-
const
|
|
1602
|
+
const loaded = loadModule(route)
|
|
1603
|
+
const mod = cacheModules && !(loaded instanceof Promise) ? loaded : await loaded
|
|
1596
1604
|
const draft = await draftFlag(c.req)
|
|
1597
1605
|
if (draft) controls.personalize()
|
|
1598
1606
|
if (mod.action === undefined) {
|
|
@@ -1810,7 +1818,7 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1810
1818
|
data,
|
|
1811
1819
|
pageData: data,
|
|
1812
1820
|
params: c.params,
|
|
1813
|
-
origin:
|
|
1821
|
+
origin: originFor(c.req),
|
|
1814
1822
|
...(nonce === undefined ? {} : { nonce }),
|
|
1815
1823
|
},
|
|
1816
1824
|
serverLeafOf(route, mod),
|
package/src/internal/parity.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
SINGLE_COPY_REGISTER_SPECIFIER,
|
|
17
17
|
type SingleCopyRegistration,
|
|
18
18
|
} from "@nifrajs/core/single-copy"
|
|
19
|
+
import { isHiddenPath } from "@nifrajs/core/static-file"
|
|
19
20
|
import { discoverRoutes } from "../fs.ts"
|
|
20
21
|
import { codeUnitOrder } from "./code-unit-order.ts"
|
|
21
22
|
import { withoutComments } from "./html-spans.ts"
|
|
@@ -1077,6 +1078,7 @@ const publicFilesUnder = (publicDir: string | false | undefined): readonly strin
|
|
|
1077
1078
|
const files: string[] = []
|
|
1078
1079
|
const walk = (current: string): void => {
|
|
1079
1080
|
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
1081
|
+
if (isHiddenPath(entry.name)) continue // the build skips it too
|
|
1080
1082
|
const path = join(current, entry.name)
|
|
1081
1083
|
if (entry.isDirectory()) walk(path)
|
|
1082
1084
|
else if (entry.isFile()) files.push(publicUrlPath(relative(root, path)))
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { pathnameOf } from "@nifrajs/core/server"
|
|
2
|
+
import { isDraftEnabled } from "../draft.ts"
|
|
3
|
+
import { DATA_HEADER } from "../router.ts"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pages a self-hosted build prerendered, which `createWebApp` answers from their file instead of
|
|
7
|
+
* rendering them. The file is served from inside the app, after `use` middleware, so a gate in front of
|
|
8
|
+
* the pages still runs.
|
|
9
|
+
*/
|
|
10
|
+
export interface PrerenderedPages {
|
|
11
|
+
/** Each servable path, spelled as a request URL spells it, mapped to the document headers its
|
|
12
|
+
* build-time render answered with (a prerender entry's `serveHeaders`). */
|
|
13
|
+
readonly pages: Readonly<Record<string, Readonly<Record<string, string>>>>
|
|
14
|
+
/** The page's HTML, or `undefined` to render it on demand instead. */
|
|
15
|
+
read(pathname: string): Promise<string | undefined>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The document headers a render produces from the route alone, so the build-time value is the value
|
|
19
|
+
// every request would get. Loader headers are left out: a prerendered page is a static file.
|
|
20
|
+
const REPLAYED = [
|
|
21
|
+
"content-language",
|
|
22
|
+
"content-security-policy",
|
|
23
|
+
"content-security-policy-report-only",
|
|
24
|
+
]
|
|
25
|
+
// Vary values a file still answers correctly: compression is negotiated by the host, and a data request
|
|
26
|
+
// never reaches the file.
|
|
27
|
+
const STATIC_VARY = new Set(["accept-encoding", DATA_HEADER])
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The headers a self-hosted server replays with a prerendered page, or `undefined` when its build-time
|
|
31
|
+
* render was specific to that request and the page must keep rendering on demand: it set a cookie, was
|
|
32
|
+
* marked private or no-store (a nonce page always is), or varied on a request header.
|
|
33
|
+
*/
|
|
34
|
+
export function prerenderedDocumentHeaders(
|
|
35
|
+
status: number,
|
|
36
|
+
headers: Headers,
|
|
37
|
+
): Record<string, string> | undefined {
|
|
38
|
+
if (status !== 200 || headers.has("set-cookie")) return undefined
|
|
39
|
+
const cacheControl = (headers.get("cache-control") ?? "").toLowerCase()
|
|
40
|
+
if (/\b(?:private|no-store)\b/.test(cacheControl)) return undefined
|
|
41
|
+
for (const name of (headers.get("vary") ?? "").split(",")) {
|
|
42
|
+
const trimmed = name.trim().toLowerCase()
|
|
43
|
+
if (trimmed !== "" && !STATIC_VARY.has(trimmed)) return undefined
|
|
44
|
+
}
|
|
45
|
+
const replayed: Record<string, string> = {}
|
|
46
|
+
for (const name of REPLAYED) {
|
|
47
|
+
const value = headers.get(name)
|
|
48
|
+
if (value !== null) replayed[name] = value
|
|
49
|
+
}
|
|
50
|
+
return replayed
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One page a self-hosting build prerendered: its file and the headers it is served with. */
|
|
54
|
+
export interface ServerPrerenderedPage {
|
|
55
|
+
readonly file: string
|
|
56
|
+
readonly headers: Readonly<Record<string, string>>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The prerendered pages a server may answer from their file, keyed by the pathname of the request that
|
|
61
|
+
* rendered them. Left out: a render specific to its request, and two paths written to one file (`/x`
|
|
62
|
+
* and `//x`, or `/A` and `/a` on a case-insensitive disk), where only the last render is on disk.
|
|
63
|
+
*/
|
|
64
|
+
export function servablePrerenderedPages(
|
|
65
|
+
entries: readonly {
|
|
66
|
+
readonly path: string
|
|
67
|
+
readonly file: string
|
|
68
|
+
readonly serveHeaders?: Readonly<Record<string, string>>
|
|
69
|
+
}[],
|
|
70
|
+
): Record<string, ServerPrerenderedPage> {
|
|
71
|
+
const writes = new Map<string, number>()
|
|
72
|
+
for (const { file } of entries) {
|
|
73
|
+
writes.set(file.toLowerCase(), (writes.get(file.toLowerCase()) ?? 0) + 1)
|
|
74
|
+
}
|
|
75
|
+
const pages: Record<string, ServerPrerenderedPage> = {}
|
|
76
|
+
for (const { path, file, serveHeaders } of entries) {
|
|
77
|
+
if (serveHeaders === undefined || writes.get(file.toLowerCase()) !== 1) continue
|
|
78
|
+
// Concatenated as prerenderRoutes requested it: `new URL("//x", base)` would read a host.
|
|
79
|
+
pages[new URL(`http://localhost${path}`).pathname] = { file, headers: serveHeaders }
|
|
80
|
+
}
|
|
81
|
+
return pages
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Wrap a page route's GET handler so a plain document request for a prerendered path is answered from
|
|
86
|
+
* its file. Anything a file cannot answer the way a render would renders on demand: a query string, a
|
|
87
|
+
* navigation data or component request, a draft-mode visitor, or a file the reader cannot return.
|
|
88
|
+
*/
|
|
89
|
+
export function servePrerenderedPages(
|
|
90
|
+
prerendered: PrerenderedPages,
|
|
91
|
+
draftSecret: string | undefined,
|
|
92
|
+
): <C extends { readonly req: Request }, R>(
|
|
93
|
+
render: (c: C) => Promise<R>,
|
|
94
|
+
) => (c: C) => Promise<R | Response> {
|
|
95
|
+
const pages = new Map(Object.entries(prerendered.pages))
|
|
96
|
+
return (render) => async (c) => {
|
|
97
|
+
const req = c.req
|
|
98
|
+
const pathname = pathnameOf(req.url)
|
|
99
|
+
const headers = pages.get(pathname)
|
|
100
|
+
if (
|
|
101
|
+
headers === undefined ||
|
|
102
|
+
req.url.includes("?") ||
|
|
103
|
+
req.headers.has(DATA_HEADER) ||
|
|
104
|
+
req.headers.has("x-nifra-component") ||
|
|
105
|
+
(draftSecret !== undefined && (await isDraftEnabled(req, draftSecret)))
|
|
106
|
+
) {
|
|
107
|
+
return render(c)
|
|
108
|
+
}
|
|
109
|
+
const html = await prerendered.read(pathname)
|
|
110
|
+
if (html === undefined) return render(c)
|
|
111
|
+
if (req.method !== "HEAD") {
|
|
112
|
+
return new Response(html, {
|
|
113
|
+
headers: { ...headers, "content-type": "text/html; charset=utf-8" },
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
return new Response(null, {
|
|
117
|
+
headers: {
|
|
118
|
+
...headers,
|
|
119
|
+
"content-type": "text/html; charset=utf-8",
|
|
120
|
+
"content-length": String(new TextEncoder().encode(html).byteLength),
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
}
|