@nifrajs/core 2.14.1 → 3.2.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.
Files changed (226) hide show
  1. package/README.md +25 -0
  2. package/dist/assurance.d.ts +16 -8
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +33 -0
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/durable-adapters.d.ts.map +1 -1
  7. package/dist/durable-adapters.js +26 -3
  8. package/dist/durable-adapters.js.map +1 -1
  9. package/dist/durable-approval.d.ts +56 -0
  10. package/dist/durable-approval.d.ts.map +1 -0
  11. package/dist/durable-approval.js +233 -0
  12. package/dist/durable-approval.js.map +1 -0
  13. package/dist/durable-effect.d.ts +42 -0
  14. package/dist/durable-effect.d.ts.map +1 -0
  15. package/dist/durable-effect.js +234 -0
  16. package/dist/durable-effect.js.map +1 -0
  17. package/dist/durable-execution.d.ts +8 -346
  18. package/dist/durable-execution.d.ts.map +1 -1
  19. package/dist/durable-execution.js +7 -1150
  20. package/dist/durable-execution.js.map +1 -1
  21. package/dist/durable-saga.d.ts +94 -0
  22. package/dist/durable-saga.d.ts.map +1 -0
  23. package/dist/durable-saga.js +550 -0
  24. package/dist/durable-saga.js.map +1 -0
  25. package/dist/edge-kit.d.ts +27 -0
  26. package/dist/edge-kit.d.ts.map +1 -0
  27. package/dist/edge-kit.js +24 -0
  28. package/dist/edge-kit.js.map +1 -0
  29. package/dist/errors.d.ts +1 -1
  30. package/dist/errors.d.ts.map +1 -1
  31. package/dist/errors.js.map +1 -1
  32. package/dist/index.d.ts +23 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +18 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/internal/durable-approval-protocol.d.ts +1 -1
  37. package/dist/internal/durable-approval-protocol.d.ts.map +1 -1
  38. package/dist/internal/durable-effect-protocol.d.ts +1 -1
  39. package/dist/internal/durable-effect-protocol.d.ts.map +1 -1
  40. package/dist/internal/durable-saga-protocol.d.ts +1 -1
  41. package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
  42. package/dist/internal/durable-shared.d.ts +26 -0
  43. package/dist/internal/durable-shared.d.ts.map +1 -0
  44. package/dist/internal/durable-shared.js +131 -0
  45. package/dist/internal/durable-shared.js.map +1 -0
  46. package/dist/internal/durable-types.d.ts +159 -0
  47. package/dist/internal/durable-types.d.ts.map +1 -0
  48. package/dist/internal/durable-types.js +2 -0
  49. package/dist/internal/durable-types.js.map +1 -0
  50. package/dist/internal/route-compiler.d.ts +61 -0
  51. package/dist/internal/route-compiler.d.ts.map +1 -0
  52. package/dist/internal/route-compiler.js +121 -0
  53. package/dist/internal/route-compiler.js.map +1 -0
  54. package/dist/internal/route-execution.d.ts +17 -9
  55. package/dist/internal/route-execution.d.ts.map +1 -1
  56. package/dist/internal/route-execution.js +4 -18
  57. package/dist/internal/route-execution.js.map +1 -1
  58. package/dist/internal/route-lanes.d.ts +35 -0
  59. package/dist/internal/route-lanes.d.ts.map +1 -0
  60. package/dist/internal/route-lanes.js +150 -0
  61. package/dist/internal/route-lanes.js.map +1 -0
  62. package/dist/internal/route-program.d.ts +67 -0
  63. package/dist/internal/route-program.d.ts.map +1 -0
  64. package/dist/internal/route-program.js +199 -0
  65. package/dist/internal/route-program.js.map +1 -0
  66. package/dist/response-observer.d.ts +25 -0
  67. package/dist/response-observer.d.ts.map +1 -0
  68. package/dist/response-observer.js +40 -0
  69. package/dist/response-observer.js.map +1 -0
  70. package/dist/security/baseline.d.ts +68 -0
  71. package/dist/security/baseline.d.ts.map +1 -0
  72. package/dist/security/baseline.js +118 -0
  73. package/dist/security/baseline.js.map +1 -0
  74. package/dist/server/bare-error-lane.d.ts +26 -0
  75. package/dist/server/bare-error-lane.d.ts.map +1 -0
  76. package/dist/server/bare-error-lane.js +60 -0
  77. package/dist/server/bare-error-lane.js.map +1 -0
  78. package/dist/server/body-lane.d.ts +13 -0
  79. package/dist/server/body-lane.d.ts.map +1 -0
  80. package/dist/server/body-lane.js +134 -0
  81. package/dist/server/body-lane.js.map +1 -0
  82. package/dist/server/body.d.ts +7 -0
  83. package/dist/server/body.d.ts.map +1 -1
  84. package/dist/server/body.js +7 -0
  85. package/dist/server/body.js.map +1 -1
  86. package/dist/server/bridge-markers.d.ts +25 -0
  87. package/dist/server/bridge-markers.d.ts.map +1 -0
  88. package/dist/server/bridge-markers.js +25 -0
  89. package/dist/server/bridge-markers.js.map +1 -0
  90. package/dist/server/context.d.ts +6 -1
  91. package/dist/server/context.d.ts.map +1 -1
  92. package/dist/server/contract.d.ts +40 -14
  93. package/dist/server/contract.d.ts.map +1 -1
  94. package/dist/server/contract.js +7 -0
  95. package/dist/server/contract.js.map +1 -1
  96. package/dist/server/cookies.d.ts.map +1 -1
  97. package/dist/server/cookies.js +16 -2
  98. package/dist/server/cookies.js.map +1 -1
  99. package/dist/server/header-case.d.ts.map +1 -1
  100. package/dist/server/header-case.js +2 -1
  101. package/dist/server/header-case.js.map +1 -1
  102. package/dist/server/http.d.ts +36 -3
  103. package/dist/server/http.d.ts.map +1 -1
  104. package/dist/server/http.js +49 -3
  105. package/dist/server/http.js.map +1 -1
  106. package/dist/server/install.d.ts +6 -8
  107. package/dist/server/install.d.ts.map +1 -1
  108. package/dist/server/install.js +7 -0
  109. package/dist/server/install.js.map +1 -1
  110. package/dist/server/node-outcome-hook.d.ts +2 -1
  111. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  112. package/dist/server/node-outcome-hook.js.map +1 -1
  113. package/dist/server/node-outcome.d.ts +17 -2
  114. package/dist/server/node-outcome.d.ts.map +1 -1
  115. package/dist/server/node-outcome.js +37 -3
  116. package/dist/server/node-outcome.js.map +1 -1
  117. package/dist/server/plugin.d.ts +2 -2
  118. package/dist/server/plugin.d.ts.map +1 -1
  119. package/dist/server/plugin.js +6 -1
  120. package/dist/server/plugin.js.map +1 -1
  121. package/dist/server/query.d.ts +7 -1
  122. package/dist/server/query.d.ts.map +1 -1
  123. package/dist/server/query.js +8 -3
  124. package/dist/server/query.js.map +1 -1
  125. package/dist/server/registry.d.ts +40 -12
  126. package/dist/server/registry.d.ts.map +1 -1
  127. package/dist/server/request-context.d.ts +2 -13
  128. package/dist/server/request-context.d.ts.map +1 -1
  129. package/dist/server/request-context.js +18 -159
  130. package/dist/server/request-context.js.map +1 -1
  131. package/dist/server/respond.d.ts.map +1 -1
  132. package/dist/server/respond.js +58 -4
  133. package/dist/server/respond.js.map +1 -1
  134. package/dist/server/response-contract-lane.d.ts.map +1 -1
  135. package/dist/server/response-contract-lane.js +5 -2
  136. package/dist/server/response-contract-lane.js.map +1 -1
  137. package/dist/server/response-observer-runtime.d.ts +20 -0
  138. package/dist/server/response-observer-runtime.d.ts.map +1 -0
  139. package/dist/server/response-observer-runtime.js +95 -0
  140. package/dist/server/response-observer-runtime.js.map +1 -0
  141. package/dist/server/runtime-core.d.ts +76 -0
  142. package/dist/server/runtime-core.d.ts.map +1 -1
  143. package/dist/server/runtime-core.js +42 -1
  144. package/dist/server/runtime-core.js.map +1 -1
  145. package/dist/server/server-types.d.ts +6 -1
  146. package/dist/server/server-types.d.ts.map +1 -1
  147. package/dist/server/server.d.ts +158 -116
  148. package/dist/server/server.d.ts.map +1 -1
  149. package/dist/server/server.js +707 -844
  150. package/dist/server/server.js.map +1 -1
  151. package/dist/server/validation.d.ts +5 -0
  152. package/dist/server/validation.d.ts.map +1 -0
  153. package/dist/server/validation.js +13 -0
  154. package/dist/server/validation.js.map +1 -0
  155. package/dist/server/websocket.d.ts +20 -4
  156. package/dist/server/websocket.d.ts.map +1 -1
  157. package/dist/server/websocket.js +81 -11
  158. package/dist/server/websocket.js.map +1 -1
  159. package/dist/server/ws-bun.d.ts +12 -4
  160. package/dist/server/ws-bun.d.ts.map +1 -1
  161. package/dist/server/ws-bun.js +50 -16
  162. package/dist/server/ws-bun.js.map +1 -1
  163. package/dist/server/ws-hook.d.ts +3 -2
  164. package/dist/server/ws-hook.d.ts.map +1 -1
  165. package/dist/server.d.ts +3 -2
  166. package/dist/server.d.ts.map +1 -1
  167. package/dist/server.js +2 -1
  168. package/dist/server.js.map +1 -1
  169. package/dist/single-copy-register.d.ts +2 -0
  170. package/dist/single-copy-register.d.ts.map +1 -0
  171. package/dist/single-copy-register.js +24 -0
  172. package/dist/single-copy-register.js.map +1 -0
  173. package/dist/single-copy.d.ts +156 -0
  174. package/dist/single-copy.d.ts.map +1 -0
  175. package/dist/single-copy.js +472 -0
  176. package/dist/single-copy.js.map +1 -0
  177. package/package.json +28 -3
  178. package/src/assurance.ts +58 -1
  179. package/src/durable-adapters.ts +20 -0
  180. package/src/durable-approval.ts +281 -0
  181. package/src/durable-effect.ts +300 -0
  182. package/src/durable-execution.ts +8 -1627
  183. package/src/durable-saga.ts +744 -0
  184. package/src/edge-kit.ts +27 -0
  185. package/src/errors.ts +1 -0
  186. package/src/index.ts +129 -2
  187. package/src/internal/durable-approval-protocol.ts +1 -1
  188. package/src/internal/durable-effect-protocol.ts +1 -1
  189. package/src/internal/durable-saga-protocol.ts +1 -1
  190. package/src/internal/durable-shared.ts +157 -0
  191. package/src/internal/durable-types.ts +194 -0
  192. package/src/internal/route-compiler.ts +234 -0
  193. package/src/internal/route-execution.ts +53 -96
  194. package/src/internal/route-lanes.ts +221 -0
  195. package/src/internal/route-program.ts +404 -0
  196. package/src/response-observer.ts +64 -0
  197. package/src/security/baseline.ts +172 -0
  198. package/src/server/bare-error-lane.ts +77 -0
  199. package/src/server/body-lane.ts +203 -0
  200. package/src/server/body.ts +8 -0
  201. package/src/server/bridge-markers.ts +24 -0
  202. package/src/server/context.ts +6 -1
  203. package/src/server/contract.ts +70 -20
  204. package/src/server/cookies.ts +16 -2
  205. package/src/server/header-case.ts +3 -1
  206. package/src/server/http.ts +53 -3
  207. package/src/server/install.ts +14 -0
  208. package/src/server/node-outcome-hook.ts +2 -1
  209. package/src/server/node-outcome.ts +44 -3
  210. package/src/server/plugin.ts +11 -5
  211. package/src/server/query.ts +10 -4
  212. package/src/server/registry.ts +71 -16
  213. package/src/server/request-context.ts +13 -216
  214. package/src/server/respond.ts +71 -3
  215. package/src/server/response-contract-lane.ts +6 -2
  216. package/src/server/response-observer-runtime.ts +135 -0
  217. package/src/server/runtime-core.ts +124 -1
  218. package/src/server/server-types.ts +6 -1
  219. package/src/server/server.ts +1446 -1485
  220. package/src/server/validation.ts +19 -0
  221. package/src/server/websocket.ts +127 -17
  222. package/src/server/ws-bun.ts +67 -15
  223. package/src/server/ws-hook.ts +3 -2
  224. package/src/server.ts +8 -2
  225. package/src/single-copy-register.ts +24 -0
  226. package/src/single-copy.ts +526 -0
