@nifrajs/core 2.14.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +25 -0
  2. package/dist/assurance.d.ts +16 -8
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +33 -0
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/durable-adapters.d.ts.map +1 -1
  7. package/dist/durable-adapters.js +26 -3
  8. package/dist/durable-adapters.js.map +1 -1
  9. package/dist/durable-approval.d.ts +56 -0
  10. package/dist/durable-approval.d.ts.map +1 -0
  11. package/dist/durable-approval.js +233 -0
  12. package/dist/durable-approval.js.map +1 -0
  13. package/dist/durable-effect.d.ts +42 -0
  14. package/dist/durable-effect.d.ts.map +1 -0
  15. package/dist/durable-effect.js +234 -0
  16. package/dist/durable-effect.js.map +1 -0
  17. package/dist/durable-execution.d.ts +8 -346
  18. package/dist/durable-execution.d.ts.map +1 -1
  19. package/dist/durable-execution.js +7 -1150
  20. package/dist/durable-execution.js.map +1 -1
  21. package/dist/durable-saga.d.ts +94 -0
  22. package/dist/durable-saga.d.ts.map +1 -0
  23. package/dist/durable-saga.js +550 -0
  24. package/dist/durable-saga.js.map +1 -0
  25. package/dist/edge-kit.d.ts +27 -0
  26. package/dist/edge-kit.d.ts.map +1 -0
  27. package/dist/edge-kit.js +24 -0
  28. package/dist/edge-kit.js.map +1 -0
  29. package/dist/errors.d.ts +1 -1
  30. package/dist/errors.d.ts.map +1 -1
  31. package/dist/errors.js.map +1 -1
  32. package/dist/index.d.ts +23 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +18 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/internal/durable-approval-protocol.d.ts +1 -1
  37. package/dist/internal/durable-approval-protocol.d.ts.map +1 -1
  38. package/dist/internal/durable-effect-protocol.d.ts +1 -1
  39. package/dist/internal/durable-effect-protocol.d.ts.map +1 -1
  40. package/dist/internal/durable-saga-protocol.d.ts +1 -1
  41. package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
  42. package/dist/internal/durable-shared.d.ts +26 -0
  43. package/dist/internal/durable-shared.d.ts.map +1 -0
  44. package/dist/internal/durable-shared.js +131 -0
  45. package/dist/internal/durable-shared.js.map +1 -0
  46. package/dist/internal/durable-types.d.ts +159 -0
  47. package/dist/internal/durable-types.d.ts.map +1 -0
  48. package/dist/internal/durable-types.js +2 -0
  49. package/dist/internal/durable-types.js.map +1 -0
  50. package/dist/internal/route-compiler.d.ts +61 -0
  51. package/dist/internal/route-compiler.d.ts.map +1 -0
  52. package/dist/internal/route-compiler.js +121 -0
  53. package/dist/internal/route-compiler.js.map +1 -0
  54. package/dist/internal/route-execution.d.ts +17 -9
  55. package/dist/internal/route-execution.d.ts.map +1 -1
  56. package/dist/internal/route-execution.js +4 -18
  57. package/dist/internal/route-execution.js.map +1 -1
  58. package/dist/internal/route-lanes.d.ts +35 -0
  59. package/dist/internal/route-lanes.d.ts.map +1 -0
  60. package/dist/internal/route-lanes.js +150 -0
  61. package/dist/internal/route-lanes.js.map +1 -0
  62. package/dist/internal/route-program.d.ts +67 -0
  63. package/dist/internal/route-program.d.ts.map +1 -0
  64. package/dist/internal/route-program.js +199 -0
  65. package/dist/internal/route-program.js.map +1 -0
  66. package/dist/response-observer.d.ts +25 -0
  67. package/dist/response-observer.d.ts.map +1 -0
  68. package/dist/response-observer.js +40 -0
  69. package/dist/response-observer.js.map +1 -0
  70. package/dist/security/baseline.d.ts +68 -0
  71. package/dist/security/baseline.d.ts.map +1 -0
  72. package/dist/security/baseline.js +118 -0
  73. package/dist/security/baseline.js.map +1 -0
  74. package/dist/server/bare-error-lane.d.ts +26 -0
  75. package/dist/server/bare-error-lane.d.ts.map +1 -0
  76. package/dist/server/bare-error-lane.js +60 -0
  77. package/dist/server/bare-error-lane.js.map +1 -0
  78. package/dist/server/body-lane.d.ts +13 -0
  79. package/dist/server/body-lane.d.ts.map +1 -0
  80. package/dist/server/body-lane.js +134 -0
  81. package/dist/server/body-lane.js.map +1 -0
  82. package/dist/server/body.d.ts +7 -0
  83. package/dist/server/body.d.ts.map +1 -1
  84. package/dist/server/body.js +7 -0
  85. package/dist/server/body.js.map +1 -1
  86. package/dist/server/bridge-markers.d.ts +25 -0
  87. package/dist/server/bridge-markers.d.ts.map +1 -0
  88. package/dist/server/bridge-markers.js +25 -0
  89. package/dist/server/bridge-markers.js.map +1 -0
  90. package/dist/server/context.d.ts +6 -1
  91. package/dist/server/context.d.ts.map +1 -1
  92. package/dist/server/contract.d.ts +40 -14
  93. package/dist/server/contract.d.ts.map +1 -1
  94. package/dist/server/contract.js +7 -0
  95. package/dist/server/contract.js.map +1 -1
  96. package/dist/server/cookies.d.ts.map +1 -1
  97. package/dist/server/cookies.js +16 -2
  98. package/dist/server/cookies.js.map +1 -1
  99. package/dist/server/header-case.d.ts.map +1 -1
  100. package/dist/server/header-case.js +2 -1
  101. package/dist/server/header-case.js.map +1 -1
  102. package/dist/server/http.d.ts +36 -3
  103. package/dist/server/http.d.ts.map +1 -1
  104. package/dist/server/http.js +49 -3
  105. package/dist/server/http.js.map +1 -1
  106. package/dist/server/install.d.ts +6 -8
  107. package/dist/server/install.d.ts.map +1 -1
  108. package/dist/server/install.js +7 -0
  109. package/dist/server/install.js.map +1 -1
  110. package/dist/server/node-outcome-hook.d.ts +2 -1
  111. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  112. package/dist/server/node-outcome-hook.js.map +1 -1
  113. package/dist/server/node-outcome.d.ts +17 -2
  114. package/dist/server/node-outcome.d.ts.map +1 -1
  115. package/dist/server/node-outcome.js +37 -3
  116. package/dist/server/node-outcome.js.map +1 -1
  117. package/dist/server/plugin.d.ts +2 -2
  118. package/dist/server/plugin.d.ts.map +1 -1
  119. package/dist/server/plugin.js +6 -1
  120. package/dist/server/plugin.js.map +1 -1
  121. package/dist/server/query.d.ts +7 -1
  122. package/dist/server/query.d.ts.map +1 -1
  123. package/dist/server/query.js +8 -3
  124. package/dist/server/query.js.map +1 -1
  125. package/dist/server/registry.d.ts +40 -12
  126. package/dist/server/registry.d.ts.map +1 -1
  127. package/dist/server/request-context.d.ts +2 -13
  128. package/dist/server/request-context.d.ts.map +1 -1
  129. package/dist/server/request-context.js +18 -159
  130. package/dist/server/request-context.js.map +1 -1
  131. package/dist/server/respond.d.ts.map +1 -1
  132. package/dist/server/respond.js +58 -4
  133. package/dist/server/respond.js.map +1 -1
  134. package/dist/server/response-contract-lane.d.ts.map +1 -1
  135. package/dist/server/response-contract-lane.js +5 -2
  136. package/dist/server/response-contract-lane.js.map +1 -1
  137. package/dist/server/response-observer-runtime.d.ts +20 -0
  138. package/dist/server/response-observer-runtime.d.ts.map +1 -0
  139. package/dist/server/response-observer-runtime.js +95 -0
  140. package/dist/server/response-observer-runtime.js.map +1 -0
  141. package/dist/server/runtime-core.d.ts +76 -0
  142. package/dist/server/runtime-core.d.ts.map +1 -1
  143. package/dist/server/runtime-core.js +42 -1
  144. package/dist/server/runtime-core.js.map +1 -1
  145. package/dist/server/server-types.d.ts +6 -1
  146. package/dist/server/server-types.d.ts.map +1 -1
  147. package/dist/server/server.d.ts +158 -116
  148. package/dist/server/server.d.ts.map +1 -1
  149. package/dist/server/server.js +707 -844
  150. package/dist/server/server.js.map +1 -1
  151. package/dist/server/validation.d.ts +5 -0
  152. package/dist/server/validation.d.ts.map +1 -0
  153. package/dist/server/validation.js +13 -0
  154. package/dist/server/validation.js.map +1 -0
  155. package/dist/server/websocket.d.ts +20 -4
  156. package/dist/server/websocket.d.ts.map +1 -1
  157. package/dist/server/websocket.js +81 -11
  158. package/dist/server/websocket.js.map +1 -1
  159. package/dist/server/ws-bun.d.ts +12 -4
  160. package/dist/server/ws-bun.d.ts.map +1 -1
  161. package/dist/server/ws-bun.js +50 -16
  162. package/dist/server/ws-bun.js.map +1 -1
  163. package/dist/server/ws-hook.d.ts +3 -2
  164. package/dist/server/ws-hook.d.ts.map +1 -1
  165. package/dist/server.d.ts +3 -2
  166. package/dist/server.d.ts.map +1 -1
  167. package/dist/server.js +2 -1
  168. package/dist/server.js.map +1 -1
  169. package/dist/single-copy-register.d.ts +2 -0
  170. package/dist/single-copy-register.d.ts.map +1 -0
  171. package/dist/single-copy-register.js +24 -0
  172. package/dist/single-copy-register.js.map +1 -0
  173. package/dist/single-copy.d.ts +156 -0
  174. package/dist/single-copy.d.ts.map +1 -0
  175. package/dist/single-copy.js +472 -0
  176. package/dist/single-copy.js.map +1 -0
  177. package/package.json +28 -3
  178. package/src/assurance.ts +58 -1
  179. package/src/durable-adapters.ts +20 -0
  180. package/src/durable-approval.ts +281 -0
  181. package/src/durable-effect.ts +300 -0
  182. package/src/durable-execution.ts +8 -1627
  183. package/src/durable-saga.ts +744 -0
  184. package/src/edge-kit.ts +27 -0
  185. package/src/errors.ts +1 -0
  186. package/src/index.ts +129 -2
  187. package/src/internal/durable-approval-protocol.ts +1 -1
  188. package/src/internal/durable-effect-protocol.ts +1 -1
  189. package/src/internal/durable-saga-protocol.ts +1 -1
  190. package/src/internal/durable-shared.ts +157 -0
  191. package/src/internal/durable-types.ts +194 -0
  192. package/src/internal/route-compiler.ts +234 -0
  193. package/src/internal/route-execution.ts +53 -96
  194. package/src/internal/route-lanes.ts +221 -0
  195. package/src/internal/route-program.ts +404 -0
  196. package/src/response-observer.ts +64 -0
  197. package/src/security/baseline.ts +172 -0
  198. package/src/server/bare-error-lane.ts +77 -0
  199. package/src/server/body-lane.ts +203 -0
  200. package/src/server/body.ts +8 -0
  201. package/src/server/bridge-markers.ts +24 -0
  202. package/src/server/context.ts +6 -1
  203. package/src/server/contract.ts +70 -20
  204. package/src/server/cookies.ts +16 -2
  205. package/src/server/header-case.ts +3 -1
  206. package/src/server/http.ts +53 -3
  207. package/src/server/install.ts +14 -0
  208. package/src/server/node-outcome-hook.ts +2 -1
  209. package/src/server/node-outcome.ts +44 -3
  210. package/src/server/plugin.ts +11 -5
  211. package/src/server/query.ts +10 -4
  212. package/src/server/registry.ts +71 -16
  213. package/src/server/request-context.ts +13 -216
  214. package/src/server/respond.ts +71 -3
  215. package/src/server/response-contract-lane.ts +6 -2
  216. package/src/server/response-observer-runtime.ts +135 -0
  217. package/src/server/runtime-core.ts +124 -1
  218. package/src/server/server-types.ts +6 -1
  219. package/src/server/server.ts +1446 -1485
  220. package/src/server/validation.ts +19 -0
  221. package/src/server/websocket.ts +127 -17
  222. package/src/server/ws-bun.ts +67 -15
  223. package/src/server/ws-hook.ts +3 -2
  224. package/src/server.ts +8 -2
  225. package/src/single-copy-register.ts +24 -0
  226. package/src/single-copy.ts +526 -0
@@ -12,22 +12,18 @@ import { FrameworkError, RouteConfigError } from "../errors.ts"
12
12
  import type { Version } from "../index.ts"
13
13
  import {
14
14
  type AroundCapabilityOptions,
15
- CAPABILITY_GUARD,
16
15
  type CapabilityInterceptor,
17
16
  type CapabilityUseEvent,
18
- createCapabilityGuard,
19
17
  DEFAULT_CAPABILITY_INTERCEPTOR_TIMEOUT_MS,
20
- normalizeRouteCapabilities,
21
18
  type RegisteredCapabilityInterceptor,
22
19
  } from "../internal/capability-runtime.ts"
23
20
  import {
24
21
  type AssuranceDeclaration,
25
22
  assuranceDeclarationsOf,
26
23
  assuranceEvidenceFor,
27
- NIFRA_ASSURANCE_IDS,
28
- validEvidenceId,
29
24
  } from "../internal/route-assurance.ts"
30
25
  import { type CatalogRoute, RouteCatalog } from "../internal/route-catalog.ts"
