@nifrajs/core 5.3.0 → 5.5.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 (93) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/internal/route-lanes.d.ts.map +1 -1
  5. package/dist/internal/route-lanes.js +2 -1
  6. package/dist/internal/route-lanes.js.map +1 -1
  7. package/dist/internal/route-program.d.ts +5 -0
  8. package/dist/internal/route-program.d.ts.map +1 -1
  9. package/dist/internal/route-program.js +36 -15
  10. package/dist/internal/route-program.js.map +1 -1
  11. package/dist/range.d.ts.map +1 -1
  12. package/dist/range.js +62 -2
  13. package/dist/range.js.map +1 -1
  14. package/dist/router/router.d.ts +3 -2
  15. package/dist/router/router.d.ts.map +1 -1
  16. package/dist/router/router.js +22 -6
  17. package/dist/router/router.js.map +1 -1
  18. package/dist/security/baseline.d.ts.map +1 -1
  19. package/dist/security/baseline.js +4 -3
  20. package/dist/security/baseline.js.map +1 -1
  21. package/dist/server/body-lane.d.ts.map +1 -1
  22. package/dist/server/body-lane.js +4 -3
  23. package/dist/server/body-lane.js.map +1 -1
  24. package/dist/server/body.d.ts +2 -0
  25. package/dist/server/body.d.ts.map +1 -1
  26. package/dist/server/body.js +5 -0
  27. package/dist/server/body.js.map +1 -1
  28. package/dist/server/cookies.d.ts.map +1 -1
  29. package/dist/server/cookies.js +9 -2
  30. package/dist/server/cookies.js.map +1 -1
  31. package/dist/server/core-copies.js +1 -1
  32. package/dist/server/idempotency-lane.d.ts +11 -12
  33. package/dist/server/idempotency-lane.d.ts.map +1 -1
  34. package/dist/server/idempotency-lane.js +301 -151
  35. package/dist/server/idempotency-lane.js.map +1 -1
  36. package/dist/server/methods.d.ts +3 -2
  37. package/dist/server/methods.d.ts.map +1 -1
  38. package/dist/server/methods.js.map +1 -1
  39. package/dist/server/multipart.d.ts.map +1 -1
  40. package/dist/server/multipart.js +42 -13
  41. package/dist/server/multipart.js.map +1 -1
  42. package/dist/server/node-outcome.d.ts.map +1 -1
  43. package/dist/server/node-outcome.js +2 -2
  44. package/dist/server/node-outcome.js.map +1 -1
  45. package/dist/server/query.d.ts +1 -1
  46. package/dist/server/query.d.ts.map +1 -1
  47. package/dist/server/query.js +13 -5
  48. package/dist/server/query.js.map +1 -1
  49. package/dist/server/request-context.d.ts.map +1 -1
  50. package/dist/server/request-context.js +3 -3
  51. package/dist/server/request-context.js.map +1 -1
  52. package/dist/server/respond.d.ts +17 -0
  53. package/dist/server/respond.d.ts.map +1 -1
  54. package/dist/server/respond.js +81 -3
  55. package/dist/server/respond.js.map +1 -1
  56. package/dist/server/runtime-core.d.ts +15 -0
  57. package/dist/server/runtime-core.d.ts.map +1 -1
  58. package/dist/server/runtime-core.js +41 -1
  59. package/dist/server/runtime-core.js.map +1 -1
  60. package/dist/server/server.d.ts +14 -5
  61. package/dist/server/server.d.ts.map +1 -1
  62. package/dist/server/server.js +187 -62
  63. package/dist/server/server.js.map +1 -1
  64. package/dist/server/transport-lane.d.ts +2 -2
  65. package/dist/server/transport-lane.d.ts.map +1 -1
  66. package/dist/server/transport-lane.js +20 -20
  67. package/dist/server/transport-lane.js.map +1 -1
  68. package/dist/static-file.d.ts +127 -0
  69. package/dist/static-file.d.ts.map +1 -0
  70. package/dist/static-file.js +269 -0
  71. package/dist/static-file.js.map +1 -0
  72. package/package.json +6 -1
  73. package/src/index.ts +1 -1
  74. package/src/internal/route-lanes.ts +18 -16
  75. package/src/internal/route-program.ts +76 -12
  76. package/src/range.ts +61 -2
  77. package/src/router/router.ts +26 -5
  78. package/src/security/baseline.ts +4 -3
  79. package/src/server/body-lane.ts +4 -4
  80. package/src/server/body.ts +6 -0
  81. package/src/server/cookies.ts +7 -2
  82. package/src/server/core-copies.ts +1 -1
  83. package/src/server/idempotency-lane.ts +343 -209
  84. package/src/server/methods.ts +3 -2
  85. package/src/server/multipart.ts +62 -14
  86. package/src/server/node-outcome.ts +2 -1
  87. package/src/server/query.ts +12 -5
  88. package/src/server/request-context.ts +6 -3
  89. package/src/server/respond.ts +108 -6
  90. package/src/server/runtime-core.ts +54 -1
  91. package/src/server/server.ts +257 -102
  92. package/src/server/transport-lane.ts +28 -22
  93. package/src/static-file.ts +362 -0