@@ -5,19 +5,13 @@
5
5
  * sits below the server module in the graph.
6
6
  */
7
7
  import type { RequestBudget } from "../budget.ts"
8
- import {
9
- applyTransportCap,
10
- drainCapped,
11
- hasTrustedBodyFraming,
12
- parseContentLength,
13
- RAW_BODY_READERS,
14
- readBoundedBytes,
15
- } from "./body.ts"
8
+ import { applyTransportCap, readBoundedBytes } from "./body.ts"
9
+ import { readBoundedJsonSource } from "./body-lane.ts"
16
10
  import type { Platform } from "./context.ts"
17
11
  import { type CookieOptions, cookieNamePrefix, parseCookies, serializeCookie } from "./cookies.ts"
18
12
  import { headerObjectOf } from "./headers.ts"
19
- import { jsonError } from "./http.ts"
20
- import { guardParsedValue, type ProtoPoisoning, parseJsonGuarded } from "./proto-guard.ts"
13
+ import { plainError } from "./http.ts"
14
+ import type { ProtoPoisoning } from "./proto-guard.ts"
21
15
  import { searchOf } from "./query.ts"
22
16
  import { responseJsonContentType } from "./respond.ts"
23
17
  import {
@@ -27,13 +21,13 @@ import {
27
21
  getNeverAbortSignal,
28
22
  getUnboundedRequestBudget,
29
23
  headerOf,
30
- PRE_DECODED_BODY,
31
- type PreDecodedBody,
24
+ isResponseResult,
32
25
  requestOf,
33
- TEXT_DECODER,
34
26
  } from "./runtime-core.ts"
35
27
  import type { CtxSet, RawContext, RequestSource } from "./server.ts"
36
28
 
29
+ export { readBodyFramed, readBoundedJsonSource } from "./body-lane.ts"
30
+
37
31
  /** A fixed past instant for cookie deletion (`Expires`). A literal epoch - deterministic, unlike an
38
32
  * argless `new Date()`. */
39
33
  const EPOCH = new Date(0)
@@ -390,7 +384,7 @@ export class RequestContext implements RawContext {
390
384
  }
391
385
  }
