@nifrajs/core 2.14.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +25 -0
  2. package/dist/assurance.d.ts +16 -8
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js +33 -0
  5. package/dist/assurance.js.map +1 -1
  6. package/dist/durable-adapters.d.ts.map +1 -1
  7. package/dist/durable-adapters.js +26 -3
  8. package/dist/durable-adapters.js.map +1 -1
  9. package/dist/durable-approval.d.ts +56 -0
  10. package/dist/durable-approval.d.ts.map +1 -0
  11. package/dist/durable-approval.js +233 -0
  12. package/dist/durable-approval.js.map +1 -0
  13. package/dist/durable-effect.d.ts +42 -0
  14. package/dist/durable-effect.d.ts.map +1 -0
  15. package/dist/durable-effect.js +234 -0
  16. package/dist/durable-effect.js.map +1 -0
  17. package/dist/durable-execution.d.ts +8 -346
  18. package/dist/durable-execution.d.ts.map +1 -1
  19. package/dist/durable-execution.js +7 -1150
  20. package/dist/durable-execution.js.map +1 -1
  21. package/dist/durable-saga.d.ts +94 -0
  22. package/dist/durable-saga.d.ts.map +1 -0
  23. package/dist/durable-saga.js +550 -0
  24. package/dist/durable-saga.js.map +1 -0
  25. package/dist/edge-kit.d.ts +27 -0
  26. package/dist/edge-kit.d.ts.map +1 -0
  27. package/dist/edge-kit.js +24 -0
  28. package/dist/edge-kit.js.map +1 -0
  29. package/dist/errors.d.ts +1 -1
  30. package/dist/errors.d.ts.map +1 -1
  31. package/dist/errors.js.map +1 -1
  32. package/dist/index.d.ts +23 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +18 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/internal/durable-approval-protocol.d.ts +1 -1
  37. package/dist/internal/durable-approval-protocol.d.ts.map +1 -1
  38. package/dist/internal/durable-effect-protocol.d.ts +1 -1
  39. package/dist/internal/durable-effect-protocol.d.ts.map +1 -1
  40. package/dist/internal/durable-saga-protocol.d.ts +1 -1
  41. package/dist/internal/durable-saga-protocol.d.ts.map +1 -1
  42. package/dist/internal/durable-shared.d.ts +26 -0
  43. package/dist/internal/durable-shared.d.ts.map +1 -0
  44. package/dist/internal/durable-shared.js +131 -0
  45. package/dist/internal/durable-shared.js.map +1 -0
  46. package/dist/internal/durable-types.d.ts +159 -0
  47. package/dist/internal/durable-types.d.ts.map +1 -0
  48. package/dist/internal/durable-types.js +2 -0
  49. package/dist/internal/durable-types.js.map +1 -0
  50. package/dist/internal/route-compiler.d.ts +61 -0
  51. package/dist/internal/route-compiler.d.ts.map +1 -0
  52. package/dist/internal/route-compiler.js +121 -0
  53. package/dist/internal/route-compiler.js.map +1 -0
  54. package/dist/internal/route-execution.d.ts +17 -9
  55. package/dist/internal/route-execution.d.ts.map +1 -1
  56. package/dist/internal/route-execution.js +4 -18
  57. package/dist/internal/route-execution.js.map +1 -1
  58. package/dist/internal/route-lanes.d.ts +35 -0
  59. package/dist/internal/route-lanes.d.ts.map +1 -0
  60. package/dist/internal/route-lanes.js +150 -0
  61. package/dist/internal/route-lanes.js.map +1 -0
  62. package/dist/internal/route-program.d.ts +67 -0
  63. package/dist/internal/route-program.d.ts.map +1 -0
  64. package/dist/internal/route-program.js +199 -0
  65. package/dist/internal/route-program.js.map +1 -0
  66. package/dist/response-observer.d.ts +25 -0
  67. package/dist/response-observer.d.ts.map +1 -0
  68. package/dist/response-observer.js +40 -0
  69. package/dist/response-observer.js.map +1 -0
  70. package/dist/security/baseline.d.ts +68 -0
  71. package/dist/security/baseline.d.ts.map +1 -0
  72. package/dist/security/baseline.js +118 -0
  73. package/dist/security/baseline.js.map +1 -0
  74. package/dist/server/bare-error-lane.d.ts +26 -0
  75. package/dist/server/bare-error-lane.d.ts.map +1 -0
  76. package/dist/server/bare-error-lane.js +60 -0
  77. package/dist/server/bare-error-lane.js.map +1 -0
  78. package/dist/server/body-lane.d.ts +13 -0
  79. package/dist/server/body-lane.d.ts.map +1 -0
  80. package/dist/server/body-lane.js +134 -0
  81. package/dist/server/body-lane.js.map +1 -0
  82. package/dist/server/body.d.ts +7 -0
  83. package/dist/server/body.d.ts.map +1 -1
  84. package/dist/server/body.js +7 -0
  85. package/dist/server/body.js.map +1 -1
  86. package/dist/server/bridge-markers.d.ts +25 -0
  87. package/dist/server/bridge-markers.d.ts.map +1 -0
  88. package/dist/server/bridge-markers.js +25 -0
  89. package/dist/server/bridge-markers.js.map +1 -0
  90. package/dist/server/context.d.ts +6 -1
  91. package/dist/server/context.d.ts.map +1 -1
  92. package/dist/server/contract.d.ts +40 -14
  93. package/dist/server/contract.d.ts.map +1 -1
  94. package/dist/server/contract.js +7 -0
  95. package/dist/server/contract.js.map +1 -1
  96. package/dist/server/cookies.d.ts.map +1 -1
  97. package/dist/server/cookies.js +16 -2
  98. package/dist/server/cookies.js.map +1 -1
  99. package/dist/server/header-case.d.ts.map +1 -1
  100. package/dist/server/header-case.js +2 -1
  101. package/dist/server/header-case.js.map +1 -1
  102. package/dist/server/http.d.ts +36 -3
  103. package/dist/server/http.d.ts.map +1 -1
  104. package/dist/server/http.js +49 -3
  105. package/dist/server/http.js.map +1 -1
  106. package/dist/server/install.d.ts +6 -8
  107. package/dist/server/install.d.ts.map +1 -1
  108. package/dist/server/install.js +7 -0
  109. package/dist/server/install.js.map +1 -1
  110. package/dist/server/node-outcome-hook.d.ts +2 -1
  111. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  112. package/dist/server/node-outcome-hook.js.map +1 -1
  113. package/dist/server/node-outcome.d.ts +17 -2
  114. package/dist/server/node-outcome.d.ts.map +1 -1
  115. package/dist/server/node-outcome.js +37 -3
  116. package/dist/server/node-outcome.js.map +1 -1
  117. package/dist/server/plugin.d.ts +2 -2
  118. package/dist/server/plugin.d.ts.map +1 -1
  119. package/dist/server/plugin.js +6 -1
  120. package/dist/server/plugin.js.map +1 -1
  121. package/dist/server/query.d.ts +7 -1
  122. package/dist/server/query.d.ts.map +1 -1
  123. package/dist/server/query.js +8 -3
  124. package/dist/server/query.js.map +1 -1
  125. package/dist/server/registry.d.ts +40 -12
  126. package/dist/server/registry.d.ts.map +1 -1
  127. package/dist/server/request-context.d.ts +2 -13
  128. package/dist/server/request-context.d.ts.map +1 -1
  129. package/dist/server/request-context.js +18 -159
  130. package/dist/server/request-context.js.map +1 -1
  131. package/dist/server/respond.d.ts.map +1 -1
  132. package/dist/server/respond.js +58 -4
  133. package/dist/server/respond.js.map +1 -1
  134. package/dist/server/response-contract-lane.d.ts.map +1 -1
  135. package/dist/server/response-contract-lane.js +5 -2
  136. package/dist/server/response-contract-lane.js.map +1 -1
  137. package/dist/server/response-observer-runtime.d.ts +20 -0
  138. package/dist/server/response-observer-runtime.d.ts.map +1 -0
  139. package/dist/server/response-observer-runtime.js +95 -0
  140. package/dist/server/response-observer-runtime.js.map +1 -0
  141. package/dist/server/runtime-core.d.ts +76 -0
  142. package/dist/server/runtime-core.d.ts.map +1 -1
  143. package/dist/server/runtime-core.js +42 -1
  144. package/dist/server/runtime-core.js.map +1 -1
  145. package/dist/server/server-types.d.ts +6 -1
  146. package/dist/server/server-types.d.ts.map +1 -1
  147. package/dist/server/server.d.ts +158 -116
  148. package/dist/server/server.d.ts.map +1 -1
  149. package/dist/server/server.js +707 -844
  150. package/dist/server/server.js.map +1 -1
  151. package/dist/server/validation.d.ts +5 -0
  152. package/dist/server/validation.d.ts.map +1 -0
  153. package/dist/server/validation.js +13 -0
  154. package/dist/server/validation.js.map +1 -0
  155. package/dist/server/websocket.d.ts +20 -4
  156. package/dist/server/websocket.d.ts.map +1 -1
  157. package/dist/server/websocket.js +81 -11
  158. package/dist/server/websocket.js.map +1 -1
  159. package/dist/server/ws-bun.d.ts +12 -4
  160. package/dist/server/ws-bun.d.ts.map +1 -1
  161. package/dist/server/ws-bun.js +50 -16
  162. package/dist/server/ws-bun.js.map +1 -1
  163. package/dist/server/ws-hook.d.ts +3 -2
  164. package/dist/server/ws-hook.d.ts.map +1 -1
  165. package/dist/server.d.ts +3 -2
  166. package/dist/server.d.ts.map +1 -1
  167. package/dist/server.js +2 -1
  168. package/dist/server.js.map +1 -1
  169. package/dist/single-copy-register.d.ts +2 -0
  170. package/dist/single-copy-register.d.ts.map +1 -0
  171. package/dist/single-copy-register.js +24 -0
  172. package/dist/single-copy-register.js.map +1 -0
  173. package/dist/single-copy.d.ts +156 -0
  174. package/dist/single-copy.d.ts.map +1 -0
  175. package/dist/single-copy.js +472 -0
  176. package/dist/single-copy.js.map +1 -0
  177. package/package.json +28 -3
  178. package/src/assurance.ts +58 -1
  179. package/src/durable-adapters.ts +20 -0
  180. package/src/durable-approval.ts +281 -0
  181. package/src/durable-effect.ts +300 -0
  182. package/src/durable-execution.ts +8 -1627
  183. package/src/durable-saga.ts +744 -0
  184. package/src/edge-kit.ts +27 -0
  185. package/src/errors.ts +1 -0
  186. package/src/index.ts +129 -2
  187. package/src/internal/durable-approval-protocol.ts +1 -1
  188. package/src/internal/durable-effect-protocol.ts +1 -1
  189. package/src/internal/durable-saga-protocol.ts +1 -1
  190. package/src/internal/durable-shared.ts +157 -0
  191. package/src/internal/durable-types.ts +194 -0
  192. package/src/internal/route-compiler.ts +234 -0
  193. package/src/internal/route-execution.ts +53 -96
  194. package/src/internal/route-lanes.ts +221 -0
  195. package/src/internal/route-program.ts +404 -0
  196. package/src/response-observer.ts +64 -0
  197. package/src/security/baseline.ts +172 -0
  198. package/src/server/bare-error-lane.ts +77 -0
  199. package/src/server/body-lane.ts +203 -0
  200. package/src/server/body.ts +8 -0
  201. package/src/server/bridge-markers.ts +24 -0
  202. package/src/server/context.ts +6 -1
  203. package/src/server/contract.ts +70 -20
  204. package/src/server/cookies.ts +16 -2
  205. package/src/server/header-case.ts +3 -1
  206. package/src/server/http.ts +53 -3
  207. package/src/server/install.ts +14 -0
  208. package/src/server/node-outcome-hook.ts +2 -1
  209. package/src/server/node-outcome.ts +44 -3
  210. package/src/server/plugin.ts +11 -5
  211. package/src/server/query.ts +10 -4
  212. package/src/server/registry.ts +71 -16
  213. package/src/server/request-context.ts +13 -216
  214. package/src/server/respond.ts +71 -3
  215. package/src/server/response-contract-lane.ts +6 -2
  216. package/src/server/response-observer-runtime.ts +135 -0
  217. package/src/server/runtime-core.ts +124 -1
  218. package/src/server/server-types.ts +6 -1
  219. package/src/server/server.ts +1446 -1485
  220. package/src/server/validation.ts +19 -0
  221. package/src/server/websocket.ts +127 -17
  222. package/src/server/ws-bun.ts +67 -15
  223. package/src/server/ws-hook.ts +3 -2
  224. package/src/server.ts +8 -2
  225. package/src/single-copy-register.ts +24 -0
  226. package/src/single-copy.ts +526 -0
@@ -1,113 +1,28 @@
1
1
  import { admitDeadline, createRequestBudget, createUnboundedRequestBudget, NIFRA_DEADLINE_HEADER, } from "../budget.js";
2
2
  import { FrameworkError, RouteConfigError } from "../errors.js";
3
- import { CAPABILITY_GUARD, createCapabilityGuard, DEFAULT_CAPABILITY_INTERCEPTOR_TIMEOUT_MS, normalizeRouteCapabilities, } from "../internal/capability-runtime.js";
4
- import { assuranceDeclarationsOf, assuranceEvidenceFor, NIFRA_ASSURANCE_IDS, validEvidenceId, } from "../internal/route-assurance.js";
3
+ import { DEFAULT_CAPABILITY_INTERCEPTOR_TIMEOUT_MS, } from "../internal/capability-runtime.js";
4
+ import { assuranceDeclarationsOf, assuranceEvidenceFor, } from "../internal/route-assurance.js";
5
5
  import { RouteCatalog } from "../internal/route-catalog.js";
6
+ import { compileRouteOptions } from "../internal/route-compiler.js";
6
7
  import { compileRouteExecutionPlan, } from "../internal/route-execution.js";
8
+ import { compileRouteProgram, executeRouteProgram } from "../internal/route-program.js";
7
9
  import { isSameOriginRequest } from "../internal/same-origin.js";
8
- import { compileRoutePattern, decodeRouteParams } from "../router/pattern.js";
10
+ import { decodeRouteParams } from "../router/pattern.js";
9
11
  import { EMPTY_PARAMS, Router } from "../router/router.js";
10
- import { assertByteLimit, markTransportCap, markTrustedBodyFraming, } from "./body.js";
12
+ import { emitRequestErrorLog, renderBareError } from "./bare-error-lane.js";
13
+ import { assertByteLimit, markTransportCap, markTrustedBodyFraming, UNLIMITED_BODY_BYTES, } from "./body.js";
11
14
  import { resolveClientIp } from "./client-ip.js";
12
15
  import { hasLowercaseHeaderKeysMark, headerKeysAllLowercase, markLowercaseHeaderKeys, } from "./header-case.js";
13
16
  import { headerObjectOf } from "./headers.js";
14
- import { jsonError, pathnameOf, urlPartsOf } from "./http.js";
17
+ import { jsonError, pathnameOf, plainError, urlPartsOf } from "./http.js";
15
18
  import { withStaticNodeHeaders } from "./node-outcome.js";
