@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
@@ -53,7 +53,11 @@ import type { ProgramValidationKind, RouteProgramStage } from "../internal/route
53
53
  import { compileRouteProgram, executeRouteProgram } from "../internal/route-program.ts"
54
54
  import { isSameOriginRequest } from "../internal/same-origin.ts"
55
55
  import type { ResponseObserverPlugin } from "../response-observer.ts"
56
- import { decodeRouteParams, expandOptionalParams } from "../router/pattern.ts"
56
+ import {
57
+ decodeRouteParams,
58
+ expandOptionalParams,
59
+ type RoutePatternSegment,
60
+ } from "../router/pattern.ts"
57
61
  import { EMPTY_PARAMS, METHODS, type Method, Router } from "../router/router.ts"
58
62
  import type {
59
63
  InferOutput,
@@ -117,8 +121,11 @@ import { needsResolving, resolveDotSegments } from "./request-target.ts"
117
121
  import {
118
122
  applyStaticResponseHeaders,
119
123
  buildStaticResponseHeaders,
124
+ claimHandedOver,
120
125
  fusedRespond,
121
126
  fusedRespondNoSet,
127
+ handedBackResponse,
128
+ recordResidentTexts,
122
129
  toResponse,
123
130
  writableResponse,
124
131
  } from "./respond.ts"
@@ -403,6 +410,9 @@ function bunPeerPlatform(server: BunPeerServer, req: Request): Platform {
403
410
 
404
411
  type BunNativeHandler = (request: Request, server: BunPeerServer) => MaybePromise<Response>
405
412
  type BunNativeMethodTable = Partial<Record<Method, BunNativeHandler>>
413
+ // Bun's route table has a slot for each of these; a QUERY entry is ignored and the request falls
414
+ // through to the table's fetch, so QUERY routes stay on the portable dispatcher.
415
+ const BUN_TABLE_METHODS: readonly Method[] = METHODS.filter((method) => method !== "QUERY")
406
416
  type BunNativeRoutes = Record<string, BunNativeMethodTable>
407
417
  type BunRequestWithParams = Request & { readonly params?: Record<string, string> }
408
418
 
@@ -431,6 +441,21 @@ function outranks(
431
441
  return ahead && other.length === route.length
432
442
  }
433
443
 
444
+ /**
445
+ * The key Bun's route table holds `path` under. Bun refuses one with a character outside printable
446
+ * ASCII, so such a path is spelled as the portable router matches it: percent-encoded static
447
+ * segments and `:name` parameters. `segments` holds static and `:param` segments only.
448
+ */
449
+ function bunTablePath(path: string, segments: readonly RoutePatternSegment[]): string {
450
+ if (!/[^ -~]/.test(path)) return path
451
+ let key = ""
452
+ for (const segment of segments) {
453
+ if (segment.kind === "static") key += `/${segment.value}`
454
+ else if (segment.kind === "param") key += `/:${segment.name}`
455
+ }
456
+ return key
457
+ }
458
+
434
459
  const WS_PASS: WebSocketUpgradeOutcome = { kind: "pass" }
435
460
 
436
461
  const DEFAULT_WS_UPGRADE_TIMEOUT_MS = 10_000
@@ -620,8 +645,9 @@ const withResolvedTarget = (req: Request): Request => new Request(resolveDotSegm
620
645
  // request for `/public` into one for `/admin`. Node refuses such a Host before routing; so does this.
621
646
  const HOST_AUTHORITY = /^(?:\[[0-9a-f:.%]+\]|[\w.-]+)(?::\d{1,5})?$/i
622
647
 
648
+ // `headerOf`, not `req.headers`: on Deno that reads the Host alone instead of building every header.
623
649
  const invalidHost = (req: Request): boolean => {
624
- const host = req.headers.get("host")
650
+ const host = headerOf(req, "host")
625
651
  return host !== null && !HOST_AUTHORITY.test(host)
626
652
  }
627
653
 
@@ -795,6 +821,16 @@ const SERVER_RUNTIMES = [
795
821
  const IDENTITY_RESPONSE = (response: Response | ResponseResult): Response => webResponseOf(response)
796
822
  const RESPONSE_TIMEOUT = (): Response => jsonError(503, "request_timeout")
797
823
 
824
+ /** An `onRequest` hook's answer, held to the single-use check a handler's result gets. */
825
+ function earlyAnswer<T>(
826
+ answer: Response | ResponseResult,
827
+ wrapResponse: (response: Response | ResponseResult) => T,
828
+ ): T {
829
+ return answer instanceof Response && !claimHandedOver(answer)
830
+ ? wrapResponse(plainError(500, "internal_error"))
831
+ : wrapResponse(answer)
832
+ }
833
+
798
834
  // The unused-order-scoped-hook audit (recorder, seal check, stack capture, message) is a dev-time
799
835
  // diagnostic. Every guard below keeps the INLINE literal `process.env.NODE_ENV !== "production"` at the
800
836
  // branch - never a shared const holding the folded boolean - because a bundler's `process.env.NODE_ENV`
@@ -861,6 +897,9 @@ interface HookAuditState {
861
897
  checked: boolean
862
898
  }
863
899
  const hookAudit = /* @__PURE__ */ new WeakMap<object, HookAuditState>()
900
+ // Audits begun and not yet sealed. Zero outside development, so a request tests it before the
901
+ // NODE_ENV literal and never reads `process.env` (a native call of ~130 ns on Node).
902
+ let unsealedHookAudits = 0
864
903
 
865
904
  /** Whether `from` reaches `target` through mounts that keep the path and take a request under `path`.
866
905
  * Module-level, not a `Server` method, so a production bundle drops it with its dev-only caller. */
@@ -892,6 +931,7 @@ const beginHookAudit = (
892
931
  policy: "warn" | "error" | "off",
893
932
  hasCustomLogger: boolean,
894
933
  ): void => {
934
+ unsealedHookAudits++
895
935
  hookAudit.set(key, {
896
936
  policy,
897
937
  hasCustomLogger,
@@ -1007,6 +1047,7 @@ const sealHookAudit = (
1007
1047
  const state = hookAudit.get(key)
1008
1048
  if (state === undefined || state.checked) return
1009
1049
  state.checked = true
1050
+ unsealedHookAudits--
1010
1051
  if (state.policy === "off" || total + sockets === 0) return
1011
1052
  const own = total - state.merged
1012
1053
  const dead = state.sites.filter(
@@ -1150,6 +1191,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1150
1191
  /** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
1151
1192
  private readonly defaultOnValidationError?: RouteSchema["onValidationError"]
1152
1193
  private bunServer: RunningServer | undefined
1194
+ // Bun's `pendingRequests` leaves out upgraded sockets, so `stop()` closes these itself.
1195
+ private bunSockets: Set<{ close(code?: number): void }> | undefined
1196
+ private signalHandler: (() => void) | undefined
1153
1197
  private sealed: boolean
1154
1198
  private readonly derives: RawDerive[]
1155
1199
  /** Dedicated authentication stages captured by subsequent routes. */
@@ -1535,7 +1579,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1535
1579
  /**
1536
1580
  * Run before routing on the raw request. Return a `Response` or a `status()` result to
1537
1581
  * short-circuit, or a replacement `Request` to continue routing with a rewritten method/URL/headers.
1538
- * Global.
1582
+ * Global. A throw or rejection goes to every registered {@link onError} (route-less context), then
1583
+ * the logged default 500.
1539
1584
  */
1540
1585
  onRequest(
1541
1586
  fn: (req: Request, platform?: Platform<EnvOf<Ctx>>) => MaybePromise<OnRequestResult>,
@@ -1639,8 +1684,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1639
1684
 
1640
1685
  /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500, or 503
1641
1686
  * `auth_unavailable` for a failed {@link authenticate} stage). Order-scoped: captured by routes
1642
- * declared *after* this call, none before; one added after the last route reaches nothing (see
1643
- * {@link ServerOptions.unusedScopedHooks}). */
1687
+ * declared *after* this call, none before; one added after the last route reaches no route (see
1688
+ * {@link ServerOptions.unusedScopedHooks}). Every one answers an {@link onRequest} failure. */
1644
1689
  onError<H extends (error: unknown, context: Context & Ctx) => MaybePromise<unknown>>(
1645
1690
  fn: H,
1646
1691
  ): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
@@ -1745,6 +1790,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1745
1790
  if (this.responseBodyTag === undefined) {
1746
1791
  this.responseBodyTag = Object.freeze({})
1747
1792
  this.responseBodyOwners.add(this.responseBodyTag)
1793
+ recordResidentTexts()
1748
1794
  }
1749
1795
  return this.responseBodyTag
1750
1796
  }
@@ -2636,6 +2682,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2636
2682
  if (this.responseBodyTag === undefined) {
2637
2683
  this.responseBodyTag = Object.freeze({})
2638
2684
  this.responseBodyOwners.add(this.responseBodyTag)
2685
+ recordResidentTexts()
2639
2686
  }
2640
2687
  return this.responseBodyTag
2641
2688
  },
@@ -3193,8 +3240,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3193
3240
  platform?: Platform<EnvOf<Ctx>>,
3194
3241
  ): MaybePromise<Response> {
3195
3242
  // Seal once on the first request so test servers (no `listen()`) still get the dead-hook check.
3196
- // After that it is a single boolean read - the per-request cost the option promises to keep at zero.
3197
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
3243
+ // After that it is one integer compare; the literal stays so a production define drops the branch.
3244
+ if (unsealedHookAudits !== 0 && process.env.NODE_ENV !== "production")
3198
3245
  sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger)
3199
3246
  // Off path (default): straight through - one property check, no closure, no promise.
3200
3247
  if (this.capacityGate === undefined) return this.fetchSourceInner(source, platform)
@@ -3317,6 +3364,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3317
3364
  * a path whose registered route rejects this method.
3318
3365
  */
3319
3366
  [RESOLVE_NODE_MOUNT](source: RequestSource): NativeMountSelection | undefined {
3367
+ // Node asks before every dispatch; an app with no mounts cannot select a native handler.
3368
+ if (this.fetchMounts.length === 0) return undefined
3320
3369
  if (
3321
3370
  this.onRequestHooks.length > 0 ||
3322
3371
  this.onResponseHooks.length > 0 ||
@@ -3378,7 +3427,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3378
3427
  suppliedRuntime?: NodeOutcomeRuntime,
3379
3428
  ): MaybePromise<NodeServeOutcome> {
3380
3429
  // Seal once on the Node-direct lane too - it can bypass `fetchSource` entirely (hookless outcome).
3381
- if (hookAuditRuntime && process.env.NODE_ENV !== "production")
3430
+ if (unsealedHookAudits !== 0 && process.env.NODE_ENV !== "production")
3382
3431
  sealHookAudit(this, this.catalog.size, this.wsRoutes.length, this.logger)
3383
3432
  // A paired header-only native hook can preserve the Node-direct JSON/body outcome for successful
3384
3433
  // responses. Arbitrary onResponse transforms need a real Web Response, but a buffered outcome can
@@ -3665,10 +3714,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3665
3714
  req: Request,
3666
3715
  platform?: Platform<EnvOf<Ctx>>,
3667
3716
  ): MaybePromise<WebSocketUpgradeOutcome> {
3668
- // Before any header is read: Deno only materializes a request's headers when asked, and that
3669
- // costs a plain request measurably.
3717
+ // Before any header is read, and then through `headerOf`: Deno builds a request's whole header
3718
+ // list when `headers` is read, which costs every plain request of an app with sockets measurably.
3670
3719
  if (this.wsRoutes.length === 0 && !this.mountsTakeUpgrades()) return WS_PASS
3671
- if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") return WS_PASS
3720
+ if (headerOf(req, "upgrade")?.toLowerCase() !== "websocket") return WS_PASS
3672
3721
  // A WebSocket handshake is a GET (RFC 6455 section 4.1). Any other method with an Upgrade header
3673
3722
  // is an ordinary request: its HTTP route answers it, and normal routing refuses an unknown token.
3674
3723
  if (req.method !== "GET") return WS_PASS
@@ -3842,18 +3891,31 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3842
3891
  this.wsHookedPasses.set(received, request)
3843
3892
  return resolveWithDeadline(request)
3844
3893
  }
3894
+ // The same boundary as an HTTP request's hooks; the answer is already wrapped, so it is not refused.
3895
+ const failed = (err: unknown): MaybePromise<WebSocketUpgradeOutcome> => {
3896
+ const answer = this.onRequestFailure(
3897
+ err,
3898
+ req,
3899
+ platform,
3900
+ this.finalizeResponse,
3901
+ this.wrapWebResponse,
3902
+ )
3903
+ return answer instanceof Promise
3904
+ ? answer.then((response) => ({ kind: "reject", response }))
3905
+ : { kind: "reject", response: answer }
3906
+ }
3845
3907
  let hooked: MaybePromise<Request | Response>
3846
3908
  try {
3847
3909
  hooked = this.runWebSocketRequestHooks(req, platform)
3848
- } catch {
3849
- return refuse(jsonError(500, "internal_error"))
3910
+ } catch (err) {
3911
+ return failed(err)
3850
3912
  }
3851
3913
  if (!(hooked instanceof Promise)) {
3852
3914
  return hooked instanceof Response ? refuse(hooked) : resolveHooked(hooked)
3853
3915
  }
3854
3916
  const pending = hooked.then(
3855
3917
  (result) => (result instanceof Response ? refuse(result) : resolveHooked(result)),
3856
- () => refuse(jsonError(500, "internal_error")),
3918
+ failed,
3857
3919
  )
3858
3920
  return bounded(
3859
3921
  Promise.resolve(pending).then((result) => result),
@@ -3962,22 +4024,29 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3962
4024
  const request = this.nodeRequestContextOf(source)
3963
4025
  for (let i = 0; i < hooks.length; i++) {
3964
4026
  const hook = hooks[i] as NodeRequestHook
3965
- const outcome = hook(request, platform)
4027
+ let outcome: MaybePromise<Response | undefined>
4028
+ try {
4029
+ outcome = hook(request, platform)
4030
+ } catch (err) {
4031
+ return this.onRequestFailure(err, source, platform, finalize, wrapResponse)
4032
+ }
3966
4033
  if (outcome instanceof Promise) {
3967
- return outcome.then((early) =>
3968
- this.continueNodeRequest(
3969
- early,
3970
- i + 1,
3971
- request,
3972
- source,
3973
- platform,
3974
- finalize,
3975
- wrapResponse,
3976
- onTimeout,
3977
- ),
4034
+ return outcome.then(
4035
+ (early) =>
4036
+ this.continueNodeRequest(
4037
+ early,
4038
+ i + 1,
4039
+ request,
4040
+ source,
4041
+ platform,
4042
+ finalize,
4043
+ wrapResponse,
4044
+ onTimeout,
4045
+ ),
4046
+ (err) => this.onRequestFailure(err, source, platform, finalize, wrapResponse),
3978
4047
  )
3979
4048
  }
3980
- if (outcome !== undefined) return wrapResponse(outcome)
4049
+ if (outcome !== undefined) return earlyAnswer(outcome, wrapResponse)
3981
4050
  }
3982
4051
  return this.routeAndRun(source, platform, finalize, wrapResponse, onTimeout, false)
3983
4052
  }
@@ -3992,12 +4061,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3992
4061
  wrapResponse: (response: Response | ResponseResult) => T,
3993
4062
  onTimeout: () => T,
3994
4063
  ): Promise<T> {
3995
- if (first !== undefined) return wrapResponse(first)
4064
+ if (first !== undefined) return earlyAnswer(first, wrapResponse)
3996
4065
  for (let i = nextIndex; i < this.onNodeRequestHooks.length; i++) {
3997
4066
  const hook = this.onNodeRequestHooks[i] as NodeRequestHook
3998
- const outcome = hook(request, platform)
3999
- const early = outcome instanceof Promise ? await outcome : outcome
4000
- if (early !== undefined) return wrapResponse(early)
4067
+ let early: Response | undefined
4068
+ try {
4069
+ const outcome = hook(request, platform)
4070
+ early = outcome instanceof Promise ? await outcome : outcome
4071
+ } catch (err) {
4072
+ return this.onRequestFailure(err, source, platform, finalize, wrapResponse)
4073
+ }
4074
+ if (early !== undefined) return earlyAnswer(early, wrapResponse)
4001
4075
  }
4002
4076
  return this.routeAndRun(source, platform, finalize, wrapResponse, onTimeout, false)
4003
4077
  }
@@ -4030,20 +4104,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4030
4104
  let current: RequestSource = source
4031
4105
  for (let i = 0; i < hooks.length; i++) {
4032
4106
  const seen = requestOf(current)
4033
- const outcome = (hooks[i] as RawOnRequest)(seen, platform)
4107
+ let outcome: MaybePromise<OnRequestResult>
4108
+ try {
4109
+ outcome = (hooks[i] as RawOnRequest)(seen, platform)
4110
+ } catch (err) {
4111
+ return this.onRequestFailure(err, current, platform, finalize, wrapResponse)
4112
+ }
4034
4113
  if (outcome instanceof Promise) {
4035
- return outcome.then((early) =>
4036
- this.continueOnRequest(
4037
- early,
4038
- i + 1,
4039
- originalRequest,
4040
- current,
4041
- platform,
4042
- finalize,
4043
- wrapResponse,
4044
- onTimeout,
4045
- webFast,
4046
- ),
4114
+ return outcome.then(
4115
+ (early) =>
4116
+ this.continueOnRequest(
4117
+ early,
4118
+ i + 1,
4119
+ originalRequest,
4120
+ current,
4121
+ platform,
4122
+ finalize,
4123
+ wrapResponse,
4124
+ onTimeout,
4125
+ webFast,
4126
+ ),
4127
+ (err) => this.onRequestFailure(err, current, platform, finalize, wrapResponse),
4047
4128
  )
4048
4129
  }
4049
4130
  if (outcome instanceof Request) {
@@ -4052,7 +4133,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4052
4133
  if (outcome !== originalRequest) this.responseRequests.set(originalRequest, outcome)
4053
4134
  continue
4054
4135
  }
4055
- if (outcome !== undefined) return wrapResponse(outcome)
4136
+ if (outcome !== undefined) return earlyAnswer(outcome, wrapResponse)
4056
4137
  }
4057
4138
  return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast)
4058
4139
  }
@@ -4080,12 +4161,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4080
4161
  current = early
4081
4162
  if (early !== originalRequest) this.responseRequests.set(originalRequest, early)
4082
4163
  } else if (early !== undefined) {
4083
- return wrapResponse(early)
4164
+ return earlyAnswer(early, wrapResponse)
4084
4165
  }
4085
4166
  if (index >= this.onRequestHooks.length) break
4086
4167
  seen = requestOf(current)
4087
- const outcome = (this.onRequestHooks[index] as RawOnRequest)(seen, platform)
4088
- early = outcome instanceof Promise ? await outcome : outcome
4168
+ try {
4169
+ const outcome = (this.onRequestHooks[index] as RawOnRequest)(seen, platform)
4170
+ early = outcome instanceof Promise ? await outcome : outcome
4171
+ } catch (err) {
4172
+ return this.onRequestFailure(err, current, platform, finalize, wrapResponse)
4173
+ }
4089
4174
  index++
4090
4175
  }
4091
4176
  return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast)
@@ -4337,34 +4422,26 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4337
4422
  const method = source.method
4338
4423
  if (method !== "GET" && method !== "HEAD") markTransportCap(source, entry.bodyLimit)
4339
4424
  }
4340
- // An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
4341
- // (with the body buffered). All non-idempotent routes skip straight to the lanes - no added cost.
4425
+ // An idempotency route meets its dedupe gate inside the program, so the gate runs after
4426
+ // authentication and under the request deadline. Its lanes render a `Response`, which the gate
4427
+ // stores. Non-idempotent routes skip straight to the lanes - no added cost.
4342
4428
  // The runtime is always present when a route resolved idempotency (enforced at registration).
4343
4429
  if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
4344
- return this.idempotencyRuntime.run(
4345
- entry.idempotent,
4346
- requestOf(source),
4347
- platform,
4348
- entry,
4349
- params,
4350
- search,
4351
- wrapResponse,
4352
- {
4353
- maxBodyBytes: entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
4354
- runLanes: (buffered, plat, ent, prm, srch) =>
4355
- this.runMatchedLanes(
4356
- buffered,
4357
- plat,
4358
- ent as RouteEntry,
4359
- prm,
4360
- srch,
4361
- this.finalizeResponse,
4362
- this.wrapWebResponse,
4363
- this.webResponseTimeout,
4364
- false,
4365
- ),
4366
- },
4367
- )
4430
+ return this.idempotencyRuntime
4431
+ .run(source, platform, (prepared) =>
4432
+ this.runMatchedLanes(
4433
+ prepared,
4434
+ platform,
4435
+ entry,
4436
+ params,
4437
+ search,
4438
+ this.finalizeResponse,
4439
+ this.wrapWebResponse,
4440
+ this.webResponseTimeout,
4441
+ false,
4442
+ ),
4443
+ )
4444
+ .then(wrapResponse)
4368
4445
  }
4369
4446
  return this.runMatchedLanes(
4370
4447
  source,
@@ -6166,7 +6243,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6166
6243
  const handed = current
6167
6244
  return next.then((settled) => this.continueOnResponse(settled, i + 1, req, handed))
6168
6245
  }
6169
- current = next === current ? next : writableResponse(next, req)
6246
+ current = next === current ? next : handedBackResponse(next, req)
6170
6247
  }
