@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
@@ -41,6 +41,7 @@ import {
41
41
  type RouteEntry,
42
42
  } from "../internal/route-execution.ts"
43
43
  import { isSameOriginRequest } from "../internal/same-origin.ts"
44
+ import type { ResponseObserverPlugin } from "../response-observer.ts"
44
45
  import { compileRoutePattern, decodeRouteParams } from "../router/pattern.ts"
45
46
  import { EMPTY_PARAMS, type Method, Router } from "../router/router.ts"
46
47
  import type {
@@ -49,11 +50,13 @@ import type {
49
50
  StandardResult,
50
51
  StandardSchemaV1,
51
52
  } from "../schema/standard.ts"
53
+ import { emitRequestErrorLog, renderBareError } from "./bare-error-lane.ts"
52
54
  import {
53
55
  assertByteLimit,
54
56
  markTransportCap,
55
57
  markTrustedBodyFraming,
56
58
  type RawBodyReaders,
59
+ UNLIMITED_BODY_BYTES,
57
60
  } from "./body.ts"
58
61
  import { type ClientIpTrust, resolveClientIp } from "./client-ip.ts"
59
62
  import type { Context, Platform, ResponseControls, RouteSchema } from "./context.ts"
@@ -63,19 +66,18 @@ import {
63
66
  markLowercaseHeaderKeys,
64
67
  } from "./header-case.ts"
65
68
  import { headerObjectOf } from "./headers.ts"
66
- import { jsonError, pathnameOf, type UrlParts, urlPartsOf } from "./http.ts"
69
+ import { jsonError, pathnameOf, plainError, type UrlParts, urlPartsOf } from "./http.ts"
67
70
  import { type NodeServeOutcome, withStaticNodeHeaders } from "./node-outcome.ts"
68
- import {
69
- type NodeOutcomeRuntime,
70
- type NodeRequestContext,
71
- type NodeRequestHook,
72
- type NodeResponseContext,
73
- type NodeResponseHook,
74
- type ResponseBodyHook,
75
- type ResponseBodyReplacement,
76
- type ResponseHeadersHook,
77
- type ResponseHeadersView,
78
- recordHeadersView,
71
+ import type {
72
+ NodeOutcomeRuntime,
73
+ NodeRequestContext,
74
+ NodeRequestHook,
75
+ NodeResponseContext,
76
+ NodeResponseHook,
77
+ ResponseBodyHook,
78
+ ResponseBodyReplacement,
79
+ ResponseHeadersHook,
80
+ ResponseHeadersView,
79
81
  } from "./node-outcome-hook.ts"
80
82
  import {
81
83
  isUrlEncodedForm,
@@ -84,21 +86,21 @@ import {
84
86
  readBoundedForm,
85
87
  searchOf,
86
88
  } from "./query.ts"
87
- import { RequestContext, readBoundedJsonSource } from "./request-context.ts"
89
+ import { RequestContext, readBodyFramed, readBoundedJsonSource } from "./request-context.ts"
88
90
  import {
89
91
  applyStaticResponseHeaders,
90
92
  buildStaticResponseHeaders,
91
93
  fusedRespond,
92
94
  fusedRespondNoSet,
93
- knownMutableHeaders,
94
- rememberMutableHeaders,
95
- taggedResponseBody,
96
95
  toResponse,
97
96
  } from "./respond.ts"
98
97
  // Type-only: erased, so the kernel never pulls the lane's implementation into a bundle that does not
99
98
  // install the plugin. The value side arrives through the symbol-keyed install seam.
100
99
  import type { ResponseContractRuntime } from "./response-contract-lane.ts"
101
- import { applyBodyReplacement, withReplacedBody } from "./response-hooks.ts"
100
+ import type {
101
+ ResponseObserverMethods,
102
+ ResponseObserverRuntime,
103
+ } from "./response-observer-runtime.ts"
102
104
  import {
103
105
  CONTEXT_SEARCH,
104
106
  CONTEXT_SET,
@@ -107,7 +109,10 @@ import {
107
109
  getUnboundedRequestBudget,
108
110
  type HandlerResult,
109
111
  headerOf,
112
+ isResponseResult,
113
+ type ResponseResult,
110
114
  requestOf,
115
+ status,
111
116
  } from "./runtime-core.ts"
112
117
  import { normalizeStaticResponseHeaders, type StaticResponseHeaders } from "./static-headers.ts"
113
118
 
@@ -125,96 +130,6 @@ export type {
125
130
  ResponseHeadersView,
126
131
  }
127
132
 
128
- const HEADER_MUTABILITY_PROBE = "x-nifra-header-probe"
129
- const GUARDED_RESPONSE_HEADERS = new WeakSet<Headers>()
130
-
131
- /**
132
- * Detect a guarded Web Headers object before invoking user code. Retrying after catching any
133
- * TypeError is observably wrong: user hooks are allowed to throw TypeError themselves, and a retry
134
- * runs those hooks twice. The hot path never reaches the probe: every framework-constructed
135
- * Response stamps its headers as known-mutable at construction (see respond.ts), so only a
136
- * handler-returned foreign `Response` - the case that can actually be guarded - pays it, once per
137
- * headers object. The probe uses a valid private header name and restores a pre-existing value, so
138
- * the fallback is limited to the actual immutable/guarded-header case.
139
- */
140
- function hasMutableResponseHeaders(headers: Headers): boolean {
141
- if (knownMutableHeaders(headers)) return true
142
- if (GUARDED_RESPONSE_HEADERS.has(headers)) return false
143
- let previous: string | null = null
144
- try {
145
- previous = headers.get(HEADER_MUTABILITY_PROBE)
146
- headers.set(HEADER_MUTABILITY_PROBE, "1")
147
- if (previous === null) headers.delete(HEADER_MUTABILITY_PROBE)
148
- else headers.set(HEADER_MUTABILITY_PROBE, previous)
149
- rememberMutableHeaders(headers)
150
- return true
151
- } catch {
152
- try {
153
- if (previous === null) headers.delete(HEADER_MUTABILITY_PROBE)
154
- else headers.set(HEADER_MUTABILITY_PROBE, previous)
155
- } catch {
156
- // The guarded object rejected the cleanup too; the clone below is authoritative.
157
- }
158
- GUARDED_RESPONSE_HEADERS.add(headers)
159
- return false
160
- }
161
- }
162
-
163
- /** Adapt a portable {@link ResponseBodyHook} into the Web `onResponse` walk. Only a Response
164
- * carrying the framework-buffered body tag participates - a raw or streamed Response is skipped by
165
- * contract, never drained. */
166
- function webResponseBodyHook(
167
- fn: ResponseBodyHook,
168
- owners: ReadonlySet<object>,
169
- ): (response: Response, req: Request) => MaybePromise<Response> {
170
- return (response, req) => {
171
- const body = taggedResponseBody(response, owners)
172
- if (body === undefined) return response
173
- const out = fn(body, response.headers, webRequestView(req), response.status)
174
- if (out instanceof Promise) return out.then((replaced) => withReplacedBody(response, replaced))
175
- return withReplacedBody(response, out)
176
- }
177
- }
178
-
179
- /** Minimal request view over a Web `Request` for portable header hooks on the Web serving paths. */
180
- function webRequestView(req: Request): NodeRequestContext {
181
- return { method: req.method, url: req.url, header: (name) => req.headers.get(name) }
182
- }
183
-
184
- /**
185
- * Adapt a portable {@link ResponseHeadersHook} into the Web `onResponse` walk: run it against the
186
- * response's own `Headers` in place (no clone). A response whose headers are GUARDED (a raw
187
- * `fetch()`ed Response returned by a handler) throws on the FIRST mutation, so nothing was applied
188
- * yet - rerun once against a mutable copy. Async hooks should perform their mutations before their
189
- * first await for the guard fallback to cover them.
190
- */
191
- function webResponseHeadersHook(
192
- fn: ResponseHeadersHook,
193
- ): (response: Response, req: Request) => MaybePromise<Response> {
194
- return (response, req) => {
195
- const view = webRequestView(req)
196
- if (!hasMutableResponseHeaders(response.headers)) {
197
- const clone = new Response(response.body, response)
198
- const out = fn(clone.headers, view, clone.status)
199
- return out instanceof Promise ? out.then(() => clone) : clone
200
- }
201
- const out = fn(response.headers, view, response.status)
202
- return out instanceof Promise ? out.then(() => response) : response
203
- }
204
- }
205
-
206
- /** Adapt a raw-response fallback hook. Tagged framework payloads already ran through the body tier,
207
- * while untagged Responses include streams, proxied fetches, and framework-generated error responses. */
208
- function webResponseRawHook(
209
- fn: (response: Response, req: Request) => MaybePromise<Response>,
210
- owners: ReadonlySet<object>,
211
- ): (response: Response, req: Request) => MaybePromise<Response> {
212
- return (response, req) => {
213
- if (taggedResponseBody(response, owners) !== undefined) return response
214
- return fn(response, req)
215
- }
216
- }
217
-
218
133
  import type { IdempotencyRuntime } from "./idempotency-lane.ts"
219
134
  import {
220
135
  INSTALL_EFFECT_LEDGER,
@@ -222,8 +137,11 @@ import {
222
137
  INSTALL_MCP,
223
138
  INSTALL_NODE_DIRECT,
224
139
  INSTALL_RESPONSE_CONTRACT,
140
+ INSTALL_RESPONSE_OBSERVER,
225
141
  INSTALL_SSE,
226
142
  INSTALL_WS,
143
+ NODE_NATIVE_MOUNT,
144
+ RESOLVE_NODE_MOUNT,
227
145
  } from "./install.ts"
228
146
  import type { EffectLedgerRuntime, ResolvedEffectLedger } from "./ledger-lane.ts"
229
147
  import { jsonLogger, type Logger } from "./logger.ts"
@@ -363,6 +281,22 @@ interface BunUpgradeServer {
363
281
 
364
282
  type MountedFetchHandler = (request: Request, platform?: Platform) => MaybePromise<Response>
365
283
 
284
+ /** Structural native mount contract. The serving adapter owns the concrete Node request/response
285
+ * types; the kernel only selects a handler after proving that taking this lane cannot skip its
286
+ * global lifecycle. `false` means the capability was unavailable at runtime, so the adapter must
287
+ * retry the ordinary Web mount with the same untouched source. */
288
+ type NativeMountHandler = (
289
+ request: unknown,
290
+ response: unknown,
291
+ platform?: Platform,
292
+ ) => MaybePromise<undefined | false>
293
+
294
+ interface NativeMountSelection {
295
+ readonly handler: NativeMountHandler
296
+ readonly path: string
297
+ readonly stripPrefix: boolean
298
+ }
299
+
366
300
  interface FetchMount {
367
301
  readonly path: string
368
302
  readonly handler: MountedFetchHandler
@@ -432,10 +366,11 @@ function requireMcpRuntime(runtime: McpRuntime | undefined): McpRuntime {
432
366
  }
433
367
 
434
368
  /** The handler's permitted return type. When the route declares a `response` schema, the return is
435
- * constrained to the contract's type (or a raw `Response`) - so the implementation can't drift from the
436
- * declared contract. Without a `response` schema it's unconstrained (`HandlerResult`), exactly as before. */
369
+ * constrained to the contract's type (a raw `Response`, or a `status(...)` early exit - both are
370
+ * control flow, not the declared payload) - so the implementation can't drift from the declared
371
+ * contract. Without a `response` schema it's unconstrained (`HandlerResult`), exactly as before. */
437
372
  type ResponseOf<S extends RouteSchema> = S extends { response: infer R extends StandardSchemaV1 }
438
- ? InferOutput<R> | Response
373
+ ? InferOutput<R> | Response | ResponseResult
439
374
  : HandlerResult
440
375
 
441
376
  /**
@@ -497,12 +432,23 @@ const STOP_HOOK_TIMEOUT_MS = 5_000
497
432
  * for the same request, so a browser that could open a socket was told its POST was cross-origin. */
498
433
  const wsSameOrigin = isSameOriginRequest
499
434
 
500
- function validationError(issues: ReadonlyArray<StandardIssue>): Response {
435
+ function validationIssues(issues: ReadonlyArray<StandardIssue>): {
436
+ ok: false
437
+ error: string
438
+ issues: unknown[]
439
+ } {
501
440
  const serialized = issues.map((issue) => {
502
441
  const path = issue.path?.map((seg) => String(typeof seg === "object" ? seg.key : seg))
503
442
  return path !== undefined ? { message: issue.message, path } : { message: issue.message }
504
443
  })
505
- return Response.json({ ok: false, error: "validation", issues: serialized }, { status: 422 })
444
+ return { ok: false, error: "validation", issues: serialized }
445
+ }
446
+
447
+ /** The 422 as plain data - the shape every lane's response wrapper takes, so a rejected body costs
448
+ * what an accepted one costs: on Node it is written straight to the socket with a `content-length`
449
+ * instead of being built as a `Response` and drained back out. */
450
+ export function plainValidationError(issues: ReadonlyArray<StandardIssue>): ResponseResult {
451
+ return status(422, validationIssues(issues))
506
452
  }
507
453
 
508
454
  // `jsonError`, `urlPartsOf`, `pathnameOf` moved to `./http.ts` (a dependency-free leaf shared with the
@@ -557,6 +503,21 @@ function responseSet(ctx: RawContext): CtxSet {
557
503
  return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS
558
504
  }
559
505
 
506
+ /**
507
+ * A `derive` that answered with a response rather than a context extension - `status(...)`, or a bare
508
+ * `Response`. The request ends there, on the same lane a `beforeHandle`'s early return takes.
509
+ *
510
+ * `derive` is the one stage that could not exit early: `beforeHandle` says "done" by returning a
511
+ * value, but a derive's return value IS the extension, so the only way out was `throw`ing a
512
+ * `Response` - which measured as the single most expensive way to answer (the `Response` object
513
+ * costs ~40% of the rejection lane; see `PlainRender`). Neither shape had a meaning here before:
514
+ * `Object.assign(ctx, response)` copies nothing (a `Response`'s properties live on its prototype),
515
+ * so a derive that returned one silently fell through to the handler it meant to prevent.
516
+ */
517
+ function isDeriveEarlyExit(extension: unknown): boolean {
518
+ return isResponseResult(extension) || extension instanceof Response
519
+ }
520
+
560
521
  function isContextlessNoArgArrow(handler: (context: never) => unknown): boolean {
561
522
  if (handler.length !== 0) return false
562
523
  try {
@@ -577,8 +538,17 @@ type FeatureVersionOf<V extends string> = V extends `${infer Major}.${infer Mino
577
538
  */
578
539
  export type NifraFeatureVersion = FeatureVersionOf<Version>
579
540
 
541
+ /**
542
+ * The Web lanes' `wrapResponse` input, as a `Response`. A plain-data carrier ({@link plainError},
543
+ * `status(...)`) is rendered on the ordinary JSON lane rather than through its own `toResponse`, so
544
+ * it picks up the same prebuilt init a handler's plain return uses. `EMPTY_RESPONSE_CONTROLS` because
545
+ * these renders happen where no request context exists - before routing, or after it was abandoned.
546
+ */
547
+ const webResponseOf = (result: Response | ResponseResult): Response =>
548
+ result instanceof Response ? result : toResponse(result, EMPTY_RESPONSE_CONTROLS)
549
+
580
550
  // Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
581
- const IDENTITY_RESPONSE = (response: Response): Response => response
551
+ const IDENTITY_RESPONSE = (response: Response | ResponseResult): Response => webResponseOf(response)
582
552
  const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
583
553
 
584
554
  // The unused-order-scoped-hook audit (recorder, seal check, stack capture, message) is a dev-time
@@ -780,7 +750,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
780
750
  private staticResponseHeaders: StaticResponseHeaders | undefined
781
751
  /** `wrapResponse` for the Web lanes: identity until static headers exist to fold into the
782
752
  * framework's own error/404/timeout renders, which are built outside the header init. */
783
- private wrapWebResponse: (response: Response) => Response
753
+ private wrapWebResponse: (response: Response | ResponseResult) => Response
784
754
  private webResponseTimeout: () => Response
785
755
  /** Body/raw response hooks need a framework-payload marker; keep that decision per app. */
786
756
  private responseBodyTag: object | undefined
@@ -925,8 +895,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
925
895
  * declared *after* this call, and reaches none declared before. One added after the last route
926
896
  * (e.g. on the app returned by a route-registering factory) covers nothing - see
927
897
  * {@link ServerOptions.unusedScopedHooks}.
898
+ *
899
+ * Returning a `status(...)` (or a `Response`) instead of an extension ends the request there - the
900
+ * guard shape, and the cheap one: it renders as plain data on the same lane a handler's return
901
+ * takes, where `throw new Response(...)` pays ~40% of the rejection lane to build the object. That
902
+ * branch is control flow, so it does not widen the derived context type.
928
903
  */
929
- derive<D extends object>(fn: (context: Context & Ctx) => MaybePromise<D>): Server<R, Ctx & D> {
904
+ derive<D extends object>(
905
+ fn: (context: Context & Ctx) => MaybePromise<D | ResponseResult | Response>,
906
+ ): Server<R, Ctx & D> {
930
907
  this.assertConfigurable("derive()")
931
908
  this.derives.push(fn as unknown as RawDerive)
932
909
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
@@ -1089,11 +1066,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1089
1066
  * hook owns part of the header state - into a hook that runs in the right order. */
1090
1067
  private addStaticResponseHeaders(record: Record<string, string>): this {
1091
1068
  if (this.onResponseHooks.length > 0) {
1092
- return this.onResponseHeaders((headers) => {
1093
- for (const name of Object.keys(record)) {
1094
- if (!headers.has(name)) headers.set(name, record[name] as string)
1095
- }
1096
- })
1069
+ const observerHeaders = (
1070
+ this as unknown as Pick<ResponseObserverMethods, "onResponseHeaders">
1071
+ ).onResponseHeaders
1072
+ if (observerHeaders !== undefined) {
1073
+ observerHeaders.call(this, (headers) => {
1074
+ for (const name of Object.keys(record)) {
1075
+ if (!headers.has(name)) headers.set(name, record[name] as string)
1076
+ }
1077
+ })
1078
+ return this
1079
+ }
1080
+ // Route the hook through the same seam the static tier uses. On a framework response (headers
1081
+ // stamped mutable) it applies the defaults in place and returns the SAME object, so the
1082
+ // serialized-body marker survives for later `onResponseBody`/`onResponseRaw` observers and the
1083
+ // Node writer; reconstructing a `Response` here would strip that marker and reclassify the Node
1084
+ // outcome from `json` to a generic `response`. A guarded foreign response takes its clone path.
1085
+ const statics = buildStaticResponseHeaders(record)
1086
+ this.onResponseHooks.push((response) => applyStaticResponseHeaders(response, statics))
1087
+ this.onNodeResponseHooks.push(undefined)
1088
+ this.nodeResponseHooksComplete = false
1089
+ return this
1097
1090
  }
1098
1091
  const merged =
1099
1092
  this.staticResponseHeaders === undefined
@@ -1101,7 +1094,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1101
1094
  : { ...this.staticResponseHeaders.record, ...record }
1102
1095
  this.staticResponseHeaders = buildStaticResponseHeaders(merged)
1103
1096
  const statics = this.staticResponseHeaders
1104
- this.wrapWebResponse = (response) => applyStaticResponseHeaders(response, statics)
1097
+ this.wrapWebResponse = (response) =>
1098
+ applyStaticResponseHeaders(webResponseOf(response), statics)
1105
1099
  this.webResponseTimeout = () => applyStaticResponseHeaders(RESPONSE_TIMEOUT(), statics)
1106
1100
  return this
1107
1101
  }
@@ -1114,61 +1108,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1114
1108
  return this.responseBodyTag
1115
1109
  }
1116
1110
 
1117
- /**
1118
- * Register a response transform for raw/streamed Responses. Framework-serialized payloads stay on
1119
- * the body tier; this hook is only entered for untagged Responses (streams, proxied fetches, and
1120
- * framework-generated error responses). A no-op native twin keeps buffered JSON on the direct lane.
1121
- */
1122
- onResponseRaw(fn: (response: Response, req: Request) => MaybePromise<Response>): this {
1123
- this.assertConfigurable("onResponseRaw()")
1124
- this.enableResponseBodyTagging()
1125
- this.onResponseHooks.push(webResponseRawHook(fn, this.responseBodyOwners))
1126
- this.onNodeResponseHooks.push(() => undefined)
1127
- return this
1128
- }
1129
-
1130
- /**
1131
- * Register a PORTABLE header-only response hook - one implementation, fast on every runtime.
1132
- *
1133
- * The hook receives a mutable case-insensitive header view, a minimal request view, and the
1134
- * status. On the Web serving paths it runs inside the normal `onResponse` walk against the
1135
- * response's own `Headers` (mutating in place - no clone). On Node it self-pairs as a native
1136
- * response hook against the outcome's plain header record, so registering one NEVER forces the
1137
- * Node adapter off its direct socket writer the way a full `onResponse(res: Response)` hook does.
1138
- * Prefer this over `onResponse` whenever the middleware only reads/writes headers.
1139
- */
1140
- onResponseHeaders(fn: ResponseHeadersHook): this {
1141
- this.assertConfigurable("onResponseHeaders()")
1142
- this.onResponseHooks.push(webResponseHeadersHook(fn))
1143
- this.onNodeResponseHooks.push((response, req) =>
1144
- fn(recordHeadersView(response), req, response.status),
1145
- )
1146
- return this
1147
- }
1148
-
1149
- /**
1150
- * Register a PORTABLE post-serialization body hook - the payload tier. The hook receives the
1151
- * FINAL framework-serialized bytes (plus the mutable header view and status) and may return
1152
- * replacement bytes; `undefined` keeps the body. On the Node direct writer the bytes come
1153
- * straight off the outcome record; on the Web serving paths they ride the framework-built
1154
- * Response as a tag, so no body stream is ever drained on any runtime. A handler-returned raw
1155
- * `Response` (proxied fetch, SSE, streamed SSR) is skipped by contract - transforming those is
1156
- * what the full `onResponse` hook is for.
1157
- */
1158
- onResponseBody(fn: ResponseBodyHook): this {
1159
- this.assertConfigurable("onResponseBody()")
1160
- this.enableResponseBodyTagging()
1161
- this.onResponseHooks.push(webResponseBodyHook(fn, this.responseBodyOwners))
1162
- this.onNodeResponseHooks.push((response, req) => {
1163
- const body = response.body
1164
- if (body === null) return undefined
1165
- const out = fn(body, recordHeadersView(response), req, response.status)
1166
- if (out instanceof Promise)
1167
- return out.then((replaced) => applyBodyReplacement(response, replaced))
1168
- applyBodyReplacement(response, out)
1169
- return undefined
1170
- })
1171
- return this
1111
+ private responseObserverMethods(): ResponseObserverMethods {
1112
+ const methods = this as unknown as Partial<ResponseObserverMethods>
1113
+ if (
1114
+ typeof methods.onResponseHeaders !== "function" ||
1115
+ typeof methods.onResponseBody !== "function" ||
1116
+ typeof methods.onResponseRaw !== "function"
1117
+ ) {
1118
+ throw new TypeError(
1119
+ "response observation requires `.use(responseObserver())` or an observer-enabled middleware",
1120
+ )
1121
+ }
1122
+ return methods as ResponseObserverMethods
1172
1123
  }
1173
1124
 
1174
1125
  /** Observe the terminal response after all transformations. Observers are ordered and fail-open. */
@@ -1180,10 +1131,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1180
1131
  return this
1181
1132
  }
1182
1133
 
1134
+ /** Enable the opt-in portable response observer methods. */
1135
+ use(plugin: ResponseObserverPlugin): this & ResponseObserverMethods
1183
1136
  /**
1184
1137
  * Apply a type-**identity** plugin ({@link IdentityPlugin}, from {@link defineIdentityPlugin}) - it
1185
1138
  * registers routes/hooks but doesn't change the types, so this returns `this` with the route registry
1186
- * and context fully intact. This overload exists specifically so a *named* identity plugin (e.g.
1139
+ * and context fully intact. This overload exists specifically so a named identity plugin (e.g.
1187
1140
  * `@nifrajs/better-auth`) threads the registry: its `& { pluginName }` intersection would otherwise
1188
1141
  * defeat the generic inference of the transforming overload below and collapse the result to `any`.
1189
1142
  */
@@ -1254,6 +1207,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1254
1207
  }
1255
1208
  this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"))
1256
1209
  this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"))
1210
+ const responseObserver = (arg as unknown as Record<symbol, unknown>)[INSTALL_RESPONSE_OBSERVER]
1211
+ if (responseObserver !== undefined) {
1212
+ if (
1213
+ typeof responseObserver !== "object" ||
1214
+ responseObserver === null ||
1215
+ typeof (responseObserver as { install?: unknown }).install !== "function"
1216
+ ) {
1217
+ throw new TypeError("response observer middleware has an invalid runtime")
1218
+ }
1219
+ this[INSTALL_RESPONSE_OBSERVER](responseObserver as ResponseObserverRuntime)
1220
+ }
1257
1221
  if (arg.onRequest !== undefined) {
1258
1222
  this.assertConfigurable("onRequest()")
1259
1223
  this.onRequestHooks.push(arg.onRequest as RawOnRequest)
@@ -1277,9 +1241,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1277
1241
  // Before the bundle's own hooks: a bundle declaring both means its static values are the
1278
1242
  // defaults its hook may then override, which is the order a single bundle reads in.
1279
1243
  if (arg.responseHeaders !== undefined) this.responseHeaders(arg.responseHeaders)
1280
- if (arg.onResponseHeaders !== undefined) this.onResponseHeaders(arg.onResponseHeaders)
1281
- if (arg.onResponseBody !== undefined) this.onResponseBody(arg.onResponseBody)
1282
- if (arg.onResponseRaw !== undefined) this.onResponseRaw(arg.onResponseRaw)
1244
+ if (arg.onResponseHeaders !== undefined)
1245
+ this.responseObserverMethods().onResponseHeaders(arg.onResponseHeaders)
1246
+ if (arg.onResponseBody !== undefined)
1247
+ this.responseObserverMethods().onResponseBody(arg.onResponseBody)
1248
+ if (arg.onResponseRaw !== undefined)
1249
+ this.responseObserverMethods().onResponseRaw(arg.onResponseRaw)
1283
1250
  if (arg.onResponseFinalized !== undefined) this.onResponseFinalized(arg.onResponseFinalized)
1284
1251
  if (arg.onError !== undefined) this.onError(arg.onError)
1285
1252
  return this
@@ -1547,6 +1514,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1547
1514
  // Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
1548
1515
  // bundle it. Registration is the loud, early failure point - never the first connection.
1549
1516
  const runtime = requireWsRuntime(this.wsRuntime)
1517
+ if (handler.validateSend === true && handler.sendSchema === undefined) {
1518
+ throw new RouteConfigError(
1519
+ "INVALID_WS_SEND_VALIDATION",
1520
+ `route WS ${path}: validateSend requires sendSchema`,
1521
+ )
1522
+ }
1550
1523
  this.topics ??= runtime.createTopics()
1551
1524
  // A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
1552
1525
  // already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
@@ -1627,6 +1600,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1627
1600
  handler: (context: never) => unknown,
1628
1601
  ): CatalogRoute {
1629
1602
  const pattern = compileRoutePattern(path)
1603
+ // Resolved here, once: `undefined` past this point means the route declared `"unlimited"`, NOT
1604
+ // "nothing declared". Readers fall back to UNLIMITED_BODY_BYTES accordingly.
1630
1605
  let bodyLimit: number | undefined = this.maxBodyBytes
1631
1606
  const invalidBodyLimit = (message: string): never => {
1632
1607
  throw new RouteConfigError("INVALID_BODY_LIMIT", `route ${method} ${path}: ${message}`)
@@ -1641,6 +1616,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1641
1616
  ) {
1642
1617
  invalidBodyLimit('bodyLimit: "unlimited" requires a non-empty bodyLimitReason')
1643
1618
  }
1619
+ if (schema.body !== undefined) {
1620
+ invalidBodyLimit(
1621
+ 'bodyLimit: "unlimited" cannot be used with a body schema; use a finite cap',
1622
+ )
1623
+ }
1624
+ if (schema.idempotency !== undefined) {
1625
+ invalidBodyLimit('bodyLimit: "unlimited" cannot be used with idempotency')
1626
+ }
1644
1627
  bodyLimit = undefined
1645
1628
  } else if (schema?.bodyLimit !== undefined) {
1646
1629
  try {
@@ -1769,7 +1752,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1769
1752
  handler as unknown as InternalHandler,
1770
1753
  schema.body as StandardSchemaV1,
1771
1754
  hasDecorations ? routeDecorations : undefined,
1772
- bodyLimit ?? this.maxBodyBytes,
1755
+ bodyLimit ?? UNLIMITED_BODY_BYTES,
1773
1756
  )
1774
1757
  : undefined
1775
1758
  const fusedWeb =
@@ -1778,14 +1761,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1778
1761
  handler as unknown as InternalHandler,
1779
1762
  hasDecorations ? routeDecorations : undefined,
1780
1763
  isContextlessNoArgArrow(handler),
1781
- bodyLimit ?? this.maxBodyBytes,
1764
+ bodyLimit ?? UNLIMITED_BODY_BYTES,
1782
1765
  )
1783
1766
  : fusedQuery
1784
1767
  ? this.buildFusedQueryWeb(
1785
1768
  handler as unknown as InternalHandler,
1786
1769
  hasDecorations ? routeDecorations : undefined,
1787
1770
  schema.query as StandardSchemaV1,
1788
- bodyLimit ?? this.maxBodyBytes,
1771
+ bodyLimit ?? UNLIMITED_BODY_BYTES,
1789
1772
  )
1790
1773
  : fusedBody
1791
1774
  ? this.buildFusedBodyWeb(fusedBodyRunner as FusedBodyRunner)
@@ -1901,7 +1884,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1901
1884
  })
1902
1885
  routeAssurance.push(Object.freeze(declared))
1903
1886
  }
1904
- if (schema?.body !== undefined) {
1887
+ // Two ways a route proves its body is bounded, both enforced by the core at read time:
1888
+ // `schema.body` (the validated read is bounded) and an explicit finite `schema.bodyLimit` (the
1889
+ // transport cap shadows every reader, including a raw `c.req.body` stream that a Content-Length
1890
+ // middleware cannot bound).
1891
+ // `bodyLimit: "unlimited"` is rejected above for schema and idempotency routes. The remaining
1892
+ // exemption is schema-less streaming/upload work, so it publishes NOTHING: there is no finite
1893
+ // framework byte ceiling for assurance to attest and the application protocol owns that bound.
1894
+ // The server-wide `maxBodyBytes` default is not evidence either. It applies to every route
1895
+ // whether or not anyone thought about it, so publishing it would make the id pass everywhere and
1896
+ // prove nothing; this evidence marks a bound the route chose.
1897
+ const unlimitedBody = schema?.bodyLimit === "unlimited"
1898
+ if (!unlimitedBody && (schema?.body !== undefined || typeof schema?.bodyLimit === "number")) {
1905
1899
  routeAssurance.push(
1906
1900
  Object.freeze({
1907
1901
  id: NIFRA_ASSURANCE_IDS.BODY_BOUNDED,
@@ -1961,6 +1955,29 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1961
1955
  this.responseContractRuntime = runtime
1962
1956
  }
1963
1957
 
1958
+ /** @internal Symbol-keyed install seam for the response observer plugin. */
1959
+ [INSTALL_RESPONSE_OBSERVER](runtime: ResponseObserverRuntime): ResponseObserverMethods {
1960
+ this.assertConfigurable("responseObserver()")
1961
+ const methods = runtime.install({
1962
+ assertConfigurable: (operation) => this.assertConfigurable(operation),
1963
+ addResponseHook: (web, node) => {
1964
+ this.onResponseHooks.push(web)
1965
+ this.onNodeResponseHooks.push(node)
1966
+ if (node === undefined) this.nodeResponseHooksComplete = false
1967
+ },
1968
+ enableResponseBodyTagging: () => {
1969
+ if (this.responseBodyTag === undefined) {
1970
+ this.responseBodyTag = Object.freeze({})
1971
+ this.responseBodyOwners.add(this.responseBodyTag)
1972
+ }
1973
+ return this.responseBodyTag
1974
+ },
1975
+ responseBodyOwners: () => this.responseBodyOwners,
1976
+ })
1977
+ Object.assign(this, methods)
1978
+ return methods
1979
+ }
1980
+
1964
1981
  /** @internal Symbol-keyed install seam for the `idempotency()` plugin. Off the public typed surface. */
1965
1982
  [INSTALL_IDEMPOTENCY](runtime: IdempotencyRuntime): void {
1966
1983
  this.assertConfigurable("idempotency()")
@@ -2116,7 +2133,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2116
2133
  entry.handler,
2117
2134
  entry.schema?.body as StandardSchemaV1,
2118
2135
  entry.hasDecorations ? entry.decorations : undefined,
2119
- entry.bodyLimit ?? this.maxBodyBytes,
2136
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2120
2137
  )
2121
2138
  : undefined
2122
2139
  const fusedWeb =
@@ -2127,13 +2144,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2127
2144
  entry.handler,
2128
2145
  entry.hasDecorations ? entry.decorations : undefined,
2129
2146
  entry.schema?.query as StandardSchemaV1,
2130
- entry.bodyLimit ?? this.maxBodyBytes,
2147
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2131
2148
  )
2132
2149
  : this.buildFusedWeb(
2133
2150
  entry.handler,
2134
2151
  entry.hasDecorations ? entry.decorations : undefined,
2135
2152
  isContextlessNoArgArrow(entry.handler),
2136
- entry.bodyLimit ?? this.maxBodyBytes,
2153
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2137
2154
  )
2138
2155
  return {
2139
2156
  ...route,
@@ -2290,6 +2307,45 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2290
2307
  return outcome
2291
2308
  }
2292
2309
 
2310
+ /**
2311
+ * Select a mounted handler's native Node lane without materializing a Web Request. This is an
2312
+ * adapter seam, not a second lifecycle: any app-wide behavior that the ordinary mount path would
2313
+ * run makes the capability ineligible and the caller falls back to `resolveNodeSource`. Typed
2314
+ * routes keep precedence exactly as `routeAndRun` does, including the existing mount behavior for
2315
+ * a path whose registered route rejects this method.
2316
+ */
2317
+ [RESOLVE_NODE_MOUNT](source: RequestSource): NativeMountSelection | undefined {
2318
+ if (
2319
+ this.onRequestHooks.length > 0 ||
2320
+ this.onResponseHooks.length > 0 ||
2321
+ this.onResponseFinalizedHooks.length > 0 ||
2322
+ this.capacityGate !== undefined ||
2323
+ this.staticResponseHeaders !== undefined ||
2324
+ this.requestTimeoutMs !== 0 ||
2325
+ this.acceptInboundDeadlines ||
2326
+ this.clientIpTrust !== undefined
2327
+ ) {
2328
+ return undefined
2329
+ }
2330
+
2331
+ const parts = source.urlParts ?? urlPartsOf(source.url)
2332
+ const match = this.catalog.find(source.method, parts.pathname)
2333
+ if (match.found) return undefined
2334
+
2335
+ for (const mount of this.fetchMounts) {
2336
+ if (!underMountPrefix(parts.pathname, mount.path)) continue
2337
+ const candidate = (mount.handler as unknown as Record<symbol, unknown>)[NODE_NATIVE_MOUNT]
2338
+ return typeof candidate === "function"
2339
+ ? {
2340
+ handler: candidate as NativeMountHandler,
2341
+ path: mount.path,
2342
+ stripPrefix: mount.stripPrefix,
2343
+ }
2344
+ : undefined
2345
+ }
2346
+ return undefined
2347
+ }
2348
+
2293
2349
  /**
2294
2350
  * Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` - the
2295
2351
  * `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
@@ -2668,7 +2724,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2668
2724
  source: RequestSource,
2669
2725
  platform: Platform | undefined,
2670
2726
  finalize: (result: unknown, set: CtxSet) => T,
2671
- wrapResponse: (response: Response) => T,
2727
+ wrapResponse: (response: Response | ResponseResult) => T,
2672
2728
  onTimeout: () => T,
2673
2729
  // True only from the Web `fetch` path - unlocks each route's fused lane, whose output type IS
2674
2730
  // `Response` (`T = Response` there by construction; the node path always passes false).
@@ -2696,7 +2752,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2696
2752
  source: RequestSource,
2697
2753
  platform: Platform | undefined,
2698
2754
  finalize: (result: unknown, set: CtxSet) => T,
2699
- wrapResponse: (response: Response) => T,
2755
+ wrapResponse: (response: Response | ResponseResult) => T,
2700
2756
  onTimeout: () => T,
2701
2757
  ): MaybePromise<T> {
2702
2758
  const hooks = this.onNodeRequestHooks
@@ -2730,7 +2786,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2730
2786
  source: RequestSource,
2731
2787
  platform: Platform | undefined,
2732
2788
  finalize: (result: unknown, set: CtxSet) => T,
2733
- wrapResponse: (response: Response) => T,
2789
+ wrapResponse: (response: Response | ResponseResult) => T,
2734
2790
  onTimeout: () => T,
2735
2791
  ): Promise<T> {
2736
2792
  if (first !== undefined) return wrapResponse(first)
@@ -2758,7 +2814,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2758
2814
  source: RequestSource,
2759
2815
  platform: Platform | undefined,
2760
2816
  finalize: (result: unknown, set: CtxSet) => T,
2761
- wrapResponse: (response: Response) => T,
2817
+ wrapResponse: (response: Response | ResponseResult) => T,
2762
2818
  onTimeout: () => T,
2763
2819
  webFast: boolean,
2764
2820
  ): MaybePromise<T> {
@@ -2805,7 +2861,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2805
2861
  sourceAtAwait: RequestSource,
2806
2862
  platform: Platform | undefined,
2807
2863
  finalize: (result: unknown, set: CtxSet) => T,
2808
- wrapResponse: (response: Response) => T,
2864
+ wrapResponse: (response: Response | ResponseResult) => T,
2809
2865
  onTimeout: () => T,
2810
2866
  webFast: boolean,
2811
2867
  ): Promise<T> {
@@ -2899,7 +2955,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2899
2955
  source: RequestSource,
2900
2956
  platform: Platform | undefined,
2901
2957
  finalize: (result: unknown, set: CtxSet) => T,
2902
- wrapResponse: (response: Response) => T,
2958
+ wrapResponse: (response: Response | ResponseResult) => T,
2903
2959
  onTimeout: () => T,
2904
2960
  webFast: boolean,
2905
2961
  ): MaybePromise<T> {
@@ -2951,17 +3007,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2951
3007
  }
2952
3008
  if (match.reason === "method-not-allowed") {
2953
3009
  return wrapResponse(
2954
- jsonError(405, "method_not_allowed", { Allow: match.allowed.join(", ") }),
3010
+ // Lowercase on purpose: a plain render's headers go into the node outcome record verbatim,
3011
+ // and every other name in that record is lowercase (the Web lane normalizes either way).
3012
+ plainError(405, "method_not_allowed", { allow: match.allowed.join(", ") }),
2955
3013
  )
2956
3014
  }
2957
- return wrapResponse(jsonError(404, "not_found"))
3015
+ return wrapResponse(plainError(404, "not_found"))
2958
3016
  }
2959
3017
 
2960
3018
  // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
2961
3019
  // already did and made every plain dynamic route pay for unrelated static path bytes.
2962
3020
  const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params)
2963
3021
  if (params === null) {
2964
- return wrapResponse(jsonError(400, "malformed_path"))
3022
+ return wrapResponse(plainError(400, "malformed_path"))
2965
3023
  }
2966
3024
 
2967
3025
  return this.runMatched(
@@ -3001,7 +3059,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3001
3059
  params: Record<string, string>,
3002
3060
  search: string | undefined,
3003
3061
  finalize: (result: unknown, set: CtxSet) => T,
3004
- wrapResponse: (response: Response) => T,
3062
+ wrapResponse: (response: Response | ResponseResult) => T,
3005
3063
  onTimeout: () => T,
3006
3064
  webFast: boolean,
3007
3065
  ): MaybePromise<T> {
@@ -3032,7 +3090,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3032
3090
  search,
3033
3091
  wrapResponse,
3034
3092
  {
3035
- maxBodyBytes: entry.bodyLimit ?? this.maxBodyBytes,
3093
+ maxBodyBytes: entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3036
3094
  runLanes: (buffered, plat, ent, prm, srch) =>
3037
3095
  this.idempotencyRunLanes(buffered, plat, ent as RouteEntry, prm, srch),
3038
3096
  },
@@ -3059,7 +3117,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3059
3117
  params: Record<string, string>,
3060
3118
  search: string | undefined,
3061
3119
  finalize: (result: unknown, set: CtxSet) => T,
3062
- wrapResponse: (response: Response) => T,
3120
+ wrapResponse: (response: Response | ResponseResult) => T,
3063
3121
  onTimeout: () => T,
3064
3122
  webFast: boolean,
3065
3123
  ): MaybePromise<T> {
@@ -3081,7 +3139,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3081
3139
  ? undefined
3082
3140
  : admitDeadline(source.headers, this.deadlineAdmissionOptions)
3083
3141
  if (admission !== undefined && !admission.ok) {
3084
- return wrapResponse(jsonError(admission.status, admission.reason))
3142
+ return wrapResponse(plainError(admission.status, admission.reason))
3085
3143
  }
3086
3144
  const effectiveTimeoutMs = admission?.timeoutMs ?? 0
3087
3145
 
@@ -3140,7 +3198,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3140
3198
  if (controller !== undefined && outcome instanceof Promise) {
3141
3199
  const timedOut =
3142
3200
  admission?.inherited === true
3143
- ? () => wrapResponse(jsonError(504, "deadline_exceeded"))
3201
+ ? () => wrapResponse(plainError(504, "deadline_exceeded"))
3144
3202
  : onTimeout
3145
3203
  return this.withTimeout(
3146
3204
  outcome,
@@ -3171,7 +3229,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3171
3229
  budget: RequestBudget,
3172
3230
  platform: Platform | undefined,
3173
3231
  finalize: (result: unknown, set: CtxSet) => T,
3174
- wrapResponse: (response: Response) => T,
3232
+ wrapResponse: (response: Response | ResponseResult) => T,
3175
3233
  ): MaybePromise<T> {
3176
3234
  let result: unknown
3177
3235
  try {
@@ -3215,9 +3273,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3215
3273
  signal: AbortSignal,
3216
3274
  budget: RequestBudget,
3217
3275
  platform: Platform | undefined,
3218
- wrapResponse: (response: Response) => T,
3276
+ wrapResponse: (response: Response | ResponseResult) => T,
3219
3277
  ): T {
3220
3278
  if (err instanceof Response) return wrapResponse(err)
3279
+ // Handed over as the plain data it is: `wrapResponse` renders it on the JSON lane (no `Response`
3280
+ // built on Node). There is no context here by construction, so there is no `c.set` to merge.
3281
+ if (isResponseResult(err)) return wrapResponse(err)
3221
3282
  const ctx = new RequestContext(
3222
3283
  source,
3223
3284
  params,
@@ -3229,7 +3290,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3229
3290
  this.protoPoisoning,
3230
3291
  )
3231
3292
  this.logRequestError(err, ctx)
3232
- return wrapResponse(jsonError(500, "internal_error"))
3293
+ return wrapResponse(plainError(500, "internal_error"))
3233
3294
  }
3234
3295
 
3235
3296
  /**
@@ -3246,20 +3307,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3246
3307
  entry: RouteEntry,
3247
3308
  ctx: RawContext,
3248
3309
  finalize: (result: unknown, set: CtxSet) => T,
3249
- wrapResponse: (response: Response) => T,
3310
+ wrapResponse: (response: Response | ResponseResult) => T,
3250
3311
  ): MaybePromise<T> {
3251
3312
  let result: unknown
3252
3313
  try {
3253
3314
  if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
3254
3315
  result = entry.handler(ctx)
3255
3316
  } catch (err) {
3256
- return this.bareError(err, ctx, wrapResponse)
3317
+ return this.bareError(err, ctx, finalize, wrapResponse)
3257
3318
  }
3258
3319
  if (result instanceof Promise) {
3259
3320
  // Async handler on an otherwise-bare route: finish on a microtask, with the same error handling.
3260
3321
  return result.then(
3261
3322
  (value) => finalize(value, responseSet(ctx)),
3262
- (err) => this.bareError(err, ctx, wrapResponse),
3323
+ (err) => this.bareError(err, ctx, finalize, wrapResponse),
3263
3324
  )
3264
3325
  }
3265
3326
  return finalize(result, responseSet(ctx))
@@ -3280,13 +3341,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3280
3341
  contextless: boolean,
3281
3342
  maxBodyBytes: number,
3282
3343
  ): FusedWebRunner {
3283
- // The fused lanes return to the runtime directly, so the framework's own error renders fold in
3284
- // static headers here rather than through the shared `wrapResponse` seam.
3285
- const logError = (err: unknown, ctx: RawContext): Response => {
3286
- if (err instanceof Response) return this.wrapWebResponse(err)
3287
- this.logRequestError(err, ctx)
3288
- return this.wrapWebResponse(jsonError(500, "internal_error"))
3289
- }
3344
+ // Same thrown-value contract as the generic lane (`bare-error-lane.ts` via `bareError`); the fused
3345
+ // lane only injects its renderer, so a thrown `status(...)` and the flat 500 fold in static headers
3346
+ // through `wrapWebResponse`. One source of truth for the error surface, no per-lane copy to drift.
3347
+ const logError = (err: unknown, ctx: RawContext): Response =>
3348
+ this.bareError(
3349
+ err,
3350
+ ctx,
3351
+ (result, set) => this.wrapWebResponse(toResponse(result, set)),
3352
+ this.wrapWebResponse,
3353
+ )
3290
3354
  if (contextless && decorations === undefined) {
3291
3355
  // `() => ...` can't observe the context - skip allocating one entirely (errors still build
3292
3356
  // one for the structured log, exactly like runContextlessBare).
@@ -3416,20 +3480,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3416
3480
  const logError = <T>(
3417
3481
  err: unknown,
3418
3482
  ctx: RawContext,
3419
- wrapResponse: (response: Response) => T,
3420
- ): T => this.bareError(err, ctx, wrapResponse)
3483
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
3484
+ wrapResponse: (response: Response | ResponseResult) => T,
3485
+ ): T => this.bareError(err, ctx, finalize, wrapResponse)
3421
3486
 
3422
3487
  const runHandler = <T>(
3423
3488
  ctx: RawContext,
3424
3489
  finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
3425
- wrapResponse: (response: Response) => T,
3490
+ wrapResponse: (response: Response | ResponseResult) => T,
3426
3491
  ): MaybePromise<T> => {
3427
3492
  if (decorations !== undefined) Object.assign(ctx, decorations)
3428
3493
  let output: MaybePromise<HandlerResult>
3429
3494
  try {
3430
3495
  output = handler(ctx)
3431
3496
  } catch (err) {
3432
- return logError(err, ctx, wrapResponse)
3497
+ return logError(err, ctx, finalize, wrapResponse)
3433
3498
  }
3434
3499
  if (output instanceof Promise) {
3435
3500
  return output.then(
@@ -3437,16 +3502,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3437
3502
  try {
3438
3503
  return finalize(result, responseSet(ctx), ctx)
3439
3504
  } catch (err) {
3440
- return logError(err, ctx, wrapResponse)
3505
+ return logError(err, ctx, finalize, wrapResponse)
3441
3506
  }
3442
3507
  },
3443
- (err) => logError(err, ctx, wrapResponse),
3508
+ (err) => logError(err, ctx, finalize, wrapResponse),
3444
3509
  )
3445
3510
  }
3446
3511
  try {
3447
3512
  return finalize(output, responseSet(ctx), ctx)
3448
3513
  } catch (err) {
3449
- return logError(err, ctx, wrapResponse)
3514
+ return logError(err, ctx, finalize, wrapResponse)
3450
3515
  }
3451
3516
  }
3452
3517
 
@@ -3454,9 +3519,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3454
3519
  result: StandardResult<unknown>,
3455
3520
  ctx: RawContext,
3456
3521
  finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
3457
- wrapResponse: (response: Response) => T,
3522
+ wrapResponse: (response: Response | ResponseResult) => T,
3458
3523
  ): MaybePromise<T> => {
3459
- if (result.issues !== undefined) return wrapResponse(validationError(result.issues))
3524
+ if (result.issues !== undefined) return wrapResponse(plainValidationError(result.issues))
3460
3525
  ctx.body = result.value
3461
3526
  return runHandler(ctx, finalize, wrapResponse)
3462
3527
  }
@@ -3465,13 +3530,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3465
3530
  parsed: unknown,
3466
3531
  ctx: RawContext,
3467
3532
  finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
3468
- wrapResponse: (response: Response) => T,
3533
+ wrapResponse: (response: Response | ResponseResult) => T,
3469
3534
  ): MaybePromise<T> => {
3470
3535
  let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
3471
3536
  try {
3472
3537
  validation = bodySchema["~standard"].validate(parsed)
3473
3538
  } catch (err) {
3474
- return logError(err, ctx, wrapResponse)
3539
+ return logError(err, ctx, finalize, wrapResponse)
3475
3540
  }
3476
3541
  if (validation instanceof Promise) {
3477
3542
  return validation.then(
@@ -3479,16 +3544,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3479
3544
  try {
3480
3545
  return runValidated(settled, ctx, finalize, wrapResponse)
3481
3546
  } catch (err) {
3482
- return logError(err, ctx, wrapResponse)
3547
+ return logError(err, ctx, finalize, wrapResponse)
3483
3548
  }
3484
3549
  },
3485
- (err) => logError(err, ctx, wrapResponse),
3550
+ (err) => logError(err, ctx, finalize, wrapResponse),
3486
3551
  )
3487
3552
  }
3488
3553
  try {
3489
3554
  return runValidated(validation, ctx, finalize, wrapResponse)
3490
3555
  } catch (err) {
3491
- return logError(err, ctx, wrapResponse)
3556
+ return logError(err, ctx, finalize, wrapResponse)
3492
3557
  }
3493
3558
  }
3494
3559
 
@@ -3501,7 +3566,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3501
3566
  platform: Platform | undefined,
3502
3567
  nativeContext: boolean,
3503
3568
  finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
3504
- wrapResponse: (response: Response) => T,
3569
+ wrapResponse: (response: Response | ResponseResult) => T,
3505
3570
  ): MaybePromise<T> => {
3506
3571
  const ctx = nativeContext
3507
3572
  ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
@@ -3517,8 +3582,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3517
3582
  )
3518
3583
  const finish = (value: unknown): MaybePromise<T> =>
3519
3584
  runParsed(value, ctx, finalize, wrapResponse)
3520
- return this.readBodyInput(source, maxBodyBytes, finish, wrapResponse, (err) =>
3521
- logError(err, ctx, wrapResponse),
3585
+ return readBodyFramed(
3586
+ source,
3587
+ maxBodyBytes,
3588
+ this.protoPoisoning,
3589
+ finish,
3590
+ wrapResponse,
3591
+ (err) => logError(err, ctx, finalize, wrapResponse),
3522
3592
  )
3523
3593
  }
3524
3594
  }
@@ -3560,13 +3630,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3560
3630
  querySchema: StandardSchemaV1,
3561
3631
  maxBodyBytes: number,
3562
3632
  ): FusedWebRunner {
3563
- // The fused lanes return to the runtime directly, so the framework's own error renders fold in
3564
- // static headers here rather than through the shared `wrapResponse` seam.
3565
- const logError = (err: unknown, ctx: RawContext): Response => {
3566
- if (err instanceof Response) return this.wrapWebResponse(err)
3567
- this.logRequestError(err, ctx)
3568
- return this.wrapWebResponse(jsonError(500, "internal_error"))
3569
- }
3633
+ // Same thrown-value contract as the generic lane (`bare-error-lane.ts` via `bareError`); the fused
3634
+ // lane only injects its renderer, so a thrown `status(...)` and the flat 500 fold in static headers
3635
+ // through `wrapWebResponse`. One source of truth for the error surface, no per-lane copy to drift.
3636
+ const logError = (err: unknown, ctx: RawContext): Response =>
3637
+ this.bareError(
3638
+ err,
3639
+ ctx,
3640
+ (result, set) => this.wrapWebResponse(toResponse(result, set)),
3641
+ this.wrapWebResponse,
3642
+ )
3570
3643
  const runHandler = (ctx: RawContext, value: unknown): MaybePromise<Response> => {
3571
3644
  ctx.query = value
3572
3645
  let result: unknown
@@ -3620,21 +3693,29 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3620
3693
  return validation.then(
3621
3694
  (settled) =>
3622
3695
  settled.issues !== undefined
3623
- ? this.wrapWebResponse(validationError(settled.issues))
3696
+ ? this.wrapWebResponse(plainValidationError(settled.issues))
3624
3697
  : runHandler(ctx, settled.value),
3625
3698
  (err) => logError(err, ctx),
3626
3699
  )
3627
3700
  }
3628
3701
  if (validation.issues !== undefined)
3629
- return this.wrapWebResponse(validationError(validation.issues))
3702
+ return this.wrapWebResponse(plainValidationError(validation.issues))
3630
3703
  return runHandler(ctx, validation.value)
3631
3704
  }
3632
3705
  }
3633
3706
 
3634
- private bareError<T>(err: unknown, ctx: RawContext, wrapResponse: (response: Response) => T): T {
3635
- if (err instanceof Response) return wrapResponse(err)
3636
- this.logRequestError(err, ctx)
3637
- return wrapResponse(jsonError(500, "internal_error"))
3707
+ private bareError<T>(
3708
+ err: unknown,
3709
+ ctx: RawContext,
3710
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
3711
+ wrapResponse: (response: Response | ResponseResult) => T,
3712
+ ): T {
3713
+ // The thrown-value contract lives in `bare-error-lane.ts`, one source of truth for both this
3714
+ // lane and the fused body runner. `responseSet` and the logger are injected because they reach
3715
+ // into class state; the closure is allocated only on the throw path, never on the hot lane.
3716
+ return renderBareError(err, ctx, finalize, wrapResponse, responseSet, (e, c) =>
3717
+ this.logRequestError(e, c),
3718
+ )
3638
3719
  }
3639
3720
 
3640
3721
  // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
@@ -3644,7 +3725,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3644
3725
  ctx: RawContext,
3645
3726
  run: () => MaybePromise<T>,
3646
3727
  finalize: (result: unknown, set: CtxSet) => T,
3647
- wrapResponse: (response: Response) => T,
3728
+ wrapResponse: (response: Response | ResponseResult) => T,
3648
3729
  ): MaybePromise<T> {
3649
3730
  let outcome: MaybePromise<T>
3650
3731
  try {
@@ -3682,52 +3763,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3682
3763
  source: RequestSource,
3683
3764
  ctx: RawContext,
3684
3765
  finalize: (result: unknown, set: CtxSet) => T,
3685
- wrapResponse: (response: Response) => T,
3766
+ wrapResponse: (response: Response | ResponseResult) => T,
3686
3767
  ): Promise<T> {
3687
- return this.readBodyInput(
3768
+ return readBodyFramed(
3688
3769
  source,
3689
- entry.bodyLimit ?? this.maxBodyBytes,
3770
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3771
+ this.protoPoisoning,
3690
3772
  (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
3691
3773
  wrapResponse,
3692
3774
  (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3693
3775
  )
3694
3776
  }
3695
3777
 
3696
- /** Shared bounded body framing/parser. Both body lanes use the same trust-boundary checks; only
3697
- * the validation + handler continuation differs. */
3698
- private readBodyInput<T>(
3699
- source: RequestSource,
3700
- maxBodyBytes: number,
3701
- onParsed: (parsed: unknown) => MaybePromise<T>,
3702
- wrapResponse: (response: Response) => T,
3703
- onError: (err: unknown) => MaybePromise<T>,
3704
- ): Promise<T> {
3705
- const contentType = headerOf(source, "content-type") ?? ""
3706
- if (contentType !== "application/json" && !contentType.includes("application/json")) {
3707
- if (isUrlEncodedForm(contentType)) {
3708
- return readBoundedForm(source, maxBodyBytes).then(
3709
- (form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)),
3710
- onError,
3711
- ) as Promise<T>
3712
- }
3713
- return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")))
3714
- }
3715
-
3716
- // All JSON bodies go through readBoundedJson - the single enforcement point for the framed
3717
- // fast path (post-read byte re-check), the streaming cap, and the prototype-poisoning guard.
3718
- // A separate inlined native-json() fast path here would fork the trust boundary in two.
3719
- try {
3720
- return this.readBoundedJson(
3721
- source,
3722
- maxBodyBytes,
3723
- (parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)),
3724
- onError,
3725
- )
3726
- } catch (err) {
3727
- return Promise.resolve(onError(err))
3728
- }
3729
- }
3730
-
3731
3778
  /** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
3732
3779
  * streaming fallback. A method (not per-request closures) so the hot path allocates nothing
3733
3780
  * beyond the one `.then` continuation. */
@@ -3736,7 +3783,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3736
3783
  parsed: unknown,
3737
3784
  ctx: RawContext,
3738
3785
  finalize: (result: unknown, set: CtxSet) => T,
3739
- wrapResponse: (response: Response) => T,
3786
+ wrapResponse: (response: Response | ResponseResult) => T,
3740
3787
  ): MaybePromise<T> {
3741
3788
  try {
3742
3789
  const bodySchema = entry.schema!.body!
@@ -3771,7 +3818,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3771
3818
  entry: RouteEntry,
3772
3819
  ctx: RawContext,
3773
3820
  finalize: (result: unknown, set: CtxSet) => T,
3774
- wrapResponse?: (response: Response) => T,
3821
+ wrapResponse?: (response: Response | ResponseResult) => T,
3775
3822
  ): MaybePromise<T> {
3776
3823
  if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
3777
3824
  const handlerOutput = entry.handler(ctx)
@@ -3794,7 +3841,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3794
3841
  recovery: unknown,
3795
3842
  ctx: RawContext,
3796
3843
  finalize: (result: unknown, set: CtxSet) => T,
3797
- wrapResponse: (response: Response) => T,
3844
+ wrapResponse: (response: Response | ResponseResult) => T,
3798
3845
  originalIssues: ReadonlyArray<StandardIssue>,
3799
3846
  kind: "body" | "query",
3800
3847
  ): MaybePromise<T> {
@@ -3806,12 +3853,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3806
3853
  const validation = entry.schema.body["~standard"].validate(recovery)
3807
3854
  if (validation instanceof Promise) {
3808
3855
  return validation.then((settled) => {
3809
- if (settled.issues !== undefined) return wrapResponse(validationError(settled.issues))
3856
+ if (settled.issues !== undefined)
3857
+ return wrapResponse(plainValidationError(settled.issues))
3810
3858
  ctx.body = settled.value
3811
3859
  return this.executeHandler(entry, ctx, finalize)
3812
3860
  })
3813
3861
  }
3814
- if (validation.issues !== undefined) return wrapResponse(validationError(validation.issues))
3862
+ if (validation.issues !== undefined)
3863
+ return wrapResponse(plainValidationError(validation.issues))
3815
3864
  ctx.body = validation.value
3816
3865
  return this.executeHandler(entry, ctx, finalize)
3817
3866
  }
@@ -3820,19 +3869,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3820
3869
  if (validation instanceof Promise) {
3821
3870
  return validation.then(
3822
3871
  (settled) => {
3823
- if (settled.issues !== undefined) return wrapResponse(validationError(settled.issues))
3872
+ if (settled.issues !== undefined)
3873
+ return wrapResponse(plainValidationError(settled.issues))
3824
3874
  ctx.query = settled.value
3825
3875
  return this.executeHandler(entry, ctx, finalize, wrapResponse)
3826
3876
  },
3827
3877
  (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3828
3878
  )
3829
3879
  }
3830
- if (validation.issues !== undefined) return wrapResponse(validationError(validation.issues))
3880
+ if (validation.issues !== undefined)
3881
+ return wrapResponse(plainValidationError(validation.issues))
3831
3882
  ctx.query = validation.value
3832
3883
  return this.executeHandler(entry, ctx, finalize, wrapResponse)
3833
3884
  }
3834
3885
  }
3835
- return wrapResponse(validationError(originalIssues))
3886
+ return wrapResponse(plainValidationError(originalIssues))
3836
3887
  }
3837
3888
 
3838
3889
  private applyBodyValidation<T>(
@@ -3840,7 +3891,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3840
3891
  result: StandardResult<unknown>,
3841
3892
  ctx: RawContext,
3842
3893
  finalize: (result: unknown, set: CtxSet) => T,
3843
- wrapResponse: (response: Response) => T,
3894
+ wrapResponse: (response: Response | ResponseResult) => T,
3844
3895
  ): MaybePromise<T> {
3845
3896
  if (result.issues !== undefined) {
3846
3897
  const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
@@ -3869,7 +3920,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3869
3920
  "body",
3870
3921
  )
3871
3922
  }
3872
- return wrapResponse(validationError(result.issues))
3923
+ return wrapResponse(plainValidationError(result.issues))
3873
3924
  }
3874
3925
  ctx.body = result.value
3875
3926
  return this.executeHandler(entry, ctx, finalize)
@@ -3881,7 +3932,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3881
3932
  entry: RouteEntry,
3882
3933
  ctx: RawContext,
3883
3934
  finalize: (result: unknown, set: CtxSet) => T,
3884
- wrapResponse: (response: Response) => T,
3935
+ wrapResponse: (response: Response | ResponseResult) => T,
3885
3936
  ): MaybePromise<T> {
3886
3937
  try {
3887
3938
  // Call the validator directly for the raw StandardResult (read `.issues`/`.value`) - skip
@@ -3919,7 +3970,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3919
3970
  result: StandardResult<unknown>,
3920
3971
  ctx: RawContext,
3921
3972
  finalize: (result: unknown, set: CtxSet) => T,
3922
- wrapResponse: (response: Response) => T,
3973
+ wrapResponse: (response: Response | ResponseResult) => T,
3923
3974
  ): MaybePromise<T> {
3924
3975
  if (result.issues !== undefined) {
3925
3976
  const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
@@ -3948,7 +3999,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3948
3999
  "query",
3949
4000
  )
3950
4001
  }
3951
- return wrapResponse(validationError(result.issues))
4002
+ return wrapResponse(plainValidationError(result.issues))
3952
4003
  }
3953
4004
  ctx.query = result.value
3954
4005
  return this.executeHandler(entry, ctx, finalize, wrapResponse)
@@ -4090,7 +4141,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4090
4141
  source: RequestSource,
4091
4142
  ctx: RawContext,
4092
4143
  finalize: (result: unknown, set: CtxSet) => T,
4093
- wrapResponse: (response: Response) => T,
4144
+ wrapResponse: (response: Response | ResponseResult) => T,
4094
4145
  ): MaybePromise<T> {
4095
4146
  try {
4096
4147
  const headersSchema = entry.schema?.headers
@@ -4163,7 +4214,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4163
4214
  source: RequestSource,
4164
4215
  ctx: RawContext,
4165
4216
  finalize: (result: unknown, set: CtxSet) => T,
4166
- wrapResponse: (response: Response) => T,
4217
+ wrapResponse: (response: Response | ResponseResult) => T,
4167
4218
  result: StandardResult<unknown>,
4168
4219
  ): MaybePromise<T> {
4169
4220
  try {
@@ -4190,7 +4241,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4190
4241
  source: RequestSource,
4191
4242
  ctx: RawContext,
4192
4243
  finalize: (result: unknown, set: CtxSet) => T,
4193
- wrapResponse: (response: Response) => T,
4244
+ wrapResponse: (response: Response | ResponseResult) => T,
4194
4245
  ): MaybePromise<T> {
4195
4246
  const paramsSchema = entry.schema?.params
4196
4247
  if (paramsSchema === undefined) {
@@ -4221,7 +4272,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4221
4272
  entry: RouteEntry,
4222
4273
  ctx: RawContext,
4223
4274
  finalize: (result: unknown, set: CtxSet) => T,
4224
- wrapResponse: (response: Response) => T,
4275
+ wrapResponse: (response: Response | ResponseResult) => T,
4225
4276
  ): MaybePromise<T> {
4226
4277
  try {
4227
4278
  // The execution plan only selects this runner when the route has a query schema. Keeping the
@@ -4255,7 +4306,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4255
4306
  source: RequestSource,
4256
4307
  ctx: RawContext,
4257
4308
  finalize: (result: unknown, set: CtxSet) => T,
4258
- wrapResponse: (response: Response) => T,
4309
+ wrapResponse: (response: Response | ResponseResult) => T,
4259
4310
  ): MaybePromise<T> {
4260
4311
  return this.readAndValidateBody(source, entry, ctx).then(
4261
4312
  (bodyError) =>
@@ -4274,7 +4325,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4274
4325
  source: RequestSource,
4275
4326
  ctx: RawContext,
4276
4327
  finalize: (result: unknown, set: CtxSet) => T,
4277
- wrapResponse: (response: Response) => T,
4328
+ wrapResponse: (response: Response | ResponseResult) => T,
4278
4329
  ): MaybePromise<T> {
4279
4330
  return this.readAndValidateBody(source, entry, ctx).then(
4280
4331
  (bodyError) =>
@@ -4290,7 +4341,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4290
4341
  entry: RouteEntry,
4291
4342
  ctx: RawContext,
4292
4343
  finalize: (result: unknown, set: CtxSet) => T,
4293
- wrapResponse: (response: Response) => T,
4344
+ wrapResponse: (response: Response | ResponseResult) => T,
4294
4345
  ): MaybePromise<T> {
4295
4346
  const querySchema = entry.schema?.query
4296
4347
  return querySchema === undefined
@@ -4302,7 +4353,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4302
4353
  entry: RouteEntry,
4303
4354
  ctx: RawContext,
4304
4355
  finalize: (result: unknown, set: CtxSet) => T,
4305
- wrapResponse: (response: Response) => T,
4356
+ wrapResponse: (response: Response | ResponseResult) => T,
4306
4357
  result: StandardResult<unknown>,
4307
4358
  ): MaybePromise<T> {
4308
4359
  const queryError = this.applyLifecycleValidation(entry, result, ctx, "query")
@@ -4324,7 +4375,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4324
4375
  source: RequestSource,
4325
4376
  ctx: RawContext,
4326
4377
  finalize: (result: unknown, set: CtxSet) => T,
4327
- wrapResponse: (response: Response) => T,
4378
+ wrapResponse: (response: Response | ResponseResult) => T,
4328
4379
  result: StandardResult<unknown>,
4329
4380
  ): MaybePromise<T> {
4330
4381
  try {
@@ -4346,8 +4397,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4346
4397
  source: RequestSource,
4347
4398
  ctx: RawContext,
4348
4399
  finalize: (result: unknown, set: CtxSet) => T,
4349
- wrapResponse: (response: Response) => T,
4350
- paramsError: MaybePromise<Response | undefined>,
4400
+ wrapResponse: (response: Response | ResponseResult) => T,
4401
+ paramsError: MaybePromise<Response | ResponseResult | undefined>,
4351
4402
  ): MaybePromise<T> {
4352
4403
  if (paramsError instanceof Promise) {
4353
4404
  return paramsError.then(
@@ -4371,8 +4422,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4371
4422
  source: RequestSource,
4372
4423
  ctx: RawContext,
4373
4424
  finalize: (result: unknown, set: CtxSet) => T,
4374
- wrapResponse: (response: Response) => T,
4375
- paramsError: Response | undefined,
4425
+ wrapResponse: (response: Response | ResponseResult) => T,
4426
+ paramsError: Response | ResponseResult | undefined,
4376
4427
  ): MaybePromise<T> {
4377
4428
  if (paramsError !== undefined) return wrapResponse(paramsError)
4378
4429
  if (entry.schema?.body !== undefined) {
@@ -4388,8 +4439,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4388
4439
  entry: RouteEntry,
4389
4440
  ctx: RawContext,
4390
4441
  finalize: (result: unknown, set: CtxSet) => T,
4391
- wrapResponse: (response: Response) => T,
4392
- bodyError: Response | undefined,
4442
+ wrapResponse: (response: Response | ResponseResult) => T,
4443
+ bodyError: Response | ResponseResult | undefined,
4393
4444
  ): MaybePromise<T> {
4394
4445
  if (bodyError !== undefined) return wrapResponse(bodyError)
4395
4446
  const querySchema = entry.schema?.query
@@ -4408,7 +4459,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4408
4459
  entry: RouteEntry,
4409
4460
  ctx: RawContext,
4410
4461
  finalize: (result: unknown, set: CtxSet) => T,
4411
- wrapResponse: (response: Response) => T,
4462
+ wrapResponse: (response: Response | ResponseResult) => T,
4412
4463
  result: StandardResult<unknown>,
4413
4464
  ): MaybePromise<T> {
4414
4465
  try {
@@ -4429,8 +4480,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4429
4480
  entry: RouteEntry,
4430
4481
  ctx: RawContext,
4431
4482
  finalize: (result: unknown, set: CtxSet) => T,
4432
- wrapResponse: (response: Response) => T,
4433
- queryError: Response | undefined,
4483
+ wrapResponse: (response: Response | ResponseResult) => T,
4484
+ queryError: Response | ResponseResult | undefined,
4434
4485
  ): MaybePromise<T> {
4435
4486
  if (queryError !== undefined) return wrapResponse(queryError)
4436
4487
  return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
@@ -4440,7 +4491,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4440
4491
  entry: RouteEntry,
4441
4492
  ctx: RawContext,
4442
4493
  finalize: (result: unknown, set: CtxSet) => T,
4443
- wrapResponse: (response: Response) => T,
4494
+ wrapResponse: (response: Response | ResponseResult) => T,
4444
4495
  ): MaybePromise<T> {
4445
4496
  if (entry.lifecycleHookLane === "derive-before") {
4446
4497
  return this.runLifecycleDeriveBefore(entry, ctx, finalize, wrapResponse)
@@ -4452,6 +4503,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4452
4503
  if (extension instanceof Promise) {
4453
4504
  return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, i, extension)
4454
4505
  }
4506
+ if (isDeriveEarlyExit(extension)) return finalize(extension, responseSet(ctx))
4455
4507
  Object.assign(ctx, extension)
4456
4508
  }
4457
4509
  for (let i = 0; i < entry.beforeHandle.length; i++) {
@@ -4479,13 +4531,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4479
4531
  entry: RouteEntry,
4480
4532
  ctx: RawContext,
4481
4533
  finalize: (result: unknown, set: CtxSet) => T,
4482
- wrapResponse: (response: Response) => T,
4534
+ wrapResponse: (response: Response | ResponseResult) => T,
4483
4535
  ): MaybePromise<T> {
4484
4536
  try {
4485
4537
  const extension = entry.derives[0]!(ctx)
4486
4538
  if (extension instanceof Promise) {
4487
4539
  return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, 0, extension)
4488
4540
  }
4541
+ if (isDeriveEarlyExit(extension)) return finalize(extension, responseSet(ctx))
4489
4542
  Object.assign(ctx, extension)
4490
4543
 
4491
4544
  const outcome = entry.beforeHandle[0]!(ctx)
@@ -4513,7 +4566,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4513
4566
  entry: RouteEntry,
4514
4567
  ctx: RawContext,
4515
4568
  finalize: (result: unknown, set: CtxSet) => T,
4516
- wrapResponse: (response: Response) => T,
4569
+ wrapResponse: (response: Response | ResponseResult) => T,
4517
4570
  result: unknown,
4518
4571
  ): MaybePromise<T> {
4519
4572
  try {
@@ -4527,15 +4580,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4527
4580
  entry: RouteEntry,
4528
4581
  ctx: RawContext,
4529
4582
  finalize: (result: unknown, set: CtxSet) => T,
4530
- wrapResponse: (response: Response) => T,
4583
+ wrapResponse: (response: Response | ResponseResult) => T,
4531
4584
  firstIndex: number,
4532
4585
  first: Promise<unknown>,
4533
4586
  ): Promise<T> {
4534
4587
  try {
4535
- Object.assign(ctx, await first)
4588
+ const settled = await first
4589
+ if (isDeriveEarlyExit(settled)) return finalize(settled, responseSet(ctx))
4590
+ Object.assign(ctx, settled)
4536
4591
  for (let i = firstIndex + 1; i < entry.derives.length; i++) {
4537
- const extension = entry.derives[i]!(ctx)
4538
- Object.assign(ctx, extension instanceof Promise ? await extension : extension)
4592
+ const raw = entry.derives[i]!(ctx)
4593
+ const extension = raw instanceof Promise ? await raw : raw
4594
+ if (isDeriveEarlyExit(extension)) return finalize(extension, responseSet(ctx))
4595
+ Object.assign(ctx, extension)
4539
4596
  }
4540
4597
  return await this.runLifecycleHooksAsync(entry, ctx, finalize, wrapResponse)
4541
4598
  } catch (err) {
@@ -4547,7 +4604,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4547
4604
  entry: RouteEntry,
4548
4605
  ctx: RawContext,
4549
4606
  finalize: (result: unknown, set: CtxSet) => T,
4550
- wrapResponse: (response: Response) => T,
4607
+ wrapResponse: (response: Response | ResponseResult) => T,
4551
4608
  ): Promise<T> {
4552
4609
  try {
4553
4610
  // Indexed, not `for...of`. An array iterator declared inside an async function stays live
@@ -4569,7 +4626,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4569
4626
  entry: RouteEntry,
4570
4627
  ctx: RawContext,
4571
4628
  finalize: (result: unknown, set: CtxSet) => T,
4572
- wrapResponse: (response: Response) => T,
4629
+ wrapResponse: (response: Response | ResponseResult) => T,
4573
4630
  firstIndex: number,
4574
4631
  first: Promise<unknown>,
4575
4632
  ): Promise<T> {
@@ -4591,7 +4648,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4591
4648
  entry: RouteEntry,
4592
4649
  ctx: RawContext,
4593
4650
  finalize: (result: unknown, set: CtxSet) => T,
4594
- wrapResponse: (response: Response) => T,
4651
+ wrapResponse: (response: Response | ResponseResult) => T,
4595
4652
  ): Promise<T> {
4596
4653
  try {
4597
4654
  const output = entry.handler(ctx)
@@ -4606,7 +4663,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4606
4663
  entry: RouteEntry,
4607
4664
  ctx: RawContext,
4608
4665
  finalize: (result: unknown, set: CtxSet) => T,
4609
- wrapResponse: (response: Response) => T,
4666
+ wrapResponse: (response: Response | ResponseResult) => T,
4610
4667
  initial: unknown,
4611
4668
  ): MaybePromise<T> {
4612
4669
  try {
@@ -4635,7 +4692,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4635
4692
  entry: RouteEntry,
4636
4693
  ctx: RawContext,
4637
4694
  finalize: (result: unknown, set: CtxSet) => T,
4638
- wrapResponse: (response: Response) => T,
4695
+ wrapResponse: (response: Response | ResponseResult) => T,
4639
4696
  firstIndex: number,
4640
4697
  first: Promise<unknown>,
4641
4698
  ): Promise<T> {
@@ -4655,7 +4712,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4655
4712
  entry: RouteEntry,
4656
4713
  ctx: RawContext,
4657
4714
  finalize: (result: unknown, set: CtxSet) => T,
4658
- wrapResponse: (response: Response) => T,
4715
+ wrapResponse: (response: Response | ResponseResult) => T,
4659
4716
  result: unknown,
4660
4717
  ): MaybePromise<T> {
4661
4718
  try {
@@ -4677,7 +4734,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4677
4734
  private finishContractOutcome<T>(
4678
4735
  ctx: RawContext,
4679
4736
  finalize: (result: unknown, set: CtxSet) => T,
4680
- wrapResponse: (response: Response) => T,
4737
+ wrapResponse: (response: Response | ResponseResult) => T,
4681
4738
  outcome: {
4682
4739
  readonly kind: "ok" | "warn" | "violation"
4683
4740
  readonly value?: unknown
@@ -4690,7 +4747,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4690
4747
  path: pathnameOf(ctx.req.url),
4691
4748
  detail: outcome.message,
4692
4749
  })
4693
- return wrapResponse(jsonError(500, "internal_error"))
4750
+ return wrapResponse(plainError(500, "internal_error"))
4694
4751
  }
4695
4752
  if (outcome.kind === "warn") {
4696
4753
  this.logger.warn("response contract", {
@@ -4702,19 +4759,44 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4702
4759
  return finalize(outcome.value, responseSet(ctx))
4703
4760
  }
4704
4761
 
4705
- private async handleLifecycleError<T>(
4762
+ /** Synchronous by design, and `MaybePromise` rather than `Promise` for the same reason: control flow
4763
+ * out of a lifecycle stage must not cost a promise. An `async` method allocates one and defers the
4764
+ * result a microtask even when every branch it takes returns immediately, which is the common case
4765
+ * here - a thrown `status(...)`/`Response`, or a route with no `onError` hook at all. Only the hook
4766
+ * loop, which may await, drops into {@link runErrorHooks}. */
4767
+ private handleLifecycleError<T>(
4706
4768
  entry: RouteEntry,
4707
4769
  err: unknown,
4708
4770
  ctx: RawContext,
4709
4771
  finalize: (result: unknown, set: CtxSet) => T,
4710
- wrapResponse: (response: Response) => T,
4711
- ): Promise<T> {
4772
+ wrapResponse: (response: Response | ResponseResult) => T,
4773
+ ): MaybePromise<T> {
4712
4774
  // A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
4713
4775
  // an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
4714
4776
  // or log it as a 500. This is what makes `throw redirect(...)` / `requireSession(...)` work from
4715
4777
  // any handler or loader.
4716
4778
  if (err instanceof Response) return wrapResponse(err)
4717
- // onError hooks may return a custom response; otherwise the default 500 stands.
4779
+ // Same rule for a thrown `status(...)`, but through `finalize` rather than `wrapResponse`: the
4780
+ // value is still plain data, so the ordinary JSON lane renders it and no `Response` is built.
4781
+ if (isResponseResult(err)) return finalize(err, responseSet(ctx))
4782
+ if (entry.onError.length === 0) {
4783
+ // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
4784
+ // (redacting) logger. Body-read failures and around-hook failures land here too.
4785
+ this.logRequestError(err, ctx)
4786
+ return wrapResponse(plainError(500, "internal_error"))
4787
+ }
4788
+ return this.runErrorHooks(entry, err, ctx, finalize, wrapResponse)
4789
+ }
4790
+
4791
+ /** The `onError` chain, split off {@link handleLifecycleError} so its `await` costs only the routes
4792
+ * that registered a hook. A hook may return a custom response; otherwise the default 500 stands. */
4793
+ private async runErrorHooks<T>(
4794
+ entry: RouteEntry,
4795
+ err: unknown,
4796
+ ctx: RawContext,
4797
+ finalize: (result: unknown, set: CtxSet) => T,
4798
+ wrapResponse: (response: Response | ResponseResult) => T,
4799
+ ): Promise<T> {
4718
4800
  // Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
4719
4801
  // the `await` and stay heap-allocated on both engines.
4720
4802
  for (let i = 0; i < entry.onError.length; i++) {
@@ -4722,51 +4804,35 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4722
4804
  const handled = outcome instanceof Promise ? await outcome : outcome
4723
4805
  if (handled !== undefined) return finalize(handled, responseSet(ctx))
4724
4806
  }
4725
- // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
4726
- // (redacting) logger. Body-read failures and around-hook failures land here too.
4727
4807
  this.logRequestError(err, ctx)
4728
- return wrapResponse(jsonError(500, "internal_error"))
4808
+ return wrapResponse(plainError(500, "internal_error"))
4729
4809
  }
4730
4810
 
4731
4811
  /** Log an unhandled request error to the (redacting) logger - shared by {@link runLifecycle} and the
4732
4812
  * bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
4733
4813
  private logRequestError(err: unknown, ctx: RawContext): void {
4734
- // An error's own text can quote the input that produced it, so how much of it reaches the sink is
4735
- // the app's call (`errorLogDetail`). The default keeps it: a 500 with no message and no stack is
4736
- // an incident nobody can diagnose, and the framework already ships the narrower instrument for
4737
- // the leak - a redacting logger with `valuePatterns`. `"none"` is there for an untrusted sink.
4738
- const detail = this.errorLogDetail
4739
- this.logger.error("unhandled request error", {
4740
- method: ctx.req.method,
4741
- path: pathnameOf(ctx.req.url),
4742
- name: err instanceof Error ? err.name : "Error",
4743
- // `detail`, not `message`: the logger uses `message` for its own first argument, so a field of
4744
- // that name is silently overwritten and the thrown error's own text never reaches the sink. It
4745
- // survived only incidentally inside `stack`, and was lost outright for a non-Error throw.
4746
- ...(detail === "none" ? {} : { detail: err instanceof Error ? err.message : String(err) }),
4747
- ...(detail === "full" && err instanceof Error ? { stack: err.stack } : {}),
4748
- })
4814
+ emitRequestErrorLog(this.logger, this.errorLogDetail, err, ctx)
4749
4815
  }
4750
4816
 
4751
4817
  private async readAndValidateBody(
4752
4818
  req: RequestSource,
4753
4819
  entry: RouteEntry,
4754
4820
  ctx: RawContext,
4755
- ): Promise<Response | undefined> {
4821
+ ): Promise<Response | ResponseResult | undefined> {
4756
4822
  const contentType = headerOf(req, "content-type") ?? ""
4757
4823
  let parsed: unknown
4758
4824
  if (contentType === "application/json" || contentType.includes("application/json")) {
4759
- const json = await this.readBoundedJson(req, entry.bodyLimit ?? this.maxBodyBytes)
4760
- if (json instanceof Response) return json
4825
+ const json = await this.readBoundedJson(req, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
4826
+ if (isResponseResult(json)) return json
4761
4827
  parsed = json
4762
4828
  } else if (isUrlEncodedForm(contentType)) {
4763
- const form = await readBoundedForm(req, entry.bodyLimit ?? this.maxBodyBytes)
4764
- if (form instanceof Response) return form
4829
+ const form = await readBoundedForm(req, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
4830
+ if (isResponseResult(form)) return form
4765
4831
  parsed = form
4766
4832
  } else {
4767
4833
  // multipart/form-data (file uploads) stays 415 on the schema path by design - files don't
4768
4834
  // fit a value schema; use a schema-less route + @nifrajs/uploads helpers for those.
4769
- return jsonError(415, "unsupported_media_type")
4835
+ return plainError(415, "unsupported_media_type")
4770
4836
  }
4771
4837
  const validation = entry.schema!.body!["~standard"].validate(parsed)
4772
4838
  const result = validation instanceof Promise ? await validation : validation
@@ -4780,7 +4846,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4780
4846
  result: StandardResult<unknown>,
4781
4847
  ctx: RawContext,
4782
4848
  kind: "body" | "query" | "params" | "headers",
4783
- ): MaybePromise<Response | undefined> {
4849
+ ): MaybePromise<Response | ResponseResult | undefined> {
4784
4850
  const assign = (value: unknown): void => {
4785
4851
  if (kind === "body") ctx.body = value
4786
4852
  else if (kind === "query") ctx.query = value
@@ -4792,7 +4858,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4792
4858
  return undefined
4793
4859
  }
4794
4860
  const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
4795
- if (hook === undefined) return validationError(result.issues)
4861
+ if (hook === undefined) return plainValidationError(result.issues)
4796
4862
  const attempted = hook(result.issues, ctx as unknown as Context, kind)
4797
4863
  if (attempted instanceof Promise) {
4798
4864
  return attempted.then((recovery) =>
@@ -4808,8 +4874,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4808
4874
  issues: ReadonlyArray<StandardIssue>,
4809
4875
  recovery: unknown,
4810
4876
  assign: (value: unknown) => void,
4811
- ): MaybePromise<Response | undefined> {
4812
- if (recovery === undefined) return validationError(issues)
4877
+ ): MaybePromise<Response | ResponseResult | undefined> {
4878
+ if (recovery === undefined) return plainValidationError(issues)
4813
4879
  if (recovery instanceof Response) return recovery
4814
4880
  const schema =
4815
4881
  kind === "body"
@@ -4822,12 +4888,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4822
4888
  const retried = schema!["~standard"].validate(recovery)
4823
4889
  if (retried instanceof Promise) {
4824
4890
  return retried.then((settled) => {
4825
- if (settled.issues !== undefined) return validationError(settled.issues)
4891
+ if (settled.issues !== undefined) return plainValidationError(settled.issues)
4826
4892
  assign(settled.value)
4827
4893
  return undefined
4828
4894
  })
4829
4895
  }
4830
- if (retried.issues !== undefined) return validationError(retried.issues)
4896
+ if (retried.issues !== undefined) return plainValidationError(retried.issues)
4831
4897
  assign(retried.value)
4832
4898
  return undefined
4833
4899
  }
@@ -4839,19 +4905,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4839
4905
  * length-less body mid-stream once the running byte count exceeds the cap - so a lying
4840
4906
  * or absent length can't force us to buffer an oversized payload.
4841
4907
  */
4842
- private readBoundedJson(req: RequestSource, maxBodyBytes?: number): Promise<unknown | Response>
4908
+ private readBoundedJson(
4909
+ req: RequestSource,
4910
+ maxBodyBytes?: number,
4911
+ ): Promise<unknown | ResponseResult>
4843
4912
  private readBoundedJson<T>(
4844
4913
  req: RequestSource,
4845
4914
  maxBodyBytes: number,
4846
- onResult: (parsed: unknown | Response) => T | Promise<T>,
4915
+ onResult: (parsed: unknown | ResponseResult) => T | Promise<T>,
4847
4916
  onError: (error: unknown) => T | Promise<T>,
4848
4917
  ): Promise<T>
4849
4918
  private readBoundedJson<T>(
4850
4919
  req: RequestSource,
4851
4920
  maxBodyBytes = this.maxBodyBytes,
4852
- onResult?: (parsed: unknown | Response) => T | Promise<T>,
4921
+ onResult?: (parsed: unknown | ResponseResult) => T | Promise<T>,
4853
4922
  onError?: (error: unknown) => T | Promise<T>,
4854
- ): Promise<unknown | Response | T> {
4923
+ ): Promise<unknown | ResponseResult | T> {
4855
4924
  // Not `async`: this is a pass-through, and wrapping the lane's own promise in a coroutine
4856
4925
  // frame costs an extra microtask hop on every JSON body.
4857
4926
  return onResult === undefined