@nifrajs/core 4.0.0 → 5.0.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 (196) hide show
  1. package/README.md +6 -2
  2. package/dist/assurance.d.ts +71 -5
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +90 -25
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +6 -0
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +52 -0
  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 +7 -2
  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 +4 -3
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +3 -2
  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-program.d.ts +1 -1
  49. package/dist/internal/route-program.d.ts.map +1 -1
  50. package/dist/internal/route-program.js +59 -16
  51. package/dist/internal/route-program.js.map +1 -1
  52. package/dist/reconciliation-worker.d.ts.map +1 -1
  53. package/dist/reconciliation-worker.js +12 -2
  54. package/dist/reconciliation-worker.js.map +1 -1
  55. package/dist/reflection.d.ts +53 -2
  56. package/dist/reflection.d.ts.map +1 -1
  57. package/dist/reflection.js +352 -5
  58. package/dist/reflection.js.map +1 -1
  59. package/dist/router/pattern.d.ts +1 -1
  60. package/dist/router/pattern.d.ts.map +1 -1
  61. package/dist/router/pattern.js +6 -3
  62. package/dist/router/pattern.js.map +1 -1
  63. package/dist/security/baseline.d.ts +3 -0
  64. package/dist/security/baseline.d.ts.map +1 -1
  65. package/dist/security/baseline.js +9 -5
  66. package/dist/security/baseline.js.map +1 -1
  67. package/dist/server/auth.d.ts +32 -3
  68. package/dist/server/auth.d.ts.map +1 -1
  69. package/dist/server/auth.js +23 -0
  70. package/dist/server/auth.js.map +1 -1
  71. package/dist/server/body.d.ts +7 -0
  72. package/dist/server/body.d.ts.map +1 -1
  73. package/dist/server/body.js +34 -1
  74. package/dist/server/body.js.map +1 -1
  75. package/dist/server/client-ip.d.ts +3 -0
  76. package/dist/server/client-ip.d.ts.map +1 -1
  77. package/dist/server/client-ip.js.map +1 -1
  78. package/dist/server/context.d.ts +2 -1
  79. package/dist/server/context.d.ts.map +1 -1
  80. package/dist/server/idempotency-lane.d.ts +6 -3
  81. package/dist/server/idempotency-lane.d.ts.map +1 -1
  82. package/dist/server/idempotency-lane.js +38 -17
  83. package/dist/server/idempotency-lane.js.map +1 -1
  84. package/dist/server/install.d.ts +2 -0
  85. package/dist/server/install.d.ts.map +1 -1
  86. package/dist/server/install.js +2 -0
  87. package/dist/server/install.js.map +1 -1
  88. package/dist/server/logger.d.ts.map +1 -1
  89. package/dist/server/logger.js +3 -1
  90. package/dist/server/logger.js.map +1 -1
  91. package/dist/server/methods.d.ts +1 -0
  92. package/dist/server/methods.d.ts.map +1 -1
  93. package/dist/server/methods.js +1 -0
  94. package/dist/server/methods.js.map +1 -1
  95. package/dist/server/node-outcome.d.ts.map +1 -1
  96. package/dist/server/node-outcome.js +29 -11
  97. package/dist/server/node-outcome.js.map +1 -1
  98. package/dist/server/platform-adapters.d.ts.map +1 -1
  99. package/dist/server/platform-adapters.js +3 -2
  100. package/dist/server/platform-adapters.js.map +1 -1
  101. package/dist/server/request-context.d.ts +2 -1
  102. package/dist/server/request-context.d.ts.map +1 -1
  103. package/dist/server/request-context.js +4 -1
  104. package/dist/server/request-context.js.map +1 -1
  105. package/dist/server/respond.d.ts +44 -11
  106. package/dist/server/respond.d.ts.map +1 -1
  107. package/dist/server/respond.js +192 -79
  108. package/dist/server/respond.js.map +1 -1
  109. package/dist/server/response-observer-runtime.d.ts.map +1 -1
  110. package/dist/server/response-observer-runtime.js +10 -41
  111. package/dist/server/response-observer-runtime.js.map +1 -1
  112. package/dist/server/runtime-core.d.ts +2 -0
  113. package/dist/server/runtime-core.d.ts.map +1 -1
  114. package/dist/server/runtime-core.js +2 -0
  115. package/dist/server/runtime-core.js.map +1 -1
  116. package/dist/server/server-types.d.ts +29 -5
  117. package/dist/server/server-types.d.ts.map +1 -1
  118. package/dist/server/server.d.ts +44 -31
  119. package/dist/server/server.d.ts.map +1 -1
  120. package/dist/server/server.js +444 -321
  121. package/dist/server/server.js.map +1 -1
  122. package/dist/server/sse.d.ts +11 -1
  123. package/dist/server/sse.d.ts.map +1 -1
  124. package/dist/server/sse.js +18 -3
  125. package/dist/server/sse.js.map +1 -1
  126. package/dist/server/transport-lane.d.ts +7 -1
  127. package/dist/server/transport-lane.d.ts.map +1 -1
  128. package/dist/server/transport-lane.js.map +1 -1
  129. package/dist/server/webhook.d.ts +5 -2
  130. package/dist/server/webhook.d.ts.map +1 -1
  131. package/dist/server/webhook.js +17 -1
  132. package/dist/server/webhook.js.map +1 -1
  133. package/dist/server/websocket.d.ts +22 -14
  134. package/dist/server/websocket.d.ts.map +1 -1
  135. package/dist/server/websocket.js +21 -0
  136. package/dist/server/websocket.js.map +1 -1
  137. package/dist/server/ws-hook.d.ts +7 -1
  138. package/dist/server/ws-hook.d.ts.map +1 -1
  139. package/dist/server.d.ts +3 -2
  140. package/dist/server.d.ts.map +1 -1
  141. package/dist/server.js +2 -1
  142. package/dist/server.js.map +1 -1
  143. package/dist/single-copy.d.ts +6 -0
  144. package/dist/single-copy.d.ts.map +1 -1
  145. package/dist/single-copy.js +20 -1
  146. package/dist/single-copy.js.map +1 -1
  147. package/dist/tool-contract.d.ts.map +1 -1
  148. package/dist/tool-contract.js +71 -40
  149. package/dist/tool-contract.js.map +1 -1
  150. package/dist/ws.d.ts.map +1 -1
  151. package/dist/ws.js +3 -1
  152. package/dist/ws.js.map +1 -1
  153. package/package.json +1 -1
  154. package/src/assurance.ts +180 -31
  155. package/src/capabilities.ts +59 -0
  156. package/src/durable-effect.ts +1 -1
  157. package/src/durable-saga.ts +94 -10
  158. package/src/edge-kit.ts +7 -2
  159. package/src/effect-scope.ts +15 -5
  160. package/src/errors.ts +1 -0
  161. package/src/evidence.ts +12 -3
  162. package/src/index.ts +6 -1
  163. package/src/internal/durable-shared.ts +25 -37
  164. package/src/internal/durable-types.ts +3 -0
  165. package/src/internal/hook-origins.ts +153 -0
  166. package/src/internal/route-assurance.ts +32 -18
  167. package/src/internal/route-program.ts +98 -44
  168. package/src/reconciliation-worker.ts +10 -2
  169. package/src/reflection.ts +455 -4
  170. package/src/router/pattern.ts +6 -3
  171. package/src/security/baseline.ts +10 -5
  172. package/src/server/auth.ts +45 -3
  173. package/src/server/body.ts +34 -1
  174. package/src/server/client-ip.ts +3 -0
  175. package/src/server/context.ts +2 -1
  176. package/src/server/idempotency-lane.ts +64 -22
  177. package/src/server/install.ts +3 -0
  178. package/src/server/logger.ts +3 -1
  179. package/src/server/methods.ts +1 -0
  180. package/src/server/node-outcome.ts +34 -12
  181. package/src/server/platform-adapters.ts +3 -2
  182. package/src/server/request-context.ts +5 -0
  183. package/src/server/respond.ts +222 -69
  184. package/src/server/response-observer-runtime.ts +16 -38
  185. package/src/server/runtime-core.ts +2 -0
  186. package/src/server/server-types.ts +29 -5
  187. package/src/server/server.ts +659 -530
  188. package/src/server/sse.ts +84 -55
  189. package/src/server/transport-lane.ts +7 -1
  190. package/src/server/webhook.ts +22 -3
  191. package/src/server/websocket.ts +44 -5
  192. package/src/server/ws-hook.ts +5 -0
  193. package/src/server.ts +5 -0
  194. package/src/single-copy.ts +20 -1
  195. package/src/tool-contract.ts +67 -39
  196. package/src/ws.ts +4 -0
@@ -17,6 +17,15 @@ 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,
@@ -107,6 +116,7 @@ import {
107
116
  fusedRespond,
108
117
  fusedRespondNoSet,
109
118
  toResponse,
119
+ writableResponse,
110
120
  } from "./respond.ts"
111
121
  // Type-only: erased, so the kernel never pulls the lane's implementation into a bundle that does not
112
122
  // install the plugin. The value side arrives through the symbol-keyed install seam.
@@ -116,6 +126,7 @@ import type {
116
126
  ResponseObserverRuntime,
117
127
  } from "./response-observer-runtime.ts"
