@nifrajs/core 5.6.0 → 5.8.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 (124) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +5 -0
  3. package/dist/binary.d.ts +15 -1
  4. package/dist/binary.d.ts.map +1 -1
  5. package/dist/binary.js +1 -0
  6. package/dist/binary.js.map +1 -1
  7. package/dist/edge-kit.d.ts +1 -0
  8. package/dist/edge-kit.d.ts.map +1 -1
  9. package/dist/edge-kit.js +1 -0
  10. package/dist/edge-kit.js.map +1 -1
  11. package/dist/errors.d.ts +1 -1
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/errors.js.map +1 -1
  14. package/dist/evidence.d.ts +2 -0
  15. package/dist/evidence.d.ts.map +1 -1
  16. package/dist/evidence.js +2 -0
  17. package/dist/evidence.js.map +1 -1
  18. package/dist/index.d.ts +2 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/internal/hook-origins.d.ts +2 -0
  23. package/dist/internal/hook-origins.d.ts.map +1 -1
  24. package/dist/internal/hook-origins.js +12 -0
  25. package/dist/internal/hook-origins.js.map +1 -1
  26. package/dist/reflection.d.ts +5 -0
  27. package/dist/reflection.d.ts.map +1 -1
  28. package/dist/reflection.js +12 -0
  29. package/dist/reflection.js.map +1 -1
  30. package/dist/server/answer-step.d.ts +27 -0
  31. package/dist/server/answer-step.d.ts.map +1 -0
  32. package/dist/server/answer-step.js +33 -0
  33. package/dist/server/answer-step.js.map +1 -0
  34. package/dist/server/base64-answers.d.ts +5 -0
  35. package/dist/server/base64-answers.d.ts.map +1 -0
  36. package/dist/server/base64-answers.js +132 -0
  37. package/dist/server/base64-answers.js.map +1 -0
  38. package/dist/server/core-copies.d.ts.map +1 -1
  39. package/dist/server/core-copies.js +5 -2
  40. package/dist/server/core-copies.js.map +1 -1
  41. package/dist/server/install.d.ts +2 -0
  42. package/dist/server/install.d.ts.map +1 -1
  43. package/dist/server/install.js +2 -0
  44. package/dist/server/install.js.map +1 -1
  45. package/dist/server/json-response.d.ts +3 -0
  46. package/dist/server/json-response.d.ts.map +1 -0
  47. package/dist/server/json-response.js +14 -0
  48. package/dist/server/json-response.js.map +1 -0
  49. package/dist/server/json-text.d.ts +16 -0
  50. package/dist/server/json-text.d.ts.map +1 -0
  51. package/dist/server/json-text.js +34 -0
  52. package/dist/server/json-text.js.map +1 -0
  53. package/dist/server/node-outcome.d.ts +5 -0
  54. package/dist/server/node-outcome.d.ts.map +1 -1
  55. package/dist/server/node-outcome.js +3 -4
  56. package/dist/server/node-outcome.js.map +1 -1
  57. package/dist/server/request-context.d.ts.map +1 -1
  58. package/dist/server/request-context.js +4 -2
  59. package/dist/server/request-context.js.map +1 -1
  60. package/dist/server/respond.d.ts +2 -1
  61. package/dist/server/respond.d.ts.map +1 -1
  62. package/dist/server/respond.js +27 -24
  63. package/dist/server/respond.js.map +1 -1
  64. package/dist/server/response-contract-lane.d.ts.map +1 -1
  65. package/dist/server/response-contract-lane.js +2 -1
  66. package/dist/server/response-contract-lane.js.map +1 -1
  67. package/dist/server/route-template.d.ts +10 -0
  68. package/dist/server/route-template.d.ts.map +1 -0
  69. package/dist/server/route-template.js +21 -0
  70. package/dist/server/route-template.js.map +1 -0
  71. package/dist/server/runtime-core.d.ts.map +1 -1
  72. package/dist/server/runtime-core.js +2 -1
  73. package/dist/server/runtime-core.js.map +1 -1
  74. package/dist/server/server-types.d.ts +26 -6
  75. package/dist/server/server-types.d.ts.map +1 -1
  76. package/dist/server/server.d.ts +22 -10
  77. package/dist/server/server.d.ts.map +1 -1
  78. package/dist/server/server.js +109 -45
  79. package/dist/server/server.js.map +1 -1
  80. package/dist/server/sse.d.ts.map +1 -1
  81. package/dist/server/sse.js +2 -1
  82. package/dist/server/sse.js.map +1 -1
  83. package/dist/server/text-slots.d.ts +8 -0
  84. package/dist/server/text-slots.d.ts.map +1 -0
  85. package/dist/server/text-slots.js +19 -0
  86. package/dist/server/text-slots.js.map +1 -0
  87. package/dist/server.d.ts +1 -0
  88. package/dist/server.d.ts.map +1 -1
  89. package/dist/server.js +1 -0
  90. package/dist/server.js.map +1 -1
  91. package/dist/single-copy.d.ts +5 -0
  92. package/dist/single-copy.d.ts.map +1 -1
  93. package/dist/single-copy.js +26 -17
  94. package/dist/single-copy.js.map +1 -1
  95. package/dist/transport-codec.d.ts.map +1 -1
  96. package/dist/transport-codec.js +2 -1
  97. package/dist/transport-codec.js.map +1 -1
  98. package/package.json +1 -1
  99. package/src/binary.ts +17 -0
  100. package/src/edge-kit.ts +1 -0
  101. package/src/errors.ts +1 -0
  102. package/src/evidence.ts +4 -0
  103. package/src/index.ts +2 -1
  104. package/src/internal/hook-origins.ts +10 -0
  105. package/src/reflection.ts +18 -0
  106. package/src/server/answer-step.ts +68 -0
  107. package/src/server/base64-answers.ts +138 -0
  108. package/src/server/core-copies.ts +5 -2
  109. package/src/server/install.ts +3 -0
  110. package/src/server/json-response.ts +13 -0
  111. package/src/server/json-text.ts +34 -0
  112. package/src/server/node-outcome.ts +4 -5
  113. package/src/server/request-context.ts +4 -2
  114. package/src/server/respond.ts +27 -18
  115. package/src/server/response-contract-lane.ts +2 -1
  116. package/src/server/route-template.ts +24 -0
  117. package/src/server/runtime-core.ts +2 -1
  118. package/src/server/server-types.ts +27 -4
  119. package/src/server/server.ts +131 -63
  120. package/src/server/sse.ts +2 -1
  121. package/src/server/text-slots.ts +21 -0
  122. package/src/server.ts +1 -0
  123. package/src/single-copy.ts +31 -15
  124. package/src/transport-codec.ts +3 -1
