@nifrajs/core 2.10.0 → 2.12.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/dist/assurance.d.ts +61 -3
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +152 -5
- package/dist/assurance.js.map +1 -1
- package/dist/capabilities.d.ts +9 -1
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +5 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/diff.d.ts +2 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +3 -1
- package/dist/diff.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 +89 -0
- package/dist/evidence.d.ts.map +1 -0
- package/dist/evidence.js +170 -0
- package/dist/evidence.js.map +1 -0
- package/dist/execution-policy.d.ts +23 -0
- package/dist/execution-policy.d.ts.map +1 -0
- package/dist/execution-policy.js +28 -0
- package/dist/execution-policy.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/internal/route-assurance.d.ts +19 -0
- package/dist/internal/route-assurance.d.ts.map +1 -1
- package/dist/internal/route-assurance.js +38 -5
- package/dist/internal/route-assurance.js.map +1 -1
- package/dist/internal/route-execution.d.ts +18 -0
- package/dist/internal/route-execution.d.ts.map +1 -1
- package/dist/internal/route-execution.js +67 -1
- package/dist/internal/route-execution.js.map +1 -1
- package/dist/manifest.d.ts +5 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +22 -4
- package/dist/manifest.js.map +1 -1
- package/dist/reflection.d.ts +5 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +13 -4
- package/dist/reflection.js.map +1 -1
- package/dist/replay.d.ts +26 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/replay.js +63 -0
- package/dist/replay.js.map +1 -0
- package/dist/schema/standard.d.ts +2 -0
- package/dist/schema/standard.d.ts.map +1 -1
- package/dist/schema/standard.js +13 -0
- package/dist/schema/standard.js.map +1 -1
- package/dist/server/body.d.ts +49 -6
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +226 -8
- package/dist/server/body.js.map +1 -1
- package/dist/server/context.d.ts +20 -3
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +15 -3
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +22 -0
- package/dist/server/contract.js.map +1 -1
- package/dist/server/cookies.d.ts +26 -5
- package/dist/server/cookies.d.ts.map +1 -1
- package/dist/server/cookies.js +76 -9
- package/dist/server/cookies.js.map +1 -1
- package/dist/server/edge.d.ts.map +1 -1
- package/dist/server/edge.js +11 -0
- package/dist/server/edge.js.map +1 -1
- package/dist/server/header-case.d.ts +9 -0
- package/dist/server/header-case.d.ts.map +1 -0
- package/dist/server/header-case.js +54 -0
- package/dist/server/header-case.js.map +1 -0
- package/dist/server/headers.d.ts +8 -0
- package/dist/server/headers.d.ts.map +1 -0
- package/dist/server/headers.js +13 -0
- package/dist/server/headers.js.map +1 -0
- package/dist/server/node-outcome-hook.d.ts.map +1 -1
- package/dist/server/node-outcome-hook.js +7 -11
- package/dist/server/node-outcome-hook.js.map +1 -1
- package/dist/server/node-outcome.d.ts +8 -1
- package/dist/server/node-outcome.d.ts.map +1 -1
- package/dist/server/node-outcome.js +10 -3
- package/dist/server/node-outcome.js.map +1 -1
- package/dist/server/plugin.d.ts +66 -7
- package/dist/server/plugin.d.ts.map +1 -1
- package/dist/server/plugin.js +14 -10
- package/dist/server/plugin.js.map +1 -1
- package/dist/server/proto-guard.d.ts +30 -0
- package/dist/server/proto-guard.d.ts.map +1 -0
- package/dist/server/proto-guard.js +116 -0
- package/dist/server/proto-guard.js.map +1 -0
- package/dist/server/registry.d.ts +6 -2
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +24 -6
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +157 -30
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +2 -2
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +34 -8
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +1 -1
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +45 -13
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/response-hooks.d.ts +10 -0
- package/dist/server/response-hooks.d.ts.map +1 -0
- package/dist/server/response-hooks.js +52 -0
- package/dist/server/response-hooks.js.map +1 -0
- package/dist/server/runtime-core.d.ts +12 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +8 -0
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +60 -6
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +80 -23
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +424 -213
- package/dist/server/server.js.map +1 -1
- package/dist/server/static-headers.d.ts +14 -3
- package/dist/server/static-headers.d.ts.map +1 -1
- package/dist/server/static-headers.js +28 -6
- package/dist/server/static-headers.js.map +1 -1
- package/dist/server/transport-lane.d.ts +8 -0
- package/dist/server/transport-lane.d.ts.map +1 -1
- package/dist/server/transport-lane.js +12 -8
- package/dist/server/transport-lane.js.map +1 -1
- package/dist/server/websocket.d.ts +3 -0
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +14 -0
- package/dist/server/websocket.js.map +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +2 -2
- package/dist/server.js.map +1 -1
- package/dist/tool-catalog.d.ts +21 -0
- package/dist/tool-catalog.d.ts.map +1 -0
- package/dist/tool-catalog.js +16 -0
- package/dist/tool-catalog.js.map +1 -0
- package/dist/tool-contract.d.ts +238 -0
- package/dist/tool-contract.d.ts.map +1 -0
- package/dist/tool-contract.js +569 -0
- package/dist/tool-contract.js.map +1 -0
- package/package.json +26 -1
- package/src/assurance.ts +236 -6
- package/src/capabilities.ts +14 -1
- package/src/diff.ts +5 -2
- package/src/errors.ts +1 -0
- package/src/evidence.ts +319 -0
- package/src/execution-policy.ts +57 -0
- package/src/index.ts +1 -1
- package/src/internal/route-assurance.ts +67 -4
- package/src/internal/route-execution.ts +276 -1
- package/src/manifest.ts +27 -5
- package/src/reflection.ts +24 -4
- package/src/replay.ts +96 -0
- package/src/schema/standard.ts +16 -0
- package/src/server/body.ts +275 -7
- package/src/server/context.ts +23 -3
- package/src/server/contract.ts +43 -3
- package/src/server/cookies.ts +80 -10
- package/src/server/edge.ts +10 -0
- package/src/server/header-case.ts +54 -0
- package/src/server/headers.ts +11 -0
- package/src/server/node-outcome-hook.ts +6 -11
- package/src/server/node-outcome.ts +10 -2
- package/src/server/plugin.ts +87 -8
- package/src/server/proto-guard.ts +118 -0
- package/src/server/registry.ts +6 -2
- package/src/server/request-context.ts +242 -26
- package/src/server/respond.ts +35 -4
- package/src/server/response-contract-lane.ts +6 -17
- package/src/server/response-hooks.ts +51 -0
- package/src/server/runtime-core.ts +14 -0
- package/src/server/server-types.ts +66 -6
- package/src/server/server.ts +667 -287
- package/src/server/static-headers.ts +27 -3
- package/src/server/transport-lane.ts +19 -8
- package/src/server/websocket.ts +18 -2
- package/src/server.ts +7 -0
- package/src/tool-catalog.ts +40 -0
- package/src/tool-contract.ts +896 -0
|
@@ -5,10 +5,19 @@
|
|
|
5
5
|
* sits below the server module in the graph.
|
|
6
6
|
*/
|
|
7
7
|
import type { RequestBudget } from "../budget.ts"
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
applyTransportCap,
|
|
10
|
+
drainCapped,
|
|
11
|
+
hasTrustedBodyFraming,
|
|
12
|
+
parseContentLength,
|
|
13
|
+
RAW_BODY_READERS,
|
|
14
|
+
readBoundedBytes,
|
|
15
|
+
} from "./body.ts"
|
|
9
16
|
import type { Platform } from "./context.ts"
|
|
10
|
-
import { type CookieOptions, parseCookies, serializeCookie } from "./cookies.ts"
|
|
17
|
+
import { type CookieOptions, cookieNamePrefix, parseCookies, serializeCookie } from "./cookies.ts"
|
|
18
|
+
import { headerObjectOf } from "./headers.ts"
|
|
11
19
|
import { jsonError } from "./http.ts"
|
|
20
|
+
import { guardParsedValue, type ProtoPoisoning, parseJsonGuarded } from "./proto-guard.ts"
|
|
12
21
|
import { searchOf } from "./query.ts"
|
|
13
22
|
import {
|
|
14
23
|
CONTEXT_SEARCH,
|
|
@@ -17,6 +26,8 @@ import {
|
|
|
17
26
|
getNeverAbortSignal,
|
|
18
27
|
getUnboundedRequestBudget,
|
|
19
28
|
headerOf,
|
|
29
|
+
PRE_DECODED_BODY,
|
|
30
|
+
type PreDecodedBody,
|
|
20
31
|
requestOf,
|
|
21
32
|
TEXT_DECODER,
|
|
22
33
|
} from "./runtime-core.ts"
|
|
@@ -58,9 +69,16 @@ class LazyResponseControls implements CtxSet {
|
|
|
58
69
|
|
|
59
70
|
deleteCookie(name: string, options?: Pick<CookieOptions, "path" | "domain">): void {
|
|
60
71
|
// Expire immediately; default Path=/, and match the original path/domain or the browser keeps it.
|
|
72
|
+
// A prefixed name gets Secure on the deletion write too - the browser holds the deletion
|
|
73
|
+
// Set-Cookie to the same prefix contract as the original, and rejects it without.
|
|
61
74
|
this._cookies ??= []
|
|
75
|
+
const base = { path: "/", ...options, maxAge: 0, expires: EPOCH }
|
|
62
76
|
this._cookies.push(
|
|
63
|
-
serializeCookie(
|
|
77
|
+
serializeCookie(
|
|
78
|
+
name,
|
|
79
|
+
"",
|
|
80
|
+
cookieNamePrefix(name) !== undefined ? { ...base, secure: true } : base,
|
|
81
|
+
),
|
|
64
82
|
)
|
|
65
83
|
}
|
|
66
84
|
}
|
|
@@ -85,11 +103,18 @@ export class RequestContext implements RawContext {
|
|
|
85
103
|
private declare setValue: CtxSet | undefined
|
|
86
104
|
private declare queryValue: unknown
|
|
87
105
|
private declare queryReady: boolean
|
|
106
|
+
private declare headersValue: Record<string, string> | undefined
|
|
88
107
|
private declare cookiesValue: Readonly<Record<string, string>> | undefined
|
|
89
108
|
private declare readonly source: RequestSource
|
|
90
109
|
private declare readonly maxBodyBytes: number
|
|
110
|
+
private declare readonly protoPoisoning: ProtoPoisoning
|
|
91
111
|
|
|
92
|
-
constructor(
|
|
112
|
+
constructor(
|
|
113
|
+
source: RequestSource,
|
|
114
|
+
params: Record<string, string>,
|
|
115
|
+
maxBodyBytes: number,
|
|
116
|
+
protoPoisoning?: ProtoPoisoning,
|
|
117
|
+
)
|
|
93
118
|
constructor(
|
|
94
119
|
source: RequestSource,
|
|
95
120
|
params: Record<string, string>,
|
|
@@ -98,27 +123,32 @@ export class RequestContext implements RawContext {
|
|
|
98
123
|
budget: RequestBudget,
|
|
99
124
|
platform: Platform | undefined,
|
|
100
125
|
maxBodyBytes: number,
|
|
126
|
+
protoPoisoning?: ProtoPoisoning,
|
|
101
127
|
)
|
|
102
128
|
constructor(
|
|
103
129
|
source: RequestSource,
|
|
104
130
|
params: Record<string, string>,
|
|
105
131
|
searchOrMaxBodyBytes: string | number | undefined,
|
|
106
|
-
|
|
132
|
+
signalOrProtoPoisoning?: AbortSignal | ProtoPoisoning,
|
|
107
133
|
budget?: RequestBudget,
|
|
108
134
|
platform?: Platform,
|
|
109
135
|
maxBodyBytes?: number,
|
|
136
|
+
protoPoisoning?: ProtoPoisoning,
|
|
110
137
|
) {
|
|
111
138
|
this.source = source
|
|
112
139
|
this.params = params
|
|
113
140
|
if (typeof searchOrMaxBodyBytes === "number") {
|
|
114
141
|
this.maxBodyBytes = searchOrMaxBodyBytes
|
|
142
|
+
this.protoPoisoning =
|
|
143
|
+
typeof signalOrProtoPoisoning === "string" ? signalOrProtoPoisoning : "reject"
|
|
115
144
|
return
|
|
116
145
|
}
|
|
117
146
|
if (searchOrMaxBodyBytes !== undefined) this.searchValue = searchOrMaxBodyBytes
|
|
118
|
-
this.signalValue =
|
|
147
|
+
this.signalValue = signalOrProtoPoisoning as AbortSignal | undefined
|
|
119
148
|
this.budgetValue = budget
|
|
120
149
|
if (platform !== undefined) this.platformValue = platform
|
|
121
150
|
this.maxBodyBytes = maxBodyBytes as number
|
|
151
|
+
this.protoPoisoning = protoPoisoning ?? "reject"
|
|
122
152
|
}
|
|
123
153
|
|
|
124
154
|
/**
|
|
@@ -133,8 +163,9 @@ export class RequestContext implements RawContext {
|
|
|
133
163
|
search: string | undefined,
|
|
134
164
|
maxBodyBytes: number,
|
|
135
165
|
platform?: Platform,
|
|
166
|
+
protoPoisoning?: ProtoPoisoning,
|
|
136
167
|
): RequestContext {
|
|
137
|
-
const context = new RequestContext(source, params, maxBodyBytes)
|
|
168
|
+
const context = new RequestContext(source, params, maxBodyBytes, protoPoisoning)
|
|
138
169
|
if (search !== undefined) context.searchValue = search
|
|
139
170
|
if (platform !== undefined) context.platformValue = platform
|
|
140
171
|
return context
|
|
@@ -177,17 +208,32 @@ export class RequestContext implements RawContext {
|
|
|
177
208
|
}
|
|
178
209
|
|
|
179
210
|
get req(): Request {
|
|
180
|
-
|
|
211
|
+
// A dispatcher-marked source gets its route's transport byte cap applied here - at the moment
|
|
212
|
+
// user code reaches for the request - so routes that never direct-read the body pay nothing.
|
|
213
|
+
const request = requestOf(this.source)
|
|
214
|
+
applyTransportCap(this.source, request)
|
|
215
|
+
return request
|
|
181
216
|
}
|
|
182
217
|
|
|
183
218
|
get request(): Request {
|
|
184
|
-
|
|
219
|
+
const request = requestOf(this.source)
|
|
220
|
+
applyTransportCap(this.source, request)
|
|
221
|
+
return request
|
|
185
222
|
}
|
|
186
223
|
|
|
187
224
|
header(name: string): string | null {
|
|
188
225
|
return headerOf(this.source, name)
|
|
189
226
|
}
|
|
190
227
|
|
|
228
|
+
get headers(): Record<string, string> {
|
|
229
|
+
this.headersValue ??= headerObjectOf(this.source.headers)
|
|
230
|
+
return this.headersValue
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
set headers(value: Record<string, string>) {
|
|
234
|
+
this.headersValue = value
|
|
235
|
+
}
|
|
236
|
+
|
|
191
237
|
json(body: unknown, init?: ResponseInit | number): Response {
|
|
192
238
|
return Response.json(body, statusInit(init))
|
|
193
239
|
}
|
|
@@ -222,7 +268,12 @@ export class RequestContext implements RawContext {
|
|
|
222
268
|
}
|
|
223
269
|
|
|
224
270
|
boundedJson<T = unknown>(maxBytes?: number): Promise<T> {
|
|
225
|
-
return readBoundedJsonBodyOrThrow<T>(
|
|
271
|
+
return readBoundedJsonBodyOrThrow<T>(
|
|
272
|
+
this.source,
|
|
273
|
+
this.maxBodyBytes,
|
|
274
|
+
this.protoPoisoning,
|
|
275
|
+
maxBytes,
|
|
276
|
+
)
|
|
226
277
|
}
|
|
227
278
|
}
|
|
228
279
|
|
|
@@ -241,27 +292,84 @@ async function readBoundedBodyOrThrow(
|
|
|
241
292
|
: jsonError(400, "invalid_content_length")
|
|
242
293
|
}
|
|
243
294
|
|
|
244
|
-
/** Backs `c.boundedJson`: `
|
|
295
|
+
/** Backs `c.boundedJson`: `readBoundedJsonSource`, throwing its flat 400/413 on failure. */
|
|
245
296
|
async function readBoundedJsonBodyOrThrow<T>(
|
|
246
297
|
req: RequestSource,
|
|
247
298
|
maxBodyBytes: number,
|
|
299
|
+
protoPoisoning: ProtoPoisoning,
|
|
248
300
|
maxBytes?: number,
|
|
249
301
|
): Promise<T> {
|
|
250
|
-
const parsed = await readBoundedJsonSource(req, maxBytes ?? maxBodyBytes)
|
|
302
|
+
const parsed = await readBoundedJsonSource(req, maxBytes ?? maxBodyBytes, protoPoisoning)
|
|
251
303
|
if (parsed instanceof Response) throw parsed
|
|
252
304
|
return parsed as T
|
|
253
305
|
}
|
|
254
306
|
|
|
255
307
|
/**
|
|
256
|
-
* Read a JSON body with the same byte cap used by schema validation and `c.boundedJson
|
|
257
|
-
*
|
|
308
|
+
* Read a JSON body with the same byte cap used by schema validation and `c.boundedJson`, then
|
|
309
|
+
* parse it through the prototype-poisoning guard (`protoPoisoning`, default `"reject"` - a
|
|
310
|
+
* poisoned payload answers the same flat 400 as malformed JSON). Non-chunked, framed requests
|
|
311
|
+
* with an in-cap `Content-Length` take the runtime's fused `json()` and walk the parsed value;
|
|
258
312
|
* chunked or length-less requests fall back to the streaming byte-cap guard.
|
|
259
313
|
*/
|
|
260
|
-
|
|
314
|
+
/**
|
|
315
|
+
* Deliberately **not** `async`: the framed lane is the hot path for every JSON POST, and an async
|
|
316
|
+
* wrapper costs a coroutine frame, a wrapper promise, and an extra microtask hop on top of the one
|
|
317
|
+
* the body read already pays (~90ns/request on Node, measured). The lane returns the body read's
|
|
318
|
+
* own promise chain instead, and only the streaming fallback - which is already doing IO in a loop
|
|
319
|
+
* - runs as an async function. Callers that `await` the result are unaffected.
|
|
320
|
+
*/
|
|
321
|
+
type JsonResultContinuation<T> = (result: unknown | Response) => T | Promise<T>
|
|
322
|
+
type JsonErrorContinuation<T> = (error: unknown) => T | Promise<T>
|
|
323
|
+
type FramedJsonSource = RequestSource & {
|
|
324
|
+
readonly bytes?: () => Promise<Uint8Array>
|
|
325
|
+
readonly jsonWithByteLength?: () => Promise<{
|
|
326
|
+
readonly value: unknown
|
|
327
|
+
readonly byteLength: number
|
|
328
|
+
}>
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function readBoundedJsonSource(
|
|
261
332
|
req: RequestSource,
|
|
262
333
|
maxBytes: number,
|
|
263
|
-
|
|
264
|
-
|
|
334
|
+
protoPoisoning?: ProtoPoisoning,
|
|
335
|
+
): Promise<unknown | Response>
|
|
336
|
+
export function readBoundedJsonSource<T>(
|
|
337
|
+
req: RequestSource,
|
|
338
|
+
maxBytes: number,
|
|
339
|
+
protoPoisoning: ProtoPoisoning,
|
|
340
|
+
onResult: JsonResultContinuation<T>,
|
|
341
|
+
onError: JsonErrorContinuation<T>,
|
|
342
|
+
): Promise<T>
|
|
343
|
+
export function readBoundedJsonSource<T>(
|
|
344
|
+
req: RequestSource,
|
|
345
|
+
maxBytes: number,
|
|
346
|
+
protoPoisoning: ProtoPoisoning = "reject",
|
|
347
|
+
onResult?: JsonResultContinuation<T>,
|
|
348
|
+
onError?: JsonErrorContinuation<T>,
|
|
349
|
+
): Promise<unknown | Response | T> {
|
|
350
|
+
// A pre-parsing hook (the transport-codec lane) may have decoded the body already - its stash
|
|
351
|
+
// is taken verbatim: the stasher enforced its own byte cap and poisoning policy on text this
|
|
352
|
+
// lane never sees. One symbol read; a miss on ordinary requests costs a cache-line, not a parse.
|
|
353
|
+
const preDecoded = (req as { [PRE_DECODED_BODY]?: PreDecodedBody })[PRE_DECODED_BODY]
|
|
354
|
+
if (preDecoded !== undefined) {
|
|
355
|
+
return onResult === undefined
|
|
356
|
+
? Promise.resolve(preDecoded.value)
|
|
357
|
+
: Promise.resolve(preDecoded.value).then(onResult)
|
|
358
|
+
}
|
|
359
|
+
// Read through the pre-cap readers when the request is transport-capped: this lane enforces its
|
|
360
|
+
// own `maxBytes` (which may exceed the route's transport cap), so the shadowed user-facing
|
|
361
|
+
// readers must not narrow it.
|
|
362
|
+
// Keep the source's header dispatch local: the normal Node source exposes a direct `header`
|
|
363
|
+
// lookup, while a Web Request only exposes `Headers.get`. Rechecking that optional method through
|
|
364
|
+
// `headerOf` twice per body is measurable on the framed lane. The raw-reader shadow is still
|
|
365
|
+
// bypassed for reads, but header authority remains the original source (same semantics as before).
|
|
366
|
+
const raw = ((req as { [RAW_BODY_READERS]?: RequestSource })[RAW_BODY_READERS] ??
|
|
367
|
+
req) as FramedJsonSource
|
|
368
|
+
const sourceHeader = req.header
|
|
369
|
+
const declared =
|
|
370
|
+
sourceHeader === undefined
|
|
371
|
+
? req.headers.get("content-length")
|
|
372
|
+
: sourceHeader.call(req, "content-length")
|
|
265
373
|
if (declared !== null) {
|
|
266
374
|
// A present Content-Length must be a non-negative integer (HTTP grammar: `1*DIGIT`). A
|
|
267
375
|
// non-numeric / negative / fractional / exponential value (`Number()` would happily accept
|
|
@@ -270,23 +378,131 @@ export async function readBoundedJsonSource(
|
|
|
270
378
|
// would otherwise be read in full. Real HTTP servers only hand us a valid framed length; this
|
|
271
379
|
// hardens hand-built Requests (tests, the in-process client) and crafted input.
|
|
272
380
|
const length = parseContentLength(declared)
|
|
273
|
-
if (length === undefined)
|
|
274
|
-
|
|
275
|
-
|
|
381
|
+
if (length === undefined)
|
|
382
|
+
return onResult === undefined
|
|
383
|
+
? Promise.resolve(jsonError(400, "invalid_content_length"))
|
|
384
|
+
: Promise.resolve(jsonError(400, "invalid_content_length")).then(onResult)
|
|
385
|
+
if (length > maxBytes)
|
|
386
|
+
return onResult === undefined
|
|
387
|
+
? Promise.resolve(jsonError(413, "payload_too_large"))
|
|
388
|
+
: Promise.resolve(jsonError(413, "payload_too_large")).then(onResult)
|
|
389
|
+
const chunked =
|
|
390
|
+
(sourceHeader === undefined
|
|
391
|
+
? req.headers.get("transfer-encoding")
|
|
392
|
+
: sourceHeader.call(req, "transfer-encoding")) !== null
|
|
276
393
|
if (!chunked) {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
394
|
+
// Bun's compiled native route table receives a Request whose bytes were already delimited by
|
|
395
|
+
// Bun's HTTP parser. Keep the exact-byte path for every portable/adapted source, but preserve
|
|
396
|
+
// Bun's fused decode+parse on this trusted ingress; the declared length is the transport frame
|
|
397
|
+
// there, not an attacker-controlled hint. The parsed-value poisoning walk still applies.
|
|
398
|
+
if (hasTrustedBodyFraming(req)) {
|
|
399
|
+
return raw.json().then(
|
|
400
|
+
(parsed) => {
|
|
401
|
+
let guarded: unknown
|
|
402
|
+
try {
|
|
403
|
+
guarded = guardParsedValue(parsed, protoPoisoning)
|
|
404
|
+
} catch {
|
|
405
|
+
return onResult === undefined
|
|
406
|
+
? jsonError(400, "invalid_json")
|
|
407
|
+
: onResult(jsonError(400, "invalid_json"))
|
|
408
|
+
}
|
|
409
|
+
return onResult === undefined ? guarded : onResult(guarded)
|
|
410
|
+
},
|
|
411
|
+
() => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
|
|
412
|
+
)
|
|
281
413
|
}
|
|
414
|
+
const jsonWithByteLength = raw.jsonWithByteLength
|
|
415
|
+
if (jsonWithByteLength !== undefined) {
|
|
416
|
+
return jsonWithByteLength.call(raw).then(
|
|
417
|
+
({ value, byteLength }) => {
|
|
418
|
+
if (byteLength > length || byteLength > maxBytes) {
|
|
419
|
+
return onResult === undefined
|
|
420
|
+
? jsonError(413, "payload_too_large")
|
|
421
|
+
: onResult(jsonError(413, "payload_too_large"))
|
|
422
|
+
}
|
|
423
|
+
let guarded: unknown
|
|
424
|
+
try {
|
|
425
|
+
guarded = guardParsedValue(value, protoPoisoning)
|
|
426
|
+
} catch {
|
|
427
|
+
return onResult === undefined
|
|
428
|
+
? jsonError(400, "invalid_json")
|
|
429
|
+
: onResult(jsonError(400, "invalid_json"))
|
|
430
|
+
}
|
|
431
|
+
return onResult === undefined ? guarded : onResult(guarded)
|
|
432
|
+
},
|
|
433
|
+
() => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
|
|
434
|
+
)
|
|
435
|
+
}
|
|
436
|
+
const bytesReader = raw.bytes
|
|
437
|
+
if (bytesReader !== undefined) {
|
|
438
|
+
return bytesReader.call(raw).then(
|
|
439
|
+
(bytes) => {
|
|
440
|
+
if (bytes.byteLength > length || bytes.byteLength > maxBytes) {
|
|
441
|
+
return onResult === undefined
|
|
442
|
+
? jsonError(413, "payload_too_large")
|
|
443
|
+
: onResult(jsonError(413, "payload_too_large"))
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
const parsed = parseJsonGuarded(TEXT_DECODER.decode(bytes), protoPoisoning)
|
|
447
|
+
return onResult === undefined ? parsed : onResult(parsed)
|
|
448
|
+
} catch {
|
|
449
|
+
return onResult === undefined
|
|
450
|
+
? jsonError(400, "invalid_json")
|
|
451
|
+
: onResult(jsonError(400, "invalid_json"))
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
() => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
|
|
455
|
+
)
|
|
456
|
+
}
|
|
457
|
+
// Read the actual framed bytes before parsing. A declared length is only a hint on Web
|
|
458
|
+
// Requests and adapter-shaped sources: a caller can construct a Request (or an adapter can
|
|
459
|
+
// decode/expand a body) whose header claims fewer bytes than the delivered payload. Calling
|
|
460
|
+
// native `json()` here would accept that understatement and bypass the cap. The byte lane
|
|
461
|
+
// already owns this post-read check; keeping the JSON lane on the same raw-byte read makes the
|
|
462
|
+
// trust boundary consistent. `Uint8Array(buffer)` is a view, not a copy.
|
|
463
|
+
return raw.arrayBuffer().then(
|
|
464
|
+
(buffer) => {
|
|
465
|
+
const bytes = new Uint8Array(buffer)
|
|
466
|
+
if (bytes.byteLength > length || bytes.byteLength > maxBytes) {
|
|
467
|
+
return onResult === undefined
|
|
468
|
+
? jsonError(413, "payload_too_large")
|
|
469
|
+
: onResult(jsonError(413, "payload_too_large"))
|
|
470
|
+
}
|
|
471
|
+
let parsed: unknown
|
|
472
|
+
try {
|
|
473
|
+
parsed = parseJsonGuarded(TEXT_DECODER.decode(bytes), protoPoisoning)
|
|
474
|
+
} catch {
|
|
475
|
+
return onResult === undefined
|
|
476
|
+
? jsonError(400, "invalid_json")
|
|
477
|
+
: onResult(jsonError(400, "invalid_json"))
|
|
478
|
+
}
|
|
479
|
+
return onResult === undefined ? parsed : onResult(parsed)
|
|
480
|
+
},
|
|
481
|
+
() => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
|
|
482
|
+
)
|
|
282
483
|
}
|
|
283
484
|
}
|
|
284
|
-
const
|
|
485
|
+
const streamed = readStreamedJsonSource(raw, maxBytes, protoPoisoning)
|
|
486
|
+
return onResult === undefined ? streamed : streamed.then(onResult, onError)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** The shared rejection handler for a body read that never completed (client abort, socket error,
|
|
490
|
+
* malformed framing): the same flat 400 a syntactically invalid body gets. Hoisted so the framed
|
|
491
|
+
* lane allocates no per-request closure for it. */
|
|
492
|
+
const INVALID_JSON = (): Response => jsonError(400, "invalid_json")
|
|
493
|
+
|
|
494
|
+
/** The chunked / length-less fallback: drain under the streaming byte cap, then parse guarded. */
|
|
495
|
+
async function readStreamedJsonSource(
|
|
496
|
+
raw: RequestSource,
|
|
497
|
+
maxBytes: number,
|
|
498
|
+
protoPoisoning: ProtoPoisoning,
|
|
499
|
+
): Promise<unknown | Response> {
|
|
500
|
+
const body = raw.body
|
|
285
501
|
if (body === null) return jsonError(400, "invalid_json")
|
|
286
502
|
const drained = await drainCapped(body, maxBytes)
|
|
287
503
|
if (!drained.ok) return jsonError(413, "payload_too_large")
|
|
288
504
|
try {
|
|
289
|
-
return
|
|
505
|
+
return parseJsonGuarded(TEXT_DECODER.decode(drained.bytes), protoPoisoning)
|
|
290
506
|
} catch {
|
|
291
507
|
return jsonError(400, "invalid_json")
|
|
292
508
|
}
|
package/src/server/respond.ts
CHANGED
|
@@ -117,8 +117,13 @@ function withStatics(response: Response, statics: StaticResponseHeaders | undefi
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
function applyStaticDefaults(headers: Headers, statics: StaticResponseHeaders): void {
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
// Indexed with an explicit tuple read, not `for...of` with array destructuring: the destructuring
|
|
121
|
+
// pattern is itself an iterator walk, so the pair form pays two iterator protocols per header.
|
|
122
|
+
// Measured on a 5-header set: 211ns cheaper on Bun, 56ns on Node.
|
|
123
|
+
const entries = statics.entries
|
|
124
|
+
for (let i = 0; i < entries.length; i++) {
|
|
125
|
+
const entry = entries[i]!
|
|
126
|
+
if (!headers.has(entry[0])) headers.set(entry[0], entry[1])
|
|
122
127
|
}
|
|
123
128
|
}
|
|
124
129
|
|
|
@@ -293,7 +298,27 @@ export function fusedRespondNoSet(
|
|
|
293
298
|
result: unknown,
|
|
294
299
|
tagResponseBody: ResponseBodyTagOption = false,
|
|
295
300
|
statics?: StaticResponseHeaders,
|
|
301
|
+
unstamped = false,
|
|
296
302
|
): Response {
|
|
303
|
+
// With no response hook registered, no code path ever asks whether these headers are mutable, so
|
|
304
|
+
// stamping the Headers object into the mutability WeakSet is dead work - `Response.json` alone is
|
|
305
|
+
// cheaper on Bun than the same construction plus the WeakSet write. `unstamped` is the caller's
|
|
306
|
+
// assertion that the hook walk is empty; an app that registers ANY response hook keeps the stamp,
|
|
307
|
+
// because the alternative is the set/delete mutability probe on every single response (each one
|
|
308
|
+
// carries a fresh `Headers`, so the probe's memo never amortizes) - measured 19% slower than
|
|
309
|
+
// stamping. Default false: an unknown caller pays one WeakSet write rather than a probe per response.
|
|
310
|
+
if (
|
|
311
|
+
unstamped &&
|
|
312
|
+
(tagResponseBody === false || tagResponseBody === undefined) &&
|
|
313
|
+
statics === undefined &&
|
|
314
|
+
result !== undefined &&
|
|
315
|
+
typeof result === "object" &&
|
|
316
|
+
result !== null &&
|
|
317
|
+
!(result instanceof Response) &&
|
|
318
|
+
!isResponseResult(result)
|
|
319
|
+
) {
|
|
320
|
+
return Response.json(result)
|
|
321
|
+
}
|
|
297
322
|
if (
|
|
298
323
|
result !== undefined &&
|
|
299
324
|
!(result instanceof Response) &&
|
|
@@ -308,7 +333,12 @@ export function fusedRespondNoSet(
|
|
|
308
333
|
// one-call prebuilt init below.
|
|
309
334
|
if (statics !== undefined && IS_DENO && !tagResponseBody) {
|
|
310
335
|
const response = Response.json(result)
|
|
311
|
-
for
|
|
336
|
+
// Indexed for the same reason as `applyStaticDefaults`: pair destructuring is a second
|
|
337
|
+
// iterator walk per header.
|
|
338
|
+
for (let i = 0; i < statics.entries.length; i++) {
|
|
339
|
+
const entry = statics.entries[i]!
|
|
340
|
+
response.headers.set(entry[0], entry[1])
|
|
341
|
+
}
|
|
312
342
|
return stamped(response)
|
|
313
343
|
}
|
|
314
344
|
// With declared static headers the prebuilt init already carries them plus the JSON
|
|
@@ -352,9 +382,10 @@ export function fusedRespond(
|
|
|
352
382
|
ctx: RawContext,
|
|
353
383
|
tagResponseBody: ResponseBodyTagOption = false,
|
|
354
384
|
statics?: StaticResponseHeaders,
|
|
385
|
+
unstamped = false,
|
|
355
386
|
): Response {
|
|
356
387
|
const set = ctx[CONTEXT_SET]()
|
|
357
|
-
if (set === undefined) return fusedRespondNoSet(result, tagResponseBody, statics)
|
|
388
|
+
if (set === undefined) return fusedRespondNoSet(result, tagResponseBody, statics, unstamped)
|
|
358
389
|
return toResponse(result as HandlerResult, set, tagResponseBody, statics)
|
|
359
390
|
}
|
|
360
391
|
|
|
@@ -41,7 +41,11 @@
|
|
|
41
41
|
* or lifecycle hook has already left that lane, so for the routes that look like production the
|
|
42
42
|
* contract is effectively free - declare it.
|
|
43
43
|
*/
|
|
44
|
-
import
|
|
44
|
+
import {
|
|
45
|
+
formatStandardIssues,
|
|
46
|
+
type StandardResult,
|
|
47
|
+
type StandardSchemaV1,
|
|
48
|
+
} from "../schema/standard.ts"
|
|
45
49
|
import { INSTALL_RESPONSE_CONTRACT } from "./install.ts"
|
|
46
50
|
import type { IdentityPlugin } from "./plugin.ts"
|
|
47
51
|
import type { AnyServer } from "./server.ts"
|
|
@@ -75,21 +79,6 @@ function droppedKeys(result: unknown, value: unknown): string[] {
|
|
|
75
79
|
return Object.keys(result).filter((k) => !(k in value))
|
|
76
80
|
}
|
|
77
81
|
|
|
78
|
-
function describeIssues(issues: ReadonlyArray<StandardIssue>): string {
|
|
79
|
-
return issues
|
|
80
|
-
.map((issue) => {
|
|
81
|
-
const path = Array.isArray(issue.path)
|
|
82
|
-
? issue.path
|
|
83
|
-
.map((seg) =>
|
|
84
|
-
String(typeof seg === "object" && seg !== null ? (seg as { key: unknown }).key : seg),
|
|
85
|
-
)
|
|
86
|
-
.join(".")
|
|
87
|
-
: ""
|
|
88
|
-
return path === "" ? issue.message : `${path}: ${issue.message}`
|
|
89
|
-
})
|
|
90
|
-
.join("; ")
|
|
91
|
-
}
|
|
92
|
-
|
|
93
82
|
/**
|
|
94
83
|
* Check one result against the route's declared response schema.
|
|
95
84
|
*
|
|
@@ -116,7 +105,7 @@ function interpret(
|
|
|
116
105
|
mode: "warn" | "enforce",
|
|
117
106
|
): ResponseContractOutcome {
|
|
118
107
|
if (settled.issues !== undefined) {
|
|
119
|
-
const message = `response does not satisfy its declared contract: ${
|
|
108
|
+
const message = `response does not satisfy its declared contract: ${formatStandardIssues(settled.issues)}`
|
|
120
109
|
// `warn` never changes what is served - it reports and gets out of the way, so switching it on can
|
|
121
110
|
// never be the thing that broke production.
|
|
122
111
|
return mode === "warn"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { NodeResponseContext, ResponseBodyReplacement } from "./node-outcome-hook.ts"
|
|
2
|
+
import { markTaggedResponse, taggedResponseBody, taggedResponseOwner } from "./respond.ts"
|
|
3
|
+
|
|
4
|
+
export function isBodylessStatus(status: number): boolean {
|
|
5
|
+
return status === 204 || status === 205 || status === 304
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Apply a body hook's return to the native response outcome. */
|
|
9
|
+
export function applyBodyReplacement(
|
|
10
|
+
response: NodeResponseContext,
|
|
11
|
+
replaced: string | Uint8Array | ResponseBodyReplacement | undefined,
|
|
12
|
+
): void {
|
|
13
|
+
if (replaced === undefined) return
|
|
14
|
+
if (typeof replaced === "string" || replaced instanceof Uint8Array) {
|
|
15
|
+
response.body = isBodylessStatus(response.status) ? null : replaced
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
if (replaced.body !== undefined) response.body = replaced.body
|
|
19
|
+
if (replaced.status !== undefined) response.status = replaced.status
|
|
20
|
+
if (isBodylessStatus(response.status)) response.body = null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Swap a tagged Response's body for a hook's replacement, re-tagging so later body hooks and the Node
|
|
25
|
+
* fallback's direct writer see the new bytes. Explicit lengths are dropped so framing is re-derived.
|
|
26
|
+
*/
|
|
27
|
+
export function withReplacedBody(
|
|
28
|
+
response: Response,
|
|
29
|
+
replaced: string | Uint8Array | ResponseBodyReplacement | undefined,
|
|
30
|
+
): Response {
|
|
31
|
+
if (replaced === undefined) return response
|
|
32
|
+
const originalBody = taggedResponseBody(response)
|
|
33
|
+
let body: string | Uint8Array | null
|
|
34
|
+
let status = response.status
|
|
35
|
+
if (typeof replaced === "string" || replaced instanceof Uint8Array) {
|
|
36
|
+
body = replaced
|
|
37
|
+
} else {
|
|
38
|
+
body = replaced.body !== undefined ? replaced.body : (originalBody ?? null)
|
|
39
|
+
if (replaced.status !== undefined) status = replaced.status
|
|
40
|
+
if (body === (originalBody ?? null) && status === response.status) return response
|
|
41
|
+
}
|
|
42
|
+
if (isBodylessStatus(status)) body = null
|
|
43
|
+
const headers = new Headers(response.headers)
|
|
44
|
+
headers.delete("content-length")
|
|
45
|
+
const next = new Response(body as ConstructorParameters<typeof Response>[0], {
|
|
46
|
+
status,
|
|
47
|
+
headers,
|
|
48
|
+
})
|
|
49
|
+
if (body !== null) markTaggedResponse(next, body, taggedResponseOwner(response))
|
|
50
|
+
return next
|
|
51
|
+
}
|
|
@@ -14,6 +14,20 @@ export const RESPONSE_RESULT = Symbol.for("nifra.response.result")
|
|
|
14
14
|
export const CONTEXT_SET = Symbol("nifra.context.set")
|
|
15
15
|
export const CONTEXT_SEARCH = Symbol("nifra.context.search")
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* A transport codec (or other pre-parsing hook) that already decoded the body stashes the value
|
|
19
|
+
* here on the replacement `RequestSource`; the JSON body lane takes it verbatim instead of
|
|
20
|
+
* parsing again. Boxed so a decoded `undefined` stays distinguishable from "not present". The
|
|
21
|
+
* stasher owns the poisoning guard for what it stashes - the body lane's own guard only covers
|
|
22
|
+
* text the lane itself parses.
|
|
23
|
+
*/
|
|
24
|
+
export const PRE_DECODED_BODY = Symbol("nifra.body.preDecoded")
|
|
25
|
+
|
|
26
|
+
/** The stash shape under {@link PRE_DECODED_BODY}. */
|
|
27
|
+
export interface PreDecodedBody {
|
|
28
|
+
readonly value: unknown
|
|
29
|
+
}
|
|
30
|
+
|
|
17
31
|
export interface ResponseResult {
|
|
18
32
|
readonly [RESPONSE_RESULT]: true
|
|
19
33
|
toResponse(): Response
|