@nifrajs/core 2.14.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +4 -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/edge-kit.d.ts +27 -0
  7. package/dist/edge-kit.d.ts.map +1 -0
  8. package/dist/edge-kit.js +24 -0
  9. package/dist/edge-kit.js.map +1 -0
  10. package/dist/errors.d.ts +1 -1
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/errors.js.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal/route-execution.d.ts +11 -5
  18. package/dist/internal/route-execution.d.ts.map +1 -1
  19. package/dist/internal/route-execution.js +2 -1
  20. package/dist/internal/route-execution.js.map +1 -1
  21. package/dist/response-observer.d.ts +25 -0
  22. package/dist/response-observer.d.ts.map +1 -0
  23. package/dist/response-observer.js +40 -0
  24. package/dist/response-observer.js.map +1 -0
  25. package/dist/security/baseline.d.ts +68 -0
  26. package/dist/security/baseline.d.ts.map +1 -0
  27. package/dist/security/baseline.js +118 -0
  28. package/dist/security/baseline.js.map +1 -0
  29. package/dist/server/bare-error-lane.d.ts +26 -0
  30. package/dist/server/bare-error-lane.d.ts.map +1 -0
  31. package/dist/server/bare-error-lane.js +60 -0
  32. package/dist/server/bare-error-lane.js.map +1 -0
  33. package/dist/server/body.d.ts +7 -0
  34. package/dist/server/body.d.ts.map +1 -1
  35. package/dist/server/body.js +7 -0
  36. package/dist/server/body.js.map +1 -1
  37. package/dist/server/bridge-markers.d.ts +25 -0
  38. package/dist/server/bridge-markers.d.ts.map +1 -0
  39. package/dist/server/bridge-markers.js +25 -0
  40. package/dist/server/bridge-markers.js.map +1 -0
  41. package/dist/server/context.d.ts +6 -1
  42. package/dist/server/context.d.ts.map +1 -1
  43. package/dist/server/header-case.d.ts.map +1 -1
  44. package/dist/server/header-case.js +2 -1
  45. package/dist/server/header-case.js.map +1 -1
  46. package/dist/server/http.d.ts +36 -3
  47. package/dist/server/http.d.ts.map +1 -1
  48. package/dist/server/http.js +49 -3
  49. package/dist/server/http.js.map +1 -1
  50. package/dist/server/install.d.ts +6 -8
  51. package/dist/server/install.d.ts.map +1 -1
  52. package/dist/server/install.js +7 -0
  53. package/dist/server/install.js.map +1 -1
  54. package/dist/server/node-outcome-hook.d.ts +2 -1
  55. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  56. package/dist/server/node-outcome-hook.js.map +1 -1
  57. package/dist/server/node-outcome.d.ts +17 -2
  58. package/dist/server/node-outcome.d.ts.map +1 -1
  59. package/dist/server/node-outcome.js +37 -3
  60. package/dist/server/node-outcome.js.map +1 -1
  61. package/dist/server/query.d.ts +7 -1
  62. package/dist/server/query.d.ts.map +1 -1
  63. package/dist/server/query.js +8 -3
  64. package/dist/server/query.js.map +1 -1
  65. package/dist/server/request-context.d.ts +17 -4
  66. package/dist/server/request-context.d.ts.map +1 -1
  67. package/dist/server/request-context.js +63 -29
  68. package/dist/server/request-context.js.map +1 -1
  69. package/dist/server/respond.d.ts.map +1 -1
  70. package/dist/server/respond.js +19 -2
  71. package/dist/server/respond.js.map +1 -1
  72. package/dist/server/response-contract-lane.d.ts.map +1 -1
  73. package/dist/server/response-contract-lane.js +5 -2
  74. package/dist/server/response-contract-lane.js.map +1 -1
  75. package/dist/server/response-observer-runtime.d.ts +20 -0
  76. package/dist/server/response-observer-runtime.d.ts.map +1 -0
  77. package/dist/server/response-observer-runtime.js +95 -0
  78. package/dist/server/response-observer-runtime.js.map +1 -0
  79. package/dist/server/runtime-core.d.ts +60 -0
  80. package/dist/server/runtime-core.d.ts.map +1 -1
  81. package/dist/server/runtime-core.js +65 -1
  82. package/dist/server/runtime-core.js.map +1 -1
  83. package/dist/server/server-types.d.ts +3 -1
  84. package/dist/server/server-types.d.ts.map +1 -1
  85. package/dist/server/server.d.ts +52 -39
  86. package/dist/server/server.d.ts.map +1 -1
  87. package/dist/server/server.js +267 -271
  88. package/dist/server/server.js.map +1 -1
  89. package/dist/server/websocket.d.ts +20 -4
  90. package/dist/server/websocket.d.ts.map +1 -1
  91. package/dist/server/websocket.js +81 -11
  92. package/dist/server/websocket.js.map +1 -1
  93. package/dist/server/ws-bun.d.ts +1 -1
  94. package/dist/server/ws-bun.d.ts.map +1 -1
  95. package/dist/server/ws-bun.js +12 -8
  96. package/dist/server/ws-bun.js.map +1 -1
  97. package/dist/server.d.ts +2 -1
  98. package/dist/server.d.ts.map +1 -1
  99. package/dist/server.js +2 -1
  100. package/dist/server.js.map +1 -1
  101. package/dist/single-copy-register.d.ts +2 -0
  102. package/dist/single-copy-register.d.ts.map +1 -0
  103. package/dist/single-copy-register.js +24 -0
  104. package/dist/single-copy-register.js.map +1 -0
  105. package/dist/single-copy.d.ts +156 -0
  106. package/dist/single-copy.d.ts.map +1 -0
  107. package/dist/single-copy.js +470 -0
  108. package/dist/single-copy.js.map +1 -0
  109. package/package.json +24 -2
  110. package/src/assurance.ts +58 -1
  111. package/src/edge-kit.ts +27 -0
  112. package/src/errors.ts +1 -0
  113. package/src/index.ts +1 -1
  114. package/src/internal/route-execution.ts +23 -16
  115. package/src/response-observer.ts +64 -0
  116. package/src/security/baseline.ts +172 -0
  117. package/src/server/bare-error-lane.ts +76 -0
  118. package/src/server/body.ts +8 -0
  119. package/src/server/bridge-markers.ts +24 -0
  120. package/src/server/context.ts +6 -1
  121. package/src/server/header-case.ts +3 -1
  122. package/src/server/http.ts +53 -3
  123. package/src/server/install.ts +14 -0
  124. package/src/server/node-outcome-hook.ts +2 -1
  125. package/src/server/node-outcome.ts +44 -3
  126. package/src/server/query.ts +10 -4
  127. package/src/server/request-context.ts +85 -33
  128. package/src/server/respond.ts +26 -1
  129. package/src/server/response-contract-lane.ts +6 -2
  130. package/src/server/response-observer-runtime.ts +135 -0
  131. package/src/server/runtime-core.ts +98 -1
  132. package/src/server/server-types.ts +3 -1
  133. package/src/server/server.ts +436 -367
  134. package/src/server/websocket.ts +127 -17
  135. package/src/server/ws-bun.ts +26 -7
  136. package/src/server.ts +2 -1
  137. package/src/single-copy-register.ts +24 -0
  138. package/src/single-copy.ts +524 -0
