@nifrajs/core 5.5.1 → 5.7.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 (209) hide show
  1. package/CHANGELOG.md +246 -0
  2. package/README.md +5 -0
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/binary.d.ts +15 -1
  6. package/dist/binary.d.ts.map +1 -1
  7. package/dist/binary.js +1 -0
  8. package/dist/binary.js.map +1 -1
  9. package/dist/edge-kit.d.ts +1 -0
  10. package/dist/edge-kit.d.ts.map +1 -1
  11. package/dist/edge-kit.js +1 -0
  12. package/dist/edge-kit.js.map +1 -1
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js.map +1 -1
  16. package/dist/evidence.d.ts +4 -0
  17. package/dist/evidence.d.ts.map +1 -1
  18. package/dist/evidence.js +4 -0
  19. package/dist/evidence.js.map +1 -1
  20. package/dist/file-type.d.ts +30 -0
  21. package/dist/file-type.d.ts.map +1 -0
  22. package/dist/file-type.js +118 -0
  23. package/dist/file-type.js.map +1 -0
  24. package/dist/index.d.ts +3 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +3 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/internal/hook-origins.d.ts +2 -0
  29. package/dist/internal/hook-origins.d.ts.map +1 -1
  30. package/dist/internal/hook-origins.js +12 -0
  31. package/dist/internal/hook-origins.js.map +1 -1
  32. package/dist/internal/route-program.d.ts +11 -0
  33. package/dist/internal/route-program.d.ts.map +1 -1
  34. package/dist/internal/route-program.js +133 -85
  35. package/dist/internal/route-program.js.map +1 -1
  36. package/dist/reflection.d.ts +19 -1
  37. package/dist/reflection.d.ts.map +1 -1
  38. package/dist/reflection.js +46 -7
  39. package/dist/reflection.js.map +1 -1
  40. package/dist/response-observer.d.ts +1 -0
  41. package/dist/response-observer.d.ts.map +1 -1
  42. package/dist/response-observer.js +1 -0
  43. package/dist/response-observer.js.map +1 -1
  44. package/dist/server/answer-step.d.ts +27 -0
  45. package/dist/server/answer-step.d.ts.map +1 -0
  46. package/dist/server/answer-step.js +33 -0
  47. package/dist/server/answer-step.js.map +1 -0
  48. package/dist/server/bare-error-lane.d.ts +4 -1
  49. package/dist/server/bare-error-lane.d.ts.map +1 -1
  50. package/dist/server/bare-error-lane.js +9 -3
  51. package/dist/server/bare-error-lane.js.map +1 -1
  52. package/dist/server/base64-answers.d.ts +5 -0
  53. package/dist/server/base64-answers.d.ts.map +1 -0
  54. package/dist/server/base64-answers.js +132 -0
  55. package/dist/server/base64-answers.js.map +1 -0
  56. package/dist/server/before-handler.d.ts +10 -0
  57. package/dist/server/before-handler.d.ts.map +1 -0
  58. package/dist/server/before-handler.js +12 -0
  59. package/dist/server/before-handler.js.map +1 -0
  60. package/dist/server/body-lane.d.ts.map +1 -1
  61. package/dist/server/body-lane.js +8 -2
  62. package/dist/server/body-lane.js.map +1 -1
  63. package/dist/server/body-parser.d.ts.map +1 -1
  64. package/dist/server/body-parser.js +2 -5
  65. package/dist/server/body-parser.js.map +1 -1
  66. package/dist/server/body.d.ts +24 -8
  67. package/dist/server/body.d.ts.map +1 -1
  68. package/dist/server/body.js +86 -33
  69. package/dist/server/body.js.map +1 -1
  70. package/dist/server/core-copies.d.ts.map +1 -1
  71. package/dist/server/core-copies.js +5 -2
  72. package/dist/server/core-copies.js.map +1 -1
  73. package/dist/server/http.d.ts.map +1 -1
  74. package/dist/server/http.js +6 -17
  75. package/dist/server/http.js.map +1 -1
  76. package/dist/server/install.d.ts +2 -0
  77. package/dist/server/install.d.ts.map +1 -1
  78. package/dist/server/install.js +2 -0
  79. package/dist/server/install.js.map +1 -1
  80. package/dist/server/json-response.d.ts +3 -0
  81. package/dist/server/json-response.d.ts.map +1 -0
  82. package/dist/server/json-response.js +14 -0
  83. package/dist/server/json-response.js.map +1 -0
  84. package/dist/server/json-text.d.ts +16 -0
  85. package/dist/server/json-text.d.ts.map +1 -0
  86. package/dist/server/json-text.js +34 -0
  87. package/dist/server/json-text.js.map +1 -0
  88. package/dist/server/multipart.d.ts +3 -0
  89. package/dist/server/multipart.d.ts.map +1 -1
  90. package/dist/server/multipart.js +226 -97
  91. package/dist/server/multipart.js.map +1 -1
  92. package/dist/server/node-outcome.d.ts +5 -0
  93. package/dist/server/node-outcome.d.ts.map +1 -1
  94. package/dist/server/node-outcome.js +3 -4
  95. package/dist/server/node-outcome.js.map +1 -1
  96. package/dist/server/proto-guard.d.ts.map +1 -1
  97. package/dist/server/proto-guard.js +39 -4
  98. package/dist/server/proto-guard.js.map +1 -1
  99. package/dist/server/query.d.ts.map +1 -1
  100. package/dist/server/query.js +8 -14
  101. package/dist/server/query.js.map +1 -1
  102. package/dist/server/repeated-prefix.d.ts +17 -0
  103. package/dist/server/repeated-prefix.d.ts.map +1 -0
  104. package/dist/server/repeated-prefix.js +17 -0
  105. package/dist/server/repeated-prefix.js.map +1 -0
  106. package/dist/server/request-context.d.ts.map +1 -1
  107. package/dist/server/request-context.js +5 -5
  108. package/dist/server/request-context.js.map +1 -1
  109. package/dist/server/respond.d.ts +3 -4
  110. package/dist/server/respond.d.ts.map +1 -1
  111. package/dist/server/respond.js +45 -37
  112. package/dist/server/respond.js.map +1 -1
  113. package/dist/server/response-contract-lane.d.ts +12 -2
  114. package/dist/server/response-contract-lane.d.ts.map +1 -1
  115. package/dist/server/response-contract-lane.js +137 -9
  116. package/dist/server/response-contract-lane.js.map +1 -1
  117. package/dist/server/route-template.d.ts +10 -0
  118. package/dist/server/route-template.d.ts.map +1 -0
  119. package/dist/server/route-template.js +21 -0
  120. package/dist/server/route-template.js.map +1 -0
  121. package/dist/server/runtime-core.d.ts +7 -0
  122. package/dist/server/runtime-core.d.ts.map +1 -1
  123. package/dist/server/runtime-core.js +10 -2
  124. package/dist/server/runtime-core.js.map +1 -1
  125. package/dist/server/server-types.d.ts +17 -8
  126. package/dist/server/server-types.d.ts.map +1 -1
  127. package/dist/server/server.d.ts +27 -12
  128. package/dist/server/server.d.ts.map +1 -1
  129. package/dist/server/server.js +326 -108
  130. package/dist/server/server.js.map +1 -1
  131. package/dist/server/sse.d.ts +1 -1
  132. package/dist/server/sse.d.ts.map +1 -1
  133. package/dist/server/sse.js +7 -5
  134. package/dist/server/sse.js.map +1 -1
  135. package/dist/server/text-slots.d.ts +8 -0
  136. package/dist/server/text-slots.d.ts.map +1 -0
  137. package/dist/server/text-slots.js +19 -0
  138. package/dist/server/text-slots.js.map +1 -0
  139. package/dist/server/webhook.d.ts +1 -1
  140. package/dist/server/webhook.d.ts.map +1 -1
  141. package/dist/server/webhook.js +24 -19
  142. package/dist/server/webhook.js.map +1 -1
  143. package/dist/server/websocket.d.ts +18 -3
  144. package/dist/server/websocket.d.ts.map +1 -1
  145. package/dist/server/websocket.js +45 -5
  146. package/dist/server/websocket.js.map +1 -1
  147. package/dist/server/ws-bun.d.ts.map +1 -1
  148. package/dist/server/ws-bun.js +7 -4
  149. package/dist/server/ws-bun.js.map +1 -1
  150. package/dist/server.d.ts +2 -0
  151. package/dist/server.d.ts.map +1 -1
  152. package/dist/server.js +2 -0
  153. package/dist/server.js.map +1 -1
  154. package/dist/single-copy.d.ts +5 -0
  155. package/dist/single-copy.d.ts.map +1 -1
  156. package/dist/single-copy.js +26 -17
  157. package/dist/single-copy.js.map +1 -1
  158. package/dist/tool-cli.d.ts +64 -0
  159. package/dist/tool-cli.d.ts.map +1 -0
  160. package/dist/tool-cli.js +495 -0
  161. package/dist/tool-cli.js.map +1 -0
  162. package/dist/transport-codec.d.ts.map +1 -1
  163. package/dist/transport-codec.js +2 -1
  164. package/dist/transport-codec.js.map +1 -1
  165. package/package.json +11 -1
  166. package/src/assurance.ts +1 -0
  167. package/src/binary.ts +17 -0
  168. package/src/edge-kit.ts +1 -0
  169. package/src/errors.ts +1 -0
  170. package/src/evidence.ts +8 -0
  171. package/src/file-type.ts +130 -0
  172. package/src/index.ts +3 -1
  173. package/src/internal/hook-origins.ts +10 -0
  174. package/src/internal/route-program.ts +261 -93
  175. package/src/reflection.ts +71 -7
  176. package/src/response-observer.ts +6 -0
  177. package/src/server/answer-step.ts +68 -0
  178. package/src/server/bare-error-lane.ts +9 -4
  179. package/src/server/base64-answers.ts +138 -0
  180. package/src/server/before-handler.ts +14 -0
  181. package/src/server/body-lane.ts +19 -9
  182. package/src/server/body-parser.ts +1 -5
  183. package/src/server/body.ts +96 -36
  184. package/src/server/core-copies.ts +5 -2
  185. package/src/server/http.ts +6 -15
  186. package/src/server/install.ts +3 -0
  187. package/src/server/json-response.ts +13 -0
  188. package/src/server/json-text.ts +34 -0
  189. package/src/server/multipart.ts +232 -100
  190. package/src/server/node-outcome.ts +4 -5
  191. package/src/server/proto-guard.ts +39 -4
  192. package/src/server/query.ts +7 -13
  193. package/src/server/repeated-prefix.ts +25 -0
  194. package/src/server/request-context.ts +5 -5
  195. package/src/server/respond.ts +47 -30
  196. package/src/server/response-contract-lane.ts +132 -9
  197. package/src/server/route-template.ts +24 -0
  198. package/src/server/runtime-core.ts +12 -2
  199. package/src/server/server-types.ts +18 -6
  200. package/src/server/server.ts +361 -112
  201. package/src/server/sse.ts +8 -6
  202. package/src/server/text-slots.ts +21 -0
  203. package/src/server/webhook.ts +29 -24
  204. package/src/server/websocket.ts +57 -8
  205. package/src/server/ws-bun.ts +7 -3
  206. package/src/server.ts +2 -0
  207. package/src/single-copy.ts +31 -15
  208. package/src/tool-cli.ts +584 -0
  209. package/src/transport-codec.ts +3 -1
