@nifrajs/core 4.0.2 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/README.md +6 -2
  2. package/dist/assurance.d.ts +86 -6
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +186 -95
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +8 -0
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +53 -1
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/durable-effect.js +1 -1
  11. package/dist/durable-effect.js.map +1 -1
  12. package/dist/durable-saga.d.ts +6 -0
  13. package/dist/durable-saga.d.ts.map +1 -1
  14. package/dist/durable-saga.js +83 -10
  15. package/dist/durable-saga.js.map +1 -1
  16. package/dist/edge-kit.d.ts +8 -3
  17. package/dist/edge-kit.d.ts.map +1 -1
  18. package/dist/edge-kit.js +7 -2
  19. package/dist/edge-kit.js.map +1 -1
  20. package/dist/effect-scope.d.ts.map +1 -1
  21. package/dist/effect-scope.js +16 -5
  22. package/dist/effect-scope.js.map +1 -1
  23. package/dist/errors.d.ts +1 -1
  24. package/dist/errors.d.ts.map +1 -1
  25. package/dist/errors.js.map +1 -1
  26. package/dist/evidence.d.ts +2 -0
  27. package/dist/evidence.d.ts.map +1 -1
  28. package/dist/evidence.js +9 -1
  29. package/dist/evidence.js.map +1 -1
  30. package/dist/index.d.ts +5 -4
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +4 -3
  33. package/dist/index.js.map +1 -1
  34. package/dist/internal/durable-shared.d.ts +1 -2
  35. package/dist/internal/durable-shared.d.ts.map +1 -1
  36. package/dist/internal/durable-shared.js +25 -44
  37. package/dist/internal/durable-shared.js.map +1 -1
  38. package/dist/internal/durable-types.d.ts +3 -0
  39. package/dist/internal/durable-types.d.ts.map +1 -1
  40. package/dist/internal/hook-origins.d.ts +67 -0
  41. package/dist/internal/hook-origins.d.ts.map +1 -0
  42. package/dist/internal/hook-origins.js +86 -0
  43. package/dist/internal/hook-origins.js.map +1 -0
  44. package/dist/internal/route-assurance.d.ts +6 -3
  45. package/dist/internal/route-assurance.d.ts.map +1 -1
  46. package/dist/internal/route-assurance.js +29 -14
  47. package/dist/internal/route-assurance.js.map +1 -1
  48. package/dist/internal/route-compiler.d.ts +9 -0
  49. package/dist/internal/route-compiler.d.ts.map +1 -1
  50. package/dist/internal/route-compiler.js +95 -13
  51. package/dist/internal/route-compiler.js.map +1 -1
  52. package/dist/internal/route-program.d.ts +1 -1
  53. package/dist/internal/route-program.d.ts.map +1 -1
  54. package/dist/internal/route-program.js +59 -16
  55. package/dist/internal/route-program.js.map +1 -1
  56. package/dist/internal/same-origin.d.ts +11 -1
  57. package/dist/internal/same-origin.d.ts.map +1 -1
  58. package/dist/internal/same-origin.js +8 -0
  59. package/dist/internal/same-origin.js.map +1 -1
  60. package/dist/reconciliation-worker.d.ts.map +1 -1
  61. package/dist/reconciliation-worker.js +12 -2
  62. package/dist/reconciliation-worker.js.map +1 -1
  63. package/dist/reflection.d.ts +53 -2
  64. package/dist/reflection.d.ts.map +1 -1
  65. package/dist/reflection.js +352 -5
  66. package/dist/reflection.js.map +1 -1
  67. package/dist/router/pattern.d.ts +1 -1
  68. package/dist/router/pattern.d.ts.map +1 -1
  69. package/dist/router/pattern.js +6 -3
  70. package/dist/router/pattern.js.map +1 -1
  71. package/dist/security/baseline.d.ts +3 -0
  72. package/dist/security/baseline.d.ts.map +1 -1
  73. package/dist/security/baseline.js +9 -5
  74. package/dist/security/baseline.js.map +1 -1
  75. package/dist/server/auth.d.ts +32 -3
  76. package/dist/server/auth.d.ts.map +1 -1
  77. package/dist/server/auth.js +23 -0
  78. package/dist/server/auth.js.map +1 -1
  79. package/dist/server/body-lane.d.ts +8 -2
  80. package/dist/server/body-lane.d.ts.map +1 -1
  81. package/dist/server/body-lane.js +27 -2
  82. package/dist/server/body-lane.js.map +1 -1
  83. package/dist/server/body.d.ts +7 -0
  84. package/dist/server/body.d.ts.map +1 -1
  85. package/dist/server/body.js +34 -1
  86. package/dist/server/body.js.map +1 -1
  87. package/dist/server/client-ip.d.ts +3 -0
  88. package/dist/server/client-ip.d.ts.map +1 -1
  89. package/dist/server/client-ip.js +6 -5
  90. package/dist/server/client-ip.js.map +1 -1
  91. package/dist/server/context.d.ts +2 -1
  92. package/dist/server/context.d.ts.map +1 -1
  93. package/dist/server/cookies.d.ts.map +1 -1
  94. package/dist/server/cookies.js +13 -1
  95. package/dist/server/cookies.js.map +1 -1
  96. package/dist/server/edge.d.ts.map +1 -1
  97. package/dist/server/edge.js +2 -1
  98. package/dist/server/edge.js.map +1 -1
  99. package/dist/server/http.d.ts.map +1 -1
  100. package/dist/server/http.js +5 -1
  101. package/dist/server/http.js.map +1 -1
  102. package/dist/server/idempotency-lane.d.ts +6 -3
  103. package/dist/server/idempotency-lane.d.ts.map +1 -1
  104. package/dist/server/idempotency-lane.js +38 -17
  105. package/dist/server/idempotency-lane.js.map +1 -1
  106. package/dist/server/install.d.ts +2 -0
  107. package/dist/server/install.d.ts.map +1 -1
  108. package/dist/server/install.js +2 -0
  109. package/dist/server/install.js.map +1 -1
  110. package/dist/server/logger.d.ts.map +1 -1
  111. package/dist/server/logger.js +3 -1
  112. package/dist/server/logger.js.map +1 -1
  113. package/dist/server/methods.d.ts +1 -0
  114. package/dist/server/methods.d.ts.map +1 -1
  115. package/dist/server/methods.js +1 -0
  116. package/dist/server/methods.js.map +1 -1
  117. package/dist/server/node-outcome.d.ts.map +1 -1
  118. package/dist/server/node-outcome.js +29 -11
  119. package/dist/server/node-outcome.js.map +1 -1
  120. package/dist/server/platform-adapters.d.ts.map +1 -1
  121. package/dist/server/platform-adapters.js +3 -2
  122. package/dist/server/platform-adapters.js.map +1 -1
  123. package/dist/server/request-context.d.ts +2 -1
  124. package/dist/server/request-context.d.ts.map +1 -1
  125. package/dist/server/request-context.js +4 -1
  126. package/dist/server/request-context.js.map +1 -1
  127. package/dist/server/respond.d.ts +44 -11
  128. package/dist/server/respond.d.ts.map +1 -1
  129. package/dist/server/respond.js +192 -79
  130. package/dist/server/respond.js.map +1 -1
  131. package/dist/server/response-contract-lane.d.ts +21 -6
  132. package/dist/server/response-contract-lane.d.ts.map +1 -1
  133. package/dist/server/response-contract-lane.js +139 -14
  134. package/dist/server/response-contract-lane.js.map +1 -1
  135. package/dist/server/response-observer-runtime.d.ts.map +1 -1
  136. package/dist/server/response-observer-runtime.js +10 -41
  137. package/dist/server/response-observer-runtime.js.map +1 -1
  138. package/dist/server/runtime-core.d.ts +2 -0
  139. package/dist/server/runtime-core.d.ts.map +1 -1
  140. package/dist/server/runtime-core.js +2 -0
  141. package/dist/server/runtime-core.js.map +1 -1
  142. package/dist/server/server-types.d.ts +45 -10
  143. package/dist/server/server-types.d.ts.map +1 -1
  144. package/dist/server/server.d.ts +67 -32
  145. package/dist/server/server.d.ts.map +1 -1
  146. package/dist/server/server.js +481 -334
  147. package/dist/server/server.js.map +1 -1
  148. package/dist/server/sse.d.ts +11 -1
  149. package/dist/server/sse.d.ts.map +1 -1
  150. package/dist/server/sse.js +18 -3
  151. package/dist/server/sse.js.map +1 -1
  152. package/dist/server/transport-lane.d.ts +7 -1
  153. package/dist/server/transport-lane.d.ts.map +1 -1
  154. package/dist/server/transport-lane.js.map +1 -1
  155. package/dist/server/webhook.d.ts +5 -2
  156. package/dist/server/webhook.d.ts.map +1 -1
  157. package/dist/server/webhook.js +17 -1
  158. package/dist/server/webhook.js.map +1 -1
  159. package/dist/server/websocket.d.ts +33 -14
  160. package/dist/server/websocket.d.ts.map +1 -1
  161. package/dist/server/websocket.js +47 -12
  162. package/dist/server/websocket.js.map +1 -1
  163. package/dist/server/ws-bun.d.ts +7 -1
  164. package/dist/server/ws-bun.d.ts.map +1 -1
  165. package/dist/server/ws-bun.js +42 -2
  166. package/dist/server/ws-bun.js.map +1 -1
  167. package/dist/server/ws-hook.d.ts +15 -3
  168. package/dist/server/ws-hook.d.ts.map +1 -1
  169. package/dist/server.d.ts +4 -3
  170. package/dist/server.d.ts.map +1 -1
  171. package/dist/server.js +3 -2
  172. package/dist/server.js.map +1 -1
  173. package/dist/single-copy.d.ts +6 -0
  174. package/dist/single-copy.d.ts.map +1 -1
  175. package/dist/single-copy.js +55 -6
  176. package/dist/single-copy.js.map +1 -1
  177. package/dist/tool-contract.d.ts.map +1 -1
  178. package/dist/tool-contract.js +71 -40
  179. package/dist/tool-contract.js.map +1 -1
  180. package/dist/wire.d.ts +5 -0
  181. package/dist/wire.d.ts.map +1 -1
  182. package/dist/wire.js +5 -0
  183. package/dist/wire.js.map +1 -1
  184. package/dist/ws.d.ts.map +1 -1
  185. package/dist/ws.js +4 -1
  186. package/dist/ws.js.map +1 -1
  187. package/package.json +1 -1
  188. package/src/assurance.ts +299 -111
  189. package/src/capabilities.ts +62 -1
  190. package/src/durable-effect.ts +1 -1
  191. package/src/durable-saga.ts +94 -10
  192. package/src/edge-kit.ts +8 -3
  193. package/src/effect-scope.ts +15 -5
  194. package/src/errors.ts +1 -0
  195. package/src/evidence.ts +12 -3
  196. package/src/index.ts +7 -2
  197. package/src/internal/durable-shared.ts +25 -37
  198. package/src/internal/durable-types.ts +3 -0
  199. package/src/internal/hook-origins.ts +153 -0
  200. package/src/internal/route-assurance.ts +32 -18
  201. package/src/internal/route-compiler.ts +107 -16
  202. package/src/internal/route-program.ts +98 -44
  203. package/src/internal/same-origin.ts +14 -1
  204. package/src/reconciliation-worker.ts +10 -2
  205. package/src/reflection.ts +455 -4
  206. package/src/router/pattern.ts +6 -3
  207. package/src/security/baseline.ts +10 -5
  208. package/src/server/auth.ts +45 -3
  209. package/src/server/body-lane.ts +36 -1
  210. package/src/server/body.ts +34 -1
  211. package/src/server/client-ip.ts +8 -5
  212. package/src/server/context.ts +2 -1
  213. package/src/server/cookies.ts +12 -1
  214. package/src/server/edge.ts +2 -1
  215. package/src/server/http.ts +7 -1
  216. package/src/server/idempotency-lane.ts +64 -22
  217. package/src/server/install.ts +3 -0
  218. package/src/server/logger.ts +3 -1
  219. package/src/server/methods.ts +1 -0
  220. package/src/server/node-outcome.ts +34 -12
  221. package/src/server/platform-adapters.ts +3 -2
  222. package/src/server/request-context.ts +5 -0
  223. package/src/server/respond.ts +222 -69
  224. package/src/server/response-contract-lane.ts +162 -10
  225. package/src/server/response-observer-runtime.ts +16 -38
  226. package/src/server/runtime-core.ts +2 -0
  227. package/src/server/server-types.ts +45 -10
  228. package/src/server/server.ts +707 -539
  229. package/src/server/sse.ts +84 -55
  230. package/src/server/transport-lane.ts +7 -1
  231. package/src/server/webhook.ts +22 -3
  232. package/src/server/websocket.ts +83 -18
  233. package/src/server/ws-bun.ts +48 -1
  234. package/src/server/ws-hook.ts +14 -2
  235. package/src/server.ts +6 -1
  236. package/src/single-copy.ts +55 -5
  237. package/src/tool-contract.ts +67 -39
  238. package/src/wire.ts +14 -0
  239. package/src/ws.ts +6 -0
@@ -17,13 +17,22 @@ import {
17
17
  DEFAULT_CAPABILITY_INTERCEPTOR_TIMEOUT_MS,
18
18
  type RegisteredCapabilityInterceptor,
19
19
  } from "../internal/capability-runtime.ts"
20
+ import {
21
+ adoptStaticTags,
22
+ enterPlugin,
23
+ leavePlugin,
24
+ tagAdoption,
25
+ tagHook,
26
+ tagScoped,
27
+ tagStatic,
28
+ } from "../internal/hook-origins.ts"
20
29
  import {
21
30
  type AssuranceDeclaration,
22
31
  assuranceDeclarationsOf,
23
32
  assuranceEvidenceFor,
24
33
  } from "../internal/route-assurance.ts"
25
34
  import { type CatalogRoute, RouteCatalog } from "../internal/route-catalog.ts"
