@nifrajs/core 2.13.0 → 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 (177) 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/binary.d.ts +32 -0
  7. package/dist/binary.d.ts.map +1 -1
  8. package/dist/binary.js +17 -0
  9. package/dist/binary.js.map +1 -1
  10. package/dist/durable-adapters.d.ts.map +1 -1
  11. package/dist/durable-adapters.js +21 -50
  12. package/dist/durable-adapters.js.map +1 -1
  13. package/dist/durable-execution.d.ts.map +1 -1
  14. package/dist/durable-execution.js +49 -69
  15. package/dist/durable-execution.js.map +1 -1
  16. package/dist/edge-kit.d.ts +27 -0
  17. package/dist/edge-kit.d.ts.map +1 -0
  18. package/dist/edge-kit.js +24 -0
  19. package/dist/edge-kit.js.map +1 -0
  20. package/dist/errors.d.ts +1 -1
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/internal/durable-approval-protocol.d.ts +13 -0
  28. package/dist/internal/durable-approval-protocol.d.ts.map +1 -0
  29. package/dist/internal/durable-approval-protocol.js +66 -0
  30. package/dist/internal/durable-approval-protocol.js.map +1 -0
  31. package/dist/internal/durable-effect-protocol.d.ts +14 -0
  32. package/dist/internal/durable-effect-protocol.d.ts.map +1 -0
  33. package/dist/internal/durable-effect-protocol.js +16 -0
  34. package/dist/internal/durable-effect-protocol.js.map +1 -0
  35. package/dist/internal/durable-saga-protocol.d.ts +8 -0
  36. package/dist/internal/durable-saga-protocol.d.ts.map +1 -0
  37. package/dist/internal/durable-saga-protocol.js +21 -0
  38. package/dist/internal/durable-saga-protocol.js.map +1 -0
  39. package/dist/internal/route-catalog.d.ts +3 -0
  40. package/dist/internal/route-catalog.d.ts.map +1 -1
  41. package/dist/internal/route-catalog.js +5 -0
  42. package/dist/internal/route-catalog.js.map +1 -1
  43. package/dist/internal/route-execution.d.ts +11 -5
  44. package/dist/internal/route-execution.d.ts.map +1 -1
  45. package/dist/internal/route-execution.js +2 -1
  46. package/dist/internal/route-execution.js.map +1 -1
  47. package/dist/response-observer.d.ts +25 -0
  48. package/dist/response-observer.d.ts.map +1 -0
  49. package/dist/response-observer.js +40 -0
  50. package/dist/response-observer.js.map +1 -0
  51. package/dist/security/baseline.d.ts +68 -0
  52. package/dist/security/baseline.d.ts.map +1 -0
  53. package/dist/security/baseline.js +118 -0
  54. package/dist/security/baseline.js.map +1 -0
  55. package/dist/server/bare-error-lane.d.ts +26 -0
  56. package/dist/server/bare-error-lane.d.ts.map +1 -0
  57. package/dist/server/bare-error-lane.js +60 -0
  58. package/dist/server/bare-error-lane.js.map +1 -0
  59. package/dist/server/body.d.ts +7 -0
  60. package/dist/server/body.d.ts.map +1 -1
  61. package/dist/server/body.js +7 -0
  62. package/dist/server/body.js.map +1 -1
  63. package/dist/server/bridge-markers.d.ts +25 -0
  64. package/dist/server/bridge-markers.d.ts.map +1 -0
  65. package/dist/server/bridge-markers.js +25 -0
  66. package/dist/server/bridge-markers.js.map +1 -0
  67. package/dist/server/context.d.ts +6 -1
  68. package/dist/server/context.d.ts.map +1 -1
  69. package/dist/server/header-case.d.ts.map +1 -1
  70. package/dist/server/header-case.js +2 -1
  71. package/dist/server/header-case.js.map +1 -1
  72. package/dist/server/http.d.ts +36 -3
  73. package/dist/server/http.d.ts.map +1 -1
  74. package/dist/server/http.js +49 -3
  75. package/dist/server/http.js.map +1 -1
  76. package/dist/server/install.d.ts +6 -8
  77. package/dist/server/install.d.ts.map +1 -1
  78. package/dist/server/install.js +7 -0
  79. package/dist/server/install.js.map +1 -1
  80. package/dist/server/node-outcome-hook.d.ts +2 -1
  81. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  82. package/dist/server/node-outcome-hook.js.map +1 -1
  83. package/dist/server/node-outcome.d.ts +17 -2
  84. package/dist/server/node-outcome.d.ts.map +1 -1
  85. package/dist/server/node-outcome.js +37 -3
  86. package/dist/server/node-outcome.js.map +1 -1
  87. package/dist/server/plugin.d.ts +1 -1
  88. package/dist/server/plugin.d.ts.map +1 -1
  89. package/dist/server/query.d.ts +7 -1
  90. package/dist/server/query.d.ts.map +1 -1
  91. package/dist/server/query.js +8 -3
  92. package/dist/server/query.js.map +1 -1
  93. package/dist/server/registry.d.ts +11 -2
  94. package/dist/server/registry.d.ts.map +1 -1
  95. package/dist/server/request-context.d.ts +17 -4
  96. package/dist/server/request-context.d.ts.map +1 -1
  97. package/dist/server/request-context.js +82 -45
  98. package/dist/server/request-context.js.map +1 -1
  99. package/dist/server/respond.d.ts.map +1 -1
  100. package/dist/server/respond.js +19 -2
  101. package/dist/server/respond.js.map +1 -1
  102. package/dist/server/response-contract-lane.d.ts.map +1 -1
  103. package/dist/server/response-contract-lane.js +5 -2
  104. package/dist/server/response-contract-lane.js.map +1 -1
  105. package/dist/server/response-observer-runtime.d.ts +20 -0
  106. package/dist/server/response-observer-runtime.d.ts.map +1 -0
  107. package/dist/server/response-observer-runtime.js +95 -0
  108. package/dist/server/response-observer-runtime.js.map +1 -0
  109. package/dist/server/runtime-core.d.ts +60 -0
  110. package/dist/server/runtime-core.d.ts.map +1 -1
  111. package/dist/server/runtime-core.js +65 -1
  112. package/dist/server/runtime-core.js.map +1 -1
  113. package/dist/server/server-types.d.ts +18 -1
  114. package/dist/server/server-types.d.ts.map +1 -1
  115. package/dist/server/server.d.ts +89 -48
  116. package/dist/server/server.d.ts.map +1 -1
  117. package/dist/server/server.js +397 -276
  118. package/dist/server/server.js.map +1 -1
  119. package/dist/server/websocket.d.ts +20 -4
  120. package/dist/server/websocket.d.ts.map +1 -1
  121. package/dist/server/websocket.js +81 -11
  122. package/dist/server/websocket.js.map +1 -1
  123. package/dist/server/ws-bun.d.ts +1 -1
  124. package/dist/server/ws-bun.d.ts.map +1 -1
  125. package/dist/server/ws-bun.js +12 -8
  126. package/dist/server/ws-bun.js.map +1 -1
  127. package/dist/server.d.ts +2 -1
  128. package/dist/server.d.ts.map +1 -1
  129. package/dist/server.js +2 -1
  130. package/dist/server.js.map +1 -1
  131. package/dist/single-copy-register.d.ts +2 -0
  132. package/dist/single-copy-register.d.ts.map +1 -0
  133. package/dist/single-copy-register.js +24 -0
  134. package/dist/single-copy-register.js.map +1 -0
  135. package/dist/single-copy.d.ts +156 -0
  136. package/dist/single-copy.d.ts.map +1 -0
  137. package/dist/single-copy.js +470 -0
  138. package/dist/single-copy.js.map +1 -0
  139. package/package.json +24 -2
  140. package/src/assurance.ts +58 -1
  141. package/src/binary.ts +35 -0
  142. package/src/durable-adapters.ts +26 -51
  143. package/src/durable-execution.ts +46 -88
  144. package/src/edge-kit.ts +27 -0
  145. package/src/errors.ts +1 -0
  146. package/src/index.ts +1 -1
  147. package/src/internal/durable-approval-protocol.ts +92 -0
  148. package/src/internal/durable-effect-protocol.ts +37 -0
  149. package/src/internal/durable-saga-protocol.ts +55 -0
  150. package/src/internal/route-catalog.ts +6 -0
  151. package/src/internal/route-execution.ts +23 -16
  152. package/src/response-observer.ts +64 -0
  153. package/src/security/baseline.ts +172 -0
  154. package/src/server/bare-error-lane.ts +76 -0
  155. package/src/server/body.ts +8 -0
  156. package/src/server/bridge-markers.ts +24 -0
  157. package/src/server/context.ts +6 -1
  158. package/src/server/header-case.ts +3 -1
  159. package/src/server/http.ts +53 -3
  160. package/src/server/install.ts +14 -0
  161. package/src/server/node-outcome-hook.ts +2 -1
  162. package/src/server/node-outcome.ts +44 -3
  163. package/src/server/plugin.ts +1 -1
  164. package/src/server/query.ts +10 -4
  165. package/src/server/registry.ts +19 -4
  166. package/src/server/request-context.ts +106 -51
  167. package/src/server/respond.ts +26 -1
  168. package/src/server/response-contract-lane.ts +6 -2
  169. package/src/server/response-observer-runtime.ts +135 -0
  170. package/src/server/runtime-core.ts +98 -1
  171. package/src/server/server-types.ts +18 -1
  172. package/src/server/server.ts +607 -376
  173. package/src/server/websocket.ts +127 -17
  174. package/src/server/ws-bun.ts +26 -7
  175. package/src/server.ts +2 -1
  176. package/src/single-copy-register.ts +24 -0
  177. 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,13 +137,21 @@ 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"
230
148
  import type { McpRuntime } from "./mcp-hook.ts"