118
128
  import {
129
+ CONTEXT_REQUEST,
119
130
  CONTEXT_SEARCH,
120
131
  CONTEXT_SET,
121
132
  EMPTY_RESPONSE_CONTROLS,
@@ -160,6 +171,7 @@ import {
160
171
  INSTALL_SSE,
161
172
  INSTALL_WS,
162
173
  NODE_NATIVE_MOUNT,
174
+ REPORT_ERROR,
163
175
  RESOLVE_NODE_MOUNT,
164
176
  } from "./install.ts"
165
177
  import type { EffectLedgerRuntime } from "./ledger-lane.ts"
@@ -186,6 +198,7 @@ import type {
186
198
  import type {
187
199
  AdmissionController,
188
200
  AdmissionDecision,
201
+ DeclaredMountRoute,
189
202
  FetchHandler,
190
203
  ListenTlsOptions,
191
204
  McpPromptDescriptor,
@@ -194,6 +207,7 @@ import type {
194
207
  MountableApp,
195
208
  MountFetchOptions,
196
209
  MountOptions,
210
+ OpaqueMountDeclaration,
197
211
  PromptArgument,
198
212
  PromptMessage,
199
213
  ResponseFinalization,
@@ -205,12 +219,7 @@ import type {
205
219
  } from "./server-types.ts"
206
220
  import type { SSEInit, TypedSSEStream } from "./sse.ts"
207
221
  import type { SseRuntime } from "./sse-hook.ts"
208
- import type {
209
- TopicRegistry,
210
- WebSocketContext,
211
- WebSocketHandler,
212
- WebSocketUpgradeOutcome,
213
- } from "./websocket.ts"
222
+ import type { TopicRegistry, WebSocketHandler, WebSocketUpgradeOutcome } from "./websocket.ts"
214
223
  import type { BunWsData } from "./ws-bun.ts"
215
224
  import type { WsRuntime } from "./ws-hook.ts"
216
225
 
@@ -285,6 +294,7 @@ export interface RawContext {
285
294
  readonly jsonReply?: Response | undefined
286
295
  readonly jsonBody?: unknown
287
296
  readonly [CONTEXT_SEARCH]: string
297
+ readonly [CONTEXT_REQUEST]: Request
288
298
  readonly signal: AbortSignal
289
299
  readonly budget: RequestBudget
290
300
  readonly env: unknown
@@ -297,14 +307,18 @@ export interface RawContext {
297
307
  /** Broad shape so the implementation signature is compatible with both typed overloads. */
298
308
  type ErasedHandler = (ctx: never) => MaybePromise<HandlerResult>
299
309
 
300
- export type OnRequestResult = Response | Request | undefined
310
+ /** What an `onRequest` hook returns: a reply (a `Response`, or a `status()`/`json()` result that takes
311
+ * the normal response path), a replacement `Request`, or `undefined` to continue. */
312
+ export type OnRequestResult = Response | ResponseResult | Request | undefined
301
313
  type RawOnRequest = (req: Request, platform?: Platform) => MaybePromise<OnRequestResult>
302
314
  type RawOnResponse = (response: Response, req: Request) => MaybePromise<Response>
303
315
  type RawOnResponseFinalized = (outcome: ResponseFinalization, req: Request) => MaybePromise<void>
304
316
 
305
- /** A registered WebSocket route - just its handler; matching reuses {@link Router} under the GET verb. */
317
+ /** A registered WebSocket route and the route chain its handshake runs; matching reuses
318
+ * {@link Router} under the GET verb. */
306
319
  interface WsEntry {
307
320
  readonly handler: WebSocketHandler
321
+ readonly chain: RouteEntry
308
322
  }
309
323
 
310
324
  /** Structural view of the socket-peer lookup on the Bun `Server`, so any Bun `Server` (WS or not)
@@ -353,10 +367,14 @@ interface FetchMount<Env = unknown> {
353
367
  readonly fallbackOn404: boolean
354
368
  readonly beforeRoutes: boolean
355
369
  readonly order: number
356
- /** The declared reason this child is not analyzed (`MountOptions.opaque`), checked by reflection. */
357
- readonly opaque: string | undefined
370
+ /** Why this child is not analyzed (`MountOptions.opaque`), checked by reflection. */
371
+ readonly opaque: string | OpaqueMountDeclaration | undefined
358
372
  /** The mounted app (`mount()` only), for reflection to tell a composed child from an opaque one. */
359
373
  readonly app?: object
374
+ /** Parent request → the stripped copy a handshake check handed this child. On a pass the HTTP
375
+ * dispatch hands over that same copy, which the child's own pass record knows, so its onRequest
376
+ * hooks do not run a second time. */
377
+ wsPassed?: WeakMap<Request, Request>
360
378
  }
361
379
 
362
380
  /** The socket peer Bun observed, as a `Platform` for the request lifecycle (`undefined` if unknown). */
@@ -410,6 +428,7 @@ function outranks(
410
428
  }
411
429
 
412
430
  const WS_PASS: WebSocketUpgradeOutcome = { kind: "pass" }
431
+
413
432
  const DEFAULT_WS_UPGRADE_TIMEOUT_MS = 10_000
414
433
  /** Counts `ws()` routes and `mount()`s across every app in this copy of core: the only
415
434
  * changes that can make a mounted app take WebSocket upgrades. An app's answer to that question
@@ -427,6 +446,9 @@ function requireWsRuntime(runtime: WsRuntime | undefined): WsRuntime {
427
446
  return runtime
428
447
  }
429
448
 
449
+ /** The method `app.sse()` registers: POST when the schema declares a body, which a GET cannot carry. */
450
+ type SseMethodOf<S> = S extends { readonly body: StandardSchemaV1 } ? "POST" : "GET"
451
+
430
452
  function requireSseRuntime(runtime: SseRuntime | undefined): SseRuntime {
431
453
  if (runtime === undefined) {
432
454
  throw new FrameworkError(
@@ -495,6 +517,7 @@ export type Handler<
495
517
  export type {
496
518
  AdmissionController,
497
519
  AdmissionDecision,
520
+ DeclaredMountRoute,
498
521
  FetchHandler,
499
522
  ListenTlsOptions,
500
523
  McpPromptDescriptor,
@@ -503,6 +526,7 @@ export type {
503
526
  MountableApp,
504
527
  MountFetchOptions,
505
528
  MountOptions,
529
+ OpaqueMountDeclaration,
506
530
  PromptArgument,
507
531
  PromptMessage,
508
532
  ResponseFinalization,
@@ -675,11 +699,16 @@ const underPrefix = (prefix: string): HookScope => {
675
699
  }
676
700
 
677
701
  /** 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. */
702
+ * the guard never reflects whatever the source's own catalog grows into afterwards. An `OPTIONS`
703
+ * request to a path they serve is in scope too: a CORS preflight names the path, not a route, and
704
+ * the group's `cors()` is the only hook that can answer it. */
679
705
  const servedBy = (routes: readonly CatalogRoute[]): HookScope => {
680
706
  const scope = new RouteCatalog()
681
707
  scope.addBatch(routes)
682
- return (req) => scope.find(req.method, pathnameOf(req.url)).found
708
+ return (req) => {
709
+ const match = scope.find(req.method, pathnameOf(req.url))
710
+ return match.found || (req.method === "OPTIONS" && match.reason === "method-not-allowed")
711
+ }
683
712
  }
684
713
 
685
714
  type AdoptedHook = ((first: never, second: never) => unknown) | undefined
@@ -800,6 +829,30 @@ interface HookAuditState {
800
829
  }
801
830
  const hookAudit = /* @__PURE__ */ new WeakMap<object, HookAuditState>()
802
831
 
832
+ /** Whether `from` reaches `target` through mounts that keep the path and take a request under `path`.
833
+ * Module-level, not a `Server` method, so a production bundle drops it with its dev-only caller. */
834
+ function mountLeadsBack(
835
+ from: unknown,
836
+ target: unknown,
837
+ path: string,
838
+ mountsOf: (server: Server) => readonly FetchMount[],
839
+ seen: Set<unknown>,
840
+ ): boolean {
841
+ if (from === target) return true
842
+ if (!(from instanceof Server) || seen.has(from)) return false
843
+ seen.add(from)
844
+ const under = (prefix: string): string => (prefix === "/" ? "/" : `${prefix}/`)
845
+ return mountsOf(from).some(
846
+ (mount) =>
847
+ !mount.stripPrefix &&
848
+ (under(path).startsWith(under(mount.path)) || under(mount.path).startsWith(under(path))) &&
849
+ mountLeadsBack(mount.app, target, path, mountsOf, seen),
850
+ )
851
+ }
852
+
853
+ /** Set by `@nifrajs/web`'s `createWebApp` on its app once the page routes are declared. */
854
+ const PAGES_DECLARED = /* @__PURE__ */ Symbol.for("nifra.web.pagesDeclared")
855
+
803
856
  /** Start tracking one server's order-scoped hook pushes (once, from the constructor). */
804
857
  const beginHookAudit = (
805
858
  key: object,
@@ -810,13 +863,42 @@ const beginHookAudit = (
810
863
  }
811
864
 
812
865
  /** 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. */
866
+ * seal means no route was declared after it) and the caller's frame. Hooks a WebSocket handshake runs
867
+ * count sockets as routes; `around` and `afterHandle` wrap a response, so they count HTTP routes only. */
814
868
  const recordScopedHook = (key: object, routesAtPush: number, kind: string): void => {
869
+ // Not a dead hook when routes follow it, but every page already copied its chain without it.
870
+ if (PAGES_DECLARED in key) {
871
+ const site = captureCallerSite()
872
+ throw new FrameworkError(
873
+ "HOOK_AFTER_PAGES",
874
+ `${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.`,
875
+ )
876
+ }
815
877
  const state = hookAudit.get(key)
816
878
  if (state === undefined || state.policy === "off") return
817
879
  state.sites.push({ kind, routesAtPush, site: captureCallerSite() })
818
880
  }
819
881
 
882
+ /** Hook provenance for an adoption (dev only, see `internal/hook-origins.ts`): which scope each
883
+ * wrapper just appended to a hook list gates its hook by. `pairs` holds each source list with the target list it
884
+ * was appended to, read right after the append so the wrappers are the target list's tail. */
885
+ const tagAdopted = (
886
+ target: object,
887
+ source: object,
888
+ prefix: string | undefined,
889
+ routes: readonly CatalogRoute[],
890
+ pairs: readonly (readonly [readonly unknown[], readonly unknown[]])[],
891
+ ): void => {
892
+ tagAdoption(target, source, prefix)
893
+ const scope =
894
+ prefix !== undefined
895
+ ? { prefix }
896
+ : { routes: routes.map((route) => ({ method: route.method, path: route.path })) }
897
+ for (const [originals, appended] of pairs) {
898
+ if (originals.length > 0) tagScoped(originals, appended.slice(-originals.length), scope)
899
+ }
900
+ }
901
+
820
902
  /** Run the deadness check exactly once. A hook whose recorded route count equals the final route count
821
903
  * had no route declared after it - unambiguously dead, since the scoping feature (a hook that covers
822
904
  * only later routes) always leaves at least one such route. Zero-route apps are exempt (different
@@ -824,13 +906,20 @@ const recordScopedHook = (key: object, routesAtPush: number, kind: string): void
824
906
  const sealHookAudit = (
825
907
  key: object,
826
908
  total: number,
909
+ sockets: number,
827
910
  logger: { warn(message: string): void },
828
911
  ): void => {
829
912
  const state = hookAudit.get(key)
830
913
  if (state === undefined || state.checked) return
831
914
  state.checked = true
832
- if (state.policy === "off" || total === 0) return
833
- const dead = state.sites.filter((hook) => hook.routesAtPush === total)
915
+ if (state.policy === "off" || total + sockets === 0) return
916
+ const dead = state.sites.filter(
917
+ (hook) =>
918
+ hook.routesAtPush ===
919
+ (hook.kind === "around" || hook.kind === "aroundCapability" || hook.kind === "afterHandle"
920
+ ? total
921
+ : total + sockets),
922
+ )
834
923
  if (dead.length === 0) return
835
924
  const detail = dead.map((hook) => ` ${hook.kind}() at ${hook.site ?? "<unknown>"}`).join("\n")
836
925
  const message =
@@ -865,7 +954,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
865
954
  private readonly catalog: RouteCatalog
866
955
  /** WebSocket routes, matched separately at upgrade time (a GET + `Upgrade: websocket`). */
867
956
  private readonly wsRouter: Router<WsEntry>
868
- private wsRouteCount: number
957
+ /** Each socket path, its declared capabilities, the evidence declarations its handshake captured
958
+ * and the chain it runs; reflection reads it. */
959
+ private readonly wsRoutes: {
960
+ readonly path: string
961
+ readonly capabilities: readonly string[] | undefined
962
+ readonly assurance: readonly AssuranceDeclaration[]
963
+ readonly chain: RouteEntry
964
+ }[]
869
965
  /** `wsChanges` when `wsMountsLive` was last worked out; -1 until then. */
870
966
  private wsMountsCheckedAt = -1
871
967
  /** Whether an app mounted here takes WebSocket upgrades, as of `wsMountsCheckedAt`. */
@@ -899,7 +995,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
899
995
  private readonly acceptInboundDeadlines: boolean
900
996
  private readonly maxInboundDeadlineMs: number
901
997
  private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
902
- private readonly gracefulSignals: boolean
998
+ private readonly gracefulSignals: { readonly drainMs?: number } | undefined
903
999
  private readonly stopHooks: StopHook[]
904
1000
  private readonly fetchMounts: FetchMount<EnvOf<Ctx>>[]
905
1001
  private mountOrder: number
@@ -980,9 +1076,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
980
1076
  /** Body/raw response hooks need a framework-payload marker; keep that decision per app. */
981
1077
  private responseBodyTag: object | undefined
982
1078
  private readonly responseBodyOwners: Set<object>
983
- private readonly finalizeResponse = (result: unknown, set: CtxSet): Response =>
984
- toResponse(result as HandlerResult, set, this.responseBodyTag, this.staticResponseHeaders)
1079
+ private readonly finalizeResponse = (result: unknown, set: CtxSet, ctx?: RawContext): Response =>
1080
+ toResponse(
1081
+ result as HandlerResult,
1082
+ set,
1083
+ this.responseBodyTag,
1084
+ this.staticResponseHeaders,
1085
+ // Response hooks write next, and they run for this same request object.
1086
+ ctx !== undefined && this.onResponseHooks.length > 0 ? ctx[CONTEXT_REQUEST] : undefined,
1087
+ )
985
1088
  private readonly responseRequests: WeakMap<Request, Request>
1089
+ /** A request the WebSocket handshake check ran the onRequest hooks on and then passed to HTTP →
1090
+ * what the hooks returned, so `fetch` routes that instead of running the hooks a second time. */
1091
+ private wsHookedPasses: WeakMap<object, Request> | undefined
986
1092
  /** Original source → request observed by generic request hooks, including in-place mutations. */
987
1093
  private readonly responseSources: WeakMap<object, Request>
988
1094
  /** Memoized NodeRequestContext per plain-`Request` source - see {@link nodeRequestContextOf}. */
@@ -1006,7 +1112,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1006
1112
  this.routePrefix = ""
1007
1113
  this.catalog = new RouteCatalog()
1008
1114
  this.wsRouter = new Router<WsEntry>()
1009
- this.wsRouteCount = 0
1115
+ this.wsRoutes = []
1010
1116
  this.topics = undefined
1011
1117
  const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
1012
1118
  assertByteLimit(maxBodyBytes, "maxBodyBytes")
@@ -1018,7 +1124,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1018
1124
  this.wsMaxPayloadBytes = wsMaxPayloadBytes
1019
1125
  this.requestTimeoutMs = options.requestTimeoutMs ?? 0
1020
1126
  this.wsUpgradeTimeoutMs = options.wsUpgradeTimeoutMs ?? DEFAULT_WS_UPGRADE_TIMEOUT_MS
1021
- this.clientIpTrust = options.clientIp
1127
+ const trust = options.clientIp
1128
+ if (
1129
+ trust !== undefined &&
1130
+ !("header" in trust) &&
1131
+ !(Number.isSafeInteger(trust.trustedHops) && trust.trustedHops >= 0)
1132
+ ) {
1133
+ throw new RangeError("clientIp trustedHops must be a non-negative integer")
1134
+ }
1135
+ this.clientIpTrust = trust
1022
1136
  this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false
1023
1137
  this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000
1024
1138
  this.deadlineAdmissionOptions = Object.freeze({
@@ -1034,7 +1148,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1034
1148
  if (!Number.isFinite(this.maxInboundDeadlineMs) || this.maxInboundDeadlineMs <= 0) {
1035
1149
  throw new RangeError("maxInboundDeadlineMs must be a finite positive number")
1036
1150
  }
1037
- this.gracefulSignals = options.gracefulSignals ?? false
1151
+ this.gracefulSignals =
1152
+ options.gracefulSignals === true ? {} : options.gracefulSignals || undefined
1038
1153
  this.stopHooks = []
1039
1154
  this.fetchMounts = []
1040
1155
  this.mountOrder = 0
@@ -1083,6 +1198,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1083
1198
  this.responseBodyTag = undefined
1084
1199
  this.responseBodyOwners = new Set()
1085
1200
  this.responseRequests = new WeakMap()
1201
+ this.wsHookedPasses = undefined
1086
1202
  this.responseSources = new WeakMap()
1087
1203
  this.nodeContexts = new WeakMap()
1088
1204
  this.appliedPlugins = new Map()
@@ -1187,6 +1303,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1187
1303
  opaque: options.opaque,
1188
1304
  app,
1189
1305
  }
1306
+ // Mounts run before routes: a request under this prefix that no mount on the way strips comes back
1307
+ // here unchanged until the stack overflows, which takes the process down. Dev-time only, like the
1308
+ // hook audit: a production define folds this away, and the first dev request would hit it anyway.
1309
+ if (
1310
+ hookAuditRuntime &&
1311
+ process.env.NODE_ENV !== "production" &&
1312
+ !mount.stripPrefix &&
1313
+ mountLeadsBack(app, this, mount.path, (server) => server.fetchMounts, new Set())
1314
+ ) {
1315
+ throw new RouteConfigError(
1316
+ "MOUNT_CYCLE",
1317
+ `mount ${mount.path}: the app leads back to this one without stripPrefix`,
1318
+ )
1319
+ }
1190
1320
  this.fetchMounts.push(mount)
1191
1321
  this.fetchMounts.sort(compareMounts)
1192
1322
  this.preRouteMountCount += 1
@@ -1214,8 +1344,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1214
1344
  > {
1215
1345
  this.assertConfigurable("derive()")
1216
1346
  this.derives.push(fn as unknown as RawDerive)
1217
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1218
- recordScopedHook(this, this.catalog.size, "derive")
1347
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
1348
+ recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "derive")
1349
+ tagHook(fn, "derive", this)
1350
+ }
1219
1351
  return this as unknown as Server<
1220
1352
  R,
1221
1353
  Ctx & ContextExtensionOf<ReturnType<H>>,
@@ -1252,6 +1384,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1252
1384
  throw new TypeError('authenticate() stage mode must be "sync" or "async"')
1253
1385
  }
1254
1386
  this.authStages.push(stage as unknown as RawAuthStage)
1387
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1388
+ tagHook(stage, "authenticate", this)
1255
1389
  return this as unknown as Server<R, Ctx & { readonly principal: Principal }, HookOutput>
1256
1390
  }
1257
1391
 
@@ -1259,19 +1393,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1259
1393
  this.assertConfigurable("decorate()")
1260
1394
  this.decorations[key] = value
1261
1395
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1262
- recordScopedHook(this, this.catalog.size, "decorate")
1396
+ recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "decorate")
1263
1397
  return this as unknown as Server<R, Ctx & Record<K, V>, HookOutput>
1264
1398
  }
1265
1399
 
1266
1400
  /**
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.
1401
+ * Run before routing on the raw request. Return a `Response` or a `status()` result to
1402
+ * short-circuit, or a replacement `Request` to continue routing with a rewritten method/URL/headers.
1403
+ * Global.
1269
1404
  */
1270
1405
  onRequest(
1271
1406
  fn: (req: Request, platform?: Platform<EnvOf<Ctx>>) => MaybePromise<OnRequestResult>,
1272
1407
  ): this {
1273
1408
  this.assertConfigurable("onRequest()")
1274
1409
  this.onRequestHooks.push(fn as RawOnRequest)
1410
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") tagHook(fn, "onRequest", this)
1275
1411
  this.onNodeRequestHooks.push(undefined)
1276
1412
  this.nodeRequestHooksComplete = false
1277
1413
  this.bunNativeRequestHooksSafe = false
@@ -1288,8 +1424,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1288
1424
  ): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
1289
1425
  this.assertConfigurable("beforeHandle()")
1290
1426
  this.beforeHandleHooks.push(fn as unknown as RawBeforeHandle)
1291
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1292
- recordScopedHook(this, this.catalog.size, "beforeHandle")
1427
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
1428
+ recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "beforeHandle")
1429
+ tagHook(fn, "beforeHandle", this)
1430
+ }
1293
1431
  return this as unknown as Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>>
1294
1432
  }
1295
1433
 
@@ -1306,8 +1444,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1306
1444
  >(fn: H): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
1307
1445
  this.assertConfigurable("around()")
1308
1446
  this.aroundHooks.push(fn as unknown as RawAround)
1309
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1447
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
1310
1448
  recordScopedHook(this, this.catalog.size, "around")
1449
+ tagHook(fn, "around", this)
1450
+ }
1311
1451
  return this as unknown as Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>>
1312
1452
  }
1313
1453
 
@@ -1355,8 +1495,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1355
1495
  ): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