26
- import { compileRouteOptions } from "../internal/route-compiler.ts"
35
+ import { adoptRouteRuntimes, compileRouteOptions } from "../internal/route-compiler.ts"
27
36
  import {
28
37
  compileRouteExecutionPlan,
29
38
  type FusedBodyRunner,
@@ -65,6 +74,7 @@ import {
65
74
  } from "./client-ip.ts"
66
75
  import type { Context, Platform, ResponseControls, RouteSchema } from "./context.ts"
67
76
  import "./core-copies.ts"
77
+ import type { FormOrigins } from "./body-lane.ts"
68
78
  import {
69
79
  hasLowercaseHeaderKeysMark,
70
80
  headerKeysAllLowercase,
@@ -107,6 +117,7 @@ import {
107
117
  fusedRespond,
108
118
  fusedRespondNoSet,
109
119
  toResponse,
120
+ writableResponse,
110
121
  } from "./respond.ts"
111
122
  // Type-only: erased, so the kernel never pulls the lane's implementation into a bundle that does not
112
123
  // install the plugin. The value side arrives through the symbol-keyed install seam.
@@ -116,6 +127,7 @@ import type {
116
127
  ResponseObserverRuntime,
117
128
  } from "./response-observer-runtime.ts"
118
129
  import {
130
+ CONTEXT_REQUEST,
119
131
  CONTEXT_SEARCH,
120
132
  CONTEXT_SET,
121
133
  EMPTY_RESPONSE_CONTROLS,
@@ -160,6 +172,7 @@ import {
160
172
  INSTALL_SSE,
161
173
  INSTALL_WS,
162
174
  NODE_NATIVE_MOUNT,
175
+ REPORT_ERROR,
163
176
  RESOLVE_NODE_MOUNT,
164
177
  } from "./install.ts"
165
178
  import type { EffectLedgerRuntime } from "./ledger-lane.ts"
@@ -186,6 +199,7 @@ import type {
186
199
  import type {
187
200
  AdmissionController,
188
201
  AdmissionDecision,
202
+ DeclaredMountRoute,
189
203
  FetchHandler,
190
204
  ListenTlsOptions,
191
205
  McpPromptDescriptor,
@@ -194,6 +208,7 @@ import type {
194
208
  MountableApp,
195
209
  MountFetchOptions,
196
210
  MountOptions,
211
+ OpaqueMountDeclaration,
197
212
  PromptArgument,
198
213
  PromptMessage,
199
214
  ResponseFinalization,
@@ -205,12 +220,7 @@ import type {
205
220
  } from "./server-types.ts"
206
221
  import type { SSEInit, TypedSSEStream } from "./sse.ts"
207
222
  import type { SseRuntime } from "./sse-hook.ts"
208
- import type {
209
- TopicRegistry,
210
- WebSocketContext,
211
- WebSocketHandler,
212
- WebSocketUpgradeOutcome,
213
- } from "./websocket.ts"
223
+ import type { TopicRegistry, WebSocketHandler, WebSocketUpgradeOutcome } from "./websocket.ts"
214
224
  import type { BunWsData } from "./ws-bun.ts"
215
225
  import type { WsRuntime } from "./ws-hook.ts"
216
226
 
@@ -285,6 +295,7 @@ export interface RawContext {
285
295
  readonly jsonReply?: Response | undefined
286
296
  readonly jsonBody?: unknown
287
297
  readonly [CONTEXT_SEARCH]: string
298
+ readonly [CONTEXT_REQUEST]: Request
288
299
  readonly signal: AbortSignal
289
300
  readonly budget: RequestBudget
290
301
  readonly env: unknown
@@ -297,14 +308,18 @@ export interface RawContext {
297
308
  /** Broad shape so the implementation signature is compatible with both typed overloads. */
298
309
  type ErasedHandler = (ctx: never) => MaybePromise<HandlerResult>
299
310
 
300
- export type OnRequestResult = Response | Request | undefined
311
+ /** What an `onRequest` hook returns: a reply (a `Response`, or a `status()`/`json()` result that takes
312
+ * the normal response path), a replacement `Request`, or `undefined` to continue. */
313
+ export type OnRequestResult = Response | ResponseResult | Request | undefined
301
314
  type RawOnRequest = (req: Request, platform?: Platform) => MaybePromise<OnRequestResult>
302
315
  type RawOnResponse = (response: Response, req: Request) => MaybePromise<Response>
303
316
  type RawOnResponseFinalized = (outcome: ResponseFinalization, req: Request) => MaybePromise<void>
304
317
 
305
- /** A registered WebSocket route - just its handler; matching reuses {@link Router} under the GET verb. */
318
+ /** A registered WebSocket route and the route chain its handshake runs; matching reuses
319
+ * {@link Router} under the GET verb. */
306
320
  interface WsEntry {
307
321
  readonly handler: WebSocketHandler
322
+ readonly chain: RouteEntry
308
323
  }
309
324
 
310
325
  /** Structural view of the socket-peer lookup on the Bun `Server`, so any Bun `Server` (WS or not)
@@ -353,10 +368,14 @@ interface FetchMount<Env = unknown> {
353
368
  readonly fallbackOn404: boolean
354
369
  readonly beforeRoutes: boolean
355
370
  readonly order: number
356
- /** The declared reason this child is not analyzed (`MountOptions.opaque`), checked by reflection. */
357
- readonly opaque: string | undefined
371
+ /** Why this child is not analyzed (`MountOptions.opaque`), checked by reflection. */
372
+ readonly opaque: string | OpaqueMountDeclaration | undefined
358
373
  /** The mounted app (`mount()` only), for reflection to tell a composed child from an opaque one. */
359
374
  readonly app?: object
375
+ /** Parent request → the stripped copy a handshake check handed this child. On a pass the HTTP
376
+ * dispatch hands over that same copy, which the child's own pass record knows, so its onRequest
377
+ * hooks do not run a second time. */
378
+ wsPassed?: WeakMap<Request, Request>
360
379
  }
361
380
 
362
381
  /** The socket peer Bun observed, as a `Platform` for the request lifecycle (`undefined` if unknown). */
@@ -410,6 +429,7 @@ function outranks(
410
429
  }
411
430
 
412
431
  const WS_PASS: WebSocketUpgradeOutcome = { kind: "pass" }
432
+
413
433
  const DEFAULT_WS_UPGRADE_TIMEOUT_MS = 10_000
414
434
  /** Counts `ws()` routes and `mount()`s across every app in this copy of core: the only
415
435
  * changes that can make a mounted app take WebSocket upgrades. An app's answer to that question
@@ -427,6 +447,9 @@ function requireWsRuntime(runtime: WsRuntime | undefined): WsRuntime {
427
447
  return runtime
428
448
  }
429
449
 
450
+ /** The method `app.sse()` registers: POST when the schema declares a body, which a GET cannot carry. */
451
+ type SseMethodOf<S> = S extends { readonly body: StandardSchemaV1 } ? "POST" : "GET"
452
+
430
453
  function requireSseRuntime(runtime: SseRuntime | undefined): SseRuntime {
431
454
  if (runtime === undefined) {
432
455
  throw new FrameworkError(
@@ -495,6 +518,7 @@ export type Handler<
495
518
  export type {
496
519
  AdmissionController,
497
520
  AdmissionDecision,
521
+ DeclaredMountRoute,
498
522
  FetchHandler,
499
523
  ListenTlsOptions,
500
524
  McpPromptDescriptor,
@@ -503,6 +527,7 @@ export type {
503
527
  MountableApp,
504
528
  MountFetchOptions,
505
529
  MountOptions,
530
+ OpaqueMountDeclaration,
506
531
  PromptArgument,
507
532
  PromptMessage,
508
533
  ResponseFinalization,
@@ -675,11 +700,16 @@ const underPrefix = (prefix: string): HookScope => {
675
700
  }
676
701
 
677
702
  /** A merged server's hook scope: exactly the routes MERGED, snapshotted into a dedicated matcher so
678
- * the guard never reflects whatever the source's own catalog grows into afterwards. */
703
+ * the guard never reflects whatever the source's own catalog grows into afterwards. An `OPTIONS`
704
+ * request to a path they serve is in scope too: a CORS preflight names the path, not a route, and
705
+ * the group's `cors()` is the only hook that can answer it. */
679
706
  const servedBy = (routes: readonly CatalogRoute[]): HookScope => {
680
707
  const scope = new RouteCatalog()
681
708
  scope.addBatch(routes)
682
- return (req) => scope.find(req.method, pathnameOf(req.url)).found
709
+ return (req) => {
710
+ const match = scope.find(req.method, pathnameOf(req.url))
711
+ return match.found || (req.method === "OPTIONS" && match.reason === "method-not-allowed")
712
+ }
683
713
  }
684
714
 
685
715
  type AdoptedHook = ((first: never, second: never) => unknown) | undefined
@@ -800,6 +830,30 @@ interface HookAuditState {
800
830
  }
801
831
  const hookAudit = /* @__PURE__ */ new WeakMap<object, HookAuditState>()
802
832
 
833
+ /** Whether `from` reaches `target` through mounts that keep the path and take a request under `path`.
834
+ * Module-level, not a `Server` method, so a production bundle drops it with its dev-only caller. */
835
+ function mountLeadsBack(
836
+ from: unknown,
837
+ target: unknown,
838
+ path: string,
839
+ mountsOf: (server: Server) => readonly FetchMount[],
840
+ seen: Set<unknown>,
841
+ ): boolean {
842
+ if (from === target) return true
843
+ if (!(from instanceof Server) || seen.has(from)) return false
844
+ seen.add(from)
845
+ const under = (prefix: string): string => (prefix === "/" ? "/" : `${prefix}/`)
846
+ return mountsOf(from).some(
847
+ (mount) =>
848
+ !mount.stripPrefix &&
849
+ (under(path).startsWith(under(mount.path)) || under(mount.path).startsWith(under(path))) &&
850
+ mountLeadsBack(mount.app, target, path, mountsOf, seen),
851
+ )
852
+ }
853
+
854
+ /** Set by `@nifrajs/web`'s `createWebApp` on its app once the page routes are declared. */
855
+ const PAGES_DECLARED = /* @__PURE__ */ Symbol.for("nifra.web.pagesDeclared")
856
+
803
857
  /** Start tracking one server's order-scoped hook pushes (once, from the constructor). */
804
858
  const beginHookAudit = (
805
859
  key: object,
@@ -810,13 +864,42 @@ const beginHookAudit = (
810
864
  }
811
865
 
812
866
  /** Record one order-scoped hook push: the route count at that moment (so `routesAtPush === total` at
813
- * seal means no route was declared after it) and the caller's frame. */
867
+ * seal means no route was declared after it) and the caller's frame. Hooks a WebSocket handshake runs
868
+ * count sockets as routes; `around` and `afterHandle` wrap a response, so they count HTTP routes only. */
814
869
  const recordScopedHook = (key: object, routesAtPush: number, kind: string): void => {
870
+ // Not a dead hook when routes follow it, but every page already copied its chain without it.
871
+ if (PAGES_DECLARED in key) {
872
+ const site = captureCallerSite()
873
+ throw new FrameworkError(
874
+ "HOOK_AFTER_PAGES",
875
+ `${kind}()${site === undefined ? "" : ` at ${site}`} was added after createWebApp declared the page routes, so no page runs it: each route keeps the order-scoped hooks that existed when it was declared. Apply it in createWebApp({ use: (app) => ... }) to cover the pages, or inside app.group(prefix, ...) to scope it to the routes declared there.`,
876
+ )
877
+ }
815
878
  const state = hookAudit.get(key)
816
879
  if (state === undefined || state.policy === "off") return
817
880
  state.sites.push({ kind, routesAtPush, site: captureCallerSite() })
818
881
  }
819
882
 
883
+ /** Hook provenance for an adoption (dev only, see `internal/hook-origins.ts`): which scope each
884
+ * wrapper just appended to a hook list gates its hook by. `pairs` holds each source list with the target list it
885
+ * was appended to, read right after the append so the wrappers are the target list's tail. */
886
+ const tagAdopted = (
887
+ target: object,
888
+ source: object,
889
+ prefix: string | undefined,
890
+ routes: readonly CatalogRoute[],
891
+ pairs: readonly (readonly [readonly unknown[], readonly unknown[]])[],
892
+ ): void => {
893
+ tagAdoption(target, source, prefix)
894
+ const scope =
895
+ prefix !== undefined
896
+ ? { prefix }
897
+ : { routes: routes.map((route) => ({ method: route.method, path: route.path })) }
898
+ for (const [originals, appended] of pairs) {
899
+ if (originals.length > 0) tagScoped(originals, appended.slice(-originals.length), scope)
900
+ }
901
+ }
902
+
820
903
  /** Run the deadness check exactly once. A hook whose recorded route count equals the final route count
821
904
  * had no route declared after it - unambiguously dead, since the scoping feature (a hook that covers
822
905
  * only later routes) always leaves at least one such route. Zero-route apps are exempt (different
@@ -824,13 +907,20 @@ const recordScopedHook = (key: object, routesAtPush: number, kind: string): void
824
907
  const sealHookAudit = (
825
908
  key: object,
826
909
  total: number,
910
+ sockets: number,
827
911
  logger: { warn(message: string): void },
828
912
  ): void => {
829
913
  const state = hookAudit.get(key)
830
914
  if (state === undefined || state.checked) return
831
915
  state.checked = true
832
- if (state.policy === "off" || total === 0) return
833
- const dead = state.sites.filter((hook) => hook.routesAtPush === total)
916
+ if (state.policy === "off" || total + sockets === 0) return
917
+ const dead = state.sites.filter(
918
+ (hook) =>
919
+ hook.routesAtPush ===
920
+ (hook.kind === "around" || hook.kind === "aroundCapability" || hook.kind === "afterHandle"
921
+ ? total
922
+ : total + sockets),
923
+ )
834
924
  if (dead.length === 0) return
835
925
  const detail = dead.map((hook) => ` ${hook.kind}() at ${hook.site ?? "<unknown>"}`).join("\n")
836
926
  const message =
@@ -865,7 +955,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
865
955
  private readonly catalog: RouteCatalog
866
956
  /** WebSocket routes, matched separately at upgrade time (a GET + `Upgrade: websocket`). */
867
957
  private readonly wsRouter: Router<WsEntry>
868
- private wsRouteCount: number
958
+ /** Each socket path, its declared capabilities, the evidence declarations its handshake captured
959
+ * and the chain it runs; reflection reads it. */
960
+ private readonly wsRoutes: {
961
+ readonly path: string
962
+ readonly capabilities: readonly string[] | undefined
963
+ readonly assurance: readonly AssuranceDeclaration[]
964
+ readonly chain: RouteEntry
965
+ }[]
869
966
  /** `wsChanges` when `wsMountsLive` was last worked out; -1 until then. */
870
967
  private wsMountsCheckedAt = -1
871
968
  /** Whether an app mounted here takes WebSocket upgrades, as of `wsMountsCheckedAt`. */
@@ -886,6 +983,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
886
983
  | undefined
887
984
  private readonly maxBodyBytes: number
888
985
  private readonly protoPoisoning: "reject" | "strip" | "ignore"
986
+ private readonly formOrigins: FormOrigins | undefined
889
987
  /** `trustBodyFraming`: mark every `app.fetch` request as runtime-framed (see ServerOptions). */
890
988
  private readonly trustBodyFraming: boolean
891
989
  private readonly wsMaxPayloadBytes: number
@@ -899,7 +997,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
899
997
  private readonly acceptInboundDeadlines: boolean
900
998
  private readonly maxInboundDeadlineMs: number
901
999
  private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
902
- private readonly gracefulSignals: boolean
1000
+ private readonly gracefulSignals: { readonly drainMs?: number } | undefined
903
1001
  private readonly stopHooks: StopHook[]
904
1002
  private readonly fetchMounts: FetchMount<EnvOf<Ctx>>[]
905
1003
  private mountOrder: number
@@ -980,9 +1078,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
980
1078
  /** Body/raw response hooks need a framework-payload marker; keep that decision per app. */
981
1079
  private responseBodyTag: object | undefined
982
1080
  private readonly responseBodyOwners: Set<object>
983
- private readonly finalizeResponse = (result: unknown, set: CtxSet): Response =>
984
- toResponse(result as HandlerResult, set, this.responseBodyTag, this.staticResponseHeaders)
1081
+ private readonly finalizeResponse = (result: unknown, set: CtxSet, ctx?: RawContext): Response =>
1082
+ toResponse(
1083
+ result as HandlerResult,
1084
+ set,
1085
+ this.responseBodyTag,
1086
+ this.staticResponseHeaders,
1087
+ // Response hooks write next, and they run for this same request object.
1088
+ ctx !== undefined && this.onResponseHooks.length > 0 ? ctx[CONTEXT_REQUEST] : undefined,
1089
+ )
985
1090
  private readonly responseRequests: WeakMap<Request, Request>
1091
+ /** A request the WebSocket handshake check ran the onRequest hooks on and then passed to HTTP →
1092
+ * what the hooks returned, so `fetch` routes that instead of running the hooks a second time. */
1093
+ private wsHookedPasses: WeakMap<object, Request> | undefined
986
1094
  /** Original source → request observed by generic request hooks, including in-place mutations. */
987
1095
  private readonly responseSources: WeakMap<object, Request>
988
1096
  /** Memoized NodeRequestContext per plain-`Request` source - see {@link nodeRequestContextOf}. */
@@ -1006,11 +1114,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1006
1114
  this.routePrefix = ""
1007
1115
  this.catalog = new RouteCatalog()
1008
1116
  this.wsRouter = new Router<WsEntry>()
1009
- this.wsRouteCount = 0
1117
+ this.wsRoutes = []
1010
1118
  this.topics = undefined
1011
1119
  const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
1012
1120
  assertByteLimit(maxBodyBytes, "maxBodyBytes")
1013
1121
  this.protoPoisoning = options.protoPoisoning ?? "reject"
1122
+ this.formOrigins = options.formOrigins
1014
1123
  this.trustBodyFraming = options.trustBodyFraming ?? false
1015
1124
  const wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? maxBodyBytes
1016
1125
  assertByteLimit(wsMaxPayloadBytes, "wsMaxPayloadBytes")
@@ -1018,7 +1127,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1018
1127
  this.wsMaxPayloadBytes = wsMaxPayloadBytes
1019
1128
  this.requestTimeoutMs = options.requestTimeoutMs ?? 0
1020
1129
  this.wsUpgradeTimeoutMs = options.wsUpgradeTimeoutMs ?? DEFAULT_WS_UPGRADE_TIMEOUT_MS
1021
- this.clientIpTrust = options.clientIp
1130
+ const trust = options.clientIp
1131
+ if (
1132
+ trust !== undefined &&
1133
+ !("header" in trust) &&
1134
+ !(Number.isSafeInteger(trust.trustedHops) && trust.trustedHops >= 0)
1135
+ ) {
1136
+ throw new RangeError("clientIp trustedHops must be a non-negative integer")
1137
+ }
1138
+ this.clientIpTrust = trust
1022
1139
  this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false
1023
1140
  this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000
1024
1141
  this.deadlineAdmissionOptions = Object.freeze({
@@ -1034,7 +1151,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1034
1151
  if (!Number.isFinite(this.maxInboundDeadlineMs) || this.maxInboundDeadlineMs <= 0) {
1035
1152
  throw new RangeError("maxInboundDeadlineMs must be a finite positive number")
1036
1153
  }
1037
- this.gracefulSignals = options.gracefulSignals ?? false
1154
+ this.gracefulSignals =
1155
+ options.gracefulSignals === true ? {} : options.gracefulSignals || undefined
1038
1156
  this.stopHooks = []
1039
1157
  this.fetchMounts = []
1040
1158
  this.mountOrder = 0
@@ -1083,6 +1201,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1083
1201
  this.responseBodyTag = undefined
1084
1202
  this.responseBodyOwners = new Set()
1085
1203
  this.responseRequests = new WeakMap()
1204
+ this.wsHookedPasses = undefined
1086
1205
  this.responseSources = new WeakMap()
1087
1206
  this.nodeContexts = new WeakMap()
1088
1207
  this.appliedPlugins = new Map()
@@ -1187,6 +1306,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1187
1306
  opaque: options.opaque,
1188
1307
  app,
1189
1308
  }
1309
+ // Mounts run before routes: a request under this prefix that no mount on the way strips comes back
1310
+ // here unchanged until the stack overflows, which takes the process down. Dev-time only, like the
1311
+ // hook audit: a production define folds this away, and the first dev request would hit it anyway.
1312
+ if (
1313
+ hookAuditRuntime &&
1314
+ process.env.NODE_ENV !== "production" &&
1315
+ !mount.stripPrefix &&
1316
+ mountLeadsBack(app, this, mount.path, (server) => server.fetchMounts, new Set())
1317
+ ) {
1318
+ throw new RouteConfigError(
1319
+ "MOUNT_CYCLE",
1320
+ `mount ${mount.path}: the app leads back to this one without stripPrefix`,
1321
+ )
1322
+ }
1190
1323
  this.fetchMounts.push(mount)
1191
1324
  this.fetchMounts.sort(compareMounts)
1192
1325
  this.preRouteMountCount += 1
@@ -1214,8 +1347,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1214
1347
  > {
1215
1348
  this.assertConfigurable("derive()")
1216
1349
  this.derives.push(fn as unknown as RawDerive)
1217
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1218
- recordScopedHook(this, this.catalog.size, "derive")
1350
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
1351
+ recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "derive")
1352
+ tagHook(fn, "derive", this)
1353
+ }
1219
1354
  return this as unknown as Server<
1220
1355
  R,
1221
1356
  Ctx & ContextExtensionOf<ReturnType<H>>,
@@ -1252,6 +1387,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1252
1387
  throw new TypeError('authenticate() stage mode must be "sync" or "async"')
1253
1388
  }
1254
1389
  this.authStages.push(stage as unknown as RawAuthStage)
1390
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1391
+ tagHook(stage, "authenticate", this)
1255
1392
  return this as unknown as Server<R, Ctx & { readonly principal: Principal }, HookOutput>
1256
1393
  }
1257
1394
 
@@ -1259,19 +1396,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1259
1396
  this.assertConfigurable("decorate()")
1260
1397
  this.decorations[key] = value
1261
1398
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1262
- recordScopedHook(this, this.catalog.size, "decorate")
1399
+ recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "decorate")
1263
1400
  return this as unknown as Server<R, Ctx & Record<K, V>, HookOutput>
1264
1401
  }
1265
1402
 
1266
1403
  /**
1267
- * Run before routing on the raw request. Return a `Response` to short-circuit, or a replacement
1268
- * `Request` to continue routing with a rewritten method/URL/headers. Global.
1404
+ * Run before routing on the raw request. Return a `Response` or a `status()` result to
1405
+ * short-circuit, or a replacement `Request` to continue routing with a rewritten method/URL/headers.
1406
+ * Global.
1269
1407
  */
1270
1408
  onRequest(
1271
1409
  fn: (req: Request, platform?: Platform<EnvOf<Ctx>>) => MaybePromise<OnRequestResult>,
1272
1410
  ): this {
1273
1411
  this.assertConfigurable("onRequest()")
1274
1412
  this.onRequestHooks.push(fn as RawOnRequest)
1413
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") tagHook(fn, "onRequest", this)
1275
1414
  this.onNodeRequestHooks.push(undefined)
1276
1415
  this.nodeRequestHooksComplete = false
1277
1416
  this.bunNativeRequestHooksSafe = false
@@ -1288,8 +1427,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1288
1427
  ): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
1289
1428
  this.assertConfigurable("beforeHandle()")
1290
1429
  this.beforeHandleHooks.push(fn as unknown as RawBeforeHandle)
1291
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1292
- recordScopedHook(this, this.catalog.size, "beforeHandle")
1430
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
1431
+ recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "beforeHandle")
1432
+ tagHook(fn, "beforeHandle", this)
1433
+ }
1293
1434
  return this as unknown as Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>>
1294
1435
  }
1295
1436
 
@@ -1306,8 +1447,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1306
1447
  >(fn: H): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
1307
1448
  this.assertConfigurable("around()")
1308
1449
  this.aroundHooks.push(fn as unknown as RawAround)
1309
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1450
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
1310
1451
  recordScopedHook(this, this.catalog.size, "around")
1452
+ tagHook(fn, "around", this)
1453
+ }
1311
1454
  return this as unknown as Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>>
1312
1455
  }
1313
1456
 
@@ -1355,8 +1498,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1355
1498
  ): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
1356
1499
  this.assertConfigurable("afterHandle()")
1357
1500
  this.afterHandleHooks.push(fn as unknown as RawAfterHandle)
1358
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1501
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
1359
1502
  recordScopedHook(this, this.catalog.size, "afterHandle")
1503
+ tagHook(fn, "afterHandle", this)
1504
+ }
1360
1505
  return this as unknown as Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>>
1361
1506
  }
