@nifrajs/core 5.7.0 → 5.9.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 (223) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +9 -4
  3. package/dist/assurance.d.ts +26 -1
  4. package/dist/assurance.d.ts.map +1 -1
  5. package/dist/assurance.js +43 -11
  6. package/dist/assurance.js.map +1 -1
  7. package/dist/canonical-json.d.ts +11 -0
  8. package/dist/canonical-json.d.ts.map +1 -0
  9. package/dist/canonical-json.js +46 -0
  10. package/dist/canonical-json.js.map +1 -0
  11. package/dist/capabilities.d.ts.map +1 -1
  12. package/dist/capabilities.js +1 -8
  13. package/dist/capabilities.js.map +1 -1
  14. package/dist/data.d.ts.map +1 -1
  15. package/dist/data.js +1 -8
  16. package/dist/data.js.map +1 -1
  17. package/dist/diff.d.ts.map +1 -1
  18. package/dist/diff.js +2 -12
  19. package/dist/diff.js.map +1 -1
  20. package/dist/durable-adapters.d.ts +2 -0
  21. package/dist/durable-adapters.d.ts.map +1 -1
  22. package/dist/durable-adapters.js +41 -49
  23. package/dist/durable-adapters.js.map +1 -1
  24. package/dist/durable-saga.d.ts.map +1 -1
  25. package/dist/durable-saga.js +14 -20
  26. package/dist/durable-saga.js.map +1 -1
  27. package/dist/evidence.d.ts.map +1 -1
  28. package/dist/evidence.js +14 -68
  29. package/dist/evidence.js.map +1 -1
  30. package/dist/idempotency.d.ts.map +1 -1
  31. package/dist/idempotency.js +60 -23
  32. package/dist/idempotency.js.map +1 -1
  33. package/dist/index.d.ts +3 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +3 -2
  36. package/dist/index.js.map +1 -1
  37. package/dist/internal/control-character.d.ts +3 -0
  38. package/dist/internal/control-character.d.ts.map +1 -0
  39. package/dist/internal/control-character.js +10 -0
  40. package/dist/internal/control-character.js.map +1 -0
  41. package/dist/internal/durable-shared.d.ts.map +1 -1
  42. package/dist/internal/durable-shared.js +4 -1
  43. package/dist/internal/durable-shared.js.map +1 -1
  44. package/dist/internal/hex.d.ts +5 -0
  45. package/dist/internal/hex.d.ts.map +1 -0
  46. package/dist/internal/hex.js +14 -0
  47. package/dist/internal/hex.js.map +1 -0
  48. package/dist/internal/record.d.ts +3 -0
  49. package/dist/internal/record.d.ts.map +1 -0
  50. package/dist/internal/record.js +5 -0
  51. package/dist/internal/record.js.map +1 -0
  52. package/dist/internal/route-compiler.d.ts.map +1 -1
  53. package/dist/internal/route-compiler.js +2 -2
  54. package/dist/internal/route-compiler.js.map +1 -1
  55. package/dist/internal/route-execution.d.ts +4 -4
  56. package/dist/internal/route-execution.d.ts.map +1 -1
  57. package/dist/internal/route-execution.js +4 -0
  58. package/dist/internal/route-execution.js.map +1 -1
  59. package/dist/internal/route-lanes.d.ts +4 -3
  60. package/dist/internal/route-lanes.d.ts.map +1 -1
  61. package/dist/internal/route-lanes.js +16 -33
  62. package/dist/internal/route-lanes.js.map +1 -1
  63. package/dist/internal/schema-parts.d.ts +13 -0
  64. package/dist/internal/schema-parts.d.ts.map +1 -0
  65. package/dist/internal/schema-parts.js +15 -0
  66. package/dist/internal/schema-parts.js.map +1 -0
  67. package/dist/ledger.d.ts.map +1 -1
  68. package/dist/ledger.js +2 -14
  69. package/dist/ledger.js.map +1 -1
  70. package/dist/manifest.d.ts.map +1 -1
  71. package/dist/manifest.js +10 -37
  72. package/dist/manifest.js.map +1 -1
  73. package/dist/open-resolved.d.ts +18 -0
  74. package/dist/open-resolved.d.ts.map +1 -0
  75. package/dist/open-resolved.js +62 -0
  76. package/dist/open-resolved.js.map +1 -0
  77. package/dist/range.js +1 -1
  78. package/dist/range.js.map +1 -1
  79. package/dist/serve-static.d.ts +22 -0
  80. package/dist/serve-static.d.ts.map +1 -0
  81. package/dist/serve-static.js +187 -0
  82. package/dist/serve-static.js.map +1 -0
  83. package/dist/server/bare-error-lane.d.ts +2 -1
  84. package/dist/server/bare-error-lane.d.ts.map +1 -1
  85. package/dist/server/bare-error-lane.js +12 -3
  86. package/dist/server/bare-error-lane.js.map +1 -1
  87. package/dist/server/base64-answers.d.ts.map +1 -1
  88. package/dist/server/base64-answers.js +1 -4
  89. package/dist/server/base64-answers.js.map +1 -1
  90. package/dist/server/body-lane.d.ts.map +1 -1
  91. package/dist/server/body-lane.js +43 -38
  92. package/dist/server/body-lane.js.map +1 -1
  93. package/dist/server/body.d.ts.map +1 -1
  94. package/dist/server/body.js +4 -1
  95. package/dist/server/body.js.map +1 -1
  96. package/dist/server/bridge-markers.d.ts +1 -1
  97. package/dist/server/bridge-markers.js +1 -1
  98. package/dist/server/cookies.d.ts.map +1 -1
  99. package/dist/server/cookies.js +4 -1
  100. package/dist/server/cookies.js.map +1 -1
  101. package/dist/server/core-copies.js +1 -1
  102. package/dist/server/http.d.ts +5 -0
  103. package/dist/server/http.d.ts.map +1 -1
  104. package/dist/server/http.js +13 -0
  105. package/dist/server/http.js.map +1 -1
  106. package/dist/server/idempotency-lane.d.ts.map +1 -1
  107. package/dist/server/idempotency-lane.js +2 -0
  108. package/dist/server/idempotency-lane.js.map +1 -1
  109. package/dist/server/idle-timeout.d.ts +39 -0
  110. package/dist/server/idle-timeout.d.ts.map +1 -0
  111. package/dist/server/idle-timeout.js +95 -0
  112. package/dist/server/idle-timeout.js.map +1 -0
  113. package/dist/server/json-shared.d.ts +23 -0
  114. package/dist/server/json-shared.d.ts.map +1 -0
  115. package/dist/server/json-shared.js +33 -0
  116. package/dist/server/json-shared.js.map +1 -0
  117. package/dist/server/json-text.d.ts +1 -1
  118. package/dist/server/json-text.js +1 -1
  119. package/dist/server/node-outcome.js +1 -1
  120. package/dist/server/node-outcome.js.map +1 -1
  121. package/dist/server/request-context.d.ts +22 -1
  122. package/dist/server/request-context.d.ts.map +1 -1
  123. package/dist/server/request-context.js +44 -4
  124. package/dist/server/request-context.js.map +1 -1
  125. package/dist/server/request-local.d.ts +44 -0
  126. package/dist/server/request-local.d.ts.map +1 -0
  127. package/dist/server/request-local.js +91 -0
  128. package/dist/server/request-local.js.map +1 -0
  129. package/dist/server/respond.js +1 -1
  130. package/dist/server/respond.js.map +1 -1
  131. package/dist/server/response-contract-lane.d.ts +10 -2
  132. package/dist/server/response-contract-lane.d.ts.map +1 -1
  133. package/dist/server/response-contract-lane.js +93 -64
  134. package/dist/server/response-contract-lane.js.map +1 -1
  135. package/dist/server/runtime-core.d.ts +7 -4
  136. package/dist/server/runtime-core.d.ts.map +1 -1
  137. package/dist/server/runtime-core.js +9 -7
  138. package/dist/server/runtime-core.js.map +1 -1
  139. package/dist/server/server-types.d.ts +27 -0
  140. package/dist/server/server-types.d.ts.map +1 -1
  141. package/dist/server/server.d.ts +59 -59
  142. package/dist/server/server.d.ts.map +1 -1
  143. package/dist/server/server.js +869 -512
  144. package/dist/server/server.js.map +1 -1
  145. package/dist/server/transport-lane.d.ts.map +1 -1
  146. package/dist/server/transport-lane.js +2 -0
  147. package/dist/server/transport-lane.js.map +1 -1
  148. package/dist/server/webhook.js +1 -1
  149. package/dist/server/webhook.js.map +1 -1
  150. package/dist/server/websocket.d.ts +3 -1
  151. package/dist/server/websocket.d.ts.map +1 -1
  152. package/dist/server/websocket.js +14 -1
  153. package/dist/server/websocket.js.map +1 -1
  154. package/dist/server/ws-adapter-view.d.ts +54 -0
  155. package/dist/server/ws-adapter-view.d.ts.map +1 -0
  156. package/dist/server/ws-adapter-view.js +7 -0
  157. package/dist/server/ws-adapter-view.js.map +1 -0
  158. package/dist/server.d.ts +2 -1
  159. package/dist/server.d.ts.map +1 -1
  160. package/dist/server.js +2 -1
  161. package/dist/server.js.map +1 -1
  162. package/dist/single-copy.d.ts.map +1 -1
  163. package/dist/single-copy.js +1 -1
  164. package/dist/single-copy.js.map +1 -1
  165. package/dist/static-file.d.ts +156 -4
  166. package/dist/static-file.d.ts.map +1 -1
  167. package/dist/static-file.js +198 -4
  168. package/dist/static-file.js.map +1 -1
  169. package/dist/wire.d.ts.map +1 -1
  170. package/dist/wire.js +1 -3
  171. package/dist/wire.js.map +1 -1
  172. package/package.json +16 -1
  173. package/src/assurance.ts +66 -16
  174. package/src/canonical-json.ts +43 -0
  175. package/src/capabilities.ts +1 -8
  176. package/src/data.ts +2 -8
  177. package/src/diff.ts +2 -12
  178. package/src/durable-adapters.ts +53 -50
  179. package/src/durable-saga.ts +30 -23
  180. package/src/evidence.ts +25 -90
  181. package/src/idempotency.ts +62 -20
  182. package/src/index.ts +13 -2
  183. package/src/internal/control-character.ts +8 -0
  184. package/src/internal/durable-shared.ts +4 -1
  185. package/src/internal/hex.ts +14 -0
  186. package/src/internal/record.ts +4 -0
  187. package/src/internal/route-compiler.ts +2 -2
  188. package/src/internal/route-execution.ts +7 -20
  189. package/src/internal/route-lanes.ts +20 -44
  190. package/src/internal/schema-parts.ts +19 -0
  191. package/src/ledger.ts +2 -16
  192. package/src/manifest.ts +10 -38
  193. package/src/open-resolved.ts +64 -0
  194. package/src/range.ts +1 -1
  195. package/src/serve-static.ts +227 -0
  196. package/src/server/bare-error-lane.ts +11 -2
  197. package/src/server/base64-answers.ts +2 -6
  198. package/src/server/body-lane.ts +58 -51
  199. package/src/server/body.ts +4 -1
  200. package/src/server/bridge-markers.ts +1 -1
  201. package/src/server/cookies.ts +4 -1
  202. package/src/server/core-copies.ts +1 -1
  203. package/src/server/http.ts +12 -0
  204. package/src/server/idempotency-lane.ts +2 -0
  205. package/src/server/idle-timeout.ts +107 -0
  206. package/src/server/json-shared.ts +38 -0
  207. package/src/server/json-text.ts +1 -1
  208. package/src/server/node-outcome.ts +1 -1
  209. package/src/server/request-context.ts +62 -3
  210. package/src/server/request-local.ts +109 -0
  211. package/src/server/respond.ts +1 -1
  212. package/src/server/response-contract-lane.ts +112 -65
  213. package/src/server/runtime-core.ts +10 -8
  214. package/src/server/server-types.ts +27 -0
  215. package/src/server/server.ts +1230 -800
  216. package/src/server/transport-lane.ts +2 -0
  217. package/src/server/webhook.ts +1 -1
  218. package/src/server/websocket.ts +18 -1
  219. package/src/server/ws-adapter-view.ts +63 -0
  220. package/src/server.ts +12 -1
  221. package/src/single-copy.ts +1 -3
  222. package/src/static-file.ts +350 -4
  223. package/src/wire.ts +2 -4