1356
1496
  this.assertConfigurable("afterHandle()")
1357
1497
  this.afterHandleHooks.push(fn as unknown as RawAfterHandle)
1358
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1498
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
1359
1499
  recordScopedHook(this, this.catalog.size, "afterHandle")
1500
+ tagHook(fn, "afterHandle", this)
1501
+ }
1360
1502
  return this as unknown as Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>>
1361
1503
  }
1362
1504
 
@@ -1368,8 +1510,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1368
1510
  ): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
1369
1511
  this.assertConfigurable("onError()")
1370
1512
  this.onErrorHooks.push(fn as unknown as RawErrorHandler)
1371
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1372
- recordScopedHook(this, this.catalog.size, "onError")
1513
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
1514
+ recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "onError")
1515
+ tagHook(fn, "onError", this)
1516
+ }
1373
1517
  return this as unknown as Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>>
1374
1518
  }
1375
1519
 
@@ -1377,6 +1521,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1377
1521
  onResponse(fn: (response: Response, req: Request) => MaybePromise<Response>): this {
1378
1522
  this.assertConfigurable("onResponse()")
1379
1523
  this.onResponseHooks.push(fn)
1524
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") tagHook(fn, "onResponse", this)
1380
1525
  this.onNodeResponseHooks.push(undefined)
1381
1526
  this.nodeResponseHooksComplete = false
1382
1527
  this.bunNativeResponseHeadersOnly = false
@@ -1409,6 +1554,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1409
1554
  */
1410
1555
  responseHeaders(record: Readonly<Record<string, string>>): this {
1411
1556
  this.assertConfigurable("responseHeaders()")
1557
+ if (
1558
+ hookAuditRuntime &&
1559
+ process.env.NODE_ENV !== "production" &&
1560
+ this.onResponseHooks.length === 0
1561
+ )
1562
+ tagStatic(this, Object.keys(record), record)
1412
1563
  return this.addStaticResponseHeaders(normalizeStaticResponseHeaders(record))
1413
1564
  }
1414
1565
 
@@ -1427,13 +1578,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1427
1578
  })
1428
1579
  return this
1429
1580
  }
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.
1581
+ // Route the hook through the same seam the static tier uses. On a framework response with a
1582
+ // body it applies the defaults in place and returns the SAME object, so the serialized-body
1583
+ // marker survives for later `onResponseBody`/`onResponseRaw` observers and the Node writer;
1584
+ // reconstructing a `Response` here would strip that marker and reclassify the Node outcome from
1585
+ // `json` to a generic `response`. A body-less or guarded response is copied, marker included.
1435
1586
  const statics = buildStaticResponseHeaders(record, this.inheritedStatics)
1436
- this.onResponseHooks.push((response) => applyStaticResponseHeaders(response, statics))
1587
+ this.onResponseHooks.push((response, req) =>
1588
+ applyStaticResponseHeaders(response, statics, req),
1589
+ )
1590
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1591
+ tagHook(this.onResponseHooks.at(-1), "responseHeaders", this)
1437
1592
  this.onNodeResponseHooks.push(undefined)
1438
1593
  this.nodeResponseHooksComplete = false
1439
1594
  return this
@@ -1478,6 +1633,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1478
1633
  ): this {
1479
1634
  this.assertConfigurable("onResponseFinalized()")
1480
1635
  this.onResponseFinalizedHooks.push(fn)
1636
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1637
+ tagHook(fn, "onResponseFinalized", this)
1481
1638
  return this
1482
1639
  }
1483
1640
 
@@ -1570,9 +1727,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1570
1727
  this.activeAssurance.push(...pluginOnly)
1571
1728
  const inherited = this.inheritedPlugins
1572
1729
  this.inheritedPlugins = undefined
