@nifrajs/core 5.8.0 → 5.10.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 +118 -0
- package/README.md +9 -4
- package/dist/assurance.d.ts +26 -1
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +43 -11
- package/dist/assurance.js.map +1 -1
- package/dist/canonical-json.d.ts +11 -0
- package/dist/canonical-json.d.ts.map +1 -0
- package/dist/canonical-json.js +46 -0
- package/dist/canonical-json.js.map +1 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +1 -8
- package/dist/capabilities.js.map +1 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +1 -8
- package/dist/data.js.map +1 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +2 -12
- package/dist/diff.js.map +1 -1
- package/dist/durable-adapters.d.ts +2 -0
- package/dist/durable-adapters.d.ts.map +1 -1
- package/dist/durable-adapters.js +41 -49
- package/dist/durable-adapters.js.map +1 -1
- package/dist/durable-saga.d.ts.map +1 -1
- package/dist/durable-saga.js +14 -20
- package/dist/durable-saga.js.map +1 -1
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +14 -68
- package/dist/evidence.js.map +1 -1
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +60 -23
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/control-character.d.ts +3 -0
- package/dist/internal/control-character.d.ts.map +1 -0
- package/dist/internal/control-character.js +10 -0
- package/dist/internal/control-character.js.map +1 -0
- package/dist/internal/durable-shared.d.ts.map +1 -1
- package/dist/internal/durable-shared.js +4 -1
- package/dist/internal/durable-shared.js.map +1 -1
- package/dist/internal/hex.d.ts +5 -0
- package/dist/internal/hex.d.ts.map +1 -0
- package/dist/internal/hex.js +14 -0
- package/dist/internal/hex.js.map +1 -0
- package/dist/internal/record.d.ts +3 -0
- package/dist/internal/record.d.ts.map +1 -0
- package/dist/internal/record.js +5 -0
- package/dist/internal/record.js.map +1 -0
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +2 -2
- package/dist/internal/route-compiler.js.map +1 -1
- package/dist/internal/route-execution.d.ts +4 -4
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js +4 -0
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/internal/route-lanes.d.ts +4 -3
- package/dist/internal/route-lanes.d.ts.map +1 -1
- package/dist/internal/route-lanes.js +16 -33
- package/dist/internal/route-lanes.js.map +1 -1
- package/dist/internal/schema-parts.d.ts +13 -0
- package/dist/internal/schema-parts.d.ts.map +1 -0
- package/dist/internal/schema-parts.js +15 -0
- package/dist/internal/schema-parts.js.map +1 -0
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +2 -14
- package/dist/ledger.js.map +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +10 -37
- package/dist/manifest.js.map +1 -1
- package/dist/open-resolved.d.ts +18 -0
- package/dist/open-resolved.d.ts.map +1 -0
- package/dist/open-resolved.js +62 -0
- package/dist/open-resolved.js.map +1 -0
- package/dist/range.js +1 -1
- package/dist/range.js.map +1 -1
- package/dist/router/path-codec.d.ts +22 -0
- package/dist/router/path-codec.d.ts.map +1 -0
- package/dist/router/path-codec.js +87 -0
- package/dist/router/path-codec.js.map +1 -0
- package/dist/router/pattern.d.ts +21 -1
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +117 -0
- package/dist/router/pattern.js.map +1 -1
- package/dist/router/same-origin-path.d.ts +22 -0
- package/dist/router/same-origin-path.d.ts.map +1 -0
- package/dist/router/same-origin-path.js +37 -0
- package/dist/router/same-origin-path.js.map +1 -0
- package/dist/serve-static.d.ts +22 -0
- package/dist/serve-static.d.ts.map +1 -0
- package/dist/serve-static.js +187 -0
- package/dist/serve-static.js.map +1 -0
- package/dist/server/bare-error-lane.d.ts +2 -1
- package/dist/server/bare-error-lane.d.ts.map +1 -1
- package/dist/server/bare-error-lane.js +12 -3
- package/dist/server/bare-error-lane.js.map +1 -1
- package/dist/server/base64-answers.d.ts.map +1 -1
- package/dist/server/base64-answers.js +1 -4
- package/dist/server/base64-answers.js.map +1 -1
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +43 -38
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +4 -1
- package/dist/server/body.js.map +1 -1
- package/dist/server/bridge-markers.d.ts +1 -1
- package/dist/server/bridge-markers.js +1 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +4 -1
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/core-copies.js +1 -1
- package/dist/server/core-copies.js.map +1 -1
- package/dist/server/http.d.ts +6 -18
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +14 -33
- package/dist/server/http.js.map +1 -1
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +2 -0
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/idle-timeout.d.ts +39 -0
- package/dist/server/idle-timeout.d.ts.map +1 -0
- package/dist/server/idle-timeout.js +95 -0
- package/dist/server/idle-timeout.js.map +1 -0
- package/dist/server/json-shared.d.ts +23 -0
- package/dist/server/json-shared.d.ts.map +1 -0
- package/dist/server/json-shared.js +33 -0
- package/dist/server/json-shared.js.map +1 -0
- package/dist/server/json-text.d.ts +1 -1
- package/dist/server/json-text.js +1 -1
- package/dist/server/node-outcome.js +1 -1
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/request-context.d.ts +22 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +44 -4
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/request-local.d.ts +44 -0
- package/dist/server/request-local.d.ts.map +1 -0
- package/dist/server/request-local.js +91 -0
- package/dist/server/request-local.js.map +1 -0
- package/dist/server/respond.js +1 -1
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +10 -2
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +93 -64
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/runtime-core.d.ts +7 -4
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +9 -7
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +23 -8
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +58 -59
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +860 -510
- package/dist/server/server.js.map +1 -1
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +2 -0
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/webhook.js +1 -1
- package/dist/server/webhook.js.map +1 -1
- package/dist/server/websocket.d.ts +3 -1
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +14 -1
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-adapter-view.d.ts +54 -0
- package/dist/server/ws-adapter-view.d.ts.map +1 -0
- package/dist/server/ws-adapter-view.js +7 -0
- package/dist/server/ws-adapter-view.js.map +1 -0
- package/dist/server.d.ts +2 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -1
- package/dist/server.js.map +1 -1
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +1 -1
- package/dist/single-copy.js.map +1 -1
- package/dist/static-file.d.ts +156 -4
- package/dist/static-file.d.ts.map +1 -1
- package/dist/static-file.js +198 -4
- package/dist/static-file.js.map +1 -1
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js +1 -3
- package/dist/wire.js.map +1 -1
- package/package.json +16 -1
- package/src/assurance.ts +66 -16
- package/src/canonical-json.ts +43 -0
- package/src/capabilities.ts +1 -8
- package/src/data.ts +2 -8
- package/src/diff.ts +2 -12
- package/src/durable-adapters.ts +53 -50
- package/src/durable-saga.ts +30 -23
- package/src/evidence.ts +25 -90
- package/src/idempotency.ts +62 -20
- package/src/index.ts +13 -2
- package/src/internal/control-character.ts +8 -0
- package/src/internal/durable-shared.ts +4 -1
- package/src/internal/hex.ts +14 -0
- package/src/internal/record.ts +4 -0
- package/src/internal/route-compiler.ts +2 -2
- package/src/internal/route-execution.ts +7 -20
- package/src/internal/route-lanes.ts +20 -44
- package/src/internal/schema-parts.ts +19 -0
- package/src/ledger.ts +2 -16
- package/src/manifest.ts +10 -38
- package/src/open-resolved.ts +64 -0
- package/src/range.ts +1 -1
- package/src/router/path-codec.ts +93 -0
- package/src/router/pattern.ts +127 -0
- package/src/router/same-origin-path.ts +36 -0
- package/src/serve-static.ts +227 -0
- package/src/server/bare-error-lane.ts +11 -2
- package/src/server/base64-answers.ts +2 -6
- package/src/server/body-lane.ts +58 -51
- package/src/server/body.ts +4 -1
- package/src/server/bridge-markers.ts +1 -1
- package/src/server/cookies.ts +4 -1
- package/src/server/core-copies.ts +1 -1
- package/src/server/http.ts +13 -33
- package/src/server/idempotency-lane.ts +2 -0
- package/src/server/idle-timeout.ts +107 -0
- package/src/server/json-shared.ts +38 -0
- package/src/server/json-text.ts +1 -1
- package/src/server/node-outcome.ts +1 -1
- package/src/server/request-context.ts +62 -3
- package/src/server/request-local.ts +109 -0
- package/src/server/respond.ts +1 -1
- package/src/server/response-contract-lane.ts +112 -65
- package/src/server/runtime-core.ts +10 -8
- package/src/server/server-types.ts +23 -8
- package/src/server/server.ts +1220 -798
- package/src/server/transport-lane.ts +2 -0
- package/src/server/webhook.ts +1 -1
- package/src/server/websocket.ts +18 -1
- package/src/server/ws-adapter-view.ts +63 -0
- package/src/server.ts +12 -1
- package/src/single-copy.ts +1 -3
- package/src/static-file.ts +350 -4
- package/src/wire.ts +2 -4
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-local state: a value tied to one request that follows it through the copies made of it.
|
|
3
|
+
* A `WeakMap<Request, T>` loses its value at the first `new Request(url, req)`, and nifra itself
|
|
4
|
+
* rebuilds a request when a mount strips its prefix, when a WebSocket pass is reused for HTTP, and
|
|
5
|
+
* when a target is resolved. Every such copy joins the request's scope, so each reads and writes
|
|
6
|
+
* the same values.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** One request-local slot from {@link requestLocal}. */
|
|
10
|
+
export interface RequestLocal<T> {
|
|
11
|
+
/** The value set through `request` or any request in its scope, else `undefined`. */
|
|
12
|
+
get(request: Request): T | undefined
|
|
13
|
+
/** Set the value for `request`'s whole scope. */
|
|
14
|
+
set(request: Request, value: T): void
|
|
15
|
+
has(request: Request): boolean
|
|
16
|
+
/** Remove the value from `request`'s scope; `true` when there was one. */
|
|
17
|
+
delete(request: Request): boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type Values = Map<object, unknown>
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A copy maps to the request it was made from, and the end of that chain (the anchor) to its
|
|
24
|
+
* scope's values once one is set. Keyed in the global symbol registry so two copies of core keep
|
|
25
|
+
* one table, and weak, so a scope lives exactly as long as a request in it.
|
|
26
|
+
*/
|
|
27
|
+
type Links = WeakMap<Request, Request | Values>
|
|
28
|
+
|
|
29
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion lint/plugin/noChainedTypeAssertions: only this module writes the slot, and only a Links table.
|
|
30
|
+
const shared = globalThis as unknown as Record<symbol, Links | undefined>
|
|
31
|
+
const LINKS = Symbol.for("nifra.requestLocals")
|
|
32
|
+
|
|
33
|
+
const linksOf = (): Links => (shared[LINKS] ??= new WeakMap())
|
|
34
|
+
|
|
35
|
+
/** The request at the end of `request`'s chain of copies. */
|
|
36
|
+
function anchorOf(links: Links, request: Request): Request {
|
|
37
|
+
let anchor = request
|
|
38
|
+
let link = links.get(anchor)
|
|
39
|
+
while (link !== undefined && !(link instanceof Map)) {
|
|
40
|
+
anchor = link
|
|
41
|
+
link = links.get(anchor)
|
|
42
|
+
}
|
|
43
|
+
return anchor
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `to` was just built from `from`, with nothing set on it: it joins `from`'s scope. Until a first
|
|
47
|
+
* slot makes the table there is no scope to join, and a copy records nothing. */
|
|
48
|
+
export function carryToCopy(from: Request, to: Request): void {
|
|
49
|
+
shared[LINKS]?.set(to, from)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Join `to` to `from`'s request-local scope: each then reads every value set through either, now
|
|
54
|
+
* and later. nifra does this wherever it rebuilds a request itself; call it where a wrapper or an
|
|
55
|
+
* `onRequest` hook rebuilds one, right after `new Request(url, req)`. A value `to` already holds is
|
|
56
|
+
* kept where `from` has none.
|
|
57
|
+
*/
|
|
58
|
+
export function carryRequestLocals(from: Request, to: Request): void {
|
|
59
|
+
const links = shared[LINKS]
|
|
60
|
+
if (links === undefined) return
|
|
61
|
+
const target = anchorOf(links, from)
|
|
62
|
+
const joining = anchorOf(links, to)
|
|
63
|
+
if (target === joining) return
|
|
64
|
+
const own = links.get(joining)
|
|
65
|
+
links.set(joining, target)
|
|
66
|
+
if (!(own instanceof Map)) return
|
|
67
|
+
const held = links.get(target)
|
|
68
|
+
if (!(held instanceof Map)) links.set(target, own)
|
|
69
|
+
else for (const [key, value] of own) if (!held.has(key)) held.set(key, value)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function valuesOf(request: Request, create: boolean): Values | undefined {
|
|
73
|
+
const links = linksOf()
|
|
74
|
+
const anchor = anchorOf(links, request)
|
|
75
|
+
const held = links.get(anchor)
|
|
76
|
+
if (held instanceof Map || !create) return held instanceof Map ? held : undefined
|
|
77
|
+
const fresh: Values = new Map()
|
|
78
|
+
links.set(anchor, fresh)
|
|
79
|
+
return fresh
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A slot for one per-request value - a verified client IP, a request id, an auth decision - that
|
|
84
|
+
* survives the request copies nifra makes (a mount's prefix strip, a WebSocket pass reused for HTTP,
|
|
85
|
+
* target resolution) and the ones a wrapper makes with {@link carryRequestLocals}. A copy made any
|
|
86
|
+
* other way starts empty. Values are held weakly and never cross requests. Make slots at startup, as
|
|
87
|
+
* below: copies are linked only once a first slot exists, so a request copied before then starts
|
|
88
|
+
* empty.
|
|
89
|
+
*
|
|
90
|
+
* ```ts
|
|
91
|
+
* const clientIp = requestLocal<string>()
|
|
92
|
+
* app.onRequest((req, platform) => { clientIp.set(req, platform?.clientIp ?? "") })
|
|
93
|
+
* // ... in a mounted child app, after the prefix strip:
|
|
94
|
+
* clientIp.get(c.req)
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export function requestLocal<T>(): RequestLocal<T> {
|
|
98
|
+
linksOf()
|
|
99
|
+
const key = {}
|
|
100
|
+
return {
|
|
101
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: only `set` below writes `key`, and only a T.
|
|
102
|
+
get: (request) => valuesOf(request, false)?.get(key) as T | undefined,
|
|
103
|
+
set: (request, value) => {
|
|
104
|
+
valuesOf(request, true)?.set(key, value)
|
|
105
|
+
},
|
|
106
|
+
has: (request) => valuesOf(request, false)?.has(key) === true,
|
|
107
|
+
delete: (request) => valuesOf(request, false)?.delete(key) === true,
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/server/respond.ts
CHANGED
|
@@ -39,7 +39,7 @@ function headersInit(set: CtxSet): Record<string, string> | Headers | undefined
|
|
|
39
39
|
function plainRenderSet(plain: PlainRender, set: CtxSet): CtxSet {
|
|
40
40
|
return {
|
|
41
41
|
status: plain.status,
|
|
42
|
-
_headers: plainRenderHeaders(plain, set),
|
|
42
|
+
_headers: plainRenderHeaders(plain.headers, set._headers),
|
|
43
43
|
_cookies: set._cookies,
|
|
44
44
|
} as CtxSet
|
|
45
45
|
}
|
|
@@ -38,8 +38,10 @@
|
|
|
38
38
|
* object. So a value's hooks are applied first, as the serializer would, and that result is validated
|
|
39
39
|
* and sent: a class whose `toJSON` adds a private field fails a closed schema instead of passing on its
|
|
40
40
|
* public ones. `strip` keeps the declared fields of what the hook writes, so a hook that writes `id`
|
|
41
|
-
* from private state keeps it. A `Date` is
|
|
42
|
-
*
|
|
41
|
+
* from private state keeps it. A `Date` is judged as the ISO string its own hook writes too, so a
|
|
42
|
+
* `t.string({ format: "date-time" })` or `t.date()` field holding a database timestamp passes: a
|
|
43
|
+
* schema that carries its JSON Schema is checked as JSON carries it. A schema without one (zod,
|
|
44
|
+
* valibot) cannot say which place expects a string, so it is shown every `Date` as one.
|
|
43
45
|
*
|
|
44
46
|
* ## What enforcement costs
|
|
45
47
|
*
|
|
@@ -53,12 +55,15 @@
|
|
|
53
55
|
* or lifecycle hook has already left that lane, so for the routes that look like production the
|
|
54
56
|
* contract is effectively free - declare it.
|
|
55
57
|
*/
|
|
58
|
+
|
|
59
|
+
import { isRecord } from "../internal/record.ts"
|
|
56
60
|
import {
|
|
57
61
|
formatStandardIssues,
|
|
58
62
|
type StandardResult,
|
|
59
63
|
type StandardSchemaV1,
|
|
60
64
|
} from "../schema/standard.ts"
|
|
61
65
|
import { INSTALL_RESPONSE_CONTRACT } from "./install.ts"
|
|
66
|
+
import { DATE_TO_JSON, define, readsShippedTime } from "./json-shared.ts"
|
|
62
67
|
import { jsonText } from "./json-text.ts"
|
|
63
68
|
import type { IdentityPlugin } from "./plugin.ts"
|
|
64
69
|
import { isResponseResult, status } from "./runtime-core.ts"
|
|
@@ -141,18 +146,14 @@ export type ResponseContractOutcome =
|
|
|
141
146
|
/** `enforce` only: the payload cannot be reconciled with the contract, so it must not be sent. */
|
|
142
147
|
| { readonly kind: "violation"; readonly message: string }
|
|
143
148
|
|
|
144
|
-
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
|
145
|
-
typeof v === "object" && v !== null && !Array.isArray(v)
|
|
146
|
-
|
|
147
149
|
/** Rebuilds a value with only what its schema declares; absent when the schema can strip nothing. */
|
|
148
150
|
type Projector = (value: unknown) => unknown
|
|
149
151
|
|
|
150
152
|
const projectors = new WeakMap<object, Projector | null>()
|
|
151
153
|
|
|
152
|
-
/** The projector for a schema's JSON Schema, compiled once per schema.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (typeof node !== "object" || node === null) return undefined
|
|
154
|
+
/** The projector for a schema's JSON Schema, compiled once per schema. It writes each `Date` as it
|
|
155
|
+
* descends, as every schema that carries its JSON Schema is judged. */
|
|
156
|
+
function projectorOf(node: object): Projector | undefined {
|
|
156
157
|
let found = projectors.get(node)
|
|
157
158
|
if (found === undefined) {
|
|
158
159
|
found = compileProjector(node) ?? null
|
|
@@ -161,6 +162,12 @@ function projectorOf(schema: StandardSchemaV1): Projector | undefined {
|
|
|
161
162
|
return found ?? undefined
|
|
162
163
|
}
|
|
163
164
|
|
|
165
|
+
/** The JSON Schema a schema carries as `jsonSchema` (every `@nifrajs/schema` `t` schema), if any. */
|
|
166
|
+
function jsonSchemaOf(schema: StandardSchemaV1): object | undefined {
|
|
167
|
+
const node: unknown = Reflect.get(schema, "jsonSchema")
|
|
168
|
+
return typeof node === "object" && node !== null ? node : undefined
|
|
169
|
+
}
|
|
170
|
+
|
|
164
171
|
const objectField = (node: object, key: string): object | undefined => {
|
|
165
172
|
const value: unknown = Reflect.get(node, key)
|
|
166
173
|
return typeof value === "object" && value !== null ? value : undefined
|
|
@@ -180,7 +187,7 @@ function compileProjector(node: unknown): Projector | undefined {
|
|
|
180
187
|
: (value) => {
|
|
181
188
|
if (!Array.isArray(value)) return value
|
|
182
189
|
const out: unknown[] = []
|
|
183
|
-
for (let i = 0; i < value.length; i++) out.push(item(writtenAt(value[i], i)))
|
|
190
|
+
for (let i = 0; i < value.length; i++) out.push(item(writtenAt(value[i], i, true)))
|
|
184
191
|
return out
|
|
185
192
|
}
|
|
186
193
|
}
|
|
@@ -207,12 +214,16 @@ function compileProjector(node: unknown): Projector | undefined {
|
|
|
207
214
|
return undefined
|
|
208
215
|
const declared = new Set(keys)
|
|
209
216
|
return (value) => {
|
|
210
|
-
if (!
|
|
217
|
+
if (!isRecord(value)) return value
|
|
211
218
|
const out: Record<string, unknown> = closed ? {} : { ...value }
|
|
212
219
|
for (const key of closed ? keys : nested.keys()) {
|
|
213
220
|
if (!hasField(value, key)) continue
|
|
214
221
|
const projector = nested.get(key)
|
|
215
|
-
define(
|
|
222
|
+
define(
|
|
223
|
+
out,
|
|
224
|
+
key,
|
|
225
|
+
projector === undefined ? value[key] : projector(writtenAt(value[key], key, true)),
|
|
226
|
+
)
|
|
216
227
|
}
|
|
217
228
|
if (patterns.length > 0) projectPatternKeys(value, out, declared, patterns, closed)
|
|
218
229
|
return out
|
|
@@ -243,7 +254,7 @@ function compileUnion(branches: readonly unknown[]): Projector | undefined {
|
|
|
243
254
|
if (forArray === undefined && forObject === undefined) return undefined
|
|
244
255
|
return (value) => {
|
|
245
256
|
if (Array.isArray(value)) return forArray === undefined ? value : forArray(value)
|
|
246
|
-
return forObject !== undefined &&
|
|
257
|
+
return forObject !== undefined && isRecord(value) ? forObject(value) : value
|
|
247
258
|
}
|
|
248
259
|
}
|
|
249
260
|
|
|
@@ -290,7 +301,7 @@ function compileDiscriminated(branches: readonly object[]): Projector | undefine
|
|
|
290
301
|
})
|
|
291
302
|
if (!disjoint) continue
|
|
292
303
|
return (value) => {
|
|
293
|
-
if (!
|
|
304
|
+
if (!isRecord(value) || !hasField(value, key)) return value
|
|
294
305
|
const projector = byTag.get(value[key])
|
|
295
306
|
return projector === undefined ? value : projector(value)
|
|
296
307
|
}
|
|
@@ -357,33 +368,31 @@ function projectPatternKeys(
|
|
|
357
368
|
}
|
|
358
369
|
if (matches === 0) continue
|
|
359
370
|
if (matches === 1 && projector !== undefined)
|
|
360
|
-
define(out, key, projector(writtenAt(value[key], key)))
|
|
371
|
+
define(out, key, projector(writtenAt(value[key], key, true)))
|
|
361
372
|
else if (closed) define(out, key, value[key])
|
|
362
373
|
}
|
|
363
374
|
}
|
|
364
375
|
|
|
365
376
|
/** `__proto__` is a declared name like any other here; plain assignment would set the prototype. */
|
|
366
|
-
function define(out: Record<string, unknown>, key: string, value: unknown): void {
|
|
367
|
-
if (key === "__proto__")
|
|
368
|
-
Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true })
|
|
369
|
-
else out[key] = value
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
const DATE_TO_JSON = Date.prototype.toJSON
|
|
373
|
-
|
|
374
377
|
// Deeper than this, the copying walk takes over with its own cycle check.
|
|
375
378
|
const MAX_HOOK_SCAN_DEPTH = 64
|
|
376
379
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
+
const isDate = (value: object, hook: unknown): value is Date =>
|
|
381
|
+
hook === DATE_TO_JSON && value instanceof Date
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Whether `value` holds a `toJSON` hook, a `Date`'s only when `dates` writes them. Stops at the first
|
|
385
|
+
* and allocates nothing: most payloads have none, and this is the price every contracted response
|
|
386
|
+
* pays.
|
|
387
|
+
*/
|
|
388
|
+
function hasHook(value: object, depth: number, dates: boolean): boolean {
|
|
380
389
|
if (depth > MAX_HOOK_SCAN_DEPTH) return true
|
|
381
390
|
const hook: unknown = Reflect.get(value, "toJSON")
|
|
382
|
-
if (typeof hook === "function") return !(
|
|
391
|
+
if (typeof hook === "function") return dates || !isDate(value, hook)
|
|
383
392
|
if (Array.isArray(value)) {
|
|
384
393
|
for (let i = 0; i < value.length; i++) {
|
|
385
394
|
const item: unknown = value[i]
|
|
386
|
-
if (typeof item === "object" && item !== null && hasHook(item, depth + 1)) return true
|
|
395
|
+
if (typeof item === "object" && item !== null && hasHook(item, depth + 1, dates)) return true
|
|
387
396
|
}
|
|
388
397
|
return false
|
|
389
398
|
}
|
|
@@ -391,7 +400,7 @@ function hasHook(value: object, depth: number): boolean {
|
|
|
391
400
|
for (const key in value) {
|
|
392
401
|
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: `key` was enumerated from `value`; `Reflect.get` here halves the speed of this for-in.
|
|
393
402
|
const field: unknown = (value as Record<string, unknown>)[key]
|
|
394
|
-
if (typeof field === "object" && field !== null && hasHook(field, depth + 1)) return true
|
|
403
|
+
if (typeof field === "object" && field !== null && hasHook(field, depth + 1, dates)) return true
|
|
395
404
|
}
|
|
396
405
|
return false
|
|
397
406
|
}
|
|
@@ -399,22 +408,26 @@ function hasHook(value: object, depth: number): boolean {
|
|
|
399
408
|
/**
|
|
400
409
|
* What `JSON.stringify` writes for `value`: every `toJSON` hook applied as the serializer applies it,
|
|
401
410
|
* so the contract judges the bytes that go out rather than fields a hook then replaces. Copies only the
|
|
402
|
-
* containers above a hook. A `Date` keeps its object
|
|
403
|
-
* string a date schema declares.
|
|
411
|
+
* containers above a hook. A `Date` keeps its object unless `dates`.
|
|
404
412
|
*/
|
|
405
|
-
const wireValueOf = (value: unknown): unknown =>
|
|
406
|
-
typeof value === "object" && value !== null && hasHook(value, 0)
|
|
407
|
-
? wireValue(value, "", [])
|
|
413
|
+
const wireValueOf = (value: unknown, dates: boolean): unknown =>
|
|
414
|
+
typeof value === "object" && value !== null && hasHook(value, 0, dates)
|
|
415
|
+
? wireValue(value, "", [], dates)
|
|
408
416
|
: value
|
|
409
417
|
|
|
410
|
-
const wireValue = (value: unknown, key: string | number, path: object[]): unknown =>
|
|
411
|
-
wireChildren(writtenAt(value, key), path)
|
|
418
|
+
const wireValue = (value: unknown, key: string | number, path: object[], dates: boolean): unknown =>
|
|
419
|
+
wireChildren(writtenAt(value, key, dates), path, dates)
|
|
412
420
|
|
|
413
421
|
/** `value` as the serializer takes it under `key`: its own hook applied, its fields still as they are. */
|
|
414
|
-
function writtenAt(value: unknown, key: string | number): unknown {
|
|
422
|
+
function writtenAt(value: unknown, key: string | number, dates: boolean): unknown {
|
|
415
423
|
if (typeof value !== "object" || value === null) return value
|
|
416
424
|
const hook: unknown = Reflect.get(value, "toJSON")
|
|
417
|
-
if (typeof hook !== "function"
|
|
425
|
+
if (typeof hook !== "function") return value
|
|
426
|
+
if (isDate(value, hook)) {
|
|
427
|
+
if (!dates) return value
|
|
428
|
+
if (readsShippedTime(value))
|
|
429
|
+
return Number.isFinite(value.valueOf()) ? value.toISOString() : null
|
|
430
|
+
}
|
|
418
431
|
return fieldsOf(Reflect.apply(hook, value, [String(key)]))
|
|
419
432
|
}
|
|
420
433
|
|
|
@@ -432,33 +445,36 @@ function fieldsOf(value: unknown): unknown {
|
|
|
432
445
|
return fields
|
|
433
446
|
}
|
|
434
447
|
|
|
435
|
-
|
|
448
|
+
/** `value` with each of its fields written; a container is copied once, when a first field changes. */
|
|
449
|
+
function wireChildren(value: unknown, path: object[], dates: boolean): unknown {
|
|
436
450
|
// A cycle is left as it is for `JSON.stringify` to refuse.
|
|
437
451
|
if (typeof value !== "object" || value === null || path.includes(value)) return value
|
|
438
452
|
path.push(value)
|
|
453
|
+
// A primitive is written as it is, so only an object field is walked.
|
|
439
454
|
if (Array.isArray(value)) {
|
|
440
455
|
let items: unknown[] | undefined
|
|
441
456
|
for (let i = 0; i < value.length; i++) {
|
|
442
457
|
const item: unknown = value[i]
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
if (
|
|
458
|
+
if (typeof item !== "object" || item === null) continue
|
|
459
|
+
const written = wireValue(item, i, path, dates)
|
|
460
|
+
if (written === item) continue
|
|
461
|
+
items ??= value.slice()
|
|
462
|
+
items[i] = written
|
|
446
463
|
}
|
|
447
464
|
path.pop()
|
|
448
465
|
return items ?? value
|
|
449
466
|
}
|
|
450
|
-
|
|
467
|
+
// biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: every key read below comes from `Object.keys(value)`; `Reflect.get` costs a call per field.
|
|
468
|
+
const record = value as Record<string, unknown>
|
|
451
469
|
let out: Record<string, unknown> | undefined
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const written = wireValue(field, name, path)
|
|
456
|
-
if (written
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
if (out !== undefined) define(out, name, written)
|
|
461
|
-
index++
|
|
470
|
+
for (const name of Object.keys(record)) {
|
|
471
|
+
const field = record[name]
|
|
472
|
+
if (typeof field !== "object" || field === null) continue
|
|
473
|
+
const written = wireValue(field, name, path, dates)
|
|
474
|
+
if (written === field) continue
|
|
475
|
+
// A spread copies the own enumerable fields JSON writes, faster than one assignment each.
|
|
476
|
+
out ??= { ...record }
|
|
477
|
+
define(out, name, written)
|
|
462
478
|
}
|
|
463
479
|
path.pop()
|
|
464
480
|
return out ?? value
|
|
@@ -480,7 +496,7 @@ function collectStripped(sent: unknown, kept: unknown, path: string, out: Set<st
|
|
|
480
496
|
}
|
|
481
497
|
return
|
|
482
498
|
}
|
|
483
|
-
if (!
|
|
499
|
+
if (!isRecord(sent) || !isRecord(kept)) return
|
|
484
500
|
for (const key of Object.keys(sent)) {
|
|
485
501
|
const short = key.length > MAX_NAME_LENGTH ? `${key.slice(0, MAX_NAME_LENGTH)}...` : key
|
|
486
502
|
const name = path === "" ? short : `${path}.${short}`
|
|
@@ -520,7 +536,7 @@ function strippedMessage(fields: readonly string[]): string {
|
|
|
520
536
|
|
|
521
537
|
/** Top-level keys present in the handler's result but absent from the validated value. */
|
|
522
538
|
function droppedKeys(result: unknown, value: unknown): string[] {
|
|
523
|
-
if (!
|
|
539
|
+
if (!isRecord(result) || !isRecord(value)) return []
|
|
524
540
|
return Object.keys(result).filter((k) => !(k in value))
|
|
525
541
|
}
|
|
526
542
|
|
|
@@ -587,6 +603,14 @@ interface ContractPolicy {
|
|
|
587
603
|
const STRIP: ContractPolicy = { strip: true, report: undefined }
|
|
588
604
|
const KEEP: ContractPolicy = { strip: false, report: undefined }
|
|
589
605
|
|
|
606
|
+
/** Set by `@nifrajs/schema` on the validate of a `t` schema with a `t.date()`: the check for a value
|
|
607
|
+
* as JSON writes it. */
|
|
608
|
+
const VALIDATE_WIRE: unique symbol = Symbol.for("nifra.schema.validateWire")
|
|
609
|
+
|
|
610
|
+
type ContractValidate = StandardSchemaV1["~standard"]["validate"] & {
|
|
611
|
+
readonly [VALIDATE_WIRE]?: unknown
|
|
612
|
+
}
|
|
613
|
+
|
|
590
614
|
function checkContract(
|
|
591
615
|
definition: ResponseContractDefinition,
|
|
592
616
|
result: unknown,
|
|
@@ -634,19 +658,32 @@ function validateContract(
|
|
|
634
658
|
policy: ContractPolicy,
|
|
635
659
|
replace: (value: unknown) => unknown,
|
|
636
660
|
): ResponseContractOutcome | Promise<ResponseContractOutcome> {
|
|
637
|
-
const
|
|
661
|
+
const node = jsonSchemaOf(schema)
|
|
662
|
+
// A `Date` is a hook like any other for a schema that carries its JSON Schema: one walk writes them.
|
|
663
|
+
const dates = node !== undefined
|
|
664
|
+
const project = policy.strip && node !== undefined ? projectorOf(node) : undefined
|
|
638
665
|
// Naming what was stripped compares against everything the payload writes, so it walks it whole.
|
|
639
|
-
const
|
|
640
|
-
project === undefined || policy.report !== undefined || mode === "warn"
|
|
641
|
-
|
|
642
|
-
|
|
666
|
+
const hooks =
|
|
667
|
+
(project === undefined || policy.report !== undefined || mode === "warn") &&
|
|
668
|
+
typeof payload === "object" &&
|
|
669
|
+
payload !== null &&
|
|
670
|
+
hasHook(payload, 0, dates)
|
|
671
|
+
const before = hooks ? wireValue(payload, "", [], dates) : payload
|
|
643
672
|
// The projection applies the hook of each value it descends into and reads only declared fields,
|
|
644
673
|
// so a quiet strip never enumerates the payload's own keys. What it copies whole is walked after.
|
|
645
|
-
const sent =
|
|
646
|
-
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
const
|
|
674
|
+
const sent =
|
|
675
|
+
project === undefined ? before : wireValueOf(project(writtenAt(before, "", true)), true)
|
|
676
|
+
// A `t` schema's check for a value as JSON carries it: its `t.date()` strings stay strings, where
|
|
677
|
+
// the plain validate would hand back the `Date`s a request's handler receives.
|
|
678
|
+
const validate: ContractValidate = schema["~standard"].validate
|
|
679
|
+
const wire = validate[VALIDATE_WIRE]
|
|
680
|
+
const settled: StandardResult<unknown> | Promise<StandardResult<unknown>> =
|
|
681
|
+
typeof wire === "function" ? wire(sent) : schema["~standard"].validate(sent)
|
|
682
|
+
// When hooks wrote the payload, `warn` sends what they wrote here rather than calling them again:
|
|
683
|
+
// a hook that answers differently on a second call cannot send what was not judged, and the
|
|
684
|
+
// serializer does not write a `Date` twice. A contracted route's `c.json` reply has none left, as
|
|
685
|
+
// `write` applied them when the reply was built.
|
|
686
|
+
const shown = mode === "warn" && hooks ? replace(before) : served
|
|
650
687
|
return settled instanceof Promise
|
|
651
688
|
? settled.then((r) => interpret(r, before, shown, mode, replace, policy.report))
|
|
652
689
|
: interpret(settled, before, shown, mode, replace, policy.report)
|
|
@@ -702,6 +739,12 @@ export interface ResponseContractRuntime {
|
|
|
702
739
|
readonly jsonBody?: unknown
|
|
703
740
|
},
|
|
704
741
|
): ResponseContractOutcome | Promise<ResponseContractOutcome>
|
|
742
|
+
/**
|
|
743
|
+
* What a `c.json(body)` reply with this status writes, each `toJSON` hook applied as `check` judges
|
|
744
|
+
* it, so the reply's bytes and its check share one call of each. Without it, as from a plugin of
|
|
745
|
+
* an older copy of core, the reply writes `body` as given.
|
|
746
|
+
*/
|
|
747
|
+
write?(definition: ResponseContractDefinition, body: unknown, status: number | undefined): unknown
|
|
705
748
|
}
|
|
706
749
|
|
|
707
750
|
interface ResponseContractInstallable {
|
|
@@ -772,6 +815,10 @@ export function responseContract(
|
|
|
772
815
|
}
|
|
773
816
|
const runtime: ResponseContractRuntime = {
|
|
774
817
|
mode,
|
|
818
|
+
write: (definition, body, status) => {
|
|
819
|
+
const schema = schemaFor(definition, status ?? 200)
|
|
820
|
+
return schema === undefined ? body : wireValueOf(body, jsonSchemaOf(schema) !== undefined)
|
|
821
|
+
},
|
|
775
822
|
check: (definition, result, context) => {
|
|
776
823
|
const jsonReply =
|
|
777
824
|
result instanceof Response && result === context?.jsonReply
|
|
@@ -26,6 +26,9 @@ export const CONTEXT_SET = Symbol("nifra.context.set")
|
|
|
26
26
|
export const CONTEXT_SEARCH = Symbol("nifra.context.search")
|
|
27
27
|
/** The request a context answers, read without the transport cap `req` applies for user code. */
|
|
28
28
|
export const CONTEXT_REQUEST = Symbol("nifra.context.request")
|
|
29
|
+
/** Sets the idle timeout of the connection a context's request arrived on (`undefined` puts back
|
|
30
|
+
* the listener's own); `false` when the serving adapter has none to set. */
|
|
31
|
+
export const CONTEXT_IDLE_TIMEOUT = Symbol()
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
34
|
* A transport codec (or other pre-parsing hook) that already decoded the body stashes the value
|
|
@@ -114,21 +117,20 @@ export function isResponseResult(value: unknown): value is ResponseResult {
|
|
|
114
117
|
}
|
|
115
118
|
|
|
116
119
|
/**
|
|
117
|
-
* The headers a plain render ships: its own on top of
|
|
118
|
-
* so an ambient header (a request id, say) survives an early exit the way it
|
|
119
|
-
* return, and a header named at the exit site still wins.
|
|
120
|
+
* The headers a plain render ships: its `own` on top of the `ambient` ones the request left in
|
|
121
|
+
* `c.set.headers`, so an ambient header (a request id, say) survives an early exit the way it
|
|
122
|
+
* survives an ordinary return, and a header named at the exit site still wins.
|
|
120
123
|
*
|
|
121
124
|
* Always a fresh object when the render has headers of its own: the node writers mutate the record
|
|
122
125
|
* they are handed (content-type, content-length, cookies), and a `status(...)` value is commonly
|
|
123
126
|
* hoisted to module scope and answered from on every request.
|
|
124
127
|
*/
|
|
125
128
|
export function plainRenderHeaders(
|
|
126
|
-
|
|
127
|
-
|
|
129
|
+
own: Readonly<Record<string, string>> | undefined,
|
|
130
|
+
ambient: Record<string, string> | undefined,
|
|
128
131
|
): Record<string, string> | undefined {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return set._headers === undefined ? { ...own } : { ...set._headers, ...own }
|
|
132
|
+
if (own === undefined) return ambient
|
|
133
|
+
return ambient === undefined ? { ...own } : { ...ambient, ...own }
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
/**
|
|
@@ -90,17 +90,32 @@ export interface ServerOptions {
|
|
|
90
90
|
*/
|
|
91
91
|
readonly trustBodyFraming?: boolean
|
|
92
92
|
/**
|
|
93
|
-
* Answer `400 invalid_host`
|
|
94
|
-
*
|
|
95
|
-
* takes
|
|
96
|
-
*
|
|
97
|
-
*
|
|
93
|
+
* Answer `400 invalid_host` for Hosts the request target cannot safely be built on: one sent
|
|
94
|
+
* twice, one with a space or byte past ASCII, a port past 65535, an IPv6 literal no URL parser
|
|
95
|
+
* takes, a percent-encoded or sub-delimiter Host, or an authority that can change Deno's route.
|
|
96
|
+
* Bun may leave `c.req.url` relative or build it on the first Host, so `new URL(c.req.url)` can
|
|
97
|
+
* throw in a handler.
|
|
98
98
|
*
|
|
99
|
-
* Disabled by default:
|
|
100
|
-
*
|
|
101
|
-
*
|
|
99
|
+
* Disabled by default: checking Host on every request measured about 6% of a bare route on Bun.
|
|
100
|
+
* Deno builds `Request.url` from Host; without this check, a malformed Host can change the routed
|
|
101
|
+
* path. Enable it when Deno receives requests with an untrusted Host header.
|
|
102
102
|
*/
|
|
103
103
|
readonly validateHost?: boolean
|
|
104
|
+
/**
|
|
105
|
+
* Route a Deno request on the path a WHATWG parser would give it. Bun and workerd deliver a parsed
|
|
106
|
+
* URL, so `/public/../admin` reaches the app as `/admin`; Deno hands over the target as sent. Off,
|
|
107
|
+
* Deno routes the text received, as most frameworks do: a guard on `/admin` does not see
|
|
108
|
+
* `/public/../admin`, and a proxy that normalizes paths before forwarding disagrees with the app
|
|
109
|
+
* about which route a request is for. On, dot segments (`.`, `..`, `%2e`), backslashes and the
|
|
110
|
+
* characters a parser percent-encodes are resolved before routing, so every runtime routes the
|
|
111
|
+
* same path.
|
|
112
|
+
*
|
|
113
|
+
* Disabled by default: the check reads and scans the whole URL of every request, measured at about
|
|
114
|
+
* 3% of a bare route on Deno. Enable it when path guards, or a path-normalizing proxy, sit in
|
|
115
|
+
* front of or inside the app. Bun already delivers parsed targets and is unaffected; `@nifrajs/node`
|
|
116
|
+
* has the same option on `serve()`.
|
|
117
|
+
*/
|
|
118
|
+
readonly normalizeTarget?: boolean
|
|
104
119
|
/**
|
|
105
120
|
* Max inbound WebSocket message size (bytes). A larger message never reaches the handler and the
|
|
106
121
|
* socket closes with `1009`; when Bun's own frame limit refuses it, Bun up to 1.4.3 drops the
|