392
386
 
393
- /** Backs `c.boundedBody`: bounded byte read that throws a flat 413/400 `Response` (caught by
387
+ /** Backs `c.boundedBody`: bounded byte read that throws a flat 413/400 plain render (caught by
394
388
  * `runLifecycle` as control flow, like `throw redirect(...)`), so a handler can't ignore the cap.
395
389
  * The byte-cap itself lives in `./body.ts` (shared with the schema path and `verifyWebhook`). */
396
390
  async function readBoundedBodyOrThrow(
@@ -401,8 +395,8 @@ async function readBoundedBodyOrThrow(
401
395
  const r = await readBoundedBytes(req, maxBytes ?? maxBodyBytes)
402
396
  if (r.ok) return r.bytes
403
397
  throw r.status === 413
404
- ? jsonError(413, "payload_too_large")
405
- : jsonError(400, "invalid_content_length")
398
+ ? plainError(413, "payload_too_large")
399
+ : plainError(400, "invalid_content_length")
406
400
  }
407
401
 
408
402
  /** Backs `c.boundedJson`: `readBoundedJsonSource`, throwing its flat 400/413 on failure. */
@@ -413,7 +407,9 @@ async function readBoundedJsonBodyOrThrow<T>(
413
407
  maxBytes?: number,
414
408
  ): Promise<T> {
415
409
  const parsed = await readBoundedJsonSource(req, maxBytes ?? maxBodyBytes, protoPoisoning)
416
- if (parsed instanceof Response) throw parsed
410
+ // The lane's own failures are the only branded values it can produce: a parsed JSON body is
411
+ // `JSON.parse` output, which carries string keys only and so can never answer to a symbol brand.
412
+ if (isResponseResult(parsed)) throw parsed
417
413
  return parsed as T
418
414
  }
419
415
 
@@ -424,202 +420,3 @@ async function readBoundedJsonBodyOrThrow<T>(
424
420
  * with an in-cap `Content-Length` take the runtime's fused `json()` and walk the parsed value;
425
421
  * chunked or length-less requests fall back to the streaming byte-cap guard.
426
422
  */
427
- /**
428
- * Deliberately **not** `async`: the framed lane is the hot path for every JSON POST, and an async
429
- * wrapper costs a coroutine frame, a wrapper promise, and an extra microtask hop on top of the one
430
- * the body read already pays (~90ns/request on Node, measured). The lane returns the body read's
431
- * own promise chain instead, and only the streaming fallback - which is already doing IO in a loop
432
- * - runs as an async function. Callers that `await` the result are unaffected.
433
- */
434
- type JsonResultContinuation<T> = (result: unknown | Response) => T | Promise<T>
435
- type JsonErrorContinuation<T> = (error: unknown) => T | Promise<T>
436
- type FramedJsonSource = RequestSource & {
437
- readonly bytes?: () => Promise<Uint8Array>
438
- readonly jsonWithByteLength?: () => Promise<{
439
- readonly value: unknown
440
- readonly byteLength: number
441
- }>
442
- }
443
-
444
- export function readBoundedJsonSource(
445
- req: RequestSource,
446
- maxBytes: number,
447
- protoPoisoning?: ProtoPoisoning,
448
- ): Promise<unknown | Response>
449
- export function readBoundedJsonSource<T>(
450
- req: RequestSource,
451
- maxBytes: number,
452
- protoPoisoning: ProtoPoisoning,
453
- onResult: JsonResultContinuation<T>,
454
- onError: JsonErrorContinuation<T>,
455
- ): Promise<T>
456
- export function readBoundedJsonSource<T>(
457
- req: RequestSource,
458
- maxBytes: number,
459
- protoPoisoning: ProtoPoisoning = "reject",
460
- onResult?: JsonResultContinuation<T>,
461
- onError?: JsonErrorContinuation<T>,
462
- ): Promise<unknown | Response | T> {
463
- // A pre-parsing hook (the transport-codec lane) may have decoded the body already - its stash
464
- // is taken verbatim: the stasher enforced its own byte cap and poisoning policy on text this
465
- // lane never sees. One symbol read; a miss on ordinary requests costs a cache-line, not a parse.
466
- const preDecoded = (req as { [PRE_DECODED_BODY]?: PreDecodedBody })[PRE_DECODED_BODY]
467
- if (preDecoded !== undefined) {
468
- return onResult === undefined
469
- ? Promise.resolve(preDecoded.value)
470
- : Promise.resolve(preDecoded.value).then(onResult)
471
- }
472
- // Read through the pre-cap readers when the request is transport-capped: this lane enforces its
473
- // own `maxBytes` (which may exceed the route's transport cap), so the shadowed user-facing
474
- // readers must not narrow it.
475
- // Keep the source's header dispatch local: the normal Node source exposes a direct `header`
476
- // lookup, while a Web Request only exposes `Headers.get`. Rechecking that optional method through
477
- // `headerOf` twice per body is measurable on the framed lane. The raw-reader shadow is still
478
- // bypassed for reads, but header authority remains the original source (same semantics as before).
479
- const raw = ((req as { [RAW_BODY_READERS]?: RequestSource })[RAW_BODY_READERS] ??
480
- req) as FramedJsonSource
481
- const sourceHeader = req.header
482
- const declared =
483
- sourceHeader === undefined
484
- ? req.headers.get("content-length")
485
- : sourceHeader.call(req, "content-length")
486
- if (declared !== null) {
487
- // A present Content-Length must be a non-negative integer (HTTP grammar: `1*DIGIT`). A
488
- // non-numeric / negative / fractional / exponential value (`Number()` would happily accept
489
- // "abc"->NaN, "-5", "1.5", "1e3", "0x10") is malformed -> 400, rather than silently falling
490
- // through to the streaming guard - which is an UPPER-bound cap only, so a lying SMALLER length
491
- // would otherwise be read in full. Real HTTP servers only hand us a valid framed length; this
492
- // hardens hand-built Requests (tests, the in-process client) and crafted input.
493
- const length = parseContentLength(declared)
494
- if (length === undefined)
495
- return onResult === undefined
496
- ? Promise.resolve(jsonError(400, "invalid_content_length"))
497
- : Promise.resolve(jsonError(400, "invalid_content_length")).then(onResult)
498
- if (length > maxBytes)
499
- return onResult === undefined
500
- ? Promise.resolve(jsonError(413, "payload_too_large"))
501
- : Promise.resolve(jsonError(413, "payload_too_large")).then(onResult)
502
- // Trusted framing (Bun's native route table, Deno.serve): the runtime's HTTP parser already
503
- // delimited the bytes, so the declared length IS the transport frame and chunked routing is
504
- // moot - the over-cap 413 above already enforced the byte cap. Take the runtime's fused
505
- // decode+parse straight away, before the transfer-encoding probe that only exists to route
506
- // *untrusted* Node/adapter sources to the streaming lane. Skipping that probe drops one
507
- // `Headers.get()` from every Bun/Deno JSON POST; Node/adapter sources are never marked and fall
508
- // through below. The parsed-value poisoning walk still applies.
509
- if (hasTrustedBodyFraming(req)) {
510
- return raw.json().then(
511
- (parsed) => {
512
- let guarded: unknown
513
- try {
514
- guarded = guardParsedValue(parsed, protoPoisoning)
515
- } catch {
516
- return onResult === undefined
517
- ? jsonError(400, "invalid_json")
518
- : onResult(jsonError(400, "invalid_json"))
519
- }
520
- return onResult === undefined ? guarded : onResult(guarded)
521
- },
522
- () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
523
- )
524
- }
525
- const chunked =
526
- (sourceHeader === undefined
527
- ? req.headers.get("transfer-encoding")
528
- : sourceHeader.call(req, "transfer-encoding")) !== null
529
- if (!chunked) {
530
- const jsonWithByteLength = raw.jsonWithByteLength
531
- if (jsonWithByteLength !== undefined) {
532
- return jsonWithByteLength.call(raw).then(
533
- ({ value, byteLength }) => {
534
- if (byteLength > length || byteLength > maxBytes) {
535
- return onResult === undefined
536
- ? jsonError(413, "payload_too_large")
537
- : onResult(jsonError(413, "payload_too_large"))
538
- }
539
- let guarded: unknown
540
- try {
541
- guarded = guardParsedValue(value, protoPoisoning)
542
- } catch {
543
- return onResult === undefined
544
- ? jsonError(400, "invalid_json")
545
- : onResult(jsonError(400, "invalid_json"))
546
- }
547
- return onResult === undefined ? guarded : onResult(guarded)
548
- },
549
- () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
550
- )
551
- }
552
- const bytesReader = raw.bytes
553
- if (bytesReader !== undefined) {
554
- return bytesReader.call(raw).then(
555
- (bytes) => {
556
- if (bytes.byteLength > length || bytes.byteLength > maxBytes) {
557
- return onResult === undefined
558
- ? jsonError(413, "payload_too_large")
559
- : onResult(jsonError(413, "payload_too_large"))
560
- }
561
- try {
562
- const parsed = parseJsonGuarded(TEXT_DECODER.decode(bytes), protoPoisoning)
563
- return onResult === undefined ? parsed : onResult(parsed)
564
- } catch {
565
- return onResult === undefined
566
- ? jsonError(400, "invalid_json")
567
- : onResult(jsonError(400, "invalid_json"))
568
- }
569
- },
570
- () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
571
- )
572
- }
573
- // Read the actual framed bytes before parsing. A declared length is only a hint on Web
574
- // Requests and adapter-shaped sources: a caller can construct a Request (or an adapter can
575
- // decode/expand a body) whose header claims fewer bytes than the delivered payload. Calling
576
- // native `json()` here would accept that understatement and bypass the cap. The byte lane
577
- // already owns this post-read check; keeping the JSON lane on the same raw-byte read makes the
578
- // trust boundary consistent. `Uint8Array(buffer)` is a view, not a copy.
579
- return raw.arrayBuffer().then(
580
- (buffer) => {
581
- const bytes = new Uint8Array(buffer)
582
- if (bytes.byteLength > length || bytes.byteLength > maxBytes) {
583
- return onResult === undefined
584
- ? jsonError(413, "payload_too_large")
585
- : onResult(jsonError(413, "payload_too_large"))
586
- }
587
- let parsed: unknown
588
- try {
589
- parsed = parseJsonGuarded(TEXT_DECODER.decode(bytes), protoPoisoning)
590
- } catch {
591
- return onResult === undefined
592
- ? jsonError(400, "invalid_json")
593
- : onResult(jsonError(400, "invalid_json"))
594
- }
595
- return onResult === undefined ? parsed : onResult(parsed)
596
- },
597
- () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
598
- )
599
- }
600
- }
601
- const streamed = readStreamedJsonSource(raw, maxBytes, protoPoisoning)
602
- return onResult === undefined ? streamed : streamed.then(onResult, onError)
603
- }
604
-
605
- /** The shared rejection handler for a body read that never completed (client abort, socket error,
606
- * malformed framing): the same flat 400 a syntactically invalid body gets. Hoisted so the framed
607
- * lane allocates no per-request closure for it. */
608
- const INVALID_JSON = (): Response => jsonError(400, "invalid_json")
609
-
610
- /** The chunked / length-less fallback: drain under the streaming byte cap, then parse guarded. */
611
- async function readStreamedJsonSource(
612
- raw: RequestSource,
613
- maxBytes: number,
614
- protoPoisoning: ProtoPoisoning,
615
- ): Promise<unknown | Response> {
616
- const body = raw.body
617
- if (body === null) return jsonError(400, "invalid_json")
618
- const drained = await drainCapped(body, maxBytes)
619
- if (!drained.ok) return jsonError(413, "payload_too_large")
620
- try {
621
- return parseJsonGuarded(TEXT_DECODER.decode(drained.bytes), protoPoisoning)
622
- } catch {
623
- return jsonError(400, "invalid_json")
624
- }
625
- }
@@ -4,11 +4,14 @@
4
4
  * spine types, so the wire-response format lives in one module distinct from the request engine.
5
5
  */
6
6
 
7
+ import { NODE_BRIDGE_MARKER_KEYS } from "./bridge-markers.ts"
7
8
  import {
8
9
  CONTEXT_SET,
9
10
  EMPTY_RESPONSE_CONTROLS,
10
11
  type HandlerResult,
11
12
  isResponseResult,
13
+ type PlainRender,
14
+ plainRenderHeaders,
12
15
  } from "./runtime-core.ts"
13
16
  import type { CtxSet, RawContext } from "./server.ts"
14
17
  import { mergeStaticHeaderRecord, type StaticResponseHeaders } from "./static-headers.ts"
@@ -24,6 +27,16 @@ function headersInit(set: CtxSet): Record<string, string> | Headers | undefined
24
27
  return headers
25
28
  }
26
29
 
30
+ /** The `c.set` a plain render stands for: its status and headers, carrying over the request's queued
31
+ * cookies so `set.cookie(...)` then an early exit still ships them. */
32
+ function plainRenderSet(plain: PlainRender, set: CtxSet): CtxSet {
33
+ return {
34
+ status: plain.status,
35
+ _headers: plainRenderHeaders(plain, set),
36
+ _cookies: set._cookies,
37
+ } as CtxSet
38
+ }
39
+
27
40
  // Keep the fast JSON respond path byte-identical to `Response.json` without probing it at module
28
41
  // scope: workerd forbids `Response.json()` during startup. A shared `Headers` is safe to reuse
29
42
  // across responses: the Response constructor copies `init.headers` into its own list.
@@ -109,6 +122,8 @@ function staticJsonResponse(
109
122
  contentType,
110
123
  )
111
124
  }
