@nifrajs/core 5.6.0 → 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 (124) hide show
  1. package/CHANGELOG.md +93 -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 +14 -6
  75. package/dist/server/server-types.d.ts.map +1 -1
  76. package/dist/server/server.d.ts +21 -10
  77. package/dist/server/server.d.ts.map +1 -1
  78. package/dist/server/server.js +94 -37
  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 +15 -4
  119. package/src/server/server.ts +114 -54
  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
@@ -303,13 +306,17 @@ function mountPriorityOf(priority) {
303
306
  function compareMounts(a, b) {
304
307
  return b.priority - a.priority || b.path.length - a.path.length || a.order - b.order;
305
308
  }
309
+ /** Rebase a mounted request's path without losing the authentication mark used by child caches. */
306
310
  function stripMountPrefix(request, prefix) {
307
311
  if (prefix === "/")
308
312
  return request;
309
313
  const url = new URL(request.url);
310
314
  const rest = url.pathname.slice(prefix.length);
311
315
  url.pathname = rest === "" ? "/" : rest;
312
- return new Request(url.href, request);
316
+ const childRequest = new Request(url.href, request);
317
+ if (isAuthenticatedRequest(request))
318
+ markAuthenticatedRequest(childRequest);
319
+ return childRequest;
313
320
  }
314
321
  function responseSet(ctx) {
315
322
  return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS;
@@ -659,6 +666,7 @@ export class Server {
659
666
  deadlineAdmissionOptions;
660
667
  gracefulSignals;
661
668
  stopHooks;
669
+ stoppingHooks;
662
670
  fetchMounts;
663
671
  mountOrder;
664
672
  /** Number of composed mounts that must run before the parent route table. Keeps legacy mounts off the hot path. */
@@ -742,7 +750,7 @@ export class Server {
742
750
  ? fusedRespondNoSet(result, this.responseBodyTag, this.staticResponseHeaders, true)
743
751
  : toResponse(result, set, this.responseBodyTag, this.staticResponseHeaders,
744
752
  // Response hooks write next, and they run for this same request object.
745
- ctx !== undefined && this.onResponseHooks.length > 0 ? ctx[CONTEXT_REQUEST] : undefined);
753
+ ctx !== undefined && this.onResponseHooks.length > 0 ? ctx[CONTEXT_REQUEST] : undefined, this.onResponseHooks.length === 0);
746
754
  responseRequests;
747
755
  /** A request the WebSocket handshake check ran the onRequest hooks on and then passed to HTTP →
748
756
  * what the hooks returned, so `fetch` routes that instead of running the hooks a second time. */
@@ -808,6 +816,7 @@ export class Server {
808
816
  this.gracefulSignals =
809
817
  options.gracefulSignals === true ? {} : options.gracefulSignals || undefined;
810
818
  this.stopHooks = [];
819
+ this.stoppingHooks = [];
811
820
  this.fetchMounts = [];
812
821
  this.mountOrder = 0;
813
822
  this.preRouteMountCount = 0;
@@ -878,6 +887,18 @@ export class Server {
878
887
  this.stopHooks.push(fn);
879
888
  return this;
880
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
+ }
881
902
  /**
882
903
  * Mount a legacy fetch handler under a literal path prefix. Mounted handlers sit behind typed
883
904
  * routes, so routes can move one at a time while the remaining legacy surface stays live.
@@ -1575,6 +1596,7 @@ export class Server {
1575
1596
  throw new RouteConfigError("INVALID_PATH", `group(${JSON.stringify(prefix)}) route path must start with "/": ${JSON.stringify(path)}`);
1576
1597
  }
1577
1598
  prepareRoute(method, path, schema, handler) {
1599
+ schema = withTextSlots(schema);
1578
1600
  // Route provenance for the CLI's capability walk, which sets NIFRA_ROUTE_SITES while it loads the
1579
1601
  // app: the module that registered the route, read where a static scan cannot see the path.
1580
1602
  if (hookAuditRuntime &&
@@ -1604,6 +1626,15 @@ export class Server {
1604
1626
  defaultOnValidationError: this.defaultOnValidationError,
1605
1627
  }, method, path, schema, handler);
1606
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;
1607
1638
  if (idempotent !== undefined && this.idempotencyRuntime !== undefined) {
1608
1639
  handler = this.idempotencyRuntime.wrapHandler(handler);
1609
1640
  }
@@ -1622,11 +1653,11 @@ export class Server {
1622
1653
  const fusedBodyRunner = fusedBody
1623
1654
  ? this.buildFusedBodyRunner(handler, schema?.body, hasDecorations ? routeDecorations : undefined, bodyLimit ?? UNLIMITED_BODY_BYTES)
1624
1655
  : isFusedBodyLifecycle
1625
- ? 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)
1626
1657
  : isFusedLifecycle && lifecycleHookLane === "derive-before"
1627
- ? 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)
1628
1659
  : isFusedLifecycle && lifecycleHookLane === "derive-before-after"
1629
- ? 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)
1630
1661
  : undefined;
1631
1662
  const fusedWeb = bare && this.aroundHooks.length === 0
1632
1663
  ? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler), bodyLimit ?? UNLIMITED_BODY_BYTES)
@@ -1635,20 +1666,20 @@ export class Server {
1635
1666
  : fusedBody
1636
1667
  ? this.buildFusedBodyWeb(fusedBodyRunner)
1637
1668
  : isFusedBodyLifecycle
1638
- ? 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)
1639
1670
  : isFusedLifecycle && lifecycleHookLane === "derive-before"
1640
- ? 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)
1641
1672
  : isFusedLifecycle && lifecycleHookLane === "derive-before-after"
1642
- ? 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)
1643
1674
  : undefined;
1644
1675
  const program = compileRouteProgram({
1645
1676
  schema,
1646
1677
  handler: handler,
1647
1678
  derives: this.derives,
1648
1679
  authStages: compiled.authStages,
1649
- beforeHandle: this.beforeHandleHooks,
1650
- afterHandle: this.afterHandleHooks,
1651
- onError: this.onErrorHooks,
1680
+ beforeHandle,
1681
+ afterHandle,
1682
+ onError,
1652
1683
  decorations: routeDecorations,
1653
1684
  hasDecorations,
1654
1685
  bodySchema: schema?.body,
@@ -1676,9 +1707,9 @@ export class Server {
1676
1707
  authStages: [...compiled.authStages],
1677
1708
  decorations: routeDecorations,
1678
1709
  hasDecorations,
1679
- beforeHandle: [...this.beforeHandleHooks],
1680
- afterHandle: [...this.afterHandleHooks],
1681
- onError: [...this.onErrorHooks],
1710
+ beforeHandle: [...beforeHandle],
1711
+ afterHandle: [...afterHandle],
1712
+ onError: [...onError],
1682
1713
  lifecycleHookLane: lanes.lifecycleHookLane,
1683
1714
  around: [...this.aroundHooks],
1684
1715
  execution,
@@ -2064,6 +2095,7 @@ export class Server {
2064
2095
  // Cleanup the source registered (a pool, a queue consumer) belongs to the server that now runs
2065
2096
  // its routes; dropping it would leak the resource past `stop()`.
2066
2097
  this.stopHooks.push(...source.stopHooks);
2098
+ this.stoppingHooks.push(...source.stoppingHooks);
2067
2099
  this.mcpResourceList.push(...source.mcpResourceList);
2068
2100
  this.mcpPromptList.push(...source.mcpPromptList);
2069
2101
  }
@@ -2691,16 +2723,20 @@ export class Server {
2691
2723
  return refuse(jsonError(500, "internal_error"));
2692
2724
  }
2693
2725
  };
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
2726
  const settle = (outcome) => outcome.kind === "pass" ||
2698
2727
  (outcome.kind === "reject" && outcome.response.status === 404 && selected.fallbackOn404)
2699
2728
  ? dispatch(selectedIndex + 1)
2700
2729
  : outcome.kind === "reject"
2701
2730
  ? refuse(outcome.response)
2702
2731
  : outcome;
2703
- 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)));
2704
2740
  };
2705
2741
  return dispatch(0);
2706
2742
  };
@@ -3094,20 +3130,26 @@ export class Server {
3094
3130
  return fallback ?? jsonError(404, "not_found");
3095
3131
  const selected = mount;
3096
3132
  const run = () => {
3133
+ const reused = selected.stripPrefix ? selected.wsPassed?.get(request) : undefined;
3097
3134
  const childRequest = selected.stripPrefix
3098
- ? (selected.wsPassed?.get(request) ?? stripMountPrefix(request, selected.path))
3135
+ ? (reused ?? stripMountPrefix(request, selected.path))
3099
3136
  : request;
3100
- return selected.handler(childRequest, platform);
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;
3101
3148
  };
3102
- const outcome = mount.authStages.length === 0
3149
+ // Keep gate refusals outside the child's 404 fallthrough path.
3150
+ return mount.authStages.length === 0
3103
3151
  ? run()
3104
3152
  : 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
3153
  };
3112
3154
  return dispatch(first);
3113
3155
  }
@@ -4874,11 +4916,12 @@ export class Server {
4874
4916
  * The Bun semantics: poll `pendingRequests` (awaiting
4875
4917
  * `stop()`'s promise drops in-flight requests), and decide graceful-vs-forced in
4876
4918
  * 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.
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.
4880
4923
  */
4881
- async stop({ drainMs = DEFAULT_DRAIN_MS } = {}) {
4924
+ async stop({ drainMs = DEFAULT_DRAIN_MS, delayMs = 0 } = {}) {
4882
4925
  const onSignal = this.signalHandler;
4883
4926
  if (onSignal !== undefined) {
4884
4927
  this.signalHandler = undefined;
@@ -4888,6 +4931,9 @@ export class Server {
4888
4931
  if (server === undefined)
4889
4932
  return;
4890
4933
  this.bunServer = undefined;
4934
+ await this[SERVER_LIFECYCLE]("stopping");
4935
+ if (delayMs > 0)
4936
+ await Bun.sleep(delayMs);
4891
4937
  const sockets = this.bunSockets;
4892
4938
  this.bunSockets = undefined;
4893
4939
  if (sockets !== undefined)
@@ -4899,14 +4945,25 @@ export class Server {
4899
4945
  await Bun.sleep(DRAIN_POLL_MS);
4900
4946
  }
4901
4947
  server.stop(busy()); // force-close iff stragglers remain past the deadline
4902
- 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)
4903
4957
  return;
4904
- const callbacks = this.stopHooks.map((hook) => Promise.resolve().then(hook));
4905
- const settled = Promise.allSettled(callbacks);
4958
+ let timer;
4906
4959
  await Promise.race([
4907
- settled,
4908
- 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
+ }),
4909
4964
  ]);
4965
+ // A pending bound would hold the event loop open after every hook settled.
4966
+ clearTimeout(timer);
4910
4967
  }
4911
4968
  installSignalHandlers(drain) {
4912
4969
  // Drain, then let the process exit naturally - the stopped server no longer