@nifrajs/web 5.4.0 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/build-plan.d.ts +6 -0
  3. package/dist/build-plan.d.ts.map +1 -1
  4. package/dist/build-plan.js +25 -0
  5. package/dist/build-plan.js.map +1 -1
  6. package/dist/build-vite.d.ts +2 -0
  7. package/dist/build-vite.d.ts.map +1 -1
  8. package/dist/build-vite.js +2 -0
  9. package/dist/build-vite.js.map +1 -1
  10. package/dist/build.d.ts +17 -3
  11. package/dist/build.d.ts.map +1 -1
  12. package/dist/build.js +81 -40
  13. package/dist/build.js.map +1 -1
  14. package/dist/deferred.d.ts.map +1 -1
  15. package/dist/deferred.js +20 -6
  16. package/dist/deferred.js.map +1 -1
  17. package/dist/dev.d.ts +5 -0
  18. package/dist/dev.d.ts.map +1 -1
  19. package/dist/dev.js +7 -6
  20. package/dist/dev.js.map +1 -1
  21. package/dist/diagnostic.d.ts +6 -0
  22. package/dist/diagnostic.d.ts.map +1 -1
  23. package/dist/diagnostic.js +41 -0
  24. package/dist/diagnostic.js.map +1 -1
  25. package/dist/host-policy.d.ts +72 -0
  26. package/dist/host-policy.d.ts.map +1 -0
  27. package/dist/host-policy.js +149 -0
  28. package/dist/host-policy.js.map +1 -0
  29. package/dist/host-reach.d.ts +49 -0
  30. package/dist/host-reach.d.ts.map +1 -0
  31. package/dist/host-reach.js +144 -0
  32. package/dist/host-reach.js.map +1 -0
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2 -1
  36. package/dist/index.js.map +1 -1
  37. package/dist/internal/certified-components.d.ts +8 -0
  38. package/dist/internal/certified-components.d.ts.map +1 -0
  39. package/dist/internal/certified-components.js +8 -0
  40. package/dist/internal/certified-components.js.map +1 -0
  41. package/dist/internal/codegen.d.ts +8 -0
  42. package/dist/internal/codegen.d.ts.map +1 -1
  43. package/dist/internal/codegen.js +15 -4
  44. package/dist/internal/codegen.js.map +1 -1
  45. package/dist/internal/output-guard.d.ts +6 -0
  46. package/dist/internal/output-guard.d.ts.map +1 -1
  47. package/dist/internal/output-guard.js +118 -16
  48. package/dist/internal/output-guard.js.map +1 -1
  49. package/dist/internal/page-execution.d.ts +2 -0
  50. package/dist/internal/page-execution.d.ts.map +1 -1
  51. package/dist/internal/page-execution.js +16 -11
  52. package/dist/internal/page-execution.js.map +1 -1
  53. package/dist/internal/render-document.d.ts +3 -1
  54. package/dist/internal/render-document.d.ts.map +1 -1
  55. package/dist/internal/render-document.js +10 -4
  56. package/dist/internal/render-document.js.map +1 -1
  57. package/dist/internal/web-app.d.ts +15 -1
  58. package/dist/internal/web-app.d.ts.map +1 -1
  59. package/dist/internal/web-app.js +8 -0
  60. package/dist/internal/web-app.js.map +1 -1
  61. package/dist/public-dir.d.ts +4 -3
  62. package/dist/public-dir.d.ts.map +1 -1
  63. package/dist/public-dir.js +24 -18
  64. package/dist/public-dir.js.map +1 -1
  65. package/dist/router.d.ts +2 -1
  66. package/dist/router.d.ts.map +1 -1
  67. package/dist/router.js +9 -8
  68. package/dist/router.js.map +1 -1
  69. package/dist/server-components/vite.d.ts.map +1 -1
  70. package/dist/server-components/vite.js +8 -6
  71. package/dist/server-components/vite.js.map +1 -1
  72. package/dist/service-worker.d.ts +3 -1
  73. package/dist/service-worker.d.ts.map +1 -1
  74. package/dist/service-worker.js +29 -8
  75. package/dist/service-worker.js.map +1 -1
  76. package/dist/zones.d.ts +5 -1
  77. package/dist/zones.d.ts.map +1 -1
  78. package/dist/zones.js +17 -4
  79. package/dist/zones.js.map +1 -1
  80. package/package.json +4 -4
  81. package/src/build-plan.ts +26 -0
  82. package/src/build-vite.ts +4 -0
  83. package/src/build.ts +114 -44
  84. package/src/deferred.ts +20 -6
  85. package/src/dev.ts +7 -6
  86. package/src/diagnostic.ts +43 -0
  87. package/src/host-policy.ts +219 -0
  88. package/src/host-reach.ts +177 -0
  89. package/src/index.ts +10 -2
  90. package/src/internal/certified-components.ts +7 -0
  91. package/src/internal/codegen.ts +24 -5
  92. package/src/internal/output-guard.ts +166 -15
  93. package/src/internal/page-execution.ts +23 -9
  94. package/src/internal/render-document.ts +11 -4
  95. package/src/internal/web-app.ts +27 -1
  96. package/src/public-dir.ts +31 -24
  97. package/src/router.ts +9 -8
  98. package/src/server-components/vite.ts +11 -6
  99. package/src/service-worker.ts +32 -9
  100. package/src/zones.ts +27 -5
