@nifrajs/core 5.4.0 → 5.5.1

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 (96) hide show
  1. package/CHANGELOG.md +35 -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/router/router.d.ts +3 -2
  12. package/dist/router/router.d.ts.map +1 -1
  13. package/dist/router/router.js +22 -6
  14. package/dist/router/router.js.map +1 -1
  15. package/dist/security/baseline.d.ts.map +1 -1
  16. package/dist/security/baseline.js +4 -3
  17. package/dist/security/baseline.js.map +1 -1
  18. package/dist/server/body-lane.d.ts.map +1 -1
  19. package/dist/server/body-lane.js +4 -3
  20. package/dist/server/body-lane.js.map +1 -1
  21. package/dist/server/body.d.ts +2 -0
  22. package/dist/server/body.d.ts.map +1 -1
  23. package/dist/server/body.js +5 -0
  24. package/dist/server/body.js.map +1 -1
  25. package/dist/server/cookies.d.ts.map +1 -1
  26. package/dist/server/cookies.js +9 -2
  27. package/dist/server/cookies.js.map +1 -1
  28. package/dist/server/core-copies.js +1 -1
  29. package/dist/server/idempotency-lane.d.ts +11 -12
  30. package/dist/server/idempotency-lane.d.ts.map +1 -1
  31. package/dist/server/idempotency-lane.js +301 -151
  32. package/dist/server/idempotency-lane.js.map +1 -1
  33. package/dist/server/methods.d.ts +3 -2
  34. package/dist/server/methods.d.ts.map +1 -1
  35. package/dist/server/methods.js.map +1 -1
  36. package/dist/server/multipart.d.ts.map +1 -1
  37. package/dist/server/multipart.js +42 -13
  38. package/dist/server/multipart.js.map +1 -1
  39. package/dist/server/node-outcome.d.ts.map +1 -1
  40. package/dist/server/node-outcome.js +2 -2
  41. package/dist/server/node-outcome.js.map +1 -1
  42. package/dist/server/query.d.ts +1 -1
  43. package/dist/server/query.d.ts.map +1 -1
  44. package/dist/server/query.js +13 -5
  45. package/dist/server/query.js.map +1 -1
  46. package/dist/server/request-context.d.ts.map +1 -1
  47. package/dist/server/request-context.js +3 -3
  48. package/dist/server/request-context.js.map +1 -1
  49. package/dist/server/respond.d.ts +17 -0
  50. package/dist/server/respond.d.ts.map +1 -1
  51. package/dist/server/respond.js +81 -3
  52. package/dist/server/respond.js.map +1 -1
  53. package/dist/server/runtime-core.d.ts +15 -0
  54. package/dist/server/runtime-core.d.ts.map +1 -1
  55. package/dist/server/runtime-core.js +41 -1
  56. package/dist/server/runtime-core.js.map +1 -1
  57. package/dist/server/server-types.d.ts +5 -3
  58. package/dist/server/server-types.d.ts.map +1 -1
  59. package/dist/server/server.d.ts +14 -5
  60. package/dist/server/server.d.ts.map +1 -1
  61. package/dist/server/server.js +179 -59
  62. package/dist/server/server.js.map +1 -1
  63. package/dist/server/transport-lane.d.ts +2 -2
  64. package/dist/server/transport-lane.d.ts.map +1 -1
  65. package/dist/server/transport-lane.js +20 -20
  66. package/dist/server/transport-lane.js.map +1 -1
  67. package/dist/server/ws-bun.d.ts.map +1 -1
  68. package/dist/server/ws-bun.js +7 -1
  69. package/dist/server/ws-bun.js.map +1 -1
  70. package/dist/static-file.d.ts +29 -6
  71. package/dist/static-file.d.ts.map +1 -1
  72. package/dist/static-file.js +43 -12
  73. package/dist/static-file.js.map +1 -1
  74. package/package.json +1 -1
  75. package/src/index.ts +1 -1
  76. package/src/internal/route-lanes.ts +18 -16
  77. package/src/internal/route-program.ts +76 -12
  78. package/src/router/router.ts +26 -5
  79. package/src/security/baseline.ts +4 -3
  80. package/src/server/body-lane.ts +4 -4
  81. package/src/server/body.ts +6 -0
  82. package/src/server/cookies.ts +7 -2
  83. package/src/server/core-copies.ts +1 -1
  84. package/src/server/idempotency-lane.ts +343 -209
  85. package/src/server/methods.ts +3 -2
  86. package/src/server/multipart.ts +62 -14
  87. package/src/server/node-outcome.ts +2 -1
  88. package/src/server/query.ts +12 -5
  89. package/src/server/request-context.ts +6 -3
  90. package/src/server/respond.ts +108 -6
  91. package/src/server/runtime-core.ts +54 -1
  92. package/src/server/server-types.ts +5 -3
  93. package/src/server/server.ts +249 -99
  94. package/src/server/transport-lane.ts +28 -22
  95. package/src/server/ws-bun.ts +12 -1
  96. package/src/static-file.ts +72 -16