@@ -58,6 +58,7 @@ import {
58
58
  import { isSameOriginRequest } from "../internal/same-origin.ts"
59
59
  import type { ResponseObserverPlugin } from "../response-observer.ts"
60
60
  import {
61
+ compileRoutePattern,
61
62
  decodeRouteParams,
62
63
  expandOptionalParams,
63
64
  type RoutePatternSegment,
@@ -124,11 +125,14 @@ import type {
124
125
  import { type QueryValue, queryObjectOf, searchOf } from "./query.ts"
125
126
  import {
126
127
  type PeerPlatform,
128
+ PLATFORM_IDLE_TIMEOUT,
127
129
  PLATFORM_PEER,
130
+ PLATFORM_REQUEST,
128
131
  RequestContext,
129
132
  readBodyFramed,
130
133
  } from "./request-context.ts"
131
134
  import { recordRequestReplacement } from "./request-lineage.ts"
135
+ import { carryToCopy } from "./request-local.ts"
132
136
  import { needsResolving, resolveDotSegments } from "./request-target.ts"
133
137
  import {
134
138
  applyStaticResponseHeaders,
@@ -347,10 +351,11 @@ interface WsEntry {
347
351
  readonly chain: RouteEntry
348
352
  }
349
353
 
350
- /** Structural view of the socket-peer lookup on the Bun `Server`, so any Bun `Server` (WS or not)
351
- * satisfies it. */
354
+ /** Structural view of the socket-peer lookup and per-request idle timeout on the Bun `Server`, so
355
+ * any Bun `Server` (WS or not) satisfies it. */
352
356
  interface BunPeerServer {
353
357
  requestIP(request: Request): { readonly address: string } | null
358
+ timeout(request: Request, seconds: number): void
354
359
  }
355
360
 
356
361
  /** Structural view of the Bun `Server` the `fetch` 2nd arg exposes (`upgrade` + the socket peer). */
@@ -405,23 +410,44 @@ interface FetchMount<Env = unknown> {
405
410
  wsPassed?: WeakMap<Request, Request>
406
411
  }
407
412
 
408
- /** The socket peer Bun observed, as a `Platform` for the request lifecycle (`undefined` if unknown). */
409
- function bunPeerPlatform(server: BunPeerServer, req: Request): Platform {
413
+ /** A route Bun's table matched, as the dispatcher would have. */
414
+ interface MatchedRoute {
415
+ readonly entry: RouteEntry
416
+ readonly params: Record<string, string>
417
+ }
418
+
419
+ type IdleTimeoutSetter = NonNullable<PeerPlatform[typeof PLATFORM_IDLE_TIMEOUT]>
420
+
421
+ /**
422
+ * The socket peer Bun observed (`undefined` if unknown) and its connection's idle timeout, as a
423
+ * `Platform` for one request. A class, not an object literal: JSC builds a literal holding a getter
424
+ * or a symbol key on a slow path, measured at about 5x the instructions of this instance.
425
+ */
426
+ class BunPeerPlatform implements PeerPlatform {
427
+ readonly #server: BunPeerServer
428
+ #resolved = false
429
+ #address: string | undefined
430
+ // Own fields, so the copy a `clientIp` trust declaration makes (`{ ...platform }`) keeps them.
431
+ declare readonly [PLATFORM_IDLE_TIMEOUT]: IdleTimeoutSetter
432
+ declare readonly [PLATFORM_REQUEST]: Request
433
+
434
+ constructor(server: BunPeerServer, req: Request, setIdleTimeout: IdleTimeoutSetter) {
435
+ this.#server = server
436
+ this[PLATFORM_IDLE_TIMEOUT] = setIdleTimeout
437
+ this[PLATFORM_REQUEST] = req
438
+ }
439
+
410
440
  // Bun's requestIP() costs ~0.5 us per call, about 7% of a bare GET's server time. Keep the
411
441
  // documented raw-peer c.clientIp behavior, but resolve it lazily: most routes never read c.clientIp,
412
442
  // so they should not pay for the socket lookup on every request. A getter also
413
443
  // preserves middleware that inspects the platform argument directly and trust-mode routes, which
414
444
  // resolve the value in deriveClientIp before the handler runs.
415
- let resolved = false
416
- let address: string | undefined
417
- return {
418
- get clientIp(): string | undefined {
419
- if (!resolved) {
420
- resolved = true
421
- address = server.requestIP(req)?.address
422
- }
423
- return address
424
- },
445
+ get clientIp(): string | undefined {
446
+ if (!this.#resolved) {
447
+ this.#resolved = true
448
+ this.#address = this.#server.requestIP(this[PLATFORM_REQUEST])?.address
449
+ }
450
+ return this.#address
425
451
  }
426
452
  }
427
453
 
@@ -458,6 +484,23 @@ function outranks(
458
484
  return ahead && other.length === route.length
459
485
  }
460
486
 
487
+ /** Whether one request can match both patterns: no two static segments differ before either ends
488
+ * in a wildcard, and the lengths agree otherwise. Errs toward `true`. */
489
+ function mayShareRequest(
490
+ a: CatalogRoute["pattern"]["segments"],
491
+ b: CatalogRoute["pattern"]["segments"],
492
+ ): boolean {
493
+ const shared = Math.min(a.length, b.length)
494
+ for (let i = 0; i < shared; i++) {
495
+ const left = a[i]!
496
+ const right = b[i]!
497
+ if (left.kind === "wildcard" || right.kind === "wildcard") return true
498
+ if (left.kind === "static" && right.kind === "static" && left.value !== right.value)
499
+ return false
500
+ }
501
+ return a.length === b.length
502
+ }
503
+
461
504
  /**
462
505
  * The key Bun's route table holds `path` under. Bun refuses one with a character outside printable
463
506
  * ASCII, so such a path is spelled as the portable router matches it: percent-encoded static
@@ -611,6 +654,8 @@ const BODY_ERROR = (error: unknown): Promise<never> => Promise.reject(error)
611
654
  const DEFAULT_DRAIN_MS = 10_000
612
655
  const DRAIN_POLL_MS = 10
613
656
  const STOP_HOOK_TIMEOUT_MS = 5_000
657
+ /** Bun's own `idleTimeout` when `listen()` is given none. */
658
+ const BUN_IDLE_TIMEOUT_SEC = 10
614
659
 
615
660
  /** Same-origin check for a WebSocket handshake (CSWSH default). {@link isSameOriginRequest} is the one
616
661
  * owner, shared with the server-function mount in `@nifrajs/web` - the two used to answer differently
@@ -652,8 +697,8 @@ function hasUnroutedParam(params: Record<string, string>): boolean {
652
697
  }
653
698
 
654
699
  /**
655
- * Deno is the runtime that hands an app a `Request` whose `url` is the target as the client sent it;
656
- * Bun, workerd and every `new Request()` parse it first. Only there does `fetch` read the URL up front.
700
+ * Deno's server builds `Request.url` from the Host and target as received, without normalizing the
701
+ * target first. Bun, workerd, and `new Request()` parse it before core sees it.
657
702
  */
658
703
  const rawRequestTargets = typeof (globalThis as { Deno?: unknown }).Deno !== "undefined"
659
704
 
@@ -679,6 +724,7 @@ function withResolvedTarget(req: Request, url: string): Request | undefined {
679
724
  }
680
725
  // The same body, so the same framing: a runtime still delimited it.
681
726
  if (hasTrustedBodyFraming(req)) markTrustedBodyFraming(rebuilt)
727
+ carryToCopy(req, rebuilt)
682
728
  return rebuilt
683
729
  }
684
730
 
@@ -713,7 +759,8 @@ function resolvedSource(req: Request, url: string): RequestSource {
713
759
  const HOST_AUTHORITY = /^(?:\[[0-9a-f:.%]+\]|[\w.-]+)(?::(\d{1,5}))?$/i
714
760
 
715
761
  // Clients repeat one Host on every request; comparing to the last valid one is ~7x cheaper than the regex.
716
- let lastValidHost = ""
762
+ // `null` until one passes, so an empty Host is never taken as the cached one.
763
+ let lastValidHost: string | null = null
717
764
 
718
765
  // A port past 65535, or an IPv6 literal no URL parser takes (`[1:2]`), is refused as Node refuses it:
719
766
  // no URL can be built on either, so resolving a dot segment against one threw out of `fetch`.
@@ -732,23 +779,35 @@ const invalidHost = (host: string): boolean => {
732
779
  * Host (400), a URL replaces the target, `false` keeps it. Deno hands the target over as sent, so
733
780
  * its dot segments are resolved; so does Bun when HTTP/1.0 leaves the Host out, and its route table
734
781
  * matched `/files/..` raw. Such a request is served as `localhost`, as Node and Deno serve it.
735
- * `headerOf`, not `req.headers`: on Deno that reads the Host alone instead of building every
736
- * header.
782
+ * `headerOf`, not `req.headers`: on Deno it reads Host only when `validateHost` is set, and dot
783
+ * segments are resolved only when `normalizeTarget` is. On Bun only a target that needs resolving
784
+ * is checked unless `validateHost` is set.
737
785
  */
738
- const requestTarget = (req: Request): string | boolean => {
739
- if (!rawRequestTargets && !unparsedBunTargets) return false
786
+ const requestTarget = (
787
+ req: Request,
788
+ validateHost: boolean,
789
+ normalizeTarget: boolean,
790
+ ): string | boolean => {
791
+ if (rawRequestTargets) {
792
+ const host = validateHost ? headerOf(req, "host") : null
793
+ if (host !== null && invalidHost(host)) return true
794
+ if (!normalizeTarget || !needsResolving(req.url)) return false
795
+ // Deno can preserve an unparseable absolute URL when Host is malformed. Do not route its
796
+ // unresolved dot segments through the raw-target fallback.
797
+ if (!URL.canParse(req.url, "http://localhost")) return true
798
+ return resolveDotSegments(req.url)
799
+ }
800
+ if (!(unparsedBunTargets && (validateHost || needsResolving(req.url)))) return false
740
801
  const host = headerOf(req, "host")
741
802
  if (host !== null && invalidHost(host)) {
742
- if (rawRequestTargets) return true
743
803
  // Bun served it anyway: it could not read the Host, so it left the target unparsed, or it
744
804
  // built the URL on it - sent twice (joined with ", ", the URL on the first; RFC 9112 section
745
- // 3.2 answers 400), percent-encoded (`%61.example` routed as `a.example`), or with a
746
- // sub-delimiter (`a!b`). A request built in process keeps the URL its caller built, unless Bun
805
+ // 3.2 answers 400), percent-encoded (%61.example routed as a.example), or with a
806
+ // sub-delimiter (a!b). A request built in process keeps the URL its caller built, unless Bun
747
807
  // would have built it so.
748
808
  const routed = URL.parse(req.url)?.host
749
809
  return routed === undefined || URL.parse(`//${host.split(", ")[0]}`, req.url)?.host === routed
750
810
  }
751
- if (rawRequestTargets) return needsResolving(req.url) && resolveDotSegments(req.url)
752
811
  // A request built in process carries an absolute URL whatever its headers say.
753
812
  return (
754
813
  host === null &&
@@ -787,7 +846,8 @@ function compareMounts<Env>(a: FetchMount<Env>, b: FetchMount<Env>): number {
787
846
  return b.priority - a.priority || b.path.length - a.path.length || a.order - b.order
788
847
  }
789
848
 
790
- /** Rebase a mounted request's path without losing the authentication mark used by child caches. */
849
+ /** Rebase a mounted request's path without losing the authentication mark used by child caches, or
850
+ * its request locals. */
791
851
  function stripMountPrefix(request: Request, prefix: string): Request {
792
852
  if (prefix === "/") return request
793
853
  const url = new URL(request.url)
@@ -795,6 +855,7 @@ function stripMountPrefix(request: Request, prefix: string): Request {
795
855
  url.pathname = rest === "" ? "/" : rest
796
856
  const childRequest = new Request(url.href, request)
797
857
  if (isAuthenticatedRequest(request)) markAuthenticatedRequest(childRequest)
858
+ carryToCopy(request, childRequest)
798
859
  return childRequest
799
860
  }
800
861
 
@@ -810,6 +871,74 @@ function responseSet(ctx: RawContext): CtxSet {
810
871
  return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS
811
872
  }
812
873
 
874
+ type Finalize<T> = (result: unknown, set: CtxSet, ctx: RawContext) => T
875
+ type Wrap<T> = (response: Response | ResponseResult) => T
876
+
877
+ /** A fused route's handler, hook arrays, error hooks and decorations, which never change after the
878
+ * route is built. The route's shared stage chain reads them per request, so routes whose hooks differ
879
+ * still share one chain. */
880
+ interface FusedHooks {
881
+ readonly handler: InternalHandler
882
+ readonly derives: readonly RawDerive[]
883
+ readonly befores: readonly RawBeforeHandle[]
884
+ readonly afters: readonly RawAfterHandle[]
885
+ readonly errors: readonly RawErrorHandler[]
886
+ readonly decorations: Record<PropertyKey, unknown> | undefined
887
+ }
888
+
889
+ /** What a fused lifecycle route runs for its hook shape on the Web lane: the first derive, or the
890
+ * handler when the shape has no hooks before it. */
891
+ type FusedWebStart = (ctx: RawContext, route: FusedHooks, index: number) => MaybePromise<Response>
892
+
893
+ /** The same chain for the Node-direct lane, finalizing through the caller's `finalize`. */
894
+ type FusedNodeStart = <T>(
895
+ ctx: RawContext,
896
+ route: FusedHooks,
897
+ finalize: Finalize<T>,
898
+ wrapResponse: Wrap<T>,
899
+ index: number,
900
+ ) => MaybePromise<T>
901
+
902
+ /** The error hooks of the many fused routes that have none, shared so each does not allocate its own. */
903
+ const NO_ERROR_HOOKS: readonly RawErrorHandler[] = []
904
+
905
+ const WEB_STAGES = new WeakMap<object, Map<number, FusedWebStart>>()
906
+ const NODE_STAGES = new WeakMap<object, Map<number, FusedNodeStart>>()
907
+
908
+ /** How many hooks a phase runs, as the chain tells them apart: none, one, or several. */
909
+ type HookCount = 0 | 1 | 2
910
+
911
+ const hookCount = (hooks: readonly object[]): HookCount =>
912
+ hooks.length > 1 ? 2 : (hooks.length as 0 | 1)
913
+
914
+ /**
915
+ * The fused stage chain of a hook shape, built once per server and handed to every route with the same
916
+ * number of derive, before and after hooks (none, one or several of each). The chain reads a route's
917
+ * hooks and handler from the `FusedHooks` it is called with, so sharing it changes no answer.
918
+ */
919
+ function sharedStages<T>(
920
+ cache: WeakMap<object, Map<number, T>>,
921
+ owner: object,
922
+ route: FusedHooks,
923
+ build: (derives: HookCount, befores: HookCount, afters: HookCount) => T,
924
+ ): T {
925
+ const derives = hookCount(route.derives)
926
+ const befores = hookCount(route.befores)
927
+ const afters = hookCount(route.afters)
928
+ const key = derives * 9 + befores * 3 + afters
929
+ let chains = cache.get(owner)
930
+ if (chains === undefined) {
931
+ chains = new Map()
932
+ cache.set(owner, chains)
933
+ }
934
+ let stages = chains.get(key)
935
+ if (stages === undefined) {
936
+ stages = build(derives, befores, afters)
937
+ chains.set(key, stages)
938
+ }
939
+ return stages
940
+ }
941
+
813
942
  function isContextlessNoArgArrow(handler: (context: never) => unknown): boolean {
814
943
  if (handler.length !== 0) return false
815
944
  try {
@@ -1270,6 +1399,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1270
1399
  private readonly formOrigins: FormOrigins | undefined
1271
1400
  /** `trustBodyFraming`: mark every `app.fetch` request as runtime-framed (see ServerOptions). */
1272
1401
  private readonly trustBodyFraming: boolean
1402
+ private readonly validateHost: boolean
1403
+ private readonly normalizeTarget: boolean
1273
1404
  private readonly wsMaxPayloadBytes: number
1274
1405
  private readonly requestTimeoutMs: number
1275
1406
  private readonly wsUpgradeTimeoutMs: number
@@ -1414,6 +1545,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1414
1545
  this.protoPoisoning = options.protoPoisoning ?? "reject"
1415
1546
  this.formOrigins = options.formOrigins
1416
1547
  this.trustBodyFraming = options.trustBodyFraming ?? false
1548
+ this.validateHost = options.validateHost ?? false
1549
+ this.normalizeTarget = options.normalizeTarget ?? false
1417
1550
  const wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? maxBodyBytes
1418
1551
  assertByteLimit(wsMaxPayloadBytes, "wsMaxPayloadBytes")
1419
1552
  this.maxBodyBytes = maxBodyBytes
@@ -2658,18 +2791,28 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2658
2791
  handler = this.idempotencyRuntime.wrapHandler(handler)
2659
2792
  }
2660
2793
  const { bare, fusedQuery, fusedBody } = lanes
2661
- // Fused lifecycle lanes: derive + before (with or without an after), and body + the same shape.
2662
- // The lane selectors in `selectRouteLanes` are exhaustive about the lifecycleHookLane /
2663
- // body-derive-before-after eligibility, so we only need to check the lane and the hook counts.
2664
- const lifecycleHookLane = lanes.lifecycleHookLane
2665
- const isFusedLifecycle = lifecycleHookLane !== undefined && this.derives.length === 1
2666
- const isFusedBodyLifecycle =
2667
- lanes.fusedLane === "body-derive-before" || lanes.fusedLane === "body-derive-before-after"
2668
- // The generic runners below are the Node-direct twins of the fused Web lanes built further
2669
- // down: same single-frame lifecycle, finalizing through the caller's own `finalize` instead of
2670
- // building a `Response`, so the `!webFast` dispatcher prefers them over the generic program
2671
- // exactly as it prefers the body-only runner. Only the original body-only lane may feed the Web
2672
- // wrapper; every other runner here preserves its derive/before/after stages.
2794
+ // `selectRouteLanes` admits the fused hook lanes only for derive, before and after hooks, so the
2795
+ // three arrays below are the route's whole lifecycle.
2796
+ const fusedLifecycle = lanes.fusedLane === "hooks" || lanes.fusedLane === "body-hooks"
2797
+ // The fused chains keep the arrays they are built from, and `beforeHandle()` inserts into the live
2798
+ // ones ahead of `beforeHandler()` guards, so the entry and the fused chains share one snapshot of
2799
+ // this route's.
2800
+ const derivesSnapshot = [...this.derives]
2801
+ const beforesSnapshot = [...beforeHandle]
2802
+ const aftersSnapshot = [...afterHandle]
2803
+ const onErrorSnapshot = onError.length === 0 ? NO_ERROR_HOOKS : [...onError]
2804
+ const fusedHooks: FusedHooks | undefined = fusedLifecycle
2805
+ ? {
2806
+ handler: handler as unknown as InternalHandler,
2807
+ derives: derivesSnapshot,
2808
+ befores: beforesSnapshot,
2809
+ afters: aftersSnapshot,
2810
+ errors: onErrorSnapshot,
2811
+ decorations: hasDecorations ? routeDecorations : undefined,
2812
+ }
2813
+ : undefined
2814
+ // The Node-direct dispatcher (`!webFast`) prefers these runners over the generic program. Only the
2815
+ // body-only runner also feeds the Web wrapper; the lifecycle lanes build their own Web closure.
2673
2816
  const fusedBodyRunner = fusedBody
2674
2817
  ? this.buildFusedBodyRunner(
2675
2818
  handler as unknown as InternalHandler,
@@ -2677,34 +2820,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2677
2820
  hasDecorations ? routeDecorations : undefined,
2678
2821
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2679
2822
  )
2680
- : isFusedBodyLifecycle
2681
- ? this.buildFusedBodyDeriveBeforeAfterRunner(
2682
- handler as unknown as InternalHandler,
2683
- this.derives[0]!,
2684
- beforeHandle[0]!,
2685
- afterHandle[0],
2686
- schema?.body as StandardSchemaV1,
2823
+ : fusedHooks !== undefined
2824
+ ? this.buildFusedLifecycleRunner(
2825
+ fusedHooks,
2826
+ schema?.query,
2827
+ schema?.body as StandardSchemaV1 | undefined,
2687
2828
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2688
2829
  )
2689
- : isFusedLifecycle && lifecycleHookLane === "derive-before"
2690
- ? this.buildFusedDeriveBeforeRunner(
2691
- handler as unknown as InternalHandler,
2692
- this.derives[0]!,
2693
- beforeHandle[0]!,
2694
- undefined,
2695
- schema?.query,
2696
- bodyLimit ?? UNLIMITED_BODY_BYTES,
2697
- )
2698
- : isFusedLifecycle && lifecycleHookLane === "derive-before-after"
2699
- ? this.buildFusedDeriveBeforeRunner(
2700
- handler as unknown as InternalHandler,
2701
- this.derives[0]!,
2702
- beforeHandle[0]!,
2703
- afterHandle[0],
2704
- schema?.query,
2705
- bodyLimit ?? UNLIMITED_BODY_BYTES,
2706
- )
2707
- : undefined
2830
+ : undefined
2708
2831
  const fusedWeb =
2709
2832
  bare && this.aroundHooks.length === 0
2710
2833
  ? this.buildFusedWeb(
@@ -2722,33 +2845,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2722
2845
  )
2723
2846
  : fusedBody
2724
2847
  ? this.buildFusedBodyWeb(fusedBodyRunner as FusedBodyRunner)
2725
- : isFusedBodyLifecycle
2726
- ? this.buildFusedBodyDeriveBeforeAfter(
2727
- handler as unknown as InternalHandler,
2728
- this.derives[0]!,
2729
- beforeHandle[0]!,
2730
- afterHandle[0],
2731
- schema?.body as StandardSchemaV1,
2848
+ : fusedHooks !== undefined
2849
+ ? this.buildFusedLifecycleWeb(
2850
+ fusedHooks,
2851
+ schema?.query,
2852
+ schema?.body as StandardSchemaV1 | undefined,
2732
2853
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2733
2854
  )
2734
- : isFusedLifecycle && lifecycleHookLane === "derive-before"
2735
- ? this.buildFusedDeriveBefore(
2736
- handler as unknown as InternalHandler,
2737
- this.derives[0]!,
2738
- beforeHandle[0]!,
2739
- schema?.query,
2740
- bodyLimit ?? UNLIMITED_BODY_BYTES,
2741
- )
2742
- : isFusedLifecycle && lifecycleHookLane === "derive-before-after"
2743
- ? this.buildFusedDeriveBeforeAfter(
2744
- handler as unknown as InternalHandler,
2745
- this.derives[0]!,
2746
- beforeHandle[0]!,
2747
- afterHandle[0]!,
2748
- schema?.query,
2749
- bodyLimit ?? UNLIMITED_BODY_BYTES,
2750
- )
2751
- : undefined
2855
+ : undefined
2752
2856
  const program = compileRouteProgram({
2753
2857
  schema,
2754
2858
  handler: handler as unknown as InternalHandler,
@@ -2781,13 +2885,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2781
2885
  idempotent,
2782
2886
  ledgered,
2783
2887
  responseContract,
2784
- derives: [...this.derives],
2888
+ derives: derivesSnapshot,
2785
2889
  authStages: [...compiled.authStages],
2786
2890
  decorations: routeDecorations,
2787
2891
  hasDecorations,
2788
- beforeHandle: [...beforeHandle],
2789
- afterHandle: [...afterHandle],
2790
- onError: [...onError],
2892
+ beforeHandle: beforesSnapshot,
2893
+ afterHandle: aftersSnapshot,
2894
+ onError: onErrorSnapshot,
2791
2895
  lifecycleHookLane: lanes.lifecycleHookLane,
2792
2896
  around: [...this.aroundHooks],
2793
2897
  execution,
@@ -3242,13 +3346,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3242
3346
  const lane = entry.execution.fusedLane
3243
3347
  // Type-erase the lane so each `===` branch sees the full union, not the narrowed remainder.
3244
3348
  const laneName: string | undefined = lane
3245
- // Body+lifecycle routes have a fused Web renderer, and - unlike before - a generic fused
3246
- // runner too: it preserves derive/before/after (it IS the lifecycle, single-framed), so the
3247
- // Node-direct dispatcher's preference for the generic slot over the execution plan stays
3248
- // semantics-preserving. Only the original body-only lane may feed the Web wrapper below.
3249
- const derive = entry.derives[0]
3250
- const before = entry.beforeHandle[0]
3251
- const after = entry.afterHandle[0]
3349
+ const lifecycle = laneName === "hooks" || laneName === "body-hooks"
3350
+ const hooks: FusedHooks | undefined = lifecycle
3351
+ ? {
3352
+ handler: entry.handler,
3353
+ derives: entry.derives,
3354
+ befores: entry.beforeHandle,
3355
+ afters: entry.afterHandle,
3356
+ errors: entry.onError,
3357
+ decorations: entry.hasDecorations ? entry.decorations : undefined,
3358
+ }
3359
+ : undefined
3252
3360
  const fusedBody =
3253
3361
  laneName === "body"
3254
3362
  ? this.buildFusedBodyRunner(
@@ -3257,49 +3365,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3257
3365
  entry.hasDecorations ? entry.decorations : undefined,
3258
3366
  entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3259
3367
  )
3260
- : laneName === "body-derive-before" && derive !== undefined && before !== undefined
3261
- ? this.buildFusedBodyDeriveBeforeAfterRunner(
3262
- entry.handler,
3263
- derive,
3264
- before,
3265
- undefined,
3266
- entry.schema?.body as StandardSchemaV1,
3368
+ : hooks !== undefined
3369
+ ? this.buildFusedLifecycleRunner(
3370
+ hooks,
3371
+ entry.schema?.query,
3372
+ entry.schema?.body as StandardSchemaV1 | undefined,
3267
3373
  entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3268
3374
  )
3269
- : laneName === "body-derive-before-after" &&
3270
- derive !== undefined &&
3271
- before !== undefined &&
3272
- after !== undefined
3273
- ? this.buildFusedBodyDeriveBeforeAfterRunner(
3274
- entry.handler,
3275
- derive,
3276
- before,
3277
- after,
3278
- entry.schema?.body as StandardSchemaV1,
3279
- entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3280
- )
3281
- : laneName === "derive-before" && derive !== undefined && before !== undefined
3282
- ? this.buildFusedDeriveBeforeRunner(
3283
- entry.handler,
3284
- derive,
3285
- before,
3286
- undefined,
3287
- entry.schema?.query,
3288
- entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3289
- )
3290
- : laneName === "derive-before-after" &&
3291
- derive !== undefined &&
3292
- before !== undefined &&
3293
- after !== undefined
3294
- ? this.buildFusedDeriveBeforeRunner(
3295
- entry.handler,
3296
- derive,
3297
- before,
3298
- after,
3299
- entry.schema?.query,
3300
- entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3301
- )
3302
- : undefined
3375
+ : undefined
3303
3376
  const fusedWeb =
3304
3377
  laneName === "body"
3305
3378
  ? this.buildFusedBodyWeb(fusedBody as FusedBodyRunner)
@@ -3310,53 +3383,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3310
3383
  entry.schema?.query as StandardSchemaV1,
3311
3384
  entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3312
3385
  )
3313
- : laneName === "body-derive-before" && derive !== undefined && before !== undefined
3314
- ? this.buildFusedBodyDeriveBeforeAfter(
3386
+ : hooks !== undefined
3387
+ ? this.buildFusedLifecycleWeb(
3388
+ hooks,
3389
+ entry.schema?.query,
3390
+ entry.schema?.body as StandardSchemaV1 | undefined,
3391
+ entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3392
+ )
3393
+ : this.buildFusedWeb(
3315
3394
  entry.handler,
3316
- derive,
3317
- before,
3318
- undefined,
3319
- entry.schema?.body as StandardSchemaV1,
3395
+ entry.hasDecorations ? entry.decorations : undefined,
3396
+ isContextlessNoArgArrow(entry.handler),
3320
3397
  entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3321
3398
  )
3322
- : laneName === "body-derive-before-after" &&
3323
- derive !== undefined &&
3324
- before !== undefined &&
3325
- after !== undefined
3326
- ? this.buildFusedBodyDeriveBeforeAfter(
3327
- entry.handler,
3328
- derive,
3329
- before,
3330
- after,
3331
- entry.schema?.body as StandardSchemaV1,
3332
- entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3333
- )
3334
- : laneName === "derive-before" && derive !== undefined && before !== undefined
3335
- ? this.buildFusedDeriveBefore(
3336
- entry.handler,
3337
- derive,
3338
- before,
3339
- entry.schema?.query,
3340
- entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3341
- )
3342
- : laneName === "derive-before-after" &&
3343
- derive !== undefined &&
3344
- before !== undefined &&
3345
- after !== undefined
3346
- ? this.buildFusedDeriveBeforeAfter(
3347
- entry.handler,
3348
- derive,
3349
- before,
3350
- after,
3351
- entry.schema?.query,
3352
- entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3353
- )
3354
- : this.buildFusedWeb(
3355
- entry.handler,
3356
- entry.hasDecorations ? entry.decorations : undefined,
3357
- isContextlessNoArgArrow(entry.handler),
3358
- entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3359
- )
3360
3399
  return {
3361
3400
  ...route,
3362
3401
  entry: {
@@ -3391,7 +3430,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3391
3430
  fetch(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<Response> {
3392
3431
  // Deno's `Request.url` is the target as sent; Bun, workerd and every `new Request()` parse it.
3393
3432
  // Route what the parsed URL says, so `/users/../admin` is `/admin` on every runtime.
3394
- const target = requestTarget(req)
3433
+ const target = requestTarget(req, this.validateHost, this.normalizeTarget)
3395
3434
  if (target === true) return jsonError(400, "invalid_host")
3396
3435
  if (target !== false) {
3397
3436
  const rebuilt = withResolvedTarget(req, target)
@@ -3463,6 +3502,47 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3463
3502
  )
3464
3503
  }
3465
3504
 
3505
+ /** {@link fetchMatched} for an app whose `onRequest` hooks run before the route does: the hooks see
3506
+ * the request first, and the route Bun matched runs unless one of them replaced the request. */
3507
+ private fetchMatchedHooked(
3508
+ source: Request,
3509
+ entry: RouteEntry,
3510
+ params: Record<string, string>,
3511
+ platform: Platform<EnvOf<Ctx>>,
3512
+ ): MaybePromise<Response> {
3513
+ if (this.capacityGate === undefined) {
3514
+ return this.fetchMatchedHookedInner(source, entry, params, platform)
3515
+ }
3516
+ return this.admitGated(source, () =>
3517
+ this.fetchMatchedHookedInner(source, entry, params, platform),
3518
+ )
3519
+ }
3520
+
3521
+ private fetchMatchedHookedInner(
3522
+ source: Request,
3523
+ entry: RouteEntry,
3524
+ params: Record<string, string>,
3525
+ platform: Platform<EnvOf<Ctx>>,
3526
+ ): MaybePromise<Response> {
3527
+ const outcome = this.runWithOnRequest<Response>(
3528
+ source,
3529
+ this.trustedPlatform(source, platform),
3530
+ this.finalizeResponse,
3531
+ this.wrapWebResponse,
3532
+ this.webResponseTimeout,
3533
+ true,
3534
+ { entry, params },
3535
+ )
3536
+ if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
3537
+ return outcome
3538
+ }
3539
+ return outcome instanceof Promise
3540
+ ? outcome.then((response) =>
3541
+ this.applyOnResponseAndFinalize(response, this.takeResponseRequest(source)),
3542
+ )
3543
+ : this.applyOnResponseAndFinalize(outcome, this.takeResponseRequest(source))
3544
+ }
3545
+
3466
3546
  private fetchMatchedInner(
3467
3547
  source: RequestSource,
3468
3548
  entry: RouteEntry,
@@ -3895,7 +3975,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3895
3975
  // The adapter answers a pass by handing this same object to `fetch`.
3896
3976
  const received = req
3897
3977
  // The handshake routes the path `fetch` would: Host checked, dot segments resolved (see `fetch`).
3898
- const target = requestTarget(req)
3978
+ const target = requestTarget(req, this.validateHost, this.normalizeTarget)
3899
3979
  if (target === true) {
3900
3980
  return { kind: "reject", response: this.wrapWebResponse(jsonError(400, "invalid_host")) }
3901
3981
  }
@@ -4116,10 +4196,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4116
4196
  private bunFetchWithWebSocket(
4117
4197
  req: Request,
4118
4198
  server: BunUpgradeServer,
4199
+ setIdleTimeout: IdleTimeoutSetter,
4119
4200
  ): MaybePromise<Response | undefined> {
4120
4201
  const handle = (o: WebSocketUpgradeOutcome): MaybePromise<Response | undefined> => {
4121
4202
  if (o.kind === "pass")
4122
- return this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>)
4203
+ return this.fetch(
4204
+ req,
4205
+ new BunPeerPlatform(server, req, setIdleTimeout) as Platform<EnvOf<Ctx>>,
4206
+ )
4123
4207
  if (o.kind === "reject") return o.response
4124
4208
  // A copy: the Bun socket keeps per-connection state on it, and a hand-built outcome may be shared.
4125
4209
  return server.upgrade(req, { data: { ...o } })
@@ -4128,7 +4212,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4128
4212
  }
4129
4213
  const outcome = this.resolveWebSocketUpgrade(
4130
4214
  req,
4131
- bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>,
4215
+ new BunPeerPlatform(server, req, setIdleTimeout) as Platform<EnvOf<Ctx>>,
4132
4216
  )
4133
4217
  return outcome instanceof Promise ? outcome.then(handle) : handle(outcome)
4134
4218
  }
@@ -4281,6 +4365,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4281
4365
  wrapResponse: (response: Response | ResponseResult) => T,
4282
4366
  onTimeout: () => T,
4283
4367
  webFast: boolean,
4368
+ matched?: MatchedRoute,
4284
4369
  ): MaybePromise<T> {
4285
4370
  const hooks = this.onRequestHooks
4286
4371
  const originalRequest = requestOf(source)
@@ -4310,6 +4395,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4310
4395
  wrapResponse,
4311
4396
  onTimeout,
4312
4397
  webFast,
4398
+ matched,
4313
4399
  ),
4314
4400
  (err) => this.onRequestFailure(err, current, platform, finalize, wrapResponse),
4315
4401
  )
@@ -4322,7 +4408,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4322
4408
  }
4323
4409
  if (outcome !== undefined) return earlyAnswer(outcome, wrapResponse)
4324
4410
  }
4325
- return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast)
4411
+ return this.routeAndRun(
4412
+ current,
4413
+ platform,
4414
+ finalize,
4415
+ wrapResponse,
4416
+ onTimeout,
4417
+ webFast,
4418
+ current === originalRequest ? matched : undefined,
4419
+ )
4326
4420
  }
4327
4421
 
4328
4422
  /** Async tail of {@link runWithOnRequest}: applies the first awaited hook's outcome, then runs
@@ -4337,6 +4431,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4337
4431
  wrapResponse: (response: Response | ResponseResult) => T,
4338
4432
  onTimeout: () => T,
4339
4433
  webFast: boolean,
4434
+ matched?: MatchedRoute,
4340
4435
  ): Promise<T> {
4341
4436
  let current = sourceAtAwait
4342
4437
  let seen = requestOf(sourceAtAwait)
@@ -4360,7 +4455,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4360
4455
  }
4361
4456
  index++
4362
4457
  }
4363
- return this.routeAndRun(current, platform, finalize, wrapResponse, onTimeout, webFast)
4458
+ return this.routeAndRun(
4459
+ current,
4460
+ platform,
4461
+ finalize,
4462
+ wrapResponse,
4463
+ onTimeout,
4464
+ webFast,
4465
+ current === originalRequest ? matched : undefined,
4466
+ )
4364
4467
  }
4365
4468
 
4366
4469
  private takeResponseRequest(source: RequestSource): Request {
@@ -4438,7 +4541,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4438
4541
  wrapResponse: (response: Response | ResponseResult) => T,
4439
4542
  onTimeout: () => T,
4440
4543
  webFast: boolean,
4544
+ // The route Bun's table already matched for this very request, which routing would find again.
4545
+ matched?: MatchedRoute,
4441
4546
  ): MaybePromise<T> {
4547
+ if (matched !== undefined) {
4548
+ return this.runMatched(
4549
+ source,
4550
+ platform,
4551
+ matched.entry,
4552
+ matched.params,
4553
+ undefined,
4554
+ finalize,
4555
+ wrapResponse,
4556
+ onTimeout,
4557
+ webFast,
4558
+ )
4559
+ }
4442
4560
  // Native delimiter searches keep path/query splitting out of a character-by-character JS loop.
4443
4561
  // Keep the slices in locals instead of allocating the public helper's pathname/search pair.
4444
4562
  let pathname: string
@@ -5287,131 +5405,298 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5287
5405
  }
5288
5406
 
5289
5407
  /**
5290
- * Compile a route's `derive + before` lifecycle to a single closure: the per-request walk through
5291
- * `runSync` → `runHooksSync` is a stack of 3 frames and 2 `Object.assign`s for what is by far the
5292
- * most common middleware shape (auth/trace/id derivation, an auth/policy `beforeHandle`). Folding
5293
- * the same steps into one closure hits the same monomorphic inline-cache site as `buildFusedWeb`,
5294
- * and `Object.assign(ctx, deriveResult)` becomes one extra property write on the context's already
5295
- * hot hidden class. Behavior is byte-identical to the compiled route program: the same thrown-value
5296
- * contract (`Response` is control flow, anything else logs + 500), the same `c.set` semantics, the
5297
- * same `fusedRespond` for the rendered body. The `LifecycleExecutionLane` guarantee
5298
- * (`schema.params === undefined && schema.headers === undefined && derives === 1 &&
5299
- * beforeHandle === 1 && afterHandle === 0 && onError === 0 && !hasResponseContract &&
5300
- * !hasDecorations`) is enforced by `selectRouteLanes`; this lane is selected ONLY when all of those
5301
- * hold. A `query` schema (the route declares `{ query }` alongside the hooks) is validated FIRST,
5302
- * mirroring the compiled route program's stage order; the fused builder runs validate → derive →
5303
- * before → handler in one frame.
5408
+ * The stage chain a hook shape runs - derive, before, handler, after, respond - for the Web lane,
5409
+ * built once per server and shared by every route with the same number of hooks in each phase (none,
5410
+ * one or several). The chain reads a route's handler and hooks from its `FusedHooks` per request, so
5411
+ * a route's own closure (see {@link buildFusedLifecycleWeb}) holds only that record and its schemas,
5412
+ * and memory grows with the number of distinct shapes, not routes or hook functions.
5304
5413
  */
5305
- private buildFusedDeriveBefore(
5306
- handler: InternalHandler,
5307
- derive: RawDerive,
5308
- before: RawBeforeHandle,
5309
- querySchema: StandardSchemaV1 | undefined,
5310
- maxBodyBytes: number,
5311
- ): FusedWebRunner {
5312
- return this.buildFusedDeriveBeforeAfter(
5313
- handler,
5314
- derive,
5315
- before,
5316
- undefined,
5317
- querySchema,
5318
- maxBodyBytes,
5319
- )
5414
+ private fusedWebStages(
5415
+ deriveCount: HookCount,
5416
+ beforeCount: HookCount,
5417
+ afterCount: HookCount,
5418
+ ): FusedWebStart {
5419
+ // A shape creates only the stages it uses. Which stage follows which is settled once here, so a
5420
+ // request makes one call per hook and per phase. A phase with a single hook, the common middleware
5421
+ // shape, gets a straight-line closure that reads that hook from the route; a phase with several
5422
+ // loops over the route's array, running synchronous hooks in place so the stack stays flat however
5423
+ // many a phase holds, and only a hook that returns a promise re-enters its stage from a microtask.
5424
+ // Each request supplies its own context and route.
5425
+ type Stage = (ctx: RawContext, route: FusedHooks, index: number) => MaybePromise<Response>
5426
+ type AfterStage = (
5427
+ result: unknown,
5428
+ ctx: RawContext,
5429
+ route: FusedHooks,
5430
+ index: number,
5431
+ ) => MaybePromise<Response>
5432
+ let runAfter: AfterStage
5433
+ if (afterCount === 0) {
5434
+ runAfter = (result, ctx, route) =>
5435
+ this.respondHooks(result, ctx, route, this.onResponseHooks.length === 0)
5436
+ } else if (afterCount === 1) {
5437
+ runAfter = (result, ctx, route) => {
5438
+ let transformed: unknown
5439
+ try {
5440
+ transformed = route.afters[0]!(result, ctx)
5441
+ } catch (err) {
5442
+ return this.fusedWebRouteFail(err, ctx, route)
5443
+ }
5444
+ if (transformed instanceof Promise) {
5445
+ return transformed.then(
5446
+ (value) => this.respondHooks(value, ctx, route, this.onResponseHooks.length === 0),
5447
+ (err) => this.fusedWebRouteFail(err, ctx, route),
5448
+ )
5449
+ }
5450
+ return this.respondHooks(transformed, ctx, route, this.onResponseHooks.length === 0)
5451
+ }
5452
+ } else {
5453
+ runAfter = (result, ctx, route, index) => {
5454
+ const lastAfter = route.afters.length - 1
5455
+ for (;;) {
5456
+ let transformed: unknown
5457
+ try {
5458
+ transformed = route.afters[index]!(result, ctx)
5459
+ } catch (err) {
5460
+ return this.fusedWebRouteFail(err, ctx, route)
5461
+ }
5462
+ if (transformed instanceof Promise) {
5463
+ return transformed.then(
5464
+ (value) =>
5465
+ index < lastAfter
5466
+ ? runAfter(value, ctx, route, index + 1)
5467
+ : this.respondHooks(value, ctx, route, this.onResponseHooks.length === 0),
5468
+ (err) => this.fusedWebRouteFail(err, ctx, route),
5469
+ )
5470
+ }
5471
+ if (index === lastAfter) {
5472
+ return this.respondHooks(transformed, ctx, route, this.onResponseHooks.length === 0)
5473
+ }
5474
+ result = transformed
5475
+ index++
5476
+ }
5477
+ }
5478
+ }
5479
+ const runHandler: Stage = (ctx, route) => {
5480
+ let result: unknown
5481
+ try {
5482
+ result = route.handler(ctx)
5483
+ } catch (err) {
5484
+ return this.fusedWebRouteFail(err, ctx, route)
5485
+ }
5486
+ if (result instanceof Promise) {
5487
+ return result.then(
5488
+ (value) => runAfter(value, ctx, route, 0),
5489
+ (err) => this.fusedWebRouteFail(err, ctx, route),
5490
+ )
5491
+ }
5492
+ return runAfter(result, ctx, route, 0)
5493
+ }
5494
+ let runBefore: Stage
5495
+ if (beforeCount === 0) {
5496
+ runBefore = runHandler
5497
+ } else if (beforeCount === 1) {
5498
+ runBefore = (ctx, route) => {
5499
+ let early: unknown
5500
+ try {
5501
+ early = route.befores[0]!(ctx)
5502
+ } catch (err) {
5503
+ return this.fusedWebRouteFail(err, ctx, route)
5504
+ }
5505
+ if (early instanceof Promise) {
5506
+ return early.then(
5507
+ (settled) =>
5508
+ settled === undefined
5509
+ ? runHandler(ctx, route, 0)
5510
+ : this.respondHooks(settled, ctx, route),
5511
+ (err) => this.fusedWebRouteFail(err, ctx, route),
5512
+ )
5513
+ }
5514
+ if (early === undefined) return runHandler(ctx, route, 0)
5515
+ return this.respondHooks(early, ctx, route)
5516
+ }
5517
+ } else {
5518
+ runBefore = (ctx, route, index) => {
5519
+ const lastBefore = route.befores.length - 1
5520
+ for (;;) {
5521
+ let early: unknown
5522
+ try {
5523
+ early = route.befores[index]!(ctx)
5524
+ } catch (err) {
5525
+ return this.fusedWebRouteFail(err, ctx, route)
5526
+ }
5527
+ if (early instanceof Promise) {
5528
+ return early.then(
5529
+ (settled) => {
5530
+ if (settled !== undefined) return this.respondHooks(settled, ctx, route)
5531
+ return index < lastBefore
5532
+ ? runBefore(ctx, route, index + 1)
5533
+ : runHandler(ctx, route, 0)
5534
+ },
5535
+ (err) => this.fusedWebRouteFail(err, ctx, route),
5536
+ )
5537
+ }
5538
+ if (early !== undefined) return this.respondHooks(early, ctx, route)
5539
+ if (index === lastBefore) return runHandler(ctx, route, 0)
5540
+ index++
5541
+ }
5542
+ }
5543
+ }
5544
+ let runDerive: Stage
5545
+ if (deriveCount === 0) {
5546
+ runDerive = runBefore
5547
+ } else if (deriveCount === 1) {
5548
+ runDerive = (ctx, route) => {
5549
+ let derived: unknown
5550
+ try {
5551
+ derived = route.derives[0]!(ctx)
5552
+ } catch (err) {
5553
+ return this.fusedWebRouteFail(err, ctx, route)
5554
+ }
5555
+ if (derived instanceof Promise) {
5556
+ return derived.then(
5557
+ (settled) => {
5558
+ if (isResponseResult(settled) || settled instanceof Response) {
5559
+ return this.respondHooks(settled, ctx, route)
5560
+ }
5561
+ try {
5562
+ Object.assign(ctx, settled)
5563
+ } catch (err) {
5564
+ return this.fusedWebRouteFail(err, ctx, route)
5565
+ }
5566
+ return runBefore(ctx, route, 0)
5567
+ },
5568
+ (err) => this.fusedWebRouteFail(err, ctx, route),
5569
+ )
5570
+ }
5571
+ if (isResponseResult(derived) || derived instanceof Response) {
5572
+ return this.respondHooks(derived, ctx, route)
5573
+ }
5574
+ try {
5575
+ Object.assign(ctx, derived)
5576
+ } catch (err) {
5577
+ return this.fusedWebRouteFail(err, ctx, route)
5578
+ }
5579
+ return runBefore(ctx, route, 0)
5580
+ }
5581
+ } else {
5582
+ runDerive = (ctx, route, index) => {
5583
+ const lastDerive = route.derives.length - 1
5584
+ for (;;) {
5585
+ let derived: unknown
5586
+ try {
5587
+ derived = route.derives[index]!(ctx)
5588
+ } catch (err) {
5589
+ return this.fusedWebRouteFail(err, ctx, route)
5590
+ }
5591
+ if (derived instanceof Promise) {
5592
+ return derived.then(
5593
+ (settled) => {
5594
+ if (isResponseResult(settled) || settled instanceof Response) {
5595
+ return this.respondHooks(settled, ctx, route)
5596
+ }
5597
+ try {
5598
+ Object.assign(ctx, settled)
5599
+ } catch (err) {
5600
+ return this.fusedWebRouteFail(err, ctx, route)
5601
+ }
5602
+ return index < lastDerive
5603
+ ? runDerive(ctx, route, index + 1)
5604
+ : runBefore(ctx, route, 0)
5605
+ },
5606
+ (err) => this.fusedWebRouteFail(err, ctx, route),
5607
+ )
5608
+ }
5609
+ if (isResponseResult(derived) || derived instanceof Response) {
5610
+ return this.respondHooks(derived, ctx, route)
5611
+ }
5612
+ try {
5613
+ Object.assign(ctx, derived)
5614
+ } catch (err) {
5615
+ return this.fusedWebRouteFail(err, ctx, route)
5616
+ }
5617
+ if (index === lastDerive) return runBefore(ctx, route, 0)
5618
+ index++
5619
+ }
5620
+ }
5621
+ }
5622
+ return runDerive
5320
5623
  }
5321
5624
 
5322
- // Both hook variants share one registered chain; request contexts stay in arguments.
5323
- private buildFusedDeriveBeforeAfter(
5625
+ /**
5626
+ * Compile a fused lifecycle route's entry closure: parse (body) → validate → the shared stage chain
5627
+ * of its hook shape (derive → before → handler → after → respond, see {@link fusedWebStages}) in a
5628
+ * single frame, where the generic program walks staged hook loops. `selectRouteLanes` admits only
5629
+ * derive, before and after hooks - no params or headers schema, onError, around, decorations,
5630
+ * response contract or recovery hook, and never a query schema beside a body schema - so the behavior
5631
+ * is the compiled program's: the same stage order, the same thrown-value contract (`Response` is
5632
+ * control flow, anything else logs and 500s), the same `c.set` semantics and `fusedRespond` render.
5633
+ * The route's own closure holds only its `FusedHooks` and schemas and takes the request's context as
5634
+ * an argument, so a request allocates no step closures.
5635
+ */
5636
+ /**
5637
+ * Keep the common one-derive/one-before shape bound to its hooks. Inline its synchronous path to
5638
+ * avoid stage calls; retain helper closures only for promise continuations.
5639
+ */
5640
+ private buildFusedLifecycleWebSingle(
5324
5641
  handler: InternalHandler,
5325
5642
  derive: RawDerive,
5326
5643
  before: RawBeforeHandle,
5327
5644
  after: RawAfterHandle | undefined,
5328
- querySchema: StandardSchemaV1 | undefined,
5329
5645
  maxBodyBytes: number,
5330
5646
  ): FusedWebRunner {
5331
- const logError = (err: unknown, ctx: RawContext): Response =>
5332
- this.bareError(
5333
- err,
5334
- ctx,
5335
- (result, set) => this.wrapWebResponse(toResponse(result, set)),
5336
- this.wrapWebResponse,
5337
- )
5338
- // Continuations capture route state once; each request supplies its own context.
5339
- const runAfter = (result: unknown, ctx: RawContext): MaybePromise<Response> => {
5340
- if (after === undefined)
5341
- return this.respondFused(result, ctx, this.onResponseHooks.length === 0)
5342
- let transformed: unknown
5343
- try {
5344
- transformed = after(result, ctx)
5345
- } catch (err) {
5346
- return logError(err, ctx)
5347
- }
5348
- if (transformed instanceof Promise) {
5349
- return transformed.then(
5350
- (value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0),
5351
- (err) => logError(err, ctx),
5352
- )
5353
- }
5354
- return this.respondFused(transformed, ctx, this.onResponseHooks.length === 0)
5355
- }
5647
+ const runAfter =
5648
+ after === undefined
5649
+ ? (result: unknown, ctx: RawContext): MaybePromise<Response> =>
5650
+ this.respondFused(result, ctx, this.onResponseHooks.length === 0)
5651
+ : (result: unknown, ctx: RawContext): MaybePromise<Response> => {
5652
+ let transformed: unknown
5653
+ try {
5654
+ transformed = after(result, ctx)
5655
+ } catch (err) {
5656
+ return this.fusedWebFail(err, ctx)
5657
+ }
5658
+ if (transformed instanceof Promise) {
5659
+ return transformed.then(
5660
+ (value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0),
5661
+ (err) => this.fusedWebFail(err, ctx),
5662
+ )
5663
+ }
5664
+ return this.respondFused(transformed, ctx, this.onResponseHooks.length === 0)
5665
+ }
5666
+
5356
5667
  const runHandler = (ctx: RawContext): MaybePromise<Response> => {
5357
5668
  let result: unknown
5358
5669
  try {
5359
5670
  result = handler(ctx)
5360
5671
  } catch (err) {
5361
- return logError(err, ctx)
5672
+ return this.fusedWebFail(err, ctx)
5362
5673
  }
5363
5674
  if (result instanceof Promise) {
5364
5675
  return result.then(
5365
5676
  (value) => runAfter(value, ctx),
5366
- (err) => logError(err, ctx),
5677
+ (err) => this.fusedWebFail(err, ctx),
5367
5678
  )
5368
5679
  }
5369
5680
  return runAfter(result, ctx)
5370
5681
  }
5682
+
5371
5683
  const runBefore = (ctx: RawContext): MaybePromise<Response> => {
5372
5684
  let early: unknown
5373
5685
  try {
5374
5686
  early = before(ctx)
5375
5687
  } catch (err) {
5376
- return logError(err, ctx)
5688
+ return this.fusedWebFail(err, ctx)
5377
5689
  }
5378
5690
  if (early instanceof Promise) {
5379
5691
  return early.then(
5380
- (settled) => {
5381
- if (settled === undefined) return runHandler(ctx)
5382
- return this.respondFused(settled, ctx)
5383
- },
5384
- (err) => logError(err, ctx),
5692
+ (settled) => (settled === undefined ? runHandler(ctx) : this.respondFused(settled, ctx)),
5693
+ (err) => this.fusedWebFail(err, ctx),
5385
5694
  )
5386
5695
  }
5387
5696
  if (early === undefined) return runHandler(ctx)
5388
5697
  return this.respondFused(early, ctx)
5389
5698
  }
5390
- const runDerive = (ctx: RawContext): MaybePromise<Response> => {
5391
- let derived: unknown
5392
- try {
5393
- derived = derive(ctx)
5394
- } catch (err) {
5395
- return logError(err, ctx)
5396
- }
5397
- if (derived instanceof Promise) {
5398
- return derived.then(
5399
- (settled) => {
5400
- if (isResponseResult(settled) || settled instanceof Response) {
5401
- return this.respondFused(settled, ctx)
5402
- }
5403
- Object.assign(ctx, settled as object)
5404
- return runBefore(ctx)
5405
- },
5406
- (err) => logError(err, ctx),
5407
- )
5408
- }
5409
- if (isResponseResult(derived) || derived instanceof Response) {
5410
- return this.respondFused(derived, ctx)
5411
- }
5412
- Object.assign(ctx, derived as object)
5413
- return runBefore(ctx)
5414
- }
5699
+
5415
5700
  return (source, params, search, signal, budget, platform, nativeContext) => {
5416
5701
  const ctx = nativeContext
5417
5702
  ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
@@ -5425,154 +5710,113 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5425
5710
  maxBodyBytes,
5426
5711
  this.protoPoisoning,
5427
5712
  )
5428
- if (querySchema === undefined) return runDerive(ctx)
5429
- let validation: MaybePromise<StandardResult<unknown>>
5713
+
5714
+ let derived: unknown
5430
5715
  try {
5431
- validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
5716
+ derived = derive(ctx)
5432
5717
  } catch (err) {
5433
- return logError(err, ctx)
5718
+ return this.fusedWebFail(err, ctx)
5434
5719
  }
5435
- if (validation instanceof Promise) {
5436
- return validation.then(
5720
+ if (derived instanceof Promise) {
5721
+ return derived.then(
5437
5722
  (settled) => {
5438
- if (settled.issues !== undefined)
5439
- return this.wrapWebResponse(plainValidationError(settled.issues))
5440
- ctx.query = settled.value
5441
- return runDerive(ctx)
5723
+ if (isResponseResult(settled) || settled instanceof Response)
5724
+ return this.respondFused(settled, ctx)
5725
+ try {
5726
+ Object.assign(ctx, settled)
5727
+ } catch (err) {
5728
+ return this.fusedWebFail(err, ctx)
5729
+ }
5730
+ return runBefore(ctx)
5442
5731
  },
5443
- (err) => logError(err, ctx),
5732
+ (err) => this.fusedWebFail(err, ctx),
5444
5733
  )
5445
5734
  }
5446
- if (validation.issues !== undefined)
5447
- return this.wrapWebResponse(plainValidationError(validation.issues))
5448
- ctx.query = validation.value
5449
- return runDerive(ctx)
5735
+ if (isResponseResult(derived) || derived instanceof Response)
5736
+ return this.respondFused(derived, ctx)
5737
+
5738
+ let early: unknown
5739
+ let result: unknown
5740
+ let transformed: unknown
5741
+ let handlerPromise: Promise<unknown> | undefined
5742
+ try {
5743
+ Object.assign(ctx, derived)
5744
+ early = before(ctx)
5745
+ if (early === undefined) {
5746
+ result = handler(ctx)
5747
+ if (result instanceof Promise) handlerPromise = result
5748
+ else if (after !== undefined) transformed = after(result, ctx)
5749
+ }
5750
+ } catch (err) {
5751
+ return this.fusedWebFail(err, ctx)
5752
+ }
5753
+
5754
+ if (early instanceof Promise) {
5755
+ return early.then(
5756
+ (settled) => (settled === undefined ? runHandler(ctx) : this.respondFused(settled, ctx)),
5757
+ (err) => this.fusedWebFail(err, ctx),
5758
+ )
5759
+ }
5760
+ if (early !== undefined) return this.respondFused(early, ctx)
5761
+ if (handlerPromise !== undefined) {
5762
+ return handlerPromise.then(
5763
+ (value) =>
5764
+ after === undefined
5765
+ ? this.respondFused(value, ctx, this.onResponseHooks.length === 0)
5766
+ : runAfter(value, ctx),
5767
+ (err) => this.fusedWebFail(err, ctx),
5768
+ )
5769
+ }
5770
+ if (after === undefined)
5771
+ return this.respondFused(result, ctx, this.onResponseHooks.length === 0)
5772
+ if (transformed instanceof Promise) {
5773
+ return transformed.then(
5774
+ (value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0),
5775
+ (err) => this.fusedWebFail(err, ctx),
5776
+ )
5777
+ }
5778
+ return this.respondFused(transformed, ctx, this.onResponseHooks.length === 0)
5450
5779
  }
5451
5780
  }
5452
5781
 
5453
- /**
5454
- * Body + lifecycle hooks fused: parse → validate → derive → before → handler → (after) in one
5455
- * closure, the same win `buildFusedBodyRunner` gives for the no-hooks body shape. The shape is
5456
- * `lifecycleHookLane` PLUS a body schema with no other validation conflicts; the lane is selected
5457
- * ONLY when `schema.body !== undefined && schema.query/params/headers === undefined &&
5458
- * schema.onValidationError === undefined && !defaultOnValidationError && !hasIdempotency &&
5459
- * !hasLedger && derives === 1 && beforeHandle === 1 && afterHandle <= 1 && onError === 0 &&
5460
- * !hasResponseContract && !hasDecorations && around === 0`. The same single `querySchema === undefined`
5461
- * trick used in the no-body lane lets the build path stay simple: a query + body route falls back
5462
- * to the generic program (this is rare in practice - validated query + validated body is usually a
5463
- * "search results" route with a middleware chain on top, and the generic lane is fine there).
5464
- */
5465
- private buildFusedBodyDeriveBeforeAfter(
5466
- handler: InternalHandler,
5467
- derive: RawDerive,
5468
- before: RawBeforeHandle,
5469
- after: RawAfterHandle | undefined,
5470
- bodySchema: StandardSchemaV1,
5782
+ private buildFusedLifecycleWeb(
5783
+ route: FusedHooks,
5784
+ querySchema: StandardSchemaV1 | undefined,
5785
+ bodySchema: StandardSchemaV1 | undefined,
5471
5786
  maxBodyBytes: number,
5472
5787
  ): FusedWebRunner {
5473
- const logError = (err: unknown, ctx: RawContext): Response =>
5474
- this.bareError(
5475
- err,
5476
- ctx,
5477
- (result, set) => this.wrapWebResponse(toResponse(result, set)),
5478
- this.wrapWebResponse,
5479
- )
5480
- return (source, params, search, signal, budget, platform, nativeContext) => {
5481
- const ctx = nativeContext
5482
- ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
5483
- : new RequestContext(
5484
- source,
5485
- params,
5486
- search,
5487
- signal,
5488
- budget,
5489
- platform,
5490
- maxBodyBytes,
5491
- this.protoPoisoning,
5492
- )
5493
- const runHandlerValidated = (): MaybePromise<Response> => {
5494
- const runAfter = (result: unknown): MaybePromise<Response> => {
5495
- if (after === undefined) {
5496
- return this.respondFused(result, ctx, this.onResponseHooks.length === 0)
5497
- }
5498
- let transformed: unknown
5499
- try {
5500
- transformed = after(result, ctx)
5501
- } catch (err) {
5502
- return logError(err, ctx)
5503
- }
5504
- if (transformed instanceof Promise) {
5505
- return transformed.then(
5506
- (value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0),
5507
- (err) => logError(err, ctx),
5508
- )
5509
- }
5510
- return this.respondFused(transformed, ctx, this.onResponseHooks.length === 0)
5511
- }
5512
- const runHandler = (): MaybePromise<Response> => {
5513
- let result: unknown
5514
- try {
5515
- result = handler(ctx)
5516
- } catch (err) {
5517
- return logError(err, ctx)
5518
- }
5519
- if (result instanceof Promise) {
5520
- return result.then(
5521
- (value) => runAfter(value),
5522
- (err) => logError(err, ctx),
5523
- )
5524
- }
5525
- return runAfter(result)
5526
- }
5527
- const runBefore = (): MaybePromise<Response> => {
5528
- let early: unknown
5529
- try {
5530
- early = before(ctx)
5531
- } catch (err) {
5532
- return logError(err, ctx)
5533
- }
5534
- if (early instanceof Promise) {
5535
- return early.then(
5536
- (settled) => {
5537
- if (settled === undefined) return runHandler()
5538
- return this.respondFused(settled, ctx)
5539
- },
5540
- (err) => logError(err, ctx),
5541
- )
5542
- }
5543
- if (early === undefined) return runHandler()
5544
- return this.respondFused(early, ctx)
5545
- }
5546
- let derived: unknown
5547
- try {
5548
- derived = derive(ctx)
5549
- } catch (err) {
5550
- return logError(err, ctx)
5551
- }
5552
- if (derived instanceof Promise) {
5553
- return derived.then(
5554
- (settled) => {
5555
- if (isResponseResult(settled) || settled instanceof Response) {
5556
- return this.respondFused(settled, ctx)
5557
- }
5558
- Object.assign(ctx, settled as object)
5559
- return runBefore()
5560
- },
5561
- (err) => logError(err, ctx),
5562
- )
5563
- }
5564
- if (isResponseResult(derived) || derived instanceof Response) {
5565
- return this.respondFused(derived, ctx)
5566
- }
5567
- Object.assign(ctx, derived as object)
5568
- return runBefore()
5788
+ if (
5789
+ querySchema === undefined &&
5790
+ bodySchema === undefined &&
5791
+ route.errors.length === 0 &&
5792
+ route.decorations === undefined &&
5793
+ route.derives.length === 1 &&
5794
+ route.befores.length === 1 &&
5795
+ route.afters.length <= 1
5796
+ ) {
5797
+ const derive = route.derives[0]
5798
+ const before = route.befores[0]
5799
+ if (derive !== undefined && before !== undefined) {
5800
+ return this.buildFusedLifecycleWebSingle(
5801
+ route.handler,
5802
+ derive,
5803
+ before,
5804
+ route.afters[0],
5805
+ maxBodyBytes,
5806
+ )
5569
5807
  }
5570
- const onParsed = (parsed: unknown): MaybePromise<Response> => {
5808
+ }
5809
+ const runDerive = sharedStages(WEB_STAGES, this, route, (d, b, a) =>
5810
+ this.fusedWebStages(d, b, a),
5811
+ )
5812
+ const decorations = route.decorations
5813
+ if (bodySchema !== undefined) {
5814
+ const onParsed = (parsed: unknown, ctx: RawContext): MaybePromise<Response> => {
5571
5815
  let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
5572
5816
  try {
5573
5817
  validation = bodySchema["~standard"].validate(parsed)
5574
5818
  } catch (err) {
5575
- return logError(err, ctx)
5819
+ return this.fusedWebRouteFail(err, ctx, route)
5576
5820
  }
5577
5821
  if (validation instanceof Promise) {
5578
5822
  return validation.then(
@@ -5580,224 +5824,50 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5580
5824
  if (settled.issues !== undefined)
5581
5825
  return this.wrapWebResponse(plainValidationError(settled.issues))
5582
5826
  ctx.body = settled.value
5583
- return runHandlerValidated()
5827
+ return runDerive(ctx, route, 0)
5584
5828
  },
5585
- (err) => logError(err, ctx),
5829
+ (err) => this.fusedWebRouteFail(err, ctx, route),
5586
5830
  )
5587
5831
  }
5588
5832
  if (validation.issues !== undefined)
5589
5833
  return this.wrapWebResponse(plainValidationError(validation.issues))
5590
5834
  ctx.body = validation.value
5591
- return runHandlerValidated()
5592
- }
5593
- return readBodyFramed(
5594
- source,
5595
- maxBodyBytes,
5596
- this.protoPoisoning,
5597
- onParsed,
5598
- (response) => this.wrapWebResponse(response),
5599
- (err) => Promise.resolve(logError(err, ctx)),
5600
- bodySchema,
5601
- this.formOrigins,
5602
- ) as MaybePromise<Response>
5603
- }
5604
- }
5605
-
5606
- /**
5607
- * Generic (finalize/wrapResponse-parameterized) fused runner for the derive-before(-after) lane:
5608
- * the Node-direct counterpart of `buildFusedDeriveBefore` / `buildFusedDeriveBeforeAfter`.
5609
- *
5610
- * The Web fused builders above return a `Response`, so the Node dispatcher (`webFast === false`)
5611
- * cannot use them and a derive+before route falls back to the generic route program there - two
5612
- * staged loops, per-stage dispatch, and the async-continuation machinery - while Bun rides the
5613
- * single closure. That asymmetry is the realistic-route Node deficit against Fastify: this runner
5614
- * closes it by running validate → derive → before → handler → (after) in one frame and finalizing
5615
- * through the caller's own `finalize` (the Node outcome on the direct lane, `toResponse` on Web),
5616
- * so the same registration feeds both runtimes with no per-lane copy of the lifecycle to drift.
5617
- *
5618
- * Semantics mirror the generic program stage-for-stage for the shapes this lane admits (exactly
5619
- * one derive, one beforeHandle, zero-or-one afterHandle, no onError/around/decorations/contracts/
5620
- * recovery hooks - enforced by `selectRouteLanes`): thrown `Response`/`status(...)` are control
5621
- * flow through `wrapResponse`/`finalize`, anything else logs + 500s via the shared `bareError`
5622
- * lane (identical to `handleLifecycleError` with no `onError` hooks), and a terminal `finalize`
5623
- * throw (e.g. an unstringifiable handler return on the Node lane) is caught exactly as
5624
- * `finishLifecycleContract` catches it. Step functions close over registration state only and take
5625
- * the request state as arguments, so the hot path allocates no per-request closures.
5626
- */
5627
- private buildFusedDeriveBeforeRunner(
5628
- handler: InternalHandler,
5629
- derive: RawDerive,
5630
- before: RawBeforeHandle,
5631
- after: RawAfterHandle | undefined,
5632
- querySchema: StandardSchemaV1 | undefined,
5633
- maxBodyBytes: number,
5634
- ): FusedBodyRunner {
5635
- const logError = <T>(
5636
- err: unknown,
5637
- ctx: RawContext,
5638
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5639
- wrapResponse: (response: Response | ResponseResult) => T,
5640
- ): T => this.bareError(err, ctx, finalize, wrapResponse)
5641
-
5642
- const runFinish = <T>(
5643
- result: unknown,
5644
- ctx: RawContext,
5645
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5646
- wrapResponse: (response: Response | ResponseResult) => T,
5647
- ): MaybePromise<T> => {
5648
- if (after === undefined) {
5649
- try {
5650
- return finalize(result, responseSet(ctx), ctx)
5651
- } catch (err) {
5652
- return logError(err, ctx, finalize, wrapResponse)
5653
- }
5654
- }
5655
- let transformed: unknown
5656
- try {
5657
- transformed = after(result, ctx)
5658
- } catch (err) {
5659
- return logError(err, ctx, finalize, wrapResponse)
5660
- }
5661
- if (transformed instanceof Promise) {
5662
- return transformed.then(
5663
- (value) => {
5664
- try {
5665
- return finalize(value, responseSet(ctx), ctx)
5666
- } catch (err) {
5667
- return logError(err, ctx, finalize, wrapResponse)
5668
- }
5669
- },
5670
- (err) => logError(err, ctx, finalize, wrapResponse),
5671
- )
5672
- }
5673
- try {
5674
- return finalize(transformed, responseSet(ctx), ctx)
5675
- } catch (err) {
5676
- return logError(err, ctx, finalize, wrapResponse)
5835
+ return runDerive(ctx, route, 0)
5677
5836
  }
5678
- }
5679
-
5680
- const finalizeEarly = <T>(
5681
- result: unknown,
5682
- ctx: RawContext,
5683
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5684
- wrapResponse: (response: Response | ResponseResult) => T,
5685
- ): T => {
5686
- try {
5687
- return finalize(result, responseSet(ctx), ctx)
5688
- } catch (err) {
5689
- return logError(err, ctx, finalize, wrapResponse)
5690
- }
5691
- }
5692
-
5693
- const runHandler = <T>(
5694
- ctx: RawContext,
5695
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5696
- wrapResponse: (response: Response | ResponseResult) => T,
5697
- ): MaybePromise<T> => {
5698
- let result: unknown
5699
- try {
5700
- result = handler(ctx)
5701
- } catch (err) {
5702
- return logError(err, ctx, finalize, wrapResponse)
5703
- }
5704
- if (result instanceof Promise) {
5705
- return result.then(
5706
- (value) => runFinish(value, ctx, finalize, wrapResponse),
5707
- (err) => logError(err, ctx, finalize, wrapResponse),
5708
- )
5709
- }
5710
- return runFinish(result, ctx, finalize, wrapResponse)
5711
- }
5712
-
5713
- const runBefore = <T>(
5714
- ctx: RawContext,
5715
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5716
- wrapResponse: (response: Response | ResponseResult) => T,
5717
- ): MaybePromise<T> => {
5718
- let early: unknown
5719
- try {
5720
- early = before(ctx)
5721
- } catch (err) {
5722
- return logError(err, ctx, finalize, wrapResponse)
5723
- }
5724
- if (early instanceof Promise) {
5725
- return early.then(
5726
- (settled) =>
5727
- settled === undefined
5728
- ? runHandler(ctx, finalize, wrapResponse)
5729
- : finalizeEarly(settled, ctx, finalize, wrapResponse),
5730
- (err) => logError(err, ctx, finalize, wrapResponse),
5731
- )
5732
- }
5733
- if (early === undefined) return runHandler(ctx, finalize, wrapResponse)
5734
- return finalizeEarly(early, ctx, finalize, wrapResponse)
5735
- }
5736
-
5737
- const runDerive = <T>(
5738
- ctx: RawContext,
5739
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5740
- wrapResponse: (response: Response | ResponseResult) => T,
5741
- ): MaybePromise<T> => {
5742
- let derived: unknown
5743
- try {
5744
- derived = derive(ctx)
5745
- } catch (err) {
5746
- return logError(err, ctx, finalize, wrapResponse)
5747
- }
5748
- if (derived instanceof Promise) {
5749
- return derived.then(
5750
- (settled) => {
5751
- if (isResponseResult(settled) || settled instanceof Response) {
5752
- return finalizeEarly(settled, ctx, finalize, wrapResponse)
5753
- }
5754
- Object.assign(ctx, settled as object)
5755
- return runBefore(ctx, finalize, wrapResponse)
5756
- },
5757
- (err) => logError(err, ctx, finalize, wrapResponse),
5758
- )
5759
- }
5760
- if (isResponseResult(derived) || derived instanceof Response) {
5761
- return finalizeEarly(derived, ctx, finalize, wrapResponse)
5762
- }
5763
- Object.assign(ctx, derived as object)
5764
- return runBefore(ctx, finalize, wrapResponse)
5765
- }
5766
-
5767
- const runValidated = <T>(
5768
- validation: StandardResult<unknown> | Promise<StandardResult<unknown>>,
5769
- ctx: RawContext,
5770
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5771
- wrapResponse: (response: Response | ResponseResult) => T,
5772
- ): MaybePromise<T> => {
5773
- if (validation instanceof Promise) {
5774
- return validation.then(
5775
- (settled) => {
5776
- if (settled.issues !== undefined)
5777
- return wrapResponse(plainValidationError(settled.issues))
5778
- ctx.query = settled.value
5779
- return runDerive(ctx, finalize, wrapResponse)
5780
- },
5781
- (err) => logError(err, ctx, finalize, wrapResponse),
5782
- )
5837
+ return (source, params, search, signal, budget, platform, nativeContext) => {
5838
+ const ctx = nativeContext
5839
+ ? RequestContext.native(
5840
+ source,
5841
+ params,
5842
+ search,
5843
+ maxBodyBytes,
5844
+ platform,
5845
+ this.protoPoisoning,
5846
+ )
5847
+ : new RequestContext(
5848
+ source,
5849
+ params,
5850
+ search,
5851
+ signal,
5852
+ budget,
5853
+ platform,
5854
+ maxBodyBytes,
5855
+ this.protoPoisoning,
5856
+ )
5857
+ if (decorations !== undefined) Object.assign(ctx, decorations)
5858
+ return readBodyFramed(
5859
+ source,
5860
+ maxBodyBytes,
5861
+ this.protoPoisoning,
5862
+ (parsed) => onParsed(parsed, ctx),
5863
+ this.wrapWebResponse,
5864
+ (err) => this.fusedWebRouteFail(err, ctx, route),
5865
+ bodySchema,
5866
+ this.formOrigins,
5867
+ ) as MaybePromise<Response>
5783
5868
  }
5784
- if (validation.issues !== undefined)
5785
- return wrapResponse(plainValidationError(validation.issues))
5786
- ctx.query = validation.value
5787
- return runDerive(ctx, finalize, wrapResponse)
5788
5869
  }
5789
-
5790
- return <T>(
5791
- source: RequestSource,
5792
- params: Record<string, string>,
5793
- search: string | undefined,
5794
- signal: AbortSignal,
5795
- budget: RequestBudget,
5796
- platform: Platform | undefined,
5797
- nativeContext: boolean,
5798
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5799
- wrapResponse: (response: Response | ResponseResult) => T,
5800
- ): MaybePromise<T> => {
5870
+ return (source, params, search, signal, budget, platform, nativeContext) => {
5801
5871
  const ctx = nativeContext
5802
5872
  ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
5803
5873
  : new RequestContext(
@@ -5810,193 +5880,383 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5810
5880
  maxBodyBytes,
5811
5881
  this.protoPoisoning,
5812
5882
  )
5813
- if (querySchema === undefined) return runDerive(ctx, finalize, wrapResponse)
5814
- let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
5883
+ if (decorations !== undefined) Object.assign(ctx, decorations)
5884
+ if (querySchema === undefined) return runDerive(ctx, route, 0)
5885
+ let validation: MaybePromise<StandardResult<unknown>>
5815
5886
  try {
5816
5887
  validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
5817
5888
  } catch (err) {
5818
- return logError(err, ctx, finalize, wrapResponse)
5889
+ return this.fusedWebRouteFail(err, ctx, route)
5819
5890
  }
5820
- return runValidated(validation, ctx, finalize, wrapResponse)
5821
- }
5822
- }
5823
-
5824
- /**
5825
- * Generic fused runner for the body+derive-before(-after) lane: the Node-direct counterpart of
5826
- * `buildFusedBodyDeriveBeforeAfter`. Same contract as `buildFusedDeriveBeforeRunner` above - one
5827
- * frame running parse → validate → derive → before → handler → (after) through the caller's own
5828
- * `finalize` - with the body read ahead of validation, exactly as the generic program orders its
5829
- * body stage before the derive/before stages. `readBodyFramed` keeps the shared bounded-read,
5830
- * content-type, and prototype-poisoning contracts; the 415/400 renders take `wrapResponse`
5831
- * directly, which is what the generic lane's wrap-after-identity amounts to.
5832
- */
5833
- private buildFusedBodyDeriveBeforeAfterRunner(
5834
- handler: InternalHandler,
5835
- derive: RawDerive,
5836
- before: RawBeforeHandle,
5837
- after: RawAfterHandle | undefined,
5838
- bodySchema: StandardSchemaV1,
5839
- maxBodyBytes: number,
5840
- ): FusedBodyRunner {
5841
- const logError = <T>(
5842
- err: unknown,
5843
- ctx: RawContext,
5844
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5845
- wrapResponse: (response: Response | ResponseResult) => T,
5846
- ): T => this.bareError(err, ctx, finalize, wrapResponse)
5847
-
5848
- const runFinish = <T>(
5849
- result: unknown,
5850
- ctx: RawContext,
5851
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5852
- wrapResponse: (response: Response | ResponseResult) => T,
5853
- ): MaybePromise<T> => {
5854
- if (after === undefined) {
5855
- try {
5856
- return finalize(result, responseSet(ctx), ctx)
5857
- } catch (err) {
5858
- return logError(err, ctx, finalize, wrapResponse)
5859
- }
5860
- }
5861
- let transformed: unknown
5862
- try {
5863
- transformed = after(result, ctx)
5864
- } catch (err) {
5865
- return logError(err, ctx, finalize, wrapResponse)
5866
- }
5867
- if (transformed instanceof Promise) {
5868
- return transformed.then(
5869
- (value) => {
5870
- try {
5871
- return finalize(value, responseSet(ctx), ctx)
5872
- } catch (err) {
5873
- return logError(err, ctx, finalize, wrapResponse)
5874
- }
5891
+ if (validation instanceof Promise) {
5892
+ return validation.then(
5893
+ (settled) => {
5894
+ if (settled.issues !== undefined)
5895
+ return this.wrapWebResponse(plainValidationError(settled.issues))
5896
+ ctx.query = settled.value
5897
+ return runDerive(ctx, route, 0)
5875
5898
  },
5876
- (err) => logError(err, ctx, finalize, wrapResponse),
5899
+ (err) => this.fusedWebRouteFail(err, ctx, route),
5877
5900
  )
5878
5901
  }
5879
- try {
5880
- return finalize(transformed, responseSet(ctx), ctx)
5881
- } catch (err) {
5882
- return logError(err, ctx, finalize, wrapResponse)
5883
- }
5902
+ if (validation.issues !== undefined)
5903
+ return this.wrapWebResponse(plainValidationError(validation.issues))
5904
+ ctx.query = validation.value
5905
+ return runDerive(ctx, route, 0)
5884
5906
  }
5907
+ }
5885
5908
 
5886
- const finalizeEarly = <T>(
5909
+ /**
5910
+ * The Node-direct twin of {@link fusedWebStages}: the same chain, finalizing through the caller's
5911
+ * own `finalize`, shared by every route with the same hook shape. A terminal `finalize` throw (an
5912
+ * unstringifiable handler return on the Node lane) is caught as `finishLifecycleContract` catches it.
5913
+ */
5914
+ private fusedNodeStages(
5915
+ deriveCount: HookCount,
5916
+ beforeCount: HookCount,
5917
+ afterCount: HookCount,
5918
+ ): FusedNodeStart {
5919
+ const finishResult = <T>(
5887
5920
  result: unknown,
5888
5921
  ctx: RawContext,
5922
+ route: FusedHooks,
5889
5923
  finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5890
5924
  wrapResponse: (response: Response | ResponseResult) => T,
5891
- ): T => {
5925
+ ): MaybePromise<T> => {
5892
5926
  try {
5893
5927
  return finalize(result, responseSet(ctx), ctx)
5894
5928
  } catch (err) {
5895
- return logError(err, ctx, finalize, wrapResponse)
5929
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse)
5896
5930
  }
5897
5931
  }
5898
5932
 
5899
- const runHandler = <T>(
5933
+ // The stages read the route's hooks, as in the Web builder: a shape creates only the stages it uses,
5934
+ // a phase with one hook gets a straight-line closure, and a phase with several loops over the route's
5935
+ // array. The generic `T` of each stage is the caller's outcome type.
5936
+ type Stage = <T>(
5900
5937
  ctx: RawContext,
5901
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5902
- wrapResponse: (response: Response | ResponseResult) => T,
5903
- ): MaybePromise<T> => {
5938
+ route: FusedHooks,
5939
+ finalize: Finalize<T>,
5940
+ wrapResponse: Wrap<T>,
5941
+ index: number,
5942
+ ) => MaybePromise<T>
5943
+ type AfterStage = <T>(
5944
+ result: unknown,
5945
+ ctx: RawContext,
5946
+ route: FusedHooks,
5947
+ finalize: Finalize<T>,
5948
+ wrapResponse: Wrap<T>,
5949
+ index: number,
5950
+ ) => MaybePromise<T>
5951
+
5952
+ let runAfter: AfterStage
5953
+ if (afterCount === 0) {
5954
+ runAfter = (result, ctx, route, finalize, wrapResponse) =>
5955
+ finishResult(result, ctx, route, finalize, wrapResponse)
5956
+ } else if (afterCount === 1) {
5957
+ runAfter = (result, ctx, route, finalize, wrapResponse) => {
5958
+ let transformed: unknown
5959
+ try {
5960
+ transformed = route.afters[0]!(result, ctx)
5961
+ } catch (err) {
5962
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse)
5963
+ }
5964
+ if (transformed instanceof Promise) {
5965
+ return transformed.then(
5966
+ (value) => finishResult(value, ctx, route, finalize, wrapResponse),
5967
+ (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse),
5968
+ )
5969
+ }
5970
+ return finishResult(transformed, ctx, route, finalize, wrapResponse)
5971
+ }
5972
+ } else {
5973
+ runAfter = (result, ctx, route, finalize, wrapResponse, index) => {
5974
+ const lastAfter = route.afters.length - 1
5975
+ for (;;) {
5976
+ let transformed: unknown
5977
+ try {
5978
+ transformed = route.afters[index]!(result, ctx)
5979
+ } catch (err) {
5980
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse)
5981
+ }
5982
+ if (transformed instanceof Promise) {
5983
+ return transformed.then(
5984
+ (value) =>
5985
+ index < lastAfter
5986
+ ? runAfter(value, ctx, route, finalize, wrapResponse, index + 1)
5987
+ : finishResult(value, ctx, route, finalize, wrapResponse),
5988
+ (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse),
5989
+ )
5990
+ }
5991
+ if (index === lastAfter)
5992
+ return finishResult(transformed, ctx, route, finalize, wrapResponse)
5993
+ result = transformed
5994
+ index++
5995
+ }
5996
+ }
5997
+ }
5998
+
5999
+ const runHandler: Stage = (ctx, route, finalize, wrapResponse) => {
5904
6000
  let result: unknown
5905
6001
  try {
5906
- result = handler(ctx)
6002
+ result = route.handler(ctx)
5907
6003
  } catch (err) {
5908
- return logError(err, ctx, finalize, wrapResponse)
6004
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse)
5909
6005
  }
5910
6006
  if (result instanceof Promise) {
5911
6007
  return result.then(
5912
- (value) => runFinish(value, ctx, finalize, wrapResponse),
5913
- (err) => logError(err, ctx, finalize, wrapResponse),
6008
+ (value) => runAfter(value, ctx, route, finalize, wrapResponse, 0),
6009
+ (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse),
5914
6010
  )
5915
6011
  }
5916
- return runFinish(result, ctx, finalize, wrapResponse)
6012
+ return runAfter(result, ctx, route, finalize, wrapResponse, 0)
5917
6013
  }
5918
6014
 
5919
- const runBefore = <T>(
5920
- ctx: RawContext,
5921
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5922
- wrapResponse: (response: Response | ResponseResult) => T,
5923
- ): MaybePromise<T> => {
5924
- let early: unknown
5925
- try {
5926
- early = before(ctx)
5927
- } catch (err) {
5928
- return logError(err, ctx, finalize, wrapResponse)
6015
+ let runBefore: Stage
6016
+ if (beforeCount === 0) {
6017
+ runBefore = runHandler
6018
+ } else if (beforeCount === 1) {
6019
+ runBefore = (ctx, route, finalize, wrapResponse) => {
6020
+ let early: unknown
6021
+ try {
6022
+ early = route.befores[0]!(ctx)
6023
+ } catch (err) {
6024
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse)
6025
+ }
6026
+ if (early instanceof Promise) {
6027
+ return early.then(
6028
+ (settled) =>
6029
+ settled === undefined
6030
+ ? runHandler(ctx, route, finalize, wrapResponse, 0)
6031
+ : finishResult(settled, ctx, route, finalize, wrapResponse),
6032
+ (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse),
6033
+ )
6034
+ }
6035
+ if (early === undefined) return runHandler(ctx, route, finalize, wrapResponse, 0)
6036
+ return finishResult(early, ctx, route, finalize, wrapResponse)
5929
6037
  }
5930
- if (early instanceof Promise) {
5931
- return early.then(
5932
- (settled) =>
5933
- settled === undefined
5934
- ? runHandler(ctx, finalize, wrapResponse)
5935
- : finalizeEarly(settled, ctx, finalize, wrapResponse),
5936
- (err) => logError(err, ctx, finalize, wrapResponse),
5937
- )
6038
+ } else {
6039
+ runBefore = (ctx, route, finalize, wrapResponse, index) => {
6040
+ const lastBefore = route.befores.length - 1
6041
+ for (;;) {
6042
+ let early: unknown
6043
+ try {
6044
+ early = route.befores[index]!(ctx)
6045
+ } catch (err) {
6046
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse)
6047
+ }
6048
+ if (early instanceof Promise) {
6049
+ return early.then(
6050
+ (settled) => {
6051
+ if (settled !== undefined)
6052
+ return finishResult(settled, ctx, route, finalize, wrapResponse)
6053
+ return index < lastBefore
6054
+ ? runBefore(ctx, route, finalize, wrapResponse, index + 1)
6055
+ : runHandler(ctx, route, finalize, wrapResponse, 0)
6056
+ },
6057
+ (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse),
6058
+ )
6059
+ }
6060
+ if (early !== undefined) return finishResult(early, ctx, route, finalize, wrapResponse)
6061
+ if (index === lastBefore) return runHandler(ctx, route, finalize, wrapResponse, 0)
6062
+ index++
6063
+ }
5938
6064
  }
5939
- if (early === undefined) return runHandler(ctx, finalize, wrapResponse)
5940
- return finalizeEarly(early, ctx, finalize, wrapResponse)
5941
6065
  }
5942
6066
 
5943
- const runDerive = <T>(
5944
- ctx: RawContext,
5945
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5946
- wrapResponse: (response: Response | ResponseResult) => T,
5947
- ): MaybePromise<T> => {
5948
- let derived: unknown
5949
- try {
5950
- derived = derive(ctx)
5951
- } catch (err) {
5952
- return logError(err, ctx, finalize, wrapResponse)
6067
+ let runDerive: Stage
6068
+ if (deriveCount === 0) {
6069
+ runDerive = runBefore
6070
+ } else if (deriveCount === 1) {
6071
+ runDerive = (ctx, route, finalize, wrapResponse) => {
6072
+ let derived: unknown
6073
+ try {
6074
+ derived = route.derives[0]!(ctx)
6075
+ } catch (err) {
6076
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse)
6077
+ }
6078
+ if (derived instanceof Promise) {
6079
+ return derived.then(
6080
+ (settled) => {
6081
+ if (isResponseResult(settled) || settled instanceof Response) {
6082
+ return finishResult(settled, ctx, route, finalize, wrapResponse)
6083
+ }
6084
+ try {
6085
+ Object.assign(ctx, settled)
6086
+ } catch (err) {
6087
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse)
6088
+ }
6089
+ return runBefore(ctx, route, finalize, wrapResponse, 0)
6090
+ },
6091
+ (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse),
6092
+ )
6093
+ }
6094
+ if (isResponseResult(derived) || derived instanceof Response) {
6095
+ return finishResult(derived, ctx, route, finalize, wrapResponse)
6096
+ }
6097
+ try {
6098
+ Object.assign(ctx, derived)
6099
+ } catch (err) {
6100
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse)
6101
+ }
6102
+ return runBefore(ctx, route, finalize, wrapResponse, 0)
5953
6103
  }
5954
- if (derived instanceof Promise) {
5955
- return derived.then(
5956
- (settled) => {
5957
- if (isResponseResult(settled) || settled instanceof Response) {
5958
- return finalizeEarly(settled, ctx, finalize, wrapResponse)
5959
- }
5960
- Object.assign(ctx, settled as object)
5961
- return runBefore(ctx, finalize, wrapResponse)
5962
- },
5963
- (err) => logError(err, ctx, finalize, wrapResponse),
5964
- )
6104
+ } else {
6105
+ runDerive = (ctx, route, finalize, wrapResponse, index) => {
6106
+ const lastDerive = route.derives.length - 1
6107
+ for (;;) {
6108
+ let derived: unknown
6109
+ try {
6110
+ derived = route.derives[index]!(ctx)
6111
+ } catch (err) {
6112
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse)
6113
+ }
6114
+ if (derived instanceof Promise) {
6115
+ return derived.then(
6116
+ (settled) => {
6117
+ if (isResponseResult(settled) || settled instanceof Response) {
6118
+ return finishResult(settled, ctx, route, finalize, wrapResponse)
6119
+ }
6120
+ try {
6121
+ Object.assign(ctx, settled)
6122
+ } catch (err) {
6123
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse)
6124
+ }
6125
+ return index < lastDerive
6126
+ ? runDerive(ctx, route, finalize, wrapResponse, index + 1)
6127
+ : runBefore(ctx, route, finalize, wrapResponse, 0)
6128
+ },
6129
+ (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse),
6130
+ )
6131
+ }
6132
+ if (isResponseResult(derived) || derived instanceof Response) {
6133
+ return finishResult(derived, ctx, route, finalize, wrapResponse)
6134
+ }
6135
+ try {
6136
+ Object.assign(ctx, derived)
6137
+ } catch (err) {
6138
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse)
6139
+ }
6140
+ if (index === lastDerive) return runBefore(ctx, route, finalize, wrapResponse, 0)
6141
+ index++
6142
+ }
6143
+ }
6144
+ }
6145
+ return runDerive
6146
+ }
6147
+
6148
+ /**
6149
+ * The Node-direct twin of {@link buildFusedLifecycleWeb}: the same steps, finalizing through the
6150
+ * caller's own `finalize` (the Node outcome on the direct lane) instead of building a `Response`, so
6151
+ * the `!webFast` dispatcher runs these routes in one frame rather than the generic program. A
6152
+ * terminal `finalize` throw (an unstringifiable handler return on the Node lane) is caught as
6153
+ * `finishLifecycleContract` catches it; the body's 415/400 renders take `wrapResponse` directly.
6154
+ */
6155
+ private buildFusedLifecycleRunner(
6156
+ route: FusedHooks,
6157
+ querySchema: StandardSchemaV1 | undefined,
6158
+ bodySchema: StandardSchemaV1 | undefined,
6159
+ maxBodyBytes: number,
6160
+ ): FusedBodyRunner {
6161
+ const runDerive = sharedStages(NODE_STAGES, this, route, (d, b, a) =>
6162
+ this.fusedNodeStages(d, b, a),
6163
+ )
6164
+ const decorations = route.decorations
6165
+
6166
+ if (bodySchema !== undefined) {
6167
+ const onParsed = <T>(
6168
+ parsed: unknown,
6169
+ ctx: RawContext,
6170
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
6171
+ wrapResponse: (response: Response | ResponseResult) => T,
6172
+ ): MaybePromise<T> => {
6173
+ let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
6174
+ try {
6175
+ validation = bodySchema["~standard"].validate(parsed)
6176
+ } catch (err) {
6177
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse)
6178
+ }
6179
+ if (validation instanceof Promise) {
6180
+ return validation.then(
6181
+ (settled) => {
6182
+ if (settled.issues !== undefined)
6183
+ return wrapResponse(plainValidationError(settled.issues))
6184
+ ctx.body = settled.value
6185
+ return runDerive(ctx, route, finalize, wrapResponse, 0)
6186
+ },
6187
+ (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse),
6188
+ )
6189
+ }
6190
+ if (validation.issues !== undefined)
6191
+ return wrapResponse(plainValidationError(validation.issues))
6192
+ ctx.body = validation.value
6193
+ return runDerive(ctx, route, finalize, wrapResponse, 0)
5965
6194
  }
5966
- if (isResponseResult(derived) || derived instanceof Response) {
5967
- return finalizeEarly(derived, ctx, finalize, wrapResponse)
6195
+ return <T>(
6196
+ source: RequestSource,
6197
+ params: Record<string, string>,
6198
+ search: string | undefined,
6199
+ signal: AbortSignal,
6200
+ budget: RequestBudget,
6201
+ platform: Platform | undefined,
6202
+ nativeContext: boolean,
6203
+ finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
6204
+ wrapResponse: (response: Response | ResponseResult) => T,
6205
+ ): MaybePromise<T> => {
6206
+ const ctx = nativeContext
6207
+ ? RequestContext.native(
6208
+ source,
6209
+ params,
6210
+ search,
6211
+ maxBodyBytes,
6212
+ platform,
6213
+ this.protoPoisoning,
6214
+ )
6215
+ : new RequestContext(
6216
+ source,
6217
+ params,
6218
+ search,
6219
+ signal,
6220
+ budget,
6221
+ platform,
6222
+ maxBodyBytes,
6223
+ this.protoPoisoning,
6224
+ )
6225
+ if (decorations !== undefined) Object.assign(ctx, decorations)
6226
+ return readBodyFramed(
6227
+ source,
6228
+ maxBodyBytes,
6229
+ this.protoPoisoning,
6230
+ (parsed) => onParsed(parsed, ctx, finalize, wrapResponse),
6231
+ wrapResponse,
6232
+ (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse),
6233
+ bodySchema,
6234
+ this.formOrigins,
6235
+ )
5968
6236
  }
5969
- Object.assign(ctx, derived as object)
5970
- return runBefore(ctx, finalize, wrapResponse)
5971
6237
  }
5972
6238
 
5973
- const onParsed = <T>(
5974
- parsed: unknown,
6239
+ const runValidated = <T>(
6240
+ validation: StandardResult<unknown> | Promise<StandardResult<unknown>>,
5975
6241
  ctx: RawContext,
5976
6242
  finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5977
6243
  wrapResponse: (response: Response | ResponseResult) => T,
5978
6244
  ): MaybePromise<T> => {
5979
- let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
5980
- try {
5981
- validation = bodySchema["~standard"].validate(parsed)
5982
- } catch (err) {
5983
- return logError(err, ctx, finalize, wrapResponse)
5984
- }
5985
6245
  if (validation instanceof Promise) {
5986
6246
  return validation.then(
5987
6247
  (settled) => {
5988
6248
  if (settled.issues !== undefined)
5989
6249
  return wrapResponse(plainValidationError(settled.issues))
5990
- ctx.body = settled.value
5991
- return runDerive(ctx, finalize, wrapResponse)
6250
+ ctx.query = settled.value
6251
+ return runDerive(ctx, route, finalize, wrapResponse, 0)
5992
6252
  },
5993
- (err) => logError(err, ctx, finalize, wrapResponse),
6253
+ (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse),
5994
6254
  )
5995
6255
  }
5996
6256
  if (validation.issues !== undefined)
5997
6257
  return wrapResponse(plainValidationError(validation.issues))
5998
- ctx.body = validation.value
5999
- return runDerive(ctx, finalize, wrapResponse)
6258
+ ctx.query = validation.value
6259
+ return runDerive(ctx, route, finalize, wrapResponse, 0)
6000
6260
  }
6001
6261
 
6002
6262
  return <T>(
@@ -6022,16 +6282,73 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6022
6282
  maxBodyBytes,
6023
6283
  this.protoPoisoning,
6024
6284
  )
6025
- return readBodyFramed(
6026
- source,
6027
- maxBodyBytes,
6028
- this.protoPoisoning,
6029
- (parsed) => onParsed(parsed, ctx, finalize, wrapResponse),
6030
- wrapResponse,
6031
- (err) => logError(err, ctx, finalize, wrapResponse),
6032
- bodySchema,
6033
- this.formOrigins,
6034
- )
6285
+ if (decorations !== undefined) Object.assign(ctx, decorations)
6286
+ if (querySchema === undefined) return runDerive(ctx, route, finalize, wrapResponse, 0)
6287
+ let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
6288
+ try {
6289
+ validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
6290
+ } catch (err) {
6291
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse)
6292
+ }
6293
+ return runValidated(validation, ctx, finalize, wrapResponse)
6294
+ }
6295
+ }
6296
+
6297
+ /** The thrown-value render of the Web fused lifecycle lanes for a route with no error hooks: a
6298
+ * `Response` is control flow, anything else is logged and answered with a plain 500. */
6299
+ private fusedWebFail(err: unknown, ctx: RawContext): Response {
6300
+ return this.bareError(err, ctx, this.finalizeResponse, this.wrapWebResponse)
6301
+ }
6302
+
6303
+ /** {@link fusedWebFail} for a fused route that may have error hooks. */
6304
+ private fusedWebRouteFail(
6305
+ err: unknown,
6306
+ ctx: RawContext,
6307
+ route: FusedHooks,
6308
+ ): MaybePromise<Response> {
6309
+ return this.fusedFail(err, ctx, route, this.finalizeResponse, this.wrapWebResponse)
6310
+ }
6311
+
6312
+ /** The thrown-value render of a fused lifecycle route. A `Response` or `status(...)` is control flow
6313
+ * that no error hook sees, unless it cannot render, which is a fault like any other. Anything else
6314
+ * goes to the route's error hooks; with none it is logged and answered with a plain 500. */
6315
+ private fusedFail<T>(
6316
+ err: unknown,
6317
+ ctx: RawContext,
6318
+ route: FusedHooks,
6319
+ finalize: Finalize<T>,
6320
+ wrapResponse: Wrap<T>,
6321
+ ): MaybePromise<T> {
6322
+ if (route.errors.length === 0) return this.bareError(err, ctx, finalize, wrapResponse)
6323
+ let fault = err
6324
+ if (err instanceof Response || isResponseResult(err)) {
6325
+ try {
6326
+ return finalize(err, responseSet(ctx), ctx)
6327
+ } catch (renderError) {
6328
+ fault = renderError
6329
+ }
6330
+ }
6331
+ return this.runErrorHooks(
6332
+ route.errors,
6333
+ undefined,
6334
+ fault,
6335
+ ctx,
6336
+ (result, set) => finalize(result, set, ctx),
6337
+ wrapResponse,
6338
+ )
6339
+ }
6340
+
6341
+ /** {@link respondFused} for a fused lifecycle route, whose failures reach its error hooks. */
6342
+ private respondHooks(
6343
+ result: unknown,
6344
+ ctx: RawContext,
6345
+ route: FusedHooks,
6346
+ unstamped?: boolean,
6347
+ ): MaybePromise<Response> {
6348
+ try {
6349
+ return fusedRespond(result, ctx, this.responseBodyTag, this.staticResponseHeaders, unstamped)
6350
+ } catch (err) {
6351
+ return this.fusedWebRouteFail(err, ctx, route)
6035
6352
  }
6036
6353
  }
6037
6354
 
@@ -6818,17 +7135,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6818
7135
  * semantics remain in the plan; this closure only supplies native params and deadline fallback. */
6819
7136
  private compileBunNativeHandler(
6820
7137
  entry: RouteEntry,
7138
+ method: string,
6821
7139
  paramNames: readonly string[],
6822
7140
  fused: FusedWebRunner | undefined,
6823
7141
  signal: AbortSignal | undefined,
6824
7142
  budget: RequestBudget | undefined,
6825
7143
  peer: PeerPlatform,
6826
7144
  fallback: BunNativeHandler,
7145
+ hookedPlatform?: (request: Request, server: BunPeerServer) => Platform<EnvOf<Ctx>>,
6827
7146
  ): BunNativeHandler {
6828
7147
  // This callback is reached only from Bun's compiled native route table. Bun has already parsed
6829
7148
  // the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
6830
- // the portable/adapted source contract (those sources are never marked).
6831
- const markFramed = (request: Request): void => markTrustedBodyFraming(request)
7149
+ // the portable/adapted source contract (those sources are never marked). Bun hands a GET or
7150
+ // HEAD slot no body, so it skips the symbol write on every request.
7151
+ const markFramed =
7152
+ method === "GET" || method === "HEAD" ? (): void => undefined : markTrustedBodyFraming
6832
7153
  // The direct native lanes bypass `runMatched`, so the route's transport byte cap must be
6833
7154
  // marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
6834
7155
  // reads uncapped. The fallback branches go through `fetchMatched` -> `runMatched`, which marks.
@@ -6857,8 +7178,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6857
7178
  this.wrapWebResponse,
6858
7179
  )
6859
7180
  : undefined)
7181
+ // The cap is never marked on a GET or HEAD, the only methods a GET or HEAD slot is handed, so those
7182
+ // slots skip the wrapper and its read of the method on every request.
6860
7183
  const capped: FusedWebRunner | undefined =
6861
- inner === undefined || bodyLimit === undefined
7184
+ inner === undefined || bodyLimit === undefined || method === "GET" || method === "HEAD"
6862
7185
  ? inner
6863
7186
  : (source, params, search, fusedSignal, fusedBudget, platform, nativeContext) => {
6864
7187
  const method = source.method
@@ -6895,6 +7218,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6895
7218
  : finishNative(request, outcome)
6896
7219
  }
6897
7220
  if (paramNames.length === 0) {
7221
+ if (hookedPlatform !== undefined) {
7222
+ return (request, server) => {
7223
+ markFramed(request)
7224
+ return this.fetchMatchedHooked(
7225
+ request,
7226
+ entry,
7227
+ EMPTY_PARAMS,
7228
+ hookedPlatform(request, server),
7229
+ )
7230
+ }
7231
+ }
6898
7232
  if (capped === undefined) {
6899
7233
  return (request) => {
6900
7234
  markFramed(request)
@@ -6919,6 +7253,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6919
7253
  paramNames.length === 1
6920
7254
  ? (params: Record<string, string>) => unroutedParam(params[paramNames[0]!])
6921
7255
  : hasUnroutedParam
7256
+ if (hookedPlatform !== undefined) {
7257
+ return (request, server) => {
7258
+ markFramed(request)
7259
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: Bun's route table hands each handler a Request carrying the `params` it matched.
7260
+ const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
7261
+ if (malformed(params)) return fallback(request, server)
7262
+ return this.fetchMatchedHooked(request, entry, params, hookedPlatform(request, server))
7263
+ }
7264
+ }
6922
7265
  if (capped === undefined) {
6923
7266
  return (request, server) => {
6924
7267
  markFramed(request)
@@ -6945,9 +7288,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6945
7288
  }
6946
7289
  }
6947
7290
 
6948
- /** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
6949
- * hooks or WebSockets retain the single portable dispatcher because those features must run before
6950
- * route selection/upgrade.
7291
+ /** Compile portable route registrations into Bun's native route table. Paths a composed mount or
7292
+ * a WebSocket handshake may reach stay with the portable dispatcher; provably disjoint HTTP routes
7293
+ * keep native matching. Request hooks run before the matched route and rewrites return to dispatch.
6951
7294
  *
6952
7295
  * Bun and the portable router agree on which of two static-and-`:param` paths wins a request, so
6953
7296
  * the table is kept to those paths. Where Bun would otherwise pass a request on to a less specific
@@ -6956,21 +7299,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6956
7299
  private buildBunNativeRoutes(
6957
7300
  fallback: BunNativeHandler,
6958
7301
  peer: PeerPlatform,
7302
+ platformFor?: (request: Request, server: BunPeerServer) => Platform<EnvOf<Ctx>>,
6959
7303
  ): BunNativeRoutes | undefined {
6960
- // A `clientIp` trust declaration must run the resolver in `dispatch`, which the fused native lane
6961
- // bypasses - so an app that declares trust routes through the fetch lane (where `c.clientIp`
6962
- // resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
7304
+ const hooked = this.onRequestHooks.length > 0 && !this.bunNativeRequestHooksSafe
6963
7305
  if (
6964
- (this.onRequestHooks.length > 0 && !this.bunNativeRequestHooksSafe) ||
6965
- this.wsRoutes.length > 0 ||
6966
- this.preRouteMountCount > 0 ||
6967
- this.clientIpTrust !== undefined
7306
+ (hooked && platformFor === undefined) ||
7307
+ // A hook may rewrite a handshake onto a socket path, which only the dispatcher's upgrade check follows.
7308
+ (hooked && this.wsRoutes.length > 0) ||
7309
+ this.mountsTakeUpgrades()
6968
7310
  ) {
6969
7311
  return undefined
6970
7312
  }
6971
7313
 
6972
7314
  const routes: BunNativeRoutes = Object.create(null) as BunNativeRoutes
6973
7315
  const mayUseFusedNative =
7316
+ !hooked &&
6974
7317
  this.requestTimeoutMs === 0 &&
6975
7318
  (this.onResponseHooks.length === 0 || this.bunNativeResponseHeadersOnly) &&
6976
7319
  this.onResponseFinalizedHooks.length === 0 &&
@@ -6981,29 +7324,50 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6981
7324
  const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined
6982
7325
  let count = 0
6983
7326
  const all = this.catalog.entries()
7327
+ const preRouteMounts = this.fetchMounts.filter((mount) => mount.beforeRoutes)
6984
7328
  // A path with a wildcard or a part-literal segment cannot go in the table: Bun reads `/:name.json`
6985
7329
  // as one parameter named `name.json`. These are filed under each run of static segments they
6986
7330
  // start with, which is where a table path they could outrank looks for them.
6987
- const under = new Map<string, CatalogRoute[]>()
7331
+ const under = new Map<string, CatalogRoute["pattern"]["segments"][]>()
7332
+ const fileUnder = (segments: CatalogRoute["pattern"]["segments"]): void => {
7333
+ let prefix = ""
7334
+ for (const segment of segments) {
7335
+ let bucket = under.get(prefix)
7336
+ if (bucket === undefined) {
7337
+ bucket = []
7338
+ under.set(prefix, bucket)
7339
+ }
7340
+ bucket.push(segments)
7341
+ if (segment.kind !== "static") break
7342
+ prefix += `/${segment.value}`
7343
+ }
7344
+ }
7345
+ // A handshake is a GET the portable dispatcher gives to a socket route before any HTTP route, so a
7346
+ // socket path is outside the table like a wildcard path, and a GET route a request may share with
7347
+ // one stays with the dispatcher.
7348
+ const sockets = this.wsRoutes.map((route) => compileRoutePattern(route.path).segments)
7349
+ for (const segments of sockets) fileUnder(segments)
6988
7350
  const lead = all.map((route) => {
6989
7351
  const segments = route.pattern.segments
6990
7352
  const plain = segments.every(
6991
7353
  (segment) => segment.kind === "static" || segment.kind === "param",
6992
7354
  )
7355
+ if (!plain) fileUnder(segments)
6993
7356
  let prefix = ""
6994
7357
  for (const segment of segments) {
6995
- if (!plain) {
6996
- let bucket = under.get(prefix)
6997
- if (bucket === undefined) {
6998
- bucket = []
6999
- under.set(prefix, bucket)
7000
- }
7001
- bucket.push(route)
7002
- }
7003
7358
  if (segment.kind !== "static") break
7004
7359
  prefix += `/${segment.value}`
7005
7360
  }
7006
- return plain ? prefix : undefined
7361
+ // Mounts take every method under their literal prefix before route matching. A parameter may
7362
+ // fill the rest of that prefix, but a static route ending above the mount cannot reach it.
7363
+ // Conservative by design: only the leading static run proves a dynamic route disjoint.
7364
+ const mayHitMount = preRouteMounts.some(
7365
+ (mount) =>
7366
+ underMountPrefix(prefix, mount.path) ||
7367
+ (route.pattern.paramNames.length > 0 &&
7368
+ (prefix === "" || underMountPrefix(mount.path, prefix))),
7369
+ )
7370
+ return plain && !mayHitMount ? prefix : undefined
7007
7371
  })
7008
7372
  // A preflight hook is intentionally handled by the fallback fetch path. Keeping OPTIONS out
7009
7373
  // of the native lane means Bun dispatches it through CORS's onRequest hook even when the
@@ -7033,7 +7397,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7033
7397
  entry.idempotent !== undefined ||
7034
7398
  !BUN_TABLE_METHODS.includes(method as Method) ||
7035
7399
  (preflightOnFallback && method === "OPTIONS") ||
7036
- under.get(run)?.some((other) => outranks(other.pattern.segments, pattern.segments))
7400
+ under.get(run)?.some((other) => outranks(other, pattern.segments)) ||
7401
+ (method === "GET" && sockets.some((socket) => mayShareRequest(socket, pattern.segments)))
7037
7402
  ) {
7038
7403
  continue
7039
7404
  }
@@ -7041,16 +7406,24 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7041
7406
  const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined
7042
7407
  const handler = this.compileBunNativeHandler(
7043
7408
  entry,
7409
+ method,
7044
7410
  paramNames,
7045
7411
  fused,
7046
7412
  unboundedSignal,
7047
7413
  unboundedBudget,
7048
7414
  peer,
7049
7415
  fallback,
7416
+ hooked ? platformFor : undefined,
7050
7417
  )
7051
- // The table matched the target as sent; one `fetch` refuses or rewrites goes through it.
7052
- tableAt(path, pattern.segments)[method as Method] = (request, server) =>
7053
- requestTarget(request) ? fallback(request, server) : handler(request, server)
7418
+ // The table matched the target as sent; one `fetch` refuses or rewrites goes through it. Left
7419
+ // off, `validateHost` costs nothing here: the table holds no wildcard, a `..` parameter falls
7420
+ // back, and reading the Host on every request measured about 6% of a bare route.
7421
+ tableAt(path, pattern.segments)[method as Method] = this.validateHost
7422
+ ? (request, server) =>
7423
+ requestTarget(request, true, this.normalizeTarget)
7424
+ ? fallback(request, server)
7425
+ : handler(request, server)
7426
+ : handler
7054
7427
  count += 1
7055
7428
  if (paramNames.length > 0) {
7056
7429
  below.add(pattern.segments.length + method)
@@ -7101,11 +7474,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7101
7474
  * nothing - so any endpoint slower than 10s (a render, an export, a long upstream call) has its
7102
7475
  * connection cut mid-flight regardless of `requestTimeoutMs`. Apps with such endpoints must raise
7103
7476
  * this above their slowest expected response, which is why it is a first-class option and not a
7104
- * reason to drop down to `Bun.serve`. `0` disables the timeout entirely; max 255.
7477
+ * reason to drop down to `Bun.serve`, or hold those requests' connections alone with
7478
+ * `withIdleTimeout()` from `@nifrajs/core/idle-timeout`. `0` disables the timeout entirely; max 255.
7105
7479
  *
7106
7480
  * `tls` serves HTTPS directly: `app.listen(443, { tls: { cert, key } })` with PEM text or bytes.
7107
7481
  * Requests then arrive with `https:` URLs. Behind a proxy or a platform that terminates TLS, leave
7108
7482
  * it unset.
7483
+ *
7484
+ * `static` sees every request before the app does, the ones Bun's route table serves included, so
7485
+ * a file it answers wins over any route at the same path. It returns the file's response, or
7486
+ * `undefined` to let the app route the request. No hook of the app runs for a request it answers.
7487
+ * `@nifrajs/web`'s Bun build serves its client bundle and public files this way.
7109
7488
  */
7110
7489
  listen(
7111
7490
  port: number,
@@ -7114,6 +7493,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7114
7493
  readonly hostname?: string
7115
7494
  readonly idleTimeoutSec?: number
7116
7495
  readonly tls?: ListenTlsOptions
7496
+ readonly static?: (request: Request) => MaybePromise<Response | undefined>
7117
7497
  },
7118
7498
  ): RunningServer {
7119
7499
  if (typeof Bun === "undefined") {
@@ -7177,6 +7557,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7177
7557
  return jsonError(500, "internal_error")
7178
7558
  },
7179
7559
  }
7560
+ const idleTimeoutSec = options?.idleTimeoutSec ?? BUN_IDLE_TIMEOUT_SEC
7561
+ const setIdleTimeout: IdleTimeoutSetter = (request, seconds) =>
7562
+ // biome-ignore lint/plugin/noChainedTypeAssertions lint/plugin/requireSafetyCommentForTypeAssertion: `running` is the Bun `Server` this call started (`RunningServer` publishes a subset of it), and every source a Bun listener routes is a `Request`.
7563
+ (running as unknown as BunPeerServer).timeout(request as Request, seconds ?? idleTimeoutSec)
7180
7564
  const fallback: BunNativeHandler = (req, server) => {
7181
7565
  // Bun has already framed and bounded this request body in its HTTP parser. Mark the
7182
7566
  // source before the portable fallback runs so body schemas use Bun's native `json()`
@@ -7184,27 +7568,73 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7184
7568
  // Requests. Native route handlers mark themselves in compileBunNativeHandler; this
7185
7569
  // covers every request the native lane does not serve.
7186
7570
  markTrustedBodyFraming(req)
7187
- return this.fetch(req, bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>)
7571
+ return this.fetch(
7572
+ req,
7573
+ new BunPeerPlatform(server, req, setIdleTimeout) as Platform<EnvOf<Ctx>>,
7574
+ )
7188
7575
  }
7189
7576
  // Bun names the socket peer of a request when its server is asked. A request served from the
7190
7577
  // native table carries this one platform, which asks only when a handler reads `c.clientIp`.
7578
+ const socketPeer = (request: RequestSource): string | undefined =>
7579
+ (running as unknown as BunPeerServer).requestIP(request as Request)?.address
7580
+ // A `clientIp` trust declaration resolves here too, from the request's headers, when a handler reads
7581
+ // the address; only a declaration of zero hops names the socket peer.
7582
+ const trust = this.clientIpTrust
7191
7583
  const peer: PeerPlatform = {
7192
- [PLATFORM_PEER]: (request) =>
7193
- (running as unknown as BunPeerServer).requestIP(request as Request)?.address,
7584
+ [PLATFORM_PEER]:
7585
+ trust === undefined
7586
+ ? socketPeer
7587
+ : (request) =>
7588
+ resolveClientIp(
7589
+ !("header" in trust) && trust.trustedHops === 0 ? socketPeer(request) : undefined,
7590
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: every source a Bun listener routes is a `Request`.
7591
+ request as Request,
7592
+ trust,
7593
+ ),
7594
+ [PLATFORM_IDLE_TIMEOUT]: setIdleTimeout,
7595
+ }
7596
+ const nativeRoutes = this.buildBunNativeRoutes(
7597
+ fallback,
7598
+ peer,
7599
+ (request, server) =>
7600
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: `fallback` hands `fetch` the same platform, which holds no environment.
7601
+ new BunPeerPlatform(server, request, setIdleTimeout) as Platform<EnvOf<Ctx>>,
7602
+ )
7603
+ const first = options?.static
7604
+ const before = <S, A>(
7605
+ inner: (req: Request, server: S) => MaybePromise<A>,
7606
+ ): ((req: Request, server: S) => MaybePromise<A | Response>) =>
7607
+ first === undefined
7608
+ ? inner
7609
+ : (req, server) => {
7610
+ const answer = first(req)
7611
+ if (answer === undefined) return inner(req, server)
7612
+ return answer instanceof Promise
7613
+ ? answer.then((response) => response ?? inner(req, server))
7614
+ : answer
7615
+ }
7616
+ // Bun runs a table handler without `fetch`, so each one gets the same front as the fallback.
7617
+ if (first !== undefined && nativeRoutes !== undefined) {
7618
+ for (const path in nativeRoutes) {
7619
+ const methods = nativeRoutes[path]!
7620
+ for (const method of BUN_TABLE_METHODS) {
7621
+ const handler = methods[method]
7622
+ if (handler !== undefined) methods[method] = before(handler)
7623
+ }
7624
+ }
7194
7625
  }
7195
- const nativeRoutes =
7196
- wsHandlers === undefined ? this.buildBunNativeRoutes(fallback, peer) : undefined
7197
7626
  const sockets = new Set<{ close(code?: number): void }>()
7198
7627
  this.bunSockets = sockets
7199
7628
  const running = (wsHandlers === undefined
7200
7629
  ? Bun.serve({
7201
7630
  ...listening,
7202
7631
  ...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
7203
- fetch: fallback,
7632
+ fetch: before(fallback),
7204
7633
  })
7205
7634
  : Bun.serve<BunWsData>({
7206
7635
  ...listening,
7207
- fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
7636
+ ...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
7637
+ fetch: before((req, server) => this.bunFetchWithWebSocket(req, server, setIdleTimeout)),
7208
7638
  // Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
7209
7639
  // `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
7210
7640
  // `unknown` params bridge a TS structural-variance quirk. Round-trip covered by websocket.test.ts.