231
- import type { ContextPlugin, IdentityPlugin } from "./plugin.ts"
149
+ import type {
150
+ ContextPlugin,
151
+ IdentityPlugin,
152
+ PluginTypeCollapsed,
153
+ ServerTypeUnpinned,
154
+ } from "./plugin.ts"
232
155
  import type {
233
156
  AddRoute,
234
157
  EmptyRegistry,
@@ -358,6 +281,22 @@ interface BunUpgradeServer {
358
281
 
359
282
  type MountedFetchHandler = (request: Request, platform?: Platform) => MaybePromise<Response>
360
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
+
361
300
  interface FetchMount {
362
301
  readonly path: string
363
302
  readonly handler: MountedFetchHandler
@@ -427,10 +366,11 @@ function requireMcpRuntime(runtime: McpRuntime | undefined): McpRuntime {
427
366
  }
428
367
 
429
368
  /** The handler's permitted return type. When the route declares a `response` schema, the return is
430
- * constrained to the contract's type (or a raw `Response`) - so the implementation can't drift from the
431
- * 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. */
432
372
  type ResponseOf<S extends RouteSchema> = S extends { response: infer R extends StandardSchemaV1 }
433
- ? InferOutput<R> | Response
373
+ ? InferOutput<R> | Response | ResponseResult
434
374
  : HandlerResult
435
375
 
436
376
  /**
@@ -492,12 +432,23 @@ const STOP_HOOK_TIMEOUT_MS = 5_000
492
432
  * for the same request, so a browser that could open a socket was told its POST was cross-origin. */
493
433
  const wsSameOrigin = isSameOriginRequest
494
434
 
495
- function validationError(issues: ReadonlyArray<StandardIssue>): Response {
435
+ function validationIssues(issues: ReadonlyArray<StandardIssue>): {
436
+ ok: false
437
+ error: string
438
+ issues: unknown[]
439
+ } {
496
440
  const serialized = issues.map((issue) => {
497
441
  const path = issue.path?.map((seg) => String(typeof seg === "object" ? seg.key : seg))
498
442
  return path !== undefined ? { message: issue.message, path } : { message: issue.message }
499
443
  })
500
- 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))
501
452
  }
502
453
 
503
454
  // `jsonError`, `urlPartsOf`, `pathnameOf` moved to `./http.ts` (a dependency-free leaf shared with the
@@ -552,6 +503,21 @@ function responseSet(ctx: RawContext): CtxSet {
552
503
  return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS
553
504
  }
554
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
+
555
521
  function isContextlessNoArgArrow(handler: (context: never) => unknown): boolean {
556
522
  if (handler.length !== 0) return false
557
523
  try {
@@ -572,10 +538,117 @@ type FeatureVersionOf<V extends string> = V extends `${infer Major}.${infer Mino
572
538
  */
573
539
  export type NifraFeatureVersion = FeatureVersionOf<Version>
574
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
+
575
550
  // Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
576
- const IDENTITY_RESPONSE = (response: Response): Response => response
551
+ const IDENTITY_RESPONSE = (response: Response | ResponseResult): Response => webResponseOf(response)
577
552
  const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
578
553
 
554
+ // The unused-order-scoped-hook audit (recorder, seal check, stack capture, message) is a dev-time
555
+ // diagnostic. Every guard below keeps the INLINE literal `process.env.NODE_ENV !== "production"` at the
556
+ // branch - never a shared const holding the folded boolean - because a bundler's `process.env.NODE_ENV`
557
+ // define only dead-code-eliminates the audit when the literal appears at the `if`; Bun does not
558
+ // propagate a module const's value into the branch. Every real production bundler (vite/esbuild/webpack/
559
+ // next, `bun build --production`) injects that define, turning each guard into `hookAuditRuntime &&
560
+ // false`, which folds away - dropping the whole audit and restoring the bare kernel size.
561
+ //
562
+ // `hookAuditRuntime` is the one shared flag: `typeof globalThis.Deno === "undefined"`. Deno ships a
563
+ // node-compat `process`, so `typeof process` does NOT gate it out there, yet reading `process.env`
564
+ // throws `NotCapable` without `--allow-env`. The flag short-circuits before the env read on Deno (audit
565
+ // off) while a production define still folds `<flag> && false` to `false` on every other runtime.
566
+ const hookAuditRuntime = typeof (globalThis as { Deno?: unknown }).Deno === "undefined"
567
+
568
+ // This module's own path, captured once from a sentinel stack. Every internal frame of an
569
+ // order-scoped hook push (the public method, its `use()`-bundle dispatcher, the recorder) lives here,
570
+ // so `captureCallerSite` skips anything starting with it and lands on the user's calling frame.
571
+ const SERVER_MODULE_PATH = /* @__PURE__ */ ((): string | undefined => {
572
+ const frame = new Error().stack?.split("\n")[1]
573
+ return frame?.match(/\(?([^()\s]+):\d+:\d+\)?\s*$/)?.[1]
574
+ })()
575
+
576
+ /** One `file:line:col` frame for the caller of an order-scoped hook method, for the unused-hook
577
+ * report. Only ever called behind the `unusedScopedHooks !== "off"` guard, so the `Error` (the sole
578
+ * non-trivial cost) is never constructed when the check is disabled. */
579
+ const captureCallerSite = (): string | undefined => {
580
+ const stack = new Error().stack
581
+ if (stack === undefined) return undefined
582
+ for (const line of stack.split("\n").slice(1)) {
583
+ const frame = line.match(/\(?([^()\s]+:\d+:\d+)\)?\s*$/)?.[1]
584
+ if (frame === undefined) continue
585
+ if (SERVER_MODULE_PATH !== undefined && frame.startsWith(`${SERVER_MODULE_PATH}:`)) continue
586
+ return frame
587
+ }
588
+ return undefined
589
+ }
590
+
591
+ /** Per-server bookkeeping for the unused-order-scoped-hook audit. Deliberately NOT `Server` fields or
592
+ * methods: those emit class members (a field declaration, a method shell) that a bundler cannot remove
593
+ * even after their bodies fold away. Held here in a module WeakMap and reached only through the
594
+ * functions below, every call guarded by `process.env.NODE_ENV !== "production"`, so a production define
595
+ * folds the guards away, leaves this map and these functions unreferenced, and the bundler eliminates
596
+ * the whole audit - restoring the bare kernel size. */
597
+ interface HookAuditState {
598
+ readonly policy: "warn" | "error" | "off"
599
+ readonly hasCustomLogger: boolean
600
+ readonly sites: {
601
+ readonly kind: string
602
+ readonly routesAtPush: number
603
+ readonly site: string | undefined
604
+ }[]
605
+ checked: boolean
606
+ }
607
+ const hookAudit = /* @__PURE__ */ new WeakMap<object, HookAuditState>()
608
+
609
+ /** Start tracking one server's order-scoped hook pushes (once, from the constructor). */
610
+ const beginHookAudit = (
611
+ key: object,
612
+ policy: "warn" | "error" | "off",
613
+ hasCustomLogger: boolean,
614
+ ): void => {
615
+ hookAudit.set(key, { policy, hasCustomLogger, sites: [], checked: false })
616
+ }
617
+
618
+ /** Record one order-scoped hook push: the route count at that moment (so `routesAtPush === total` at
619
+ * seal means no route was declared after it) and the caller's frame. */
620
+ const recordScopedHook = (key: object, routesAtPush: number, kind: string): void => {
621
+ const state = hookAudit.get(key)
622
+ if (state === undefined || state.policy === "off") return
623
+ state.sites.push({ kind, routesAtPush, site: captureCallerSite() })
624
+ }
625
+
626
+ /** Run the deadness check exactly once. A hook whose recorded route count equals the final route count
627
+ * had no route declared after it - unambiguously dead, since the scoping feature (a hook that covers
628
+ * only later routes) always leaves at least one such route. Zero-route apps are exempt (different
629
+ * mistake, and the warning would be noise). */
630
+ const sealHookAudit = (
631
+ key: object,
632
+ total: number,
633
+ logger: { warn(message: string): void },
634
+ ): void => {
635
+ const state = hookAudit.get(key)
636
+ if (state === undefined || state.checked) return
637
+ state.checked = true
638
+ if (state.policy === "off" || total === 0) return
639
+ const dead = state.sites.filter((hook) => hook.routesAtPush === total)
640
+ if (dead.length === 0) return
641
+ const detail = dead.map((hook) => ` ${hook.kind}() at ${hook.site ?? "<unknown>"}`).join("\n")
642
+ const message =
643
+ `[nifra] ${dead.length} order-scoped hook(s) apply to no route - they were added after the ` +
644
+ `last route was registered, and register() snapshots the chain into each route as it is ` +
645
+ `declared:\n${detail}\nMove them before the routes they should cover. App-global hooks ` +
646
+ `(onRequest/onResponse) are not order-scoped and can be added at any time.`
647
+ if (state.policy === "error") throw new FrameworkError("UNUSED_SCOPED_HOOKS", message)
648
+ if (state.hasCustomLogger) logger.warn(message)
649
+ else console.warn(message)
650
+ }
651
+
579
652
  /**
580
653
  * The inline server. Routes are chainable and fully type-inferred. `derive`/
581
654
  * `decorate` extend the handler context (`Ctx`) for routes defined *after* them,
@@ -677,7 +750,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
677
750
  private staticResponseHeaders: StaticResponseHeaders | undefined
678
751
  /** `wrapResponse` for the Web lanes: identity until static headers exist to fold into the
679
752
  * framework's own error/404/timeout renders, which are built outside the header init. */
680
- private wrapWebResponse: (response: Response) => Response
753
+ private wrapWebResponse: (response: Response | ResponseResult) => Response
681
754
  private webResponseTimeout: () => Response
682
755
  /** Body/raw response hooks need a framework-payload marker; keep that decision per app. */
683
756
  private responseBodyTag: object | undefined
@@ -744,6 +817,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
744
817
  this.defaultOnValidationError = options.onValidationError
745
818
  this.bunServer = undefined
746
819
  this.sealed = false
820
+ // Guarded so a production define strips the call, leaving `beginHookAudit`/`hookAudit` unreferenced
821
+ // for the bundler to eliminate; a non-bundled production run skips it the same way.
822
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
823
+ beginHookAudit(this, options.unusedScopedHooks ?? "warn", options.logger !== undefined)
824
+ }
747
825
  this.derives = []
748
826
  this.decorations = {}
749
827
  this.beforeHandleHooks = []
@@ -812,17 +890,37 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
812
890
  return this
813
891
  }
814
892
 
815
- /** Add a per-request, computed context extension for subsequent routes. */
816
- derive<D extends object>(fn: (context: Context & Ctx) => MaybePromise<D>): Server<R, Ctx & D> {
893
+ /**
894
+ * Add a per-request, computed context extension. Order-scoped: it is snapshotted into every route
895
+ * declared *after* this call, and reaches none declared before. One added after the last route
896
+ * (e.g. on the app returned by a route-registering factory) covers nothing - see
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.
903
+ */
904
+ derive<D extends object>(
905
+ fn: (context: Context & Ctx) => MaybePromise<D | ResponseResult | Response>,
906
+ ): Server<R, Ctx & D> {
817
907
  this.assertConfigurable("derive()")
818
908
  this.derives.push(fn as unknown as RawDerive)
909
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
910
+ recordScopedHook(this, this.catalog.size, "derive")
819
911
  return this as unknown as Server<R, Ctx & D>
820
912
  }
821
913
 
822
- /** Add a static context value for subsequent routes. */
914
+ /**
915
+ * Add a static context value. Order-scoped: captured by every route declared *after* this call, and
916
+ * by none before; one added after the last route reaches nothing
917
+ * (see {@link ServerOptions.unusedScopedHooks}).
918
+ */
823
919
  decorate<const K extends string, V>(key: K, value: V): Server<R, Ctx & Record<K, V>> {
824
920
  this.assertConfigurable("decorate()")
825
921
  this.decorations[key] = value
922
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
923
+ recordScopedHook(this, this.catalog.size, "decorate")
826
924
  return this as unknown as Server<R, Ctx & Record<K, V>>
827
925
  }
828
926
 
@@ -840,10 +938,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
840
938
  return this
841
939
  }
842
940
 
843
- /** Run after validation, before the handler; a non-`undefined` return short-circuits. Order-scoped. */
941
+ /**
942
+ * Run after validation, before the handler; a non-`undefined` return short-circuits. Order-scoped:
943
+ * it is snapshotted into every route declared *after* this call and covers none declared before, so
944
+ * one added after the last route reaches nothing (see {@link ServerOptions.unusedScopedHooks}).
945
+ */
844
946
  beforeHandle(fn: (context: Context & Ctx) => MaybePromise<unknown>): this {
845
947
  this.assertConfigurable("beforeHandle()")
846
948
  this.beforeHandleHooks.push(fn as unknown as RawBeforeHandle)
949
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
950
+ recordScopedHook(this, this.catalog.size, "beforeHandle")
847
951
  return this
848
952
  }
849
953
 
@@ -851,10 +955,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
851
955
  * Wrap the matched route lifecycle for subsequent routes. This is intentionally generic over the
852
956
  * route output, so wrappers like async context storage do not force Node's direct JSON path through
853
957
  * a Web `Response`. The first registered wrapper is outermost.
958
+ *
959
+ * Order-scoped: captured by every route declared *after* this call, none before; one added after the
960
+ * last route reaches nothing (see {@link ServerOptions.unusedScopedHooks}).
854
961
  */
855
962
  around(fn: <T>(context: Context & Ctx, next: () => MaybePromise<T>) => MaybePromise<T>): this {
856
963
  this.assertConfigurable("around()")
857
964
  this.aroundHooks.push(fn as unknown as RawAround)
965
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
966
+ recordScopedHook(this, this.catalog.size, "around")
858
967
  return this
859
968
  }
860
969
 
@@ -879,6 +988,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
879
988
  this.capabilityInterceptors.push(
880
989
  Object.freeze({ interceptor, timeoutMs: Math.min(timeoutMs, 2_147_483_647) }),
881
990
  )
991
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
992
+ recordScopedHook(this, this.catalog.size, "aroundCapability")
882
993
  return this
883
994
  }
884
995
 
@@ -892,17 +1003,25 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
892
1003
  return this
893
1004
  }
