@nifrajs/core 4.0.2 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. package/README.md +6 -2
  2. package/dist/assurance.d.ts +86 -6
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +186 -95
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/capabilities.d.ts +8 -0
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +53 -1
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/durable-effect.js +1 -1
  11. package/dist/durable-effect.js.map +1 -1
  12. package/dist/durable-saga.d.ts +6 -0
  13. package/dist/durable-saga.d.ts.map +1 -1
  14. package/dist/durable-saga.js +83 -10
  15. package/dist/durable-saga.js.map +1 -1
  16. package/dist/edge-kit.d.ts +8 -3
  17. package/dist/edge-kit.d.ts.map +1 -1
  18. package/dist/edge-kit.js +7 -2
  19. package/dist/edge-kit.js.map +1 -1
  20. package/dist/effect-scope.d.ts.map +1 -1
  21. package/dist/effect-scope.js +16 -5
  22. package/dist/effect-scope.js.map +1 -1
  23. package/dist/errors.d.ts +1 -1
  24. package/dist/errors.d.ts.map +1 -1
  25. package/dist/errors.js.map +1 -1
  26. package/dist/evidence.d.ts +2 -0
  27. package/dist/evidence.d.ts.map +1 -1
  28. package/dist/evidence.js +9 -1
  29. package/dist/evidence.js.map +1 -1
  30. package/dist/index.d.ts +5 -4
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +4 -3
  33. package/dist/index.js.map +1 -1
  34. package/dist/internal/durable-shared.d.ts +1 -2
  35. package/dist/internal/durable-shared.d.ts.map +1 -1
  36. package/dist/internal/durable-shared.js +25 -44
  37. package/dist/internal/durable-shared.js.map +1 -1
  38. package/dist/internal/durable-types.d.ts +3 -0
  39. package/dist/internal/durable-types.d.ts.map +1 -1
  40. package/dist/internal/hook-origins.d.ts +67 -0
  41. package/dist/internal/hook-origins.d.ts.map +1 -0
  42. package/dist/internal/hook-origins.js +86 -0
  43. package/dist/internal/hook-origins.js.map +1 -0
  44. package/dist/internal/route-assurance.d.ts +6 -3
  45. package/dist/internal/route-assurance.d.ts.map +1 -1
  46. package/dist/internal/route-assurance.js +29 -14
  47. package/dist/internal/route-assurance.js.map +1 -1
  48. package/dist/internal/route-compiler.d.ts +9 -0
  49. package/dist/internal/route-compiler.d.ts.map +1 -1
  50. package/dist/internal/route-compiler.js +95 -13
  51. package/dist/internal/route-compiler.js.map +1 -1
  52. package/dist/internal/route-program.d.ts +1 -1
  53. package/dist/internal/route-program.d.ts.map +1 -1
  54. package/dist/internal/route-program.js +59 -16
  55. package/dist/internal/route-program.js.map +1 -1
  56. package/dist/internal/same-origin.d.ts +11 -1
  57. package/dist/internal/same-origin.d.ts.map +1 -1
  58. package/dist/internal/same-origin.js +8 -0
  59. package/dist/internal/same-origin.js.map +1 -1
  60. package/dist/reconciliation-worker.d.ts.map +1 -1
  61. package/dist/reconciliation-worker.js +12 -2
  62. package/dist/reconciliation-worker.js.map +1 -1
  63. package/dist/reflection.d.ts +53 -2
  64. package/dist/reflection.d.ts.map +1 -1
  65. package/dist/reflection.js +352 -5
  66. package/dist/reflection.js.map +1 -1
  67. package/dist/router/pattern.d.ts +1 -1
  68. package/dist/router/pattern.d.ts.map +1 -1
  69. package/dist/router/pattern.js +6 -3
  70. package/dist/router/pattern.js.map +1 -1
  71. package/dist/security/baseline.d.ts +3 -0
  72. package/dist/security/baseline.d.ts.map +1 -1
  73. package/dist/security/baseline.js +9 -5
  74. package/dist/security/baseline.js.map +1 -1
  75. package/dist/server/auth.d.ts +32 -3
  76. package/dist/server/auth.d.ts.map +1 -1
  77. package/dist/server/auth.js +23 -0
  78. package/dist/server/auth.js.map +1 -1
  79. package/dist/server/body-lane.d.ts +8 -2
  80. package/dist/server/body-lane.d.ts.map +1 -1
  81. package/dist/server/body-lane.js +27 -2
  82. package/dist/server/body-lane.js.map +1 -1
  83. package/dist/server/body.d.ts +7 -0
  84. package/dist/server/body.d.ts.map +1 -1
  85. package/dist/server/body.js +34 -1
  86. package/dist/server/body.js.map +1 -1
  87. package/dist/server/client-ip.d.ts +3 -0
  88. package/dist/server/client-ip.d.ts.map +1 -1
  89. package/dist/server/client-ip.js +6 -5
  90. package/dist/server/client-ip.js.map +1 -1
  91. package/dist/server/context.d.ts +2 -1
  92. package/dist/server/context.d.ts.map +1 -1
  93. package/dist/server/cookies.d.ts.map +1 -1
  94. package/dist/server/cookies.js +13 -1
  95. package/dist/server/cookies.js.map +1 -1
  96. package/dist/server/edge.d.ts.map +1 -1
  97. package/dist/server/edge.js +2 -1
  98. package/dist/server/edge.js.map +1 -1
  99. package/dist/server/http.d.ts.map +1 -1
  100. package/dist/server/http.js +5 -1
  101. package/dist/server/http.js.map +1 -1
  102. package/dist/server/idempotency-lane.d.ts +6 -3
  103. package/dist/server/idempotency-lane.d.ts.map +1 -1
  104. package/dist/server/idempotency-lane.js +38 -17
  105. package/dist/server/idempotency-lane.js.map +1 -1
  106. package/dist/server/install.d.ts +2 -0
  107. package/dist/server/install.d.ts.map +1 -1
  108. package/dist/server/install.js +2 -0
  109. package/dist/server/install.js.map +1 -1
  110. package/dist/server/logger.d.ts.map +1 -1
  111. package/dist/server/logger.js +3 -1
  112. package/dist/server/logger.js.map +1 -1
  113. package/dist/server/methods.d.ts +1 -0
  114. package/dist/server/methods.d.ts.map +1 -1
  115. package/dist/server/methods.js +1 -0
  116. package/dist/server/methods.js.map +1 -1
  117. package/dist/server/node-outcome.d.ts.map +1 -1
  118. package/dist/server/node-outcome.js +29 -11
  119. package/dist/server/node-outcome.js.map +1 -1
  120. package/dist/server/platform-adapters.d.ts.map +1 -1
  121. package/dist/server/platform-adapters.js +3 -2
  122. package/dist/server/platform-adapters.js.map +1 -1
  123. package/dist/server/request-context.d.ts +2 -1
  124. package/dist/server/request-context.d.ts.map +1 -1
  125. package/dist/server/request-context.js +4 -1
  126. package/dist/server/request-context.js.map +1 -1
  127. package/dist/server/respond.d.ts +44 -11
  128. package/dist/server/respond.d.ts.map +1 -1
  129. package/dist/server/respond.js +192 -79
  130. package/dist/server/respond.js.map +1 -1
  131. package/dist/server/response-contract-lane.d.ts +21 -6
  132. package/dist/server/response-contract-lane.d.ts.map +1 -1
  133. package/dist/server/response-contract-lane.js +139 -14
  134. package/dist/server/response-contract-lane.js.map +1 -1
  135. package/dist/server/response-observer-runtime.d.ts.map +1 -1
  136. package/dist/server/response-observer-runtime.js +10 -41
  137. package/dist/server/response-observer-runtime.js.map +1 -1
  138. package/dist/server/runtime-core.d.ts +2 -0
  139. package/dist/server/runtime-core.d.ts.map +1 -1
  140. package/dist/server/runtime-core.js +2 -0
  141. package/dist/server/runtime-core.js.map +1 -1
  142. package/dist/server/server-types.d.ts +45 -10
  143. package/dist/server/server-types.d.ts.map +1 -1
  144. package/dist/server/server.d.ts +67 -32
  145. package/dist/server/server.d.ts.map +1 -1
  146. package/dist/server/server.js +481 -334
  147. package/dist/server/server.js.map +1 -1
  148. package/dist/server/sse.d.ts +11 -1
  149. package/dist/server/sse.d.ts.map +1 -1
  150. package/dist/server/sse.js +18 -3
  151. package/dist/server/sse.js.map +1 -1
  152. package/dist/server/transport-lane.d.ts +7 -1
  153. package/dist/server/transport-lane.d.ts.map +1 -1
  154. package/dist/server/transport-lane.js.map +1 -1
  155. package/dist/server/webhook.d.ts +5 -2
  156. package/dist/server/webhook.d.ts.map +1 -1
  157. package/dist/server/webhook.js +17 -1
  158. package/dist/server/webhook.js.map +1 -1
  159. package/dist/server/websocket.d.ts +33 -14
  160. package/dist/server/websocket.d.ts.map +1 -1
  161. package/dist/server/websocket.js +47 -12
  162. package/dist/server/websocket.js.map +1 -1
  163. package/dist/server/ws-bun.d.ts +7 -1
  164. package/dist/server/ws-bun.d.ts.map +1 -1
  165. package/dist/server/ws-bun.js +42 -2
  166. package/dist/server/ws-bun.js.map +1 -1
  167. package/dist/server/ws-hook.d.ts +15 -3
  168. package/dist/server/ws-hook.d.ts.map +1 -1
  169. package/dist/server.d.ts +4 -3
  170. package/dist/server.d.ts.map +1 -1
  171. package/dist/server.js +3 -2
  172. package/dist/server.js.map +1 -1
  173. package/dist/single-copy.d.ts +6 -0
  174. package/dist/single-copy.d.ts.map +1 -1
  175. package/dist/single-copy.js +55 -6
  176. package/dist/single-copy.js.map +1 -1
  177. package/dist/tool-contract.d.ts.map +1 -1
  178. package/dist/tool-contract.js +71 -40
  179. package/dist/tool-contract.js.map +1 -1
  180. package/dist/wire.d.ts +5 -0
  181. package/dist/wire.d.ts.map +1 -1
  182. package/dist/wire.js +5 -0
  183. package/dist/wire.js.map +1 -1
  184. package/dist/ws.d.ts.map +1 -1
  185. package/dist/ws.js +4 -1
  186. package/dist/ws.js.map +1 -1
  187. package/package.json +1 -1
  188. package/src/assurance.ts +299 -111
  189. package/src/capabilities.ts +62 -1
  190. package/src/durable-effect.ts +1 -1
  191. package/src/durable-saga.ts +94 -10
  192. package/src/edge-kit.ts +8 -3
  193. package/src/effect-scope.ts +15 -5
  194. package/src/errors.ts +1 -0
  195. package/src/evidence.ts +12 -3
  196. package/src/index.ts +7 -2
  197. package/src/internal/durable-shared.ts +25 -37
  198. package/src/internal/durable-types.ts +3 -0
  199. package/src/internal/hook-origins.ts +153 -0
  200. package/src/internal/route-assurance.ts +32 -18
  201. package/src/internal/route-compiler.ts +107 -16
  202. package/src/internal/route-program.ts +98 -44
  203. package/src/internal/same-origin.ts +14 -1
  204. package/src/reconciliation-worker.ts +10 -2
  205. package/src/reflection.ts +455 -4
  206. package/src/router/pattern.ts +6 -3
  207. package/src/security/baseline.ts +10 -5
  208. package/src/server/auth.ts +45 -3
  209. package/src/server/body-lane.ts +36 -1
  210. package/src/server/body.ts +34 -1
  211. package/src/server/client-ip.ts +8 -5
  212. package/src/server/context.ts +2 -1
  213. package/src/server/cookies.ts +12 -1
  214. package/src/server/edge.ts +2 -1
  215. package/src/server/http.ts +7 -1
  216. package/src/server/idempotency-lane.ts +64 -22
  217. package/src/server/install.ts +3 -0
  218. package/src/server/logger.ts +3 -1
  219. package/src/server/methods.ts +1 -0
  220. package/src/server/node-outcome.ts +34 -12
  221. package/src/server/platform-adapters.ts +3 -2
  222. package/src/server/request-context.ts +5 -0
  223. package/src/server/respond.ts +222 -69
  224. package/src/server/response-contract-lane.ts +162 -10
  225. package/src/server/response-observer-runtime.ts +16 -38
  226. package/src/server/runtime-core.ts +2 -0
  227. package/src/server/server-types.ts +45 -10
  228. package/src/server/server.ts +707 -539
  229. package/src/server/sse.ts +84 -55
  230. package/src/server/transport-lane.ts +7 -1
  231. package/src/server/webhook.ts +22 -3
  232. package/src/server/websocket.ts +83 -18
  233. package/src/server/ws-bun.ts +48 -1
  234. package/src/server/ws-hook.ts +14 -2
  235. package/src/server.ts +6 -1
  236. package/src/single-copy.ts +55 -5
  237. package/src/tool-contract.ts +67 -39
  238. package/src/wire.ts +14 -0
  239. package/src/ws.ts +6 -0
