@nifrajs/core 2.13.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +4 -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/binary.d.ts +32 -0
  7. package/dist/binary.d.ts.map +1 -1
  8. package/dist/binary.js +17 -0
  9. package/dist/binary.js.map +1 -1
  10. package/dist/durable-adapters.d.ts.map +1 -1
  11. package/dist/durable-adapters.js +21 -50
  12. package/dist/durable-adapters.js.map +1 -1
  13. package/dist/durable-execution.d.ts.map +1 -1
  14. package/dist/durable-execution.js +49 -69
  15. package/dist/durable-execution.js.map +1 -1
  16. package/dist/edge-kit.d.ts +27 -0
  17. package/dist/edge-kit.d.ts.map +1 -0
  18. package/dist/edge-kit.js +24 -0
  19. package/dist/edge-kit.js.map +1 -0
  20. package/dist/errors.d.ts +1 -1
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/internal/durable-approval-protocol.d.ts +13 -0
  28. package/dist/internal/durable-approval-protocol.d.ts.map +1 -0
  29. package/dist/internal/durable-approval-protocol.js +66 -0
  30. package/dist/internal/durable-approval-protocol.js.map +1 -0
  31. package/dist/internal/durable-effect-protocol.d.ts +14 -0
  32. package/dist/internal/durable-effect-protocol.d.ts.map +1 -0
  33. package/dist/internal/durable-effect-protocol.js +16 -0
  34. package/dist/internal/durable-effect-protocol.js.map +1 -0
  35. package/dist/internal/durable-saga-protocol.d.ts +8 -0
  36. package/dist/internal/durable-saga-protocol.d.ts.map +1 -0
  37. package/dist/internal/durable-saga-protocol.js +21 -0
  38. package/dist/internal/durable-saga-protocol.js.map +1 -0
  39. package/dist/internal/route-catalog.d.ts +3 -0
  40. package/dist/internal/route-catalog.d.ts.map +1 -1
  41. package/dist/internal/route-catalog.js +5 -0
  42. package/dist/internal/route-catalog.js.map +1 -1
  43. package/dist/internal/route-execution.d.ts +11 -5
  44. package/dist/internal/route-execution.d.ts.map +1 -1
  45. package/dist/internal/route-execution.js +2 -1
  46. package/dist/internal/route-execution.js.map +1 -1
  47. package/dist/response-observer.d.ts +25 -0
  48. package/dist/response-observer.d.ts.map +1 -0
  49. package/dist/response-observer.js +40 -0
  50. package/dist/response-observer.js.map +1 -0
  51. package/dist/security/baseline.d.ts +68 -0
  52. package/dist/security/baseline.d.ts.map +1 -0
  53. package/dist/security/baseline.js +118 -0
  54. package/dist/security/baseline.js.map +1 -0
  55. package/dist/server/bare-error-lane.d.ts +26 -0
  56. package/dist/server/bare-error-lane.d.ts.map +1 -0
  57. package/dist/server/bare-error-lane.js +60 -0
  58. package/dist/server/bare-error-lane.js.map +1 -0
  59. package/dist/server/body.d.ts +7 -0
  60. package/dist/server/body.d.ts.map +1 -1
  61. package/dist/server/body.js +7 -0
  62. package/dist/server/body.js.map +1 -1
  63. package/dist/server/bridge-markers.d.ts +25 -0
  64. package/dist/server/bridge-markers.d.ts.map +1 -0
  65. package/dist/server/bridge-markers.js +25 -0
  66. package/dist/server/bridge-markers.js.map +1 -0
  67. package/dist/server/context.d.ts +6 -1
  68. package/dist/server/context.d.ts.map +1 -1
  69. package/dist/server/header-case.d.ts.map +1 -1
  70. package/dist/server/header-case.js +2 -1
  71. package/dist/server/header-case.js.map +1 -1
  72. package/dist/server/http.d.ts +36 -3
  73. package/dist/server/http.d.ts.map +1 -1
  74. package/dist/server/http.js +49 -3
  75. package/dist/server/http.js.map +1 -1
  76. package/dist/server/install.d.ts +6 -8
  77. package/dist/server/install.d.ts.map +1 -1
  78. package/dist/server/install.js +7 -0
  79. package/dist/server/install.js.map +1 -1
  80. package/dist/server/node-outcome-hook.d.ts +2 -1
  81. package/dist/server/node-outcome-hook.d.ts.map +1 -1
  82. package/dist/server/node-outcome-hook.js.map +1 -1
  83. package/dist/server/node-outcome.d.ts +17 -2
  84. package/dist/server/node-outcome.d.ts.map +1 -1
  85. package/dist/server/node-outcome.js +37 -3
  86. package/dist/server/node-outcome.js.map +1 -1
  87. package/dist/server/plugin.d.ts +1 -1
  88. package/dist/server/plugin.d.ts.map +1 -1
  89. package/dist/server/query.d.ts +7 -1
  90. package/dist/server/query.d.ts.map +1 -1
  91. package/dist/server/query.js +8 -3
  92. package/dist/server/query.js.map +1 -1
  93. package/dist/server/registry.d.ts +11 -2
  94. package/dist/server/registry.d.ts.map +1 -1
  95. package/dist/server/request-context.d.ts +17 -4
  96. package/dist/server/request-context.d.ts.map +1 -1
  97. package/dist/server/request-context.js +82 -45
  98. package/dist/server/request-context.js.map +1 -1
  99. package/dist/server/respond.d.ts.map +1 -1
  100. package/dist/server/respond.js +19 -2
  101. package/dist/server/respond.js.map +1 -1
  102. package/dist/server/response-contract-lane.d.ts.map +1 -1
  103. package/dist/server/response-contract-lane.js +5 -2
  104. package/dist/server/response-contract-lane.js.map +1 -1
  105. package/dist/server/response-observer-runtime.d.ts +20 -0
  106. package/dist/server/response-observer-runtime.d.ts.map +1 -0
  107. package/dist/server/response-observer-runtime.js +95 -0
  108. package/dist/server/response-observer-runtime.js.map +1 -0
  109. package/dist/server/runtime-core.d.ts +60 -0
  110. package/dist/server/runtime-core.d.ts.map +1 -1
  111. package/dist/server/runtime-core.js +65 -1
  112. package/dist/server/runtime-core.js.map +1 -1
  113. package/dist/server/server-types.d.ts +18 -1
  114. package/dist/server/server-types.d.ts.map +1 -1
  115. package/dist/server/server.d.ts +89 -48
  116. package/dist/server/server.d.ts.map +1 -1
  117. package/dist/server/server.js +397 -276
  118. package/dist/server/server.js.map +1 -1
  119. package/dist/server/websocket.d.ts +20 -4
  120. package/dist/server/websocket.d.ts.map +1 -1
  121. package/dist/server/websocket.js +81 -11
  122. package/dist/server/websocket.js.map +1 -1
  123. package/dist/server/ws-bun.d.ts +1 -1
  124. package/dist/server/ws-bun.d.ts.map +1 -1
  125. package/dist/server/ws-bun.js +12 -8
  126. package/dist/server/ws-bun.js.map +1 -1
  127. package/dist/server.d.ts +2 -1
  128. package/dist/server.d.ts.map +1 -1
  129. package/dist/server.js +2 -1
  130. package/dist/server.js.map +1 -1
  131. package/dist/single-copy-register.d.ts +2 -0
  132. package/dist/single-copy-register.d.ts.map +1 -0
  133. package/dist/single-copy-register.js +24 -0
  134. package/dist/single-copy-register.js.map +1 -0
  135. package/dist/single-copy.d.ts +156 -0
  136. package/dist/single-copy.d.ts.map +1 -0
  137. package/dist/single-copy.js +470 -0
  138. package/dist/single-copy.js.map +1 -0
  139. package/package.json +24 -2
  140. package/src/assurance.ts +58 -1
  141. package/src/binary.ts +35 -0
  142. package/src/durable-adapters.ts +26 -51
  143. package/src/durable-execution.ts +46 -88
  144. package/src/edge-kit.ts +27 -0
  145. package/src/errors.ts +1 -0
  146. package/src/index.ts +1 -1
  147. package/src/internal/durable-approval-protocol.ts +92 -0
  148. package/src/internal/durable-effect-protocol.ts +37 -0
  149. package/src/internal/durable-saga-protocol.ts +55 -0
  150. package/src/internal/route-catalog.ts +6 -0
  151. package/src/internal/route-execution.ts +23 -16
  152. package/src/response-observer.ts +64 -0
  153. package/src/security/baseline.ts +172 -0
  154. package/src/server/bare-error-lane.ts +76 -0
  155. package/src/server/body.ts +8 -0
  156. package/src/server/bridge-markers.ts +24 -0
  157. package/src/server/context.ts +6 -1
  158. package/src/server/header-case.ts +3 -1
  159. package/src/server/http.ts +53 -3
  160. package/src/server/install.ts +14 -0
  161. package/src/server/node-outcome-hook.ts +2 -1
  162. package/src/server/node-outcome.ts +44 -3
  163. package/src/server/plugin.ts +1 -1
  164. package/src/server/query.ts +10 -4
  165. package/src/server/registry.ts +19 -4
  166. package/src/server/request-context.ts +106 -51
  167. package/src/server/respond.ts +26 -1
  168. package/src/server/response-contract-lane.ts +6 -2
  169. package/src/server/response-observer-runtime.ts +135 -0
  170. package/src/server/runtime-core.ts +98 -1
  171. package/src/server/server-types.ts +18 -1
  172. package/src/server/server.ts +607 -376
  173. package/src/server/websocket.ts +127 -17
  174. package/src/server/ws-bun.ts +26 -7
  175. package/src/server.ts +2 -1
  176. package/src/single-copy-register.ts +24 -0
  177. package/src/single-copy.ts +524 -0