894
1005
 
895
- /** Transform the handler's result before it is serialized. Order-scoped. */
1006
+ /** Transform the handler's result before it is serialized. Order-scoped: captured by routes declared
1007
+ * *after* this call, none before; one added after the last route reaches nothing (see
1008
+ * {@link ServerOptions.unusedScopedHooks}). */
896
1009
  afterHandle(fn: (result: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
897
1010
  this.assertConfigurable("afterHandle()")
898
1011
  this.afterHandleHooks.push(fn as unknown as RawAfterHandle)
1012
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1013
+ recordScopedHook(this, this.catalog.size, "afterHandle")
899
1014
  return this
900
1015
  }
901
1016
 
902
- /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500). Order-scoped. */
1017
+ /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500).
1018
+ * Order-scoped: captured by routes declared *after* this call, none before; one added after the last
1019
+ * route reaches nothing (see {@link ServerOptions.unusedScopedHooks}). */
903
1020
  onError(fn: (error: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
904
1021
  this.assertConfigurable("onError()")
905
1022
  this.onErrorHooks.push(fn as unknown as RawErrorHandler)
1023
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1024
+ recordScopedHook(this, this.catalog.size, "onError")
906
1025
  return this
907
1026
  }
908
1027
 
@@ -947,11 +1066,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
947
1066
  * hook owns part of the header state - into a hook that runs in the right order. */
948
1067
  private addStaticResponseHeaders(record: Record<string, string>): this {
949
1068
  if (this.onResponseHooks.length > 0) {
950
- return this.onResponseHeaders((headers) => {
951
- for (const name of Object.keys(record)) {
952
- if (!headers.has(name)) headers.set(name, record[name] as string)
953
- }
954
- })
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
955
1090
  }
956
1091
  const merged =
957
1092
  this.staticResponseHeaders === undefined
@@ -959,7 +1094,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
959
1094
  : { ...this.staticResponseHeaders.record, ...record }
960
1095
  this.staticResponseHeaders = buildStaticResponseHeaders(merged)
961
1096
  const statics = this.staticResponseHeaders
962
- this.wrapWebResponse = (response) => applyStaticResponseHeaders(response, statics)
1097
+ this.wrapWebResponse = (response) =>
1098
+ applyStaticResponseHeaders(webResponseOf(response), statics)
963
1099
  this.webResponseTimeout = () => applyStaticResponseHeaders(RESPONSE_TIMEOUT(), statics)
964
1100
  return this
965
1101
  }
@@ -972,61 +1108,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
972
1108
  return this.responseBodyTag
973
1109
  }
974
1110
 
975
- /**
976
- * Register a response transform for raw/streamed Responses. Framework-serialized payloads stay on
977
- * the body tier; this hook is only entered for untagged Responses (streams, proxied fetches, and
978
- * framework-generated error responses). A no-op native twin keeps buffered JSON on the direct lane.
979
- */
980
- onResponseRaw(fn: (response: Response, req: Request) => MaybePromise<Response>): this {
981
- this.assertConfigurable("onResponseRaw()")
982
- this.enableResponseBodyTagging()
983
- this.onResponseHooks.push(webResponseRawHook(fn, this.responseBodyOwners))
984
- this.onNodeResponseHooks.push(() => undefined)
985
- return this
986
- }
987
-
988
- /**
989
- * Register a PORTABLE header-only response hook - one implementation, fast on every runtime.
990
- *
991
- * The hook receives a mutable case-insensitive header view, a minimal request view, and the
992
- * status. On the Web serving paths it runs inside the normal `onResponse` walk against the
993
- * response's own `Headers` (mutating in place - no clone). On Node it self-pairs as a native
994
- * response hook against the outcome's plain header record, so registering one NEVER forces the
995
- * Node adapter off its direct socket writer the way a full `onResponse(res: Response)` hook does.
996
- * Prefer this over `onResponse` whenever the middleware only reads/writes headers.
997
- */
998
- onResponseHeaders(fn: ResponseHeadersHook): this {
999
- this.assertConfigurable("onResponseHeaders()")
1000
- this.onResponseHooks.push(webResponseHeadersHook(fn))
1001
- this.onNodeResponseHooks.push((response, req) =>
1002
- fn(recordHeadersView(response), req, response.status),
1003
- )
1004
- return this
1005
- }
1006
-
1007
- /**
1008
- * Register a PORTABLE post-serialization body hook - the payload tier. The hook receives the
1009
- * FINAL framework-serialized bytes (plus the mutable header view and status) and may return
1010
- * replacement bytes; `undefined` keeps the body. On the Node direct writer the bytes come
1011
- * straight off the outcome record; on the Web serving paths they ride the framework-built
1012
- * Response as a tag, so no body stream is ever drained on any runtime. A handler-returned raw
1013
- * `Response` (proxied fetch, SSE, streamed SSR) is skipped by contract - transforming those is
1014
- * what the full `onResponse` hook is for.
1015
- */
1016
- onResponseBody(fn: ResponseBodyHook): this {
1017
- this.assertConfigurable("onResponseBody()")
1018
- this.enableResponseBodyTagging()
1019
- this.onResponseHooks.push(webResponseBodyHook(fn, this.responseBodyOwners))
1020
- this.onNodeResponseHooks.push((response, req) => {
1021
- const body = response.body
1022
- if (body === null) return undefined
1023
- const out = fn(body, recordHeadersView(response), req, response.status)
1024
- if (out instanceof Promise)
1025
- return out.then((replaced) => applyBodyReplacement(response, replaced))
1026
- applyBodyReplacement(response, out)
1027
- return undefined
1028
- })
1029
- 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
1030
1123
  }
1031
1124
 
1032
1125
  /** Observe the terminal response after all transformations. Observers are ordered and fail-open. */
@@ -1038,10 +1131,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1038
1131
  return this
1039
1132
  }
1040
1133
 
1134
+ /** Enable the opt-in portable response observer methods. */
1135
+ use(plugin: ResponseObserverPlugin): this & ResponseObserverMethods
1041
1136
  /**
1042
1137
  * Apply a type-**identity** plugin ({@link IdentityPlugin}, from {@link defineIdentityPlugin}) - it
1043
1138
  * registers routes/hooks but doesn't change the types, so this returns `this` with the route registry
1044
- * 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.
1045
1140
  * `@nifrajs/better-auth`) threads the registry: its `& { pluginName }` intersection would otherwise
1046
1141
  * defeat the generic inference of the transforming overload below and collapse the result to `any`.
1047
1142
  */
@@ -1059,13 +1154,23 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1059
1154
  * called with `this` and its result is returned, so an inline plugin's `derive`/`decorate` thread
1060
1155
  * the added context to handlers defined after `use` (the overload is generic over the concrete
1061
1156
  * `this`). A named plugin already applied is skipped (idempotent dedupe).
1157
+ *
1158
+ * If the plugin's return type is unpinned - `Server<any, any>`, as a hand-rolled `(app) => app` or a
1159
+ * `NifraPlugin<AnyServer, AnyServer>` infers (e.g. an auth plugin whose own types collapsed) - this
1160
+ * returns the non-callable {@link PluginTypeCollapsed} instead of silently widening the whole typed
1161
+ * client to `any`. `.get()`/`.post()` then fail right here rather than surfacing as `any` hundreds of
1162
+ * lines away. Fix at the plugin: build it with {@link defineIdentityPlugin}/{@link defineContextPlugin},
1163
+ * or pin its input server type. This mirrors the guard {@link definePlugin} already applies.
1062
1164
  */
1063
- use<Out extends AnyServer>(plugin: (app: this) => Out): Out
1165
+ use<Out extends AnyServer>(
1166
+ plugin: (app: this) => Out,
1167
+ ): [ServerTypeUnpinned<Out>] extends [true] ? PluginTypeCollapsed : Out
1064
1168
  /**
1065
1169
  * Apply a {@link Middleware} bundle - wire each hook it provides to its lifecycle point. Returns
1066
1170
  * `this` (no context-type merging); call it before the routes its `beforeHandle`/`afterHandle`
1067
- * should cover (those are order-scoped; `onRequest`/`onResponse` are global). A named bundle already
1068
- * applied is skipped (idempotent).
1171
+ * should cover (those are order-scoped; `onRequest`/`onResponse` are global). A bundle applied after
1172
+ * the last route reaches nothing - {@link ServerOptions.unusedScopedHooks} logs that at seal. A named
1173
+ * bundle already applied is skipped (idempotent).
1069
1174
  */
1070
1175
  use(mw: Middleware): this
