@nifrajs/core 5.5.1 → 5.6.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 (138) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/dist/assurance.d.ts.map +1 -1
  3. package/dist/assurance.js.map +1 -1
  4. package/dist/evidence.d.ts +2 -0
  5. package/dist/evidence.d.ts.map +1 -1
  6. package/dist/evidence.js +2 -0
  7. package/dist/evidence.js.map +1 -1
  8. package/dist/file-type.d.ts +30 -0
  9. package/dist/file-type.d.ts.map +1 -0
  10. package/dist/file-type.js +118 -0
  11. package/dist/file-type.js.map +1 -0
  12. package/dist/index.d.ts +2 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +2 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/internal/route-program.d.ts +11 -0
  17. package/dist/internal/route-program.d.ts.map +1 -1
  18. package/dist/internal/route-program.js +133 -85
  19. package/dist/internal/route-program.js.map +1 -1
  20. package/dist/reflection.d.ts +14 -1
  21. package/dist/reflection.d.ts.map +1 -1
  22. package/dist/reflection.js +34 -7
  23. package/dist/reflection.js.map +1 -1
  24. package/dist/response-observer.d.ts +1 -0
  25. package/dist/response-observer.d.ts.map +1 -1
  26. package/dist/response-observer.js +1 -0
  27. package/dist/response-observer.js.map +1 -1
  28. package/dist/server/bare-error-lane.d.ts +4 -1
  29. package/dist/server/bare-error-lane.d.ts.map +1 -1
  30. package/dist/server/bare-error-lane.js +9 -3
  31. package/dist/server/bare-error-lane.js.map +1 -1
  32. package/dist/server/before-handler.d.ts +10 -0
  33. package/dist/server/before-handler.d.ts.map +1 -0
  34. package/dist/server/before-handler.js +12 -0
  35. package/dist/server/before-handler.js.map +1 -0
  36. package/dist/server/body-lane.d.ts.map +1 -1
  37. package/dist/server/body-lane.js +8 -2
  38. package/dist/server/body-lane.js.map +1 -1
  39. package/dist/server/body-parser.d.ts.map +1 -1
  40. package/dist/server/body-parser.js +2 -5
  41. package/dist/server/body-parser.js.map +1 -1
  42. package/dist/server/body.d.ts +24 -8
  43. package/dist/server/body.d.ts.map +1 -1
  44. package/dist/server/body.js +86 -33
  45. package/dist/server/body.js.map +1 -1
  46. package/dist/server/core-copies.js +1 -1
  47. package/dist/server/http.d.ts.map +1 -1
  48. package/dist/server/http.js +6 -17
  49. package/dist/server/http.js.map +1 -1
  50. package/dist/server/multipart.d.ts +3 -0
  51. package/dist/server/multipart.d.ts.map +1 -1
  52. package/dist/server/multipart.js +226 -97
  53. package/dist/server/multipart.js.map +1 -1
  54. package/dist/server/proto-guard.d.ts.map +1 -1
  55. package/dist/server/proto-guard.js +39 -4
  56. package/dist/server/proto-guard.js.map +1 -1
  57. package/dist/server/query.d.ts.map +1 -1
  58. package/dist/server/query.js +8 -14
  59. package/dist/server/query.js.map +1 -1
  60. package/dist/server/repeated-prefix.d.ts +17 -0
  61. package/dist/server/repeated-prefix.d.ts.map +1 -0
  62. package/dist/server/repeated-prefix.js +17 -0
  63. package/dist/server/repeated-prefix.js.map +1 -0
  64. package/dist/server/request-context.d.ts.map +1 -1
  65. package/dist/server/request-context.js +1 -3
  66. package/dist/server/request-context.js.map +1 -1
  67. package/dist/server/respond.d.ts +1 -3
  68. package/dist/server/respond.d.ts.map +1 -1
  69. package/dist/server/respond.js +21 -16
  70. package/dist/server/respond.js.map +1 -1
  71. package/dist/server/response-contract-lane.d.ts +12 -2
  72. package/dist/server/response-contract-lane.d.ts.map +1 -1
  73. package/dist/server/response-contract-lane.js +135 -8
  74. package/dist/server/response-contract-lane.js.map +1 -1
  75. package/dist/server/runtime-core.d.ts +7 -0
  76. package/dist/server/runtime-core.d.ts.map +1 -1
  77. package/dist/server/runtime-core.js +8 -1
  78. package/dist/server/runtime-core.js.map +1 -1
  79. package/dist/server/server-types.d.ts +3 -2
  80. package/dist/server/server-types.d.ts.map +1 -1
  81. package/dist/server/server.d.ts +6 -2
  82. package/dist/server/server.d.ts.map +1 -1
  83. package/dist/server/server.js +239 -78
  84. package/dist/server/server.js.map +1 -1
  85. package/dist/server/sse.d.ts +1 -1
  86. package/dist/server/sse.d.ts.map +1 -1
  87. package/dist/server/sse.js +5 -4
  88. package/dist/server/sse.js.map +1 -1
  89. package/dist/server/webhook.d.ts +1 -1
  90. package/dist/server/webhook.d.ts.map +1 -1
  91. package/dist/server/webhook.js +24 -19
  92. package/dist/server/webhook.js.map +1 -1
  93. package/dist/server/websocket.d.ts +18 -3
  94. package/dist/server/websocket.d.ts.map +1 -1
  95. package/dist/server/websocket.js +45 -5
  96. package/dist/server/websocket.js.map +1 -1
  97. package/dist/server/ws-bun.d.ts.map +1 -1
  98. package/dist/server/ws-bun.js +7 -4
  99. package/dist/server/ws-bun.js.map +1 -1
  100. package/dist/server.d.ts +1 -0
  101. package/dist/server.d.ts.map +1 -1
  102. package/dist/server.js +1 -0
  103. package/dist/server.js.map +1 -1
  104. package/dist/tool-cli.d.ts +64 -0
  105. package/dist/tool-cli.d.ts.map +1 -0
  106. package/dist/tool-cli.js +495 -0
  107. package/dist/tool-cli.js.map +1 -0
  108. package/package.json +11 -1
  109. package/src/assurance.ts +1 -0
  110. package/src/evidence.ts +4 -0
  111. package/src/file-type.ts +130 -0
  112. package/src/index.ts +2 -1
  113. package/src/internal/route-program.ts +261 -93
  114. package/src/reflection.ts +53 -7
  115. package/src/response-observer.ts +6 -0
  116. package/src/server/bare-error-lane.ts +9 -4
  117. package/src/server/before-handler.ts +14 -0
  118. package/src/server/body-lane.ts +19 -9
  119. package/src/server/body-parser.ts +1 -5
  120. package/src/server/body.ts +96 -36
  121. package/src/server/core-copies.ts +1 -1
  122. package/src/server/http.ts +6 -15
  123. package/src/server/multipart.ts +232 -100
  124. package/src/server/proto-guard.ts +39 -4
  125. package/src/server/query.ts +7 -13
  126. package/src/server/repeated-prefix.ts +25 -0
  127. package/src/server/request-context.ts +1 -3
  128. package/src/server/respond.ts +23 -15
  129. package/src/server/response-contract-lane.ts +130 -8
  130. package/src/server/runtime-core.ts +10 -1
  131. package/src/server/server-types.ts +3 -2
  132. package/src/server/server.ts +265 -76
  133. package/src/server/sse.ts +6 -5
  134. package/src/server/webhook.ts +29 -24
  135. package/src/server/websocket.ts +57 -8
  136. package/src/server/ws-bun.ts +7 -3
  137. package/src/server.ts +1 -0
  138. package/src/tool-cli.ts +584 -0