@@ -7,107 +7,19 @@ import { compileRouteExecutionPlan, } from "../internal/route-execution.js";
7
7
  import { isSameOriginRequest } from "../internal/same-origin.js";
8
8
  import { compileRoutePattern, decodeRouteParams } from "../router/pattern.js";
9
9
  import { EMPTY_PARAMS, Router } from "../router/router.js";
10
- import { assertByteLimit, markTransportCap, markTrustedBodyFraming, } from "./body.js";
10
+ import { emitRequestErrorLog, renderBareError } from "./bare-error-lane.js";
11
+ import { assertByteLimit, markTransportCap, markTrustedBodyFraming, UNLIMITED_BODY_BYTES, } from "./body.js";
11
12
  import { resolveClientIp } from "./client-ip.js";
12
13
  import { hasLowercaseHeaderKeysMark, headerKeysAllLowercase, markLowercaseHeaderKeys, } from "./header-case.js";
13
14
  import { headerObjectOf } from "./headers.js";
14
- import { jsonError, pathnameOf, urlPartsOf } from "./http.js";
15
+ import { jsonError, pathnameOf, plainError, urlPartsOf } from "./http.js";
15
16
  import { withStaticNodeHeaders } from "./node-outcome.js";
16
- import { recordHeadersView, } from "./node-outcome-hook.js";
17
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";
18
+ import { RequestContext, readBodyFramed, readBoundedJsonSource } from "./request-context.js";
19
+ import { applyStaticResponseHeaders, buildStaticResponseHeaders, fusedRespond, fusedRespondNoSet, toResponse, } from "./respond.js";
20
+ import { CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, isResponseResult, requestOf, status, } from "./runtime-core.js";
22
21
  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";
22
+ 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
23
  import { jsonLogger } from "./logger.js";
112
24
  const functionToString = Function.prototype.toString;
113
25
  const CONTEXTLESS_ARROW = /^(?:async\s*)?\(\s*\)\s*(?::[\s\S]*?)?=>/;
@@ -162,12 +74,18 @@ const STOP_HOOK_TIMEOUT_MS = 5_000;
162
74
  * owner, shared with the server-function mount in `@nifrajs/web` - the two used to answer differently
163
75
  * for the same request, so a browser that could open a socket was told its POST was cross-origin. */
164
76
  const wsSameOrigin = isSameOriginRequest;