1730
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") enterPlugin(name)
1573
1731
  try {
1574
1732
  return arg(this)
1575
1733
  } finally {
1734
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") leavePlugin()
1576
1735
  this.inheritedPlugins = inherited
1577
1736
  // Remove only this plugin's temporary evidence. Nested assured plugins may deliberately leave
1578
1737
  // subsequent evidence active, so truncating the whole array would lose real ordering semantics.
@@ -1602,6 +1761,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1602
1761
  if (arg.onRequest !== undefined) {
1603
1762
  this.assertConfigurable("onRequest()")
1604
1763
  this.onRequestHooks.push(arg.onRequest as RawOnRequest)
1764
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1765
+ tagHook(arg.onRequest, "onRequest", this)
1605
1766
  this.onNodeRequestHooks.push(arg.onNodeRequest)
1606
1767
  if (arg.onNodeRequest === undefined) this.nodeRequestHooksComplete = false
1607
1768
  if (!(arg as unknown as Record<symbol, unknown>)[BUN_NATIVE_REQUEST_SAFE]) {
@@ -1616,6 +1777,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1616
1777
  if (arg.onResponse !== undefined) {
1617
1778
  this.assertConfigurable("onResponse()")
1618
1779
  this.onResponseHooks.push(arg.onResponse)
1780
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1781
+ tagHook(arg.onResponse, "onResponse", this)
1619
1782
  this.onNodeResponseHooks.push(arg.onNodeResponse)
1620
1783
  if (arg.onNodeResponse === undefined) this.nodeResponseHooksComplete = false
1621
1784
  else this.hasRawNodeResponseHook = true
@@ -1672,8 +1835,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1672
1835
  }
1673
1836
 
1674
1837
  /**
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
1838
+ * Register a **typed SSE route** - an endpoint streaming `text/event-stream` whose event
1839
+ * payloads are contracted by `schema.sse`. It is a GET route, or a POST route when the schema
1840
+ * declares a `body` (a GET carries none): the body validates as on any POST, and the client sends
1841
+ * it with `.subscribe(onEvent, { body })` - the shape of a chat stream that posts its prompt. The handler receives the validated context plus a
1677
1842
  * {@link TypedSSEStream}: `stream.send(event)` is compile-time-checked against the schema and
1678
1843
  * JSON-serialized into the SSE `data:` field. The typed client sees the marker and grows a
1679
1844
  * `.subscribe(onEvent)` for the route with the same payload type - end-to-end typed streaming.
@@ -1688,9 +1853,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1688
1853
  *
1689
1854
  * // client: const off = api.feed.subscribe((post) => console.log(post.title))
1690
1855
  *
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.
1856
+ * `init` passes through to the underlying {@link sse} helper (`keepAlive`, `maxBufferedBytes`,
1857
+ * extra headers). The connection closes when the handler resolves, `stream.close()` runs, or the
1858
+ * client disconnects (`stream.signal`). Query/body schemas validate exactly as on any other route.
1694
1859
  */
1695
1860
  sse<Path extends string, S extends RouteSchema & { sse: StandardSchemaV1 }>(
1696
1861
  path: Path,
@@ -1701,14 +1866,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1701
1866
  ) => void | Promise<void>,
1702
1867
  init?: SSEInit,
1703
1868
  ): Server<
1704
- AddRoute<R, "GET", Path, RouteInfoFor<Path, S, Response, HookOutput>>,
1869
+ AddRoute<R, SseMethodOf<S>, Path, RouteInfoFor<Path, S, Response, HookOutput>>,
1705
1870
  Ctx,
1706
1871
  HookOutput
1707
1872
  > {
1708
1873
  const handler = (context: Context<Path, S> & Ctx): Response =>
1709
1874
  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>>,
1875
+ return this.route(
1876
+ schema.body ? "POST" : "GET",
1877
+ path,
1878
+ schema,
1879
+ handler as unknown as ErasedHandler,
1880
+ ) as Server<
1881
+ AddRoute<R, SseMethodOf<S>, Path, RouteInfoFor<Path, S, Response, HookOutput>>,
1712
1882
  Ctx,
1713
1883
  HookOutput
1714
1884
  >
@@ -1927,8 +2097,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1927
2097
 
1928
2098
  /**
1929
2099
  * 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
2100
+ * `Upgrade: websocket`. The handshake runs the chain an HTTP `GET` registered here would: the
2101
+ * `authenticate()` stages, `derive`s and `beforeHandle`s added before this call, then the optional
2102
+ * `handler.upgrade(c)`. Any of them may reject (a `Response`, a `status(...)`, a failed auth stage);
2103
+ * `upgrade()` may seed per-connection `ws.data`. To keep a socket public, register it before the
2104
+ * guard. WebSockets are served by the
1932
2105
  * adapter (`listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler`) - not by bare `app.fetch`, which
1933
2106
  * has no socket (a WS path through `app.fetch` is a normal HTTP response).
1934
2107
  *
@@ -1947,7 +2120,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1947
2120
  Path extends string = string,
1948
2121
  >(
1949
2122
  path: Path,
1950
- handler: WebSocketHandler<Data, EnvOf<Ctx>, Schema, Send>,
2123
+ handler: WebSocketHandler<Data, EnvOf<Ctx>, Schema, Send, Ctx>,
1951
2124
  ): string extends Path
1952
2125
  ? Server<R, Ctx, HookOutput>
1953
2126
  : Server<AddRoute<R, "WS", Path, WsRouteInfoFor<Path, Schema, Send>>, Ctx, HookOutput> {
@@ -1967,9 +2140,25 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1967
2140
  this.topics ??= runtime.createTopics()
1968
2141
  // A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
1969
2142
  // 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
2143
+ const wrapped = runtime.wrapHandler(handler as WebSocketHandler, this.protoPoisoning)
2144
+ const upgrade = runtime.upgradeStep(wrapped)
2145
+ for (const form of expandOptionalParams(path)) {
2146
+ // The chain an HTTP `GET` to `form` would capture now, ending in `upgrade()` instead of a
2147
+ // handler. `afterHandle` and `around` are left out: they wrap a response, and an accepted
2148
+ // handshake has none.
2149
+ const { entry, assurance, descriptor } = this.prepareRoute(
2150
+ "WS",
2151
+ form,
2152
+ wrapped.capabilities === undefined ? undefined : { capabilities: wrapped.capabilities },
2153
+ upgrade,
2154
+ )
2155
+ const chain = { ...entry, afterHandle: [] }
2156
+ this.wsRouter.add("GET", form, {
2157
+ handler: wrapped,
2158
+ chain: { ...chain, program: compileRouteProgram({ ...chain, bodySchema: undefined }) },
2159
+ })
2160
+ this.wsRoutes.push({ path: form, capabilities: descriptor.capabilities, assurance, chain })
2161
+ }
1973
2162
  wsChanges += 1
1974
2163
  return this as never
1975
2164
  }
@@ -2265,30 +2454,6 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2265
2454
  }
2266
2455
  }
2267
2456
 
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
2457
  /** @internal Symbol-keyed install seam for the `responseContract()` plugin. Off the public typed surface. */
2293
2458
  [INSTALL_RESPONSE_CONTRACT](runtime: ResponseContractRuntime): void {
2294
2459
  this.assertConfigurable("responseContract()")
@@ -2302,6 +2467,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2302
2467
  assertConfigurable: (operation) => this.assertConfigurable(operation),
2303
2468
  addResponseHook: (web, node, headerOnly = false) => {
2304
2469
  this.onResponseHooks.push(web)
2470
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2471
+ tagHook(web, headerOnly ? "responseHeaders" : "onResponse", this)
2305
2472
  this.onNodeResponseHooks.push(node)
2306
2473
  if (node === undefined) this.nodeResponseHooksComplete = false
2307
2474
  if (!headerOnly) this.bunNativeResponseHeadersOnly = false
@@ -2343,6 +2510,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2343
2510
  this.nodeOutcomeRuntime = runtime
2344
2511
  }
2345
2512
 
2513
+ /** @internal An adapter reports a failure no response hook saw (a hook that threw past `fetch`, a
2514
+ * response the runtime refused to write), logged as Bun's `listen()` logs one. */
2515
+ [REPORT_ERROR](error: unknown): void {
2516
+ emitRequestErrorLog(this.logger, this.errorLogDetail, error, undefined)
2517
+ }
2518
+
2346
2519
  /** @internal Symbol-keyed install seam for the `streaming()` plugin. Off the public typed surface. */
2347
2520
  [INSTALL_SSE](runtime: SseRuntime): void {
2348
2521
  this.assertConfigurable("streaming()")
@@ -2363,7 +2536,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2363
2536
  * counts. `seen` ends a mount cycle (an app mounted under itself to alias a prefix).
2364
2537
  */
2365
2538
  [GET_WS_RUNTIME](seen?: Set<unknown>): WsRuntime | null | undefined {
2366
- return this.wsRouteCount > 0 ? this.wsRuntime : this.mountedWsRuntime(seen)
2539
+ return this.wsRoutes.length > 0 ? this.wsRuntime : this.mountedWsRuntime(seen)
2367
2540
  }
2368
2541
 
2369
2542
  /** The `[GET_WS_RUNTIME]` answer for the apps mounted here alone, whatever this app's own routes. */
@@ -2404,15 +2577,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2404
2577
  * `derive`/`decorate`/`beforeHandle`/`afterHandle`/`onError`/`around` apply to its routes
2405
2578
  * exactly as they did standalone, so a group wires its own plugins. The same locality rule
2406
2579
  * 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
2580
+ * 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
2581
  * because the app composed it in), and the group's global assurance follows its hooks onto
2409
2582
  * exactly those routes. A group with hooks but NO routes is a middleware bundle - its hooks
2410
2583
  * can only mean app-wide intent, so they are appended globally, unchanged. Response-side hooks
2411
2584
  * (`onResponse`/`onResponseFinalized`) are appended to this server's. This server's
2412
2585
  * route-scoped chains do NOT retroactively wrap merged routes (order-scoped, like routes
2413
2586
  * 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).
2587
+ * `RouteConfigError` at merge time, and a group with WebSocket routes or mounts is refused
2588
+ * (register those on the parent).
2416
2589
  */
2417
2590
  merge<R2 extends Registry, Ctx2, HookOutput2>(
2418
2591
  other: Server<R2, Ctx2, HookOutput2>,
@@ -2432,10 +2605,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2432
2605
  )
2433
2606
  }
2434
2607
  const source = other as unknown as Server<Registry, EmptyContext>
2435
- if (source.wsRouteCount > 0) {
2608
+ if (source.wsRoutes.length > 0 || source.fetchMounts.length > 0) {
2436
2609
  throw new RouteConfigError(
2437
2610
  "INVALID_PATH",
2438
- "merge() does not carry WebSocket routes - register .ws() routes on the parent server",
2611
+ "merge() does not carry WebSocket routes or mounts - register them on the parent server",
2439
2612
  )
2440
2613
  }
2441
2614
  this.adopt(source, undefined, 0)
@@ -2521,7 +2694,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2521
2694
  throw new TypeError(`${label} builder must return its group synchronously`)
2522
2695
  }
2523
2696
  if (
2524
- scope.wsRouteCount > 0 ||
2697
+ scope.wsRoutes.length > 0 ||
2525
2698
  scope.fetchMounts.length > 0 ||
2526
2699
  scope.catalog.entries().some((route) => route.descriptor.tool !== undefined)
2527
2700
  ) {
@@ -2531,7 +2704,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2531
2704
  )
2532
2705
  }
2533
2706
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2534
- sealHookAudit(scope, scope.catalog.size, this.logger)
2707
+ sealHookAudit(scope, scope.catalog.size, scope.wsRoutes.length, this.logger)
2535
2708
  this.adopt(scope, fullPrefix, this.globalAssurance.length)
2536
2709
  return this as unknown as Server<R & PrefixRegistry<P, R2>, Ctx, HookOutput>
2537
2710
  }
@@ -2593,12 +2766,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2593
2766
  // The group's static declarations came before its own response hooks, so they are folded in
2594
2767
  // first - and fold themselves into a hook here if this server already has one (same ordering
2595
2768
  // rule as a direct `responseHeaders()` call).
2769
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2770
+ adoptStaticTags(this, source, this.onResponseHooks.length > 0)
2596
2771
  this.addStaticResponseHeaders({ ...statics.record })
2597
2772
  } else {
2598
2773
  // The static tier is folded into EVERY response this server builds, so a group's static
2599
2774
  // headers cannot join it. A hook ahead of the group's own (the sealed scope is discarded
2600
2775
  // after adoption) keeps their meaning - defaults a value already on the response wins over.
2601
- source.onResponseHooks.unshift((response) => applyStaticResponseHeaders(response, statics))
2776
+ source.onResponseHooks.unshift((response, req) =>
2777
+ applyStaticResponseHeaders(response, statics, req),
2778
+ )
2779
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2780
+ tagHook(source.onResponseHooks[0], "responseHeaders", source, true)
2602
2781
  source.onNodeResponseHooks.unshift(undefined)
2603
2782
  source.nodeResponseHooksComplete = false
2604
2783
  }
@@ -2608,6 +2787,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2608
2787
  this.onResponseFinalizedHooks.push(
2609
2788
  ...scopeHooks(source.onResponseFinalizedHooks, responseScope, 1),
2610
2789
  )
2790
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
2791
+ // Read before anything else is appended: the wrappers are still each list's tail.
2792
+ const pairs: (readonly [readonly unknown[], readonly unknown[]])[] = []
2793
+ if (inScope !== undefined) pairs.push([source.onRequestHooks, this.onRequestHooks])
2794
+ if (responseScope !== undefined) {
2795
+ pairs.push([source.onResponseHooks, this.onResponseHooks])
2796
+ pairs.push([source.onResponseFinalizedHooks, this.onResponseFinalizedHooks])
2797
+ }
2798
+ tagAdopted(this, source, prefix, sourceRoutes, pairs)
2799
+ }
2611
2800
  this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete
2612
2801
  this.bunNativeResponseHeadersOnly &&= source.bunNativeResponseHeadersOnly
2613
2802
  this.hasRawNodeResponseHook ||= source.hasRawNodeResponseHook
@@ -2799,7 +2988,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2799
2988
  // Seal once on the first request so test servers (no `listen()`) still get the dead-hook check.
2800
2989
  // After that it is a single boolean read - the per-request cost the option promises to keep at zero.
2801
2990
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2802
- sealHookAudit(this, this.catalog.size, this.logger)
2991
+ sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger)
2803
2992
  // Off path (default): straight through - one property check, no closure, no promise.
2804
2993
  if (this.capacityGate === undefined) return this.fetchSourceInner(source, platform)
2805
2994
  return this.admitGated(requestOf(source), () => this.fetchSourceInner(source, platform))
@@ -2983,7 +3172,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2983
3172
  ): MaybePromise<NodeServeOutcome> {
2984
3173
  // Seal once on the Node-direct lane too - it can bypass `fetchSource` entirely (hookless outcome).
2985
3174
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2986
- sealHookAudit(this, this.catalog.size, this.logger)
3175
+ sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger)
2987
3176
  // A paired header-only native hook can preserve the Node-direct JSON/body outcome for successful
2988
3177
  // responses. Arbitrary onResponse transforms need a real Web Response, but a buffered outcome can
2989
3178
  // be materialized with a direct-write marker and return to the socket path when the hook mutates
@@ -3242,7 +3431,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3242
3431
  current = outcome
3243
3432
  continue
3244
3433
  }
3245
- if (outcome !== undefined) return outcome
3434
+ if (outcome !== undefined) return webResponseOf(outcome)
3246
3435
  }
3247
3436
  return current
3248
3437
  }