@@ -6,12 +6,15 @@ import { assuranceDeclarationsOf, assuranceEvidenceFor, NIFRA_ASSURANCE_IDS, } f
6
6
  import { RouteCatalog } from "../internal/route-catalog.js";
7
7
  import { adoptRouteRuntimes, compileRouteOptions } from "../internal/route-compiler.js";
8
8
  import { compileRouteExecutionPlan, } from "../internal/route-execution.js";
9
- import { compileRouteProgram, executeRouteProgram } from "../internal/route-program.js";
9
+ import { authenticateMount, compileRouteProgram, executeRouteProgram, } from "../internal/route-program.js";
10
10
  import { isSameOriginRequest } from "../internal/same-origin.js";
11
11
  import { decodeRouteParams, expandOptionalParams, } from "../router/pattern.js";
12
12
  import { EMPTY_PARAMS, METHODS, Router } from "../router/router.js";
13
+ import { withAnswerSteps } from "./answer-step.js";
14
+ import { isAuthenticatedRequest, markAuthenticatedRequest, } from "./auth.js";
13
15
  import { emitRequestErrorLog, renderBareError } from "./bare-error-lane.js";
14
- import { assertByteLimit, markTransportCap, markTrustedBodyFraming, UNLIMITED_BODY_BYTES, } from "./body.js";
16
+ import { LAST_BEFORE_HANDLE } from "./before-handler.js";
17
+ import { assertByteLimit, clientBodyErrorCode, hasTrustedBodyFraming, markTransportCap, markTrustedBodyFraming, UNLIMITED_BODY_BYTES, } from "./body.js";
15
18
  import { NIFRA_PLATFORM_CLIENT_IP_DERIVED, resolveClientIp, } from "./client-ip.js";
16
19
  import { mergeRefusal, recordCopyVersion } from "./core-copies.js";
17
20
  import { hasLowercaseHeaderKeysMark, headerKeysAllLowercase, markLowercaseHeaderKeys, } from "./header-case.js";
@@ -25,9 +28,10 @@ import { needsResolving, resolveDotSegments } from "./request-target.js";
25
28
  import { applyStaticResponseHeaders, buildStaticResponseHeaders, claimHandedOver, fusedRespond, fusedRespondNoSet, handedBackResponse, recordResidentTexts, toResponse, writableResponse, } from "./respond.js";
26
29
  import { CONTEXT_REQUEST, CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, isResponseResult, requestOf, } from "./runtime-core.js";
27
30
  import { normalizeStaticResponseHeaders } from "./static-headers.js";
31
+ import { withTextSlots } from "./text-slots.js";
28
32
  import { plainValidationError } from "./validation.js";
29
33
  import { NIFRA_BACKEND_WS_RUNTIME } from "../mount.js";