1071
1176
  use(arg: Middleware | ((app: this) => AnyServer)): AnyServer {
@@ -1102,6 +1207,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1102
1207
  }
1103
1208
  this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"))
1104
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
+ }
1105
1221
  if (arg.onRequest !== undefined) {
1106
1222
  this.assertConfigurable("onRequest()")
1107
1223
  this.onRequestHooks.push(arg.onRequest as RawOnRequest)
@@ -1125,9 +1241,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1125
1241
  // Before the bundle's own hooks: a bundle declaring both means its static values are the
1126
1242
  // defaults its hook may then override, which is the order a single bundle reads in.
1127
1243
  if (arg.responseHeaders !== undefined) this.responseHeaders(arg.responseHeaders)
1128
- if (arg.onResponseHeaders !== undefined) this.onResponseHeaders(arg.onResponseHeaders)
1129
- if (arg.onResponseBody !== undefined) this.onResponseBody(arg.onResponseBody)
1130
- 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)
1131
1250
  if (arg.onResponseFinalized !== undefined) this.onResponseFinalized(arg.onResponseFinalized)
1132
1251
  if (arg.onError !== undefined) this.onError(arg.onError)
1133
1252
  return this
@@ -1395,6 +1514,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1395
1514
  // Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
1396
1515
  // bundle it. Registration is the loud, early failure point - never the first connection.
1397
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
+ }
1398
1523
  this.topics ??= runtime.createTopics()
1399
1524
  // A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
1400
1525
  // already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
@@ -1475,6 +1600,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1475
1600
  handler: (context: never) => unknown,
1476
1601
  ): CatalogRoute {
1477
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.
1478
1605
  let bodyLimit: number | undefined = this.maxBodyBytes
1479
1606
  const invalidBodyLimit = (message: string): never => {
1480
1607
  throw new RouteConfigError("INVALID_BODY_LIMIT", `route ${method} ${path}: ${message}`)
@@ -1489,6 +1616,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1489
1616
  ) {
1490
1617
  invalidBodyLimit('bodyLimit: "unlimited" requires a non-empty bodyLimitReason')
1491
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
+ }
1492
1627
  bodyLimit = undefined
1493
1628
  } else if (schema?.bodyLimit !== undefined) {
1494
1629
  try {
@@ -1617,7 +1752,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1617
1752
  handler as unknown as InternalHandler,
1618
1753
  schema.body as StandardSchemaV1,
1619
1754
  hasDecorations ? routeDecorations : undefined,
1620
- bodyLimit ?? this.maxBodyBytes,
1755
+ bodyLimit ?? UNLIMITED_BODY_BYTES,
1621
1756
  )
1622
1757
  : undefined
1623
1758
  const fusedWeb =
@@ -1626,14 +1761,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1626
1761
  handler as unknown as InternalHandler,
1627
1762
  hasDecorations ? routeDecorations : undefined,
1628
1763
  isContextlessNoArgArrow(handler),
1629
- bodyLimit ?? this.maxBodyBytes,
1764
+ bodyLimit ?? UNLIMITED_BODY_BYTES,
1630
1765
  )
1631
1766
  : fusedQuery
1632
1767
  ? this.buildFusedQueryWeb(
1633
1768
  handler as unknown as InternalHandler,
1634
1769
  hasDecorations ? routeDecorations : undefined,
1635
1770
  schema.query as StandardSchemaV1,
1636
- bodyLimit ?? this.maxBodyBytes,
1771
+ bodyLimit ?? UNLIMITED_BODY_BYTES,
1637
1772
  )
1638
1773
  : fusedBody
1639
1774
  ? this.buildFusedBodyWeb(fusedBodyRunner as FusedBodyRunner)
@@ -1749,7 +1884,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1749
1884
  })
1750
1885
  routeAssurance.push(Object.freeze(declared))
1751
1886
  }