@@ -3257,7 +3446,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3257
3446
  let outcome = first
3258
3447
  for (let i = start; ; i++) {
3259
3448
  if (outcome instanceof Request) next = outcome
3260
- else if (outcome !== undefined) return outcome
3449
+ else if (outcome !== undefined) return webResponseOf(outcome)
3261
3450
  if (i >= this.onRequestHooks.length) return next
3262
3451
  const hook = this.onRequestHooks[i]
3263
3452
  if (hook === undefined) return next
@@ -3271,11 +3460,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3271
3460
  ): MaybePromise<WebSocketUpgradeOutcome> {
3272
3461
  // Before any header is read: Deno only materializes a request's headers when asked, and that
3273
3462
  // costs a plain request measurably.
3274
- if (this.wsRouteCount === 0 && !this.mountsTakeUpgrades()) return WS_PASS
3463
+ if (this.wsRoutes.length === 0 && !this.mountsTakeUpgrades()) return WS_PASS
3275
3464
  if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") return WS_PASS
3276
3465
  // A WebSocket handshake is a GET (RFC 6455 section 4.1). Any other method with an Upgrade header
3277
3466
  // is an ordinary request: its HTTP route answers it, and normal routing refuses an unknown token.
3278
3467
  if (req.method !== "GET") return WS_PASS
3468
+ // The adapter answers a pass by handing this same object to `fetch`.
3469
+ const received = req
3279
3470
  // The handshake routes the path `fetch` would: dot segments resolved (see `fetch`).
3280
3471
  if (rawRequestTargets && hasDotSegment(req.url)) req = withResolvedTarget(req)
3281
3472
  // The handshake's caller is the one `fetch` derives: hooks and `upgrade()` see the trusted IP.
@@ -3290,11 +3481,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3290
3481
  const controller = timeoutMs > 0 ? new AbortController() : undefined
3291
3482
  const signal = controller?.signal ?? getNeverAbortSignal()
3292
3483
  const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : undefined
3293
- const timeoutResponse = jsonError(503, "request_timeout")
3294
- const onTimeout = (): WebSocketUpgradeOutcome => ({
3484
+ // A refusal carries the static response headers an HTTP answer would; a guard's already does.
3485
+ const refuse = (response: Response): WebSocketUpgradeOutcome => ({
3295
3486
  kind: "reject",
3296
- response: timeoutResponse,
3487
+ response: this.wrapWebResponse(response),
3297
3488
  })
3489
+ const onTimeout = (): WebSocketUpgradeOutcome => refuse(jsonError(503, "request_timeout"))
3298
3490
  const bounded = <T>(work: Promise<T>, onDone: () => T): Promise<T> =>
3299
3491
  controller === undefined
3300
3492
  ? work
@@ -3307,11 +3499,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3307
3499
  // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
3308
3500
  // already did and made every plain dynamic route pay for unrelated static path bytes.
3309
3501
  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.
3502
+ if (params === null) return refuse(jsonError(400, "malformed_path"))
3503
+ const { handler, chain } = match.payload
3504
+ // Non-null: a socket route ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
3313
3505
  const pubsub = this.topics as TopicRegistry
3314
- const attach = (this.wsRuntime as WsRuntime).attach
3506
+ const runtime = this.wsRuntime as WsRuntime
3315
3507
  // CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
3316
3508
  // Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
3317
3509
  // blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
@@ -3321,23 +3513,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3321
3513
  typeof handler.allowedOrigins === "function"
3322
3514
  ? handler.allowedOrigins(origin)
3323
3515
  : origin !== null && handler.allowedOrigins.includes(origin)
3324
- if (!allowed) return { kind: "reject", response: jsonError(403, "forbidden_origin") }
3516
+ if (!allowed) return refuse(jsonError(403, "forbidden_origin"))
3325
3517
  } else if (origin !== null && !wsSameOrigin(origin, request)) {
3326
3518
  // Secure default (no explicit `allowedOrigins`): reject a CROSS-ORIGIN browser handshake - the
3327
3519
  // CSWSH case, since browsers send cookies on WS handshakes and don't apply CORS. Non-browser
3328
3520
  // clients send no `Origin` and pass; same-origin browsers pass. Set `allowedOrigins` to permit
3329
3521
  // 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
- }
3522
+ return refuse(jsonError(403, "forbidden_origin"))
3341
3523
  }
3342
3524
  const budget =
3343
3525
  controller === undefined
@@ -3353,28 +3535,31 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3353
3535
  this.maxBodyBytes,
3354
3536
  this.protoPoisoning,
3355
3537
  )
3356
- const settle = (value: unknown): WebSocketUpgradeOutcome =>
3357
- value instanceof Response
3358
- ? { kind: "reject", response: value }
3538
+ // Rendered as the same chain on an HTTP `GET` renders its early results.
3539
+ const finalize = (result: unknown, set: CtxSet): WebSocketUpgradeOutcome => {
3540
+ const accepted = runtime.accepted(result)
3541
+ return accepted === undefined
3542
+ ? { kind: "reject", response: this.finalizeResponse(result, set) }
3359
3543
  : {
3360
3544
  kind: "upgrade",
3361
3545
  handler,
3362
- data: value,
3546
+ data: accepted.data,
3363
3547
  pubsub,
3364
- attach,
3548
+ attach: runtime.attach,
3365
3549
  maxPayloadBytes: this.wsMaxPayloadBytes,
3366
3550
  }
3551
+ }
3552
+ const wrap = (response: Response | ResponseResult): WebSocketUpgradeOutcome =>
3553
+ finalize(response, EMPTY_RESPONSE_CONTROLS)
3554
+ let outcome: MaybePromise<WebSocketUpgradeOutcome>
3367
3555
  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)
3556
+ outcome = executeRouteProgram(this, chain, chain.program, request, ctx, finalize, wrap)
3375
3557
  } catch {
3376
- return { kind: "reject", response: jsonError(500, "internal_error") }
3558
+ return refuse(jsonError(500, "internal_error"))
3377
3559
  }
3560
+ if (!(outcome instanceof Promise)) return outcome
3561
+ const pending = outcome.then(undefined, () => refuse(jsonError(500, "internal_error")))
3562
+ return bounded(pending, onTimeout)
3378
3563
  }
3379
3564
 
3380
3565
  const resolveMounted = (request: Request): MaybePromise<WebSocketUpgradeOutcome> => {
@@ -3397,20 +3582,25 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3397
3582
  }
3398
3583
  }
3399
3584
  if (selected === undefined) return WS_PASS
3400
- const childRequest = selected.stripPrefix
3401
- ? stripMountPrefix(request, selected.path)
3402
- : request
3585
+ let childRequest = request
3586
+ if (selected.stripPrefix) {
3587
+ childRequest = stripMountPrefix(request, selected.path)
3588
+ selected.wsPassed ??= new WeakMap()
3589
+ selected.wsPassed.set(request, childRequest)
3590
+ }
3403
3591
  let child: MaybePromise<WebSocketUpgradeOutcome>
3404
3592
  try {
3405
3593
  child = selected.resolveWebSocketUpgrade!(childRequest, platform)
3406
3594
  } catch {
3407
- return { kind: "reject", response: jsonError(500, "internal_error") }
3595
+ return refuse(jsonError(500, "internal_error"))
3408
3596
  }
3409
3597
  const settle = (outcome: WebSocketUpgradeOutcome): MaybePromise<WebSocketUpgradeOutcome> =>
3410
3598
  outcome.kind === "pass" ||
3411
3599
  (outcome.kind === "reject" && outcome.response.status === 404 && selected!.fallbackOn404)
3412
3600
  ? dispatch(selectedIndex + 1)
3413
- : outcome
3601
+ : outcome.kind === "reject"
3602
+ ? refuse(outcome.response)
3603
+ : outcome
3414
3604
  return child instanceof Promise ? child.then(settle) : settle(child)
3415
3605
  }
3416
3606
  return dispatch(0)
@@ -3432,23 +3622,25 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3432
3622
  }
3433
3623
 
3434
3624
  if (this.onRequestHooks.length === 0) return resolveWithDeadline(req)
3625
+ // On a pass the adapter hands `received` to `fetch`, which then routes these results. An upgrade
3626
+ // or a rejection never reaches `fetch`, so the entry dies with the request.
3627
+ const resolveHooked = (request: Request): MaybePromise<WebSocketUpgradeOutcome> => {
3628
+ this.wsHookedPasses ??= new WeakMap()
3629
+ this.wsHookedPasses.set(received, request)
3630
+ return resolveWithDeadline(request)
3631
+ }
3435
3632
  let hooked: MaybePromise<Request | Response>
3436
3633
  try {
3437
3634
  hooked = this.runWebSocketRequestHooks(req, platform)
3438
3635
  } catch {
3439
- return { kind: "reject", response: jsonError(500, "internal_error") }
3636
+ return refuse(jsonError(500, "internal_error"))
3440
3637
  }
3441
3638
  if (!(hooked instanceof Promise)) {
3442
- return hooked instanceof Response
3443
- ? { kind: "reject", response: hooked }
3444
- : resolveWithDeadline(hooked)
3639
+ return hooked instanceof Response ? refuse(hooked) : resolveHooked(hooked)
3445
3640
  }
3446
3641
  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") }),
3642
+ (result) => (result instanceof Response ? refuse(result) : resolveHooked(result)),
3643
+ () => refuse(jsonError(500, "internal_error")),
3452
3644
  )