@@ -1,9 +1,10 @@
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
- import { compileRouteOptions } from "../internal/route-compiler.js";
7
+ import { adoptRouteRuntimes, compileRouteOptions } from "../internal/route-compiler.js";
7
8
  import { compileRouteExecutionPlan, } from "../internal/route-execution.js";
8
9
  import { compileRouteProgram, executeRouteProgram } from "../internal/route-program.js";
9
10
  import { isSameOriginRequest } from "../internal/same-origin.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`. */
@@ -374,6 +419,7 @@ export class Server {
374
419
  nativePublish;
375
420
  maxBodyBytes;
376
421
  protoPoisoning;
422
+ formOrigins;
377
423
  /** `trustBodyFraming`: mark every `app.fetch` request as runtime-framed (see ServerOptions). */
378
424
  trustBodyFraming;
379
425
  wsMaxPayloadBytes;
@@ -463,8 +509,13 @@ export class Server {
463
509
  /** Body/raw response hooks need a framework-payload marker; keep that decision per app. */
464
510
  responseBodyTag;
465
511
  responseBodyOwners;
466
- finalizeResponse = (result, set) => toResponse(result, set, this.responseBodyTag, this.staticResponseHeaders);
512
+ finalizeResponse = (result, set, ctx) => toResponse(result, set, this.responseBodyTag, this.staticResponseHeaders,
513
+ // Response hooks write next, and they run for this same request object.
514
+ ctx !== undefined && this.onResponseHooks.length > 0 ? ctx[CONTEXT_REQUEST] : undefined);
467
515
  responseRequests;
516
+ /** A request the WebSocket handshake check ran the onRequest hooks on and then passed to HTTP →
517
+ * what the hooks returned, so `fetch` routes that instead of running the hooks a second time. */
518
+ wsHookedPasses;
468
519
  /** Original source → request observed by generic request hooks, including in-place mutations. */
469
520
  responseSources;
470
521
  /** Memoized NodeRequestContext per plain-`Request` source - see {@link nodeRequestContextOf}. */
@@ -488,11 +539,12 @@ export class Server {
488
539
  this.routePrefix = "";
489
540
  this.catalog = new RouteCatalog();
490
541
  this.wsRouter = new Router();
491
- this.wsRouteCount = 0;
542
+ this.wsRoutes = [];
492
543
  this.topics = undefined;
493
544
  const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
494
545
  assertByteLimit(maxBodyBytes, "maxBodyBytes");
495
546
  this.protoPoisoning = options.protoPoisoning ?? "reject";
547
+ this.formOrigins = options.formOrigins;
496
548
  this.trustBodyFraming = options.trustBodyFraming ?? false;
497
549
  const wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? maxBodyBytes;
498
550
  assertByteLimit(wsMaxPayloadBytes, "wsMaxPayloadBytes");
@@ -500,7 +552,13 @@ export class Server {
500
552
  this.wsMaxPayloadBytes = wsMaxPayloadBytes;
501
553
  this.requestTimeoutMs = options.requestTimeoutMs ?? 0;
502
554
  this.wsUpgradeTimeoutMs = options.wsUpgradeTimeoutMs ?? DEFAULT_WS_UPGRADE_TIMEOUT_MS;
503
- this.clientIpTrust = options.clientIp;
555
+ const trust = options.clientIp;
556
+ if (trust !== undefined &&
557
+ !("header" in trust) &&
558
+ !(Number.isSafeInteger(trust.trustedHops) && trust.trustedHops >= 0)) {
559
+ throw new RangeError("clientIp trustedHops must be a non-negative integer");
560
+ }
561
+ this.clientIpTrust = trust;
504
562
  this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false;
505
563
  this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000;
506
564
  this.deadlineAdmissionOptions = Object.freeze({
@@ -516,7 +574,8 @@ export class Server {
516
574
  if (!Number.isFinite(this.maxInboundDeadlineMs) || this.maxInboundDeadlineMs <= 0) {
517
575
  throw new RangeError("maxInboundDeadlineMs must be a finite positive number");
518
576
  }
519
- this.gracefulSignals = options.gracefulSignals ?? false;
577
+ this.gracefulSignals =
578
+ options.gracefulSignals === true ? {} : options.gracefulSignals || undefined;
520
579
  this.stopHooks = [];
521
580
  this.fetchMounts = [];
522
581
  this.mountOrder = 0;
@@ -565,6 +624,7 @@ export class Server {
565
624
  this.responseBodyTag = undefined;
566
625
  this.responseBodyOwners = new Set();
567
626
  this.responseRequests = new WeakMap();
627
+ this.wsHookedPasses = undefined;
568
628
  this.responseSources = new WeakMap();
569
629
  this.nodeContexts = new WeakMap();
570
630
  this.appliedPlugins = new Map();
@@ -659,6 +719,15 @@ export class Server {
659
719
  opaque: options.opaque,
660
720
  app,
661
721
  };
722
+ // Mounts run before routes: a request under this prefix that no mount on the way strips comes back
723
+ // here unchanged until the stack overflows, which takes the process down. Dev-time only, like the
724
+ // hook audit: a production define folds this away, and the first dev request would hit it anyway.
725
+ if (hookAuditRuntime &&
726
+ process.env.NODE_ENV !== "production" &&
727
+ !mount.stripPrefix &&
728
+ mountLeadsBack(app, this, mount.path, (server) => server.fetchMounts, new Set())) {
729
+ throw new RouteConfigError("MOUNT_CYCLE", `mount ${mount.path}: the app leads back to this one without stripPrefix`);
730
+ }
662
731
  this.fetchMounts.push(mount);
663
732
  this.fetchMounts.sort(compareMounts);
664
733
  this.preRouteMountCount += 1;
@@ -679,8 +748,10 @@ export class Server {
679
748
  derive(fn) {
680
749
  this.assertConfigurable("derive()");
681
750
  this.derives.push(fn);
682
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
683
- recordScopedHook(this, this.catalog.size, "derive");
751
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
752
+ recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "derive");
753
+ tagHook(fn, "derive", this);
754
+ }
684
755
  return this;
685
756
  }
686
757
  /**
@@ -710,22 +781,27 @@ export class Server {
710
781
  throw new TypeError('authenticate() stage mode must be "sync" or "async"');
711
782
  }
712
783
  this.authStages.push(stage);
784
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
785
+ tagHook(stage, "authenticate", this);
713
786
  return this;
714
787
  }
715
788
  decorate(key, value) {
716
789
  this.assertConfigurable("decorate()");
717
790
  this.decorations[key] = value;
718
791
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
719
- recordScopedHook(this, this.catalog.size, "decorate");
792
+ recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "decorate");
720
793
  return this;
721
794
  }
722
795
  /**
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.
796
+ * Run before routing on the raw request. Return a `Response` or a `status()` result to
797
+ * short-circuit, or a replacement `Request` to continue routing with a rewritten method/URL/headers.
798
+ * Global.
725
799
  */
726
800
  onRequest(fn) {
727
801
  this.assertConfigurable("onRequest()");
728
802
  this.onRequestHooks.push(fn);
803
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
804
+ tagHook(fn, "onRequest", this);
729
805
  this.onNodeRequestHooks.push(undefined);
730
806
  this.nodeRequestHooksComplete = false;
731
807
  this.bunNativeRequestHooksSafe = false;
@@ -739,8 +815,10 @@ export class Server {
739
815
  beforeHandle(fn) {
740
816
  this.assertConfigurable("beforeHandle()");
741
817
  this.beforeHandleHooks.push(fn);
742
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
743
- recordScopedHook(this, this.catalog.size, "beforeHandle");
818
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
819
+ recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "beforeHandle");
820
+ tagHook(fn, "beforeHandle", this);
821
+ }
744
822
  return this;
745
823
  }
746
824
  /**
@@ -754,8 +832,10 @@ export class Server {
754
832
  around(fn) {
755
833
  this.assertConfigurable("around()");
756
834
  this.aroundHooks.push(fn);
757
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
835
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
758
836
  recordScopedHook(this, this.catalog.size, "around");
837
+ tagHook(fn, "around", this);
838
+ }
759
839
  return this;
760
840
  }
761
841
  /**
@@ -793,8 +873,10 @@ export class Server {
793
873
  afterHandle(fn) {
794
874
  this.assertConfigurable("afterHandle()");
795
875
  this.afterHandleHooks.push(fn);
796
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
876
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
797
877
  recordScopedHook(this, this.catalog.size, "afterHandle");
878
+ tagHook(fn, "afterHandle", this);
879
+ }
798
880
  return this;
799
881
  }
800
882
  /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500).
@@ -803,14 +885,18 @@ export class Server {
803
885
  onError(fn) {
804
886
  this.assertConfigurable("onError()");
805
887
  this.onErrorHooks.push(fn);
806
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
807
- recordScopedHook(this, this.catalog.size, "onError");
888
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
889
+ recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "onError");
890
+ tagHook(fn, "onError", this);
891
+ }
808
892
  return this;
809
893
  }
810
894
  /** Transform every outgoing response - success, error, 404, 405, short-circuit. Global. */
811
895
  onResponse(fn) {
812
896
  this.assertConfigurable("onResponse()");
813
897
  this.onResponseHooks.push(fn);
898
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
899
+ tagHook(fn, "onResponse", this);
814
900
  this.onNodeResponseHooks.push(undefined);
815
901
  this.nodeResponseHooksComplete = false;
816
902
  this.bunNativeResponseHeadersOnly = false;
@@ -842,6 +928,10 @@ export class Server {
842
928
  */
843
929
  responseHeaders(record) {
844
930
  this.assertConfigurable("responseHeaders()");
931
+ if (hookAuditRuntime &&
932
+ process.env.NODE_ENV !== "production" &&
933
+ this.onResponseHooks.length === 0)
934
+ tagStatic(this, Object.keys(record), record);
845
935
  return this.addStaticResponseHeaders(normalizeStaticResponseHeaders(record));
846
936
  }
847
937
  /** Wire an already-validated lowercase record into the static tier, or - once a dynamic response
@@ -858,13 +948,15 @@ export class Server {
858
948
  });
859
949
  return this;
860
950
  }
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.
951
+ // Route the hook through the same seam the static tier uses. On a framework response with a
952
+ // body it applies the defaults in place and returns the SAME object, so the serialized-body
953
+ // marker survives for later `onResponseBody`/`onResponseRaw` observers and the Node writer;
954
+ // reconstructing a `Response` here would strip that marker and reclassify the Node outcome from
955
+ // `json` to a generic `response`. A body-less or guarded response is copied, marker included.
866
956
  const statics = buildStaticResponseHeaders(record, this.inheritedStatics);
867
- this.onResponseHooks.push((response) => applyStaticResponseHeaders(response, statics));
957
+ this.onResponseHooks.push((response, req) => applyStaticResponseHeaders(response, statics, req));
958
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
959
+ tagHook(this.onResponseHooks.at(-1), "responseHeaders", this);
868
960
  this.onNodeResponseHooks.push(undefined);
869
961
  this.nodeResponseHooksComplete = false;
870
962
  return this;
@@ -898,6 +990,8 @@ export class Server {
898
990
  onResponseFinalized(fn) {
899
991
  this.assertConfigurable("onResponseFinalized()");
900
992
  this.onResponseFinalizedHooks.push(fn);
993
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
994
+ tagHook(fn, "onResponseFinalized", this);
901
995
  return this;
902
996
  }
903
997
  use(input) {
@@ -925,10 +1019,14 @@ export class Server {
925
1019
  this.activeAssurance.push(...pluginOnly);
926
1020
  const inherited = this.inheritedPlugins;
927
1021
  this.inheritedPlugins = undefined;
1022
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1023
+ enterPlugin(name);
928
1024
  try {
929
1025
  return arg(this);
930
1026
  }
931
1027
  finally {
1028
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1029
+ leavePlugin();
932
1030
  this.inheritedPlugins = inherited;
933
1031
  // Remove only this plugin's temporary evidence. Nested assured plugins may deliberately leave
934
1032
  // subsequent evidence active, so truncating the whole array would lose real ordering semantics.
@@ -957,6 +1055,8 @@ export class Server {
957
1055
  if (arg.onRequest !== undefined) {
958
1056
  this.assertConfigurable("onRequest()");
959
1057
  this.onRequestHooks.push(arg.onRequest);
1058
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1059
+ tagHook(arg.onRequest, "onRequest", this);
960
1060
  this.onNodeRequestHooks.push(arg.onNodeRequest);
961
1061
  if (arg.onNodeRequest === undefined)
962
1062
  this.nodeRequestHooksComplete = false;
@@ -976,6 +1076,8 @@ export class Server {
976
1076
  if (arg.onResponse !== undefined) {
977
1077
  this.assertConfigurable("onResponse()");
978
1078
  this.onResponseHooks.push(arg.onResponse);
1079
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1080
+ tagHook(arg.onResponse, "onResponse", this);
979
1081
  this.onNodeResponseHooks.push(arg.onNodeResponse);
980
1082
  if (arg.onNodeResponse === undefined)
981
1083
  this.nodeResponseHooksComplete = false;
@@ -1014,8 +1116,10 @@ export class Server {
1014
1116
  return this.route("GET", path, schemaOrHandler, handler);
1015
1117
  }
1016
1118
  /**
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
1119
+ * Register a **typed SSE route** - an endpoint streaming `text/event-stream` whose event
1120
+ * payloads are contracted by `schema.sse`. It is a GET route, or a POST route when the schema
1121
+ * declares a `body` (a GET carries none): the body validates as on any POST, and the client sends
1122
+ * it with `.subscribe(onEvent, { body })` - the shape of a chat stream that posts its prompt. The handler receives the validated context plus a
1019
1123
  * {@link TypedSSEStream}: `stream.send(event)` is compile-time-checked against the schema and
1020
1124
  * JSON-serialized into the SSE `data:` field. The typed client sees the marker and grows a
1021
1125
  * `.subscribe(onEvent)` for the route with the same payload type - end-to-end typed streaming.
@@ -1030,13 +1134,13 @@ export class Server {
1030
1134
  *
1031
1135
  * // client: const off = api.feed.subscribe((post) => console.log(post.title))
1032
1136
  *
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.
1137
+ * `init` passes through to the underlying {@link sse} helper (`keepAlive`, `maxBufferedBytes`,
1138
+ * extra headers). The connection closes when the handler resolves, `stream.close()` runs, or the
1139
+ * client disconnects (`stream.signal`). Query/body schemas validate exactly as on any other route.
1036
1140
  */
1037
1141
  sse(path, schema, run, init) {
1038
1142
  const handler = (context) => requireSseRuntime(this.sseRuntime).response(context, (stream) => run(context, stream), init);
1039
- return this.route("GET", path, schema, handler);
1143
+ return this.route(schema.body ? "POST" : "GET", path, schema, handler);
1040
1144
  }
1041
1145
  post(path, schemaOrHandler, handler) {
1042
1146
  return this.route("POST", path, schemaOrHandler, handler);
@@ -1092,8 +1196,11 @@ export class Server {
1092
1196
  }
1093
1197
  /**
1094
1198
  * 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
1199
+ * `Upgrade: websocket`. The handshake runs the chain an HTTP `GET` registered here would: the
1200
+ * `authenticate()` stages, `derive`s and `beforeHandle`s added before this call, then the optional
1201
+ * `handler.upgrade(c)`. Any of them may reject (a `Response`, a `status(...)`, a failed auth stage);
1202
+ * `upgrade()` may seed per-connection `ws.data`. To keep a socket public, register it before the
1203
+ * guard. WebSockets are served by the
1097
1204
  * adapter (`listen()`, `@nifrajs/node`, `@nifrajs/deno`, `toFetchHandler`) - not by bare `app.fetch`, which
1098
1205
  * has no socket (a WS path through `app.fetch` is a normal HTTP response).
1099
1206
  *
@@ -1120,10 +1227,20 @@ export class Server {
1120
1227
  this.topics ??= runtime.createTopics();
1121
1228
  // A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
1122
1229
  // 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;
1230
+ const wrapped = runtime.wrapHandler(handler, this.protoPoisoning);
1231
+ const upgrade = runtime.upgradeStep(wrapped);
1232
+ for (const form of expandOptionalParams(path)) {
1233
+ // The chain an HTTP `GET` to `form` would capture now, ending in `upgrade()` instead of a
1234
+ // handler. `afterHandle` and `around` are left out: they wrap a response, and an accepted
1235
+ // handshake has none.
1236
+ const { entry, assurance, descriptor } = this.prepareRoute("WS", form, wrapped.capabilities === undefined ? undefined : { capabilities: wrapped.capabilities }, upgrade);
1237
+ const chain = { ...entry, afterHandle: [] };
1238
+ this.wsRouter.add("GET", form, {
1239
+ handler: wrapped,
1240
+ chain: { ...chain, program: compileRouteProgram({ ...chain, bodySchema: undefined }) },
1241
+ });
1242
+ this.wsRoutes.push({ path: form, capabilities: descriptor.capabilities, assurance, chain });
1243
+ }
1127
1244
  wsChanges += 1;
1128
1245
  return this;
1129
1246
  }
@@ -1315,11 +1432,6 @@ export class Server {
1315
1432
  assurance: routeAssurance,
1316
1433
  };
1317
1434
  }
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
1435
  /** @internal Symbol-keyed install seam for the `responseContract()` plugin. Off the public typed surface. */
1324
1436
  [INSTALL_RESPONSE_CONTRACT](runtime) {
1325
1437
  this.assertConfigurable("responseContract()");
@@ -1332,6 +1444,8 @@ export class Server {
1332
1444
  assertConfigurable: (operation) => this.assertConfigurable(operation),
1333
1445
  addResponseHook: (web, node, headerOnly = false) => {
1334
1446
  this.onResponseHooks.push(web);
1447
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1448
+ tagHook(web, headerOnly ? "responseHeaders" : "onResponse", this);
1335
1449
  this.onNodeResponseHooks.push(node);
1336
1450
  if (node === undefined)
1337
1451
  this.nodeResponseHooksComplete = false;
@@ -1370,6 +1484,11 @@ export class Server {
1370
1484
  this.assertConfigurable("nodeDirect()");
1371
1485
  this.nodeOutcomeRuntime = runtime;
1372
1486
  }
1487
+ /** @internal An adapter reports a failure no response hook saw (a hook that threw past `fetch`, a
1488
+ * response the runtime refused to write), logged as Bun's `listen()` logs one. */
1489
+ [REPORT_ERROR](error) {
1490
+ emitRequestErrorLog(this.logger, this.errorLogDetail, error, undefined);
1491
+ }
1373
1492
  /** @internal Symbol-keyed install seam for the `streaming()` plugin. Off the public typed surface. */
1374
1493
  [INSTALL_SSE](runtime) {
1375
1494
  this.assertConfigurable("streaming()");
@@ -1388,7 +1507,7 @@ export class Server {
1388
1507
  * counts. `seen` ends a mount cycle (an app mounted under itself to alias a prefix).
1389
1508
  */
1390
1509
  [GET_WS_RUNTIME](seen) {
1391
- return this.wsRouteCount > 0 ? this.wsRuntime : this.mountedWsRuntime(seen);
1510
+ return this.wsRoutes.length > 0 ? this.wsRuntime : this.mountedWsRuntime(seen);
1392
1511
  }
1393
1512
  /** The `[GET_WS_RUNTIME]` answer for the apps mounted here alone, whatever this app's own routes. */
1394
1513
  mountedWsRuntime(seen = new Set()) {
@@ -1429,15 +1548,36 @@ export class Server {
1429
1548
  * `derive`/`decorate`/`beforeHandle`/`afterHandle`/`onError`/`around` apply to its routes
1430
1549
  * exactly as they did standalone, so a group wires its own plugins. The same locality rule
1431
1550
  * 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
1551
+ * 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
1552
  * because the app composed it in), and the group's global assurance follows its hooks onto
1434
1553
  * exactly those routes. A group with hooks but NO routes is a middleware bundle - its hooks
1435
1554
  * can only mean app-wide intent, so they are appended globally, unchanged. Response-side hooks
1436
1555
  * (`onResponse`/`onResponseFinalized`) are appended to this server's. This server's
1437
1556
  * route-scoped chains do NOT retroactively wrap merged routes (order-scoped, like routes
1438
1557
  * 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).
1558
+ * `RouteConfigError` at merge time, and a group with WebSocket routes or mounts is refused
1559
+ * (register those on the parent).
1560
+ *
1561
+ * What this server's `.use()`, made BEFORE the merge, covers on a merged route versus a
1562
+ * `group()` route:
1563
+ *
1564
+ * | installed on this server | group() route | merge()d route |
1565
+ * | ------------------------------------------ | ------------- | ------------------------------ |
1566
+ * | derive/decorate/beforeHandle/afterHandle, | yes | no - wire it on the merged |
1567
+ * | onError/around, authenticate, route-scoped | | server |
1568
+ * | assurance | | |
1569
+ * | onRequest/onResponse/responseHeaders hooks | yes | yes (app-wide) |
1570
+ * | responseContract() | yes | yes, when the route declares a |
1571
+ * | | | response and its own server |
1572
+ * | | | installed no contract |
1573
+ * | effectLedger() | yes | yes, when the route declares |
1574
+ * | | | capabilities and has no ledger |
1575
+ * | idempotency() | yes | the merged server's own; a |
1576
+ * | | | route declaring it without one |
1577
+ * | | | is refused at registration |
1578
+ *
1579
+ * The response contract and the ledger can only add a check or a record, so a merged route is
1580
+ * recompiled under them. A runtime installed AFTER the merge covers neither kind of route.
1441
1581
  */
1442
1582
  merge(other) {
1443
1583
  this.assertConfigurable("merge()");
@@ -1452,8 +1592,8 @@ export class Server {
1452
1592
  throw new RouteConfigError("INVALID_PATH", "merge() inside a group cannot prefix compiled routes - merge on the parent");
1453
1593
  }
1454
1594
  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");
1595
+ if (source.wsRoutes.length > 0 || source.fetchMounts.length > 0) {
1596
+ throw new RouteConfigError("INVALID_PATH", "merge() does not carry WebSocket routes or mounts - register them on the parent server");
1457
1597
  }
1458
1598
  this.adopt(source, undefined, 0);
1459
1599
  return this;
@@ -1473,7 +1613,7 @@ export class Server {
1473
1613
  * The builder receives a scope that INHERITS this server's route chain as it stands at the call
1474
1614
  * (`derive`/`decorate`/`authenticate`/`beforeHandle`/`afterHandle`/`onError`/`around`, assurance,
1475
1615
  * capability interceptors, installed runtimes, applied plugins), exactly as a route declared here
1476
- * would. A plugin this server already applied is in effect for the group: the group's `use()` of
1616
+ * would. {@link merge} documents how that differs for a merged server. A plugin this server already applied is in effect for the group: the group's `use()` of
1477
1617
  * that same instance does nothing, and its `use()` of another instance under the same name throws a
1478
1618
  * `RouteConfigError` (`PLUGIN_RECONFIGURED`), because that instance could never replace the parent's.
1479
1619
  * A plugin the group applies still shares the parent's copy of a dependency it `use()`s itself.
@@ -1534,13 +1674,13 @@ export class Server {
1534
1674
  if (built !== scope) {
1535
1675
  throw new TypeError(`${label} builder must return its group synchronously`);
1536
1676
  }
1537
- if (scope.wsRouteCount > 0 ||
1677
+ if (scope.wsRoutes.length > 0 ||
1538
1678
  scope.fetchMounts.length > 0 ||
1539
1679
  scope.catalog.entries().some((route) => route.descriptor.tool !== undefined)) {
1540
1680
  throw new RouteConfigError("INVALID_PATH", `${label} cannot hold ws(), mount, or MCP tool routes - add them to the parent`);
1541
1681
  }
1542
1682
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1543
- sealHookAudit(scope, scope.catalog.size, this.logger);
1683
+ sealHookAudit(scope, scope.catalog.size, scope.wsRoutes.length, this.logger);
1544
1684
  this.adopt(scope, fullPrefix, this.globalAssurance.length);
1545
1685
  return this;
1546
1686
  }
@@ -1567,7 +1707,7 @@ export class Server {
1567
1707
  assurance: [...route.assurance, ...ownGlobal],
1568
1708
  })
1569
1709
  : (route) => route;
1570
- this.catalog.addBatch(sourceRoutes.map((route) => this.bindFusedRuntime(foldedAssurance(route))));
1710
+ this.catalog.addBatch(sourceRoutes.map((route) => this.bindFusedRuntime(foldedAssurance(adoptRouteRuntimes(route, this.responseContractRuntime, this.effectLedgerRuntime)))));
1571
1711
  // Resolved idempotency/ledger route entries carry their own store/sink configuration, while the
1572
1712
  // runtime object supplies the generic execution machinery. If the parent already has a runtime,
1573
1713
  // either implementation can execute every resolved entry because route-specific options were
@@ -1594,13 +1734,17 @@ export class Server {
1594
1734
  // The group's static declarations came before its own response hooks, so they are folded in
1595
1735
  // first - and fold themselves into a hook here if this server already has one (same ordering
1596
1736
  // rule as a direct `responseHeaders()` call).
1737
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1738
+ adoptStaticTags(this, source, this.onResponseHooks.length > 0);
1597
1739
  this.addStaticResponseHeaders({ ...statics.record });
1598
1740
  }
1599
1741
  else {
1600
1742
  // The static tier is folded into EVERY response this server builds, so a group's static
1601
1743
  // headers cannot join it. A hook ahead of the group's own (the sealed scope is discarded
1602
1744
  // after adoption) keeps their meaning - defaults a value already on the response wins over.
1603
- source.onResponseHooks.unshift((response) => applyStaticResponseHeaders(response, statics));
1745
+ source.onResponseHooks.unshift((response, req) => applyStaticResponseHeaders(response, statics, req));
1746
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1747
+ tagHook(source.onResponseHooks[0], "responseHeaders", source, true);
1604
1748
  source.onNodeResponseHooks.unshift(undefined);
1605
1749
  source.nodeResponseHooksComplete = false;
1606
1750
  }
@@ -1608,6 +1752,17 @@ export class Server {
1608
1752
  this.onResponseHooks.push(...scopeHooks(source.onResponseHooks, responseScope, 1, true));
1609
1753
  this.onNodeResponseHooks.push(...scopeHooks(source.onNodeResponseHooks, responseScope, 1));
1610
1754
  this.onResponseFinalizedHooks.push(...scopeHooks(source.onResponseFinalizedHooks, responseScope, 1));
1755
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
1756
+ // Read before anything else is appended: the wrappers are still each list's tail.
1757
+ const pairs = [];
1758
+ if (inScope !== undefined)
1759
+ pairs.push([source.onRequestHooks, this.onRequestHooks]);
1760
+ if (responseScope !== undefined) {
1761
+ pairs.push([source.onResponseHooks, this.onResponseHooks]);
1762
+ pairs.push([source.onResponseFinalizedHooks, this.onResponseFinalizedHooks]);
1763
+ }
1764
+ tagAdopted(this, source, prefix, sourceRoutes, pairs);
1765
+ }
1611
1766
  this.nodeResponseHooksComplete &&= source.nodeResponseHooksComplete;
1612
1767
  this.bunNativeResponseHeadersOnly &&= source.bunNativeResponseHeadersOnly;
1613
1768
  this.hasRawNodeResponseHook ||= source.hasRawNodeResponseHook;
@@ -1724,7 +1879,7 @@ export class Server {
1724
1879
  // Seal once on the first request so test servers (no `listen()`) still get the dead-hook check.
1725
1880
  // After that it is a single boolean read - the per-request cost the option promises to keep at zero.
1726
1881
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1727
- sealHookAudit(this, this.catalog.size, this.logger);
1882
+ sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger);
1728
1883
  // Off path (default): straight through - one property check, no closure, no promise.
1729
1884
  if (this.capacityGate === undefined)
1730
1885
  return this.fetchSourceInner(source, platform);
@@ -1857,7 +2012,7 @@ export class Server {
1857
2012
  resolveNodeSource(source, platform, suppliedRuntime) {
1858
2013
  // Seal once on the Node-direct lane too - it can bypass `fetchSource` entirely (hookless outcome).
1859
2014
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1860
- sealHookAudit(this, this.catalog.size, this.logger);
2015
+ sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger);
1861
2016
  // A paired header-only native hook can preserve the Node-direct JSON/body outcome for successful
1862
2017
  // responses. Arbitrary onResponse transforms need a real Web Response, but a buffered outcome can
1863
2018
  // be materialized with a direct-write marker and return to the socket path when the hook mutates
@@ -2075,7 +2230,7 @@ export class Server {
2075
2230
  continue;
2076
2231
  }
2077
2232
  if (outcome !== undefined)
2078
- return outcome;
2233
+ return webResponseOf(outcome);
2079
2234
  }
2080
2235
  return current;
2081
2236
  }
@@ -2086,7 +2241,7 @@ export class Server {
2086
2241
  if (outcome instanceof Request)
2087
2242
  next = outcome;
2088
2243
  else if (outcome !== undefined)
2089
- return outcome;
2244
+ return webResponseOf(outcome);
2090
2245
  if (i >= this.onRequestHooks.length)
2091
2246
  return next;
2092
2247
  const hook = this.onRequestHooks[i];
@@ -2098,7 +2253,7 @@ export class Server {
2098
2253
  resolveWebSocketUpgrade(req, platform) {
2099
2254
  // Before any header is read: Deno only materializes a request's headers when asked, and that
2100
2255
  // costs a plain request measurably.
2101
- if (this.wsRouteCount === 0 && !this.mountsTakeUpgrades())
2256
+ if (this.wsRoutes.length === 0 && !this.mountsTakeUpgrades())
2102
2257
  return WS_PASS;
2103
2258
  if (req.headers.get("upgrade")?.toLowerCase() !== "websocket")
2104
2259
  return WS_PASS;
@@ -2106,6 +2261,8 @@ export class Server {
2106
2261
  // is an ordinary request: its HTTP route answers it, and normal routing refuses an unknown token.
2107
2262
  if (req.method !== "GET")
2108
2263
  return WS_PASS;
2264
+ // The adapter answers a pass by handing this same object to `fetch`.
2265
+ const received = req;
2109
2266
  // The handshake routes the path `fetch` would: dot segments resolved (see `fetch`).
2110
2267
  if (rawRequestTargets && hasDotSegment(req.url))
2111
2268
  req = withResolvedTarget(req);
@@ -2119,11 +2276,12 @@ export class Server {
2119
2276
  const controller = timeoutMs > 0 ? new AbortController() : undefined;
2120
2277
  const signal = controller?.signal ?? getNeverAbortSignal();
2121
2278
  const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : undefined;
2122
- const timeoutResponse = jsonError(503, "request_timeout");
2123
- const onTimeout = () => ({
2279
+ // A refusal carries the static response headers an HTTP answer would; a guard's already does.
2280
+ const refuse = (response) => ({
2124
2281
  kind: "reject",
2125
- response: timeoutResponse,
2282
+ response: this.wrapWebResponse(response),
2126
2283
  });
2284
+ const onTimeout = () => refuse(jsonError(503, "request_timeout"));
2127
2285
  const bounded = (work, onDone) => controller === undefined
2128
2286
  ? work
2129
2287
  : this.withTimeout(work, controller, onDone, Math.max(0, deadline - Date.now()));
@@ -2136,11 +2294,11 @@ export class Server {
2136
2294
  // already did and made every plain dynamic route pay for unrelated static path bytes.
2137
2295
  const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params);
2138
2296
  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.
2297
+ return refuse(jsonError(400, "malformed_path"));
2298
+ const { handler, chain } = match.payload;
2299
+ // Non-null: a socket route ⇒ ws() ran ⇒ `.use(websocket())` installed the runtime + registry.
2142
2300
  const pubsub = this.topics;
2143
- const attach = this.wsRuntime.attach;
2301
+ const runtime = this.wsRuntime;
2144
2302
  // CSWSH guard, before any per-connection work or the user's upgrade(): reject a disallowed
2145
2303
  // Origin with 403. Browsers don't CORS-protect WS handshakes but do send cookies, so this
2146
2304
  // blocks cross-site authenticated sockets when the route opts in via `allowedOrigins`.
@@ -2150,52 +2308,46 @@ export class Server {
2150
2308
  ? handler.allowedOrigins(origin)
2151
2309
  : origin !== null && handler.allowedOrigins.includes(origin);
2152
2310
  if (!allowed)
2153
- return { kind: "reject", response: jsonError(403, "forbidden_origin") };
2311
+ return refuse(jsonError(403, "forbidden_origin"));
2154
2312
  }
2155
2313
  else if (origin !== null && !wsSameOrigin(origin, request)) {
2156
2314
  // Secure default (no explicit `allowedOrigins`): reject a CROSS-ORIGIN browser handshake - the
2157
2315
  // CSWSH case, since browsers send cookies on WS handshakes and don't apply CORS. Non-browser
2158
2316
  // clients send no `Origin` and pass; same-origin browsers pass. Set `allowedOrigins` to permit
2159
2317
  // 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
- };
2318
+ return refuse(jsonError(403, "forbidden_origin"));
2171
2319
  }
2172
2320
  const budget = controller === undefined
2173
2321
  ? createUnboundedRequestBudget(signal)
2174
2322
  : createRequestBudget({ deadline: deadline, signal });
2175
2323
  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
- };
2324
+ // Rendered as the same chain on an HTTP `GET` renders its early results.
2325
+ const finalize = (result, set) => {
2326
+ const accepted = runtime.accepted(result);
2327
+ return accepted === undefined
2328
+ ? { kind: "reject", response: this.finalizeResponse(result, set) }
2329
+ : {
2330
+ kind: "upgrade",
2331
+ handler,
2332
+ data: accepted.data,
2333
+ pubsub,
2334
+ attach: runtime.attach,
2335
+ maxPayloadBytes: this.wsMaxPayloadBytes,
2336
+ sender: runtime.sender,
2337
+ };
2338
+ };
2339
+ const wrap = (response) => finalize(response, EMPTY_RESPONSE_CONTROLS);
2340
+ let outcome;
2186
2341
  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);
2342
+ outcome = executeRouteProgram(this, chain, chain.program, request, ctx, finalize, wrap);
2195
2343
  }
2196
2344
  catch {
2197
- return { kind: "reject", response: jsonError(500, "internal_error") };
2345
+ return refuse(jsonError(500, "internal_error"));
2198
2346
  }
2347
+ if (!(outcome instanceof Promise))
2348
+ return outcome;
2349
+ const pending = outcome.then(undefined, () => refuse(jsonError(500, "internal_error")));
2350
+ return bounded(pending, onTimeout);
2199
2351
  };
2200
2352
  const resolveMounted = (request) => {
2201
2353
  // Native pub/sub was chosen because no mounted app took upgrades when `listen()` ran. One that
@@ -2217,20 +2369,25 @@ export class Server {
2217
2369
  }
2218
2370
  if (selected === undefined)
2219
2371
  return WS_PASS;
2220
- const childRequest = selected.stripPrefix
2221
- ? stripMountPrefix(request, selected.path)
2222
- : request;
2372
+ let childRequest = request;
2373
+ if (selected.stripPrefix) {
2374
+ childRequest = stripMountPrefix(request, selected.path);
2375
+ selected.wsPassed ??= new WeakMap();
2376
+ selected.wsPassed.set(request, childRequest);
2377
+ }
2223
2378
  let child;
2224
2379
  try {
2225
2380
  child = selected.resolveWebSocketUpgrade(childRequest, platform);
2226
2381
  }
2227
2382
  catch {
2228
- return { kind: "reject", response: jsonError(500, "internal_error") };
2383
+ return refuse(jsonError(500, "internal_error"));
2229
2384
  }
2230
2385
  const settle = (outcome) => outcome.kind === "pass" ||
2231
2386
  (outcome.kind === "reject" && outcome.response.status === 404 && selected.fallbackOn404)
2232
2387
  ? dispatch(selectedIndex + 1)
2233
- : outcome;
2388
+ : outcome.kind === "reject"
2389
+ ? refuse(outcome.response)
2390
+ : outcome;
2234
2391
  return child instanceof Promise ? child.then(settle) : settle(child);
2235
2392
  };
2236
2393
  return dispatch(0);
@@ -2248,21 +2405,24 @@ export class Server {
2248
2405
  };
2249
2406
  if (this.onRequestHooks.length === 0)
2250
2407
  return resolveWithDeadline(req);
2408
+ // On a pass the adapter hands `received` to `fetch`, which then routes these results. An upgrade
2409
+ // or a rejection never reaches `fetch`, so the entry dies with the request.
2410
+ const resolveHooked = (request) => {
2411
+ this.wsHookedPasses ??= new WeakMap();
2412
+ this.wsHookedPasses.set(received, request);
2413
+ return resolveWithDeadline(request);
2414
+ };
2251
2415
  let hooked;
2252
2416
  try {
2253
2417
  hooked = this.runWebSocketRequestHooks(req, platform);
2254
2418
  }
2255
2419
  catch {
2256
- return { kind: "reject", response: jsonError(500, "internal_error") };
2420
+ return refuse(jsonError(500, "internal_error"));
2257
2421
  }
2258
2422
  if (!(hooked instanceof Promise)) {
2259
- return hooked instanceof Response
2260
- ? { kind: "reject", response: hooked }
2261
- : resolveWithDeadline(hooked);
2423
+ return hooked instanceof Response ? refuse(hooked) : resolveHooked(hooked);
2262
2424
  }
2263
- const pending = hooked.then((result) => result instanceof Response
2264
- ? { kind: "reject", response: result }
2265
- : resolveWithDeadline(result), () => ({ kind: "reject", response: jsonError(500, "internal_error") }));
2425
+ const pending = hooked.then((result) => (result instanceof Response ? refuse(result) : resolveHooked(result)), () => refuse(jsonError(500, "internal_error")));
2266
2426
  return bounded(Promise.resolve(pending).then((result) => result), onTimeout);
2267
2427
  }
2268
2428
  /** Bun `fetch` when WS routes exist: try a WS upgrade first, else run the normal HTTP lifecycle.
@@ -2274,9 +2434,8 @@ export class Server {
2274
2434
  return this.fetch(req, bunPeerPlatform(server, req));
2275
2435
  if (o.kind === "reject")
2276
2436
  return o.response;
2277
- return server.upgrade(req, {
2278
- data: { handler: o.handler, data: o.data, pubsub: o.pubsub },
2279
- })
2437
+ // A copy: the Bun socket keeps per-connection state on it, and a hand-built outcome may be shared.
2438
+ return server.upgrade(req, { data: { ...o } })
2280
2439
  ? undefined
2281
2440
  : jsonError(426, "upgrade_required");
2282
2441
  };
@@ -2319,6 +2478,13 @@ export class Server {
2319
2478
  if (this.onRequestHooks.length === 0 || !isRoutableMethod(source.method)) {
2320
2479
  return this.routeAndRun(source, resolved, finalize, wrapResponse, onTimeout, webFast);
2321
2480
  }
2481
+ const hooked = this.wsHookedPasses?.get(source);
2482
+ if (hooked !== undefined) {
2483
+ const request = requestOf(source);
2484
+ this.wsHookedPasses?.delete(request);
2485
+ this.responseRequests.set(request, hooked);
2486
+ return this.routeAndRun(hooked, resolved, finalize, wrapResponse, onTimeout, webFast);
2487
+ }
2322
2488
  if (!webFast && this.canUseNodeRequestHooks()) {
2323
2489
  return this.runWithNodeRequest(source, resolved, finalize, wrapResponse, onTimeout);
2324
2490
  }
@@ -2595,7 +2761,9 @@ export class Server {
2595
2761
  }
2596
2762
  if (mount === undefined)
2597
2763
  return fallback ?? jsonError(404, "not_found");
2598
- const childRequest = mount.stripPrefix ? stripMountPrefix(request, mount.path) : request;
2764
+ const childRequest = mount.stripPrefix
2765
+ ? (mount.wsPassed?.get(request) ?? stripMountPrefix(request, mount.path))
2766
+ : request;
2599
2767
  const outcome = mount.handler(childRequest, platform);
2600
2768
  if (!replayable || !mount.fallbackOn404)
2601
2769
  return outcome;
@@ -2631,7 +2799,7 @@ export class Server {
2631
2799
  if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
2632
2800
  return this.idempotencyRuntime.run(entry.idempotent, requestOf(source), platform, entry, params, search, wrapResponse, {
2633
2801
  maxBodyBytes: entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2634
- runLanes: (buffered, plat, ent, prm, srch) => this.idempotencyRunLanes(buffered, plat, ent, prm, srch),
2802
+ runLanes: (buffered, plat, ent, prm, srch) => this.runMatchedLanes(buffered, plat, ent, prm, srch, this.finalizeResponse, this.wrapWebResponse, this.webResponseTimeout, false),
2635
2803
  });
2636
2804
  }
2637
2805
  return this.runMatchedLanes(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast);
@@ -2672,11 +2840,20 @@ export class Server {
2672
2840
  : createRequestBudget({ deadline: admission.deadline, signal });
2673
2841
  const plan = entry.execution;
2674
2842
  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);
2843
+ let outcome;
2844
+ try {
2845
+ outcome =
2846
+ webFast && plan.fusedWeb !== undefined
2847
+ ? plan.fusedWeb(source, params, search, signal, budget, platform, nativeContext)
2848
+ : !webFast && plan.fusedBody !== undefined
2849
+ ? plan.fusedBody(source, params, search, signal, budget, platform, false, finalize, wrapResponse)
2850
+ : plan.run(this, entry, source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse);
2851
+ }
2852
+ catch (err) {
2853
+ // A lane's synchronous render throw (a BigInt, a header value with a line break): the lanes
2854
+ // guard only their async continuations, since a `try` around a fused render slows it.
2855
+ return this.contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse);
2856
+ }
2680
2857
  // The request timeout only bounds work that is actually pending - a synchronous (bare) result is
2681
2858
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
2682
2859
  if (controller !== undefined && outcome instanceof Promise) {
@@ -2704,7 +2881,14 @@ export class Server {
2704
2881
  return this.contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse);
2705
2882
  }
2706
2883
  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));
2884
+ return result.then((value) => {
2885
+ try {
2886
+ return finalize(value, EMPTY_RESPONSE_CONTROLS);
2887
+ }
2888
+ catch (err) {
2889
+ return this.contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse);
2890
+ }
2891
+ }, (err) => this.contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse));
2708
2892
  }
2709
2893
  return finalize(result, EMPTY_RESPONSE_CONTROLS);
2710
2894
  }
@@ -2740,9 +2924,17 @@ export class Server {
2740
2924
  }
2741
2925
  if (result instanceof Promise) {
2742
2926
  // 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));
2927
+ return result.then((value) => {
2928
+ // A synchronous finalize throw reaches runMatchedLanes; this one would reject.
2929
+ try {
2930
+ return finalize(value, responseSet(ctx), ctx);
2931
+ }
2932
+ catch (err) {
2933
+ return this.bareError(err, ctx, finalize, wrapResponse);
2934
+ }
2935
+ }, (err) => this.bareError(err, ctx, finalize, wrapResponse));
2744
2936
  }
2745
- return finalize(result, responseSet(ctx));
2937
+ return finalize(result, responseSet(ctx), ctx);
2746
2938
  }
2747
2939
  /** Bare-route error rendering - identical to the compiled route program's catch minus the (absent) onError
2748
2940
  * loop: a thrown `Response` is returned as deliberate control flow; anything else is logged + 500. */
@@ -2762,22 +2954,29 @@ export class Server {
2762
2954
  // `() => ...` can't observe the context - skip allocating one entirely (errors still build
2763
2955
  // one for the structured log, exactly like runContextlessBare).
2764
2956
  const contextlessHandler = handler;
2957
+ const fail = (err, source, params, search, signal, budget, platform, nativeContext) => logError(err, nativeContext
2958
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2959
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning));
2765
2960
  return (source, params, search, signal, budget, platform, nativeContext) => {
2766
2961
  let result;
2767
2962
  try {
2768
2963
  result = contextlessHandler();
2769
2964
  }
2770
2965
  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));
2966
+ return fail(err, source, params, search, signal, budget, platform, nativeContext);
2774
2967
  }
2775
2968
  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)));
2969
+ return result.then((value) => {
2970
+ // A synchronous render throw reaches runMatchedLanes; this one would reject.
2971
+ try {
2972
+ return fusedRespondNoSet(value, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0, this.onResponseHooks.length === 0 ? undefined : requestOf(source));
2973
+ }
2974
+ catch (err) {
2975
+ return fail(err, source, params, search, signal, budget, platform, nativeContext);
2976
+ }
2977
+ }, (err) => fail(err, source, params, search, signal, budget, platform, nativeContext));
2779
2978
  }
2780
- return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
2979
+ return fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0, this.onResponseHooks.length === 0 ? undefined : requestOf(source));
2781
2980
  };
2782
2981
  }
2783
2982
  return (source, params, search, signal, budget, platform, nativeContext) => {
@@ -2794,11 +2993,21 @@ export class Server {
2794
2993
  return logError(err, ctx);
2795
2994
  }
2796
2995
  if (result instanceof Promise) {
2797
- return result.then((value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
2996
+ return result.then((value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
2798
2997
  }
2799
2998
  return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
2800
2999
  };
2801
3000
  }
3001
+ /** A fused Web lane's render in an async continuation, where a result that cannot be rendered
3002
+ * must fail like a handler throw; a synchronous render throw reaches {@link runMatchedLanes}. */
3003
+ respondFused(result, ctx, unstamped) {
3004
+ try {
3005
+ return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, unstamped);
3006
+ }
3007
+ catch (err) {
3008
+ return this.bareError(err, ctx, (value, set) => this.wrapWebResponse(toResponse(value, set)), this.wrapWebResponse);
3009
+ }
3010
+ }
2802
3011
  /** Compile the eligible body-only route's parser → validator → handler continuation once. The
2803
3012
  * bounded parser remains shared with the generic lane; only the route-invariant entry lookups and
2804
3013
  * lifecycle dispatch disappear from the common synchronous-validator/synchronous-handler case. */
@@ -2867,7 +3076,7 @@ export class Server {
2867
3076
  ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2868
3077
  : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
2869
3078
  const finish = (value) => runParsed(value, ctx, finalize, wrapResponse);
2870
- return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, finish, wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse), bodySchema);
3079
+ return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, finish, wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse), bodySchema, this.formOrigins);
2871
3080
  };
2872
3081
  }
2873
3082
  /** Web adapter wrapper for the shared body runner. Node passes its native finalizer directly through
@@ -2897,9 +3106,9 @@ export class Server {
2897
3106
  return logError(err, ctx);
2898
3107
  }
2899
3108
  if (result instanceof Promise) {
2900
- return result.then((settled) => fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
3109
+ return result.then((settled) => this.respondFused(settled, ctx, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
2901
3110
  }
2902
- return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
3111
+ return this.respondFused(result, ctx, this.onResponseHooks.length === 0);
2903
3112
  };
2904
3113
  return (source, params, search, signal, budget, platform, nativeContext) => {
2905
3114
  const ctx = nativeContext
@@ -2941,173 +3150,88 @@ export class Server {
2941
3150
  * before → handler in one frame.
2942
3151
  */
2943
3152
  buildFusedDeriveBefore(handler, derive, before, querySchema, maxBodyBytes) {
3153
+ return this.buildFusedDeriveBeforeAfter(handler, derive, before, undefined, querySchema, maxBodyBytes);
3154
+ }
3155
+ // Both hook variants share one registered chain; request contexts stay in arguments.
3156
+ buildFusedDeriveBeforeAfter(handler, derive, before, after, querySchema, maxBodyBytes) {
2944
3157
  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;
3158
+ // Continuations capture route state once; each request supplies its own context.
3159
+ const runAfter = (result, ctx) => {
3160
+ if (after === undefined)
3161
+ return this.respondFused(result, ctx, this.onResponseHooks.length === 0);
3162
+ let transformed;
3007
3163
  try {
3008
- validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
3164
+ transformed = after(result, ctx);
3009
3165
  }
3010
3166
  catch (err) {
3011
3167
  return logError(err, ctx);
3012
3168
  }
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();
3169
+ if (transformed instanceof Promise) {
3170
+ return transformed.then((value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
3171
+ }
3172
+ return this.respondFused(transformed, ctx, this.onResponseHooks.length === 0);
3173
+ };
3174
+ const runHandler = (ctx) => {
3175
+ let result;
3176
+ try {
3177
+ result = handler(ctx);
3178
+ }
3179
+ catch (err) {
3180
+ return logError(err, ctx);
3181
+ }
3182
+ if (result instanceof Promise) {
3183
+ return result.then((value) => runAfter(value, ctx), (err) => logError(err, ctx));
3184
+ }
3185
+ return runAfter(result, ctx);
3186
+ };
3187
+ const runBefore = (ctx) => {
3188
+ let early;
3189
+ try {
3190
+ early = before(ctx);
3191
+ }
3192
+ catch (err) {
3193
+ return logError(err, ctx);
3194
+ }
3195
+ if (early instanceof Promise) {
3196
+ return early.then((settled) => {
3197
+ if (settled === undefined)
3198
+ return runHandler(ctx);
3199
+ return this.respondFused(settled, ctx);
3019
3200
  }, (err) => logError(err, ctx));
3020
3201
  }
3021
- if (validation.issues !== undefined)
3022
- return this.wrapWebResponse(plainValidationError(validation.issues));
3023
- ctx.query = validation.value;
3024
- return runDerive();
3202
+ if (early === undefined)
3203
+ return runHandler(ctx);
3204
+ return this.respondFused(early, ctx);
3205
+ };
3206
+ const runDerive = (ctx) => {
3207
+ let derived;
3208
+ try {
3209
+ derived = derive(ctx);
3210
+ }
3211
+ catch (err) {
3212
+ return logError(err, ctx);
3213
+ }
3214
+ if (derived instanceof Promise) {
3215
+ return derived.then((settled) => {
3216
+ if (isResponseResult(settled) || settled instanceof Response) {
3217
+ return this.respondFused(settled, ctx);
3218
+ }
3219
+ Object.assign(ctx, settled);
3220
+ return runBefore(ctx);
3221
+ }, (err) => logError(err, ctx));
3222
+ }
3223
+ if (isResponseResult(derived) || derived instanceof Response) {
3224
+ return this.respondFused(derived, ctx);
3225
+ }
3226
+ Object.assign(ctx, derived);
3227
+ return runBefore(ctx);
3025
3228
  };
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
3229
  return (source, params, search, signal, budget, platform, nativeContext) => {
3038
3230
  const ctx = nativeContext
3039
3231
  ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3040
3232
  : 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
3233
  if (querySchema === undefined)
3110
- return runDerive();
3234
+ return runDerive(ctx);
3111
3235
  let validation;
3112
3236
  try {
3113
3237
  validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
@@ -3120,13 +3244,13 @@ export class Server {
3120
3244
  if (settled.issues !== undefined)
3121
3245
  return this.wrapWebResponse(plainValidationError(settled.issues));
3122
3246
  ctx.query = settled.value;
3123
- return runDerive();
3247
+ return runDerive(ctx);
3124
3248
  }, (err) => logError(err, ctx));
3125
3249
  }
3126
3250
  if (validation.issues !== undefined)
3127
3251
  return this.wrapWebResponse(plainValidationError(validation.issues));
3128
3252
  ctx.query = validation.value;
3129
- return runDerive();
3253
+ return runDerive(ctx);
3130
3254
  };
3131
3255
  }
3132
3256
  /**
@@ -3150,7 +3274,7 @@ export class Server {
3150
3274
  const runHandlerValidated = () => {
3151
3275
  const runAfter = (result) => {
3152
3276
  if (after === undefined) {
3153
- return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
3277
+ return this.respondFused(result, ctx, this.onResponseHooks.length === 0);
3154
3278
  }
3155
3279
  let transformed;
3156
3280
  try {
@@ -3160,9 +3284,9 @@ export class Server {
3160
3284
  return logError(err, ctx);
3161
3285
  }
3162
3286
  if (transformed instanceof Promise) {
3163
- return transformed.then((value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
3287
+ return transformed.then((value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
3164
3288
  }
3165
- return fusedRespond(transformed, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
3289
+ return this.respondFused(transformed, ctx, this.onResponseHooks.length === 0);
3166
3290
  };
3167
3291
  const runHandler = () => {
3168
3292
  let result;
@@ -3189,12 +3313,12 @@ export class Server {
3189
3313
  return early.then((settled) => {
3190
3314
  if (settled === undefined)
3191
3315
  return runHandler();
3192
- return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders);
3316
+ return this.respondFused(settled, ctx);
3193
3317
  }, (err) => logError(err, ctx));
3194
3318
  }
3195
3319
  if (early === undefined)
3196
3320
  return runHandler();
3197
- return fusedRespond(early, ctx, this.responseBodyTag, this.staticResponseHeaders);
3321
+ return this.respondFused(early, ctx);
3198
3322
  };
3199
3323
  let derived;
3200
3324
  try {
@@ -3206,14 +3330,14 @@ export class Server {
3206
3330
  if (derived instanceof Promise) {
3207
3331
  return derived.then((settled) => {
3208
3332
  if (isResponseResult(settled) || settled instanceof Response) {
3209
- return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders);
3333
+ return this.respondFused(settled, ctx);
3210
3334
  }
3211
3335
  Object.assign(ctx, settled);
3212
3336
  return runBefore();
3213
3337
  }, (err) => logError(err, ctx));
3214
3338
  }
3215
3339
  if (isResponseResult(derived) || derived instanceof Response) {
3216
- return fusedRespond(derived, ctx, this.responseBodyTag, this.staticResponseHeaders);
3340
+ return this.respondFused(derived, ctx);
3217
3341
  }
3218
3342
  Object.assign(ctx, derived);
3219
3343
  return runBefore();
@@ -3239,7 +3363,7 @@ export class Server {
3239
3363
  ctx.body = validation.value;
3240
3364
  return runHandlerValidated();
3241
3365
  };
3242
- return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, onParsed, (response) => this.wrapWebResponse(response), (err) => Promise.resolve(logError(err, ctx)), bodySchema);
3366
+ return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, onParsed, (response) => this.wrapWebResponse(response), (err) => Promise.resolve(logError(err, ctx)), bodySchema, this.formOrigins);
3243
3367
  };
3244
3368
  }
3245
3369
  /**
@@ -3519,7 +3643,7 @@ export class Server {
3519
3643
  const ctx = nativeContext
3520
3644
  ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
3521
3645
  : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
3522
- return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, (parsed) => onParsed(parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse), bodySchema);
3646
+ return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, (parsed) => onParsed(parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse), bodySchema, this.formOrigins);
3523
3647
  };
3524
3648
  }
3525
3649
  bareError(err, ctx, finalize, wrapResponse) {
@@ -3638,14 +3762,14 @@ export class Server {
3638
3762
  Object.assign(ctx, entry.decorations);
3639
3763
  const handlerOutput = entry.handler(ctx);
3640
3764
  if (handlerOutput instanceof Promise) {
3641
- return handlerOutput.then((value) => finalize(value, responseSet(ctx)), (err) => {
3765
+ return handlerOutput.then((value) => finalize(value, responseSet(ctx), ctx), (err) => {
3642
3766
  if (wrapResponse) {
3643
3767
  return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
3644
3768
  }
3645
3769
  throw err;
3646
3770
  });
3647
3771
  }
3648
- return finalize(handlerOutput, responseSet(ctx));
3772
+ return finalize(handlerOutput, responseSet(ctx), ctx);
3649
3773
  }
3650
3774
  handleValidationErrorRecovery(entry, recovery, ctx, finalize, wrapResponse, originalIssues, kind) {
3651
3775
  if (recovery !== undefined) {
@@ -3751,13 +3875,16 @@ export class Server {
3751
3875
  */
3752
3876
  applyOnResponse(response, req) {
3753
3877
  const hooks = this.onResponseHooks;
3754
- let current = response;
3878
+ // Hooks write onto the response they are handed (cookies, CORS, request ids), so every response
3879
+ // entering the chain - and any other one a hook hands back - is first made this request's own.
3880
+ let current = writableResponse(response, req);
3755
3881
  for (let i = 0; i < hooks.length; i++) {
3756
3882
  const next = hooks[i](current, req);
3757
3883
  if (next instanceof Promise) {
3758
- return next.then((settled) => this.continueOnResponse(settled, i + 1, req));
3884
+ const handed = current;
3885
+ return next.then((settled) => this.continueOnResponse(settled, i + 1, req, handed));
3759
3886
  }
3760
- current = next;
3887
+ current = next === current ? next : writableResponse(next, req);
3761
3888
  }
3762
3889
  return current;
3763
3890
  }
@@ -3816,11 +3943,12 @@ export class Server {
3816
3943
  return pending;
3817
3944
  }
3818
3945
  /** Async tail of {@link applyOnResponse}: runs the remaining hooks once one has gone async. */
3819
- async continueOnResponse(response, nextIndex, req) {
3820
- let current = response;
3946
+ async continueOnResponse(response, nextIndex, req, handed) {
3947
+ let current = response === handed ? response : writableResponse(response, req);
3821
3948
  for (let i = nextIndex; i < this.onResponseHooks.length; i++) {
3822
3949
  const next = this.onResponseHooks[i](current, req);
3823
- current = next instanceof Promise ? await next : next;
3950
+ const settled = next instanceof Promise ? await next : next;
3951
+ current = settled === current ? settled : writableResponse(settled, req);
3824
3952
  }
3825
3953
  return current;
3826
3954
  }
@@ -3849,7 +3977,7 @@ export class Server {
3849
3977
  try {
3850
3978
  const contract = entry.responseContract;
3851
3979
  if (contract === undefined)
3852
- return finalize(result, responseSet(ctx));
3980
+ return finalize(result, responseSet(ctx), ctx);
3853
3981
  const checked = contract.runtime.check(contract.definition, result, ctx);
3854
3982
  if (checked instanceof Promise) {
3855
3983
  return checked.then((outcome) => this.finishContractOutcome(ctx, finalize, wrapResponse, outcome), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
@@ -3876,7 +4004,7 @@ export class Server {
3876
4004
  detail: outcome.message,
3877
4005
  });
3878
4006
  }
3879
- return finalize(outcome.value, responseSet(ctx));
4007
+ return finalize(outcome.value, responseSet(ctx), ctx);
3880
4008
  }
3881
4009
  /** Synchronous by design, and `MaybePromise` rather than `Promise` for the same reason: control flow
3882
4010
  * out of a lifecycle stage must not cost a promise. An `async` method allocates one and defers the
@@ -3889,11 +4017,10 @@ export class Server {
3889
4017
  // SvelteKit semantics): as built, plus the cookies the request queued, so a session set or
3890
4018
  // cleared before `throw redirect(...)` / `requireSession(...)` ships. Don't run onError or log
3891
4019
  // 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))
4020
+ if (err instanceof Response && err !== ctx.jsonReply)
4021
+ return finalize(err, responseSet(ctx), ctx);
4022
+ // Same rule for a thrown `status(...)` or `c.json(...)`, which stay held to the route's contract.
4023
+ if (err instanceof Response || isResponseResult(err))
3897
4024
  return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, err);
3898
4025
  if (entry.onError.length === 0) {
3899
4026
  // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
@@ -3911,8 +4038,20 @@ export class Server {
3911
4038
  for (let i = 0; i < entry.onError.length; i++) {
3912
4039
  const outcome = entry.onError[i](err, ctx);
3913
4040
  const handled = outcome instanceof Promise ? await outcome : outcome;
3914
- if (handled !== undefined)
3915
- return finalize(handled, responseSet(ctx));
4041
+ if (handled === undefined)
4042
+ continue;
4043
+ const contract = entry.responseContract;
4044
+ if (contract === undefined || (handled !== ctx.jsonReply && !isResponseResult(handled)))
4045
+ return finalize(handled, responseSet(ctx), ctx);
4046
+ // Checked here, not through finishLifecycleContract: a throwing schema must not re-enter onError.
4047
+ try {
4048
+ const checked = await contract.runtime.check(contract.definition, handled, ctx);
4049
+ return this.finishContractOutcome(ctx, finalize, wrapResponse, checked);
4050
+ }
4051
+ catch (checkError) {
4052
+ this.logRequestError(checkError, ctx);
4053
+ return wrapResponse(plainError(500, "internal_error"));
4054
+ }
3916
4055
  }
3917
4056
  this.logRequestError(err, ctx);
3918
4057
  return wrapResponse(plainError(500, "internal_error"));
@@ -3933,7 +4072,7 @@ export class Server {
3933
4072
  ? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, "body"))
3934
4073
  : this.applyLifecycleValidation(entry, validation, ctx, "body");
3935
4074
  };
3936
- return readBodyFramed(req, bodyLimit ?? UNLIMITED_BODY_BYTES, this.protoPoisoning, validate, (response) => response, (error) => Promise.reject(error), bodySchema);
4075
+ return readBodyFramed(req, bodyLimit ?? UNLIMITED_BODY_BYTES, this.protoPoisoning, validate, (response) => response, (error) => Promise.reject(error), bodySchema, this.formOrigins);
3937
4076
  }
3938
4077
  /** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
3939
4078
  * before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
@@ -4003,7 +4142,14 @@ export class Server {
4003
4142
  return this.applyOnResponseAndFinalize(response, request);
4004
4143
  };
4005
4144
  const runNative = (request, params) => {
4006
- const outcome = capped(request, params, undefined, signal, budget, peer, true);
4145
+ let outcome;
4146
+ try {
4147
+ outcome = capped(request, params, undefined, signal, budget, peer, true);
4148
+ }
4149
+ catch (err) {
4150
+ // A synchronous render throw, as in runMatchedLanes.
4151
+ outcome = this.contextlessBareError(err, request, params, undefined, signal, budget, peer, this.wrapWebResponse);
4152
+ }
4007
4153
  return outcome instanceof Promise
4008
4154
  ? outcome.then((response) => finishNative(request, response))
4009
4155
  : finishNative(request, outcome);
@@ -4020,7 +4166,7 @@ export class Server {
4020
4166
  markFramed(request);
4021
4167
  return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
4022
4168
  ? this.fetchMatched(request, entry, EMPTY_PARAMS, peer)
4023
- : capped(request, EMPTY_PARAMS, undefined, signal, budget, peer, true);
4169
+ : runNative(request, EMPTY_PARAMS);
4024
4170
  };
4025
4171
  }
4026
4172
  return (request) => {
@@ -4048,7 +4194,7 @@ export class Server {
4048
4194
  return fallback(request, server);
4049
4195
  return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
4050
4196
  ? this.fetchMatched(request, entry, params, peer)
4051
- : capped(request, params, undefined, signal, budget, peer, true);
4197
+ : runNative(request, params);
4052
4198
  };
4053
4199
  }
4054
4200
  return (request, server) => {
@@ -4072,7 +4218,7 @@ export class Server {
4072
4218
  // bypasses - so an app that declares trust routes through the fetch lane (where `c.clientIp`
4073
4219
  // resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
4074
4220
  if ((this.onRequestHooks.length > 0 && !this.bunNativeRequestHooksSafe) ||
4075
- this.wsRouteCount > 0 ||
4221
+ this.wsRoutes.length > 0 ||
4076
4222
  this.preRouteMountCount > 0 ||
4077
4223
  this.clientIpTrust !== undefined) {
4078
4224
  return undefined;
@@ -4208,7 +4354,7 @@ export class Server {
4208
4354
  }
4209
4355
  // Seal before Bun.serve: in `"error"` mode this throws instead of leaving a bound port behind.
4210
4356
  if (hookAuditRuntime && process.env.NODE_ENV !== "production")
4211
- sealHookAudit(this, this.catalog.size, this.logger);
4357
+ sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger);
4212
4358
  // Bun's `Server` is the concrete handle; we expose the stable `RunningServer`
4213
4359
  // subset so the public types don't depend on the ambient `Bun` global. The cast
4214
4360
  // bridges them - Bun's `.port` is `number | undefined` (undefined only for unix
@@ -4219,7 +4365,7 @@ export class Server {
4219
4365
  // `websocket` handlers are one shared dispatcher - each connection's `ws.data.handler` is the
4220
4366
  // matched route's handler, set by `server.upgrade`.
4221
4367
  // 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.
4368
+ // because a socket route means ws() ran, and ws() requires the runtime at registration.
4223
4369
  // Native pub/sub when the app has WS routes and none validate outbound frames: `ws.subscribe` and
4224
4370
  // `app.publish` go through Bun's own (uWebSockets) broadcast instead of the JS registry loop.
4225
4371
  // A mounted app counts only while it has a WebSocket route (see `[GET_WS_RUNTIME]`), so composing
@@ -4232,8 +4378,8 @@ export class Server {
4232
4378
  // upgrades owns a different TopicRegistry, so force the per-connection JS dispatcher and carry
4233
4379
  // that registry in the upgrade outcome instead of accidentally broadcasting child sockets through
4234
4380
  // the parent.
4235
- const nativePubsub = this.wsRouteCount > 0 && !this.mountsTakeUpgrades() && !this.wsHasValidatedSend;
4236
- const wsHandlers = wsRuntime?.bunHandlers(this.topics ?? wsRuntime.createTopics(), nativePubsub);
4381
+ const nativePubsub = this.wsRoutes.length > 0 && !this.mountsTakeUpgrades() && !this.wsHasValidatedSend;
4382
+ const wsHandlers = wsRuntime?.bunHandlers(this.topics ?? wsRuntime.createTopics(), nativePubsub, this);
4237
4383
  // Spread rather than pass `hostname: undefined` (or an undefined idle timeout or TLS) - Bun treats
4238
4384
  // an explicit undefined as a value on some option paths, and omitting is what selects its own
4239
4385
  // defaults, 0.0.0.0 among them.
@@ -4279,8 +4425,9 @@ export class Server {
4279
4425
  // `unknown` params bridge a TS structural-variance quirk. Round-trip covered by websocket.test.ts.
4280
4426
  websocket: {
4281
4427
  // Cap inbound frames so a huge message can't be buffered/JSON-parsed into memory; the runtime
4282
- // closes an over-cap connection before the handler runs. Default 1 MB (maxBodyBytes).
4283
- maxPayloadLength: this.wsMaxPayloadBytes,
4428
+ // closes an over-cap connection before the handler runs. Default 1 MB (maxBodyBytes). One
4429
+ // limit per listener: the largest cap among this app and the apps mounted on it.
4430
+ maxPayloadLength: wsHandlers.maxPayloadLength,
4284
4431
  open: (ws) => wsHandlers.open(ws),
4285
4432
  message: (ws, message) => wsHandlers.message(ws, message),
4286
4433
  close: (ws, code, reason) => wsHandlers.close(ws, code, reason),
@@ -4299,7 +4446,7 @@ export class Server {
4299
4446
  }
4300
4447
  this.sealed = true;
4301
4448
  if (this.gracefulSignals)
4302
- this.installSignalHandlers();
4449
+ this.installSignalHandlers(this.gracefulSignals);
4303
4450
  return running;
4304
4451
  }
4305
4452
  /**
@@ -4332,12 +4479,12 @@ export class Server {
4332
4479
  new Promise((resolve) => setTimeout(resolve, STOP_HOOK_TIMEOUT_MS)),
4333
4480
  ]);
4334
4481
  }
4335
- installSignalHandlers() {
4482
+ installSignalHandlers(drain) {
4336
4483
  // Drain, then let the process exit naturally - the stopped server no longer
4337
4484
  // holds the event loop open. Opt-in (`gracefulSignals`), so taking over the
4338
4485
  // signals is consented; we don't force `process.exit`.
4339
4486
  const onSignal = () => {
4340
- void this.stop();
4487
+ void this.stop(drain);
4341
4488
  };
4342
4489
  process.once("SIGTERM", onSignal);
4343
4490
  process.once("SIGINT", onSignal);