@nifrajs/core 4.0.2 → 5.1.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/README.md +6 -2
- package/dist/assurance.d.ts +86 -6
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +186 -95
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +8 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +53 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/durable-effect.js +1 -1
- package/dist/durable-effect.js.map +1 -1
- package/dist/durable-saga.d.ts +6 -0
- package/dist/durable-saga.d.ts.map +1 -1
- package/dist/durable-saga.js +83 -10
- package/dist/durable-saga.js.map +1 -1
- package/dist/edge-kit.d.ts +8 -3
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +7 -2
- package/dist/edge-kit.js.map +1 -1
- package/dist/effect-scope.d.ts.map +1 -1
- package/dist/effect-scope.js +16 -5
- package/dist/effect-scope.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/evidence.d.ts +2 -0
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +9 -1
- package/dist/evidence.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/internal/durable-shared.d.ts +1 -2
- package/dist/internal/durable-shared.d.ts.map +1 -1
- package/dist/internal/durable-shared.js +25 -44
- package/dist/internal/durable-shared.js.map +1 -1
- package/dist/internal/durable-types.d.ts +3 -0
- package/dist/internal/durable-types.d.ts.map +1 -1
- package/dist/internal/hook-origins.d.ts +67 -0
- package/dist/internal/hook-origins.d.ts.map +1 -0
- package/dist/internal/hook-origins.js +86 -0
- package/dist/internal/hook-origins.js.map +1 -0
- package/dist/internal/route-assurance.d.ts +6 -3
- package/dist/internal/route-assurance.d.ts.map +1 -1
- package/dist/internal/route-assurance.js +29 -14
- package/dist/internal/route-assurance.js.map +1 -1
- package/dist/internal/route-compiler.d.ts +9 -0
- package/dist/internal/route-compiler.d.ts.map +1 -1
- package/dist/internal/route-compiler.js +95 -13
- package/dist/internal/route-compiler.js.map +1 -1
- package/dist/internal/route-program.d.ts +1 -1
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +59 -16
- package/dist/internal/route-program.js.map +1 -1
- package/dist/internal/same-origin.d.ts +11 -1
- package/dist/internal/same-origin.d.ts.map +1 -1
- package/dist/internal/same-origin.js +8 -0
- package/dist/internal/same-origin.js.map +1 -1
- package/dist/reconciliation-worker.d.ts.map +1 -1
- package/dist/reconciliation-worker.js +12 -2
- package/dist/reconciliation-worker.js.map +1 -1
- package/dist/reflection.d.ts +53 -2
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +352 -5
- package/dist/reflection.js.map +1 -1
- package/dist/router/pattern.d.ts +1 -1
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +6 -3
- package/dist/router/pattern.js.map +1 -1
- package/dist/security/baseline.d.ts +3 -0
- package/dist/security/baseline.d.ts.map +1 -1
- package/dist/security/baseline.js +9 -5
- package/dist/security/baseline.js.map +1 -1
- package/dist/server/auth.d.ts +32 -3
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +23 -0
- package/dist/server/auth.js.map +1 -1
- package/dist/server/body-lane.d.ts +8 -2
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +27 -2
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body.d.ts +7 -0
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +34 -1
- package/dist/server/body.js.map +1 -1
- package/dist/server/client-ip.d.ts +3 -0
- package/dist/server/client-ip.d.ts.map +1 -1
- package/dist/server/client-ip.js +6 -5
- package/dist/server/client-ip.js.map +1 -1
- package/dist/server/context.d.ts +2 -1
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +13 -1
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +2 -1
- package/dist/server/edge.js.map +1 -1
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +5 -1
- package/dist/server/http.js.map +1 -1
- package/dist/server/idempotency-lane.d.ts +6 -3
- package/dist/server/idempotency-lane.d.ts.map +1 -1
- package/dist/server/idempotency-lane.js +38 -17
- package/dist/server/idempotency-lane.js.map +1 -1
- package/dist/server/install.d.ts +2 -0
- package/dist/server/install.d.ts.map +1 -1
- package/dist/server/install.js +2 -0
- package/dist/server/install.js.map +1 -1
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/logger.js +3 -1
- package/dist/server/logger.js.map +1 -1
- package/dist/server/methods.d.ts +1 -0
- package/dist/server/methods.d.ts.map +1 -1
- package/dist/server/methods.js +1 -0
- package/dist/server/methods.js.map +1 -1
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +29 -11
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/platform-adapters.d.ts.map +1 -1
- package/dist/server/platform-adapters.js +3 -2
- package/dist/server/platform-adapters.js.map +1 -1
- package/dist/server/request-context.d.ts +2 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +4 -1
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +44 -11
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +192 -79
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +21 -6
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +139 -14
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-observer-runtime.d.ts.map +1 -1
- package/dist/server/response-observer-runtime.js +10 -41
- package/dist/server/response-observer-runtime.js.map +1 -1
- package/dist/server/runtime-core.d.ts +2 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +2 -0
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +45 -10
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +67 -32
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +481 -334
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts +11 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +18 -3
- package/dist/server/sse.js.map +1 -1
- package/dist/server/transport-lane.d.ts +7 -1
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/webhook.d.ts +5 -2
- package/dist/server/webhook.d.ts.map +1 -1
- package/dist/server/webhook.js +17 -1
- package/dist/server/webhook.js.map +1 -1
- package/dist/server/websocket.d.ts +33 -14
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +47 -12
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts +7 -1
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +42 -2
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server/ws-hook.d.ts +15 -3
- package/dist/server/ws-hook.d.ts.map +1 -1
- package/dist/server.d.ts +4 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -2
- package/dist/server.js.map +1 -1
- package/dist/single-copy.d.ts +6 -0
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +55 -6
- package/dist/single-copy.js.map +1 -1
- package/dist/tool-contract.d.ts.map +1 -1
- package/dist/tool-contract.js +71 -40
- package/dist/tool-contract.js.map +1 -1
- package/dist/wire.d.ts +5 -0
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js +5 -0
- package/dist/wire.js.map +1 -1
- package/dist/ws.d.ts.map +1 -1
- package/dist/ws.js +4 -1
- package/dist/ws.js.map +1 -1
- package/package.json +1 -1
- package/src/assurance.ts +299 -111
- package/src/capabilities.ts +62 -1
- package/src/durable-effect.ts +1 -1
- package/src/durable-saga.ts +94 -10
- package/src/edge-kit.ts +8 -3
- package/src/effect-scope.ts +15 -5
- package/src/errors.ts +1 -0
- package/src/evidence.ts +12 -3
- package/src/index.ts +7 -2
- package/src/internal/durable-shared.ts +25 -37
- package/src/internal/durable-types.ts +3 -0
- package/src/internal/hook-origins.ts +153 -0
- package/src/internal/route-assurance.ts +32 -18
- package/src/internal/route-compiler.ts +107 -16
- package/src/internal/route-program.ts +98 -44
- package/src/internal/same-origin.ts +14 -1
- package/src/reconciliation-worker.ts +10 -2
- package/src/reflection.ts +455 -4
- package/src/router/pattern.ts +6 -3
- package/src/security/baseline.ts +10 -5
- package/src/server/auth.ts +45 -3
- package/src/server/body-lane.ts +36 -1
- package/src/server/body.ts +34 -1
- package/src/server/client-ip.ts +8 -5
- package/src/server/context.ts +2 -1
- package/src/server/cookies.ts +12 -1
- package/src/server/edge.ts +2 -1
- package/src/server/http.ts +7 -1
- package/src/server/idempotency-lane.ts +64 -22
- package/src/server/install.ts +3 -0
- package/src/server/logger.ts +3 -1
- package/src/server/methods.ts +1 -0
- package/src/server/node-outcome.ts +34 -12
- package/src/server/platform-adapters.ts +3 -2
- package/src/server/request-context.ts +5 -0
- package/src/server/respond.ts +222 -69
- package/src/server/response-contract-lane.ts +162 -10
- package/src/server/response-observer-runtime.ts +16 -38
- package/src/server/runtime-core.ts +2 -0
- package/src/server/server-types.ts +45 -10
- package/src/server/server.ts +707 -539
- package/src/server/sse.ts +84 -55
- package/src/server/transport-lane.ts +7 -1
- package/src/server/webhook.ts +22 -3
- package/src/server/websocket.ts +83 -18
- package/src/server/ws-bun.ts +48 -1
- package/src/server/ws-hook.ts +14 -2
- package/src/server.ts +6 -1
- package/src/single-copy.ts +55 -5
- package/src/tool-contract.ts +67 -39
- package/src/wire.ts +14 -0
- package/src/ws.ts +6 -0
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
*
|
|
29
29
|
* That is not an inconsistency to paper over: a strict schema has already declared that extra fields
|
|
30
30
|
* are an error, and a stripping one has declared they are ignorable. Enforcement honours what the
|
|
31
|
-
* author wrote rather than overriding it
|
|
31
|
+
* author wrote rather than overriding it - unless the app asks for `extra: "strip"`. A schema that
|
|
32
|
+
* carries its JSON Schema as `jsonSchema` (every `t` schema does) CAN list its declared keys, so for
|
|
33
|
+
* those the payload is projected onto the declared shape before it is validated.
|
|
32
34
|
*
|
|
33
35
|
* ## What enforcement costs
|
|
34
36
|
*
|
|
@@ -62,6 +64,18 @@ import type { AnyServer } from "./server.ts"
|
|
|
62
64
|
*/
|
|
63
65
|
export type ResponseContractMode = "warn" | "enforce"
|
|
64
66
|
|
|
67
|
+
export interface ResponseContractOptions {
|
|
68
|
+
/**
|
|
69
|
+
* Fields the schema does not declare. `"reject"` (the default) leaves them to the validator: a
|
|
70
|
+
* closed `t.object` reports them, so an enforced response becomes a 500 and the log names them.
|
|
71
|
+
* `"strip"` drops them first, for any schema exposing its JSON Schema as `jsonSchema` (every
|
|
72
|
+
* `@nifrajs/schema` `t` schema) - nested objects, arrays and records included. A union is projected
|
|
73
|
+
* only when exactly one branch can hold an object. Schemas without `jsonSchema` (zod, valibot) are
|
|
74
|
+
* unaffected: they already strip or reject by their own rules.
|
|
75
|
+
*/
|
|
76
|
+
readonly extra?: "reject" | "strip"
|
|
77
|
+
}
|
|
78
|
+
|
|
65
79
|
/** Success and status-specific error schemas captured by one route at registration time. */
|
|
66
80
|
export interface ResponseContractDefinition {
|
|
67
81
|
readonly response?: StandardSchemaV1
|
|
@@ -80,6 +94,127 @@ export type ResponseContractOutcome =
|
|
|
80
94
|
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
|
81
95
|
typeof v === "object" && v !== null && !Array.isArray(v)
|
|
82
96
|
|
|
97
|
+
/** Rebuilds a value with only what its schema declares; absent when the schema can strip nothing. */
|
|
98
|
+
type Projector = (value: unknown) => unknown
|
|
99
|
+
|
|
100
|
+
const projectors = new WeakMap<object, Projector | null>()
|
|
101
|
+
|
|
102
|
+
/** The projector for a schema's JSON Schema, compiled once per schema. */
|
|
103
|
+
function projectorOf(schema: StandardSchemaV1): Projector | undefined {
|
|
104
|
+
const node: unknown = Reflect.get(schema, "jsonSchema")
|
|
105
|
+
if (typeof node !== "object" || node === null) return undefined
|
|
106
|
+
let found = projectors.get(node)
|
|
107
|
+
if (found === undefined) {
|
|
108
|
+
found = compileProjector(node) ?? null
|
|
109
|
+
projectors.set(node, found)
|
|
110
|
+
}
|
|
111
|
+
return found ?? undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const canHoldObject = (node: object): boolean => {
|
|
115
|
+
const type: unknown = Reflect.get(node, "type")
|
|
116
|
+
return type === undefined || type === "object" || type === "array"
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const objectField = (node: object, key: string): object | undefined => {
|
|
120
|
+
const value: unknown = Reflect.get(node, key)
|
|
121
|
+
return typeof value === "object" && value !== null ? value : undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function compileProjector(node: unknown): Projector | undefined {
|
|
125
|
+
if (typeof node !== "object" || node === null) return undefined
|
|
126
|
+
const schema: object = node
|
|
127
|
+
const type: unknown = Reflect.get(schema, "type")
|
|
128
|
+
const branches: unknown = Reflect.get(schema, "anyOf") ?? Reflect.get(schema, "oneOf")
|
|
129
|
+
if (Array.isArray(branches)) {
|
|
130
|
+
// Which branch a value meets is the validator's call; projecting onto one of two object branches
|
|
131
|
+
// could strip the fields that make the other match.
|
|
132
|
+
const holders = branches.filter(
|
|
133
|
+
(branch): branch is object =>
|
|
134
|
+
typeof branch === "object" && branch !== null && canHoldObject(branch),
|
|
135
|
+
)
|
|
136
|
+
const only = holders.length === 1 ? compileProjector(holders[0]) : undefined
|
|
137
|
+
return only === undefined
|
|
138
|
+
? undefined
|
|
139
|
+
: (value) => (typeof value === "object" && value !== null ? only(value) : value)
|
|
140
|
+
}
|
|
141
|
+
if (type === "array") {
|
|
142
|
+
const items: unknown = Reflect.get(schema, "items")
|
|
143
|
+
const item = Array.isArray(items) ? undefined : compileProjector(items)
|
|
144
|
+
return item === undefined
|
|
145
|
+
? undefined
|
|
146
|
+
: (value) => (Array.isArray(value) ? value.map(item) : value)
|
|
147
|
+
}
|
|
148
|
+
if (type !== "object") return undefined
|
|
149
|
+
const properties = objectField(schema, "properties") ?? {}
|
|
150
|
+
const keys = Object.keys(properties)
|
|
151
|
+
const nested = new Map<string, Projector>()
|
|
152
|
+
for (const key of keys) {
|
|
153
|
+
const projector = compileProjector(Reflect.get(properties, key))
|
|
154
|
+
if (projector !== undefined) nested.set(key, projector)
|
|
155
|
+
}
|
|
156
|
+
const patterns: Array<readonly [RegExp, Projector | undefined]> = []
|
|
157
|
+
for (const [source, child] of Object.entries(objectField(schema, "patternProperties") ?? {})) {
|
|
158
|
+
let pattern: RegExp
|
|
159
|
+
try {
|
|
160
|
+
pattern = new RegExp(source, "u")
|
|
161
|
+
} catch {
|
|
162
|
+
return undefined // a pattern this engine can't read: the validator judges the value whole
|
|
163
|
+
}
|
|
164
|
+
patterns.push([pattern, compileProjector(child)])
|
|
165
|
+
}
|
|
166
|
+
const closed = Reflect.get(schema, "additionalProperties") === false
|
|
167
|
+
if (!closed && nested.size === 0 && patterns.every(([, projector]) => projector === undefined))
|
|
168
|
+
return undefined
|
|
169
|
+
const declared = new Set(keys)
|
|
170
|
+
return (value) => {
|
|
171
|
+
if (!isPlainObject(value)) return value
|
|
172
|
+
const out: Record<string, unknown> = closed ? {} : { ...value }
|
|
173
|
+
for (const key of closed ? keys : nested.keys()) {
|
|
174
|
+
// A getter on a row class's prototype is a declared field; `Object.prototype`'s members are not.
|
|
175
|
+
if (!Object.hasOwn(value, key) && (!(key in value) || key in Object.prototype)) continue
|
|
176
|
+
const projector = nested.get(key)
|
|
177
|
+
define(out, key, projector === undefined ? value[key] : projector(value[key]))
|
|
178
|
+
}
|
|
179
|
+
if (patterns.length > 0) projectPatternKeys(value, out, declared, patterns, closed)
|
|
180
|
+
return out
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Carry the undeclared keys a `patternProperties` entry admits: a closed record's keys are all
|
|
186
|
+
* pattern keys, so dropping them empties a valid value. A key two patterns match is copied verbatim,
|
|
187
|
+
* since projecting it onto one could strip what the other requires.
|
|
188
|
+
*/
|
|
189
|
+
function projectPatternKeys(
|
|
190
|
+
value: Record<string, unknown>,
|
|
191
|
+
out: Record<string, unknown>,
|
|
192
|
+
declared: ReadonlySet<string>,
|
|
193
|
+
patterns: ReadonlyArray<readonly [RegExp, Projector | undefined]>,
|
|
194
|
+
closed: boolean,
|
|
195
|
+
): void {
|
|
196
|
+
for (const key of Object.keys(value)) {
|
|
197
|
+
if (declared.has(key)) continue
|
|
198
|
+
let projector: Projector | undefined
|
|
199
|
+
let matches = 0
|
|
200
|
+
for (const [pattern, child] of patterns)
|
|
201
|
+
if (pattern.test(key)) {
|
|
202
|
+
projector = child
|
|
203
|
+
matches++
|
|
204
|
+
}
|
|
205
|
+
if (matches === 0) continue
|
|
206
|
+
if (matches === 1 && projector !== undefined) define(out, key, projector(value[key]))
|
|
207
|
+
else if (closed) define(out, key, value[key])
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** `__proto__` is a declared name like any other here; plain assignment would set the prototype. */
|
|
212
|
+
function define(out: Record<string, unknown>, key: string, value: unknown): void {
|
|
213
|
+
if (key === "__proto__")
|
|
214
|
+
Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true })
|
|
215
|
+
else out[key] = value
|
|
216
|
+
}
|
|
217
|
+
|
|
83
218
|
/** Top-level keys present in the handler's result but absent from the validated value. */
|
|
84
219
|
function droppedKeys(result: unknown, value: unknown): string[] {
|
|
85
220
|
if (!isPlainObject(result) || !isPlainObject(value)) return []
|
|
@@ -96,8 +231,9 @@ export function checkResponseContract(
|
|
|
96
231
|
schema: StandardSchemaV1,
|
|
97
232
|
result: unknown,
|
|
98
233
|
mode: "warn" | "enforce",
|
|
234
|
+
options?: ResponseContractOptions,
|
|
99
235
|
): ResponseContractOutcome | Promise<ResponseContractOutcome> {
|
|
100
|
-
return checkRouteResponseContract({ response: schema }, result, mode)
|
|
236
|
+
return checkRouteResponseContract({ response: schema }, result, mode, undefined, options)
|
|
101
237
|
}
|
|
102
238
|
|
|
103
239
|
/** The schema a reply with this status is held to: `response` for 2xx, else `errors[status]`. */
|
|
@@ -127,12 +263,14 @@ export function checkRouteResponseContract(
|
|
|
127
263
|
result: unknown,
|
|
128
264
|
mode: "warn" | "enforce",
|
|
129
265
|
jsonReply?: { readonly body: unknown },
|
|
266
|
+
options?: ResponseContractOptions,
|
|
130
267
|
): ResponseContractOutcome | Promise<ResponseContractOutcome> {
|
|
268
|
+
const strip = options?.extra === "strip"
|
|
131
269
|
const plain = isResponseResult(result) ? result.plain : undefined
|
|
132
270
|
if (plain !== undefined) {
|
|
133
271
|
const schema = schemaFor(definition, plain.status)
|
|
134
272
|
if (schema === undefined) return { kind: "ok", value: result }
|
|
135
|
-
return validateContract(schema, plain.body, result, mode, (value) =>
|
|
273
|
+
return validateContract(schema, plain.body, result, mode, strip, (value) =>
|
|
136
274
|
status(
|
|
137
275
|
plain.status,
|
|
138
276
|
value,
|
|
@@ -143,7 +281,7 @@ export function checkRouteResponseContract(
|
|
|
143
281
|
if (jsonReply !== undefined && result instanceof Response) {
|
|
144
282
|
const schema = schemaFor(definition, result.status)
|
|
145
283
|
if (schema === undefined) return { kind: "ok", value: result }
|
|
146
|
-
return validateContract(schema, jsonReply.body, result, mode, (value) =>
|
|
284
|
+
return validateContract(schema, jsonReply.body, result, mode, strip, (value) =>
|
|
147
285
|
reserialize(result, value),
|
|
148
286
|
)
|
|
149
287
|
}
|
|
@@ -155,7 +293,7 @@ export function checkRouteResponseContract(
|
|
|
155
293
|
if (result instanceof Response || result === undefined) return { kind: "ok", value: result }
|
|
156
294
|
const schema = definition.response
|
|
157
295
|
if (schema === undefined) return { kind: "ok", value: result }
|
|
158
|
-
return validateContract(schema, result, result, mode, (value) => value)
|
|
296
|
+
return validateContract(schema, result, result, mode, strip, (value) => value)
|
|
159
297
|
}
|
|
160
298
|
|
|
161
299
|
/** Validate `payload`; `served` is what goes out unchanged when nothing is enforced - the whole
|
|
@@ -165,9 +303,11 @@ function validateContract(
|
|
|
165
303
|
payload: unknown,
|
|
166
304
|
served: unknown,
|
|
167
305
|
mode: "warn" | "enforce",
|
|
306
|
+
strip: boolean,
|
|
168
307
|
replace: (value: unknown) => unknown,
|
|
169
308
|
): ResponseContractOutcome | Promise<ResponseContractOutcome> {
|
|
170
|
-
const
|
|
309
|
+
const project = strip ? projectorOf(schema) : undefined
|
|
310
|
+
const settled = schema["~standard"].validate(project === undefined ? payload : project(payload))
|
|
171
311
|
return settled instanceof Promise
|
|
172
312
|
? settled.then((r) => interpret(r, payload, served, mode, replace))
|
|
173
313
|
: interpret(settled, payload, served, mode, replace)
|
|
@@ -195,7 +335,7 @@ function interpret(
|
|
|
195
335
|
: {
|
|
196
336
|
kind: "warn",
|
|
197
337
|
value: served,
|
|
198
|
-
message: `response carries fields its contract does not declare: ${dropped.join(", ")}. They are being sent; \`responseContract
|
|
338
|
+
message: `response carries fields its contract does not declare: ${dropped.join(", ")}. They are being sent; \`responseContract("enforce")\` would strip them.`,
|
|
199
339
|
}
|
|
200
340
|
}
|
|
201
341
|
|
|
@@ -219,10 +359,12 @@ interface ResponseContractInstallable {
|
|
|
219
359
|
/**
|
|
220
360
|
* Hold every route's declared `response` schema to what the handler actually returned.
|
|
221
361
|
*
|
|
222
|
-
* app.use(responseContract("enforce"))
|
|
362
|
+
* app.use(responseContract("enforce")) // a closed t.object -> 500 on extras
|
|
363
|
+
* app.use(responseContract("enforce", { extra: "strip" })) // extras dropped, declared fields sent
|
|
223
364
|
*
|
|
224
365
|
* Install it before the routes it should cover - like `idempotency()`, the decision is made per route
|
|
225
|
-
* at registration, so routes registered earlier are not retroactively covered.
|
|
366
|
+
* at registration, so routes registered earlier are not retroactively covered. It covers routes on
|
|
367
|
+
* the server it is installed on, its `group()`s, and servers `merge()`d into it afterwards.
|
|
226
368
|
*
|
|
227
369
|
* The check itself is cheap - with a compiled validator it measures in the ~100ns-per-response
|
|
228
370
|
* range, within benchmark noise of an uncontracted route on any route that carries middleware or a
|
|
@@ -231,7 +373,16 @@ interface ResponseContractInstallable {
|
|
|
231
373
|
* taking anyway. Opt-in because not installing the plugin keeps the lane out of the bundle, not
|
|
232
374
|
* because enforcement is expensive.
|
|
233
375
|
*/
|
|
234
|
-
export function responseContract(
|
|
376
|
+
export function responseContract(
|
|
377
|
+
mode: ResponseContractMode = "warn",
|
|
378
|
+
options: ResponseContractOptions = {},
|
|
379
|
+
): IdentityPlugin {
|
|
380
|
+
if (options.extra !== undefined && options.extra !== "reject" && options.extra !== "strip") {
|
|
381
|
+
throw new TypeError(
|
|
382
|
+
`responseContract(): extra must be "reject" or "strip", got ${JSON.stringify(options.extra)}`,
|
|
383
|
+
)
|
|
384
|
+
}
|
|
385
|
+
const contractOptions = options.extra === "strip" ? options : undefined
|
|
235
386
|
const runtime: ResponseContractRuntime = {
|
|
236
387
|
mode,
|
|
237
388
|
check: (definition, result, context) =>
|
|
@@ -242,6 +393,7 @@ export function responseContract(mode: ResponseContractMode = "warn"): IdentityP
|
|
|
242
393
|
result instanceof Response && result === context?.jsonReply
|
|
243
394
|
? { body: context.jsonBody }
|
|
244
395
|
: undefined,
|
|
396
|
+
contractOptions,
|
|
245
397
|
),
|
|
246
398
|
}
|
|
247
399
|
const apply = <S extends AnyServer>(app: S): S => {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { tagSource } from "../internal/hook-origins.ts"
|
|
1
2
|
import type {
|
|
2
3
|
NodeRequestContext,
|
|
3
4
|
NodeResponseBodyHook,
|
|
@@ -7,7 +8,7 @@ import type {
|
|
|
7
8
|
ResponseHeadersView,
|
|
8
9
|
} from "./node-outcome-hook.ts"
|
|
9
10
|
import { recordHeadersView } from "./node-outcome-hook.ts"
|
|
10
|
-
import {
|
|
11
|
+
import { taggedResponseBody, writableResponse } from "./respond.ts"
|
|
11
12
|
import { applyBodyReplacement, withReplacedBody } from "./response-hooks.ts"
|
|
12
13
|
|
|
13
14
|
type MaybePromise<T> = T | Promise<T>
|
|
@@ -19,6 +20,9 @@ export type {
|
|
|
19
20
|
ResponseHeadersHook,
|
|
20
21
|
} from "./node-outcome-hook.ts"
|
|
21
22
|
|
|
23
|
+
// Deno reports `process.env` reads as `NotCapable` without --allow-env; read it only off Deno.
|
|
24
|
+
declare const Deno: unknown
|
|
25
|
+
|
|
22
26
|
export interface ResponseObserverHost {
|
|
23
27
|
assertConfigurable(operation: string): void
|
|
24
28
|
addResponseHook(
|
|
@@ -44,32 +48,6 @@ export interface ResponseObserverRuntime {
|
|
|
44
48
|
install(host: ResponseObserverHost): ResponseObserverMethods
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
const HEADER_MUTABILITY_PROBE = "x-nifra-header-probe"
|
|
48
|
-
const GUARDED_RESPONSE_HEADERS = new WeakSet<Headers>()
|
|
49
|
-
|
|
50
|
-
function hasMutableResponseHeaders(headers: Headers): boolean {
|
|
51
|
-
if (knownMutableHeaders(headers)) return true
|
|
52
|
-
if (GUARDED_RESPONSE_HEADERS.has(headers)) return false
|
|
53
|
-
let previous: string | null = null
|
|
54
|
-
try {
|
|
55
|
-
previous = headers.get(HEADER_MUTABILITY_PROBE)
|
|
56
|
-
headers.set(HEADER_MUTABILITY_PROBE, "1")
|
|
57
|
-
if (previous === null) headers.delete(HEADER_MUTABILITY_PROBE)
|
|
58
|
-
else headers.set(HEADER_MUTABILITY_PROBE, previous)
|
|
59
|
-
rememberMutableHeaders(headers)
|
|
60
|
-
return true
|
|
61
|
-
} catch {
|
|
62
|
-
try {
|
|
63
|
-
if (previous === null) headers.delete(HEADER_MUTABILITY_PROBE)
|
|
64
|
-
else headers.set(HEADER_MUTABILITY_PROBE, previous)
|
|
65
|
-
} catch {
|
|
66
|
-
// A guarded response rejected the probe and the clone path is authoritative.
|
|
67
|
-
}
|
|
68
|
-
GUARDED_RESPONSE_HEADERS.add(headers)
|
|
69
|
-
return false
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
51
|
function webRequestView(req: Request): NodeRequestContext {
|
|
74
52
|
return { method: req.method, url: req.url, header: (name) => req.headers.get(name) }
|
|
75
53
|
}
|
|
@@ -82,12 +60,8 @@ function webHeadersView(
|
|
|
82
60
|
readonly headers: ResponseHeadersView
|
|
83
61
|
readonly request: NodeRequestContext
|
|
84
62
|
} {
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
const clone = new Response(response.body, response)
|
|
88
|
-
return { response: clone, headers: clone.headers, request }
|
|
89
|
-
}
|
|
90
|
-
return { response, headers: response.headers, request }
|
|
63
|
+
const writable = writableResponse(response, req)
|
|
64
|
+
return { response: writable, headers: writable.headers, request: webRequestView(req) }
|
|
91
65
|
}
|
|
92
66
|
|
|
93
67
|
export function createResponseObserverRuntime(): ResponseObserverRuntime {
|
|
@@ -100,12 +74,16 @@ export function createResponseObserverRuntime(): ResponseObserverRuntime {
|
|
|
100
74
|
node?: NodeResponseHook,
|
|
101
75
|
): ResponseObserverMethods {
|
|
102
76
|
host.assertConfigurable("onResponseHeaders()")
|
|
77
|
+
const web: Parameters<typeof host.addResponseHook>[0] = (response, req) => {
|
|
78
|
+
const view = webHeadersView(response, req)
|
|
79
|
+
const out = fn(view.headers, view.request, response.status)
|
|
80
|
+
return out instanceof Promise ? out.then(() => view.response) : view.response
|
|
81
|
+
}
|
|
82
|
+
// Development-only provenance for `reflectHooks`; a production define folds it away.
|
|
83
|
+
if (typeof Deno === "undefined" && process.env.NODE_ENV !== "production")
|
|
84
|
+
tagSource(web, fn)
|
|
103
85
|
host.addResponseHook(
|
|
104
|
-
|
|
105
|
-
const view = webHeadersView(response, req)
|
|
106
|
-
const out = fn(view.headers, view.request, response.status)
|
|
107
|
-
return out instanceof Promise ? out.then(() => view.response) : view.response
|
|
108
|
-
},
|
|
86
|
+
web,
|
|
109
87
|
node ?? ((response, req) => fn(recordHeadersView(response), req, response.status)),
|
|
110
88
|
true,
|
|
111
89
|
)
|
|
@@ -14,6 +14,8 @@ export type HandlerResult = Response | unknown
|
|
|
14
14
|
export const RESPONSE_RESULT = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseResult)
|
|
15
15
|
export const CONTEXT_SET = Symbol("nifra.context.set")
|
|
16
16
|
export const CONTEXT_SEARCH = Symbol("nifra.context.search")
|
|
17
|
+
/** The request a context answers, read without the transport cap `req` applies for user code. */
|
|
18
|
+
export const CONTEXT_REQUEST = Symbol("nifra.context.request")
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* A transport codec (or other pre-parsing hook) that already decoded the body stashes the value
|
|
@@ -59,10 +59,18 @@ export interface ServerOptions {
|
|
|
59
59
|
* `__proto__` key, or a `constructor` carrying a `prototype`, is the payload shape that turns a
|
|
60
60
|
* later innocent merge/assign into prototype pollution. `"reject"` (default) answers the same
|
|
61
61
|
* flat 400 as malformed JSON; `"strip"` removes the offending keys and continues; `"ignore"`
|
|
62
|
-
* parses as-is (only for routes that never merge body input into other objects).
|
|
63
|
-
*
|
|
62
|
+
* parses as-is (only for routes that never merge body input into other objects). Every guarded
|
|
63
|
+
* payload pays one iterative pass over its parsed object nodes; `"ignore"` skips it.
|
|
64
64
|
*/
|
|
65
65
|
readonly protoPoisoning?: "reject" | "strip" | "ignore"
|
|
66
|
+
/**
|
|
67
|
+
* Other sites whose HTML forms may post to a body-schema route: exact origins, or a function that
|
|
68
|
+
* receives the `Origin`. A urlencoded, multipart or `text/plain` body is one a form on any site can
|
|
69
|
+
* send with the visitor's cookies and no preflight, so one whose `Origin` names another site is
|
|
70
|
+
* refused with `403` before it is read. Same-origin posts and requests without an `Origin`, such
|
|
71
|
+
* as server-to-server calls, pass. JSON is not affected: a browser preflights it, so CORS decides.
|
|
72
|
+
*/
|
|
73
|
+
readonly formOrigins?: ReadonlyArray<string> | ((origin: string) => boolean)
|
|
66
74
|
/**
|
|
67
75
|
* Declare that every `Request` reaching `app.fetch` was framed by a runtime HTTP parser, so its
|
|
68
76
|
* `Content-Length` is an exact transport boundary rather than a hint. Set this ONLY when the
|
|
@@ -81,9 +89,12 @@ export interface ServerOptions {
|
|
|
81
89
|
* untrusted input.
|
|
82
90
|
*/
|
|
83
91
|
readonly trustBodyFraming?: boolean
|
|
84
|
-
/**
|
|
85
|
-
*
|
|
86
|
-
* Default: `maxBodyBytes` (1 MB).
|
|
92
|
+
/**
|
|
93
|
+
* Max inbound WebSocket message size (bytes). A larger message never reaches the handler and the
|
|
94
|
+
* socket closes with `1009`. Default: `maxBodyBytes` (1 MB). Bun and Node stop buffering at the cap,
|
|
95
|
+
* so a huge frame is never held in memory; on Bun a mounted app's smaller cap, and on Deno and
|
|
96
|
+
* Workers every cap, is checked after the runtime has buffered the whole message.
|
|
97
|
+
*/
|
|
87
98
|
readonly wsMaxPayloadBytes?: number
|
|
88
99
|
/** Per-request timeout (ms): a slower request gets a 503 and `ctx.signal` aborts. 0 disables (default). */
|
|
89
100
|
readonly requestTimeoutMs?: number
|
|
@@ -109,8 +120,12 @@ export interface ServerOptions {
|
|
|
109
120
|
* Default 30_000; applies only when `acceptInboundDeadlines` is enabled and the header is present.
|
|
110
121
|
*/
|
|
111
122
|
readonly maxInboundDeadlineMs?: number
|
|
112
|
-
/**
|
|
113
|
-
|
|
123
|
+
/**
|
|
124
|
+
* When `listen()`ing, install SIGTERM/SIGINT handlers that gracefully `stop()`. Default false.
|
|
125
|
+
* `{ drainMs }` sets how long in-flight requests get to finish before the stop forces them closed
|
|
126
|
+
* (default 10_000) - keep it under the platform's kill grace period (`docker stop` waits 10 s).
|
|
127
|
+
*/
|
|
128
|
+
readonly gracefulSignals?: boolean | { readonly drainMs?: number }
|
|
114
129
|
/**
|
|
115
130
|
* Capacity-admission gate (see {@link AdmissionController}). Bounds concurrent in-flight work so a
|
|
116
131
|
* healthy instance sheds load (`429` + `Retry-After`) instead of accepting more than it can finish.
|
|
@@ -196,9 +211,29 @@ export interface MountOptions<Env = unknown> {
|
|
|
196
211
|
* Why this child is not analyzed, for a handler whose effects nifra cannot follow (a closure from
|
|
197
212
|
* another package). Capability assurance lists a mount with a reason as a known gap; a mount
|
|
198
213
|
* without one fails it, since its routes are invisible. A nifra `server()` belongs in `merge()`,
|
|
199
|
-
* where its routes are analyzed.
|
|
214
|
+
* where its routes are analyzed. Pass an {@link OpaqueMountDeclaration} to also state the routes
|
|
215
|
+
* the child serves.
|
|
200
216
|
*/
|
|
201
|
-
readonly opaque?: string
|
|
217
|
+
readonly opaque?: string | OpaqueMountDeclaration
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* An opaque mount's reason plus the routes it serves. Assurance lists those routes as declared, not
|
|
222
|
+
* analyzed: their capabilities are the mount's word, checked against the policy but never proven.
|
|
223
|
+
*/
|
|
224
|
+
export interface OpaqueMountDeclaration {
|
|
225
|
+
/** Why the child is not analyzed. A blank reason leaves the mount undeclared. */
|
|
226
|
+
readonly reason: string
|
|
227
|
+
readonly routes?: readonly DeclaredMountRoute[]
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** One route an opaque mount serves, as the app states it. */
|
|
231
|
+
export interface DeclaredMountRoute {
|
|
232
|
+
readonly method: string
|
|
233
|
+
/** The request path, from the app root, under the mount's prefix (`/api/auth/sign-in`). */
|
|
234
|
+
readonly path: string
|
|
235
|
+
/** Capability ids the route exercises. */
|
|
236
|
+
readonly capabilities?: readonly string[]
|
|
202
237
|
}
|
|
203
238
|
|
|
204
239
|
/** Options for a legacy fetch-handler mount. */
|
|
@@ -210,7 +245,7 @@ export interface MountFetchOptions {
|
|
|
210
245
|
/** For safe replayable methods only, try the next matching legacy mount when it returns 404. */
|
|
211
246
|
readonly fallbackOn?: 404
|
|
212
247
|
/** Why this handler is not analyzed - see {@link MountOptions.opaque}. */
|
|
213
|
-
readonly opaque?: string
|
|
248
|
+
readonly opaque?: string | OpaqueMountDeclaration
|
|
214
249
|
}
|
|
215
250
|
|
|
216
251
|
/** A callback awaited after the Bun server has drained and stopped. */
|