1362
1507
 
@@ -1368,8 +1513,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1368
1513
  ): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
1369
1514
  this.assertConfigurable("onError()")
1370
1515
  this.onErrorHooks.push(fn as unknown as RawErrorHandler)
1371
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1372
- recordScopedHook(this, this.catalog.size, "onError")
1516
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
1517
+ recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "onError")
1518
+ tagHook(fn, "onError", this)
1519
+ }
1373
1520
  return this as unknown as Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>>
1374
1521
  }
1375
1522
 
@@ -1377,6 +1524,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1377
1524
  onResponse(fn: (response: Response, req: Request) => MaybePromise<Response>): this {
1378
1525
  this.assertConfigurable("onResponse()")
1379
1526
  this.onResponseHooks.push(fn)
1527
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") tagHook(fn, "onResponse", this)
1380
1528
  this.onNodeResponseHooks.push(undefined)
1381
1529
  this.nodeResponseHooksComplete = false
1382
1530
  this.bunNativeResponseHeadersOnly = false
@@ -1409,6 +1557,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1409
1557
  */
1410
1558
  responseHeaders(record: Readonly<Record<string, string>>): this {
1411
1559
  this.assertConfigurable("responseHeaders()")
1560
+ if (
1561
+ hookAuditRuntime &&
1562
+ process.env.NODE_ENV !== "production" &&
1563
+ this.onResponseHooks.length === 0
1564
+ )
1565
+ tagStatic(this, Object.keys(record), record)
1412
1566
  return this.addStaticResponseHeaders(normalizeStaticResponseHeaders(record))
1413
1567
  }
1414
1568
 
@@ -1427,13 +1581,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1427
1581
  })
1428
1582
  return this
1429
1583
  }
1430
- // Route the hook through the same seam the static tier uses. On a framework response (headers
1431
- // stamped mutable) it applies the defaults in place and returns the SAME object, so the
1432
- // serialized-body marker survives for later `onResponseBody`/`onResponseRaw` observers and the
1433
- // Node writer; reconstructing a `Response` here would strip that marker and reclassify the Node
1434
- // outcome from `json` to a generic `response`. A guarded foreign response takes its clone path.
1584
+ // Route the hook through the same seam the static tier uses. On a framework response with a
1585
+ // body it applies the defaults in place and returns the SAME object, so the serialized-body
1586
+ // marker survives for later `onResponseBody`/`onResponseRaw` observers and the Node writer;
1587
+ // reconstructing a `Response` here would strip that marker and reclassify the Node outcome from
1588
+ // `json` to a generic `response`. A body-less or guarded response is copied, marker included.
1435
1589
  const statics = buildStaticResponseHeaders(record, this.inheritedStatics)
1436
- this.onResponseHooks.push((response) => applyStaticResponseHeaders(response, statics))
1590
+ this.onResponseHooks.push((response, req) =>
1591
+ applyStaticResponseHeaders(response, statics, req),
1592
+ )
1593
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1594
+ tagHook(this.onResponseHooks.at(-1), "responseHeaders", this)
1437
1595
  this.onNodeResponseHooks.push(undefined)
1438
1596
  this.nodeResponseHooksComplete = false
1439
1597
  return this
@@ -1478,6 +1636,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1478
1636
  ): this {
1479
1637
  this.assertConfigurable("onResponseFinalized()")
1480
1638
  this.onResponseFinalizedHooks.push(fn)
1639
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1640
+ tagHook(fn, "onResponseFinalized", this)
1481
1641
  return this
1482
1642
  }
1483
1643
 
@@ -1570,9 +1730,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1570
1730
  this.activeAssurance.push(...pluginOnly)
1571
1731
  const inherited = this.inheritedPlugins
1572
1732
  this.inheritedPlugins = undefined