30
- import { GET_WS_RUNTIME, INSTALL_EFFECT_LEDGER, INSTALL_IDEMPOTENCY, INSTALL_MCP, INSTALL_NODE_DIRECT, INSTALL_NOT_FOUND, INSTALL_RESPONSE_CONTRACT, INSTALL_RESPONSE_OBSERVER, INSTALL_SSE, INSTALL_WS, NODE_NATIVE_MOUNT, REPORT_ERROR, RESOLVE_NODE_MOUNT, } from "./install.js";
34
+ import { GET_WS_RUNTIME, INSTALL_EFFECT_LEDGER, INSTALL_IDEMPOTENCY, INSTALL_MCP, INSTALL_NODE_DIRECT, INSTALL_NOT_FOUND, INSTALL_RESPONSE_CONTRACT, INSTALL_RESPONSE_OBSERVER, INSTALL_SSE, INSTALL_WS, NODE_NATIVE_MOUNT, REPORT_ERROR, RESOLVE_NODE_MOUNT, SERVER_LIFECYCLE, } from "./install.js";
31
35
  import { jsonLogger } from "./logger.js";
32
36
  // CORS uses this marker to say its request hook only handles OPTIONS preflight. Bun can keep
33
37
  // ordinary registered routes on its native table and send unmatched OPTIONS requests through the