@@ -10,6 +10,8 @@ import { authenticateMount, compileRouteProgram, executeRouteProgram, } from "..
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
16
  import { LAST_BEFORE_HANDLE } from "./before-handler.js";
15
17
  import { assertByteLimit, clientBodyErrorCode, hasTrustedBodyFraming, markTransportCap, markTrustedBodyFraming, UNLIMITED_BODY_BYTES, } from "./body.js";
@@ -26,9 +28,10 @@ import { needsResolving, resolveDotSegments } from "./request-target.js";
26
28
  import { applyStaticResponseHeaders, buildStaticResponseHeaders, claimHandedOver, fusedRespond, fusedRespondNoSet, handedBackResponse, recordResidentTexts, toResponse, writableResponse, } from "./respond.js";
27
29
  import { CONTEXT_REQUEST, CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, isResponseResult, requestOf, } from "./runtime-core.js";
28
30
  import { normalizeStaticResponseHeaders } from "./static-headers.js";
31
+ import { withTextSlots } from "./text-slots.js";
29
32
  import { plainValidationError } from "./validation.js";
30
33
  import { NIFRA_BACKEND_WS_RUNTIME } from "../mount.js";
31
- 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";
32
35
  import { jsonLogger } from "./logger.js";
33
36
  // CORS uses this marker to say its request hook only handles OPTIONS preflight. Bun can keep
34
37
  // ordinary registered routes on its native table and send unmatched OPTIONS requests through the