@@ -6,6 +6,7 @@
6
6
  * - the seam stays off the server's public typed surface (symbol-keyed methods are not part of it).
7
7
  * Both sides reference the SAME registered symbol, so no module has to import the other.
8
8
  */
9
+ import { NODE_BRIDGE_MARKER_KEYS } from "./bridge-markers.ts"
9
10
 
10
11
  /** @internal Install the idempotency runtime on a server (called by the `idempotency()` plugin). */
11
12
  export const INSTALL_IDEMPOTENCY: unique symbol = Symbol.for("@nifrajs/core/install-idempotency")
@@ -31,3 +32,16 @@ export const INSTALL_WS: unique symbol = Symbol.for("@nifrajs/core/install-ws")
31
32
  export const INSTALL_RESPONSE_CONTRACT: unique symbol = Symbol.for(
32
33
  "@nifrajs/core/install-response-contract",
33
34
  )
35
+
36
+ /** @internal Install the response-observer adapters on a server. */
37
+ export const INSTALL_RESPONSE_OBSERVER: unique symbol = Symbol.for(
38
+ "@nifrajs/core/install-response-observer",
39
+ )
40
+
41
+ /** @internal Resolve a native mount for an adapter that can keep the request on its own stream lane. */
42
+ export const RESOLVE_NODE_MOUNT: unique symbol = Symbol.for(
43
+ NODE_BRIDGE_MARKER_KEYS.resolveNodeMount,
44
+ )
45
+
46
+ /** @internal Marker on a mounted handler that knows how to serve a native Node request/response. */
47
+ export const NODE_NATIVE_MOUNT: unique symbol = Symbol.for("@nifrajs/core/node-native-mount")
@@ -3,6 +3,7 @@
3
3
  import type { Platform } from "./context.ts"