@@ -129,6 +133,8 @@ function requireMcpRuntime(runtime) {
129
133
  // existing importers keep resolving them from the server module.
130
134
  export { defineContextPlugin, defineIdentityPlugin, definePlugin, defineRouterPlugin, } from "./plugin.js";
131
135
  const DEFAULT_MAX_BODY_BYTES = 1_000_000;
136
+ const BODY_RESPONSE = (response) => response;
137
+ const BODY_ERROR = (error) => Promise.reject(error);
132
138
  const DEFAULT_DRAIN_MS = 10_000;
133
139
  const DRAIN_POLL_MS = 10;
134
140
  const STOP_HOOK_TIMEOUT_MS = 5_000;
@@ -171,16 +177,106 @@ function hasUnroutedParam(params) {
171
177
  * Bun, workerd and every `new Request()` parse it first. Only there does `fetch` read the URL up front.
172
178
  */
173
179
  const rawRequestTargets = typeof globalThis.Deno !== "undefined";
174
- /** `req` as Bun or workerd would have delivered it: the same request, its URL resolved as they parse it. */
175
- const withResolvedTarget = (req) => new Request(resolveDotSegments(req.url), req);
180
+ /**
181
+ * Bun parses the target only when it can read the Host. Otherwise `Request.url` is the target as sent:
182
+ * relative for a Host with a space or a byte past ASCII or for no Host at all, the raw authority
183
+ * for a port past 65535, and `new URL(req.url)` throws in the app. So Bun is held to the Host check
184
+ * Deno runs.
185
+ */
186
+ const unparsedBunTargets = typeof Bun !== "undefined";
187
+ /**
188
+ * `req` as Bun or workerd would have delivered it: the same request, its URL resolved as they parse it.
189
+ * `undefined` when the `Request` constructor refuses what the wire carried - a TRACE, CONNECT or TRACK,
190
+ * a lower-case `get` with a body - which {@link resolvedSource} routes instead.
191
+ */
192
+ function withResolvedTarget(req, url) {
193
+ let rebuilt;
194
+ try {
195
+ rebuilt = new Request(url, req);
196
+ }
197
+ catch {
198
+ return undefined;
199
+ }
200
+ // The same body, so the same framing: a runtime still delimited it.
201
+ if (hasTrustedBodyFraming(req))
202
+ markTrustedBodyFraming(rebuilt);
203
+ return rebuilt;
204
+ }
205
+ /**
206
+ * A request no `Request` can be rebuilt from, as a source carrying its resolved URL: it is routed on that
207
+ * URL and gets the 404 or 405 the other runtimes give, where rebuilding it threw out of `fetch`. A
208
+ * handler that reads `c.req` gets the request as Deno delivered it.
209
+ */
210
+ function resolvedSource(req, url) {
211
+ const source = {
212
+ method: req.method,
213
+ url,
214
+ get headers() {
215
+ return req.headers;
216
+ },
217
+ header: (name) => headerOf(req, name),
218
+ get body() {
219
+ return req.body;
220
+ },
221
+ arrayBuffer: () => req.arrayBuffer(),
222
+ json: () => req.json(),
223
+ bytes: () => req.bytes(),
224
+ request: req,
225
+ };
226
+ if (hasTrustedBodyFraming(req))
227
+ markTrustedBodyFraming(source);
228
+ return source;
229
+ }
176
230
  // A Host as RFC 9110 allows it: a reg-name or an IP literal, then an optional port. Deno builds
177
231
  // `Request.url` by appending the target to the Host as received, so `Host: x/admin#` turned a
178
232
  // request for `/public` into one for `/admin`. Node refuses such a Host before routing; so does this.
179
- const HOST_AUTHORITY = /^(?:\[[0-9a-f:.%]+\]|[\w.-]+)(?::\d{1,5})?$/i;
180
- // `headerOf`, not `req.headers`: on Deno that reads the Host alone instead of building every header.
181
- const invalidHost = (req) => {
233
+ const HOST_AUTHORITY = /^(?:\[[0-9a-f:.%]+\]|[\w.-]+)(?::(\d{1,5}))?$/i;
234
+ // Clients repeat one Host on every request; comparing to the last valid one is ~7x cheaper than the regex.
235
+ let lastValidHost = "";
236
+ // A port past 65535, or an IPv6 literal no URL parser takes (`[1:2]`), is refused as Node refuses it:
237
+ // no URL can be built on either, so resolving a dot segment against one threw out of `fetch`.
238
+ const invalidHost = (host) => {
239
+ if (host === lastValidHost)
240
+ return false;
241
+ const authority = HOST_AUTHORITY.exec(host);
242
+ if (authority === null)
243
+ return true;
244
+ if (authority[1] !== undefined && Number(authority[1]) > 65_535)
245
+ return true;
246
+ if (host.charCodeAt(0) === 91 /* [ */ && !URL.canParse(`http://${host}/`))
247
+ return true;
248
+ lastValidHost = host;
249
+ return false;
250
+ };
251
+ /**
252
+ * What a request is routed on in place of `req.url`, before anything reads it: `true` refuses its
253
+ * Host (400), a URL replaces the target, `false` keeps it. Deno hands the target over as sent, so
254
+ * its dot segments are resolved; so does Bun when HTTP/1.0 leaves the Host out, and its route table
255
+ * matched `/files/..` raw. Such a request is served as `localhost`, as Node and Deno serve it.
256
+ * `headerOf`, not `req.headers`: on Deno that reads the Host alone instead of building every
257
+ * header.
258
+ */
259
+ const requestTarget = (req) => {
260
+ if (!rawRequestTargets && !unparsedBunTargets)
261
+ return false;
182
262
  const host = headerOf(req, "host");
183
- return host !== null && !HOST_AUTHORITY.test(host);
263
+ if (host !== null && invalidHost(host)) {
264
+ if (rawRequestTargets)
265
+ return true;
266
+ // Bun served it anyway: it could not read the Host, so it left the target unparsed, or it
267
+ // built the URL on it - sent twice (joined with ", ", the URL on the first; RFC 9112 section
268
+ // 3.2 answers 400), percent-encoded (`%61.example` routed as `a.example`), or with a
269
+ // sub-delimiter (`a!b`). A request built in process keeps the URL its caller built, unless Bun
270
+ // would have built it so.
271
+ const routed = URL.parse(req.url)?.host;
272
+ return routed === undefined || URL.parse(`//${host.split(", ")[0]}`, req.url)?.host === routed;
273
+ }
274
+ if (rawRequestTargets)
275
+ return needsResolving(req.url) && resolveDotSegments(req.url);
276
+ // A request built in process carries an absolute URL whatever its headers say.
277
+ return (host === null &&
278
+ req.url.charCodeAt(0) === 47 /* / */ &&
279
+ resolveDotSegments(`http://localhost${req.url}`));
184
280
  };
185
281
  function normalizeMountPrefix(path) {
186
282
  if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
@@ -210,13 +306,17 @@ function mountPriorityOf(priority) {
210
306
  function compareMounts(a, b) {
211
307
  return b.priority - a.priority || b.path.length - a.path.length || a.order - b.order;
212
308
  }
309
+ /** Rebase a mounted request's path without losing the authentication mark used by child caches. */
213
310
  function stripMountPrefix(request, prefix) {
214
311
  if (prefix === "/")
215
312
  return request;
216
313
  const url = new URL(request.url);
217
314
  const rest = url.pathname.slice(prefix.length);
218
315
  url.pathname = rest === "" ? "/" : rest;
219
- return new Request(url.href, request);
316
+ const childRequest = new Request(url.href, request);
317
+ if (isAuthenticatedRequest(request))
318
+ markAuthenticatedRequest(childRequest);
319
+ return childRequest;
220
320
  }
221
321
  function responseSet(ctx) {
222
322
  return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS;
@@ -403,6 +503,17 @@ const recordScopedHook = (key, routesAtPush, kind) => {
403
503
  site: captureCallerSite(),
404
504
  });
405
505
  };
506
+ /** `next(value)` now, or once `value` settles. */
507
+ const thenMaybe = (value, next) => (value instanceof Promise ? value.then(next) : next(value));
508
+ /** A mount registered after an `authenticate()` stage runs it, so the stage covers something. */
509
+ const noteAuthenticatedMount = (key) => {
510
+ const state = hookAudit.get(key);
511
+ if (state === undefined)
512
+ return;
513
+ for (const site of state.sites)
514
+ if (site.kind === "authenticate")
515
+ site.routesAtPush = -1;
516
+ };
406
517
  /** Count routes a `merge()` adopted: they never run this server's order-scoped hooks, so a hook
407
518
  * followed only by merges covers nothing. */
408
519
  const noteMergedRoutes = (key, count) => {
@@ -555,6 +666,7 @@ export class Server {
555
666
  deadlineAdmissionOptions;
556
667
  gracefulSignals;
557
668
  stopHooks;
669
+ stoppingHooks;
558
670
  fetchMounts;
559
671
  mountOrder;
560
672
  /** Number of composed mounts that must run before the parent route table. Keeps legacy mounts off the hot path. */
@@ -632,9 +744,13 @@ export class Server {
632
744
  /** Body/raw response hooks need a framework-payload marker; keep that decision per app. */
633
745
  responseBodyTag;
634
746
  responseBodyOwners;
635
- finalizeResponse = (result, set, ctx) => toResponse(result, set, this.responseBodyTag, this.staticResponseHeaders,
636
- // Response hooks write next, and they run for this same request object.
637
- ctx !== undefined && this.onResponseHooks.length > 0 ? ctx[CONTEXT_REQUEST] : undefined);
747
+ // General lifecycles need no extra response bookkeeping when neither controls nor observers
748
+ // use it. Share the fused renderer after the lifecycle, including on body/write routes.
749
+ finalizeResponse = (result, set, ctx) => set === EMPTY_RESPONSE_CONTROLS && this.onResponseHooks.length === 0
750
+ ? fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders, true)
751
+ : toResponse(result, set, this.responseBodyTag, this.staticResponseHeaders,
752
+ // Response hooks write next, and they run for this same request object.
753
+ ctx !== undefined && this.onResponseHooks.length > 0 ? ctx[CONTEXT_REQUEST] : undefined, this.onResponseHooks.length === 0);
638
754
  responseRequests;
639
755
  /** A request the WebSocket handshake check ran the onRequest hooks on and then passed to HTTP →
640
756
  * what the hooks returned, so `fetch` routes that instead of running the hooks a second time. */
@@ -700,6 +816,7 @@ export class Server {
700
816
  this.gracefulSignals =
701
817
  options.gracefulSignals === true ? {} : options.gracefulSignals || undefined;
702
818
  this.stopHooks = [];
819
+ this.stoppingHooks = [];
703
820
  this.fetchMounts = [];
704
821
  this.mountOrder = 0;
705
822
  this.preRouteMountCount = 0;
@@ -770,10 +887,25 @@ export class Server {
770
887
  this.stopHooks.push(fn);
771
888
  return this;
772
889
  }
890
+ /**
891
+ * Register a callback awaited as soon as `stop()` begins, while the server still serves: readiness
892
+ * should fail from here, and work that outlives a request (a poller, a queue consumer) should stop
893
+ * taking more. Hung callbacks are bounded.
894
+ */
895
+ onStopping(fn) {
896
+ this.assertConfigurable("onStopping()");
897
+ if (typeof fn !== "function")
898
+ throw new TypeError("onStopping callback must be a function");
899
+ this.stoppingHooks.push(fn);
900
+ return this;
901
+ }
773
902
  /**
774
903
  * Mount a legacy fetch handler under a literal path prefix. Mounted handlers sit behind typed
775
904
  * routes, so routes can move one at a time while the remaining legacy surface stays live.
776
- * Mounted responses are outside Nifra's typed route and response-contract checks.
905
+ * Mounted responses are outside Nifra's typed route and response-contract checks. An
906
+ * `authenticate()` stage registered before the mount, or a required auth plugin built on one
907
+ * (`bearer()`, `jwt()`, `basicAuth()`, `apiKey()`), refuses a request before the handler sees it;
908
+ * mount before the stage to keep the legacy surface public.
777
909
  *
778
910
  * It carries HTTP only: a WebSocket upgrade under the prefix reaches the handler as an ordinary
779
911
  * request and never becomes a socket. Serve that path's sockets with `app.ws()` routes or a
@@ -792,7 +924,10 @@ export class Server {
792
924
  beforeRoutes: false,
793
925
  order: this.mountOrder++,
794
926
  opaque: options.opaque,
927
+ authStages: [...this.authStages],
795
928
  };
929
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production" && this.authStages.length > 0)
930
+ noteAuthenticatedMount(this);
796
931
  this.fetchMounts.push(mount);
797
932
  this.fetchMounts.sort(compareMounts);
798
933
  return this;
@@ -846,7 +981,10 @@ export class Server {
846
981
  order: this.mountOrder++,
847
982
  opaque: options.opaque,
848
983
  app,
984
+ authStages: [...this.authStages],
849
985
  };
986
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production" && this.authStages.length > 0)
987
+ noteAuthenticatedMount(this);
850
988
  // Mounts run before routes: a request under this prefix that no mount on the way strips comes back
851
989
  // here unchanged until the stack overflows, which takes the process down. Dev-time only, like the
852
990
  // hook audit: a production define folds this away, and the first dev request would hit it anyway.
@@ -888,7 +1026,8 @@ export class Server {
888
1026
  * (see {@link ServerOptions.unusedScopedHooks}).
889
1027
  */
890
1028
  /**
891
- * Register a dedicated authentication stage for subsequent routes.
1029
+ * Register a dedicated authentication stage for subsequent routes, and for the `mount()` and
1030
+ * `mountFetch()` handlers registered after it: a request it refuses never reaches them.
892
1031
  *
893
1032
  * Unlike `derive`, this stage receives a body-blind authentication capsule and is compiled before
894
1033
  * route validation. Keeping it separate prevents an unrelated derive from accidentally becoming a
@@ -950,7 +1089,13 @@ export class Server {
950
1089
  */
951
1090
  beforeHandle(fn) {
952
1091
  this.assertConfigurable("beforeHandle()");
953
- this.beforeHandleHooks.push(fn);
1092
+ const hooks = this.beforeHandleHooks;
1093
+ let at = hooks.length;
1094
+ if (!LAST_BEFORE_HANDLE.has(fn)) {
1095
+ while (at > 0 && LAST_BEFORE_HANDLE.has(hooks[at - 1]))
1096
+ at--;
1097
+ }
1098
+ hooks.splice(at, 0, fn);
954
1099
  if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
955
1100
  recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "beforeHandle");
956
1101
  tagHook(fn, "beforeHandle", this);
@@ -1451,6 +1596,7 @@ export class Server {
1451
1596
  throw new RouteConfigError("INVALID_PATH", `group(${JSON.stringify(prefix)}) route path must start with "/": ${JSON.stringify(path)}`);
1452
1597
  }
1453
1598
  prepareRoute(method, path, schema, handler) {
1599
+ schema = withTextSlots(schema);
1454
1600
  // Route provenance for the CLI's capability walk, which sets NIFRA_ROUTE_SITES while it loads the
1455
1601
  // app: the module that registered the route, read where a static scan cannot see the path.
1456
1602
  if (hookAuditRuntime &&
@@ -1480,6 +1626,15 @@ export class Server {
1480
1626
  defaultOnValidationError: this.defaultOnValidationError,
1481
1627
  }, method, path, schema, handler);
1482
1628
  const { pattern, bodyLimit, capabilities, routeDecorations, hasDecorations, idempotent, ledgered, responseContract, lanes, routeAssurance, } = compiled;
1629
+ // A schema that writes its answer its own way (`t.bytes()`) wraps every stage that can answer.
1630
+ const stages = withAnswerSteps(schema, method, path, {
1631
+ handler,
1632
+ beforeHandle: this.beforeHandleHooks,
1633
+ afterHandle: this.afterHandleHooks,
1634
+ onError: this.onErrorHooks,
1635
+ });
1636
+ handler = stages.handler;
1637
+ const { beforeHandle, afterHandle, onError } = stages;
1483
1638
  if (idempotent !== undefined && this.idempotencyRuntime !== undefined) {
1484
1639
  handler = this.idempotencyRuntime.wrapHandler(handler);
1485
1640
  }
@@ -1498,11 +1653,11 @@ export class Server {
1498
1653
  const fusedBodyRunner = fusedBody
1499
1654
  ? this.buildFusedBodyRunner(handler, schema?.body, hasDecorations ? routeDecorations : undefined, bodyLimit ?? UNLIMITED_BODY_BYTES)
1500
1655
  : isFusedBodyLifecycle
1501
- ? this.buildFusedBodyDeriveBeforeAfterRunner(handler, this.derives[0], this.beforeHandleHooks[0], this.afterHandleHooks[0], schema?.body, bodyLimit ?? UNLIMITED_BODY_BYTES)
1656
+ ? this.buildFusedBodyDeriveBeforeAfterRunner(handler, this.derives[0], beforeHandle[0], afterHandle[0], schema?.body, bodyLimit ?? UNLIMITED_BODY_BYTES)
1502
1657
  : isFusedLifecycle && lifecycleHookLane === "derive-before"
1503
- ? this.buildFusedDeriveBeforeRunner(handler, this.derives[0], this.beforeHandleHooks[0], undefined, schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1658
+ ? this.buildFusedDeriveBeforeRunner(handler, this.derives[0], beforeHandle[0], undefined, schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1504
1659
  : isFusedLifecycle && lifecycleHookLane === "derive-before-after"
1505
- ? this.buildFusedDeriveBeforeRunner(handler, this.derives[0], this.beforeHandleHooks[0], this.afterHandleHooks[0], schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1660
+ ? this.buildFusedDeriveBeforeRunner(handler, this.derives[0], beforeHandle[0], afterHandle[0], schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1506
1661
  : undefined;
1507
1662
  const fusedWeb = bare && this.aroundHooks.length === 0
1508
1663
  ? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler), bodyLimit ?? UNLIMITED_BODY_BYTES)
@@ -1511,20 +1666,20 @@ export class Server {
1511
1666
  : fusedBody
1512
1667
  ? this.buildFusedBodyWeb(fusedBodyRunner)
1513
1668
  : isFusedBodyLifecycle
1514
- ? this.buildFusedBodyDeriveBeforeAfter(handler, this.derives[0], this.beforeHandleHooks[0], this.afterHandleHooks[0], schema?.body, bodyLimit ?? UNLIMITED_BODY_BYTES)
1669
+ ? this.buildFusedBodyDeriveBeforeAfter(handler, this.derives[0], beforeHandle[0], afterHandle[0], schema?.body, bodyLimit ?? UNLIMITED_BODY_BYTES)
1515
1670
  : isFusedLifecycle && lifecycleHookLane === "derive-before"
1516
- ? this.buildFusedDeriveBefore(handler, this.derives[0], this.beforeHandleHooks[0], schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1671
+ ? this.buildFusedDeriveBefore(handler, this.derives[0], beforeHandle[0], schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1517
1672
  : isFusedLifecycle && lifecycleHookLane === "derive-before-after"
1518
- ? this.buildFusedDeriveBeforeAfter(handler, this.derives[0], this.beforeHandleHooks[0], this.afterHandleHooks[0], schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1673
+ ? this.buildFusedDeriveBeforeAfter(handler, this.derives[0], beforeHandle[0], afterHandle[0], schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1519
1674
  : undefined;
1520
1675
  const program = compileRouteProgram({
1521
1676
  schema,
1522
1677
  handler: handler,
1523
1678
  derives: this.derives,
1524
1679
  authStages: compiled.authStages,
1525
- beforeHandle: this.beforeHandleHooks,
1526
- afterHandle: this.afterHandleHooks,
1527
- onError: this.onErrorHooks,
1680
+ beforeHandle,
1681
+ afterHandle,
1682
+ onError,
1528
1683
  decorations: routeDecorations,
1529
1684
  hasDecorations,
1530
1685
  bodySchema: schema?.body,
@@ -1552,9 +1707,9 @@ export class Server {
1552
1707
  authStages: [...compiled.authStages],
1553
1708
  decorations: routeDecorations,
1554
1709
  hasDecorations,
1555
- beforeHandle: [...this.beforeHandleHooks],
1556
- afterHandle: [...this.afterHandleHooks],
1557
- onError: [...this.onErrorHooks],
1710
+ beforeHandle: [...beforeHandle],
1711
+ afterHandle: [...afterHandle],
1712
+ onError: [...onError],
1558
1713
  lifecycleHookLane: lanes.lifecycleHookLane,
1559
1714
  around: [...this.aroundHooks],
1560
1715
  execution,
@@ -1940,6 +2095,7 @@ export class Server {
1940
2095
  // Cleanup the source registered (a pool, a queue consumer) belongs to the server that now runs
1941
2096
  // its routes; dropping it would leak the resource past `stop()`.
1942
2097
  this.stopHooks.push(...source.stopHooks);
2098
+ this.stoppingHooks.push(...source.stoppingHooks);
1943
2099
  this.mcpResourceList.push(...source.mcpResourceList);
1944
2100
  this.mcpPromptList.push(...source.mcpPromptList);
1945
2101
  }
@@ -2029,11 +2185,14 @@ export class Server {
2029
2185
  fetch(req, platform) {
2030
2186
  // Deno's `Request.url` is the target as sent; Bun, workerd and every `new Request()` parse it.
2031
2187
  // Route what the parsed URL says, so `/users/../admin` is `/admin` on every runtime.
2032
- if (rawRequestTargets) {
2033
- if (invalidHost(req))
2034
- return jsonError(400, "invalid_host");
2035
- if (needsResolving(req.url))
2036
- req = withResolvedTarget(req);
2188
+ const target = requestTarget(req);
2189
+ if (target === true)
2190
+ return jsonError(400, "invalid_host");
2191
+ if (target !== false) {
2192
+ const rebuilt = withResolvedTarget(req, target);
2193
+ if (rebuilt === undefined)
2194
+ return this.fetchSource(resolvedSource(req, target), platform);
2195
+ req = rebuilt;
2037
2196
  }
2038
2197
  // An edge deployment whose only ingress is this method declares its requests runtime-framed at
2039
2198
  // construction; everything else stays on the delivered-byte check (see `trustBodyFraming`).
@@ -2151,7 +2310,7 @@ export class Server {
2151
2310
  // The mount the ordinary path reaches first: every `mount()` runs before any `mountFetch()`,
2152
2311
  // whatever their prefix lengths. A 404 fallthrough chain stays on the ordinary path.
2153
2312
  const mount = this.firstMountUnder(parts.pathname, true) ?? this.firstMountUnder(parts.pathname, false);
2154
- if (mount === undefined || mount.fallbackOn404)
2313
+ if (mount === undefined || mount.fallbackOn404 || mount.authStages.length > 0)
2155
2314
  return undefined;
2156
2315
  const candidate = mount.handler[NODE_NATIVE_MOUNT];
2157
2316
  return typeof candidate === "function"
@@ -2435,12 +2594,16 @@ export class Server {
2435
2594
  // The adapter answers a pass by handing this same object to `fetch`.
2436
2595
  const received = req;
2437
2596
  // The handshake routes the path `fetch` would: Host checked, dot segments resolved (see `fetch`).
2438
- if (rawRequestTargets) {
2439
- if (invalidHost(req)) {
2440
- return { kind: "reject", response: this.wrapWebResponse(jsonError(400, "invalid_host")) };
2441
- }
2442
- if (needsResolving(req.url))
2443
- req = withResolvedTarget(req);
2597
+ const target = requestTarget(req);
2598
+ if (target === true) {
2599
+ return { kind: "reject", response: this.wrapWebResponse(jsonError(400, "invalid_host")) };
2600
+ }
2601
+ if (target !== false) {
2602
+ // A GET carrying a body cannot be rebuilt; `fetch` routes it as the ordinary request it is.
2603
+ const rebuilt = withResolvedTarget(req, target);
2604
+ if (rebuilt === undefined)
2605
+ return WS_PASS;
2606
+ req = rebuilt;
2444
2607
  }
2445
2608
  // The handshake's caller is the one `fetch` derives: hooks and `upgrade()` see the trusted IP.
2446
2609
  platform = this.trustedPlatform(req, platform);
@@ -2545,26 +2708,35 @@ export class Server {
2545
2708
  }
2546
2709
  if (selected === undefined)
2547
2710
  return WS_PASS;
2548
- let childRequest = request;
2549
- if (selected.stripPrefix) {
2550
- childRequest = stripMountPrefix(request, selected.path);
2551
- selected.wsPassed ??= new WeakMap();
2552
- selected.wsPassed.set(request, childRequest);
2553
- }
2554
- let child;
2555
- try {
2556
- child = selected.resolveWebSocketUpgrade(childRequest, platform);
2557
- }
2558
- catch {
2559
- return refuse(jsonError(500, "internal_error"));
2560
- }
2711
+ const mount = selected;
2712
+ const upgrade = () => {
2713
+ let childRequest = request;
2714
+ if (mount.stripPrefix) {
2715
+ childRequest = stripMountPrefix(request, mount.path);
2716
+ mount.wsPassed ??= new WeakMap();
2717
+ mount.wsPassed.set(request, childRequest);
2718
+ }
2719
+ try {
2720
+ return mount.resolveWebSocketUpgrade(childRequest, platform);
2721
+ }
2722
+ catch {
2723
+ return refuse(jsonError(500, "internal_error"));
2724
+ }
2725
+ };
2561
2726
  const settle = (outcome) => outcome.kind === "pass" ||
2562
2727
  (outcome.kind === "reject" && outcome.response.status === 404 && selected.fallbackOn404)
2563
2728
  ? dispatch(selectedIndex + 1)
2564
2729
  : outcome.kind === "reject"
2565
2730
  ? refuse(outcome.response)
2566
2731
  : outcome;
2567
- return child instanceof Promise ? child.then(settle) : settle(child);
2732
+ const run = () => {
2733
+ const child = upgrade();
2734
+ return child instanceof Promise ? child.then(settle) : settle(child);
2735
+ };
2736
+ // Only a child's miss may fall through, never an authentication refusal.
2737
+ return mount.authStages.length === 0
2738
+ ? run()
2739
+ : thenMaybe(authenticateMount(mount.authStages, request, platform, this.logger), (refusal) => (refusal === undefined ? run() : refuse(refusal)));
2568
2740
  };
2569
2741
  return dispatch(0);
2570
2742
  };
@@ -2857,8 +3029,8 @@ export class Server {
2857
3029
  * routes with validation/hooks keep the full compiled route program, unchanged.
2858
3030
  */
2859
3031
  routeAndRun(source, platform, finalize, wrapResponse, onTimeout, webFast) {
2860
- // Routing only needs the pathname. Scan the URL once but keep the two slices in locals instead
2861
- // of allocating the `{ pathname, search }` pair returned by the public helper on every request.
3032
+ // Native delimiter searches keep path/query splitting out of a character-by-character JS loop.
3033
+ // Keep the slices in locals instead of allocating the public helper's pathname/search pair.
2862
3034
  let pathname;
2863
3035
  let search;
2864
3036
  // Read the source's split ONCE. `urlParts` is an accessor on the Node request sources that
@@ -2879,22 +3051,11 @@ export class Server {
2879
3051
  search = "";
2880
3052
  }
2881
3053
  else {
2882
- let pathEnd = rawUrl.length;
2883
- let searchStart = -1;
2884
- let searchEnd = rawUrl.length;
2885
- for (let i = start; i < rawUrl.length; i++) {
2886
- const c = rawUrl.charCodeAt(i);
2887
- if (c === 63 /* ? */ && searchStart === -1) {
2888
- pathEnd = i;
2889
- searchStart = i;
2890
- }
2891
- else if (c === 35 /* # */) {
2892
- if (searchStart === -1)
2893
- pathEnd = i;
2894
- searchEnd = i;
2895
- break;
2896
- }
2897
- }
3054
+ const fragment = rawUrl.indexOf("#", start);
3055
+ const searchEnd = fragment === -1 ? rawUrl.length : fragment;
3056
+ const question = rawUrl.indexOf("?", start);
3057
+ const searchStart = question !== -1 && question < searchEnd ? question : -1;
3058
+ const pathEnd = searchStart === -1 ? searchEnd : searchStart;
2898
3059
  pathname = rawUrl.slice(start, pathEnd);
2899
3060
  search = searchStart === -1 ? "" : rawUrl.slice(searchStart, searchEnd);
2900
3061
  }
@@ -2967,16 +3128,28 @@ export class Server {
2967
3128
  }
2968
3129
  if (mount === undefined)
2969
3130
  return fallback ?? jsonError(404, "not_found");
2970
- const childRequest = mount.stripPrefix
2971
- ? (mount.wsPassed?.get(request) ?? stripMountPrefix(request, mount.path))
2972
- : request;
2973
- const outcome = mount.handler(childRequest, platform);
2974
- if (!replayable || !mount.fallbackOn404)
2975
- return outcome;
2976
- if (outcome instanceof Promise) {
2977
- return outcome.then((response) => response.status === 404 ? dispatch(index + 1, response) : response);
2978
- }
2979
- return outcome.status === 404 ? dispatch(index + 1, outcome) : outcome;
3131
+ const selected = mount;
3132
+ const run = () => {
3133
+ const reused = selected.stripPrefix ? selected.wsPassed?.get(request) : undefined;
3134
+ const childRequest = selected.stripPrefix
3135
+ ? (reused ?? stripMountPrefix(request, selected.path))
3136
+ : request;
3137
+ // HTTP authentication may have marked the original since the WebSocket pass.
3138
+ if (reused !== undefined && isAuthenticatedRequest(request)) {
3139
+ markAuthenticatedRequest(reused);
3140
+ }
3141
+ const outcome = selected.handler(childRequest, platform);
3142
+ if (!replayable || !selected.fallbackOn404)
3143
+ return outcome;
3144
+ if (outcome instanceof Promise) {
3145
+ return outcome.then((response) => response.status === 404 ? dispatch(index + 1, response) : response);
3146
+ }
3147
+ return outcome.status === 404 ? dispatch(index + 1, outcome) : outcome;
3148
+ };
3149
+ // Keep gate refusals outside the child's 404 fallthrough path.
3150
+ return mount.authStages.length === 0
3151
+ ? run()
3152
+ : thenMaybe(authenticateMount(mount.authStages, request, platform, this.logger), (refusal) => refusal ?? run());
2980
3153
  };
2981
3154
  return dispatch(first);
2982
3155
  }
@@ -3861,7 +4034,7 @@ export class Server {
3861
4034
  // The thrown-value contract lives in `bare-error-lane.ts`, one source of truth for both this
3862
4035
  // lane and the fused body runner. `responseSet` and the logger are injected because they reach
3863
4036
  // into class state; the closure is allocated only on the throw path, never on the hot lane.
3864
- return renderBareError(err, ctx, finalize, responseSet, (e, c) => this.logRequestError(e, c), () => this.internalErrorResponse(wrapResponse));
4037
+ return renderBareError(err, ctx, finalize, responseSet, (e, c) => this.logRequestError(e, c), wrapResponse);
3865
4038
  }
3866
4039
  /**
3867
4040
  * Keep the ordinary thrown-error response independent from the caught value. Deliberate Response
@@ -3939,7 +4112,7 @@ export class Server {
3939
4112
  }
3940
4113
  // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
3941
4114
  runBodyOnly(entry, source, ctx, finalize, wrapResponse) {
3942
- 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), entry.schema?.body);
4115
+ 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), entry.schema?.body, this.formOrigins);
3943
4116
  }
3944
4117
  /** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
3945
4118
  * streaming fallback. A method (not per-request closures) so the hot path allocates nothing
@@ -4193,7 +4366,15 @@ export class Server {
4193
4366
  return finalize(result, responseSet(ctx), ctx);
4194
4367
  const checked = contract.runtime.check(contract.definition, result, ctx);
4195
4368
  if (checked instanceof Promise) {
4196
- return checked.then((outcome) => this.finishContractOutcome(ctx, finalize, wrapResponse, outcome), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
4369
+ return checked.then((outcome) => {
4370
+ // The render runs outside the try below: a throw (a BigInt, a cycle) must still reach onError.
4371
+ try {
4372
+ return this.finishContractOutcome(ctx, finalize, wrapResponse, outcome);
4373
+ }
4374
+ catch (err) {
4375
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
4376
+ }
4377
+ }, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
4197
4378
  }
4198
4379
  return this.finishContractOutcome(ctx, finalize, wrapResponse, checked);
4199
4380
  }
@@ -4238,6 +4419,9 @@ export class Server {
4238
4419
  if (err instanceof Response || isResponseResult(err))
4239
4420
  return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, err);
4240
4421
  if (entry.onError.length === 0) {
4422
+ const clientCode = clientBodyErrorCode(err);
4423
+ if (clientCode !== undefined)
4424
+ return wrapResponse(plainError(400, clientCode));
4241
4425
  // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
4242
4426
  // (redacting) logger. Body-read failures and around-hook failures land here too.
4243
4427
  this.logRequestError(err, ctx);
@@ -4297,6 +4481,9 @@ export class Server {
4297
4481
  return wrapResponse(plainError(500, "internal_error"));
4298
4482
  }
4299
4483
  }
4484
+ const clientCode = clientBodyErrorCode(err);
4485
+ if (clientCode !== undefined)
4486
+ return wrapResponse(plainError(400, clientCode));
4300
4487
  this.logRequestError(err, ctx);
4301
4488
  return fallback === undefined ? wrapResponse(plainError(500, "internal_error")) : fallback();
4302
4489
  }
@@ -4311,27 +4498,35 @@ export class Server {
4311
4498
  // second continuation, while retaining the exact bounded-read, content-type, and poisoning
4312
4499
  // contracts shared by every body route.
4313
4500
  const validate = (parsed) => {
4314
- const validation = bodySchema["~standard"].validate(parsed);
4315
- return validation instanceof Promise
4316
- ? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, "body"))
4317
- : this.applyLifecycleValidation(entry, validation, ctx, "body");
4501
+ // Reject outside the parser's JSON catch so a custom validator throw reaches onError.
4502
+ try {
4503
+ const validation = bodySchema["~standard"].validate(parsed);
4504
+ return validation instanceof Promise
4505
+ ? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, "body"))
4506
+ : this.applyLifecycleValidation(entry, validation, ctx, "body");
4507
+ }
4508
+ catch (error) {
4509
+ return Promise.reject(error);
4510
+ }
4318
4511
  };
4319
- return readBodyFramed(req, bodyLimit ?? UNLIMITED_BODY_BYTES, this.protoPoisoning, validate, (response) => response, (error) => Promise.reject(error), bodySchema, this.formOrigins);
4512
+ return readBodyFramed(req, bodyLimit ?? UNLIMITED_BODY_BYTES, this.protoPoisoning, validate, BODY_RESPONSE, BODY_ERROR, bodySchema, this.formOrigins);
4320
4513
  }
4321
4514
  /** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
4322
4515
  * before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
4323
4516
  applyLifecycleValidation(entry, result, ctx, kind) {
4324
- const assign = (value) => {
4325
- ;
4326
- ctx[kind] = value;
4327
- };
4328
4517
  if (result.issues === undefined) {
4329
- assign(result.value);
4518
+ // biome-ignore lint/plugin/noChainedTypeAssertions lint/plugin/requireSafetyCommentForTypeAssertion: RawContext carries every ProgramValidationKind slot; this instruction assigns its validated value before handlers run.
4519
+ ;
4520
+ ctx[kind] = result.value;
4330
4521
  return undefined;
4331
4522
  }
4332
4523
  const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError;
4333
4524
  if (hook === undefined)
4334
4525
  return plainValidationError(result.issues);
4526
+ const assign = (value) => {
4527
+ ;
4528
+ ctx[kind] = value;
4529
+ };
4335
4530
  const attempted = hook(result.issues, ctx, kind);
4336
4531
  if (attempted instanceof Promise) {
4337
4532
  return attempted.then((recovery) => this.finishLifecycleValidationRecovery(entry, kind, result.issues, recovery, assign));
@@ -4364,13 +4559,19 @@ export class Server {
4364
4559
  // the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
4365
4560
  // the portable/adapted source contract (those sources are never marked).
4366
4561
  const markFramed = (request) => markTrustedBodyFraming(request);
4367
- // The fused native lane bypasses `runMatched`, so the route's transport byte cap must be
4562
+ // The direct native lanes bypass `runMatched`, so the route's transport byte cap must be
4368
4563
  // marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
4369
- // reads uncapped. The non-fused branches go through `fetchMatched` -> `runMatched`, which marks.
4564
+ // reads uncapped. The fallback branches go through `fetchMatched` -> `runMatched`, which marks.
4370
4565
  const bodyLimit = entry.schema?.body === undefined || entry.program.authBeforeValidation
4371
4566
  ? entry.bodyLimit
4372
4567
  : undefined;
4373
- const inner = fused;
4568
+ // The complete plan already preserves general validation, hooks, recovery, around, and ledgers.
4569
+ // When native admission is safe, use it directly even if the route has no closed-shape fusion.
4570
+ // Idempotency still needs runMatched's request preparation and is excluded from the table.
4571
+ const inner = fused ??
4572
+ (signal && budget
4573
+ ? (source, params, search, activeSignal, activeBudget, platform, nativeContext) => entry.execution.run(this, entry, source, params, search, activeSignal, activeBudget, platform, nativeContext, this.finalizeResponse, this.wrapWebResponse)
4574
+ : undefined);
4374
4575
  const capped = inner === undefined || bodyLimit === undefined
4375
4576
  ? inner
4376
4577
  : (source, params, search, fusedSignal, fusedBudget, platform, nativeContext) => {
@@ -4530,7 +4731,9 @@ export class Server {
4530
4731
  }
4531
4732
  const paramNames = pattern.paramNames;
4532
4733
  const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined;
4533
- tableAt(path, pattern.segments)[method] = this.compileBunNativeHandler(entry, paramNames, fused, unboundedSignal, unboundedBudget, peer, fallback);
4734
+ const handler = this.compileBunNativeHandler(entry, paramNames, fused, unboundedSignal, unboundedBudget, peer, fallback);
4735
+ // The table matched the target as sent; one `fetch` refuses or rewrites goes through it.
4736
+ tableAt(path, pattern.segments)[method] = (request, server) => requestTarget(request) ? fallback(request, server) : handler(request, server);
4534
4737
  count += 1;
4535
4738
  if (paramNames.length > 0) {
4536
4739
  below.add(pattern.segments.length + method);
@@ -4713,11 +4916,12 @@ export class Server {
4713
4916
  * The Bun semantics: poll `pendingRequests` (awaiting
4714
4917
  * `stop()`'s promise drops in-flight requests), and decide graceful-vs-forced in
4715
4918
  * ONE call (Bun can't escalate an already-graceful `stop()` to a forced close).
4716
- * New connections may be accepted during the drain window; in a real deploy the
4717
- * load balancer has already stopped routing here, and `drainMs` bounds it. Open WebSockets
4718
- * get a 1001 close at once and the same window to finish it, before any `onStop` hook runs.
4919
+ * New connections may be accepted during the drain window; `delayMs` is the time a load balancer
4920
+ * gets to stop routing here first. The `onStopping` hooks run before that delay, the `onStop`
4921
+ * hooks after the drain. Open WebSockets get a 1001 close when the drain starts and the same
4922
+ * window to finish it; a socket opened after `stop()` began is closed at once.
4719
4923
  */
4720
- async stop({ drainMs = DEFAULT_DRAIN_MS } = {}) {
4924
+ async stop({ drainMs = DEFAULT_DRAIN_MS, delayMs = 0 } = {}) {
4721
4925
  const onSignal = this.signalHandler;
4722
4926
  if (onSignal !== undefined) {
4723
4927
  this.signalHandler = undefined;
@@ -4727,6 +4931,9 @@ export class Server {
4727
4931
  if (server === undefined)
4728
4932
  return;
4729
4933
  this.bunServer = undefined;
4934
+ await this[SERVER_LIFECYCLE]("stopping");
4935
+ if (delayMs > 0)
4936
+ await Bun.sleep(delayMs);
4730
4937
  const sockets = this.bunSockets;
4731
4938
  this.bunSockets = undefined;
4732
4939
  if (sockets !== undefined)
@@ -4738,14 +4945,25 @@ export class Server {
4738
4945
  await Bun.sleep(DRAIN_POLL_MS);
4739
4946
  }
4740
4947
  server.stop(busy()); // force-close iff stragglers remain past the deadline
4741
- if (this.stopHooks.length === 0)
4948
+ await this[SERVER_LIFECYCLE]("stopped");
4949
+ }
4950
+ /**
4951
+ * @internal Run the `onStopping` or the `onStop` hooks, each bounded. A runtime adapter that drains
4952
+ * its own listener calls it on either side of the drain, as `stop()` does here.
4953
+ */
4954
+ async [SERVER_LIFECYCLE](phase) {
4955
+ const hooks = phase === "stopping" ? this.stoppingHooks : this.stopHooks;
4956
+ if (hooks.length === 0)
4742
4957
  return;
4743
- const callbacks = this.stopHooks.map((hook) => Promise.resolve().then(hook));
4744
- const settled = Promise.allSettled(callbacks);
4958
+ let timer;
4745
4959
  await Promise.race([
4746
- settled,
4747
- new Promise((resolve) => setTimeout(resolve, STOP_HOOK_TIMEOUT_MS)),
4960
+ Promise.allSettled(hooks.map((hook) => Promise.resolve().then(hook))),
4961
+ new Promise((resolve) => {
4962
+ timer = setTimeout(resolve, STOP_HOOK_TIMEOUT_MS);
4963
+ }),
4748
4964
  ]);
4965
+ // A pending bound would hold the event loop open after every hook settled.
4966
+ clearTimeout(timer);
4749
4967
  }
4750
4968
  installSignalHandlers(drain) {
4751
4969
  // Drain, then let the process exit naturally - the stopped server no longer