1733
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") enterPlugin(name)
1573
1734
  try {
1574
1735
  return arg(this)
1575
1736
  } finally {
1737
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") leavePlugin()
1576
1738
  this.inheritedPlugins = inherited
1577
1739
  // Remove only this plugin's temporary evidence. Nested assured plugins may deliberately leave
1578
1740
  // subsequent evidence active, so truncating the whole array would lose real ordering semantics.
@@ -1602,6 +1764,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1602
1764
  if (arg.onRequest !== undefined) {
1603
1765
  this.assertConfigurable("onRequest()")
1604
1766
  this.onRequestHooks.push(arg.onRequest as RawOnRequest)
1767
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1768
+ tagHook(arg.onRequest, "onRequest", this)
1605
1769
  this.onNodeRequestHooks.push(arg.onNodeRequest)
1606
1770
  if (arg.onNodeRequest === undefined) this.nodeRequestHooksComplete = false
1607
1771
  if (!(arg as unknown as Record<symbol, unknown>)[BUN_NATIVE_REQUEST_SAFE]) {
@@ -1616,6 +1780,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1616
1780
  if (arg.onResponse !== undefined) {
1617
1781
  this.assertConfigurable("onResponse()")
1618
1782
  this.onResponseHooks.push(arg.onResponse)
1783
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1784
+ tagHook(arg.onResponse, "onResponse", this)
1619
1785
  this.onNodeResponseHooks.push(arg.onNodeResponse)
1620
1786
  if (arg.onNodeResponse === undefined) this.nodeResponseHooksComplete = false
1621
1787
  else this.hasRawNodeResponseHook = true
@@ -1672,8 +1838,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1672
1838
  }
1673
1839
 
1674
1840
  /**
1675
- * Register a **typed SSE route** - a GET endpoint streaming `text/event-stream` whose event
1676
- * payloads are contracted by `schema.sse`. The handler receives the validated context plus a
1841
+ * Register a **typed SSE route** - an endpoint streaming `text/event-stream` whose event
1842
+ * payloads are contracted by `schema.sse`. It is a GET route, or a POST route when the schema
1843
+ * declares a `body` (a GET carries none): the body validates as on any POST, and the client sends
1844
+ * it with `.subscribe(onEvent, { body })` - the shape of a chat stream that posts its prompt. The handler receives the validated context plus a
1677
1845
  * {@link TypedSSEStream}: `stream.send(event)` is compile-time-checked against the schema and
1678
1846
  * JSON-serialized into the SSE `data:` field. The typed client sees the marker and grows a
1679
1847
  * `.subscribe(onEvent)` for the route with the same payload type - end-to-end typed streaming.
@@ -1688,9 +1856,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1688
1856
  *
1689
1857
  * // client: const off = api.feed.subscribe((post) => console.log(post.title))
1690
1858
  *
1691
- * `init` passes through to the underlying {@link sse} helper (`keepAlive`, extra headers). The
1692
- * connection closes when the handler resolves, `stream.close()` runs, or the client disconnects
1693
- * (`stream.signal`). Query/body schemas validate exactly as on any other route.
1859
+ * `init` passes through to the underlying {@link sse} helper (`keepAlive`, `maxBufferedBytes`,
1860
+ * extra headers). The connection closes when the handler resolves, `stream.close()` runs, or the
1861
+ * client disconnects (`stream.signal`). Query/body schemas validate exactly as on any other route.
1694
1862
  */
1695
1863
  sse<Path extends string, S extends RouteSchema & { sse: StandardSchemaV1 }>(
1696
1864
  path: Path,
@@ -1701,14 +1869,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1701
1869
  ) => void | Promise<void>,
1702
1870
  init?: SSEInit,
1703
1871
  ): Server<
1704
- AddRoute<R, "GET", Path, RouteInfoFor<Path, S, Response, HookOutput>>,
1872
+ AddRoute<R, SseMethodOf<S>, Path, RouteInfoFor<Path, S, Response, HookOutput>>,
1705
1873
  Ctx,
1706
1874
  HookOutput
1707
1875
  > {
1708
1876
  const handler = (context: Context<Path, S> & Ctx): Response =>
1709
1877
  requireSseRuntime(this.sseRuntime).response(context, (stream) => run(context, stream), init)
1710
- return this.route("GET", path, schema, handler as unknown as ErasedHandler) as Server<
1711
- AddRoute<R, "GET", Path, RouteInfoFor<Path, S, Response, HookOutput>>,
1878
+ return this.route(
1879
+ schema.body ? "POST" : "GET",
1880
+ path,
1881
+ schema,
1882
+ handler as unknown as ErasedHandler,
1883
+ ) as Server<
1884
+ AddRoute<R, SseMethodOf<S>, Path, RouteInfoFor<Path, S, Response, HookOutput>>,
1712
1885
  Ctx,
1713
1886
  HookOutput
1714
1887
  >
@@ -1927,8 +2100,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1927
2100
 
1928
2101
  /**
1929
2102
  * Register a **WebSocket** route. The connection upgrades on a `GET` to `path` carrying
1930
- * `Upgrade: websocket`; the optional `handler.upgrade(c)` runs in the request context first and may
1931
- * reject (return a `Response`) or seed per-connection `ws.data`. WebSockets are served by the
2103
+ * `Upgrade: websocket`. The handshake runs the chain an HTTP `GET` registered here would: the
2104
+ * `authenticate()` stages, `derive`s and `beforeHandle`s added before this call, then the optional
2105
+ * `handler.upgrade(c)`. Any of them may reject (a `Response`, a `status(...)`, a failed auth stage);
2106
+ * `upgrade()` may seed per-connection `ws.data`. To keep a socket public, register it before the
2107
+ * guard. WebSockets are served by the
1932
2108
  * adapter (`listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler`) - not by bare `app.fetch`, which
1933
2109
  * has no socket (a WS path through `app.fetch` is a normal HTTP response).
1934
2110
  *
@@ -1947,7 +2123,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1947
2123
  Path extends string = string,
1948
2124
  >(
1949
2125
  path: Path,
1950
- handler: WebSocketHandler<Data, EnvOf<Ctx>, Schema, Send>,
2126
+ handler: WebSocketHandler<Data, EnvOf<Ctx>, Schema, Send, Ctx>,
1951
2127
  ): string extends Path
1952
2128
  ? Server<R, Ctx, HookOutput>
1953
2129
  : Server<AddRoute<R, "WS", Path, WsRouteInfoFor<Path, Schema, Send>>, Ctx, HookOutput> {
@@ -1967,9 +2143,25 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1967
2143
  this.topics ??= runtime.createTopics()
1968
2144
  // A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
1969
2145
  // already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
1970
- const entry = { handler: runtime.wrapHandler(handler as WebSocketHandler, this.protoPoisoning) }
1971
- for (const form of expandOptionalParams(path)) this.wsRouter.add("GET", form, entry)
1972
- this.wsRouteCount += 1
2146
+ const wrapped = runtime.wrapHandler(handler as WebSocketHandler, this.protoPoisoning)
2147
+ const upgrade = runtime.upgradeStep(wrapped)
2148
+ for (const form of expandOptionalParams(path)) {
2149
+ // The chain an HTTP `GET` to `form` would capture now, ending in `upgrade()` instead of a
2150
+ // handler. `afterHandle` and `around` are left out: they wrap a response, and an accepted
2151
+ // handshake has none.
2152
+ const { entry, assurance, descriptor } = this.prepareRoute(
2153
+ "WS",
2154
+ form,
2155
+ wrapped.capabilities === undefined ? undefined : { capabilities: wrapped.capabilities },
2156
+ upgrade,
2157
+ )
2158
+ const chain = { ...entry, afterHandle: [] }
2159
+ this.wsRouter.add("GET", form, {
2160
+ handler: wrapped,
2161
+ chain: { ...chain, program: compileRouteProgram({ ...chain, bodySchema: undefined }) },
2162
+ })
2163
+ this.wsRoutes.push({ path: form, capabilities: descriptor.capabilities, assurance, chain })
2164
+ }
1973
2165
  wsChanges += 1
1974
2166
  return this as never
1975
2167
  }
@@ -2265,30 +2457,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2265
2457
  }
2266
2458
  }
2267
2459
 
2268
- /** The idempotency lane's bridge back into the normal matched lanes, resolved to a concrete Response
2269
- * (the lane buffers the body, then replays it through the route's real validation + handler). */
2270
- private idempotencyRunLanes(
2271
- buffered: RequestSource,
2272
- platform: Platform | undefined,
2273
- entry: RouteEntry,
2274
- params: Record<string, string>,
2275
- search: string | undefined,
2276
- ): Promise<Response> {
2277
- return Promise.resolve(
2278
- this.runMatchedLanes(
2279
- buffered,
2280
- platform,
2281
- entry,
2282
- params,
2283
- search,
2284
- this.finalizeResponse,
2285
- this.wrapWebResponse,
2286
- this.webResponseTimeout,
2287
- false,
2288
- ),
2289
- )
2290
- }
2291
-
2292
2460
  /** @internal Symbol-keyed install seam for the `responseContract()` plugin. Off the public typed surface. */
2293
2461
  [INSTALL_RESPONSE_CONTRACT](runtime: ResponseContractRuntime): void {
2294
2462
  this.assertConfigurable("responseContract()")
@@ -2302,6 +2470,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2302
2470
  assertConfigurable: (operation) => this.assertConfigurable(operation),
2303
2471
  addResponseHook: (web, node, headerOnly = false) => {
2304
2472
  this.onResponseHooks.push(web)
2473
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2474
+ tagHook(web, headerOnly ? "responseHeaders" : "onResponse", this)
2305
2475
  this.onNodeResponseHooks.push(node)
2306
2476
  if (node === undefined) this.nodeResponseHooksComplete = false
2307
2477
  if (!headerOnly) this.bunNativeResponseHeadersOnly = false
@@ -2343,6 +2513,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2343
2513
  this.nodeOutcomeRuntime = runtime
2344
2514
  }
2345
2515
 
2516
+ /** @internal An adapter reports a failure no response hook saw (a hook that threw past `fetch`, a
2517
+ * response the runtime refused to write), logged as Bun's `listen()` logs one. */
2518
+ [REPORT_ERROR](error: unknown): void {
2519
+ emitRequestErrorLog(this.logger, this.errorLogDetail, error, undefined)
2520
+ }
2521
+
2346
2522
  /** @internal Symbol-keyed install seam for the `streaming()` plugin. Off the public typed surface. */
2347
2523
  [INSTALL_SSE](runtime: SseRuntime): void {
2348
2524
  this.assertConfigurable("streaming()")
@@ -2363,7 +2539,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2363
2539
  * counts. `seen` ends a mount cycle (an app mounted under itself to alias a prefix).
2364
2540
  */
2365
2541
  [GET_WS_RUNTIME](seen?: Set<unknown>): WsRuntime | null | undefined {
2366
- return this.wsRouteCount > 0 ? this.wsRuntime : this.mountedWsRuntime(seen)
2542
+ return this.wsRoutes.length > 0 ? this.wsRuntime : this.mountedWsRuntime(seen)
2367
2543
  }
2368
2544
 
2369
2545
  /** The `[GET_WS_RUNTIME]` answer for the apps mounted here alone, whatever this app's own routes. */
@@ -2404,15 +2580,36 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2404
2580
  * `derive`/`decorate`/`beforeHandle`/`afterHandle`/`onError`/`around` apply to its routes
2405
2581
  * exactly as they did standalone, so a group wires its own plugins. The same locality rule
2406
2582
  * covers the group's `onRequest` hooks: they run only for requests that route to the GROUP's
2407
- * routes (a `bodyLimit()` mounted on an uploads group must not start gating the whole app
2583
+ * routes, and for an `OPTIONS` preflight to a path they serve (a `bodyLimit()` mounted on an uploads group must not start gating the whole app
2408
2584
  * because the app composed it in), and the group's global assurance follows its hooks onto
2409
2585
  * exactly those routes. A group with hooks but NO routes is a middleware bundle - its hooks
2410
2586
  * can only mean app-wide intent, so they are appended globally, unchanged. Response-side hooks
2411
2587
  * (`onResponse`/`onResponseFinalized`) are appended to this server's. This server's
2412
2588
  * route-scoped chains do NOT retroactively wrap merged routes (order-scoped, like routes
2413
2589
  * registered before a `derive`). Fail closed: a path+method collision throws
2414
- * `RouteConfigError` at merge time, and a group with WebSocket routes is refused (register
2415
- * those on the parent).
2590
+ * `RouteConfigError` at merge time, and a group with WebSocket routes or mounts is refused
2591
+ * (register those on the parent).
2592
+ *
2593
+ * What this server's `.use()`, made BEFORE the merge, covers on a merged route versus a
2594
+ * `group()` route:
2595
+ *
2596
+ * | installed on this server | group() route | merge()d route |
2597
+ * | ------------------------------------------ | ------------- | ------------------------------ |
2598
+ * | derive/decorate/beforeHandle/afterHandle, | yes | no - wire it on the merged |
2599
+ * | onError/around, authenticate, route-scoped | | server |
2600
+ * | assurance | | |
2601
+ * | onRequest/onResponse/responseHeaders hooks | yes | yes (app-wide) |
2602
+ * | responseContract() | yes | yes, when the route declares a |
2603
+ * | | | response and its own server |
2604
+ * | | | installed no contract |
2605
+ * | effectLedger() | yes | yes, when the route declares |
2606
+ * | | | capabilities and has no ledger |
2607
+ * | idempotency() | yes | the merged server's own; a |
2608
+ * | | | route declaring it without one |
2609
+ * | | | is refused at registration |
2610
+ *
2611
+ * The response contract and the ledger can only add a check or a record, so a merged route is
2612
+ * recompiled under them. A runtime installed AFTER the merge covers neither kind of route.
2416
2613
  */
2417
2614
  merge<R2 extends Registry, Ctx2, HookOutput2>(
2418
2615
  other: Server<R2, Ctx2, HookOutput2>,
@@ -2432,10 +2629,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2432
2629
  )
2433
2630
  }
2434
2631
  const source = other as unknown as Server<Registry, EmptyContext>
2435
- if (source.wsRouteCount > 0) {
2632
+ if (source.wsRoutes.length > 0 || source.fetchMounts.length > 0) {
2436
2633
  throw new RouteConfigError(
2437
2634
  "INVALID_PATH",
2438
- "merge() does not carry WebSocket routes - register .ws() routes on the parent server",
2635
+ "merge() does not carry WebSocket routes or mounts - register them on the parent server",
2439
2636
  )
2440
2637
  }
2441
2638
  this.adopt(source, undefined, 0)
@@ -2457,7 +2654,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2457
2654
  * The builder receives a scope that INHERITS this server's route chain as it stands at the call
2458
2655
  * (`derive`/`decorate`/`authenticate`/`beforeHandle`/`afterHandle`/`onError`/`around`, assurance,
2459
2656
  * capability interceptors, installed runtimes, applied plugins), exactly as a route declared here
2460
- * would. A plugin this server already applied is in effect for the group: the group's `use()` of
2657
+ * would. {@link merge} documents how that differs for a merged server. A plugin this server already applied is in effect for the group: the group's `use()` of
2461
2658
  * that same instance does nothing, and its `use()` of another instance under the same name throws a
2462
2659
  * `RouteConfigError` (`PLUGIN_RECONFIGURED`), because that instance could never replace the parent's.
2463
2660
  * A plugin the group applies still shares the parent's copy of a dependency it `use()`s itself.
@@ -2521,7 +2718,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2521
2718
  throw new TypeError(`${label} builder must return its group synchronously`)
2522
2719
  }
2523
2720
  if (
2524
- scope.wsRouteCount > 0 ||
2721
+ scope.wsRoutes.length > 0 ||
2525
2722
  scope.fetchMounts.length > 0 ||
2526
2723
  scope.catalog.entries().some((route) => route.descriptor.tool !== undefined)
2527
2724
  ) {
@@ -2531,7 +2728,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2531
2728
  )
2532
2729
  }
2533
2730
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2534
- sealHookAudit(scope, scope.catalog.size, this.logger)
2731
+ sealHookAudit(scope, scope.catalog.size, scope.wsRoutes.length, this.logger)
2535
2732
  this.adopt(scope, fullPrefix, this.globalAssurance.length)
2536
2733
  return this as unknown as Server<R & PrefixRegistry<P, R2>, Ctx, HookOutput>
2537
2734
  }
@@ -2565,7 +2762,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2565
2762
  })
2566
2763
  : (route: CatalogRoute): CatalogRoute => route
2567
2764
  this.catalog.addBatch(
2568
- sourceRoutes.map((route) => this.bindFusedRuntime(foldedAssurance(route))),
2765
+ sourceRoutes.map((route) =>
2766
+ this.bindFusedRuntime(
2767
+ foldedAssurance(
2768
+ adoptRouteRuntimes(route, this.responseContractRuntime, this.effectLedgerRuntime),
2769
+ ),
2770
+ ),
2771
+ ),
2569
2772
  )
2570
2773
  // Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
2571
2774
  // runtime object supplies the generic execution machinery. If the parent already has a runtime,
@@ -2593,12 +2796,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2593
2796
  // The group's static declarations came before its own response hooks, so they are folded in
2594
2797
  // first - and fold themselves into a hook here if this server already has one (same ordering
2595
2798
  // rule as a direct `responseHeaders()` call).
2799
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2800
+ adoptStaticTags(this, source, this.onResponseHooks.length > 0)
2596
2801
  this.addStaticResponseHeaders({ ...statics.record })
2597
2802
  } else {
2598
2803
  // The static tier is folded into EVERY response this server builds, so a group's static
2599
2804
  // headers cannot join it. A hook ahead of the group's own (the sealed scope is discarded
2600
2805
  // after adoption) keeps their meaning - defaults a value already on the response wins over.
2601
- source.onResponseHooks.unshift((response) => applyStaticResponseHeaders(response, statics))
2806
+ source.onResponseHooks.unshift((response, req) =>
2807
+ applyStaticResponseHeaders(response, statics, req),
2808
+ )
2809
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2810
+ tagHook(source.onResponseHooks[0], "responseHeaders", source, true)
2602
2811
  source.onNodeResponseHooks.unshift(undefined)
2603
2812
  source.nodeResponseHooksComplete = false
2604
2813
  }
@@ -2608,6 +2817,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2608
2817
  this.onResponseFinalizedHooks.push(
2609
2818
  ...scopeHooks(source.onResponseFinalizedHooks, responseScope, 1),
2610
2819
  )
2820
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
2821
+ // Read before anything else is appended: the wrappers are still each list's tail.
2822
+ const pairs: (readonly [readonly unknown[], readonly unknown[]])[] = []
2823
+ if (inScope !== undefined) pairs.push([source.onRequestHooks, this.onRequestHooks])
2824
+ if (responseScope !== undefined) {
2825
+ pairs.push([source.onResponseHooks, this.onResponseHooks])
2826
+ pairs.push([source.onResponseFinalizedHooks, this.onResponseFinalizedHooks])
2827
+ }
2828
+ tagAdopted(this, source, prefix, sourceRoutes, pairs)
2829
+ }
2611
2830
  this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete
2612
2831
  this.bunNativeResponseHeadersOnly &&= source.bunNativeResponseHeadersOnly
2613
2832
  this.hasRawNodeResponseHook ||= source.hasRawNodeResponseHook
@@ -2799,7 +3018,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2799
3018
  // Seal once on the first request so test servers (no `listen()`) still get the dead-hook check.
2800
3019
  // After that it is a single boolean read - the per-request cost the option promises to keep at zero.
2801
3020
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2802
- sealHookAudit(this, this.catalog.size, this.logger)
3021
+ sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger)
2803
3022
  // Off path (default): straight through - one property check, no closure, no promise.
2804
3023
  if (this.capacityGate === undefined) return this.fetchSourceInner(source, platform)
2805
3024
  return this.admitGated(requestOf(source), () => this.fetchSourceInner(source, platform))
@@ -2983,7 +3202,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2983
3202
  ): MaybePromise<NodeServeOutcome> {
2984
3203
  // Seal once on the Node-direct lane too - it can bypass `fetchSource` entirely (hookless outcome).
2985
3204
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2986
- sealHookAudit(this, this.catalog.size, this.logger)
3205
+ sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger)
2987
3206
  // A paired header-only native hook can preserve the Node-direct JSON/body outcome for successful
2988
3207
  // responses. Arbitrary onResponse transforms need a real Web Response, but a buffered outcome can
2989
3208
  // be materialized with a direct-write marker and return to the socket path when the hook mutates
@@ -3242,7 +3461,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3242
3461
  current = outcome
3243
3462
  continue
3244
3463
  }
3245
- if (outcome !== undefined) return outcome
3464
+ if (outcome !== undefined) return webResponseOf(outcome)
3246
3465
  }
3247
3466
  return current
3248
3467
  }