@@ -8,7 +8,7 @@ import { adoptRouteRuntimes, compileRouteOptions } from "../internal/route-compi
8
8
  import { compileRouteExecutionPlan, } from "../internal/route-execution.js";
9
9
  import { compileRouteProgram, executeRouteProgram } from "../internal/route-program.js";
10
10
  import { isSameOriginRequest } from "../internal/same-origin.js";
11
- import { decodeRouteParams, expandOptionalParams } from "../router/pattern.js";
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
14
  import { assertByteLimit, markTransportCap, markTrustedBodyFraming, UNLIMITED_BODY_BYTES, } from "./body.js";
@@ -22,7 +22,7 @@ import { queryObjectOf, searchOf } from "./query.js";
22
22
  import { PLATFORM_PEER, RequestContext, readBodyFramed, } from "./request-context.js";
23
23
  import { recordRequestReplacement } from "./request-lineage.js";
24
24
  import { needsResolving, resolveDotSegments } from "./request-target.js";
25
- import { applyStaticResponseHeaders, buildStaticResponseHeaders, fusedRespond, fusedRespondNoSet, toResponse, writableResponse, } from "./respond.js";
25
+ import { applyStaticResponseHeaders, buildStaticResponseHeaders, claimHandedOver, fusedRespond, fusedRespondNoSet, handedBackResponse, recordResidentTexts, toResponse, writableResponse, } from "./respond.js";
26
26
  import { CONTEXT_REQUEST, CONTEXT_SEARCH, CONTEXT_SET, EMPTY_RESPONSE_CONTROLS, getNeverAbortSignal, getUnboundedRequestBudget, headerOf, isResponseResult, requestOf, } from "./runtime-core.js";
27
27
  import { normalizeStaticResponseHeaders } from "./static-headers.js";
28
28
  import { plainValidationError } from "./validation.js";
@@ -54,6 +54,9 @@ function bunPeerPlatform(server, req) {
54
54
  },
55
55
  };
56
56
  }
57
+ // Bun's route table has a slot for each of these; a QUERY entry is ignored and the request falls
58
+ // through to the table's fetch, so QUERY routes stay on the portable dispatcher.
59
+ const BUN_TABLE_METHODS = METHODS.filter((method) => method !== "QUERY");
57
60
  /**
58
61
  * Whether the portable router gives `other` a request that `route` also matches: the two patterns can
59
62
  * match one path, and `other` is the more specific at the first segment where they differ. `route`
@@ -80,6 +83,23 @@ function outranks(other, route) {
80
83
  }
81
84
  return ahead && other.length === route.length;
82
85
  }
86
+ /**
87
+ * The key Bun's route table holds `path` under. Bun refuses one with a character outside printable
88
+ * ASCII, so such a path is spelled as the portable router matches it: percent-encoded static
89
+ * segments and `:name` parameters. `segments` holds static and `:param` segments only.
90
+ */
91
+ function bunTablePath(path, segments) {
92
+ if (!/[^ -~]/.test(path))
93
+ return path;
94
+ let key = "";
95
+ for (const segment of segments) {
96
+ if (segment.kind === "static")
97
+ key += `/${segment.value}`;
98
+ else if (segment.kind === "param")
99
+ key += `/:${segment.name}`;
100
+ }
101
+ return key;
102
+ }
83
103
  const WS_PASS = { kind: "pass" };
84
104
  const DEFAULT_WS_UPGRADE_TIMEOUT_MS = 10_000;
85
105
  /** Counts `ws()` routes and `mount()`s across every app in this copy of core: the only
@@ -157,8 +177,9 @@ const withResolvedTarget = (req) => new Request(resolveDotSegments(req.url), req
157
177
  // `Request.url` by appending the target to the Host as received, so `Host: x/admin#` turned a
158
178
  // request for `/public` into one for `/admin`. Node refuses such a Host before routing; so does this.
159
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.
160
181
  const invalidHost = (req) => {
161
- const host = req.headers.get("host");
182
+ const host = headerOf(req, "host");
162
183
  return host !== null && !HOST_AUTHORITY.test(host);
163
184
  };
164
185
  function normalizeMountPrefix(path) {
@@ -287,6 +308,12 @@ const SERVER_RUNTIMES = [
287
308
  // Stable module-level finalizers so `fetch`/`resolveNode` allocate no per-request closures.
288
309
  const IDENTITY_RESPONSE = (response) => webResponseOf(response);
289
310
  const RESPONSE_TIMEOUT = () => jsonError(503, "request_timeout");
311
+ /** An `onRequest` hook's answer, held to the single-use check a handler's result gets. */
312
+ function earlyAnswer(answer, wrapResponse) {
313
+ return answer instanceof Response && !claimHandedOver(answer)
314
+ ? wrapResponse(plainError(500, "internal_error"))
315
+ : wrapResponse(answer);
316
+ }
290
317
  // The unused-order-scoped-hook audit (recorder, seal check, stack capture, message) is a dev-time