@@ -12,6 +12,12 @@
12
12
  * Projection reads the schema's JSON Schema: a nifra schema's `jsonSchema`, or a Standard JSON Schema
13
13
  * (`~standard.jsonSchema.output`). A schema that exposes neither is trusted to strip unknown keys in
14
14
  * its own `validate` output, as zod and valibot objects do by default.
15
+ *
16
+ * Validation then runs on the projected value. A `@nifrajs/schema` schema validates it without the
17
+ * undeclared-key check of each closed object projection reached, which projection already did. A
18
+ * non-enumerable property is no key to either: JSON and `structuredClone` skip it and server
19
+ * component props refuse it, so it never reaches the browser. The one such property `JSON.stringify`
20
+ * does read, an own `toJSON` (or an array subclass's), never survives projection.
15
21
  */
16
22
  import type { StandardResult, StandardSchemaV1 } from "@nifrajs/core/server"
17
23
  import { markDeferredFree } from "./deferred-free.ts"
@@ -22,6 +28,8 @@ const RESPONSE_RESULT = Symbol.for("nifra.response.result")
22
28
  const STATUS_SIGNAL = Symbol.for("nifra.web.status-signal")
23
29
  /** Set by `t.deferred(inner)` on its schema node: `inner`'s own validate. */
24
30
  const DEFERRED_VALIDATE = Symbol.for("nifra.schema.deferredValidate")
31
+ /** On a `@nifrajs/schema` validate: its variant for a value projected by its own JSON Schema. */
32
+ const VALIDATE_PROJECTED = Symbol.for("nifra.schema.validateProjected")
25
33
 
26
34
  export const DECLASSIFIED_KEY = "x-nifra-declassified"
27
35
  const DEFERRED_KEY = "x-nifra-deferred"
@@ -168,7 +176,7 @@ const DATE: Project = (value) =>
168
176
  : value
169
177
 
170
178
  /** An array whose items are all scalars, passed through whole. */
171
- const LEAF_ARRAY: Project = (value) => value
179
+ const LEAF_ARRAY: Project = (value) => (Array.isArray(value) ? plainArray(value) : value)
172
180
 
173
181
  const typesOf = (node: Node): readonly unknown[] | undefined =>
174
182
  Array.isArray(node.type) ? node.type : node.type === undefined ? undefined : [node.type]
@@ -317,7 +325,7 @@ class Compiler {
317
325
  // A closed object drops a deferred marker's keys unless it declares them, so only an open one, or
318
326
  // one declaring the marker key, could pass a marker on.
319
327
  const checksMarker = open || declared.__nifra_deferred !== undefined
320
- const project: Project = (value, state) => {
328
+ const closure: Project = (value, state) => {
321
329
  if (typeof value !== "object" || value === null || Array.isArray(value)) return value
322
330
  if (checksMarker && isDeferred(value)) {
323
331
  state.issues.push("")
@@ -325,13 +333,7 @@ class Compiler {
325
333
  }
326
334
  const proto = Object.getPrototypeOf(value)
327
335
  if (proto !== Object.prototype && proto !== null) {
328
- if (value instanceof Date) return value.toJSON()
329
- // A class instance: its own fields as a plain object, so no prototype `toJSON` or getter
330
- // decides what is serialized.
331
- const snapshot: Record<string, unknown> = {}
332
- for (const key of Object.keys(value))
333
- assign(snapshot, key, (value as Record<string, unknown>)[key])
334
- return project(snapshot, state)
336
+ return value instanceof Date ? value.toJSON() : closure(snapshotOf(value), state)
335
337
  }
336
338
  const input = value as Record<string, unknown>
337
339
  // Copy-on-write: a value that already holds only declared keys is returned as it is.
@@ -345,7 +347,11 @@ class Compiler {
345
347
  continue
346
348
  }
347
349
  const before = input[key]
348
- if (projector === LEAF_ARRAY || (projector === LEAF && !(before instanceof Date))) {
350
+ if (
351
+ projector === LEAF_ARRAY
352
+ ? !hasJsonHook(before)
353
+ : projector === LEAF && !(before instanceof Date)
354
+ ) {
349
355
  if (out !== undefined) assign(out, key, before)
350
356
  continue
351
357
  }
@@ -358,8 +364,10 @@ class Compiler {
358
364
  assign(out, key, after)
359
365
  }
360
366
  }
361
- return out ?? value
367
+ return out ?? (input.toJSON === undefined ? value : snapshotOf(input))
362
368
  }
369
+ const project =
370
+ generatedObject(declared, open ? undeclared : undefined, checksMarker) ?? closure
363
371
  if (!checksMarker && Object.values(declared).every((child) => this.issueFree.has(child)))
364
372
  this.issueFree.add(project)
365
373
  return project
@@ -393,7 +401,7 @@ class Compiler {
393
401
  out[i] = after
394
402
  }
395
403
  }
396
- return out ?? value
404
+ return plainArray(out ?? value)
397
405
  }
398
406
  this.issueFree.add(project)
399
407
  return project
@@ -417,7 +425,7 @@ class Compiler {
417
425
  out[i] = after
418
426
  }
419
427
  }
420
- return out ?? value
428
+ return plainArray(out ?? value)
421
429
  }
422
430
  }
423
431
 
@@ -507,7 +515,7 @@ class Compiler {
507
515
  }