4
4
  import { hasLowercaseHeaderKeysMark, hasUpperAscii } from "./header-case.ts"
5
5
  import type { NodeServeOutcome } from "./node-outcome.ts"
6
+ import type { ResponseResult } from "./runtime-core.ts"
6
7
  import type { CtxSet, MaybePromise } from "./server.ts"
7
8
 
8
9
  /**
@@ -309,7 +310,7 @@ export interface NodeOutcomeRuntime {
309
310
  toOutcome(result: unknown, set: CtxSet): NodeServeOutcome
310
311
  /** Materialize a buffered outcome for a Web response hook without losing its direct-write marker. */
311
312
  toResponse(outcome: NodeServeOutcome): Response
312
- fromResponse(response: Response): NodeServeOutcome
313
+ fromResponse(response: Response | ResponseResult): NodeServeOutcome
313
314
  timeout(): NodeServeOutcome
314
315
  /** The `Content-Type` this runtime's json render writes implicitly. The native hook walk
315
316
  * materializes it into the hook-visible header record so a body hook's content-type checks see
@@ -11,7 +11,14 @@ import {
11
11
  rememberMutableHeaders,
12
12
  taggedResponseBody,
13
13
  } from "./respond.ts"
14
- import { type HandlerResult, isResponseResult } from "./runtime-core.ts"
14
+ import {
15
+ EMPTY_RESPONSE_CONTROLS,
16
+ type HandlerResult,
17
+ isResponseResult,
18
+ type PlainRender,
19
+ plainRenderHeaders,
20
+ type ResponseResult,
21
+ } from "./runtime-core.ts"
15
22
  import type { CtxSet } from "./server.ts"
16
23
  import {
17
24
  mergeStaticHeaderRecord,
@@ -58,6 +65,8 @@ export type NodeServeOutcome =
58
65
  */