3453
3645
  return bounded(
3454
3646
  Promise.resolve(pending).then((result) => result),
@@ -3512,7 +3704,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3512
3704
  private dispatch<T>(
3513
3705
  source: RequestSource,
3514
3706
  platform: Platform<EnvOf<Ctx>> | undefined,
3515
- finalize: (result: unknown, set: CtxSet) => T,
3707
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
3516
3708
  wrapResponse: (response: Response | ResponseResult) => T,
3517
3709
  onTimeout: () => T,
3518
3710
  // True only from the Web `fetch` path - unlocks each route's fused lane, whose output type IS
@@ -3532,6 +3724,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3532
3724
  if (this.onRequestHooks.length === 0 || !isRoutableMethod(source.method)) {
3533
3725
  return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast)
3534
3726
  }
3727
+ const hooked = this.wsHookedPasses?.get(source)
3728
+ if (hooked !== undefined) {
3729
+ const request = requestOf(source)
3730
+ this.wsHookedPasses?.delete(request)
3731
+ this.responseRequests.set(request, hooked)
3732
+ return this.routeAndRun(hooked, resolved, finalize, wrapResponse, onTimeout, webFast)
3733
+ }
3535
3734
  if (!webFast && this.canUseNodeRequestHooks()) {
3536
3735
  return this.runWithNodeRequest(source, resolved, finalize, wrapResponse, onTimeout)
3537
3736
  }
@@ -3543,7 +3742,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3543
3742
  private runWithNodeRequest<T>(
3544
3743
  source: RequestSource,
3545
3744
  platform: Platform<EnvOf<Ctx>> | undefined,
3546
- finalize: (result: unknown, set: CtxSet) => T,
3745
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
3547
3746
  wrapResponse: (response: Response | ResponseResult) => T,
3548
3747
  onTimeout: () => T,
3549
3748
  ): MaybePromise<T> {
@@ -3577,7 +3776,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3577
3776
  request: NodeRequestContext,
3578
3777
  source: RequestSource,
3579
3778
  platform: Platform<EnvOf<Ctx>> | undefined,
3580
- finalize: (result: unknown, set: CtxSet) => T,
3779
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
3581
3780
  wrapResponse: (response: Response | ResponseResult) => T,
3582
3781
  onTimeout: () => T,
3583
3782
  ): Promise<T> {
@@ -3605,7 +3804,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3605
3804
  private runWithOnRequest<T>(
3606
3805
  source: RequestSource,
3607
3806
  platform: Platform<EnvOf<Ctx>> | undefined,
3608
- finalize: (result: unknown, set: CtxSet) => T,
3807
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
3609
3808
  wrapResponse: (response: Response | ResponseResult) => T,
3610
3809
  onTimeout: () => T,
3611
3810
  webFast: boolean,
@@ -3654,7 +3853,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3654
3853
  originalRequest: Request,
3655
3854
  sourceAtAwait: RequestSource,
3656
3855
  platform: Platform<EnvOf<Ctx>> | undefined,
3657
- finalize: (result: unknown, set: CtxSet) => T,
3856
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
3658
3857
  wrapResponse: (response: Response | ResponseResult) => T,
3659
3858
  onTimeout: () => T,
3660
3859
  webFast: boolean,
@@ -3751,7 +3950,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3751
3950
  private routeAndRun<T>(
3752
3951
  source: RequestSource,
3753
3952
  platform: Platform<EnvOf<Ctx>> | undefined,
3754
- finalize: (result: unknown, set: CtxSet) => T,
3953
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
3755
3954
  wrapResponse: (response: Response | ResponseResult) => T,
3756
3955
  onTimeout: () => T,
3757
3956
  webFast: boolean,
@@ -3881,7 +4080,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3881
4080
  }
3882
4081
  }
3883
4082
  if (mount === undefined) return fallback ?? jsonError(404, "not_found")
3884
- const childRequest = mount.stripPrefix ? stripMountPrefix(request, mount.path) : request
4083
+ const childRequest = mount.stripPrefix
4084
+ ? (mount.wsPassed?.get(request) ?? stripMountPrefix(request, mount.path))
4085
+ : request
3885
4086
  const outcome = mount.handler(childRequest, platform)
3886
4087
  if (!replayable || !mount.fallbackOn404) return outcome
3887
4088
  if (outcome instanceof Promise) {
@@ -3901,7 +4102,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3901
4102
  entry: RouteEntry,
3902
4103
  params: Record<string, string>,
3903
4104
  search: string | undefined,
3904
- finalize: (result: unknown, set: CtxSet) => T,
4105
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
3905
4106
  wrapResponse: (response: Response | ResponseResult) => T,
3906
4107
  onTimeout: () => T,
3907
4108
  webFast: boolean,
@@ -3939,7 +4140,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3939
4140
  {
3940
4141
  maxBodyBytes: entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3941
4142
  runLanes: (buffered, plat, ent, prm, srch) =>
3942
- this.idempotencyRunLanes(buffered, plat, ent as RouteEntry, prm, srch),
4143
+ this.runMatchedLanes(
4144
+ buffered,
4145
+ plat,
4146
+ ent as RouteEntry,
4147
+ prm,
4148
+ srch,
4149
+ this.finalizeResponse,
4150
+ this.wrapWebResponse,
4151
+ this.webResponseTimeout,
4152
+ false,
4153
+ ),
3943
4154
  },
3944
4155
  )
3945
4156
  }
@@ -3963,7 +4174,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3963
4174
  entry: RouteEntry,
3964
4175
  params: Record<string, string>,
3965
4176
  search: string | undefined,
3966
- finalize: (result: unknown, set: CtxSet) => T,
4177
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
3967
4178
  wrapResponse: (response: Response | ResponseResult) => T,
3968
4179
  onTimeout: () => T,
3969
4180
  webFast: boolean,
@@ -4004,32 +4215,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4004
4215
  : createRequestBudget({ deadline: admission!.deadline as number, signal })
4005
4216
  const plan = entry.execution
4006
4217
  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,
4218
+ let outcome: MaybePromise<T>
4219
+ try {
4220
+ outcome =
4221
+ webFast && plan.fusedWeb !== undefined
4222
+ ? (plan.fusedWeb(
4033
4223
  source,
4034
4224
  params,
4035
4225
  search,
@@ -4037,9 +4227,46 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4037
4227
  budget,
4038
4228
  platform,
4039
4229
  nativeContext,
4040
- finalize,
4041
- wrapResponse,
4042
- )
4230
+ ) as MaybePromise<T>)
4231
+ : !webFast && plan.fusedBody !== undefined
4232
+ ? plan.fusedBody(
4233
+ source,
4234
+ params,
4235
+ search,
4236
+ signal,
4237
+ budget,
4238
+ platform,
4239
+ false,
4240
+ finalize,
4241
+ wrapResponse,
4242
+ )
4243
+ : plan.run(
4244
+ this,
4245
+ entry,
4246
+ source,
4247
+ params,
4248
+ search,
4249
+ signal,
4250
+ budget,
4251
+ platform,
4252
+ nativeContext,
4253
+ finalize,
4254
+ wrapResponse,
4255
+ )
4256
+ } catch (err) {
4257
+ // A lane's synchronous render throw (a BigInt, a header value with a line break): the lanes
4258
+ // guard only their async continuations, since a `try` around a fused render slows it.
4259
+ return this.contextlessBareError(
4260
+ err,
4261
+ source,
4262
+ params,
4263
+ search,
4264
+ signal,
4265
+ budget,
4266
+ platform,
4267
+ wrapResponse,
4268
+ )
4269
+ }
4043
4270
  // The request timeout only bounds work that is actually pending - a synchronous (bare) result is
4044
4271
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
4045
4272
  if (controller !== undefined && outcome instanceof Promise) {
@@ -4074,7 +4301,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4074
4301
  signal: AbortSignal,
4075
4302
  budget: RequestBudget,
4076
4303
  platform: Platform | undefined,
4077
- finalize: (result: unknown, set: CtxSet) => T,
4304
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
4078
4305
  wrapResponse: (response: Response | ResponseResult) => T,
4079
4306
  ): MaybePromise<T> {
4080
4307
  let result: unknown
@@ -4094,7 +4321,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4094
4321
  }
4095
4322
  if (result instanceof Promise) {
4096
4323
  return result.then(
4097
- (value) => finalize(value, EMPTY_RESPONSE_CONTROLS),
4324
+ (value) => {
4325
+ try {
4326
+ return finalize(value, EMPTY_RESPONSE_CONTROLS)
4327
+ } catch (err) {
4328
+ return this.contextlessBareError(
4329
+ err,
4330
+ source,
4331
+ params,
4332
+ search,
4333
+ signal,
4334
+ budget,
4335
+ platform,
4336
+ wrapResponse,
4337
+ )
4338
+ }
4339
+ },
4098
4340
  (err) =>
4099
4341
  this.contextlessBareError(
4100
4342
  err,
@@ -4151,7 +4393,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4151
4393
  private runBare<T>(
4152
4394
  entry: RouteEntry,
4153
4395
  ctx: RawContext,
4154
- finalize: (result: unknown, set: CtxSet) => T,
4396
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
4155
4397
  wrapResponse: (response: Response | ResponseResult) => T,
4156
4398
  ): MaybePromise<T> {
4157
4399
  let result: unknown
@@ -4164,11 +4406,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4164
4406
  if (result instanceof Promise) {
4165
4407
  // Async handler on an otherwise-bare route: finish on a microtask, with the same error handling.
4166
4408
  return result.then(
4167
- (value) => finalize(value, responseSet(ctx)),
4409
+ (value) => {
4410
+ // A synchronous finalize throw reaches runMatchedLanes; this one would reject.
4411
+ try {
4412
+ return finalize(value, responseSet(ctx), ctx)
4413
+ } catch (err) {
4414
+ return this.bareError(err, ctx, finalize, wrapResponse)
4415
+ }
4416
+ },
4168
4417
  (err) => this.bareError(err, ctx, finalize, wrapResponse),
4169
4418
  )
4170
4419
  }
4171
- return finalize(result, responseSet(ctx))
4420
+ return finalize(result, responseSet(ctx), ctx)
4172
4421
  }
4173
4422
 
4174
4423
  /** Bare-route error rendering - identical to the compiled route program's catch minus the (absent) onError
@@ -4200,66 +4449,62 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4200
4449
  // `() => ...` can't observe the context - skip allocating one entirely (errors still build
4201
4450
  // one for the structured log, exactly like runContextlessBare).
4202
4451
  const contextlessHandler = handler as unknown as ContextlessHandler
4452
+ const fail = (
4453
+ err: unknown,
4454
+ source: RequestSource,
4455
+ params: Record<string, string>,
4456
+ search: string | undefined,
4457
+ signal: AbortSignal,
4458
+ budget: RequestBudget,
4459
+ platform: Platform | undefined,
4460
+ nativeContext: boolean,
4461
+ ): Response =>
4462
+ logError(
4463
+ err,
4464
+ nativeContext
4465
+ ? RequestContext.native(
4466
+ source,
4467
+ params,
4468
+ search,
4469
+ maxBodyBytes,
4470
+ platform,
4471
+ this.protoPoisoning,
4472
+ )
4473
+ : new RequestContext(
4474
+ source,
4475
+ params,
4476
+ search,
4477
+ signal,
4478
+ budget,
4479
+ platform,
4480
+ maxBodyBytes,
4481
+ this.protoPoisoning,
4482
+ ),
4483
+ )
4203
4484
  return (source, params, search, signal, budget, platform, nativeContext) => {
4204
4485
  let result: unknown
4205
4486
  try {
4206
4487
  result = contextlessHandler()
4207
4488
  } 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
- )
4489
+ return fail(err, source, params, search, signal, budget, platform, nativeContext)
4230
4490
  }
4231
4491
  if (result instanceof Promise) {
4232
4492
  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
- ),
4493
+ (value) => {
4494
+ // A synchronous render throw reaches runMatchedLanes; this one would reject.
4495
+ try {
4496
+ return fusedRespondNoSet(
4497
+ value,
4498
+ this.responseBodyTag,
4499
+ this.staticResponseHeaders,
4500
+ this.onResponseHooks.length === 0,
4501
+ this.onResponseHooks.length === 0 ? undefined : requestOf(source),
4502
+ )
4503
+ } catch (err) {
4504
+ return fail(err, source, params, search, signal, budget, platform, nativeContext)
4505
+ }
4506
+ },
4507
+ (err) => fail(err, source, params, search, signal, budget, platform, nativeContext),
4263
4508
  )
4264
4509
  }
4265
4510
  return fusedRespondNoSet(
@@ -4267,6 +4512,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4267
4512
  this.responseBodyTag,
4268
4513
  this.staticResponseHeaders,
4269
4514
  this.onResponseHooks.length === 0,
4515
+ this.onResponseHooks.length === 0 ? undefined : requestOf(source),
4270
4516
  )
4271
4517
  }
4272
4518
  }
@@ -4292,14 +4538,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4292
4538
  }
4293
4539
  if (result instanceof Promise) {
4294
4540
  return result.then(
4295
- (value) =>
4296
- fusedRespond(
4297
- value,
4298
- ctx,
4299
- this.responseBodyTag,
4300
- this.staticResponseHeaders,
4301
- this.onResponseHooks.length === 0,
4302
- ),
4541
+ (value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0),
4303
4542
  (err) => logError(err, ctx),
4304
4543
  )
4305
4544
  }
@@ -4313,6 +4552,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4313
4552
  }
4314
4553
  }
4315
4554
 
4555
+ /** A fused Web lane's render in an async continuation, where a result that cannot be rendered
4556
+ * must fail like a handler throw; a synchronous render throw reaches {@link runMatchedLanes}. */
4557
+ private respondFused(result: unknown, ctx: RawContext, unstamped?: boolean): Response {
4558
+ try {
4559
+ return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, unstamped)
4560
+ } catch (err) {
4561
+ return this.bareError(
4562
+ err,
4563
+ ctx,
4564
+ (value, set) => this.wrapWebResponse(toResponse(value, set)),
4565
+ this.wrapWebResponse,
4566
+ )
4567
+ }
4568
+ }
4569
+
4316
4570
  /** Compile the eligible body-only route's parser → validator → handler continuation once. The
4317
4571
  * bounded parser remains shared with the generic lane; only the route-invariant entry lookups and
4318
4572
  * lifecycle dispatch disappear from the common synchronous-validator/synchronous-handler case. */
@@ -4496,24 +4750,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4496
4750
  }
4497
4751
  if (result instanceof Promise) {
4498
4752
  return result.then(
4499
- (settled) =>
4500
- fusedRespond(
4501
- settled,
4502
- ctx,
4503
- this.responseBodyTag,
4504
- this.staticResponseHeaders,
4505
- this.onResponseHooks.length === 0,
4506
- ),
4753
+ (settled) => this.respondFused(settled, ctx, this.onResponseHooks.length === 0),
4507
4754
  (err) => logError(err, ctx),
4508
4755
  )
4509
4756
  }
4510
- return fusedRespond(
4511
- result,
4512
- ctx,
4513
- this.responseBodyTag,
4514
- this.staticResponseHeaders,
4515
- this.onResponseHooks.length === 0,
4516
- )
4757
+ return this.respondFused(result, ctx, this.onResponseHooks.length === 0)
4517
4758
  }