508
516
  const inner: CompiledGuard = {
509
517
  project: this.compile(node.inner, where),
510
- validate: validate as CompiledGuard["validate"],
518
+ validate: validatorFor(validate as CompiledGuard["validate"], node.inner),
511
519
  warnDropped: inDevelopment(),
512
520
  deferredFree: false,
513
521
  }
@@ -522,6 +530,133 @@ class Compiler {
522
530
  }
523
531
  }
524
532
 
533
+ /**
534
+ * A class instance as a plain object of its own fields, so no prototype `toJSON` or getter decides
535
+ * what is serialized.
536
+ */
537
+ function snapshotOf(value: object): Record<string, unknown> {
538
+ const snapshot: Record<string, unknown> = {}
539
+ for (const key of Object.keys(value))
540
+ assign(snapshot, key, (value as Record<string, unknown>)[key])
541
+ return snapshot
542
+ }
543
+
544
+ /** Whether `JSON.stringify` would call a `toJSON` of `value`'s own or of its prototype chain. */
545
+ const hasJsonHook = (value: unknown): boolean =>
546
+ typeof value === "object" &&
547
+ value !== null &&
548
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: reads one optional property of a value already narrowed to a non-null object.
549
+ (value as { readonly toJSON?: unknown }).toJSON !== undefined
550
+
551
+ /**
552
+ * `value`, or a plain copy of its items when `JSON.stringify` would serialize it by a hook instead:
553
+ * an own `toJSON`, enumerable or not, or one on a subclass prototype (which `slice` also keeps).
554
+ */
555
+ function plainArray(value: unknown[]): unknown[] {
556
+ if (!hasJsonHook(value)) return value
557
+ const copy = new Array<unknown>(value.length)
558
+ for (let i = 0; i < value.length; i++) copy[i] = value[i]
559
+ return copy
560
+ }
561
+
562
+ // Inside `for-in`, V8 folds `hasOwnProperty.call` into the loop but calls `Object.hasOwn` per key.
563
+ // Bun runs both alike.
564
+ const ownKey = Object.prototype.hasOwnProperty
565
+ // Bun's string `switch` stops beating the dictionary lookup near 128 declared keys; V8's does not.
566
+ const MAX_GENERATED_KEYS = 64
567
+
568
+ /**
569
+ * The object projector as code generated for its declared keys: a `switch` on each key, with every
570
+ * key's projector a call site of its own. It reads, keeps and drops exactly what the closure in
571
+ * `Compiler.object` does, which serves where code generation is off: an edge bundle (the build
572
+ * defines `__NIFRA_EDGE_RUNTIME__`), a browser page (where even a caught attempt is a reported CSP
573
+ * violation), or a runtime that refuses it. Only `JSON.stringify`d key names reach the source.
574
+ */
575
+ function generatedObject(
576
+ declared: Record<string, Project>,
577
+ undeclared: ((key: string) => Project | undefined) | undefined,
578
+ checksMarker: boolean,
579
+ ): Project | undefined {
580
+ const keys = Object.keys(declared)
581
+ // The edge build defines this global as `true`, so that bundle drops the generator.
582
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: only nifra's build defines this global, as a boolean literal.
583
+ const edge = (globalThis as { readonly __NIFRA_EDGE_RUNTIME__?: boolean }).__NIFRA_EDGE_RUNTIME__
584
+ if (keys.length > MAX_GENERATED_KEYS || edge === true || "document" in globalThis)
585
+ return undefined
586
+ const tail = (store: (value: string) => string, call: string): string =>
587
+ `const from = state.issues.length; const after = ${call}(before, state);` +
588
+ "if (state.issues.length > from) prefix(state, from, key);" +
589
+ `if (out !== undefined) ${store("after")};` +
590
+ `else if (after !== before) { out = copyBefore(value, key); ${store("after")}; }` +
591
+ "continue;"
592
+ let cases = ""
593
+ for (const [i, key] of keys.entries()) {
594
+ const projector = declared[key]
595
+ const store = (value: string): string =>
596
+ key === "__proto__" ? `assign(out, key, ${value})` : `out[${JSON.stringify(key)}] = ${value}`
597
+ cases += `case ${JSON.stringify(key)}: { const before = value[key];`
598
+ if (projector === LEAF_ARRAY) {
599
+ cases += `if (!hasJsonHook(before)) { if (out !== undefined) ${store("before")}; continue; }`
600
+ } else if (projector === LEAF) {
601
+ cases += `if (!(before instanceof Date)) { if (out !== undefined) ${store("before")}; continue; }`
602
+ }
603
+ cases += `${tail(store, `p[${i}]`)} }`
604
+ }
605
+ const generic = (value: string): string => `assign(out, key, ${value})`
606
+ const rest =
607
+ undeclared === undefined
608
+ ? "out ??= copyBefore(value, key); continue;"
609
+ : "const projector = undeclared(key);" +
610
+ "if (projector === undefined) { out ??= copyBefore(value, key); continue; }" +
611
+ "const before = value[key];" +
612
+ "if (projector === LEAF_ARRAY ? !hasJsonHook(before) : projector === LEAF && !(before instanceof Date)) {" +
613
+ `if (out !== undefined) ${generic("before")}; continue; }` +
614
+ tail(generic, "projector")
615
+ const source =
616
+ '"use strict"; return function project(value, state) {' +
617
+ 'if (typeof value !== "object" || value === null || Array.isArray(value)) return value;' +
618
+ (checksMarker ? 'if (isDeferred(value)) { state.issues.push(""); return value; }' : "") +
619
+ "const proto = Object.getPrototypeOf(value);" +
620
+ "if (proto !== Object.prototype && proto !== null)" +
621
+ "return value instanceof Date ? value.toJSON() : project(snapshotOf(value), state);" +
622
+ "let out;" +
623
+ "for (const key in value) {" +
624
+ "if (!ownKey.call(value, key)) continue;" +
625
+ `switch (key) { ${cases} default: { ${rest} } }` +
626
+ "}" +
627
+ "return out ?? (value.toJSON === undefined ? value : snapshotOf(value)); }"
628
+ try {
629
+ return new Function(
630
+ "p",
631
+ "undeclared",
632
+ "isDeferred",
633
+ "snapshotOf",
634
+ "copyBefore",
635
+ "assign",
636
+ "prefix",
637
+ "ownKey",
638
+ "hasJsonHook",
639
+ "LEAF",
640
+ "LEAF_ARRAY",
641
+ source,
642
+ )(
643
+ keys.map((key) => declared[key]),
644
+ undeclared,
645
+ isDeferred,
646
+ snapshotOf,
647
+ copyBefore,
648
+ assign,
649
+ prefix,
650
+ ownKey,
651
+ hasJsonHook,
652
+ LEAF,
653
+ LEAF_ARRAY,
654
+ )
655
+ } catch {
656
+ return undefined
657
+ }
658
+ }
659
+
525
660
  /** The keys of a plain object before `stop`, every one of them kept as it is. */