@@ -3257,7 +3476,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3257
3476
  let outcome = first
3258
3477
  for (let i = start; ; i++) {
3259
3478
  if (outcome instanceof Request) next = outcome
3260
- else if (outcome !== undefined) return outcome
3479
+ else if (outcome !== undefined) return webResponseOf(outcome)
3261
3480
  if (i >= this.onRequestHooks.length) return next
3262
3481
  const hook = this.onRequestHooks[i]
3263
3482
  if (hook === undefined) return next
@@ -3271,11 +3490,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3271
3490
  ): MaybePromise<WebSocketUpgradeOutcome> {
3272
3491
  // Before any header is read: Deno only materializes a request's headers when asked, and that
3273
3492
  // costs a plain request measurably.
3274
- if (this.wsRouteCount === 0 && !this.mountsTakeUpgrades()) return WS_PASS
3493
+ if (this.wsRoutes.length === 0 && !this.mountsTakeUpgrades()) return WS_PASS
3275
3494
  if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") return WS_PASS
3276
3495
  // A WebSocket handshake is a GET (RFC 6455 section 4.1). Any other method with an Upgrade header
3277
3496
  // is an ordinary request: its HTTP route answers it, and normal routing refuses an unknown token.
3278
3497
  if (req.method !== "GET") return WS_PASS
3498
+ // The adapter answers a pass by handing this same object to `fetch`.
3499
+ const received = req
3279
3500
  // The handshake routes the path `fetch` would: dot segments resolved (see `fetch`).
3280
3501
  if (rawRequestTargets && hasDotSegment(req.url)) req = withResolvedTarget(req)
3281
3502
  // The handshake's caller is the one `fetch` derives: hooks and `upgrade()` see the trusted IP.
@@ -3290,11 +3511,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3290
3511
  const controller = timeoutMs > 0 ? new AbortController() : undefined
3291
3512
  const signal = controller?.signal ?? getNeverAbortSignal()
3292
3513
  const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : undefined
3293
- const timeoutResponse = jsonError(503, "request_timeout")
3294
- const onTimeout = (): WebSocketUpgradeOutcome => ({
3514
+ // A refusal carries the static response headers an HTTP answer would; a guard's already does.
3515
+ const refuse = (response: Response): WebSocketUpgradeOutcome => ({
3295
3516
  kind: "reject",
3296
- response: timeoutResponse,
3517
+ response: this.wrapWebResponse(response),
3297
3518
  })
3519
+ const onTimeout = (): WebSocketUpgradeOutcome => refuse(jsonError(503, "request_timeout"))
3298
3520
  const bounded = <T>(work: Promise<T>, onDone: () => T): Promise<T> =>
3299
3521
  controller === undefined
3300
3522
  ? work
@@ -3307,11 +3529,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3307
3529
  // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
3308
3530
  // already did and made every plain dynamic route pay for unrelated static path bytes.
3309
3531
  const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params)
3310
- if (params === null) return { kind: "reject", response: jsonError(400, "malformed_path") }
3311
- const handler = match.payload.handler
3312
- // Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
3532
+ if (params === null) return refuse(jsonError(400, "malformed_path"))
3533
+ const { handler, chain } = match.payload
3534
+ // Non-null: a socket route ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
3313
3535
  const pubsub = this.topics as TopicRegistry
3314
- const attach = (this.wsRuntime as WsRuntime).attach
3536
+ const runtime = this.wsRuntime as WsRuntime
3315
3537
  // CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
3316
3538
  // Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
3317
3539
  // blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
@@ -3321,23 +3543,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3321
3543
  typeof handler.allowedOrigins === "function"
3322
3544
  ? handler.allowedOrigins(origin)
3323
3545
  : origin !== null && handler.allowedOrigins.includes(origin)
3324
- if (!allowed) return { kind: "reject", response: jsonError(403, "forbidden_origin") }
3546
+ if (!allowed) return refuse(jsonError(403, "forbidden_origin"))
3325
3547
  } else if (origin !== null && !wsSameOrigin(origin, request)) {
3326
3548
  // Secure default (no explicit `allowedOrigins`): reject a CROSS-ORIGIN browser handshake - the
3327
3549
  // CSWSH case, since browsers send cookies on WS handshakes and don't apply CORS. Non-browser
3328
3550
  // clients send no `Origin` and pass; same-origin browsers pass. Set `allowedOrigins` to permit
3329
3551
  // specific cross-origin clients (or `() => true` for a genuinely public socket).
3330
- return { kind: "reject", response: jsonError(403, "forbidden_origin") }
3331
- }
3332
- if (handler.upgrade === undefined) {
3333
- return {
3334
- kind: "upgrade",
3335
- handler,
3336
- data: undefined,
3337
- pubsub,
3338
- attach,
3339
- maxPayloadBytes: this.wsMaxPayloadBytes,
3340
- }
3552
+ return refuse(jsonError(403, "forbidden_origin"))
3341
3553
  }
3342
3554
  const budget =
3343
3555
  controller === undefined
@@ -3353,28 +3565,32 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3353
3565
  this.maxBodyBytes,
3354
3566
  this.protoPoisoning,
3355
3567
  )
3356
- const settle = (value: unknown): WebSocketUpgradeOutcome =>
3357
- value instanceof Response
3358
- ? { kind: "reject", response: value }
3568
+ // Rendered as the same chain on an HTTP `GET` renders its early results.
3569
+ const finalize = (result: unknown, set: CtxSet): WebSocketUpgradeOutcome => {
3570
+ const accepted = runtime.accepted(result)
3571
+ return accepted === undefined
3572
+ ? { kind: "reject", response: this.finalizeResponse(result, set) }
3359
3573
  : {
3360
3574
  kind: "upgrade",
3361
3575
  handler,
3362
- data: value,
3576
+ data: accepted.data,
3363
3577
  pubsub,
3364
- attach,
3578
+ attach: runtime.attach,
3365
3579
  maxPayloadBytes: this.wsMaxPayloadBytes,
3580
+ sender: runtime.sender,
3366
3581
  }
3582
+ }
3583
+ const wrap = (response: Response | ResponseResult): WebSocketUpgradeOutcome =>
3584
+ finalize(response, EMPTY_RESPONSE_CONTROLS)
3585
+ let outcome: MaybePromise<WebSocketUpgradeOutcome>
3367
3586
  try {
3368
- const result = handler.upgrade(ctx as unknown as WebSocketContext<EnvOf<Ctx>>)
3369
- if (!(result instanceof Promise)) return settle(result)
3370
- const pending = result.then(settle, () => ({
3371
- kind: "reject" as const,
3372
- response: jsonError(500, "internal_error"),
3373
- }))
3374
- return bounded(pending, onTimeout)
3587
+ outcome = executeRouteProgram(this, chain, chain.program, request, ctx, finalize, wrap)
3375
3588
  } catch {
3376
- return { kind: "reject", response: jsonError(500, "internal_error") }
3589
+ return refuse(jsonError(500, "internal_error"))
3377
3590
  }
3591
+ if (!(outcome instanceof Promise)) return outcome
3592
+ const pending = outcome.then(undefined, () => refuse(jsonError(500, "internal_error")))
3593
+ return bounded(pending, onTimeout)
3378
3594
  }
3379
3595
 
3380
3596
  const resolveMounted = (request: Request): MaybePromise<WebSocketUpgradeOutcome> => {
@@ -3397,20 +3613,25 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3397
3613
  }
3398
3614
  }
3399
3615
  if (selected === undefined) return WS_PASS
3400
- const childRequest = selected.stripPrefix
3401
- ? stripMountPrefix(request, selected.path)
3402
- : request
3616
+ let childRequest = request
3617
+ if (selected.stripPrefix) {
3618
+ childRequest = stripMountPrefix(request, selected.path)
3619
+ selected.wsPassed ??= new WeakMap()
3620
+ selected.wsPassed.set(request, childRequest)
3621
+ }
3403
3622
  let child: MaybePromise<WebSocketUpgradeOutcome>
3404
3623
  try {
3405
3624
  child = selected.resolveWebSocketUpgrade!(childRequest, platform)
3406
3625
  } catch {
3407
- return { kind: "reject", response: jsonError(500, "internal_error") }
3626
+ return refuse(jsonError(500, "internal_error"))
3408
3627
  }
3409
3628
  const settle = (outcome: WebSocketUpgradeOutcome): MaybePromise<WebSocketUpgradeOutcome> =>
3410
3629
  outcome.kind === "pass" ||
3411
3630
  (outcome.kind === "reject" && outcome.response.status === 404 && selected!.fallbackOn404)
3412
3631
  ? dispatch(selectedIndex + 1)
3413
- : outcome
3632
+ : outcome.kind === "reject"
3633
+ ? refuse(outcome.response)
3634
+ : outcome
3414
3635
  return child instanceof Promise ? child.then(settle) : settle(child)
3415
3636
  }
3416
3637
  return dispatch(0)
@@ -3432,23 +3653,25 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3432
3653
  }
3433
3654
 
3434
3655
  if (this.onRequestHooks.length === 0) return resolveWithDeadline(req)
3656
+ // On a pass the adapter hands `received` to `fetch`, which then routes these results. An upgrade
3657
+ // or a rejection never reaches `fetch`, so the entry dies with the request.
3658
+ const resolveHooked = (request: Request): MaybePromise<WebSocketUpgradeOutcome> => {
3659
+ this.wsHookedPasses ??= new WeakMap()
3660
+ this.wsHookedPasses.set(received, request)
3661
+ return resolveWithDeadline(request)
3662
+ }
3435
3663
  let hooked: MaybePromise<Request | Response>
3436
3664
  try {
3437
3665
  hooked = this.runWebSocketRequestHooks(req, platform)
3438
3666
  } catch {
3439
- return { kind: "reject", response: jsonError(500, "internal_error") }
3667
+ return refuse(jsonError(500, "internal_error"))
3440
3668
  }
3441
3669
  if (!(hooked instanceof Promise)) {
3442
- return hooked instanceof Response
3443
- ? { kind: "reject", response: hooked }
3444
- : resolveWithDeadline(hooked)
3670
+ return hooked instanceof Response ? refuse(hooked) : resolveHooked(hooked)
3445
3671
  }
3446
3672
  const pending = hooked.then(
3447
- (result) =>
3448
- result instanceof Response
3449
- ? { kind: "reject" as const, response: result }
3450
- : resolveWithDeadline(result),
3451
- () => ({ kind: "reject" as const, response: jsonError(500, "internal_error") }),
3673
+ (result) => (result instanceof Response ? refuse(result) : resolveHooked(result)),
3674
+ () => refuse(jsonError(500, "internal_error")),
3452
3675
  )
3453
3676
  return bounded(
3454
3677
  Promise.resolve(pending).then((result) => result),
@@ -3467,9 +3690,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3467
3690
  if (o.kind === "pass")
3468
3691
  return this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>)
3469
3692
  if (o.kind === "reject") return o.response
3470
- return server.upgrade(req, {
3471
- data: { handler: o.handler, data: o.data, pubsub: o.pubsub },
3472
- })
3693
+ // A copy: the Bun socket keeps per-connection state on it, and a hand-built outcome may be shared.
3694
+ return server.upgrade(req, { data: { ...o } })
3473
3695
  ? undefined
3474
3696
  : jsonError(426, "upgrade_required")
3475
3697
  }
@@ -3512,7 +3734,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3512
3734
  private dispatch<T>(
3513
3735
  source: RequestSource,
3514
3736
  platform: Platform<EnvOf<Ctx>> | undefined,
3515
- finalize: (result: unknown, set: CtxSet) => T,
3737
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
3516
3738
  wrapResponse: (response: Response | ResponseResult) => T,
3517
3739
  onTimeout: () => T,
3518
3740
  // True only from the Web `fetch` path - unlocks each route's fused lane, whose output type IS
@@ -3532,6 +3754,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3532
3754
  if (this.onRequestHooks.length === 0 || !isRoutableMethod(source.method)) {
3533
3755
  return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast)
3534
3756
  }
3757
+ const hooked = this.wsHookedPasses?.get(source)
3758
+ if (hooked !== undefined) {
3759
+ const request = requestOf(source)
3760
+ this.wsHookedPasses?.delete(request)
3761
+ this.responseRequests.set(request, hooked)
3762
+ return this.routeAndRun(hooked, resolved, finalize, wrapResponse, onTimeout, webFast)
3763
+ }
3535
3764
  if (!webFast && this.canUseNodeRequestHooks()) {
3536
3765
  return this.runWithNodeRequest(source, resolved, finalize, wrapResponse, onTimeout)
3537
3766
  }
@@ -3543,7 +3772,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3543
3772
  private runWithNodeRequest<T>(
3544
3773
  source: RequestSource,
3545
3774
  platform: Platform<EnvOf<Ctx>> | undefined,
3546
- finalize: (result: unknown, set: CtxSet) => T,
3775
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
3547
3776
  wrapResponse: (response: Response | ResponseResult) => T,
3548
3777
  onTimeout: () => T,
3549
3778
  ): MaybePromise<T> {
@@ -3577,7 +3806,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3577
3806
  request: NodeRequestContext,
3578
3807
  source: RequestSource,
3579
3808
  platform: Platform<EnvOf<Ctx>> | undefined,
3580
- finalize: (result: unknown, set: CtxSet) => T,
3809
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
3581
3810
  wrapResponse: (response: Response | ResponseResult) => T,
3582
3811
  onTimeout: () => T,
3583
3812
  ): Promise<T> {
@@ -3605,7 +3834,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3605
3834
  private runWithOnRequest<T>(
3606
3835
  source: RequestSource,
3607
3836
  platform: Platform<EnvOf<Ctx>> | undefined,
3608
- finalize: (result: unknown, set: CtxSet) => T,
3837
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
3609
3838
  wrapResponse: (response: Response | ResponseResult) => T,
3610
3839
  onTimeout: () => T,
3611
3840
  webFast: boolean,
@@ -3654,7 +3883,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3654
3883
  originalRequest: Request,
3655
3884
  sourceAtAwait: RequestSource,
3656
3885
  platform: Platform<EnvOf<Ctx>> | undefined,
3657
- finalize: (result: unknown, set: CtxSet) => T,
3886
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
3658
3887
  wrapResponse: (response: Response | ResponseResult) => T,
3659
3888
  onTimeout: () => T,
3660
3889
  webFast: boolean,
@@ -3751,7 +3980,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3751
3980
  private routeAndRun<T>(
3752
3981
  source: RequestSource,
3753
3982
  platform: Platform<EnvOf<Ctx>> | undefined,
3754
- finalize: (result: unknown, set: CtxSet) => T,
3983
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
3755
3984
  wrapResponse: (response: Response | ResponseResult) => T,
3756
3985
  onTimeout: () => T,
3757
3986
  webFast: boolean,
@@ -3881,7 +4110,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3881
4110
  }
3882
4111
  }
3883
4112
  if (mount === undefined) return fallback ?? jsonError(404, "not_found")
3884
- const childRequest = mount.stripPrefix ? stripMountPrefix(request, mount.path) : request
4113
+ const childRequest = mount.stripPrefix
4114
+ ? (mount.wsPassed?.get(request) ?? stripMountPrefix(request, mount.path))
4115
+ : request
3885
4116
  const outcome = mount.handler(childRequest, platform)
3886
4117
  if (!replayable || !mount.fallbackOn404) return outcome
3887
4118
  if (outcome instanceof Promise) {
@@ -3901,7 +4132,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3901
4132
  entry: RouteEntry,
3902
4133
  params: Record<string, string>,
3903
4134
  search: string | undefined,
3904
- finalize: (result: unknown, set: CtxSet) => T,
4135
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
3905
4136
  wrapResponse: (response: Response | ResponseResult) => T,
3906
4137
  onTimeout: () => T,
3907
4138
  webFast: boolean,
@@ -3939,7 +4170,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3939
4170
  {
3940
4171
  maxBodyBytes: entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3941
4172
  runLanes: (buffered, plat, ent, prm, srch) =>
3942
- this.idempotencyRunLanes(buffered, plat, ent as RouteEntry, prm, srch),
4173
+ this.runMatchedLanes(
4174
+ buffered,
4175
+ plat,
4176
+ ent as RouteEntry,
4177
+ prm,
4178
+ srch,
4179
+ this.finalizeResponse,
4180
+ this.wrapWebResponse,
4181
+ this.webResponseTimeout,
4182
+ false,
4183
+ ),
3943
4184
  },
3944
4185
  )
3945
4186
  }
