@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
@@ -1,6 +1,7 @@
1
1
  import { admitDeadline, createRequestBudget, createUnboundedRequestBudget, NIFRA_DEADLINE_HEADER, } from "../budget.js";
2
2
  import { FrameworkError, RouteConfigError } from "../errors.js";
3
3
  import { DEFAULT_CAPABILITY_INTERCEPTOR_TIMEOUT_MS, } from "../internal/capability-runtime.js";
4
+ import { adoptStaticTags, enterPlugin, leavePlugin, tagAdoption, tagHook, tagScoped, tagStatic, } from "../internal/hook-origins.js";
4
5
  import { assuranceDeclarationsOf, assuranceEvidenceFor, } from "../internal/route-assurance.js";
5
6
  import { RouteCatalog } from "../internal/route-catalog.js";
6
7
  import { compileRouteOptions } from "../internal/route-compiler.js";
@@ -21,12 +22,12 @@ import { queryObjectOf, searchOf } from "./query.js";
21
22
  import { PLATFORM_PEER, RequestContext, readBodyFramed, } from "./request-context.js";
22
23
  import { recordRequestReplacement } from "./request-lineage.js";
23
24
  import { hasDotSegment, resolveDotSegments } from "./request-target.js";
24
- import { applyStaticResponseHeaders, buildStaticResponseHeaders, fusedRespond, fusedRespondNoSet, toResponse, } from "./respond.js";
25
- import { CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, isResponseResult, requestOf, } from "./runtime-core.js";
25
+ import { applyStaticResponseHeaders, buildStaticResponseHeaders, fusedRespond, fusedRespondNoSet, toResponse, writableResponse, } from "./respond.js";
26
+ import { CONTEXT_REQUEST, CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, isResponseResult, requestOf, } from "./runtime-core.js";
26
27
  import { normalizeStaticResponseHeaders } from "./static-headers.js";
27
28
  import { plainValidationError } from "./validation.js";
28
29
  import { NIFRA_BACKEND_WS_RUNTIME } from "../mount.js";
29
- import { GET_WS_RUNTIME, INSTALL_EFFECT_LEDGER, INSTALL_IDEMPOTENCY, INSTALL_MCP, INSTALL_NODE_DIRECT, INSTALL_NOT_FOUND, INSTALL_RESPONSE_CONTRACT, INSTALL_RESPONSE_OBSERVER, INSTALL_SSE, INSTALL_WS, NODE_NATIVE_MOUNT, RESOLVE_NODE_MOUNT, } from "./install.js";
30
+ import { GET_WS_RUNTIME, INSTALL_EFFECT_LEDGER, INSTALL_IDEMPOTENCY, INSTALL_MCP, INSTALL_NODE_DIRECT, INSTALL_NOT_FOUND, INSTALL_RESPONSE_CONTRACT, INSTALL_RESPONSE_OBSERVER, INSTALL_SSE, INSTALL_WS, NODE_NATIVE_MOUNT, REPORT_ERROR, RESOLVE_NODE_MOUNT, } from "./install.js";
30
31
  import { jsonLogger } from "./logger.js";
31
32
  // CORS uses this marker to say its request hook only handles OPTIONS preflight. Bun can keep
32
33
  // ordinary registered routes on its native table and send unmatched OPTIONS requests through the
@@ -214,11 +215,16 @@ const underPrefix = (prefix) => {
214
215
  };
215
216
  };
216
217
  /** A merged server's hook scope: exactly the routes MERGED, snapshotted into a dedicated matcher so
217
- * the guard never reflects whatever the source's own catalog grows into afterwards. */
218
+ * the guard never reflects whatever the source's own catalog grows into afterwards. An `OPTIONS`
219
+ * request to a path they serve is in scope too: a CORS preflight names the path, not a route, and
220
+ * the group's `cors()` is the only hook that can answer it. */
218
221
  const servedBy = (routes) => {
219
222
  const scope = new RouteCatalog();
220
223
  scope.addBatch(routes);
221
- return (req) => scope.find(req.method, pathnameOf(req.url)).found;
224
+ return (req) => {
225
+ const match = scope.find(req.method, pathnameOf(req.url));
226
+ return match.found || (req.method === "OPTIONS" && match.reason === "method-not-allowed");
227
+ };
222
228
  };
223
229
  /** Gate adopted hooks to `inScope` (`undefined`: adopt them unchanged). The request is a request
224
230
  * hook's first argument (`reqAt` 0) and every response-side hook's second; out of scope a hook
@@ -305,30 +311,67 @@ const captureCallerSite = () => {
305
311
  return undefined;
306
312
  };
307
313
  const hookAudit = /* @__PURE__ */ new WeakMap();
314
+ /** Whether `from` reaches `target` through mounts that keep the path and take a request under `path`.
315
+ * Module-level, not a `Server` method, so a production bundle drops it with its dev-only caller. */
316
+ function mountLeadsBack(from, target, path, mountsOf, seen) {
317
+ if (from === target)
318
+ return true;
319
+ if (!(from instanceof Server) || seen.has(from))
320
+ return false;
321
+ seen.add(from);
322
+ const under = (prefix) => (prefix === "/" ? "/" : `${prefix}/`);
323
+ return mountsOf(from).some((mount) => !mount.stripPrefix &&
324
+ (under(path).startsWith(under(mount.path)) || under(mount.path).startsWith(under(path))) &&
325
+ mountLeadsBack(mount.app, target, path, mountsOf, seen));
326
+ }
327
+ /** Set by `@nifrajs/web`'s `createWebApp` on its app once the page routes are declared. */
328
+ const PAGES_DECLARED = /* @__PURE__ */ Symbol.for("nifra.web.pagesDeclared");
308
329
  /** Start tracking one server's order-scoped hook pushes (once, from the constructor). */
309
330
  const beginHookAudit = (key, policy, hasCustomLogger) => {
310
331
  hookAudit.set(key, { policy, hasCustomLogger, sites: [], checked: false });
311
332
  };
312
333
  /** Record one order-scoped hook push: the route count at that moment (so `routesAtPush === total` at
313
- * seal means no route was declared after it) and the caller's frame. */
334
+ * seal means no route was declared after it) and the caller's frame. Hooks a WebSocket handshake runs
335
+ * count sockets as routes; `around` and `afterHandle` wrap a response, so they count HTTP routes only. */
314
336
  const recordScopedHook = (key, routesAtPush, kind) => {
337
+ // Not a dead hook when routes follow it, but every page already copied its chain without it.
338
+ if (PAGES_DECLARED in key) {
339
+ const site = captureCallerSite();
340
+ throw new FrameworkError("HOOK_AFTER_PAGES", `${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.`);
341
+ }
315
342
  const state = hookAudit.get(key);
316
343
  if (state === undefined || state.policy === "off")
317
344
  return;
318
345
  state.sites.push({ kind, routesAtPush, site: captureCallerSite() });
319
346
  };
347
+ /** Hook provenance for an adoption (dev only, see `internal/hook-origins.ts`): which scope each
348
+ * wrapper just appended to a hook list gates its hook by. `pairs` holds each source list with the target list it
349
+ * was appended to, read right after the append so the wrappers are the target list's tail. */
350
+ const tagAdopted = (target, source, prefix, routes, pairs) => {
351
+ tagAdoption(target, source, prefix);
352
+ const scope = prefix !== undefined
353
+ ? { prefix }
354
+ : { routes: routes.map((route) => ({ method: route.method, path: route.path })) };
355
+ for (const [originals, appended] of pairs) {
356
+ if (originals.length > 0)
357
+ tagScoped(originals, appended.slice(-originals.length), scope);
358
+ }
359
+ };
320
360
  /** Run the deadness check exactly once. A hook whose recorded route count equals the final route count
321
361
  * had no route declared after it - unambiguously dead, since the scoping feature (a hook that covers
322
362
  * only later routes) always leaves at least one such route. Zero-route apps are exempt (different
323
363
  * mistake, and the warning would be noise). */