165
- function validationError(issues) {
77
+ function validationIssues(issues) {
166
78
  const serialized = issues.map((issue) => {
167
79
  const path = issue.path?.map((seg) => String(typeof seg === "object" ? seg.key : seg));
168
80
  return path !== undefined ? { message: issue.message, path } : { message: issue.message };
169
81
  });
170
- return Response.json({ ok: false, error: "validation", issues: serialized }, { status: 422 });
82
+ return { ok: false, error: "validation", issues: serialized };
83
+ }
84
+ /** The 422 as plain data - the shape every lane's response wrapper takes, so a rejected body costs
85
+ * what an accepted one costs: on Node it is written straight to the socket with a `content-length`
86
+ * instead of being built as a `Response` and drained back out. */
87
+ export function plainValidationError(issues) {
88
+ return status(422, validationIssues(issues));
171
89
  }
172
90
  // `jsonError`, `urlPartsOf`, `pathnameOf` moved to `./http.ts` (a dependency-free leaf shared with the
173
91
  // opt-in request lanes); re-exported so existing importers keep resolving from here.
@@ -210,6 +128,20 @@ function stripMountPrefix(request, prefix) {
210
128
  function responseSet(ctx) {
211
129
  return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS;
212
130
  }
131
+ /**
132
+ * A `derive` that answered with a response rather than a context extension - `status(...)`, or a bare
133
+ * `Response`. The request ends there, on the same lane a `beforeHandle`'s early return takes.
134
+ *
135
+ * `derive` is the one stage that could not exit early: `beforeHandle` says "done" by returning a
136
+ * value, but a derive's return value IS the extension, so the only way out was `throw`ing a
137
+ * `Response` - which measured as the single most expensive way to answer (the `Response` object
138
+ * costs ~40% of the rejection lane; see `PlainRender`). Neither shape had a meaning here before:
139
+ * `Object.assign(ctx, response)` copies nothing (a `Response`'s properties live on its prototype),
140
+ * so a derive that returned one silently fell through to the handler it meant to prevent.
141
+ */
142
+ function isDeriveEarlyExit(extension) {
143
+ return isResponseResult(extension) || extension instanceof Response;
144
+ }
213
145
  function isContextlessNoArgArrow(handler) {
214
146
  if (handler.length !== 0)
215
147
  return false;
@@ -220,9 +152,92 @@ function isContextlessNoArgArrow(handler) {
220
152
  return false;
221
153
  }
222
154
  }
155
+ /**
156
+ * The Web lanes' `wrapResponse` input, as a `Response`. A plain-data carrier ({@link plainError},
157
+ * `status(...)`) is rendered on the ordinary JSON lane rather than through its own `toResponse`, so
158
+ * it picks up the same prebuilt init a handler's plain return uses. `EMPTY_RESPONSE_CONTROLS` because
159
+ * these renders happen where no request context exists - before routing, or after it was abandoned.
160
+ */
161
+ const webResponseOf = (result) => result instanceof Response ? result : toResponse(result, EMPTY_RESPONSE_CONTROLS);
223
162
  // Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
224
- const IDENTITY_RESPONSE = (response) => response;
163
+ const IDENTITY_RESPONSE = (response) => webResponseOf(response);
225
164
  const RESPONSE_TIMEOUT = () => jsonError(503, "request_timeout");
165
+ // The unused-order-scoped-hook audit (recorder, seal check, stack capture, message) is a dev-time
166
+ // diagnostic. Every guard below keeps the INLINE literal `process.env.NODE_ENV !== "production"` at the
167
+ // branch - never a shared const holding the folded boolean - because a bundler's `process.env.NODE_ENV`
168
+ // define only dead-code-eliminates the audit when the literal appears at the `if`; Bun does not
169
+ // propagate a module const's value into the branch. Every real production bundler (vite/esbuild/webpack/
170
+ // next, `bun build --production`) injects that define, turning each guard into `hookAuditRuntime &&
171
+ // false`, which folds away - dropping the whole audit and restoring the bare kernel size.
172
+ //
173
+ // `hookAuditRuntime` is the one shared flag: `typeof globalThis.Deno === "undefined"`. Deno ships a
174
+ // node-compat `process`, so `typeof process` does NOT gate it out there, yet reading `process.env`
175
+ // throws `NotCapable` without `--allow-env`. The flag short-circuits before the env read on Deno (audit
176
+ // off) while a production define still folds `<flag> && false` to `false` on every other runtime.
177
+ const hookAuditRuntime = typeof globalThis.Deno === "undefined";
178
+ // This module's own path, captured once from a sentinel stack. Every internal frame of an
179
+ // order-scoped hook push (the public method, its `use()`-bundle dispatcher, the recorder) lives here,
180
+ // so `captureCallerSite` skips anything starting with it and lands on the user's calling frame.
181
+ const SERVER_MODULE_PATH = /* @__PURE__ */ (() => {
182
+ const frame = new Error().stack?.split("\n")[1];
183
+ return frame?.match(/\(?([^()\s]+):\d+:\d+\)?\s*$/)?.[1];
184
+ })();
185
+ /** One `file:line:col` frame for the caller of an order-scoped hook method, for the unused-hook
186
+ * report. Only ever called behind the `unusedScopedHooks !== "off"` guard, so the `Error` (the sole
187
+ * non-trivial cost) is never constructed when the check is disabled. */
188
+ const captureCallerSite = () => {
189
+ const stack = new Error().stack;
190
+ if (stack === undefined)
191
+ return undefined;
192
+ for (const line of stack.split("\n").slice(1)) {
193
+ const frame = line.match(/\(?([^()\s]+:\d+:\d+)\)?\s*$/)?.[1];
194
+ if (frame === undefined)
195
+ continue;
196
+ if (SERVER_MODULE_PATH !== undefined && frame.startsWith(`${SERVER_MODULE_PATH}:`))
197
+ continue;
198
+ return frame;
199
+ }
200
+ return undefined;
201
+ };
202
+ const hookAudit = /* @__PURE__ */ new WeakMap();
203
+ /** Start tracking one server's order-scoped hook pushes (once, from the constructor). */
204
+ const beginHookAudit = (key, policy, hasCustomLogger) => {
205
+ hookAudit.set(key, { policy, hasCustomLogger, sites: [], checked: false });
206
+ };
207
+ /** Record one order-scoped hook push: the route count at that moment (so `routesAtPush === total` at
208
+ * seal means no route was declared after it) and the caller's frame. */
209
+ const recordScopedHook = (key, routesAtPush, kind) => {
210
+ const state = hookAudit.get(key);
211
+ if (state === undefined || state.policy === "off")
212
+ return;
213
+ state.sites.push({ kind, routesAtPush, site: captureCallerSite() });
214
+ };
215
+ /** Run the deadness check exactly once. A hook whose recorded route count equals the final route count
216
+ * had no route declared after it - unambiguously dead, since the scoping feature (a hook that covers
217
+ * only later routes) always leaves at least one such route. Zero-route apps are exempt (different
218
+ * mistake, and the warning would be noise). */
219
+ const sealHookAudit = (key, total, logger) => {
220
+ const state = hookAudit.get(key);
221
+ if (state === undefined || state.checked)
222
+ return;
223
+ state.checked = true;
224
+ if (state.policy === "off" || total === 0)
225
+ return;
226
+ const dead = state.sites.filter((hook) => hook.routesAtPush === total);
227
+ if (dead.length === 0)
228
+ return;
229
+ const detail = dead.map((hook) => ` ${hook.kind}() at ${hook.site ?? "<unknown>"}`).join("\n");
230
+ const message = `[nifra] ${dead.length} order-scoped hook(s) apply to no route - they were added after the ` +
231
+ `last route was registered, and register() snapshots the chain into each route as it is ` +
232
+ `declared:\n${detail}\nMove them before the routes they should cover. App-global hooks ` +
233
+ `(onRequest/onResponse) are not order-scoped and can be added at any time.`;
234
+ if (state.policy === "error")
235
+ throw new FrameworkError("UNUSED_SCOPED_HOOKS", message);
236
+ if (state.hasCustomLogger)
237
+ logger.warn(message);
238
+ else
239
+ console.warn(message);
240
+ };
226
241
  /**
227
242
  * The inline server. Routes are chainable and fully type-inferred. `derive`/
228
243
  * `decorate` extend the handler context (`Ctx`) for routes defined *after* them,
@@ -380,6 +395,11 @@ export class Server {
380
395
  this.defaultOnValidationError = options.onValidationError;
381
396
  this.bunServer = undefined;
382
397
  this.sealed = false;
398
+ // Guarded so a production define strips the call, leaving `beginHookAudit`/`hookAudit` unreferenced
399
+ // for the bundler to eliminate; a non-bundled production run skips it the same way.
400
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
401
+ beginHookAudit(this, options.unusedScopedHooks ?? "warn", options.logger !== undefined);
402
+ }
383
403
  this.derives = [];
384
404
  this.decorations = {};
385
405
  this.beforeHandleHooks = [];
@@ -439,16 +459,34 @@ export class Server {
439
459
  this.fetchMounts.sort((a, b) => b.path.length - a.path.length);
440
460
  return this;
441
461
  }
442
- /** Add a per-request, computed context extension for subsequent routes. */
462
+ /**
463
+ * Add a per-request, computed context extension. Order-scoped: it is snapshotted into every route
464
+ * declared *after* this call, and reaches none declared before. One added after the last route
465
+ * (e.g. on the app returned by a route-registering factory) covers nothing - see
466
+ * {@link ServerOptions.unusedScopedHooks}.
467
+ *
468
+ * Returning a `status(...)` (or a `Response`) instead of an extension ends the request there - the
469
+ * guard shape, and the cheap one: it renders as plain data on the same lane a handler's return
470
+ * takes, where `throw new Response(...)` pays ~40% of the rejection lane to build the object. That
471
+ * branch is control flow, so it does not widen the derived context type.
472
+ */
443
473
  derive(fn) {
444
474
  this.assertConfigurable("derive()");
445
475
  this.derives.push(fn);
476
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
477
+ recordScopedHook(this, this.catalog.size, "derive");
446
478
  return this;
447
479
  }
448
- /** Add a static context value for subsequent routes. */
480
+ /**
481
+ * Add a static context value. Order-scoped: captured by every route declared *after* this call, and
482
+ * by none before; one added after the last route reaches nothing
483
+ * (see {@link ServerOptions.unusedScopedHooks}).
484
+ */
449
485
  decorate(key, value) {
450
486
  this.assertConfigurable("decorate()");
451
487
  this.decorations[key] = value;
488
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
489
+ recordScopedHook(this, this.catalog.size, "decorate");
452
490
  return this;
453
491
  }
454
492
  /**
@@ -462,20 +500,31 @@ export class Server {
462
500
  this.nodeRequestHooksComplete = false;
463
501
  return this;
464
502
  }
465
- /** Run after validation, before the handler; a non-`undefined` return short-circuits. Order-scoped. */
503
+ /**
504
+ * Run after validation, before the handler; a non-`undefined` return short-circuits. Order-scoped:
505
+ * it is snapshotted into every route declared *after* this call and covers none declared before, so
506
+ * one added after the last route reaches nothing (see {@link ServerOptions.unusedScopedHooks}).
507
+ */
466
508
  beforeHandle(fn) {
467
509
  this.assertConfigurable("beforeHandle()");
468
510
  this.beforeHandleHooks.push(fn);
511
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
512
+ recordScopedHook(this, this.catalog.size, "beforeHandle");
469
513
  return this;
470
514
  }
471
515
  /**
472
516
  * Wrap the matched route lifecycle for subsequent routes. This is intentionally generic over the
473
517
  * route output, so wrappers like async context storage do not force Node's direct JSON path through
474
518
  * a Web `Response`. The first registered wrapper is outermost.
519
+ *
520
+ * Order-scoped: captured by every route declared *after* this call, none before; one added after the
521
+ * last route reaches nothing (see {@link ServerOptions.unusedScopedHooks}).
475
522
  */
476
523
  around(fn) {
477
524
  this.assertConfigurable("around()");
478
525
  this.aroundHooks.push(fn);
526
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
527
+ recordScopedHook(this, this.catalog.size, "around");
479
528
  return this;
480
529
  }
481
530
  /**
@@ -494,6 +543,8 @@ export class Server {
494
543
  }
495
544
  // JS timers wrap larger delays to ~1ms, which would unexpectedly deny every effect.
496
545
  this.capabilityInterceptors.push(Object.freeze({ interceptor, timeoutMs: Math.min(timeoutMs, 2_147_483_647) }));
546
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
547
+ recordScopedHook(this, this.catalog.size, "aroundCapability");
497
548
  return this;
498
549
  }
499
550
  /** Observe token-only admission/execution lifecycle events for subsequent capability routes. */
@@ -505,16 +556,24 @@ export class Server {
505
556
  this.capabilityObservers.push(observer);
506
557
  return this;
507
558
  }
508
- /** Transform the handler's result before it is serialized. Order-scoped. */
559
+ /** Transform the handler's result before it is serialized. Order-scoped: captured by routes declared
560
+ * *after* this call, none before; one added after the last route reaches nothing (see
561
+ * {@link ServerOptions.unusedScopedHooks}). */
509
562
  afterHandle(fn) {
510
563
  this.assertConfigurable("afterHandle()");
511
564
  this.afterHandleHooks.push(fn);
565
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
566
+ recordScopedHook(this, this.catalog.size, "afterHandle");
512
567
  return this;
513
568
  }
514
- /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500). Order-scoped. */
569
+ /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500).
570
+ * Order-scoped: captured by routes declared *after* this call, none before; one added after the last
571
+ * route reaches nothing (see {@link ServerOptions.unusedScopedHooks}). */
515
572
  onError(fn) {
516
573
  this.assertConfigurable("onError()");
517
574
  this.onErrorHooks.push(fn);
575
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
576
+ recordScopedHook(this, this.catalog.size, "onError");
518
577
  return this;
519
578
  }
520
579
  /** Transform every outgoing response - success, error, 404, 405, short-circuit. Global. */
@@ -556,19 +615,33 @@ export class Server {
556
615
  * hook owns part of the header state - into a hook that runs in the right order. */
557
616
  addStaticResponseHeaders(record) {
558
617
  if (this.onResponseHooks.length > 0) {
559
- return this.onResponseHeaders((headers) => {
560
- for (const name of Object.keys(record)) {
561
- if (!headers.has(name))
562
- headers.set(name, record[name]);
563
- }
564
- });
618
+ const observerHeaders = this.onResponseHeaders;
619
+ if (observerHeaders !== undefined) {
620
+ observerHeaders.call(this, (headers) => {
621
+ for (const name of Object.keys(record)) {
622
+ if (!headers.has(name))
623
+ headers.set(name, record[name]);
624
+ }
625
+ });
626
+ return this;
627
+ }
628
+ // Route the hook through the same seam the static tier uses. On a framework response (headers
629
+ // stamped mutable) it applies the defaults in place and returns the SAME object, so the
630
+ // serialized-body marker survives for later `onResponseBody`/`onResponseRaw` observers and the
631
+ // Node writer; reconstructing a `Response` here would strip that marker and reclassify the Node
632
+ // outcome from `json` to a generic `response`. A guarded foreign response takes its clone path.
633
+ const statics = buildStaticResponseHeaders(record);
634
+ this.onResponseHooks.push((response) => applyStaticResponseHeaders(response, statics));
635
+ this.onNodeResponseHooks.push(undefined);
636
+ this.nodeResponseHooksComplete = false;
637
+ return this;
565
638
  }
566
639
  const merged = this.staticResponseHeaders === undefined
567
640
  ? record
568
641
  : { ...this.staticResponseHeaders.record, ...record };
569
642
  this.staticResponseHeaders = buildStaticResponseHeaders(merged);
570
643
  const statics = this.staticResponseHeaders;
571
- this.wrapWebResponse = (response) => applyStaticResponseHeaders(response, statics);
644
+ this.wrapWebResponse = (response) => applyStaticResponseHeaders(webResponseOf(response), statics);
572
645
  this.webResponseTimeout = () => applyStaticResponseHeaders(RESPONSE_TIMEOUT(), statics);
573
646
  return this;
574
647
  }
@@ -579,58 +652,14 @@ export class Server {
579
652
  }
580
653
  return this.responseBodyTag;
581
654
  }