1752
- 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")) {
1753
1899
  routeAssurance.push(
1754
1900
  Object.freeze({
1755
1901
  id: NIFRA_ASSURANCE_IDS.BODY_BOUNDED,
@@ -1809,6 +1955,29 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1809
1955
  this.responseContractRuntime = runtime
1810
1956
  }
1811
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
+
1812
1981
  /** @internal Symbol-keyed install seam for the `idempotency()` plugin. Off the public typed surface. */
1813
1982
  [INSTALL_IDEMPOTENCY](runtime: IdempotencyRuntime): void {
1814
1983
  this.assertConfigurable("idempotency()")
@@ -1964,7 +2133,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1964
2133
  entry.handler,
1965
2134
  entry.schema?.body as StandardSchemaV1,
1966
2135
  entry.hasDecorations ? entry.decorations : undefined,
1967
- entry.bodyLimit ?? this.maxBodyBytes,
2136
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
1968
2137
  )
1969
2138
  : undefined
1970
2139
  const fusedWeb =
@@ -1975,13 +2144,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1975
2144
  entry.handler,
1976
2145
  entry.hasDecorations ? entry.decorations : undefined,
1977
2146
  entry.schema?.query as StandardSchemaV1,
1978
- entry.bodyLimit ?? this.maxBodyBytes,
2147
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
1979
2148
  )
1980
2149
  : this.buildFusedWeb(
1981
2150
  entry.handler,
1982
2151
  entry.hasDecorations ? entry.decorations : undefined,
1983
2152
  isContextlessNoArgArrow(entry.handler),
1984
- entry.bodyLimit ?? this.maxBodyBytes,
2153
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
1985
2154
  )
1986
2155
  return {
1987
2156
  ...route,
@@ -2027,6 +2196,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2027
2196
  source: RequestSource,
2028
2197
  platform?: Platform<EnvOf<Ctx>>,
2029
2198
  ): MaybePromise<Response> {
2199
+ // Seal once on the first request so test servers (no `listen()`) still get the dead-hook check.
2200
+ // After that it is a single boolean read - the per-request cost the option promises to keep at zero.
2201
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2202
+ sealHookAudit(this, this.catalog.size, this.logger)
2030
2203
  // Off path (default): straight through - one property check, no closure, no promise.
2031
2204
  if (this.capacityGate === undefined) return this.fetchSourceInner(source, platform)
2032
2205
  return this.admitGated(requestOf(source), () => this.fetchSourceInner(source, platform))
@@ -2134,6 +2307,45 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2134
2307
  return outcome
2135
2308
  }
2136
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
+
2137
2349
  /**
2138
2350
  * Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` - the
2139
2351
  * `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
@@ -2151,6 +2363,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2151
2363
  platform?: Platform<EnvOf<Ctx>>,
2152
2364
  suppliedRuntime?: NodeOutcomeRuntime,
2153
2365
  ): MaybePromise<NodeServeOutcome> {
2366
+ // Seal once on the Node-direct lane too - it can bypass `fetchSource` entirely (hookless outcome).
2367
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2368
+ sealHookAudit(this, this.catalog.size, this.logger)
2154
2369
  // A paired header-only native hook can preserve the Node-direct JSON/body outcome for successful
2155
2370
  // responses. Arbitrary onResponse transforms need a real Web Response, but a buffered outcome can
2156
2371
  // be materialized with a direct-write marker and return to the socket path when the hook mutates
@@ -2509,7 +2724,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2509
2724
  source: RequestSource,
2510
2725
  platform: Platform | undefined,
2511
2726
  finalize: (result: unknown, set: CtxSet) => T,
2512
- wrapResponse: (response: Response) => T,
2727
+ wrapResponse: (response: Response | ResponseResult) => T,
2513
2728
  onTimeout: () => T,
2514
2729
  // True only from the Web `fetch` path - unlocks each route's fused lane, whose output type IS
2515
2730
  // `Response` (`T = Response` there by construction; the node path always passes false).
@@ -2537,7 +2752,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2537
2752
  source: RequestSource,
2538
2753
  platform: Platform | undefined,
2539
2754
  finalize: (result: unknown, set: CtxSet) => T,
2540
- wrapResponse: (response: Response) => T,
2755
+ wrapResponse: (response: Response | ResponseResult) => T,
2541
2756
  onTimeout: () => T,
2542
2757
  ): MaybePromise<T> {
2543
2758
  const hooks = this.onNodeRequestHooks
@@ -2571,7 +2786,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2571
2786
  source: RequestSource,
2572
2787
  platform: Platform | undefined,
2573
2788
  finalize: (result: unknown, set: CtxSet) => T,
2574
- wrapResponse: (response: Response) => T,
2789
+ wrapResponse: (response: Response | ResponseResult) => T,
2575
2790
  onTimeout: () => T,
2576
2791
  ): Promise<T> {
2577
2792
  if (first !== undefined) return wrapResponse(first)
@@ -2599,7 +2814,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2599
2814
  source: RequestSource,
2600
2815
  platform: Platform | undefined,
2601
2816
  finalize: (result: unknown, set: CtxSet) => T,
2602
- wrapResponse: (response: Response) => T,
2817
+ wrapResponse: (response: Response | ResponseResult) => T,
2603
2818
  onTimeout: () => T,
2604
2819
  webFast: boolean,
2605
2820
  ): MaybePromise<T> {
@@ -2646,7 +2861,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2646
2861
  sourceAtAwait: RequestSource,
2647
2862
  platform: Platform | undefined,
2648
2863
  finalize: (result: unknown, set: CtxSet) => T,
2649
- wrapResponse: (response: Response) => T,
2864
+ wrapResponse: (response: Response | ResponseResult) => T,
2650
2865
  onTimeout: () => T,
2651
2866
  webFast: boolean,
2652
2867
  ): Promise<T> {
@@ -2740,7 +2955,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2740
2955
  source: RequestSource,
2741
2956
  platform: Platform | undefined,
2742
2957
  finalize: (result: unknown, set: CtxSet) => T,
2743
- wrapResponse: (response: Response) => T,
2958
+ wrapResponse: (response: Response | ResponseResult) => T,
2744
2959
  onTimeout: () => T,
2745
2960
  webFast: boolean,
2746
2961
  ): MaybePromise<T> {
@@ -2792,17 +3007,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2792
3007
  }
2793
3008
  if (match.reason === "method-not-allowed") {
2794
3009
  return wrapResponse(
2795
- 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(", ") }),
2796
3013
  )
2797
3014
  }
2798
- return wrapResponse(jsonError(404, "not_found"))
3015
+ return wrapResponse(plainError(404, "not_found"))
2799
3016
  }
2800
3017
 
2801
3018
  // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
2802
3019
  // already did and made every plain dynamic route pay for unrelated static path bytes.
2803
3020
  const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params)
2804
3021
  if (params === null) {
2805
- return wrapResponse(jsonError(400, "malformed_path"))
3022
+ return wrapResponse(plainError(400, "malformed_path"))
2806
3023
  }
2807
3024
 
2808
3025
  return this.runMatched(
@@ -2842,7 +3059,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2842
3059
  params: Record<string, string>,
2843
3060
  search: string | undefined,
2844
3061
  finalize: (result: unknown, set: CtxSet) => T,
2845
- wrapResponse: (response: Response) => T,
3062
+ wrapResponse: (response: Response | ResponseResult) => T,
2846
3063
  onTimeout: () => T,
2847
3064
  webFast: boolean,
2848
3065
  ): MaybePromise<T> {
@@ -2873,7 +3090,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2873
3090
  search,
2874
3091
  wrapResponse,
2875
3092
  {
2876
- maxBodyBytes: entry.bodyLimit ?? this.maxBodyBytes,
3093
+ maxBodyBytes: entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2877
3094
  runLanes: (buffered, plat, ent, prm, srch) =>
2878
3095
  this.idempotencyRunLanes(buffered, plat, ent as RouteEntry, prm, srch),
2879
3096
  },
@@ -2900,7 +3117,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2900
3117
  params: Record<string, string>,
2901
3118
  search: string | undefined,
2902
3119
  finalize: (result: unknown, set: CtxSet) => T,
2903
- wrapResponse: (response: Response) => T,
3120
+ wrapResponse: (response: Response | ResponseResult) => T,
2904
3121
  onTimeout: () => T,
2905
3122
  webFast: boolean,
2906
3123
  ): MaybePromise<T> {
@@ -2922,7 +3139,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2922
3139
  ? undefined
2923
3140
  : admitDeadline(source.headers, this.deadlineAdmissionOptions)
2924
3141
  if (admission !== undefined && !admission.ok) {
2925
- return wrapResponse(jsonError(admission.status, admission.reason))
3142
+ return wrapResponse(plainError(admission.status, admission.reason))
2926
3143
  }
2927
3144
  const effectiveTimeoutMs = admission?.timeoutMs ?? 0
2928
3145
 
@@ -2981,7 +3198,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2981
3198
  if (controller !== undefined && outcome instanceof Promise) {
2982
3199
  const timedOut =
2983
3200
  admission?.inherited === true
2984
- ? () => wrapResponse(jsonError(504, "deadline_exceeded"))
3201
+ ? () => wrapResponse(plainError(504, "deadline_exceeded"))
2985
3202
  : onTimeout
2986
3203
  return this.withTimeout(
2987
3204
  outcome,
@@ -3012,7 +3229,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3012
3229
  budget: RequestBudget,
3013
3230
  platform: Platform | undefined,
3014
3231
  finalize: (result: unknown, set: CtxSet) => T,
3015
- wrapResponse: (response: Response) => T,
3232
+ wrapResponse: (response: Response | ResponseResult) => T,
3016
3233
  ): MaybePromise<T> {
3017
3234
  let result: unknown
3018
3235
  try {
@@ -3056,9 +3273,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3056
3273
  signal: AbortSignal,
3057
3274
  budget: RequestBudget,
3058
3275
  platform: Platform | undefined,
3059
- wrapResponse: (response: Response) => T,
3276
+ wrapResponse: (response: Response | ResponseResult) => T,
3060
3277
  ): T {
3061
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)
3062
3282
  const ctx = new RequestContext(
3063
3283
  source,
3064
3284
  params,
@@ -3070,7 +3290,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3070
3290
  this.protoPoisoning,
3071
3291
  )
3072
3292
  this.logRequestError(err, ctx)
3073
- return wrapResponse(jsonError(500, "internal_error"))
3293
+ return wrapResponse(plainError(500, "internal_error"))
3074
3294
  }
3075
3295
 
3076
3296
  /**
@@ -3087,20 +3307,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3087
3307
  entry: RouteEntry,
3088
3308
  ctx: RawContext,
3089
3309
  finalize: (result: unknown, set: CtxSet) => T,
3090
- wrapResponse: (response: Response) => T,
3310
+ wrapResponse: (response: Response | ResponseResult) => T,
3091
3311
  ): MaybePromise<T> {
3092
3312
  let result: unknown
3093
3313
  try {
3094
3314
  if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
3095
3315
  result = entry.handler(ctx)
3096
3316
  } catch (err) {
3097
- return this.bareError(err, ctx, wrapResponse)
3317
+ return this.bareError(err, ctx, finalize, wrapResponse)
3098
3318
  }
3099
3319
  if (result instanceof Promise) {
3100
3320
  // Async handler on an otherwise-bare route: finish on a microtask, with the same error handling.
3101
3321
  return result.then(
3102
3322
  (value) => finalize(value, responseSet(ctx)),
3103
- (err) => this.bareError(err, ctx, wrapResponse),
3323
+ (err) => this.bareError(err, ctx, finalize, wrapResponse),
3104
3324
  )
3105
3325
  }
3106
3326
  return finalize(result, responseSet(ctx))
@@ -3121,13 +3341,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3121
3341
  contextless: boolean,
3122
3342
  maxBodyBytes: number,
3123
3343
  ): FusedWebRunner {
3124
- // The fused lanes return to the runtime directly, so the framework's own error renders fold in
3125
- // static headers here rather than through the shared `wrapResponse` seam.
3126
- const logError = (err: unknown, ctx: RawContext): Response => {
3127
- if (err instanceof Response) return this.wrapWebResponse(err)
3128
- this.logRequestError(err, ctx)
3129
- return this.wrapWebResponse(jsonError(500, "internal_error"))
3130
- }
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
+ )
3131
3354
  if (contextless && decorations === undefined) {
3132
3355
  // `() => ...` can't observe the context - skip allocating one entirely (errors still build
3133
3356
  // one for the structured log, exactly like runContextlessBare).
@@ -3257,20 +3480,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3257
3480
  const logError = <T>(
3258
3481
  err: unknown,
3259
3482
  ctx: RawContext,
3260
- wrapResponse: (response: Response) => T,
3261
- ): 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)
3262
3486
 
3263
3487
  const runHandler = <T>(
3264
3488
  ctx: RawContext,
3265
3489
  finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
3266
- wrapResponse: (response: Response) => T,
3490
+ wrapResponse: (response: Response | ResponseResult) => T,
3267
3491
  ): MaybePromise<T> => {
3268
3492
  if (decorations !== undefined) Object.assign(ctx, decorations)
3269
3493
  let output: MaybePromise<HandlerResult>
3270
3494
  try {
3271
3495
  output = handler(ctx)
3272
3496
  } catch (err) {
3273
- return logError(err, ctx, wrapResponse)
3497
+ return logError(err, ctx, finalize, wrapResponse)
3274
3498
  }
3275
3499
  if (output instanceof Promise) {
3276
3500
  return output.then(
@@ -3278,16 +3502,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3278
3502
  try {
3279
3503
  return finalize(result, responseSet(ctx), ctx)
3280
3504
  } catch (err) {
3281
- return logError(err, ctx, wrapResponse)
3505
+ return logError(err, ctx, finalize, wrapResponse)
3282
3506
  }
3283
3507
  },
3284
- (err) => logError(err, ctx, wrapResponse),
3508
+ (err) => logError(err, ctx, finalize, wrapResponse),
3285
3509
  )
3286
3510
  }
3287
3511
  try {
3288
3512
  return finalize(output, responseSet(ctx), ctx)
3289
3513
  } catch (err) {
3290
- return logError(err, ctx, wrapResponse)
3514
+ return logError(err, ctx, finalize, wrapResponse)
3291
3515
  }
3292
3516
  }
3293
3517
 
@@ -3295,9 +3519,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3295
3519
  result: StandardResult<unknown>,
3296
3520
  ctx: RawContext,
3297
3521
  finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
3298
- wrapResponse: (response: Response) => T,
3522
+ wrapResponse: (response: Response | ResponseResult) => T,
3299
3523
  ): MaybePromise<T> => {
3300
- if (result.issues !== undefined) return wrapResponse(validationError(result.issues))
3524
+ if (result.issues !== undefined) return wrapResponse(plainValidationError(result.issues))
3301
3525
  ctx.body = result.value
3302
3526
  return runHandler(ctx, finalize, wrapResponse)
3303
3527
  }
@@ -3306,13 +3530,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3306
3530
  parsed: unknown,
3307
3531
  ctx: RawContext,
3308
3532
  finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
3309
- wrapResponse: (response: Response) => T,
3533
+ wrapResponse: (response: Response | ResponseResult) => T,
3310
3534
  ): MaybePromise<T> => {
3311
3535
  let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
3312
3536
  try {
3313
3537
  validation = bodySchema["~standard"].validate(parsed)
3314
3538
  } catch (err) {
3315
- return logError(err, ctx, wrapResponse)
3539
+ return logError(err, ctx, finalize, wrapResponse)
3316
3540
  }
3317
3541
  if (validation instanceof Promise) {
3318
3542
  return validation.then(
@@ -3320,16 +3544,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3320
3544
  try {
3321
3545
  return runValidated(settled, ctx, finalize, wrapResponse)
3322
3546
  } catch (err) {
3323
- return logError(err, ctx, wrapResponse)
3547
+ return logError(err, ctx, finalize, wrapResponse)
3324
3548
  }
3325
3549
  },
3326
- (err) => logError(err, ctx, wrapResponse),
3550
+ (err) => logError(err, ctx, finalize, wrapResponse),
3327
3551
  )
3328
3552
  }
3329
3553
  try {
3330
3554
  return runValidated(validation, ctx, finalize, wrapResponse)
3331
3555
  } catch (err) {
3332
- return logError(err, ctx, wrapResponse)
3556
+ return logError(err, ctx, finalize, wrapResponse)
3333
3557
  }
3334
3558
  }
3335
3559
 
@@ -3342,7 +3566,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3342
3566
  platform: Platform | undefined,
3343
3567
  nativeContext: boolean,
3344
3568
  finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
3345
- wrapResponse: (response: Response) => T,
3569
+ wrapResponse: (response: Response | ResponseResult) => T,
3346
3570
  ): MaybePromise<T> => {
3347
3571
  const ctx = nativeContext
3348
3572
  ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
@@ -3358,8 +3582,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3358
3582
  )
3359
3583
  const finish = (value: unknown): MaybePromise<T> =>
3360
3584
  runParsed(value, ctx, finalize, wrapResponse)
3361
- return this.readBodyInput(source, maxBodyBytes, finish, wrapResponse, (err) =>
3362
- 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),
3363
3592
  )
3364
3593
  }
3365
3594
  }
@@ -3401,13 +3630,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3401
3630
  querySchema: StandardSchemaV1,
3402
3631
  maxBodyBytes: number,
3403
3632
  ): FusedWebRunner {
3404
- // The fused lanes return to the runtime directly, so the framework's own error renders fold in
3405
- // static headers here rather than through the shared `wrapResponse` seam.
3406
- const logError = (err: unknown, ctx: RawContext): Response => {
3407
- if (err instanceof Response) return this.wrapWebResponse(err)
3408
- this.logRequestError(err, ctx)
3409
- return this.wrapWebResponse(jsonError(500, "internal_error"))
3410
- }
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
+ )
3411
3643
  const runHandler = (ctx: RawContext, value: unknown): MaybePromise<Response> => {
3412
3644
  ctx.query = value
3413
3645
  let result: unknown
@@ -3461,21 +3693,29 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3461
3693
  return validation.then(
3462
3694
  (settled) =>
3463
3695
  settled.issues !== undefined
3464
- ? this.wrapWebResponse(validationError(settled.issues))
3696
+ ? this.wrapWebResponse(plainValidationError(settled.issues))
3465
3697
  : runHandler(ctx, settled.value),
3466
3698
  (err) => logError(err, ctx),
3467
3699
  )
3468
3700
  }
3469
3701
  if (validation.issues !== undefined)
3470
- return this.wrapWebResponse(validationError(validation.issues))
3702
+ return this.wrapWebResponse(plainValidationError(validation.issues))
3471
3703
  return runHandler(ctx, validation.value)
3472
3704
  }
3473
3705
  }
3474
3706
 
3475
- private bareError<T>(err: unknown, ctx: RawContext, wrapResponse: (response: Response) => T): T {
3476
- if (err instanceof Response) return wrapResponse(err)
3477
- this.logRequestError(err, ctx)
3478
- 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
+ )
3479
3719
  }
3480
3720
 
3481
3721
  // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
@@ -3485,7 +3725,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3485
3725
  ctx: RawContext,
3486
3726
  run: () => MaybePromise<T>,
3487
3727
  finalize: (result: unknown, set: CtxSet) => T,
3488
- wrapResponse: (response: Response) => T,
3728
+ wrapResponse: (response: Response | ResponseResult) => T,
3489
3729
  ): MaybePromise<T> {
3490
3730
  let outcome: MaybePromise<T>
3491
3731
  try {
@@ -3523,52 +3763,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3523
3763
  source: RequestSource,
3524
3764
  ctx: RawContext,
3525
3765
  finalize: (result: unknown, set: CtxSet) => T,
3526
- wrapResponse: (response: Response) => T,
3766
+ wrapResponse: (response: Response | ResponseResult) => T,
3527
3767
  ): Promise<T> {
3528
- return this.readBodyInput(
3768
+ return readBodyFramed(
3529
3769
  source,
3530
- entry.bodyLimit ?? this.maxBodyBytes,
3770
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3771
+ this.protoPoisoning,
3531
3772
  (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
3532
3773
  wrapResponse,
3533
3774
  (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3534
3775
  )
3535
3776
  }
3536
3777
 
3537
- /** Shared bounded body framing/parser. Both body lanes use the same trust-boundary checks; only
3538
- * the validation + handler continuation differs. */
3539
- private readBodyInput<T>(
3540
- source: RequestSource,
3541
- maxBodyBytes: number,
3542
- onParsed: (parsed: unknown) => MaybePromise<T>,
3543
- wrapResponse: (response: Response) => T,
3544
- onError: (err: unknown) => MaybePromise<T>,
3545
- ): Promise<T> {
3546
- const contentType = headerOf(source, "content-type") ?? ""
3547
- if (contentType !== "application/json" && !contentType.includes("application/json")) {
3548
- if (isUrlEncodedForm(contentType)) {
3549
- return readBoundedForm(source, maxBodyBytes).then(
3550
- (form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)),
3551
- onError,
3552
- ) as Promise<T>
3553
- }
3554
- return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")))
3555
- }
3556
-
3557
- // All JSON bodies go through readBoundedJson - the single enforcement point for the framed
3558
- // fast path (post-read byte re-check), the streaming cap, and the prototype-poisoning guard.
3559
- // A separate inlined native-json() fast path here would fork the trust boundary in two.
3560
- try {
3561
- return this.readBoundedJson(
3562
- source,
3563
- maxBodyBytes,
3564
- (parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)),
3565
- onError,
3566
- )
3567
- } catch (err) {
3568
- return Promise.resolve(onError(err))
3569
- }
3570
- }
3571
-
3572
3778
  /** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
3573
3779
  * streaming fallback. A method (not per-request closures) so the hot path allocates nothing
3574
3780
  * beyond the one `.then` continuation. */