@@ -3963,7 +4204,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3963
4204
  entry: RouteEntry,
3964
4205
  params: Record<string, string>,
3965
4206
  search: string | undefined,
3966
- finalize: (result: unknown, set: CtxSet) => T,
4207
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
3967
4208
  wrapResponse: (response: Response | ResponseResult) => T,
3968
4209
  onTimeout: () => T,
3969
4210
  webFast: boolean,
@@ -4004,32 +4245,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4004
4245
  : createRequestBudget({ deadline: admission!.deadline as number, signal })
4005
4246
  const plan = entry.execution
4006
4247
  const nativeContext = controller === undefined
4007
- const outcome: MaybePromise<T> =
4008
- webFast && plan.fusedWeb !== undefined
4009
- ? (plan.fusedWeb(
4010
- source,
4011
- params,
4012
- search,
4013
- signal,
4014
- budget,
4015
- platform,
4016
- nativeContext,
4017
- ) as MaybePromise<T>)
4018
- : !webFast && plan.fusedBody !== undefined
4019
- ? plan.fusedBody(
4020
- source,
4021
- params,
4022
- search,
4023
- signal,
4024
- budget,
4025
- platform,
4026
- false,
4027
- finalize,
4028
- wrapResponse,
4029
- )
4030
- : plan.run(
4031
- this,
4032
- entry,
4248
+ let outcome: MaybePromise<T>
4249
+ try {
4250
+ outcome =
4251
+ webFast && plan.fusedWeb !== undefined
4252
+ ? (plan.fusedWeb(
4033
4253
  source,
4034
4254
  params,
4035
4255
  search,
@@ -4037,9 +4257,46 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4037
4257
  budget,
4038
4258
  platform,
4039
4259
  nativeContext,
4040
- finalize,
4041
- wrapResponse,
4042
- )
4260
+ ) as MaybePromise<T>)
4261
+ : !webFast && plan.fusedBody !== undefined
4262
+ ? plan.fusedBody(
4263
+ source,
4264
+ params,
4265
+ search,
4266
+ signal,
4267
+ budget,
4268
+ platform,
4269
+ false,
4270
+ finalize,
4271
+ wrapResponse,
4272
+ )
4273
+ : plan.run(
4274
+ this,
4275
+ entry,
4276
+ source,
4277
+ params,
4278
+ search,
4279
+ signal,
4280
+ budget,
4281
+ platform,
4282
+ nativeContext,
4283
+ finalize,
4284
+ wrapResponse,
4285
+ )
4286
+ } catch (err) {
4287
+ // A lane's synchronous render throw (a BigInt, a header value with a line break): the lanes
4288
+ // guard only their async continuations, since a `try` around a fused render slows it.
4289
+ return this.contextlessBareError(
4290
+ err,
4291
+ source,
4292
+ params,
4293
+ search,
4294
+ signal,
4295
+ budget,
4296
+ platform,
4297
+ wrapResponse,
4298
+ )
4299
+ }
4043
4300
  // The request timeout only bounds work that is actually pending - a synchronous (bare) result is
4044
4301
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
4045
4302
  if (controller !== undefined && outcome instanceof Promise) {
@@ -4074,7 +4331,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4074
4331
  signal: AbortSignal,
4075
4332
  budget: RequestBudget,
4076
4333
  platform: Platform | undefined,
4077
- finalize: (result: unknown, set: CtxSet) => T,
4334
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
4078
4335
  wrapResponse: (response: Response | ResponseResult) => T,
4079
4336
  ): MaybePromise<T> {
4080
4337
  let result: unknown
@@ -4094,7 +4351,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4094
4351
  }
4095
4352
  if (result instanceof Promise) {
4096
4353
  return result.then(
4097
- (value) => finalize(value, EMPTY_RESPONSE_CONTROLS),
4354
+ (value) => {
4355
+ try {
4356
+ return finalize(value, EMPTY_RESPONSE_CONTROLS)
4357
+ } catch (err) {
4358
+ return this.contextlessBareError(
4359
+ err,
4360
+ source,
4361
+ params,
4362
+ search,
4363
+ signal,
4364
+ budget,
4365
+ platform,
4366
+ wrapResponse,
4367
+ )
4368
+ }
4369
+ },
4098
4370
  (err) =>
4099
4371
  this.contextlessBareError(
4100
4372
  err,
@@ -4151,7 +4423,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4151
4423
  private runBare<T>(
4152
4424
  entry: RouteEntry,
4153
4425
  ctx: RawContext,
4154
- finalize: (result: unknown, set: CtxSet) => T,
4426
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
4155
4427
  wrapResponse: (response: Response | ResponseResult) => T,
4156
4428
  ): MaybePromise<T> {
4157
4429
  let result: unknown
@@ -4164,11 +4436,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4164
4436
  if (result instanceof Promise) {
4165
4437
  // Async handler on an otherwise-bare route: finish on a microtask, with the same error handling.
4166
4438
  return result.then(
4167
- (value) => finalize(value, responseSet(ctx)),
4439
+ (value) => {
4440
+ // A synchronous finalize throw reaches runMatchedLanes; this one would reject.
4441
+ try {
4442
+ return finalize(value, responseSet(ctx), ctx)
4443
+ } catch (err) {
4444
+ return this.bareError(err, ctx, finalize, wrapResponse)
4445
+ }
4446
+ },
4168
4447
  (err) => this.bareError(err, ctx, finalize, wrapResponse),
4169
4448
  )
4170
4449
  }
4171
- return finalize(result, responseSet(ctx))
4450
+ return finalize(result, responseSet(ctx), ctx)
4172
4451
  }
4173
4452
 
4174
4453
  /** Bare-route error rendering - identical to the compiled route program's catch minus the (absent) onError
@@ -4200,66 +4479,62 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4200
4479
  // `() => ...` can't observe the context - skip allocating one entirely (errors still build
4201
4480
  // one for the structured log, exactly like runContextlessBare).
4202
4481
  const contextlessHandler = handler as unknown as ContextlessHandler
4482
+ const fail = (
4483
+ err: unknown,
4484
+ source: RequestSource,
4485
+ params: Record<string, string>,
4486
+ search: string | undefined,
4487
+ signal: AbortSignal,
4488
+ budget: RequestBudget,
4489
+ platform: Platform | undefined,
4490
+ nativeContext: boolean,
4491
+ ): Response =>
4492
+ logError(
4493
+ err,
4494
+ nativeContext
4495
+ ? RequestContext.native(
4496
+ source,
4497
+ params,
4498
+ search,
4499
+ maxBodyBytes,
4500
+ platform,
4501
+ this.protoPoisoning,
4502
+ )
4503
+ : new RequestContext(
4504
+ source,
4505
+ params,
4506
+ search,
4507
+ signal,
4508
+ budget,
4509
+ platform,
4510
+ maxBodyBytes,
4511
+ this.protoPoisoning,
4512
+ ),
4513
+ )
4203
4514
  return (source, params, search, signal, budget, platform, nativeContext) => {
4204
4515
  let result: unknown
4205
4516
  try {
4206
4517
  result = contextlessHandler()
4207
4518
  } catch (err) {
4208
- return logError(
4209
- err,
4210
- nativeContext
4211
- ? RequestContext.native(
4212
- source,
4213
- params,
4214
- search,
4215
- maxBodyBytes,
4216
- platform,
4217
- this.protoPoisoning,
4218
- )
4219
- : new RequestContext(
4220
- source,
4221
- params,
4222
- search,
4223
- signal,
4224
- budget,
4225
- platform,
4226
- maxBodyBytes,
4227
- this.protoPoisoning,
4228
- ),
4229
- )
4519
+ return fail(err, source, params, search, signal, budget, platform, nativeContext)
4230
4520
  }
4231
4521
  if (result instanceof Promise) {
4232
4522
  return result.then(
4233
- (value) =>
4234
- fusedRespondNoSet(
4235
- value,
4236
- this.responseBodyTag,
4237
- this.staticResponseHeaders,
4238
- this.onResponseHooks.length === 0,
4239
- ),
4240
- (err) =>
4241
- logError(
4242
- err,
4243
- nativeContext
4244
- ? RequestContext.native(
4245
- source,
4246
- params,
4247
- search,
4248
- maxBodyBytes,
4249
- platform,
4250
- this.protoPoisoning,
4251
- )
4252
- : new RequestContext(
4253
- source,
4254
- params,
4255
- search,
4256
- signal,
4257
- budget,
4258
- platform,
4259
- maxBodyBytes,
4260
- this.protoPoisoning,
4261
- ),
4262
- ),
4523
+ (value) => {
4524
+ // A synchronous render throw reaches runMatchedLanes; this one would reject.
4525
+ try {
4526
+ return fusedRespondNoSet(
4527
+ value,
4528
+ this.responseBodyTag,
4529
+ this.staticResponseHeaders,
4530
+ this.onResponseHooks.length === 0,
4531
+ this.onResponseHooks.length === 0 ? undefined : requestOf(source),
4532
+ )
4533
+ } catch (err) {
4534
+ return fail(err, source, params, search, signal, budget, platform, nativeContext)
4535
+ }
4536
+ },
4537
+ (err) => fail(err, source, params, search, signal, budget, platform, nativeContext),
4263
4538
  )
4264
4539
  }
4265
4540
  return fusedRespondNoSet(
@@ -4267,6 +4542,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4267
4542
  this.responseBodyTag,
4268
4543
  this.staticResponseHeaders,
4269
4544
  this.onResponseHooks.length === 0,
4545
+ this.onResponseHooks.length === 0 ? undefined : requestOf(source),
4270
4546
  )
4271
4547
  }
4272
4548
  }
@@ -4292,14 +4568,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4292
4568
  }
4293
4569
  if (result instanceof Promise) {
4294
4570
  return result.then(
4295
- (value) =>
4296
- fusedRespond(
4297
- value,
4298
- ctx,
4299
- this.responseBodyTag,
4300
- this.staticResponseHeaders,
4301
- this.onResponseHooks.length === 0,
4302
- ),
4571
+ (value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0),
4303
4572
  (err) => logError(err, ctx),
4304
4573
  )
4305
4574
  }
@@ -4313,6 +4582,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4313
4582
  }
4314
4583
  }
4315
4584
 
4585
+ /** A fused Web lane's render in an async continuation, where a result that cannot be rendered
4586
+ * must fail like a handler throw; a synchronous render throw reaches {@link runMatchedLanes}. */
4587
+ private respondFused(result: unknown, ctx: RawContext, unstamped?: boolean): Response {
4588
+ try {
4589
+ return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, unstamped)
4590
+ } catch (err) {
4591
+ return this.bareError(
4592
+ err,
4593
+ ctx,
4594
+ (value, set) => this.wrapWebResponse(toResponse(value, set)),
4595
+ this.wrapWebResponse,
4596
+ )
4597
+ }
4598
+ }
4599
+
4316
4600
  /** Compile the eligible body-only route's parser → validator → handler continuation once. The
4317
4601
  * bounded parser remains shared with the generic lane; only the route-invariant entry lookups and
4318
4602
  * lifecycle dispatch disappear from the common synchronous-validator/synchronous-handler case. */
@@ -4435,6 +4719,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4435
4719
  wrapResponse,
4436
4720
  (err) => logError(err, ctx, finalize, wrapResponse),
4437
4721
  bodySchema,
4722
+ this.formOrigins,
4438
4723
  )
4439
4724
  }
4440
4725
  }
@@ -4496,24 +4781,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4496
4781
  }
4497
4782
  if (result instanceof Promise) {
4498
4783
  return result.then(
4499
- (settled) =>
4500
- fusedRespond(
4501
- settled,
4502
- ctx,
4503
- this.responseBodyTag,
4504
- this.staticResponseHeaders,
4505
- this.onResponseHooks.length === 0,
4506
- ),
4784
+ (settled) => this.respondFused(settled, ctx, this.onResponseHooks.length === 0),
4507
4785
  (err) => logError(err, ctx),
4508
4786
  )
4509
4787
  }
4510
- return fusedRespond(
4511
- result,
4512
- ctx,
4513
- this.responseBodyTag,
4514
- this.staticResponseHeaders,
4515
- this.onResponseHooks.length === 0,
4516
- )
4788
+ return this.respondFused(result, ctx, this.onResponseHooks.length === 0)
4517
4789
  }