582
- /**
583
- * Register a response transform for raw/streamed Responses. Framework-serialized payloads stay on
584
- * the body tier; this hook is only entered for untagged Responses (streams, proxied fetches, and
585
- * framework-generated error responses). A no-op native twin keeps buffered JSON on the direct lane.
586
- */
587
- onResponseRaw(fn) {
588
- this.assertConfigurable("onResponseRaw()");
589
- this.enableResponseBodyTagging();
590
- this.onResponseHooks.push(webResponseRawHook(fn, this.responseBodyOwners));
591
- this.onNodeResponseHooks.push(() => undefined);
592
- return this;
593
- }
594
- /**
595
- * Register a PORTABLE header-only response hook - one implementation, fast on every runtime.
596
- *
597
- * The hook receives a mutable case-insensitive header view, a minimal request view, and the
598
- * status. On the Web serving paths it runs inside the normal `onResponse` walk against the
599
- * response's own `Headers` (mutating in place - no clone). On Node it self-pairs as a native
600
- * response hook against the outcome's plain header record, so registering one NEVER forces the
601
- * Node adapter off its direct socket writer the way a full `onResponse(res: Response)` hook does.
602
- * Prefer this over `onResponse` whenever the middleware only reads/writes headers.
603
- */
604
- onResponseHeaders(fn) {
605
- this.assertConfigurable("onResponseHeaders()");
606
- this.onResponseHooks.push(webResponseHeadersHook(fn));
607
- this.onNodeResponseHooks.push((response, req) => fn(recordHeadersView(response), req, response.status));
608
- return this;
609
- }
610
- /**
611
- * Register a PORTABLE post-serialization body hook - the payload tier. The hook receives the
612
- * FINAL framework-serialized bytes (plus the mutable header view and status) and may return
613
- * replacement bytes; `undefined` keeps the body. On the Node direct writer the bytes come
614
- * straight off the outcome record; on the Web serving paths they ride the framework-built
615
- * Response as a tag, so no body stream is ever drained on any runtime. A handler-returned raw
616
- * `Response` (proxied fetch, SSE, streamed SSR) is skipped by contract - transforming those is
617
- * what the full `onResponse` hook is for.
618
- */
619
- onResponseBody(fn) {
620
- this.assertConfigurable("onResponseBody()");
621
- this.enableResponseBodyTagging();
622
- this.onResponseHooks.push(webResponseBodyHook(fn, this.responseBodyOwners));
623
- this.onNodeResponseHooks.push((response, req) => {
624
- const body = response.body;
625
- if (body === null)
626
- return undefined;
627
- const out = fn(body, recordHeadersView(response), req, response.status);
628
- if (out instanceof Promise)
629
- return out.then((replaced) => applyBodyReplacement(response, replaced));
630
- applyBodyReplacement(response, out);
631
- return undefined;
632
- });
633
- return this;
655
+ responseObserverMethods() {
656
+ const methods = this;
657
+ if (typeof methods.onResponseHeaders !== "function" ||
658
+ typeof methods.onResponseBody !== "function" ||
659
+ typeof methods.onResponseRaw !== "function") {
660
+ throw new TypeError("response observation requires `.use(responseObserver())` or an observer-enabled middleware");
661
+ }
662
+ return methods;
634
663
  }
635
664
  /** Observe the terminal response after all transformations. Observers are ordered and fail-open. */
636
665
  onResponseFinalized(fn) {
@@ -676,6 +705,15 @@ export class Server {
676
705
  }
677
706
  this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"));
678
707
  this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"));
708
+ const responseObserver = arg[INSTALL_RESPONSE_OBSERVER];
709
+ if (responseObserver !== undefined) {
710
+ if (typeof responseObserver !== "object" ||
711
+ responseObserver === null ||
712
+ typeof responseObserver.install !== "function") {
713
+ throw new TypeError("response observer middleware has an invalid runtime");
714
+ }
715
+ this[INSTALL_RESPONSE_OBSERVER](responseObserver);
716
+ }
679
717
  if (arg.onRequest !== undefined) {
680
718
  this.assertConfigurable("onRequest()");
681
719
  this.onRequestHooks.push(arg.onRequest);
@@ -709,11 +747,11 @@ export class Server {
709
747
  if (arg.responseHeaders !== undefined)
710
748
  this.responseHeaders(arg.responseHeaders);
711
749
  if (arg.onResponseHeaders !== undefined)
712
- this.onResponseHeaders(arg.onResponseHeaders);
750
+ this.responseObserverMethods().onResponseHeaders(arg.onResponseHeaders);
713
751
  if (arg.onResponseBody !== undefined)
714
- this.onResponseBody(arg.onResponseBody);
752
+ this.responseObserverMethods().onResponseBody(arg.onResponseBody);
715
753
  if (arg.onResponseRaw !== undefined)
716
- this.onResponseRaw(arg.onResponseRaw);
754
+ this.responseObserverMethods().onResponseRaw(arg.onResponseRaw);
717
755
  if (arg.onResponseFinalized !== undefined)
718
756
  this.onResponseFinalized(arg.onResponseFinalized);
719
757
  if (arg.onError !== undefined)
@@ -820,6 +858,9 @@ export class Server {
820
858
  // Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
821
859
  // bundle it. Registration is the loud, early failure point - never the first connection.
822
860
  const runtime = requireWsRuntime(this.wsRuntime);
861
+ if (handler.validateSend === true && handler.sendSchema === undefined) {
862
+ throw new RouteConfigError("INVALID_WS_SEND_VALIDATION", `route WS ${path}: validateSend requires sendSchema`);
863
+ }
823
864
  this.topics ??= runtime.createTopics();
824
865
  // A `messageSchema` wraps `message` with validation once, here - every adapter then dispatches
825
866
  // already-validated, typed messages (Bun/Deno/Node/Workers) with no per-adapter code.
@@ -872,6 +913,8 @@ export class Server {
872
913
  }
873
914
  prepareRoute(method, path, schema, handler) {
874
915
  const pattern = compileRoutePattern(path);
916
+ // Resolved here, once: `undefined` past this point means the route declared `"unlimited"`, NOT
917
+ // "nothing declared". Readers fall back to UNLIMITED_BODY_BYTES accordingly.
875
918
  let bodyLimit = this.maxBodyBytes;
876
919
  const invalidBodyLimit = (message) => {
877
920
  throw new RouteConfigError("INVALID_BODY_LIMIT", `route ${method} ${path}: ${message}`);
@@ -884,6 +927,12 @@ export class Server {
884
927
  schema.bodyLimitReason.trim().length === 0) {
885
928
  invalidBodyLimit('bodyLimit: "unlimited" requires a non-empty bodyLimitReason');
886
929
  }
930
+ if (schema.body !== undefined) {
931
+ invalidBodyLimit('bodyLimit: "unlimited" cannot be used with a body schema; use a finite cap');
932
+ }
933
+ if (schema.idempotency !== undefined) {
934
+ invalidBodyLimit('bodyLimit: "unlimited" cannot be used with idempotency');
935
+ }
887
936
  bodyLimit = undefined;
888
937
  }
889
938
  else if (schema?.bodyLimit !== undefined) {
@@ -981,12 +1030,12 @@ export class Server {
981
1030
  // handler continuation once. The runner keeps the bounded parser and all error semantics while
982
1031
  // avoiding the generic entry/schema/lifecycle dispatch on Bun, Deno, and Node-direct.
983
1032
  const fusedBodyRunner = fusedBody
984
- ? this.buildFusedBodyRunner(handler, schema.body, hasDecorations ? routeDecorations : undefined, bodyLimit ?? this.maxBodyBytes)
1033
+ ? this.buildFusedBodyRunner(handler, schema.body, hasDecorations ? routeDecorations : undefined, bodyLimit ?? UNLIMITED_BODY_BYTES)
985
1034
  : undefined;
986
1035
  const fusedWeb = bare && this.aroundHooks.length === 0
987
- ? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler), bodyLimit ?? this.maxBodyBytes)
1036
+ ? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler), bodyLimit ?? UNLIMITED_BODY_BYTES)
988
1037
  : fusedQuery
989
- ? this.buildFusedQueryWeb(handler, hasDecorations ? routeDecorations : undefined, schema.query, bodyLimit ?? this.maxBodyBytes)
1038
+ ? this.buildFusedQueryWeb(handler, hasDecorations ? routeDecorations : undefined, schema.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
990
1039
  : fusedBody
991
1040
  ? this.buildFusedBodyWeb(fusedBodyRunner)
992
1041
  : undefined;
@@ -1094,7 +1143,18 @@ export class Server {
1094
1143
  });
1095
1144
  routeAssurance.push(Object.freeze(declared));
1096
1145
  }
1097
- if (schema?.body !== undefined) {
1146
+ // Two ways a route proves its body is bounded, both enforced by the core at read time:
1147
+ // `schema.body` (the validated read is bounded) and an explicit finite `schema.bodyLimit` (the
1148
+ // transport cap shadows every reader, including a raw `c.req.body` stream that a Content-Length
1149
+ // middleware cannot bound).
1150
+ // `bodyLimit: "unlimited"` is rejected above for schema and idempotency routes. The remaining
1151
+ // exemption is schema-less streaming/upload work, so it publishes NOTHING: there is no finite
1152
+ // framework byte ceiling for assurance to attest and the application protocol owns that bound.
1153
+ // The server-wide `maxBodyBytes` default is not evidence either. It applies to every route
1154
+ // whether or not anyone thought about it, so publishing it would make the id pass everywhere and
1155
+ // prove nothing; this evidence marks a bound the route chose.
1156
+ const unlimitedBody = schema?.bodyLimit === "unlimited";
1157
+ if (!unlimitedBody && (schema?.body !== undefined || typeof schema?.bodyLimit === "number")) {
1098
1158
  routeAssurance.push(Object.freeze({
1099
1159
  id: NIFRA_ASSURANCE_IDS.BODY_BOUNDED,
1100
1160
  source: "route-schema",
@@ -1129,6 +1189,29 @@ export class Server {
1129
1189
  this.assertConfigurable("responseContract()");
1130
1190
  this.responseContractRuntime = runtime;
1131
1191
  }
1192
+ /** @internal Symbol-keyed install seam for the response observer plugin. */
1193
+ [INSTALL_RESPONSE_OBSERVER](runtime) {
1194
+ this.assertConfigurable("responseObserver()");
1195
+ const methods = runtime.install({
1196
+ assertConfigurable: (operation) => this.assertConfigurable(operation),
1197
+ addResponseHook: (web, node) => {
1198
+ this.onResponseHooks.push(web);
1199
+ this.onNodeResponseHooks.push(node);
1200
+ if (node === undefined)
1201
+ this.nodeResponseHooksComplete = false;
1202
+ },
1203
+ enableResponseBodyTagging: () => {
1204
+ if (this.responseBodyTag === undefined) {
1205
+ this.responseBodyTag = Object.freeze({});
1206
+ this.responseBodyOwners.add(this.responseBodyTag);
1207
+ }
1208
+ return this.responseBodyTag;
1209
+ },
1210
+ responseBodyOwners: () => this.responseBodyOwners,
1211
+ });
1212
+ Object.assign(this, methods);
1213
+ return methods;
1214
+ }
1132
1215
  /** @internal Symbol-keyed install seam for the `idempotency()` plugin. Off the public typed surface. */
1133
1216
  [INSTALL_IDEMPOTENCY](runtime) {
1134
1217
  this.assertConfigurable("idempotency()");
@@ -1259,13 +1342,13 @@ export class Server {
1259
1342
  // Rebuild with the SAME builder that produced the closure - rebinding a query-fused route as
1260
1343
  // bare would silently drop its validation.
1261
1344
  const fusedBody = entry.execution.fusedLane === "body"
1262
- ? this.buildFusedBodyRunner(entry.handler, entry.schema?.body, entry.hasDecorations ? entry.decorations : undefined, entry.bodyLimit ?? this.maxBodyBytes)
1345
+ ? this.buildFusedBodyRunner(entry.handler, entry.schema?.body, entry.hasDecorations ? entry.decorations : undefined, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
1263
1346
  : undefined;
1264
1347
  const fusedWeb = entry.execution.fusedLane === "body"
1265
1348
  ? this.buildFusedBodyWeb(fusedBody)
1266
1349
  : entry.execution.fusedLane === "query"
1267
- ? this.buildFusedQueryWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, entry.schema?.query, entry.bodyLimit ?? this.maxBodyBytes)
1268
- : this.buildFusedWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, isContextlessNoArgArrow(entry.handler), entry.bodyLimit ?? this.maxBodyBytes);
1350
+ ? this.buildFusedQueryWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, entry.schema?.query, entry.bodyLimit ?? UNLIMITED_BODY_BYTES)
1351
+ : this.buildFusedWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, isContextlessNoArgArrow(entry.handler), entry.bodyLimit ?? UNLIMITED_BODY_BYTES);
1269
1352
  return {
1270
1353
  ...route,
1271
1354
  entry: {
@@ -1305,6 +1388,10 @@ export class Server {
1305
1388
  return this.fetchSource(req, platform);
1306
1389
  }
1307
1390
  fetchSource(source, platform) {
1391
+ // Seal once on the first request so test servers (no `listen()`) still get the dead-hook check.
1392
+ // After that it is a single boolean read - the per-request cost the option promises to keep at zero.
1393
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1394
+ sealHookAudit(this, this.catalog.size, this.logger);
1308
1395
  // Off path (default): straight through - one property check, no closure, no promise.
1309
1396
  if (this.capacityGate === undefined)
1310
1397
  return this.fetchSourceInner(source, platform);
@@ -1379,6 +1466,42 @@ export class Server {
1379
1466
  release();
1380
1467
  return outcome;
1381
1468
  }
1469
+ /**
1470
+ * Select a mounted handler's native Node lane without materializing a Web Request. This is an
1471
+ * adapter seam, not a second lifecycle: any app-wide behavior that the ordinary mount path would
1472
+ * run makes the capability ineligible and the caller falls back to `resolveNodeSource`. Typed
1473
+ * routes keep precedence exactly as `routeAndRun` does, including the existing mount behavior for
1474
+ * a path whose registered route rejects this method.
1475
+ */
1476
+ [RESOLVE_NODE_MOUNT](source) {
1477
+ if (this.onRequestHooks.length > 0 ||
1478
+ this.onResponseHooks.length > 0 ||
1479
+ this.onResponseFinalizedHooks.length > 0 ||
1480
+ this.capacityGate !== undefined ||
1481
+ this.staticResponseHeaders !== undefined ||
1482
+ this.requestTimeoutMs !== 0 ||
1483
+ this.acceptInboundDeadlines ||
1484
+ this.clientIpTrust !== undefined) {
1485
+ return undefined;
1486
+ }
1487
+ const parts = source.urlParts ?? urlPartsOf(source.url);
1488
+ const match = this.catalog.find(source.method, parts.pathname);
1489
+ if (match.found)
1490
+ return undefined;
1491
+ for (const mount of this.fetchMounts) {
1492
+ if (!underMountPrefix(parts.pathname, mount.path))
1493
+ continue;
1494
+ const candidate = mount.handler[NODE_NATIVE_MOUNT];
1495
+ return typeof candidate === "function"
1496
+ ? {
1497
+ handler: candidate,
1498
+ path: mount.path,
1499
+ stripPrefix: mount.stripPrefix,
1500
+ }
1501
+ : undefined;
1502
+ }
1503
+ return undefined;
1504
+ }
1382
1505
  /**
1383
1506
  * Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` - the
1384
1507
  * `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
@@ -1391,6 +1514,9 @@ export class Server {
1391
1514
  return this.resolveNodeSource(req, platform);
1392
1515
  }
1393
1516
  resolveNodeSource(source, platform, suppliedRuntime) {
1517
+ // Seal once on the Node-direct lane too - it can bypass `fetchSource` entirely (hookless outcome).
1518
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
1519
+ sealHookAudit(this, this.catalog.size, this.logger);
1394
1520
  // A paired header-only native hook can preserve the Node-direct JSON/body outcome for successful
1395
1521
  // responses. Arbitrary onResponse transforms need a real Web Response, but a buffered outcome can
1396
1522
  // be materialized with a direct-write marker and return to the socket path when the hook mutates
@@ -1915,15 +2041,18 @@ export class Server {
1915
2041
  : wrapResponse(mounted);
1916
2042
  }
1917
2043
  if (match.reason === "method-not-allowed") {
1918
- return wrapResponse(jsonError(405, "method_not_allowed", { Allow: match.allowed.join(", ") }));
2044
+ return wrapResponse(
2045
+ // Lowercase on purpose: a plain render's headers go into the node outcome record verbatim,
2046
+ // and every other name in that record is lowercase (the Web lane normalizes either way).
2047
+ plainError(405, "method_not_allowed", { allow: match.allowed.join(", ") }));
1919
2048
  }
1920
- return wrapResponse(jsonError(404, "not_found"));
2049
+ return wrapResponse(plainError(404, "not_found"));
1921
2050
  }
1922
2051
  // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
1923
2052
  // already did and made every plain dynamic route pay for unrelated static path bytes.
1924
2053
  const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params);
1925
2054
  if (params === null) {
1926
- return wrapResponse(jsonError(400, "malformed_path"));
2055
+ return wrapResponse(plainError(400, "malformed_path"));
1927
2056
  }
1928
2057
  return this.runMatched(source, platform, match.payload, params, search, finalize, wrapResponse, onTimeout, webFast);
1929
2058
  }
@@ -1958,7 +2087,7 @@ export class Server {
1958
2087
  // The runtime is always present when a route resolved idempotency (enforced at registration).
1959
2088
  if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
1960
2089
  return this.idempotencyRuntime.run(entry.idempotent, requestOf(source), platform, entry, params, search, wrapResponse, {
1961
- maxBodyBytes: entry.bodyLimit ?? this.maxBodyBytes,
2090
+ maxBodyBytes: entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
1962
2091
  runLanes: (buffered, plat, ent, prm, srch) => this.idempotencyRunLanes(buffered, plat, ent, prm, srch),
1963
2092
  });
1964
2093
  }
@@ -1984,7 +2113,7 @@ export class Server {
1984
2113
  ? undefined
1985
2114
  : admitDeadline(source.headers, this.deadlineAdmissionOptions);
1986
2115
  if (admission !== undefined && !admission.ok) {
1987
- return wrapResponse(jsonError(admission.status, admission.reason));
2116
+ return wrapResponse(plainError(admission.status, admission.reason));
1988
2117
  }
1989
2118
  const effectiveTimeoutMs = admission?.timeoutMs ?? 0;
1990
2119
  // Only allocate a controller for a finite budget; the historical no-timeout path remains
@@ -2009,7 +2138,7 @@ export class Server {
2009
2138
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
2010
2139
  if (controller !== undefined && outcome instanceof Promise) {
2011
2140
  const timedOut = admission?.inherited === true
2012
- ? () => wrapResponse(jsonError(504, "deadline_exceeded"))
2141
+ ? () => wrapResponse(plainError(504, "deadline_exceeded"))
2013
2142
  : onTimeout;
2014
2143
  return this.withTimeout(outcome, controller, timedOut, Math.max(0, Math.ceil(budget.remaining())));
2015
2144
  }
@@ -2040,9 +2169,13 @@ export class Server {
2040
2169
  contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse) {
2041
2170
  if (err instanceof Response)
2042
2171
  return wrapResponse(err);
2172
+ // Handed over as the plain data it is: `wrapResponse` renders it on the JSON lane (no `Response`
2173
+ // built on Node). There is no context here by construction, so there is no `c.set` to merge.
2174
+ if (isResponseResult(err))
2175
+ return wrapResponse(err);
2043
2176
  const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes, this.protoPoisoning);
2044
2177
  this.logRequestError(err, ctx);
2045
- return wrapResponse(jsonError(500, "internal_error"));
2178
+ return wrapResponse(plainError(500, "internal_error"));
2046
2179
  }
2047
2180
  /**
2048
2181
  * The synchronous fast path selected by a route's execution plan: apply static decorations, call the
@@ -2062,11 +2195,11 @@ export class Server {
2062
2195
  result = entry.handler(ctx);
2063
2196
  }
2064
2197
  catch (err) {
2065
- return this.bareError(err, ctx, wrapResponse);
2198
+ return this.bareError(err, ctx, finalize, wrapResponse);
2066
2199
  }
2067
2200
  if (result instanceof Promise) {
2068
2201
  // Async handler on an otherwise-bare route: finish on a microtask, with the same error handling.
2069
- return result.then((value) => finalize(value, responseSet(ctx)), (err) => this.bareError(err, ctx, wrapResponse));
2202
+ return result.then((value) => finalize(value, responseSet(ctx)), (err) => this.bareError(err, ctx, finalize, wrapResponse));
2070
2203
  }
2071
2204
  return finalize(result, responseSet(ctx));
2072
2205
  }
@@ -2080,14 +2213,10 @@ export class Server {
2080
2213
  * 500s), same respond semantics (the lifecycle parity suite pins it).
2081
2214
  */
2082
2215
  buildFusedWeb(handler, decorations, contextless, maxBodyBytes) {
2083
- // The fused lanes return to the runtime directly, so the framework's own error renders fold in
2084
- // static headers here rather than through the shared `wrapResponse` seam.
2085
- const logError = (err, ctx) => {
2086
- if (err instanceof Response)
2087
- return this.wrapWebResponse(err);
2088
- this.logRequestError(err, ctx);
2089
- return this.wrapWebResponse(jsonError(500, "internal_error"));
2090
- };
2216
+ // Same thrown-value contract as the generic lane (`bare-error-lane.ts` via `bareError`); the fused
2217
+ // lane only injects its renderer, so a thrown `status(...)` and the flat 500 fold in static headers
2218
+ // through `wrapWebResponse`. One source of truth for the error surface, no per-lane copy to drift.
2219
+ const logError = (err, ctx) => this.bareError(err, ctx, (result, set) => this.wrapWebResponse(toResponse(result, set)), this.wrapWebResponse);
2091
2220
  if (contextless && decorations === undefined) {
2092
2221
  // `() => ...` can't observe the context - skip allocating one entirely (errors still build
2093
2222
  // one for the structured log, exactly like runContextlessBare).
@@ -2133,7 +2262,7 @@ export class Server {
2133
2262
  * bounded parser remains shared with the generic lane; only the route-invariant entry lookups and
2134
2263
  * lifecycle dispatch disappear from the common synchronous-validator/synchronous-handler case. */
2135
2264
  buildFusedBodyRunner(handler, bodySchema, decorations, maxBodyBytes) {
2136
- const logError = (err, ctx, wrapResponse) => this.bareError(err, ctx, wrapResponse);
2265
+ const logError = (err, ctx, finalize, wrapResponse) => this.bareError(err, ctx, finalize, wrapResponse);
2137
2266
  const runHandler = (ctx, finalize, wrapResponse) => {
2138
2267
  if (decorations !== undefined)
2139
2268
  Object.assign(ctx, decorations);
@@ -2142,7 +2271,7 @@ export class Server {
2142
2271
  output = handler(ctx);
2143
2272
  }
2144
2273
  catch (err) {
2145
- return logError(err, ctx, wrapResponse);
2274
+ return logError(err, ctx, finalize, wrapResponse);
2146
2275
  }
2147
2276
  if (output instanceof Promise) {
2148
2277
  return output.then((result) => {
@@ -2150,20 +2279,20 @@ export class Server {
2150
2279
  return finalize(result, responseSet(ctx), ctx);
2151
2280
  }
2152
2281
  catch (err) {
2153
- return logError(err, ctx, wrapResponse);
2282
+ return logError(err, ctx, finalize, wrapResponse);
2154
2283
  }
2155
- }, (err) => logError(err, ctx, wrapResponse));
2284
+ }, (err) => logError(err, ctx, finalize, wrapResponse));
2156
2285
  }
2157
2286
  try {
2158
2287
  return finalize(output, responseSet(ctx), ctx);
2159
2288
  }
2160
2289
  catch (err) {
2161
- return logError(err, ctx, wrapResponse);
2290
+ return logError(err, ctx, finalize, wrapResponse);
2162
2291
  }
2163
2292
  };
2164
2293
  const runValidated = (result, ctx, finalize, wrapResponse) => {
2165
2294
  if (result.issues !== undefined)
2166
- return wrapResponse(validationError(result.issues));
2295
+ return wrapResponse(plainValidationError(result.issues));
2167
2296
  ctx.body = result.value;
2168
2297
  return runHandler(ctx, finalize, wrapResponse);
2169
2298
  };
@@ -2173,7 +2302,7 @@ export class Server {
2173
2302
  validation = bodySchema["~standard"].validate(parsed);
2174
2303
  }
2175
2304
  catch (err) {
2176
- return logError(err, ctx, wrapResponse);
2305
+ return logError(err, ctx, finalize, wrapResponse);
2177
2306
  }
2178
2307
  if (validation instanceof Promise) {
2179
2308
  return validation.then((settled) => {
@@ -2181,15 +2310,15 @@ export class Server {
2181
2310
  return runValidated(settled, ctx, finalize, wrapResponse);
2182
2311
  }
2183
2312
  catch (err) {
2184
- return logError(err, ctx, wrapResponse);
2313
+ return logError(err, ctx, finalize, wrapResponse);
2185
2314
  }
2186
- }, (err) => logError(err, ctx, wrapResponse));
2315
+ }, (err) => logError(err, ctx, finalize, wrapResponse));
2187
2316
  }
2188
2317
  try {
2189
2318
  return runValidated(validation, ctx, finalize, wrapResponse);
2190
2319
  }
2191
2320
  catch (err) {
2192
- return logError(err, ctx, wrapResponse);
2321
+ return logError(err, ctx, finalize, wrapResponse);
2193
2322
  }
2194
2323
  };
2195
2324
  return (source, params, search, signal, budget, platform, nativeContext, finalize, wrapResponse) => {
@@ -2197,7 +2326,7 @@ export class Server {
2197
2326
  ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
2198
2327
  : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes, this.protoPoisoning);
2199
2328
  const finish = (value) => runParsed(value, ctx, finalize, wrapResponse);
2200
- return this.readBodyInput(source, maxBodyBytes, finish, wrapResponse, (err) => logError(err, ctx, wrapResponse));
2329
+ return readBodyFramed(source, maxBodyBytes, this.protoPoisoning, finish, wrapResponse, (err) => logError(err, ctx, finalize, wrapResponse));
2201
2330
  };
2202
2331
  }
2203
2332
  /** Web adapter wrapper for the shared body runner. Node passes its native finalizer directly through
@@ -2213,14 +2342,10 @@ export class Server {
2213
2342
  * `Response` passes through, anything else logs and returns a flat 500, and an async validator
2214
2343
  * falls to a then-chain with the same steps. */
2215
2344
  buildFusedQueryWeb(handler, decorations, querySchema, maxBodyBytes) {
2216
- // The fused lanes return to the runtime directly, so the framework's own error renders fold in
2217
- // static headers here rather than through the shared `wrapResponse` seam.
2218
- const logError = (err, ctx) => {
2219
- if (err instanceof Response)
2220
- return this.wrapWebResponse(err);
2221
- this.logRequestError(err, ctx);
2222
- return this.wrapWebResponse(jsonError(500, "internal_error"));
2223
- };
2345
+ // Same thrown-value contract as the generic lane (`bare-error-lane.ts` via `bareError`); the fused
2346
+ // lane only injects its renderer, so a thrown `status(...)` and the flat 500 fold in static headers
2347
+ // through `wrapWebResponse`. One source of truth for the error surface, no per-lane copy to drift.
2348
+ const logError = (err, ctx) => this.bareError(err, ctx, (result, set) => this.wrapWebResponse(toResponse(result, set)), this.wrapWebResponse);
2224
2349
  const runHandler = (ctx, value) => {
2225
2350
  ctx.query = value;
2226
2351
  let result;
@@ -2250,19 +2375,19 @@ export class Server {
2250
2375
  }
2251
2376
  if (validation instanceof Promise) {
2252
2377
  return validation.then((settled) => settled.issues !== undefined
2253
- ? this.wrapWebResponse(validationError(settled.issues))
2378
+ ? this.wrapWebResponse(plainValidationError(settled.issues))
2254
2379
  : runHandler(ctx, settled.value), (err) => logError(err, ctx));
2255
2380
  }
2256
2381
  if (validation.issues !== undefined)
2257
- return this.wrapWebResponse(validationError(validation.issues));
2382
+ return this.wrapWebResponse(plainValidationError(validation.issues));
2258
2383
  return runHandler(ctx, validation.value);
2259
2384
  };
2260
2385
  }
2261
- bareError(err, ctx, wrapResponse) {
2262
- if (err instanceof Response)
2263
- return wrapResponse(err);
2264
- this.logRequestError(err, ctx);
2265
- return wrapResponse(jsonError(500, "internal_error"));
2386
+ bareError(err, ctx, finalize, wrapResponse) {
2387
+ // The thrown-value contract lives in `bare-error-lane.ts`, one source of truth for both this
2388
+ // lane and the fused body runner. `responseSet` and the logger are injected because they reach
2389
+ // into class state; the closure is allocated only on the throw path, never on the hot lane.
2390
+ return renderBareError(err, ctx, finalize, wrapResponse, responseSet, (e, c) => this.logRequestError(e, c));
2266
2391
  }
2267
2392
  // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2268
2393
  // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
@@ -2296,27 +2421,7 @@ export class Server {
2296
2421
  // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
2297
2422
  // biome-ignore lint/correctness/noUnusedPrivateClassMembers: invoked structurally by compiled plans (internal/route-execution.ts)
2298
2423
  runBodyOnly(entry, source, ctx, finalize, wrapResponse) {
2299
- 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));
2300
- }
2301
- /** Shared bounded body framing/parser. Both body lanes use the same trust-boundary checks; only
2302
- * the validation + handler continuation differs. */
2303
- readBodyInput(source, maxBodyBytes, onParsed, wrapResponse, onError) {
2304
- const contentType = headerOf(source, "content-type") ?? "";
2305
- if (contentType !== "application/json" && !contentType.includes("application/json")) {
2306
- if (isUrlEncodedForm(contentType)) {
2307
- return readBoundedForm(source, maxBodyBytes).then((form) => (form instanceof Response ? wrapResponse(form) : onParsed(form)), onError);
2308
- }
2309
- return Promise.resolve(wrapResponse(jsonError(415, "unsupported_media_type")));
2310
- }
2311
- // All JSON bodies go through readBoundedJson - the single enforcement point for the framed
2312
- // fast path (post-read byte re-check), the streaming cap, and the prototype-poisoning guard.
2313
- // A separate inlined native-json() fast path here would fork the trust boundary in two.
2314
- try {
2315
- return this.readBoundedJson(source, maxBodyBytes, (parsed) => (parsed instanceof Response ? wrapResponse(parsed) : onParsed(parsed)), onError);
2316
- }
2317
- catch (err) {
2318
- return Promise.resolve(onError(err));
2319
- }
2424
+ 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));
2320
2425
  }
2321
2426
  /** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
2322
2427
  * streaming fallback. A method (not per-request closures) so the hot path allocates nothing
@@ -2371,13 +2476,13 @@ export class Server {
2371
2476
  if (validation instanceof Promise) {
2372
2477
  return validation.then((settled) => {
2373
2478
  if (settled.issues !== undefined)
2374
- return wrapResponse(validationError(settled.issues));
2479
+ return wrapResponse(plainValidationError(settled.issues));
2375
2480
  ctx.body = settled.value;
2376
2481
  return this.executeHandler(entry, ctx, finalize);
2377
2482
  });
2378
2483
  }
2379
2484
  if (validation.issues !== undefined)
2380
- return wrapResponse(validationError(validation.issues));
2485
+ return wrapResponse(plainValidationError(validation.issues));
2381
2486
  ctx.body = validation.value;
2382
2487
  return this.executeHandler(entry, ctx, finalize);
2383
2488
  }
@@ -2386,18 +2491,18 @@ export class Server {
2386
2491
  if (validation instanceof Promise) {
2387
2492
  return validation.then((settled) => {
2388
2493
  if (settled.issues !== undefined)
2389
- return wrapResponse(validationError(settled.issues));
2494
+ return wrapResponse(plainValidationError(settled.issues));
2390
2495
  ctx.query = settled.value;
2391
2496
  return this.executeHandler(entry, ctx, finalize, wrapResponse);
2392
2497
  }, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
2393
2498
  }
2394
2499
  if (validation.issues !== undefined)
2395
- return wrapResponse(validationError(validation.issues));
2500
+ return wrapResponse(plainValidationError(validation.issues));
2396
2501
  ctx.query = validation.value;
2397
2502
  return this.executeHandler(entry, ctx, finalize, wrapResponse);
2398
2503
  }
2399
2504
  }
2400
- return wrapResponse(validationError(originalIssues));
2505
+ return wrapResponse(plainValidationError(originalIssues));
2401
2506
  }
2402
2507
  applyBodyValidation(entry, result, ctx, finalize, wrapResponse) {
2403
2508
  if (result.issues !== undefined) {
@@ -2409,7 +2514,7 @@ export class Server {
2409
2514
  }
2410
2515
  return this.handleValidationErrorRecovery(entry, recovery, ctx, finalize, wrapResponse, result.issues, "body");
2411
2516
  }
2412
- return wrapResponse(validationError(result.issues));
2517
+ return wrapResponse(plainValidationError(result.issues));
2413
2518
  }
2414
2519
  ctx.body = result.value;
2415
2520
  return this.executeHandler(entry, ctx, finalize);
@@ -2452,7 +2557,7 @@ export class Server {
2452
2557
  }
2453
2558
  return this.handleValidationErrorRecovery(entry, recovery, ctx, finalize, wrapResponse, result.issues, "query");
2454
2559
  }
2455
- return wrapResponse(validationError(result.issues));
2560
+ return wrapResponse(plainValidationError(result.issues));
2456
2561
  }
2457
2562
  ctx.query = result.value;
2458
2563
  return this.executeHandler(entry, ctx, finalize, wrapResponse);
@@ -2747,6 +2852,8 @@ export class Server {
2747
2852
  if (extension instanceof Promise) {
2748
2853
  return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, i, extension);
2749
2854
  }
2855
+ if (isDeriveEarlyExit(extension))
2856
+ return finalize(extension, responseSet(ctx));
2750
2857
  Object.assign(ctx, extension);
2751
2858
  }
2752
2859
  for (let i = 0; i < entry.beforeHandle.length; i++) {
@@ -2774,6 +2881,8 @@ export class Server {
2774
2881
  if (extension instanceof Promise) {
2775
2882
  return this.continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, 0, extension);
2776
2883
  }
2884
+ if (isDeriveEarlyExit(extension))
2885
+ return finalize(extension, responseSet(ctx));
2777
2886
  Object.assign(ctx, extension);
2778
2887
  const outcome = entry.beforeHandle[0](ctx);
2779
2888
  if (outcome instanceof Promise) {
@@ -2802,10 +2911,16 @@ export class Server {
2802
2911
  }
2803
2912
  async continueLifecycleAfterDerive(entry, ctx, finalize, wrapResponse, firstIndex, first) {
2804
2913
  try {
2805
- Object.assign(ctx, await first);
2914
+ const settled = await first;
2915
+ if (isDeriveEarlyExit(settled))
2916
+ return finalize(settled, responseSet(ctx));
2917
+ Object.assign(ctx, settled);
2806
2918
  for (let i = firstIndex + 1; i < entry.derives.length; i++) {
2807
- const extension = entry.derives[i](ctx);
2808
- Object.assign(ctx, extension instanceof Promise ? await extension : extension);
2919
+ const raw = entry.derives[i](ctx);
2920
+ const extension = raw instanceof Promise ? await raw : raw;
2921
+ if (isDeriveEarlyExit(extension))
2922
+ return finalize(extension, responseSet(ctx));
2923
+ Object.assign(ctx, extension);
2809
2924
  }
2810
2925
  return await this.runLifecycleHooksAsync(entry, ctx, finalize, wrapResponse);
2811
2926
  }
@@ -2909,7 +3024,7 @@ export class Server {
2909
3024
  path: pathnameOf(ctx.req.url),
2910
3025
  detail: outcome.message,
2911
3026
  });
2912
- return wrapResponse(jsonError(500, "internal_error"));
3027
+ return wrapResponse(plainError(500, "internal_error"));
2913
3028
  }
2914
3029
  if (outcome.kind === "warn") {
2915
3030
  this.logger.warn("response contract", {
@@ -2920,14 +3035,33 @@ export class Server {
2920
3035
  }
2921
3036
  return finalize(outcome.value, responseSet(ctx));
2922
3037
  }
2923
- async handleLifecycleError(entry, err, ctx, finalize, wrapResponse) {
3038
+ /** Synchronous by design, and `MaybePromise` rather than `Promise` for the same reason: control flow
3039
+ * out of a lifecycle stage must not cost a promise. An `async` method allocates one and defers the
3040
+ * result a microtask even when every branch it takes returns immediately, which is the common case
3041
+ * here - a thrown `status(...)`/`Response`, or a route with no `onError` hook at all. Only the hook
3042
+ * loop, which may await, drops into {@link runErrorHooks}. */
3043
+ handleLifecycleError(entry, err, ctx, finalize, wrapResponse) {
2924
3044
  // A *thrown* Response is deliberate control flow, not an error - a guard throws a redirect/401,
2925
3045
  // an action throws an error page. Return it as-is (Remix/SvelteKit semantics); don't run onError
2926
3046
  // or log it as a 500. This is what makes `throw redirect(...)` / `requireSession(...)` work from
2927
3047
  // any handler or loader.
2928
3048
  if (err instanceof Response)
2929
3049
  return wrapResponse(err);
2930
- // onError hooks may return a custom response; otherwise the default 500 stands.
3050
+ // Same rule for a thrown `status(...)`, but through `finalize` rather than `wrapResponse`: the
3051
+ // value is still plain data, so the ordinary JSON lane renders it and no `Response` is built.
3052
+ if (isResponseResult(err))
3053
+ return finalize(err, responseSet(ctx));
3054
+ if (entry.onError.length === 0) {
3055
+ // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
3056
+ // (redacting) logger. Body-read failures and around-hook failures land here too.
3057
+ this.logRequestError(err, ctx);
3058
+ return wrapResponse(plainError(500, "internal_error"));
3059
+ }
3060
+ return this.runErrorHooks(entry, err, ctx, finalize, wrapResponse);
3061
+ }
3062
+ /** The `onError` chain, split off {@link handleLifecycleError} so its `await` costs only the routes
3063
+ * that registered a hook. A hook may return a custom response; otherwise the default 500 stands. */
3064
+ async runErrorHooks(entry, err, ctx, finalize, wrapResponse) {
2931
3065
  // Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
2932
3066
  // the `await` and stay heap-allocated on both engines.
2933
3067
  for (let i = 0; i < entry.onError.length; i++) {
@@ -2936,49 +3070,33 @@ export class Server {
2936
3070
  if (handled !== undefined)
2937
3071
  return finalize(handled, responseSet(ctx));
2938
3072
  }
2939
- // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
2940
- // (redacting) logger. Body-read failures and around-hook failures land here too.
2941
3073
  this.logRequestError(err, ctx);
2942
- return wrapResponse(jsonError(500, "internal_error"));
3074
+ return wrapResponse(plainError(500, "internal_error"));
2943
3075
  }
2944
3076
  /** Log an unhandled request error to the (redacting) logger - shared by {@link runLifecycle} and the
2945
3077
  * bare fast path ({@link bareError}) so both record the same fields. Never throws; never leaks. */
2946
3078
  logRequestError(err, ctx) {
2947
- // An error's own text can quote the input that produced it, so how much of it reaches the sink is
2948
- // the app's call (`errorLogDetail`). The default keeps it: a 500 with no message and no stack is
2949
- // an incident nobody can diagnose, and the framework already ships the narrower instrument for
2950
- // the leak - a redacting logger with `valuePatterns`. `"none"` is there for an untrusted sink.
2951
- const detail = this.errorLogDetail;
2952
- this.logger.error("unhandled request error", {
2953
- method: ctx.req.method,
2954
- path: pathnameOf(ctx.req.url),
2955
- name: err instanceof Error ? err.name : "Error",
2956
- // `detail`, not `message`: the logger uses `message` for its own first argument, so a field of
2957
- // that name is silently overwritten and the thrown error's own text never reaches the sink. It
2958
- // survived only incidentally inside `stack`, and was lost outright for a non-Error throw.
2959
- ...(detail === "none" ? {} : { detail: err instanceof Error ? err.message : String(err) }),
2960
- ...(detail === "full" && err instanceof Error ? { stack: err.stack } : {}),
2961
- });
3079
+ emitRequestErrorLog(this.logger, this.errorLogDetail, err, ctx);
2962
3080
  }
2963
3081
  async readAndValidateBody(req, entry, ctx) {
2964
3082
  const contentType = headerOf(req, "content-type") ?? "";
2965
3083
  let parsed;
2966
3084
  if (contentType === "application/json" || contentType.includes("application/json")) {
2967
- const json = await this.readBoundedJson(req, entry.bodyLimit ?? this.maxBodyBytes);
2968
- if (json instanceof Response)
3085
+ const json = await this.readBoundedJson(req, entry.bodyLimit ?? UNLIMITED_BODY_BYTES);
3086
+ if (isResponseResult(json))
2969
3087
  return json;
2970
3088
  parsed = json;
2971
3089
  }
2972
3090
  else if (isUrlEncodedForm(contentType)) {
2973
- const form = await readBoundedForm(req, entry.bodyLimit ?? this.maxBodyBytes);
2974
- if (form instanceof Response)
3091
+ const form = await readBoundedForm(req, entry.bodyLimit ?? UNLIMITED_BODY_BYTES);
3092
+ if (isResponseResult(form))
2975
3093
  return form;
2976
3094
  parsed = form;
2977
3095
  }
2978
3096
  else {
2979
3097
  // multipart/form-data (file uploads) stays 415 on the schema path by design - files don't
2980
3098
  // fit a value schema; use a schema-less route + @nifrajs/uploads helpers for those.
2981
- return jsonError(415, "unsupported_media_type");
3099
+ return plainError(415, "unsupported_media_type");
2982
3100
  }
2983
3101
  const validation = entry.schema.body["~standard"].validate(parsed);
2984
3102
  const result = validation instanceof Promise ? await validation : validation;
@@ -3003,7 +3121,7 @@ export class Server {
3003
3121
  }
3004
3122
  const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError;
3005
3123
  if (hook === undefined)
3006
- return validationError(result.issues);
3124
+ return plainValidationError(result.issues);
3007
3125
  const attempted = hook(result.issues, ctx, kind);
3008
3126
  if (attempted instanceof Promise) {
3009
3127
  return attempted.then((recovery) => this.finishLifecycleValidationRecovery(entry, kind, result.issues, recovery, assign));
@@ -3012,7 +3130,7 @@ export class Server {
3012
3130
  }
3013
3131
  finishLifecycleValidationRecovery(entry, kind, issues, recovery, assign) {
3014
3132
  if (recovery === undefined)
3015
- return validationError(issues);
3133
+ return plainValidationError(issues);
3016
3134
  if (recovery instanceof Response)
3017
3135
  return recovery;
3018
3136
  const schema = kind === "body"
@@ -3026,13 +3144,13 @@ export class Server {
3026
3144
  if (retried instanceof Promise) {
3027
3145
  return retried.then((settled) => {
3028
3146
  if (settled.issues !== undefined)
3029
- return validationError(settled.issues);
3147
+ return plainValidationError(settled.issues);
3030
3148
  assign(settled.value);
3031
3149
  return undefined;
3032
3150
  });
3033
3151
  }
3034
3152
  if (retried.issues !== undefined)
3035
- return validationError(retried.issues);
3153
+ return plainValidationError(retried.issues);
3036
3154
  assign(retried.value);
3037
3155
  return undefined;
3038
3156
  }
@@ -3192,6 +3310,9 @@ export class Server {
3192
3310
  // defined`. Exercised by the @nifrajs/deno suite (which runs on a non-Bun runtime).
3193
3311
  throw new FrameworkError("BUN_REQUIRED", "listen() uses Bun.serve and runs only on Bun. Serve on Node with @nifrajs/node or on Deno with @nifrajs/deno, or hand app.fetch to any fetch-compatible runtime (Workers, etc.).");
3194
3312
  }
3313
+ // Seal before Bun.serve: in `"error"` mode this throws instead of leaving a bound port behind.
3314
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production")
3315
+ sealHookAudit(this, this.catalog.size, this.logger);
3195
3316
  // Bun's `Server` is the concrete handle; we expose the stable `RunningServer`
3196
3317
  // subset so the public types don't depend on the ambient `Bun` global. The cast
3197
3318
  // bridges them - Bun's `.port` is `number | undefined` (undefined only for unix