@nifrajs/core 5.1.0 → 5.3.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 +2409 -0
- package/dist/edge-kit.d.ts +1 -1
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +1 -1
- package/dist/edge-kit.js.map +1 -1
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +2 -0
- package/dist/idempotency.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/hook-origins.d.ts +17 -1
- package/dist/internal/hook-origins.d.ts.map +1 -1
- package/dist/internal/hook-origins.js +20 -0
- package/dist/internal/hook-origins.js.map +1 -1
- package/dist/internal/route-program.d.ts +2 -0
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +22 -17
- package/dist/internal/route-program.js.map +1 -1
- package/dist/mount.d.ts +12 -0
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +20 -0
- package/dist/mount.js.map +1 -1
- package/dist/reflection.d.ts +23 -2
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +57 -1
- package/dist/reflection.js.map +1 -1
- package/dist/server/context.d.ts +7 -4
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/core-copies.d.ts +7 -1
- package/dist/server/core-copies.d.ts.map +1 -1
- package/dist/server/core-copies.js +37 -1
- package/dist/server/core-copies.js.map +1 -1
- package/dist/server/headers.d.ts +9 -0
- package/dist/server/headers.d.ts.map +1 -1
- package/dist/server/headers.js +26 -0
- package/dist/server/headers.js.map +1 -1
- package/dist/server/registry.d.ts +8 -10
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +4 -3
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/request-target.d.ts +9 -7
- package/dist/server/request-target.d.ts.map +1 -1
- package/dist/server/request-target.js +21 -12
- package/dist/server/request-target.js.map +1 -1
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +10 -7
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +37 -2
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +278 -32
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/server-types.d.ts +7 -4
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +17 -4
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +184 -40
- package/dist/server/server.js.map +1 -1
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +4 -0
- package/dist/server/websocket.js.map +1 -1
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +46 -5
- package/dist/single-copy.js.map +1 -1
- package/dist/tool-contract.d.ts.map +1 -1
- package/dist/tool-contract.js +8 -4
- package/dist/tool-contract.js.map +1 -1
- package/dist/transport-codec.d.ts.map +1 -1
- package/dist/transport-codec.js +4 -1
- package/dist/transport-codec.js.map +1 -1
- package/package.json +3 -2
- package/src/edge-kit.ts +1 -1
- package/src/idempotency.ts +2 -0
- package/src/index.ts +1 -1
- package/src/internal/hook-origins.ts +35 -0
- package/src/internal/route-program.ts +39 -20
- package/src/mount.ts +25 -0
- package/src/reflection.ts +61 -1
- package/src/server/context.ts +7 -4
- package/src/server/core-copies.ts +40 -0
- package/src/server/headers.ts +39 -0
- package/src/server/registry.ts +18 -19
- package/src/server/request-context.ts +6 -3
- package/src/server/request-target.ts +21 -12
- package/src/server/respond.ts +10 -7
- package/src/server/response-contract-lane.ts +357 -42
- package/src/server/server-types.ts +7 -4
- package/src/server/server.ts +235 -35
- package/src/server/websocket.ts +3 -0
- package/src/single-copy.ts +55 -5
- package/src/tool-contract.ts +7 -4
- package/src/transport-codec.ts +4 -1
|
@@ -70,10 +70,43 @@ export interface ResponseContractOptions {
|
|
|
70
70
|
* closed `t.object` reports them, so an enforced response becomes a 500 and the log names them.
|
|
71
71
|
* `"strip"` drops them first, for any schema exposing its JSON Schema as `jsonSchema` (every
|
|
72
72
|
* `@nifrajs/schema` `t` schema) - nested objects, arrays and records included. A union is projected
|
|
73
|
-
*
|
|
73
|
+
* when an object value can meet only one of its branches: a single object branch, or object
|
|
74
|
+
* branches that all require one key holding literals no two branches share (`ok: true` /
|
|
75
|
+
* `ok: false`, `kind: "a"` / `kind: "b"`). Schemas without `jsonSchema` (zod, valibot) are
|
|
74
76
|
* unaffected: they already strip or reject by their own rules.
|
|
75
77
|
*/
|
|
76
78
|
readonly extra?: "reject" | "strip"
|
|
79
|
+
/**
|
|
80
|
+
* `enforce` only: log the names of the fields that never reached the wire - dropped by `extra:
|
|
81
|
+
* "strip"` or by a validator that strips on its own - so a field the contract does not declare
|
|
82
|
+
* (a new column) shows up in the log instead of vanishing. Logged as a `warn` named "response
|
|
83
|
+
* contract", once per route for each distinct set of names, at most 32 sets per route. Values are
|
|
84
|
+
* never logged. Each response is compared with what was sent, so it costs a walk of the payload.
|
|
85
|
+
*/
|
|
86
|
+
readonly logStripped?: boolean
|
|
87
|
+
/**
|
|
88
|
+
* Called for every response the contract has something to say about, for a metric or an alert, where
|
|
89
|
+
* the log speaks once per route. `violation`: enforce mode, the payload failed and a 500 went out in
|
|
90
|
+
* its place. `warn`: warn mode, the payload failed or carried undeclared top-level keys (`fields`)
|
|
91
|
+
* and was sent anyway. `stripped`: enforce mode, the payload was sent without `fields`. The message is
|
|
92
|
+
* the text the log line carries; values are never passed. Called before the response is sent; a hook
|
|
93
|
+
* that throws, rejects or is not a function is logged once and changes nothing. In enforce mode it costs the payload walk
|
|
94
|
+
* `logStripped` does.
|
|
95
|
+
*/
|
|
96
|
+
readonly onReport?: (report: ResponseContractReport) => void
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** One response the contract reported on, passed to {@link ResponseContractOptions.onReport}. */
|
|
100
|
+
export interface ResponseContractReport {
|
|
101
|
+
readonly kind: "violation" | "warn" | "stripped"
|
|
102
|
+
/** The route's method as registered, e.g. `GET`. */
|
|
103
|
+
readonly method: string
|
|
104
|
+
/** The route's path as registered, e.g. `/users/:id`, so a metric keyed by it stays bounded. */
|
|
105
|
+
readonly route: string
|
|
106
|
+
/** `stripped`: each field that never reached the wire (`user.passwordHash`, `items[].cost`), sorted.
|
|
107
|
+
* `warn`: the undeclared top-level keys that were sent, when they are the reason. */
|
|
108
|
+
readonly fields?: readonly string[] | undefined
|
|
109
|
+
readonly message: string
|
|
77
110
|
}
|
|
78
111
|
|
|
79
112
|
/** Success and status-specific error schemas captured by one route at registration time. */
|
|
@@ -86,8 +119,14 @@ export interface ResponseContractDefinition {
|
|
|
86
119
|
export type ResponseContractOutcome =
|
|
87
120
|
/** Nothing to report. `value` is what should be serialized (the original result, or the cleaned one). */
|
|
88
121
|
| { readonly kind: "ok"; readonly value: unknown }
|
|
89
|
-
/** `
|
|
90
|
-
|
|
122
|
+
/** Logged, then `value` is served: in `warn` the ORIGINAL result, in `enforce` (with
|
|
123
|
+
* `logStripped`) the cleaned one. `fields`: in `warn`, the undeclared keys when they are the reason. */
|
|
124
|
+
| {
|
|
125
|
+
readonly kind: "warn"
|
|
126
|
+
readonly value: unknown
|
|
127
|
+
readonly message: string
|
|
128
|
+
readonly fields?: readonly string[]
|
|
129
|
+
}
|
|
91
130
|
/** `enforce` only: the payload cannot be reconciled with the contract, so it must not be sent. */
|
|
92
131
|
| { readonly kind: "violation"; readonly message: string }
|
|
93
132
|
|
|
@@ -111,11 +150,6 @@ function projectorOf(schema: StandardSchemaV1): Projector | undefined {
|
|
|
111
150
|
return found ?? undefined
|
|
112
151
|
}
|
|
113
152
|
|
|
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
153
|
const objectField = (node: object, key: string): object | undefined => {
|
|
120
154
|
const value: unknown = Reflect.get(node, key)
|
|
121
155
|
return typeof value === "object" && value !== null ? value : undefined
|
|
@@ -126,18 +160,7 @@ function compileProjector(node: unknown): Projector | undefined {
|
|
|
126
160
|
const schema: object = node
|
|
127
161
|
const type: unknown = Reflect.get(schema, "type")
|
|
128
162
|
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
|
-
}
|
|
163
|
+
if (Array.isArray(branches)) return compileUnion(branches)
|
|
141
164
|
if (type === "array") {
|
|
142
165
|
const items: unknown = Reflect.get(schema, "items")
|
|
143
166
|
const item = Array.isArray(items) ? undefined : compileProjector(items)
|
|
@@ -171,8 +194,7 @@ function compileProjector(node: unknown): Projector | undefined {
|
|
|
171
194
|
if (!isPlainObject(value)) return value
|
|
172
195
|
const out: Record<string, unknown> = closed ? {} : { ...value }
|
|
173
196
|
for (const key of closed ? keys : nested.keys()) {
|
|
174
|
-
|
|
175
|
-
if (!Object.hasOwn(value, key) && (!(key in value) || key in Object.prototype)) continue
|
|
197
|
+
if (!hasField(value, key)) continue
|
|
176
198
|
const projector = nested.get(key)
|
|
177
199
|
define(out, key, projector === undefined ? value[key] : projector(value[key]))
|
|
178
200
|
}
|
|
@@ -181,6 +203,121 @@ function compileProjector(node: unknown): Projector | undefined {
|
|
|
181
203
|
}
|
|
182
204
|
}
|
|
183
205
|
|
|
206
|
+
/** A getter on a row class's prototype is a declared field; `Object.prototype`'s members are not. */
|
|
207
|
+
const hasField = (value: Record<string, unknown>, key: string): boolean =>
|
|
208
|
+
Object.hasOwn(value, key) || (key in value && !(key in Object.prototype))
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* A union projects a value onto the one branch able to accept it, and leaves anything less certain to
|
|
212
|
+
* the validator. JSON Schema's `object` never matches an array nor `array` an object, so the two are
|
|
213
|
+
* routed apart; two object branches are told apart only by a discriminator (see
|
|
214
|
+
* {@link compileDiscriminated}). A branch that could hold anything (`{}`, `$ref`, `allOf`) stops it.
|
|
215
|
+
*/
|
|
216
|
+
function compileUnion(branches: readonly unknown[]): Projector | undefined {
|
|
217
|
+
const objects: object[] = []
|
|
218
|
+
const arrays: object[] = []
|
|
219
|
+
if (!sortBranches(branches, objects, arrays)) return undefined
|
|
220
|
+
const forArray = arrays.length === 1 ? compileProjector(arrays[0]) : undefined
|
|
221
|
+
const forObject =
|
|
222
|
+
objects.length === 1
|
|
223
|
+
? compileProjector(objects[0])
|
|
224
|
+
: objects.length > 1
|
|
225
|
+
? compileDiscriminated(objects)
|
|
226
|
+
: undefined
|
|
227
|
+
if (forArray === undefined && forObject === undefined) return undefined
|
|
228
|
+
return (value) => {
|
|
229
|
+
if (Array.isArray(value)) return forArray === undefined ? value : forArray(value)
|
|
230
|
+
return forObject !== undefined && isPlainObject(value) ? forObject(value) : value
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Sort a union's branches, nested unions flattened; `false` when one could hold anything. */
|
|
235
|
+
function sortBranches(branches: readonly unknown[], objects: object[], arrays: object[]): boolean {
|
|
236
|
+
for (const branch of branches) {
|
|
237
|
+
if (branch === false) continue
|
|
238
|
+
if (typeof branch !== "object" || branch === null) return false
|
|
239
|
+
const nested: unknown = Reflect.get(branch, "anyOf") ?? Reflect.get(branch, "oneOf")
|
|
240
|
+
if (Array.isArray(nested)) {
|
|
241
|
+
if (!sortBranches(nested, objects, arrays)) return false
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
const type: unknown = Reflect.get(branch, "type")
|
|
245
|
+
if (type === "object") objects.push(branch)
|
|
246
|
+
else if (type === "array") arrays.push(branch)
|
|
247
|
+
else if (typeof type === "string") continue
|
|
248
|
+
else if (type !== undefined || literalsOf(branch) === undefined) return false
|
|
249
|
+
}
|
|
250
|
+
return true
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Object branches that all require one key, each holding literals no other branch holds: at most one
|
|
255
|
+
* branch can accept a value, so projecting onto the branch its tag names cannot strip a field another
|
|
256
|
+
* branch needed. The first such key in the first branch's declared order is used. A value whose tag
|
|
257
|
+
* names no branch is left to the validator, which rejects it.
|
|
258
|
+
*/
|
|
259
|
+
function compileDiscriminated(branches: readonly object[]): Projector | undefined {
|
|
260
|
+
const projectors = branches.map((branch) => compileProjector(branch))
|
|
261
|
+
const first = branches[0]
|
|
262
|
+
if (first === undefined || projectors.every((projector) => projector === undefined))
|
|
263
|
+
return undefined
|
|
264
|
+
for (const key of Object.keys(objectField(first, "properties") ?? {})) {
|
|
265
|
+
const byTag = new Map<unknown, Projector | undefined>()
|
|
266
|
+
const disjoint = branches.every((branch, index) => {
|
|
267
|
+
const tags = requiredLiterals(branch, key)
|
|
268
|
+
if (tags === undefined) return false
|
|
269
|
+
for (const tag of tags) {
|
|
270
|
+
if (byTag.has(tag)) return false
|
|
271
|
+
byTag.set(tag, projectors[index])
|
|
272
|
+
}
|
|
273
|
+
return true
|
|
274
|
+
})
|
|
275
|
+
if (!disjoint) continue
|
|
276
|
+
return (value) => {
|
|
277
|
+
if (!isPlainObject(value) || !hasField(value, key)) return value
|
|
278
|
+
const projector = byTag.get(value[key])
|
|
279
|
+
return projector === undefined ? value : projector(value)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return undefined
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The distinct literals `key` may hold in an object branch that requires it. */
|
|
286
|
+
function requiredLiterals(branch: object, key: string): ReadonlySet<unknown> | undefined {
|
|
287
|
+
const required: unknown = Reflect.get(branch, "required")
|
|
288
|
+
const properties = objectField(branch, "properties")
|
|
289
|
+
if (!Array.isArray(required) || !required.includes(key)) return undefined
|
|
290
|
+
if (properties === undefined || !Object.hasOwn(properties, key)) return undefined
|
|
291
|
+
const literals = literalsOf(Reflect.get(properties, key))
|
|
292
|
+
return literals === undefined ? undefined : new Set(literals)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const isLiteral = (value: unknown): boolean =>
|
|
296
|
+
value === null || ["string", "number", "boolean"].includes(typeof value)
|
|
297
|
+
|
|
298
|
+
/** The finite set of primitives a schema admits (`const`, `enum`, `null`, a union of those), else `undefined`. */
|
|
299
|
+
function literalsOf(node: unknown): readonly unknown[] | undefined {
|
|
300
|
+
if (typeof node !== "object" || node === null) return undefined
|
|
301
|
+
if (Object.hasOwn(node, "const")) {
|
|
302
|
+
const value: unknown = Reflect.get(node, "const")
|
|
303
|
+
return isLiteral(value) ? [value] : undefined
|
|
304
|
+
}
|
|
305
|
+
const listed: unknown = Reflect.get(node, "enum")
|
|
306
|
+
if (Array.isArray(listed))
|
|
307
|
+
return listed.length > 0 && listed.every(isLiteral) ? listed : undefined
|
|
308
|
+
const branches: unknown = Reflect.get(node, "anyOf") ?? Reflect.get(node, "oneOf")
|
|
309
|
+
if (Array.isArray(branches)) {
|
|
310
|
+
const all: unknown[] = []
|
|
311
|
+
for (const branch of branches) {
|
|
312
|
+
const literals = literalsOf(branch)
|
|
313
|
+
if (literals === undefined) return undefined
|
|
314
|
+
all.push(...literals)
|
|
315
|
+
}
|
|
316
|
+
return all.length > 0 ? all : undefined
|
|
317
|
+
}
|
|
318
|
+
return Reflect.get(node, "type") === "null" ? [null] : undefined
|
|
319
|
+
}
|
|
320
|
+
|
|
184
321
|
/**
|
|
185
322
|
* Carry the undeclared keys a `patternProperties` entry admits: a closed record's keys are all
|
|
186
323
|
* pattern keys, so dropping them empties a valid value. A key two patterns match is copied verbatim,
|
|
@@ -215,6 +352,60 @@ function define(out: Record<string, unknown>, key: string, value: unknown): void
|
|
|
215
352
|
else out[key] = value
|
|
216
353
|
}
|
|
217
354
|
|
|
355
|
+
const MAX_STRIPPED_NAMES = 20
|
|
356
|
+
const MAX_NAME_LENGTH = 64
|
|
357
|
+
const MAX_REPORTED_SETS = 32
|
|
358
|
+
|
|
359
|
+
/** Paths in what the handler returned that are absent from what was sent, array items as `name[]`.
|
|
360
|
+
* Stops one past {@link MAX_STRIPPED_NAMES}, so the log can say there were more. */
|
|
361
|
+
function collectStripped(sent: unknown, kept: unknown, path: string, out: Set<string>): void {
|
|
362
|
+
if (Array.isArray(sent)) {
|
|
363
|
+
if (!Array.isArray(kept)) return
|
|
364
|
+
const items = `${path}[]`
|
|
365
|
+
for (let i = 0; i < sent.length && i < kept.length; i++) {
|
|
366
|
+
collectStripped(sent[i], kept[i], items, out)
|
|
367
|
+
if (out.size > MAX_STRIPPED_NAMES) return
|
|
368
|
+
}
|
|
369
|
+
return
|
|
370
|
+
}
|
|
371
|
+
if (!isPlainObject(sent) || !isPlainObject(kept)) return
|
|
372
|
+
for (const key of Object.keys(sent)) {
|
|
373
|
+
const short = key.length > MAX_NAME_LENGTH ? `${key.slice(0, MAX_NAME_LENGTH)}...` : key
|
|
374
|
+
const name = path === "" ? short : `${path}.${short}`
|
|
375
|
+
if (!Object.hasOwn(kept, key)) out.add(name)
|
|
376
|
+
else collectStripped(sent[key], kept[key], name, out)
|
|
377
|
+
if (out.size > MAX_STRIPPED_NAMES) return
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The log line for a route's stripped names the first time it sends that set, else `undefined`. */
|
|
382
|
+
function firstReport(
|
|
383
|
+
reported: WeakMap<ResponseContractDefinition, Set<string>>,
|
|
384
|
+
definition: ResponseContractDefinition,
|
|
385
|
+
fields: readonly string[],
|
|
386
|
+
): string | undefined {
|
|
387
|
+
if (fields.length === 0) return undefined
|
|
388
|
+
let seen = reported.get(definition)
|
|
389
|
+
if (seen === undefined) {
|
|
390
|
+
seen = new Set()
|
|
391
|
+
reported.set(definition, seen)
|
|
392
|
+
}
|
|
393
|
+
const key = fields.join("\n")
|
|
394
|
+
if (seen.size >= MAX_REPORTED_SETS || seen.has(key)) return undefined
|
|
395
|
+
seen.add(key)
|
|
396
|
+
const last =
|
|
397
|
+
seen.size === MAX_REPORTED_SETS ? " Further sets of fields on this route are not logged." : ""
|
|
398
|
+
return `${strippedMessage(fields)}${last}`
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function strippedMessage(fields: readonly string[]): string {
|
|
402
|
+
const listed =
|
|
403
|
+
fields.length > MAX_STRIPPED_NAMES
|
|
404
|
+
? `${fields.slice(0, MAX_STRIPPED_NAMES).join(", ")}, and more`
|
|
405
|
+
: fields.join(", ")
|
|
406
|
+
return `response carried fields its contract does not declare: ${listed}. They were not sent.`
|
|
407
|
+
}
|
|
408
|
+
|
|
218
409
|
/** Top-level keys present in the handler's result but absent from the validated value. */
|
|
219
410
|
function droppedKeys(result: unknown, value: unknown): string[] {
|
|
220
411
|
if (!isPlainObject(result) || !isPlainObject(value)) return []
|
|
@@ -265,12 +456,37 @@ export function checkRouteResponseContract(
|
|
|
265
456
|
jsonReply?: { readonly body: unknown },
|
|
266
457
|
options?: ResponseContractOptions,
|
|
267
458
|
): ResponseContractOutcome | Promise<ResponseContractOutcome> {
|
|
268
|
-
|
|
459
|
+
return checkContract(
|
|
460
|
+
definition,
|
|
461
|
+
result,
|
|
462
|
+
mode,
|
|
463
|
+
jsonReply,
|
|
464
|
+
options?.extra === "strip" ? STRIP : KEEP,
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** How the plugin holds a payload: projected first or not, and the stripped-field reporter. */
|
|
469
|
+
interface ContractPolicy {
|
|
470
|
+
readonly strip: boolean
|
|
471
|
+
/** The message to log for these stripped names, or `undefined` to stay quiet. */
|
|
472
|
+
readonly report: ((fields: readonly string[]) => string | undefined) | undefined
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const STRIP: ContractPolicy = { strip: true, report: undefined }
|
|
476
|
+
const KEEP: ContractPolicy = { strip: false, report: undefined }
|
|
477
|
+
|
|
478
|
+
function checkContract(
|
|
479
|
+
definition: ResponseContractDefinition,
|
|
480
|
+
result: unknown,
|
|
481
|
+
mode: "warn" | "enforce",
|
|
482
|
+
jsonReply: { readonly body: unknown } | undefined,
|
|
483
|
+
policy: ContractPolicy,
|
|
484
|
+
): ResponseContractOutcome | Promise<ResponseContractOutcome> {
|
|
269
485
|
const plain = isResponseResult(result) ? result.plain : undefined
|
|
270
486
|
if (plain !== undefined) {
|
|
271
487
|
const schema = schemaFor(definition, plain.status)
|
|
272
488
|
if (schema === undefined) return { kind: "ok", value: result }
|
|
273
|
-
return validateContract(schema, plain.body, result, mode,
|
|
489
|
+
return validateContract(schema, plain.body, result, mode, policy, (value) =>
|
|
274
490
|
status(
|
|
275
491
|
plain.status,
|
|
276
492
|
value,
|
|
@@ -281,7 +497,7 @@ export function checkRouteResponseContract(
|
|
|
281
497
|
if (jsonReply !== undefined && result instanceof Response) {
|
|
282
498
|
const schema = schemaFor(definition, result.status)
|
|
283
499
|
if (schema === undefined) return { kind: "ok", value: result }
|
|
284
|
-
return validateContract(schema, jsonReply.body, result, mode,
|
|
500
|
+
return validateContract(schema, jsonReply.body, result, mode, policy, (value) =>
|
|
285
501
|
reserialize(result, value),
|
|
286
502
|
)
|
|
287
503
|
}
|
|
@@ -293,7 +509,7 @@ export function checkRouteResponseContract(
|
|
|
293
509
|
if (result instanceof Response || result === undefined) return { kind: "ok", value: result }
|
|
294
510
|
const schema = definition.response
|
|
295
511
|
if (schema === undefined) return { kind: "ok", value: result }
|
|
296
|
-
return validateContract(schema, result, result, mode,
|
|
512
|
+
return validateContract(schema, result, result, mode, policy, (value) => value)
|
|
297
513
|
}
|
|
298
514
|
|
|
299
515
|
/** Validate `payload`; `served` is what goes out unchanged when nothing is enforced - the whole
|
|
@@ -303,14 +519,14 @@ function validateContract(
|
|
|
303
519
|
payload: unknown,
|
|
304
520
|
served: unknown,
|
|
305
521
|
mode: "warn" | "enforce",
|
|
306
|
-
|
|
522
|
+
policy: ContractPolicy,
|
|
307
523
|
replace: (value: unknown) => unknown,
|
|
308
524
|
): ResponseContractOutcome | Promise<ResponseContractOutcome> {
|
|
309
|
-
const project = strip ? projectorOf(schema) : undefined
|
|
525
|
+
const project = policy.strip ? projectorOf(schema) : undefined
|
|
310
526
|
const settled = schema["~standard"].validate(project === undefined ? payload : project(payload))
|
|
311
527
|
return settled instanceof Promise
|
|
312
|
-
? settled.then((r) => interpret(r, payload, served, mode, replace))
|
|
313
|
-
: interpret(settled, payload, served, mode, replace)
|
|
528
|
+
? settled.then((r) => interpret(r, payload, served, mode, replace, policy.report))
|
|
529
|
+
: interpret(settled, payload, served, mode, replace, policy.report)
|
|
314
530
|
}
|
|
315
531
|
|
|
316
532
|
function interpret(
|
|
@@ -319,6 +535,7 @@ function interpret(
|
|
|
319
535
|
served: unknown,
|
|
320
536
|
mode: "warn" | "enforce",
|
|
321
537
|
replace: (value: unknown) => unknown,
|
|
538
|
+
report: ContractPolicy["report"],
|
|
322
539
|
): ResponseContractOutcome {
|
|
323
540
|
if (settled.issues !== undefined) {
|
|
324
541
|
const message = `response does not satisfy its declared contract: ${formatStandardIssues(settled.issues)}`
|
|
@@ -328,14 +545,22 @@ function interpret(
|
|
|
328
545
|
? { kind: "warn", value: served, message }
|
|
329
546
|
: { kind: "violation", message }
|
|
330
547
|
}
|
|
548
|
+
if (mode === "enforce") {
|
|
549
|
+
const value = replace(settled.value)
|
|
550
|
+
if (report === undefined) return { kind: "ok", value }
|
|
551
|
+
const stripped = new Set<string>()
|
|
552
|
+
collectStripped(payload, settled.value, "", stripped)
|
|
553
|
+
const message = report([...stripped].sort())
|
|
554
|
+
return message === undefined ? { kind: "ok", value } : { kind: "warn", value, message }
|
|
555
|
+
}
|
|
331
556
|
const dropped = droppedKeys(payload, settled.value)
|
|
332
|
-
if (mode === "enforce") return { kind: "ok", value: replace(settled.value) }
|
|
333
557
|
return dropped.length === 0
|
|
334
558
|
? { kind: "ok", value: served }
|
|
335
559
|
: {
|
|
336
560
|
kind: "warn",
|
|
337
561
|
value: served,
|
|
338
562
|
message: `response carries fields its contract does not declare: ${dropped.join(", ")}. They are being sent; \`responseContract("enforce")\` would strip them.`,
|
|
563
|
+
fields: dropped,
|
|
339
564
|
}
|
|
340
565
|
}
|
|
341
566
|
|
|
@@ -348,7 +573,11 @@ export interface ResponseContractRuntime {
|
|
|
348
573
|
check(
|
|
349
574
|
definition: ResponseContractDefinition,
|
|
350
575
|
result: unknown,
|
|
351
|
-
context?: {
|
|
576
|
+
context?: {
|
|
577
|
+
readonly req?: Request
|
|
578
|
+
readonly jsonReply?: Response | undefined
|
|
579
|
+
readonly jsonBody?: unknown
|
|
580
|
+
},
|
|
352
581
|
): ResponseContractOutcome | Promise<ResponseContractOutcome>
|
|
353
582
|
}
|
|
354
583
|
|
|
@@ -356,6 +585,18 @@ interface ResponseContractInstallable {
|
|
|
356
585
|
[INSTALL_RESPONSE_CONTRACT](runtime: ResponseContractRuntime): void
|
|
357
586
|
}
|
|
358
587
|
|
|
588
|
+
/** What the plugin reads off a server it was applied to, to name a route and to log a failing hook. */
|
|
589
|
+
interface ContractHost {
|
|
590
|
+
readonly catalog: {
|
|
591
|
+
entries(): readonly {
|
|
592
|
+
readonly method: string
|
|
593
|
+
readonly path: string
|
|
594
|
+
readonly entry: { readonly responseContract?: { readonly definition: object } }
|
|
595
|
+
}[]
|
|
596
|
+
}
|
|
597
|
+
readonly logger: { error(message: string, fields: { detail: string }): void }
|
|
598
|
+
}
|
|
599
|
+
|
|
359
600
|
/**
|
|
360
601
|
* Hold every route's declared `response` schema to what the handler actually returned.
|
|
361
602
|
*
|
|
@@ -382,22 +623,96 @@ export function responseContract(
|
|
|
382
623
|
`responseContract(): extra must be "reject" or "strip", got ${JSON.stringify(options.extra)}`,
|
|
383
624
|
)
|
|
384
625
|
}
|
|
385
|
-
|
|
626
|
+
if (options.logStripped !== undefined && typeof options.logStripped !== "boolean") {
|
|
627
|
+
throw new TypeError(
|
|
628
|
+
`responseContract(): logStripped must be a boolean, got ${JSON.stringify(options.logStripped)}`,
|
|
629
|
+
)
|
|
630
|
+
}
|
|
631
|
+
const onReport = options.onReport
|
|
632
|
+
const strip = options.extra === "strip"
|
|
633
|
+
const quiet = strip ? STRIP : KEEP
|
|
634
|
+
const reported =
|
|
635
|
+
options.logStripped === true && mode === "enforce"
|
|
636
|
+
? new WeakMap<ResponseContractDefinition, Set<string>>()
|
|
637
|
+
: undefined
|
|
638
|
+
const hosts: ContractHost[] = []
|
|
639
|
+
// A definition is compiled per route into the catalog of a server this plugin was applied to (a
|
|
640
|
+
// `group()`'s and a `merge()`d server's routes are adopted into it), so the route is found there
|
|
641
|
+
// and naming it costs the kernel nothing.
|
|
642
|
+
const routes = new WeakMap<object, { readonly method: string; readonly path: string }>()
|
|
643
|
+
let failed = false
|
|
644
|
+
// A hook that throws or rejects (or is not a function) is logged once and never reaches a response.
|
|
645
|
+
const fail = (error: unknown): void => {
|
|
646
|
+
if (!failed)
|
|
647
|
+
hosts[0]?.logger.error("response contract onReport threw", { detail: String(error) })
|
|
648
|
+
failed = true
|
|
649
|
+
}
|
|
386
650
|
const runtime: ResponseContractRuntime = {
|
|
387
651
|
mode,
|
|
388
|
-
check: (definition, result, context) =>
|
|
389
|
-
|
|
390
|
-
definition,
|
|
391
|
-
result,
|
|
392
|
-
mode,
|
|
652
|
+
check: (definition, result, context) => {
|
|
653
|
+
const jsonReply =
|
|
393
654
|
result instanceof Response && result === context?.jsonReply
|
|
394
655
|
? { body: context.jsonBody }
|
|
395
|
-
: undefined
|
|
396
|
-
|
|
397
|
-
|
|
656
|
+
: undefined
|
|
657
|
+
const logged =
|
|
658
|
+
reported === undefined
|
|
659
|
+
? undefined
|
|
660
|
+
: (fields: readonly string[]) => firstReport(reported, definition, fields)
|
|
661
|
+
if (onReport === undefined)
|
|
662
|
+
return checkContract(
|
|
663
|
+
definition,
|
|
664
|
+
result,
|
|
665
|
+
mode,
|
|
666
|
+
jsonReply,
|
|
667
|
+
logged === undefined ? quiet : { strip, report: logged },
|
|
668
|
+
)
|
|
669
|
+
const send = (
|
|
670
|
+
kind: ResponseContractReport["kind"],
|
|
671
|
+
message: string,
|
|
672
|
+
fields?: readonly string[],
|
|
673
|
+
) => {
|
|
674
|
+
if (!routes.has(definition))
|
|
675
|
+
for (const host of hosts)
|
|
676
|
+
for (const route of host.catalog.entries()) {
|
|
677
|
+
const owned = route.entry.responseContract?.definition
|
|
678
|
+
if (owned !== undefined) routes.set(owned, route)
|
|
679
|
+
}
|
|
680
|
+
const route = routes.get(definition)
|
|
681
|
+
try {
|
|
682
|
+
const returned: unknown = onReport({
|
|
683
|
+
kind,
|
|
684
|
+
method: route?.method ?? "",
|
|
685
|
+
route: route?.path ?? "",
|
|
686
|
+
fields,
|
|
687
|
+
message,
|
|
688
|
+
})
|
|
689
|
+
if (returned instanceof Promise) returned.catch(fail)
|
|
690
|
+
} catch (error) {
|
|
691
|
+
fail(error)
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
const outcome = checkContract(definition, result, mode, jsonReply, {
|
|
695
|
+
strip,
|
|
696
|
+
report: (fields) => {
|
|
697
|
+
if (fields.length > 0) send("stripped", strippedMessage(fields), fields)
|
|
698
|
+
return logged?.(fields)
|
|
699
|
+
},
|
|
700
|
+
})
|
|
701
|
+
const observe = (settled: ResponseContractOutcome): ResponseContractOutcome => {
|
|
702
|
+
// An enforce-mode warn is the stripped-field log line, already reported as `stripped`.
|
|
703
|
+
if (settled.kind === "violation") send("violation", settled.message)
|
|
704
|
+
else if (settled.kind === "warn" && mode === "warn")
|
|
705
|
+
send("warn", settled.message, settled.fields)
|
|
706
|
+
return settled
|
|
707
|
+
}
|
|
708
|
+
return outcome instanceof Promise ? outcome.then(observe) : observe(outcome)
|
|
709
|
+
},
|
|
398
710
|
}
|
|
399
711
|
const apply = <S extends AnyServer>(app: S): S => {
|
|
400
|
-
|
|
712
|
+
// A server also holds the route catalog and the logger ContractHost reads.
|
|
713
|
+
const host = app as unknown as ResponseContractInstallable & ContractHost
|
|
714
|
+
host[INSTALL_RESPONSE_CONTRACT](runtime)
|
|
715
|
+
if (!hosts.includes(host)) hosts.push(host)
|
|
401
716
|
return app
|
|
402
717
|
}
|
|
403
718
|
return Object.assign(apply, { pluginName: "nifra:response-contract" }) as IdentityPlugin
|
|
@@ -164,10 +164,13 @@ export interface ServerOptions {
|
|
|
164
164
|
*/
|
|
165
165
|
readonly onValidationError?: RouteSchema["onValidationError"]
|
|
166
166
|
/**
|
|
167
|
-
* What to do when an order-scoped hook (`derive`/`beforeHandle`/`around
|
|
168
|
-
* `afterHandle`/`onError`/`decorate`) covers no route because it was added after
|
|
169
|
-
* registered. `register()` snapshots the current chain into each route as it is
|
|
170
|
-
* appended after every route reaches nothing and is silently dead. `
|
|
167
|
+
* What to do when an order-scoped hook (`derive`/`authenticate`/`beforeHandle`/`around`/
|
|
168
|
+
* `aroundCapability`/`afterHandle`/`onError`/`decorate`) covers no route because it was added after
|
|
169
|
+
* the last route was registered. `register()` snapshots the current chain into each route as it is
|
|
170
|
+
* declared, so a hook appended after every route reaches nothing and is silently dead. A `merge()`d
|
|
171
|
+
* route keeps the chain of the server that declared it, so a hook followed only by merges is dead
|
|
172
|
+
* too. The same report covers `responseContract()` and `effectLedger()` installed after routes they
|
|
173
|
+
* would have covered (a declared response, declared capabilities). `"warn"` (default) logs once at
|
|
171
174
|
* seal (first `listen()`/`fetch`/`resolveNode`); `"error"` throws a `FrameworkError`
|
|
172
175
|
* (`UNUSED_SCOPED_HOOKS`); `"off"` skips the bookkeeping entirely, including the one-frame stack
|
|
173
176
|
* capture. App-global hooks (`onRequest`/`onResponse`/...) are unaffected in every mode.
|