4518
4790
  return (source, params, search, signal, budget, platform, nativeContext) => {
4519
4791
  const ctx = nativeContext
@@ -4572,6 +4844,25 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4572
4844
  before: RawBeforeHandle,
4573
4845
  querySchema: StandardSchemaV1 | undefined,
4574
4846
  maxBodyBytes: number,
4847
+ ): FusedWebRunner {
4848
+ return this.buildFusedDeriveBeforeAfter(
4849
+ handler,
4850
+ derive,
4851
+ before,
4852
+ undefined,
4853
+ querySchema,
4854
+ maxBodyBytes,
4855
+ )
4856
+ }
4857
+
4858
+ // Both hook variants share one registered chain; request contexts stay in arguments.
4859
+ private buildFusedDeriveBeforeAfter(
4860
+ handler: InternalHandler,
4861
+ derive: RawDerive,
4862
+ before: RawBeforeHandle,
4863
+ after: RawAfterHandle | undefined,
4864
+ querySchema: StandardSchemaV1 | undefined,
4865
+ maxBodyBytes: number,
4575
4866
  ): FusedWebRunner {
4576
4867
  const logError = (err: unknown, ctx: RawContext): Response =>
4577
4868
  this.bareError(
@@ -4580,139 +4871,83 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4580
4871
  (result, set) => this.wrapWebResponse(toResponse(result, set)),
4581
4872
  this.wrapWebResponse,
4582
4873
  )
4583
- return (source, params, search, signal, budget, platform, nativeContext) => {
4584
- const ctx = nativeContext
4585
- ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
4586
- : new RequestContext(
4587
- source,
4588
- params,
4589
- search,
4590
- signal,
4591
- budget,
4592
- platform,
4593
- maxBodyBytes,
4594
- this.protoPoisoning,
4595
- )
4596
- const runHandler = (): MaybePromise<Response> => {
4597
- let result: unknown
4598
- try {
4599
- result = handler(ctx)
4600
- } catch (err) {
4601
- return logError(err, ctx)
4602
- }
4603
- if (result instanceof Promise) {
4604
- return result.then(
4605
- (value) =>
4606
- fusedRespond(
4607
- value,
4608
- ctx,
4609
- this.responseBodyTag,
4610
- this.staticResponseHeaders,
4611
- this.onResponseHooks.length === 0,
4612
- ),
4613
- (err) => logError(err, ctx),
4614
- )
4615
- }
4616
- return fusedRespond(
4617
- result,
4618
- ctx,
4619
- this.responseBodyTag,
4620
- this.staticResponseHeaders,
4621
- this.onResponseHooks.length === 0,
4874
+ // Continuations capture route state once; each request supplies its own context.
4875
+ const runAfter = (result: unknown, ctx: RawContext): MaybePromise<Response> => {
4876
+ if (after === undefined)
4877
+ return this.respondFused(result, ctx, this.onResponseHooks.length === 0)
4878
+ let transformed: unknown
4879
+ try {
4880
+ transformed = after(result, ctx)
4881
+ } catch (err) {
4882
+ return logError(err, ctx)
4883
+ }
4884
+ if (transformed instanceof Promise) {
4885
+ return transformed.then(
4886
+ (value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0),
4887
+ (err) => logError(err, ctx),
4622
4888
  )
4623
4889
  }
4624
- const runBefore = (): MaybePromise<Response> => {
4625
- let early: unknown
4626
- try {
4627
- early = before(ctx)
4628
- } catch (err) {
4629
- return logError(err, ctx)
4630
- }
4631
- if (early instanceof Promise) {
4632
- return early.then(
4633
- (settled) => {
4634
- if (settled === undefined) return runHandler()
4635
- return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders)
4636
- },
4637
- (err) => logError(err, ctx),
4638
- )
4639
- }
4640
- if (early === undefined) return runHandler()
4641
- return fusedRespond(early, ctx, this.responseBodyTag, this.staticResponseHeaders)
4890
+ return this.respondFused(transformed, ctx, this.onResponseHooks.length === 0)
4891
+ }
4892
+ const runHandler = (ctx: RawContext): MaybePromise<Response> => {
4893
+ let result: unknown
4894
+ try {
4895
+ result = handler(ctx)
4896
+ } catch (err) {
4897
+ return logError(err, ctx)
4642
4898
  }
4643
- const runDerive = (): MaybePromise<Response> => {
4644
- let derived: unknown
4645
- try {
4646
- derived = derive(ctx)
4647
- } catch (err) {
4648
- return logError(err, ctx)
4649
- }
4650
- if (derived instanceof Promise) {
4651
- return derived.then(
4652
- (settled) => {
4653
- if (isResponseResult(settled) || settled instanceof Response) {
4654
- return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders)
4655
- }
4656
- Object.assign(ctx, settled as object)
4657
- return runBefore()
4658
- },
4659
- (err) => logError(err, ctx),
4660
- )
4661
- }
4662
- if (isResponseResult(derived) || derived instanceof Response) {
4663
- return fusedRespond(derived, ctx, this.responseBodyTag, this.staticResponseHeaders)
4664
- }
4665
- Object.assign(ctx, derived as object)
4666
- return runBefore()
4899
+ if (result instanceof Promise) {
4900
+ return result.then(
4901
+ (value) => runAfter(value, ctx),
4902
+ (err) => logError(err, ctx),
4903
+ )
4667
4904
  }
4668
- if (querySchema === undefined) return runDerive()
4669
- let validation: MaybePromise<StandardResult<unknown>>
4905
+ return runAfter(result, ctx)
4906
+ }
4907
+ const runBefore = (ctx: RawContext): MaybePromise<Response> => {
4908
+ let early: unknown
4670
4909
  try {
4671
- validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
4910
+ early = before(ctx)
4672
4911
  } catch (err) {
4673
4912
  return logError(err, ctx)
4674
4913
  }
4675
- if (validation instanceof Promise) {
4676
- return validation.then(
4914
+ if (early instanceof Promise) {
4915
+ return early.then(
4677
4916
  (settled) => {
4678
- if (settled.issues !== undefined)
4679
- return this.wrapWebResponse(plainValidationError(settled.issues))
4680
- ctx.query = settled.value
4681
- return runDerive()
4917
+ if (settled === undefined) return runHandler(ctx)
4918
+ return this.respondFused(settled, ctx)
4682
4919
  },
4683
4920
  (err) => logError(err, ctx),
4684
4921
  )
4685
4922
  }
4686
- if (validation.issues !== undefined)
4687
- return this.wrapWebResponse(plainValidationError(validation.issues))
4688
- ctx.query = validation.value
4689
- return runDerive()
4923
+ if (early === undefined) return runHandler(ctx)
4924
+ return this.respondFused(early, ctx)
4925
+ }
4926
+ const runDerive = (ctx: RawContext): MaybePromise<Response> => {
4927
+ let derived: unknown
4928
+ try {
4929
+ derived = derive(ctx)
4930
+ } catch (err) {
4931
+ return logError(err, ctx)
4932
+ }
4933
+ if (derived instanceof Promise) {
4934
+ return derived.then(
4935
+ (settled) => {
4936
+ if (isResponseResult(settled) || settled instanceof Response) {
4937
+ return this.respondFused(settled, ctx)
4938
+ }
4939
+ Object.assign(ctx, settled as object)
4940
+ return runBefore(ctx)
4941
+ },
4942
+ (err) => logError(err, ctx),
4943
+ )
4944
+ }
4945
+ if (isResponseResult(derived) || derived instanceof Response) {
4946
+ return this.respondFused(derived, ctx)
4947
+ }
4948
+ Object.assign(ctx, derived as object)
4949
+ return runBefore(ctx)
4690
4950
  }
4691
- }
4692
-
4693
- /**
4694
- * Same shape as `buildFusedDeriveBefore`, plus one `afterHandle` step. The closure runs (validate) →
4695
- * derive → before → handler → after in one frame, the shape the realistic middleware route lands on
4696
- * when it also wants a response transform (e.g. attaching a request id to a successful payload's
4697
- * headers). `LifecycleExecutionLane` and `selectRouteLanes` guarantee `afterHandle === 1`, the
4698
- * throw contract is the same as the generic program, and the `after` step is the final transform +
4699
- * respond the generic program already does.
4700
- */
4701
- private buildFusedDeriveBeforeAfter(
4702
- handler: InternalHandler,
4703
- derive: RawDerive,
4704
- before: RawBeforeHandle,
4705
- after: RawAfterHandle,
4706
- querySchema: StandardSchemaV1 | undefined,
4707
- maxBodyBytes: number,
4708
- ): FusedWebRunner {
4709
- const logError = (err: unknown, ctx: RawContext): Response =>
4710
- this.bareError(
4711
- err,
4712
- ctx,
4713
- (result, set) => this.wrapWebResponse(toResponse(result, set)),
4714
- this.wrapWebResponse,
4715
- )
4716
4951
  return (source, params, search, signal, budget, platform, nativeContext) => {
4717
4952
  const ctx = nativeContext
4718
4953
  ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
@@ -4726,94 +4961,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4726
4961
  maxBodyBytes,
4727
4962
  this.protoPoisoning,
4728
4963
  )
4729
- const runAfter = (result: unknown): MaybePromise<Response> => {
4730
- let transformed: unknown
4731
- try {
4732
- transformed = after(result, ctx)
4733
- } catch (err) {
4734
- return logError(err, ctx)
4735
- }
4736
- if (transformed instanceof Promise) {
4737
- return transformed.then(
4738
- (value) =>
4739
- fusedRespond(
4740
- value,
4741
- ctx,
4742
- this.responseBodyTag,
4743
- this.staticResponseHeaders,
4744
- this.onResponseHooks.length === 0,
4745
- ),
4746
- (err) => logError(err, ctx),
4747
- )
4748
- }
4749
- return fusedRespond(
4750
- transformed,
4751
- ctx,
4752
- this.responseBodyTag,
4753
- this.staticResponseHeaders,
4754
- this.onResponseHooks.length === 0,
4755
- )
4756
- }
4757
- const runHandler = (): MaybePromise<Response> => {
4758
- let result: unknown
4759
- try {
4760
- result = handler(ctx)
4761
- } catch (err) {
4762
- return logError(err, ctx)
4763
- }
4764
- if (result instanceof Promise) {
4765
- return result.then(
4766
- (value) => runAfter(value),
4767
- (err) => logError(err, ctx),
4768
- )
4769
- }
4770
- return runAfter(result)
4771
- }
4772
- const runBefore = (): MaybePromise<Response> => {
4773
- let early: unknown
4774
- try {
4775
- early = before(ctx)
4776
- } catch (err) {
4777
- return logError(err, ctx)
4778
- }
4779
- if (early instanceof Promise) {
4780
- return early.then(
4781
- (settled) => {
4782
- if (settled === undefined) return runHandler()
4783
- return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders)
4784
- },
4785
- (err) => logError(err, ctx),
4786
- )
4787
- }
4788
- if (early === undefined) return runHandler()
4789
- return fusedRespond(early, ctx, this.responseBodyTag, this.staticResponseHeaders)
4790
- }
4791
- const runDerive = (): MaybePromise<Response> => {
4792
- let derived: unknown
4793
- try {
4794
- derived = derive(ctx)
4795
- } catch (err) {
4796
- return logError(err, ctx)
4797
- }
4798
- if (derived instanceof Promise) {
4799
- return derived.then(
4800
- (settled) => {
4801
- if (isResponseResult(settled) || settled instanceof Response) {
4802
- return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders)
4803
- }
4804
- Object.assign(ctx, settled as object)
4805
- return runBefore()
4806
- },
4807
- (err) => logError(err, ctx),
4808
- )
4809
- }
4810
- if (isResponseResult(derived) || derived instanceof Response) {
4811
- return fusedRespond(derived, ctx, this.responseBodyTag, this.staticResponseHeaders)
4812
- }
4813
- Object.assign(ctx, derived as object)
4814
- return runBefore()
4815
- }
4816
- if (querySchema === undefined) return runDerive()
4964
+ if (querySchema === undefined) return runDerive(ctx)
4817
4965
  let validation: MaybePromise<StandardResult<unknown>>
4818
4966
  try {
4819
4967
  validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
@@ -4826,7 +4974,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4826
4974
  if (settled.issues !== undefined)
4827
4975
  return this.wrapWebResponse(plainValidationError(settled.issues))
4828
4976
  ctx.query = settled.value
4829
- return runDerive()
4977
+ return runDerive(ctx)
4830
4978
  },
4831
4979
  (err) => logError(err, ctx),
4832
4980
  )
@@ -4834,7 +4982,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4834
4982
  if (validation.issues !== undefined)
4835
4983
  return this.wrapWebResponse(plainValidationError(validation.issues))
4836
4984
  ctx.query = validation.value
4837
- return runDerive()
4985
+ return runDerive(ctx)
4838
4986
  }
4839
4987
  }
4840
4988
 
@@ -4881,13 +5029,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4881
5029
  const runHandlerValidated = (): MaybePromise<Response> => {
4882
5030
  const runAfter = (result: unknown): MaybePromise<Response> => {
4883
5031
  if (after === undefined) {
4884
- return fusedRespond(
4885
- result,
4886
- ctx,
4887
- this.responseBodyTag,
4888
- this.staticResponseHeaders,
4889
- this.onResponseHooks.length === 0,
4890
- )
5032
+ return this.respondFused(result, ctx, this.onResponseHooks.length === 0)
4891
5033
  }
4892
5034
  let transformed: unknown
4893
5035
  try {
@@ -4897,24 +5039,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4897
5039
  }
4898
5040
  if (transformed instanceof Promise) {
4899
5041
  return transformed.then(
4900
- (value) =>
4901
- fusedRespond(
4902
- value,
4903
- ctx,
4904
- this.responseBodyTag,
4905
- this.staticResponseHeaders,
4906
- this.onResponseHooks.length === 0,
4907
- ),
5042
+ (value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0),
4908
5043
  (err) => logError(err, ctx),
4909
5044
  )
4910
5045
  }
4911
- return fusedRespond(
4912
- transformed,
4913
- ctx,
4914
- this.responseBodyTag,
4915
- this.staticResponseHeaders,
4916
- this.onResponseHooks.length === 0,
4917
- )
5046
+ return this.respondFused(transformed, ctx, this.onResponseHooks.length === 0)
4918
5047
  }
4919
5048
  const runHandler = (): MaybePromise<Response> => {
4920
5049
  let result: unknown
@@ -4942,13 +5071,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4942
5071
  return early.then(
4943
5072
  (settled) => {
4944
5073
  if (settled === undefined) return runHandler()
4945
- return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders)
5074
+ return this.respondFused(settled, ctx)
4946
5075
  },
4947
5076
  (err) => logError(err, ctx),
4948
5077
  )
4949
5078
  }
4950
5079
  if (early === undefined) return runHandler()
4951
- return fusedRespond(early, ctx, this.responseBodyTag, this.staticResponseHeaders)
5080
+ return this.respondFused(early, ctx)
4952
5081
  }
4953
5082
  let derived: unknown
4954
5083
  try {
@@ -4960,7 +5089,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4960
5089
  return derived.then(
4961
5090
  (settled) => {
4962
5091
  if (isResponseResult(settled) || settled instanceof Response) {
4963
- return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders)
5092
+ return this.respondFused(settled, ctx)
4964
5093
  }
4965
5094
  Object.assign(ctx, settled as object)
4966
5095
  return runBefore()
@@ -4969,7 +5098,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4969
5098
  )
4970
5099
  }
4971
5100
  if (isResponseResult(derived) || derived instanceof Response) {
4972
- return fusedRespond(derived, ctx, this.responseBodyTag, this.staticResponseHeaders)
5101
+ return this.respondFused(derived, ctx)
4973
5102
  }
4974
5103
  Object.assign(ctx, derived as object)
4975
5104
  return runBefore()
@@ -5005,6 +5134,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5005
5134
  (response) => this.wrapWebResponse(response),
5006
5135
  (err) => Promise.resolve(logError(err, ctx)),
5007
5136
  bodySchema,
5137
+ this.formOrigins,
5008
5138
  ) as MaybePromise<Response>
5009
5139
  }
5010
5140
  }
@@ -5436,6 +5566,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5436
5566
  wrapResponse,
5437
5567
  (err) => logError(err, ctx, finalize, wrapResponse),
5438
5568
  bodySchema,
5569
+ this.formOrigins,
5439
5570
  )
5440
5571
  }
5441
5572
  }
@@ -5472,7 +5603,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5472
5603
  entry: RouteEntry,
5473
5604
  ctx: RawContext,
5474
5605
  run: () => MaybePromise<T>,
5475
- finalize: (result: unknown, set: CtxSet) => T,
5606
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5476
5607
  wrapResponse: (response: Response | ResponseResult) => T,
5477
5608
  ): MaybePromise<T> {
5478
5609
  let outcome: MaybePromise<T>
@@ -5492,7 +5623,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5492
5623
  entry: RouteEntry,
5493
5624
  source: RequestSource,
5494
5625
  ctx: RawContext,
5495
- finalize: (result: unknown, set: CtxSet) => T,
5626
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5496
5627
  wrapResponse: (response: Response | ResponseResult) => T,
5497
5628
  ): MaybePromise<T> {
5498
5629
  return executeRouteProgram(this, entry, entry.program, source, ctx, finalize, wrapResponse)
@@ -5536,7 +5667,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5536
5667
  entry: RouteEntry,
5537
5668
  error: unknown,
5538
5669
  ctx: RawContext,
5539
- finalize: (result: unknown, set: CtxSet) => T,
5670
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5540
5671
  wrapResponse: (response: Response | ResponseResult) => T,
5541
5672
  ): MaybePromise<T> {
5542
5673
  return this.handleLifecycleError(entry, error, ctx, finalize, wrapResponse)
@@ -5548,7 +5679,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5548
5679
  entry: RouteEntry,
5549
5680
  ctx: RawContext,
5550
5681
  result: unknown,
5551
- finalize: (result: unknown, set: CtxSet) => T,
5682
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5552
5683
  wrapResponse: (response: Response | ResponseResult) => T,
5553
5684
  ): MaybePromise<T> {
5554
5685
  return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, result)
@@ -5577,7 +5708,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5577
5708
  entry: RouteEntry,
5578
5709
  source: RequestSource,
5579
5710
  ctx: RawContext,
5580
- finalize: (result: unknown, set: CtxSet) => T,
5711
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5581
5712
  wrapResponse: (response: Response | ResponseResult) => T,