6171
6248
  return current
6172
6249
  }
@@ -6242,11 +6319,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6242
6319
  req: Request,
6243
6320
  handed: Response,
6244
6321
  ): Promise<Response> {
6245
- let current = response === handed ? response : writableResponse(response, req)
6322
+ let current = response === handed ? response : handedBackResponse(response, req)
6246
6323
  for (let i = nextIndex; i < this.onResponseHooks.length; i++) {
6247
6324
  const next = (this.onResponseHooks[i] as RawOnResponse)(current, req)
6248
6325
  const settled = next instanceof Promise ? await next : next
6249
- current = settled === current ? settled : writableResponse(settled, req)
6326
+ current = settled === current ? settled : handedBackResponse(settled, req)
6250
6327
  }
6251
6328
  return current
6252
6329
  }
@@ -6356,13 +6433,52 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6356
6433
  this.logRequestError(err, ctx)
6357
6434
  return fallback === undefined ? wrapResponse(plainError(500, "internal_error")) : fallback()
6358
6435
  }
6359
- return this.runErrorHooks(entry, err, ctx, finalize, wrapResponse, fallback)
6436
+ return this.runErrorHooks(
6437
+ entry.onError,
6438
+ entry.responseContract,
6439
+ err,
6440
+ ctx,
6441
+ finalize,
6442
+ wrapResponse,
6443
+ fallback,
6444
+ )
6445
+ }
6446
+
6447
+ /** An `onRequest` hook threw or rejected. No route has matched, so every `onError` the app holds
6448
+ * answers it, with a route-less context; otherwise the logged default 500, as for a handler. */
6449
+ private onRequestFailure<T>(
6450
+ err: unknown,
6451
+ source: RequestSource,
6452
+ platform: Platform | undefined,
6453
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
6454
+ wrapResponse: (response: Response | ResponseResult) => T,
6455
+ ): MaybePromise<T> {
6456
+ if (err instanceof Response || isResponseResult(err)) return wrapResponse(err)
6457
+ const ctx = new RequestContext(
6458
+ source,
6459
+ EMPTY_PARAMS,
6460
+ undefined,
6461
+ getNeverAbortSignal(),
6462
+ getUnboundedRequestBudget(),
6463
+ platform,
6464
+ this.maxBodyBytes,
6465
+ this.protoPoisoning,
6466
+ )
6467
+ if (this.onErrorHooks.length === 0) {
6468
+ this.logRequestError(err, ctx)
6469
+ return this.internalErrorResponse(wrapResponse)
6470
+ }
6471
+ Object.assign(ctx, this.decorations)
6472
+ return this.runErrorHooks(this.onErrorHooks, undefined, err, ctx, finalize, wrapResponse)
6360
6473
  }