@@ -3577,7 +3783,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3577
3783
  parsed: unknown,
3578
3784
  ctx: RawContext,
3579
3785
  finalize: (result: unknown, set: CtxSet) => T,
3580
- wrapResponse: (response: Response) => T,
3786
+ wrapResponse: (response: Response | ResponseResult) => T,
3581
3787
  ): MaybePromise<T> {
3582
3788
  try {
3583
3789
  const bodySchema = entry.schema!.body!
@@ -3612,7 +3818,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3612
3818
  entry: RouteEntry,
3613
3819
  ctx: RawContext,
3614
3820
  finalize: (result: unknown, set: CtxSet) => T,
3615
- wrapResponse?: (response: Response) => T,
3821
+ wrapResponse?: (response: Response | ResponseResult) => T,
3616
3822
  ): MaybePromise<T> {
3617
3823
  if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
3618
3824
  const handlerOutput = entry.handler(ctx)
@@ -3635,7 +3841,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3635
3841
  recovery: unknown,
3636
3842
  ctx: RawContext,
3637
3843
  finalize: (result: unknown, set: CtxSet) => T,
3638
- wrapResponse: (response: Response) => T,
3844
+ wrapResponse: (response: Response | ResponseResult) => T,
3639
3845
  originalIssues: ReadonlyArray<StandardIssue>,
3640
3846
  kind: "body" | "query",
3641
3847
  ): MaybePromise<T> {
@@ -3647,12 +3853,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3647
3853
  const validation = entry.schema.body["~standard"].validate(recovery)
3648
3854
  if (validation instanceof Promise) {
3649
3855
  return validation.then((settled) => {
3650
- if (settled.issues !== undefined) return wrapResponse(validationError(settled.issues))
3856
+ if (settled.issues !== undefined)
3857
+ return wrapResponse(plainValidationError(settled.issues))
3651
3858
  ctx.body = settled.value
3652
3859
  return this.executeHandler(entry, ctx, finalize)
3653
3860
  })
3654
3861
  }
3655
- if (validation.issues !== undefined) return wrapResponse(validationError(validation.issues))
3862
+ if (validation.issues !== undefined)
3863
+ return wrapResponse(plainValidationError(validation.issues))
3656
3864
  ctx.body = validation.value
3657
3865
  return this.executeHandler(entry, ctx, finalize)
3658
3866
  }
@@ -3661,19 +3869,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3661
3869
  if (validation instanceof Promise) {
3662
3870
  return validation.then(
3663
3871
  (settled) => {
3664
- if (settled.issues !== undefined) return wrapResponse(validationError(settled.issues))
3872
+ if (settled.issues !== undefined)
3873
+ return wrapResponse(plainValidationError(settled.issues))
3665
3874
  ctx.query = settled.value
3666
3875
  return this.executeHandler(entry, ctx, finalize, wrapResponse)
3667
3876
  },
3668
3877
  (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3669
3878
  )
3670
3879
  }
3671
- if (validation.issues !== undefined) return wrapResponse(validationError(validation.issues))
3880
+ if (validation.issues !== undefined)
3881
+ return wrapResponse(plainValidationError(validation.issues))
3672
3882
  ctx.query = validation.value
3673
3883
  return this.executeHandler(entry, ctx, finalize, wrapResponse)
3674
3884
  }
3675
3885
  }
3676
- return wrapResponse(validationError(originalIssues))
3886
+ return wrapResponse(plainValidationError(originalIssues))
3677
3887
  }
3678
3888
 
3679
3889
  private applyBodyValidation<T>(
@@ -3681,7 +3891,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3681
3891
  result: StandardResult<unknown>,
3682
3892
  ctx: RawContext,
3683
3893
  finalize: (result: unknown, set: CtxSet) => T,
3684
- wrapResponse: (response: Response) => T,
3894
+ wrapResponse: (response: Response | ResponseResult) => T,
3685
3895
  ): MaybePromise<T> {
3686
3896
  if (result.issues !== undefined) {
3687
3897
  const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
@@ -3710,7 +3920,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3710
3920
  "body",
3711
3921
  )
3712
3922
  }
3713
- return wrapResponse(validationError(result.issues))
3923
+ return wrapResponse(plainValidationError(result.issues))
3714
3924
  }
3715
3925
  ctx.body = result.value
3716
3926
  return this.executeHandler(entry, ctx, finalize)
@@ -3722,7 +3932,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3722
3932
  entry: RouteEntry,
3723
3933
  ctx: RawContext,
3724
3934
  finalize: (result: unknown, set: CtxSet) => T,
3725
- wrapResponse: (response: Response) => T,
3935
+ wrapResponse: (response: Response | ResponseResult) => T,
3726
3936
  ): MaybePromise<T> {
3727
3937
  try {
3728
3938
  // Call the validator directly for the raw StandardResult (read `.issues`/`.value`) - skip
@@ -3760,7 +3970,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3760
3970
  result: StandardResult<unknown>,
3761
3971
  ctx: RawContext,
3762
3972
  finalize: (result: unknown, set: CtxSet) => T,
3763
- wrapResponse: (response: Response) => T,
3973
+ wrapResponse: (response: Response | ResponseResult) => T,
3764
3974
  ): MaybePromise<T> {
3765
3975
  if (result.issues !== undefined) {
3766
3976
  const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
@@ -3789,7 +3999,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3789
3999
  "query",
3790
4000
  )
3791
4001
  }
3792
- return wrapResponse(validationError(result.issues))
4002
+ return wrapResponse(plainValidationError(result.issues))
3793
4003
  }
3794
4004
  ctx.query = result.value
3795
4005
  return this.executeHandler(entry, ctx, finalize, wrapResponse)
@@ -3931,7 +4141,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3931
4141
  source: RequestSource,
3932
4142
  ctx: RawContext,
3933
4143
  finalize: (result: unknown, set: CtxSet) => T,