16
- import { recordHeadersView, } from "./node-outcome-hook.js";
17
- import { isUrlEncodedForm, queryObjectOf, readBoundedForm, searchOf, } from "./query.js";
18
- import { RequestContext, readBoundedJsonSource } from "./request-context.js";
19
- import { applyStaticResponseHeaders, buildStaticResponseHeaders, fusedRespond, fusedRespondNoSet, knownMutableHeaders, rememberMutableHeaders, taggedResponseBody, toResponse, } from "./respond.js";
20
- import { applyBodyReplacement, withReplacedBody } from "./response-hooks.js";
21
- import { CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, requestOf, } from "./runtime-core.js";
19
+ import { queryObjectOf, searchOf } from "./query.js";
20
+ import { RequestContext, readBodyFramed } from "./request-context.js";
21
+ import { applyStaticResponseHeaders, buildStaticResponseHeaders, fusedRespond, fusedRespondNoSet, toResponse, } from "./respond.js";
22
+ import { CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, isResponseResult, requestOf, } from "./runtime-core.js";
22
23
  import { normalizeStaticResponseHeaders } from "./static-headers.js";
23
- const HEADER_MUTABILITY_PROBE = "x-nifra-header-probe";
24
- const GUARDED_RESPONSE_HEADERS = new WeakSet();
25
- /**
26
- * Detect a guarded Web Headers object before invoking user code. Retrying after catching any
27
- * TypeError is observably wrong: user hooks are allowed to throw TypeError themselves, and a retry
28
- * runs those hooks twice. The hot path never reaches the probe: every framework-constructed
29
- * Response stamps its headers as known-mutable at construction (see respond.ts), so only a
30
- * handler-returned foreign `Response` - the case that can actually be guarded - pays it, once per
31
- * headers object. The probe uses a valid private header name and restores a pre-existing value, so
32
- * the fallback is limited to the actual immutable/guarded-header case.
33
- */
34
- function hasMutableResponseHeaders(headers) {
35
- if (knownMutableHeaders(headers))
36
- return true;
37
- if (GUARDED_RESPONSE_HEADERS.has(headers))
38
- return false;
39
- let previous = null;
40
- try {
41
- previous = headers.get(HEADER_MUTABILITY_PROBE);
42
- headers.set(HEADER_MUTABILITY_PROBE, "1");
43
- if (previous === null)
44
- headers.delete(HEADER_MUTABILITY_PROBE);
45
- else
46
- headers.set(HEADER_MUTABILITY_PROBE, previous);
47
- rememberMutableHeaders(headers);
48
- return true;
49
- }
50
- catch {
51
- try {
52
- if (previous === null)
53
- headers.delete(HEADER_MUTABILITY_PROBE);
54
- else
55
- headers.set(HEADER_MUTABILITY_PROBE, previous);
56
- }
57
- catch {
58
- // The guarded object rejected the cleanup too; the clone below is authoritative.
59
- }
60
- GUARDED_RESPONSE_HEADERS.add(headers);
61
- return false;
62
- }
63
- }
64
- /** Adapt a portable {@link ResponseBodyHook} into the Web `onResponse` walk. Only a Response
65
- * carrying the framework-buffered body tag participates - a raw or streamed Response is skipped by
66
- * contract, never drained. */
67
- function webResponseBodyHook(fn, owners) {
68
- return (response, req) => {
69
- const body = taggedResponseBody(response, owners);
70
- if (body === undefined)
71
- return response;
72
- const out = fn(body, response.headers, webRequestView(req), response.status);
73
- if (out instanceof Promise)
74
- return out.then((replaced) => withReplacedBody(response, replaced));
75
- return withReplacedBody(response, out);
76
- };
77
- }
78
- /** Minimal request view over a Web `Request` for portable header hooks on the Web serving paths. */
79
- function webRequestView(req) {
80
- return { method: req.method, url: req.url, header: (name) => req.headers.get(name) };
81
- }
82
- /**
83
- * Adapt a portable {@link ResponseHeadersHook} into the Web `onResponse` walk: run it against the
84
- * response's own `Headers` in place (no clone). A response whose headers are GUARDED (a raw
85
- * `fetch()`ed Response returned by a handler) throws on the FIRST mutation, so nothing was applied
86
- * yet - rerun once against a mutable copy. Async hooks should perform their mutations before their
87
- * first await for the guard fallback to cover them.
88
- */
89
- function webResponseHeadersHook(fn) {
90
- return (response, req) => {
91
- const view = webRequestView(req);
92
- if (!hasMutableResponseHeaders(response.headers)) {
93
- const clone = new Response(response.body, response);
94
- const out = fn(clone.headers, view, clone.status);
95
- return out instanceof Promise ? out.then(() => clone) : clone;
96
- }
97
- const out = fn(response.headers, view, response.status);
98
- return out instanceof Promise ? out.then(() => response) : response;
99
- };
100
- }
101
- /** Adapt a raw-response fallback hook. Tagged framework payloads already ran through the body tier,
102
- * while untagged Responses include streams, proxied fetches, and framework-generated error responses. */
103
- function webResponseRawHook(fn, owners) {
104
- return (response, req) => {
105
- if (taggedResponseBody(response, owners) !== undefined)
106
- return response;
107
- return fn(response, req);
108
- };
109
- }
110
- import { INSTALL_EFFECT_LEDGER, INSTALL_IDEMPOTENCY, INSTALL_MCP, INSTALL_NODE_DIRECT, INSTALL_RESPONSE_CONTRACT, INSTALL_SSE, INSTALL_WS, } from "./install.js";
24
+ import { plainValidationError } from "./validation.js";
25
+ import { INSTALL_EFFECT_LEDGER, INSTALL_IDEMPOTENCY, INSTALL_MCP, INSTALL_NODE_DIRECT, INSTALL_RESPONSE_CONTRACT, INSTALL_RESPONSE_OBSERVER, INSTALL_SSE, INSTALL_WS, NODE_NATIVE_MOUNT, RESOLVE_NODE_MOUNT, } from "./install.js";
111
26
  import { jsonLogger } from "./logger.js";
112
27
  const functionToString = Function.prototype.toString;
113
28
  const CONTEXTLESS_ARROW = /^(?:async\s*)?\(\s*\)\s*(?::[\s\S]*?)?=>/;
@@ -162,16 +77,13 @@ const STOP_HOOK_TIMEOUT_MS = 5_000;
162
77
  * owner, shared with the server-function mount in `@nifrajs/web` - the two used to answer differently
163
78
  * for the same request, so a browser that could open a socket was told its POST was cross-origin. */
164
79
  const wsSameOrigin = isSameOriginRequest;
165
- function validationError(issues) {
166
- const serialized = issues.map((issue) => {
167
- const path = issue.path?.map((seg) => String(typeof seg === "object" ? seg.key : seg));
168
- return path !== undefined ? { message: issue.message, path } : { message: issue.message };
169
- });
170
- return Response.json({ ok: false, error: "validation", issues: serialized }, { status: 422 });
171
- }
172
80
  // `jsonError`, `urlPartsOf`, `pathnameOf` moved to `./http.ts` (a dependency-free leaf shared with the
173
81
  // opt-in request lanes); re-exported so existing importers keep resolving from here.
174
82
  export { pathnameOf, urlPartsOf } from "./http.js";
83
+ /** The 422 as plain data - the shape every lane's response wrapper takes, so a rejected body costs
84
+ * what an accepted one costs: on Node it is written straight to the socket with a `content-length`
85
+ * instead of being built as a `Response` and drained back out. */
86
+ export { plainValidationError } from "./validation.js";
175
87
  // Query-string + urlencoded-form parsing now lives in `./query.ts`; re-exported so existing
176
88
  // importers keep resolving `searchOf`/`queryObjectOf`/`QueryValue` from here.
177
89
  export { queryObjectOf, searchOf };
@@ -220,8 +132,15 @@ function isContextlessNoArgArrow(handler) {
220
132
  return false;
221
133
  }
222
134
  }
135
+ /**
136
+ * The Web lanes' `wrapResponse` input, as a `Response`. A plain-data carrier ({@link plainError},
137
+ * `status(...)`) is rendered on the ordinary JSON lane rather than through its own `toResponse`, so
138
+ * it picks up the same prebuilt init a handler's plain return uses. `EMPTY_RESPONSE_CONTROLS` because
139
+ * these renders happen where no request context exists - before routing, or after it was abandoned.
140
+ */
141
+ const webResponseOf = (result) => result instanceof Response ? result : toResponse(result, EMPTY_RESPONSE_CONTROLS);
223
142
  // Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
224
- const IDENTITY_RESPONSE = (response) => response;
143
+ const IDENTITY_RESPONSE = (response) => webResponseOf(response);
225
144
  const RESPONSE_TIMEOUT = () => jsonError(503, "request_timeout");
226
145
  // The unused-order-scoped-hook audit (recorder, seal check, stack capture, message) is a dev-time
227
146
  // diagnostic. Every guard below keeps the INLINE literal `process.env.NODE_ENV !== "production"` at the