@@ -229,7 +232,8 @@ function resolvedSource(req, url) {
229
232
  // request for `/public` into one for `/admin`. Node refuses such a Host before routing; so does this.
230
233
  const HOST_AUTHORITY = /^(?:\[[0-9a-f:.%]+\]|[\w.-]+)(?::(\d{1,5}))?$/i;
231
234
  // Clients repeat one Host on every request; comparing to the last valid one is ~7x cheaper than the regex.
232
- let lastValidHost = "";
235
+ // `null` until one passes, so an empty Host is never taken as the cached one.
236
+ let lastValidHost = null;
233
237
  // A port past 65535, or an IPv6 literal no URL parser takes (`[1:2]`), is refused as Node refuses it:
234
238
  // no URL can be built on either, so resolving a dot segment against one threw out of `fetch`.
235
239
  const invalidHost = (host) => {
@@ -251,10 +255,10 @@ const invalidHost = (host) => {
251
255
  * its dot segments are resolved; so does Bun when HTTP/1.0 leaves the Host out, and its route table
252
256
  * matched `/files/..` raw. Such a request is served as `localhost`, as Node and Deno serve it.
253
257
  * `headerOf`, not `req.headers`: on Deno that reads the Host alone instead of building every
254
- * header.
258
+ * header. On Bun only a target that needs resolving is checked unless `validateHost` is set.
255
259
  */
256
- const requestTarget = (req) => {
257
- if (!rawRequestTargets && !unparsedBunTargets)
260
+ const requestTarget = (req, validateHost) => {
261
+ if (!rawRequestTargets && !(unparsedBunTargets && (validateHost || needsResolving(req.url))))
258
262
  return false;
259
263
  const host = headerOf(req, "host");
260
264
  if (host !== null && invalidHost(host)) {
@@ -303,13 +307,17 @@ function mountPriorityOf(priority) {
303
307
  function compareMounts(a, b) {
304
308
  return b.priority - a.priority || b.path.length - a.path.length || a.order - b.order;
305
309
  }
310
+ /** Rebase a mounted request's path without losing the authentication mark used by child caches. */
306
311
  function stripMountPrefix(request, prefix) {
307
312
  if (prefix === "/")
308
313
  return request;
309
314
  const url = new URL(request.url);
310
315
  const rest = url.pathname.slice(prefix.length);
311
316
  url.pathname = rest === "" ? "/" : rest;
312
- return new Request(url.href, request);
317
+ const childRequest = new Request(url.href, request);
318
+ if (isAuthenticatedRequest(request))
319
+ markAuthenticatedRequest(childRequest);
320
+ return childRequest;
313
321
  }
314
322
  function responseSet(ctx) {
315
323
  return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS;
@@ -646,6 +654,7 @@ export class Server {
646
654
  formOrigins;
647
655
  /** `trustBodyFraming`: mark every `app.fetch` request as runtime-framed (see ServerOptions). */
648
656
  trustBodyFraming;
657
+ validateHost;
649
658
  wsMaxPayloadBytes;
650
659
  requestTimeoutMs;
651
660
  wsUpgradeTimeoutMs;
@@ -659,6 +668,7 @@ export class Server {
659
668
  deadlineAdmissionOptions;
660
669
  gracefulSignals;
661
670
  stopHooks;
671
+ stoppingHooks;
662
672
  fetchMounts;
663
673
  mountOrder;
664
674
  /** Number of composed mounts that must run before the parent route table. Keeps legacy mounts off the hot path. */
@@ -742,7 +752,7 @@ export class Server {
742
752
  ? fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders, true)
743
753
  : toResponse(result, set, this.responseBodyTag, this.staticResponseHeaders,
744
754
  // Response hooks write next, and they run for this same request object.
745
- ctx !== undefined && this.onResponseHooks.length > 0 ? ctx[CONTEXT_REQUEST] : undefined);
755
+ ctx !== undefined && this.onResponseHooks.length > 0 ? ctx[CONTEXT_REQUEST] : undefined, this.onResponseHooks.length === 0);
746
756
  responseRequests;
747
757
  /** A request the WebSocket handshake check ran the onRequest hooks on and then passed to HTTP →
748
758
  * what the hooks returned, so `fetch` routes that instead of running the hooks a second time. */
@@ -777,6 +787,7 @@ export class Server {
777
787
  this.protoPoisoning = options.protoPoisoning ?? "reject";
778
788
  this.formOrigins = options.formOrigins;
779
789
  this.trustBodyFraming = options.trustBodyFraming ?? false;
790
+ this.validateHost = options.validateHost ?? false;
780
791
  const wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? maxBodyBytes;
781
792
  assertByteLimit(wsMaxPayloadBytes, "wsMaxPayloadBytes");
782
793
  this.maxBodyBytes = maxBodyBytes;
@@ -808,6 +819,7 @@ export class Server {
808
819
  this.gracefulSignals =
809
820
  options.gracefulSignals === true ? {} : options.gracefulSignals || undefined;
810
821
  this.stopHooks = [];
822
+ this.stoppingHooks = [];
811
823
  this.fetchMounts = [];
812
824
  this.mountOrder = 0;
813
825
  this.preRouteMountCount = 0;
@@ -878,6 +890,18 @@ export class Server {
878
890
  this.stopHooks.push(fn);
879
891
  return this;
880
892
  }
893
+ /**
894
+ * Register a callback awaited as soon as `stop()` begins, while the server still serves: readiness
895
+ * should fail from here, and work that outlives a request (a poller, a queue consumer) should stop
896
+ * taking more. Hung callbacks are bounded.
897
+ */
898
+ onStopping(fn) {
899
+ this.assertConfigurable("onStopping()");
900
+ if (typeof fn !== "function")
901
+ throw new TypeError("onStopping callback must be a function");
902
+ this.stoppingHooks.push(fn);
903
+ return this;
904
+ }
881
905
  /**
882
906
  * Mount a legacy fetch handler under a literal path prefix. Mounted handlers sit behind typed
883
907
  * routes, so routes can move one at a time while the remaining legacy surface stays live.
@@ -1575,6 +1599,7 @@ export class Server {
1575
1599
  throw new RouteConfigError("INVALID_PATH", `group(${JSON.stringify(prefix)}) route path must start with "/": ${JSON.stringify(path)}`);
1576
1600
  }
1577
1601
  prepareRoute(method, path, schema, handler) {
1602
+ schema = withTextSlots(schema);
1578
1603
  // Route provenance for the CLI's capability walk, which sets NIFRA_ROUTE_SITES while it loads the
1579
1604
  // app: the module that registered the route, read where a static scan cannot see the path.
1580
1605
  if (hookAuditRuntime &&
@@ -1604,6 +1629,15 @@ export class Server {
1604
1629
  defaultOnValidationError: this.defaultOnValidationError,
1605
1630
  }, method, path, schema, handler);
1606
1631
  const { pattern, bodyLimit, capabilities, routeDecorations, hasDecorations, idempotent, ledgered, responseContract, lanes, routeAssurance, } = compiled;
1632
+ // A schema that writes its answer its own way (`t.bytes()`) wraps every stage that can answer.
1633
+ const stages = withAnswerSteps(schema, method, path, {
1634
+ handler,
1635
+ beforeHandle: this.beforeHandleHooks,
1636
+ afterHandle: this.afterHandleHooks,
1637
+ onError: this.onErrorHooks,
1638
+ });
1639
+ handler = stages.handler;
1640
+ const { beforeHandle, afterHandle, onError } = stages;
1607
1641
  if (idempotent !== undefined && this.idempotencyRuntime !== undefined) {
1608
1642
  handler = this.idempotencyRuntime.wrapHandler(handler);
1609
1643
  }
@@ -1622,11 +1656,11 @@ export class Server {
1622
1656
  const fusedBodyRunner = fusedBody
1623
1657
  ? this.buildFusedBodyRunner(handler, schema?.body, hasDecorations ? routeDecorations : undefined, bodyLimit ?? UNLIMITED_BODY_BYTES)
1624
1658
  : isFusedBodyLifecycle
1625
- ? this.buildFusedBodyDeriveBeforeAfterRunner(handler, this.derives[0], this.beforeHandleHooks[0], this.afterHandleHooks[0], schema?.body, bodyLimit ?? UNLIMITED_BODY_BYTES)
1659
+ ? this.buildFusedBodyDeriveBeforeAfterRunner(handler, this.derives[0], beforeHandle[0], afterHandle[0], schema?.body, bodyLimit ?? UNLIMITED_BODY_BYTES)
1626
1660
  : isFusedLifecycle && lifecycleHookLane === "derive-before"
1627
- ? this.buildFusedDeriveBeforeRunner(handler, this.derives[0], this.beforeHandleHooks[0], undefined, schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1661
+ ? this.buildFusedDeriveBeforeRunner(handler, this.derives[0], beforeHandle[0], undefined, schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1628
1662
  : isFusedLifecycle && lifecycleHookLane === "derive-before-after"
1629
- ? this.buildFusedDeriveBeforeRunner(handler, this.derives[0], this.beforeHandleHooks[0], this.afterHandleHooks[0], schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1663
+ ? this.buildFusedDeriveBeforeRunner(handler, this.derives[0], beforeHandle[0], afterHandle[0], schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1630
1664
  : undefined;
1631
1665
  const fusedWeb = bare && this.aroundHooks.length === 0
1632
1666
  ? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler), bodyLimit ?? UNLIMITED_BODY_BYTES)
@@ -1635,20 +1669,20 @@ export class Server {
1635
1669
  : fusedBody
1636
1670
  ? this.buildFusedBodyWeb(fusedBodyRunner)
1637
1671
  : isFusedBodyLifecycle
1638
- ? this.buildFusedBodyDeriveBeforeAfter(handler, this.derives[0], this.beforeHandleHooks[0], this.afterHandleHooks[0], schema?.body, bodyLimit ?? UNLIMITED_BODY_BYTES)
1672
+ ? this.buildFusedBodyDeriveBeforeAfter(handler, this.derives[0], beforeHandle[0], afterHandle[0], schema?.body, bodyLimit ?? UNLIMITED_BODY_BYTES)
1639
1673
  : isFusedLifecycle && lifecycleHookLane === "derive-before"
1640
- ? this.buildFusedDeriveBefore(handler, this.derives[0], this.beforeHandleHooks[0], schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1674
+ ? this.buildFusedDeriveBefore(handler, this.derives[0], beforeHandle[0], schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1641
1675
  : isFusedLifecycle && lifecycleHookLane === "derive-before-after"
1642
- ? this.buildFusedDeriveBeforeAfter(handler, this.derives[0], this.beforeHandleHooks[0], this.afterHandleHooks[0], schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1676
+ ? this.buildFusedDeriveBeforeAfter(handler, this.derives[0], beforeHandle[0], afterHandle[0], schema?.query, bodyLimit ?? UNLIMITED_BODY_BYTES)
1643
1677
  : undefined;
1644
1678
  const program = compileRouteProgram({
1645
1679
  schema,
1646
1680
  handler: handler,
1647
1681
  derives: this.derives,
1648
1682
  authStages: compiled.authStages,
1649
- beforeHandle: this.beforeHandleHooks,
1650
- afterHandle: this.afterHandleHooks,
1651
- onError: this.onErrorHooks,
1683
+ beforeHandle,
1684
+ afterHandle,
1685
+ onError,
1652
1686
  decorations: routeDecorations,
1653
1687
  hasDecorations,
1654
1688
  bodySchema: schema?.body,
@@ -1676,9 +1710,9 @@ export class Server {
1676
1710
  authStages: [...compiled.authStages],
1677
1711
  decorations: routeDecorations,
1678
1712
  hasDecorations,
1679
- beforeHandle: [...this.beforeHandleHooks],
1680
- afterHandle: [...this.afterHandleHooks],
1681
- onError: [...this.onErrorHooks],
1713
+ beforeHandle: [...beforeHandle],
1714
+ afterHandle: [...afterHandle],
1715
+ onError: [...onError],
1682
1716
  lifecycleHookLane: lanes.lifecycleHookLane,
1683
1717
  around: [...this.aroundHooks],
1684
1718
  execution,
@@ -2064,6 +2098,7 @@ export class Server {
2064
2098
  // Cleanup the source registered (a pool, a queue consumer) belongs to the server that now runs
2065
2099
  // its routes; dropping it would leak the resource past `stop()`.
2066
2100
  this.stopHooks.push(...source.stopHooks);
2101
+ this.stoppingHooks.push(...source.stoppingHooks);
2067
2102
  this.mcpResourceList.push(...source.mcpResourceList);
2068
2103
  this.mcpPromptList.push(...source.mcpPromptList);
2069
2104
  }
@@ -2153,7 +2188,7 @@ export class Server {
2153
2188
  fetch(req, platform) {
2154
2189
  // Deno's `Request.url` is the target as sent; Bun, workerd and every `new Request()` parse it.
2155
2190
  // Route what the parsed URL says, so `/users/../admin` is `/admin` on every runtime.
2156
- const target = requestTarget(req);
2191
+ const target = requestTarget(req, this.validateHost);
2157
2192
  if (target === true)
2158
2193
  return jsonError(400, "invalid_host");
2159
2194
  if (target !== false) {
@@ -2562,7 +2597,7 @@ export class Server {
2562
2597
  // The adapter answers a pass by handing this same object to `fetch`.
2563
2598
  const received = req;
2564
2599
  // The handshake routes the path `fetch` would: Host checked, dot segments resolved (see `fetch`).
2565
- const target = requestTarget(req);
2600
+ const target = requestTarget(req, this.validateHost);
2566
2601
  if (target === true) {
2567
2602
  return { kind: "reject", response: this.wrapWebResponse(jsonError(400, "invalid_host")) };
2568
2603
  }
@@ -2691,16 +2726,20 @@ export class Server {
2691
2726
  return refuse(jsonError(500, "internal_error"));
2692
2727
  }
2693
2728
  };
2694
- const child = mount.authStages.length === 0
2695
- ? upgrade()
2696
- : thenMaybe(authenticateMount(mount.authStages, request, platform, this.logger), (refusal) => refusal === undefined ? upgrade() : refuse(refusal));
2697
2729
  const settle = (outcome) => outcome.kind === "pass" ||
2698
2730
  (outcome.kind === "reject" && outcome.response.status === 404 && selected.fallbackOn404)
2699
2731
  ? dispatch(selectedIndex + 1)
2700
2732
  : outcome.kind === "reject"
2701
2733
  ? refuse(outcome.response)
2702
2734
  : outcome;
2703
- return child instanceof Promise ? child.then(settle) : settle(child);
2735
+ const run = () => {
2736
+ const child = upgrade();
2737
+ return child instanceof Promise ? child.then(settle) : settle(child);
2738
+ };
2739
+ // Only a child's miss may fall through, never an authentication refusal.
2740
+ return mount.authStages.length === 0
2741
+ ? run()
2742
+ : thenMaybe(authenticateMount(mount.authStages, request, platform, this.logger), (refusal) => (refusal === undefined ? run() : refuse(refusal)));
2704
2743
  };
2705
2744
  return dispatch(0);
2706
2745
  };
@@ -3094,20 +3133,26 @@ export class Server {
3094
3133
  return fallback ?? jsonError(404, "not_found");
3095
3134
  const selected = mount;
3096
3135
  const run = () => {
3136
+ const reused = selected.stripPrefix ? selected.wsPassed?.get(request) : undefined;
3097
3137
  const childRequest = selected.stripPrefix
3098
- ? (selected.wsPassed?.get(request) ?? stripMountPrefix(request, selected.path))
3138
+ ? (reused ?? stripMountPrefix(request, selected.path))
3099
3139
  : request;
3100
- return selected.handler(childRequest, platform);
3140
+ // HTTP authentication may have marked the original since the WebSocket pass.
3141
+ if (reused !== undefined && isAuthenticatedRequest(request)) {
3142
+ markAuthenticatedRequest(reused);
3143
+ }
3144
+ const outcome = selected.handler(childRequest, platform);
3145
+ if (!replayable || !selected.fallbackOn404)
3146
+ return outcome;
3147
+ if (outcome instanceof Promise) {
3148
+ return outcome.then((response) => response.status === 404 ? dispatch(index + 1, response) : response);
3149
+ }
3150
+ return outcome.status === 404 ? dispatch(index + 1, outcome) : outcome;
3101
3151
  };
3102
- const outcome = mount.authStages.length === 0
3152
+ // Keep gate refusals outside the child's 404 fallthrough path.
3153
+ return mount.authStages.length === 0
3103
3154
  ? run()
3104
3155
  : thenMaybe(authenticateMount(mount.authStages, request, platform, this.logger), (refusal) => refusal ?? run());
3105
- if (!replayable || !mount.fallbackOn404)
3106
- return outcome;
3107
- if (outcome instanceof Promise) {
3108
- return outcome.then((response) => response.status === 404 ? dispatch(index + 1, response) : response);
3109
- }
3110
- return outcome.status === 404 ? dispatch(index + 1, outcome) : outcome;
3111
3156
  };
3112
3157
  return dispatch(first);
3113
3158
  }
@@ -4690,8 +4735,12 @@ export class Server {
4690
4735
  const paramNames = pattern.paramNames;
4691
4736
  const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined;
4692
4737
  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);
4738
+ // The table matched the target as sent; one `fetch` refuses or rewrites goes through it. Left
4739
+ // off, `validateHost` costs nothing here: the table holds no wildcard, a `..` parameter falls
4740
+ // back, and reading the Host on every request measured about 6% of a bare route.
4741
+ tableAt(path, pattern.segments)[method] = this.validateHost
4742
+ ? (request, server) => requestTarget(request, true) ? fallback(request, server) : handler(request, server)
4743
+ : handler;
4695
4744
  count += 1;
4696
4745
  if (paramNames.length > 0) {
4697
4746
  below.add(pattern.segments.length + method);
@@ -4874,11 +4923,12 @@ export class Server {
4874
4923
  * The Bun semantics: poll `pendingRequests` (awaiting
4875
4924
  * `stop()`'s promise drops in-flight requests), and decide graceful-vs-forced in
4876
4925
  * ONE call (Bun can't escalate an already-graceful `stop()` to a forced close).
4877
- * New connections may be accepted during the drain window; in a real deploy the
4878
- * load balancer has already stopped routing here, and `drainMs` bounds it. Open WebSockets
4879
- * get a 1001 close at once and the same window to finish it, before any `onStop` hook runs.
4926
+ * New connections may be accepted during the drain window; `delayMs` is the time a load balancer
4927
+ * gets to stop routing here first. The `onStopping` hooks run before that delay, the `onStop`
4928
+ * hooks after the drain. Open WebSockets get a 1001 close when the drain starts and the same
4929
+ * window to finish it; a socket opened after `stop()` began is closed at once.
4880
4930
  */
4881
- async stop({ drainMs = DEFAULT_DRAIN_MS } = {}) {
4931
+ async stop({ drainMs = DEFAULT_DRAIN_MS, delayMs = 0 } = {}) {
4882
4932
  const onSignal = this.signalHandler;
4883
4933
  if (onSignal !== undefined) {
4884
4934
  this.signalHandler = undefined;
@@ -4888,6 +4938,9 @@ export class Server {
4888
4938
  if (server === undefined)
4889
4939
  return;
4890
4940
  this.bunServer = undefined;
4941
+ await this[SERVER_LIFECYCLE]("stopping");
4942
+ if (delayMs > 0)
4943
+ await Bun.sleep(delayMs);
4891
4944
  const sockets = this.bunSockets;
4892
4945
  this.bunSockets = undefined;
4893
4946
  if (sockets !== undefined)
@@ -4899,14 +4952,25 @@ export class Server {
4899
4952
  await Bun.sleep(DRAIN_POLL_MS);
4900
4953
  }
4901
4954
  server.stop(busy()); // force-close iff stragglers remain past the deadline
4902
- if (this.stopHooks.length === 0)
4955
+ await this[SERVER_LIFECYCLE]("stopped");
4956
+ }
4957
+ /**
4958
+ * @internal Run the `onStopping` or the `onStop` hooks, each bounded. A runtime adapter that drains
4959
+ * its own listener calls it on either side of the drain, as `stop()` does here.
4960
+ */
4961
+ async [SERVER_LIFECYCLE](phase) {
4962
+ const hooks = phase === "stopping" ? this.stoppingHooks : this.stopHooks;
4963
+ if (hooks.length === 0)
4903
4964
  return;
4904
- const callbacks = this.stopHooks.map((hook) => Promise.resolve().then(hook));
4905
- const settled = Promise.allSettled(callbacks);
4965
+ let timer;
4906
4966
  await Promise.race([
4907
- settled,
4908
- new Promise((resolve) => setTimeout(resolve, STOP_HOOK_TIMEOUT_MS)),
4967
+ Promise.allSettled(hooks.map((hook) => Promise.resolve().then(hook))),
4968
+ new Promise((resolve) => {
4969
+ timer = setTimeout(resolve, STOP_HOOK_TIMEOUT_MS);
4970
+ }),
4909
4971
  ]);
4972
+ // A pending bound would hold the event loop open after every hook settled.
4973
+ clearTimeout(timer);
4910
4974
  }
4911
4975
  installSignalHandlers(drain) {
4912
4976
  // Drain, then let the process exit naturally - the stopped server no longer