4518
4759
  return (source, params, search, signal, budget, platform, nativeContext) => {
4519
4760
  const ctx = nativeContext
@@ -4572,6 +4813,25 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4572
4813
  before: RawBeforeHandle,
4573
4814
  querySchema: StandardSchemaV1 | undefined,
4574
4815
  maxBodyBytes: number,
4816
+ ): FusedWebRunner {
4817
+ return this.buildFusedDeriveBeforeAfter(
4818
+ handler,
4819
+ derive,
4820
+ before,
4821
+ undefined,
4822
+ querySchema,
4823
+ maxBodyBytes,
4824
+ )
4825
+ }
4826
+
4827
+ // Both hook variants share one registered chain; request contexts stay in arguments.
4828
+ private buildFusedDeriveBeforeAfter(
4829
+ handler: InternalHandler,
4830
+ derive: RawDerive,
4831
+ before: RawBeforeHandle,
4832
+ after: RawAfterHandle | undefined,
4833
+ querySchema: StandardSchemaV1 | undefined,
4834
+ maxBodyBytes: number,
4575
4835
  ): FusedWebRunner {
4576
4836
  const logError = (err: unknown, ctx: RawContext): Response =>
4577
4837
  this.bareError(
@@ -4580,139 +4840,83 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4580
4840
  (result, set) => this.wrapWebResponse(toResponse(result, set)),
4581
4841
  this.wrapWebResponse,
4582
4842
  )
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,
4843
+ // Continuations capture route state once; each request supplies its own context.
4844
+ const runAfter = (result: unknown, ctx: RawContext): MaybePromise<Response> => {
4845
+ if (after === undefined)
4846
+ return this.respondFused(result, ctx, this.onResponseHooks.length === 0)
4847
+ let transformed: unknown
4848
+ try {
4849
+ transformed = after(result, ctx)
4850
+ } catch (err) {
4851
+ return logError(err, ctx)
4852
+ }
4853
+ if (transformed instanceof Promise) {
4854
+ return transformed.then(
4855
+ (value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0),
4856
+ (err) => logError(err, ctx),
4622
4857
  )
4623
4858
  }
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)
4859
+ return this.respondFused(transformed, ctx, this.onResponseHooks.length === 0)
4860
+ }
4861
+ const runHandler = (ctx: RawContext): MaybePromise<Response> => {
4862
+ let result: unknown
4863
+ try {
4864
+ result = handler(ctx)
4865
+ } catch (err) {
4866
+ return logError(err, ctx)
4642
4867
  }
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()
4868
+ if (result instanceof Promise) {
4869
+ return result.then(
4870
+ (value) => runAfter(value, ctx),
4871
+ (err) => logError(err, ctx),
4872
+ )
4667
4873
  }
4668
- if (querySchema === undefined) return runDerive()
4669
- let validation: MaybePromise<StandardResult<unknown>>
4874
+ return runAfter(result, ctx)
4875
+ }
4876
+ const runBefore = (ctx: RawContext): MaybePromise<Response> => {
4877
+ let early: unknown
4670
4878
  try {
4671
- validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
4879
+ early = before(ctx)
4672
4880
  } catch (err) {
4673
4881
  return logError(err, ctx)
4674
4882
  }
4675
- if (validation instanceof Promise) {
4676
- return validation.then(
4883
+ if (early instanceof Promise) {
4884
+ return early.then(
4677
4885
  (settled) => {
4678
- if (settled.issues !== undefined)
4679
- return this.wrapWebResponse(plainValidationError(settled.issues))
4680
- ctx.query = settled.value
4681
- return runDerive()
4886
+ if (settled === undefined) return runHandler(ctx)
4887
+ return this.respondFused(settled, ctx)
4682
4888
  },
4683
4889
  (err) => logError(err, ctx),
4684
4890
  )
4685
4891
  }
4686
- if (validation.issues !== undefined)
4687
- return this.wrapWebResponse(plainValidationError(validation.issues))
4688
- ctx.query = validation.value
4689
- return runDerive()
4892
+ if (early === undefined) return runHandler(ctx)
4893
+ return this.respondFused(early, ctx)
4894
+ }
4895
+ const runDerive = (ctx: RawContext): MaybePromise<Response> => {
4896
+ let derived: unknown
4897
+ try {
4898
+ derived = derive(ctx)
4899
+ } catch (err) {
4900
+ return logError(err, ctx)
4901
+ }
4902
+ if (derived instanceof Promise) {
4903
+ return derived.then(
4904
+ (settled) => {
4905
+ if (isResponseResult(settled) || settled instanceof Response) {
4906
+ return this.respondFused(settled, ctx)
4907
+ }
4908
+ Object.assign(ctx, settled as object)
4909
+ return runBefore(ctx)
4910
+ },
4911
+ (err) => logError(err, ctx),
4912
+ )
4913
+ }
4914
+ if (isResponseResult(derived) || derived instanceof Response) {
4915
+ return this.respondFused(derived, ctx)
4916
+ }
4917
+ Object.assign(ctx, derived as object)
4918
+ return runBefore(ctx)
4690
4919
  }
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
4920
  return (source, params, search, signal, budget, platform, nativeContext) => {
4717
4921
  const ctx = nativeContext
4718
4922
  ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
@@ -4726,94 +4930,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4726
4930
  maxBodyBytes,
4727
4931
  this.protoPoisoning,
4728
4932
  )
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()
4933
+ if (querySchema === undefined) return runDerive(ctx)
4817
4934
  let validation: MaybePromise<StandardResult<unknown>>
4818
4935
  try {
4819
4936
  validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
@@ -4826,7 +4943,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4826
4943
  if (settled.issues !== undefined)
4827
4944
  return this.wrapWebResponse(plainValidationError(settled.issues))
4828
4945
  ctx.query = settled.value
4829
- return runDerive()
4946
+ return runDerive(ctx)
4830
4947
  },
4831
4948
  (err) => logError(err, ctx),
4832
4949
  )
@@ -4834,7 +4951,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4834
4951
  if (validation.issues !== undefined)
4835
4952
  return this.wrapWebResponse(plainValidationError(validation.issues))
4836
4953
  ctx.query = validation.value
4837
- return runDerive()
4954
+ return runDerive(ctx)
4838
4955
  }
4839
4956
  }
4840
4957
 
@@ -4881,13 +4998,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4881
4998
  const runHandlerValidated = (): MaybePromise<Response> => {
4882
4999
  const runAfter = (result: unknown): MaybePromise<Response> => {
4883
5000
  if (after === undefined) {
4884
- return fusedRespond(
4885
- result,
4886
- ctx,
4887
- this.responseBodyTag,
4888
- this.staticResponseHeaders,
4889
- this.onResponseHooks.length === 0,
4890
- )
5001
+ return this.respondFused(result, ctx, this.onResponseHooks.length === 0)
4891
5002
  }
4892
5003
  let transformed: unknown
4893
5004
  try {
@@ -4897,24 +5008,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4897
5008
  }
4898
5009
  if (transformed instanceof Promise) {
4899
5010
  return transformed.then(
4900
- (value) =>
4901
- fusedRespond(
4902
- value,
4903
- ctx,
4904
- this.responseBodyTag,
4905
- this.staticResponseHeaders,
4906
- this.onResponseHooks.length === 0,
4907
- ),
5011
+ (value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0),
4908
5012
  (err) => logError(err, ctx),
4909
5013
  )
4910
5014
  }
4911
- return fusedRespond(
4912
- transformed,
4913
- ctx,
4914
- this.responseBodyTag,
4915
- this.staticResponseHeaders,
4916
- this.onResponseHooks.length === 0,
4917
- )
5015
+ return this.respondFused(transformed, ctx, this.onResponseHooks.length === 0)
4918
5016
  }
4919
5017
  const runHandler = (): MaybePromise<Response> => {
4920
5018
  let result: unknown
@@ -4942,13 +5040,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4942
5040
  return early.then(
4943
5041
  (settled) => {
4944
5042
  if (settled === undefined) return runHandler()
4945
- return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders)
5043
+ return this.respondFused(settled, ctx)
4946
5044
  },
4947
5045
  (err) => logError(err, ctx),
4948
5046
  )
4949
5047
  }
4950
5048
  if (early === undefined) return runHandler()
4951
- return fusedRespond(early, ctx, this.responseBodyTag, this.staticResponseHeaders)
5049
+ return this.respondFused(early, ctx)
4952
5050
  }
4953
5051
  let derived: unknown
4954
5052
  try {
@@ -4960,7 +5058,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4960
5058
  return derived.then(
4961
5059
  (settled) => {
4962
5060
  if (isResponseResult(settled) || settled instanceof Response) {
4963
- return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders)
5061
+ return this.respondFused(settled, ctx)
4964
5062
  }
4965
5063
  Object.assign(ctx, settled as object)
4966
5064
  return runBefore()
@@ -4969,7 +5067,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4969
5067
  )
4970
5068
  }
4971
5069
  if (isResponseResult(derived) || derived instanceof Response) {
4972
- return fusedRespond(derived, ctx, this.responseBodyTag, this.staticResponseHeaders)
5070
+ return this.respondFused(derived, ctx)
4973
5071
  }
4974
5072
  Object.assign(ctx, derived as object)
4975
5073
  return runBefore()
@@ -5472,7 +5570,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5472
5570
  entry: RouteEntry,
5473
5571
  ctx: RawContext,
5474
5572
  run: () => MaybePromise<T>,
5475
- finalize: (result: unknown, set: CtxSet) => T,
5573
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5476
5574
  wrapResponse: (response: Response | ResponseResult) => T,
5477
5575
  ): MaybePromise<T> {
5478
5576
  let outcome: MaybePromise<T>
@@ -5492,7 +5590,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5492
5590
  entry: RouteEntry,
5493
5591
  source: RequestSource,
5494
5592
  ctx: RawContext,
5495
- finalize: (result: unknown, set: CtxSet) => T,
5593
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5496
5594
  wrapResponse: (response: Response | ResponseResult) => T,
5497
5595
  ): MaybePromise<T> {
5498
5596
  return executeRouteProgram(this, entry, entry.program, source, ctx, finalize, wrapResponse)
@@ -5536,7 +5634,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5536
5634
  entry: RouteEntry,
5537
5635
  error: unknown,
5538
5636
  ctx: RawContext,
5539
- finalize: (result: unknown, set: CtxSet) => T,
5637
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5540
5638
  wrapResponse: (response: Response | ResponseResult) => T,
5541
5639
  ): MaybePromise<T> {
5542
5640
  return this.handleLifecycleError(entry, error, ctx, finalize, wrapResponse)
@@ -5548,7 +5646,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5548
5646
  entry: RouteEntry,
5549
5647
  ctx: RawContext,
5550
5648
  result: unknown,
5551
- finalize: (result: unknown, set: CtxSet) => T,
5649
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5552
5650
  wrapResponse: (response: Response | ResponseResult) => T,
5553
5651
  ): MaybePromise<T> {
5554
5652
  return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, result)
@@ -5577,7 +5675,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5577
5675
  entry: RouteEntry,
5578
5676
  source: RequestSource,
5579
5677
  ctx: RawContext,
5580
- finalize: (result: unknown, set: CtxSet) => T,
5678
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5581
5679
  wrapResponse: (response: Response | ResponseResult) => T,