@@ -6,12 +6,13 @@ 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
13
  import { emitRequestErrorLog, renderBareError } from "./bare-error-lane.js";
14
- import { assertByteLimit, markTransportCap, markTrustedBodyFraming, UNLIMITED_BODY_BYTES, } from "./body.js";
14
+ import { LAST_BEFORE_HANDLE } from "./before-handler.js";
15
+ import { assertByteLimit, clientBodyErrorCode, hasTrustedBodyFraming, markTransportCap, markTrustedBodyFraming, UNLIMITED_BODY_BYTES, } from "./body.js";
15
16
  import { NIFRA_PLATFORM_CLIENT_IP_DERIVED, resolveClientIp, } from "./client-ip.js";
16
17
  import { mergeRefusal, recordCopyVersion } from "./core-copies.js";
17
18
  import { hasLowercaseHeaderKeysMark, headerKeysAllLowercase, markLowercaseHeaderKeys, } from "./header-case.js";
@@ -129,6 +130,8 @@ function requireMcpRuntime(runtime) {
129
130
  // existing importers keep resolving them from the server module.
130
131
  export { defineContextPlugin, defineIdentityPlugin, definePlugin, defineRouterPlugin, } from "./plugin.js";
131
132
  const DEFAULT_MAX_BODY_BYTES = 1_000_000;
133
+ const BODY_RESPONSE = (response) => response;
134
+ const BODY_ERROR = (error) => Promise.reject(error);
132
135
  const DEFAULT_DRAIN_MS = 10_000;
133
136
  const DRAIN_POLL_MS = 10;
134
137
  const STOP_HOOK_TIMEOUT_MS = 5_000;
@@ -171,16 +174,106 @@ function hasUnroutedParam(params) {
171
174
  * Bun, workerd and every `new Request()` parse it first. Only there does `fetch` read the URL up front.
172
175
  */
173
176
  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);
177
+ /**
178
+ * Bun parses the target only when it can read the Host. Otherwise `Request.url` is the target as sent:
179
+ * relative for a Host with a space or a byte past ASCII or for no Host at all, the raw authority
180
+ * for a port past 65535, and `new URL(req.url)` throws in the app. So Bun is held to the Host check
181
+ * Deno runs.
182
+ */
183
+ const unparsedBunTargets = typeof Bun !== "undefined";
184
+ /**
185
+ * `req` as Bun or workerd would have delivered it: the same request, its URL resolved as they parse it.
186
+ * `undefined` when the `Request` constructor refuses what the wire carried - a TRACE, CONNECT or TRACK,
187
+ * a lower-case `get` with a body - which {@link resolvedSource} routes instead.
188
+ */
189
+ function withResolvedTarget(req, url) {
190
+ let rebuilt;
191
+ try {
192
+ rebuilt = new Request(url, req);
193
+ }
194
+ catch {
195
+ return undefined;
196
+ }
197
+ // The same body, so the same framing: a runtime still delimited it.
198
+ if (hasTrustedBodyFraming(req))
199
+ markTrustedBodyFraming(rebuilt);
200
+ return rebuilt;
201
+ }
202
+ /**
203
+ * A request no `Request` can be rebuilt from, as a source carrying its resolved URL: it is routed on that
204
+ * URL and gets the 404 or 405 the other runtimes give, where rebuilding it threw out of `fetch`. A
205
+ * handler that reads `c.req` gets the request as Deno delivered it.
206
+ */
207
+ function resolvedSource(req, url) {
208
+ const source = {
209
+ method: req.method,
210
+ url,
211
+ get headers() {
212
+ return req.headers;
213
+ },
214
+ header: (name) => headerOf(req, name),
215
+ get body() {
216
+ return req.body;
217
+ },
218
+ arrayBuffer: () => req.arrayBuffer(),
219
+ json: () => req.json(),
220
+ bytes: () => req.bytes(),
221
+ request: req,
222
+ };
223
+ if (hasTrustedBodyFraming(req))
224
+ markTrustedBodyFraming(source);
225
+ return source;
226
+ }
176
227
  // A Host as RFC 9110 allows it: a reg-name or an IP literal, then an optional port. Deno builds
177
228
  // `Request.url` by appending the target to the Host as received, so `Host: x/admin#` turned a
178
229
  // 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) => {
230
+ const HOST_AUTHORITY = /^(?:\[[0-9a-f:.%]+\]|[\w.-]+)(?::(\d{1,5}))?$/i;
231
+ // Clients repeat one Host on every request; comparing to the last valid one is ~7x cheaper than the regex.
232
+ let lastValidHost = "";
233
+ // A port past 65535, or an IPv6 literal no URL parser takes (`[1:2]`), is refused as Node refuses it:
234
+ // no URL can be built on either, so resolving a dot segment against one threw out of `fetch`.
235
+ const invalidHost = (host) => {
236
+ if (host === lastValidHost)
237
+ return false;
238
+ const authority = HOST_AUTHORITY.exec(host);
239
+ if (authority === null)
240
+ return true;
241
+ if (authority[1] !== undefined && Number(authority[1]) > 65_535)
242
+ return true;
243
+ if (host.charCodeAt(0) === 91 /* [ */ && !URL.canParse(`http://${host}/`))
244
+ return true;
245
+ lastValidHost = host;
246
+ return false;
247
+ };
248
+ /**
249
+ * What a request is routed on in place of `req.url`, before anything reads it: `true` refuses its
250
+ * Host (400), a URL replaces the target, `false` keeps it. Deno hands the target over as sent, so
251
+ * its dot segments are resolved; so does Bun when HTTP/1.0 leaves the Host out, and its route table
252
+ * matched `/files/..` raw. Such a request is served as `localhost`, as Node and Deno serve it.
253
+ * `headerOf`, not `req.headers`: on Deno that reads the Host alone instead of building every
254
+ * header.
255
+ */
256
+ const requestTarget = (req) => {
257
+ if (!rawRequestTargets && !unparsedBunTargets)
258
+ return false;
182
259
  const host = headerOf(req, "host");
183
- return host !== null && !HOST_AUTHORITY.test(host);
260
+ if (host !== null && invalidHost(host)) {
261
+ if (rawRequestTargets)
262
+ return true;
263
+ // Bun served it anyway: it could not read the Host, so it left the target unparsed, or it
264
+ // built the URL on it - sent twice (joined with ", ", the URL on the first; RFC 9112 section
265
+ // 3.2 answers 400), percent-encoded (`%61.example` routed as `a.example`), or with a
266
+ // sub-delimiter (`a!b`). A request built in process keeps the URL its caller built, unless Bun
267
+ // would have built it so.
268
+ const routed = URL.parse(req.url)?.host;
269
+ return routed === undefined || URL.parse(`//${host.split(", ")[0]}`, req.url)?.host === routed;
270
+ }
271
+ if (rawRequestTargets)
272
+ return needsResolving(req.url) && resolveDotSegments(req.url);
273
+ // A request built in process carries an absolute URL whatever its headers say.
274
+ return (host === null &&
275
+ req.url.charCodeAt(0) === 47 /* / */ &&
276
+ resolveDotSegments(`http://localhost${req.url}`));
184
277
  };
185
278
  function normalizeMountPrefix(path) {
186
279
  if (!path.startsWith("/") || path.includes("?") || path.includes("#")) {
@@ -403,6 +496,17 @@ const recordScopedHook = (key, routesAtPush, kind) => {
403
496
  site: captureCallerSite(),
404
497
  });
405
498
  };
499
+ /** `next(value)` now, or once `value` settles. */
500
+ const thenMaybe = (value, next) => (value instanceof Promise ? value.then(next) : next(value));
501
+ /** A mount registered after an `authenticate()` stage runs it, so the stage covers something. */
502
+ const noteAuthenticatedMount = (key) => {
503
+ const state = hookAudit.get(key);
504
+ if (state === undefined)
505
+ return;
506
+ for (const site of state.sites)
507
+ if (site.kind === "authenticate")
508
+ site.routesAtPush = -1;
509
+ };
406
510
  /** Count routes a `merge()` adopted: they never run this server's order-scoped hooks, so a hook
407
511
  * followed only by merges covers nothing. */
408
512
  const noteMergedRoutes = (key, count) => {
@@ -632,9 +736,13 @@ export class Server {
632
736
  /** Body/raw response hooks need a framework-payload marker; keep that decision per app. */
633
737
  responseBodyTag;
634
738
  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);
739
+ // General lifecycles need no extra response bookkeeping when neither controls nor observers
740
+ // use it. Share the fused renderer after the lifecycle, including on body/write routes.
741
+ finalizeResponse = (result, set, ctx) => set === EMPTY_RESPONSE_CONTROLS && this.onResponseHooks.length === 0
742
+ ? fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders, true)
743
+ : toResponse(result, set, this.responseBodyTag, this.staticResponseHeaders,
744
+ // Response hooks write next, and they run for this same request object.
745
+ ctx !== undefined && this.onResponseHooks.length > 0 ? ctx[CONTEXT_REQUEST] : undefined);
638
746
  responseRequests;
639
747
  /** A request the WebSocket handshake check ran the onRequest hooks on and then passed to HTTP →
640
748
  * what the hooks returned, so `fetch` routes that instead of running the hooks a second time. */
@@ -773,7 +881,10 @@ export class Server {
773
881
  /**
774
882
  * Mount a legacy fetch handler under a literal path prefix. Mounted handlers sit behind typed
775
883
  * 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.
884
+ * Mounted responses are outside Nifra's typed route and response-contract checks. An
885
+ * `authenticate()` stage registered before the mount, or a required auth plugin built on one
886
+ * (`bearer()`, `jwt()`, `basicAuth()`, `apiKey()`), refuses a request before the handler sees it;
887
+ * mount before the stage to keep the legacy surface public.
777
888
  *
778
889
  * It carries HTTP only: a WebSocket upgrade under the prefix reaches the handler as an ordinary
779
890
  * request and never becomes a socket. Serve that path's sockets with `app.ws()` routes or a
@@ -792,7 +903,10 @@ export class Server {
792
903
  beforeRoutes: false,
793
904
  order: this.mountOrder++,
794
905
  opaque: options.opaque,
906
+ authStages: [...this.authStages],
795
907
  };
908
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production" && this.authStages.length > 0)
909
+ noteAuthenticatedMount(this);
796
910
  this.fetchMounts.push(mount);
797
911
  this.fetchMounts.sort(compareMounts);
798
912
  return this;
@@ -846,7 +960,10 @@ export class Server {
846
960
  order: this.mountOrder++,
847
961
  opaque: options.opaque,
848
962
  app,
963
+ authStages: [...this.authStages],
849
964
  };
965
+ if (hookAuditRuntime && process.env.NODE_ENV !== "production" && this.authStages.length > 0)
966
+ noteAuthenticatedMount(this);
850
967
  // Mounts run before routes: a request under this prefix that no mount on the way strips comes back
851
968
  // here unchanged until the stack overflows, which takes the process down. Dev-time only, like the
852
969
  // hook audit: a production define folds this away, and the first dev request would hit it anyway.
@@ -888,7 +1005,8 @@ export class Server {
888
1005
  * (see {@link ServerOptions.unusedScopedHooks}).
889
1006
  */
890
1007
  /**
891
- * Register a dedicated authentication stage for subsequent routes.
1008
+ * Register a dedicated authentication stage for subsequent routes, and for the `mount()` and
1009
+ * `mountFetch()` handlers registered after it: a request it refuses never reaches them.
892
1010
  *
893
1011
  * Unlike `derive`, this stage receives a body-blind authentication capsule and is compiled before
894
1012
  * route validation. Keeping it separate prevents an unrelated derive from accidentally becoming a
@@ -950,7 +1068,13 @@ export class Server {
950
1068
  */
951
1069
  beforeHandle(fn) {
952
1070
  this.assertConfigurable("beforeHandle()");
953
- this.beforeHandleHooks.push(fn);
1071
+ const hooks = this.beforeHandleHooks;
1072
+ let at = hooks.length;
1073
+ if (!LAST_BEFORE_HANDLE.has(fn)) {
1074
+ while (at > 0 && LAST_BEFORE_HANDLE.has(hooks[at - 1]))
1075
+ at--;
1076
+ }
1077
+ hooks.splice(at, 0, fn);
954
1078
  if (hookAuditRuntime && process.env.NODE_ENV !== "production") {
955
1079
  recordScopedHook(this, this.catalog.size + this.wsRoutes.length, "beforeHandle");
956
1080
  tagHook(fn, "beforeHandle", this);
@@ -2029,11 +2153,14 @@ export class Server {
2029
2153
  fetch(req, platform) {
2030
2154
  // Deno's `Request.url` is the target as sent; Bun, workerd and every `new Request()` parse it.
2031
2155
  // 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);
2156
+ const target = requestTarget(req);
2157
+ if (target === true)
2158
+ return jsonError(400, "invalid_host");
2159
+ if (target !== false) {
2160
+ const rebuilt = withResolvedTarget(req, target);
2161
+ if (rebuilt === undefined)
2162
+ return this.fetchSource(resolvedSource(req, target), platform);
2163
+ req = rebuilt;
2037
2164
  }
2038
2165
  // An edge deployment whose only ingress is this method declares its requests runtime-framed at
2039
2166
  // construction; everything else stays on the delivered-byte check (see `trustBodyFraming`).
@@ -2151,7 +2278,7 @@ export class Server {
2151
2278
  // The mount the ordinary path reaches first: every `mount()` runs before any `mountFetch()`,
2152
2279
  // whatever their prefix lengths. A 404 fallthrough chain stays on the ordinary path.
2153
2280
  const mount = this.firstMountUnder(parts.pathname, true) ?? this.firstMountUnder(parts.pathname, false);
2154
- if (mount === undefined || mount.fallbackOn404)
2281
+ if (mount === undefined || mount.fallbackOn404 || mount.authStages.length > 0)
2155
2282
  return undefined;
2156
2283
  const candidate = mount.handler[NODE_NATIVE_MOUNT];
2157
2284
  return typeof candidate === "function"
@@ -2435,12 +2562,16 @@ export class Server {
2435
2562
  // The adapter answers a pass by handing this same object to `fetch`.
2436
2563
  const received = req;
2437
2564
  // 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);
2565
+ const target = requestTarget(req);
2566
+ if (target === true) {
2567
+ return { kind: "reject", response: this.wrapWebResponse(jsonError(400, "invalid_host")) };
2568
+ }
2569
+ if (target !== false) {
2570
+ // A GET carrying a body cannot be rebuilt; `fetch` routes it as the ordinary request it is.
2571
+ const rebuilt = withResolvedTarget(req, target);
2572
+ if (rebuilt === undefined)
2573
+ return WS_PASS;
2574
+ req = rebuilt;
2444
2575
  }
2445
2576
  // The handshake's caller is the one `fetch` derives: hooks and `upgrade()` see the trusted IP.
2446
2577
  platform = this.trustedPlatform(req, platform);
@@ -2545,19 +2676,24 @@ export class Server {
2545
2676
  }
2546
2677
  if (selected === undefined)
2547
2678
  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
- }
2679
+ const mount = selected;
2680
+ const upgrade = () => {
2681
+ let childRequest = request;
2682
+ if (mount.stripPrefix) {
2683
+ childRequest = stripMountPrefix(request, mount.path);
2684
+ mount.wsPassed ??= new WeakMap();
2685
+ mount.wsPassed.set(request, childRequest);
2686
+ }
2687
+ try {
2688
+ return mount.resolveWebSocketUpgrade(childRequest, platform);
2689
+ }
2690
+ catch {
2691
+ return refuse(jsonError(500, "internal_error"));
2692
+ }
2693
+ };
2694
+ const child = mount.authStages.length === 0
2695
+ ? upgrade()
2696
+ : thenMaybe(authenticateMount(mount.authStages, request, platform, this.logger), (refusal) => refusal === undefined ? upgrade() : refuse(refusal));
2561
2697
  const settle = (outcome) => outcome.kind === "pass" ||
2562
2698
  (outcome.kind === "reject" && outcome.response.status === 404 && selected.fallbackOn404)
2563
2699
  ? dispatch(selectedIndex + 1)
@@ -2857,8 +2993,8 @@ export class Server {
2857
2993
  * routes with validation/hooks keep the full compiled route program, unchanged.
2858
2994
  */
2859
2995
  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.
2996
+ // Native delimiter searches keep path/query splitting out of a character-by-character JS loop.
2997
+ // Keep the slices in locals instead of allocating the public helper's pathname/search pair.
2862
2998
  let pathname;
2863
2999
  let search;
2864
3000
  // Read the source's split ONCE. `urlParts` is an accessor on the Node request sources that
@@ -2879,22 +3015,11 @@ export class Server {
2879
3015
  search = "";
2880
3016
  }
2881
3017
  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
- }
3018
+ const fragment = rawUrl.indexOf("#", start);
3019
+ const searchEnd = fragment === -1 ? rawUrl.length : fragment;
3020
+ const question = rawUrl.indexOf("?", start);
3021
+ const searchStart = question !== -1 && question < searchEnd ? question : -1;
3022
+ const pathEnd = searchStart === -1 ? searchEnd : searchStart;
2898
3023
  pathname = rawUrl.slice(start, pathEnd);
2899
3024
  search = searchStart === -1 ? "" : rawUrl.slice(searchStart, searchEnd);
2900
3025
  }
@@ -2967,10 +3092,16 @@ export class Server {
2967
3092
  }
2968
3093
  if (mount === undefined)
2969
3094
  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);
3095
+ const selected = mount;
3096
+ const run = () => {
3097
+ const childRequest = selected.stripPrefix
3098
+ ? (selected.wsPassed?.get(request) ?? stripMountPrefix(request, selected.path))
3099
+ : request;
3100
+ return selected.handler(childRequest, platform);
3101
+ };
3102
+ const outcome = mount.authStages.length === 0
3103
+ ? run()
3104
+ : thenMaybe(authenticateMount(mount.authStages, request, platform, this.logger), (refusal) => refusal ?? run());
2974
3105
  if (!replayable || !mount.fallbackOn404)
2975
3106
  return outcome;
2976
3107
  if (outcome instanceof Promise) {
@@ -3861,7 +3992,7 @@ export class Server {
3861
3992
  // The thrown-value contract lives in `bare-error-lane.ts`, one source of truth for both this
3862
3993
  // lane and the fused body runner. `responseSet` and the logger are injected because they reach
3863
3994
  // 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));
3995
+ return renderBareError(err, ctx, finalize, responseSet, (e, c) => this.logRequestError(e, c), wrapResponse);
3865
3996
  }
3866
3997
  /**
3867
3998
  * Keep the ordinary thrown-error response independent from the caught value. Deliberate Response
@@ -3939,7 +4070,7 @@ export class Server {
3939
4070
  }
3940
4071
  // @ts-expect-error TS6133 -- invoked structurally by compiled plans (internal/route-execution.ts)
3941
4072
  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);
4073
+ 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
4074
  }
3944
4075
  /** Validate + run the handler for the bodyOnly path - shared by the inline fast path and the
3945
4076
  * streaming fallback. A method (not per-request closures) so the hot path allocates nothing
@@ -4193,7 +4324,15 @@ export class Server {
4193
4324
  return finalize(result, responseSet(ctx), ctx);
4194
4325
  const checked = contract.runtime.check(contract.definition, result, ctx);
4195
4326
  if (checked instanceof Promise) {
4196
- return checked.then((outcome) => this.finishContractOutcome(ctx, finalize, wrapResponse, outcome), (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
4327
+ return checked.then((outcome) => {
4328
+ // The render runs outside the try below: a throw (a BigInt, a cycle) must still reach onError.
4329
+ try {
4330
+ return this.finishContractOutcome(ctx, finalize, wrapResponse, outcome);
4331
+ }
4332
+ catch (err) {
4333
+ return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse);
4334
+ }
4335
+ }, (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse));
4197
4336
  }
4198
4337
  return this.finishContractOutcome(ctx, finalize, wrapResponse, checked);
4199
4338
  }
@@ -4238,6 +4377,9 @@ export class Server {
4238
4377
  if (err instanceof Response || isResponseResult(err))
4239
4378
  return this.finishLifecycleContract(entry, ctx, finalize, wrapResponse, err);
4240
4379
  if (entry.onError.length === 0) {
4380
+ const clientCode = clientBodyErrorCode(err);
4381
+ if (clientCode !== undefined)
4382
+ return wrapResponse(plainError(400, clientCode));
4241
4383
  // Never crash the server or leak internals. The client gets a flat 500; the detail goes to the
4242
4384
  // (redacting) logger. Body-read failures and around-hook failures land here too.
4243
4385
  this.logRequestError(err, ctx);
@@ -4297,6 +4439,9 @@ export class Server {
4297
4439
  return wrapResponse(plainError(500, "internal_error"));
4298
4440
  }
4299
4441
  }
4442
+ const clientCode = clientBodyErrorCode(err);
4443
+ if (clientCode !== undefined)
4444
+ return wrapResponse(plainError(400, clientCode));
4300
4445
  this.logRequestError(err, ctx);
4301
4446
  return fallback === undefined ? wrapResponse(plainError(500, "internal_error")) : fallback();
4302
4447
  }
@@ -4311,27 +4456,35 @@ export class Server {
4311
4456
  // second continuation, while retaining the exact bounded-read, content-type, and poisoning
4312
4457
  // contracts shared by every body route.
4313
4458
  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");
4459
+ // Reject outside the parser's JSON catch so a custom validator throw reaches onError.
4460
+ try {
4461
+ const validation = bodySchema["~standard"].validate(parsed);
4462
+ return validation instanceof Promise
4463
+ ? validation.then((result) => this.applyLifecycleValidation(entry, result, ctx, "body"))
4464
+ : this.applyLifecycleValidation(entry, validation, ctx, "body");
4465
+ }
4466
+ catch (error) {
4467
+ return Promise.reject(error);
4468
+ }
4318
4469
  };
4319
- return readBodyFramed(req, bodyLimit ?? UNLIMITED_BODY_BYTES, this.protoPoisoning, validate, (response) => response, (error) => Promise.reject(error), bodySchema, this.formOrigins);
4470
+ return readBodyFramed(req, bodyLimit ?? UNLIMITED_BODY_BYTES, this.protoPoisoning, validate, BODY_RESPONSE, BODY_ERROR, bodySchema, this.formOrigins);
4320
4471
  }
4321
4472
  /** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
4322
4473
  * before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
4323
4474
  applyLifecycleValidation(entry, result, ctx, kind) {
4324
- const assign = (value) => {
4325
- ;
4326
- ctx[kind] = value;
4327
- };
4328
4475
  if (result.issues === undefined) {
4329
- assign(result.value);
4476
+ // biome-ignore lint/plugin/noChainedTypeAssertions lint/plugin/requireSafetyCommentForTypeAssertion: RawContext carries every ProgramValidationKind slot; this instruction assigns its validated value before handlers run.
4477
+ ;
4478
+ ctx[kind] = result.value;
4330
4479
  return undefined;
4331
4480
  }
4332
4481
  const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError;
4333
4482
  if (hook === undefined)
4334
4483
  return plainValidationError(result.issues);
4484
+ const assign = (value) => {
4485
+ ;
4486
+ ctx[kind] = value;
4487
+ };
4335
4488
  const attempted = hook(result.issues, ctx, kind);
4336
4489
  if (attempted instanceof Promise) {
4337
4490
  return attempted.then((recovery) => this.finishLifecycleValidationRecovery(entry, kind, result.issues, recovery, assign));
@@ -4364,13 +4517,19 @@ export class Server {
4364
4517
  // the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
4365
4518
  // the portable/adapted source contract (those sources are never marked).
4366
4519
  const markFramed = (request) => markTrustedBodyFraming(request);
4367
- // The fused native lane bypasses `runMatched`, so the route's transport byte cap must be
4520
+ // The direct native lanes bypass `runMatched`, so the route's transport byte cap must be
4368
4521
  // 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.
4522
+ // reads uncapped. The fallback branches go through `fetchMatched` -> `runMatched`, which marks.
4370
4523
  const bodyLimit = entry.schema?.body === undefined || entry.program.authBeforeValidation
4371
4524
  ? entry.bodyLimit
4372
4525
  : undefined;
4373
- const inner = fused;
4526
+ // The complete plan already preserves general validation, hooks, recovery, around, and ledgers.
4527
+ // When native admission is safe, use it directly even if the route has no closed-shape fusion.
4528
+ // Idempotency still needs runMatched's request preparation and is excluded from the table.
4529
+ const inner = fused ??
4530
+ (signal && budget
4531
+ ? (source, params, search, activeSignal, activeBudget, platform, nativeContext) => entry.execution.run(this, entry, source, params, search, activeSignal, activeBudget, platform, nativeContext, this.finalizeResponse, this.wrapWebResponse)
4532
+ : undefined);
4374
4533
  const capped = inner === undefined || bodyLimit === undefined
4375
4534
  ? inner
4376
4535
  : (source, params, search, fusedSignal, fusedBudget, platform, nativeContext) => {
@@ -4530,7 +4689,9 @@ export class Server {
4530
4689
  }
4531
4690
  const paramNames = pattern.paramNames;
4532
4691
  const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined;
4533
- tableAt(path, pattern.segments)[method] = this.compileBunNativeHandler(entry, paramNames, fused, unboundedSignal, unboundedBudget, peer, fallback);
4692
+ const handler = this.compileBunNativeHandler(entry, paramNames, fused, unboundedSignal, unboundedBudget, peer, fallback);
4693
+ // The table matched the target as sent; one `fetch` refuses or rewrites goes through it.
4694
+ tableAt(path, pattern.segments)[method] = (request, server) => requestTarget(request) ? fallback(request, server) : handler(request, server);
4534
4695
  count += 1;
4535
4696
  if (paramNames.length > 0) {
4536
4697
  below.add(pattern.segments.length + method);