526
661
  function copyBefore(input: Record<string, unknown>, stop: string): Record<string, unknown> {
527
662
  const out: Record<string, unknown> = {}
@@ -609,6 +744,19 @@ export function jsonSchemaOf(schema: StandardSchemaV1): Node | undefined {
609
744
  }
610
745
  }
611
746
 
747
+ /**
748
+ * `validate`, or the variant its schema offers for a value projected by `json`: one that skips the
749
+ * undeclared-key check of each closed object the projection already kept to its declared keys.
750
+ */
751
+ function validatorFor(validate: CompiledGuard["validate"], json: Node): CompiledGuard["validate"] {
752
+ const variant: unknown = Reflect.get(validate, VALIDATE_PROJECTED)
753
+ const projected: unknown = typeof variant === "function" ? variant(json) : undefined
754
+ return isValidate(projected) ? projected : validate
755
+ }
756
+
757
+ const isValidate = (value: unknown): value is CompiledGuard["validate"] =>
758
+ typeof value === "function"
759
+
612
760
  const compiled = new WeakMap<object, CompiledGuard>()
613
761
 
614
762
  /**
@@ -642,7 +790,10 @@ export function outputGuard(schema: unknown, owner: string, exportName: string):
642
790
  }
643
791
  const guard: CompiledGuard = {
644
792
  project,
645
- validate: standard["~standard"].validate,
793
+ validate:
794
+ json === undefined
795
+ ? standard["~standard"].validate
796
+ : validatorFor(standard["~standard"].validate, json),
646
797
  warnDropped: inDevelopment(),
647
798
  deferredFree,
648
799
  }
@@ -96,6 +96,13 @@ export interface PageRouteContext<Env = unknown> {
96
96
  /** The visitor's IP as the serving app derived it; forwarded to a request-bound `ctx.api`. */
97
97
  readonly clientIp?: string | undefined
98
98
  readonly waitUntil?: (promise: Promise<unknown>) => void
99
+ /** One request header, read off the serving app's source without building `req.headers`. */
100
+ readonly header?: (name: string) => string | null
101
+ }
102
+
103
+ /** Whether `c` is a client navigation's data request. On Node, `req.headers` builds every header. */
104
+ function isDataRequest(c: PageRouteContext<unknown>): boolean {
105
+ return (c.header !== undefined ? c.header(DATA_HEADER) : c.req.headers.get(DATA_HEADER)) !== null
99
106
  }
100
107
 
101
108
  /** The page slot of an `ssr = false` route with no `HydrateFallback`: a component that renders nothing. */