@@ -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`
@@ -559,6 +586,9 @@ export class Server {
559
586
  /** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
560
587
  defaultOnValidationError;
561
588
  bunServer;
589
+ // Bun's `pendingRequests` leaves out upgraded sockets, so `stop()` closes these itself.
590
+ bunSockets;
591
+ signalHandler;
562
592
  sealed;
563
593
  derives;
564
594
  /** Dedicated authentication stages captured by subsequent routes. */
@@ -900,7 +930,8 @@ export class Server {
900
930
  /**
901
931
  * Run before routing on the raw request. Return a `Response` or a `status()` result to
902
932
  * short-circuit, or a replacement `Request` to continue routing with a rewritten method/URL/headers.
903
- * Global.
933
+ * Global. A throw or rejection goes to every registered {@link onError} (route-less context), then
934
+ * the logged default 500.
904
935
  */
905
936
  onRequest(fn) {
906
937
  this.assertConfigurable("onRequest()");
@@ -986,8 +1017,8 @@ export class Server {
986
1017
  }
987
1018
  /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500, or 503
988
1019
  * `auth_unavailable` for a failed {@link authenticate} stage). Order-scoped: captured by routes
989
- * declared *after* this call, none before; one added after the last route reaches nothing (see
990
- * {@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. */
991
1022
  onError(fn) {
992
1023
  this.assertConfigurable("onError()");
993
1024
  this.onErrorHooks.push(fn);
@@ -1080,6 +1111,7 @@ export class Server {
1080
1111
  if (this.responseBodyTag === undefined) {
1081
1112
  this.responseBodyTag = Object.freeze({});
1082
1113
  this.responseBodyOwners.add(this.responseBodyTag);
1114
+ recordResidentTexts();
1083
1115
  }
1084
1116
  return this.responseBodyTag;
1085
1117
  }
@@ -1577,6 +1609,7 @@ export class Server {
1577
1609
  if (this.responseBodyTag === undefined) {
1578
1610
  this.responseBodyTag = Object.freeze({});
1579
1611
  this.responseBodyOwners.add(this.responseBodyTag);
1612
+ recordResidentTexts();
1580
1613
  }
1581
1614
  return this.responseBodyTag;
1582
1615
  },
@@ -2098,6 +2131,9 @@ export class Server {
2098
2131
  * a path whose registered route rejects this method.
2099
2132
  */
2100
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;
2101
2137
  if (this.onRequestHooks.length > 0 ||
2102
2138
  this.onResponseHooks.length > 0 ||
2103
2139
  this.onResponseFinalizedHooks.length > 0 ||
@@ -2386,11 +2422,11 @@ export class Server {
2386
2422
  }
2387
2423
  }
2388
2424
  resolveWebSocketUpgrade(req, platform) {
2389
- // Before any header is read: Deno only materializes a request's headers when asked, and that
2390
- // 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.
2391
2427
  if (this.wsRoutes.length === 0 && !this.mountsTakeUpgrades())
2392
2428
  return WS_PASS;
2393
- if (req.headers.get("upgrade")?.toLowerCase() !== "websocket")
2429
+ if (headerOf(req, "upgrade")?.toLowerCase() !== "websocket")
2394
2430
  return WS_PASS;
2395
2431
  // A WebSocket handshake is a GET (RFC 6455 section 4.1). Any other method with an Upgrade header
2396
2432
  // is an ordinary request: its HTTP route answers it, and normal routing refuses an unknown token.
@@ -2552,17 +2588,24 @@ export class Server {
2552
2588
  this.wsHookedPasses.set(received, request);
2553
2589
  return resolveWithDeadline(request);
2554
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
+ };
2555
2598
  let hooked;
2556
2599
  try {
2557
2600
  hooked = this.runWebSocketRequestHooks(req, platform);
2558
2601
  }
2559
- catch {
2560
- return refuse(jsonError(500, "internal_error"));
2602
+ catch (err) {
2603
+ return failed(err);
2561
2604
  }
2562
2605
  if (!(hooked instanceof Promise)) {
2563
2606
  return hooked instanceof Response ? refuse(hooked) : resolveHooked(hooked);
2564
2607
  }
2565
- 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);
2566
2609
  return bounded(Promise.resolve(pending).then((result) => result), onTimeout);
2567
2610
  }
2568
2611
  /** Bun `fetch` when WS routes exist: try a WS upgrade first, else run the normal HTTP lifecycle.
@@ -2637,24 +2680,36 @@ export class Server {
2637
2680
  const request = this.nodeRequestContextOf(source);
2638
2681
  for (let i = 0; i < hooks.length; i++) {
2639
2682
  const hook = hooks[i];
2640
- 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
+ }
2641
2690
  if (outcome instanceof Promise) {
2642
- 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));
2643
2692
  }
2644
2693
  if (outcome !== undefined)
2645
- return wrapResponse(outcome);
2694
+ return earlyAnswer(outcome, wrapResponse);
2646
2695
  }
2647
2696
  return this.routeAndRun(source, platform, finalize, wrapResponse, onTimeout, false);
2648
2697
  }
2649
2698
  async continueNodeRequest(first, nextIndex, request, source, platform, finalize, wrapResponse, onTimeout) {
2650
2699
  if (first !== undefined)
2651
- return wrapResponse(first);
2700
+ return earlyAnswer(first, wrapResponse);
2652
2701
  for (let i = nextIndex; i < this.onNodeRequestHooks.length; i++) {
2653
2702
  const hook = this.onNodeRequestHooks[i];
2654
- const outcome = hook(request, platform);
2655
- 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
+ }
2656
2711
  if (early !== undefined)
2657
- return wrapResponse(early);
2712
+ return earlyAnswer(early, wrapResponse);
2658
2713
  }
2659
2714
  return this.routeAndRun(source, platform, finalize, wrapResponse, onTimeout, false);
2660
2715
  }
@@ -2679,9 +2734,15 @@ export class Server {
2679
2734
  let current = source;
2680
2735
  for (let i = 0; i < hooks.length; i++) {
2681
2736
  const seen = requestOf(current);
2682
- 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
+ }
2683
2744
  if (outcome instanceof Promise) {
2684
- 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));
2685
2746
  }
2686
2747
  if (outcome instanceof Request) {
2687
2748
  if (outcome !== seen)
@@ -2692,7 +2753,7 @@ export class Server {
2692
2753
  continue;
2693
2754
  }
2694
2755
  if (outcome !== undefined)
2695
- return wrapResponse(outcome);
2756
+ return earlyAnswer(outcome, wrapResponse);
2696
2757
  }
2697
2758
  return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast);
2698
2759
  }
@@ -2712,13 +2773,18 @@ export class Server {
2712
2773
  this.responseRequests.set(originalRequest, early);
2713
2774
  }
2714
2775
  else if (early !== undefined) {
2715
- return wrapResponse(early);
2776
+ return earlyAnswer(early, wrapResponse);
2716
2777
  }
2717
2778
  if (index >= this.onRequestHooks.length)
2718
2779
  break;
2719
2780
  seen = requestOf(current);
2720
- const outcome = this.onRequestHooks[index](seen, platform);
2721
- 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
+ }
2722
2788
  index++;
2723
2789
  }
2724
2790
  return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast);
@@ -2933,14 +2999,14 @@ export class Server {
2933
2999
  if (method !== "GET" && method !== "HEAD")
2934
3000
  markTransportCap(source, entry.bodyLimit);
2935
3001
  }
2936
- // An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
2937
- // (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.
2938
3005
  // The runtime is always present when a route resolved idempotency (enforced at registration).
2939
3006
  if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
2940
- return this.idempotencyRuntime.run(entry.idempotent, requestOf(source), platform, entry, params, search, wrapResponse, {
2941
- maxBodyBytes: entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
2942
- runLanes: (buffered, plat, ent, prm, srch) => this.runMatchedLanes(buffered, plat, ent, prm, srch, this.finalizeResponse, this.wrapWebResponse, this.webResponseTimeout, false),
2943
- });
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);
2944
3010
  }
2945
3011
  return this.runMatchedLanes(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast);
2946
3012
  }
@@ -4031,7 +4097,7 @@ export class Server {
4031
4097
  const handed = current;
4032
4098
  return next.then((settled) => this.continueOnResponse(settled, i + 1, req, handed));
4033
4099
  }
4034
- current = next === current ? next : writableResponse(next, req);
4100
+ current = next === current ? next : handedBackResponse(next, req);
4035
4101
  }
4036
4102
  return current;
4037
4103
  }
@@ -4091,11 +4157,11 @@ export class Server {
4091
4157
  }
4092
4158
  /** Async tail of {@link applyOnResponse}: runs the remaining hooks once one has gone async. */
4093
4159
  async continueOnResponse(response, nextIndex, req, handed) {
4094
- let current = response === handed ? response : writableResponse(response, req);
4160
+ let current = response === handed ? response : handedBackResponse(response, req);
4095
4161
  for (let i = nextIndex; i < this.onResponseHooks.length; i++) {
4096
4162
  const next = this.onResponseHooks[i](current, req);
4097
4163
  const settled = next instanceof Promise ? await next : next;
4098
- current = settled === current ? settled : writableResponse(settled, req);
4164
+ current = settled === current ? settled : handedBackResponse(settled, req);
4099
4165
  }
4100
4166
  return current;
4101
4167
  }
@@ -4177,28 +4243,57 @@ export class Server {
4177
4243
  this.logRequestError(err, ctx);
4178
4244
  return fallback === undefined ? wrapResponse(plainError(500, "internal_error")) : fallback();
4179
4245
  }
4180
- 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);
4181
4260
  }
4182
4261
  /** The `onError` chain, split off {@link handleLifecycleError} so its `await` costs only the routes
4183
- * that registered a hook. A hook may return a custom response; otherwise the default 500 stands. */
4184
- 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) {
4185
4266
  // Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
4186
4267
  // the `await` and stay heap-allocated on both engines.
4187
- for (let i = 0; i < entry.onError.length; i++) {
4188
- const outcome = entry.onError[i](err, ctx);
4189
- 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
+ }
4190
4285
  if (handled === undefined)
4191
4286
  continue;
4192
- const contract = entry.responseContract;
4193
- if (contract === undefined || (handled !== ctx.jsonReply && !isResponseResult(handled)))
4194
- return finalize(handled, responseSet(ctx), ctx);
4195
- // 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.
4196
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.
4197
4292
  const checked = await contract.runtime.check(contract.definition, handled, ctx);
4198
- return this.finishContractOutcome(ctx, finalize, wrapResponse, checked);
4293
+ return await this.finishContractOutcome(ctx, finalize, wrapResponse, checked);
4199
4294
  }
4200
- catch (checkError) {
4201
- this.logRequestError(checkError, ctx);
4295
+ catch (finishError) {
4296
+ this.logRequestError(finishError, ctx);
4202
4297
  return wrapResponse(plainError(500, "internal_error"));
4203
4298
  }
4204
4299
  }
@@ -4413,9 +4508,10 @@ export class Server {
4413
4508
  // Where Bun could pass a request down: the methods served natively on a path with a parameter,
4414
4509
  // as segment count + method. Two table paths match one request only at the same segment count.
4415
4510
  const below = new Set();
4416
- const tableAt = (path) => {
4417
- routes[path] ??= Object.create(null);
4418
- return routes[path];
4511
+ const tableAt = (path, segments) => {
4512
+ const key = bunTablePath(path, segments);
4513
+ routes[key] ??= Object.create(null);
4514
+ return routes[key];
4419
4515
  };
4420
4516
  for (let i = 0; i < all.length; i++) {
4421
4517
  const { method, path, pattern, entry } = all[i];
@@ -4427,14 +4523,14 @@ export class Server {
4427
4523
  // the platform the table shares could not answer `c.clientIp` there.
4428
4524
  if (run === undefined ||
4429
4525
  entry.idempotent !== undefined ||
4430
- !METHODS.includes(method) ||
4526
+ !BUN_TABLE_METHODS.includes(method) ||
4431
4527
  (preflightOnFallback && method === "OPTIONS") ||
4432
4528
  under.get(run)?.some((other) => outranks(other.pattern.segments, pattern.segments))) {
4433
4529
  continue;
4434
4530
  }
4435
4531
  const paramNames = pattern.paramNames;
4436
4532
  const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined;
4437
- 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);
4438
4534
  count += 1;
4439
4535
  if (paramNames.length > 0) {
4440
4536
  below.add(pattern.segments.length + method);
@@ -4461,9 +4557,9 @@ export class Server {
4461
4557
  if (lead[i] === undefined)
4462
4558
  continue;
4463
4559
  const { path, pattern } = all[i];
4464
- for (const method of METHODS) {
4560
+ for (const method of BUN_TABLE_METHODS) {
4465
4561
  if (below.has(pattern.segments.length + method))
4466
- tableAt(path)[method] ??= fallback;
4562
+ tableAt(path, pattern.segments)[method] ??= fallback;
4467
4563
  }
4468
4564
  }
4469
4565
  return routes;
@@ -4560,6 +4656,8 @@ export class Server {
4560
4656
  [PLATFORM_PEER]: (request) => running.requestIP(request)?.address,
4561
4657
  };
4562
4658
  const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes(fallback, peer) : undefined;
4659
+ const sockets = new Set();
4660
+ this.bunSockets = sockets;
4563
4661
  const running = (wsHandlers === undefined
4564
4662
  ? Bun.serve({
4565
4663
  ...listening,
@@ -4577,9 +4675,18 @@ export class Server {
4577
4675
  // closes an over-cap connection before the handler runs. Default 1 MB (maxBodyBytes). One
4578
4676
  // limit per listener: the largest cap among this app and the apps mounted on it.
4579
4677
  maxPayloadLength: wsHandlers.maxPayloadLength,
4580
- 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
+ },
4581
4685
  message: (ws, message) => wsHandlers.message(ws, message),
4582
- 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
+ },
4583
4690
  },
4584
4691
  }));
4585
4692
  this.bunServer = running;
@@ -4607,18 +4714,30 @@ export class Server {
4607
4714
  * `stop()`'s promise drops in-flight requests), and decide graceful-vs-forced in
4608
4715
  * ONE call (Bun can't escalate an already-graceful `stop()` to a forced close).
4609
4716
  * New connections may be accepted during the drain window; in a real deploy the
4610
- * 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.
4611
4719
  */
4612
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
+ }
4613
4726
  const server = this.bunServer;
4614
4727
  if (server === undefined)
4615
4728
  return;
4616
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;
4617
4736
  const deadline = Date.now() + drainMs;
4618
- while (server.pendingRequests > 0 && Date.now() < deadline) {
4737
+ while (busy() && Date.now() < deadline) {
4619
4738
  await Bun.sleep(DRAIN_POLL_MS);
4620
4739
  }
4621
- 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
4622
4741
  if (this.stopHooks.length === 0)
4623
4742
  return;
4624
4743
  const callbacks = this.stopHooks.map((hook) => Promise.resolve().then(hook));
@@ -4635,6 +4754,7 @@ export class Server {
4635
4754
  const onSignal = () => {
4636
4755
  void this.stop(drain);
4637
4756
  };
4757
+ this.signalHandler = onSignal;
4638
4758
  process.once("SIGTERM", onSignal);
4639
4759
  process.once("SIGINT", onSignal);
4640
4760
  }