@nifrajs/core 5.4.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 (89) hide show
  1. package/CHANGELOG.md +29 -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.d.ts +14 -5
  58. package/dist/server/server.d.ts.map +1 -1
  59. package/dist/server/server.js +179 -59
  60. package/dist/server/server.js.map +1 -1
  61. package/dist/server/transport-lane.d.ts +2 -2
  62. package/dist/server/transport-lane.d.ts.map +1 -1
  63. package/dist/server/transport-lane.js +20 -20
  64. package/dist/server/transport-lane.js.map +1 -1
  65. package/dist/static-file.d.ts +29 -6
  66. package/dist/static-file.d.ts.map +1 -1
  67. package/dist/static-file.js +43 -12
  68. package/dist/static-file.js.map +1 -1
  69. package/package.json +1 -1
  70. package/src/index.ts +1 -1
  71. package/src/internal/route-lanes.ts +18 -16
  72. package/src/internal/route-program.ts +76 -12
  73. package/src/router/router.ts +26 -5
  74. package/src/security/baseline.ts +4 -3
  75. package/src/server/body-lane.ts +4 -4
  76. package/src/server/body.ts +6 -0
  77. package/src/server/cookies.ts +7 -2
  78. package/src/server/core-copies.ts +1 -1
  79. package/src/server/idempotency-lane.ts +343 -209
  80. package/src/server/methods.ts +3 -2
  81. package/src/server/multipart.ts +62 -14
  82. package/src/server/node-outcome.ts +2 -1
  83. package/src/server/query.ts +12 -5
  84. package/src/server/request-context.ts +6 -3
  85. package/src/server/respond.ts +108 -6
  86. package/src/server/runtime-core.ts +54 -1
  87. package/src/server/server.ts +249 -99
  88. package/src/server/transport-lane.ts +28 -22
  89. package/src/static-file.ts +72 -16
