@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.
- package/CHANGELOG.md +46 -0
- package/dist/build-plan.d.ts +6 -0
- package/dist/build-plan.d.ts.map +1 -1
- package/dist/build-plan.js +25 -0
- package/dist/build-plan.js.map +1 -1
- package/dist/build-vite.d.ts +2 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +2 -0
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts +17 -3
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +81 -40
- package/dist/build.js.map +1 -1
- package/dist/deferred.d.ts.map +1 -1
- package/dist/deferred.js +20 -6
- package/dist/deferred.js.map +1 -1
- package/dist/dev.d.ts +5 -0
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +7 -6
- package/dist/dev.js.map +1 -1
- package/dist/diagnostic.d.ts +6 -0
- package/dist/diagnostic.d.ts.map +1 -1
- package/dist/diagnostic.js +41 -0
- package/dist/diagnostic.js.map +1 -1
- package/dist/host-policy.d.ts +72 -0
- package/dist/host-policy.d.ts.map +1 -0
- package/dist/host-policy.js +149 -0
- package/dist/host-policy.js.map +1 -0
- package/dist/host-reach.d.ts +49 -0
- package/dist/host-reach.d.ts.map +1 -0
- package/dist/host-reach.js +144 -0
- package/dist/host-reach.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/certified-components.d.ts +8 -0
- package/dist/internal/certified-components.d.ts.map +1 -0
- package/dist/internal/certified-components.js +8 -0
- package/dist/internal/certified-components.js.map +1 -0
- package/dist/internal/codegen.d.ts +8 -0
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +15 -4
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/output-guard.d.ts +6 -0
- package/dist/internal/output-guard.d.ts.map +1 -1
- package/dist/internal/output-guard.js +118 -16
- package/dist/internal/output-guard.js.map +1 -1
- package/dist/internal/page-execution.d.ts +2 -0
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +16 -11
- package/dist/internal/page-execution.js.map +1 -1
- package/dist/internal/render-document.d.ts +3 -1
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +10 -4
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/web-app.d.ts +15 -1
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +8 -0
- package/dist/internal/web-app.js.map +1 -1
- package/dist/public-dir.d.ts +4 -3
- package/dist/public-dir.d.ts.map +1 -1
- package/dist/public-dir.js +24 -18
- package/dist/public-dir.js.map +1 -1
- package/dist/router.d.ts +2 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +9 -8
- package/dist/router.js.map +1 -1
- package/dist/server-components/vite.d.ts.map +1 -1
- package/dist/server-components/vite.js +8 -6
- package/dist/server-components/vite.js.map +1 -1
- package/dist/service-worker.d.ts +3 -1
- package/dist/service-worker.d.ts.map +1 -1
- package/dist/service-worker.js +29 -8
- package/dist/service-worker.js.map +1 -1
- package/dist/zones.d.ts +5 -1
- package/dist/zones.d.ts.map +1 -1
- package/dist/zones.js +17 -4
- package/dist/zones.js.map +1 -1
- package/package.json +4 -4
- package/src/build-plan.ts +26 -0
- package/src/build-vite.ts +4 -0
- package/src/build.ts +114 -44
- package/src/deferred.ts +20 -6
- package/src/dev.ts +7 -6
- package/src/diagnostic.ts +43 -0
- package/src/host-policy.ts +219 -0
- package/src/host-reach.ts +177 -0
- package/src/index.ts +10 -2
- package/src/internal/certified-components.ts +7 -0
- package/src/internal/codegen.ts +24 -5
- package/src/internal/output-guard.ts +166 -15
- package/src/internal/page-execution.ts +23 -9
- package/src/internal/render-document.ts +11 -4
- package/src/internal/web-app.ts +27 -1
- package/src/public-dir.ts +31 -24
- package/src/router.ts +9 -8
- package/src/server-components/vite.ts +11 -6
- package/src/service-worker.ts +32 -9
- 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
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
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(
|
|
143
|
-
|
|
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 = (
|
|
680
|
-
if (
|
|
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 (
|
|
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
|
|
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`
|
|
752
|
-
//
|
|
753
|
-
`<script${nonceAttr}>window.__nifraResolve(${d.id}
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
1114
1121
|
}
|
package/src/internal/web-app.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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(
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
const pathname =
|
|
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 !==
|
|
109
|
+
if (viteVersion !== CERTIFIED_COMPONENT_VITE)
|
|
106
110
|
throw new Error(
|
|
107
|
-
|
|
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
|
|
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}
|
|
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 !==
|
|
126
|
+
if (versions.some((version) => version !== CERTIFIED_COMPONENT_REACT))
|
|
122
127
|
throw new Error(
|
|
123
|
-
|
|
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
|
},
|