6361
6474
 
6362
6475
  /** The `onError` chain, split off {@link handleLifecycleError} so its `await` costs only the routes
6363
- * that registered a hook. A hook may return a custom response; otherwise the default 500 stands. */
6476
+ * that registered a hook. A hook may return a custom response; otherwise the default 500 stands.
6477
+ * Never rejects: nothing outside it re-enters `onError`, so a hook that throws, or an answer that
6478
+ * cannot be rendered, is logged and answered here. */
6364
6479
  private async runErrorHooks<T>(
6365
- entry: RouteEntry,
6480
+ hooks: readonly RawErrorHandler[],
6481
+ contract: RouteEntry["responseContract"],
6366
6482
  err: unknown,
6367
6483
  ctx: RawContext,
6368
6484
  finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
@@ -6371,19 +6487,32 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6371
6487
  ): Promise<T> {
6372
6488
  // Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
6373
6489
  // the `await` and stay heap-allocated on both engines.
6374
- for (let i = 0; i < entry.onError.length; i++) {
6375
- const outcome = entry.onError[i]!(err, ctx)
6376
- const handled = outcome instanceof Promise ? await outcome : outcome
6490
+ for (let i = 0; i < hooks.length; i++) {
6491
+ let handled: unknown
6492
+ try {
6493
+ const outcome = hooks[i]!(err, ctx)
6494
+ handled = outcome instanceof Promise ? await outcome : outcome
6495
+ } catch (hookError) {
6496
+ // A thrown Response or status() is the hook's answer, as from beforeHandle.
6497
+ if (!(hookError instanceof Response) && !isResponseResult(hookError)) {
6498
+ this.logRequestError(err, ctx)
6499
+ this.logRequestError(hookError, ctx)
6500
+ return fallback === undefined
6501
+ ? wrapResponse(plainError(500, "internal_error"))
6502
+ : fallback()
6503
+ }
6504
+ handled = hookError
6505
+ }
6377
6506
  if (handled === undefined) continue
6378
- const contract = entry.responseContract
6379
- if (contract === undefined || (handled !== ctx.jsonReply && !isResponseResult(handled)))
6380
- return finalize(handled, responseSet(ctx), ctx)
6381
- // Checked here, not through finishLifecycleContract: a throwing schema must not re-enter onError.
6507
+ // Awaited so an asynchronous finalize's rejection lands in the catch too.
6382
6508
  try {
6509
+ if (contract === undefined || (handled !== ctx.jsonReply && !isResponseResult(handled)))
6510
+ return await finalize(handled, responseSet(ctx), ctx)
6511
+ // Checked here, not through finishLifecycleContract: a throwing schema must not re-enter onError.
6383
6512
  const checked = await contract.runtime.check(contract.definition, handled, ctx)
6384
- return this.finishContractOutcome(ctx, finalize, wrapResponse, checked)
6385
- } catch (checkError) {
6386
- this.logRequestError(checkError, ctx)
6513
+ return await this.finishContractOutcome(ctx, finalize, wrapResponse, checked)
6514
+ } catch (finishError) {
6515
+ this.logRequestError(finishError, ctx)
6387
6516
  return wrapResponse(plainError(500, "internal_error"))
6388
6517
  }
6389
6518
  }
@@ -6652,9 +6781,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6652
6781
  // Where Bun could pass a request down: the methods served natively on a path with a parameter,
6653
6782
  // as segment count + method. Two table paths match one request only at the same segment count.
6654
6783
  const below = new Set<string>()
6655
- const tableAt = (path: string): BunNativeMethodTable => {
6656
- routes[path] ??= Object.create(null) as BunNativeMethodTable
6657
- return routes[path]
6784
+ const tableAt = (
6785
+ path: string,
6786
+ segments: readonly RoutePatternSegment[],
6787
+ ): BunNativeMethodTable => {
6788
+ const key = bunTablePath(path, segments)
6789
+ routes[key] ??= Object.create(null) as BunNativeMethodTable
6790
+ return routes[key]
6658
6791
  }
6659
6792
  for (let i = 0; i < all.length; i++) {
6660
6793
  const { method, path, pattern, entry } = all[i]!
@@ -6667,7 +6800,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6667
6800
  if (
6668
6801
  run === undefined ||
6669
6802
  entry.idempotent !== undefined ||
6670
- !METHODS.includes(method as Method) ||
6803
+ !BUN_TABLE_METHODS.includes(method as Method) ||
6671
6804
  (preflightOnFallback && method === "OPTIONS") ||
6672
6805
  under.get(run)?.some((other) => outranks(other.pattern.segments, pattern.segments))
6673
6806
  ) {
@@ -6675,7 +6808,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6675
6808
  }
6676
6809
  const paramNames = pattern.paramNames
6677
6810
  const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined
6678
- tableAt(path)[method as Method] = this.compileBunNativeHandler(
6811
+ tableAt(path, pattern.segments)[method as Method] = this.compileBunNativeHandler(
6679
6812
  entry,
6680
6813
  paramNames,
6681
6814
  fused,
@@ -6706,8 +6839,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6706
6839
  for (let i = 0; i < all.length; i++) {
6707
6840
  if (lead[i] === undefined) continue
6708
6841
  const { path, pattern } = all[i]!
6709
- for (const method of METHODS) {
6710
- if (below.has(pattern.segments.length + method)) tableAt(path)[method] ??= fallback
6842
+ for (const method of BUN_TABLE_METHODS) {
6843
+ if (below.has(pattern.segments.length + method))
6844
+ tableAt(path, pattern.segments)[method] ??= fallback
6711
6845
  }
6712
6846
  }
6713
6847
  return routes
@@ -6826,6 +6960,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6826
6960
  }
6827
6961
  const nativeRoutes =
6828
6962
  wsHandlers === undefined ? this.buildBunNativeRoutes(fallback, peer) : undefined
6963
+ const sockets = new Set<{ close(code?: number): void }>()
6964
+ this.bunSockets = sockets
6829
6965
  const running = (wsHandlers === undefined
6830
6966
  ? Bun.serve({
6831
6967
  ...listening,
@@ -6843,9 +6979,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6843
6979
  // closes an over-cap connection before the handler runs. Default 1 MB (maxBodyBytes). One
6844
6980
  // limit per listener: the largest cap among this app and the apps mounted on it.
6845
6981
  maxPayloadLength: wsHandlers.maxPayloadLength,
6846
- open: (ws) => wsHandlers.open(ws),
6982
+ open: (ws) => {
6983
+ sockets.add(ws)
6984
+ wsHandlers.open(ws)
6985
+ // An upgrade accepted while `stop()` drains gets the close the earlier sockets got.
6986
+ if (this.bunServer !== running) ws.close(1001)
6987
+ },
6847
6988
  message: (ws, message) => wsHandlers.message(ws, message),
6848
- close: (ws, code, reason) => wsHandlers.close(ws, code, reason),
6989
+ close: (ws, code, reason) => {
6990
+ sockets.delete(ws)
6991
+ wsHandlers.close(ws, code, reason)
6992
+ },
6849
6993
  },
6850
6994
  })) as unknown as RunningServer
6851
6995
  this.bunServer = running
@@ -6873,17 +7017,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6873
7017
  * `stop()`'s promise drops in-flight requests), and decide graceful-vs-forced in
6874
7018
  * ONE call (Bun can't escalate an already-graceful `stop()` to a forced close).
6875
7019
  * New connections may be accepted during the drain window; in a real deploy the
6876
- * load balancer has already stopped routing here, and `drainMs` bounds it.
7020
+ * load balancer has already stopped routing here, and `drainMs` bounds it. Open WebSockets
7021
+ * get a 1001 close at once and the same window to finish it, before any `onStop` hook runs.
6877
7022
  */
6878
7023
  async stop({ drainMs = DEFAULT_DRAIN_MS }: { drainMs?: number } = {}): Promise<void> {
7024
+ const onSignal = this.signalHandler
7025
+ if (onSignal !== undefined) {
7026
+ this.signalHandler = undefined
7027
+ process.off("SIGTERM", onSignal).off("SIGINT", onSignal)
7028
+ }
6879
7029
  const server = this.bunServer
6880
7030
  if (server === undefined) return
6881
7031
  this.bunServer = undefined
7032
+ const sockets = this.bunSockets
7033
+ this.bunSockets = undefined
7034
+ if (sockets !== undefined) for (const ws of sockets) ws.close(1001)
7035
+ const busy = (): boolean => server.pendingRequests + (sockets?.size ?? 0) > 0
6882
7036
  const deadline = Date.now() + drainMs
6883
- while (server.pendingRequests > 0 && Date.now() < deadline) {
7037
+ while (busy() && Date.now() < deadline) {
6884
7038
  await Bun.sleep(DRAIN_POLL_MS)
6885
7039
  }
6886
- server.stop(server.pendingRequests > 0) // force-close iff stragglers remain past the deadline
7040
+ server.stop(busy()) // force-close iff stragglers remain past the deadline
6887
7041
  if (this.stopHooks.length === 0) return
6888
7042
  const callbacks = this.stopHooks.map((hook) => Promise.resolve().then(hook))
6889
7043
  const settled = Promise.allSettled(callbacks)
@@ -6900,6 +7054,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6900
7054
  const onSignal = (): void => {
6901
7055
  void this.stop(drain)
6902
7056
  }
7057
+ this.signalHandler = onSignal
6903
7058
  process.once("SIGTERM", onSignal)
6904
7059
  process.once("SIGINT", onSignal)
6905
7060
  }