291
318
  // diagnostic. Every guard below keeps the INLINE literal `process.env.NODE_ENV !== "production"` at the
292
319
  // branch - never a shared const holding the folded boolean - because a bundler's `process.env.NODE_ENV`
@@ -327,6 +354,9 @@ const captureCallerSite = () => {
327
354
  return undefined;
328
355
  };
329
356
  const hookAudit = /* @__PURE__ */ new WeakMap();
357
+ // Audits begun and not yet sealed. Zero outside development, so a request tests it before the
358
+ // NODE_ENV literal and never reads `process.env` (a native call of ~130 ns on Node).
359
+ let unsealedHookAudits = 0;
330
360
  /** Whether `from` reaches `target` through mounts that keep the path and take a request under `path`.
331
361
  * Module-level, not a `Server` method, so a production bundle drops it with its dev-only caller. */
332
362
  function mountLeadsBack(from, target, path, mountsOf, seen) {
@@ -344,6 +374,7 @@ function mountLeadsBack(from, target, path, mountsOf, seen) {
344
374
  const PAGES_DECLARED = /* @__PURE__ */ Symbol.for("nifra.web.pagesDeclared");
345
375
  /** Start tracking one server's order-scoped hook pushes (once, from the constructor). */
346
376
  const beginHookAudit = (key, policy, hasCustomLogger) => {
377
+ unsealedHookAudits++;
347
378
  hookAudit.set(key, {
348
379
  policy,
349
380
  hasCustomLogger,
@@ -434,6 +465,7 @@ const sealHookAudit = (key, total, sockets, logger) => {
434
465
  if (state === undefined || state.checked)
435
466
  return;
436
467
  state.checked = true;
468
+ unsealedHookAudits--;
437
469
  if (state.policy === "off" || total + sockets === 0)
438
470
  return;
439
471
  const own = total - state.merged;
@@ -554,6 +586,9 @@ export class Server {
554
586
  /** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
555
587
  defaultOnValidationError;
556
588
  bunServer;
589
+ // Bun's `pendingRequests` leaves out upgraded sockets, so `stop()` closes these itself.
590
+ bunSockets;
591
+ signalHandler;
557
592
  sealed;
558
593
  derives;
559
594
  /** Dedicated authentication stages captured by subsequent routes. */
@@ -895,7 +930,8 @@ export class Server {
895
930
  /**
896
931
  * Run before routing on the raw request. Return a `Response` or a `status()` result to
897
932
  * short-circuit, or a replacement `Request` to continue routing with a rewritten method/URL/headers.
898
- * Global.
933
+ * Global. A throw or rejection goes to every registered {@link onError} (route-less context), then
934
+ * the logged default 500.
899
935
  */
900
936
  onRequest(fn) {
901
937
  this.assertConfigurable("onRequest()");
@@ -981,8 +1017,8 @@ export class Server {
981
1017
  }
982
1018
  /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500, or 503
983
1019
  * `auth_unavailable` for a failed {@link authenticate} stage). Order-scoped: captured by routes
984
- * declared *after* this call, none before; one added after the last route reaches nothing (see
985
- * {@link ServerOptions.unusedScopedHooks}). */
1020
+ * declared *after* this call, none before; one added after the last route reaches no route (see
1021
+ * {@link ServerOptions.unusedScopedHooks}). Every one answers an {@link onRequest} failure. */
986
1022
  onError(fn) {
987
1023
  this.assertConfigurable("onError()");
988
1024
  this.onErrorHooks.push(fn);
@@ -1075,6 +1111,7 @@ export class Server {
1075
1111
  if (this.responseBodyTag === undefined) {
1076
1112
  this.responseBodyTag = Object.freeze({});
1077
1113
  this.responseBodyOwners.add(this.responseBodyTag);
1114
+ recordResidentTexts();
1078
1115
  }
1079
1116
  return this.responseBodyTag;
1080
1117
  }
@@ -1572,6 +1609,7 @@ export class Server {
1572
1609
  if (this.responseBodyTag === undefined) {
1573
1610
  this.responseBodyTag = Object.freeze({});
1574
1611
  this.responseBodyOwners.add(this.responseBodyTag);
1612
+ recordResidentTexts();
1575
1613
  }
1576
1614
  return this.responseBodyTag;
1577
1615
  },
@@ -2007,8 +2045,8 @@ export class Server {
2007
2045
  }
2008
2046
  fetchSource(source, platform) {
2009
2047
  // Seal once on the first request so test servers (no `listen()`) still get the dead-hook check.
2010
- // After that it is a single boolean read - the per-request cost the option promises to keep at zero.
2011
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2048
+ // After that it is one integer compare; the literal stays so a production define drops the branch.
2049
+ if (unsealedHookAudits !== 0 && process.env.NODE_ENV !== "production")
2012
2050
  sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger);
2013
2051
  // Off path (default): straight through - one property check, no closure, no promise.
2014
2052
  if (this.capacityGate === undefined)
@@ -2093,6 +2131,9 @@ export class Server {
2093
2131
  * a path whose registered route rejects this method.
2094
2132
  */
2095
2133
  [RESOLVE_NODE_MOUNT](source) {
2134
+ // Node asks before every dispatch; an app with no mounts cannot select a native handler.
2135
+ if (this.fetchMounts.length === 0)
2136
+ return undefined;
2096
2137
  if (this.onRequestHooks.length > 0 ||
2097
2138
  this.onResponseHooks.length > 0 ||
2098
2139
  this.onResponseFinalizedHooks.length > 0 ||
@@ -2141,7 +2182,7 @@ export class Server {
2141
2182
  }
2142
2183
  resolveNodeSource(source, platform, suppliedRuntime) {
2143
2184
  // Seal once on the Node-direct lane too - it can bypass `fetchSource` entirely (hookless outcome).
2144
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
2185
+ if (unsealedHookAudits !== 0 && process.env.NODE_ENV !== "production")
2145
2186
  sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger);
2146
2187
  // A paired header-only native hook can preserve the Node-direct JSON/body outcome for successful
2147
2188
  // responses. Arbitrary onResponse transforms need a real Web Response, but a buffered outcome can
@@ -2381,11 +2422,11 @@ export class Server {
2381
2422
  }
2382
2423
  }
2383
2424
  resolveWebSocketUpgrade(req, platform) {
2384
- // Before any header is read: Deno only materializes a request's headers when asked, and that
2385
- // costs a plain request measurably.
2425
+ // Before any header is read, and then through `headerOf`: Deno builds a request's whole header
2426
+ // list when `headers` is read, which costs every plain request of an app with sockets measurably.
2386
2427
  if (this.wsRoutes.length === 0 && !this.mountsTakeUpgrades())
2387
2428
  return WS_PASS;
2388
- if (req.headers.get("upgrade")?.toLowerCase() !== "websocket")
2429
+ if (headerOf(req, "upgrade")?.toLowerCase() !== "websocket")
2389
2430
  return WS_PASS;
2390
2431
  // A WebSocket handshake is a GET (RFC 6455 section 4.1). Any other method with an Upgrade header
2391
2432
  // is an ordinary request: its HTTP route answers it, and normal routing refuses an unknown token.
@@ -2547,17 +2588,24 @@ export class Server {
2547
2588
  this.wsHookedPasses.set(received, request);
2548
2589
  return resolveWithDeadline(request);
2549
2590
  };
2591
+ // The same boundary as an HTTP request's hooks; the answer is already wrapped, so it is not refused.
2592
+ const failed = (err) => {
2593
+ const answer = this.onRequestFailure(err, req, platform, this.finalizeResponse, this.wrapWebResponse);
2594
+ return answer instanceof Promise
2595
+ ? answer.then((response) => ({ kind: "reject", response }))
2596
+ : { kind: "reject", response: answer };
2597
+ };
2550
2598
  let hooked;
2551
2599
  try {
2552
2600
  hooked = this.runWebSocketRequestHooks(req, platform);
2553
2601
  }
2554
- catch {
2555
- return refuse(jsonError(500, "internal_error"));
2602
+ catch (err) {
2603
+ return failed(err);
2556
2604
  }
2557
2605
  if (!(hooked instanceof Promise)) {
2558
2606
  return hooked instanceof Response ? refuse(hooked) : resolveHooked(hooked);
2559
2607
  }
2560
- const pending = hooked.then((result) => (result instanceof Response ? refuse(result) : resolveHooked(result)), () => refuse(jsonError(500, "internal_error")));
2608
+ const pending = hooked.then((result) => (result instanceof Response ? refuse(result) : resolveHooked(result)), failed);
2561
2609
  return bounded(Promise.resolve(pending).then((result) => result), onTimeout);
2562
2610
  }
2563
2611
  /** Bun `fetch` when WS routes exist: try a WS upgrade first, else run the normal HTTP lifecycle.
@@ -2632,24 +2680,36 @@ export class Server {
2632
2680
  const request = this.nodeRequestContextOf(source);
2633
2681
  for (let i = 0; i < hooks.length; i++) {
2634
2682
  const hook = hooks[i];
2635
- const outcome = hook(request, platform);
2683
+ let outcome;
2684
+ try {
2685
+ outcome = hook(request, platform);
2686
+ }
2687
+ catch (err) {
2688
+ return this.onRequestFailure(err, source, platform, finalize, wrapResponse);
2689
+ }
2636
2690
  if (outcome instanceof Promise) {
2637
- return outcome.then((early) => this.continueNodeRequest(early, i + 1, request, source, platform, finalize, wrapResponse, onTimeout));
2691
+ return outcome.then((early) => this.continueNodeRequest(early, i + 1, request, source, platform, finalize, wrapResponse, onTimeout), (err) => this.onRequestFailure(err, source, platform, finalize, wrapResponse));
2638
2692
  }
2639
2693
  if (outcome !== undefined)
2640
- return wrapResponse(outcome);
2694
+ return earlyAnswer(outcome, wrapResponse);
2641
2695
  }
2642
2696
  return this.routeAndRun(source, platform, finalize, wrapResponse, onTimeout, false);
2643
2697
  }
2644
2698
  async continueNodeRequest(first, nextIndex, request, source, platform, finalize, wrapResponse, onTimeout) {
2645
2699
  if (first !== undefined)
2646
- return wrapResponse(first);
2700
+ return earlyAnswer(first, wrapResponse);
2647
2701
  for (let i = nextIndex; i < this.onNodeRequestHooks.length; i++) {
2648
2702
  const hook = this.onNodeRequestHooks[i];
2649
- const outcome = hook(request, platform);
2650
- const early = outcome instanceof Promise ? await outcome : outcome;
2703
+ let early;
2704
+ try {
2705
+ const outcome = hook(request, platform);
2706
+ early = outcome instanceof Promise ? await outcome : outcome;
2707
+ }
2708
+ catch (err) {
2709
+ return this.onRequestFailure(err, source, platform, finalize, wrapResponse);
2710
+ }
2651
2711
  if (early !== undefined)
2652
- return wrapResponse(early);
2712
+ return earlyAnswer(early, wrapResponse);
2653
2713
  }
2654
2714
  return this.routeAndRun(source, platform, finalize, wrapResponse, onTimeout, false);
2655
2715
  }
@@ -2674,9 +2734,15 @@ export class Server {
2674
2734
  let current = source;
2675
2735
  for (let i = 0; i < hooks.length; i++) {
2676
2736
  const seen = requestOf(current);
2677
- const outcome = hooks[i](seen, platform);
2737
+ let outcome;
2738
+ try {
2739
+ outcome = hooks[i](seen, platform);
2740
+ }
2741
+ catch (err) {
2742
+ return this.onRequestFailure(err, current, platform, finalize, wrapResponse);
2743
+ }
2678
2744
  if (outcome instanceof Promise) {
2679
- return outcome.then((early) => this.continueOnRequest(early, i + 1, originalRequest, current, platform, finalize, wrapResponse, onTimeout, webFast));
2745
+ return outcome.then((early) => this.continueOnRequest(early, i + 1, originalRequest, current, platform, finalize, wrapResponse, onTimeout, webFast), (err) => this.onRequestFailure(err, current, platform, finalize, wrapResponse));
2680
2746
  }
2681
2747
  if (outcome instanceof Request) {
2682
2748
  if (outcome !== seen)
@@ -2687,7 +2753,7 @@ export class Server {
2687
2753
  continue;
2688
2754
  }
2689
2755
  if (outcome !== undefined)
2690
- return wrapResponse(outcome);
2756
+ return earlyAnswer(outcome, wrapResponse);
2691
2757
  }
2692
2758
  return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast);
2693
2759
  }
@@ -2707,13 +2773,18 @@ export class Server {
2707
2773
  this.responseRequests.set(originalRequest, early);
2708
2774
  }
2709
2775
  else if (early !== undefined) {
2710
- return wrapResponse(early);
2776
+ return earlyAnswer(early, wrapResponse);
2711
2777
  }
2712
2778
  if (index >= this.onRequestHooks.length)
2713
2779
  break;
2714
2780
  seen = requestOf(current);
2715
- const outcome = this.onRequestHooks[index](seen, platform);
2716
- early = outcome instanceof Promise ? await outcome : outcome;
2781
+ try {
2782
+ const outcome = this.onRequestHooks[index](seen, platform);
2783
+ early = outcome instanceof Promise ? await outcome : outcome;
2784
+ }
2785
+ catch (err) {
2786
+ return this.onRequestFailure(err, current, platform, finalize, wrapResponse);
2787
+ }
2717
2788
  index++;
2718
2789
  }
2719
2790
  return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast);
@@ -2928,14 +2999,14 @@ export class Server {
2928
2999
  if (method !== "GET" && method !== "HEAD")
2929
3000
  markTransportCap(source, entry.bodyLimit);
2930
3001
  }
2931
- // An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
2932
- // (with the body buffered). All non-idempotent routes skip straight to the lanes - no added cost.
3002
+ // An idempotency route meets its dedupe gate inside the program, so the gate runs after
3003
+ // authentication and under the request deadline. Its lanes render a `Response`, which the gate
3004
+ // stores. Non-idempotent routes skip straight to the lanes - no added cost.
2933
3005
  // The runtime is always present when a route resolved idempotency (enforced at registration).
2934
3006
  if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
2935
- return this.idempotencyRuntime.run(entry.idempotent, requestOf(source), platform, entry, params, search, wrapResponse, {
2936
- maxBodyBytes: entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2937
- runLanes: (buffered, plat, ent, prm, srch) => this.runMatchedLanes(buffered, plat, ent, prm, srch, this.finalizeResponse, this.wrapWebResponse, this.webResponseTimeout, false),
2938
- });
3007
+ return this.idempotencyRuntime
3008
+ .run(source, platform, (prepared) => this.runMatchedLanes(prepared, platform, entry, params, search, this.finalizeResponse, this.wrapWebResponse, this.webResponseTimeout, false))
3009
+ .then(wrapResponse);
2939
3010
  }
2940
3011
  return this.runMatchedLanes(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast);
2941
3012
  }
@@ -4026,7 +4097,7 @@ export class Server {
4026
4097
  const handed = current;
4027
4098
  return next.then((settled) => this.continueOnResponse(settled, i + 1, req, handed));
4028
4099
  }
4029
- current = next === current ? next : writableResponse(next, req);
4100
+ current = next === current ? next : handedBackResponse(next, req);
4030
4101
  }
4031
4102
  return current;
4032
4103
  }
@@ -4086,11 +4157,11 @@ export class Server {
4086
4157
  }
4087
4158
  /** Async tail of {@link applyOnResponse}: runs the remaining hooks once one has gone async. */
4088
4159
  async continueOnResponse(response, nextIndex, req, handed) {
4089
- let current = response === handed ? response : writableResponse(response, req);
4160
+ let current = response === handed ? response : handedBackResponse(response, req);
4090
4161
  for (let i = nextIndex; i < this.onResponseHooks.length; i++) {
4091
4162
  const next = this.onResponseHooks[i](current, req);
4092
4163
  const settled = next instanceof Promise ? await next : next;
4093
- current = settled === current ? settled : writableResponse(settled, req);
4164
+ current = settled === current ? settled : handedBackResponse(settled, req);
4094
4165
  }
4095
4166
  return current;
4096
4167
  }
@@ -4172,28 +4243,57 @@ export class Server {
4172
4243
  this.logRequestError(err, ctx);
4173
4244
  return fallback === undefined ? wrapResponse(plainError(500, "internal_error")) : fallback();
4174
4245
  }
4175
- return this.runErrorHooks(entry, err, ctx, finalize, wrapResponse, fallback);
4246
+ return this.runErrorHooks(entry.onError, entry.responseContract, err, ctx, finalize, wrapResponse, fallback);
4247
+ }
4248
+ /** An `onRequest` hook threw or rejected. No route has matched, so every `onError` the app holds
4249
+ * answers it, with a route-less context; otherwise the logged default 500, as for a handler. */
4250
+ onRequestFailure(err, source, platform, finalize, wrapResponse) {
4251
+ if (err instanceof Response || isResponseResult(err))
4252
+ return wrapResponse(err);
4253
+ const ctx = new RequestContext(source, EMPTY_PARAMS, undefined, getNeverAbortSignal(), getUnboundedRequestBudget(), platform, this.maxBodyBytes, this.protoPoisoning);
4254
+ if (this.onErrorHooks.length === 0) {
4255
+ this.logRequestError(err, ctx);
4256
+ return this.internalErrorResponse(wrapResponse);
4257
+ }
4258
+ Object.assign(ctx, this.decorations);
4259
+ return this.runErrorHooks(this.onErrorHooks, undefined, err, ctx, finalize, wrapResponse);
4176
4260
  }
4177
4261
  /** The `onError` chain, split off {@link handleLifecycleError} so its `await` costs only the routes
4178
- * that registered a hook. A hook may return a custom response; otherwise the default 500 stands. */
4179
- async runErrorHooks(entry, err, ctx, finalize, wrapResponse, fallback) {
4262
+ * that registered a hook. A hook may return a custom response; otherwise the default 500 stands.
4263
+ * Never rejects: nothing outside it re-enters `onError`, so a hook that throws, or an answer that
4264
+ * cannot be rendered, is logged and answered here. */
4265
+ async runErrorHooks(hooks, contract, err, ctx, finalize, wrapResponse, fallback) {
4180
4266
  // Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
4181
4267
  // the `await` and stay heap-allocated on both engines.
4182
- for (let i = 0; i < entry.onError.length; i++) {
4183
- const outcome = entry.onError[i](err, ctx);
4184
- const handled = outcome instanceof Promise ? await outcome : outcome;
4268
+ for (let i = 0; i < hooks.length; i++) {
4269
+ let handled;
4270
+ try {
4271
+ const outcome = hooks[i](err, ctx);
4272
+ handled = outcome instanceof Promise ? await outcome : outcome;
4273
+ }
4274
+ catch (hookError) {
4275
+ // A thrown Response or status() is the hook's answer, as from beforeHandle.
4276
+ if (!(hookError instanceof Response) && !isResponseResult(hookError)) {
4277
+ this.logRequestError(err, ctx);
4278
+ this.logRequestError(hookError, ctx);
4279
+ return fallback === undefined
4280
+ ? wrapResponse(plainError(500, "internal_error"))
4281
+ : fallback();
4282
+ }
4283
+ handled = hookError;
4284
+ }
4185
4285
  if (handled === undefined)
4186
4286
  continue;
4187
- const contract = entry.responseContract;
4188
- if (contract === undefined || (handled !== ctx.jsonReply && !isResponseResult(handled)))
4189
- return finalize(handled, responseSet(ctx), ctx);
4190
- // Checked here, not through finishLifecycleContract: a throwing schema must not re-enter onError.
4287
+ // Awaited so an asynchronous finalize's rejection lands in the catch too.
4191
4288
  try {
4289
+ if (contract === undefined || (handled !== ctx.jsonReply && !isResponseResult(handled)))
4290
+ return await finalize(handled, responseSet(ctx), ctx);
4291
+ // Checked here, not through finishLifecycleContract: a throwing schema must not re-enter onError.
4192
4292
  const checked = await contract.runtime.check(contract.definition, handled, ctx);
4193
- return this.finishContractOutcome(ctx, finalize, wrapResponse, checked);
4293
+ return await this.finishContractOutcome(ctx, finalize, wrapResponse, checked);
4194
4294
  }
4195
- catch (checkError) {
4196
- this.logRequestError(checkError, ctx);
4295
+ catch (finishError) {
4296
+ this.logRequestError(finishError, ctx);
4197
4297
  return wrapResponse(plainError(500, "internal_error"));
4198
4298
  }
4199
4299
  }
@@ -4408,9 +4508,10 @@ export class Server {
4408
4508
  // Where Bun could pass a request down: the methods served natively on a path with a parameter,
4409
4509
  // as segment count + method. Two table paths match one request only at the same segment count.
4410
4510
  const below = new Set();
4411
- const tableAt = (path) => {
4412
- routes[path] ??= Object.create(null);
4413
- return routes[path];
4511
+ const tableAt = (path, segments) => {
4512
+ const key = bunTablePath(path, segments);
4513
+ routes[key] ??= Object.create(null);
4514
+ return routes[key];
4414
4515
  };
4415
4516
  for (let i = 0; i < all.length; i++) {
4416
4517
  const { method, path, pattern, entry } = all[i];
@@ -4422,14 +4523,14 @@ export class Server {
4422
4523
  // the platform the table shares could not answer `c.clientIp` there.
4423
4524
  if (run === undefined ||
4424
4525
  entry.idempotent !== undefined ||
4425
- !METHODS.includes(method) ||
4526
+ !BUN_TABLE_METHODS.includes(method) ||
4426
4527
  (preflightOnFallback && method === "OPTIONS") ||
4427
4528
  under.get(run)?.some((other) => outranks(other.pattern.segments, pattern.segments))) {
4428
4529
  continue;
4429
4530
  }
4430
4531
  const paramNames = pattern.paramNames;
4431
4532
  const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined;
4432
- tableAt(path)[method] = this.compileBunNativeHandler(entry, paramNames, fused, unboundedSignal, unboundedBudget, peer, fallback);
4533
+ tableAt(path, pattern.segments)[method] = this.compileBunNativeHandler(entry, paramNames, fused, unboundedSignal, unboundedBudget, peer, fallback);
4433
4534
  count += 1;
4434
4535
  if (paramNames.length > 0) {
4435
4536
  below.add(pattern.segments.length + method);
@@ -4456,9 +4557,9 @@ export class Server {
4456
4557
  if (lead[i] === undefined)
4457
4558
  continue;
4458
4559
  const { path, pattern } = all[i];
4459
- for (const method of METHODS) {
4560
+ for (const method of BUN_TABLE_METHODS) {
4460
4561
  if (below.has(pattern.segments.length + method))
4461
- tableAt(path)[method] ??= fallback;
4562
+ tableAt(path, pattern.segments)[method] ??= fallback;
4462
4563
  }
4463
4564
  }
4464
4565
  return routes;
@@ -4555,6 +4656,8 @@ export class Server {
4555
4656
  [PLATFORM_PEER]: (request) => running.requestIP(request)?.address,
4556
4657
  };
4557
4658
  const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes(fallback, peer) : undefined;
4659
+ const sockets = new Set();
4660
+ this.bunSockets = sockets;
4558
4661
  const running = (wsHandlers === undefined
4559
4662
  ? Bun.serve({
4560
4663
  ...listening,
@@ -4572,9 +4675,18 @@ export class Server {
4572
4675
  // closes an over-cap connection before the handler runs. Default 1 MB (maxBodyBytes). One
4573
4676
  // limit per listener: the largest cap among this app and the apps mounted on it.
4574
4677
  maxPayloadLength: wsHandlers.maxPayloadLength,
4575
- open: (ws) => wsHandlers.open(ws),
4678
+ open: (ws) => {
4679
+ sockets.add(ws);
4680
+ wsHandlers.open(ws);
4681
+ // An upgrade accepted while `stop()` drains gets the close the earlier sockets got.
4682
+ if (this.bunServer !== running)
4683
+ ws.close(1001);
4684
+ },
4576
4685
  message: (ws, message) => wsHandlers.message(ws, message),
4577
- close: (ws, code, reason) => wsHandlers.close(ws, code, reason),
4686
+ close: (ws, code, reason) => {
4687
+ sockets.delete(ws);
4688
+ wsHandlers.close(ws, code, reason);
4689
+ },
4578
4690
  },
4579
4691
  }));
4580
4692
  this.bunServer = running;
@@ -4602,18 +4714,30 @@ export class Server {
4602
4714
  * `stop()`'s promise drops in-flight requests), and decide graceful-vs-forced in
4603
4715
  * ONE call (Bun can't escalate an already-graceful `stop()` to a forced close).
4604
4716
  * New connections may be accepted during the drain window; in a real deploy the
4605
- * load balancer has already stopped routing here, and `drainMs` bounds it.
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.
4606
4719
  */
4607
4720
  async stop({ drainMs = DEFAULT_DRAIN_MS } = {}) {
4721
+ const onSignal = this.signalHandler;
4722
+ if (onSignal !== undefined) {
4723
+ this.signalHandler = undefined;
4724
+ process.off("SIGTERM", onSignal).off("SIGINT", onSignal);
4725
+ }
4608
4726
  const server = this.bunServer;
4609
4727
  if (server === undefined)
4610
4728
  return;
4611
4729
  this.bunServer = undefined;
4730
+ const sockets = this.bunSockets;
4731
+ this.bunSockets = undefined;
4732
+ if (sockets !== undefined)
4733
+ for (const ws of sockets)
4734
+ ws.close(1001);
4735
+ const busy = () => server.pendingRequests + (sockets?.size ?? 0) > 0;
4612
4736
  const deadline = Date.now() + drainMs;
4613
- while (server.pendingRequests > 0 && Date.now() < deadline) {
4737
+ while (busy() && Date.now() < deadline) {
4614
4738
  await Bun.sleep(DRAIN_POLL_MS);
4615
4739
  }
4616
- server.stop(server.pendingRequests > 0); // force-close iff stragglers remain past the deadline
4740
+ server.stop(busy()); // force-close iff stragglers remain past the deadline
4617
4741
  if (this.stopHooks.length === 0)
4618
4742
  return;
4619
4743
  const callbacks = this.stopHooks.map((hook) => Promise.resolve().then(hook));
@@ -4630,6 +4754,7 @@ export class Server {
4630
4754
  const onSignal = () => {
4631
4755
  void this.stop(drain);
4632
4756
  };
4757
+ this.signalHandler = onSignal;
4633
4758
  process.once("SIGTERM", onSignal);
4634
4759
  process.once("SIGINT", onSignal);
4635
4760
  }