@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.
Files changed (102) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/dist/build.d.ts +11 -2
  3. package/dist/build.d.ts.map +1 -1
  4. package/dist/build.js +265 -15
  5. package/dist/build.js.map +1 -1
  6. package/dist/csp.d.ts +2 -0
  7. package/dist/csp.d.ts.map +1 -1
  8. package/dist/csp.js +2 -0
  9. package/dist/csp.js.map +1 -1
  10. package/dist/deferred.d.ts.map +1 -1
  11. package/dist/deferred.js +23 -3
  12. package/dist/deferred.js.map +1 -1
  13. package/dist/index.d.ts +2 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal/deferred-free.d.ts +3 -0
  18. package/dist/internal/deferred-free.d.ts.map +1 -0
  19. package/dist/internal/deferred-free.js +15 -0
  20. package/dist/internal/deferred-free.js.map +1 -0
  21. package/dist/internal/module-preparation.d.ts +7 -0
  22. package/dist/internal/module-preparation.d.ts.map +1 -0
  23. package/dist/internal/module-preparation.js +24 -0
  24. package/dist/internal/module-preparation.js.map +1 -0
  25. package/dist/internal/node-adapter.d.ts +19 -0
  26. package/dist/internal/node-adapter.d.ts.map +1 -0
  27. package/dist/internal/node-adapter.js +48 -0
  28. package/dist/internal/node-adapter.js.map +1 -0
  29. package/dist/internal/output-guard.d.ts +2 -0
  30. package/dist/internal/output-guard.d.ts.map +1 -1
  31. package/dist/internal/output-guard.js +51 -9
  32. package/dist/internal/output-guard.js.map +1 -1
  33. package/dist/internal/page-execution.d.ts +1 -0
  34. package/dist/internal/page-execution.d.ts.map +1 -1
  35. package/dist/internal/page-execution.js +53 -43
  36. package/dist/internal/page-execution.js.map +1 -1
  37. package/dist/internal/parity.d.ts.map +1 -1
  38. package/dist/internal/parity.js +3 -0
  39. package/dist/internal/parity.js.map +1 -1
  40. package/dist/internal/prerendered-pages.d.ts +42 -0
  41. package/dist/internal/prerendered-pages.d.ts.map +1 -0
  42. package/dist/internal/prerendered-pages.js +92 -0
  43. package/dist/internal/prerendered-pages.js.map +1 -0
  44. package/dist/internal/render-document.d.ts.map +1 -1
  45. package/dist/internal/render-document.js +93 -35
  46. package/dist/internal/render-document.js.map +1 -1
  47. package/dist/internal/request-url.d.ts +4 -0
  48. package/dist/internal/request-url.d.ts.map +1 -1
  49. package/dist/internal/request-url.js +24 -0
  50. package/dist/internal/request-url.js.map +1 -1
  51. package/dist/internal/secret-scan.d.ts +4 -0
  52. package/dist/internal/secret-scan.d.ts.map +1 -1
  53. package/dist/internal/secret-scan.js +8 -1
  54. package/dist/internal/secret-scan.js.map +1 -1
  55. package/dist/internal/web-app.d.ts +11 -0
  56. package/dist/internal/web-app.d.ts.map +1 -1
  57. package/dist/internal/web-app.js +10 -1
  58. package/dist/internal/web-app.js.map +1 -1
  59. package/dist/isr.d.ts +1 -0
  60. package/dist/isr.d.ts.map +1 -1
  61. package/dist/isr.js +7 -4
  62. package/dist/isr.js.map +1 -1
  63. package/dist/prerender.d.ts +4 -0
  64. package/dist/prerender.d.ts.map +1 -1
  65. package/dist/prerender.js +9 -1
  66. package/dist/prerender.js.map +1 -1
  67. package/dist/public-dir.d.ts +19 -7
  68. package/dist/public-dir.d.ts.map +1 -1
  69. package/dist/public-dir.js +130 -99
  70. package/dist/public-dir.js.map +1 -1
  71. package/dist/render-seam.d.ts +5 -2
  72. package/dist/render-seam.d.ts.map +1 -1
  73. package/dist/render-seam.js.map +1 -1
  74. package/dist/search.d.ts.map +1 -1
  75. package/dist/search.js +2 -0
  76. package/dist/search.js.map +1 -1
  77. package/dist/vite.d.ts +6 -0
  78. package/dist/vite.d.ts.map +1 -1
  79. package/dist/vite.js +127 -54
  80. package/dist/vite.js.map +1 -1
  81. package/package.json +3 -3
  82. package/src/build.ts +330 -49
  83. package/src/csp.ts +4 -0
  84. package/src/deferred.ts +20 -3
  85. package/src/index.ts +3 -0
  86. package/src/internal/deferred-free.ts +15 -0
  87. package/src/internal/module-preparation.ts +30 -0
  88. package/src/internal/node-adapter.ts +57 -0
  89. package/src/internal/output-guard.ts +51 -9
  90. package/src/internal/page-execution.ts +55 -47
  91. package/src/internal/parity.ts +2 -0
  92. package/src/internal/prerendered-pages.ts +124 -0
  93. package/src/internal/render-document.ts +116 -52
  94. package/src/internal/request-url.ts +24 -0
  95. package/src/internal/secret-scan.ts +9 -1
  96. package/src/internal/web-app.ts +21 -1
  97. package/src/isr.ts +7 -4
  98. package/src/prerender.ts +13 -1
  99. package/src/public-dir.ts +158 -97
  100. package/src/render-seam.ts +5 -2
  101. package/src/search.ts +1 -0
  102. 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 UNKNOWN
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)) return UNKNOWN
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 = new Map<string, Project>()
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.set(key, this.compile(sub, `${where}/${key}`))
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.has("__nifra_deferred")
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.get(key) ?? (open ? undeclared(key) : undefined)
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}`) : UNKNOWN,
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) ?? UNKNOWN
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 UNKNOWN
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).load()),
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.load()).default
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: originOf(req) }
798
- : { data, pageData: data, params: {}, origin: originOf(req), nonce }
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: originOf(req),
1134
+ origin: originFor(req),
1125
1135
  ...(nonce === undefined ? {} : { nonce }),
1126
1136
  },
1127
1137
  serverLeafOf(route, mod),
1128
1138
  )
1129
1139
  try {
1130
- return await renderPageResult({
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
- ...prerenderedHandover,
1149
- ...copyOption,
1156
+ ...appDocumentOptions,
1150
1157
  ...localeOption(req),
1151
- ...(responseHeaders === undefined ? {} : { headers: responseHeaders }),
1152
- // A response that sets a cookie is one visitor's: it never advertises ISR freshness.
1153
- ...(mod.revalidate !== undefined && !personalized && options.cacheChannel?.enabled === true
1154
- ? { revalidate: mod.revalidate }
1155
- : {}),
1156
- ...(mod.revalidateTags !== undefined &&
1157
- !personalized &&
1158
- options.cacheChannel?.enabled === true
1159
- ? {
1160
- revalidateTags: routeTags(
1161
- mod.revalidateTags,
1162
- { params, url: new URL(req.url) },
1163
- route.id,
1164
- ),
1165
- }
1166
- : {}),
1167
- ...(mod.islandScripts !== undefined ? { islandScripts: mod.islandScripts } : {}),
1168
- ...(nonce === undefined ? {} : { nonce }),
1169
- ...cspOption,
1170
- ...titleOption,
1171
- ...(assemblyCache === undefined ? {} : { assemblyCache }),
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: originOf(req),
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 mod = await route.load()
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 mod = await route.load()
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: originOf(c.req),
1821
+ origin: originFor(c.req),
1814
1822
  ...(nonce === undefined ? {} : { nonce }),
1815
1823
  },
1816
1824
  serverLeafOf(route, mod),
@@ -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
+ }