@@ -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`
@@ -1155,6 +1191,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1155
1191
  /** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
1156
1192
  private readonly defaultOnValidationError?: RouteSchema["onValidationError"]
1157
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
1158
1197
  private sealed: boolean
1159
1198
  private readonly derives: RawDerive[]
1160
1199
  /** Dedicated authentication stages captured by subsequent routes. */
@@ -1540,7 +1579,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1540
1579
  /**
1541
1580
  * Run before routing on the raw request. Return a `Response` or a `status()` result to
1542
1581
  * short-circuit, or a replacement `Request` to continue routing with a rewritten method/URL/headers.
1543
- * Global.
1582
+ * Global. A throw or rejection goes to every registered {@link onError} (route-less context), then
1583
+ * the logged default 500.
1544
1584
  */
1545
1585
  onRequest(
1546
1586
  fn: (req: Request, platform?: Platform<EnvOf<Ctx>>) => MaybePromise<OnRequestResult>,
@@ -1644,8 +1684,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1644
1684
 
1645
1685
  /** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500, or 503
1646
1686
  * `auth_unavailable` for a failed {@link authenticate} stage). Order-scoped: captured by routes
1647
- * declared *after* this call, none before; one added after the last route reaches nothing (see
1648
- * {@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. */
1649
1689
  onError<H extends (error: unknown, context: Context & Ctx) => MaybePromise<unknown>>(
1650
1690
  fn: H,
1651
1691
  ): Server<R, Ctx, HookOutput | HookOutputOf<ReturnType<H>>> {
@@ -1750,6 +1790,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1750
1790
  if (this.responseBodyTag === undefined) {
1751
1791
  this.responseBodyTag = Object.freeze({})
1752
1792
  this.responseBodyOwners.add(this.responseBodyTag)
1793
+ recordResidentTexts()
1753
1794
  }
1754
1795
  return this.responseBodyTag
1755
1796
  }
@@ -2641,6 +2682,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2641
2682
  if (this.responseBodyTag === undefined) {
2642
2683
  this.responseBodyTag = Object.freeze({})
2643
2684
  this.responseBodyOwners.add(this.responseBodyTag)
2685
+ recordResidentTexts()
2644
2686
  }
2645
2687
  return this.responseBodyTag
2646
2688
  },
@@ -3322,6 +3364,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3322
3364
  * a path whose registered route rejects this method.
3323
3365
  */
3324
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
3325
3369
  if (
3326
3370
  this.onRequestHooks.length > 0 ||
3327
3371
  this.onResponseHooks.length > 0 ||
@@ -3670,10 +3714,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3670
3714
  req: Request,
3671
3715
  platform?: Platform<EnvOf<Ctx>>,
3672
3716
  ): MaybePromise<WebSocketUpgradeOutcome> {
3673
- // Before any header is read: Deno only materializes a request's headers when asked, and that
3674
- // 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.
3675
3719
  if (this.wsRoutes.length === 0 && !this.mountsTakeUpgrades()) return WS_PASS
3676
- if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") return WS_PASS
3720
+ if (headerOf(req, "upgrade")?.toLowerCase() !== "websocket") return WS_PASS
3677
3721
  // A WebSocket handshake is a GET (RFC 6455 section 4.1). Any other method with an Upgrade header
3678
3722
  // is an ordinary request: its HTTP route answers it, and normal routing refuses an unknown token.
3679
3723
  if (req.method !== "GET") return WS_PASS
@@ -3847,18 +3891,31 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3847
3891
  this.wsHookedPasses.set(received, request)
3848
3892
  return resolveWithDeadline(request)
3849
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
+ }
3850
3907
  let hooked: MaybePromise<Request | Response>
3851
3908
  try {
3852
3909
  hooked = this.runWebSocketRequestHooks(req, platform)
3853
- } catch {
3854
- return refuse(jsonError(500, "internal_error"))
3910
+ } catch (err) {
3911
+ return failed(err)
3855
3912
  }
3856
3913
  if (!(hooked instanceof Promise)) {
3857
3914
  return hooked instanceof Response ? refuse(hooked) : resolveHooked(hooked)
3858
3915
  }
3859
3916
  const pending = hooked.then(
3860
3917
  (result) => (result instanceof Response ? refuse(result) : resolveHooked(result)),
3861
- () => refuse(jsonError(500, "internal_error")),
3918
+ failed,
3862
3919
  )
3863
3920
  return bounded(
3864
3921
  Promise.resolve(pending).then((result) => result),
@@ -3967,22 +4024,29 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3967
4024
  const request = this.nodeRequestContextOf(source)
3968
4025
  for (let i = 0; i < hooks.length; i++) {
3969
4026
  const hook = hooks[i] as NodeRequestHook
3970
- 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
+ }
3971
4033
  if (outcome instanceof Promise) {
3972
- return outcome.then((early) =>
3973
- this.continueNodeRequest(
3974
- early,
3975
- i + 1,
3976
- request,
3977
- source,
3978
- platform,
3979
- finalize,
3980
- wrapResponse,
3981
- onTimeout,
3982
- ),
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),
3983
4047
  )
3984
4048
  }
3985
- if (outcome !== undefined) return wrapResponse(outcome)
4049
+ if (outcome !== undefined) return earlyAnswer(outcome, wrapResponse)
3986
4050
  }
3987
4051
  return this.routeAndRun(source, platform, finalize, wrapResponse, onTimeout, false)
3988
4052
  }
@@ -3997,12 +4061,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3997
4061
  wrapResponse: (response: Response | ResponseResult) => T,
3998
4062
  onTimeout: () => T,
3999
4063
  ): Promise<T> {
4000
- if (first !== undefined) return wrapResponse(first)
4064
+ if (first !== undefined) return earlyAnswer(first, wrapResponse)
4001
4065
  for (let i = nextIndex; i < this.onNodeRequestHooks.length; i++) {
4002
4066
  const hook = this.onNodeRequestHooks[i] as NodeRequestHook
4003
- const outcome = hook(request, platform)
4004
- const early = outcome instanceof Promise ? await outcome : outcome
4005
- 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)
4006
4075
  }
4007
4076
  return this.routeAndRun(source, platform, finalize, wrapResponse, onTimeout, false)
4008
4077
  }
@@ -4035,20 +4104,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4035
4104
  let current: RequestSource = source
4036
4105
  for (let i = 0; i < hooks.length; i++) {
4037
4106
  const seen = requestOf(current)
4038
- 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
+ }
4039
4113
  if (outcome instanceof Promise) {
4040
- return outcome.then((early) =>
4041
- this.continueOnRequest(
4042
- early,
4043
- i + 1,
4044
- originalRequest,
4045
- current,
4046
- platform,
4047
- finalize,
4048
- wrapResponse,
4049
- onTimeout,
4050
- webFast,
4051
- ),
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),
4052
4128
  )
4053
4129
  }
4054
4130
  if (outcome instanceof Request) {
@@ -4057,7 +4133,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4057
4133
  if (outcome !== originalRequest) this.responseRequests.set(originalRequest, outcome)
4058
4134
  continue
4059
4135
  }
4060
- if (outcome !== undefined) return wrapResponse(outcome)
4136
+ if (outcome !== undefined) return earlyAnswer(outcome, wrapResponse)
4061
4137
  }
4062
4138
  return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast)
4063
4139
  }
@@ -4085,12 +4161,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4085
4161
  current = early
4086
4162
  if (early !== originalRequest) this.responseRequests.set(originalRequest, early)
4087
4163
  } else if (early !== undefined) {
4088
- return wrapResponse(early)
4164
+ return earlyAnswer(early, wrapResponse)
4089
4165
  }
4090
4166
  if (index >= this.onRequestHooks.length) break
4091
4167
  seen = requestOf(current)
4092
- const outcome = (this.onRequestHooks[index] as RawOnRequest)(seen, platform)
4093
- 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
+ }
4094
4174
  index++
4095
4175
  }
4096
4176
  return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast)
@@ -4342,34 +4422,26 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4342
4422
  const method = source.method
4343
4423
  if (method !== "GET" && method !== "HEAD") markTransportCap(source, entry.bodyLimit)
4344
4424
  }
4345
- // An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
4346
- // (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.
4347
4428
  // The runtime is always present when a route resolved idempotency (enforced at registration).
4348
4429
  if (entry.idempotent !== undefined && this.idempotencyRuntime !== undefined) {
4349
- return this.idempotencyRuntime.run(
4350
- entry.idempotent,
4351
- requestOf(source),
4352
- platform,
4353
- entry,
4354
- params,
4355
- search,
4356
- wrapResponse,
4357
- {
4358
- maxBodyBytes: entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
4359
- runLanes: (buffered, plat, ent, prm, srch) =>
4360
- this.runMatchedLanes(
4361
- buffered,
4362
- plat,
4363
- ent as RouteEntry,
4364
- prm,
4365
- srch,
4366
- this.finalizeResponse,
4367
- this.wrapWebResponse,
4368
- this.webResponseTimeout,
4369
- false,
4370
- ),
4371
- },
4372
- )
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)
4373
4445
  }
4374
4446
  return this.runMatchedLanes(
4375
4447
  source,
@@ -6171,7 +6243,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6171
6243
  const handed = current
6172
6244
  return next.then((settled) => this.continueOnResponse(settled, i + 1, req, handed))
6173
6245
  }
6174
- current = next === current ? next : writableResponse(next, req)
6246
+ current = next === current ? next : handedBackResponse(next, req)
6175
6247
  }
6176
6248
  return current
6177
6249
  }
@@ -6247,11 +6319,11 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6247
6319
  req: Request,
6248
6320
  handed: Response,
6249
6321
  ): Promise<Response> {
6250
- let current = response === handed ? response : writableResponse(response, req)
6322
+ let current = response === handed ? response : handedBackResponse(response, req)
6251
6323
  for (let i = nextIndex; i < this.onResponseHooks.length; i++) {
6252
6324
  const next = (this.onResponseHooks[i] as RawOnResponse)(current, req)
6253
6325
  const settled = next instanceof Promise ? await next : next
6254
- current = settled === current ? settled : writableResponse(settled, req)
6326
+ current = settled === current ? settled : handedBackResponse(settled, req)
6255
6327
  }
6256
6328
  return current
6257
6329
  }
@@ -6361,13 +6433,52 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6361
6433
  this.logRequestError(err, ctx)
6362
6434
  return fallback === undefined ? wrapResponse(plainError(500, "internal_error")) : fallback()
6363
6435
  }
6364
- 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)
6365
6473
  }
6366
6474
 
6367
6475
  /** The `onError` chain, split off {@link handleLifecycleError} so its `await` costs only the routes
6368
- * 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. */
6369
6479
  private async runErrorHooks<T>(
6370
- entry: RouteEntry,
6480
+ hooks: readonly RawErrorHandler[],
6481
+ contract: RouteEntry["responseContract"],
6371
6482
  err: unknown,
6372
6483
  ctx: RawContext,
6373
6484
  finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
@@ -6376,19 +6487,32 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6376
6487
  ): Promise<T> {
6377
6488
  // Indexed for the same reason as the `beforeHandle` chain above: the iterator would outlive
6378
6489
  // the `await` and stay heap-allocated on both engines.
6379
- for (let i = 0; i < entry.onError.length; i++) {
6380
- const outcome = entry.onError[i]!(err, ctx)
6381
- 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
+ }
6382
6506
  if (handled === undefined) continue
6383
- const contract = entry.responseContract
6384
- if (contract === undefined || (handled !== ctx.jsonReply && !isResponseResult(handled)))
6385
- return finalize(handled, responseSet(ctx), ctx)
6386
- // 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.
6387
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.
6388
6512
  const checked = await contract.runtime.check(contract.definition, handled, ctx)
6389
- return this.finishContractOutcome(ctx, finalize, wrapResponse, checked)
6390
- } catch (checkError) {
6391
- this.logRequestError(checkError, ctx)
6513
+ return await this.finishContractOutcome(ctx, finalize, wrapResponse, checked)
6514
+ } catch (finishError) {
6515
+ this.logRequestError(finishError, ctx)
6392
6516
  return wrapResponse(plainError(500, "internal_error"))
6393
6517
  }
6394
6518
  }
@@ -6657,9 +6781,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6657
6781
  // Where Bun could pass a request down: the methods served natively on a path with a parameter,
6658
6782
  // as segment count + method. Two table paths match one request only at the same segment count.
6659
6783
  const below = new Set<string>()
6660
- const tableAt = (path: string): BunNativeMethodTable => {
6661
- routes[path] ??= Object.create(null) as BunNativeMethodTable
6662
- 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]
6663
6791
  }
6664
6792
  for (let i = 0; i < all.length; i++) {
6665
6793
  const { method, path, pattern, entry } = all[i]!
@@ -6672,7 +6800,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6672
6800
  if (
6673
6801
  run === undefined ||
6674
6802
  entry.idempotent !== undefined ||
6675
- !METHODS.includes(method as Method) ||
6803
+ !BUN_TABLE_METHODS.includes(method as Method) ||
6676
6804
  (preflightOnFallback && method === "OPTIONS") ||
6677
6805
  under.get(run)?.some((other) => outranks(other.pattern.segments, pattern.segments))
6678
6806
  ) {
@@ -6680,7 +6808,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6680
6808
  }
6681
6809
  const paramNames = pattern.paramNames
6682
6810
  const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined
6683
- tableAt(path)[method as Method] = this.compileBunNativeHandler(
6811
+ tableAt(path, pattern.segments)[method as Method] = this.compileBunNativeHandler(
6684
6812
  entry,
6685
6813
  paramNames,
6686
6814
  fused,
@@ -6711,8 +6839,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6711
6839
  for (let i = 0; i < all.length; i++) {
6712
6840
  if (lead[i] === undefined) continue
6713
6841
  const { path, pattern } = all[i]!
6714
- for (const method of METHODS) {
6715
- 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
6716
6845
  }
6717
6846
  }
6718
6847
  return routes
@@ -6831,6 +6960,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6831
6960
  }
6832
6961
  const nativeRoutes =
6833
6962
  wsHandlers === undefined ? this.buildBunNativeRoutes(fallback, peer) : undefined
6963
+ const sockets = new Set<{ close(code?: number): void }>()
6964
+ this.bunSockets = sockets
6834
6965
  const running = (wsHandlers === undefined
6835
6966
  ? Bun.serve({
6836
6967
  ...listening,
@@ -6848,9 +6979,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6848
6979
  // closes an over-cap connection before the handler runs. Default 1 MB (maxBodyBytes). One
6849
6980
  // limit per listener: the largest cap among this app and the apps mounted on it.
6850
6981
  maxPayloadLength: wsHandlers.maxPayloadLength,
6851
- 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
+ },
6852
6988
  message: (ws, message) => wsHandlers.message(ws, message),
6853
- 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
+ },
6854
6993
  },
6855
6994
  })) as unknown as RunningServer
6856
6995
  this.bunServer = running
@@ -6878,17 +7017,27 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6878
7017
  * `stop()`'s promise drops in-flight requests), and decide graceful-vs-forced in
6879
7018
  * ONE call (Bun can't escalate an already-graceful `stop()` to a forced close).
6880
7019
  * New connections may be accepted during the drain window; in a real deploy the
6881
- * 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.
6882
7022
  */
6883
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
+ }
6884
7029
  const server = this.bunServer
6885
7030
  if (server === undefined) return
6886
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
6887
7036
  const deadline = Date.now() + drainMs
6888
- while (server.pendingRequests > 0 && Date.now() < deadline) {
7037
+ while (busy() && Date.now() < deadline) {
6889
7038
  await Bun.sleep(DRAIN_POLL_MS)
6890
7039
  }
6891
- 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
6892
7041
  if (this.stopHooks.length === 0) return
6893
7042
  const callbacks = this.stopHooks.map((hook) => Promise.resolve().then(hook))
6894
7043
  const settled = Promise.allSettled(callbacks)
@@ -6905,6 +7054,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6905
7054
  const onSignal = (): void => {
6906
7055
  void this.stop(drain)
6907
7056
  }
7057
+ this.signalHandler = onSignal
6908
7058
  process.once("SIGTERM", onSignal)
6909
7059
  process.once("SIGINT", onSignal)
6910
7060
  }