@@ -316,6 +235,13 @@ export class Server {
316
235
  * Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime - `undefined` until then, so a
317
236
  * no-WebSocket app never constructs (or bundles) it. */
318
237
  topics;
238
+ /** True once any `app.ws()` route sets `validateSend: true` - the one case where a broadcast frame
239
+ * is validated (and possibly dropped) per socket, so `app.publish` must stay on the JS registry loop
240
+ * rather than delegate to Bun's native `server.publish`. */
241
+ wsHasValidatedSend = false;
242
+ /** Bun's native topic broadcast, bound by `listen()` in native-pubsub mode; `app.publish` uses it
243
+ * instead of the JS registry. `undefined` off Bun, before `listen()`, or with a validated-send route. */
244
+ nativePublish;
319
245
  maxBodyBytes;
320
246
  protoPoisoning;
321
247
  /** `trustBodyFraming`: mark every `app.fetch` request as runtime-framed (see ServerOptions). */
@@ -525,6 +451,11 @@ export class Server {
525
451
  * declared *after* this call, and reaches none declared before. One added after the last route
526
452
  * (e.g. on the app returned by a route-registering factory) covers nothing - see
527
453
  * {@link ServerOptions.unusedScopedHooks}.
454
+ *
455
+ * Returning a `status(...)` (or a `Response`) instead of an extension ends the request there - the
456
+ * guard shape, and the cheap one: it renders as plain data on the same lane a handler's return
457
+ * takes, where `throw new Response(...)` pays ~40% of the rejection lane to build the object. That
458
+ * branch is control flow, so it does not widen the derived context type.
528
459
  */
529
460
  derive(fn) {
530
461
  this.assertConfigurable("derive()");
@@ -671,19 +602,33 @@ export class Server {
671
602
  * hook owns part of the header state - into a hook that runs in the right order. */
672
603
  addStaticResponseHeaders(record) {
673
604
  if (this.onResponseHooks.length > 0) {
674
- return this.onResponseHeaders((headers) => {
675
- for (const name of Object.keys(record)) {
676
- if (!headers.has(name))
677
- headers.set(name, record[name]);
678
- }
679
- });
605
+ const observerHeaders = this.onResponseHeaders;
606
+ if (observerHeaders !== undefined) {
607
+ observerHeaders.call(this, (headers) => {
608
+ for (const name of Object.keys(record)) {
609
+ if (!headers.has(name))
610
+ headers.set(name, record[name]);
611
+ }
612
+ });
613
+ return this;
614
+ }
615
+ // Route the hook through the same seam the static tier uses. On a framework response (headers
616
+ // stamped mutable) it applies the defaults in place and returns the SAME object, so the
617
+ // serialized-body marker survives for later `onResponseBody`/`onResponseRaw` observers and the
618
+ // Node writer; reconstructing a `Response` here would strip that marker and reclassify the Node
619
+ // outcome from `json` to a generic `response`. A guarded foreign response takes its clone path.
620
+ const statics = buildStaticResponseHeaders(record);
621
+ this.onResponseHooks.push((response) => applyStaticResponseHeaders(response, statics));
622
+ this.onNodeResponseHooks.push(undefined);
623
+ this.nodeResponseHooksComplete = false;
624
+ return this;
680
625
  }
681
626
  const merged = this.staticResponseHeaders === undefined
682
627
  ? record
683
628
  : { ...this.staticResponseHeaders.record, ...record };
684
629
  this.staticResponseHeaders = buildStaticResponseHeaders(merged);
685
630
  const statics = this.staticResponseHeaders;
686
- this.wrapWebResponse = (response) => applyStaticResponseHeaders(response, statics);
631
+ this.wrapWebResponse = (response) => applyStaticResponseHeaders(webResponseOf(response), statics);
687
632
  this.webResponseTimeout = () => applyStaticResponseHeaders(RESPONSE_TIMEOUT(), statics);
688
633
  return this;
689
634
  }
@@ -694,58 +639,14 @@ export class Server {
694
639
  }
695
640
  return this.responseBodyTag;
696
641
  }
697
- /**
698
- * Register a response transform for raw/streamed Responses. Framework-serialized payloads stay on
699
- * the body tier; this hook is only entered for untagged Responses (streams, proxied fetches, and
700
- * framework-generated error responses). A no-op native twin keeps buffered JSON on the direct lane.
701
- */
702
- onResponseRaw(fn) {
703
- this.assertConfigurable("onResponseRaw()");
704
- this.enableResponseBodyTagging();
705
- this.onResponseHooks.push(webResponseRawHook(fn, this.responseBodyOwners));
706
- this.onNodeResponseHooks.push(() => undefined);
707
- return this;
708
- }
709
- /**
710
- * Register a PORTABLE header-only response hook - one implementation, fast on every runtime.
711
- *
712
- * The hook receives a mutable case-insensitive header view, a minimal request view, and the
713
- * status. On the Web serving paths it runs inside the normal `onResponse` walk against the
714
- * response's own `Headers` (mutating in place - no clone). On Node it self-pairs as a native
715
- * response hook against the outcome's plain header record, so registering one NEVER forces the
716
- * Node adapter off its direct socket writer the way a full `onResponse(res: Response)` hook does.
717
- * Prefer this over `onResponse` whenever the middleware only reads/writes headers.
718
- */
719
- onResponseHeaders(fn) {
720
- this.assertConfigurable("onResponseHeaders()");
721
- this.onResponseHooks.push(webResponseHeadersHook(fn));
722
- this.onNodeResponseHooks.push((response, req) => fn(recordHeadersView(response), req, response.status));
723
- return this;
724
- }
725
- /**
726
- * Register a PORTABLE post-serialization body hook - the payload tier. The hook receives the
727
- * FINAL framework-serialized bytes (plus the mutable header view and status) and may return
728
- * replacement bytes; `undefined` keeps the body. On the Node direct writer the bytes come
729
- * straight off the outcome record; on the Web serving paths they ride the framework-built
730
- * Response as a tag, so no body stream is ever drained on any runtime. A handler-returned raw
731
- * `Response` (proxied fetch, SSE, streamed SSR) is skipped by contract - transforming those is
732
- * what the full `onResponse` hook is for.
733
- */
734
- onResponseBody(fn) {
735
- this.assertConfigurable("onResponseBody()");
736
- this.enableResponseBodyTagging();
737
- this.onResponseHooks.push(webResponseBodyHook(fn, this.responseBodyOwners));
738
- this.onNodeResponseHooks.push((response, req) => {
739
- const body = response.body;
740
- if (body === null)
741
- return undefined;
742
- const out = fn(body, recordHeadersView(response), req, response.status);
743
- if (out instanceof Promise)
744
- return out.then((replaced) => applyBodyReplacement(response, replaced));
745
- applyBodyReplacement(response, out);
746
- return undefined;
747
- });
748
- return this;
642
+ responseObserverMethods() {
643
+ const methods = this;
644
+ if (typeof methods.onResponseHeaders !== "function" ||
645
+ typeof methods.onResponseBody !== "function" ||
646
+ typeof methods.onResponseRaw !== "function") {
647
+ throw new TypeError("response observation requires `.use(responseObserver())` or an observer-enabled middleware");
648
+ }
649
+ return methods;
749
650
  }
750
651
  /** Observe the terminal response after all transformations. Observers are ordered and fail-open. */
751
652
  onResponseFinalized(fn) {
@@ -791,6 +692,15 @@ export class Server {
791
692
  }
792
693
  this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"));
793
694
  this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"));
695
+ const responseObserver = arg[INSTALL_RESPONSE_OBSERVER];
696
+ if (responseObserver !== undefined) {
697
+ if (typeof responseObserver !== "object" ||
698
+ responseObserver === null ||
699
+ typeof responseObserver.install !== "function") {
700
+ throw new TypeError("response observer middleware has an invalid runtime");
701
+ }
702
+ this[INSTALL_RESPONSE_OBSERVER](responseObserver);
703
+ }
794
704
  if (arg.onRequest !== undefined) {
795
705
  this.assertConfigurable("onRequest()");
796
706
  this.onRequestHooks.push(arg.onRequest);
@@ -824,11 +734,11 @@ export class Server {
824
734
  if (arg.responseHeaders !== undefined)
825
735
  this.responseHeaders(arg.responseHeaders);
826
736
  if (arg.onResponseHeaders !== undefined)
827
- this.onResponseHeaders(arg.onResponseHeaders);
737
+ this.responseObserverMethods().onResponseHeaders(arg.onResponseHeaders);
828
738
  if (arg.onResponseBody !== undefined)
829
- this.onResponseBody(arg.onResponseBody);
739
+ this.responseObserverMethods().onResponseBody(arg.onResponseBody);
830
740
  if (arg.onResponseRaw !== undefined)
831
- this.onResponseRaw(arg.onResponseRaw);
741
+ this.responseObserverMethods().onResponseRaw(arg.onResponseRaw);
832
742
  if (arg.onResponseFinalized !== undefined)
833
743
  this.onResponseFinalized(arg.onResponseFinalized);
834
744
  if (arg.onError !== undefined)
@@ -935,6 +845,13 @@ export class Server {
935
845
  // Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
936
846
  // bundle it. Registration is the loud, early failure point - never the first connection.
937
847
  const runtime = requireWsRuntime(this.wsRuntime);
848
+ if (handler.validateSend === true && handler.sendSchema === undefined) {
849
+ throw new RouteConfigError("INVALID_WS_SEND_VALIDATION", `route WS ${path}: validateSend requires sendSchema`);
850
+ }
851
+ // A validated-send route makes broadcast bytes route-dependent (drop-on-invalid per socket), so the
852
+ // whole app forgoes native pub/sub and keeps the JS registry loop that runs each send through the sender.
853
+ if (handler.validateSend === true)
854
+ this.wsHasValidatedSend = true;
938
855
  this.topics ??= runtime.createTopics();
939
856
  // A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
940
857
  // already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
@@ -950,6 +867,12 @@ export class Server {
950
867
  * an external fan-out (Redis, a Durable Object) to this. A no-op when nobody is subscribed.
951
868
  */
952
869
  publish(topic, data) {
870
+ // Native-pubsub mode (Bun, no validated-send route): one native broadcast to every subscriber, no
871
+ // per-connection JS loop. Bound only after `listen()`; before that (or off Bun) the registry runs.
872
+ if (this.nativePublish !== undefined) {
873
+ this.nativePublish(topic, data);
874
+ return;
875
+ }
953
876
  // No `app.ws()` yet ⇒ no registry and necessarily no subscribers - a publish is a no-op anyway.
954
877
  this.topics?.publish(topic, data);
955
878
  }
@@ -986,197 +909,88 @@ export class Server {
986
909
  this.catalog.addBatch(staged);
987
910
  }
988
911
  prepareRoute(method, path, schema, handler) {
989
- const pattern = compileRoutePattern(path);
990
- let bodyLimit = this.maxBodyBytes;
991
- const invalidBodyLimit = (message) => {
992
- throw new RouteConfigError("INVALID_BODY_LIMIT", `route ${method} ${path}: ${message}`);
993
- };
994
- if (schema?.bodyLimitReason !== undefined && schema.bodyLimit !== "unlimited") {
995
- invalidBodyLimit('bodyLimitReason is only valid with bodyLimit: "unlimited"');
996
- }
997
- if (schema?.bodyLimit === "unlimited") {
998
- if (typeof schema.bodyLimitReason !== "string" ||
999
- schema.bodyLimitReason.trim().length === 0) {
1000
- invalidBodyLimit('bodyLimit: "unlimited" requires a non-empty bodyLimitReason');
1001
- }
1002
- bodyLimit = undefined;
1003
- }
1004
- else if (schema?.bodyLimit !== undefined) {
1005
- try {
1006
- assertByteLimit(schema.bodyLimit, "route bodyLimit");
1007
- }
1008
- catch {
1009
- invalidBodyLimit('bodyLimit must be a non-negative safe integer or "unlimited"');
1010
- }
1011
- bodyLimit = schema.bodyLimit;
1012
- }
1013
- const capabilities = normalizeRouteCapabilities(schema?.capabilities);
1014
- const handlerAssurance = assuranceDeclarationsOf(handler);
1015
- const invalidHandlerScope = handlerAssurance.find((declaration) => declaration.scope !== "plugin");
1016
- if (invalidHandlerScope !== undefined) {
1017
- throw new RouteConfigError("INVALID_ASSURANCE", `route handler assurance must use plugin scope (received ${invalidHandlerScope.scope})`);
1018
- }
1019
- const authenticated = assuranceEvidenceFor([...this.activeAssurance, ...handlerAssurance, ...this.globalAssurance], method, path).some((evidence) => evidence.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED);
1020
- const routeDecorations = { ...this.decorations };
1021
- if (capabilities.length > 0) {
1022
- routeDecorations[CAPABILITY_GUARD] = createCapabilityGuard(capabilities, method, path, this.onCapabilityUse, this.idempotencyRuntime?.trackEffect, Object.freeze([...this.capabilityInterceptors]), Object.freeze([...this.capabilityObservers]));
1023
- }
1024
- const hasDecorations = Reflect.ownKeys(routeDecorations).length > 0;
1025
- // An idempotency route runs a dedupe lane that must buffer the body and capture the response, so it
1026
- // never takes the fused/native fast path - force it onto the portable matched lane (which routes
1027
- // through `fetchMatched`, where the dedupe wrapper lives). Fail closed: a route may not declare
1028
- // idempotency unless the idempotency runtime is installed, so the safety gate can never be silently
1029
- // dropped by a missing plugin.
1030
- if (schema?.idempotency !== undefined && this.idempotencyRuntime === undefined) {
1031
- throw new RouteConfigError("INVALID_IDEMPOTENCY", "route declares idempotency but the idempotency plugin is not installed; add .use(idempotency())");
1032
- }
1033
- const idempotent = this.idempotencyRuntime?.resolve(schema, authenticated, this.maxBodyBytes);
1034
- // A ledgered route (capabilities declared + `.use(effectLedger())`) needs a per-request
1035
- // context to carry the ledger and a settle step to seal + sink it, so it too leaves the
1036
- // fused/contextless fast path. Resolved per route, at registration - like the capability guard.
1037
- const ledgered = this.effectLedgerRuntime?.resolve(capabilities, method, path);
1038
- // A checked response schema needs the handler's VALUE before it becomes bytes, and the fused and
1039
- // native lanes exist precisely to skip that step. So a contracted route leaves them - the same
1040
- // structural trade an idempotent route makes above. The check itself is not the cost: a compiled
1041
- // validator measures ~100ns/response, within benchmark noise on any route these lanes already
1042
- // exclude (middleware, derives, lifecycle hooks).
1043
- const runtime = this.responseContractRuntime;
1044
- const contracted = runtime !== undefined && schema?.response !== undefined
1045
- ? { runtime, schema: schema.response }
1046
- : undefined;
1047
- const bare = schema?.params === undefined &&
1048
- schema?.headers === undefined &&
1049
- schema?.body === undefined &&
1050
- schema?.query === undefined &&
1051
- idempotent === undefined &&
1052
- ledgered === undefined &&
1053
- contracted === undefined &&
1054
- this.derives.length === 0 &&
1055
- this.beforeHandleHooks.length === 0 &&
1056
- this.afterHandleHooks.length === 0 &&
1057
- this.onErrorHooks.length === 0;
1058
- // A route whose ONLY lifecycle step is a query schema can fuse too: the parse + validate +
1059
- // handler + respond collapse into one closure with no lifecycle promise on the sync path. The
1060
- // guards mirror the `query` lane below PLUS everything the fused dispatch skips: around hooks,
1061
- // the idempotency/ledger wrappers, and validation-error recovery (schema or server default) -
1062
- // the fused invalid path is exactly `validationError(issues)`, so any recovery semantics keep
1063
- // the generic lane.
1064
- const fusedQuery = !bare &&
1065
- contracted === undefined &&
1066
- schema?.query !== undefined &&
1067
- schema.body === undefined &&
1068
- schema.params === undefined &&
1069
- schema.headers === undefined &&
1070
- schema.onValidationError === undefined &&
1071
- this.defaultOnValidationError === undefined &&
1072
- idempotent === undefined &&
1073
- ledgered === undefined &&
1074
- this.derives.length === 0 &&
1075
- this.beforeHandleHooks.length === 0 &&
1076
- this.afterHandleHooks.length === 0 &&
1077
- this.onErrorHooks.length === 0 &&
1078
- this.aroundHooks.length === 0;
1079
- const bodyOnly = contracted === undefined &&
1080
- schema?.body !== undefined &&
1081
- schema.query === undefined &&
1082
- schema.params === undefined &&
1083
- schema.headers === undefined &&
1084
- this.derives.length === 0 &&
1085
- this.beforeHandleHooks.length === 0 &&
1086
- this.afterHandleHooks.length === 0 &&
1087
- this.onErrorHooks.length === 0;
1088
- const fusedBody = !bare &&
1089
- bodyOnly &&
1090
- schema.onValidationError === undefined &&
1091
- this.defaultOnValidationError === undefined &&
1092
- idempotent === undefined &&
1093
- ledgered === undefined &&
1094
- this.aroundHooks.length === 0;
1095
- // Body-only routes still need an async body read, but eligible routes can compile the validation +
1096
- // handler continuation once. The runner keeps the bounded parser and all error semantics while
1097
- // avoiding the generic entry/schema/lifecycle dispatch on Bun, Deno, and Node-direct.
912
+ const compiled = compileRouteOptions({
913
+ maxBodyBytes: this.maxBodyBytes,
914
+ activeAssurance: this.activeAssurance,
915
+ globalAssurance: this.globalAssurance,
916
+ decorations: this.decorations,
917
+ onCapabilityUse: this.onCapabilityUse,
918
+ capabilityInterceptors: this.capabilityInterceptors,
919
+ capabilityObservers: this.capabilityObservers,
920
+ effectLedgerRuntime: this.effectLedgerRuntime,
921
+ idempotencyRuntime: this.idempotencyRuntime,
922
+ responseContractRuntime: this.responseContractRuntime,
923
+ derives: this.derives,
924
+ beforeHandleHooks: this.beforeHandleHooks,
925
+ afterHandleHooks: this.afterHandleHooks,
926
+ onErrorHooks: this.onErrorHooks,
927
+ aroundHooks: this.aroundHooks,
928
+ defaultOnValidationError: this.defaultOnValidationError,
929
+ }, method, path, schema, handler);
930
+ const { pattern, bodyLimit, capabilities, routeDecorations, hasDecorations, idempotent, ledgered, responseContract, lanes, routeAssurance, } = compiled;
931
+ const { bare, fusedQuery, fusedBody } = lanes;
1098
932
  const fusedBodyRunner = fusedBody
1099
- ? this.buildFusedBodyRunner(handler, schema.body, hasDecorations ? routeDecorations : undefined, bodyLimit ?? this.maxBodyBytes)
933
+ ? this.buildFusedBodyRunner(handler, schema?.body, hasDecorations ? routeDecorations : undefined, bodyLimit ?? UNLIMITED_BODY_BYTES)
1100
934
  : undefined;
935
+ // Fused lifecycle lanes: derive + before (with or without an after), and body + the same shape.
936
+ // The lane selectors in `selectRouteLanes` are exhaustive about the lifecycleHookLane /
937
+ // body-derive-before-after eligibility, so we only need to check the lane and the hook counts.
938
+ const lifecycleHookLane = lanes.lifecycleHookLane;
939
+ const isFusedLifecycle = lifecycleHookLane !== undefined && this.derives.length === 1;
940
+ const isFusedBodyLifecycle = lanes.fusedLane === "body-derive-before" || lanes.fusedLane === "body-derive-before-after";
1101
941
  const fusedWeb = bare && this.aroundHooks.length === 0
1102
- ? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler), bodyLimit ?? this.maxBodyBytes)
942
+ ? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler), bodyLimit ?? UNLIMITED_BODY_BYTES)
1103
943
  : fusedQuery
1104
- ? this.buildFusedQueryWeb(handler, hasDecorations ? routeDecorations : undefined, schema.query, bodyLimit ?? this.maxBodyBytes)
944
+ ? this.buildFusedQueryWeb(handler, hasDecorations ? routeDecorations : undefined, schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1105
945
  : fusedBody
1106
946
  ? this.buildFusedBodyWeb(fusedBodyRunner)
1107
- : undefined;
1108
- const contextless = bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler);
1109
- // The body and query lanes finalize the handler's result themselves, so a contracted route takes
1110
- // the general lifecycle lane instead - one place owns the check rather than three.
1111
- const lane = bare
1112
- ? "bare"
1113
- : bodyOnly
1114
- ? "body"
1115
- : contracted === undefined &&
1116
- schema?.body === undefined &&
1117
- schema?.query !== undefined &&
1118
- schema.params === undefined &&
1119
- schema.headers === undefined &&
1120
- this.derives.length === 0 &&
1121
- this.beforeHandleHooks.length === 0 &&
1122
- this.afterHandleHooks.length === 0 &&
1123
- this.onErrorHooks.length === 0
1124
- ? "query"
1125
- : "lifecycle";
1126
- // Lifecycle routes with no params schema are the common middleware shape: a derive/before hook
1127
- // plus an optional query or body schema. Select their complete validation stage at registration
1128
- // so the request path never re-checks params/body presence. Parameter-schema routes retain the
1129
- // generic lifecycle runner until their more involved recovery matrix is selected explicitly.
1130
- const lifecycleLane = lane !== "lifecycle" || schema?.params !== undefined || schema?.headers !== undefined
1131
- ? undefined
1132
- : schema?.body !== undefined
1133
- ? schema.query !== undefined
1134
- ? "body-query"
1135
- : "body"
1136
- : schema?.query !== undefined
1137
- ? "query"
1138
- : "hooks";
1139
- // The realistic middleware shape is commonly exactly one synchronous-or-async derive followed
1140
- // by one before hook. Keep the generic runner for every route that can observe decorations,
1141
- // after hooks, error hooks, or response contracts; this lane only removes the two per-request
1142
- // hook-loop dispatches and preserves the same async continuations and error handling.
1143
- const lifecycleHookLane = lane === "lifecycle" &&
1144
- contracted === undefined &&
1145
- !hasDecorations &&
1146
- this.derives.length === 1 &&
1147
- this.beforeHandleHooks.length === 1 &&
1148
- this.afterHandleHooks.length === 0 &&
1149
- this.onErrorHooks.length === 0
1150
- ? "derive-before"
1151
- : undefined;
947
+ : isFusedBodyLifecycle
948
+ ? this.buildFusedBodyDeriveBeforeAfter(handler, this.derives[0], this.beforeHandleHooks[0], this.afterHandleHooks[0], schema?.body, bodyLimit ?? UNLIMITED_BODY_BYTES)
949
+ : isFusedLifecycle && lifecycleHookLane === "derive-before"
950
+ ? this.buildFusedDeriveBefore(handler, this.derives[0], this.beforeHandleHooks[0], schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
951
+ : isFusedLifecycle && lifecycleHookLane === "derive-before-after"
952
+ ? this.buildFusedDeriveBeforeAfter(handler, this.derives[0], this.beforeHandleHooks[0], this.afterHandleHooks[0], schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
953
+ : undefined;
954
+ const program = compileRouteProgram({
955
+ schema,
956
+ handler: handler,
957
+ derives: this.derives,
958
+ beforeHandle: this.beforeHandleHooks,
959
+ afterHandle: this.afterHandleHooks,
960
+ onError: this.onErrorHooks,
961
+ decorations: routeDecorations,
962
+ hasDecorations,
963
+ bodySchema: schema?.body,
964
+ bodyLimit,
965
+ responseContract,
966
+ });
967
+ const contextless = lanes.bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler);
1152
968
  const execution = compileRouteExecutionPlan({
1153
- lane,
969
+ lane: lanes.lane,
1154
970
  contextless,
1155
971
  hasAround: this.aroundHooks.length > 0,
1156
972
  hasLedger: ledgered !== undefined,
1157
- lifecycleLane,
1158
973
  fusedWeb,
1159
974
  fusedBody: fusedBodyRunner,
1160
- fusedLane: fusedWeb === undefined ? undefined : fusedQuery ? "query" : fusedBody ? "body" : "bare",
975
+ fusedLane: lanes.fusedLane,
1161
976
  });
1162
977
  const registeredEntry = {
1163
- // (context: never) => unknown -> InternalHandler: the framework invokes it
1164
- // with the concrete RawContext the typed handler expects, so this is sound.
1165
978
  handler: handler,
1166
979
  schema,
1167
980
  bodyLimit,
1168
981
  idempotent,
1169
982
  ledgered,
1170
- responseContract: contracted,
983
+ responseContract,
1171
984
  derives: [...this.derives],
1172
985
  decorations: routeDecorations,
1173
986
  hasDecorations,
1174
987
  beforeHandle: [...this.beforeHandleHooks],
1175
988
  afterHandle: [...this.afterHandleHooks],
1176
989
  onError: [...this.onErrorHooks],
1177
- lifecycleHookLane,
990
+ lifecycleHookLane: lanes.lifecycleHookLane,
1178
991
  around: [...this.aroundHooks],
1179
992
  execution,
993
+ program,
1180
994
  };
1181
995
  const descriptor = {
1182
996
  method,
@@ -1185,53 +999,13 @@ export class Server {
1185
999
  ...(capabilities.length > 0 ? { capabilities } : {}),
1186
1000
  ...(schema?.family === true ? { family: true } : {}),
1187
1001
  };
1188
- const routeAssurance = [...this.activeAssurance, ...handlerAssurance];
1189
- if (contracted?.runtime.mode === "enforce" && schema?.response !== undefined) {
1190
- routeAssurance.push(Object.freeze({
1191
- id: NIFRA_ASSURANCE_IDS.RESPONSE_CONTRACT,
1192
- source: "response-contract",
1193
- scope: "plugin",
1194
- }));
1195
- }
1196
- // Inline `schema.assurance`: the route DECLARES its enforcement evidence adjacent to the handler, so an
1197
- // in-handler-guarded route satisfies a policy `require:` clause without a `withRouteAssurance` middleware
1198
- // rewrite. Each id becomes route-scoped `declared` evidence (invalid ids fail closed at registration).
1199
- for (const id of schema?.assurance ?? []) {
1200
- if (!validEvidenceId(id)) {
1201
- throw new Error(`route assurance: invalid evidence id ${JSON.stringify(id)} on ${method} ${path} (use lowercase dot/dash segments)`);
1202
- }
1203
- const declared = { id, source: "declared", scope: "plugin" };
1204
- Object.defineProperty(declared, "provenance", {
1205
- value: "declared",
1206
- enumerable: false,
1207
- configurable: false,
1208
- writable: false,
1209
- });
1210
- routeAssurance.push(Object.freeze(declared));
1211
- }
1212
- if (schema?.body !== undefined) {
1213
- routeAssurance.push(Object.freeze({
1214
- id: NIFRA_ASSURANCE_IDS.BODY_BOUNDED,
1215
- source: "route-schema",
1216
- scope: "plugin",
1217
- }));
1218
- }
1219
- // Declaring `schema.idempotency` is evidence for request replay only. It deliberately never proves
1220
- // durable command execution; that stronger evidence belongs to a command/outbox adapter.
1221
- if (schema?.idempotency !== undefined) {
1222
- routeAssurance.push(Object.freeze({
1223
- id: NIFRA_ASSURANCE_IDS.IDEMPOTENCY_KEY,
1224
- source: "route-schema",
1225
- scope: "plugin",
1226
- }));
1227
- }
1228
1002
  return {
1229
1003
  method,
1230
1004
  path,
1231
1005
  pattern,
1232
1006
  entry: registeredEntry,
1233
1007
  descriptor,
1234
- assurance: Object.freeze(routeAssurance),
1008
+ assurance: routeAssurance,
1235
1009
  };
1236
1010
  }
1237
1011
  /** The idempotency lane's bridge back into the normal matched lanes, resolved to a concrete Response
@@ -1244,6 +1018,29 @@ export class Server {
1244
1018
  this.assertConfigurable("responseContract()");
1245
1019
  this.responseContractRuntime = runtime;
1246
1020
  }
1021
+ /** @internal Symbol-keyed install seam for the response observer plugin. */
1022
+ [INSTALL_RESPONSE_OBSERVER](runtime) {
1023
+ this.assertConfigurable("responseObserver()");
1024
+ const methods = runtime.install({
1025
+ assertConfigurable: (operation) => this.assertConfigurable(operation),
1026
+ addResponseHook: (web, node) => {
1027
+ this.onResponseHooks.push(web);
1028
+ this.onNodeResponseHooks.push(node);
1029
+ if (node === undefined)
1030
+ this.nodeResponseHooksComplete = false;
1031
+ },
1032
+ enableResponseBodyTagging: () => {
1033
+ if (this.responseBodyTag === undefined) {
1034
+ this.responseBodyTag = Object.freeze({});
1035
+ this.responseBodyOwners.add(this.responseBodyTag);
1036
+ }
1037
+ return this.responseBodyTag;
1038
+ },
1039
+ responseBodyOwners: () => this.responseBodyOwners,
1040
+ });
1041
+ Object.assign(this, methods);
1042
+ return methods;
1043
+ }
1247
1044
  /** @internal Symbol-keyed install seam for the `idempotency()` plugin. Off the public typed surface. */
1248
1045
  [INSTALL_IDEMPOTENCY](runtime) {
1249
1046
  this.assertConfigurable("idempotency()");
@@ -1373,14 +1170,37 @@ export class Server {
1373
1170
  return route;
1374
1171
  // Rebuild with the SAME builder that produced the closure - rebinding a query-fused route as
1375
1172
  // bare would silently drop its validation.
1376
- const fusedBody = entry.execution.fusedLane === "body"
1377
- ? this.buildFusedBodyRunner(entry.handler, entry.schema?.body, entry.hasDecorations ? entry.decorations : undefined, entry.bodyLimit ?? this.maxBodyBytes)
1173
+ const lane = entry.execution.fusedLane;
1174
+ // Type-erase the lane so each `===` branch sees the full union, not the narrowed remainder.
1175
+ const laneName = lane;
1176
+ // Body+lifecycle routes have a fused Web renderer, but no body-only Node renderer: the latter
1177
+ // would bypass derive/before/after because the Node-direct dispatcher prefers `fusedBody` over
1178
+ // the generic execution plan. Only the original body-only lane may populate this slot.
1179
+ const fusedBody = laneName === "body"
1180
+ ? this.buildFusedBodyRunner(entry.handler, entry.schema?.body, entry.hasDecorations ? entry.decorations : undefined, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
1378
1181
  : undefined;
1379
- const fusedWeb = entry.execution.fusedLane === "body"
1182
+ const derive = entry.derives[0];
1183
+ const before = entry.beforeHandle[0];
1184
+ const after = entry.afterHandle[0];
1185
+ const fusedWeb = laneName === "body"
1380
1186
  ? this.buildFusedBodyWeb(fusedBody)
1381
- : entry.execution.fusedLane === "query"
1382
- ? this.buildFusedQueryWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, entry.schema?.query, entry.bodyLimit ?? this.maxBodyBytes)
1383
- : this.buildFusedWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, isContextlessNoArgArrow(entry.handler), entry.bodyLimit ?? this.maxBodyBytes);
1187
+ : laneName === "query"
1188
+ ? this.buildFusedQueryWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, entry.schema?.query, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
1189
+ : laneName === "body-derive-before" && derive !== undefined && before !== undefined
1190
+ ? this.buildFusedBodyDeriveBeforeAfter(entry.handler, derive, before, undefined, entry.schema?.body, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
1191
+ : laneName === "body-derive-before-after" &&
1192
+ derive !== undefined &&
1193
+ before !== undefined &&
1194
+ after !== undefined
1195
+ ? this.buildFusedBodyDeriveBeforeAfter(entry.handler, derive, before, after, entry.schema?.body, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
1196
+ : laneName === "derive-before" && derive !== undefined && before !== undefined
1197
+ ? this.buildFusedDeriveBefore(entry.handler, derive, before, entry.schema?.query, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
1198
+ : laneName === "derive-before-after" &&
1199
+ derive !== undefined &&
1200
+ before !== undefined &&
1201
+ after !== undefined
1202
+ ? this.buildFusedDeriveBeforeAfter(entry.handler, derive, before, after, entry.schema?.query, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
1203
+ : this.buildFusedWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, isContextlessNoArgArrow(entry.handler), entry.bodyLimit ?? UNLIMITED_BODY_BYTES);
1384
1204
  return {
1385
1205
  ...route,
1386
1206
  entry: {
@@ -1498,6 +1318,42 @@ export class Server {
1498
1318
  release();
1499
1319
  return outcome;
1500
1320
  }
1321
+ /**
1322
+ * Select a mounted handler's native Node lane without materializing a Web Request. This is an
1323
+ * adapter seam, not a second lifecycle: any app-wide behavior that the ordinary mount path would
1324
+ * run makes the capability ineligible and the caller falls back to `resolveNodeSource`. Typed
1325
+ * routes keep precedence exactly as `routeAndRun` does, including the existing mount behavior for
1326
+ * a path whose registered route rejects this method.
1327
+ */
1328
+ [RESOLVE_NODE_MOUNT](source) {
1329
+ if (this.onRequestHooks.length > 0 ||
1330
+ this.onResponseHooks.length > 0 ||
1331
+ this.onResponseFinalizedHooks.length > 0 ||
1332
+ this.capacityGate !== undefined ||
1333
+ this.staticResponseHeaders !== undefined ||
1334
+ this.requestTimeoutMs !== 0 ||
1335
+ this.acceptInboundDeadlines ||
1336
+ this.clientIpTrust !== undefined) {
1337
+ return undefined;
1338
+ }
1339
+ const parts = source.urlParts ?? urlPartsOf(source.url);
1340
+ const match = this.catalog.find(source.method, parts.pathname);
1341
+ if (match.found)
1342
+ return undefined;
1343
+ for (const mount of this.fetchMounts) {
1344
+ if (!underMountPrefix(parts.pathname, mount.path))
1345
+ continue;
1346
+ const candidate = mount.handler[NODE_NATIVE_MOUNT];
1347
+ return typeof candidate === "function"
1348
+ ? {
1349
+ handler: candidate,
1350
+ path: mount.path,
1351
+ stripPrefix: mount.stripPrefix,
1352
+ }
1353
+ : undefined;
1354
+ }
1355
+ return undefined;
1356
+ }
1501
1357
  /**
1502
1358
  * Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` - the
1503
1359
  * `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
@@ -1983,7 +1839,7 @@ export class Server {
1983
1839
  /**
1984
1840
  * Route → build context → run. Synchronous through to the handler for a **bare** route
1985
1841
  * (selected by its compiled plan), so a sync handler produces its result with zero promise allocations;
1986
- * routes with validation/hooks keep the full async {@link runLifecycle}, unchanged.
1842
+ * routes with validation/hooks keep the full compiled route program, unchanged.
1987
1843
  */
1988
1844
  routeAndRun(source, platform, finalize, wrapResponse, onTimeout, webFast) {
1989
1845
  // Routing only needs the pathname. Scan the URL once but keep the two slices in locals instead
@@ -2037,15 +1893,18 @@ export class Server {
2037
1893
  : wrapResponse(mounted);
2038
1894
  }
2039
1895
  if (match.reason === "method-not-allowed") {
2040
- return wrapResponse(jsonError(405, "method_not_allowed", { Allow: match.allowed.join(", ") }));
1896
+ return wrapResponse(
1897
+ // Lowercase on purpose: a plain render's headers go into the node outcome record verbatim,
1898
+ // and every other name in that record is lowercase (the Web lane normalizes either way).
1899
+ plainError(405, "method_not_allowed", { allow: match.allowed.join(", ") }));
2041
1900
  }
2042
- return wrapResponse(jsonError(404, "not_found"));
1901
+ return wrapResponse(plainError(404, "not_found"));
2043
1902
  }
2044
1903
  // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
2045
1904
  // already did and made every plain dynamic route pay for unrelated static path bytes.
2046
1905
  const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params);
2047
1906
  if (params === null) {
2048
- return wrapResponse(jsonError(400, "malformed_path"));
1907
+ return wrapResponse(plainError(400, "malformed_path"));
2049
1908
  }
2050
1909
  return this.runMatched(source, platform, match.payload, params, search, finalize, wrapResponse, onTimeout, webFast);
2051
1910
  }
@@ -2080,7 +1939,7 @@ export class Server {
2080
1939
  // The runtime is always present when a route resolved idempotency (enforced at registration).
2081
1940
  if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
2082
1941
  return this.idempotencyRuntime.run(entry.idempotent, requestOf(source), platform, entry, params, search, wrapResponse, {
2083
- maxBodyBytes: entry.bodyLimit ?? this.maxBodyBytes,
1942
+ maxBodyBytes: entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2084
1943
  runLanes: (buffered, plat, ent, prm, srch) => this.idempotencyRunLanes(buffered, plat, ent, prm, srch),
2085
1944
  });
2086
1945
  }
@@ -2106,7 +1965,7 @@ export class Server {
2106
1965
  ? undefined
2107
1966
  : admitDeadline(source.headers, this.deadlineAdmissionOptions);
2108
1967
  if (admission !== undefined && !admission.ok) {
2109
- return wrapResponse(jsonError(admission.status, admission.reason));
1968
+ return wrapResponse(plainError(admission.status, admission.reason));
2110
1969
  }
2111
1970
  const effectiveTimeoutMs = admission?.timeoutMs ?? 0;
2112
1971
  // Only allocate a controller for a finite budget; the historical no-timeout path remains
@@ -2131,7 +1990,7 @@ export class Server {
2131
1990
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
2132
1991
  if (controller !== undefined && outcome instanceof Promise) {
2133
1992
  const timedOut = admission?.inherited === true
2134
- ? () => wrapResponse(jsonError(504, "deadline_exceeded"))
1993
+ ? () => wrapResponse(plainError(504, "deadline_exceeded"))
2135
1994
  : onTimeout;
2136
1995
  return this.withTimeout(outcome, controller, timedOut, Math.max(0, Math.ceil(budget.remaining())));
2137
1996
  }
@@ -2145,7 +2004,6 @@ export class Server {
2145
2004
  /** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
2146
2005
  * successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
2147
2006
  // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2148
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2149
2007
  runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse) {
2150
2008
  let result;
2151
2009
  try {
@@ -2162,20 +2020,23 @@ export class Server {
2162
2020
  contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse) {
2163
2021
  if (err instanceof Response)
2164
2022
  return wrapResponse(err);
2023
+ // Handed over as the plain data it is: `wrapResponse` renders it on the JSON lane (no `Response`
2024
+ // built on Node). There is no context here by construction, so there is no `c.set` to merge.
2025
+ if (isResponseResult(err))
2026
+ return wrapResponse(err);
2165
2027
  const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes, this.protoPoisoning);
2166
2028
  this.logRequestError(err, ctx);
2167
- return wrapResponse(jsonError(500, "internal_error"));
2029
+ return this.internalErrorResponse(wrapResponse);
2168
2030
  }
2169
2031
  /**
2170
2032
  * The synchronous fast path selected by a route's execution plan: apply static decorations, call the
2171
2033
  * handler, render the result - **no `await`** unless the handler itself returns a promise. It mirrors
2172
- * the bare slice of {@link runLifecycle} (which a bare route would otherwise no-op through) and shares
2034
+ * the same error boundary as the compiled route program (which a bare route would otherwise no-op through) and shares
2173
2035
  * {@link logRequestError}; a bare route has no `onError` hooks, so error handling is fully synchronous
2174
2036
  * (a thrown `Response` is control flow; anything else is a logged flat 500). This is where nifra skips
2175
2037
  * the per-request async-frame tax - the same win codegen routers get, but without `eval`.
2176
2038
  */
2177
2039
  // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2178
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2179
2040
  runBare(entry, ctx, finalize, wrapResponse) {
2180
2041
  let result;
2181
2042
  try {
@@ -2184,15 +2045,15 @@ export class Server {
2184
2045
  result = entry.handler(ctx);
2185
2046
  }
2186
2047
  catch (err) {
2187
- return this.bareError(err, ctx, wrapResponse);
2048
+ return this.bareError(err, ctx, finalize, wrapResponse);
2188
2049
  }
2189
2050
  if (result instanceof Promise) {
2190
2051
  // Async handler on an otherwise-bare route: finish on a microtask, with the same error handling.
2191
- return result.then((value) => finalize(value, responseSet(ctx)), (err) => this.bareError(err, ctx, wrapResponse));
2052
+ return result.then((value) => finalize(value, responseSet(ctx)), (err) => this.bareError(err, ctx, finalize, wrapResponse));
2192
2053
  }
2193
2054
  return finalize(result, responseSet(ctx));
2194
2055
  }
2195
- /** Bare-route error rendering - identical to {@link runLifecycle}'s catch minus the (absent) onError
2056
+ /** Bare-route error rendering - identical to the compiled route program's catch minus the (absent) onError
2196
2057
  * loop: a thrown `Response` is returned as deliberate control flow; anything else is logged + 500. */
2197
2058
  /**
2198
2059
  * Build a route's fused Web renderer. Composition happens once at
@@ -2202,14 +2063,10 @@ export class Server {
2202
2063
  * 500s), same respond semantics (the lifecycle parity suite pins it).
2203
2064
  */
2204
2065
  buildFusedWeb(handler, decorations, contextless, maxBodyBytes) {
2205
- // The fused lanes return to the runtime directly, so the framework's own error renders fold in
2206
- // static headers here rather than through the shared `wrapResponse` seam.
2207
- const logError = (err, ctx) => {
2208
- if (err instanceof Response)
2209
- return this.wrapWebResponse(err);
2210
- this.logRequestError(err, ctx);
2211
- return this.wrapWebResponse(jsonError(500, "internal_error"));
2212
- };
2066
+ // Same thrown-value contract as the generic lane (`bare-error-lane.ts` via `bareError`); the fused
2067
+ // lane only injects its renderer, so a thrown `status(...)` and the flat 500 fold in static headers
2068
+ // through `wrapWebResponse`. One source of truth for the error surface, no per-lane copy to drift.
2069
+ const logError = (err, ctx) => this.bareError(err, ctx, (result, set) => this.wrapWebResponse(toResponse(result, set)), this.wrapWebResponse);
2213
2070
  if (contextless && decorations === undefined) {
2214
2071
  // `() => ...` can't observe the context - skip allocating one entirely (errors still build
2215
2072
  // one for the structured log, exactly like runContextlessBare).
@@ -2255,7 +2112,7 @@ export class Server {
2255
2112
  * bounded parser remains shared with the generic lane; only the route-invariant entry lookups and
2256
2113
  * lifecycle dispatch disappear from the common synchronous-validator/synchronous-handler case. */
2257
2114
  buildFusedBodyRunner(handler, bodySchema, decorations, maxBodyBytes) {
2258
- const logError = (err, ctx, wrapResponse) => this.bareError(err, ctx, wrapResponse);
2115
+ const logError = (err, ctx, finalize, wrapResponse) => this.bareError(err, ctx, finalize, wrapResponse);
2259
2116
  const runHandler = (ctx, finalize, wrapResponse) => {
2260
2117
  if (decorations !== undefined)
2261
2118
  Object.assign(ctx, decorations);
@@ -2264,7 +2121,7 @@ export class Server {
2264
2121
  output = handler(ctx);
2265
2122
  }
2266
2123
  catch (err) {
2267
- return logError(err, ctx, wrapResponse);
2124
+ return logError(err, ctx, finalize, wrapResponse);
2268
2125
  }
2269
2126
  if (output instanceof Promise) {
2270
2127
  return output.then((result) => {
@@ -2272,20 +2129,20 @@ export class Server {
2272
2129
  return finalize(result, responseSet(ctx), ctx);
2273
2130
  }
2274
2131
  catch (err) {
2275
- return logError(err, ctx, wrapResponse);
2132
+ return logError(err, ctx, finalize, wrapResponse);
2276
2133
  }
2277
- }, (err) => logError(err, ctx, wrapResponse));
2134
+ }, (err) => logError(err, ctx, finalize, wrapResponse));
2278
2135
  }
2279
2136
  try {
2280
2137
  return finalize(output, responseSet(ctx), ctx);
2281
2138
  }
2282
2139
  catch (err) {
2283
- return logError(err, ctx, wrapResponse);
2140
+ return logError(err, ctx, finalize, wrapResponse);
2284
2141
  }
2285
2142
  };
2286
2143
  const runValidated = (result, ctx, finalize, wrapResponse) => {
2287
2144
  if (result.issues !== undefined)
2288
- return wrapResponse(validationError(result.issues));
2145
+ return wrapResponse(plainValidationError(result.issues));
2289
2146
  ctx.body = result.value;
2290
2147
  return runHandler(ctx, finalize, wrapResponse);
2291
2148
  };
@@ -2295,7 +2152,7 @@ export class Server {
2295
2152
  validation = bodySchema["~standard"].validate(parsed);
2296
2153
  }
2297
2154
  catch (err) {
2298
- return logError(err, ctx, wrapResponse);
2155
+ return logError(err, ctx, finalize, wrapResponse);
2299
2156
  }
2300
2157
  if (validation instanceof Promise) {
2301
2158
  return validation.then((settled) => {
@@ -2303,15 +2160,15 @@ export class Server {
2303
2160
  return runValidated(settled, ctx, finalize, wrapResponse);
2304
2161
  }
2305
2162
  catch (err) {
2306
- return logError(err, ctx, wrapResponse);
2163
+ return logError(err, ctx, finalize, wrapResponse);
2307
2164
  }
2308
- }, (err) => logError(err, ctx, wrapResponse));
2165
+ }, (err) => logError(err, ctx, finalize, wrapResponse));
2309
2166
  }
2310
2167
  try {
2311
2168
  return runValidated(validation, ctx, finalize, wrapResponse);
2312
2169
  }
2313
2170
  catch (err) {
2314
- return logError(err, ctx, wrapResponse);
2171
+ return logError(err, ctx, finalize, wrapResponse);
2315
2172
  }
2316
2173
  };
2317
2174
  return (source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse) => {
@@ -2319,7 +2176,7 @@ export class Server {
2319
2176
  ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2320
2177
  : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
2321
2178
  const finish = (value) => runParsed(value, ctx, finalize, wrapResponse);
2322
- return this.readBodyInput(source, maxBodyBytes, finish, wrapResponse, (err) => logError(err, ctx, wrapResponse));
2179
+ return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, finish, wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse));
2323
2180
  };
2324
2181
  }
2325
2182
  /** Web adapter wrapper for the shared body runner. Node passes its native finalizer directly through
@@ -2335,14 +2192,10 @@ export class Server {
2335
2192
  * `Response` passes through, anything else logs and returns a flat 500, and an async validator
2336
2193
  * falls to a then-chain with the same steps. */
2337
2194
  buildFusedQueryWeb(handler, decorations, querySchema, maxBodyBytes) {
2338
- // The fused lanes return to the runtime directly, so the framework's own error renders fold in
2339
- // static headers here rather than through the shared `wrapResponse` seam.
2340
- const logError = (err, ctx) => {
2341
- if (err instanceof Response)
2342
- return this.wrapWebResponse(err);
2343
- this.logRequestError(err, ctx);
2344
- return this.wrapWebResponse(jsonError(500, "internal_error"));
2345
- };
2195
+ // Same thrown-value contract as the generic lane (`bare-error-lane.ts` via `bareError`); the fused
2196
+ // lane only injects its renderer, so a thrown `status(...)` and the flat 500 fold in static headers
2197
+ // through `wrapWebResponse`. One source of truth for the error surface, no per-lane copy to drift.
2198
+ const logError = (err, ctx) => this.bareError(err, ctx, (result, set) => this.wrapWebResponse(toResponse(result, set)), this.wrapWebResponse);
2346
2199
  const runHandler = (ctx, value) => {
2347
2200
  ctx.query = value;
2348
2201
  let result;
@@ -2372,22 +2225,346 @@ export class Server {
2372
2225
  }
2373
2226
  if (validation instanceof Promise) {
2374
2227
  return validation.then((settled) => settled.issues !== undefined
2375
- ? this.wrapWebResponse(validationError(settled.issues))
2228
+ ? this.wrapWebResponse(plainValidationError(settled.issues))
2376
2229
  : runHandler(ctx, settled.value), (err) => logError(err, ctx));
2377
2230
  }
2378
2231
  if (validation.issues !== undefined)
2379
- return this.wrapWebResponse(validationError(validation.issues));
2232
+ return this.wrapWebResponse(plainValidationError(validation.issues));
2380
2233
  return runHandler(ctx, validation.value);
2381
2234
  };
2382
2235
  }
2383
- bareError(err, ctx, wrapResponse) {
2384
- if (err instanceof Response)
2385
- return wrapResponse(err);
2386
- this.logRequestError(err, ctx);
2387
- return wrapResponse(jsonError(500, "internal_error"));
2236
+ /**
2237
+ * Compile a route's `derive + before` lifecycle to a single closure: the per-request walk through
2238
+ * `runSync` → `runHooksSync` is a stack of 3 frames and 2 `Object.assign`s for what is by far the
2239
+ * most common middleware shape (auth/trace/id derivation, an auth/policy `beforeHandle`). Folding
2240
+ * the same steps into one closure hits the same monomorphic inline-cache site as `buildFusedWeb`,
2241
+ * and `Object.assign(ctx, deriveResult)` becomes one extra property write on the context's already
2242
+ * hot hidden class. Behavior is byte-identical to the compiled route program: the same thrown-value
2243
+ * contract (`Response` is control flow, anything else logs + 500), the same `c.set` semantics, the
2244
+ * same `fusedRespond` for the rendered body. The `LifecycleExecutionLane` guarantee
2245
+ * (`schema.params === undefined && schema.headers === undefined && derives === 1 &&
2246
+ * beforeHandle === 1 && afterHandle === 0 && onError === 0 && !hasResponseContract &&
2247
+ * !hasDecorations`) is enforced by `selectRouteLanes`; this lane is selected ONLY when all of those
2248
+ * hold. A `query` schema (the route declares `{ query }` alongside the hooks) is validated FIRST,
2249
+ * mirroring the compiled route program's stage order; the fused builder runs validate → derive →
2250
+ * before → handler in one frame.
2251
+ */
2252
+ buildFusedDeriveBefore(handler, derive, before, querySchema, maxBodyBytes) {
2253
+ const logError = (err, ctx) => this.bareError(err, ctx, (result, set) => this.wrapWebResponse(toResponse(result, set)), this.wrapWebResponse);
2254
+ return (source, params, search, signal, budget, platform, nativeContext) => {
2255
+ const ctx = nativeContext
2256
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2257
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
2258
+ const runHandler = () => {
2259
+ let result;
2260
+ try {
2261
+ result = handler(ctx);
2262
+ }
2263
+ catch (err) {
2264
+ return logError(err, ctx);
2265
+ }
2266
+ if (result instanceof Promise) {
2267
+ return result.then((value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
2268
+ }
2269
+ return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
2270
+ };
2271
+ const runBefore = () => {
2272
+ let early;
2273
+ try {
2274
+ early = before(ctx);
2275
+ }
2276
+ catch (err) {
2277
+ return logError(err, ctx);
2278
+ }
2279
+ if (early instanceof Promise) {
2280
+ return early.then((settled) => {
2281
+ if (settled === undefined)
2282
+ return runHandler();
2283
+ return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders);
2284
+ }, (err) => logError(err, ctx));
2285
+ }
2286
+ if (early === undefined)
2287
+ return runHandler();
2288
+ return fusedRespond(early, ctx, this.responseBodyTag, this.staticResponseHeaders);
2289
+ };
2290
+ const runDerive = () => {
2291
+ let derived;
2292
+ try {
2293
+ derived = derive(ctx);
2294
+ }
2295
+ catch (err) {
2296
+ return logError(err, ctx);
2297
+ }
2298
+ if (derived instanceof Promise) {
2299
+ return derived.then((settled) => {
2300
+ if (isResponseResult(settled) || settled instanceof Response) {
2301
+ return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders);
2302
+ }
2303
+ Object.assign(ctx, settled);
2304
+ return runBefore();
2305
+ }, (err) => logError(err, ctx));
2306
+ }
2307
+ if (isResponseResult(derived) || derived instanceof Response) {
2308
+ return fusedRespond(derived, ctx, this.responseBodyTag, this.staticResponseHeaders);
2309
+ }
2310
+ Object.assign(ctx, derived);
2311
+ return runBefore();
2312
+ };
2313
+ if (querySchema === undefined)
2314
+ return runDerive();
2315
+ let validation;
2316
+ try {
2317
+ validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
2318
+ }
2319
+ catch (err) {
2320
+ return logError(err, ctx);
2321
+ }
2322
+ if (validation instanceof Promise) {
2323
+ return validation.then((settled) => {
2324
+ if (settled.issues !== undefined)
2325
+ return this.wrapWebResponse(plainValidationError(settled.issues));
2326
+ ctx.query = settled.value;
2327
+ return runDerive();
2328
+ }, (err) => logError(err, ctx));
2329
+ }
2330
+ if (validation.issues !== undefined)
2331
+ return this.wrapWebResponse(plainValidationError(validation.issues));
2332
+ ctx.query = validation.value;
2333
+ return runDerive();
2334
+ };
2335
+ }
2336
+ /**
2337
+ * Same shape as `buildFusedDeriveBefore`, plus one `afterHandle` step. The closure runs (validate) →
2338
+ * derive → before → handler → after in one frame, the shape the realistic middleware route lands on
2339
+ * when it also wants a response transform (e.g. attaching a request id to a successful payload's
2340
+ * headers). `LifecycleExecutionLane` and `selectRouteLanes` guarantee `afterHandle === 1`, the
2341
+ * throw contract is the same as the generic program, and the `after` step is the final transform +
2342
+ * respond the generic program already does.
2343
+ */
2344
+ buildFusedDeriveBeforeAfter(handler, derive, before, after, querySchema, maxBodyBytes) {
2345
+ const logError = (err, ctx) => this.bareError(err, ctx, (result, set) => this.wrapWebResponse(toResponse(result, set)), this.wrapWebResponse);
2346
+ return (source, params, search, signal, budget, platform, nativeContext) => {
2347
+ const ctx = nativeContext
2348
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2349
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
2350
+ const runAfter = (result) => {
2351
+ let transformed;
2352
+ try {
2353
+ transformed = after(result, ctx);
2354
+ }
2355
+ catch (err) {
2356
+ return logError(err, ctx);
2357
+ }
2358
+ if (transformed instanceof Promise) {
2359
+ return transformed.then((value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
2360
+ }
2361
+ return fusedRespond(transformed, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
2362
+ };
2363
+ const runHandler = () => {
2364
+ let result;
2365
+ try {
2366
+ result = handler(ctx);
2367
+ }
2368
+ catch (err) {
2369
+ return logError(err, ctx);
2370
+ }
2371
+ if (result instanceof Promise) {
2372
+ return result.then((value) => runAfter(value), (err) => logError(err, ctx));
2373
+ }
2374
+ return runAfter(result);
2375
+ };
2376
+ const runBefore = () => {
2377
+ let early;
2378
+ try {
2379
+ early = before(ctx);
2380
+ }
2381
+ catch (err) {
2382
+ return logError(err, ctx);
2383
+ }
2384
+ if (early instanceof Promise) {
2385
+ return early.then((settled) => {
2386
+ if (settled === undefined)
2387
+ return runHandler();
2388
+ return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders);
2389
+ }, (err) => logError(err, ctx));
2390
+ }
2391
+ if (early === undefined)
2392
+ return runHandler();
2393
+ return fusedRespond(early, ctx, this.responseBodyTag, this.staticResponseHeaders);
2394
+ };
2395
+ const runDerive = () => {
2396
+ let derived;
2397
+ try {
2398
+ derived = derive(ctx);
2399
+ }
2400
+ catch (err) {
2401
+ return logError(err, ctx);
2402
+ }
2403
+ if (derived instanceof Promise) {
2404
+ return derived.then((settled) => {
2405
+ if (isResponseResult(settled) || settled instanceof Response) {
2406
+ return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders);
2407
+ }
2408
+ Object.assign(ctx, settled);
2409
+ return runBefore();
2410
+ }, (err) => logError(err, ctx));
2411
+ }
2412
+ if (isResponseResult(derived) || derived instanceof Response) {
2413
+ return fusedRespond(derived, ctx, this.responseBodyTag, this.staticResponseHeaders);
2414
+ }
2415
+ Object.assign(ctx, derived);
2416
+ return runBefore();
2417
+ };
2418
+ if (querySchema === undefined)
2419
+ return runDerive();
2420
+ let validation;
2421
+ try {
2422
+ validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
2423
+ }
2424
+ catch (err) {
2425
+ return logError(err, ctx);
2426
+ }
2427
+ if (validation instanceof Promise) {
2428
+ return validation.then((settled) => {
2429
+ if (settled.issues !== undefined)
2430
+ return this.wrapWebResponse(plainValidationError(settled.issues));
2431
+ ctx.query = settled.value;
2432
+ return runDerive();
2433
+ }, (err) => logError(err, ctx));
2434
+ }
2435
+ if (validation.issues !== undefined)
2436
+ return this.wrapWebResponse(plainValidationError(validation.issues));
2437
+ ctx.query = validation.value;
2438
+ return runDerive();
2439
+ };
2440
+ }
2441
+ /**
2442
+ * Body + lifecycle hooks fused: parse → validate → derive → before → handler → (after) in one
2443
+ * closure, the same win `buildFusedBodyRunner` gives for the no-hooks body shape. The shape is
2444
+ * `lifecycleHookLane` PLUS a body schema with no other validation conflicts; the lane is selected
2445
+ * ONLY when `schema.body !== undefined && schema.query/params/headers === undefined &&
2446
+ * schema.onValidationError === undefined && !defaultOnValidationError && !hasIdempotency &&
2447
+ * !hasLedger && derives === 1 && beforeHandle === 1 && afterHandle <= 1 && onError === 0 &&
2448
+ * !hasResponseContract && !hasDecorations && around === 0`. The same single `querySchema === undefined`
2449
+ * trick used in the no-body lane lets the build path stay simple: a query + body route falls back
2450
+ * to the generic program (this is rare in practice - validated query + validated body is usually a
2451
+ * "search results" route with a middleware chain on top, and the generic lane is fine there).
2452
+ */
2453
+ buildFusedBodyDeriveBeforeAfter(handler, derive, before, after, bodySchema, maxBodyBytes) {
2454
+ const logError = (err, ctx) => this.bareError(err, ctx, (result, set) => this.wrapWebResponse(toResponse(result, set)), this.wrapWebResponse);
2455
+ return (source, params, search, signal, budget, platform, nativeContext) => {
2456
+ const ctx = nativeContext
2457
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2458
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
2459
+ const runHandlerValidated = () => {
2460
+ const runAfter = (result) => {
2461
+ if (after === undefined) {
2462
+ return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
2463
+ }
2464
+ let transformed;
2465
+ try {
2466
+ transformed = after(result, ctx);
2467
+ }
2468
+ catch (err) {
2469
+ return logError(err, ctx);
2470
+ }
2471
+ if (transformed instanceof Promise) {
2472
+ return transformed.then((value) => fusedRespond(value, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0), (err) => logError(err, ctx));
2473
+ }
2474
+ return fusedRespond(transformed, ctx, this.responseBodyTag, this.staticResponseHeaders, this.onResponseHooks.length === 0);
2475
+ };
2476
+ const runHandler = () => {
2477
+ let result;
2478
+ try {
2479
+ result = handler(ctx);
2480
+ }
2481
+ catch (err) {
2482
+ return logError(err, ctx);
2483
+ }
2484
+ if (result instanceof Promise) {
2485
+ return result.then((value) => runAfter(value), (err) => logError(err, ctx));
2486
+ }
2487
+ return runAfter(result);
2488
+ };
2489
+ const runBefore = () => {
2490
+ let early;
2491
+ try {
2492
+ early = before(ctx);
2493
+ }
2494
+ catch (err) {
2495
+ return logError(err, ctx);
2496
+ }
2497
+ if (early instanceof Promise) {
2498
+ return early.then((settled) => {
2499
+ if (settled === undefined)
2500
+ return runHandler();
2501
+ return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders);
2502
+ }, (err) => logError(err, ctx));
2503
+ }
2504
+ if (early === undefined)
2505
+ return runHandler();
2506
+ return fusedRespond(early, ctx, this.responseBodyTag, this.staticResponseHeaders);
2507
+ };
2508
+ let derived;
2509
+ try {
2510
+ derived = derive(ctx);
2511
+ }
2512
+ catch (err) {
2513
+ return logError(err, ctx);
2514
+ }
2515
+ if (derived instanceof Promise) {
2516
+ return derived.then((settled) => {
2517
+ if (isResponseResult(settled) || settled instanceof Response) {
2518
+ return fusedRespond(settled, ctx, this.responseBodyTag, this.staticResponseHeaders);
2519
+ }
2520
+ Object.assign(ctx, settled);
2521
+ return runBefore();
2522
+ }, (err) => logError(err, ctx));
2523
+ }
2524
+ if (isResponseResult(derived) || derived instanceof Response) {
2525
+ return fusedRespond(derived, ctx, this.responseBodyTag, this.staticResponseHeaders);
2526
+ }
2527
+ Object.assign(ctx, derived);
2528
+ return runBefore();
2529
+ };
2530
+ const onParsed = (parsed) => {
2531
+ let validation;
2532
+ try {
2533
+ validation = bodySchema["~standard"].validate(parsed);
2534
+ }
2535
+ catch (err) {
2536
+ return logError(err, ctx);
2537
+ }
2538
+ if (validation instanceof Promise) {
2539
+ return validation.then((settled) => {
2540
+ if (settled.issues !== undefined)
2541
+ return this.wrapWebResponse(plainValidationError(settled.issues));
2542
+ ctx.body = settled.value;
2543
+ return runHandlerValidated();
2544
+ }, (err) => logError(err, ctx));
2545
+ }
2546
+ if (validation.issues !== undefined)
2547
+ return this.wrapWebResponse(plainValidationError(validation.issues));
2548
+ ctx.body = validation.value;
2549
+ return runHandlerValidated();
2550
+ };
2551
+ return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, onParsed, (response) => this.wrapWebResponse(response), (err) => Promise.resolve(logError(err, ctx)));
2552
+ };
2553
+ }
2554
+ bareError(err, ctx, finalize, wrapResponse) {
2555
+ // The thrown-value contract lives in `bare-error-lane.ts`, one source of truth for both this
2556
+ // lane and the fused body runner. `responseSet` and the logger are injected because they reach
2557
+ // into class state; the closure is allocated only on the throw path, never on the hot lane.
2558
+ return renderBareError(err, ctx, finalize, wrapResponse, responseSet, (e, c) => this.logRequestError(e, c), () => this.internalErrorResponse(wrapResponse));
2559
+ }
2560
+ /**
2561
+ * Keep the ordinary thrown-error response independent from the caught value. Deliberate Response
2562
+ * control flow is handled by `renderBareError`; every other throw gets this fixed public envelope.
2563
+ */
2564
+ internalErrorResponse(wrapResponse) {
2565
+ return wrapResponse(plainError(500, "internal_error"));
2388
2566
  }
2389
2567
  // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2390
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2391
2568
  runWithAround(entry, ctx, run, finalize, wrapResponse) {
2392
2569
  let outcome;
2393
2570
  try {
@@ -2400,6 +2577,45 @@ export class Server {
2400
2577
  ? outcome.catch((err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse))
2401
2578
  : outcome;
2402
2579
  }
2580
+ /** Execute the registration-compiled general stage program. */
2581
+ // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2582
+ runProgram(entry, source, ctx, finalize, wrapResponse) {
2583
+ return executeRouteProgram(this, entry, entry.program, source, ctx, finalize, wrapResponse);
2584
+ }
2585
+ /** Bind a compiled validation stage to its stable schema and the existing recovery contract. */
2586
+ // @ts-expect-error TS6133 -- invoked structurally by the general route program
2587
+ validateProgramStage(entry, stage, source, ctx) {
2588
+ const input = stage.kind === "headers"
2589
+ ? headerObjectOf(source.headers)
2590
+ : stage.kind === "params"
2591
+ ? ctx.params
2592
+ : stage.kind === "query"
2593
+ ? queryObjectOf(ctx[CONTEXT_SEARCH])
2594
+ : undefined;
2595
+ if (stage.kind === "body")
2596
+ return this.readProgramBody(entry, source, ctx);
2597
+ const validation = stage.schema["~standard"].validate(input);
2598
+ return validation instanceof Promise
2599
+ ? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, stage.kind))
2600
+ : this.applyLifecycleValidation(entry, validation, ctx, stage.kind);
2601
+ }
2602
+ /** Preserve the existing bounded body reader and its fail-closed framing/prototype checks. */
2603
+ readProgramBody(entry, source, ctx) {
2604
+ const bodySchema = entry.program.bodySchema;
2605
+ if (bodySchema === undefined)
2606
+ return Promise.resolve(plainError(415, "unsupported_media_type"));
2607
+ return this.readAndValidateBody(source, entry, ctx, bodySchema, entry.program.bodyLimit);
2608
+ }
2609
+ /** Keep thrown Response/status and redacted 500 semantics in one existing error boundary. */
2610
+ // @ts-expect-error TS6133 -- invoked structurally by the general route program
2611
+ handleProgramError(entry, error, ctx, finalize, wrapResponse) {
2612
+ return this.handleLifecycleError(entry, error, ctx, finalize, wrapResponse);
2613
+ }
2614
+ /** Finish a successful program result through response-contract enforcement and finalization. */
2615
+ // @ts-expect-error TS6133 -- invoked structurally by the general route program
2616
+ finishProgramResult(entry, ctx, result, finalize, wrapResponse) {
2617
+ return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, result);
2618
+ }
2403
2619
  runAround(hooks, ctx, run) {
2404
2620
  const dispatch = (index) => {
2405
2621
  if (index >= hooks.length)
@@ -2416,29 +2632,8 @@ export class Server {
2416
2632
  return dispatch(0);
2417
2633
  }
2418
2634
  // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2419
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2420
2635
  runBodyOnly(entry, source, ctx, finalize, wrapResponse) {
2421
- return this.readBodyInput(source, entry.bodyLimit ?? this.maxBodyBytes, (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2422
- }
2423
- /** Shared bounded body framing/parser. Both body lanes use the same trust-boundary checks; only
2424
- * the validation + handler continuation differs. */
2425
- readBodyInput(source, maxBodyBytes, onParsed, wrapResponse, onError) {
2426
- const contentType = headerOf(source, "content-type") ?? "";
2427
- if (contentType !== "application/json" && !contentType.includes("application/json")) {
2428
- if (isUrlEncodedForm(contentType)) {
2429
- return readBoundedForm(source, maxBodyBytes).then((form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)), onError);
2430
- }
2431
- return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")));
2432
- }
2433
- // All JSON bodies go through readBoundedJson - the single enforcement point for the framed
2434
- // fast path (post-read byte re-check), the streaming cap, and the prototype-poisoning guard.
2435
- // A separate inlined native-json() fast path here would fork the trust boundary in two.
2436
- try {
2437
- return this.readBoundedJson(source, maxBodyBytes, (parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)), onError);
2438
- }
2439
- catch (err) {
2440
- return Promise.resolve(onError(err));
2441
- }
2636
+ return readBodyFramed(source, entry.bodyLimit ?? UNLIMITED_BODY_BYTES, this.protoPoisoning, (parsed) => this.finishBodyOnly(entry, parsed, ctx, finalize, wrapResponse), wrapResponse, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2442
2637
  }
2443
2638
  /** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
2444
2639
  * streaming fallback. A method (not per-request closures) so the hot path allocates nothing
@@ -2493,13 +2688,13 @@ export class Server {
2493
2688
  if (validation instanceof Promise) {
2494
2689
  return validation.then((settled) => {
2495
2690
  if (settled.issues !== undefined)
2496
- return wrapResponse(validationError(settled.issues));
2691
+ return wrapResponse(plainValidationError(settled.issues));
2497
2692
  ctx.body = settled.value;
2498
2693
  return this.executeHandler(entry, ctx, finalize);
2499
2694
  });
2500
2695
  }
2501
2696
  if (validation.issues !== undefined)
2502
- return wrapResponse(validationError(validation.issues));
2697
+ return wrapResponse(plainValidationError(validation.issues));
2503
2698
  ctx.body = validation.value;
2504
2699
  return this.executeHandler(entry, ctx, finalize);
2505
2700
  }
@@ -2508,18 +2703,18 @@ export class Server {
2508
2703
  if (validation instanceof Promise) {
2509
2704
  return validation.then((settled) => {
2510
2705
  if (settled.issues !== undefined)
2511
- return wrapResponse(validationError(settled.issues));
2706
+ return wrapResponse(plainValidationError(settled.issues));
2512
2707
  ctx.query = settled.value;
2513
2708
  return this.executeHandler(entry, ctx, finalize, wrapResponse);
2514
2709
  }, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2515
2710
  }
2516
2711
  if (validation.issues !== undefined)
2517
- return wrapResponse(validationError(validation.issues));
2712
+ return wrapResponse(plainValidationError(validation.issues));
2518
2713
  ctx.query = validation.value;
2519
2714
  return this.executeHandler(entry, ctx, finalize, wrapResponse);
2520
2715
  }
2521
2716
  }
2522
- return wrapResponse(validationError(originalIssues));
2717
+ return wrapResponse(plainValidationError(originalIssues));
2523
2718
  }
2524
2719
  applyBodyValidation(entry, result, ctx, finalize, wrapResponse) {
2525
2720
  if (result.issues !== undefined) {
@@ -2531,13 +2726,12 @@ export class Server {
2531
2726
  }
2532
2727
  return this.handleValidationErrorRecovery(entry, recovery, ctx, finalize, wrapResponse, result.issues, "body");
2533
2728
  }
2534
- return wrapResponse(validationError(result.issues));
2729
+ return wrapResponse(plainValidationError(result.issues));
2535
2730
  }
2536
2731
  ctx.body = result.value;
2537
2732
  return this.executeHandler(entry, ctx, finalize);
2538
2733
  }
2539
2734
  // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2540
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2541
2735
  runQueryOnly(entry, ctx, finalize, wrapResponse) {
2542
2736
  try {
2543
2737
  // Call the validator directly for the raw StandardResult (read `.issues`/`.value`) - skip
@@ -2574,7 +2768,7 @@ export class Server {
2574
2768
  }
2575
2769
  return this.handleValidationErrorRecovery(entry, recovery, ctx, finalize, wrapResponse, result.issues, "query");
2576
2770
  }
2577
- return wrapResponse(validationError(result.issues));
2771
+ return wrapResponse(plainValidationError(result.issues));
2578
2772
  }
2579
2773
  ctx.query = result.value;
2580
2774
  return this.executeHandler(entry, ctx, finalize, wrapResponse);
@@ -2682,333 +2876,6 @@ export class Server {
2682
2876
  clearTimeout(timer);
2683
2877
  }
2684
2878
  }
2685
- /**
2686
- * The post-match lifecycle: validate → derive → beforeHandle → handler → afterHandle, with onError.
2687
- * Generic over the render: a success value goes through `finalize(result, set)` (a Web `Response`, or
2688
- * node-direct primitives); an early/error `Response` (validation, thrown, 500) through `wrapResponse`.
2689
- */
2690
- /** Synchronous until a validator, lifecycle hook, handler, or contract check actually returns a Promise. */
2691
- // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2692
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2693
- runLifecycle(entry, source, ctx, finalize, wrapResponse) {
2694
- try {
2695
- const headersSchema = entry.schema?.headers;
2696
- if (headersSchema !== undefined) {
2697
- const validation = headersSchema["~standard"].validate(headerObjectOf(source.headers));
2698
- if (validation instanceof Promise) {
2699
- return validation.then((result) => this.runLifecycleAfterHeadersResult(entry, source, ctx, finalize, wrapResponse, result), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2700
- }
2701
- return this.runLifecycleAfterHeadersResult(entry, source, ctx, finalize, wrapResponse, validation);
2702
- }
2703
- const paramsSchema = entry.schema?.params;
2704
- if (paramsSchema !== undefined) {
2705
- const validation = paramsSchema["~standard"].validate(ctx.params);
2706
- if (validation instanceof Promise) {
2707
- return validation.then((result) => this.runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, result), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2708
- }
2709
- return this.runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, validation);
2710
- }
2711
- // Registration already knows whether this route has body/params/query validation. Most real
2712
- // read routes have no body or params schema, so skip the generic stage ladder and go straight to
2713
- // query validation + lifecycle hooks. The fallback below remains the complete path for routes
2714
- // with body/params combinations and validation recovery.
2715
- if (entry.schema?.body === undefined) {
2716
- return this.runQueryAndLifecycle(entry, ctx, finalize, wrapResponse);
2717
- }
2718
- return this.runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, undefined);
2719
- }
2720
- catch (err) {
2721
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2722
- }
2723
- }
2724
- runLifecycleAfterHeadersResult(entry, source, ctx, finalize, wrapResponse, result) {
2725
- try {
2726
- const headersError = this.applyLifecycleValidation(entry, result, ctx, "headers");
2727
- if (headersError instanceof Promise) {
2728
- return headersError.then((error) => error === undefined
2729
- ? this.runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse)
2730
- : wrapResponse(error), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2731
- }
2732
- return headersError === undefined
2733
- ? this.runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse)
2734
- : wrapResponse(headersError);
2735
- }
2736
- catch (err) {
2737
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2738
- }
2739
- }
2740
- runLifecycleAfterHeaders(entry, source, ctx, finalize, wrapResponse) {
2741
- const paramsSchema = entry.schema?.params;
2742
- if (paramsSchema === undefined) {
2743
- if (entry.schema?.body === undefined)
2744
- return this.runQueryAndLifecycle(entry, ctx, finalize, wrapResponse);
2745
- return this.runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, undefined);
2746
- }
2747
- const validation = paramsSchema["~standard"].validate(ctx.params);
2748
- if (validation instanceof Promise) {
2749
- return validation.then((result) => this.runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, result), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2750
- }
2751
- return this.runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, validation);
2752
- }
2753
- /** Registration-specialized query lifecycle: query validation (including recovery) → hooks. */
2754
- runLifecycleQuery(entry, ctx, finalize, wrapResponse) {
2755
- try {
2756
- // The execution plan only selects this runner when the route has a query schema. Keeping the
2757
- // non-null access here removes the per-request schema-presence branch from realistic GETs.
2758
- const validation = entry.schema.query["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
2759
- if (validation instanceof Promise) {
2760
- return validation.then((result) => {
2761
- try {
2762
- return this.runQueryAndLifecycleResult(entry, ctx, finalize, wrapResponse, result);
2763
- }
2764
- catch (err) {
2765
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2766
- }
2767
- }, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2768
- }
2769
- return this.runQueryAndLifecycleResult(entry, ctx, finalize, wrapResponse, validation);
2770
- }
2771
- catch (err) {
2772
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2773
- }
2774
- }
2775
- /** Registration-specialized body lifecycle: bounded body validation (including recovery) → hooks. */
2776
- // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2777
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2778
- runLifecycleBody(entry, source, ctx, finalize, wrapResponse) {
2779
- return this.readAndValidateBody(source, entry, ctx).then((bodyError) => bodyError === undefined
2780
- ? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
2781
- : wrapResponse(bodyError), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2782
- }
2783
- /** Registration-specialized body + query lifecycle: bounded body validation → query validation → hooks. */
2784
- // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2785
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2786
- runLifecycleBodyQuery(entry, source, ctx, finalize, wrapResponse) {
2787
- return this.readAndValidateBody(source, entry, ctx).then((bodyError) => bodyError === undefined
2788
- ? this.runLifecycleQuery(entry, ctx, finalize, wrapResponse)
2789
- : wrapResponse(bodyError), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2790
- }
2791
- /** Registration-specialized no-body lifecycle: query validation (if present) → derives/hooks. */
2792
- runQueryAndLifecycle(entry, ctx, finalize, wrapResponse) {
2793
- const querySchema = entry.schema?.query;
2794
- return querySchema === undefined
2795
- ? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
2796
- : this.runLifecycleQuery(entry, ctx, finalize, wrapResponse);
2797
- }
2798
- runQueryAndLifecycleResult(entry, ctx, finalize, wrapResponse, result) {
2799
- const queryError = this.applyLifecycleValidation(entry, result, ctx, "query");
2800
- if (queryError instanceof Promise) {
2801
- return queryError.then((error) => error === undefined
2802
- ? this.runLifecycleHooks(entry, ctx, finalize, wrapResponse)
2803
- : wrapResponse(error), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2804
- }
2805
- if (queryError !== undefined)
2806
- return wrapResponse(queryError);
2807
- return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse);
2808
- }
2809
- runLifecycleAfterParamsResult(entry, source, ctx, finalize, wrapResponse, result) {
2810
- try {
2811
- return this.runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, this.applyLifecycleValidation(entry, result, ctx, "params"));
2812
- }
2813
- catch (err) {
2814
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2815
- }
2816
- }
2817
- runLifecycleAfterParams(entry, source, ctx, finalize, wrapResponse, paramsError) {
2818
- if (paramsError instanceof Promise) {
2819
- return paramsError.then((error) => this.runLifecycleAfterParamsSettled(entry, source, ctx, finalize, wrapResponse, error), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2820
- }
2821
- return this.runLifecycleAfterParamsSettled(entry, source, ctx, finalize, wrapResponse, paramsError);
2822
- }
2823
- runLifecycleAfterParamsSettled(entry, source, ctx, finalize, wrapResponse, paramsError) {
2824
- if (paramsError !== undefined)
2825
- return wrapResponse(paramsError);
2826
- if (entry.schema?.body !== undefined) {
2827
- return this.readAndValidateBody(source, entry, ctx).then((bodyError) => this.runLifecycleAfterBody(entry, ctx, finalize, wrapResponse, bodyError), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2828
- }
2829
- return this.runLifecycleAfterBody(entry, ctx, finalize, wrapResponse, undefined);
2830
- }
2831
- runLifecycleAfterBody(entry, ctx, finalize, wrapResponse, bodyError) {
2832
- if (bodyError !== undefined)
2833
- return wrapResponse(bodyError);
2834
- const querySchema = entry.schema?.query;
2835
- if (querySchema === undefined)
2836
- return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse);
2837
- const validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
2838
- if (validation instanceof Promise) {
2839
- return validation.then((result) => this.runLifecycleAfterQueryResult(entry, ctx, finalize, wrapResponse, result), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2840
- }
2841
- return this.runLifecycleAfterQueryResult(entry, ctx, finalize, wrapResponse, validation);
2842
- }
2843
- runLifecycleAfterQueryResult(entry, ctx, finalize, wrapResponse, result) {
2844
- try {
2845
- const queryError = this.applyLifecycleValidation(entry, result, ctx, "query");
2846
- if (queryError instanceof Promise) {
2847
- return queryError.then((error) => this.runLifecycleAfterQuery(entry, ctx, finalize, wrapResponse, error), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2848
- }
2849
- return this.runLifecycleAfterQuery(entry, ctx, finalize, wrapResponse, queryError);
2850
- }
2851
- catch (err) {
2852
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2853
- }
2854
- }
2855
- runLifecycleAfterQuery(entry, ctx, finalize, wrapResponse, queryError) {
2856
- if (queryError !== undefined)
2857
- return wrapResponse(queryError);
2858
- return this.runLifecycleHooks(entry, ctx, finalize, wrapResponse);
2859
- }
2860
- runLifecycleHooks(entry, ctx, finalize, wrapResponse) {
2861
- if (entry.lifecycleHookLane === "derive-before") {
2862
- return this.runLifecycleDeriveBefore(entry, ctx, finalize, wrapResponse);
2863
- }
2864
- try {
2865
- if (entry.hasDecorations)
2866
- Object.assign(ctx, entry.decorations);
2867
- for (let i = 0; i < entry.derives.length; i++) {
2868
- const extension = entry.derives[i](ctx);
2869
- if (extension instanceof Promise) {
2870
- return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, i, extension);
2871
- }
2872
- Object.assign(ctx, extension);
2873
- }
2874
- for (let i = 0; i < entry.beforeHandle.length; i++) {
2875
- const outcome = entry.beforeHandle[i](ctx);
2876
- if (outcome instanceof Promise) {
2877
- return this.continueLifecycleAfterBefore(entry, ctx, finalize, wrapResponse, i, outcome);
2878
- }
2879
- if (outcome !== undefined)
2880
- return finalize(outcome, responseSet(ctx));
2881
- }
2882
- const result = entry.handler(ctx);
2883
- if (result instanceof Promise) {
2884
- return result.then((value) => this.finishLifecycleResult(entry, ctx, finalize, wrapResponse, value), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2885
- }
2886
- return this.finishLifecycleResult(entry, ctx, finalize, wrapResponse, result);
2887
- }
2888
- catch (err) {
2889
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2890
- }
2891
- }
2892
- /** Registration-specialized derive → before → handler lifecycle. */
2893
- runLifecycleDeriveBefore(entry, ctx, finalize, wrapResponse) {
2894
- try {
2895
- const extension = entry.derives[0](ctx);
2896
- if (extension instanceof Promise) {
2897
- return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, 0, extension);
2898
- }
2899
- Object.assign(ctx, extension);
2900
- const outcome = entry.beforeHandle[0](ctx);
2901
- if (outcome instanceof Promise) {
2902
- return this.continueLifecycleAfterBefore(entry, ctx, finalize, wrapResponse, 0, outcome);
2903
- }
2904
- if (outcome !== undefined)
2905
- return this.finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, outcome);
2906
- const result = entry.handler(ctx);
2907
- if (result instanceof Promise) {
2908
- return result.then((value) => this.finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, value), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2909
- }
2910
- return this.finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, result);
2911
- }
2912
- catch (err) {
2913
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2914
- }
2915
- }
2916
- /** Finalize the specialized lane without rechecking its registration-proven invariants. */
2917
- finishSimpleLifecycleResult(entry, ctx, finalize, wrapResponse, result) {
2918
- try {
2919
- return finalize(result, responseSet(ctx));
2920
- }
2921
- catch (err) {
2922
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2923
- }
2924
- }
2925
- async continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, firstIndex, first) {
2926
- try {
2927
- Object.assign(ctx, await first);
2928
- for (let i = firstIndex + 1; i < entry.derives.length; i++) {
2929
- const extension = entry.derives[i](ctx);
2930
- Object.assign(ctx, extension instanceof Promise ? await extension : extension);
2931
- }
2932
- return await this.runLifecycleHooksAsync(entry, ctx, finalize, wrapResponse);
2933
- }
2934
- catch (err) {
2935
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2936
- }
2937
- }
2938
- async runLifecycleHooksAsync(entry, ctx, finalize, wrapResponse) {
2939
- try {
2940
- // Indexed, not `for...of`. An array iterator declared inside an async function stays live
2941
- // across the `await` below, so neither engine can sink it - unlike the sync lanes above,
2942
- // where V8 elides it. Measured cost of the iterator form: 43ns per hook on Bun, ~9ns on
2943
- // Node, on a chain whose hooks return synchronously (the common shape).
2944
- for (let i = 0; i < entry.beforeHandle.length; i++) {
2945
- const outcome = entry.beforeHandle[i](ctx);
2946
- const early = outcome instanceof Promise ? await outcome : outcome;
2947
- if (early !== undefined)
2948
- return finalize(early, responseSet(ctx));
2949
- }
2950
- return await this.runLifecycleHandlerAsync(entry, ctx, finalize, wrapResponse);
2951
- }
2952
- catch (err) {
2953
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2954
- }
2955
- }
2956
- async continueLifecycleAfterBefore(entry, ctx, finalize, wrapResponse, firstIndex, first) {
2957
- try {
2958
- const firstOutcome = await first;
2959
- if (firstOutcome !== undefined)
2960
- return finalize(firstOutcome, responseSet(ctx));
2961
- for (let i = firstIndex + 1; i < entry.beforeHandle.length; i++) {
2962
- const outcome = entry.beforeHandle[i](ctx);
2963
- const early = outcome instanceof Promise ? await outcome : outcome;
2964
- if (early !== undefined)
2965
- return finalize(early, responseSet(ctx));
2966
- }
2967
- return await this.runLifecycleHandlerAsync(entry, ctx, finalize, wrapResponse);
2968
- }
2969
- catch (err) {
2970
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2971
- }
2972
- }
2973
- async runLifecycleHandlerAsync(entry, ctx, finalize, wrapResponse) {
2974
- try {
2975
- const output = entry.handler(ctx);
2976
- const result = output instanceof Promise ? await output : output;
2977
- return await this.finishLifecycleResult(entry, ctx, finalize, wrapResponse, result);
2978
- }
2979
- catch (err) {
2980
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2981
- }
2982
- }
2983
- finishLifecycleResult(entry, ctx, finalize, wrapResponse, initial) {
2984
- try {
2985
- let result = initial;
2986
- for (let i = 0; i < entry.afterHandle.length; i++) {
2987
- const transformed = entry.afterHandle[i](result, ctx);
2988
- if (transformed instanceof Promise) {
2989
- return this.continueLifecycleAfterHandle(entry, ctx, finalize, wrapResponse, i, transformed);
2990
- }
2991
- result = transformed;
2992
- }
2993
- return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, result);
2994
- }
2995
- catch (err) {
2996
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
2997
- }
2998
- }
2999
- async continueLifecycleAfterHandle(entry, ctx, finalize, wrapResponse, firstIndex, first) {
3000
- try {
3001
- let result = await first;
3002
- for (let i = firstIndex + 1; i < entry.afterHandle.length; i++) {
3003
- const transformed = entry.afterHandle[i](result, ctx);
3004
- result = transformed instanceof Promise ? await transformed : transformed;
3005
- }
3006
- return await this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, result);
3007
- }
3008
- catch (err) {
3009
- return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
3010
- }
3011
- }
3012
2879
  finishLifecycleContract(entry, ctx, finalize, wrapResponse, result) {
3013
2880
  try {
3014
2881
  const contract = entry.responseContract;
@@ -3031,7 +2898,7 @@ export class Server {
3031
2898
  path: pathnameOf(ctx.req.url),
3032
2899
  detail: outcome.message,
3033
2900
  });
3034
- return wrapResponse(jsonError(500, "internal_error"));
2901
+ return wrapResponse(plainError(500, "internal_error"));
3035
2902
  }
3036
2903
  if (outcome.kind === "warn") {
3037
2904
  this.logger.warn("response contract", {
@@ -3042,14 +2909,33 @@ export class Server {
3042
2909
  }
3043
2910
  return finalize(outcome.value, responseSet(ctx));
3044
2911
  }
3045
- async handleLifecycleError(entry, err, ctx, finalize, wrapResponse) {
2912
+ /** Synchronous by design, and `MaybePromise` rather than `Promise` for the same reason: control flow
2913
+ * out of a lifecycle stage must not cost a promise. An `async` method allocates one and defers the
2914
+ * result a microtask even when every branch it takes returns immediately, which is the common case
2915
+ * here - a thrown `status(...)`/`Response`, or a route with no `onError` hook at all. Only the hook
2916
+ * loop, which may await, drops into {@link runErrorHooks}. */
2917
+ handleLifecycleError(entry, err, ctx, finalize, wrapResponse) {
3046
2918
  // A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
3047
2919
  // an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
3048
2920
  // or log it as a 500. This is what makes `throw redirect(...)` / `requireSession(...)` work from
3049
2921
  // any handler or loader.
3050
2922
  if (err instanceof Response)
3051
2923
  return wrapResponse(err);
3052
- // onError hooks may return a custom response; otherwise the default 500 stands.
2924
+ // Same rule for a thrown `status(...)`, but through `finalize` rather than `wrapResponse`: the
2925
+ // value is still plain data, so the ordinary JSON lane renders it and no `Response` is built.
2926
+ if (isResponseResult(err))
2927
+ return finalize(err, responseSet(ctx));
2928
+ if (entry.onError.length === 0) {
2929
+ // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
2930
+ // (redacting) logger. Body-read failures and around-hook failures land here too.
2931
+ this.logRequestError(err, ctx);
2932
+ return wrapResponse(plainError(500, "internal_error"));
2933
+ }
2934
+ return this.runErrorHooks(entry, err, ctx, finalize, wrapResponse);
2935
+ }
2936
+ /** The `onError` chain, split off {@link handleLifecycleError} so its `await` costs only the routes
2937
+ * that registered a hook. A hook may return a custom response; otherwise the default 500 stands. */
2938
+ async runErrorHooks(entry, err, ctx, finalize, wrapResponse) {
3053
2939
  // Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
3054
2940
  // the `await` and stay heap-allocated on both engines.
3055
2941
  for (let i = 0; i < entry.onError.length; i++) {
@@ -3058,53 +2944,26 @@ export class Server {
3058
2944
  if (handled !== undefined)
3059
2945
  return finalize(handled, responseSet(ctx));
3060
2946
  }
3061
- // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
3062
- // (redacting) logger. Body-read failures and around-hook failures land here too.
3063
2947
  this.logRequestError(err, ctx);
3064
- return wrapResponse(jsonError(500, "internal_error"));
2948
+ return wrapResponse(plainError(500, "internal_error"));
3065
2949
  }
3066
- /** Log an unhandled request error to the (redacting) logger - shared by {@link runLifecycle} and the
2950
+ /** Log an unhandled request error to the (redacting) logger - shared by the compiled route program and the
3067
2951
  * bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
3068
2952
  logRequestError(err, ctx) {
3069
- // An error's own text can quote the input that produced it, so how much of it reaches the sink is
3070
- // the app's call (`errorLogDetail`). The default keeps it: a 500 with no message and no stack is
3071
- // an incident nobody can diagnose, and the framework already ships the narrower instrument for
3072
- // the leak - a redacting logger with `valuePatterns`. `"none"` is there for an untrusted sink.
3073
- const detail = this.errorLogDetail;
3074
- this.logger.error("unhandled request error", {
3075
- method: ctx.req.method,
3076
- path: pathnameOf(ctx.req.url),
3077
- name: err instanceof Error ? err.name : "Error",
3078
- // `detail`, not `message`: the logger uses `message` for its own first argument, so a field of
3079
- // that name is silently overwritten and the thrown error's own text never reaches the sink. It
3080
- // survived only incidentally inside `stack`, and was lost outright for a non-Error throw.
3081
- ...(detail === "none" ? {} : { detail: err instanceof Error ? err.message : String(err) }),
3082
- ...(detail === "full" && err instanceof Error ? { stack: err.stack } : {}),
3083
- });
3084
- }
3085
- async readAndValidateBody(req, entry, ctx) {
3086
- const contentType = headerOf(req, "content-type") ?? "";
3087
- let parsed;
3088
- if (contentType === "application/json" || contentType.includes("application/json")) {
3089
- const json = await this.readBoundedJson(req, entry.bodyLimit ?? this.maxBodyBytes);
3090
- if (json instanceof Response)
3091
- return json;
3092
- parsed = json;
3093
- }
3094
- else if (isUrlEncodedForm(contentType)) {
3095
- const form = await readBoundedForm(req, entry.bodyLimit ?? this.maxBodyBytes);
3096
- if (form instanceof Response)
3097
- return form;
3098
- parsed = form;
3099
- }
3100
- else {
3101
- // multipart/form-data (file uploads) stays 415 on the schema path by design - files don't
3102
- // fit a value schema; use a schema-less route + @nifrajs/uploads helpers for those.
3103
- return jsonError(415, "unsupported_media_type");
3104
- }
3105
- const validation = entry.schema.body["~standard"].validate(parsed);
3106
- const result = validation instanceof Promise ? await validation : validation;
3107
- return this.applyLifecycleValidation(entry, result, ctx, "body");
2953
+ emitRequestErrorLog(this.logger, this.errorLogDetail, err, ctx);
2954
+ }
2955
+ readAndValidateBody(req, entry, ctx, bodySchema = entry.schema?.body, bodyLimit = entry.bodyLimit) {
2956
+ // Keep the generic lifecycle body stage on the same framed parser/continuation as the body-only
2957
+ // lane. This avoids an async wrapper that first awaits parsing and then starts validation in a
2958
+ // second continuation, while retaining the exact bounded-read, content-type, and poisoning
2959
+ // contracts shared by every body route.
2960
+ const validate = (parsed) => {
2961
+ const validation = bodySchema["~standard"].validate(parsed);
2962
+ return validation instanceof Promise
2963
+ ? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, "body"))
2964
+ : this.applyLifecycleValidation(entry, validation, ctx, "body");
2965
+ };
2966
+ return readBodyFramed(req, bodyLimit ?? UNLIMITED_BODY_BYTES, this.protoPoisoning, validate, (response) => response, (error) => Promise.reject(error));
3108
2967
  }
3109
2968
  /** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
3110
2969
  * before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
@@ -3125,7 +2984,7 @@ export class Server {
3125
2984
  }
3126
2985
  const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError;
3127
2986
  if (hook === undefined)
3128
- return validationError(result.issues);
2987
+ return plainValidationError(result.issues);
3129
2988
  const attempted = hook(result.issues, ctx, kind);
3130
2989
  if (attempted instanceof Promise) {
3131
2990
  return attempted.then((recovery) => this.finishLifecycleValidationRecovery(entry, kind, result.issues, recovery, assign));
@@ -3134,7 +2993,7 @@ export class Server {
3134
2993
  }
3135
2994
  finishLifecycleValidationRecovery(entry, kind, issues, recovery, assign) {
3136
2995
  if (recovery === undefined)
3137
- return validationError(issues);
2996
+ return plainValidationError(issues);
3138
2997
  if (recovery instanceof Response)
3139
2998
  return recovery;
3140
2999
  const schema = kind === "body"
@@ -3148,23 +3007,16 @@ export class Server {
3148
3007
  if (retried instanceof Promise) {
3149
3008
  return retried.then((settled) => {
3150
3009
  if (settled.issues !== undefined)
3151
- return validationError(settled.issues);
3010
+ return plainValidationError(settled.issues);
3152
3011
  assign(settled.value);
3153
3012
  return undefined;
3154
3013
  });
3155
3014
  }
3156
3015
  if (retried.issues !== undefined)
3157
- return validationError(retried.issues);
3016
+ return plainValidationError(retried.issues);
3158
3017
  assign(retried.value);
3159
3018
  return undefined;
3160
3019
  }
3161
- readBoundedJson(req, maxBodyBytes = this.maxBodyBytes, onResult, onError) {
3162
- // Not `async`: this is a pass-through, and wrapping the lane's own promise in a coroutine
3163
- // frame costs an extra microtask hop on every JSON body.
3164
- return onResult === undefined
3165
- ? readBoundedJsonSource(req, maxBodyBytes, this.protoPoisoning)
3166
- : readBoundedJsonSource(req, maxBodyBytes, this.protoPoisoning, onResult, onError);
3167
- }
3168
3020
  /** Adapt one route's compiled execution plan to Bun's already-matched request shape. Route
3169
3021
  * semantics remain in the plan; this closure only supplies native params and deadline fallback. */
3170
3022
  compileBunNativeHandler(entry, paramNames, fused, signal, budget) {
@@ -3328,9 +3180,12 @@ export class Server {
3328
3180
  // matched route's handler, set by `server.upgrade`.
3329
3181
  // With WS routes, the dispatcher comes from the installed `.use(websocket())` runtime - non-null
3330
3182
  // because wsRouteCount > 0 means ws() ran, and ws() requires the runtime at registration.
3183
+ // Native pub/sub when the app has WS routes and none validate outbound frames: `ws.subscribe` and
3184
+ // `app.publish` go through Bun's own (uWebSockets) broadcast instead of the JS registry loop.
3185
+ const nativePubsub = this.wsRouteCount > 0 && !this.wsHasValidatedSend;
3331
3186
  const wsHandlers = this.wsRouteCount === 0
3332
3187
  ? undefined
3333
- : this.wsRuntime.bunHandlers(this.topics);
3188
+ : this.wsRuntime.bunHandlers(this.topics, nativePubsub);
3334
3189
  const reusePort = options?.reusePort === true;
3335
3190
  // Spread rather than pass `hostname: undefined` - Bun treats an explicit undefined as a value
3336
3191
  // on some option paths, and omitting is what selects its 0.0.0.0 default.
@@ -3366,6 +3221,14 @@ export class Server {
3366
3221
  },
3367
3222
  }));
3368
3223
  this.bunServer = running;
3224
+ // Bind `app.publish` to Bun's native broadcast now that the server handle exists. Guarded on the
3225
+ // method's presence so a runtime whose handle lacks it simply keeps the registry path.
3226
+ if (nativePubsub && typeof running.publish === "function") {
3227
+ const native = running.publish.bind(running);
3228
+ this.nativePublish = (topic, data) => {
3229
+ native(topic, data);
3230
+ };
3231
+ }
3369
3232
  this.sealed = true;
3370
3233
  if (this.gracefulSignals)
3371
3234
  this.installSignalHandlers();