324
- const sealHookAudit = (key, total, logger) => {
364
+ const sealHookAudit = (key, total, sockets, logger) => {
325
365
  const state = hookAudit.get(key);
326
366
  if (state === undefined || state.checked)
327
367
  return;
328
368
  state.checked = true;
329
- if (state.policy === "off" || total === 0)
369
+ if (state.policy === "off" || total + sockets === 0)
330
370
  return;
331
- const dead = state.sites.filter((hook) => hook.routesAtPush === total);
371
+ const dead = state.sites.filter((hook) => hook.routesAtPush ===
372
+ (hook.kind === "around" || hook.kind === "aroundCapability" || hook.kind === "afterHandle"
373
+ ? total
374
+ : total + sockets));
332
375
  if (dead.length === 0)
333
376
  return;
334
377
  const detail = dead.map((hook) => ` ${hook.kind}() at ${hook.site ?? "<unknown>"}`).join("\n");
@@ -355,7 +398,9 @@ export class Server {
355
398
  catalog;
356
399
  /** WebSocket routes, matched separately at upgrade time (a GET + `Upgrade: websocket`). */
357
400
  wsRouter;
358
- wsRouteCount;
401
+ /** Each socket path, its declared capabilities, the evidence declarations its handshake captured
402
+ * and the chain it runs; reflection reads it. */
403
+ wsRoutes;
359
404
  /** `wsChanges` when `wsMountsLive` was last worked out; -1 until then. */
360
405
  wsMountsCheckedAt = -1;
361
406
  /** Whether an app mounted here takes WebSocket upgrades, as of `wsMountsCheckedAt`. */
@@ -463,8 +508,13 @@ export class Server {
463
508
  /** Body/raw response hooks need a framework-payload marker; keep that decision per app. */
464
509
  responseBodyTag;
465
510
  responseBodyOwners;
466
- finalizeResponse = (result, set) => toResponse(result, set, this.responseBodyTag, this.staticResponseHeaders);
511
+ finalizeResponse = (result, set, ctx) => toResponse(result, set, this.responseBodyTag, this.staticResponseHeaders,
512
+ // Response hooks write next, and they run for this same request object.
513
+ ctx !== undefined && this.onResponseHooks.length > 0 ? ctx[CONTEXT_REQUEST] : undefined);
467
514
  responseRequests;
515
+ /** A request the WebSocket handshake check ran the onRequest hooks on and then passed to HTTP →
516
+ * what the hooks returned, so `fetch` routes that instead of running the hooks a second time. */
517
+ wsHookedPasses;
468
518
  /** Original source → request observed by generic request hooks, including in-place mutations. */
469
519
  responseSources;
470
520
  /** Memoized NodeRequestContext per plain-`Request` source - see {@link nodeRequestContextOf}. */
@@ -488,7 +538,7 @@ export class Server {
488
538
  this.routePrefix = "";
489
539
  this.catalog = new RouteCatalog();
490
540
  this.wsRouter = new Router();
491
- this.wsRouteCount = 0;
541
+ this.wsRoutes = [];
492
542
  this.topics = undefined;
493
543
  const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
494
544
  assertByteLimit(maxBodyBytes, "maxBodyBytes");
@@ -500,7 +550,13 @@ export class Server {
500
550
  this.wsMaxPayloadBytes = wsMaxPayloadBytes;
501
551
  this.requestTimeoutMs = options.requestTimeoutMs ?? 0;
502
552
  this.wsUpgradeTimeoutMs = options.wsUpgradeTimeoutMs ?? DEFAULT_WS_UPGRADE_TIMEOUT_MS;
503
- this.clientIpTrust = options.clientIp;
553
+ const trust = options.clientIp;
554
+ if (trust !== undefined &&
555
+ !("header" in trust) &&
556
+ !(Number.isSafeInteger(trust.trustedHops) && trust.trustedHops >= 0)) {
557
+ throw new RangeError("clientIp trustedHops must be a non-negative integer");
558
+ }
559
+ this.clientIpTrust = trust;
504
560
  this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false;
505
561
  this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000;
506
562
  this.deadlineAdmissionOptions = Object.freeze({
@@ -516,7 +572,8 @@ export class Server {
516
572
  if (!Number.isFinite(this.maxInboundDeadlineMs) || this.maxInboundDeadlineMs <= 0) {
517
573
  throw new RangeError("maxInboundDeadlineMs must be a finite positive number");
518
574
  }
519
- this.gracefulSignals = options.gracefulSignals ?? false;
575
+ this.gracefulSignals =
576
+ options.gracefulSignals === true ? {} : options.gracefulSignals || undefined;
520
577
  this.stopHooks = [];
521
578
  this.fetchMounts = [];
522
579
  this.mountOrder = 0;
@@ -565,6 +622,7 @@ export class Server {
565
622
  this.responseBodyTag = undefined;
566
623
  this.responseBodyOwners = new Set();
567
624
  this.responseRequests = new WeakMap();
625
+ this.wsHookedPasses = undefined;
568
626
  this.responseSources = new WeakMap();
569
627
  this.nodeContexts = new WeakMap();
570
628
  this.appliedPlugins = new Map();
@@ -659,6 +717,15 @@ export class Server {
659
717
  opaque: options.opaque,
660
718
  app,
661
719
  };
720
+ // Mounts run before routes: a request under this prefix that no mount on the way strips comes back
721
+ // here unchanged until the stack overflows, which takes the process down. Dev-time only, like the
722
+ // hook audit: a production define folds this away, and the first dev request would hit it anyway.
723
+ if (hookAuditRuntime &&
724
+ process.env.NODE_ENV !== "production" &&
725
+ !mount.stripPrefix &&
726
+ mountLeadsBack(app, this, mount.path, (server) => server.fetchMounts, new Set())) {
727
+ throw new RouteConfigError("MOUNT_CYCLE", `mount ${mount.path}: the app leads back to this one without stripPrefix`);
728
+ }
662
729
  this.fetchMounts.push(mount);
663
730
  this.fetchMounts.sort(compareMounts);
664
731
  this.preRouteMountCount += 1;
@@ -679,8 +746,10 @@ export class Server {
679
746
  derive(fn) {
680
747
  this.assertConfigurable("derive()");
681
748
  this.derives.push(fn);
682
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
683
- recordScopedHook(this, this.catalog.size, "derive");
749
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
750
+ recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "derive");
751
+ tagHook(fn, "derive", this);
752
+ }
684
753
  return this;
685
754
  }
686
755
  /**
@@ -710,22 +779,27 @@ export class Server {
710
779
  throw new TypeError('authenticate() stage mode must be "sync" or "async"');
711
780
  }
712
781
  this.authStages.push(stage);
782
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
783
+ tagHook(stage, "authenticate", this);
713
784
  return this;
714
785
  }
715
786
  decorate(key, value) {
716
787
  this.assertConfigurable("decorate()");
717
788
  this.decorations[key] = value;
718
789
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
719
- recordScopedHook(this, this.catalog.size, "decorate");
790
+ recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "decorate");
720
791
  return this;
721
792
  }
722
793
  /**
723
- * Run before routing on the raw request. Return a `Response` to short-circuit, or a replacement
724
- * `Request` to continue routing with a rewritten method/URL/headers. Global.
794
+ * Run before routing on the raw request. Return a `Response` or a `status()` result to
795
+ * short-circuit, or a replacement `Request` to continue routing with a rewritten method/URL/headers.
796
+ * Global.
725
797
  */
726
798
  onRequest(fn) {
727
799
  this.assertConfigurable("onRequest()");
728
800
  this.onRequestHooks.push(fn);
801
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
802
+ tagHook(fn, "onRequest", this);
729
803
  this.onNodeRequestHooks.push(undefined);
730
804
  this.nodeRequestHooksComplete = false;
731
805
  this.bunNativeRequestHooksSafe = false;
@@ -739,8 +813,10 @@ export class Server {
739
813
  beforeHandle(fn) {
740
814
  this.assertConfigurable("beforeHandle()");
741
815
  this.beforeHandleHooks.push(fn);
742
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
743
- recordScopedHook(this, this.catalog.size, "beforeHandle");
816
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
817
+ recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "beforeHandle");
818
+ tagHook(fn, "beforeHandle", this);
819
+ }
744
820
  return this;
745
821
  }
746
822
  /**
@@ -754,8 +830,10 @@ export class Server {
754
830
  around(fn) {
755
831
  this.assertConfigurable("around()");
756
832
  this.aroundHooks.push(fn);
757
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
833
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
758
834
  recordScopedHook(this, this.catalog.size, "around");
835
+ tagHook(fn, "around", this);
836
+ }
759
837
  return this;
760
838
  }
761
839
  /**
@@ -793,8 +871,10 @@ export class Server {
793
871
  afterHandle(fn) {
794
872
  this.assertConfigurable("afterHandle()");
795
873
  this.afterHandleHooks.push(fn);
796
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
874
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
797
875
  recordScopedHook(this, this.catalog.size, "afterHandle");
876
+ tagHook(fn, "afterHandle", this);
877
+ }
798
878
  return this;
799
879
  }
800
880
  /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500).
@@ -803,14 +883,18 @@ export class Server {
803
883
  onError(fn) {
804
884
  this.assertConfigurable("onError()");
805
885
  this.onErrorHooks.push(fn);
806
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
807
- recordScopedHook(this, this.catalog.size, "onError");
886
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
887
+ recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "onError");
888
+ tagHook(fn, "onError", this);
889
+ }
808
890
  return this;
809
891
  }
810
892
  /** Transform every outgoing response - success, error, 404, 405, short-circuit. Global. */
811
893
  onResponse(fn) {
812
894
  this.assertConfigurable("onResponse()");
813
895
  this.onResponseHooks.push(fn);
896
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
897
+ tagHook(fn, "onResponse", this);
814
898
  this.onNodeResponseHooks.push(undefined);
815
899
  this.nodeResponseHooksComplete = false;
816
900
  this.bunNativeResponseHeadersOnly = false;
@@ -842,6 +926,10 @@ export class Server {
842
926
  */
843
927
  responseHeaders(record) {
844
928
  this.assertConfigurable("responseHeaders()");
929
+ if (hookAuditRuntime &&
930
+ process.env.NODE_ENV !== "production" &&
931
+ this.onResponseHooks.length === 0)
932
+ tagStatic(this, Object.keys(record), record);
845
933
  return this.addStaticResponseHeaders(normalizeStaticResponseHeaders(record));
846
934
  }
847
935
  /** Wire an already-validated lowercase record into the static tier, or - once a dynamic response
@@ -858,13 +946,15 @@ export class Server {
858
946
  });
859
947
  return this;
860
948
  }
861
- // Route the hook through the same seam the static tier uses. On a framework response (headers
862
- // stamped mutable) it applies the defaults in place and returns the SAME object, so the
863
- // serialized-body marker survives for later `onResponseBody`/`onResponseRaw` observers and the
864
- // Node writer; reconstructing a `Response` here would strip that marker and reclassify the Node
865
- // outcome from `json` to a generic `response`. A guarded foreign response takes its clone path.
949
+ // Route the hook through the same seam the static tier uses. On a framework response with a
950
+ // body it applies the defaults in place and returns the SAME object, so the serialized-body
951
+ // marker survives for later `onResponseBody`/`onResponseRaw` observers and the Node writer;
952
+ // reconstructing a `Response` here would strip that marker and reclassify the Node outcome from
953
+ // `json` to a generic `response`. A body-less or guarded response is copied, marker included.
866
954
  const statics = buildStaticResponseHeaders(record, this.inheritedStatics);
867
- this.onResponseHooks.push((response) => applyStaticResponseHeaders(response, statics));
955
+ this.onResponseHooks.push((response, req) => applyStaticResponseHeaders(response, statics, req));
956
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
957
+ tagHook(this.onResponseHooks.at(-1), "responseHeaders", this);
868
958
  this.onNodeResponseHooks.push(undefined);
869
959
  this.nodeResponseHooksComplete = false;
870
960
  return this;
@@ -898,6 +988,8 @@ export class Server {
898
988
  onResponseFinalized(fn) {
899
989
  this.assertConfigurable("onResponseFinalized()");
900
990
  this.onResponseFinalizedHooks.push(fn);
991
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
992
+ tagHook(fn, "onResponseFinalized", this);
901
993
  return this;
902
994
  }
903
995
  use(input) {
@@ -925,10 +1017,14 @@ export class Server {
925
1017
  this.activeAssurance.push(...pluginOnly);
926
1018
  const inherited = this.inheritedPlugins;
927
1019
  this.inheritedPlugins = undefined;
1020
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1021
+ enterPlugin(name);
928
1022
  try {
929
1023
  return arg(this);
930
1024
  }
931
1025
  finally {
1026
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1027
+ leavePlugin();
932
1028
  this.inheritedPlugins = inherited;
933
1029
  // Remove only this plugin's temporary evidence. Nested assured plugins may deliberately leave
934
1030
  // subsequent evidence active, so truncating the whole array would lose real ordering semantics.
@@ -957,6 +1053,8 @@ export class Server {
957
1053
  if (arg.onRequest !== undefined) {
958
1054
  this.assertConfigurable("onRequest()");
959
1055
  this.onRequestHooks.push(arg.onRequest);
1056
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1057
+ tagHook(arg.onRequest, "onRequest", this);
960
1058
  this.onNodeRequestHooks.push(arg.onNodeRequest);
961
1059
  if (arg.onNodeRequest === undefined)
962
1060
  this.nodeRequestHooksComplete = false;
@@ -976,6 +1074,8 @@ export class Server {
976
1074
  if (arg.onResponse !== undefined) {
977
1075
  this.assertConfigurable("onResponse()");
978
1076
  this.onResponseHooks.push(arg.onResponse);
1077
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1078
+ tagHook(arg.onResponse, "onResponse", this);
979
1079
  this.onNodeResponseHooks.push(arg.onNodeResponse);
980
1080
  if (arg.onNodeResponse === undefined)
981
1081
  this.nodeResponseHooksComplete = false;
@@ -1014,8 +1114,10 @@ export class Server {
1014
1114
  return this.route("GET", path, schemaOrHandler, handler);
1015
1115
  }
1016
1116
  /**
1017
- * Register a **typed SSE route** - a GET endpoint streaming `text/event-stream` whose event
1018
- * payloads are contracted by `schema.sse`. The handler receives the validated context plus a
1117
+ * Register a **typed SSE route** - an endpoint streaming `text/event-stream` whose event
1118
+ * payloads are contracted by `schema.sse`. It is a GET route, or a POST route when the schema
1119
+ * declares a `body` (a GET carries none): the body validates as on any POST, and the client sends
1120
+ * it with `.subscribe(onEvent, { body })` - the shape of a chat stream that posts its prompt. The handler receives the validated context plus a
1019
1121
  * {@link TypedSSEStream}: `stream.send(event)` is compile-time-checked against the schema and
1020
1122
  * JSON-serialized into the SSE `data:` field. The typed client sees the marker and grows a
1021
1123
  * `.subscribe(onEvent)` for the route with the same payload type - end-to-end typed streaming.
@@ -1030,13 +1132,13 @@ export class Server {
1030
1132
  *
1031
1133
  * // client: const off = api.feed.subscribe((post) => console.log(post.title))
1032
1134
  *
1033
- * `init` passes through to the underlying {@link sse} helper (`keepAlive`, extra headers). The
1034
- * connection closes when the handler resolves, `stream.close()` runs, or the client disconnects
1035
- * (`stream.signal`). Query/body schemas validate exactly as on any other route.
1135
+ * `init` passes through to the underlying {@link sse} helper (`keepAlive`, `maxBufferedBytes`,
1136
+ * extra headers). The connection closes when the handler resolves, `stream.close()` runs, or the
1137
+ * client disconnects (`stream.signal`). Query/body schemas validate exactly as on any other route.
1036
1138
  */
1037
1139
  sse(path, schema, run, init) {
1038
1140
  const handler = (context) => requireSseRuntime(this.sseRuntime).response(context, (stream) => run(context, stream), init);
1039
- return this.route("GET", path, schema, handler);
1141
+ return this.route(schema.body ? "POST" : "GET", path, schema, handler);
1040
1142
  }
1041
1143
  post(path, schemaOrHandler, handler) {
1042
1144
  return this.route("POST", path, schemaOrHandler, handler);
@@ -1092,8 +1194,11 @@ export class Server {
1092
1194
  }
1093
1195
  /**
1094
1196
  * Register a **WebSocket** route. The connection upgrades on a `GET` to `path` carrying
1095
- * `Upgrade: websocket`; the optional `handler.upgrade(c)` runs in the request context first and may
1096
- * reject (return a `Response`) or seed per-connection `ws.data`. WebSockets are served by the
1197
+ * `Upgrade: websocket`. The handshake runs the chain an HTTP `GET` registered here would: the
1198
+ * `authenticate()` stages, `derive`s and `beforeHandle`s added before this call, then the optional
1199
+ * `handler.upgrade(c)`. Any of them may reject (a `Response`, a `status(...)`, a failed auth stage);
1200
+ * `upgrade()` may seed per-connection `ws.data`. To keep a socket public, register it before the
1201
+ * guard. WebSockets are served by the
1097
1202
  * adapter (`listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler`) - not by bare `app.fetch`, which
1098
1203
  * has no socket (a WS path through `app.fetch` is a normal HTTP response).
1099
1204
  *
@@ -1120,10 +1225,20 @@ export class Server {
1120
1225
  this.topics ??= runtime.createTopics();
1121
1226
  // A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
1122
1227
  // already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
1123
- const entry = { handler: runtime.wrapHandler(handler, this.protoPoisoning) };
1124
- for (const form of expandOptionalParams(path))
1125
- this.wsRouter.add("GET", form, entry);
1126
- this.wsRouteCount += 1;
1228
+ const wrapped = runtime.wrapHandler(handler, this.protoPoisoning);
1229
+ const upgrade = runtime.upgradeStep(wrapped);
1230
+ for (const form of expandOptionalParams(path)) {
1231
+ // The chain an HTTP `GET` to `form` would capture now, ending in `upgrade()` instead of a
1232
+ // handler. `afterHandle` and `around` are left out: they wrap a response, and an accepted
1233
+ // handshake has none.
1234
+ const { entry, assurance, descriptor } = this.prepareRoute("WS", form, wrapped.capabilities === undefined ? undefined : { capabilities: wrapped.capabilities }, upgrade);
1235
+ const chain = { ...entry, afterHandle: [] };
1236
+ this.wsRouter.add("GET", form, {
1237
+ handler: wrapped,
1238
+ chain: { ...chain, program: compileRouteProgram({ ...chain, bodySchema: undefined }) },
1239
+ });
1240
+ this.wsRoutes.push({ path: form, capabilities: descriptor.capabilities, assurance, chain });
1241
+ }
1127
1242
  wsChanges += 1;
1128
1243
  return this;
1129
1244
  }
@@ -1315,11 +1430,6 @@ export class Server {
1315
1430
  assurance: routeAssurance,
1316
1431
  };
1317
1432
  }
1318
- /** The idempotency lane's bridge back into the normal matched lanes, resolved to a concrete Response
1319
- * (the lane buffers the body, then replays it through the route's real validation + handler). */
1320
- idempotencyRunLanes(buffered, platform, entry, params, search) {
1321
- return Promise.resolve(this.runMatchedLanes(buffered, platform, entry, params, search, this.finalizeResponse, this.wrapWebResponse, this.webResponseTimeout, false));
1322
- }
1323
1433
  /** @internal Symbol-keyed install seam for the `responseContract()` plugin. Off the public typed surface. */
1324
1434
  [INSTALL_RESPONSE_CONTRACT](runtime) {
1325
1435
  this.assertConfigurable("responseContract()");
@@ -1332,6 +1442,8 @@ export class Server {
1332
1442
  assertConfigurable: (operation) => this.assertConfigurable(operation),
1333
1443
  addResponseHook: (web, node, headerOnly = false) => {
1334
1444
  this.onResponseHooks.push(web);
1445
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1446
+ tagHook(web, headerOnly ? "responseHeaders" : "onResponse", this);
1335
1447
  this.onNodeResponseHooks.push(node);
1336
1448
  if (node === undefined)
1337
1449
  this.nodeResponseHooksComplete = false;
@@ -1370,6 +1482,11 @@ export class Server {
1370
1482
  this.assertConfigurable("nodeDirect()");
1371
1483
  this.nodeOutcomeRuntime = runtime;
1372
1484
  }
1485
+ /** @internal An adapter reports a failure no response hook saw (a hook that threw past `fetch`, a
1486
+ * response the runtime refused to write), logged as Bun's `listen()` logs one. */
1487
+ [REPORT_ERROR](error) {
1488
+ emitRequestErrorLog(this.logger, this.errorLogDetail, error, undefined);
1489
+ }
1373
1490
  /** @internal Symbol-keyed install seam for the `streaming()` plugin. Off the public typed surface. */
1374
1491
  [INSTALL_SSE](runtime) {
1375
1492
  this.assertConfigurable("streaming()");
@@ -1388,7 +1505,7 @@ export class Server {
1388
1505
  * counts. `seen` ends a mount cycle (an app mounted under itself to alias a prefix).
1389
1506
  */
1390
1507
  [GET_WS_RUNTIME](seen) {
1391
- return this.wsRouteCount > 0 ? this.wsRuntime : this.mountedWsRuntime(seen);
1508
+ return this.wsRoutes.length > 0 ? this.wsRuntime : this.mountedWsRuntime(seen);
1392
1509
  }
1393
1510
  /** The `[GET_WS_RUNTIME]` answer for the apps mounted here alone, whatever this app's own routes. */
1394
1511
  mountedWsRuntime(seen = new Set()) {
@@ -1429,15 +1546,15 @@ export class Server {
1429
1546
  * `derive`/`decorate`/`beforeHandle`/`afterHandle`/`onError`/`around` apply to its routes
1430
1547
  * exactly as they did standalone, so a group wires its own plugins. The same locality rule
1431
1548
  * covers the group's `onRequest` hooks: they run only for requests that route to the GROUP's
1432
- * routes (a `bodyLimit()` mounted on an uploads group must not start gating the whole app
1549
+ * 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
1433
1550
  * because the app composed it in), and the group's global assurance follows its hooks onto
1434
1551
  * exactly those routes. A group with hooks but NO routes is a middleware bundle - its hooks
1435
1552
  * can only mean app-wide intent, so they are appended globally, unchanged. Response-side hooks
1436
1553
  * (`onResponse`/`onResponseFinalized`) are appended to this server's. This server's
1437
1554
  * route-scoped chains do NOT retroactively wrap merged routes (order-scoped, like routes
1438
1555
  * registered before a `derive`). Fail closed: a path+method collision throws
1439
- * `RouteConfigError` at merge time, and a group with WebSocket routes is refused (register
1440
- * those on the parent).
1556
+ * `RouteConfigError` at merge time, and a group with WebSocket routes or mounts is refused
1557
+ * (register those on the parent).
1441
1558
  */
1442
1559
  merge(other) {
1443
1560
  this.assertConfigurable("merge()");
@@ -1452,8 +1569,8 @@ export class Server {
1452
1569
  throw new RouteConfigError("INVALID_PATH", "merge() inside a group cannot prefix compiled routes - merge on the parent");
1453
1570
  }
1454
1571
  const source = other;
1455
- if (source.wsRouteCount > 0) {
1456
- throw new RouteConfigError("INVALID_PATH", "merge() does not carry WebSocket routes - register .ws() routes on the parent server");
1572
+ if (source.wsRoutes.length > 0 || source.fetchMounts.length > 0) {
1573
+ throw new RouteConfigError("INVALID_PATH", "merge() does not carry WebSocket routes or mounts - register them on the parent server");
1457
1574
  }
1458
1575
  this.adopt(source, undefined, 0);
1459
1576
  return this;
@@ -1534,13 +1651,13 @@ export class Server {
1534
1651
  if (built !== scope) {
1535
1652
  throw new TypeError(`${label} builder must return its group synchronously`);
1536
1653
  }
1537
- if (scope.wsRouteCount > 0 ||
1654
+ if (scope.wsRoutes.length > 0 ||
1538
1655
  scope.fetchMounts.length > 0 ||
1539
1656
  scope.catalog.entries().some((route) => route.descriptor.tool !== undefined)) {
1540
1657
  throw new RouteConfigError("INVALID_PATH", `${label} cannot hold ws(), mount, or MCP tool routes - add them to the parent`);
1541
1658
  }
1542
1659
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1543
- sealHookAudit(scope, scope.catalog.size, this.logger);
1660
+ sealHookAudit(scope, scope.catalog.size, scope.wsRoutes.length, this.logger);
1544
1661
  this.adopt(scope, fullPrefix, this.globalAssurance.length);
1545
1662
  return this;
1546
1663
  }
@@ -1594,13 +1711,17 @@ export class Server {
1594
1711
  // The group's static declarations came before its own response hooks, so they are folded in
1595
1712
  // first - and fold themselves into a hook here if this server already has one (same ordering
1596
1713
  // rule as a direct `responseHeaders()` call).
1714
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1715
+ adoptStaticTags(this, source, this.onResponseHooks.length > 0);
1597
1716
  this.addStaticResponseHeaders({ ...statics.record });
1598
1717
  }
1599
1718
  else {
1600
1719
  // The static tier is folded into EVERY response this server builds, so a group's static
1601
1720
  // headers cannot join it. A hook ahead of the group's own (the sealed scope is discarded
1602
1721
  // after adoption) keeps their meaning - defaults a value already on the response wins over.
1603
- source.onResponseHooks.unshift((response) => applyStaticResponseHeaders(response, statics));
1722
+ source.onResponseHooks.unshift((response, req) => applyStaticResponseHeaders(response, statics, req));
1723
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1724
+ tagHook(source.onResponseHooks[0], "responseHeaders", source, true);
1604
1725
  source.onNodeResponseHooks.unshift(undefined);
1605
1726
  source.nodeResponseHooksComplete = false;
1606
1727
  }
@@ -1608,6 +1729,17 @@ export class Server {
1608
1729
  this.onResponseHooks.push(...scopeHooks(source.onResponseHooks, responseScope, 1, true));
1609
1730
  this.onNodeResponseHooks.push(...scopeHooks(source.onNodeResponseHooks, responseScope, 1));
1610
1731
  this.onResponseFinalizedHooks.push(...scopeHooks(source.onResponseFinalizedHooks, responseScope, 1));
1732
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
1733
+ // Read before anything else is appended: the wrappers are still each list's tail.
1734
+ const pairs = [];
1735
+ if (inScope !== undefined)
1736
+ pairs.push([source.onRequestHooks, this.onRequestHooks]);
1737
+ if (responseScope !== undefined) {
1738
+ pairs.push([source.onResponseHooks, this.onResponseHooks]);
1739
+ pairs.push([source.onResponseFinalizedHooks, this.onResponseFinalizedHooks]);
1740
+ }
1741
+ tagAdopted(this, source, prefix, sourceRoutes, pairs);
1742
+ }
1611
1743
  this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete;
1612
1744
  this.bunNativeResponseHeadersOnly &&= source.bunNativeResponseHeadersOnly;
1613
1745
  this.hasRawNodeResponseHook ||= source.hasRawNodeResponseHook;
@@ -1724,7 +1856,7 @@ export class Server {
1724
1856
  // Seal once on the first request so test servers (no `listen()`) still get the dead-hook check.
1725
1857
  // After that it is a single boolean read - the per-request cost the option promises to keep at zero.
1726
1858
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1727
- sealHookAudit(this, this.catalog.size, this.logger);
1859
+ sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger);
1728
1860
  // Off path (default): straight through - one property check, no closure, no promise.
1729
1861
  if (this.capacityGate === undefined)
1730
1862
  return this.fetchSourceInner(source, platform);
@@ -1857,7 +1989,7 @@ export class Server {
1857
1989
  resolveNodeSource(source, platform, suppliedRuntime) {
1858
1990
  // Seal once on the Node-direct lane too - it can bypass `fetchSource` entirely (hookless outcome).
1859
1991
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1860
- sealHookAudit(this, this.catalog.size, this.logger);
1992
+ sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger);
1861
1993
  // A paired header-only native hook can preserve the Node-direct JSON/body outcome for successful
1862
1994
  // responses. Arbitrary onResponse transforms need a real Web Response, but a buffered outcome can
1863
1995
  // be materialized with a direct-write marker and return to the socket path when the hook mutates
@@ -2075,7 +2207,7 @@ export class Server {
2075
2207
  continue;
2076
2208
  }
2077
2209
  if (outcome !== undefined)
2078
- return outcome;
2210
+ return webResponseOf(outcome);
2079
2211
  }
2080
2212
  return current;
2081
2213
  }
@@ -2086,7 +2218,7 @@ export class Server {
2086
2218
  if (outcome instanceof Request)
2087
2219
  next = outcome;
2088
2220
  else if (outcome !== undefined)
2089
- return outcome;
2221
+ return webResponseOf(outcome);
2090
2222
  if (i >= this.onRequestHooks.length)
2091
2223
  return next;
2092
2224
  const hook = this.onRequestHooks[i];
@@ -2098,7 +2230,7 @@ export class Server {
2098
2230
  resolveWebSocketUpgrade(req, platform) {
2099
2231
  // Before any header is read: Deno only materializes a request's headers when asked, and that
2100
2232
  // costs a plain request measurably.
2101
- if (this.wsRouteCount === 0 && !this.mountsTakeUpgrades())
2233
+ if (this.wsRoutes.length === 0 && !this.mountsTakeUpgrades())
2102
2234
  return WS_PASS;
2103
2235
  if (req.headers.get("upgrade")?.toLowerCase() !== "websocket")
2104
2236
  return WS_PASS;
@@ -2106,6 +2238,8 @@ export class Server {
2106
2238
  // is an ordinary request: its HTTP route answers it, and normal routing refuses an unknown token.
2107
2239
  if (req.method !== "GET")
2108
2240
  return WS_PASS;
2241
+ // The adapter answers a pass by handing this same object to `fetch`.
2242
+ const received = req;
2109
2243
  // The handshake routes the path `fetch` would: dot segments resolved (see `fetch`).
2110
2244
  if (rawRequestTargets && hasDotSegment(req.url))
2111
2245
  req = withResolvedTarget(req);
@@ -2119,11 +2253,12 @@ export class Server {
2119
2253
  const controller = timeoutMs > 0 ? new AbortController() : undefined;
2120
2254
  const signal = controller?.signal ?? getNeverAbortSignal();
2121
2255
  const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : undefined;
2122
- const timeoutResponse = jsonError(503, "request_timeout");
2123
- const onTimeout = () => ({
2256
+ // A refusal carries the static response headers an HTTP answer would; a guard's already does.
2257
+ const refuse = (response) => ({
2124
2258
  kind: "reject",
2125
- response: timeoutResponse,
2259
+ response: this.wrapWebResponse(response),
2126
2260
  });
2261
+ const onTimeout = () => refuse(jsonError(503, "request_timeout"));
2127
2262
  const bounded = (work, onDone) => controller === undefined
2128
2263
  ? work
2129
2264
  : this.withTimeout(work, controller, onDone, Math.max(0, deadline - Date.now()));
@@ -2136,11 +2271,11 @@ export class Server {
2136
2271
  // already did and made every plain dynamic route pay for unrelated static path bytes.
2137
2272
  const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params);
2138
2273
  if (params === null)
2139
- return { kind: "reject", response: jsonError(400, "malformed_path") };
2140
- const handler = match.payload.handler;
2141
- // Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
2274
+ return refuse(jsonError(400, "malformed_path"));
2275
+ const { handler, chain } = match.payload;
2276
+ // Non-null: a socket route ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
2142
2277
  const pubsub = this.topics;
2143
- const attach = this.wsRuntime.attach;
2278
+ const runtime = this.wsRuntime;
2144
2279
  // CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
2145
2280
  // Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
2146
2281
  // blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
@@ -2150,52 +2285,45 @@ export class Server {
2150
2285
  ? handler.allowedOrigins(origin)
2151
2286
  : origin !== null && handler.allowedOrigins.includes(origin);
2152
2287
  if (!allowed)
2153
- return { kind: "reject", response: jsonError(403, "forbidden_origin") };
2288
+ return refuse(jsonError(403, "forbidden_origin"));
2154
2289
  }
2155
2290
  else if (origin !== null && !wsSameOrigin(origin, request)) {
2156
2291
  // Secure default (no explicit `allowedOrigins`): reject a CROSS-ORIGIN browser handshake - the
2157
2292
  // CSWSH case, since browsers send cookies on WS handshakes and don't apply CORS. Non-browser
2158
2293
  // clients send no `Origin` and pass; same-origin browsers pass. Set `allowedOrigins` to permit
2159
2294
  // specific cross-origin clients (or `() => true` for a genuinely public socket).
2160
- return { kind: "reject", response: jsonError(403, "forbidden_origin") };
2161
- }
2162
- if (handler.upgrade === undefined) {
2163
- return {
2164
- kind: "upgrade",
2165
- handler,
2166
- data: undefined,
2167
- pubsub,
2168
- attach,
2169
- maxPayloadBytes: this.wsMaxPayloadBytes,
2170
- };
2295
+ return refuse(jsonError(403, "forbidden_origin"));
2171
2296
  }
2172
2297
  const budget = controller === undefined
2173
2298
  ? createUnboundedRequestBudget(signal)
2174
2299
  : createRequestBudget({ deadline: deadline, signal });
2175
2300
  const ctx = new RequestContext(request, params, url.search, signal, budget, platform, this.maxBodyBytes, this.protoPoisoning);
2176
- const settle = (value) => value instanceof Response
2177
- ? { kind: "reject", response: value }
2178
- : {
2179
- kind: "upgrade",
2180
- handler,
2181
- data: value,
2182
- pubsub,
2183
- attach,
2184
- maxPayloadBytes: this.wsMaxPayloadBytes,
2185
- };
2301
+ // Rendered as the same chain on an HTTP `GET` renders its early results.
2302
+ const finalize = (result, set) => {
2303
+ const accepted = runtime.accepted(result);
2304
+ return accepted === undefined
2305
+ ? { kind: "reject", response: this.finalizeResponse(result, set) }
2306
+ : {
2307
+ kind: "upgrade",
2308
+ handler,
2309
+ data: accepted.data,
2310
+ pubsub,
2311
+ attach: runtime.attach,
2312
+ maxPayloadBytes: this.wsMaxPayloadBytes,
2313
+ };
2314
+ };
2315
+ const wrap = (response) => finalize(response, EMPTY_RESPONSE_CONTROLS);
2316
+ let outcome;
2186
2317
  try {
2187
- const result = handler.upgrade(ctx);
2188
- if (!(result instanceof Promise))
2189
- return settle(result);
2190
- const pending = result.then(settle, () => ({
2191
- kind: "reject",
2192
- response: jsonError(500, "internal_error"),
2193
- }));
2194
- return bounded(pending, onTimeout);
2318
+ outcome = executeRouteProgram(this, chain, chain.program, request, ctx, finalize, wrap);
2195
2319
  }
2196
2320
  catch {
2197
- return { kind: "reject", response: jsonError(500, "internal_error") };
2321
+ return refuse(jsonError(500, "internal_error"));
2198
2322
  }
2323
+ if (!(outcome instanceof Promise))
2324
+ return outcome;
2325
+ const pending = outcome.then(undefined, () => refuse(jsonError(500, "internal_error")));
2326
+ return bounded(pending, onTimeout);
2199
2327
  };
2200
2328
  const resolveMounted = (request) => {
2201
2329
  // Native pub/sub was chosen because no mounted app took upgrades when `listen()` ran. One that
@@ -2217,20 +2345,25 @@ export class Server {
2217
2345
  }
2218
2346
  if (selected === undefined)
2219
2347
  return WS_PASS;
2220
- const childRequest = selected.stripPrefix
2221
- ? stripMountPrefix(request, selected.path)
2222
- : request;
2348
+ let childRequest = request;
2349
+ if (selected.stripPrefix) {
2350
+ childRequest = stripMountPrefix(request, selected.path);
2351
+ selected.wsPassed ??= new WeakMap();
2352
+ selected.wsPassed.set(request, childRequest);
2353
+ }
2223
2354
  let child;
2224
2355
  try {
2225
2356
  child = selected.resolveWebSocketUpgrade(childRequest, platform);
2226
2357
  }
2227
2358
  catch {
2228
- return { kind: "reject", response: jsonError(500, "internal_error") };
2359
+ return refuse(jsonError(500, "internal_error"));
2229
2360
  }
2230
2361
  const settle = (outcome) => outcome.kind === "pass" ||
2231
2362
  (outcome.kind === "reject" && outcome.response.status === 404 && selected.fallbackOn404)
2232
2363
  ? dispatch(selectedIndex + 1)
2233
- : outcome;
2364
+ : outcome.kind === "reject"
2365
+ ? refuse(outcome.response)
2366
+ : outcome;
2234
2367
  return child instanceof Promise ? child.then(settle) : settle(child);
2235
2368
  };
2236
2369
  return dispatch(0);
@@ -2248,21 +2381,24 @@ export class Server {
2248
2381
  };
2249
2382
  if (this.onRequestHooks.length === 0)
2250
2383
  return resolveWithDeadline(req);
2384
+ // On a pass the adapter hands `received` to `fetch`, which then routes these results. An upgrade
2385
+ // or a rejection never reaches `fetch`, so the entry dies with the request.
2386
+ const resolveHooked = (request) => {
2387
+ this.wsHookedPasses ??= new WeakMap();
2388
+ this.wsHookedPasses.set(received, request);
2389
+ return resolveWithDeadline(request);
2390
+ };
2251
2391
  let hooked;
2252
2392
  try {
2253
2393
  hooked = this.runWebSocketRequestHooks(req, platform);
2254
2394
  }
2255
2395
  catch {
2256
- return { kind: "reject", response: jsonError(500, "internal_error") };
2396
+ return refuse(jsonError(500, "internal_error"));
2257
2397
  }
2258
2398
  if (!(hooked instanceof Promise)) {
2259
- return hooked instanceof Response
2260
- ? { kind: "reject", response: hooked }
2261
- : resolveWithDeadline(hooked);
2399
+ return hooked instanceof Response ? refuse(hooked) : resolveHooked(hooked);
2262
2400
  }
2263
- const pending = hooked.then((result) => result instanceof Response
2264
- ? { kind: "reject", response: result }
2265
- : resolveWithDeadline(result), () => ({ kind: "reject", response: jsonError(500, "internal_error") }));
2401
+ const pending = hooked.then((result) => (result instanceof Response ? refuse(result) : resolveHooked(result)), () => refuse(jsonError(500, "internal_error")));
2266
2402
  return bounded(Promise.resolve(pending).then((result) => result), onTimeout);
2267
2403
  }
2268
2404
  /** Bun `fetch` when WS routes exist: try a WS upgrade first, else run the normal HTTP lifecycle.
@@ -2319,6 +2455,13 @@ export class Server {
2319
2455
  if (this.onRequestHooks.length === 0 || !isRoutableMethod(source.method)) {
2320
2456
  return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast);
2321
2457
  }
2458
+ const hooked = this.wsHookedPasses?.get(source);
2459
+ if (hooked !== undefined) {
2460
+ const request = requestOf(source);
2461
+ this.wsHookedPasses?.delete(request);
2462
+ this.responseRequests.set(request, hooked);
2463
+ return this.routeAndRun(hooked, resolved, finalize, wrapResponse, onTimeout, webFast);
2464
+ }
2322
2465
  if (!webFast && this.canUseNodeRequestHooks()) {
2323
2466
  return this.runWithNodeRequest(source, resolved, finalize, wrapResponse, onTimeout);
2324
2467
  }
@@ -2595,7 +2738,9 @@ export class Server {
2595
2738
  }
2596
2739
  if (mount === undefined)
2597
2740
  return fallback ?? jsonError(404, "not_found");
2598
- const childRequest = mount.stripPrefix ? stripMountPrefix(request, mount.path) : request;
2741
+ const childRequest = mount.stripPrefix
2742
+ ? (mount.wsPassed?.get(request) ?? stripMountPrefix(request, mount.path))
2743
+ : request;
2599
2744
  const outcome = mount.handler(childRequest, platform);
2600
2745
  if (!replayable || !mount.fallbackOn404)
2601
2746
  return outcome;
@@ -2631,7 +2776,7 @@ export class Server {
2631
2776
  if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
2632
2777
  return this.idempotencyRuntime.run(entry.idempotent, requestOf(source), platform, entry, params, search, wrapResponse, {
2633
2778
  maxBodyBytes: entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2634
- runLanes: (buffered, plat, ent, prm, srch) => this.idempotencyRunLanes(buffered, plat, ent, prm, srch),
2779
+ runLanes: (buffered, plat, ent, prm, srch) => this.runMatchedLanes(buffered, plat, ent, prm, srch, this.finalizeResponse, this.wrapWebResponse, this.webResponseTimeout, false),
2635
2780
  });
2636
2781
  }
2637
2782
  return this.runMatchedLanes(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast);
@@ -2672,11 +2817,20 @@ export class Server {
2672
2817
  : createRequestBudget({ deadline: admission.deadline, signal });
2673
2818
  const plan = entry.execution;
2674
2819
  const nativeContext = controller === undefined;
2675
- const outcome = webFast && plan.fusedWeb !== undefined
2676
- ? plan.fusedWeb(source, params, search, signal, budget, platform, nativeContext)
2677
- : !webFast && plan.fusedBody !== undefined
2678
- ? plan.fusedBody(source, params, search, signal, budget, platform, false, finalize, wrapResponse)
2679
- : plan.run(this, entry, source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse);
2820
+ let outcome;
2821
+ try {
2822
+ outcome =
2823
+ webFast && plan.fusedWeb !== undefined
2824
+ ? plan.fusedWeb(source, params, search, signal, budget, platform, nativeContext)
2825
+ : !webFast && plan.fusedBody !== undefined
2826
+ ? plan.fusedBody(source, params, search, signal, budget, platform, false, finalize, wrapResponse)
2827
+ : plan.run(this, entry, source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse);
2828
+ }
2829
+ catch (err) {
2830
+ // A lane's synchronous render throw (a BigInt, a header value with a line break): the lanes
2831
+ // guard only their async continuations, since a `try` around a fused render slows it.
2832
+ return this.contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse);
2833
+ }
2680
2834
  // The request timeout only bounds work that is actually pending - a synchronous (bare) result is
2681
2835
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
2682
2836
  if (controller !== undefined && outcome instanceof Promise) {
@@ -2704,7 +2858,14 @@ export class Server {
2704
2858
  return this.contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse);
2705
2859
  }
2706
2860
  if (result instanceof Promise) {
2707
- return result.then((value) => finalize(value, EMPTY_RESPONSE_CONTROLS), (err) => this.contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse));
2861
+ return result.then((value) => {
2862
+ try {
2863
+ return finalize(value, EMPTY_RESPONSE_CONTROLS);
2864
+ }
2865
+ catch (err) {
2866
+ return this.contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse);
2867
+ }
2868
+ }, (err) => this.contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse));
2708
2869
  }
2709
2870
  return finalize(result, EMPTY_RESPONSE_CONTROLS);
2710
2871
  }
@@ -2740,9 +2901,17 @@ export class Server {
2740
2901
  }
2741
2902
  if (result instanceof Promise) {
2742
2903
  // Async handler on an otherwise-bare route: finish on a microtask, with the same error handling.
2743
- return result.then((value) => finalize(value, responseSet(ctx)), (err) => this.bareError(err, ctx, finalize, wrapResponse));
2904
+ return result.then((value) => {
2905
+ // A synchronous finalize throw reaches runMatchedLanes; this one would reject.
2906
+ try {
2907
+ return finalize(value, responseSet(ctx), ctx);
2908
+ }
2909
+ catch (err) {
2910
+ return this.bareError(err, ctx, finalize, wrapResponse);
2911
+ }
2912
+ }, (err) => this.bareError(err, ctx, finalize, wrapResponse));
2744
2913
  }
2745
- return finalize(result, responseSet(ctx));
2914
+ return finalize(result, responseSet(ctx), ctx);
2746
2915
  }
2747
2916
  /** Bare-route error rendering - identical to the compiled route program's catch minus the (absent) onError
2748
2917
  * loop: a thrown `Response` is returned as deliberate control flow; anything else is logged + 500. */
@@ -2762,22 +2931,29 @@ export class Server {
2762
2931
  // `() => ...` can't observe the context - skip allocating one entirely (errors still build
2763
2932
  // one for the structured log, exactly like runContextlessBare).
2764
2933
  const contextlessHandler = handler;
2934
+ const fail = (err, source, params, search, signal, budget, platform, nativeContext) => logError(err, nativeContext
2935
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2936
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning));
2765
2937
  return (source, params, search, signal, budget, platform, nativeContext) => {
2766
2938
  let result;
2767
2939
  try {
2768
2940
  result = contextlessHandler();
2769
2941
  }
2770
2942
  catch (err) {
2771
- return logError(err, nativeContext
2772
- ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2773
- : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning));
2943
+ return fail(err, source, params, search, signal, budget, platform, nativeContext);
2774
2944
  }
2775
2945
  if (result instanceof Promise) {
2776
- return result.then((value) => fusedRespondNoSet(value, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, nativeContext
2777
- ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2778
- : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning)));
2946
+ return result.then((value) => {
2947
+ // A synchronous render throw reaches runMatchedLanes; this one would reject.
2948
+ try {
2949
+ return fusedRespondNoSet(value, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0, this.onResponseHooks.length === 0 ? undefined : requestOf(source));
2950
+ }
2951
+ catch (err) {
2952
+ return fail(err, source, params, search, signal, budget, platform, nativeContext);
2953
+ }
2954
+ }, (err) => fail(err, source, params, search, signal, budget, platform, nativeContext));
2779
2955
  }
2780
- return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
2956
+ return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0, this.onResponseHooks.length === 0 ? undefined : requestOf(source));
2781
2957
  };
2782
2958
  }
2783
2959
  return (source, params, search, signal, budget, platform, nativeContext) => {
@@ -2794,11 +2970,21 @@ export class Server {
2794
2970
  return logError(err, ctx);
2795
2971
  }
2796
2972
  if (result instanceof Promise) {
2797
- return result.then((value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
2973
+ return result.then((value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
2798
2974
  }
2799
2975
  return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
2800
2976
  };
2801
2977
  }
2978
+ /** A fused Web lane's render in an async continuation, where a result that cannot be rendered
2979
+ * must fail like a handler throw; a synchronous render throw reaches {@link runMatchedLanes}. */
2980
+ respondFused(result, ctx, unstamped) {
2981
+ try {
2982
+ return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, unstamped);
2983
+ }
2984
+ catch (err) {
2985
+ return this.bareError(err, ctx, (value, set) => this.wrapWebResponse(toResponse(value, set)), this.wrapWebResponse);
2986
+ }
2987
+ }
2802
2988
  /** Compile the eligible body-only route's parser → validator → handler continuation once. The
2803
2989
  * bounded parser remains shared with the generic lane; only the route-invariant entry lookups and
2804
2990
  * lifecycle dispatch disappear from the common synchronous-validator/synchronous-handler case. */
@@ -2897,9 +3083,9 @@ export class Server {
2897
3083
  return logError(err, ctx);
2898
3084
  }
2899
3085
  if (result instanceof Promise) {
2900
- return result.then((settled) => fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
3086
+ return result.then((settled) => this.respondFused(settled, ctx, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
2901
3087
  }
2902
- return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
3088
+ return this.respondFused(result, ctx, this.onResponseHooks.length === 0);
2903
3089
  };
2904
3090
  return (source, params, search, signal, budget, platform, nativeContext) => {
2905
3091
  const ctx = nativeContext
@@ -2941,173 +3127,88 @@ export class Server {
2941
3127
  * before → handler in one frame.
2942
3128
  */
2943
3129
  buildFusedDeriveBefore(handler, derive, before, querySchema, maxBodyBytes) {
3130
+ return this.buildFusedDeriveBeforeAfter(handler, derive, before, undefined, querySchema, maxBodyBytes);
3131
+ }
3132
+ // Both hook variants share one registered chain; request contexts stay in arguments.
3133
+ buildFusedDeriveBeforeAfter(handler, derive, before, after, querySchema, maxBodyBytes) {
2944
3134
  const logError = (err, ctx) => this.bareError(err, ctx, (result, set) => this.wrapWebResponse(toResponse(result, set)), this.wrapWebResponse);
2945
- return (source, params, search, signal, budget, platform, nativeContext) => {
2946
- const ctx = nativeContext
2947
- ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2948
- : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
2949
- const runHandler = () => {
2950
- let result;
2951
- try {
2952
- result = handler(ctx);
2953
- }
2954
- catch (err) {
2955
- return logError(err, ctx);
2956
- }
2957
- if (result instanceof Promise) {
2958
- return result.then((value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
2959
- }
2960
- return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
2961
- };
2962
- const runBefore = () => {
2963
- let early;
2964
- try {
2965
- early = before(ctx);
2966
- }
2967
- catch (err) {
2968
- return logError(err, ctx);
2969
- }
2970
- if (early instanceof Promise) {
2971
- return early.then((settled) => {
2972
- if (settled === undefined)
2973
- return runHandler();
2974
- return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders);
2975
- }, (err) => logError(err, ctx));
2976
- }
2977
- if (early === undefined)
2978
- return runHandler();
2979
- return fusedRespond(early, ctx, this.responseBodyTag, this.staticResponseHeaders);
2980
- };
2981
- const runDerive = () => {
2982
- let derived;
2983
- try {
2984
- derived = derive(ctx);
2985
- }
2986
- catch (err) {
2987
- return logError(err, ctx);
2988
- }
2989
- if (derived instanceof Promise) {
2990
- return derived.then((settled) => {
2991
- if (isResponseResult(settled) || settled instanceof Response) {
2992
- return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders);
2993
- }
2994
- Object.assign(ctx, settled);
2995
- return runBefore();
2996
- }, (err) => logError(err, ctx));
2997
- }
2998
- if (isResponseResult(derived) || derived instanceof Response) {
2999
- return fusedRespond(derived, ctx, this.responseBodyTag, this.staticResponseHeaders);
3000
- }
3001
- Object.assign(ctx, derived);
3002
- return runBefore();
3003
- };
3004
- if (querySchema === undefined)
3005
- return runDerive();
3006
- let validation;
3135
+ // Continuations capture route state once; each request supplies its own context.
3136
+ const runAfter = (result, ctx) => {
3137
+ if (after === undefined)
3138
+ return this.respondFused(result, ctx, this.onResponseHooks.length === 0);
3139
+ let transformed;
3007
3140
  try {
3008
- validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
3141
+ transformed = after(result, ctx);
3009
3142
  }
3010
3143
  catch (err) {
3011
3144
  return logError(err, ctx);
3012
3145
  }
3013
- if (validation instanceof Promise) {
3014
- return validation.then((settled) => {
3015
- if (settled.issues !== undefined)
3016
- return this.wrapWebResponse(plainValidationError(settled.issues));
3017
- ctx.query = settled.value;
3018
- return runDerive();
3146
+ if (transformed instanceof Promise) {
3147
+ return transformed.then((value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
3148
+ }
3149
+ return this.respondFused(transformed, ctx, this.onResponseHooks.length === 0);
3150
+ };
3151
+ const runHandler = (ctx) => {
3152
+ let result;
3153
+ try {
3154
+ result = handler(ctx);
3155
+ }
3156
+ catch (err) {
3157
+ return logError(err, ctx);
3158
+ }
3159
+ if (result instanceof Promise) {
3160
+ return result.then((value) => runAfter(value, ctx), (err) => logError(err, ctx));
3161
+ }
3162
+ return runAfter(result, ctx);
3163
+ };
3164
+ const runBefore = (ctx) => {
3165
+ let early;
3166
+ try {
3167
+ early = before(ctx);
3168
+ }
3169
+ catch (err) {
3170
+ return logError(err, ctx);
3171
+ }
3172
+ if (early instanceof Promise) {
3173
+ return early.then((settled) => {
3174
+ if (settled === undefined)
3175
+ return runHandler(ctx);
3176
+ return this.respondFused(settled, ctx);
3019
3177
  }, (err) => logError(err, ctx));
3020
3178
  }
3021
- if (validation.issues !== undefined)
3022
- return this.wrapWebResponse(plainValidationError(validation.issues));
3023
- ctx.query = validation.value;
3024
- return runDerive();
3179
+ if (early === undefined)
3180
+ return runHandler(ctx);
3181
+ return this.respondFused(early, ctx);
3182
+ };
3183
+ const runDerive = (ctx) => {
3184
+ let derived;
3185
+ try {
3186
+ derived = derive(ctx);
3187
+ }
3188
+ catch (err) {
3189
+ return logError(err, ctx);
3190
+ }
3191
+ if (derived instanceof Promise) {
3192
+ return derived.then((settled) => {
3193
+ if (isResponseResult(settled) || settled instanceof Response) {
3194
+ return this.respondFused(settled, ctx);
3195
+ }
3196
+ Object.assign(ctx, settled);
3197
+ return runBefore(ctx);
3198
+ }, (err) => logError(err, ctx));
3199
+ }
3200
+ if (isResponseResult(derived) || derived instanceof Response) {
3201
+ return this.respondFused(derived, ctx);
3202
+ }
3203
+ Object.assign(ctx, derived);
3204
+ return runBefore(ctx);
3025
3205
  };
3026
- }
3027
- /**
3028
- * Same shape as `buildFusedDeriveBefore`, plus one `afterHandle` step. The closure runs (validate) →
3029
- * derive → before → handler → after in one frame, the shape the realistic middleware route lands on
3030
- * when it also wants a response transform (e.g. attaching a request id to a successful payload's
3031
- * headers). `LifecycleExecutionLane` and `selectRouteLanes` guarantee `afterHandle === 1`, the
3032
- * throw contract is the same as the generic program, and the `after` step is the final transform +
3033
- * respond the generic program already does.
3034
- */
3035
- buildFusedDeriveBeforeAfter(handler, derive, before, after, querySchema, maxBodyBytes) {
3036
- const logError = (err, ctx) => this.bareError(err, ctx, (result, set) => this.wrapWebResponse(toResponse(result, set)), this.wrapWebResponse);
3037
3206
  return (source, params, search, signal, budget, platform, nativeContext) => {
3038
3207
  const ctx = nativeContext
3039
3208
  ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3040
3209
  : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
3041
- const runAfter = (result) => {
3042
- let transformed;
3043
- try {
3044
- transformed = after(result, ctx);
3045
- }
3046
- catch (err) {
3047
- return logError(err, ctx);
3048
- }
3049
- if (transformed instanceof Promise) {
3050
- return transformed.then((value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
3051
- }
3052
- return fusedRespond(transformed, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
3053
- };
3054
- const runHandler = () => {
3055
- let result;
3056
- try {
3057
- result = handler(ctx);
3058
- }
3059
- catch (err) {
3060
- return logError(err, ctx);
3061
- }
3062
- if (result instanceof Promise) {
3063
- return result.then((value) => runAfter(value), (err) => logError(err, ctx));
3064
- }
3065
- return runAfter(result);
3066
- };
3067
- const runBefore = () => {
3068
- let early;
3069
- try {
3070
- early = before(ctx);
3071
- }
3072
- catch (err) {
3073
- return logError(err, ctx);
3074
- }
3075
- if (early instanceof Promise) {
3076
- return early.then((settled) => {
3077
- if (settled === undefined)
3078
- return runHandler();
3079
- return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders);
3080
- }, (err) => logError(err, ctx));
3081
- }
3082
- if (early === undefined)
3083
- return runHandler();
3084
- return fusedRespond(early, ctx, this.responseBodyTag, this.staticResponseHeaders);
3085
- };
3086
- const runDerive = () => {
3087
- let derived;
3088
- try {
3089
- derived = derive(ctx);
3090
- }
3091
- catch (err) {
3092
- return logError(err, ctx);
3093
- }
3094
- if (derived instanceof Promise) {
3095
- return derived.then((settled) => {
3096
- if (isResponseResult(settled) || settled instanceof Response) {
3097
- return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders);
3098
- }
3099
- Object.assign(ctx, settled);
3100
- return runBefore();
3101
- }, (err) => logError(err, ctx));
3102
- }
3103
- if (isResponseResult(derived) || derived instanceof Response) {
3104
- return fusedRespond(derived, ctx, this.responseBodyTag, this.staticResponseHeaders);
3105
- }
3106
- Object.assign(ctx, derived);
3107
- return runBefore();
3108
- };
3109
3210
  if (querySchema === undefined)
3110
- return runDerive();
3211
+ return runDerive(ctx);
3111
3212
  let validation;
3112
3213
  try {
3113
3214
  validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
@@ -3120,13 +3221,13 @@ export class Server {
3120
3221
  if (settled.issues !== undefined)
3121
3222
  return this.wrapWebResponse(plainValidationError(settled.issues));
3122
3223
  ctx.query = settled.value;
3123
- return runDerive();
3224
+ return runDerive(ctx);
3124
3225
  }, (err) => logError(err, ctx));
3125
3226
  }
3126
3227
  if (validation.issues !== undefined)
3127
3228
  return this.wrapWebResponse(plainValidationError(validation.issues));
3128
3229
  ctx.query = validation.value;
3129
- return runDerive();
3230
+ return runDerive(ctx);
3130
3231
  };
3131
3232
  }
3132
3233
  /**
@@ -3150,7 +3251,7 @@ export class Server {
3150
3251
  const runHandlerValidated = () => {
3151
3252
  const runAfter = (result) => {
3152
3253
  if (after === undefined) {
3153
- return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
3254
+ return this.respondFused(result, ctx, this.onResponseHooks.length === 0);
3154
3255
  }
3155
3256
  let transformed;
3156
3257
  try {
@@ -3160,9 +3261,9 @@ export class Server {
3160
3261
  return logError(err, ctx);
3161
3262
  }
3162
3263
  if (transformed instanceof Promise) {
3163
- return transformed.then((value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
3264
+ return transformed.then((value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
3164
3265
  }
3165
- return fusedRespond(transformed, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
3266
+ return this.respondFused(transformed, ctx, this.onResponseHooks.length === 0);
3166
3267
  };
3167
3268
  const runHandler = () => {
3168
3269
  let result;
@@ -3189,12 +3290,12 @@ export class Server {
3189
3290
  return early.then((settled) => {
3190
3291
  if (settled === undefined)
3191
3292
  return runHandler();
3192
- return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders);
3293
+ return this.respondFused(settled, ctx);
3193
3294
  }, (err) => logError(err, ctx));
3194
3295
  }
3195
3296
  if (early === undefined)
3196
3297
  return runHandler();
3197
- return fusedRespond(early, ctx, this.responseBodyTag, this.staticResponseHeaders);
3298
+ return this.respondFused(early, ctx);
3198
3299
  };
3199
3300
  let derived;
3200
3301
  try {
@@ -3206,14 +3307,14 @@ export class Server {
3206
3307
  if (derived instanceof Promise) {
3207
3308
  return derived.then((settled) => {
3208
3309
  if (isResponseResult(settled) || settled instanceof Response) {
3209
- return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders);
3310
+ return this.respondFused(settled, ctx);
3210
3311
  }
3211
3312
  Object.assign(ctx, settled);
3212
3313
  return runBefore();
3213
3314
  }, (err) => logError(err, ctx));
3214
3315
  }
3215
3316
  if (isResponseResult(derived) || derived instanceof Response) {
3216
- return fusedRespond(derived, ctx, this.responseBodyTag, this.staticResponseHeaders);
3317
+ return this.respondFused(derived, ctx);
3217
3318
  }
3218
3319
  Object.assign(ctx, derived);
3219
3320
  return runBefore();
@@ -3638,14 +3739,14 @@ export class Server {
3638
3739
  Object.assign(ctx, entry.decorations);
3639
3740
  const handlerOutput = entry.handler(ctx);
3640
3741
  if (handlerOutput instanceof Promise) {
3641
- return handlerOutput.then((value) => finalize(value, responseSet(ctx)), (err) => {
3742
+ return handlerOutput.then((value) => finalize(value, responseSet(ctx), ctx), (err) => {
3642
3743
  if (wrapResponse) {
3643
3744
  return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
3644
3745
  }
3645
3746
  throw err;
3646
3747
  });
3647
3748
  }
3648
- return finalize(handlerOutput, responseSet(ctx));
3749
+ return finalize(handlerOutput, responseSet(ctx), ctx);
3649
3750
  }
3650
3751
  handleValidationErrorRecovery(entry, recovery, ctx, finalize, wrapResponse, originalIssues, kind) {
3651
3752
  if (recovery !== undefined) {
@@ -3751,13 +3852,16 @@ export class Server {
3751
3852
  */
3752
3853
  applyOnResponse(response, req) {
3753
3854
  const hooks = this.onResponseHooks;
3754
- let current = response;
3855
+ // Hooks write onto the response they are handed (cookies, CORS, request ids), so every response
3856
+ // entering the chain - and any other one a hook hands back - is first made this request's own.
3857
+ let current = writableResponse(response, req);
3755
3858
  for (let i = 0; i < hooks.length; i++) {
3756
3859
  const next = hooks[i](current, req);
3757
3860
  if (next instanceof Promise) {
3758
- return next.then((settled) => this.continueOnResponse(settled, i + 1, req));
3861
+ const handed = current;
3862
+ return next.then((settled) => this.continueOnResponse(settled, i + 1, req, handed));
3759
3863
  }
3760
- current = next;
3864
+ current = next === current ? next : writableResponse(next, req);
3761
3865
  }
3762
3866
  return current;
3763
3867
  }
@@ -3816,11 +3920,12 @@ export class Server {
3816
3920
  return pending;
3817
3921
  }
3818
3922
  /** Async tail of {@link applyOnResponse}: runs the remaining hooks once one has gone async. */
3819
- async continueOnResponse(response, nextIndex, req) {
3820
- let current = response;
3923
+ async continueOnResponse(response, nextIndex, req, handed) {
3924
+ let current = response === handed ? response : writableResponse(response, req);
3821
3925
  for (let i = nextIndex; i < this.onResponseHooks.length; i++) {
3822
3926
  const next = this.onResponseHooks[i](current, req);
3823
- current = next instanceof Promise ? await next : next;
3927
+ const settled = next instanceof Promise ? await next : next;
3928
+ current = settled === current ? settled : writableResponse(settled, req);
3824
3929
  }
3825
3930
  return current;
3826
3931
  }
@@ -3849,7 +3954,7 @@ export class Server {
3849
3954
  try {
3850
3955
  const contract = entry.responseContract;
3851
3956
  if (contract === undefined)
3852
- return finalize(result, responseSet(ctx));
3957
+ return finalize(result, responseSet(ctx), ctx);
3853
3958
  const checked = contract.runtime.check(contract.definition, result, ctx);
3854
3959
  if (checked instanceof Promise) {
3855
3960
  return checked.then((outcome) => this.finishContractOutcome(ctx, finalize, wrapResponse, outcome), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
@@ -3876,7 +3981,7 @@ export class Server {
3876
3981
  detail: outcome.message,
3877
3982
  });
3878
3983
  }
3879
- return finalize(outcome.value, responseSet(ctx));
3984
+ return finalize(outcome.value, responseSet(ctx), ctx);
3880
3985
  }
3881
3986
  /** Synchronous by design, and `MaybePromise` rather than `Promise` for the same reason: control flow
3882
3987
  * out of a lifecycle stage must not cost a promise. An `async` method allocates one and defers the
@@ -3889,11 +3994,10 @@ export class Server {
3889
3994
  // SvelteKit semantics): as built, plus the cookies the request queued, so a session set or
3890
3995
  // cleared before `throw redirect(...)` / `requireSession(...)` ships. Don't run onError or log
3891
3996
  // it as a 500.
3892
- if (err instanceof Response)
3893
- return finalize(err, responseSet(ctx));
3894
- // Same rule for a thrown `status(...)`, but through `finalize` rather than `wrapResponse`: the
3895
- // value is still plain data, so the ordinary JSON lane renders it and no `Response` is built.
3896
- if (isResponseResult(err))
3997
+ if (err instanceof Response && err !== ctx.jsonReply)
3998
+ return finalize(err, responseSet(ctx), ctx);
3999
+ // Same rule for a thrown `status(...)` or `c.json(...)`, which stay held to the route's contract.
4000
+ if (err instanceof Response || isResponseResult(err))
3897
4001
  return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, err);
3898
4002
  if (entry.onError.length === 0) {
3899
4003
  // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
@@ -3911,8 +4015,20 @@ export class Server {
3911
4015
  for (let i = 0; i < entry.onError.length; i++) {
3912
4016
  const outcome = entry.onError[i](err, ctx);
3913
4017
  const handled = outcome instanceof Promise ? await outcome : outcome;
3914
- if (handled !== undefined)
3915
- return finalize(handled, responseSet(ctx));
4018
+ if (handled === undefined)
4019
+ continue;
4020
+ const contract = entry.responseContract;
4021
+ if (contract === undefined || (handled !== ctx.jsonReply && !isResponseResult(handled)))
4022
+ return finalize(handled, responseSet(ctx), ctx);
4023
+ // Checked here, not through finishLifecycleContract: a throwing schema must not re-enter onError.
4024
+ try {
4025
+ const checked = await contract.runtime.check(contract.definition, handled, ctx);
4026
+ return this.finishContractOutcome(ctx, finalize, wrapResponse, checked);
4027
+ }
4028
+ catch (checkError) {
4029
+ this.logRequestError(checkError, ctx);
4030
+ return wrapResponse(plainError(500, "internal_error"));
4031
+ }
3916
4032
  }
3917
4033
  this.logRequestError(err, ctx);
3918
4034
  return wrapResponse(plainError(500, "internal_error"));
@@ -4003,7 +4119,14 @@ export class Server {
4003
4119
  return this.applyOnResponseAndFinalize(response, request);
4004
4120
  };
4005
4121
  const runNative = (request, params) => {
4006
- const outcome = capped(request, params, undefined, signal, budget, peer, true);
4122
+ let outcome;
4123
+ try {
4124
+ outcome = capped(request, params, undefined, signal, budget, peer, true);
4125
+ }
4126
+ catch (err) {
4127
+ // A synchronous render throw, as in runMatchedLanes.
4128
+ outcome = this.contextlessBareError(err, request, params, undefined, signal, budget, peer, this.wrapWebResponse);
4129
+ }
4007
4130
  return outcome instanceof Promise
4008
4131
  ? outcome.then((response) => finishNative(request, response))
4009
4132
  : finishNative(request, outcome);
@@ -4020,7 +4143,7 @@ export class Server {
4020
4143
  markFramed(request);
4021
4144
  return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
4022
4145
  ? this.fetchMatched(request, entry, EMPTY_PARAMS, peer)
4023
- : capped(request, EMPTY_PARAMS, undefined, signal, budget, peer, true);
4146
+ : runNative(request, EMPTY_PARAMS);
4024
4147
  };
4025
4148
  }
4026
4149
  return (request) => {
@@ -4048,7 +4171,7 @@ export class Server {
4048
4171
  return fallback(request, server);
4049
4172
  return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
4050
4173
  ? this.fetchMatched(request, entry, params, peer)
4051
- : capped(request, params, undefined, signal, budget, peer, true);
4174
+ : runNative(request, params);
4052
4175
  };
4053
4176
  }
4054
4177
  return (request, server) => {
@@ -4072,7 +4195,7 @@ export class Server {
4072
4195
  // bypasses - so an app that declares trust routes through the fetch lane (where `c.clientIp`
4073
4196
  // resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
4074
4197
  if ((this.onRequestHooks.length > 0 && !this.bunNativeRequestHooksSafe) ||
4075
- this.wsRouteCount > 0 ||
4198
+ this.wsRoutes.length > 0 ||
4076
4199
  this.preRouteMountCount > 0 ||
4077
4200
  this.clientIpTrust !== undefined) {
4078
4201
  return undefined;
@@ -4208,7 +4331,7 @@ export class Server {
4208
4331
  }
4209
4332
  // Seal before Bun.serve: in `"error"` mode this throws instead of leaving a bound port behind.
4210
4333
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
4211
- sealHookAudit(this, this.catalog.size, this.logger);
4334
+ sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger);
4212
4335
  // Bun's `Server` is the concrete handle; we expose the stable `RunningServer`
4213
4336
  // subset so the public types don't depend on the ambient `Bun` global. The cast
4214
4337
  // bridges them - Bun's `.port` is `number | undefined` (undefined only for unix
@@ -4219,7 +4342,7 @@ export class Server {
4219
4342
  // `websocket` handlers are one shared dispatcher - each connection's `ws.data.handler` is the
4220
4343
  // matched route's handler, set by `server.upgrade`.
4221
4344
  // With WS routes, the dispatcher comes from the installed `.use(websocket())` runtime - non-null
4222
- // because wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
4345
+ // because a socket route means ws() ran, and ws() requires the runtime at registration.
4223
4346
  // Native pub/sub when the app has WS routes and none validate outbound frames: `ws.subscribe` and
4224
4347
  // `app.publish` go through Bun's own (uWebSockets) broadcast instead of the JS registry loop.
4225
4348
  // A mounted app counts only while it has a WebSocket route (see `[GET_WS_RUNTIME]`), so composing
@@ -4232,7 +4355,7 @@ export class Server {
4232
4355
  // upgrades owns a different TopicRegistry, so force the per-connection JS dispatcher and carry
4233
4356
  // that registry in the upgrade outcome instead of accidentally broadcasting child sockets through
4234
4357
  // the parent.
4235
- const nativePubsub = this.wsRouteCount > 0 && !this.mountsTakeUpgrades() && !this.wsHasValidatedSend;
4358
+ const nativePubsub = this.wsRoutes.length > 0 && !this.mountsTakeUpgrades() && !this.wsHasValidatedSend;
4236
4359
  const wsHandlers = wsRuntime?.bunHandlers(this.topics ?? wsRuntime.createTopics(), nativePubsub);
4237
4360
  // Spread rather than pass `hostname: undefined` (or an undefined idle timeout or TLS) - Bun treats
4238
4361
  // an explicit undefined as a value on some option paths, and omitting is what selects its own
@@ -4299,7 +4422,7 @@ export class Server {
4299
4422
  }
4300
4423
  this.sealed = true;
4301
4424
  if (this.gracefulSignals)
4302
- this.installSignalHandlers();
4425
+ this.installSignalHandlers(this.gracefulSignals);
4303
4426
  return running;
4304
4427
  }
4305
4428
  /**
@@ -4332,12 +4455,12 @@ export class Server {
4332
4455
  new Promise((resolve) => setTimeout(resolve, STOP_HOOK_TIMEOUT_MS)),
4333
4456
  ]);
4334
4457
  }
4335
- installSignalHandlers() {
4458
+ installSignalHandlers(drain) {
4336
4459
  // Drain, then let the process exit naturally - the stopped server no longer
4337
4460
  // holds the event loop open. Opt-in (`gracefulSignals`), so taking over the
4338
4461
  // signals is consented; we don't force `process.exit`.
4339
4462
  const onSignal = () => {
4340
- void this.stop();
4463
+ void this.stop(drain);
4341
4464
  };
4342
4465
  process.once("SIGTERM", onSignal);
4343
4466
  process.once("SIGINT", onSignal);