@@ -139,11 +146,15 @@ class LoaderPlatform {
139
146
  * `x-nifra-redirect` on a 204 that keeps the redirect's own headers. `undefined` for any other outcome
140
147
  * or request.
141
148
  */
142
- function dataRedirect(req: Request, outcome: unknown): Response | ResponseResult | undefined {
143
- if (req.headers.get(DATA_HEADER) === null) return undefined
149
+ function dataRedirect(
150
+ req: Request,
151
+ isData: boolean,
152
+ outcome: unknown,
153
+ ): Response | ResponseResult | undefined {
154
+ if (!isData) return undefined
144
155
  if (isResponseResult(outcome)) {
145
156
  const plain = outcome.plain
146
- if (plain === undefined) return dataRedirect(req, outcome.toResponse())
157
+ if (plain === undefined) return dataRedirect(req, isData, outcome.toResponse())
147
158
  if (plain.status < 300 || plain.status >= 400) return undefined
148
159
  const { location, ...own } = plain.headers ?? {}
149
160
  if (location === undefined) return undefined
@@ -676,8 +687,9 @@ export function createPageRequestExecutor<Env = unknown>(
676
687
  * The browser's side of a client navigation: the page it is leaving and the layout slots it asked
677
688
  * to keep. Undefined unless this is a data request whose `from` is same-origin and routable.
678
689
  */
679
- const retainContextOf = (req: Request): RetainContext | undefined => {
680
- if (req.headers.get(DATA_HEADER) === null) return undefined
690
+ const retainContextOf = (c: PageRouteContext<Env>): RetainContext | undefined => {
691
+ if (!isDataRequest(c)) return undefined
692
+ const req = c.req
681
693
  const requested = retainedIndices(req.headers.get(RETAIN_HEADER))
682
694
  const from = req.headers.get(NAV_FROM_HEADER)
683
695
  if (requested.size === 0 || from === null) return undefined
@@ -1065,6 +1077,7 @@ export function createPageRequestExecutor<Env = unknown>(
1065
1077
 
1066
1078
  const handleDataOrDocument = async (
1067
1079
  req: Request,
1080
+ isData: boolean,
1068
1081
  env: Env,
1069
1082
  route: RouteEntry,
1070
1083
  params: Readonly<Record<string, string>>,
@@ -1084,7 +1097,7 @@ export function createPageRequestExecutor<Env = unknown>(
1084
1097
  settledLayouts(layoutModules, layoutData, count)
1085
1098
  if (isStatusSignal(data)) return renderStatusSignal(req, env, data, personalized, settled)
1086
1099
  if (isControlFlow(data)) return data
1087
- if (req.headers.get(DATA_HEADER) !== null) {
1100
+ if (isData) {
1088
1101
  const pageSplit = prepareDeferred(data)
1089
1102
  const layoutSplits: Array<ReturnType<typeof prepareDeferred>> = []
1090
1103
  let offset = pageSplit.deferred.length
@@ -1354,11 +1367,11 @@ export function createPageRequestExecutor<Env = unknown>(
1354
1367
  const controls = new PageResponseControls(c)
1355
1368
  try {
1356
1369
  const outcome = await run(c, controls)
1357
- const answer = dataRedirect(c.req, outcome) ?? outcome
1370
+ const answer = dataRedirect(c.req, isDataRequest(c), outcome) ?? outcome
1358
1371
  return controls.personalized ? (privateOutcome(answer) as PageResponse) : answer
1359
1372
  } catch (err) {
1360
1373
  if (!isControlFlow(err)) throw err
1361
- const answer = dataRedirect(c.req, err)
1374
+ const answer = dataRedirect(c.req, isDataRequest(c), err)
1362
1375
  // Returned rather than rethrown: the serving app sends a thrown `Response` exactly as it is,
1363
1376
  // without the cookies the request queued.
1364
1377
  if (controls.personalized) return privateOutcome(answer ?? err) as PageResponse
@@ -1448,7 +1461,7 @@ export function createPageRequestExecutor<Env = unknown>(
1448
1461
  route,
1449
1462
  ctx,
1450
1463
  controls,
1451
- componentOwner ? undefined : retainContextOf(c.req),
1464
+ componentOwner ? undefined : retainContextOf(c),
1452
1465
  )
1453
1466
  layoutModules = run.modules
1454
1467
  layoutRetained = run.retained
@@ -1580,6 +1593,7 @@ export function createPageRequestExecutor<Env = unknown>(
1580
1593
  }
1581
1594
  return handleDataOrDocument(
1582
1595
  c.req,
1596
+ isDataRequest(c),
1583
1597
  c.env,
1584
1598
  route,
1585
1599
  c.params,
@@ -748,9 +748,9 @@ function streamDocument(
748
748
  d.promise
749
749
  .then(
750
750
  (value) =>
751
- // `serializeData` JSON-serializes the value and escapes `<`, `>`, and Unicode line
752
- // separators before this framework-owned script is emitted.
753
- `<script${nonceAttr}>window.__nifraResolve(${d.id},${serializeData(value)})</script>`, // nosemgrep: javascript.lang.security.audit.unknown-value-with-script-tag.unknown-value-with-script-tag
751
+ // `serializeData` escapes `<`, `>`, and Unicode line separators. The JSON goes through
752
+ // `JSON.parse`, never read as an object literal, where a `"__proto__"` key sets the prototype.
753
+ `<script${nonceAttr}>window.__nifraResolve(${d.id},JSON.parse(${jsStringLiteral(serializeData(value))}))</script>`, // nosemgrep: javascript.lang.security.audit.unknown-value-with-script-tag.unknown-value-with-script-tag
754
754
  )
755
755
  .catch((err: unknown) => {
756
756
  // A rejected (or unserializable) deferred streams __nifraReject (the client `<Await>`
@@ -1088,7 +1088,9 @@ export function revalidate<T>(paths: readonly string[], data: T): RevalidateResu
1088
1088
  * Serialize loader data for embedding inside an inline `<script>`. `JSON.stringify` alone
1089
1089
  * is NOT safe there: a string containing `</script>` or `<!--` would break out of the
1090
1090
  * script element (an XSS vector). Escape `<`/`>` to `\uXXXX`, plus the U+2028/U+2029
1091
- * separators.
1091
+ * separators. The result is JSON text: read it with `JSON.parse` (or from a
1092
+ * `type="application/json"` script), never as a JavaScript object literal, where a `"__proto__"`
1093
+ * key sets the object's prototype instead of becoming a property.
1092
1094
  */
1093
1095
  export function serializeData(data: unknown): string {
1094
1096
  const serialized = JSON.stringify(data ?? null)
@@ -1109,6 +1111,11 @@ export function serializeData(data: unknown): string {
1109
1111
  : serialized
1110
1112
  }
1111
1113
 
1114
+ /** Single-quoted JS string literal for script-safe JSON text, which has no raw line terminators. */
1115
+ function jsStringLiteral(json: string): string {
1116
+ return `'${json.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`
1117
+ }
1118
+
1112
1119
  function escapeHtml(value: string): string {
1113
1120
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
1114
1121
  }
@@ -14,6 +14,12 @@ import type { MountableApp, MountOptions } from "@nifrajs/core/server"
14
14
  import { type ServerOptions, server } from "@nifrajs/core/server"
15
15
  import { type CspPolicy, cspNonceResolver, DOCUMENT_POLICY, isCspPolicy } from "../csp.ts"
16
16
  import type { CssLoadingMode } from "../css-contract.ts"
17
+ import {
18
+ type BuildAssetSet,
19
+ type HostPolicy,
20
+ hostPolicyGate,
21
+ isHostPolicy,
22
+ } from "../host-policy.ts"
17
23
  import { CACHE_CHANNEL } from "../isr.ts"
18
24
  import { generateLlmsTxt } from "../llms-txt.ts"
19
25
  import type { Manifest } from "../manifest.ts"
@@ -251,7 +257,8 @@ export interface CreateWebAppOptions<Env = unknown> {
251
257
  readonly cssLoading?: CssLoadingMode
252
258
  /** SSG: the prerendered-path set (e.g. `enumerateStaticRoutes(routes).paths` or the build's
253
259
  * `prerendered.json`). Handed over as `window.__NIFRA_PRERENDERED__` on every page so a client soft-nav
254
- * into a prerendered route fetches its static `_data.json` instead of hitting the worker. A set over
260
+ * into a prerendered route fetches its static `_data.json` instead of hitting the worker, unless the
261
+ * target carries a query string, which renders on demand as a hard request does. A set over
255
262
  * 4 KB of JSON is served once from `/__nifra/prerendered.json?v=<version>`, cacheable for good, and
256
263
  * pages hand over only that URL; `prerenderRoutes` writes the file into a static output. */
257
264
  readonly prerenderedPaths?: readonly string[]
@@ -263,6 +270,18 @@ export interface CreateWebAppOptions<Env = unknown> {
263
270
  * {@link nonce}, whose documents need a nonce minted for their request.
264
271
  */
265
272
  readonly prerenderedPages?: PrerenderedPages
273
+ /**
274
+ * Which pages and files each host serves, from `createHostPolicy()`. Evaluated before everything
275
+ * else the app answers (prerendered pages, mounts, the API, every page) and, in a `nifra build`
276
+ * server entry, before its static files too. A refused path answers the policy's refusal.
277
+ */
278
+ readonly hostPolicy?: HostPolicy
279
+ /**
280
+ * The files the build emitted, by request path: `buildAssets` from the generated server manifest.
281
+ * A {@link hostPolicy} rule's `buildAssets` token matches exactly these. `nifra dev` serves the
282
+ * client bundle and `public/` ahead of the app, so the app's own check never sees them there.
283
+ */
284
+ readonly buildAssets?: BuildAssetSet
266
285
  /** Publish the project's `AGENTS.md` inside `/llms.txt` and `/llms-full.txt`. **Off by default**:
267
286
  * those endpoints are public and unauthenticated, while `AGENTS.md` is a repo file written for the
268
287
  * team - unreleased feature names, internal hostnames, and "don't touch X yet" notes live there
@@ -395,6 +414,13 @@ export function createWebApp<Env = unknown>(
395
414
  // the platform bindings (see the `createWebApp` doc). The runtime `env` still arrives per-request via
396
415
  // `app.fetch(req, { env })`; this is a compile-time-only seed (`server<Env>()` casts, doesn't store).
397
416
  const app = server<Env>(options.server)
417
+ // First of all: a host's refused path must not reach a rate limiter, a mount or a page.
418
+ if (options.hostPolicy !== undefined) {
419
+ if (!isHostPolicy(options.hostPolicy)) {
420
+ throw new TypeError("[nifra/web] createWebApp `hostPolicy` must come from createHostPolicy()")
421
+ }
422
+ app.use(hostPolicyGate(options.hostPolicy, options.buildAssets))
423
+ }
398
424
  // Before the mount hooks and before every page route, so the order-scoped hooks (`derive`,
399
425
  // `beforeHandle`, assurance evidence) actually bind to them - see the `use` option. A caller
400
426
  // cannot do this after the fact: by the time this function returns, the routes are declared.
package/src/public-dir.ts CHANGED
@@ -19,12 +19,14 @@ import { Readable } from "node:stream"
19
19
  import { parseByteRange } from "@nifrajs/core/range"
20
20
  import { pathnameOf } from "@nifrajs/core/server"
21
21
  import {
22
+ CONTENT_SNAPSHOT_MAX,
22
23
  contentTags,
23
24
  contentTypeOf,
24
25
  ifRangeAllowsPartial,
25
26
  isHiddenPath,
26
27
  isNotModified,
27
28
  lastModifiedOf,
29
+ streamedLastModifiedOf,
28
30
  } from "@nifrajs/core/static-file"
29
31
  import { openResolved } from "./open-resolved.ts"
30
32
 
@@ -69,7 +71,7 @@ function hiddenUnder(root: string, abs: string): boolean {
69
71
  // Up to this many bytes are read whole through the pinned descriptor, which then closes before the
70
72
  // response leaves: on Bun a 200 KB asset is served in 0.17 ms against 0.28 ms through a node:fs
71
73
  // stream. A larger body streams, so a video holds a stream's buffer per request rather than the file.
72
- const BUFFERED_BODY_MAX = 256 * 1024
74
+ const BUFFERED_BODY_MAX = CONTENT_SNAPSHOT_MAX
73
75
  // Bun.serve drops the declared length of any stream body and sends it chunked, and a Bun.file body
74
76
  // would need this descriptor open for a send nothing reports the end of. So on Bun a body up to 1 MiB
75
77
  // is read whole as well: a slow client then holds ~1 MB, against ~0.6 MB through Bun's stream sink.
@@ -125,9 +127,10 @@ export function resolvePublicPath(root: string, pathname: string): string | unde
125
127
  * `public/robots.txt` only if the caller checks routes first; the documented precedence is that the
126
128
  * static probe runs first, so `public/` wins, and an app wanting the route should not ship both.
127
129
  *
128
- * A file is sent with a strong `ETag` from its bytes, the tag `@nifrajs/node`'s `static` reader and
129
- * the Deno entry send, and with `Last-Modified` only while its mtime is that of its own last change,
130
- * so a replacement that keeps a file's size and timestamps is never judged current or resumed into.
130
+ * Files up to 256 KiB carry a strong `ETag` from the exact snapshot served, matching the Node and
131
+ * Deno readers. Those snapshots carry `Last-Modified` only while mtime reflects their own last change.
132
+ * Larger files stream without an `ETag`: an open inode can change after a separate hash pass. They
133
+ * are dated once settled, but an `If-Range` request gets the whole file rather than spliced versions.
131
134
  */
132
135
  export function servePublicDir(
133
136
  options: ServePublicDirOptions,
@@ -196,40 +199,44 @@ export function servePublicDir(
196
199
  // Set explicitly rather than inferred from the body, so HEAD (a null body) carries it too.
197
200
  "content-type": contentTypeOf(resolvedFile),
198
201
  })
199
- // The whole file, when tagging it had to read it: the response is then served from these bytes.
200
- let whole: Uint8Array<ArrayBuffer> | undefined
201
- let etag = TAGS.cached(resolvedFile, stat)
202
- if (etag === undefined && size <= bufferedMax) {
203
- whole = await readPinned(file, 0, size)
204
- etag = TAGS.ofBytes(resolvedFile, stat, whole)
205
- }
206
- etag ??= await TAGS.ofFile(resolvedFile, stat, async (bytes, position) => {
207
- return (await file.read(bytes, 0, bytes.byteLength, position)).bytesRead
208
- })
209
- if (etag !== undefined) headers.set("etag", etag)
210
- const lastModified = lastModifiedOf(stat)
202
+ const snapshot = size <= CONTENT_SNAPSHOT_MAX
203
+ const lastModified = snapshot ? lastModifiedOf(stat) : streamedLastModifiedOf(stat)
211
204
  if (lastModified !== undefined) {
212
205
  headers.set("last-modified", new Date(lastModified).toUTCString())
213
206
  }
214
207
  const head = request.method === "HEAD"
215
-
208
+ const ifNoneMatch = request.headers.get("if-none-match")
209
+ const ifModifiedSince = request.headers.get("if-modified-since")
210
+ // The whole file, when tagging it had to read it: the response is then served from these bytes.
211
+ let whole: Uint8Array<ArrayBuffer> | undefined
212
+ // A HEAD or a 304 sends none of the file, so the tag remembered for this settled version
213
+ // answers it from the stat alone: a write since that tag would show in the stat.
214
+ let etag = snapshot ? TAGS.cached(resolvedFile, stat) : undefined
216
215
  if (
217
- isNotModified(
218
- request.headers.get("if-none-match"),
219
- request.headers.get("if-modified-since"),
220
- etag,
221
- lastModified,
222
- )
216
+ snapshot &&
217
+ (etag === undefined ||
218
+ !(head || isNotModified(ifNoneMatch, ifModifiedSince, etag, lastModified)))
223
219
  ) {
220
+ whole = await readPinned(file, 0, size)
221
+ etag = await TAGS.ofSnapshot(resolvedFile, stat, whole, () => file.stat())
222
+ }
223
+ if (etag !== undefined) headers.set("etag", etag)
224
+
225
+ if (isNotModified(ifNoneMatch, ifModifiedSince, etag, lastModified)) {
224
226
  headers.delete("content-type")
225
227
  return new Response(null, { status: 304, headers })
226
228
  }
227
229
 
228
230
  // Range applies to GET only (RFC 9110 section 14.2); HEAD reports the whole representation.
229
231
  const rangeHeader = head ? null : request.headers.get("range")
232
+ // A streamed body's date is weak: a write during the stream must never splice two versions.
230
233
  const range =
231
234
  rangeHeader !== null &&
232
- ifRangeAllowsPartial(request.headers.get("if-range"), etag, lastModified)
235
+ ifRangeAllowsPartial(
236
+ request.headers.get("if-range"),
237
+ etag,
238
+ snapshot ? lastModified : undefined,
239
+ )
233
240
  ? parseByteRange(rangeHeader, size)
234
241
  : ({ kind: "none" } as const)
235
242
 
package/src/router.ts CHANGED
@@ -24,7 +24,8 @@ export const DATA_HEADER = "x-nifra-data"
24
24
  /**
25
25
  * Global the server injects (`createWebApp({ prerenderedPaths })`) listing the SSG-prerendered paths.
26
26
  * The client's default data fetch reads it: a soft-nav INTO a prerendered route fetches its static
27
- * `<path>/_data.json` (a CDN file - no worker round-trip) instead of the dynamic header-GET.
27
+ * `<path>/_data.json` (a CDN file - no worker round-trip) instead of the dynamic header-GET, unless
28
+ * the target carries a query string.
28
29
  */
29
30
  export const PRERENDERED_GLOBAL = "__NIFRA_PRERENDERED__"
30
31
 
@@ -422,13 +423,13 @@ const defaultFetchData: FetchRouteData = async (path, _match, signal, navigation
422
423
  // worker). Falls through to the dynamic header-GET on any miss (file absent, e.g. a deferred route,
423
424
  // or a stale set), so it's always safe. Non-SSG apps have no global → the dynamic path, unchanged.
424
425
  const prerendered = await prerenderedOf()
425
- if (Array.isArray(prerendered)) {
426
- const query = path.indexOf("?")
427
- const hash = path.indexOf("#")
428
- // `indexOf` rather than `replace(/[?#].*$/)`: no regex allocation or backtracking on a
429
- // caller-controlled navigation path.
430
- const cut = query === -1 ? hash : hash === -1 ? query : Math.min(query, hash)
431
- const pathname = cut === -1 ? path : path.slice(0, cut)
426
+ // `indexOf` rather than a regex: no allocation or backtracking on a caller-controlled path.
427
+ const query = path.indexOf("?")
428
+ const hash = path.indexOf("#")
429
+ // The file was rendered without a query, so a query-bearing path renders on demand, exactly as
430
+ // `servePrerenderedPages` answers the same URL on a hard request.
431
+ if (Array.isArray(prerendered) && (query === -1 || (hash !== -1 && hash < query))) {
432
+ const pathname = hash === -1 ? path : path.slice(0, hash)
432
433
  if (prerenderedSetOf(prerendered).has(pathname)) {
433
434
  const staticRes = await fetch(appUrl(dataUrlFor(pathname)), { signal: signal ?? null })
434
435
  if (staticRes.ok) return readResponseData(staticRes, signal)
@@ -3,6 +3,10 @@ import { createRequire } from "node:module"
3
3
  import { resolve } from "node:path"
4
4
  import type * as TS from "typescript"
5
5
  import { type Plugin, version as viteVersion } from "vite"
6
+ import {
7
+ CERTIFIED_COMPONENT_REACT,
8
+ CERTIFIED_COMPONENT_VITE,
9
+ } from "../internal/certified-components.ts"
6
10
  import {
7
11
  compileComponentModule,
8
12
  componentReference,
@@ -102,25 +106,26 @@ export function serverComponentsPlugin(
102
106
  name: "nifra:server-components",
103
107
  enforce: "pre",
104
108
  configResolved(config) {
105
- if (viteVersion !== "8.2.1")
109
+ if (viteVersion !== CERTIFIED_COMPONENT_VITE)
106
110
  throw new Error(
107
- "[nifra/components] Use the certified Vite 8.2.1 component pipeline; Bun remains a supported server runtime",
111
+ `[nifra/components] Use the certified Vite ${CERTIFIED_COMPONENT_VITE} component pipeline, not ${viteVersion}; Bun remains a supported server runtime`,
108
112
  )
109
113
  if (options.adapter === "react") {
110
114
  const require = createRequire(resolve(config.root, "package.json"))
111
- const versions = ["react", "react-dom", "react-server-dom-webpack"].map((name) => {
115
+ const packages = ["react", "react-dom", "react-server-dom-webpack"]
116
+ const versions = packages.map((name) => {
112
117
  try {
113
118
  // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: The compiler inspects trusted AST/package metadata and its own populated stub map; application data never supplies these values.
114
119
  return (require(`${name}/package.json`) as { version: string }).version
115
120
  } catch {
116
121
  throw new Error(
117
- `[nifra/components] Install ${name}@19.2.8 for the React component pipeline`,
122
+ `[nifra/components] Install ${name}@${CERTIFIED_COMPONENT_REACT} for the React component pipeline`,
118
123
  )
119
124
  }
120
125
  })
121
- if (versions.some((version) => version !== "19.2.8"))
126
+ if (versions.some((version) => version !== CERTIFIED_COMPONENT_REACT))
122
127
  throw new Error(
123
- "[nifra/components] React, React DOM, and Flight must all use the matching 19.2.8 version",
128
+ `[nifra/components] React, React DOM, and Flight must all use the matching ${CERTIFIED_COMPONENT_REACT} version, not ${packages.map((name, index) => `${name}@${versions[index]}`).join(", ")}`,
124
129
  )
125
130
  }
126
131
  },