5582
5713
  ): Promise<T> {
5583
5714
  return readBodyFramed(
@@ -5598,7 +5729,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5598
5729
  entry: RouteEntry,
5599
5730
  parsed: unknown,
5600
5731
  ctx: RawContext,
5601
- finalize: (result: unknown, set: CtxSet) => T,
5732
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5602
5733
  wrapResponse: (response: Response | ResponseResult) => T,
5603
5734
  ): MaybePromise<T> {
5604
5735
  try {
@@ -5633,14 +5764,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5633
5764
  private executeHandler<T>(
5634
5765
  entry: RouteEntry,
5635
5766
  ctx: RawContext,
5636
- finalize: (result: unknown, set: CtxSet) => T,
5767
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5637
5768
  wrapResponse?: (response: Response | ResponseResult) => T,
5638
5769
  ): MaybePromise<T> {
5639
5770
  if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
5640
5771
  const handlerOutput = entry.handler(ctx)
5641
5772
  if (handlerOutput instanceof Promise) {
5642
5773
  return handlerOutput.then(
5643
- (value) => finalize(value, responseSet(ctx)),
5774
+ (value) => finalize(value, responseSet(ctx), ctx),
5644
5775
  (err) => {
5645
5776
  if (wrapResponse) {
5646
5777
  return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
@@ -5649,14 +5780,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5649
5780
  },
5650
5781
  )
5651
5782
  }
5652
- return finalize(handlerOutput, responseSet(ctx))
5783
+ return finalize(handlerOutput, responseSet(ctx), ctx)
5653
5784
  }
5654
5785
 
5655
5786
  private handleValidationErrorRecovery<T>(
5656
5787
  entry: RouteEntry,
5657
5788
  recovery: unknown,
5658
5789
  ctx: RawContext,
5659
- finalize: (result: unknown, set: CtxSet) => T,
5790
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5660
5791
  wrapResponse: (response: Response | ResponseResult) => T,
5661
5792
  originalIssues: ReadonlyArray<StandardIssue>,
5662
5793
  kind: "body" | "query",
@@ -5706,7 +5837,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5706
5837
  entry: RouteEntry,
5707
5838
  result: StandardResult<unknown>,
5708
5839
  ctx: RawContext,
5709
- finalize: (result: unknown, set: CtxSet) => T,
5840
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5710
5841
  wrapResponse: (response: Response | ResponseResult) => T,
5711
5842
  ): MaybePromise<T> {
5712
5843
  if (result.issues !== undefined) {
@@ -5746,7 +5877,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5746
5877
  private runQueryOnly<T>(
5747
5878
  entry: RouteEntry,
5748
5879
  ctx: RawContext,
5749
- finalize: (result: unknown, set: CtxSet) => T,
5880
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5750
5881
  wrapResponse: (response: Response | ResponseResult) => T,
5751
5882
  ): MaybePromise<T> {
5752
5883
  try {
@@ -5784,7 +5915,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5784
5915
  entry: RouteEntry,
5785
5916
  result: StandardResult<unknown>,
5786
5917
  ctx: RawContext,
5787
- finalize: (result: unknown, set: CtxSet) => T,
5918
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5788
5919
  wrapResponse: (response: Response | ResponseResult) => T,
5789
5920
  ): MaybePromise<T> {
5790
5921
  if (result.issues !== undefined) {
@@ -5829,13 +5960,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5829
5960
  */
5830
5961
  private applyOnResponse(response: Response, req: Request): MaybePromise<Response> {
5831
5962
  const hooks = this.onResponseHooks
5832
- let current = response
5963
+ // Hooks write onto the response they are handed (cookies, CORS, request ids), so every response
5964
+ // entering the chain - and any other one a hook hands back - is first made this request's own.
5965
+ let current = writableResponse(response, req)
5833
5966
  for (let i = 0; i < hooks.length; i++) {
5834
5967
  const next = (hooks[i] as RawOnResponse)(current, req)
5835
5968
  if (next instanceof Promise) {
5836
- return next.then((settled) => this.continueOnResponse(settled, i + 1, req))
5969
+ const handed = current
5970
+ return next.then((settled) => this.continueOnResponse(settled, i + 1, req, handed))
5837
5971
  }
5838
- current = next
5972
+ current = next === current ? next : writableResponse(next, req)
5839
5973
  }
5840
5974
  return current
5841
5975
  }
@@ -5909,11 +6043,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5909
6043
  response: Response,
5910
6044
  nextIndex: number,
5911
6045
  req: Request,
6046
+ handed: Response,
5912
6047
  ): Promise<Response> {
5913
- let current = response
6048
+ let current = response === handed ? response : writableResponse(response, req)
5914
6049
  for (let i = nextIndex; i < this.onResponseHooks.length; i++) {
5915
6050
  const next = (this.onResponseHooks[i] as RawOnResponse)(current, req)
5916
- current = next instanceof Promise ? await next : next
6051
+ const settled = next instanceof Promise ? await next : next
6052
+ current = settled === current ? settled : writableResponse(settled, req)
5917
6053
  }
5918
6054
  return current
5919
6055
  }
@@ -5945,13 +6081,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5945
6081
  private finishLifecycleContract<T>(
5946
6082
  entry: RouteEntry,
5947
6083
  ctx: RawContext,
5948
- finalize: (result: unknown, set: CtxSet) => T,
6084
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5949
6085
  wrapResponse: (response: Response | ResponseResult) => T,
5950
6086
  result: unknown,
5951
6087
  ): MaybePromise<T> {
5952
6088
  try {
5953
6089
  const contract = entry.responseContract
5954
- if (contract === undefined) return finalize(result, responseSet(ctx))
6090
+ if (contract === undefined) return finalize(result, responseSet(ctx), ctx)
5955
6091
  const checked = contract.runtime.check(contract.definition, result, ctx)
5956
6092
  if (checked instanceof Promise) {
5957
6093
  return checked.then(
@@ -5967,7 +6103,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5967
6103
 
5968
6104
  private finishContractOutcome<T>(
5969
6105
  ctx: RawContext,
5970
- finalize: (result: unknown, set: CtxSet) => T,
6106
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5971
6107
  wrapResponse: (response: Response | ResponseResult) => T,
5972
6108
  outcome: {
5973
6109
  readonly kind: "ok" | "warn" | "violation"
@@ -5990,7 +6126,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5990
6126
  detail: outcome.message,
5991
6127
  })
5992
6128
  }
5993
- return finalize(outcome.value, responseSet(ctx))
6129
+ return finalize(outcome.value, responseSet(ctx), ctx)
5994
6130
  }
5995
6131
 
5996
6132
  /** Synchronous by design, and `MaybePromise` rather than `Promise` for the same reason: control flow
@@ -6002,7 +6138,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6002
6138
  entry: RouteEntry,
6003
6139
  err: unknown,
6004
6140
  ctx: RawContext,
6005
- finalize: (result: unknown, set: CtxSet) => T,
6141
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
6006
6142
  wrapResponse: (response: Response | ResponseResult) => T,
6007
6143
  ): MaybePromise<T> {
6008
6144
  // A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
@@ -6010,10 +6146,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6010
6146
  // SvelteKit semantics): as built, plus the cookies the request queued, so a session set or
6011
6147
  // cleared before `throw redirect(...)` / `requireSession(...)` ships. Don't run onError or log
6012
6148
  // it as a 500.
6013
- if (err instanceof Response) return finalize(err, responseSet(ctx))
6014
- // Same rule for a thrown `status(...)`, but through `finalize` rather than `wrapResponse`: the
6015
- // value is still plain data, so the ordinary JSON lane renders it and no `Response` is built.
6016
- if (isResponseResult(err))
6149
+ if (err instanceof Response && err !== ctx.jsonReply)
6150
+ return finalize(err, responseSet(ctx), ctx)
6151
+ // Same rule for a thrown `status(...)` or `c.json(...)`, which stay held to the route's contract.
6152
+ if (err instanceof Response || isResponseResult(err))
6017
6153
  return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, err)
6018
6154
  if (entry.onError.length === 0) {
6019
6155
  // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
@@ -6030,7 +6166,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6030
6166
  entry: RouteEntry,
6031
6167
  err: unknown,
6032
6168
  ctx: RawContext,
6033
- finalize: (result: unknown, set: CtxSet) => T,
6169
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
6034
6170
  wrapResponse: (response: Response | ResponseResult) => T,
6035
6171
  ): Promise<T> {
6036
6172
  // Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
@@ -6038,7 +6174,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6038
6174
  for (let i = 0; i < entry.onError.length; i++) {
6039
6175
  const outcome = entry.onError[i]!(err, ctx)
6040
6176
  const handled = outcome instanceof Promise ? await outcome : outcome
6041
- if (handled !== undefined) return finalize(handled, responseSet(ctx))
6177
+ if (handled === undefined) continue
6178
+ const contract = entry.responseContract
6179
+ if (contract === undefined || (handled !== ctx.jsonReply && !isResponseResult(handled)))
6180
+ return finalize(handled, responseSet(ctx), ctx)
6181
+ // Checked here, not through finishLifecycleContract: a throwing schema must not re-enter onError.
6182
+ try {
6183
+ const checked = await contract.runtime.check(contract.definition, handled, ctx)
6184
+ return this.finishContractOutcome(ctx, finalize, wrapResponse, checked)
6185
+ } catch (checkError) {
6186
+ this.logRequestError(checkError, ctx)
6187
+ return wrapResponse(plainError(500, "internal_error"))
6188
+ }
6042
6189
  }
6043
6190
  this.logRequestError(err, ctx)
6044
6191
  return wrapResponse(plainError(500, "internal_error"))
@@ -6075,6 +6222,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6075
6222
  (response) => response,
6076
6223
  (error) => Promise.reject(error),
6077
6224
  bodySchema,
6225
+ this.formOrigins,
6078
6226
  )
6079
6227
  }
6080
6228
 
@@ -6166,7 +6314,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6166
6314
  request: Request,
6167
6315
  params: Record<string, string>,
6168
6316
  ): MaybePromise<Response> => {
6169
- const outcome = capped!(request, params, undefined, signal!, budget!, peer, true)
6317
+ let outcome: MaybePromise<Response>
6318
+ try {
6319
+ outcome = capped!(request, params, undefined, signal!, budget!, peer, true)
6320
+ } catch (err) {
6321
+ // A synchronous render throw, as in runMatchedLanes.
6322
+ outcome = this.contextlessBareError(
6323
+ err,
6324
+ request,
6325
+ params,
6326
+ undefined,
6327
+ signal!,
6328
+ budget!,
6329
+ peer,
6330
+ this.wrapWebResponse,
6331
+ )
6332
+ }
6170
6333
  return outcome instanceof Promise
6171
6334
  ? outcome.then((response) => finishNative(request, response))
6172
6335
  : finishNative(request, outcome)
@@ -6183,7 +6346,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6183
6346
  markFramed(request)
6184
6347
  return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
6185
6348
  ? this.fetchMatched(request, entry, EMPTY_PARAMS, peer)
6186
- : capped(request, EMPTY_PARAMS, undefined, signal!, budget!, peer, true)
6349
+ : runNative(request, EMPTY_PARAMS)
6187
6350
  }
6188
6351
  }
6189
6352
  return (request) => {
@@ -6211,7 +6374,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6211
6374
  if (malformed(params)) return fallback(request, server)
6212
6375
  return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
6213
6376
  ? this.fetchMatched(request, entry, params, peer)
6214
- : capped(request, params, undefined, signal!, budget!, peer, true)
6377
+ : runNative(request, params)
6215
6378
  }
6216
6379
  }
6217
6380
  return (request, server) => {
@@ -6239,7 +6402,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6239
6402
  // resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
6240
6403
  if (
6241
6404
  (this.onRequestHooks.length > 0 && !this.bunNativeRequestHooksSafe) ||
6242
- this.wsRouteCount > 0 ||
6405
+ this.wsRoutes.length > 0 ||
6243
6406
  this.preRouteMountCount > 0 ||
6244
6407
  this.clientIpTrust !== undefined
6245
6408
  ) {
@@ -6396,7 +6559,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6396
6559
  }
6397
6560
  // Seal before Bun.serve: in `"error"` mode this throws instead of leaving a bound port behind.
6398
6561
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
6399
- sealHookAudit(this, this.catalog.size, this.logger)
6562
+ sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger)
6400
6563
  // Bun's `Server` is the concrete handle; we expose the stable `RunningServer`
6401
6564
  // subset so the public types don't depend on the ambient `Bun` global. The cast
6402
6565
  // bridges them - Bun's `.port` is `number | undefined` (undefined only for unix
@@ -6407,7 +6570,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6407
6570
  // `websocket` handlers are one shared dispatcher - each connection's `ws.data.handler` is the
6408
6571
  // matched route's handler, set by `server.upgrade`.
6409
6572
  // With WS routes, the dispatcher comes from the installed `.use(websocket())` runtime - non-null
6410
- // because wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
6573
+ // because a socket route means ws() ran, and ws() requires the runtime at registration.
6411
6574
  // Native pub/sub when the app has WS routes and none validate outbound frames: `ws.subscribe` and
6412
6575
  // `app.publish` go through Bun's own (uWebSockets) broadcast instead of the JS registry loop.
6413
6576
  // A mounted app counts only while it has a WebSocket route (see `[GET_WS_RUNTIME]`), so composing
@@ -6424,8 +6587,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6424
6587
  // that registry in the upgrade outcome instead of accidentally broadcasting child sockets through
6425
6588
  // the parent.
6426
6589
  const nativePubsub =
6427
- this.wsRouteCount > 0 && !this.mountsTakeUpgrades() && !this.wsHasValidatedSend
6428
- const wsHandlers = wsRuntime?.bunHandlers(this.topics ?? wsRuntime.createTopics(), nativePubsub)
6590
+ this.wsRoutes.length > 0 && !this.mountsTakeUpgrades() && !this.wsHasValidatedSend
6591
+ const wsHandlers = wsRuntime?.bunHandlers(
6592
+ this.topics ?? wsRuntime.createTopics(),
6593
+ nativePubsub,
6594
+ this,
6595
+ )
6429
6596
  // Spread rather than pass `hostname: undefined` (or an undefined idle timeout or TLS) - Bun treats
6430
6597
  // an explicit undefined as a value on some option paths, and omitting is what selects its own
6431
6598
  // defaults, 0.0.0.0 among them.
@@ -6473,8 +6640,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6473
6640
  // `unknown` params bridge a TS structural-variance quirk. Round-trip covered by websocket.test.ts.
6474
6641
  websocket: {
6475
6642
  // Cap inbound frames so a huge message can't be buffered/JSON-parsed into memory; the runtime
6476
- // closes an over-cap connection before the handler runs. Default 1 MB (maxBodyBytes).
6477
- maxPayloadLength: this.wsMaxPayloadBytes,
6643
+ // closes an over-cap connection before the handler runs. Default 1 MB (maxBodyBytes). One
6644
+ // limit per listener: the largest cap among this app and the apps mounted on it.
6645
+ maxPayloadLength: wsHandlers.maxPayloadLength,
6478
6646
  open: (ws) => wsHandlers.open(ws),
6479
6647
  message: (ws, message) => wsHandlers.message(ws, message),
6480
6648
  close: (ws, code, reason) => wsHandlers.close(ws, code, reason),
@@ -6492,7 +6660,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6492
6660
  }
6493
6661
  }
6494
6662
  this.sealed = true
6495
- if (this.gracefulSignals) this.installSignalHandlers()
6663
+ if (this.gracefulSignals) this.installSignalHandlers(this.gracefulSignals)
6496
6664
  return running
6497
6665
  }
6498
6666
 
@@ -6525,12 +6693,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6525
6693
  ])
6526
6694
  }
6527
6695
 
6528
- private installSignalHandlers(): void {
6696
+ private installSignalHandlers(drain: { readonly drainMs?: number }): void {
6529
6697
  // Drain, then let the process exit naturally - the stopped server no longer
6530
6698
  // holds the event loop open. Opt-in (`gracefulSignals`), so taking over the
6531
6699
  // signals is consented; we don't force `process.exit`.
6532
6700
  const onSignal = (): void => {
6533
- void this.stop()
6701
+ void this.stop(drain)
6534
6702
  }
6535
6703
  process.once("SIGTERM", onSignal)
6536
6704
  process.once("SIGINT", onSignal)