26
+ import { compileRouteOptions } from "../internal/route-compiler.ts"
31
27
  import {
32
28
  compileRouteExecutionPlan,
33
29
  type FusedBodyRunner,
@@ -40,8 +36,11 @@ import {
40
36
  type RawErrorHandler,
41
37
  type RouteEntry,
42
38
  } from "../internal/route-execution.ts"
39
+ import type { RouteProgramStage } from "../internal/route-program.ts"
40
+ import { compileRouteProgram, executeRouteProgram } from "../internal/route-program.ts"
43
41
  import { isSameOriginRequest } from "../internal/same-origin.ts"
44
- import { compileRoutePattern, decodeRouteParams } from "../router/pattern.ts"
42
+ import type { ResponseObserverPlugin } from "../response-observer.ts"
43
+ import { decodeRouteParams } from "../router/pattern.ts"
45
44
  import { EMPTY_PARAMS, type Method, Router } from "../router/router.ts"
46
45
  import type {
47
46
  InferOutput,
@@ -49,11 +48,13 @@ import type {
49
48
  StandardResult,
50
49
  StandardSchemaV1,
51
50
  } from "../schema/standard.ts"
51
+ import { emitRequestErrorLog, renderBareError } from "./bare-error-lane.ts"
52
52
  import {
53
53
  assertByteLimit,
54
54
  markTransportCap,
55
55
  markTrustedBodyFraming,
56
56
  type RawBodyReaders,
57
+ UNLIMITED_BODY_BYTES,
57
58
  } from "./body.ts"
58
59
  import { type ClientIpTrust, resolveClientIp } from "./client-ip.ts"
59
60
  import type { Context, Platform, ResponseControls, RouteSchema } from "./context.ts"
@@ -63,42 +64,35 @@ import {
63
64
  markLowercaseHeaderKeys,
64
65
  } from "./header-case.ts"
65
66
  import { headerObjectOf } from "./headers.ts"
66
- import { jsonError, pathnameOf, type UrlParts, urlPartsOf } from "./http.ts"
67
+ import { jsonError, pathnameOf, plainError, type UrlParts, urlPartsOf } from "./http.ts"
67
68
  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,
69
+ import type {
70
+ NodeOutcomeRuntime,
71
+ NodeRequestContext,
72
+ NodeRequestHook,
73
+ NodeResponseContext,
74
+ NodeResponseHook,
75
+ ResponseBodyHook,
76
+ ResponseBodyReplacement,
77
+ ResponseHeadersHook,
78
+ ResponseHeadersView,
79
79
  } from "./node-outcome-hook.ts"
80
- import {
81
- isUrlEncodedForm,
82
- type QueryValue,
83
- queryObjectOf,
84
- readBoundedForm,
85
- searchOf,
86
- } from "./query.ts"
87
- import { RequestContext, readBoundedJsonSource } from "./request-context.ts"
80
+ import { type QueryValue, queryObjectOf, searchOf } from "./query.ts"
81
+ import { RequestContext, readBodyFramed } from "./request-context.ts"
88
82
  import {
89
83
  applyStaticResponseHeaders,
90
84
  buildStaticResponseHeaders,
91
85
  fusedRespond,
92
86
  fusedRespondNoSet,
93
- knownMutableHeaders,
94
- rememberMutableHeaders,
95
- taggedResponseBody,
96
87
  toResponse,
97
88
  } from "./respond.ts"
98
89
  // Type-only: erased, so the kernel never pulls the lane's implementation into a bundle that does not
99
90
  // install the plugin. The value side arrives through the symbol-keyed install seam.
100
91
  import type { ResponseContractRuntime } from "./response-contract-lane.ts"
101
- import { applyBodyReplacement, withReplacedBody } from "./response-hooks.ts"
92
+ import type {
93
+ ResponseObserverMethods,
94
+ ResponseObserverRuntime,
95
+ } from "./response-observer-runtime.ts"
102
96
  import {
103
97
  CONTEXT_SEARCH,
104
98
  CONTEXT_SET,
@@ -107,9 +101,13 @@ import {
107
101
  getUnboundedRequestBudget,
108
102
  type HandlerResult,
109
103
  headerOf,
104
+ isResponseResult,
105
+ type ResponseResult,
110
106
  requestOf,
107
+ type StatusResponse,
111
108
  } from "./runtime-core.ts"
112
109
  import { normalizeStaticResponseHeaders, type StaticResponseHeaders } from "./static-headers.ts"
110
+ import { plainValidationError } from "./validation.ts"
113
111
 
114
112
  // NodeServeOutcome (the nifra<->node bridge render form) now lives in `./node-outcome.ts`; re-exported
115
113
  // so existing importers keep resolving it from the server module.
@@ -125,96 +123,6 @@ export type {
125
123
  ResponseHeadersView,
126
124
  }
127
125
 
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
126
  import type { IdempotencyRuntime } from "./idempotency-lane.ts"
219
127
  import {
220
128
  INSTALL_EFFECT_LEDGER,
@@ -222,10 +130,13 @@ import {
222
130
  INSTALL_MCP,
223
131
  INSTALL_NODE_DIRECT,
224
132
  INSTALL_RESPONSE_CONTRACT,
133
+ INSTALL_RESPONSE_OBSERVER,
225
134
  INSTALL_SSE,
226
135
  INSTALL_WS,
136
+ NODE_NATIVE_MOUNT,
137
+ RESOLVE_NODE_MOUNT,
227
138
  } from "./install.ts"
228
- import type { EffectLedgerRuntime, ResolvedEffectLedger } from "./ledger-lane.ts"
139
+ import type { EffectLedgerRuntime } from "./ledger-lane.ts"
229
140
  import { jsonLogger, type Logger } from "./logger.ts"
230
141
  import type { McpRuntime } from "./mcp-hook.ts"
231
142
  import type {
@@ -363,6 +274,22 @@ interface BunUpgradeServer {
363
274
 
364
275
  type MountedFetchHandler = (request: Request, platform?: Platform) => MaybePromise<Response>
365
276
 
277
+ /** Structural native mount contract. The serving adapter owns the concrete Node request/response
278
+ * types; the kernel only selects a handler after proving that taking this lane cannot skip its
279
+ * global lifecycle. `false` means the capability was unavailable at runtime, so the adapter must
280
+ * retry the ordinary Web mount with the same untouched source. */
281
+ type NativeMountHandler = (
282
+ request: unknown,
283
+ response: unknown,
284
+ platform?: Platform,
285
+ ) => MaybePromise<undefined | false>
286
+
287
+ interface NativeMountSelection {
288
+ readonly handler: NativeMountHandler
289
+ readonly path: string
290
+ readonly stripPrefix: boolean
291
+ }
292
+
366
293
  interface FetchMount {
367
294
  readonly path: string
368
295
  readonly handler: MountedFetchHandler
@@ -432,12 +359,38 @@ function requireMcpRuntime(runtime: McpRuntime | undefined): McpRuntime {
432
359
  }
433
360
 
434
361
  /** The handler's permitted return type. When the route declares a `response` schema, the return is
435
- * constrained to the contract's type (or a raw `Response`) - so the implementation can't drift from the
436
- * declared contract. Without a `response` schema it's unconstrained (`HandlerResult`), exactly as before. */
362
+ * constrained to the contract's type (a raw `Response`, or a `status(...)` early exit - both are
363
+ * control flow, not the declared payload) - so the implementation can't drift from the declared
364
+ * contract. Without a `response` schema it's unconstrained (`HandlerResult`), exactly as before. */
437
365
  type ResponseOf<S extends RouteSchema> = S extends { response: infer R extends StandardSchemaV1 }
438
- ? InferOutput<R> | Response
366
+ ? InferOutput<R> | Response | ResponseResult
439
367
  : HandlerResult
440
368
 
369
+ /** A typed status result is control flow, not a context extension. */
370
+ type StatusResponseOf<T> = T extends StatusResponse<number, unknown> ? T : never
371
+
372
+ type ContextExtensionPart<T> = T extends object ? T : EmptyContext
373
+ type ContextExtensionOf<T> = [Exclude<Awaited<T>, Response | ResponseResult>] extends [never]
374
+ ? EmptyContext
375
+ : ContextExtensionPart<Exclude<Awaited<T>, Response | ResponseResult>>
376
+
377
+ /** Keep concrete lifecycle return values for route inference, but don't let an untyped hook widen every route to unknown. */
378
+ type HookOutputOf<T> = unknown extends Awaited<T> ? never : Exclude<Awaited<T>, void>
379
+
380
+ type ReturnOfHookField<M, K extends PropertyKey> = M extends {
381
+ readonly [P in K]?: infer F
382
+ }
383
+ ? F extends (...args: never[]) => infer R
384
+ ? HookOutputOf<R>
385
+ : never
386
+ : never
387
+
388
+ type MiddlewareOutputOf<M> =
389
+ | ReturnOfHookField<M, "beforeHandle">
390
+ | ReturnOfHookField<M, "afterHandle">
391
+ | ReturnOfHookField<M, "onError">
392
+ | ReturnOfHookField<M, "around">
393
+
441
394
  /**
442
395
  * Public handler shape: context typed from the path, the (optional) schema, and
443
396
  * any accumulated middleware context `Ctx` (from `derive`/`decorate`).
@@ -471,7 +424,7 @@ export type {
471
424
  // A plugin operates over arbitrary Server shapes; `any` here is the standard framework escape hatch
472
425
  // (the precise threading happens at the `use` call site, which is generic over the *concrete* `this`).
473
426
  // biome-ignore lint/suspicious/noExplicitAny: plugins are generic over any Server's Registry/Context
474
- export type AnyServer = Server<any, any>
427
+ export type AnyServer = Server<any, any, any>
475
428
 
476
429
  // Plugin definers + their types now live in `./plugin.ts`; re-exported here so `.use()` callers and
477
430
  // existing importers keep resolving them from the server module.
@@ -497,17 +450,13 @@ const STOP_HOOK_TIMEOUT_MS = 5_000
497
450
  * for the same request, so a browser that could open a socket was told its POST was cross-origin. */
498
451
  const wsSameOrigin = isSameOriginRequest
499
452
 
500
- function validationError(issues: ReadonlyArray<StandardIssue>): Response {
501
- const serialized = issues.map((issue) => {
502
- const path = issue.path?.map((seg) => String(typeof seg === "object" ? seg.key : seg))
503
- return path !== undefined ? { message: issue.message, path } : { message: issue.message }
504
- })
505
- return Response.json({ ok: false, error: "validation", issues: serialized }, { status: 422 })
506
- }
507
-
508
453
  // `jsonError`, `urlPartsOf`, `pathnameOf` moved to `./http.ts` (a dependency-free leaf shared with the
509
454
  // opt-in request lanes); re-exported so existing importers keep resolving from here.
510
455
  export { pathnameOf, urlPartsOf } from "./http.ts"
456
+ /** The 422 as plain data - the shape every lane's response wrapper takes, so a rejected body costs
457
+ * what an accepted one costs: on Node it is written straight to the socket with a `content-length`
458
+ * instead of being built as a `Response` and drained back out. */
459
+ export { plainValidationError } from "./validation.ts"
511
460
  // Query-string + urlencoded-form parsing now lives in `./query.ts`; re-exported so existing
512
461
  // importers keep resolving `searchOf`/`queryObjectOf`/`QueryValue` from here.
513
462
  export { type QueryValue, queryObjectOf, searchOf }
@@ -577,8 +526,17 @@ type FeatureVersionOf<V extends string> = V extends `${infer Major}.${infer Mino
577
526
  */
578
527
  export type NifraFeatureVersion = FeatureVersionOf<Version>
579
528
 
529
+ /**
530
+ * The Web lanes' `wrapResponse` input, as a `Response`. A plain-data carrier ({@link plainError},
531
+ * `status(...)`) is rendered on the ordinary JSON lane rather than through its own `toResponse`, so
532
+ * it picks up the same prebuilt init a handler's plain return uses. `EMPTY_RESPONSE_CONTROLS` because
533
+ * these renders happen where no request context exists - before routing, or after it was abandoned.
534
+ */
535
+ const webResponseOf = (result: Response | ResponseResult): Response =>
536
+ result instanceof Response ? result : toResponse(result, EMPTY_RESPONSE_CONTROLS)
537
+
580
538
  // Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
581
- const IDENTITY_RESPONSE = (response: Response): Response => response
539
+ const IDENTITY_RESPONSE = (response: Response | ResponseResult): Response => webResponseOf(response)
582
540
  const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
583
541
 
584
542
  // The unused-order-scoped-hook audit (recorder, seal check, stack capture, message) is a dev-time
@@ -687,7 +645,7 @@ const sealHookAudit = (
687
645
  * app.decorate("db", db).derive((c) => ({ user: auth(c) }))
688
646
  * .get("/me", (c) => c.user) // c.user + c.db are typed
689
647
  */
690
- export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
648
+ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, HookOutput = never> {
691
649
  /**
692
650
  * Which copy of `@nifrajs/core` declared this type. Two copies in one build (a linked sibling repo,
693
651
  * a hoisting split) produce two unrelated `Server` types whose assignment error is a wall of
@@ -706,6 +664,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
706
664
  * Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime - `undefined` until then, so a
707
665
  * no-WebSocket app never constructs (or bundles) it. */
708
666
  private topics: TopicRegistry | undefined
667
+ /** True once any `app.ws()` route sets `validateSend: true` - the one case where a broadcast frame
668
+ * is validated (and possibly dropped) per socket, so `app.publish` must stay on the JS registry loop
669
+ * rather than delegate to Bun's native `server.publish`. */
670
+ private wsHasValidatedSend = false
671
+ /** Bun's native topic broadcast, bound by `listen()` in native-pubsub mode; `app.publish` uses it
672
+ * instead of the JS registry. `undefined` off Bun, before `listen()`, or with a validated-send route. */
673
+ private nativePublish:
674
+ | ((topic: string, data: string | ArrayBufferView | ArrayBuffer) => void)
675
+ | undefined
709
676
  private readonly maxBodyBytes: number
710
677
  private readonly protoPoisoning: "reject" | "strip" | "ignore"
711
678
  /** `trustBodyFraming`: mark every `app.fetch` request as runtime-framed (see ServerOptions). */
@@ -780,7 +747,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
780
747
  private staticResponseHeaders: StaticResponseHeaders | undefined
781
748
  /** `wrapResponse` for the Web lanes: identity until static headers exist to fold into the
782
749
  * framework's own error/404/timeout renders, which are built outside the header init. */
783
- private wrapWebResponse: (response: Response) => Response
750
+ private wrapWebResponse: (response: Response | ResponseResult) => Response
784
751
  private webResponseTimeout: () => Response
785
752
  /** Body/raw response hooks need a framework-payload marker; keep that decision per app. */
786
753
  private responseBodyTag: object | undefined
@@ -925,13 +892,28 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
925
892
  * declared *after* this call, and reaches none declared before. One added after the last route
926
893
  * (e.g. on the app returned by a route-registering factory) covers nothing - see
927
894
  * {@link ServerOptions.unusedScopedHooks}.
895
+ *
896
+ * Returning a `status(...)` (or a `Response`) instead of an extension ends the request there - the
897
+ * guard shape, and the cheap one: it renders as plain data on the same lane a handler's return
898
+ * takes, where `throw new Response(...)` pays ~40% of the rejection lane to build the object. That
899
+ * branch is control flow, so it does not widen the derived context type.
928
900
  */
929
- derive<D extends object>(fn: (context: Context & Ctx) => MaybePromise<D>): Server<R, Ctx & D> {
901
+ derive<H extends (context: Context & Ctx) => MaybePromise<unknown>>(
902
+ fn: H,
903
+ ): Server<
904
+ R,
905
+ Ctx & ContextExtensionOf<ReturnType<H>>,
906
+ HookOutput | StatusResponseOf<Awaited<ReturnType<H>>>
907
+ > {
930
908
  this.assertConfigurable("derive()")
931
909
  this.derives.push(fn as unknown as RawDerive)
932
910
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
933
911
  recordScopedHook(this, this.catalog.size, "derive")
934
- return this as unknown as Server<R, Ctx & D>
912
+ return this as unknown as Server<
913
+ R,
914
+ Ctx & ContextExtensionOf<ReturnType<H>>,
915
+ HookOutput | StatusResponseOf<Awaited<ReturnType<H>>>
916
+ >
935
917
  }
936
918
 
937
919
  /**
@@ -939,12 +921,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
939
921
  * by none before; one added after the last route reaches nothing
940
922
  * (see {@link ServerOptions.unusedScopedHooks}).
941
923
  */
942
- decorate<const K extends string, V>(key: K, value: V): Server<R, Ctx & Record<K, V>> {
924
+ decorate<const K extends string, V>(key: K, value: V): Server<R, Ctx & Record<K, V>, HookOutput> {
943
925
  this.assertConfigurable("decorate()")
944
926
  this.decorations[key] = value
945
927
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
946
928
  recordScopedHook(this, this.catalog.size, "decorate")
947
- return this as unknown as Server<R, Ctx & Record<K, V>>
929
+ return this as unknown as Server<R, Ctx & Record<K, V>, HookOutput>
948
930
  }
949
931
 
950
932
  /**
@@ -966,12 +948,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
966
948
  * it is snapshotted into every route declared *after* this call and covers none declared before, so
967
949
  * one added after the last route reaches nothing (see {@link ServerOptions.unusedScopedHooks}).
968
950
  */
969
- beforeHandle(fn: (context: Context & Ctx) => MaybePromise<unknown>): this {
951
+ beforeHandle<H extends (context: Context & Ctx) => MaybePromise<unknown>>(
952
+ fn: H,
953
+ ): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
970
954
  this.assertConfigurable("beforeHandle()")
971
955
  this.beforeHandleHooks.push(fn as unknown as RawBeforeHandle)
972
956
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
973
957
  recordScopedHook(this, this.catalog.size, "beforeHandle")
974
- return this
958
+ return this as unknown as Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>>
975
959
  }
976
960
 
977
961
  /**
@@ -982,12 +966,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
982
966
  * Order-scoped: captured by every route declared *after* this call, none before; one added after the
983
967
  * last route reaches nothing (see {@link ServerOptions.unusedScopedHooks}).
984
968
  */
985
- around(fn: <T>(context: Context & Ctx, next: () => MaybePromise<T>) => MaybePromise<T>): this {
969
+ around<
970
+ H extends (context: Context & Ctx, next: () => MaybePromise<unknown>) => MaybePromise<unknown>,
971
+ >(fn: H): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
986
972
  this.assertConfigurable("around()")
987
973
  this.aroundHooks.push(fn as unknown as RawAround)
988
974
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
989
975
  recordScopedHook(this, this.catalog.size, "around")
990
- return this
976
+ return this as unknown as Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>>
991
977
  }
992
978
 
993
979
  /**
@@ -1029,23 +1015,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1029
1015
  /** Transform the handler's result before it is serialized. Order-scoped: captured by routes declared
1030
1016
  * *after* this call, none before; one added after the last route reaches nothing (see
1031
1017
  * {@link ServerOptions.unusedScopedHooks}). */
1032
- afterHandle(fn: (result: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
1018
+ afterHandle<H extends (result: unknown, context: Context & Ctx) => MaybePromise<unknown>>(
1019
+ fn: H,
1020
+ ): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
1033
1021
  this.assertConfigurable("afterHandle()")
1034
1022
  this.afterHandleHooks.push(fn as unknown as RawAfterHandle)
1035
1023
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1036
1024
  recordScopedHook(this, this.catalog.size, "afterHandle")
1037
- return this
1025
+ return this as unknown as Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>>
1038
1026
  }
1039
1027
 
1040
1028
  /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500).
1041
1029
  * Order-scoped: captured by routes declared *after* this call, none before; one added after the last
1042
1030
  * route reaches nothing (see {@link ServerOptions.unusedScopedHooks}). */
1043
- onError(fn: (error: unknown, context: Context & Ctx) => MaybePromise<unknown>): this {
1031
+ onError<H extends (error: unknown, context: Context & Ctx) => MaybePromise<unknown>>(
1032
+ fn: H,
1033
+ ): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
1044
1034
  this.assertConfigurable("onError()")
1045
1035
  this.onErrorHooks.push(fn as unknown as RawErrorHandler)
1046
1036
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1047
1037
  recordScopedHook(this, this.catalog.size, "onError")
1048
- return this
1038
+ return this as unknown as Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>>
1049
1039
  }
1050
1040
 
1051
1041
  /** Transform every outgoing response - success, error, 404, 405, short-circuit. Global. */
@@ -1089,11 +1079,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1089
1079
  * hook owns part of the header state - into a hook that runs in the right order. */
1090
1080
  private addStaticResponseHeaders(record: Record<string, string>): this {
1091
1081
  if (this.onResponseHooks.length > 0) {
1092
- return this.onResponseHeaders((headers) => {
1093
- for (const name of Object.keys(record)) {
1094
- if (!headers.has(name)) headers.set(name, record[name] as string)
1095
- }
1096
- })
1082
+ const observerHeaders = (
1083
+ this as unknown as Pick<ResponseObserverMethods, "onResponseHeaders">
1084
+ ).onResponseHeaders
1085
+ if (observerHeaders !== undefined) {
1086
+ observerHeaders.call(this, (headers) => {
1087
+ for (const name of Object.keys(record)) {
1088
+ if (!headers.has(name)) headers.set(name, record[name] as string)
1089
+ }
1090
+ })
1091
+ return this
1092
+ }
1093
+ // Route the hook through the same seam the static tier uses. On a framework response (headers
1094
+ // stamped mutable) it applies the defaults in place and returns the SAME object, so the
1095
+ // serialized-body marker survives for later `onResponseBody`/`onResponseRaw` observers and the
1096
+ // Node writer; reconstructing a `Response` here would strip that marker and reclassify the Node
1097
+ // outcome from `json` to a generic `response`. A guarded foreign response takes its clone path.
1098
+ const statics = buildStaticResponseHeaders(record)
1099
+ this.onResponseHooks.push((response) => applyStaticResponseHeaders(response, statics))
1100
+ this.onNodeResponseHooks.push(undefined)
1101
+ this.nodeResponseHooksComplete = false
1102
+ return this
1097
1103
  }
1098
1104
  const merged =
1099
1105
  this.staticResponseHeaders === undefined
@@ -1101,7 +1107,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1101
1107
  : { ...this.staticResponseHeaders.record, ...record }
1102
1108
  this.staticResponseHeaders = buildStaticResponseHeaders(merged)
1103
1109
  const statics = this.staticResponseHeaders
1104
- this.wrapWebResponse = (response) => applyStaticResponseHeaders(response, statics)
1110
+ this.wrapWebResponse = (response) =>
1111
+ applyStaticResponseHeaders(webResponseOf(response), statics)
1105
1112
  this.webResponseTimeout = () => applyStaticResponseHeaders(RESPONSE_TIMEOUT(), statics)
1106
1113
  return this
1107
1114
  }
@@ -1114,61 +1121,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1114
1121
  return this.responseBodyTag
1115
1122
  }
1116
1123
 
1117
- /**
1118
- * Register a response transform for raw/streamed Responses. Framework-serialized payloads stay on
1119
- * the body tier; this hook is only entered for untagged Responses (streams, proxied fetches, and
1120
- * framework-generated error responses). A no-op native twin keeps buffered JSON on the direct lane.
1121
- */
1122
- onResponseRaw(fn: (response: Response, req: Request) => MaybePromise<Response>): this {
1123
- this.assertConfigurable("onResponseRaw()")
1124
- this.enableResponseBodyTagging()
1125
- this.onResponseHooks.push(webResponseRawHook(fn, this.responseBodyOwners))
1126
- this.onNodeResponseHooks.push(() => undefined)
1127
- return this
1128
- }
1129
-
1130
- /**
1131
- * Register a PORTABLE header-only response hook - one implementation, fast on every runtime.
1132
- *
1133
- * The hook receives a mutable case-insensitive header view, a minimal request view, and the
1134
- * status. On the Web serving paths it runs inside the normal `onResponse` walk against the
1135
- * response's own `Headers` (mutating in place - no clone). On Node it self-pairs as a native
1136
- * response hook against the outcome's plain header record, so registering one NEVER forces the
1137
- * Node adapter off its direct socket writer the way a full `onResponse(res: Response)` hook does.
1138
- * Prefer this over `onResponse` whenever the middleware only reads/writes headers.
1139
- */
1140
- onResponseHeaders(fn: ResponseHeadersHook): this {
1141
- this.assertConfigurable("onResponseHeaders()")
1142
- this.onResponseHooks.push(webResponseHeadersHook(fn))
1143
- this.onNodeResponseHooks.push((response, req) =>
1144
- fn(recordHeadersView(response), req, response.status),
1145
- )
1146
- return this
1147
- }
1148
-
1149
- /**
1150
- * Register a PORTABLE post-serialization body hook - the payload tier. The hook receives the
1151
- * FINAL framework-serialized bytes (plus the mutable header view and status) and may return
1152
- * replacement bytes; `undefined` keeps the body. On the Node direct writer the bytes come
1153
- * straight off the outcome record; on the Web serving paths they ride the framework-built
1154
- * Response as a tag, so no body stream is ever drained on any runtime. A handler-returned raw
1155
- * `Response` (proxied fetch, SSE, streamed SSR) is skipped by contract - transforming those is
1156
- * what the full `onResponse` hook is for.
1157
- */
1158
- onResponseBody(fn: ResponseBodyHook): this {
1159
- this.assertConfigurable("onResponseBody()")
1160
- this.enableResponseBodyTagging()
1161
- this.onResponseHooks.push(webResponseBodyHook(fn, this.responseBodyOwners))
1162
- this.onNodeResponseHooks.push((response, req) => {
1163
- const body = response.body
1164
- if (body === null) return undefined
1165
- const out = fn(body, recordHeadersView(response), req, response.status)
1166
- if (out instanceof Promise)
1167
- return out.then((replaced) => applyBodyReplacement(response, replaced))
1168
- applyBodyReplacement(response, out)
1169
- return undefined
1170
- })
1171
- return this
1124
+ private responseObserverMethods(): ResponseObserverMethods {
1125
+ const methods = this as unknown as Partial<ResponseObserverMethods>
1126
+ if (
1127
+ typeof methods.onResponseHeaders !== "function" ||
1128
+ typeof methods.onResponseBody !== "function" ||
1129
+ typeof methods.onResponseRaw !== "function"
1130
+ ) {
1131
+ throw new TypeError(
1132
+ "response observation requires `.use(responseObserver())` or an observer-enabled middleware",
1133
+ )
1134
+ }
1135
+ return methods as ResponseObserverMethods
1172
1136
  }
1173
1137
 
1174
1138
  /** Observe the terminal response after all transformations. Observers are ordered and fail-open. */
@@ -1180,14 +1144,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1180
1144
  return this
1181
1145
  }
1182
1146
 
1183
- /**
1184
- * Apply a type-**identity** plugin ({@link IdentityPlugin}, from {@link defineIdentityPlugin}) - it
1185
- * registers routes/hooks but doesn't change the types, so this returns `this` with the route registry
1186
- * and context fully intact. This overload exists specifically so a *named* identity plugin (e.g.
1187
- * `@nifrajs/better-auth`) threads the registry: its `& { pluginName }` intersection would otherwise
1188
- * defeat the generic inference of the transforming overload below and collapse the result to `any`.
1189
- */
1190
- use(plugin: IdentityPlugin): this
1147
+ /** Enable the opt-in portable response observer methods. */
1148
+ use(plugin: ResponseObserverPlugin): this & ResponseObserverMethods
1191
1149
  /**
1192
1150
  * Apply a **context** plugin ({@link ContextPlugin}, from {@link defineContextPlugin}) - it adds `D`
1193
1151
  * to the handler context and changes nothing else, so the route registry `R` and the existing context
@@ -1195,7 +1153,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1195
1153
  * `(app: this) => Out` overload below cannot infer through a *generic* plugin signature: it erases the
1196
1154
  * plugin's own type parameters to their constraints, which silently widens `R` to `Registry`.
1197
1155
  */
1198
- use<D extends object>(plugin: ContextPlugin<D>): Server<R, Ctx & D>
1156
+ use<D extends object>(plugin: ContextPlugin<D>): Server<R, Ctx & D, HookOutput>
1157
+ /**
1158
+ * Apply a type-**identity** plugin ({@link IdentityPlugin}, from {@link defineIdentityPlugin}) - it
1159
+ * registers routes/hooks but doesn't change the types, so this returns `this` with the route registry
1160
+ * and context fully intact. This overload exists specifically so a named identity plugin (e.g.
1161
+ * `@nifrajs/better-auth`) threads the registry: its `& { pluginName }` intersection would otherwise
1162
+ * defeat the generic inference of the transforming overload below and collapse the result to `any`.
1163
+ */
1164
+ use<AddedHookOutput = never>(
1165
+ plugin: IdentityPlugin<AddedHookOutput>,
1166
+ ): Server<R, Ctx, HookOutput | AddedHookOutput>
1199
1167
  /**
1200
1168
  * Apply a **plugin function** - `(app) => app`, typically built with {@link definePlugin}. It's
1201
1169
  * called with `this` and its result is returned, so an inline plugin's `derive`/`decorate` thread
@@ -1219,6 +1187,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1219
1187
  * the last route reaches nothing - {@link ServerOptions.unusedScopedHooks} logs that at seal. A named
1220
1188
  * bundle already applied is skipped (idempotent).
1221
1189
  */
1190
+ use<M extends Middleware>(mw: M): Server<R, Ctx, HookOutput | MiddlewareOutputOf<M>>
1222
1191
  use(mw: Middleware): this
1223
1192
  use(arg: Middleware | ((app: this) => AnyServer)): AnyServer {
1224
1193
  this.assertConfigurable("use()")
@@ -1254,6 +1223,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1254
1223
  }
1255
1224
  this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"))
1256
1225
  this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"))
1226
+ const responseObserver = (arg as unknown as Record<symbol, unknown>)[INSTALL_RESPONSE_OBSERVER]
1227
+ if (responseObserver !== undefined) {
1228
+ if (
1229
+ typeof responseObserver !== "object" ||
1230
+ responseObserver === null ||
1231
+ typeof (responseObserver as { install?: unknown }).install !== "function"
1232
+ ) {
1233
+ throw new TypeError("response observer middleware has an invalid runtime")
1234
+ }
1235
+ this[INSTALL_RESPONSE_OBSERVER](responseObserver as ResponseObserverRuntime)
1236
+ }
1257
1237
  if (arg.onRequest !== undefined) {
1258
1238
  this.assertConfigurable("onRequest()")
1259
1239
  this.onRequestHooks.push(arg.onRequest as RawOnRequest)
@@ -1277,9 +1257,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1277
1257
  // Before the bundle's own hooks: a bundle declaring both means its static values are the
1278
1258
  // defaults its hook may then override, which is the order a single bundle reads in.
1279
1259
  if (arg.responseHeaders !== undefined) this.responseHeaders(arg.responseHeaders)
1280
- if (arg.onResponseHeaders !== undefined) this.onResponseHeaders(arg.onResponseHeaders)
1281
- if (arg.onResponseBody !== undefined) this.onResponseBody(arg.onResponseBody)
1282
- if (arg.onResponseRaw !== undefined) this.onResponseRaw(arg.onResponseRaw)
1260
+ if (arg.onResponseHeaders !== undefined)
1261
+ this.responseObserverMethods().onResponseHeaders(arg.onResponseHeaders)
1262
+ if (arg.onResponseBody !== undefined)
1263
+ this.responseObserverMethods().onResponseBody(arg.onResponseBody)
1264
+ if (arg.onResponseRaw !== undefined)
1265
+ this.responseObserverMethods().onResponseRaw(arg.onResponseRaw)
1283
1266
  if (arg.onResponseFinalized !== undefined) this.onResponseFinalized(arg.onResponseFinalized)
1284
1267
  if (arg.onError !== undefined) this.onError(arg.onError)
1285
1268
  return this
@@ -1289,16 +1272,24 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1289
1272
  path: Path,
1290
1273
  schema: S,
1291
1274
  handler: H,
1292
- ): Server<AddRoute<R, "GET", Path, RouteInfoFor<Path, S, OutputOf<H>>>, Ctx>
1275
+ ): Server<
1276
+ AddRoute<R, "GET", Path, RouteInfoFor<Path, S, OutputOf<H>, HookOutput>>,
1277
+ Ctx,
1278
+ HookOutput
1279
+ >
1293
1280
  get<Path extends string, H extends Handler<Path, RouteSchema, Ctx>>(
1294
1281
  path: Path,
1295
1282
  handler: H,
1296
- ): Server<AddRoute<R, "GET", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>>>, Ctx>
1283
+ ): Server<
1284
+ AddRoute<R, "GET", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>, HookOutput>>,
1285
+ Ctx,
1286
+ HookOutput
1287
+ >
1297
1288
  get(
1298
1289
  path: string,
1299
1290
  schemaOrHandler: RouteSchema | ErasedHandler,
1300
1291
  handler?: ErasedHandler,
1301
- ): Server<Registry, Ctx> {
1292
+ ): Server<Registry, Ctx, HookOutput> {
1302
1293
  return this.route("GET", path, schemaOrHandler, handler)
1303
1294
  }
1304
1295
 
@@ -1331,12 +1322,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1331
1322
  stream: TypedSSEStream<InferOutput<S["sse"]>>,
1332
1323
  ) => void | Promise<void>,
1333
1324
  init?: SSEInit,
1334
- ): Server<AddRoute<R, "GET", Path, RouteInfoFor<Path, S, Response>>, Ctx> {
1325
+ ): Server<
1326
+ AddRoute<R, "GET", Path, RouteInfoFor<Path, S, Response, HookOutput>>,
1327
+ Ctx,
1328
+ HookOutput
1329
+ > {
1335
1330
  const handler = (context: Context<Path, S> & Ctx): Response =>
1336
1331
  requireSseRuntime(this.sseRuntime).response(context, (stream) => run(context, stream), init)
1337
1332
  return this.route("GET", path, schema, handler as unknown as ErasedHandler) as Server<
1338
- AddRoute<R, "GET", Path, RouteInfoFor<Path, S, Response>>,
1339
- Ctx
1333
+ AddRoute<R, "GET", Path, RouteInfoFor<Path, S, Response, HookOutput>>,
1334
+ Ctx,
1335
+ HookOutput
1340
1336
  >
1341
1337
  }
1342
1338
 
@@ -1344,16 +1340,24 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1344
1340
  path: Path,
1345
1341
  schema: S,
1346
1342
  handler: H,
1347
- ): Server<AddRoute<R, "POST", Path, RouteInfoFor<Path, S, OutputOf<H>>>, Ctx>
1343
+ ): Server<
1344
+ AddRoute<R, "POST", Path, RouteInfoFor<Path, S, OutputOf<H>, HookOutput>>,
1345
+ Ctx,
1346
+ HookOutput
1347
+ >
1348
1348
  post<Path extends string, H extends Handler<Path, RouteSchema, Ctx>>(
1349
1349
  path: Path,
1350
1350
  handler: H,
1351
- ): Server<AddRoute<R, "POST", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>>>, Ctx>
1351
+ ): Server<
1352
+ AddRoute<R, "POST", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>, HookOutput>>,
1353
+ Ctx,
1354
+ HookOutput
1355
+ >
1352
1356
  post(
1353
1357
  path: string,
1354
1358
  schemaOrHandler: RouteSchema | ErasedHandler,
1355
1359
  handler?: ErasedHandler,
1356
- ): Server<Registry, Ctx> {
1360
+ ): Server<Registry, Ctx, HookOutput> {
1357
1361
  return this.route("POST", path, schemaOrHandler, handler)
1358
1362
  }
1359
1363
 
@@ -1361,16 +1365,24 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1361
1365
  path: Path,
1362
1366
  schema: S,
1363
1367
  handler: H,
1364
- ): Server<AddRoute<R, "PUT", Path, RouteInfoFor<Path, S, OutputOf<H>>>, Ctx>
1368
+ ): Server<
1369
+ AddRoute<R, "PUT", Path, RouteInfoFor<Path, S, OutputOf<H>, HookOutput>>,
1370
+ Ctx,
1371
+ HookOutput
1372
+ >
1365
1373
  put<Path extends string, H extends Handler<Path, RouteSchema, Ctx>>(
1366
1374
  path: Path,
1367
1375
  handler: H,
1368
- ): Server<AddRoute<R, "PUT", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>>>, Ctx>
1376
+ ): Server<
1377
+ AddRoute<R, "PUT", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>, HookOutput>>,
1378
+ Ctx,
1379
+ HookOutput
1380
+ >
1369
1381
  put(
1370
1382
  path: string,
1371
1383
  schemaOrHandler: RouteSchema | ErasedHandler,
1372
1384
  handler?: ErasedHandler,
1373
- ): Server<Registry, Ctx> {
1385
+ ): Server<Registry, Ctx, HookOutput> {
1374
1386
  return this.route("PUT", path, schemaOrHandler, handler)
1375
1387
  }
1376
1388
 
@@ -1378,16 +1390,24 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1378
1390
  path: Path,
1379
1391
  schema: S,
1380
1392
  handler: H,
1381
- ): Server<AddRoute<R, "PATCH", Path, RouteInfoFor<Path, S, OutputOf<H>>>, Ctx>
1393
+ ): Server<
1394
+ AddRoute<R, "PATCH", Path, RouteInfoFor<Path, S, OutputOf<H>, HookOutput>>,
1395
+ Ctx,
1396
+ HookOutput
1397
+ >
1382
1398
  patch<Path extends string, H extends Handler<Path, RouteSchema, Ctx>>(
1383
1399
  path: Path,
1384
1400
  handler: H,
1385
- ): Server<AddRoute<R, "PATCH", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>>>, Ctx>
1401
+ ): Server<
1402
+ AddRoute<R, "PATCH", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>, HookOutput>>,
1403
+ Ctx,
1404
+ HookOutput
1405
+ >
1386
1406
  patch(
1387
1407
  path: string,
1388
1408
  schemaOrHandler: RouteSchema | ErasedHandler,
1389
1409
  handler?: ErasedHandler,
1390
- ): Server<Registry, Ctx> {
1410
+ ): Server<Registry, Ctx, HookOutput> {
1391
1411
  return this.route("PATCH", path, schemaOrHandler, handler)
1392
1412
  }
1393
1413
 
@@ -1395,16 +1415,24 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1395
1415
  path: Path,
1396
1416
  schema: S,
1397
1417
  handler: H,
1398
- ): Server<AddRoute<R, "DELETE", Path, RouteInfoFor<Path, S, OutputOf<H>>>, Ctx>
1418
+ ): Server<
1419
+ AddRoute<R, "DELETE", Path, RouteInfoFor<Path, S, OutputOf<H>, HookOutput>>,
1420
+ Ctx,
1421
+ HookOutput
1422
+ >
1399
1423
  delete<Path extends string, H extends Handler<Path, RouteSchema, Ctx>>(
1400
1424
  path: Path,
1401
1425
  handler: H,
1402
- ): Server<AddRoute<R, "DELETE", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>>>, Ctx>
1426
+ ): Server<
1427
+ AddRoute<R, "DELETE", Path, RouteInfoFor<Path, Record<never, never>, OutputOf<H>, HookOutput>>,
1428
+ Ctx,
1429
+ HookOutput
1430
+ >
1403
1431
  delete(
1404
1432
  path: string,
1405
1433
  schemaOrHandler: RouteSchema | ErasedHandler,
1406
1434
  handler?: ErasedHandler,
1407
- ): Server<Registry, Ctx> {
1435
+ ): Server<Registry, Ctx, HookOutput> {
1408
1436
  return this.route("DELETE", path, schemaOrHandler, handler)
1409
1437
  }
1410
1438
 
@@ -1447,10 +1475,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1447
1475
  S["output"] extends StandardSchemaV1
1448
1476
  ? { body: S["input"]; response: S["output"] }
1449
1477
  : { body: S["input"] },
1450
- OutputOf<H>
1478
+ OutputOf<H>,
1479
+ HookOutput
1451
1480
  >
1452
1481
  >,
1453
- Ctx
1482
+ Ctx,
1483
+ HookOutput
1454
1484
  >
1455
1485
  tool(
1456
1486
  name: string,
@@ -1461,7 +1491,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1461
1491
  annotations?: ToolAnnotations
1462
1492
  },
1463
1493
  handler: (input: unknown, ctx: Context & Ctx) => unknown,
1464
- ): Server<Registry, Ctx> {
1494
+ ): Server<Registry, Ctx, HookOutput> {
1465
1495
  const plan = requireMcpRuntime(this.mcpRuntime).tool(
1466
1496
  name,
1467
1497
  config,
@@ -1474,7 +1504,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1474
1504
  if (lastRoute) {
1475
1505
  ;(lastRoute as { tool?: RouteDescriptor["tool"] }).tool = plan.descriptor
1476
1506
  }
1477
- return this as unknown as Server<Registry, Ctx>
1507
+ return this as unknown as Server<Registry, Ctx, HookOutput>
1478
1508
  }
1479
1509
 
1480
1510
  /**
@@ -1487,7 +1517,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1487
1517
  uri: string,
1488
1518
  config: { readonly name: string; readonly description?: string; readonly mimeType?: string },
1489
1519
  read: McpResourceDescriptor["read"],
1490
- ): Server<R, Ctx> {
1520
+ ): Server<R, Ctx, HookOutput> {
1491
1521
  this.assertConfigurable("resource()")
1492
1522
  this.mcpResourceList.push(requireMcpRuntime(this.mcpRuntime).resource(uri, config, read))
1493
1523
  return this
@@ -1501,7 +1531,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1501
1531
  name: string,
1502
1532
  config: { readonly description: string; readonly arguments?: readonly PromptArgument[] },
1503
1533
  handler: McpPromptDescriptor["handler"],
1504
- ): Server<R, Ctx> {
1534
+ ): Server<R, Ctx, HookOutput> {
1505
1535
  this.assertConfigurable("prompt()")
1506
1536
  this.mcpPromptList.push(requireMcpRuntime(this.mcpRuntime).prompt(name, config, handler))
1507
1537
  return this
@@ -1541,12 +1571,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1541
1571
  path: Path,
1542
1572
  handler: WebSocketHandler<Data, EnvOf<Ctx>, Schema, Send>,
1543
1573
  ): string extends Path
1544
- ? Server<R, Ctx>
1545
- : Server<AddRoute<R, "WS", Path, WsRouteInfoFor<Path, Schema, Send>>, Ctx> {
1574
+ ? Server<R, Ctx, HookOutput>
1575
+ : Server<AddRoute<R, "WS", Path, WsRouteInfoFor<Path, Schema, Send>>, Ctx, HookOutput> {
1546
1576
  this.assertConfigurable("ws()")
1547
1577
  // Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
1548
1578
  // bundle it. Registration is the loud, early failure point - never the first connection.
1549
1579
  const runtime = requireWsRuntime(this.wsRuntime)
1580
+ if (handler.validateSend === true && handler.sendSchema === undefined) {
1581
+ throw new RouteConfigError(
1582
+ "INVALID_WS_SEND_VALIDATION",
1583
+ `route WS ${path}: validateSend requires sendSchema`,
1584
+ )
1585
+ }
1586
+ // A validated-send route makes broadcast bytes route-dependent (drop-on-invalid per socket), so the
1587
+ // whole app forgoes native pub/sub and keeps the JS registry loop that runs each send through the sender.
1588
+ if (handler.validateSend === true) this.wsHasValidatedSend = true
1550
1589
  this.topics ??= runtime.createTopics()
1551
1590
  // A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
1552
1591
  // already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
@@ -1563,6 +1602,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1563
1602
  * an external fan-out (Redis, a Durable Object) to this. A no-op when nobody is subscribed.
1564
1603
  */
1565
1604
  publish(topic: string, data: string | ArrayBufferView | ArrayBuffer): void {
1605
+ // Native-pubsub mode (Bun, no validated-send route): one native broadcast to every subscriber, no
1606
+ // per-connection JS loop. Bound only after `listen()`; before that (or off Bun) the registry runs.
1607
+ if (this.nativePublish !== undefined) {
1608
+ this.nativePublish(topic, data)
1609
+ return
1610
+ }
1566
1611
  // No `app.ws()` yet ⇒ no registry and necessarily no subscribers - a publish is a no-op anyway.
1567
1612
  this.topics?.publish(topic, data)
1568
1613
  }
@@ -1572,7 +1617,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1572
1617
  path: string,
1573
1618
  schemaOrHandler: RouteSchema | ErasedHandler,
1574
1619
  handler?: ErasedHandler,
1575
- ): Server<Registry, Ctx> {
1620
+ ): Server<Registry, Ctx, HookOutput> {
1576
1621
  let rawHandler: ErasedHandler
1577
1622
  let schema: RouteSchema | undefined
1578
1623
  if (handler !== undefined) {
@@ -1585,7 +1630,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1585
1630
  this.register(method, path, schema, rawHandler)
1586
1631
  // The accumulated registry type is compile-time only; the same instance
1587
1632
  // carries every route, so the public methods re-type `this` per call.
1588
- return this as unknown as Server<Registry, Ctx>
1633
+ return this as unknown as Server<Registry, Ctx, HookOutput>
1589
1634
  }
1590
1635
 
1591
1636
  /**
@@ -1626,245 +1671,143 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1626
1671
  schema: RouteSchema | undefined,
1627
1672
  handler: (context: never) => unknown,
1628
1673
  ): CatalogRoute {
1629
- const pattern = compileRoutePattern(path)
1630
- let bodyLimit: number | undefined = this.maxBodyBytes
1631
- const invalidBodyLimit = (message: string): never => {
1632
- throw new RouteConfigError("INVALID_BODY_LIMIT", `route ${method} ${path}: ${message}`)
1633
- }
1634
- if (schema?.bodyLimitReason !== undefined && schema.bodyLimit !== "unlimited") {
1635
- invalidBodyLimit('bodyLimitReason is only valid with bodyLimit: "unlimited"')
1636
- }
1637
- if (schema?.bodyLimit === "unlimited") {
1638
- if (
1639
- typeof schema.bodyLimitReason !== "string" ||
1640
- schema.bodyLimitReason.trim().length === 0
1641
- ) {
1642
- invalidBodyLimit('bodyLimit: "unlimited" requires a non-empty bodyLimitReason')
1643
- }
1644
- bodyLimit = undefined
1645
- } else if (schema?.bodyLimit !== undefined) {
1646
- try {
1647
- assertByteLimit(schema.bodyLimit, "route bodyLimit")
1648
- } catch {
1649
- invalidBodyLimit('bodyLimit must be a non-negative safe integer or "unlimited"')
1650
- }
1651
- bodyLimit = schema.bodyLimit
1652
- }
1653
- const capabilities = normalizeRouteCapabilities(schema?.capabilities)
1654
- const handlerAssurance = assuranceDeclarationsOf(handler as unknown as object)
1655
- const invalidHandlerScope = handlerAssurance.find(
1656
- (declaration) => declaration.scope !== "plugin",
1657
- )
1658
- if (invalidHandlerScope !== undefined) {
1659
- throw new RouteConfigError(
1660
- "INVALID_ASSURANCE",
1661
- `route handler assurance must use plugin scope (received ${invalidHandlerScope.scope})`,
1662
- )
1663
- }
1664
- const authenticated = assuranceEvidenceFor(
1665
- [...this.activeAssurance, ...handlerAssurance, ...this.globalAssurance],
1666
- method,
1667
- path,
1668
- ).some((evidence) => evidence.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED)
1669
- const routeDecorations: Record<PropertyKey, unknown> = { ...this.decorations }
1670
- if (capabilities.length > 0) {
1671
- routeDecorations[CAPABILITY_GUARD] = createCapabilityGuard(
1672
- capabilities,
1673
- method,
1674
- path,
1675
- this.onCapabilityUse,
1676
- this.idempotencyRuntime?.trackEffect,
1677
- Object.freeze([...this.capabilityInterceptors]),
1678
- Object.freeze([...this.capabilityObservers]),
1679
- )
1680
- }
1681
- const hasDecorations = Reflect.ownKeys(routeDecorations).length > 0
1682
- // An idempotency route runs a dedupe lane that must buffer the body and capture the response, so it
1683
- // never takes the fused/native fast path - force it onto the portable matched lane (which routes
1684
- // through `fetchMatched`, where the dedupe wrapper lives). Fail closed: a route may not declare
1685
- // idempotency unless the idempotency runtime is installed, so the safety gate can never be silently
1686
- // dropped by a missing plugin.
1687
- if (schema?.idempotency !== undefined && this.idempotencyRuntime === undefined) {
1688
- throw new RouteConfigError(
1689
- "INVALID_IDEMPOTENCY",
1690
- "route declares idempotency but the idempotency plugin is not installed; add .use(idempotency())",
1691
- )
1692
- }
1693
- const idempotent = this.idempotencyRuntime?.resolve(schema, authenticated, this.maxBodyBytes)
1694
- // A ledgered route (capabilities declared + `.use(effectLedger())`) needs a per-request
1695
- // context to carry the ledger and a settle step to seal + sink it, so it too leaves the
1696
- // fused/contextless fast path. Resolved per route, at registration - like the capability guard.
1697
- const ledgered: ResolvedEffectLedger | undefined = this.effectLedgerRuntime?.resolve(
1698
- capabilities,
1674
+ const compiled = compileRouteOptions(
1675
+ {
1676
+ maxBodyBytes: this.maxBodyBytes,
1677
+ activeAssurance: this.activeAssurance,
1678
+ globalAssurance: this.globalAssurance,
1679
+ decorations: this.decorations,
1680
+ onCapabilityUse: this.onCapabilityUse,
1681
+ capabilityInterceptors: this.capabilityInterceptors,
1682
+ capabilityObservers: this.capabilityObservers,
1683
+ effectLedgerRuntime: this.effectLedgerRuntime,
1684
+ idempotencyRuntime: this.idempotencyRuntime,
1685
+ responseContractRuntime: this.responseContractRuntime,
1686
+ derives: this.derives,
1687
+ beforeHandleHooks: this.beforeHandleHooks,
1688
+ afterHandleHooks: this.afterHandleHooks,
1689
+ onErrorHooks: this.onErrorHooks,
1690
+ aroundHooks: this.aroundHooks,
1691
+ defaultOnValidationError: this.defaultOnValidationError,
1692
+ },
1699
1693
  method,
1700
1694
  path,
1695
+ schema,
1696
+ handler,
1701
1697
  )
1702
- // A checked response schema needs the handler's VALUE before it becomes bytes, and the fused and
1703
- // native lanes exist precisely to skip that step. So a contracted route leaves them - the same
1704
- // structural trade an idempotent route makes above. The check itself is not the cost: a compiled
1705
- // validator measures ~100ns/response, within benchmark noise on any route these lanes already
1706
- // exclude (middleware, derives, lifecycle hooks).
1707
- const runtime = this.responseContractRuntime
1708
- const contracted =
1709
- runtime !== undefined && schema?.response !== undefined
1710
- ? { runtime, schema: schema.response }
1711
- : undefined
1712
- const bare =
1713
- schema?.params === undefined &&
1714
- schema?.headers === undefined &&
1715
- schema?.body === undefined &&
1716
- schema?.query === undefined &&
1717
- idempotent === undefined &&
1718
- ledgered === undefined &&
1719
- contracted === undefined &&
1720
- this.derives.length === 0 &&
1721
- this.beforeHandleHooks.length === 0 &&
1722
- this.afterHandleHooks.length === 0 &&
1723
- this.onErrorHooks.length === 0
1724
- // A route whose ONLY lifecycle step is a query schema can fuse too: the parse + validate +
1725
- // handler + respond collapse into one closure with no lifecycle promise on the sync path. The
1726
- // guards mirror the `query` lane below PLUS everything the fused dispatch skips: around hooks,
1727
- // the idempotency/ledger wrappers, and validation-error recovery (schema or server default) -
1728
- // the fused invalid path is exactly `validationError(issues)`, so any recovery semantics keep
1729
- // the generic lane.
1730
- const fusedQuery =
1731
- !bare &&
1732
- contracted === undefined &&
1733
- schema?.query !== undefined &&
1734
- schema.body === undefined &&
1735
- schema.params === undefined &&
1736
- schema.headers === undefined &&
1737
- schema.onValidationError === undefined &&
1738
- this.defaultOnValidationError === undefined &&
1739
- idempotent === undefined &&
1740
- ledgered === undefined &&
1741
- this.derives.length === 0 &&
1742
- this.beforeHandleHooks.length === 0 &&
1743
- this.afterHandleHooks.length === 0 &&
1744
- this.onErrorHooks.length === 0 &&
1745
- this.aroundHooks.length === 0
1746
- const bodyOnly =
1747
- contracted === undefined &&
1748
- schema?.body !== undefined &&
1749
- schema.query === undefined &&
1750
- schema.params === undefined &&
1751
- schema.headers === undefined &&
1752
- this.derives.length === 0 &&
1753
- this.beforeHandleHooks.length === 0 &&
1754
- this.afterHandleHooks.length === 0 &&
1755
- this.onErrorHooks.length === 0
1756
- const fusedBody =
1757
- !bare &&
1758
- bodyOnly &&
1759
- schema.onValidationError === undefined &&
1760
- this.defaultOnValidationError === undefined &&
1761
- idempotent === undefined &&
1762
- ledgered === undefined &&
1763
- this.aroundHooks.length === 0
1764
- // Body-only routes still need an async body read, but eligible routes can compile the validation +
1765
- // handler continuation once. The runner keeps the bounded parser and all error semantics while
1766
- // avoiding the generic entry/schema/lifecycle dispatch on Bun, Deno, and Node-direct.
1698
+ const {
1699
+ pattern,
1700
+ bodyLimit,
1701
+ capabilities,
1702
+ routeDecorations,
1703
+ hasDecorations,
1704
+ idempotent,
1705
+ ledgered,
1706
+ responseContract,
1707
+ lanes,
1708
+ routeAssurance,
1709
+ } = compiled
1710
+ const { bare, fusedQuery, fusedBody } = lanes
1767
1711
  const fusedBodyRunner = fusedBody
1768
1712
  ? this.buildFusedBodyRunner(
1769
1713
  handler as unknown as InternalHandler,
1770
- schema.body as StandardSchemaV1,
1714
+ schema?.body as StandardSchemaV1,
1771
1715
  hasDecorations ? routeDecorations : undefined,
1772
- bodyLimit ?? this.maxBodyBytes,
1716
+ bodyLimit ?? UNLIMITED_BODY_BYTES,
1773
1717
  )
1774
1718
  : undefined
1719
+ // Fused lifecycle lanes: derive + before (with or without an after), and body + the same shape.
1720
+ // The lane selectors in `selectRouteLanes` are exhaustive about the lifecycleHookLane /
1721
+ // body-derive-before-after eligibility, so we only need to check the lane and the hook counts.
1722
+ const lifecycleHookLane = lanes.lifecycleHookLane
1723
+ const isFusedLifecycle = lifecycleHookLane !== undefined && this.derives.length === 1
1724
+ const isFusedBodyLifecycle =
1725
+ lanes.fusedLane === "body-derive-before" || lanes.fusedLane === "body-derive-before-after"
1775
1726
  const fusedWeb =
1776
1727
  bare && this.aroundHooks.length === 0
1777
1728
  ? this.buildFusedWeb(
1778
1729
  handler as unknown as InternalHandler,
1779
1730
  hasDecorations ? routeDecorations : undefined,
1780
1731
  isContextlessNoArgArrow(handler),
1781
- bodyLimit ?? this.maxBodyBytes,
1732
+ bodyLimit ?? UNLIMITED_BODY_BYTES,
1782
1733
  )
1783
1734
  : fusedQuery
1784
1735
  ? this.buildFusedQueryWeb(
1785
1736
  handler as unknown as InternalHandler,
1786
1737
  hasDecorations ? routeDecorations : undefined,
1787
- schema.query as StandardSchemaV1,
1788
- bodyLimit ?? this.maxBodyBytes,
1738
+ schema?.query as StandardSchemaV1,
1739
+ bodyLimit ?? UNLIMITED_BODY_BYTES,
1789
1740
  )
1790
1741
  : fusedBody
1791
1742
  ? this.buildFusedBodyWeb(fusedBodyRunner as FusedBodyRunner)
1792
- : undefined
1793
- const contextless = bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler)
1794
- // The body and query lanes finalize the handler's result themselves, so a contracted route takes
1795
- // the general lifecycle lane instead - one place owns the check rather than three.
1796
- const lane = bare
1797
- ? "bare"
1798
- : bodyOnly
1799
- ? "body"
1800
- : contracted === undefined &&
1801
- schema?.body === undefined &&
1802
- schema?.query !== undefined &&
1803
- schema.params === undefined &&
1804
- schema.headers === undefined &&
1805
- this.derives.length === 0 &&
1806
- this.beforeHandleHooks.length === 0 &&
1807
- this.afterHandleHooks.length === 0 &&
1808
- this.onErrorHooks.length === 0
1809
- ? "query"
1810
- : "lifecycle"
1811
- // Lifecycle routes with no params schema are the common middleware shape: a derive/before hook
1812
- // plus an optional query or body schema. Select their complete validation stage at registration
1813
- // so the request path never re-checks params/body presence. Parameter-schema routes retain the
1814
- // generic lifecycle runner until their more involved recovery matrix is selected explicitly.
1815
- const lifecycleLane =
1816
- lane !== "lifecycle" || schema?.params !== undefined || schema?.headers !== undefined
1817
- ? undefined
1818
- : schema?.body !== undefined
1819
- ? schema.query !== undefined
1820
- ? "body-query"
1821
- : "body"
1822
- : schema?.query !== undefined
1823
- ? "query"
1824
- : "hooks"
1825
- // The realistic middleware shape is commonly exactly one synchronous-or-async derive followed
1826
- // by one before hook. Keep the generic runner for every route that can observe decorations,
1827
- // after hooks, error hooks, or response contracts; this lane only removes the two per-request
1828
- // hook-loop dispatches and preserves the same async continuations and error handling.
1829
- const lifecycleHookLane =
1830
- lane === "lifecycle" &&
1831
- contracted === undefined &&
1832
- !hasDecorations &&
1833
- this.derives.length === 1 &&
1834
- this.beforeHandleHooks.length === 1 &&
1835
- this.afterHandleHooks.length === 0 &&
1836
- this.onErrorHooks.length === 0
1837
- ? "derive-before"
1838
- : undefined
1743
+ : isFusedBodyLifecycle
1744
+ ? this.buildFusedBodyDeriveBeforeAfter(
1745
+ handler as unknown as InternalHandler,
1746
+ this.derives[0]!,
1747
+ this.beforeHandleHooks[0]!,
1748
+ this.afterHandleHooks[0],
1749
+ schema?.body as StandardSchemaV1,
1750
+ bodyLimit ?? UNLIMITED_BODY_BYTES,
1751
+ )
1752
+ : isFusedLifecycle && lifecycleHookLane === "derive-before"
1753
+ ? this.buildFusedDeriveBefore(
1754
+ handler as unknown as InternalHandler,
1755
+ this.derives[0]!,
1756
+ this.beforeHandleHooks[0]!,
1757
+ schema?.query,
1758
+ bodyLimit ?? UNLIMITED_BODY_BYTES,
1759
+ )
1760
+ : isFusedLifecycle && lifecycleHookLane === "derive-before-after"
1761
+ ? this.buildFusedDeriveBeforeAfter(
1762
+ handler as unknown as InternalHandler,
1763
+ this.derives[0]!,
1764
+ this.beforeHandleHooks[0]!,
1765
+ this.afterHandleHooks[0]!,
1766
+ schema?.query,
1767
+ bodyLimit ?? UNLIMITED_BODY_BYTES,
1768
+ )
1769
+ : undefined
1770
+ const program = compileRouteProgram({
1771
+ schema,
1772
+ handler: handler as unknown as InternalHandler,
1773
+ derives: this.derives,
1774
+ beforeHandle: this.beforeHandleHooks,
1775
+ afterHandle: this.afterHandleHooks,
1776
+ onError: this.onErrorHooks,
1777
+ decorations: routeDecorations,
1778
+ hasDecorations,
1779
+ bodySchema: schema?.body,
1780
+ bodyLimit,
1781
+ responseContract,
1782
+ })
1783
+ const contextless =
1784
+ lanes.bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler)
1839
1785
  const execution = compileRouteExecutionPlan({
1840
- lane,
1786
+ lane: lanes.lane,
1841
1787
  contextless,
1842
1788
  hasAround: this.aroundHooks.length > 0,
1843
1789
  hasLedger: ledgered !== undefined,
1844
- lifecycleLane,
1845
1790
  fusedWeb,
1846
1791
  fusedBody: fusedBodyRunner,
1847
- fusedLane:
1848
- fusedWeb === undefined ? undefined : fusedQuery ? "query" : fusedBody ? "body" : "bare",
1792
+ fusedLane: lanes.fusedLane,
1849
1793
  })
1850
1794
  const registeredEntry: RouteEntry = {
1851
- // (context: never) => unknown -> InternalHandler: the framework invokes it
1852
- // with the concrete RawContext the typed handler expects, so this is sound.
1853
1795
  handler: handler as unknown as InternalHandler,
1854
1796
  schema,
1855
1797
  bodyLimit,
1856
1798
  idempotent,
1857
1799
  ledgered,
1858
- responseContract: contracted,
1800
+ responseContract,
1859
1801
  derives: [...this.derives],
1860
1802
  decorations: routeDecorations,
1861
1803
  hasDecorations,
1862
1804
  beforeHandle: [...this.beforeHandleHooks],
1863
1805
  afterHandle: [...this.afterHandleHooks],
1864
1806
  onError: [...this.onErrorHooks],
1865
- lifecycleHookLane,
1807
+ lifecycleHookLane: lanes.lifecycleHookLane,
1866
1808
  around: [...this.aroundHooks],
1867
1809
  execution,
1810
+ program,
1868
1811
  }
1869
1812
  const descriptor: RouteDescriptor = {
1870
1813
  method,
@@ -1873,61 +1816,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1873
1816
  ...(capabilities.length > 0 ? { capabilities } : {}),
1874
1817
  ...(schema?.family === true ? { family: true } : {}),
1875
1818
  }
1876
- const routeAssurance: AssuranceDeclaration[] = [...this.activeAssurance, ...handlerAssurance]
1877
- if (contracted?.runtime.mode === "enforce" && schema?.response !== undefined) {
1878
- routeAssurance.push(
1879
- Object.freeze({
1880
- id: NIFRA_ASSURANCE_IDS.RESPONSE_CONTRACT,
1881
- source: "response-contract",
1882
- scope: "plugin",
1883
- }),
1884
- )
1885
- }
1886
- // Inline `schema.assurance`: the route DECLARES its enforcement evidence adjacent to the handler, so an
1887
- // in-handler-guarded route satisfies a policy `require:` clause without a `withRouteAssurance` middleware
1888
- // rewrite. Each id becomes route-scoped `declared` evidence (invalid ids fail closed at registration).
1889
- for (const id of schema?.assurance ?? []) {
1890
- if (!validEvidenceId(id)) {
1891
- throw new Error(
1892
- `route assurance: invalid evidence id ${JSON.stringify(id)} on ${method} ${path} (use lowercase dot/dash segments)`,
1893
- )
1894
- }
1895
- const declared = { id, source: "declared", scope: "plugin" } as AssuranceDeclaration
1896
- Object.defineProperty(declared, "provenance", {
1897
- value: "declared",
1898
- enumerable: false,
1899
- configurable: false,
1900
- writable: false,
1901
- })
1902
- routeAssurance.push(Object.freeze(declared))
1903
- }
1904
- if (schema?.body !== undefined) {
1905
- routeAssurance.push(
1906
- Object.freeze({
1907
- id: NIFRA_ASSURANCE_IDS.BODY_BOUNDED,
1908
- source: "route-schema",
1909
- scope: "plugin",
1910
- }),
1911
- )
1912
- }
1913
- // Declaring `schema.idempotency` is evidence for request replay only. It deliberately never proves
1914
- // durable command execution; that stronger evidence belongs to a command/outbox adapter.
1915
- if (schema?.idempotency !== undefined) {
1916
- routeAssurance.push(
1917
- Object.freeze({
1918
- id: NIFRA_ASSURANCE_IDS.IDEMPOTENCY_KEY,
1919
- source: "route-schema",
1920
- scope: "plugin",
1921
- }),
1922
- )
1923
- }
1924
1819
  return {
1925
1820
  method,
1926
1821
  path,
1927
1822
  pattern,
1928
1823
  entry: registeredEntry,
1929
1824
  descriptor,
1930
- assurance: Object.freeze(routeAssurance),
1825
+ assurance: routeAssurance,
1931
1826
  }
1932
1827
  }
1933
1828
 
@@ -1961,6 +1856,29 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1961
1856
  this.responseContractRuntime = runtime
1962
1857
  }
1963
1858
 
1859
+ /** @internal Symbol-keyed install seam for the response observer plugin. */
1860
+ [INSTALL_RESPONSE_OBSERVER](runtime: ResponseObserverRuntime): ResponseObserverMethods {
1861
+ this.assertConfigurable("responseObserver()")
1862
+ const methods = runtime.install({
1863
+ assertConfigurable: (operation) => this.assertConfigurable(operation),
1864
+ addResponseHook: (web, node) => {
1865
+ this.onResponseHooks.push(web)
1866
+ this.onNodeResponseHooks.push(node)
1867
+ if (node === undefined) this.nodeResponseHooksComplete = false
1868
+ },
1869
+ enableResponseBodyTagging: () => {
1870
+ if (this.responseBodyTag === undefined) {
1871
+ this.responseBodyTag = Object.freeze({})
1872
+ this.responseBodyOwners.add(this.responseBodyTag)
1873
+ }
1874
+ return this.responseBodyTag
1875
+ },
1876
+ responseBodyOwners: () => this.responseBodyOwners,
1877
+ })
1878
+ Object.assign(this, methods)
1879
+ return methods
1880
+ }
1881
+
1964
1882
  /** @internal Symbol-keyed install seam for the `idempotency()` plugin. Off the public typed surface. */
1965
1883
  [INSTALL_IDEMPOTENCY](runtime: IdempotencyRuntime): void {
1966
1884
  this.assertConfigurable("idempotency()")
@@ -2015,7 +1933,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2015
1933
  * `RouteConfigError` at merge time, and a group with WebSocket routes is refused (register
2016
1934
  * those on the parent).
2017
1935
  */
2018
- merge<R2 extends Registry, Ctx2>(other: Server<R2, Ctx2>): Server<R & R2, Ctx> {
1936
+ merge<R2 extends Registry, Ctx2, HookOutput2>(
1937
+ other: Server<R2, Ctx2, HookOutput2>,
1938
+ ): Server<R & R2, Ctx, HookOutput> {
2019
1939
  this.assertConfigurable("merge()")
2020
1940
  const source = other as unknown as Server<Registry, EmptyContext>
2021
1941
  if (source.wsRouteCount > 0) {
@@ -2100,7 +2020,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2100
2020
  if (!scoped) this.globalAssurance.push(...source.globalAssurance)
2101
2021
  this.mcpResourceList.push(...source.mcpResourceList)
2102
2022
  this.mcpPromptList.push(...source.mcpPromptList)
2103
- return this as unknown as Server<R & R2, Ctx>
2023
+ return this as unknown as Server<R & R2, Ctx, HookOutput>
2104
2024
  }
2105
2025
 
2106
2026
  /** A fused renderer closes over runtime services to keep its seven-argument JSC fast path. Merging
@@ -2110,31 +2030,81 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2110
2030
  if (entry.execution.fusedWeb === undefined) return route
2111
2031
  // Rebuild with the SAME builder that produced the closure - rebinding a query-fused route as
2112
2032
  // bare would silently drop its validation.
2033
+ const lane = entry.execution.fusedLane
2034
+ // Type-erase the lane so each `===` branch sees the full union, not the narrowed remainder.
2035
+ const laneName: string | undefined = lane
2036
+ // Body+lifecycle routes have a fused Web renderer, but no body-only Node renderer: the latter
2037
+ // would bypass derive/before/after because the Node-direct dispatcher prefers `fusedBody` over
2038
+ // the generic execution plan. Only the original body-only lane may populate this slot.
2113
2039
  const fusedBody =
2114
- entry.execution.fusedLane === "body"
2040
+ laneName === "body"
2115
2041
  ? this.buildFusedBodyRunner(
2116
2042
  entry.handler,
2117
2043
  entry.schema?.body as StandardSchemaV1,
2118
2044
  entry.hasDecorations ? entry.decorations : undefined,
2119
- entry.bodyLimit ?? this.maxBodyBytes,
2045
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2120
2046
  )
2121
2047
  : undefined
2048
+ const derive = entry.derives[0]
2049
+ const before = entry.beforeHandle[0]
2050
+ const after = entry.afterHandle[0]
2122
2051
  const fusedWeb =
2123
- entry.execution.fusedLane === "body"
2052
+ laneName === "body"
2124
2053
  ? this.buildFusedBodyWeb(fusedBody as FusedBodyRunner)
2125
- : entry.execution.fusedLane === "query"
2054
+ : laneName === "query"
2126
2055
  ? this.buildFusedQueryWeb(
2127
2056
  entry.handler,
2128
2057
  entry.hasDecorations ? entry.decorations : undefined,
2129
2058
  entry.schema?.query as StandardSchemaV1,
2130
- entry.bodyLimit ?? this.maxBodyBytes,
2131
- )
2132
- : this.buildFusedWeb(
2133
- entry.handler,
2134
- entry.hasDecorations ? entry.decorations : undefined,
2135
- isContextlessNoArgArrow(entry.handler),
2136
- entry.bodyLimit ?? this.maxBodyBytes,
2059
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2137
2060
  )
2061
+ : laneName === "body-derive-before" && derive !== undefined && before !== undefined
2062
+ ? this.buildFusedBodyDeriveBeforeAfter(
2063
+ entry.handler,
2064
+ derive,
2065
+ before,
2066
+ undefined,
2067
+ entry.schema?.body as StandardSchemaV1,
2068
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2069
+ )
2070
+ : laneName === "body-derive-before-after" &&
2071
+ derive !== undefined &&
2072
+ before !== undefined &&
2073
+ after !== undefined
2074
+ ? this.buildFusedBodyDeriveBeforeAfter(
2075
+ entry.handler,
2076
+ derive,
2077
+ before,
2078
+ after,
2079
+ entry.schema?.body as StandardSchemaV1,
2080
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2081
+ )
2082
+ : laneName === "derive-before" && derive !== undefined && before !== undefined
2083
+ ? this.buildFusedDeriveBefore(
2084
+ entry.handler,
2085
+ derive,
2086
+ before,
2087
+ entry.schema?.query,
2088
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2089
+ )
2090
+ : laneName === "derive-before-after" &&
2091
+ derive !== undefined &&
2092
+ before !== undefined &&
2093
+ after !== undefined
2094
+ ? this.buildFusedDeriveBeforeAfter(
2095
+ entry.handler,
2096
+ derive,
2097
+ before,
2098
+ after,
2099
+ entry.schema?.query,
2100
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2101
+ )
2102
+ : this.buildFusedWeb(
2103
+ entry.handler,
2104
+ entry.hasDecorations ? entry.decorations : undefined,
2105
+ isContextlessNoArgArrow(entry.handler),
2106
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2107
+ )
2138
2108
  return {
2139
2109
  ...route,
2140
2110
  entry: {
@@ -2290,6 +2260,45 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2290
2260
  return outcome
2291
2261
  }
2292
2262
 
2263
+ /**
2264
+ * Select a mounted handler's native Node lane without materializing a Web Request. This is an
2265
+ * adapter seam, not a second lifecycle: any app-wide behavior that the ordinary mount path would
2266
+ * run makes the capability ineligible and the caller falls back to `resolveNodeSource`. Typed
2267
+ * routes keep precedence exactly as `routeAndRun` does, including the existing mount behavior for
2268
+ * a path whose registered route rejects this method.
2269
+ */
2270
+ [RESOLVE_NODE_MOUNT](source: RequestSource): NativeMountSelection | undefined {
2271
+ if (
2272
+ this.onRequestHooks.length > 0 ||
2273
+ this.onResponseHooks.length > 0 ||
2274
+ this.onResponseFinalizedHooks.length > 0 ||
2275
+ this.capacityGate !== undefined ||
2276
+ this.staticResponseHeaders !== undefined ||
2277
+ this.requestTimeoutMs !== 0 ||
2278
+ this.acceptInboundDeadlines ||
2279
+ this.clientIpTrust !== undefined
2280
+ ) {
2281
+ return undefined
2282
+ }
2283
+
2284
+ const parts = source.urlParts ?? urlPartsOf(source.url)
2285
+ const match = this.catalog.find(source.method, parts.pathname)
2286
+ if (match.found) return undefined
2287
+
2288
+ for (const mount of this.fetchMounts) {
2289
+ if (!underMountPrefix(parts.pathname, mount.path)) continue
2290
+ const candidate = (mount.handler as unknown as Record<symbol, unknown>)[NODE_NATIVE_MOUNT]
2291
+ return typeof candidate === "function"
2292
+ ? {
2293
+ handler: candidate as NativeMountHandler,
2294
+ path: mount.path,
2295
+ stripPrefix: mount.stripPrefix,
2296
+ }
2297
+ : undefined
2298
+ }
2299
+ return undefined
2300
+ }
2301
+
2293
2302
  /**
2294
2303
  * Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` - the
2295
2304
  * `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
@@ -2668,7 +2677,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2668
2677
  source: RequestSource,
2669
2678
  platform: Platform | undefined,
2670
2679
  finalize: (result: unknown, set: CtxSet) => T,
2671
- wrapResponse: (response: Response) => T,
2680
+ wrapResponse: (response: Response | ResponseResult) => T,
2672
2681
  onTimeout: () => T,
2673
2682
  // True only from the Web `fetch` path - unlocks each route's fused lane, whose output type IS
2674
2683
  // `Response` (`T = Response` there by construction; the node path always passes false).
@@ -2696,7 +2705,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2696
2705
  source: RequestSource,
2697
2706
  platform: Platform | undefined,
2698
2707
  finalize: (result: unknown, set: CtxSet) => T,
2699
- wrapResponse: (response: Response) => T,
2708
+ wrapResponse: (response: Response | ResponseResult) => T,
2700
2709
  onTimeout: () => T,
2701
2710
  ): MaybePromise<T> {
2702
2711
  const hooks = this.onNodeRequestHooks
@@ -2730,7 +2739,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2730
2739
  source: RequestSource,
2731
2740
  platform: Platform | undefined,
2732
2741
  finalize: (result: unknown, set: CtxSet) => T,
2733
- wrapResponse: (response: Response) => T,
2742
+ wrapResponse: (response: Response | ResponseResult) => T,
2734
2743
  onTimeout: () => T,
2735
2744
  ): Promise<T> {
2736
2745
  if (first !== undefined) return wrapResponse(first)
@@ -2758,7 +2767,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2758
2767
  source: RequestSource,
2759
2768
  platform: Platform | undefined,
2760
2769
  finalize: (result: unknown, set: CtxSet) => T,
2761
- wrapResponse: (response: Response) => T,
2770
+ wrapResponse: (response: Response | ResponseResult) => T,
2762
2771
  onTimeout: () => T,
2763
2772
  webFast: boolean,
2764
2773
  ): MaybePromise<T> {
@@ -2805,7 +2814,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2805
2814
  sourceAtAwait: RequestSource,
2806
2815
  platform: Platform | undefined,
2807
2816
  finalize: (result: unknown, set: CtxSet) => T,
2808
- wrapResponse: (response: Response) => T,
2817
+ wrapResponse: (response: Response | ResponseResult) => T,
2809
2818
  onTimeout: () => T,
2810
2819
  webFast: boolean,
2811
2820
  ): Promise<T> {
@@ -2893,13 +2902,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2893
2902
  /**
2894
2903
  * Route → build context → run. Synchronous through to the handler for a **bare** route
2895
2904
  * (selected by its compiled plan), so a sync handler produces its result with zero promise allocations;
2896
- * routes with validation/hooks keep the full async {@link runLifecycle}, unchanged.
2905
+ * routes with validation/hooks keep the full compiled route program, unchanged.
2897
2906
  */
2898
2907
  private routeAndRun<T>(
2899
2908
  source: RequestSource,
2900
2909
  platform: Platform | undefined,
2901
2910
  finalize: (result: unknown, set: CtxSet) => T,
2902
- wrapResponse: (response: Response) => T,
2911
+ wrapResponse: (response: Response | ResponseResult) => T,
2903
2912
  onTimeout: () => T,
2904
2913
  webFast: boolean,
2905
2914
  ): MaybePromise<T> {
@@ -2951,17 +2960,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2951
2960
  }
2952
2961
  if (match.reason === "method-not-allowed") {
2953
2962
  return wrapResponse(
2954
- jsonError(405, "method_not_allowed", { Allow: match.allowed.join(", ") }),
2963
+ // Lowercase on purpose: a plain render's headers go into the node outcome record verbatim,
2964
+ // and every other name in that record is lowercase (the Web lane normalizes either way).
2965
+ plainError(405, "method_not_allowed", { allow: match.allowed.join(", ") }),
2955
2966
  )
2956
2967
  }
2957
- return wrapResponse(jsonError(404, "not_found"))
2968
+ return wrapResponse(plainError(404, "not_found"))
2958
2969
  }
2959
2970
 
2960
2971
  // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
2961
2972
  // already did and made every plain dynamic route pay for unrelated static path bytes.
2962
2973
  const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params)
2963
2974
  if (params === null) {
2964
- return wrapResponse(jsonError(400, "malformed_path"))
2975
+ return wrapResponse(plainError(400, "malformed_path"))
2965
2976
  }
2966
2977
 
2967
2978
  return this.runMatched(
@@ -3001,7 +3012,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3001
3012
  params: Record<string, string>,
3002
3013
  search: string | undefined,
3003
3014
  finalize: (result: unknown, set: CtxSet) => T,
3004
- wrapResponse: (response: Response) => T,
3015
+ wrapResponse: (response: Response | ResponseResult) => T,
3005
3016
  onTimeout: () => T,
3006
3017
  webFast: boolean,
3007
3018
  ): MaybePromise<T> {
@@ -3032,7 +3043,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3032
3043
  search,
3033
3044
  wrapResponse,
3034
3045
  {
3035
- maxBodyBytes: entry.bodyLimit ?? this.maxBodyBytes,
3046
+ maxBodyBytes: entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3036
3047
  runLanes: (buffered, plat, ent, prm, srch) =>
3037
3048
  this.idempotencyRunLanes(buffered, plat, ent as RouteEntry, prm, srch),
3038
3049
  },
@@ -3059,7 +3070,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3059
3070
  params: Record<string, string>,
3060
3071
  search: string | undefined,
3061
3072
  finalize: (result: unknown, set: CtxSet) => T,
3062
- wrapResponse: (response: Response) => T,
3073
+ wrapResponse: (response: Response | ResponseResult) => T,
3063
3074
  onTimeout: () => T,
3064
3075
  webFast: boolean,
3065
3076
  ): MaybePromise<T> {
@@ -3081,7 +3092,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3081
3092
  ? undefined
3082
3093
  : admitDeadline(source.headers, this.deadlineAdmissionOptions)
3083
3094
  if (admission !== undefined && !admission.ok) {
3084
- return wrapResponse(jsonError(admission.status, admission.reason))
3095
+ return wrapResponse(plainError(admission.status, admission.reason))
3085
3096
  }
3086
3097
  const effectiveTimeoutMs = admission?.timeoutMs ?? 0
3087
3098
 
@@ -3140,7 +3151,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3140
3151
  if (controller !== undefined && outcome instanceof Promise) {
3141
3152
  const timedOut =
3142
3153
  admission?.inherited === true
3143
- ? () => wrapResponse(jsonError(504, "deadline_exceeded"))
3154
+ ? () => wrapResponse(plainError(504, "deadline_exceeded"))
3144
3155
  : onTimeout
3145
3156
  return this.withTimeout(
3146
3157
  outcome,
@@ -3161,7 +3172,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3161
3172
  /** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
3162
3173
  * successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
3163
3174
  // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
3164
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
3165
3175
  private runContextlessBare<T>(
3166
3176
  entry: RouteEntry,
3167
3177
  source: RequestSource,
@@ -3171,7 +3181,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3171
3181
  budget: RequestBudget,
3172
3182
  platform: Platform | undefined,
3173
3183
  finalize: (result: unknown, set: CtxSet) => T,
3174
- wrapResponse: (response: Response) => T,
3184
+ wrapResponse: (response: Response | ResponseResult) => T,
3175
3185
  ): MaybePromise<T> {
3176
3186
  let result: unknown
3177
3187
  try {
@@ -3215,9 +3225,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3215
3225
  signal: AbortSignal,
3216
3226
  budget: RequestBudget,
3217
3227
  platform: Platform | undefined,
3218
- wrapResponse: (response: Response) => T,
3228
+ wrapResponse: (response: Response | ResponseResult) => T,
3219
3229
  ): T {
3220
3230
  if (err instanceof Response) return wrapResponse(err)
3231
+ // Handed over as the plain data it is: `wrapResponse` renders it on the JSON lane (no `Response`
3232
+ // built on Node). There is no context here by construction, so there is no `c.set` to merge.
3233
+ if (isResponseResult(err)) return wrapResponse(err)
3221
3234
  const ctx = new RequestContext(
3222
3235
  source,
3223
3236
  params,
@@ -3229,43 +3242,42 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3229
3242
  this.protoPoisoning,
3230
3243
  )
3231
3244
  this.logRequestError(err, ctx)
3232
- return wrapResponse(jsonError(500, "internal_error"))
3245
+ return this.internalErrorResponse(wrapResponse)
3233
3246
  }
3234
3247
 
3235
3248
  /**
3236
3249
  * The synchronous fast path selected by a route's execution plan: apply static decorations, call the
3237
3250
  * handler, render the result - **no `await`** unless the handler itself returns a promise. It mirrors
3238
- * the bare slice of {@link runLifecycle} (which a bare route would otherwise no-op through) and shares
3251
+ * the same error boundary as the compiled route program (which a bare route would otherwise no-op through) and shares
3239
3252
  * {@link logRequestError}; a bare route has no `onError` hooks, so error handling is fully synchronous
3240
3253
  * (a thrown `Response` is control flow; anything else is a logged flat 500). This is where nifra skips
3241
3254
  * the per-request async-frame tax - the same win codegen routers get, but without `eval`.
3242
3255
  */
3243
3256
  // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
3244
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
3245
3257
  private runBare<T>(
3246
3258
  entry: RouteEntry,
3247
3259
  ctx: RawContext,
3248
3260
  finalize: (result: unknown, set: CtxSet) => T,
3249
- wrapResponse: (response: Response) => T,
3261
+ wrapResponse: (response: Response | ResponseResult) => T,
3250
3262
  ): MaybePromise<T> {
3251
3263
  let result: unknown
3252
3264
  try {
3253
3265
  if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
3254
3266
  result = entry.handler(ctx)
3255
3267
  } catch (err) {
3256
- return this.bareError(err, ctx, wrapResponse)
3268
+ return this.bareError(err, ctx, finalize, wrapResponse)
3257
3269
  }
3258
3270
  if (result instanceof Promise) {
3259
3271
  // Async handler on an otherwise-bare route: finish on a microtask, with the same error handling.
3260
3272
  return result.then(
3261
3273
  (value) => finalize(value, responseSet(ctx)),
3262
- (err) => this.bareError(err, ctx, wrapResponse),
3274
+ (err) => this.bareError(err, ctx, finalize, wrapResponse),
3263
3275
  )
3264
3276
  }
3265
3277
  return finalize(result, responseSet(ctx))
3266
3278
  }
3267
3279
 
3268
- /** Bare-route error rendering - identical to {@link runLifecycle}'s catch minus the (absent) onError
3280
+ /** Bare-route error rendering - identical to the compiled route program's catch minus the (absent) onError
3269
3281
  * loop: a thrown `Response` is returned as deliberate control flow; anything else is logged + 500. */
3270
3282
  /**
3271
3283
  * Build a route's fused Web renderer. Composition happens once at
@@ -3280,13 +3292,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3280
3292
  contextless: boolean,
3281
3293
  maxBodyBytes: number,
3282
3294
  ): FusedWebRunner {
3283
- // The fused lanes return to the runtime directly, so the framework's own error renders fold in
3284
- // static headers here rather than through the shared `wrapResponse` seam.
3285
- const logError = (err: unknown, ctx: RawContext): Response => {
3286
- if (err instanceof Response) return this.wrapWebResponse(err)
3287
- this.logRequestError(err, ctx)
3288
- return this.wrapWebResponse(jsonError(500, "internal_error"))
3289
- }
3295
+ // Same thrown-value contract as the generic lane (`bare-error-lane.ts` via `bareError`); the fused
3296
+ // lane only injects its renderer, so a thrown `status(...)` and the flat 500 fold in static headers
3297
+ // through `wrapWebResponse`. One source of truth for the error surface, no per-lane copy to drift.
3298
+ const logError = (err: unknown, ctx: RawContext): Response =>
3299
+ this.bareError(
3300
+ err,
3301
+ ctx,
3302
+ (result, set) => this.wrapWebResponse(toResponse(result, set)),
3303
+ this.wrapWebResponse,
3304
+ )
3290
3305
  if (contextless && decorations === undefined) {
3291
3306
  // `() => ...` can't observe the context - skip allocating one entirely (errors still build
3292
3307
  // one for the structured log, exactly like runContextlessBare).
@@ -3416,20 +3431,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3416
3431
  const logError = <T>(
3417
3432
  err: unknown,
3418
3433
  ctx: RawContext,
3419
- wrapResponse: (response: Response) => T,
3420
- ): T => this.bareError(err, ctx, wrapResponse)
3434
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
3435
+ wrapResponse: (response: Response | ResponseResult) => T,
3436
+ ): T => this.bareError(err, ctx, finalize, wrapResponse)
3421
3437
 
3422
3438
  const runHandler = <T>(
3423
3439
  ctx: RawContext,
3424
3440
  finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
3425
- wrapResponse: (response: Response) => T,
3441
+ wrapResponse: (response: Response | ResponseResult) => T,
3426
3442
  ): MaybePromise<T> => {
3427
3443
  if (decorations !== undefined) Object.assign(ctx, decorations)
3428
3444
  let output: MaybePromise<HandlerResult>
3429
3445
  try {
3430
3446
  output = handler(ctx)
3431
3447
  } catch (err) {
3432
- return logError(err, ctx, wrapResponse)
3448
+ return logError(err, ctx, finalize, wrapResponse)
3433
3449
  }
3434
3450
  if (output instanceof Promise) {
3435
3451
  return output.then(
@@ -3437,16 +3453,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3437
3453
  try {
3438
3454
  return finalize(result, responseSet(ctx), ctx)
3439
3455
  } catch (err) {
3440
- return logError(err, ctx, wrapResponse)
3456
+ return logError(err, ctx, finalize, wrapResponse)
3441
3457
  }
3442
3458
  },
3443
- (err) => logError(err, ctx, wrapResponse),
3459
+ (err) => logError(err, ctx, finalize, wrapResponse),
3444
3460
  )
3445
3461
  }
3446
3462
  try {
3447
3463
  return finalize(output, responseSet(ctx), ctx)
3448
3464
  } catch (err) {
3449
- return logError(err, ctx, wrapResponse)
3465
+ return logError(err, ctx, finalize, wrapResponse)
3450
3466
  }
3451
3467
  }
3452
3468
 
@@ -3454,9 +3470,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3454
3470
  result: StandardResult<unknown>,
3455
3471
  ctx: RawContext,
3456
3472
  finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
3457
- wrapResponse: (response: Response) => T,
3473
+ wrapResponse: (response: Response | ResponseResult) => T,
3458
3474
  ): MaybePromise<T> => {
3459
- if (result.issues !== undefined) return wrapResponse(validationError(result.issues))
3475
+ if (result.issues !== undefined) return wrapResponse(plainValidationError(result.issues))
3460
3476
  ctx.body = result.value
3461
3477
  return runHandler(ctx, finalize, wrapResponse)
3462
3478
  }
@@ -3465,13 +3481,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3465
3481
  parsed: unknown,
3466
3482
  ctx: RawContext,
3467
3483
  finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
3468
- wrapResponse: (response: Response) => T,
3484
+ wrapResponse: (response: Response | ResponseResult) => T,
3469
3485
  ): MaybePromise<T> => {
3470
3486
  let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
3471
3487
  try {
3472
3488
  validation = bodySchema["~standard"].validate(parsed)
3473
3489
  } catch (err) {
3474
- return logError(err, ctx, wrapResponse)
3490
+ return logError(err, ctx, finalize, wrapResponse)
3475
3491
  }
3476
3492
  if (validation instanceof Promise) {
3477
3493
  return validation.then(
@@ -3479,16 +3495,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3479
3495
  try {
3480
3496
  return runValidated(settled, ctx, finalize, wrapResponse)
3481
3497
  } catch (err) {
3482
- return logError(err, ctx, wrapResponse)
3498
+ return logError(err, ctx, finalize, wrapResponse)
3483
3499
  }
3484
3500
  },
3485
- (err) => logError(err, ctx, wrapResponse),
3501
+ (err) => logError(err, ctx, finalize, wrapResponse),
3486
3502
  )
3487
3503
  }
3488
3504
  try {
3489
3505
  return runValidated(validation, ctx, finalize, wrapResponse)
3490
3506
  } catch (err) {
3491
- return logError(err, ctx, wrapResponse)
3507
+ return logError(err, ctx, finalize, wrapResponse)
3492
3508
  }
3493
3509
  }
3494
3510
 
@@ -3501,7 +3517,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3501
3517
  platform: Platform | undefined,
3502
3518
  nativeContext: boolean,
3503
3519
  finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
3504
- wrapResponse: (response: Response) => T,
3520
+ wrapResponse: (response: Response | ResponseResult) => T,
3505
3521
  ): MaybePromise<T> => {
3506
3522
  const ctx = nativeContext
3507
3523
  ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
@@ -3517,8 +3533,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3517
3533
  )
3518
3534
  const finish = (value: unknown): MaybePromise<T> =>
3519
3535
  runParsed(value, ctx, finalize, wrapResponse)
3520
- return this.readBodyInput(source, maxBodyBytes, finish, wrapResponse, (err) =>
3521
- logError(err, ctx, wrapResponse),
3536
+ return readBodyFramed(
3537
+ source,
3538
+ maxBodyBytes,
3539
+ this.protoPoisoning,
3540
+ finish,
3541
+ wrapResponse,
3542
+ (err) => logError(err, ctx, finalize, wrapResponse),
3522
3543
  )
3523
3544
  }
3524
3545
  }
@@ -3560,13 +3581,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3560
3581
  querySchema: StandardSchemaV1,
3561
3582
  maxBodyBytes: number,
3562
3583
  ): FusedWebRunner {
3563
- // The fused lanes return to the runtime directly, so the framework's own error renders fold in
3564
- // static headers here rather than through the shared `wrapResponse` seam.
3565
- const logError = (err: unknown, ctx: RawContext): Response => {
3566
- if (err instanceof Response) return this.wrapWebResponse(err)
3567
- this.logRequestError(err, ctx)
3568
- return this.wrapWebResponse(jsonError(500, "internal_error"))
3569
- }
3584
+ // Same thrown-value contract as the generic lane (`bare-error-lane.ts` via `bareError`); the fused
3585
+ // lane only injects its renderer, so a thrown `status(...)` and the flat 500 fold in static headers
3586
+ // through `wrapWebResponse`. One source of truth for the error surface, no per-lane copy to drift.
3587
+ const logError = (err: unknown, ctx: RawContext): Response =>
3588
+ this.bareError(
3589
+ err,
3590
+ ctx,
3591
+ (result, set) => this.wrapWebResponse(toResponse(result, set)),
3592
+ this.wrapWebResponse,
3593
+ )
3570
3594
  const runHandler = (ctx: RawContext, value: unknown): MaybePromise<Response> => {
3571
3595
  ctx.query = value
3572
3596
  let result: unknown
@@ -3620,1042 +3644,984 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
3620
3644
  return validation.then(
3621
3645
  (settled) =>
3622
3646
  settled.issues !== undefined
3623
- ? this.wrapWebResponse(validationError(settled.issues))
3647
+ ? this.wrapWebResponse(plainValidationError(settled.issues))
3624
3648
  : runHandler(ctx, settled.value),
3625
3649
  (err) => logError(err, ctx),
3626
3650
  )
3627
3651
  }
3628
3652
  if (validation.issues !== undefined)
3629
- return this.wrapWebResponse(validationError(validation.issues))
3653
+ return this.wrapWebResponse(plainValidationError(validation.issues))
3630
3654
  return runHandler(ctx, validation.value)
3631
3655
  }
3632
3656
  }
3633
3657
 
3634
- private bareError<T>(err: unknown, ctx: RawContext, wrapResponse: (response: Response) => T): T {
3635
- if (err instanceof Response) return wrapResponse(err)
3636
- this.logRequestError(err, ctx)
3637
- return wrapResponse(jsonError(500, "internal_error"))
3638
- }
3639
-
3640
- // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
3641
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
3642
- private runWithAround<T>(
3643
- entry: RouteEntry,
3644
- ctx: RawContext,
3645
- run: () => MaybePromise<T>,
3646
- finalize: (result: unknown, set: CtxSet) => T,
3647
- wrapResponse: (response: Response) => T,
3648
- ): MaybePromise<T> {
3649
- let outcome: MaybePromise<T>
3650
- try {
3651
- outcome = this.runAround(entry.around, ctx, run)
3652
- } catch (err) {
3653
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
3654
- }
3655
- return outcome instanceof Promise
3656
- ? outcome.catch((err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse))
3657
- : outcome
3658
- }
3659
-
3660
- private runAround<T>(
3661
- hooks: ReadonlyArray<RawAround>,
3662
- ctx: RawContext,
3663
- run: () => MaybePromise<T>,
3664
- ): MaybePromise<T> {
3665
- const dispatch = (index: number): MaybePromise<T> => {
3666
- if (index >= hooks.length) return run()
3667
- const hook = hooks[index]!
3668
- let called = false
3669
- return hook(ctx, () => {
3670
- if (called) throw new Error("around next() called multiple times")
3671
- called = true
3672
- return dispatch(index + 1)
3673
- })
3674
- }
3675
- return dispatch(0)
3676
- }
3677
-
3678
- // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
3679
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
3680
- private runBodyOnly<T>(
3681
- entry: RouteEntry,
3682
- source: RequestSource,
3683
- ctx: RawContext,
3684
- finalize: (result: unknown, set: CtxSet) => T,
3685
- wrapResponse: (response: Response) => T,
3686
- ): Promise<T> {
3687
- return this.readBodyInput(
3688
- source,
3689
- entry.bodyLimit ?? this.maxBodyBytes,
3690
- (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
3691
- wrapResponse,
3692
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3693
- )
3694
- }
3695
-
3696
- /** Shared bounded body framing/parser. Both body lanes use the same trust-boundary checks; only
3697
- * the validation + handler continuation differs. */
3698
- private readBodyInput<T>(
3699
- source: RequestSource,
3658
+ /**
3659
+ * Compile a route's `derive + before` lifecycle to a single closure: the per-request walk through
3660
+ * `runSync` → `runHooksSync` is a stack of 3 frames and 2 `Object.assign`s for what is by far the
3661
+ * most common middleware shape (auth/trace/id derivation, an auth/policy `beforeHandle`). Folding
3662
+ * the same steps into one closure hits the same monomorphic inline-cache site as `buildFusedWeb`,
3663
+ * and `Object.assign(ctx, deriveResult)` becomes one extra property write on the context's already
3664
+ * hot hidden class. Behavior is byte-identical to the compiled route program: the same thrown-value
3665
+ * contract (`Response` is control flow, anything else logs + 500), the same `c.set` semantics, the
3666
+ * same `fusedRespond` for the rendered body. The `LifecycleExecutionLane` guarantee
3667
+ * (`schema.params === undefined && schema.headers === undefined && derives === 1 &&
3668
+ * beforeHandle === 1 && afterHandle === 0 && onError === 0 && !hasResponseContract &&
3669
+ * !hasDecorations`) is enforced by `selectRouteLanes`; this lane is selected ONLY when all of those
3670
+ * hold. A `query` schema (the route declares `{ query }` alongside the hooks) is validated FIRST,
3671
+ * mirroring the compiled route program's stage order; the fused builder runs validate → derive →
3672
+ * before → handler in one frame.
3673
+ */
3674
+ private buildFusedDeriveBefore(
3675
+ handler: InternalHandler,
3676
+ derive: RawDerive,
3677
+ before: RawBeforeHandle,
3678
+ querySchema: StandardSchemaV1 | undefined,
3700
3679
  maxBodyBytes: number,
3701
- onParsed: (parsed: unknown) => MaybePromise<T>,
3702
- wrapResponse: (response: Response) => T,
3703
- onError: (err: unknown) => MaybePromise<T>,
3704
- ): Promise<T> {
3705
- const contentType = headerOf(source, "content-type") ?? ""
3706
- if (contentType !== "application/json" && !contentType.includes("application/json")) {
3707
- if (isUrlEncodedForm(contentType)) {
3708
- return readBoundedForm(source, maxBodyBytes).then(
3709
- (form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)),
3710
- onError,
3711
- ) as Promise<T>
3712
- }
3713
- return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")))
3714
- }
3715
-
3716
- // All JSON bodies go through readBoundedJson - the single enforcement point for the framed
3717
- // fast path (post-read byte re-check), the streaming cap, and the prototype-poisoning guard.
3718
- // A separate inlined native-json() fast path here would fork the trust boundary in two.
3719
- try {
3720
- return this.readBoundedJson(
3721
- source,
3722
- maxBodyBytes,
3723
- (parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)),
3724
- onError,
3680
+ ): FusedWebRunner {
3681
+ const logError = (err: unknown, ctx: RawContext): Response =>
3682
+ this.bareError(
3683
+ err,
3684
+ ctx,
3685
+ (result, set) => this.wrapWebResponse(toResponse(result, set)),
3686
+ this.wrapWebResponse,
3725
3687
  )
3726
- } catch (err) {
3727
- return Promise.resolve(onError(err))
3728
- }
3729
- }
3730
-
3731
- /** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
3732
- * streaming fallback. A method (not per-request closures) so the hot path allocates nothing
3733
- * beyond the one `.then` continuation. */
3734
- private finishBodyOnly<T>(
3735
- entry: RouteEntry,
3736
- parsed: unknown,
3737
- ctx: RawContext,
3738
- finalize: (result: unknown, set: CtxSet) => T,
3739
- wrapResponse: (response: Response) => T,
3740
- ): MaybePromise<T> {
3741
- try {
3742
- const bodySchema = entry.schema!.body!
3743
- const validation = bodySchema["~standard"].validate(parsed)
3744
- if (validation instanceof Promise) {
3745
- return validation.then(
3746
- (result) => {
3747
- try {
3748
- const outcome = this.applyBodyValidation(entry, result, ctx, finalize, wrapResponse)
3749
- return outcome instanceof Promise
3750
- ? outcome.catch((err) =>
3751
- this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3752
- )
3753
- : outcome
3754
- } catch (err) {
3755
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
3756
- }
3757
- },
3758
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3688
+ return (source, params, search, signal, budget, platform, nativeContext) => {
3689
+ const ctx = nativeContext
3690
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3691
+ : new RequestContext(
3692
+ source,
3693
+ params,
3694
+ search,
3695
+ signal,
3696
+ budget,
3697
+ platform,
3698
+ maxBodyBytes,
3699
+ this.protoPoisoning,
3700
+ )
3701
+ const runHandler = (): MaybePromise<Response> => {
3702
+ let result: unknown
3703
+ try {
3704
+ result = handler(ctx)
3705
+ } catch (err) {
3706
+ return logError(err, ctx)
3707
+ }
3708
+ if (result instanceof Promise) {
3709
+ return result.then(
3710
+ (value) =>
3711
+ fusedRespond(
3712
+ value,
3713
+ ctx,
3714
+ this.responseBodyTag,
3715
+ this.staticResponseHeaders,
3716
+ this.onResponseHooks.length === 0,
3717
+ ),
3718
+ (err) => logError(err, ctx),
3719
+ )
3720
+ }
3721
+ return fusedRespond(
3722
+ result,
3723
+ ctx,
3724
+ this.responseBodyTag,
3725
+ this.staticResponseHeaders,
3726
+ this.onResponseHooks.length === 0,
3759
3727
  )
3760
3728
  }
3761
- const outcome = this.applyBodyValidation(entry, validation, ctx, finalize, wrapResponse)
3762
- return outcome instanceof Promise
3763
- ? outcome.catch((err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse))
3764
- : outcome
3765
- } catch (err) {
3766
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
3767
- }
3768
- }
3769
-
3770
- private executeHandler<T>(
3771
- entry: RouteEntry,
3772
- ctx: RawContext,
3773
- finalize: (result: unknown, set: CtxSet) => T,
3774
- wrapResponse?: (response: Response) => T,
3775
- ): MaybePromise<T> {
3776
- if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
3777
- const handlerOutput = entry.handler(ctx)
3778
- if (handlerOutput instanceof Promise) {
3779
- return handlerOutput.then(
3780
- (value) => finalize(value, responseSet(ctx)),
3781
- (err) => {
3782
- if (wrapResponse) {
3783
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
3784
- }
3785
- throw err
3786
- },
3787
- )
3788
- }
3789
- return finalize(handlerOutput, responseSet(ctx))
3790
- }
3791
-
3792
- private handleValidationErrorRecovery<T>(
3793
- entry: RouteEntry,
3794
- recovery: unknown,
3795
- ctx: RawContext,
3796
- finalize: (result: unknown, set: CtxSet) => T,
3797
- wrapResponse: (response: Response) => T,
3798
- originalIssues: ReadonlyArray<StandardIssue>,
3799
- kind: "body" | "query",
3800
- ): MaybePromise<T> {
3801
- if (recovery !== undefined) {
3802
- if (recovery instanceof Response) {
3803
- return wrapResponse(recovery)
3804
- }
3805
- if (kind === "body" && entry.schema?.body) {
3806
- const validation = entry.schema.body["~standard"].validate(recovery)
3807
- if (validation instanceof Promise) {
3808
- return validation.then((settled) => {
3809
- if (settled.issues !== undefined) return wrapResponse(validationError(settled.issues))
3810
- ctx.body = settled.value
3811
- return this.executeHandler(entry, ctx, finalize)
3812
- })
3729
+ const runBefore = (): MaybePromise<Response> => {
3730
+ let early: unknown
3731
+ try {
3732
+ early = before(ctx)
3733
+ } catch (err) {
3734
+ return logError(err, ctx)
3813
3735
  }
3814
- if (validation.issues !== undefined) return wrapResponse(validationError(validation.issues))
3815
- ctx.body = validation.value
3816
- return this.executeHandler(entry, ctx, finalize)
3817
- }
3818
- if (kind === "query" && entry.schema?.query) {
3819
- const validation = entry.schema.query["~standard"].validate(recovery)
3820
- if (validation instanceof Promise) {
3821
- return validation.then(
3736
+ if (early instanceof Promise) {
3737
+ return early.then(
3822
3738
  (settled) => {
3823
- if (settled.issues !== undefined) return wrapResponse(validationError(settled.issues))
3824
- ctx.query = settled.value
3825
- return this.executeHandler(entry, ctx, finalize, wrapResponse)
3739
+ if (settled === undefined) return runHandler()
3740
+ return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders)
3826
3741
  },
3827
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3742
+ (err) => logError(err, ctx),
3828
3743
  )
3829
3744
  }
3830
- if (validation.issues !== undefined) return wrapResponse(validationError(validation.issues))
3831
- ctx.query = validation.value
3832
- return this.executeHandler(entry, ctx, finalize, wrapResponse)
3745
+ if (early === undefined) return runHandler()
3746
+ return fusedRespond(early, ctx, this.responseBodyTag, this.staticResponseHeaders)
3833
3747
  }
3834
- }
3835
- return wrapResponse(validationError(originalIssues))
3836
- }
3837
-
3838
- private applyBodyValidation<T>(
3839
- entry: RouteEntry,
3840
- result: StandardResult<unknown>,
3841
- ctx: RawContext,
3842
- finalize: (result: unknown, set: CtxSet) => T,
3843
- wrapResponse: (response: Response) => T,
3844
- ): MaybePromise<T> {
3845
- if (result.issues !== undefined) {
3846
- const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
3847
- if (hook) {
3848
- const recovery = hook(result.issues, ctx as unknown as Context, "body")
3849
- if (recovery instanceof Promise) {
3850
- return recovery.then((rec) =>
3851
- this.handleValidationErrorRecovery(
3852
- entry,
3853
- rec,
3854
- ctx,
3855
- finalize,
3856
- wrapResponse,
3857
- result.issues!,
3858
- "body",
3859
- ),
3748
+ const runDerive = (): MaybePromise<Response> => {
3749
+ let derived: unknown
3750
+ try {
3751
+ derived = derive(ctx)
3752
+ } catch (err) {
3753
+ return logError(err, ctx)
3754
+ }
3755
+ if (derived instanceof Promise) {
3756
+ return derived.then(
3757
+ (settled) => {
3758
+ if (isResponseResult(settled) || settled instanceof Response) {
3759
+ return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders)
3760
+ }
3761
+ Object.assign(ctx, settled as object)
3762
+ return runBefore()
3763
+ },
3764
+ (err) => logError(err, ctx),
3860
3765
  )
3861
3766
  }
3862
- return this.handleValidationErrorRecovery(
3863
- entry,
3864
- recovery,
3865
- ctx,
3866
- finalize,
3867
- wrapResponse,
3868
- result.issues,
3869
- "body",
3870
- )
3767
+ if (isResponseResult(derived) || derived instanceof Response) {
3768
+ return fusedRespond(derived, ctx, this.responseBodyTag, this.staticResponseHeaders)
3769
+ }
3770
+ Object.assign(ctx, derived as object)
3771
+ return runBefore()
3772
+ }
3773
+ if (querySchema === undefined) return runDerive()
3774
+ let validation: MaybePromise<StandardResult<unknown>>
3775
+ try {
3776
+ validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
3777
+ } catch (err) {
3778
+ return logError(err, ctx)
3871
3779
  }
3872
- return wrapResponse(validationError(result.issues))
3873
- }
3874
- ctx.body = result.value
3875
- return this.executeHandler(entry, ctx, finalize)
3876
- }
3877
-
3878
- // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
3879
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
3880
- private runQueryOnly<T>(
3881
- entry: RouteEntry,
3882
- ctx: RawContext,
3883
- finalize: (result: unknown, set: CtxSet) => T,
3884
- wrapResponse: (response: Response) => T,
3885
- ): MaybePromise<T> {
3886
- try {
3887
- // Call the validator directly for the raw StandardResult (read `.issues`/`.value`) - skip
3888
- // `validateStandard`'s per-request wrapper-object allocation, mirroring the bodyOnly path.
3889
- const validation = entry.schema!.query!["~standard"].validate(
3890
- queryObjectOf(ctx[CONTEXT_SEARCH]),
3891
- )
3892
3780
  if (validation instanceof Promise) {
3893
3781
  return validation.then(
3894
3782
  (settled) => {
3895
- try {
3896
- const outcome = this.applyQueryValidation(entry, settled, ctx, finalize, wrapResponse)
3897
- return outcome instanceof Promise
3898
- ? outcome.catch((err) =>
3899
- this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3900
- )
3901
- : outcome
3902
- } catch (err) {
3903
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
3904
- }
3783
+ if (settled.issues !== undefined)
3784
+ return this.wrapWebResponse(plainValidationError(settled.issues))
3785
+ ctx.query = settled.value
3786
+ return runDerive()
3905
3787
  },
3906
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3788
+ (err) => logError(err, ctx),
3907
3789
  )
3908
3790
  }
3909
- return this.applyQueryValidation(entry, validation, ctx, finalize, wrapResponse)
3910
- } catch (err) {
3911
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
3791
+ if (validation.issues !== undefined)
3792
+ return this.wrapWebResponse(plainValidationError(validation.issues))
3793
+ ctx.query = validation.value
3794
+ return runDerive()
3912
3795
  }
3913
3796
  }
3914
3797
 
3915
- /** Validate-result → set `ctx.query` → run handler. A method (not a per-request closure), the
3916
- * query analogue of {@link applyBodyValidation}. */
3917
- private applyQueryValidation<T>(
3918
- entry: RouteEntry,
3919
- result: StandardResult<unknown>,
3920
- ctx: RawContext,
3921
- finalize: (result: unknown, set: CtxSet) => T,
3922
- wrapResponse: (response: Response) => T,
3923
- ): MaybePromise<T> {
3924
- if (result.issues !== undefined) {
3925
- const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
3926
- if (hook) {
3927
- const recovery = hook(result.issues, ctx as unknown as Context, "query")
3928
- if (recovery instanceof Promise) {
3929
- return recovery.then((rec) =>
3930
- this.handleValidationErrorRecovery(
3931
- entry,
3932
- rec,
3933
- ctx,
3934
- finalize,
3935
- wrapResponse,
3936
- result.issues!,
3937
- "query",
3938
- ),
3798
+ /**
3799
+ * Same shape as `buildFusedDeriveBefore`, plus one `afterHandle` step. The closure runs (validate) →
3800
+ * derive → before → handler → after in one frame, the shape the realistic middleware route lands on
3801
+ * when it also wants a response transform (e.g. attaching a request id to a successful payload's
3802
+ * headers). `LifecycleExecutionLane` and `selectRouteLanes` guarantee `afterHandle === 1`, the
3803
+ * throw contract is the same as the generic program, and the `after` step is the final transform +
3804
+ * respond the generic program already does.
3805
+ */
3806
+ private buildFusedDeriveBeforeAfter(
3807
+ handler: InternalHandler,
3808
+ derive: RawDerive,
3809
+ before: RawBeforeHandle,
3810
+ after: RawAfterHandle,
3811
+ querySchema: StandardSchemaV1 | undefined,
3812
+ maxBodyBytes: number,
3813
+ ): FusedWebRunner {
3814
+ const logError = (err: unknown, ctx: RawContext): Response =>
3815
+ this.bareError(
3816
+ err,
3817
+ ctx,
3818
+ (result, set) => this.wrapWebResponse(toResponse(result, set)),
3819
+ this.wrapWebResponse,
3820
+ )
3821
+ return (source, params, search, signal, budget, platform, nativeContext) => {
3822
+ const ctx = nativeContext
3823
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3824
+ : new RequestContext(
3825
+ source,
3826
+ params,
3827
+ search,
3828
+ signal,
3829
+ budget,
3830
+ platform,
3831
+ maxBodyBytes,
3832
+ this.protoPoisoning,
3833
+ )
3834
+ const runAfter = (result: unknown): MaybePromise<Response> => {
3835
+ let transformed: unknown
3836
+ try {
3837
+ transformed = after(result, ctx)
3838
+ } catch (err) {
3839
+ return logError(err, ctx)
3840
+ }
3841
+ if (transformed instanceof Promise) {
3842
+ return transformed.then(
3843
+ (value) =>
3844
+ fusedRespond(
3845
+ value,
3846
+ ctx,
3847
+ this.responseBodyTag,
3848
+ this.staticResponseHeaders,
3849
+ this.onResponseHooks.length === 0,
3850
+ ),
3851
+ (err) => logError(err, ctx),
3939
3852
  )
3940
3853
  }
3941
- return this.handleValidationErrorRecovery(
3942
- entry,
3943
- recovery,
3854
+ return fusedRespond(
3855
+ transformed,
3944
3856
  ctx,
3945
- finalize,
3946
- wrapResponse,
3947
- result.issues,
3948
- "query",
3857
+ this.responseBodyTag,
3858
+ this.staticResponseHeaders,
3859
+ this.onResponseHooks.length === 0,
3949
3860
  )
3950
3861
  }
3951
- return wrapResponse(validationError(result.issues))
3952
- }
3953
- ctx.query = result.value
3954
- return this.executeHandler(entry, ctx, finalize, wrapResponse)
3955
- }
3956
-
3957
- /**
3958
- * Thread the response through each global `onResponse` hook. Stays SYNCHRONOUS until a hook
3959
- * actually returns a Promise - an `async` version forced a promise + microtask on EVERY response
3960
- * of any app with an onResponse hook (cors/securityHeaders/etag/timing all use onResponse), the
3961
- * same ~13%/req tax the onRequest walk was de-async'd to avoid. The first async hook hands the
3962
- * rest to {@link continueOnResponse}.
3963
- */
3964
- private applyOnResponse(response: Response, req: Request): MaybePromise<Response> {
3965
- const hooks = this.onResponseHooks
3966
- let current = response
3967
- for (let i = 0; i < hooks.length; i++) {
3968
- const next = (hooks[i] as RawOnResponse)(current, req)
3969
- if (next instanceof Promise) {
3970
- return next.then((settled) => this.continueOnResponse(settled, i + 1, req))
3862
+ const runHandler = (): MaybePromise<Response> => {
3863
+ let result: unknown
3864
+ try {
3865
+ result = handler(ctx)
3866
+ } catch (err) {
3867
+ return logError(err, ctx)
3868
+ }
3869
+ if (result instanceof Promise) {
3870
+ return result.then(
3871
+ (value) => runAfter(value),
3872
+ (err) => logError(err, ctx),
3873
+ )
3874
+ }
3875
+ return runAfter(result)
3971
3876
  }
3972
- current = next
3973
- }
3974
- return current
3975
- }
3976
-
3977
- private applyOnResponseAndFinalize(response: Response, req: Request): MaybePromise<Response> {
3978
- try {
3979
- const transformed = this.applyOnResponse(response, req)
3980
- return transformed instanceof Promise
3981
- ? transformed.then(
3982
- (settled) => this.completeResponseFinalization({ response: settled }, req),
3983
- (error) => this.failResponseFinalization(response, error, req),
3877
+ const runBefore = (): MaybePromise<Response> => {
3878
+ let early: unknown
3879
+ try {
3880
+ early = before(ctx)
3881
+ } catch (err) {
3882
+ return logError(err, ctx)
3883
+ }
3884
+ if (early instanceof Promise) {
3885
+ return early.then(
3886
+ (settled) => {
3887
+ if (settled === undefined) return runHandler()
3888
+ return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders)
3889
+ },
3890
+ (err) => logError(err, ctx),
3984
3891
  )
3985
- : this.completeResponseFinalization({ response: transformed }, req)
3986
- } catch (error) {
3987
- return this.failResponseFinalization(response, error, req)
3988
- }
3989
- }
3990
-
3991
- private completeResponseFinalization(
3992
- outcome: ResponseFinalization,
3993
- req: Request,
3994
- ): MaybePromise<Response> {
3995
- const notified = this.notifyResponseFinalized(outcome, req)
3996
- return notified instanceof Promise ? notified.then(() => outcome.response) : outcome.response
3997
- }
3998
-
3999
- private failResponseFinalization(
4000
- response: Response,
4001
- error: unknown,
4002
- req: Request,
4003
- ): Promise<never> {
4004
- const notified = this.notifyResponseFinalized({ response, error }, req)
4005
- if (notified instanceof Promise) {
4006
- return notified.then(() => {
4007
- throw error
4008
- })
4009
- }
4010
- // A hook failure must surface as a REJECTION even when every prior step ran synchronously:
4011
- // `fetch()` may now resolve without a promise, but its failure contract stays promise-shaped -
4012
- // a synchronous throw here would escape `Promise.resolve(app.fetch(...))` bridges and
4013
- // `.then()`-style callers entirely instead of reaching their rejection handling.
4014
- return Promise.reject(error)
4015
- }
4016
-
4017
- /** Notify terminal observers in order while isolating both sync and async failures. */
4018
- private notifyResponseFinalized(outcome: ResponseFinalization, req: Request): MaybePromise<void> {
4019
- let pending: Promise<void> | undefined
4020
- for (const hook of this.onResponseFinalizedHooks) {
4021
- if (pending !== undefined) {
4022
- pending = pending.then(async () => {
4023
- try {
4024
- await hook(outcome, req)
4025
- } catch {
4026
- // Terminal observation must never change request behavior.
4027
- }
4028
- })
4029
- continue
3892
+ }
3893
+ if (early === undefined) return runHandler()
3894
+ return fusedRespond(early, ctx, this.responseBodyTag, this.staticResponseHeaders)
3895
+ }
3896
+ const runDerive = (): MaybePromise<Response> => {
3897
+ let derived: unknown
3898
+ try {
3899
+ derived = derive(ctx)
3900
+ } catch (err) {
3901
+ return logError(err, ctx)
3902
+ }
3903
+ if (derived instanceof Promise) {
3904
+ return derived.then(
3905
+ (settled) => {
3906
+ if (isResponseResult(settled) || settled instanceof Response) {
3907
+ return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders)
3908
+ }
3909
+ Object.assign(ctx, settled as object)
3910
+ return runBefore()
3911
+ },
3912
+ (err) => logError(err, ctx),
3913
+ )
3914
+ }
3915
+ if (isResponseResult(derived) || derived instanceof Response) {
3916
+ return fusedRespond(derived, ctx, this.responseBodyTag, this.staticResponseHeaders)
3917
+ }
3918
+ Object.assign(ctx, derived as object)
3919
+ return runBefore()
4030
3920
  }
3921
+ if (querySchema === undefined) return runDerive()
3922
+ let validation: MaybePromise<StandardResult<unknown>>
4031
3923
  try {
4032
- const result = hook(outcome, req)
4033
- if (result instanceof Promise) pending = result.catch(() => {})
4034
- } catch {
4035
- // Terminal observation must never change request behavior.
3924
+ validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
3925
+ } catch (err) {
3926
+ return logError(err, ctx)
4036
3927
  }
4037
- }
4038
- return pending
4039
- }
4040
-
4041
- /** Async tail of {@link applyOnResponse}: runs the remaining hooks once one has gone async. */
4042
- private async continueOnResponse(
4043
- response: Response,
4044
- nextIndex: number,
4045
- req: Request,
4046
- ): Promise<Response> {
4047
- let current = response
4048
- for (let i = nextIndex; i < this.onResponseHooks.length; i++) {
4049
- const next = (this.onResponseHooks[i] as RawOnResponse)(current, req)
4050
- current = next instanceof Promise ? await next : next
4051
- }
4052
- return current
4053
- }
4054
-
4055
- /**
4056
- * Bound the response time. On timeout we abort `ctx.signal` (so cancellation-aware
4057
- * handlers can bail) and return 503; the in-flight work keeps running but its
4058
- * result is discarded - JS can't forcibly cancel a promise.
4059
- */
4060
- private async withTimeout<T>(
4061
- work: Promise<T>,
4062
- controller: AbortController,
4063
- onTimeout: () => T,
4064
- timeoutMs: number,
4065
- ): Promise<T> {
4066
- let timer: ReturnType<typeof setTimeout> | undefined
4067
- const timeout = new Promise<T>((resolve) => {
4068
- timer = setTimeout(() => {
4069
- controller.abort()
4070
- resolve(onTimeout())
4071
- }, timeoutMs)
4072
- })
4073
- try {
4074
- return await Promise.race([work, timeout])
4075
- } finally {
4076
- if (timer !== undefined) clearTimeout(timer)
3928
+ if (validation instanceof Promise) {
3929
+ return validation.then(
3930
+ (settled) => {
3931
+ if (settled.issues !== undefined)
3932
+ return this.wrapWebResponse(plainValidationError(settled.issues))
3933
+ ctx.query = settled.value
3934
+ return runDerive()
3935
+ },
3936
+ (err) => logError(err, ctx),
3937
+ )
3938
+ }
3939
+ if (validation.issues !== undefined)
3940
+ return this.wrapWebResponse(plainValidationError(validation.issues))
3941
+ ctx.query = validation.value
3942
+ return runDerive()
4077
3943
  }
4078
3944
  }
4079
3945
 
4080
3946
  /**
4081
- * The post-match lifecycle: validate → derive → beforeHandle → handler → afterHandle, with onError.
4082
- * Generic over the render: a success value goes through `finalize(result, set)` (a Web `Response`, or
4083
- * node-direct primitives); an early/error `Response` (validation, thrown, 500) through `wrapResponse`.
3947
+ * Body + lifecycle hooks fused: parse → validate → derive → before → handler → (after) in one
3948
+ * closure, the same win `buildFusedBodyRunner` gives for the no-hooks body shape. The shape is
3949
+ * `lifecycleHookLane` PLUS a body schema with no other validation conflicts; the lane is selected
3950
+ * ONLY when `schema.body !== undefined && schema.query/params/headers === undefined &&
3951
+ * schema.onValidationError === undefined && !defaultOnValidationError && !hasIdempotency &&
3952
+ * !hasLedger && derives === 1 && beforeHandle === 1 && afterHandle <= 1 && onError === 0 &&
3953
+ * !hasResponseContract && !hasDecorations && around === 0`. The same single `querySchema === undefined`
3954
+ * trick used in the no-body lane lets the build path stay simple: a query + body route falls back
3955
+ * to the generic program (this is rare in practice - validated query + validated body is usually a
3956
+ * "search results" route with a middleware chain on top, and the generic lane is fine there).
4084
3957
  */
4085
- /** Synchronous until a validator, lifecycle hook, handler, or contract check actually returns a Promise. */
4086
- // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
4087
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
4088
- private runLifecycle<T>(
4089
- entry: RouteEntry,
4090
- source: RequestSource,
4091
- ctx: RawContext,
4092
- finalize: (result: unknown, set: CtxSet) => T,
4093
- wrapResponse: (response: Response) => T,
4094
- ): MaybePromise<T> {
4095
- try {
4096
- const headersSchema = entry.schema?.headers
4097
- if (headersSchema !== undefined) {
4098
- const validation = headersSchema["~standard"].validate(headerObjectOf(source.headers))
4099
- if (validation instanceof Promise) {
4100
- return validation.then(
4101
- (result) =>
4102
- this.runLifecycleAfterHeadersResult(
4103
- entry,
4104
- source,
4105
- ctx,
4106
- finalize,
4107
- wrapResponse,
4108
- result,
4109
- ),
4110
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
3958
+ private buildFusedBodyDeriveBeforeAfter(
3959
+ handler: InternalHandler,
3960
+ derive: RawDerive,
3961
+ before: RawBeforeHandle,
3962
+ after: RawAfterHandle | undefined,
3963
+ bodySchema: StandardSchemaV1,
3964
+ maxBodyBytes: number,
3965
+ ): FusedWebRunner {
3966
+ const logError = (err: unknown, ctx: RawContext): Response =>
3967
+ this.bareError(
3968
+ err,
3969
+ ctx,
3970
+ (result, set) => this.wrapWebResponse(toResponse(result, set)),
3971
+ this.wrapWebResponse,
3972
+ )
3973
+ return (source, params, search, signal, budget, platform, nativeContext) => {
3974
+ const ctx = nativeContext
3975
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3976
+ : new RequestContext(
3977
+ source,
3978
+ params,
3979
+ search,
3980
+ signal,
3981
+ budget,
3982
+ platform,
3983
+ maxBodyBytes,
3984
+ this.protoPoisoning,
3985
+ )
3986
+ const runHandlerValidated = (): MaybePromise<Response> => {
3987
+ const runAfter = (result: unknown): MaybePromise<Response> => {
3988
+ if (after === undefined) {
3989
+ return fusedRespond(
3990
+ result,
3991
+ ctx,
3992
+ this.responseBodyTag,
3993
+ this.staticResponseHeaders,
3994
+ this.onResponseHooks.length === 0,
3995
+ )
3996
+ }
3997
+ let transformed: unknown
3998
+ try {
3999
+ transformed = after(result, ctx)
4000
+ } catch (err) {
4001
+ return logError(err, ctx)
4002
+ }
4003
+ if (transformed instanceof Promise) {
4004
+ return transformed.then(
4005
+ (value) =>
4006
+ fusedRespond(
4007
+ value,
4008
+ ctx,
4009
+ this.responseBodyTag,
4010
+ this.staticResponseHeaders,
4011
+ this.onResponseHooks.length === 0,
4012
+ ),
4013
+ (err) => logError(err, ctx),
4014
+ )
4015
+ }
4016
+ return fusedRespond(
4017
+ transformed,
4018
+ ctx,
4019
+ this.responseBodyTag,
4020
+ this.staticResponseHeaders,
4021
+ this.onResponseHooks.length === 0,
4022
+ )
4023
+ }
4024
+ const runHandler = (): MaybePromise<Response> => {
4025
+ let result: unknown
4026
+ try {
4027
+ result = handler(ctx)
4028
+ } catch (err) {
4029
+ return logError(err, ctx)
4030
+ }
4031
+ if (result instanceof Promise) {
4032
+ return result.then(
4033
+ (value) => runAfter(value),
4034
+ (err) => logError(err, ctx),
4035
+ )
4036
+ }
4037
+ return runAfter(result)
4038
+ }
4039
+ const runBefore = (): MaybePromise<Response> => {
4040
+ let early: unknown
4041
+ try {
4042
+ early = before(ctx)
4043
+ } catch (err) {
4044
+ return logError(err, ctx)
4045
+ }
4046
+ if (early instanceof Promise) {
4047
+ return early.then(
4048
+ (settled) => {
4049
+ if (settled === undefined) return runHandler()
4050
+ return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders)
4051
+ },
4052
+ (err) => logError(err, ctx),
4053
+ )
4054
+ }
4055
+ if (early === undefined) return runHandler()
4056
+ return fusedRespond(early, ctx, this.responseBodyTag, this.staticResponseHeaders)
4057
+ }
4058
+ let derived: unknown
4059
+ try {
4060
+ derived = derive(ctx)
4061
+ } catch (err) {
4062
+ return logError(err, ctx)
4063
+ }
4064
+ if (derived instanceof Promise) {
4065
+ return derived.then(
4066
+ (settled) => {
4067
+ if (isResponseResult(settled) || settled instanceof Response) {
4068
+ return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders)
4069
+ }
4070
+ Object.assign(ctx, settled as object)
4071
+ return runBefore()
4072
+ },
4073
+ (err) => logError(err, ctx),
4111
4074
  )
4112
4075
  }
4113
- return this.runLifecycleAfterHeadersResult(
4114
- entry,
4115
- source,
4116
- ctx,
4117
- finalize,
4118
- wrapResponse,
4119
- validation,
4120
- )
4076
+ if (isResponseResult(derived) || derived instanceof Response) {
4077
+ return fusedRespond(derived, ctx, this.responseBodyTag, this.staticResponseHeaders)
4078
+ }
4079
+ Object.assign(ctx, derived as object)
4080
+ return runBefore()
4121
4081
  }
4122
- const paramsSchema = entry.schema?.params
4123
- if (paramsSchema !== undefined) {
4124
- const validation = paramsSchema["~standard"].validate(ctx.params)
4082
+ const onParsed = (parsed: unknown): MaybePromise<Response> => {
4083
+ let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
4084
+ try {
4085
+ validation = bodySchema["~standard"].validate(parsed)
4086
+ } catch (err) {
4087
+ return logError(err, ctx)
4088
+ }
4125
4089
  if (validation instanceof Promise) {
4126
4090
  return validation.then(
4127
- (result) =>
4128
- this.runLifecycleAfterParamsResult(
4129
- entry,
4130
- source,
4131
- ctx,
4132
- finalize,
4133
- wrapResponse,
4134
- result,
4135
- ),
4136
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4091
+ (settled) => {
4092
+ if (settled.issues !== undefined)
4093
+ return this.wrapWebResponse(plainValidationError(settled.issues))
4094
+ ctx.body = settled.value
4095
+ return runHandlerValidated()
4096
+ },
4097
+ (err) => logError(err, ctx),
4137
4098
  )
4138
4099
  }
4139
- return this.runLifecycleAfterParamsResult(
4140
- entry,
4141
- source,
4142
- ctx,
4143
- finalize,
4144
- wrapResponse,
4145
- validation,
4146
- )
4147
- }
4148
- // Registration already knows whether this route has body/params/query validation. Most real
4149
- // read routes have no body or params schema, so skip the generic stage ladder and go straight to
4150
- // query validation + lifecycle hooks. The fallback below remains the complete path for routes
4151
- // with body/params combinations and validation recovery.
4152
- if (entry.schema?.body === undefined) {
4153
- return this.runQueryAndLifecycle(entry, ctx, finalize, wrapResponse)
4154
- }
4155
- return this.runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, undefined)
4156
- } catch (err) {
4157
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4158
- }
4159
- }
4160
-
4161
- private runLifecycleAfterHeadersResult<T>(
4162
- entry: RouteEntry,
4163
- source: RequestSource,
4164
- ctx: RawContext,
4165
- finalize: (result: unknown, set: CtxSet) => T,
4166
- wrapResponse: (response: Response) => T,
4167
- result: StandardResult<unknown>,
4168
- ): MaybePromise<T> {
4169
- try {
4170
- const headersError = this.applyLifecycleValidation(entry, result, ctx, "headers")
4171
- if (headersError instanceof Promise) {
4172
- return headersError.then(
4173
- (error) =>
4174
- error === undefined
4175
- ? this.runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse)
4176
- : wrapResponse(error),
4177
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4178
- )
4100
+ if (validation.issues !== undefined)
4101
+ return this.wrapWebResponse(plainValidationError(validation.issues))
4102
+ ctx.body = validation.value
4103
+ return runHandlerValidated()
4179
4104
  }
4180
- return headersError === undefined
4181
- ? this.runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse)
4182
- : wrapResponse(headersError)
4183
- } catch (err) {
4184
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4105
+ return readBodyFramed(
4106
+ source,
4107
+ maxBodyBytes,
4108
+ this.protoPoisoning,
4109
+ onParsed,
4110
+ (response) => this.wrapWebResponse(response),
4111
+ (err) => Promise.resolve(logError(err, ctx)),
4112
+ ) as MaybePromise<Response>
4185
4113
  }
4186
4114
  }
4187
4115
 
4188
- private runLifecycleAfterHeaders<T>(
4189
- entry: RouteEntry,
4190
- source: RequestSource,
4116
+ private bareError<T>(
4117
+ err: unknown,
4191
4118
  ctx: RawContext,
4192
- finalize: (result: unknown, set: CtxSet) => T,
4193
- wrapResponse: (response: Response) => T,
4194
- ): MaybePromise<T> {
4195
- const paramsSchema = entry.schema?.params
4196
- if (paramsSchema === undefined) {
4197
- if (entry.schema?.body === undefined)
4198
- return this.runQueryAndLifecycle(entry, ctx, finalize, wrapResponse)
4199
- return this.runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, undefined)
4200
- }
4201
- const validation = paramsSchema["~standard"].validate(ctx.params)
4202
- if (validation instanceof Promise) {
4203
- return validation.then(
4204
- (result) =>
4205
- this.runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, result),
4206
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4207
- )
4208
- }
4209
- return this.runLifecycleAfterParamsResult(
4210
- entry,
4211
- source,
4119
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
4120
+ wrapResponse: (response: Response | ResponseResult) => T,
4121
+ ): T {
4122
+ // The thrown-value contract lives in `bare-error-lane.ts`, one source of truth for both this
4123
+ // lane and the fused body runner. `responseSet` and the logger are injected because they reach
4124
+ // into class state; the closure is allocated only on the throw path, never on the hot lane.
4125
+ return renderBareError(
4126
+ err,
4212
4127
  ctx,
4213
4128
  finalize,
4214
4129
  wrapResponse,
4215
- validation,
4130
+ responseSet,
4131
+ (e, c) => this.logRequestError(e, c),
4132
+ () => this.internalErrorResponse(wrapResponse),
4216
4133
  )
4217
4134
  }
4218
4135
 
4219
- /** Registration-specialized query lifecycle: query validation (including recovery) → hooks. */
4220
- private runLifecycleQuery<T>(
4136
+ /**
4137
+ * Keep the ordinary thrown-error response independent from the caught value. Deliberate Response
4138
+ * control flow is handled by `renderBareError`; every other throw gets this fixed public envelope.
4139
+ */
4140
+ private internalErrorResponse<T>(wrapResponse: (response: Response | ResponseResult) => T): T {
4141
+ return wrapResponse(plainError(500, "internal_error"))
4142
+ }
4143
+
4144
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
4145
+ private runWithAround<T>(
4221
4146
  entry: RouteEntry,
4222
4147
  ctx: RawContext,
4148
+ run: () => MaybePromise<T>,
4223
4149
  finalize: (result: unknown, set: CtxSet) => T,
4224
- wrapResponse: (response: Response) => T,
4150
+ wrapResponse: (response: Response | ResponseResult) => T,
4225
4151
  ): MaybePromise<T> {
4152
+ let outcome: MaybePromise<T>
4226
4153
  try {
4227
- // The execution plan only selects this runner when the route has a query schema. Keeping the
4228
- // non-null access here removes the per-request schema-presence branch from realistic GETs.
4229
- const validation = entry.schema!.query!["~standard"].validate(
4230
- queryObjectOf(ctx[CONTEXT_SEARCH]),
4231
- )
4232
- if (validation instanceof Promise) {
4233
- return validation.then(
4234
- (result) => {
4235
- try {
4236
- return this.runQueryAndLifecycleResult(entry, ctx, finalize, wrapResponse, result)
4237
- } catch (err) {
4238
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4239
- }
4240
- },
4241
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4242
- )
4243
- }
4244
- return this.runQueryAndLifecycleResult(entry, ctx, finalize, wrapResponse, validation)
4154
+ outcome = this.runAround(entry.around, ctx, run)
4245
4155
  } catch (err) {
4246
4156
  return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4247
4157
  }
4158
+ return outcome instanceof Promise
4159
+ ? outcome.catch((err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse))
4160
+ : outcome
4248
4161
  }
4249
4162
 
4250
- /** Registration-specialized body lifecycle: bounded body validation (including recovery) → hooks. */
4163
+ /** Execute the registration-compiled general stage program. */
4251
4164
  // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
4252
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
4253
- private runLifecycleBody<T>(
4165
+ private runProgram<T>(
4254
4166
  entry: RouteEntry,
4255
4167
  source: RequestSource,
4256
4168
  ctx: RawContext,
4257
4169
  finalize: (result: unknown, set: CtxSet) => T,
4258
- wrapResponse: (response: Response) => T,
4170
+ wrapResponse: (response: Response | ResponseResult) => T,
4259
4171
  ): MaybePromise<T> {
4260
- return this.readAndValidateBody(source, entry, ctx).then(
4261
- (bodyError) =>
4262
- bodyError === undefined
4263
- ? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
4264
- : wrapResponse(bodyError),
4265
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4266
- )
4172
+ return executeRouteProgram(this, entry, entry.program, source, ctx, finalize, wrapResponse)
4267
4173
  }
4268
4174
 
4269
- /** Registration-specialized body + query lifecycle: bounded body validation → query validation → hooks. */
4270
- // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
4271
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
4272
- private runLifecycleBodyQuery<T>(
4175
+ /** Bind a compiled validation stage to its stable schema and the existing recovery contract. */
4176
+ // @ts-expect-error TS6133 -- invoked structurally by the general route program
4177
+ private validateProgramStage(
4273
4178
  entry: RouteEntry,
4179
+ stage: Extract<RouteProgramStage, { kind: "headers" | "params" | "body" | "query" }>,
4274
4180
  source: RequestSource,
4275
4181
  ctx: RawContext,
4276
- finalize: (result: unknown, set: CtxSet) => T,
4277
- wrapResponse: (response: Response) => T,
4278
- ): MaybePromise<T> {
4279
- return this.readAndValidateBody(source, entry, ctx).then(
4280
- (bodyError) =>
4281
- bodyError === undefined
4282
- ? this.runLifecycleQuery(entry, ctx, finalize, wrapResponse)
4283
- : wrapResponse(bodyError),
4284
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4285
- )
4182
+ ): MaybePromise<Response | ResponseResult | undefined> {
4183
+ const input =
4184
+ stage.kind === "headers"
4185
+ ? headerObjectOf(source.headers)
4186
+ : stage.kind === "params"
4187
+ ? ctx.params
4188
+ : stage.kind === "query"
4189
+ ? queryObjectOf(ctx[CONTEXT_SEARCH])
4190
+ : undefined
4191
+ if (stage.kind === "body") return this.readProgramBody(entry, source, ctx)
4192
+ const validation = stage.schema["~standard"].validate(input)
4193
+ return validation instanceof Promise
4194
+ ? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, stage.kind))
4195
+ : this.applyLifecycleValidation(entry, validation, ctx, stage.kind)
4286
4196
  }
4287
4197
 
4288
- /** Registration-specialized no-body lifecycle: query validation (if present) → derives/hooks. */
4289
- private runQueryAndLifecycle<T>(
4198
+ /** Preserve the existing bounded body reader and its fail-closed framing/prototype checks. */
4199
+ private readProgramBody(
4290
4200
  entry: RouteEntry,
4201
+ source: RequestSource,
4291
4202
  ctx: RawContext,
4292
- finalize: (result: unknown, set: CtxSet) => T,
4293
- wrapResponse: (response: Response) => T,
4294
- ): MaybePromise<T> {
4295
- const querySchema = entry.schema?.query
4296
- return querySchema === undefined
4297
- ? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
4298
- : this.runLifecycleQuery(entry, ctx, finalize, wrapResponse)
4203
+ ): Promise<Response | ResponseResult | undefined> {
4204
+ const bodySchema = entry.program.bodySchema
4205
+ if (bodySchema === undefined) return Promise.resolve(plainError(415, "unsupported_media_type"))
4206
+ return this.readAndValidateBody(source, entry, ctx, bodySchema, entry.program.bodyLimit)
4299
4207
  }
4300
4208
 
4301
- private runQueryAndLifecycleResult<T>(
4209
+ /** Keep thrown Response/status and redacted 500 semantics in one existing error boundary. */
4210
+ // @ts-expect-error TS6133 -- invoked structurally by the general route program
4211
+ private handleProgramError<T>(
4302
4212
  entry: RouteEntry,
4213
+ error: unknown,
4303
4214
  ctx: RawContext,
4304
4215
  finalize: (result: unknown, set: CtxSet) => T,
4305
- wrapResponse: (response: Response) => T,
4306
- result: StandardResult<unknown>,
4216
+ wrapResponse: (response: Response | ResponseResult) => T,
4307
4217
  ): MaybePromise<T> {
4308
- const queryError = this.applyLifecycleValidation(entry, result, ctx, "query")
4309
- if (queryError instanceof Promise) {
4310
- return queryError.then(
4311
- (error) =>
4312
- error === undefined
4313
- ? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
4314
- : wrapResponse(error),
4315
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4316
- )
4317
- }
4318
- if (queryError !== undefined) return wrapResponse(queryError)
4319
- return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
4218
+ return this.handleLifecycleError(entry, error, ctx, finalize, wrapResponse)
4320
4219
  }
4321
4220
 
4322
- private runLifecycleAfterParamsResult<T>(
4221
+ /** Finish a successful program result through response-contract enforcement and finalization. */
4222
+ // @ts-expect-error TS6133 -- invoked structurally by the general route program
4223
+ private finishProgramResult<T>(
4323
4224
  entry: RouteEntry,
4324
- source: RequestSource,
4325
4225
  ctx: RawContext,
4226
+ result: unknown,
4326
4227
  finalize: (result: unknown, set: CtxSet) => T,
4327
- wrapResponse: (response: Response) => T,
4328
- result: StandardResult<unknown>,
4228
+ wrapResponse: (response: Response | ResponseResult) => T,
4329
4229
  ): MaybePromise<T> {
4330
- try {
4331
- return this.runLifecycleAfterParams(
4332
- entry,
4333
- source,
4334
- ctx,
4335
- finalize,
4336
- wrapResponse,
4337
- this.applyLifecycleValidation(entry, result, ctx, "params"),
4338
- )
4339
- } catch (err) {
4340
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4341
- }
4230
+ return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, result)
4342
4231
  }
4343
4232
 
4344
- private runLifecycleAfterParams<T>(
4345
- entry: RouteEntry,
4346
- source: RequestSource,
4233
+ private runAround<T>(
4234
+ hooks: ReadonlyArray<RawAround>,
4347
4235
  ctx: RawContext,
4348
- finalize: (result: unknown, set: CtxSet) => T,
4349
- wrapResponse: (response: Response) => T,
4350
- paramsError: MaybePromise<Response | undefined>,
4236
+ run: () => MaybePromise<T>,
4351
4237
  ): MaybePromise<T> {
4352
- if (paramsError instanceof Promise) {
4353
- return paramsError.then(
4354
- (error) =>
4355
- this.runLifecycleAfterParamsSettled(entry, source, ctx, finalize, wrapResponse, error),
4356
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4357
- )
4238
+ const dispatch = (index: number): MaybePromise<T> => {
4239
+ if (index >= hooks.length) return run()
4240
+ const hook = hooks[index]!
4241
+ let called = false
4242
+ return hook(ctx, () => {
4243
+ if (called) throw new Error("around next() called multiple times")
4244
+ called = true
4245
+ return dispatch(index + 1)
4246
+ })
4358
4247
  }
4359
- return this.runLifecycleAfterParamsSettled(
4360
- entry,
4361
- source,
4362
- ctx,
4363
- finalize,
4364
- wrapResponse,
4365
- paramsError,
4366
- )
4248
+ return dispatch(0)
4367
4249
  }
4368
4250
 
4369
- private runLifecycleAfterParamsSettled<T>(
4251
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
4252
+ private runBodyOnly<T>(
4370
4253
  entry: RouteEntry,
4371
4254
  source: RequestSource,
4372
4255
  ctx: RawContext,
4373
4256
  finalize: (result: unknown, set: CtxSet) => T,
4374
- wrapResponse: (response: Response) => T,
4375
- paramsError: Response | undefined,
4376
- ): MaybePromise<T> {
4377
- if (paramsError !== undefined) return wrapResponse(paramsError)
4378
- if (entry.schema?.body !== undefined) {
4379
- return this.readAndValidateBody(source, entry, ctx).then(
4380
- (bodyError) => this.runLifecycleAfterBody(entry, ctx, finalize, wrapResponse, bodyError),
4381
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4382
- )
4383
- }
4384
- return this.runLifecycleAfterBody(entry, ctx, finalize, wrapResponse, undefined)
4385
- }
4386
-
4387
- private runLifecycleAfterBody<T>(
4388
- entry: RouteEntry,
4389
- ctx: RawContext,
4390
- finalize: (result: unknown, set: CtxSet) => T,
4391
- wrapResponse: (response: Response) => T,
4392
- bodyError: Response | undefined,
4393
- ): MaybePromise<T> {
4394
- if (bodyError !== undefined) return wrapResponse(bodyError)
4395
- const querySchema = entry.schema?.query
4396
- if (querySchema === undefined) return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
4397
- const validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
4398
- if (validation instanceof Promise) {
4399
- return validation.then(
4400
- (result) => this.runLifecycleAfterQueryResult(entry, ctx, finalize, wrapResponse, result),
4401
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4402
- )
4403
- }
4404
- return this.runLifecycleAfterQueryResult(entry, ctx, finalize, wrapResponse, validation)
4257
+ wrapResponse: (response: Response | ResponseResult) => T,
4258
+ ): Promise<T> {
4259
+ return readBodyFramed(
4260
+ source,
4261
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
4262
+ this.protoPoisoning,
4263
+ (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse),
4264
+ wrapResponse,
4265
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4266
+ )
4405
4267
  }
4406
4268
 
4407
- private runLifecycleAfterQueryResult<T>(
4269
+ /** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
4270
+ * streaming fallback. A method (not per-request closures) so the hot path allocates nothing
4271
+ * beyond the one `.then` continuation. */
4272
+ private finishBodyOnly<T>(
4408
4273
  entry: RouteEntry,
4274
+ parsed: unknown,
4409
4275
  ctx: RawContext,
4410
4276
  finalize: (result: unknown, set: CtxSet) => T,
4411
- wrapResponse: (response: Response) => T,
4412
- result: StandardResult<unknown>,
4277
+ wrapResponse: (response: Response | ResponseResult) => T,
4413
4278
  ): MaybePromise<T> {
4414
4279
  try {
4415
- const queryError = this.applyLifecycleValidation(entry, result, ctx, "query")
4416
- if (queryError instanceof Promise) {
4417
- return queryError.then(
4418
- (error) => this.runLifecycleAfterQuery(entry, ctx, finalize, wrapResponse, error),
4280
+ const bodySchema = entry.schema!.body!
4281
+ const validation = bodySchema["~standard"].validate(parsed)
4282
+ if (validation instanceof Promise) {
4283
+ return validation.then(
4284
+ (result) => {
4285
+ try {
4286
+ const outcome = this.applyBodyValidation(entry, result, ctx, finalize, wrapResponse)
4287
+ return outcome instanceof Promise
4288
+ ? outcome.catch((err) =>
4289
+ this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4290
+ )
4291
+ : outcome
4292
+ } catch (err) {
4293
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4294
+ }
4295
+ },
4419
4296
  (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4420
4297
  )
4421
4298
  }
4422
- return this.runLifecycleAfterQuery(entry, ctx, finalize, wrapResponse, queryError)
4299
+ const outcome = this.applyBodyValidation(entry, validation, ctx, finalize, wrapResponse)
4300
+ return outcome instanceof Promise
4301
+ ? outcome.catch((err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse))
4302
+ : outcome
4423
4303
  } catch (err) {
4424
4304
  return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4425
4305
  }
4426
4306
  }
4427
4307
 
4428
- private runLifecycleAfterQuery<T>(
4308
+ private executeHandler<T>(
4429
4309
  entry: RouteEntry,
4430
4310
  ctx: RawContext,
4431
4311
  finalize: (result: unknown, set: CtxSet) => T,
4432
- wrapResponse: (response: Response) => T,
4433
- queryError: Response | undefined,
4312
+ wrapResponse?: (response: Response | ResponseResult) => T,
4434
4313
  ): MaybePromise<T> {
4435
- if (queryError !== undefined) return wrapResponse(queryError)
4436
- return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
4314
+ if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
4315
+ const handlerOutput = entry.handler(ctx)
4316
+ if (handlerOutput instanceof Promise) {
4317
+ return handlerOutput.then(
4318
+ (value) => finalize(value, responseSet(ctx)),
4319
+ (err) => {
4320
+ if (wrapResponse) {
4321
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4322
+ }
4323
+ throw err
4324
+ },
4325
+ )
4326
+ }
4327
+ return finalize(handlerOutput, responseSet(ctx))
4437
4328
  }
4438
4329
 
4439
- private runLifecycleHooks<T>(
4330
+ private handleValidationErrorRecovery<T>(
4440
4331
  entry: RouteEntry,
4332
+ recovery: unknown,
4441
4333
  ctx: RawContext,
4442
4334
  finalize: (result: unknown, set: CtxSet) => T,
4443
- wrapResponse: (response: Response) => T,
4335
+ wrapResponse: (response: Response | ResponseResult) => T,
4336
+ originalIssues: ReadonlyArray<StandardIssue>,
4337
+ kind: "body" | "query",
4444
4338
  ): MaybePromise<T> {
4445
- if (entry.lifecycleHookLane === "derive-before") {
4446
- return this.runLifecycleDeriveBefore(entry, ctx, finalize, wrapResponse)
4447
- }
4448
- try {
4449
- if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
4450
- for (let i = 0; i < entry.derives.length; i++) {
4451
- const extension = entry.derives[i]!(ctx)
4452
- if (extension instanceof Promise) {
4453
- return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, i, extension)
4339
+ if (recovery !== undefined) {
4340
+ if (recovery instanceof Response) {
4341
+ return wrapResponse(recovery)
4342
+ }
4343
+ if (kind === "body" && entry.schema?.body) {
4344
+ const validation = entry.schema.body["~standard"].validate(recovery)
4345
+ if (validation instanceof Promise) {
4346
+ return validation.then((settled) => {
4347
+ if (settled.issues !== undefined)
4348
+ return wrapResponse(plainValidationError(settled.issues))
4349
+ ctx.body = settled.value
4350
+ return this.executeHandler(entry, ctx, finalize)
4351
+ })
4454
4352
  }
4455
- Object.assign(ctx, extension)
4353
+ if (validation.issues !== undefined)
4354
+ return wrapResponse(plainValidationError(validation.issues))
4355
+ ctx.body = validation.value
4356
+ return this.executeHandler(entry, ctx, finalize)
4456
4357
  }
4457
- for (let i = 0; i < entry.beforeHandle.length; i++) {
4458
- const outcome = entry.beforeHandle[i]!(ctx)
4459
- if (outcome instanceof Promise) {
4460
- return this.continueLifecycleAfterBefore(entry, ctx, finalize, wrapResponse, i, outcome)
4358
+ if (kind === "query" && entry.schema?.query) {
4359
+ const validation = entry.schema.query["~standard"].validate(recovery)
4360
+ if (validation instanceof Promise) {
4361
+ return validation.then(
4362
+ (settled) => {
4363
+ if (settled.issues !== undefined)
4364
+ return wrapResponse(plainValidationError(settled.issues))
4365
+ ctx.query = settled.value
4366
+ return this.executeHandler(entry, ctx, finalize, wrapResponse)
4367
+ },
4368
+ (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4369
+ )
4461
4370
  }
4462
- if (outcome !== undefined) return finalize(outcome, responseSet(ctx))
4371
+ if (validation.issues !== undefined)
4372
+ return wrapResponse(plainValidationError(validation.issues))
4373
+ ctx.query = validation.value
4374
+ return this.executeHandler(entry, ctx, finalize, wrapResponse)
4463
4375
  }
4464
- const result = entry.handler(ctx)
4465
- if (result instanceof Promise) {
4466
- return result.then(
4467
- (value) => this.finishLifecycleResult(entry, ctx, finalize, wrapResponse, value),
4468
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4376
+ }
4377
+ return wrapResponse(plainValidationError(originalIssues))
4378
+ }
4379
+
4380
+ private applyBodyValidation<T>(
4381
+ entry: RouteEntry,
4382
+ result: StandardResult<unknown>,
4383
+ ctx: RawContext,
4384
+ finalize: (result: unknown, set: CtxSet) => T,
4385
+ wrapResponse: (response: Response | ResponseResult) => T,
4386
+ ): MaybePromise<T> {
4387
+ if (result.issues !== undefined) {
4388
+ const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
4389
+ if (hook) {
4390
+ const recovery = hook(result.issues, ctx as unknown as Context, "body")
4391
+ if (recovery instanceof Promise) {
4392
+ return recovery.then((rec) =>
4393
+ this.handleValidationErrorRecovery(
4394
+ entry,
4395
+ rec,
4396
+ ctx,
4397
+ finalize,
4398
+ wrapResponse,
4399
+ result.issues!,
4400
+ "body",
4401
+ ),
4402
+ )
4403
+ }
4404
+ return this.handleValidationErrorRecovery(
4405
+ entry,
4406
+ recovery,
4407
+ ctx,
4408
+ finalize,
4409
+ wrapResponse,
4410
+ result.issues,
4411
+ "body",
4469
4412
  )
4470
4413
  }
4471
- return this.finishLifecycleResult(entry, ctx, finalize, wrapResponse, result)
4472
- } catch (err) {
4473
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4414
+ return wrapResponse(plainValidationError(result.issues))
4474
4415
  }
4416
+ ctx.body = result.value
4417
+ return this.executeHandler(entry, ctx, finalize)
4475
4418
  }
4476
4419
 
4477
- /** Registration-specialized derive → before → handler lifecycle. */
4478
- private runLifecycleDeriveBefore<T>(
4420
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
4421
+ private runQueryOnly<T>(
4479
4422
  entry: RouteEntry,
4480
4423
  ctx: RawContext,
4481
4424
  finalize: (result: unknown, set: CtxSet) => T,
4482
- wrapResponse: (response: Response) => T,
4425
+ wrapResponse: (response: Response | ResponseResult) => T,
4483
4426
  ): MaybePromise<T> {
4484
4427
  try {
4485
- const extension = entry.derives[0]!(ctx)
4486
- if (extension instanceof Promise) {
4487
- return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, 0, extension)
4488
- }
4489
- Object.assign(ctx, extension)
4490
-
4491
- const outcome = entry.beforeHandle[0]!(ctx)
4492
- if (outcome instanceof Promise) {
4493
- return this.continueLifecycleAfterBefore(entry, ctx, finalize, wrapResponse, 0, outcome)
4494
- }
4495
- if (outcome !== undefined)
4496
- return this.finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, outcome)
4497
-
4498
- const result = entry.handler(ctx)
4499
- if (result instanceof Promise) {
4500
- return result.then(
4501
- (value) => this.finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, value),
4428
+ // Call the validator directly for the raw StandardResult (read `.issues`/`.value`) - skip
4429
+ // `validateStandard`'s per-request wrapper-object allocation, mirroring the bodyOnly path.
4430
+ const validation = entry.schema!.query!["~standard"].validate(
4431
+ queryObjectOf(ctx[CONTEXT_SEARCH]),
4432
+ )
4433
+ if (validation instanceof Promise) {
4434
+ return validation.then(
4435
+ (settled) => {
4436
+ try {
4437
+ const outcome = this.applyQueryValidation(entry, settled, ctx, finalize, wrapResponse)
4438
+ return outcome instanceof Promise
4439
+ ? outcome.catch((err) =>
4440
+ this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4441
+ )
4442
+ : outcome
4443
+ } catch (err) {
4444
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4445
+ }
4446
+ },
4502
4447
  (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
4503
4448
  )
4504
4449
  }
4505
- return this.finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, result)
4450
+ return this.applyQueryValidation(entry, validation, ctx, finalize, wrapResponse)
4506
4451
  } catch (err) {
4507
4452
  return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4508
4453
  }
4509
4454
  }
4510
4455
 
4511
- /** Finalize the specialized lane without rechecking its registration-proven invariants. */
4512
- private finishSimpleLifecycleResult<T>(
4456
+ /** Validate-result → set `ctx.query` → run handler. A method (not a per-request closure), the
4457
+ * query analogue of {@link applyBodyValidation}. */
4458
+ private applyQueryValidation<T>(
4513
4459
  entry: RouteEntry,
4460
+ result: StandardResult<unknown>,
4514
4461
  ctx: RawContext,
4515
4462
  finalize: (result: unknown, set: CtxSet) => T,
4516
- wrapResponse: (response: Response) => T,
4517
- result: unknown,
4463
+ wrapResponse: (response: Response | ResponseResult) => T,
4518
4464
  ): MaybePromise<T> {
4519
- try {
4520
- return finalize(result, responseSet(ctx))
4521
- } catch (err) {
4522
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4465
+ if (result.issues !== undefined) {
4466
+ const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
4467
+ if (hook) {
4468
+ const recovery = hook(result.issues, ctx as unknown as Context, "query")
4469
+ if (recovery instanceof Promise) {
4470
+ return recovery.then((rec) =>
4471
+ this.handleValidationErrorRecovery(
4472
+ entry,
4473
+ rec,
4474
+ ctx,
4475
+ finalize,
4476
+ wrapResponse,
4477
+ result.issues!,
4478
+ "query",
4479
+ ),
4480
+ )
4481
+ }
4482
+ return this.handleValidationErrorRecovery(
4483
+ entry,
4484
+ recovery,
4485
+ ctx,
4486
+ finalize,
4487
+ wrapResponse,
4488
+ result.issues,
4489
+ "query",
4490
+ )
4491
+ }
4492
+ return wrapResponse(plainValidationError(result.issues))
4523
4493
  }
4494
+ ctx.query = result.value
4495
+ return this.executeHandler(entry, ctx, finalize, wrapResponse)
4524
4496
  }
4525
4497
 
4526
- private async continueLifecycleAfterDerive<T>(
4527
- entry: RouteEntry,
4528
- ctx: RawContext,
4529
- finalize: (result: unknown, set: CtxSet) => T,
4530
- wrapResponse: (response: Response) => T,
4531
- firstIndex: number,
4532
- first: Promise<unknown>,
4533
- ): Promise<T> {
4534
- try {
4535
- Object.assign(ctx, await first)
4536
- for (let i = firstIndex + 1; i < entry.derives.length; i++) {
4537
- const extension = entry.derives[i]!(ctx)
4538
- Object.assign(ctx, extension instanceof Promise ? await extension : extension)
4498
+ /**
4499
+ * Thread the response through each global `onResponse` hook. Stays SYNCHRONOUS until a hook
4500
+ * actually returns a Promise - an `async` version forced a promise + microtask on EVERY response
4501
+ * of any app with an onResponse hook (cors/securityHeaders/etag/timing all use onResponse), the
4502
+ * same ~13%/req tax the onRequest walk was de-async'd to avoid. The first async hook hands the
4503
+ * rest to {@link continueOnResponse}.
4504
+ */
4505
+ private applyOnResponse(response: Response, req: Request): MaybePromise<Response> {
4506
+ const hooks = this.onResponseHooks
4507
+ let current = response
4508
+ for (let i = 0; i < hooks.length; i++) {
4509
+ const next = (hooks[i] as RawOnResponse)(current, req)
4510
+ if (next instanceof Promise) {
4511
+ return next.then((settled) => this.continueOnResponse(settled, i + 1, req))
4539
4512
  }
4540
- return await this.runLifecycleHooksAsync(entry, ctx, finalize, wrapResponse)
4541
- } catch (err) {
4542
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4513
+ current = next
4543
4514
  }
4515
+ return current
4544
4516
  }
4545
4517
 
4546
- private async runLifecycleHooksAsync<T>(
4547
- entry: RouteEntry,
4548
- ctx: RawContext,
4549
- finalize: (result: unknown, set: CtxSet) => T,
4550
- wrapResponse: (response: Response) => T,
4551
- ): Promise<T> {
4518
+ private applyOnResponseAndFinalize(response: Response, req: Request): MaybePromise<Response> {
4552
4519
  try {
4553
- // Indexed, not `for...of`. An array iterator declared inside an async function stays live
4554
- // across the `await` below, so neither engine can sink it - unlike the sync lanes above,
4555
- // where V8 elides it. Measured cost of the iterator form: 43ns per hook on Bun, ~9ns on
4556
- // Node, on a chain whose hooks return synchronously (the common shape).
4557
- for (let i = 0; i < entry.beforeHandle.length; i++) {
4558
- const outcome = entry.beforeHandle[i]!(ctx)
4559
- const early = outcome instanceof Promise ? await outcome : outcome
4560
- if (early !== undefined) return finalize(early, responseSet(ctx))
4561
- }
4562
- return await this.runLifecycleHandlerAsync(entry, ctx, finalize, wrapResponse)
4563
- } catch (err) {
4564
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4520
+ const transformed = this.applyOnResponse(response, req)
4521
+ return transformed instanceof Promise
4522
+ ? transformed.then(
4523
+ (settled) => this.completeResponseFinalization({ response: settled }, req),
4524
+ (error) => this.failResponseFinalization(response, error, req),
4525
+ )
4526
+ : this.completeResponseFinalization({ response: transformed }, req)
4527
+ } catch (error) {
4528
+ return this.failResponseFinalization(response, error, req)
4565
4529
  }
4566
4530
  }
4567
4531
 
4568
- private async continueLifecycleAfterBefore<T>(
4569
- entry: RouteEntry,
4570
- ctx: RawContext,
4571
- finalize: (result: unknown, set: CtxSet) => T,
4572
- wrapResponse: (response: Response) => T,
4573
- firstIndex: number,
4574
- first: Promise<unknown>,
4575
- ): Promise<T> {
4576
- try {
4577
- const firstOutcome = await first
4578
- if (firstOutcome !== undefined) return finalize(firstOutcome, responseSet(ctx))
4579
- for (let i = firstIndex + 1; i < entry.beforeHandle.length; i++) {
4580
- const outcome = entry.beforeHandle[i]!(ctx)
4581
- const early = outcome instanceof Promise ? await outcome : outcome
4582
- if (early !== undefined) return finalize(early, responseSet(ctx))
4583
- }
4584
- return await this.runLifecycleHandlerAsync(entry, ctx, finalize, wrapResponse)
4585
- } catch (err) {
4586
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4587
- }
4532
+ private completeResponseFinalization(
4533
+ outcome: ResponseFinalization,
4534
+ req: Request,
4535
+ ): MaybePromise<Response> {
4536
+ const notified = this.notifyResponseFinalized(outcome, req)
4537
+ return notified instanceof Promise ? notified.then(() => outcome.response) : outcome.response
4588
4538
  }
4589
4539
 
4590
- private async runLifecycleHandlerAsync<T>(
4591
- entry: RouteEntry,
4592
- ctx: RawContext,
4593
- finalize: (result: unknown, set: CtxSet) => T,
4594
- wrapResponse: (response: Response) => T,
4595
- ): Promise<T> {
4596
- try {
4597
- const output = entry.handler(ctx)
4598
- const result = output instanceof Promise ? await output : output
4599
- return await this.finishLifecycleResult(entry, ctx, finalize, wrapResponse, result)
4600
- } catch (err) {
4601
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4540
+ private failResponseFinalization(
4541
+ response: Response,
4542
+ error: unknown,
4543
+ req: Request,
4544
+ ): Promise<never> {
4545
+ const notified = this.notifyResponseFinalized({ response, error }, req)
4546
+ if (notified instanceof Promise) {
4547
+ return notified.then(() => {
4548
+ throw error
4549
+ })
4602
4550
  }
4551
+ // A hook failure must surface as a REJECTION even when every prior step ran synchronously:
4552
+ // `fetch()` may now resolve without a promise, but its failure contract stays promise-shaped -
4553
+ // a synchronous throw here would escape `Promise.resolve(app.fetch(...))` bridges and
4554
+ // `.then()`-style callers entirely instead of reaching their rejection handling.
4555
+ return Promise.reject(error)
4603
4556
  }
4604
4557
 
4605
- private finishLifecycleResult<T>(
4606
- entry: RouteEntry,
4607
- ctx: RawContext,
4608
- finalize: (result: unknown, set: CtxSet) => T,
4609
- wrapResponse: (response: Response) => T,
4610
- initial: unknown,
4611
- ): MaybePromise<T> {
4612
- try {
4613
- let result = initial
4614
- for (let i = 0; i < entry.afterHandle.length; i++) {
4615
- const transformed = entry.afterHandle[i]!(result, ctx)
4616
- if (transformed instanceof Promise) {
4617
- return this.continueLifecycleAfterHandle(
4618
- entry,
4619
- ctx,
4620
- finalize,
4621
- wrapResponse,
4622
- i,
4623
- transformed,
4624
- )
4625
- }
4626
- result = transformed
4558
+ /** Notify terminal observers in order while isolating both sync and async failures. */
4559
+ private notifyResponseFinalized(outcome: ResponseFinalization, req: Request): MaybePromise<void> {
4560
+ let pending: Promise<void> | undefined
4561
+ for (const hook of this.onResponseFinalizedHooks) {
4562
+ if (pending !== undefined) {
4563
+ pending = pending.then(async () => {
4564
+ try {
4565
+ await hook(outcome, req)
4566
+ } catch {
4567
+ // Terminal observation must never change request behavior.
4568
+ }
4569
+ })
4570
+ continue
4571
+ }
4572
+ try {
4573
+ const result = hook(outcome, req)
4574
+ if (result instanceof Promise) pending = result.catch(() => {})
4575
+ } catch {
4576
+ // Terminal observation must never change request behavior.
4627
4577
  }
4628
- return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, result)
4629
- } catch (err) {
4630
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4631
4578
  }
4579
+ return pending
4632
4580
  }
4633
4581
 
4634
- private async continueLifecycleAfterHandle<T>(
4635
- entry: RouteEntry,
4636
- ctx: RawContext,
4637
- finalize: (result: unknown, set: CtxSet) => T,
4638
- wrapResponse: (response: Response) => T,
4639
- firstIndex: number,
4640
- first: Promise<unknown>,
4582
+ /** Async tail of {@link applyOnResponse}: runs the remaining hooks once one has gone async. */
4583
+ private async continueOnResponse(
4584
+ response: Response,
4585
+ nextIndex: number,
4586
+ req: Request,
4587
+ ): Promise<Response> {
4588
+ let current = response
4589
+ for (let i = nextIndex; i < this.onResponseHooks.length; i++) {
4590
+ const next = (this.onResponseHooks[i] as RawOnResponse)(current, req)
4591
+ current = next instanceof Promise ? await next : next
4592
+ }
4593
+ return current
4594
+ }
4595
+
4596
+ /**
4597
+ * Bound the response time. On timeout we abort `ctx.signal` (so cancellation-aware
4598
+ * handlers can bail) and return 503; the in-flight work keeps running but its
4599
+ * result is discarded - JS can't forcibly cancel a promise.
4600
+ */
4601
+ private async withTimeout<T>(
4602
+ work: Promise<T>,
4603
+ controller: AbortController,
4604
+ onTimeout: () => T,
4605
+ timeoutMs: number,
4641
4606
  ): Promise<T> {
4607
+ let timer: ReturnType<typeof setTimeout> | undefined
4608
+ const timeout = new Promise<T>((resolve) => {
4609
+ timer = setTimeout(() => {
4610
+ controller.abort()
4611
+ resolve(onTimeout())
4612
+ }, timeoutMs)
4613
+ })
4642
4614
  try {
4643
- let result = await first
4644
- for (let i = firstIndex + 1; i < entry.afterHandle.length; i++) {
4645
- const transformed = entry.afterHandle[i]!(result, ctx)
4646
- result = transformed instanceof Promise ? await transformed : transformed
4647
- }
4648
- return await this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, result)
4649
- } catch (err) {
4650
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
4615
+ return await Promise.race([work, timeout])
4616
+ } finally {
4617
+ if (timer !== undefined) clearTimeout(timer)
4651
4618
  }
4652
4619
  }
4653
-
4654
4620
  private finishLifecycleContract<T>(
4655
4621
  entry: RouteEntry,
4656
4622
  ctx: RawContext,
4657
4623
  finalize: (result: unknown, set: CtxSet) => T,
4658
- wrapResponse: (response: Response) => T,
4624
+ wrapResponse: (response: Response | ResponseResult) => T,
4659
4625
  result: unknown,
4660
4626
  ): MaybePromise<T> {
4661
4627
  try {
@@ -4677,7 +4643,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4677
4643
  private finishContractOutcome<T>(
4678
4644
  ctx: RawContext,
4679
4645
  finalize: (result: unknown, set: CtxSet) => T,
4680
- wrapResponse: (response: Response) => T,
4646
+ wrapResponse: (response: Response | ResponseResult) => T,
4681
4647
  outcome: {
4682
4648
  readonly kind: "ok" | "warn" | "violation"
4683
4649
  readonly value?: unknown
@@ -4690,7 +4656,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4690
4656
  path: pathnameOf(ctx.req.url),
4691
4657
  detail: outcome.message,
4692
4658
  })
4693
- return wrapResponse(jsonError(500, "internal_error"))
4659
+ return wrapResponse(plainError(500, "internal_error"))
4694
4660
  }
4695
4661
  if (outcome.kind === "warn") {
4696
4662
  this.logger.warn("response contract", {
@@ -4702,19 +4668,44 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4702
4668
  return finalize(outcome.value, responseSet(ctx))
4703
4669
  }
4704
4670
 
4705
- private async handleLifecycleError<T>(
4671
+ /** Synchronous by design, and `MaybePromise` rather than `Promise` for the same reason: control flow
4672
+ * out of a lifecycle stage must not cost a promise. An `async` method allocates one and defers the
4673
+ * result a microtask even when every branch it takes returns immediately, which is the common case
4674
+ * here - a thrown `status(...)`/`Response`, or a route with no `onError` hook at all. Only the hook
4675
+ * loop, which may await, drops into {@link runErrorHooks}. */
4676
+ private handleLifecycleError<T>(
4706
4677
  entry: RouteEntry,
4707
4678
  err: unknown,
4708
4679
  ctx: RawContext,
4709
4680
  finalize: (result: unknown, set: CtxSet) => T,
4710
- wrapResponse: (response: Response) => T,
4711
- ): Promise<T> {
4681
+ wrapResponse: (response: Response | ResponseResult) => T,
4682
+ ): MaybePromise<T> {
4712
4683
  // A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
4713
4684
  // an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
4714
4685
  // or log it as a 500. This is what makes `throw redirect(...)` / `requireSession(...)` work from
4715
4686
  // any handler or loader.
4716
4687
  if (err instanceof Response) return wrapResponse(err)
4717
- // onError hooks may return a custom response; otherwise the default 500 stands.
4688
+ // Same rule for a thrown `status(...)`, but through `finalize` rather than `wrapResponse`: the
4689
+ // value is still plain data, so the ordinary JSON lane renders it and no `Response` is built.
4690
+ if (isResponseResult(err)) return finalize(err, responseSet(ctx))
4691
+ if (entry.onError.length === 0) {
4692
+ // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
4693
+ // (redacting) logger. Body-read failures and around-hook failures land here too.
4694
+ this.logRequestError(err, ctx)
4695
+ return wrapResponse(plainError(500, "internal_error"))
4696
+ }
4697
+ return this.runErrorHooks(entry, err, ctx, finalize, wrapResponse)
4698
+ }
4699
+
4700
+ /** The `onError` chain, split off {@link handleLifecycleError} so its `await` costs only the routes
4701
+ * that registered a hook. A hook may return a custom response; otherwise the default 500 stands. */
4702
+ private async runErrorHooks<T>(
4703
+ entry: RouteEntry,
4704
+ err: unknown,
4705
+ ctx: RawContext,
4706
+ finalize: (result: unknown, set: CtxSet) => T,
4707
+ wrapResponse: (response: Response | ResponseResult) => T,
4708
+ ): Promise<T> {
4718
4709
  // Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
4719
4710
  // the `await` and stay heap-allocated on both engines.
4720
4711
  for (let i = 0; i < entry.onError.length; i++) {
@@ -4722,55 +4713,41 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4722
4713
  const handled = outcome instanceof Promise ? await outcome : outcome
4723
4714
  if (handled !== undefined) return finalize(handled, responseSet(ctx))
4724
4715
  }
4725
- // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
4726
- // (redacting) logger. Body-read failures and around-hook failures land here too.
4727
4716
  this.logRequestError(err, ctx)
4728
- return wrapResponse(jsonError(500, "internal_error"))
4717
+ return wrapResponse(plainError(500, "internal_error"))
4729
4718
  }
4730
4719
 
4731
- /** Log an unhandled request error to the (redacting) logger - shared by {@link runLifecycle} and the
4720
+ /** Log an unhandled request error to the (redacting) logger - shared by the compiled route program and the
4732
4721
  * bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
4733
4722
  private logRequestError(err: unknown, ctx: RawContext): void {
4734
- // An error's own text can quote the input that produced it, so how much of it reaches the sink is
4735
- // the app's call (`errorLogDetail`). The default keeps it: a 500 with no message and no stack is
4736
- // an incident nobody can diagnose, and the framework already ships the narrower instrument for
4737
- // the leak - a redacting logger with `valuePatterns`. `"none"` is there for an untrusted sink.
4738
- const detail = this.errorLogDetail
4739
- this.logger.error("unhandled request error", {
4740
- method: ctx.req.method,
4741
- path: pathnameOf(ctx.req.url),
4742
- name: err instanceof Error ? err.name : "Error",
4743
- // `detail`, not `message`: the logger uses `message` for its own first argument, so a field of
4744
- // that name is silently overwritten and the thrown error's own text never reaches the sink. It
4745
- // survived only incidentally inside `stack`, and was lost outright for a non-Error throw.
4746
- ...(detail === "none" ? {} : { detail: err instanceof Error ? err.message : String(err) }),
4747
- ...(detail === "full" && err instanceof Error ? { stack: err.stack } : {}),
4748
- })
4723
+ emitRequestErrorLog(this.logger, this.errorLogDetail, err, ctx)
4749
4724
  }
4750
4725
 
4751
- private async readAndValidateBody(
4726
+ private readAndValidateBody(
4752
4727
  req: RequestSource,
4753
4728
  entry: RouteEntry,
4754
4729
  ctx: RawContext,
4755
- ): Promise<Response | undefined> {
4756
- const contentType = headerOf(req, "content-type") ?? ""
4757
- let parsed: unknown
4758
- if (contentType === "application/json" || contentType.includes("application/json")) {
4759
- const json = await this.readBoundedJson(req, entry.bodyLimit ?? this.maxBodyBytes)
4760
- if (json instanceof Response) return json
4761
- parsed = json
4762
- } else if (isUrlEncodedForm(contentType)) {
4763
- const form = await readBoundedForm(req, entry.bodyLimit ?? this.maxBodyBytes)
4764
- if (form instanceof Response) return form
4765
- parsed = form
4766
- } else {
4767
- // multipart/form-data (file uploads) stays 415 on the schema path by design - files don't
4768
- // fit a value schema; use a schema-less route + @nifrajs/uploads helpers for those.
4769
- return jsonError(415, "unsupported_media_type")
4730
+ bodySchema: StandardSchemaV1 = entry.schema?.body as StandardSchemaV1,
4731
+ bodyLimit: number | undefined = entry.bodyLimit,
4732
+ ): Promise<Response | ResponseResult | undefined> {
4733
+ // Keep the generic lifecycle body stage on the same framed parser/continuation as the body-only
4734
+ // lane. This avoids an async wrapper that first awaits parsing and then starts validation in a
4735
+ // second continuation, while retaining the exact bounded-read, content-type, and poisoning
4736
+ // contracts shared by every body route.
4737
+ const validate = (parsed: unknown): MaybePromise<Response | ResponseResult | undefined> => {
4738
+ const validation = bodySchema["~standard"].validate(parsed)
4739
+ return validation instanceof Promise
4740
+ ? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, "body"))
4741
+ : this.applyLifecycleValidation(entry, validation, ctx, "body")
4770
4742
  }
4771
- const validation = entry.schema!.body!["~standard"].validate(parsed)
4772
- const result = validation instanceof Promise ? await validation : validation
4773
- return this.applyLifecycleValidation(entry, result, ctx, "body")
4743
+ return readBodyFramed(
4744
+ req,
4745
+ bodyLimit ?? UNLIMITED_BODY_BYTES,
4746
+ this.protoPoisoning,
4747
+ validate,
4748
+ (response) => response,
4749
+ (error) => Promise.reject(error),
4750
+ )
4774
4751
  }
4775
4752
 
4776
4753
  /** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
@@ -4780,7 +4757,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4780
4757
  result: StandardResult<unknown>,
4781
4758
  ctx: RawContext,
4782
4759
  kind: "body" | "query" | "params" | "headers",
4783
- ): MaybePromise<Response | undefined> {
4760
+ ): MaybePromise<Response | ResponseResult | undefined> {
4784
4761
  const assign = (value: unknown): void => {
4785
4762
  if (kind === "body") ctx.body = value
4786
4763
  else if (kind === "query") ctx.query = value
@@ -4792,7 +4769,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4792
4769
  return undefined
4793
4770
  }
4794
4771
  const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
4795
- if (hook === undefined) return validationError(result.issues)
4772
+ if (hook === undefined) return plainValidationError(result.issues)
4796
4773
  const attempted = hook(result.issues, ctx as unknown as Context, kind)
4797
4774
  if (attempted instanceof Promise) {
4798
4775
  return attempted.then((recovery) =>
@@ -4808,8 +4785,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4808
4785
  issues: ReadonlyArray<StandardIssue>,
4809
4786
  recovery: unknown,
4810
4787
  assign: (value: unknown) => void,
4811
- ): MaybePromise<Response | undefined> {
4812
- if (recovery === undefined) return validationError(issues)
4788
+ ): MaybePromise<Response | ResponseResult | undefined> {
4789
+ if (recovery === undefined) return plainValidationError(issues)
4813
4790
  if (recovery instanceof Response) return recovery
4814
4791
  const schema =
4815
4792
  kind === "body"
@@ -4822,43 +4799,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
4822
4799
  const retried = schema!["~standard"].validate(recovery)
4823
4800
  if (retried instanceof Promise) {
4824
4801
  return retried.then((settled) => {
4825
- if (settled.issues !== undefined) return validationError(settled.issues)
4802
+ if (settled.issues !== undefined) return plainValidationError(settled.issues)
4826
4803
  assign(settled.value)
4827
4804
  return undefined
4828
4805
  })
4829
4806
  }
4830
- if (retried.issues !== undefined) return validationError(retried.issues)
4807
+ if (retried.issues !== undefined) return plainValidationError(retried.issues)
4831
4808
  assign(retried.value)
4832
4809
  return undefined
4833
4810
  }
4834
4811
 
4835
- /**
4836
- * Read + parse the JSON body, capped at `maxBodyBytes` and screened by the server's
4837
- * `protoPoisoning` policy. Rejects a `Content-Length` over the cap *before* buffering,
4838
- * re-checks what was actually delivered after the read, and aborts a chunked or
4839
- * length-less body mid-stream once the running byte count exceeds the cap - so a lying
4840
- * or absent length can't force us to buffer an oversized payload.
4841
- */
4842
- private readBoundedJson(req: RequestSource, maxBodyBytes?: number): Promise<unknown | Response>
4843
- private readBoundedJson<T>(
4844
- req: RequestSource,
4845
- maxBodyBytes: number,
4846
- onResult: (parsed: unknown | Response) => T | Promise<T>,
4847
- onError: (error: unknown) => T | Promise<T>,
4848
- ): Promise<T>
4849
- private readBoundedJson<T>(
4850
- req: RequestSource,
4851
- maxBodyBytes = this.maxBodyBytes,
4852
- onResult?: (parsed: unknown | Response) => T | Promise<T>,
4853
- onError?: (error: unknown) => T | Promise<T>,
4854
- ): Promise<unknown | Response | T> {
4855
- // Not `async`: this is a pass-through, and wrapping the lane's own promise in a coroutine
4856
- // frame costs an extra microtask hop on every JSON body.
4857
- return onResult === undefined
4858
- ? readBoundedJsonSource(req, maxBodyBytes, this.protoPoisoning)
4859
- : readBoundedJsonSource(req, maxBodyBytes, this.protoPoisoning, onResult, onError!)
4860
- }
4861
-
4862
4812
  /** Adapt one route's compiled execution plan to Bun's already-matched request shape. Route
4863
4813
  * semantics remain in the plan; this closure only supplies native params and deadline fallback. */
4864
4814
  private compileBunNativeHandler(
@@ -5046,10 +4996,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
5046
4996
  // matched route's handler, set by `server.upgrade`.
5047
4997
  // With WS routes, the dispatcher comes from the installed `.use(websocket())` runtime - non-null
5048
4998
  // because wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
4999
+ // Native pub/sub when the app has WS routes and none validate outbound frames: `ws.subscribe` and
5000
+ // `app.publish` go through Bun's own (uWebSockets) broadcast instead of the JS registry loop.
5001
+ const nativePubsub = this.wsRouteCount > 0 && !this.wsHasValidatedSend
5049
5002
  const wsHandlers =
5050
5003
  this.wsRouteCount === 0
5051
5004
  ? undefined
5052
- : (this.wsRuntime as WsRuntime).bunHandlers(this.topics as TopicRegistry)
5005
+ : (this.wsRuntime as WsRuntime).bunHandlers(this.topics as TopicRegistry, nativePubsub)
5053
5006
  const reusePort = options?.reusePort === true
5054
5007
  // Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
5055
5008
  // on some option paths, and omitting is what selects its 0.0.0.0 default.
@@ -5087,6 +5040,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
5087
5040
  },
5088
5041
  })) as unknown as RunningServer
5089
5042
  this.bunServer = running
5043
+ // Bind `app.publish` to Bun's native broadcast now that the server handle exists. Guarded on the
5044
+ // method's presence so a runtime whose handle lacks it simply keeps the registry path.
5045
+ if (nativePubsub && typeof running.publish === "function") {
5046
+ const native = running.publish.bind(running)
5047
+ this.nativePublish = (topic, data) => {
5048
+ native(topic, data)
5049
+ }
5050
+ }
5090
5051
  this.sealed = true
5091
5052
  if (this.gracefulSignals) this.installSignalHandlers()
5092
5053
  return running