125
+ // lgtm [js/stack-trace-exposure] `body` is already serialized; ordinary thrown errors are replaced
126
+ // with the fixed internal-error envelope before reaching this response boundary.
112
127
  return new Response(body, init)
113
128
  }
114
129
 
@@ -147,6 +162,7 @@ function withStaticHeaderInit(
147
162
  // portable constructor path there and select the Deno-specific shape only for Web responses that
148
163
  // carry a plain record. The first request lazily probes Deno's native JSON content-type so this
149
164
  // preserves the runtime's Response.json contract instead of baking in Bun's charset suffix.
165
+ const IS_BUN = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"
150
166
  const IS_DENO = typeof (globalThis as { Deno?: unknown }).Deno !== "undefined"
151
167
  let denoJsonContentType: string | undefined
152
168
 
@@ -163,6 +179,8 @@ function denoResponseWithJsonBody(
163
179
  headers: Record<string, string>,
164
180
  defaultContentType = responseJsonContentType(),
165
181
  ): Response {
182
+ // lgtm [js/stack-trace-exposure] this body is serialized data; ordinary thrown errors are replaced
183
+ // with the fixed internal-error envelope before reaching this response boundary.
166
184
  const response = new Response(body, { status })
167
185
  let hasContentType = false
168
186
  for (const [name, value] of Object.entries(headers)) {
@@ -175,10 +193,28 @@ function denoResponseWithJsonBody(
175
193
  return response
176
194
  }
177
195
 
196
+ /**
197
+ * Render a JSON value with caller-owned headers on the runtimes where mutating the response is
198
+ * cheaper than asking the Response constructor to ingest a record. Node deliberately stays on the
199
+ * record path: its undici implementation is a little faster there, and its production HTTP adapter
200
+ * normally uses the direct node outcome anyway. The two operations are equivalent at the Web
201
+ * boundary: Response.json supplies the same default content type, then the caller's headers override
202
+ * it exactly as `withJsonContentType` did.
203
+ */
204
+ function jsonResponseWithOwnHeaders(
205
+ result: object,
206
+ status: number,
207
+ headers: Record<string, string>,
208
+ ): Response {
209
+ const response = status === 200 ? Response.json(result) : Response.json(result, { status })
210
+ for (const name of Object.keys(headers)) response.headers.set(name, headers[name]!)
211
+ return stamped(response)
212
+ }
213
+
178
214
  // The framework-buffered-body marker shared with the Node adapter: a Response carrying it exposes
179
215
  // its already-serialized bytes without draining. On the Web serving paths it exists ONLY when a
180
216
  // registered body/raw hook needs it, so hook-less apps pay nothing.
181
- const RESPONSE_BODY = Symbol.for("nifra.response.body")
217
+ const RESPONSE_BODY = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseBody)
182
218
 
183
219
  /**
184
220
  * Headers objects the framework itself constructed - guaranteed mutable, no guard. Response
@@ -396,6 +432,18 @@ export function toResponse(
396
432
  statics?: StaticResponseHeaders,
397
433
  ): Response {
398
434
  if (isResponseResult(result)) {
435
+ const plain = result.plain
436
+ if (plain !== undefined) {
437
+ // Rendered as the value it is, on the branches below, rather than through `toResponse()`: the
438
+ // status and headers fold into a `set` and the body takes the ordinary JSON lane. One level
439
+ // only - a plain render's body is a user value, never another response carrier.
440
+ return toResponse(
441
+ plain.body as HandlerResult,
442
+ plainRenderSet(plain, set),
443
+ tagResponseBody,
444
+ statics,
445
+ )
446
+ }
399
447
  return withStatics(
400
448
  appendCookiesToResponse(normalizeBodylessResponse(result.toResponse()), set),
401
449
  statics,
@@ -413,6 +461,24 @@ export function toResponse(
413
461
  if (status === 204 || status === 205 || status === 304) {
414
462
  return stamped(new Response(null, headers === undefined ? { status } : { status, headers }))
415
463
  }
464
+ // Bun and Deno both make a JSON response first and then accept a few header mutations more cheaply
465
+ // than ingesting the same plain record in the constructor. This is the common dynamic-header
466
+ // shape (`c.set.headers`) after all cookies/static-header/tagging cases have been excluded. Keep the
467
+ // Node record path unchanged: its Web constructor wins by a small margin, and the native Node
468
+ // adapter's normal route finalizer does not build this Response in the first place.
469
+ if (
470
+ own !== undefined &&
471
+ !(own instanceof Headers) &&
472
+ statics === undefined &&
473
+ (set._cookies === undefined || set._cookies.length === 0) &&
474
+ (IS_BUN || IS_DENO) &&
475
+ !tagResponseBody &&
476
+ result !== undefined &&
477
+ typeof result === "object" &&
478
+ result !== null
479
+ ) {
480
+ return jsonResponseWithOwnHeaders(result, status, own)
481
+ }
416
482
  if (own === undefined && result !== undefined) {
417
483
  // Fast respond (profiled ~50 ns/req faster on every plain-JSON return): `JSON.stringify` + a
418
484
  // prebuilt init beats `Response.json`'s internal init handling. Output is byte-identical -
@@ -426,7 +492,8 @@ export function toResponse(
426
492
  : { status, headers: jsonCtHeaders }
427
493
  return tagged(
428
494
  statics === undefined
429
- ? new Response(body, init)
495
+ ? // lgtm [js/stack-trace-exposure] the serialized body is not a caught error stack.
496
+ new Response(body, init)
430
497
  : staticJsonResponse(body, status, statics, JSON_CONTENT_TYPE, init),
431
498
  body,
432
499
  tagResponseBody,
@@ -446,7 +513,8 @@ export function toResponse(
446
513
  headers as Record<string, string>,
447
514
  JSON_CT_HEADERS.get("content-type") ?? "application/json;charset=utf-8",
448
515
  )
449
- : new Response(body, { status, headers: withJsonContentType(headers) })
516
+ : // lgtm [js/stack-trace-exposure] the serialized body is not a caught error stack.
517
+ new Response(body, { status, headers: withJsonContentType(headers) })
450
518
  return tagged(
451
519
  response,
452
520
  body,
@@ -48,6 +48,7 @@ import {
48
48
  } from "../schema/standard.ts"
49
49
  import { INSTALL_RESPONSE_CONTRACT } from "./install.ts"
50
50
  import type { IdentityPlugin } from "./plugin.ts"
51
+ import { isResponseResult } from "./runtime-core.ts"
51
52
  import type { AnyServer } from "./server.ts"
52
53
 
53
54
  /**
@@ -91,8 +92,11 @@ export function checkResponseContract(
91
92
  mode: "warn" | "enforce",
92
93
  ): ResponseContractOutcome | Promise<ResponseContractOutcome> {
93
94
  // A handler may return a raw Response as deliberate control flow (a redirect, a stream). There is no
94
- // JSON payload to hold to the contract, and re-serializing one would corrupt it.
95
- if (result instanceof Response || result === undefined) return { kind: "ok", value: result }
95
+ // JSON payload to hold to the contract, and re-serializing one would corrupt it. A `status(...)` is
96
+ // the same control flow in plain-data form: the contract describes the route's SUCCESS payload, so
97
+ // holding an early exit (a 401 body, say) to it would fail every guarded route that declares one.
98
+ if (result instanceof Response || result === undefined || isResponseResult(result))
99
+ return { kind: "ok", value: result }
96
100
  const settled = schema["~standard"].validate(result)
97
101
  return settled instanceof Promise
98
102
  ? settled.then((r) => interpret(r, result, mode))
@@ -0,0 +1,135 @@
1
+ import type {
2
+ NodeRequestContext,
3
+ NodeResponseHook,
4
+ ResponseBodyHook,
5
+ ResponseHeadersHook,
6
+ ResponseHeadersView,
7
+ } from "./node-outcome-hook.ts"
8
+ import { recordHeadersView } from "./node-outcome-hook.ts"
9
+ import { knownMutableHeaders, rememberMutableHeaders, taggedResponseBody } from "./respond.ts"
10
+ import { applyBodyReplacement, withReplacedBody } from "./response-hooks.ts"
11
+
12
+ type MaybePromise<T> = T | Promise<T>
13
+ type WebResponseHook = (response: Response, req: Request) => MaybePromise<Response>
14
+
15
+ export type { ResponseBodyHook, ResponseHeadersHook } from "./node-outcome-hook.ts"
16
+
17
+ export interface ResponseObserverHost {
18
+ assertConfigurable(operation: string): void
19
+ addResponseHook(web: WebResponseHook, node: NodeResponseHook | undefined): void
20
+ enableResponseBodyTagging(): object
21
+ responseBodyOwners(): ReadonlySet<object>
22
+ }
23
+
24
+ export interface ResponseObserverMethods {
25
+ onResponseHeaders(fn: ResponseHeadersHook): this
26
+ onResponseBody(fn: ResponseBodyHook): this
27
+ onResponseRaw(fn: (response: Response, req: Request) => MaybePromise<Response>): this
28
+ }
29
+
30
+ export interface ResponseObserverRuntime {
31
+ install(host: ResponseObserverHost): ResponseObserverMethods
32
+ }
33
+
34
+ const HEADER_MUTABILITY_PROBE = "x-nifra-header-probe"
35
+ const GUARDED_RESPONSE_HEADERS = new WeakSet<Headers>()
36
+
37
+ function hasMutableResponseHeaders(headers: Headers): boolean {
38
+ if (knownMutableHeaders(headers)) return true
39
+ if (GUARDED_RESPONSE_HEADERS.has(headers)) return false
40
+ let previous: string | null = null
41
+ try {
42
+ previous = headers.get(HEADER_MUTABILITY_PROBE)
43
+ headers.set(HEADER_MUTABILITY_PROBE, "1")
44
+ if (previous === null) headers.delete(HEADER_MUTABILITY_PROBE)
45
+ else headers.set(HEADER_MUTABILITY_PROBE, previous)
46
+ rememberMutableHeaders(headers)
47
+ return true
48
+ } catch {
49
+ try {
50
+ if (previous === null) headers.delete(HEADER_MUTABILITY_PROBE)
51
+ else headers.set(HEADER_MUTABILITY_PROBE, previous)
52
+ } catch {
53
+ // A guarded response rejected the probe and the clone path is authoritative.
54
+ }
55
+ GUARDED_RESPONSE_HEADERS.add(headers)
56
+ return false
57
+ }
58
+ }
59
+
60
+ function webRequestView(req: Request): NodeRequestContext {
61
+ return { method: req.method, url: req.url, header: (name) => req.headers.get(name) }
62
+ }
63
+
64
+ function webHeadersView(
65
+ response: Response,
66
+ req: Request,
67
+ ): {
68
+ readonly response: Response
69
+ readonly headers: ResponseHeadersView
70
+ readonly request: NodeRequestContext
71
+ } {
72
+ const request = webRequestView(req)
73
+ if (!hasMutableResponseHeaders(response.headers)) {
74
+ const clone = new Response(response.body, response)
75
+ return { response: clone, headers: clone.headers, request }
76
+ }
77
+ return { response, headers: response.headers, request }
78
+ }
79
+
80
+ export function createResponseObserverRuntime(): ResponseObserverRuntime {
81
+ return {
82
+ install(host) {
83
+ return {
84
+ onResponseHeaders(this: ResponseObserverMethods, fn): ResponseObserverMethods {
85
+ host.assertConfigurable("onResponseHeaders()")
86
+ host.addResponseHook(
87
+ (response, req) => {
88
+ const view = webHeadersView(response, req)
89
+ const out = fn(view.headers, view.request, response.status)
90
+ return out instanceof Promise ? out.then(() => view.response) : view.response
91
+ },
92
+ (response, req) => fn(recordHeadersView(response), req, response.status),
93
+ )
94
+ return this
95
+ },
96
+ onResponseBody(this: ResponseObserverMethods, fn): ResponseObserverMethods {
97
+ host.assertConfigurable("onResponseBody()")
98
+ host.enableResponseBodyTagging()
99
+ host.addResponseHook(
100
+ (response, req) => {
101
+ const body = taggedResponseBody(response, host.responseBodyOwners())
102
+ if (body === undefined) return response
103
+ const view = webHeadersView(response, req)
104
+ const out = fn(body, view.headers, view.request, response.status)
105
+ return out instanceof Promise
106
+ ? out.then((replaced) => withReplacedBody(response, replaced))
107
+ : withReplacedBody(response, out)
108
+ },
109
+ (response, req) => {
110
+ if (response.body === null) return undefined
111
+ const out = fn(response.body, recordHeadersView(response), req, response.status)
112
+ if (out instanceof Promise)
113
+ return out.then((replaced) => applyBodyReplacement(response, replaced))
114
+ applyBodyReplacement(response, out)
115
+ return undefined
116
+ },
117
+ )
118
+ return this
119
+ },
120
+ onResponseRaw(this: ResponseObserverMethods, fn): ResponseObserverMethods {
121
+ host.assertConfigurable("onResponseRaw()")
122
+ host.enableResponseBodyTagging()
123
+ host.addResponseHook(
124
+ (response, req) =>
125
+ taggedResponseBody(response, host.responseBodyOwners()) === undefined
126
+ ? fn(response, req)
127
+ : response,
128
+ () => undefined,
129
+ )
130
+ return this
131
+ },
132
+ }
133
+ },
134
+ }
135
+ }