5582
5680
  ): Promise<T> {
5583
5681
  return readBodyFramed(
@@ -5598,7 +5696,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5598
5696
  entry: RouteEntry,
5599
5697
  parsed: unknown,
5600
5698
  ctx: RawContext,
5601
- finalize: (result: unknown, set: CtxSet) => T,
5699
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5602
5700
  wrapResponse: (response: Response | ResponseResult) => T,
5603
5701
  ): MaybePromise<T> {
5604
5702
  try {
@@ -5633,14 +5731,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5633
5731
  private executeHandler<T>(
5634
5732
  entry: RouteEntry,
5635
5733
  ctx: RawContext,
5636
- finalize: (result: unknown, set: CtxSet) => T,
5734
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5637
5735
  wrapResponse?: (response: Response | ResponseResult) => T,
5638
5736
  ): MaybePromise<T> {
5639
5737
  if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
5640
5738
  const handlerOutput = entry.handler(ctx)
5641
5739
  if (handlerOutput instanceof Promise) {
5642
5740
  return handlerOutput.then(
5643
- (value) => finalize(value, responseSet(ctx)),
5741
+ (value) => finalize(value, responseSet(ctx), ctx),
5644
5742
  (err) => {
5645
5743
  if (wrapResponse) {
5646
5744
  return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
@@ -5649,14 +5747,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5649
5747
  },
5650
5748
  )
5651
5749
  }
5652
- return finalize(handlerOutput, responseSet(ctx))
5750
+ return finalize(handlerOutput, responseSet(ctx), ctx)
5653
5751
  }
5654
5752
 
5655
5753
  private handleValidationErrorRecovery<T>(
5656
5754
  entry: RouteEntry,
5657
5755
  recovery: unknown,
5658
5756
  ctx: RawContext,
5659
- finalize: (result: unknown, set: CtxSet) => T,
5757
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5660
5758
  wrapResponse: (response: Response | ResponseResult) => T,
5661
5759
  originalIssues: ReadonlyArray<StandardIssue>,
5662
5760
  kind: "body" | "query",
@@ -5706,7 +5804,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5706
5804
  entry: RouteEntry,
5707
5805
  result: StandardResult<unknown>,
5708
5806
  ctx: RawContext,
5709
- finalize: (result: unknown, set: CtxSet) => T,
5807
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5710
5808
  wrapResponse: (response: Response | ResponseResult) => T,
5711
5809
  ): MaybePromise<T> {
5712
5810
  if (result.issues !== undefined) {
@@ -5746,7 +5844,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5746
5844
  private runQueryOnly<T>(
5747
5845
  entry: RouteEntry,
5748
5846
  ctx: RawContext,
5749
- finalize: (result: unknown, set: CtxSet) => T,
5847
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5750
5848
  wrapResponse: (response: Response | ResponseResult) => T,
5751
5849
  ): MaybePromise<T> {
5752
5850
  try {
@@ -5784,7 +5882,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5784
5882
  entry: RouteEntry,
5785
5883
  result: StandardResult<unknown>,
5786
5884
  ctx: RawContext,
5787
- finalize: (result: unknown, set: CtxSet) => T,
5885
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5788
5886
  wrapResponse: (response: Response | ResponseResult) => T,
5789
5887
  ): MaybePromise<T> {
5790
5888
  if (result.issues !== undefined) {
@@ -5829,13 +5927,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5829
5927
  */
5830
5928
  private applyOnResponse(response: Response, req: Request): MaybePromise<Response> {
5831
5929
  const hooks = this.onResponseHooks
5832
- let current = response
5930
+ // Hooks write onto the response they are handed (cookies, CORS, request ids), so every response
5931
+ // entering the chain - and any other one a hook hands back - is first made this request's own.
5932
+ let current = writableResponse(response, req)
5833
5933
  for (let i = 0; i < hooks.length; i++) {
5834
5934
  const next = (hooks[i] as RawOnResponse)(current, req)
5835
5935
  if (next instanceof Promise) {
5836
- return next.then((settled) => this.continueOnResponse(settled, i + 1, req))
5936
+ const handed = current
5937
+ return next.then((settled) => this.continueOnResponse(settled, i + 1, req, handed))
5837
5938
  }
5838
- current = next
5939
+ current = next === current ? next : writableResponse(next, req)
5839
5940
  }
5840
5941
  return current
5841
5942
  }
@@ -5909,11 +6010,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5909
6010
  response: Response,
5910
6011
  nextIndex: number,
5911
6012
  req: Request,
6013
+ handed: Response,
5912
6014
  ): Promise<Response> {
5913
- let current = response
6015
+ let current = response === handed ? response : writableResponse(response, req)
5914
6016
  for (let i = nextIndex; i < this.onResponseHooks.length; i++) {
5915
6017
  const next = (this.onResponseHooks[i] as RawOnResponse)(current, req)
5916
- current = next instanceof Promise ? await next : next
6018
+ const settled = next instanceof Promise ? await next : next
6019
+ current = settled === current ? settled : writableResponse(settled, req)
5917
6020
  }
5918
6021
  return current
5919
6022
  }
@@ -5945,13 +6048,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5945
6048
  private finishLifecycleContract<T>(
5946
6049
  entry: RouteEntry,
5947
6050
  ctx: RawContext,
5948
- finalize: (result: unknown, set: CtxSet) => T,
6051
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5949
6052
  wrapResponse: (response: Response | ResponseResult) => T,
5950
6053
  result: unknown,
5951
6054
  ): MaybePromise<T> {
5952
6055
  try {
5953
6056
  const contract = entry.responseContract
5954
- if (contract === undefined) return finalize(result, responseSet(ctx))
6057
+ if (contract === undefined) return finalize(result, responseSet(ctx), ctx)
5955
6058
  const checked = contract.runtime.check(contract.definition, result, ctx)
5956
6059
  if (checked instanceof Promise) {
5957
6060
  return checked.then(
@@ -5967,7 +6070,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5967
6070
 
5968
6071
  private finishContractOutcome<T>(
5969
6072
  ctx: RawContext,
5970
- finalize: (result: unknown, set: CtxSet) => T,
6073
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
5971
6074
  wrapResponse: (response: Response | ResponseResult) => T,
5972
6075
  outcome: {
5973
6076
  readonly kind: "ok" | "warn" | "violation"
@@ -5990,7 +6093,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5990
6093
  detail: outcome.message,
5991
6094
  })
5992
6095
  }
5993
- return finalize(outcome.value, responseSet(ctx))
6096
+ return finalize(outcome.value, responseSet(ctx), ctx)
5994
6097
  }
5995
6098
 
5996
6099
  /** Synchronous by design, and `MaybePromise` rather than `Promise` for the same reason: control flow
@@ -6002,7 +6105,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6002
6105
  entry: RouteEntry,
6003
6106
  err: unknown,
6004
6107
  ctx: RawContext,
6005
- finalize: (result: unknown, set: CtxSet) => T,
6108
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
6006
6109
  wrapResponse: (response: Response | ResponseResult) => T,
6007
6110
  ): MaybePromise<T> {
6008
6111
  // A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
@@ -6010,10 +6113,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6010
6113
  // SvelteKit semantics): as built, plus the cookies the request queued, so a session set or
6011
6114
  // cleared before `throw redirect(...)` / `requireSession(...)` ships. Don't run onError or log
6012
6115
  // 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))
6116
+ if (err instanceof Response && err !== ctx.jsonReply)
6117
+ return finalize(err, responseSet(ctx), ctx)
6118
+ // Same rule for a thrown `status(...)` or `c.json(...)`, which stay held to the route's contract.
6119
+ if (err instanceof Response || isResponseResult(err))
6017
6120
  return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, err)
6018
6121
  if (entry.onError.length === 0) {
6019
6122
  // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
@@ -6030,7 +6133,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6030
6133
  entry: RouteEntry,
6031
6134
  err: unknown,
6032
6135
  ctx: RawContext,
6033
- finalize: (result: unknown, set: CtxSet) => T,
6136
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
6034
6137
  wrapResponse: (response: Response | ResponseResult) => T,
6035
6138
  ): Promise<T> {
6036
6139
  // Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
@@ -6038,7 +6141,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6038
6141
  for (let i = 0; i < entry.onError.length; i++) {
6039
6142
  const outcome = entry.onError[i]!(err, ctx)
6040
6143
  const handled = outcome instanceof Promise ? await outcome : outcome
6041
- if (handled !== undefined) return finalize(handled, responseSet(ctx))
6144
+ if (handled === undefined) continue
6145
+ const contract = entry.responseContract
6146
+ if (contract === undefined || (handled !== ctx.jsonReply && !isResponseResult(handled)))
6147
+ return finalize(handled, responseSet(ctx), ctx)
6148
+ // Checked here, not through finishLifecycleContract: a throwing schema must not re-enter onError.
6149
+ try {
6150
+ const checked = await contract.runtime.check(contract.definition, handled, ctx)
6151
+ return this.finishContractOutcome(ctx, finalize, wrapResponse, checked)
6152
+ } catch (checkError) {
6153
+ this.logRequestError(checkError, ctx)
6154
+ return wrapResponse(plainError(500, "internal_error"))
6155
+ }
6042
6156
  }
6043
6157
  this.logRequestError(err, ctx)
6044
6158
  return wrapResponse(plainError(500, "internal_error"))
@@ -6166,7 +6280,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6166
6280
  request: Request,
6167
6281
  params: Record<string, string>,
6168
6282
  ): MaybePromise<Response> => {
6169
- const outcome = capped!(request, params, undefined, signal!, budget!, peer, true)
6283
+ let outcome: MaybePromise<Response>
6284
+ try {
6285
+ outcome = capped!(request, params, undefined, signal!, budget!, peer, true)
6286
+ } catch (err) {
6287
+ // A synchronous render throw, as in runMatchedLanes.
6288
+ outcome = this.contextlessBareError(
6289
+ err,
6290
+ request,
6291
+ params,
6292
+ undefined,
6293
+ signal!,
6294
+ budget!,
6295
+ peer,
6296
+ this.wrapWebResponse,
6297
+ )
6298
+ }
6170
6299
  return outcome instanceof Promise
6171
6300
  ? outcome.then((response) => finishNative(request, response))
6172
6301
  : finishNative(request, outcome)
@@ -6183,7 +6312,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6183
6312
  markFramed(request)
6184
6313
  return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
6185
6314
  ? this.fetchMatched(request, entry, EMPTY_PARAMS, peer)
6186
- : capped(request, EMPTY_PARAMS, undefined, signal!, budget!, peer, true)
6315
+ : runNative(request, EMPTY_PARAMS)
6187
6316
  }
6188
6317
  }
6189
6318
  return (request) => {
@@ -6211,7 +6340,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6211
6340
  if (malformed(params)) return fallback(request, server)
6212
6341
  return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
6213
6342
  ? this.fetchMatched(request, entry, params, peer)
6214
- : capped(request, params, undefined, signal!, budget!, peer, true)
6343
+ : runNative(request, params)
6215
6344
  }
6216
6345
  }
6217
6346
  return (request, server) => {
@@ -6239,7 +6368,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6239
6368
  // resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
6240
6369
  if (
6241
6370
  (this.onRequestHooks.length > 0 && !this.bunNativeRequestHooksSafe) ||
6242
- this.wsRouteCount > 0 ||
6371
+ this.wsRoutes.length > 0 ||
6243
6372
  this.preRouteMountCount > 0 ||
6244
6373
  this.clientIpTrust !== undefined
6245
6374
  ) {
@@ -6396,7 +6525,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6396
6525
  }
6397
6526
  // Seal before Bun.serve: in `"error"` mode this throws instead of leaving a bound port behind.
6398
6527
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
6399
- sealHookAudit(this, this.catalog.size, this.logger)
6528
+ sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger)
6400
6529
  // Bun's `Server` is the concrete handle; we expose the stable `RunningServer`
6401
6530
  // subset so the public types don't depend on the ambient `Bun` global. The cast
6402
6531
  // bridges them - Bun's `.port` is `number | undefined` (undefined only for unix
@@ -6407,7 +6536,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6407
6536
  // `websocket` handlers are one shared dispatcher - each connection's `ws.data.handler` is the
6408
6537
  // matched route's handler, set by `server.upgrade`.
6409
6538
  // 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.
6539
+ // because a socket route means ws() ran, and ws() requires the runtime at registration.
6411
6540
  // Native pub/sub when the app has WS routes and none validate outbound frames: `ws.subscribe` and
6412
6541
  // `app.publish` go through Bun's own (uWebSockets) broadcast instead of the JS registry loop.
6413
6542
  // A mounted app counts only while it has a WebSocket route (see `[GET_WS_RUNTIME]`), so composing
@@ -6424,7 +6553,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6424
6553
  // that registry in the upgrade outcome instead of accidentally broadcasting child sockets through
6425
6554
  // the parent.
6426
6555
  const nativePubsub =
6427
- this.wsRouteCount > 0 && !this.mountsTakeUpgrades() && !this.wsHasValidatedSend
6556
+ this.wsRoutes.length > 0 && !this.mountsTakeUpgrades() && !this.wsHasValidatedSend
6428
6557
  const wsHandlers = wsRuntime?.bunHandlers(this.topics ?? wsRuntime.createTopics(), nativePubsub)
6429
6558
  // Spread rather than pass `hostname: undefined` (or an undefined idle timeout or TLS) - Bun treats
6430
6559
  // an explicit undefined as a value on some option paths, and omitting is what selects its own
@@ -6492,7 +6621,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6492
6621
  }
6493
6622
  }
6494
6623
  this.sealed = true
6495
- if (this.gracefulSignals) this.installSignalHandlers()
6624
+ if (this.gracefulSignals) this.installSignalHandlers(this.gracefulSignals)
6496
6625
  return running
6497
6626
  }
6498
6627
 
@@ -6525,12 +6654,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6525
6654
  ])
6526
6655
  }
6527
6656
 
6528
- private installSignalHandlers(): void {
6657
+ private installSignalHandlers(drain: { readonly drainMs?: number }): void {
6529
6658
  // Drain, then let the process exit naturally - the stopped server no longer
6530
6659
  // holds the event loop open. Opt-in (`gracefulSignals`), so taking over the
6531
6660
  // signals is consented; we don't force `process.exit`.
6532
6661
  const onSignal = (): void => {
6533
- void this.stop()
6662
+ void this.stop(drain)
6534
6663
  }
6535
6664
  process.once("SIGTERM", onSignal)
6536
6665
  process.once("SIGINT", onSignal)