@nifrajs/core 2.11.0 → 2.12.1

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.
Files changed (180) hide show
  1. package/dist/assurance.d.ts +61 -3
  2. package/dist/assurance.d.ts.map +1 -1
  3. package/dist/assurance.js +152 -5
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/capabilities.d.ts +9 -1
  6. package/dist/capabilities.d.ts.map +1 -1
  7. package/dist/capabilities.js +5 -1
  8. package/dist/capabilities.js.map +1 -1
  9. package/dist/diff.d.ts +2 -1
  10. package/dist/diff.d.ts.map +1 -1
  11. package/dist/diff.js +3 -1
  12. package/dist/diff.js.map +1 -1
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js.map +1 -1
  16. package/dist/evidence.d.ts +89 -0
  17. package/dist/evidence.d.ts.map +1 -0
  18. package/dist/evidence.js +170 -0
  19. package/dist/evidence.js.map +1 -0
  20. package/dist/execution-policy.d.ts +23 -0
  21. package/dist/execution-policy.d.ts.map +1 -0
  22. package/dist/execution-policy.js +28 -0
  23. package/dist/execution-policy.js.map +1 -0
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/internal/route-assurance.d.ts +19 -0
  27. package/dist/internal/route-assurance.d.ts.map +1 -1
  28. package/dist/internal/route-assurance.js +38 -5
  29. package/dist/internal/route-assurance.js.map +1 -1
  30. package/dist/internal/route-execution.d.ts +18 -0
  31. package/dist/internal/route-execution.d.ts.map +1 -1
  32. package/dist/internal/route-execution.js +67 -1
  33. package/dist/internal/route-execution.js.map +1 -1
  34. package/dist/manifest.d.ts +5 -1
  35. package/dist/manifest.d.ts.map +1 -1
  36. package/dist/manifest.js +22 -4
  37. package/dist/manifest.js.map +1 -1
  38. package/dist/reflection.d.ts +5 -0
  39. package/dist/reflection.d.ts.map +1 -1
  40. package/dist/reflection.js +13 -4
  41. package/dist/reflection.js.map +1 -1
  42. package/dist/replay.d.ts +26 -0
  43. package/dist/replay.d.ts.map +1 -0
  44. package/dist/replay.js +63 -0
  45. package/dist/replay.js.map +1 -0
  46. package/dist/schema/standard.d.ts +2 -0
  47. package/dist/schema/standard.d.ts.map +1 -1
  48. package/dist/schema/standard.js +13 -0
  49. package/dist/schema/standard.js.map +1 -1
  50. package/dist/server/body.d.ts +49 -6
  51. package/dist/server/body.d.ts.map +1 -1
  52. package/dist/server/body.js +226 -8
  53. package/dist/server/body.js.map +1 -1
  54. package/dist/server/context.d.ts +20 -3
  55. package/dist/server/context.d.ts.map +1 -1
  56. package/dist/server/contract.d.ts +15 -3
  57. package/dist/server/contract.d.ts.map +1 -1
  58. package/dist/server/contract.js +22 -0
  59. package/dist/server/contract.js.map +1 -1
  60. package/dist/server/cookies.d.ts +26 -5
  61. package/dist/server/cookies.d.ts.map +1 -1
  62. package/dist/server/cookies.js +76 -9
  63. package/dist/server/cookies.js.map +1 -1
  64. package/dist/server/edge.d.ts.map +1 -1
  65. package/dist/server/edge.js +11 -0
  66. package/dist/server/edge.js.map +1 -1
  67. package/dist/server/header-case.d.ts +9 -0
  68. package/dist/server/header-case.d.ts.map +1 -0
  69. package/dist/server/header-case.js +54 -0
  70. package/dist/server/header-case.js.map +1 -0
  71. package/dist/server/headers.d.ts +8 -0
  72. package/dist/server/headers.d.ts.map +1 -0
  73. package/dist/server/headers.js +13 -0
  74. package/dist/server/headers.js.map +1 -0
  75. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  76. package/dist/server/node-outcome-hook.js +7 -11
  77. package/dist/server/node-outcome-hook.js.map +1 -1
  78. package/dist/server/node-outcome.d.ts +8 -1
  79. package/dist/server/node-outcome.d.ts.map +1 -1
  80. package/dist/server/node-outcome.js +10 -3
  81. package/dist/server/node-outcome.js.map +1 -1
  82. package/dist/server/plugin.d.ts +66 -7
  83. package/dist/server/plugin.d.ts.map +1 -1
  84. package/dist/server/plugin.js +14 -10
  85. package/dist/server/plugin.js.map +1 -1
  86. package/dist/server/proto-guard.d.ts +30 -0
  87. package/dist/server/proto-guard.d.ts.map +1 -0
  88. package/dist/server/proto-guard.js +116 -0
  89. package/dist/server/proto-guard.js.map +1 -0
  90. package/dist/server/registry.d.ts +6 -2
  91. package/dist/server/registry.d.ts.map +1 -1
  92. package/dist/server/request-context.d.ts +24 -6
  93. package/dist/server/request-context.d.ts.map +1 -1
  94. package/dist/server/request-context.js +157 -30
  95. package/dist/server/request-context.js.map +1 -1
  96. package/dist/server/respond.d.ts +2 -2
  97. package/dist/server/respond.d.ts.map +1 -1
  98. package/dist/server/respond.js +34 -8
  99. package/dist/server/respond.js.map +1 -1
  100. package/dist/server/response-contract-lane.d.ts +1 -1
  101. package/dist/server/response-contract-lane.d.ts.map +1 -1
  102. package/dist/server/response-contract-lane.js +45 -13
  103. package/dist/server/response-contract-lane.js.map +1 -1
  104. package/dist/server/response-hooks.d.ts +10 -0
  105. package/dist/server/response-hooks.d.ts.map +1 -0
  106. package/dist/server/response-hooks.js +52 -0
  107. package/dist/server/response-hooks.js.map +1 -0
  108. package/dist/server/runtime-core.d.ts +12 -0
  109. package/dist/server/runtime-core.d.ts.map +1 -1
  110. package/dist/server/runtime-core.js +8 -0
  111. package/dist/server/runtime-core.js.map +1 -1
  112. package/dist/server/server-types.d.ts +60 -6
  113. package/dist/server/server-types.d.ts.map +1 -1
  114. package/dist/server/server.d.ts +80 -23
  115. package/dist/server/server.d.ts.map +1 -1
  116. package/dist/server/server.js +429 -213
  117. package/dist/server/server.js.map +1 -1
  118. package/dist/server/static-headers.d.ts +14 -3
  119. package/dist/server/static-headers.d.ts.map +1 -1
  120. package/dist/server/static-headers.js +28 -6
  121. package/dist/server/static-headers.js.map +1 -1
  122. package/dist/server/transport-lane.d.ts +8 -0
  123. package/dist/server/transport-lane.d.ts.map +1 -1
  124. package/dist/server/transport-lane.js +12 -8
  125. package/dist/server/transport-lane.js.map +1 -1
  126. package/dist/server/websocket.d.ts +3 -0
  127. package/dist/server/websocket.d.ts.map +1 -1
  128. package/dist/server/websocket.js +14 -0
  129. package/dist/server/websocket.js.map +1 -1
  130. package/dist/server.d.ts +2 -2
  131. package/dist/server.d.ts.map +1 -1
  132. package/dist/server.js +2 -2
  133. package/dist/server.js.map +1 -1
  134. package/dist/tool-catalog.d.ts +21 -0
  135. package/dist/tool-catalog.d.ts.map +1 -0
  136. package/dist/tool-catalog.js +16 -0
  137. package/dist/tool-catalog.js.map +1 -0
  138. package/dist/tool-contract.d.ts +238 -0
  139. package/dist/tool-contract.d.ts.map +1 -0
  140. package/dist/tool-contract.js +569 -0
  141. package/dist/tool-contract.js.map +1 -0
  142. package/package.json +26 -1
  143. package/src/assurance.ts +236 -6
  144. package/src/capabilities.ts +14 -1
  145. package/src/diff.ts +5 -2
  146. package/src/errors.ts +1 -0
  147. package/src/evidence.ts +319 -0
  148. package/src/execution-policy.ts +57 -0
  149. package/src/index.ts +1 -1
  150. package/src/internal/route-assurance.ts +67 -4
  151. package/src/internal/route-execution.ts +276 -1
  152. package/src/manifest.ts +27 -5
  153. package/src/reflection.ts +24 -4
  154. package/src/replay.ts +96 -0
  155. package/src/schema/standard.ts +16 -0
  156. package/src/server/body.ts +275 -7
  157. package/src/server/context.ts +23 -3
  158. package/src/server/contract.ts +43 -3
  159. package/src/server/cookies.ts +80 -10
  160. package/src/server/edge.ts +10 -0
  161. package/src/server/header-case.ts +54 -0
  162. package/src/server/headers.ts +11 -0
  163. package/src/server/node-outcome-hook.ts +6 -11
  164. package/src/server/node-outcome.ts +10 -2
  165. package/src/server/plugin.ts +87 -8
  166. package/src/server/proto-guard.ts +118 -0
  167. package/src/server/registry.ts +6 -2
  168. package/src/server/request-context.ts +242 -26
  169. package/src/server/respond.ts +35 -4
  170. package/src/server/response-contract-lane.ts +6 -17
  171. package/src/server/response-hooks.ts +51 -0
  172. package/src/server/runtime-core.ts +14 -0
  173. package/src/server/server-types.ts +66 -6
  174. package/src/server/server.ts +672 -287
  175. package/src/server/static-headers.ts +27 -3
  176. package/src/server/transport-lane.ts +19 -8
  177. package/src/server/websocket.ts +18 -2
  178. package/src/server.ts +7 -0
  179. package/src/tool-catalog.ts +40 -0
  180. 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 { drainCapped, parseContentLength, readBoundedBytes } from "./body.ts"
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(name, "", { path: "/", ...options, maxAge: 0, expires: EPOCH }),
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(source: RequestSource, params: Record<string, string>, maxBodyBytes: number)
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
- signal?: AbortSignal,
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 = signal
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
- return requestOf(this.source)
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
- return requestOf(this.source)
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>(this.source, this.maxBodyBytes, maxBytes)
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`: `readBoundedBodyOrThrow` + `JSON.parse`, throwing a flat 400 on bad JSON. */
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
- * Non-chunked, framed requests with an in-cap `Content-Length` use the runtime-native `req.json()`;
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
- export async function readBoundedJsonSource(
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
- ): Promise<unknown | Response> {
264
- const declared = headerOf(req, "content-length")
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) return jsonError(400, "invalid_content_length")
274
- if (length > maxBytes) return jsonError(413, "payload_too_large")
275
- const chunked = headerOf(req, "transfer-encoding") !== null
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
- try {
278
- return await req.json()
279
- } catch {
280
- return jsonError(400, "invalid_json")
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 body = req.body
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 JSON.parse(TEXT_DECODER.decode(drained.bytes))
505
+ return parseJsonGuarded(TEXT_DECODER.decode(drained.bytes), protoPoisoning)
290
506
  } catch {
291
507
  return jsonError(400, "invalid_json")
292
508
  }
@@ -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
- for (const [name, value] of statics.entries) {
121
- if (!headers.has(name)) headers.set(name, value)
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 (const [name, value] of statics.entries) response.headers.set(name, value)
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 type { StandardIssue, StandardResult, StandardSchemaV1 } from "../schema/standard.ts"
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: ${describeIssues(settled.issues)}`
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