59
66
  export function toNodeOutcome(result: HandlerResult, set: CtxSet): NodeServeOutcome {
60
67
  if (isResponseResult(result)) {
68
+ const plain = result.plain
69
+ if (plain !== undefined) return plainNodeOutcome(plain, set)
61
70
  const body = result.toNodeBody?.()
62
71
  if (body !== undefined) {
63
72
  return {
@@ -85,6 +94,24 @@ export function toNodeOutcome(result: HandlerResult, set: CtxSet): NodeServeOutc
85
94
  }
86
95
  }
87
96
 
97
+ /**
98
+ * A `status(...)` (or any other {@link PlainRender} carrier) on the node lane: the same `kind: "json"`
99
+ * outcome a handler's plain return produces, so an early exit is written to the socket by the same
100
+ * direct writer, with a `content-length`, and never builds a `Response`.
101
+ */
102
+ export function plainNodeOutcome(plain: PlainRender, set: CtxSet): NodeServeOutcome {
103
+ return {
104
+ kind: "json",
105
+ status: plain.status,
106
+ headers: plainRenderHeaders(plain, set),
107
+ cookies: set._cookies,
108
+ body:
109
+ plain.body === undefined || isBodylessStatus(plain.status)
110
+ ? null
111
+ : JSON.stringify(plain.body),
112
+ }
113
+ }
114
+
88
115
  /**
89
116
  * Materialize a buffered node outcome only when a Web `onResponse` hook needs to see a real
90
117
  * `Response`. The marker lets an in-place hook (`response.headers.set(...); return response`) go back
@@ -162,8 +189,22 @@ export function withStaticNodeHeaders(
162
189
  return { ...outcome, headers }
163
190
  }
164
191
 
165
- export function nodeOutcomeFromResponse(response: Response): NodeServeOutcome {
166
- response = normalizeBodylessResponse(response)
192
+ /**
193
+ * The node lane's `wrapResponse`: what an early exit built OUTSIDE the handler's finalizer renders as
194
+ * - an `onRequest` hook's `Response`, a mount's, and every framework error render.
195
+ *
196
+ * A plain-data carrier ({@link plainError}, `status(...)`) takes the same `kind: "json"` lane a
197
+ * handler's plain return takes: no `Response` built, and none drained. `EMPTY_RESPONSE_CONTROLS`
198
+ * rather than the request's `c.set` on purpose - these renders happen where no context exists (before
199
+ * routing, or after it was abandoned), which is exactly why they are wrapped rather than finalized.
200
+ */
201
+ export function nodeOutcomeFromResponse(result: Response | ResponseResult): NodeServeOutcome {
202
+ if (!(result instanceof Response)) {
203
+ const plain = result.plain
204
+ if (plain !== undefined) return plainNodeOutcome(plain, EMPTY_RESPONSE_CONTROLS)
205
+ return nodeOutcomeFromResponse(result.toResponse())
206
+ }
207
+ const response = normalizeBodylessResponse(result)
167
208
  const body = nodeResponseBody(response)
168
209
  return body === undefined
169
210
  ? { kind: "response", response }
@@ -4,7 +4,8 @@
4
4
  * and the byte-capped HTML-form reader. Split out of the server kernel so the file stays one domain.
5
5
  */
6
6
  import { readBoundedBytes } from "./body.ts"
7
- import { jsonError, urlPartsOf } from "./http.ts"
7
+ import { plainError, urlPartsOf } from "./http.ts"
8
+ import type { ResponseResult } from "./runtime-core.ts"
8
9
  import type { RequestSource } from "./server.ts"
9
10
 
10
11
  // The query string ("?a=1", or "" when absent) - for lazily building `c.query` only when read.
@@ -123,16 +124,21 @@ const FORM_DECODER = new TextDecoder()
123
124
  * Read an HTML-form body (urlencoded) into the plain object a body schema validates - same byte
124
125
  * cap as the JSON path (a lying/absent Content-Length can't force an oversized buffer), same
125
126
  * repeated-key -> `string[]` promotion as query parsing, same `__proto__` guard.
127
+ *
128
+ * A rejection is a `ResponseResult`, not a built `Response`, matching the JSON reader: the cap is a
129
+ * framework render, and answering one on the plain lane costs what a handler's plain return costs
130
+ * (see `plainError`). The distinction is observable on the Node adapter - a `Response` there is
131
+ * untagged, so its body drains through a Web stream and the answer goes out chunked.
126
132
  */
127
133
  export async function readBoundedForm(
128
134
  req: RequestSource,
129
135
  maxBytes: number,
130
- ): Promise<Record<string, QueryValue> | Response> {
136
+ ): Promise<Record<string, QueryValue> | ResponseResult> {
131
137
  const read = await readBoundedBytes(req, maxBytes)
132
138
  if (!read.ok) {
133
139
  return read.status === 413
134
- ? jsonError(413, "payload_too_large")
135
- : jsonError(400, "invalid_content_length")
140
+ ? plainError(413, "payload_too_large")
141
+ : plainError(400, "invalid_content_length")
136
142
  }
137
143
  const out: Record<string, QueryValue> = Object.create(null) as Record<string, QueryValue>
138
144
  // URLSearchParams owns the format's quirks (`+` as space, percent-decoding, empty keys);
@@ -16,9 +16,9 @@ import {
16
16
  import type { Platform } from "./context.ts"
17
17
  import { type CookieOptions, cookieNamePrefix, parseCookies, serializeCookie } from "./cookies.ts"
18
18
  import { headerObjectOf } from "./headers.ts"
19
- import { jsonError } from "./http.ts"
19
+ import { plainError } from "./http.ts"
20
20
  import { guardParsedValue, type ProtoPoisoning, parseJsonGuarded } from "./proto-guard.ts"
21
- import { searchOf } from "./query.ts"
21
+ import { isUrlEncodedForm, readBoundedForm, searchOf } from "./query.ts"
22
22
  import { responseJsonContentType } from "./respond.ts"
23
23
  import {
24
24
  CONTEXT_SEARCH,
@@ -27,12 +27,14 @@ import {
27
27
  getNeverAbortSignal,
28
28
  getUnboundedRequestBudget,
29
29
  headerOf,
30
+ isResponseResult,
30
31
  PRE_DECODED_BODY,
31
32
  type PreDecodedBody,
33
+ type ResponseResult,
32
34
  requestOf,
33
35
  TEXT_DECODER,
34
36
  } from "./runtime-core.ts"
35
- import type { CtxSet, RawContext, RequestSource } from "./server.ts"
37
+ import type { CtxSet, MaybePromise, RawContext, RequestSource } from "./server.ts"
36
38
 
37
39
  /** A fixed past instant for cookie deletion (`Expires`). A literal epoch - deterministic, unlike an
38
40
  * argless `new Date()`. */
@@ -390,7 +392,7 @@ export class RequestContext implements RawContext {
390
392
  }
391
393
  }
392
394
 
393
- /** Backs `c.boundedBody`: bounded byte read that throws a flat 413/400 `Response` (caught by
395
+ /** Backs `c.boundedBody`: bounded byte read that throws a flat 413/400 plain render (caught by
394
396
  * `runLifecycle` as control flow, like `throw redirect(...)`), so a handler can't ignore the cap.
395
397
  * The byte-cap itself lives in `./body.ts` (shared with the schema path and `verifyWebhook`). */
396
398
  async function readBoundedBodyOrThrow(
@@ -401,8 +403,8 @@ async function readBoundedBodyOrThrow(
401
403
  const r = await readBoundedBytes(req, maxBytes ?? maxBodyBytes)
402
404
  if (r.ok) return r.bytes
403
405
  throw r.status === 413
404
- ? jsonError(413, "payload_too_large")
405
- : jsonError(400, "invalid_content_length")
406
+ ? plainError(413, "payload_too_large")
407
+ : plainError(400, "invalid_content_length")
406
408
  }
407
409
 
408
410
  /** Backs `c.boundedJson`: `readBoundedJsonSource`, throwing its flat 400/413 on failure. */
@@ -413,7 +415,9 @@ async function readBoundedJsonBodyOrThrow<T>(
413
415
  maxBytes?: number,
414
416
  ): Promise<T> {
415
417
  const parsed = await readBoundedJsonSource(req, maxBytes ?? maxBodyBytes, protoPoisoning)
416
- if (parsed instanceof Response) throw parsed
418
+ // The lane's own failures are the only branded values it can produce: a parsed JSON body is
419
+ // `JSON.parse` output, which carries string keys only and so can never answer to a symbol brand.
420
+ if (isResponseResult(parsed)) throw parsed
417
421
  return parsed as T
418
422
  }
419
423
 
@@ -431,7 +435,7 @@ async function readBoundedJsonBodyOrThrow<T>(
431
435
  * own promise chain instead, and only the streaming fallback - which is already doing IO in a loop
432
436
  * - runs as an async function. Callers that `await` the result are unaffected.
433
437
  */
434
- type JsonResultContinuation<T> = (result: unknown | Response) => T | Promise<T>
438
+ type JsonResultContinuation<T> = (result: unknown | ResponseResult) => T | Promise<T>
435
439
  type JsonErrorContinuation<T> = (error: unknown) => T | Promise<T>
436
440
  type FramedJsonSource = RequestSource & {
437
441
  readonly bytes?: () => Promise<Uint8Array>
@@ -445,7 +449,7 @@ export function readBoundedJsonSource(
445
449
  req: RequestSource,
446
450
  maxBytes: number,
447
451
  protoPoisoning?: ProtoPoisoning,
448
- ): Promise<unknown | Response>
452
+ ): Promise<unknown | ResponseResult>
449
453
  export function readBoundedJsonSource<T>(
450
454
  req: RequestSource,
451
455
  maxBytes: number,
@@ -459,7 +463,7 @@ export function readBoundedJsonSource<T>(
459
463
  protoPoisoning: ProtoPoisoning = "reject",
460
464
  onResult?: JsonResultContinuation<T>,
461
465
  onError?: JsonErrorContinuation<T>,
462
- ): Promise<unknown | Response | T> {
466
+ ): Promise<unknown | ResponseResult | T> {
463
467
  // A pre-parsing hook (the transport-codec lane) may have decoded the body already - its stash
464
468
  // is taken verbatim: the stasher enforced its own byte cap and poisoning policy on text this
465
469
  // lane never sees. One symbol read; a miss on ordinary requests costs a cache-line, not a parse.
@@ -493,12 +497,12 @@ export function readBoundedJsonSource<T>(
493
497
  const length = parseContentLength(declared)
494
498
  if (length === undefined)
495
499
  return onResult === undefined
496
- ? Promise.resolve(jsonError(400, "invalid_content_length"))
497
- : Promise.resolve(jsonError(400, "invalid_content_length")).then(onResult)
500
+ ? Promise.resolve(plainError(400, "invalid_content_length"))
501
+ : Promise.resolve(plainError(400, "invalid_content_length")).then(onResult)
498
502
  if (length > maxBytes)
499
503
  return onResult === undefined
500
- ? Promise.resolve(jsonError(413, "payload_too_large"))
501
- : Promise.resolve(jsonError(413, "payload_too_large")).then(onResult)
504
+ ? Promise.resolve(plainError(413, "payload_too_large"))
505
+ : Promise.resolve(plainError(413, "payload_too_large")).then(onResult)
502
506
  // Trusted framing (Bun's native route table, Deno.serve): the runtime's HTTP parser already
503
507
  // delimited the bytes, so the declared length IS the transport frame and chunked routing is
504
508
  // moot - the over-cap 413 above already enforced the byte cap. Take the runtime's fused
@@ -514,8 +518,8 @@ export function readBoundedJsonSource<T>(
514
518
  guarded = guardParsedValue(parsed, protoPoisoning)
515
519
  } catch {
516
520
  return onResult === undefined
517
- ? jsonError(400, "invalid_json")
518
- : onResult(jsonError(400, "invalid_json"))
521
+ ? plainError(400, "invalid_json")
522
+ : onResult(plainError(400, "invalid_json"))
519
523
  }
520
524
  return onResult === undefined ? guarded : onResult(guarded)
521
525
  },
@@ -533,16 +537,16 @@ export function readBoundedJsonSource<T>(
533
537
  ({ value, byteLength }) => {
534
538
  if (byteLength > length || byteLength > maxBytes) {
535
539
  return onResult === undefined
536
- ? jsonError(413, "payload_too_large")
537
- : onResult(jsonError(413, "payload_too_large"))
540
+ ? plainError(413, "payload_too_large")
541
+ : onResult(plainError(413, "payload_too_large"))
538
542
  }
539
543
  let guarded: unknown
540
544
  try {
541
545
  guarded = guardParsedValue(value, protoPoisoning)
542
546
  } catch {
543
547
  return onResult === undefined
544
- ? jsonError(400, "invalid_json")
545
- : onResult(jsonError(400, "invalid_json"))
548
+ ? plainError(400, "invalid_json")
549
+ : onResult(plainError(400, "invalid_json"))
546
550
  }
547
551
  return onResult === undefined ? guarded : onResult(guarded)
548
552
  },
@@ -555,16 +559,16 @@ export function readBoundedJsonSource<T>(
555
559
  (bytes) => {
556
560
  if (bytes.byteLength > length || bytes.byteLength > maxBytes) {
557
561
  return onResult === undefined
558
- ? jsonError(413, "payload_too_large")
559
- : onResult(jsonError(413, "payload_too_large"))
562
+ ? plainError(413, "payload_too_large")
563
+ : onResult(plainError(413, "payload_too_large"))
560
564
  }
561
565
  try {
562
566
  const parsed = parseJsonGuarded(TEXT_DECODER.decode(bytes), protoPoisoning)
563
567
  return onResult === undefined ? parsed : onResult(parsed)
564
568
  } catch {
565
569
  return onResult === undefined
566
- ? jsonError(400, "invalid_json")
567
- : onResult(jsonError(400, "invalid_json"))
570
+ ? plainError(400, "invalid_json")
571
+ : onResult(plainError(400, "invalid_json"))
568
572
  }
569
573
  },
570
574
  () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
@@ -581,16 +585,16 @@ export function readBoundedJsonSource<T>(
581
585
  const bytes = new Uint8Array(buffer)
582
586
  if (bytes.byteLength > length || bytes.byteLength > maxBytes) {
583
587
  return onResult === undefined
584
- ? jsonError(413, "payload_too_large")
585
- : onResult(jsonError(413, "payload_too_large"))
588
+ ? plainError(413, "payload_too_large")
589
+ : onResult(plainError(413, "payload_too_large"))
586
590
  }
587
591
  let parsed: unknown
588
592
  try {
589
593
  parsed = parseJsonGuarded(TEXT_DECODER.decode(bytes), protoPoisoning)
590
594
  } catch {
591
595
  return onResult === undefined
592
- ? jsonError(400, "invalid_json")
593
- : onResult(jsonError(400, "invalid_json"))
596
+ ? plainError(400, "invalid_json")
597
+ : onResult(plainError(400, "invalid_json"))
594
598
  }
595
599
  return onResult === undefined ? parsed : onResult(parsed)
596
600
  },
@@ -605,21 +609,69 @@ export function readBoundedJsonSource<T>(
605
609
  /** The shared rejection handler for a body read that never completed (client abort, socket error,
606
610
  * malformed framing): the same flat 400 a syntactically invalid body gets. Hoisted so the framed
607
611
  * lane allocates no per-request closure for it. */
608
- const INVALID_JSON = (): Response => jsonError(400, "invalid_json")
612
+ const INVALID_JSON = (): ResponseResult => plainError(400, "invalid_json")
609
613
 
610
614
  /** The chunked / length-less fallback: drain under the streaming byte cap, then parse guarded. */
611
615
  async function readStreamedJsonSource(
612
616
  raw: RequestSource,
613
617
  maxBytes: number,
614
618
  protoPoisoning: ProtoPoisoning,
615
- ): Promise<unknown | Response> {
619
+ ): Promise<unknown | ResponseResult> {
616
620
  const body = raw.body
617
- if (body === null) return jsonError(400, "invalid_json")
621
+ if (body === null) return plainError(400, "invalid_json")
618
622
  const drained = await drainCapped(body, maxBytes)
619
- if (!drained.ok) return jsonError(413, "payload_too_large")
623
+ if (!drained.ok) return plainError(413, "payload_too_large")
620
624
  try {
621
625
  return parseJsonGuarded(TEXT_DECODER.decode(drained.bytes), protoPoisoning)
622
626
  } catch {
623
- return jsonError(400, "invalid_json")
627
+ return plainError(400, "invalid_json")
628
+ }
629
+ }
630
+
631
+ /**
632
+ * The shared bounded body framing/parser: the single trust-boundary enforcement point every body
633
+ * lane routes through. Content-type dispatch, the urlencoded-form cap, and the JSON path
634
+ * (`Content-Length` pre-reject -> streaming cap -> prototype-poisoning guard, all inside
635
+ * `readBoundedJsonSource`) live here so the fused body runner, the generic body-only lane, and a
636
+ * compact edge entrypoint all enforce the same boundary from one definition. `protoPoisoning` is a
637
+ * parameter, not read off a server instance, so this stays free of any dependency on the class.
638
+ *
639
+ * `onParsed` receives the decoded, guard-screened value; a framing rejection (`Content-Length` over
640
+ * the cap, a lying length caught mid-stream, malformed JSON, an unsupported media type) is rendered
641
+ * through `wrapResponse`; a read that never completed (client abort, socket error) reaches `onError`.
642
+ */
643
+ export function readBodyFramed<T>(
644
+ source: RequestSource,
645
+ maxBodyBytes: number,
646
+ protoPoisoning: ProtoPoisoning,
647
+ onParsed: (parsed: unknown) => MaybePromise<T>,
648
+ wrapResponse: (response: Response | ResponseResult) => T,
649
+ onError: (err: unknown) => MaybePromise<T>,
650
+ ): Promise<T> {
651
+ const contentType = headerOf(source, "content-type") ?? ""
652
+ if (contentType !== "application/json" && !contentType.includes("application/json")) {
653
+ if (isUrlEncodedForm(contentType)) {
654
+ return readBoundedForm(source, maxBodyBytes).then(
655
+ (form) => (isResponseResult(form) ? wrapResponse(form) : onParsed(form)),
656
+ onError,
657
+ ) as Promise<T>
658
+ }
659
+ // multipart/form-data (file uploads) stays 415 on the schema path by design - files don't fit a
660
+ // value schema; use a schema-less route + @nifrajs/uploads helpers for those.
661
+ return Promise.resolve(wrapResponse(plainError(415, "unsupported_media_type")))
662
+ }
663
+ // All JSON bodies go through readBoundedJsonSource - the single enforcement point for the framed
664
+ // fast path (post-read byte re-check), the streaming cap, and the prototype-poisoning guard. A
665
+ // separate inlined native-json() fast path here would fork the trust boundary in two.
666
+ try {
667
+ return readBoundedJsonSource(
668
+ source,
669
+ maxBodyBytes,
670
+ protoPoisoning,
671
+ (parsed) => (isResponseResult(parsed) ? wrapResponse(parsed) : onParsed(parsed)),
672
+ onError,
673
+ )
674
+ } catch (err) {
675
+ return Promise.resolve(onError(err))
624
676
  }
625
677
  }
@@ -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.
@@ -178,7 +191,7 @@ function denoResponseWithJsonBody(
178
191
  // The framework-buffered-body marker shared with the Node adapter: a Response carrying it exposes
179
192
  // its already-serialized bytes without draining. On the Web serving paths it exists ONLY when a
180
193
  // registered body/raw hook needs it, so hook-less apps pay nothing.
181
- const RESPONSE_BODY = Symbol.for("nifra.response.body")
194
+ const RESPONSE_BODY = Symbol.for(NODE_BRIDGE_MARKER_KEYS.responseBody)
182
195
 
183
196
  /**
184
197
  * Headers objects the framework itself constructed - guaranteed mutable, no guard. Response
@@ -396,6 +409,18 @@ export function toResponse(
396
409
  statics?: StaticResponseHeaders,
397
410
  ): Response {
398
411
  if (isResponseResult(result)) {
412
+ const plain = result.plain
413
+ if (plain !== undefined) {
414
+ // Rendered as the value it is, on the branches below, rather than through `toResponse()`: the
415
+ // status and headers fold into a `set` and the body takes the ordinary JSON lane. One level
416
+ // only - a plain render's body is a user value, never another response carrier.
417
+ return toResponse(
418
+ plain.body as HandlerResult,
419
+ plainRenderSet(plain, set),
420
+ tagResponseBody,
421
+ statics,
422
+ )
423
+ }
399
424
  return withStatics(
400
425
  appendCookiesToResponse(normalizeBodylessResponse(result.toResponse()), set),
401
426
  statics,
@@ -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
+ }