3934
- wrapResponse: (response: Response) => T,
4144
+ wrapResponse: (response: Response | ResponseResult) => T,
3935
4145
  ): MaybePromise<T> {
3936
4146
  try {
3937
4147
  const headersSchema = entry.schema?.headers
@@ -4004,7 +4214,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4004
4214
  source: RequestSource,
4005
4215
  ctx: RawContext,
4006
4216
  finalize: (result: unknown, set: CtxSet) => T,
4007
- wrapResponse: (response: Response) => T,
4217
+ wrapResponse: (response: Response | ResponseResult) => T,
4008
4218
  result: StandardResult<unknown>,
4009
4219
  ): MaybePromise<T> {
4010
4220
  try {
@@ -4031,7 +4241,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4031
4241
  source: RequestSource,
4032
4242
  ctx: RawContext,
4033
4243
  finalize: (result: unknown, set: CtxSet) => T,
4034
- wrapResponse: (response: Response) => T,
4244
+ wrapResponse: (response: Response | ResponseResult) => T,
4035
4245
  ): MaybePromise<T> {
4036
4246
  const paramsSchema = entry.schema?.params
4037
4247
  if (paramsSchema === undefined) {
@@ -4062,7 +4272,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4062
4272
  entry: RouteEntry,
4063
4273
  ctx: RawContext,
4064
4274
  finalize: (result: unknown, set: CtxSet) => T,
4065
- wrapResponse: (response: Response) => T,
4275
+ wrapResponse: (response: Response | ResponseResult) => T,
4066
4276
  ): MaybePromise<T> {
4067
4277
  try {
4068
4278
  // The execution plan only selects this runner when the route has a query schema. Keeping the
@@ -4096,7 +4306,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4096
4306
  source: RequestSource,
4097
4307
  ctx: RawContext,
4098
4308
  finalize: (result: unknown, set: CtxSet) => T,
4099
- wrapResponse: (response: Response) => T,
4309
+ wrapResponse: (response: Response | ResponseResult) => T,
4100
4310
  ): MaybePromise<T> {
4101
4311
  return this.readAndValidateBody(source, entry, ctx).then(
4102
4312
  (bodyError) =>
@@ -4115,7 +4325,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4115
4325
  source: RequestSource,
4116
4326
  ctx: RawContext,
4117
4327
  finalize: (result: unknown, set: CtxSet) => T,
4118
- wrapResponse: (response: Response) => T,
4328
+ wrapResponse: (response: Response | ResponseResult) => T,
4119
4329
  ): MaybePromise<T> {
4120
4330
  return this.readAndValidateBody(source, entry, ctx).then(
4121
4331
  (bodyError) =>
@@ -4131,7 +4341,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4131
4341
  entry: RouteEntry,
4132
4342
  ctx: RawContext,
4133
4343
  finalize: (result: unknown, set: CtxSet) => T,
4134
- wrapResponse: (response: Response) => T,
4344
+ wrapResponse: (response: Response | ResponseResult) => T,
4135
4345
  ): MaybePromise<T> {
4136
4346
  const querySchema = entry.schema?.query
4137
4347
  return querySchema === undefined
@@ -4143,7 +4353,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4143
4353
  entry: RouteEntry,
4144
4354
  ctx: RawContext,
4145
4355
  finalize: (result: unknown, set: CtxSet) => T,
4146
- wrapResponse: (response: Response) => T,
4356
+ wrapResponse: (response: Response | ResponseResult) => T,
4147
4357
  result: StandardResult<unknown>,
4148
4358
  ): MaybePromise<T> {
4149
4359
  const queryError = this.applyLifecycleValidation(entry, result, ctx, "query")
@@ -4165,7 +4375,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4165
4375
  source: RequestSource,
4166
4376
  ctx: RawContext,
4167
4377
  finalize: (result: unknown, set: CtxSet) => T,
4168
- wrapResponse: (response: Response) => T,
4378
+ wrapResponse: (response: Response | ResponseResult) => T,
4169
4379
  result: StandardResult<unknown>,
4170
4380
  ): MaybePromise<T> {
4171
4381
  try {
@@ -4187,8 +4397,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4187
4397
  source: RequestSource,
4188
4398
  ctx: RawContext,
4189
4399
  finalize: (result: unknown, set: CtxSet) => T,
4190
- wrapResponse: (response: Response) => T,
4191
- paramsError: MaybePromise<Response | undefined>,
4400
+ wrapResponse: (response: Response | ResponseResult) => T,
4401
+ paramsError: MaybePromise<Response | ResponseResult | undefined>,
4192
4402
  ): MaybePromise<T> {
4193
4403
  if (paramsError instanceof Promise) {
4194
4404
  return paramsError.then(
@@ -4212,8 +4422,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4212
4422
  source: RequestSource,
4213
4423
  ctx: RawContext,
4214
4424
  finalize: (result: unknown, set: CtxSet) => T,
4215
- wrapResponse: (response: Response) => T,
4216
- paramsError: Response | undefined,
4425
+ wrapResponse: (response: Response | ResponseResult) => T,
4426
+ paramsError: Response | ResponseResult | undefined,
4217
4427
  ): MaybePromise<T> {
4218
4428
  if (paramsError !== undefined) return wrapResponse(paramsError)
4219
4429
  if (entry.schema?.body !== undefined) {
@@ -4229,8 +4439,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4229
4439
  entry: RouteEntry,
4230
4440
  ctx: RawContext,
4231
4441
  finalize: (result: unknown, set: CtxSet) => T,
4232
- wrapResponse: (response: Response) => T,
4233
- bodyError: Response | undefined,
4442
+ wrapResponse: (response: Response | ResponseResult) => T,
4443
+ bodyError: Response | ResponseResult | undefined,
4234
4444
  ): MaybePromise<T> {
4235
4445
  if (bodyError !== undefined) return wrapResponse(bodyError)
4236
4446
  const querySchema = entry.schema?.query
@@ -4249,7 +4459,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4249
4459
  entry: RouteEntry,
4250
4460
  ctx: RawContext,
4251
4461
  finalize: (result: unknown, set: CtxSet) => T,
4252
- wrapResponse: (response: Response) => T,
4462
+ wrapResponse: (response: Response | ResponseResult) => T,
4253
4463
  result: StandardResult<unknown>,
4254
4464
  ): MaybePromise<T> {
4255
4465
  try {
@@ -4270,8 +4480,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4270
4480
  entry: RouteEntry,
4271
4481
  ctx: RawContext,
4272
4482
  finalize: (result: unknown, set: CtxSet) => T,
4273
- wrapResponse: (response: Response) => T,
4274
- queryError: Response | undefined,
4483
+ wrapResponse: (response: Response | ResponseResult) => T,
4484
+ queryError: Response | ResponseResult | undefined,
4275
4485
  ): MaybePromise<T> {
4276
4486
  if (queryError !== undefined) return wrapResponse(queryError)
4277
4487
  return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
@@ -4281,7 +4491,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4281
4491
  entry: RouteEntry,
4282
4492
  ctx: RawContext,
4283
4493
  finalize: (result: unknown, set: CtxSet) => T,
4284
- wrapResponse: (response: Response) => T,
4494
+ wrapResponse: (response: Response | ResponseResult) => T,
4285
4495
  ): MaybePromise<T> {
4286
4496
  if (entry.lifecycleHookLane === "derive-before") {
4287
4497
  return this.runLifecycleDeriveBefore(entry, ctx, finalize, wrapResponse)
@@ -4293,6 +4503,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4293
4503
  if (extension instanceof Promise) {
4294
4504
  return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, i, extension)
4295
4505
  }
4506
+ if (isDeriveEarlyExit(extension)) return finalize(extension, responseSet(ctx))
4296
4507
  Object.assign(ctx, extension)
4297
4508
  }
4298
4509
  for (let i = 0; i < entry.beforeHandle.length; i++) {
@@ -4320,13 +4531,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4320
4531
  entry: RouteEntry,
4321
4532
  ctx: RawContext,
4322
4533
  finalize: (result: unknown, set: CtxSet) => T,
4323
- wrapResponse: (response: Response) => T,
4534
+ wrapResponse: (response: Response | ResponseResult) => T,
4324
4535
  ): MaybePromise<T> {
4325
4536
  try {
4326
4537
  const extension = entry.derives[0]!(ctx)
4327
4538
  if (extension instanceof Promise) {
4328
4539
  return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, 0, extension)
4329
4540
  }
4541
+ if (isDeriveEarlyExit(extension)) return finalize(extension, responseSet(ctx))
4330
4542
  Object.assign(ctx, extension)
4331
4543
 
4332
4544
  const outcome = entry.beforeHandle[0]!(ctx)
@@ -4354,7 +4566,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4354
4566
  entry: RouteEntry,
4355
4567
  ctx: RawContext,
4356
4568
  finalize: (result: unknown, set: CtxSet) => T,
4357
- wrapResponse: (response: Response) => T,
4569
+ wrapResponse: (response: Response | ResponseResult) => T,
4358
4570
  result: unknown,
4359
4571
  ): MaybePromise<T> {
4360
4572
  try {
@@ -4368,15 +4580,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4368
4580
  entry: RouteEntry,
4369
4581
  ctx: RawContext,
4370
4582
  finalize: (result: unknown, set: CtxSet) => T,
4371
- wrapResponse: (response: Response) => T,
4583
+ wrapResponse: (response: Response | ResponseResult) => T,
4372
4584
  firstIndex: number,
4373
4585
  first: Promise<unknown>,
4374
4586
  ): Promise<T> {
4375
4587
  try {
4376
- Object.assign(ctx, await first)
4588
+ const settled = await first
4589
+ if (isDeriveEarlyExit(settled)) return finalize(settled, responseSet(ctx))
4590
+ Object.assign(ctx, settled)
4377
4591
  for (let i = firstIndex + 1; i < entry.derives.length; i++) {
4378
- const extension = entry.derives[i]!(ctx)
4379
- 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)
4380
4596
  }
4381
4597
  return await this.runLifecycleHooksAsync(entry, ctx, finalize, wrapResponse)
4382
4598
  } catch (err) {
@@ -4388,7 +4604,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4388
4604
  entry: RouteEntry,
4389
4605
  ctx: RawContext,
4390
4606
  finalize: (result: unknown, set: CtxSet) => T,
4391
- wrapResponse: (response: Response) => T,
4607
+ wrapResponse: (response: Response | ResponseResult) => T,
4392
4608
  ): Promise<T> {
4393
4609
  try {
4394
4610
  // Indexed, not `for...of`. An array iterator declared inside an async function stays live
@@ -4410,7 +4626,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4410
4626
  entry: RouteEntry,
4411
4627
  ctx: RawContext,
4412
4628
  finalize: (result: unknown, set: CtxSet) => T,
4413
- wrapResponse: (response: Response) => T,
4629
+ wrapResponse: (response: Response | ResponseResult) => T,
4414
4630
  firstIndex: number,
4415
4631
  first: Promise<unknown>,
4416
4632
  ): Promise<T> {
@@ -4432,7 +4648,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4432
4648
  entry: RouteEntry,
4433
4649
  ctx: RawContext,
4434
4650
  finalize: (result: unknown, set: CtxSet) => T,
4435
- wrapResponse: (response: Response) => T,
4651
+ wrapResponse: (response: Response | ResponseResult) => T,
4436
4652
  ): Promise<T> {
4437
4653
  try {
4438
4654
  const output = entry.handler(ctx)
@@ -4447,7 +4663,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4447
4663
  entry: RouteEntry,
4448
4664
  ctx: RawContext,
4449
4665
  finalize: (result: unknown, set: CtxSet) => T,
4450
- wrapResponse: (response: Response) => T,
4666
+ wrapResponse: (response: Response | ResponseResult) => T,
4451
4667
  initial: unknown,
4452
4668
  ): MaybePromise<T> {
4453
4669
  try {
@@ -4476,7 +4692,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4476
4692
  entry: RouteEntry,
4477
4693
  ctx: RawContext,
4478
4694
  finalize: (result: unknown, set: CtxSet) => T,
4479
- wrapResponse: (response: Response) => T,
4695
+ wrapResponse: (response: Response | ResponseResult) => T,
4480
4696
  firstIndex: number,
4481
4697
  first: Promise<unknown>,
4482
4698
  ): Promise<T> {
@@ -4496,7 +4712,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4496
4712
  entry: RouteEntry,
4497
4713
  ctx: RawContext,
4498
4714
  finalize: (result: unknown, set: CtxSet) => T,
4499
- wrapResponse: (response: Response) => T,
4715
+ wrapResponse: (response: Response | ResponseResult) => T,
4500
4716
  result: unknown,
4501
4717
  ): MaybePromise<T> {
4502
4718
  try {
@@ -4518,7 +4734,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4518
4734
  private finishContractOutcome<T>(
4519
4735
  ctx: RawContext,
4520
4736
  finalize: (result: unknown, set: CtxSet) => T,
4521
- wrapResponse: (response: Response) => T,
4737
+ wrapResponse: (response: Response | ResponseResult) => T,
4522
4738
  outcome: {
4523
4739
  readonly kind: "ok" | "warn" | "violation"
4524
4740
  readonly value?: unknown
@@ -4531,7 +4747,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4531
4747
  path: pathnameOf(ctx.req.url),
4532
4748
  detail: outcome.message,
4533
4749
  })
4534
- return wrapResponse(jsonError(500, "internal_error"))
4750
+ return wrapResponse(plainError(500, "internal_error"))
4535
4751
  }
4536
4752
  if (outcome.kind === "warn") {
4537
4753
  this.logger.warn("response contract", {
@@ -4543,19 +4759,44 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4543
4759
  return finalize(outcome.value, responseSet(ctx))
4544
4760
  }
4545
4761
 
4546
- 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>(
4547
4768
  entry: RouteEntry,
4548
4769
  err: unknown,
4549
4770
  ctx: RawContext,
4550
4771
  finalize: (result: unknown, set: CtxSet) => T,
4551
- wrapResponse: (response: Response) => T,
4552
- ): Promise<T> {
4772
+ wrapResponse: (response: Response | ResponseResult) => T,
4773
+ ): MaybePromise<T> {
4553
4774
  // A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
4554
4775
  // an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
4555
4776
  // or log it as a 500. This is what makes `throw redirect(...)` / `requireSession(...)` work from
4556
4777
  // any handler or loader.
4557
4778
  if (err instanceof Response) return wrapResponse(err)
4558
- // 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> {
4559
4800
  // Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
4560
4801
  // the `await` and stay heap-allocated on both engines.
4561
4802
  for (let i = 0; i < entry.onError.length; i++) {
@@ -4563,51 +4804,35 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4563
4804
  const handled = outcome instanceof Promise ? await outcome : outcome
4564
4805
  if (handled !== undefined) return finalize(handled, responseSet(ctx))
4565
4806
  }
4566
- // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
4567
- // (redacting) logger. Body-read failures and around-hook failures land here too.
4568
4807
  this.logRequestError(err, ctx)
4569
- return wrapResponse(jsonError(500, "internal_error"))
4808
+ return wrapResponse(plainError(500, "internal_error"))
4570
4809
  }
4571
4810
 
4572
4811
  /** Log an unhandled request error to the (redacting) logger - shared by {@link runLifecycle} and the
4573
4812
  * bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
4574
4813
  private logRequestError(err: unknown, ctx: RawContext): void {
4575
- // An error's own text can quote the input that produced it, so how much of it reaches the sink is
4576
- // the app's call (`errorLogDetail`). The default keeps it: a 500 with no message and no stack is
4577
- // an incident nobody can diagnose, and the framework already ships the narrower instrument for
4578
- // the leak - a redacting logger with `valuePatterns`. `"none"` is there for an untrusted sink.
4579
- const detail = this.errorLogDetail
4580
- this.logger.error("unhandled request error", {
4581
- method: ctx.req.method,
4582
- path: pathnameOf(ctx.req.url),
4583
- name: err instanceof Error ? err.name : "Error",
4584
- // `detail`, not `message`: the logger uses `message` for its own first argument, so a field of
4585
- // that name is silently overwritten and the thrown error's own text never reaches the sink. It
4586
- // survived only incidentally inside `stack`, and was lost outright for a non-Error throw.
4587
- ...(detail === "none" ? {} : { detail: err instanceof Error ? err.message : String(err) }),
4588
- ...(detail === "full" && err instanceof Error ? { stack: err.stack } : {}),
4589
- })
4814
+ emitRequestErrorLog(this.logger, this.errorLogDetail, err, ctx)
4590
4815
  }
4591
4816
 
4592
4817
  private async readAndValidateBody(
4593
4818
  req: RequestSource,
4594
4819
  entry: RouteEntry,
4595
4820
  ctx: RawContext,
4596
- ): Promise<Response | undefined> {
4821
+ ): Promise<Response | ResponseResult | undefined> {
4597
4822
  const contentType = headerOf(req, "content-type") ?? ""
4598
4823
  let parsed: unknown
4599
4824
  if (contentType === "application/json" || contentType.includes("application/json")) {
4600
- const json = await this.readBoundedJson(req, entry.bodyLimit ?? this.maxBodyBytes)
4601
- if (json instanceof Response) return json
4825
+ const json = await this.readBoundedJson(req, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
4826
+ if (isResponseResult(json)) return json
4602
4827
  parsed = json
4603
4828
  } else if (isUrlEncodedForm(contentType)) {
4604
- const form = await readBoundedForm(req, entry.bodyLimit ?? this.maxBodyBytes)
4605
- if (form instanceof Response) return form
4829
+ const form = await readBoundedForm(req, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
4830
+ if (isResponseResult(form)) return form
4606
4831
  parsed = form
4607
4832
  } else {
4608
4833
  // multipart/form-data (file uploads) stays 415 on the schema path by design - files don't
4609
4834
  // fit a value schema; use a schema-less route + @nifrajs/uploads helpers for those.
4610
- return jsonError(415, "unsupported_media_type")
4835
+ return plainError(415, "unsupported_media_type")
4611
4836
  }
4612
4837
  const validation = entry.schema!.body!["~standard"].validate(parsed)
4613
4838
  const result = validation instanceof Promise ? await validation : validation
@@ -4621,7 +4846,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4621
4846
  result: StandardResult<unknown>,
4622
4847
  ctx: RawContext,
4623
4848
  kind: "body" | "query" | "params" | "headers",
4624
- ): MaybePromise<Response | undefined> {
4849
+ ): MaybePromise<Response | ResponseResult | undefined> {
4625
4850
  const assign = (value: unknown): void => {
4626
4851
  if (kind === "body") ctx.body = value
4627
4852
  else if (kind === "query") ctx.query = value
@@ -4633,7 +4858,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4633
4858
  return undefined
4634
4859
  }
4635
4860
  const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
4636
- if (hook === undefined) return validationError(result.issues)
4861
+ if (hook === undefined) return plainValidationError(result.issues)
4637
4862
  const attempted = hook(result.issues, ctx as unknown as Context, kind)
4638
4863
  if (attempted instanceof Promise) {
4639
4864
  return attempted.then((recovery) =>
@@ -4649,8 +4874,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4649
4874
  issues: ReadonlyArray<StandardIssue>,
4650
4875
  recovery: unknown,
4651
4876
  assign: (value: unknown) => void,
4652
- ): MaybePromise<Response | undefined> {
4653
- if (recovery === undefined) return validationError(issues)
4877
+ ): MaybePromise<Response | ResponseResult | undefined> {
4878
+ if (recovery === undefined) return plainValidationError(issues)
4654
4879
  if (recovery instanceof Response) return recovery
4655
4880
  const schema =
4656
4881
  kind === "body"
@@ -4663,12 +4888,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4663
4888
  const retried = schema!["~standard"].validate(recovery)
4664
4889
  if (retried instanceof Promise) {
4665
4890
  return retried.then((settled) => {
4666
- if (settled.issues !== undefined) return validationError(settled.issues)
4891
+ if (settled.issues !== undefined) return plainValidationError(settled.issues)
4667
4892
  assign(settled.value)
4668
4893
  return undefined
4669
4894
  })
4670
4895
  }
4671
- if (retried.issues !== undefined) return validationError(retried.issues)
4896
+ if (retried.issues !== undefined) return plainValidationError(retried.issues)
4672
4897
  assign(retried.value)
4673
4898
  return undefined
4674
4899
  }
@@ -4680,19 +4905,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4680
4905
  * length-less body mid-stream once the running byte count exceeds the cap - so a lying
4681
4906
  * or absent length can't force us to buffer an oversized payload.
4682
4907
  */
4683
- private readBoundedJson(req: RequestSource, maxBodyBytes?: number): Promise<unknown | Response>
4908
+ private readBoundedJson(
4909
+ req: RequestSource,
4910
+ maxBodyBytes?: number,
4911
+ ): Promise<unknown | ResponseResult>
4684
4912
  private readBoundedJson<T>(
4685
4913
  req: RequestSource,
4686
4914
  maxBodyBytes: number,
4687
- onResult: (parsed: unknown | Response) => T | Promise<T>,
4915
+ onResult: (parsed: unknown | ResponseResult) => T | Promise<T>,
4688
4916
  onError: (error: unknown) => T | Promise<T>,
4689
4917
  ): Promise<T>
4690
4918
  private readBoundedJson<T>(
4691
4919
  req: RequestSource,
4692
4920
  maxBodyBytes = this.maxBodyBytes,
4693
- onResult?: (parsed: unknown | Response) => T | Promise<T>,
4921
+ onResult?: (parsed: unknown | ResponseResult) => T | Promise<T>,
4694
4922
  onError?: (error: unknown) => T | Promise<T>,
4695
- ): Promise<unknown | Response | T> {
4923
+ ): Promise<unknown | ResponseResult | T> {
4696
4924
  // Not `async`: this is a pass-through, and wrapping the lane's own promise in a coroutine
4697
4925
  // frame costs an extra microtask hop on every JSON body.
4698
4926
  return onResult === undefined
@@ -4873,6 +5101,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4873
5101
  "listen() uses Bun.serve and runs only on Bun. Serve on Node with @nifrajs/node or on Deno with @nifrajs/deno, or hand app.fetch to any fetch-compatible runtime (Workers, etc.).",
4874
5102
  )
4875
5103
  }
5104
+ // Seal before Bun.serve: in `"error"` mode this throws instead of leaving a bound port behind.
5105
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
5106
+ sealHookAudit(this, this.catalog.size, this.logger)
4876
5107
  // Bun's `Server` is the concrete handle; we expose the stable `RunningServer`
4877
5108
  // subset so the public types don't depend on the ambient `Bun` global. The cast
4878
5109
  // bridges them - Bun's `.port` is `number | undefined` (undefined only for unix