@nifrajs/core 5.8.0 → 5.10.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 (239) hide show
  1. package/CHANGELOG.md +118 -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/router/path-codec.d.ts +22 -0
  80. package/dist/router/path-codec.d.ts.map +1 -0
  81. package/dist/router/path-codec.js +87 -0
  82. package/dist/router/path-codec.js.map +1 -0
  83. package/dist/router/pattern.d.ts +21 -1
  84. package/dist/router/pattern.d.ts.map +1 -1
  85. package/dist/router/pattern.js +117 -0
  86. package/dist/router/pattern.js.map +1 -1
  87. package/dist/router/same-origin-path.d.ts +22 -0
  88. package/dist/router/same-origin-path.d.ts.map +1 -0
  89. package/dist/router/same-origin-path.js +37 -0
  90. package/dist/router/same-origin-path.js.map +1 -0
  91. package/dist/serve-static.d.ts +22 -0
  92. package/dist/serve-static.d.ts.map +1 -0
  93. package/dist/serve-static.js +187 -0
  94. package/dist/serve-static.js.map +1 -0
  95. package/dist/server/bare-error-lane.d.ts +2 -1
  96. package/dist/server/bare-error-lane.d.ts.map +1 -1
  97. package/dist/server/bare-error-lane.js +12 -3
  98. package/dist/server/bare-error-lane.js.map +1 -1
  99. package/dist/server/base64-answers.d.ts.map +1 -1
  100. package/dist/server/base64-answers.js +1 -4
  101. package/dist/server/base64-answers.js.map +1 -1
  102. package/dist/server/body-lane.d.ts.map +1 -1
  103. package/dist/server/body-lane.js +43 -38
  104. package/dist/server/body-lane.js.map +1 -1
  105. package/dist/server/body.d.ts.map +1 -1
  106. package/dist/server/body.js +4 -1
  107. package/dist/server/body.js.map +1 -1
  108. package/dist/server/bridge-markers.d.ts +1 -1
  109. package/dist/server/bridge-markers.js +1 -1
  110. package/dist/server/cookies.d.ts.map +1 -1
  111. package/dist/server/cookies.js +4 -1
  112. package/dist/server/cookies.js.map +1 -1
  113. package/dist/server/core-copies.js +1 -1
  114. package/dist/server/core-copies.js.map +1 -1
  115. package/dist/server/http.d.ts +6 -18
  116. package/dist/server/http.d.ts.map +1 -1
  117. package/dist/server/http.js +14 -33
  118. package/dist/server/http.js.map +1 -1
  119. package/dist/server/idempotency-lane.d.ts.map +1 -1
  120. package/dist/server/idempotency-lane.js +2 -0
  121. package/dist/server/idempotency-lane.js.map +1 -1
  122. package/dist/server/idle-timeout.d.ts +39 -0
  123. package/dist/server/idle-timeout.d.ts.map +1 -0
  124. package/dist/server/idle-timeout.js +95 -0
  125. package/dist/server/idle-timeout.js.map +1 -0
  126. package/dist/server/json-shared.d.ts +23 -0
  127. package/dist/server/json-shared.d.ts.map +1 -0
  128. package/dist/server/json-shared.js +33 -0
  129. package/dist/server/json-shared.js.map +1 -0
  130. package/dist/server/json-text.d.ts +1 -1
  131. package/dist/server/json-text.js +1 -1
  132. package/dist/server/node-outcome.js +1 -1
  133. package/dist/server/node-outcome.js.map +1 -1
  134. package/dist/server/request-context.d.ts +22 -1
  135. package/dist/server/request-context.d.ts.map +1 -1
  136. package/dist/server/request-context.js +44 -4
  137. package/dist/server/request-context.js.map +1 -1
  138. package/dist/server/request-local.d.ts +44 -0
  139. package/dist/server/request-local.d.ts.map +1 -0
  140. package/dist/server/request-local.js +91 -0
  141. package/dist/server/request-local.js.map +1 -0
  142. package/dist/server/respond.js +1 -1
  143. package/dist/server/respond.js.map +1 -1
  144. package/dist/server/response-contract-lane.d.ts +10 -2
  145. package/dist/server/response-contract-lane.d.ts.map +1 -1
  146. package/dist/server/response-contract-lane.js +93 -64
  147. package/dist/server/response-contract-lane.js.map +1 -1
  148. package/dist/server/runtime-core.d.ts +7 -4
  149. package/dist/server/runtime-core.d.ts.map +1 -1
  150. package/dist/server/runtime-core.js +9 -7
  151. package/dist/server/runtime-core.js.map +1 -1
  152. package/dist/server/server-types.d.ts +23 -8
  153. package/dist/server/server-types.d.ts.map +1 -1
  154. package/dist/server/server.d.ts +58 -59
  155. package/dist/server/server.d.ts.map +1 -1
  156. package/dist/server/server.js +860 -510
  157. package/dist/server/server.js.map +1 -1
  158. package/dist/server/transport-lane.d.ts.map +1 -1
  159. package/dist/server/transport-lane.js +2 -0
  160. package/dist/server/transport-lane.js.map +1 -1
  161. package/dist/server/webhook.js +1 -1
  162. package/dist/server/webhook.js.map +1 -1
  163. package/dist/server/websocket.d.ts +3 -1
  164. package/dist/server/websocket.d.ts.map +1 -1
  165. package/dist/server/websocket.js +14 -1
  166. package/dist/server/websocket.js.map +1 -1
  167. package/dist/server/ws-adapter-view.d.ts +54 -0
  168. package/dist/server/ws-adapter-view.d.ts.map +1 -0
  169. package/dist/server/ws-adapter-view.js +7 -0
  170. package/dist/server/ws-adapter-view.js.map +1 -0
  171. package/dist/server.d.ts +2 -1
  172. package/dist/server.d.ts.map +1 -1
  173. package/dist/server.js +2 -1
  174. package/dist/server.js.map +1 -1
  175. package/dist/single-copy.d.ts.map +1 -1
  176. package/dist/single-copy.js +1 -1
  177. package/dist/single-copy.js.map +1 -1
  178. package/dist/static-file.d.ts +156 -4
  179. package/dist/static-file.d.ts.map +1 -1
  180. package/dist/static-file.js +198 -4
  181. package/dist/static-file.js.map +1 -1
  182. package/dist/wire.d.ts.map +1 -1
  183. package/dist/wire.js +1 -3
  184. package/dist/wire.js.map +1 -1
  185. package/package.json +16 -1
  186. package/src/assurance.ts +66 -16
  187. package/src/canonical-json.ts +43 -0
  188. package/src/capabilities.ts +1 -8
  189. package/src/data.ts +2 -8
  190. package/src/diff.ts +2 -12
  191. package/src/durable-adapters.ts +53 -50
  192. package/src/durable-saga.ts +30 -23
  193. package/src/evidence.ts +25 -90
  194. package/src/idempotency.ts +62 -20
  195. package/src/index.ts +13 -2
  196. package/src/internal/control-character.ts +8 -0
  197. package/src/internal/durable-shared.ts +4 -1
  198. package/src/internal/hex.ts +14 -0
  199. package/src/internal/record.ts +4 -0
  200. package/src/internal/route-compiler.ts +2 -2
  201. package/src/internal/route-execution.ts +7 -20
  202. package/src/internal/route-lanes.ts +20 -44
  203. package/src/internal/schema-parts.ts +19 -0
  204. package/src/ledger.ts +2 -16
  205. package/src/manifest.ts +10 -38
  206. package/src/open-resolved.ts +64 -0
  207. package/src/range.ts +1 -1
  208. package/src/router/path-codec.ts +93 -0
  209. package/src/router/pattern.ts +127 -0
  210. package/src/router/same-origin-path.ts +36 -0
  211. package/src/serve-static.ts +227 -0
  212. package/src/server/bare-error-lane.ts +11 -2
  213. package/src/server/base64-answers.ts +2 -6
  214. package/src/server/body-lane.ts +58 -51
  215. package/src/server/body.ts +4 -1
  216. package/src/server/bridge-markers.ts +1 -1
  217. package/src/server/cookies.ts +4 -1
  218. package/src/server/core-copies.ts +1 -1
  219. package/src/server/http.ts +13 -33
  220. package/src/server/idempotency-lane.ts +2 -0
  221. package/src/server/idle-timeout.ts +107 -0
  222. package/src/server/json-shared.ts +38 -0
  223. package/src/server/json-text.ts +1 -1
  224. package/src/server/node-outcome.ts +1 -1
  225. package/src/server/request-context.ts +62 -3
  226. package/src/server/request-local.ts +109 -0
  227. package/src/server/respond.ts +1 -1
  228. package/src/server/response-contract-lane.ts +112 -65
  229. package/src/server/runtime-core.ts +10 -8
  230. package/src/server/server-types.ts +23 -8
  231. package/src/server/server.ts +1220 -798
  232. package/src/server/transport-lane.ts +2 -0
  233. package/src/server/webhook.ts +1 -1
  234. package/src/server/websocket.ts +18 -1
  235. package/src/server/ws-adapter-view.ts +63 -0
  236. package/src/server.ts +12 -1
  237. package/src/single-copy.ts +1 -3
  238. package/src/static-file.ts +350 -4
  239. 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
 
@@ -733,24 +779,35 @@ const invalidHost = (host: string): boolean => {
733
779
  * Host (400), a URL replaces the target, `false` keeps it. Deno hands the target over as sent, so
734
780
  * its dot segments are resolved; so does Bun when HTTP/1.0 leaves the Host out, and its route table
735
781
  * matched `/files/..` raw. Such a request is served as `localhost`, as Node and Deno serve it.
736
- * `headerOf`, not `req.headers`: on Deno that reads the Host alone instead of building every
737
- * header. On Bun only a target that needs resolving is checked unless `validateHost` is set.
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.
738
785
  */
739
- const requestTarget = (req: Request, validateHost: boolean): string | boolean => {
740
- if (!rawRequestTargets && !(unparsedBunTargets && (validateHost || needsResolving(req.url))))
741
- 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
742
801
  const host = headerOf(req, "host")
743
802
  if (host !== null && invalidHost(host)) {
744
- if (rawRequestTargets) return true
745
803
  // Bun served it anyway: it could not read the Host, so it left the target unparsed, or it
746
804
  // built the URL on it - sent twice (joined with ", ", the URL on the first; RFC 9112 section
747
- // 3.2 answers 400), percent-encoded (`%61.example` routed as `a.example`), or with a
748
- // 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
749
807
  // would have built it so.
750
808
  const routed = URL.parse(req.url)?.host
751
809
  return routed === undefined || URL.parse(`//${host.split(", ")[0]}`, req.url)?.host === routed
752
810
  }
753
- if (rawRequestTargets) return needsResolving(req.url) && resolveDotSegments(req.url)
754
811
  // A request built in process carries an absolute URL whatever its headers say.
755
812
  return (
756
813
  host === null &&
@@ -789,7 +846,8 @@ function compareMounts<Env>(a: FetchMount<Env>, b: FetchMount<Env>): number {
789
846
  return b.priority - a.priority || b.path.length - a.path.length || a.order - b.order
790
847
  }
791
848
 
792
- /** 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. */
793
851
  function stripMountPrefix(request: Request, prefix: string): Request {
794
852
  if (prefix === "/") return request
795
853
  const url = new URL(request.url)
@@ -797,6 +855,7 @@ function stripMountPrefix(request: Request, prefix: string): Request {
797
855
  url.pathname = rest === "" ? "/" : rest
798
856
  const childRequest = new Request(url.href, request)
799
857
  if (isAuthenticatedRequest(request)) markAuthenticatedRequest(childRequest)
858
+ carryToCopy(request, childRequest)
800
859
  return childRequest
801
860
  }
802
861
 
@@ -812,6 +871,74 @@ function responseSet(ctx: RawContext): CtxSet {
812
871
  return ctx[CONTEXT_SET]() ?? EMPTY_RESPONSE_CONTROLS
813
872
  }
814
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
+
815
942
  function isContextlessNoArgArrow(handler: (context: never) => unknown): boolean {
816
943
  if (handler.length !== 0) return false
817
944
  try {
@@ -1273,6 +1400,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1273
1400
  /** `trustBodyFraming`: mark every `app.fetch` request as runtime-framed (see ServerOptions). */
1274
1401
  private readonly trustBodyFraming: boolean
1275
1402
  private readonly validateHost: boolean
1403
+ private readonly normalizeTarget: boolean
1276
1404
  private readonly wsMaxPayloadBytes: number
1277
1405
  private readonly requestTimeoutMs: number
1278
1406
  private readonly wsUpgradeTimeoutMs: number
@@ -1418,6 +1546,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
1418
1546
  this.formOrigins = options.formOrigins
1419
1547
  this.trustBodyFraming = options.trustBodyFraming ?? false
1420
1548
  this.validateHost = options.validateHost ?? false
1549
+ this.normalizeTarget = options.normalizeTarget ?? false
1421
1550
  const wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? maxBodyBytes
1422
1551
  assertByteLimit(wsMaxPayloadBytes, "wsMaxPayloadBytes")
1423
1552
  this.maxBodyBytes = maxBodyBytes
@@ -2662,18 +2791,28 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2662
2791
  handler = this.idempotencyRuntime.wrapHandler(handler)
2663
2792
  }
2664
2793
  const { bare, fusedQuery, fusedBody } = lanes
2665
- // Fused lifecycle lanes: derive + before (with or without an after), and body + the same shape.
2666
- // The lane selectors in `selectRouteLanes` are exhaustive about the lifecycleHookLane /
2667
- // body-derive-before-after eligibility, so we only need to check the lane and the hook counts.
2668
- const lifecycleHookLane = lanes.lifecycleHookLane
2669
- const isFusedLifecycle = lifecycleHookLane !== undefined && this.derives.length === 1
2670
- const isFusedBodyLifecycle =
2671
- lanes.fusedLane === "body-derive-before" || lanes.fusedLane === "body-derive-before-after"
2672
- // The generic runners below are the Node-direct twins of the fused Web lanes built further
2673
- // down: same single-frame lifecycle, finalizing through the caller's own `finalize` instead of
2674
- // building a `Response`, so the `!webFast` dispatcher prefers them over the generic program
2675
- // exactly as it prefers the body-only runner. Only the original body-only lane may feed the Web
2676
- // 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.
2677
2816
  const fusedBodyRunner = fusedBody
2678
2817
  ? this.buildFusedBodyRunner(
2679
2818
  handler as unknown as InternalHandler,
@@ -2681,34 +2820,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2681
2820
  hasDecorations ? routeDecorations : undefined,
2682
2821
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2683
2822
  )
2684
- : isFusedBodyLifecycle
2685
- ? this.buildFusedBodyDeriveBeforeAfterRunner(
2686
- handler as unknown as InternalHandler,
2687
- this.derives[0]!,
2688
- beforeHandle[0]!,
2689
- afterHandle[0],
2690
- schema?.body as StandardSchemaV1,
2823
+ : fusedHooks !== undefined
2824
+ ? this.buildFusedLifecycleRunner(
2825
+ fusedHooks,
2826
+ schema?.query,
2827
+ schema?.body as StandardSchemaV1 | undefined,
2691
2828
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2692
2829
  )
2693
- : isFusedLifecycle && lifecycleHookLane === "derive-before"
2694
- ? this.buildFusedDeriveBeforeRunner(
2695
- handler as unknown as InternalHandler,
2696
- this.derives[0]!,
2697
- beforeHandle[0]!,
2698
- undefined,
2699
- schema?.query,
2700
- bodyLimit ?? UNLIMITED_BODY_BYTES,
2701
- )
2702
- : isFusedLifecycle && lifecycleHookLane === "derive-before-after"
2703
- ? this.buildFusedDeriveBeforeRunner(
2704
- handler as unknown as InternalHandler,
2705
- this.derives[0]!,
2706
- beforeHandle[0]!,
2707
- afterHandle[0],
2708
- schema?.query,
2709
- bodyLimit ?? UNLIMITED_BODY_BYTES,
2710
- )
2711
- : undefined
2830
+ : undefined
2712
2831
  const fusedWeb =
2713
2832
  bare && this.aroundHooks.length === 0
2714
2833
  ? this.buildFusedWeb(
@@ -2726,33 +2845,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2726
2845
  )
2727
2846
  : fusedBody
2728
2847
  ? this.buildFusedBodyWeb(fusedBodyRunner as FusedBodyRunner)
2729
- : isFusedBodyLifecycle
2730
- ? this.buildFusedBodyDeriveBeforeAfter(
2731
- handler as unknown as InternalHandler,
2732
- this.derives[0]!,
2733
- beforeHandle[0]!,
2734
- afterHandle[0],
2735
- schema?.body as StandardSchemaV1,
2848
+ : fusedHooks !== undefined
2849
+ ? this.buildFusedLifecycleWeb(
2850
+ fusedHooks,
2851
+ schema?.query,
2852
+ schema?.body as StandardSchemaV1 | undefined,
2736
2853
  bodyLimit ?? UNLIMITED_BODY_BYTES,
2737
2854
  )
2738
- : isFusedLifecycle && lifecycleHookLane === "derive-before"
2739
- ? this.buildFusedDeriveBefore(
2740
- handler as unknown as InternalHandler,
2741
- this.derives[0]!,
2742
- beforeHandle[0]!,
2743
- schema?.query,
2744
- bodyLimit ?? UNLIMITED_BODY_BYTES,
2745
- )
2746
- : isFusedLifecycle && lifecycleHookLane === "derive-before-after"
2747
- ? this.buildFusedDeriveBeforeAfter(
2748
- handler as unknown as InternalHandler,
2749
- this.derives[0]!,
2750
- beforeHandle[0]!,
2751
- afterHandle[0]!,
2752
- schema?.query,
2753
- bodyLimit ?? UNLIMITED_BODY_BYTES,
2754
- )
2755
- : undefined
2855
+ : undefined
2756
2856
  const program = compileRouteProgram({
2757
2857
  schema,
2758
2858
  handler: handler as unknown as InternalHandler,
@@ -2785,13 +2885,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
2785
2885
  idempotent,
2786
2886
  ledgered,
2787
2887
  responseContract,
2788
- derives: [...this.derives],
2888
+ derives: derivesSnapshot,
2789
2889
  authStages: [...compiled.authStages],
2790
2890
  decorations: routeDecorations,
2791
2891
  hasDecorations,
2792
- beforeHandle: [...beforeHandle],
2793
- afterHandle: [...afterHandle],
2794
- onError: [...onError],
2892
+ beforeHandle: beforesSnapshot,
2893
+ afterHandle: aftersSnapshot,
2894
+ onError: onErrorSnapshot,
2795
2895
  lifecycleHookLane: lanes.lifecycleHookLane,
2796
2896
  around: [...this.aroundHooks],
2797
2897
  execution,
@@ -3246,13 +3346,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3246
3346
  const lane = entry.execution.fusedLane
3247
3347
  // Type-erase the lane so each `===` branch sees the full union, not the narrowed remainder.
3248
3348
  const laneName: string | undefined = lane
3249
- // Body+lifecycle routes have a fused Web renderer, and - unlike before - a generic fused
3250
- // runner too: it preserves derive/before/after (it IS the lifecycle, single-framed), so the
3251
- // Node-direct dispatcher's preference for the generic slot over the execution plan stays
3252
- // semantics-preserving. Only the original body-only lane may feed the Web wrapper below.
3253
- const derive = entry.derives[0]
3254
- const before = entry.beforeHandle[0]
3255
- 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
3256
3360
  const fusedBody =
3257
3361
  laneName === "body"
3258
3362
  ? this.buildFusedBodyRunner(
@@ -3261,49 +3365,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3261
3365
  entry.hasDecorations ? entry.decorations : undefined,
3262
3366
  entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3263
3367
  )
3264
- : laneName === "body-derive-before" && derive !== undefined && before !== undefined
3265
- ? this.buildFusedBodyDeriveBeforeAfterRunner(
3266
- entry.handler,
3267
- derive,
3268
- before,
3269
- undefined,
3270
- entry.schema?.body as StandardSchemaV1,
3368
+ : hooks !== undefined
3369
+ ? this.buildFusedLifecycleRunner(
3370
+ hooks,
3371
+ entry.schema?.query,
3372
+ entry.schema?.body as StandardSchemaV1 | undefined,
3271
3373
  entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3272
3374
  )
3273
- : laneName === "body-derive-before-after" &&
3274
- derive !== undefined &&
3275
- before !== undefined &&
3276
- after !== undefined
3277
- ? this.buildFusedBodyDeriveBeforeAfterRunner(
3278
- entry.handler,
3279
- derive,
3280
- before,
3281
- after,
3282
- entry.schema?.body as StandardSchemaV1,
3283
- entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3284
- )
3285
- : laneName === "derive-before" && derive !== undefined && before !== undefined
3286
- ? this.buildFusedDeriveBeforeRunner(
3287
- entry.handler,
3288
- derive,
3289
- before,
3290
- undefined,
3291
- entry.schema?.query,
3292
- entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3293
- )
3294
- : laneName === "derive-before-after" &&
3295
- derive !== undefined &&
3296
- before !== undefined &&
3297
- after !== undefined
3298
- ? this.buildFusedDeriveBeforeRunner(
3299
- entry.handler,
3300
- derive,
3301
- before,
3302
- after,
3303
- entry.schema?.query,
3304
- entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3305
- )
3306
- : undefined
3375
+ : undefined
3307
3376
  const fusedWeb =
3308
3377
  laneName === "body"
3309
3378
  ? this.buildFusedBodyWeb(fusedBody as FusedBodyRunner)
@@ -3314,53 +3383,19 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3314
3383
  entry.schema?.query as StandardSchemaV1,
3315
3384
  entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3316
3385
  )
3317
- : laneName === "body-derive-before" && derive !== undefined && before !== undefined
3318
- ? 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(
3319
3394
  entry.handler,
3320
- derive,
3321
- before,
3322
- undefined,
3323
- entry.schema?.body as StandardSchemaV1,
3395
+ entry.hasDecorations ? entry.decorations : undefined,
3396
+ isContextlessNoArgArrow(entry.handler),
3324
3397
  entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3325
3398
  )
3326
- : laneName === "body-derive-before-after" &&
3327
- derive !== undefined &&
3328
- before !== undefined &&
3329
- after !== undefined
3330
- ? this.buildFusedBodyDeriveBeforeAfter(
3331
- entry.handler,
3332
- derive,
3333
- before,
3334
- after,
3335
- entry.schema?.body as StandardSchemaV1,
3336
- entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3337
- )
3338
- : laneName === "derive-before" && derive !== undefined && before !== undefined
3339
- ? this.buildFusedDeriveBefore(
3340
- entry.handler,
3341
- derive,
3342
- before,
3343
- entry.schema?.query,
3344
- entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3345
- )
3346
- : laneName === "derive-before-after" &&
3347
- derive !== undefined &&
3348
- before !== undefined &&
3349
- after !== undefined
3350
- ? this.buildFusedDeriveBeforeAfter(
3351
- entry.handler,
3352
- derive,
3353
- before,
3354
- after,
3355
- entry.schema?.query,
3356
- entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3357
- )
3358
- : this.buildFusedWeb(
3359
- entry.handler,
3360
- entry.hasDecorations ? entry.decorations : undefined,
3361
- isContextlessNoArgArrow(entry.handler),
3362
- entry.bodyLimit ?? UNLIMITED_BODY_BYTES,
3363
- )
3364
3399
  return {
3365
3400
  ...route,
3366
3401
  entry: {
@@ -3395,7 +3430,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3395
3430
  fetch(req: Request, platform?: Platform<EnvOf<Ctx>>): MaybePromise<Response> {
3396
3431
  // Deno's `Request.url` is the target as sent; Bun, workerd and every `new Request()` parse it.
3397
3432
  // Route what the parsed URL says, so `/users/../admin` is `/admin` on every runtime.
3398
- const target = requestTarget(req, this.validateHost)
3433
+ const target = requestTarget(req, this.validateHost, this.normalizeTarget)
3399
3434
  if (target === true) return jsonError(400, "invalid_host")
3400
3435
  if (target !== false) {
3401
3436
  const rebuilt = withResolvedTarget(req, target)
@@ -3467,6 +3502,47 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3467
3502
  )
3468
3503
  }
3469
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
+
3470
3546
  private fetchMatchedInner(
3471
3547
  source: RequestSource,
3472
3548
  entry: RouteEntry,
@@ -3899,7 +3975,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
3899
3975
  // The adapter answers a pass by handing this same object to `fetch`.
3900
3976
  const received = req
3901
3977
  // The handshake routes the path `fetch` would: Host checked, dot segments resolved (see `fetch`).
3902
- const target = requestTarget(req, this.validateHost)
3978
+ const target = requestTarget(req, this.validateHost, this.normalizeTarget)
3903
3979
  if (target === true) {
3904
3980
  return { kind: "reject", response: this.wrapWebResponse(jsonError(400, "invalid_host")) }
3905
3981
  }
@@ -4120,10 +4196,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4120
4196
  private bunFetchWithWebSocket(
4121
4197
  req: Request,
4122
4198
  server: BunUpgradeServer,
4199
+ setIdleTimeout: IdleTimeoutSetter,
4123
4200
  ): MaybePromise<Response | undefined> {
4124
4201
  const handle = (o: WebSocketUpgradeOutcome): MaybePromise<Response | undefined> => {
4125
4202
  if (o.kind === "pass")
4126
- 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
+ )
4127
4207
  if (o.kind === "reject") return o.response
4128
4208
  // A copy: the Bun socket keeps per-connection state on it, and a hand-built outcome may be shared.
4129
4209
  return server.upgrade(req, { data: { ...o } })
@@ -4132,7 +4212,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4132
4212
  }
4133
4213
  const outcome = this.resolveWebSocketUpgrade(
4134
4214
  req,
4135
- bunPeerPlatform(server, req) as Platform<EnvOf<Ctx>>,
4215
+ new BunPeerPlatform(server, req, setIdleTimeout) as Platform<EnvOf<Ctx>>,
4136
4216
  )
4137
4217
  return outcome instanceof Promise ? outcome.then(handle) : handle(outcome)
4138
4218
  }
@@ -4285,6 +4365,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4285
4365
  wrapResponse: (response: Response | ResponseResult) => T,
4286
4366
  onTimeout: () => T,
4287
4367
  webFast: boolean,
4368
+ matched?: MatchedRoute,
4288
4369
  ): MaybePromise<T> {
4289
4370
  const hooks = this.onRequestHooks
4290
4371
  const originalRequest = requestOf(source)
@@ -4314,6 +4395,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4314
4395
  wrapResponse,
4315
4396
  onTimeout,
4316
4397
  webFast,
4398
+ matched,
4317
4399
  ),
4318
4400
  (err) => this.onRequestFailure(err, current, platform, finalize, wrapResponse),
4319
4401
  )
@@ -4326,7 +4408,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4326
4408
  }
4327
4409
  if (outcome !== undefined) return earlyAnswer(outcome, wrapResponse)
4328
4410
  }
4329
- 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
+ )
4330
4420
  }
4331
4421
 
4332
4422
  /** Async tail of {@link runWithOnRequest}: applies the first awaited hook's outcome, then runs
@@ -4341,6 +4431,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4341
4431
  wrapResponse: (response: Response | ResponseResult) => T,
4342
4432
  onTimeout: () => T,
4343
4433
  webFast: boolean,
4434
+ matched?: MatchedRoute,
4344
4435
  ): Promise<T> {
4345
4436
  let current = sourceAtAwait
4346
4437
  let seen = requestOf(sourceAtAwait)
@@ -4364,7 +4455,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4364
4455
  }
4365
4456
  index++
4366
4457
  }
4367
- 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
+ )
4368
4467
  }
4369
4468
 
4370
4469
  private takeResponseRequest(source: RequestSource): Request {
@@ -4442,7 +4541,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
4442
4541
  wrapResponse: (response: Response | ResponseResult) => T,
4443
4542
  onTimeout: () => T,
4444
4543
  webFast: boolean,
4544
+ // The route Bun's table already matched for this very request, which routing would find again.
4545
+ matched?: MatchedRoute,
4445
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
+ }
4446
4560
  // Native delimiter searches keep path/query splitting out of a character-by-character JS loop.
4447
4561
  // Keep the slices in locals instead of allocating the public helper's pathname/search pair.
4448
4562
  let pathname: string
@@ -5291,292 +5405,418 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5291
5405
  }
5292
5406
 
5293
5407
  /**
5294
- * Compile a route's `derive + before` lifecycle to a single closure: the per-request walk through
5295
- * `runSync` → `runHooksSync` is a stack of 3 frames and 2 `Object.assign`s for what is by far the
5296
- * most common middleware shape (auth/trace/id derivation, an auth/policy `beforeHandle`). Folding
5297
- * the same steps into one closure hits the same monomorphic inline-cache site as `buildFusedWeb`,
5298
- * and `Object.assign(ctx, deriveResult)` becomes one extra property write on the context's already
5299
- * hot hidden class. Behavior is byte-identical to the compiled route program: the same thrown-value
5300
- * contract (`Response` is control flow, anything else logs + 500), the same `c.set` semantics, the
5301
- * same `fusedRespond` for the rendered body. The `LifecycleExecutionLane` guarantee
5302
- * (`schema.params === undefined && schema.headers === undefined && derives === 1 &&
5303
- * beforeHandle === 1 && afterHandle === 0 && onError === 0 && !hasResponseContract &&
5304
- * !hasDecorations`) is enforced by `selectRouteLanes`; this lane is selected ONLY when all of those
5305
- * hold. A `query` schema (the route declares `{ query }` alongside the hooks) is validated FIRST,
5306
- * mirroring the compiled route program's stage order; the fused builder runs validate → derive →
5307
- * 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.
5308
5413
  */
5309
- private buildFusedDeriveBefore(
5310
- handler: InternalHandler,
5311
- derive: RawDerive,
5312
- before: RawBeforeHandle,
5313
- querySchema: StandardSchemaV1 | undefined,
5314
- maxBodyBytes: number,
5315
- ): FusedWebRunner {
5316
- return this.buildFusedDeriveBeforeAfter(
5317
- handler,
5318
- derive,
5319
- before,
5320
- undefined,
5321
- querySchema,
5322
- maxBodyBytes,
5323
- )
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
5324
5623
  }
5325
5624
 
5326
- // Both hook variants share one registered chain; request contexts stay in arguments.
5327
- 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(
5328
5641
  handler: InternalHandler,
5329
5642
  derive: RawDerive,
5330
5643
  before: RawBeforeHandle,
5331
5644
  after: RawAfterHandle | undefined,
5332
- querySchema: StandardSchemaV1 | undefined,
5333
5645
  maxBodyBytes: number,
5334
5646
  ): FusedWebRunner {
5335
- const logError = (err: unknown, ctx: RawContext): Response =>
5336
- this.bareError(
5337
- err,
5338
- ctx,
5339
- (result, set) => this.wrapWebResponse(toResponse(result, set)),
5340
- this.wrapWebResponse,
5341
- )
5342
- // Continuations capture route state once; each request supplies its own context.
5343
- const runAfter = (result: unknown, ctx: RawContext): MaybePromise<Response> => {
5344
- if (after === undefined)
5345
- return this.respondFused(result, ctx, this.onResponseHooks.length === 0)
5346
- let transformed: unknown
5347
- try {
5348
- transformed = after(result, ctx)
5349
- } catch (err) {
5350
- return logError(err, ctx)
5351
- }
5352
- if (transformed instanceof Promise) {
5353
- return transformed.then(
5354
- (value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0),
5355
- (err) => logError(err, ctx),
5356
- )
5357
- }
5358
- return this.respondFused(transformed, ctx, this.onResponseHooks.length === 0)
5359
- }
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
+
5360
5667
  const runHandler = (ctx: RawContext): MaybePromise<Response> => {
5361
5668
  let result: unknown
5362
5669
  try {
5363
5670
  result = handler(ctx)
5364
5671
  } catch (err) {
5365
- return logError(err, ctx)
5672
+ return this.fusedWebFail(err, ctx)
5366
5673
  }
5367
5674
  if (result instanceof Promise) {
5368
5675
  return result.then(
5369
5676
  (value) => runAfter(value, ctx),
5370
- (err) => logError(err, ctx),
5677
+ (err) => this.fusedWebFail(err, ctx),
5371
5678
  )
5372
5679
  }
5373
5680
  return runAfter(result, ctx)
5374
5681
  }
5682
+
5375
5683
  const runBefore = (ctx: RawContext): MaybePromise<Response> => {
5376
5684
  let early: unknown
5377
5685
  try {
5378
5686
  early = before(ctx)
5379
5687
  } catch (err) {
5380
- return logError(err, ctx)
5688
+ return this.fusedWebFail(err, ctx)
5381
5689
  }
5382
5690
  if (early instanceof Promise) {
5383
5691
  return early.then(
5384
- (settled) => {
5385
- if (settled === undefined) return runHandler(ctx)
5386
- return this.respondFused(settled, ctx)
5387
- },
5388
- (err) => logError(err, ctx),
5692
+ (settled) => (settled === undefined ? runHandler(ctx) : this.respondFused(settled, ctx)),
5693
+ (err) => this.fusedWebFail(err, ctx),
5389
5694
  )
5390
5695
  }
5391
5696
  if (early === undefined) return runHandler(ctx)
5392
5697
  return this.respondFused(early, ctx)
5393
5698
  }
5394
- const runDerive = (ctx: RawContext): MaybePromise<Response> => {
5699
+
5700
+ return (source, params, search, signal, budget, platform, nativeContext) => {
5701
+ const ctx = nativeContext
5702
+ ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
5703
+ : new RequestContext(
5704
+ source,
5705
+ params,
5706
+ search,
5707
+ signal,
5708
+ budget,
5709
+ platform,
5710
+ maxBodyBytes,
5711
+ this.protoPoisoning,
5712
+ )
5713
+
5395
5714
  let derived: unknown
5396
5715
  try {
5397
5716
  derived = derive(ctx)
5398
5717
  } catch (err) {
5399
- return logError(err, ctx)
5718
+ return this.fusedWebFail(err, ctx)
5400
5719
  }
5401
5720
  if (derived instanceof Promise) {
5402
5721
  return derived.then(
5403
5722
  (settled) => {
5404
- if (isResponseResult(settled) || settled instanceof Response) {
5723
+ if (isResponseResult(settled) || settled instanceof Response)
5405
5724
  return this.respondFused(settled, ctx)
5725
+ try {
5726
+ Object.assign(ctx, settled)
5727
+ } catch (err) {
5728
+ return this.fusedWebFail(err, ctx)
5406
5729
  }
5407
- Object.assign(ctx, settled as object)
5408
5730
  return runBefore(ctx)
5409
5731
  },
5410
- (err) => logError(err, ctx),
5732
+ (err) => this.fusedWebFail(err, ctx),
5411
5733
  )
5412
5734
  }
5413
- if (isResponseResult(derived) || derived instanceof Response) {
5735
+ if (isResponseResult(derived) || derived instanceof Response)
5414
5736
  return this.respondFused(derived, ctx)
5415
- }
5416
- Object.assign(ctx, derived as object)
5417
- return runBefore(ctx)
5418
- }
5419
- return (source, params, search, signal, budget, platform, nativeContext) => {
5420
- const ctx = nativeContext
5421
- ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
5422
- : new RequestContext(
5423
- source,
5424
- params,
5425
- search,
5426
- signal,
5427
- budget,
5428
- platform,
5429
- maxBodyBytes,
5430
- this.protoPoisoning,
5431
- )
5432
- if (querySchema === undefined) return runDerive(ctx)
5433
- let validation: MaybePromise<StandardResult<unknown>>
5737
+
5738
+ let early: unknown
5739
+ let result: unknown
5740
+ let transformed: unknown
5741
+ let handlerPromise: Promise<unknown> | undefined
5434
5742
  try {
5435
- validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
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
+ }
5436
5750
  } catch (err) {
5437
- return logError(err, ctx)
5751
+ return this.fusedWebFail(err, ctx)
5438
5752
  }
5439
- if (validation instanceof Promise) {
5440
- return validation.then(
5441
- (settled) => {
5442
- if (settled.issues !== undefined)
5443
- return this.wrapWebResponse(plainValidationError(settled.issues))
5444
- ctx.query = settled.value
5445
- return runDerive(ctx)
5446
- },
5447
- (err) => logError(err, ctx),
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),
5448
5758
  )
5449
5759
  }
5450
- if (validation.issues !== undefined)
5451
- return this.wrapWebResponse(plainValidationError(validation.issues))
5452
- ctx.query = validation.value
5453
- return runDerive(ctx)
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)
5454
5779
  }
5455
5780
  }
5456
5781
 
5457
- /**
5458
- * Body + lifecycle hooks fused: parse → validate → derive → before → handler → (after) in one
5459
- * closure, the same win `buildFusedBodyRunner` gives for the no-hooks body shape. The shape is
5460
- * `lifecycleHookLane` PLUS a body schema with no other validation conflicts; the lane is selected
5461
- * ONLY when `schema.body !== undefined && schema.query/params/headers === undefined &&
5462
- * schema.onValidationError === undefined && !defaultOnValidationError && !hasIdempotency &&
5463
- * !hasLedger && derives === 1 && beforeHandle === 1 && afterHandle <= 1 && onError === 0 &&
5464
- * !hasResponseContract && !hasDecorations && around === 0`. The same single `querySchema === undefined`
5465
- * trick used in the no-body lane lets the build path stay simple: a query + body route falls back
5466
- * to the generic program (this is rare in practice - validated query + validated body is usually a
5467
- * "search results" route with a middleware chain on top, and the generic lane is fine there).
5468
- */
5469
- private buildFusedBodyDeriveBeforeAfter(
5470
- handler: InternalHandler,
5471
- derive: RawDerive,
5472
- before: RawBeforeHandle,
5473
- after: RawAfterHandle | undefined,
5474
- bodySchema: StandardSchemaV1,
5782
+ private buildFusedLifecycleWeb(
5783
+ route: FusedHooks,
5784
+ querySchema: StandardSchemaV1 | undefined,
5785
+ bodySchema: StandardSchemaV1 | undefined,
5475
5786
  maxBodyBytes: number,
5476
5787
  ): FusedWebRunner {
5477
- const logError = (err: unknown, ctx: RawContext): Response =>
5478
- this.bareError(
5479
- err,
5480
- ctx,
5481
- (result, set) => this.wrapWebResponse(toResponse(result, set)),
5482
- this.wrapWebResponse,
5483
- )
5484
- return (source, params, search, signal, budget, platform, nativeContext) => {
5485
- const ctx = nativeContext
5486
- ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
5487
- : new RequestContext(
5488
- source,
5489
- params,
5490
- search,
5491
- signal,
5492
- budget,
5493
- platform,
5494
- maxBodyBytes,
5495
- this.protoPoisoning,
5496
- )
5497
- const runHandlerValidated = (): MaybePromise<Response> => {
5498
- const runAfter = (result: unknown): MaybePromise<Response> => {
5499
- if (after === undefined) {
5500
- return this.respondFused(result, ctx, this.onResponseHooks.length === 0)
5501
- }
5502
- let transformed: unknown
5503
- try {
5504
- transformed = after(result, ctx)
5505
- } catch (err) {
5506
- return logError(err, ctx)
5507
- }
5508
- if (transformed instanceof Promise) {
5509
- return transformed.then(
5510
- (value) => this.respondFused(value, ctx, this.onResponseHooks.length === 0),
5511
- (err) => logError(err, ctx),
5512
- )
5513
- }
5514
- return this.respondFused(transformed, ctx, this.onResponseHooks.length === 0)
5515
- }
5516
- const runHandler = (): MaybePromise<Response> => {
5517
- let result: unknown
5518
- try {
5519
- result = handler(ctx)
5520
- } catch (err) {
5521
- return logError(err, ctx)
5522
- }
5523
- if (result instanceof Promise) {
5524
- return result.then(
5525
- (value) => runAfter(value),
5526
- (err) => logError(err, ctx),
5527
- )
5528
- }
5529
- return runAfter(result)
5530
- }
5531
- const runBefore = (): MaybePromise<Response> => {
5532
- let early: unknown
5533
- try {
5534
- early = before(ctx)
5535
- } catch (err) {
5536
- return logError(err, ctx)
5537
- }
5538
- if (early instanceof Promise) {
5539
- return early.then(
5540
- (settled) => {
5541
- if (settled === undefined) return runHandler()
5542
- return this.respondFused(settled, ctx)
5543
- },
5544
- (err) => logError(err, ctx),
5545
- )
5546
- }
5547
- if (early === undefined) return runHandler()
5548
- return this.respondFused(early, ctx)
5549
- }
5550
- let derived: unknown
5551
- try {
5552
- derived = derive(ctx)
5553
- } catch (err) {
5554
- return logError(err, ctx)
5555
- }
5556
- if (derived instanceof Promise) {
5557
- return derived.then(
5558
- (settled) => {
5559
- if (isResponseResult(settled) || settled instanceof Response) {
5560
- return this.respondFused(settled, ctx)
5561
- }
5562
- Object.assign(ctx, settled as object)
5563
- return runBefore()
5564
- },
5565
- (err) => logError(err, ctx),
5566
- )
5567
- }
5568
- if (isResponseResult(derived) || derived instanceof Response) {
5569
- return this.respondFused(derived, ctx)
5570
- }
5571
- Object.assign(ctx, derived as object)
5572
- 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
+ )
5573
5807
  }
5574
- 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> => {
5575
5815
  let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
5576
5816
  try {
5577
5817
  validation = bodySchema["~standard"].validate(parsed)
5578
5818
  } catch (err) {
5579
- return logError(err, ctx)
5819
+ return this.fusedWebRouteFail(err, ctx, route)
5580
5820
  }
5581
5821
  if (validation instanceof Promise) {
5582
5822
  return validation.then(
@@ -5584,224 +5824,50 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5584
5824
  if (settled.issues !== undefined)
5585
5825
  return this.wrapWebResponse(plainValidationError(settled.issues))
5586
5826
  ctx.body = settled.value
5587
- return runHandlerValidated()
5827
+ return runDerive(ctx, route, 0)
5588
5828
  },
5589
- (err) => logError(err, ctx),
5829
+ (err) => this.fusedWebRouteFail(err, ctx, route),
5590
5830
  )
5591
5831
  }
5592
5832
  if (validation.issues !== undefined)
5593
5833
  return this.wrapWebResponse(plainValidationError(validation.issues))
5594
5834
  ctx.body = validation.value
5595
- return runHandlerValidated()
5596
- }
5597
- return readBodyFramed(
5598
- source,
5599
- maxBodyBytes,
5600
- this.protoPoisoning,
5601
- onParsed,
5602
- (response) => this.wrapWebResponse(response),
5603
- (err) => Promise.resolve(logError(err, ctx)),
5604
- bodySchema,
5605
- this.formOrigins,
5606
- ) as MaybePromise<Response>
5607
- }
5608
- }
5609
-
5610
- /**
5611
- * Generic (finalize/wrapResponse-parameterized) fused runner for the derive-before(-after) lane:
5612
- * the Node-direct counterpart of `buildFusedDeriveBefore` / `buildFusedDeriveBeforeAfter`.
5613
- *
5614
- * The Web fused builders above return a `Response`, so the Node dispatcher (`webFast === false`)
5615
- * cannot use them and a derive+before route falls back to the generic route program there - two
5616
- * staged loops, per-stage dispatch, and the async-continuation machinery - while Bun rides the
5617
- * single closure. That asymmetry is the realistic-route Node deficit against Fastify: this runner
5618
- * closes it by running validate → derive → before → handler → (after) in one frame and finalizing
5619
- * through the caller's own `finalize` (the Node outcome on the direct lane, `toResponse` on Web),
5620
- * so the same registration feeds both runtimes with no per-lane copy of the lifecycle to drift.
5621
- *
5622
- * Semantics mirror the generic program stage-for-stage for the shapes this lane admits (exactly
5623
- * one derive, one beforeHandle, zero-or-one afterHandle, no onError/around/decorations/contracts/
5624
- * recovery hooks - enforced by `selectRouteLanes`): thrown `Response`/`status(...)` are control
5625
- * flow through `wrapResponse`/`finalize`, anything else logs + 500s via the shared `bareError`
5626
- * lane (identical to `handleLifecycleError` with no `onError` hooks), and a terminal `finalize`
5627
- * throw (e.g. an unstringifiable handler return on the Node lane) is caught exactly as
5628
- * `finishLifecycleContract` catches it. Step functions close over registration state only and take
5629
- * the request state as arguments, so the hot path allocates no per-request closures.
5630
- */
5631
- private buildFusedDeriveBeforeRunner(
5632
- handler: InternalHandler,
5633
- derive: RawDerive,
5634
- before: RawBeforeHandle,
5635
- after: RawAfterHandle | undefined,
5636
- querySchema: StandardSchemaV1 | undefined,
5637
- maxBodyBytes: number,
5638
- ): FusedBodyRunner {
5639
- const logError = <T>(
5640
- err: unknown,
5641
- ctx: RawContext,
5642
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5643
- wrapResponse: (response: Response | ResponseResult) => T,
5644
- ): T => this.bareError(err, ctx, finalize, wrapResponse)
5645
-
5646
- const runFinish = <T>(
5647
- result: unknown,
5648
- ctx: RawContext,
5649
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5650
- wrapResponse: (response: Response | ResponseResult) => T,
5651
- ): MaybePromise<T> => {
5652
- if (after === undefined) {
5653
- try {
5654
- return finalize(result, responseSet(ctx), ctx)
5655
- } catch (err) {
5656
- return logError(err, ctx, finalize, wrapResponse)
5657
- }
5658
- }
5659
- let transformed: unknown
5660
- try {
5661
- transformed = after(result, ctx)
5662
- } catch (err) {
5663
- return logError(err, ctx, finalize, wrapResponse)
5664
- }
5665
- if (transformed instanceof Promise) {
5666
- return transformed.then(
5667
- (value) => {
5668
- try {
5669
- return finalize(value, responseSet(ctx), ctx)
5670
- } catch (err) {
5671
- return logError(err, ctx, finalize, wrapResponse)
5672
- }
5673
- },
5674
- (err) => logError(err, ctx, finalize, wrapResponse),
5675
- )
5676
- }
5677
- try {
5678
- return finalize(transformed, responseSet(ctx), ctx)
5679
- } catch (err) {
5680
- return logError(err, ctx, finalize, wrapResponse)
5681
- }
5682
- }
5683
-
5684
- const finalizeEarly = <T>(
5685
- result: unknown,
5686
- ctx: RawContext,
5687
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5688
- wrapResponse: (response: Response | ResponseResult) => T,
5689
- ): T => {
5690
- try {
5691
- return finalize(result, responseSet(ctx), ctx)
5692
- } catch (err) {
5693
- return logError(err, ctx, finalize, wrapResponse)
5694
- }
5695
- }
5696
-
5697
- const runHandler = <T>(
5698
- ctx: RawContext,
5699
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5700
- wrapResponse: (response: Response | ResponseResult) => T,
5701
- ): MaybePromise<T> => {
5702
- let result: unknown
5703
- try {
5704
- result = handler(ctx)
5705
- } catch (err) {
5706
- return logError(err, ctx, finalize, wrapResponse)
5707
- }
5708
- if (result instanceof Promise) {
5709
- return result.then(
5710
- (value) => runFinish(value, ctx, finalize, wrapResponse),
5711
- (err) => logError(err, ctx, finalize, wrapResponse),
5712
- )
5713
- }
5714
- return runFinish(result, ctx, finalize, wrapResponse)
5715
- }
5716
-
5717
- const runBefore = <T>(
5718
- ctx: RawContext,
5719
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5720
- wrapResponse: (response: Response | ResponseResult) => T,
5721
- ): MaybePromise<T> => {
5722
- let early: unknown
5723
- try {
5724
- early = before(ctx)
5725
- } catch (err) {
5726
- return logError(err, ctx, finalize, wrapResponse)
5727
- }
5728
- if (early instanceof Promise) {
5729
- return early.then(
5730
- (settled) =>
5731
- settled === undefined
5732
- ? runHandler(ctx, finalize, wrapResponse)
5733
- : finalizeEarly(settled, ctx, finalize, wrapResponse),
5734
- (err) => logError(err, ctx, finalize, wrapResponse),
5735
- )
5736
- }
5737
- if (early === undefined) return runHandler(ctx, finalize, wrapResponse)
5738
- return finalizeEarly(early, ctx, finalize, wrapResponse)
5739
- }
5740
-
5741
- const runDerive = <T>(
5742
- ctx: RawContext,
5743
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5744
- wrapResponse: (response: Response | ResponseResult) => T,
5745
- ): MaybePromise<T> => {
5746
- let derived: unknown
5747
- try {
5748
- derived = derive(ctx)
5749
- } catch (err) {
5750
- return logError(err, ctx, finalize, wrapResponse)
5835
+ return runDerive(ctx, route, 0)
5751
5836
  }
5752
- if (derived instanceof Promise) {
5753
- return derived.then(
5754
- (settled) => {
5755
- if (isResponseResult(settled) || settled instanceof Response) {
5756
- return finalizeEarly(settled, ctx, finalize, wrapResponse)
5757
- }
5758
- Object.assign(ctx, settled as object)
5759
- return runBefore(ctx, finalize, wrapResponse)
5760
- },
5761
- (err) => logError(err, ctx, finalize, wrapResponse),
5762
- )
5763
- }
5764
- if (isResponseResult(derived) || derived instanceof Response) {
5765
- return finalizeEarly(derived, ctx, finalize, wrapResponse)
5766
- }
5767
- Object.assign(ctx, derived as object)
5768
- return runBefore(ctx, finalize, wrapResponse)
5769
- }
5770
-
5771
- const runValidated = <T>(
5772
- validation: StandardResult<unknown> | Promise<StandardResult<unknown>>,
5773
- ctx: RawContext,
5774
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5775
- wrapResponse: (response: Response | ResponseResult) => T,
5776
- ): MaybePromise<T> => {
5777
- if (validation instanceof Promise) {
5778
- return validation.then(
5779
- (settled) => {
5780
- if (settled.issues !== undefined)
5781
- return wrapResponse(plainValidationError(settled.issues))
5782
- ctx.query = settled.value
5783
- return runDerive(ctx, finalize, wrapResponse)
5784
- },
5785
- (err) => logError(err, ctx, finalize, wrapResponse),
5786
- )
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>
5787
5868
  }
5788
- if (validation.issues !== undefined)
5789
- return wrapResponse(plainValidationError(validation.issues))
5790
- ctx.query = validation.value
5791
- return runDerive(ctx, finalize, wrapResponse)
5792
5869
  }
5793
-
5794
- return <T>(
5795
- source: RequestSource,
5796
- params: Record<string, string>,
5797
- search: string | undefined,
5798
- signal: AbortSignal,
5799
- budget: RequestBudget,
5800
- platform: Platform | undefined,
5801
- nativeContext: boolean,
5802
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5803
- wrapResponse: (response: Response | ResponseResult) => T,
5804
- ): MaybePromise<T> => {
5870
+ return (source, params, search, signal, budget, platform, nativeContext) => {
5805
5871
  const ctx = nativeContext
5806
5872
  ? RequestContext.native(source, params, search, maxBodyBytes, platform, this.protoPoisoning)
5807
5873
  : new RequestContext(
@@ -5814,193 +5880,383 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
5814
5880
  maxBodyBytes,
5815
5881
  this.protoPoisoning,
5816
5882
  )
5817
- if (querySchema === undefined) return runDerive(ctx, finalize, wrapResponse)
5818
- 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>>
5819
5886
  try {
5820
5887
  validation = querySchema["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]))
5821
5888
  } catch (err) {
5822
- return logError(err, ctx, finalize, wrapResponse)
5823
- }
5824
- return runValidated(validation, ctx, finalize, wrapResponse)
5825
- }
5826
- }
5827
-
5828
- /**
5829
- * Generic fused runner for the body+derive-before(-after) lane: the Node-direct counterpart of
5830
- * `buildFusedBodyDeriveBeforeAfter`. Same contract as `buildFusedDeriveBeforeRunner` above - one
5831
- * frame running parse → validate → derive → before → handler → (after) through the caller's own
5832
- * `finalize` - with the body read ahead of validation, exactly as the generic program orders its
5833
- * body stage before the derive/before stages. `readBodyFramed` keeps the shared bounded-read,
5834
- * content-type, and prototype-poisoning contracts; the 415/400 renders take `wrapResponse`
5835
- * directly, which is what the generic lane's wrap-after-identity amounts to.
5836
- */
5837
- private buildFusedBodyDeriveBeforeAfterRunner(
5838
- handler: InternalHandler,
5839
- derive: RawDerive,
5840
- before: RawBeforeHandle,
5841
- after: RawAfterHandle | undefined,
5842
- bodySchema: StandardSchemaV1,
5843
- maxBodyBytes: number,
5844
- ): FusedBodyRunner {
5845
- const logError = <T>(
5846
- err: unknown,
5847
- ctx: RawContext,
5848
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5849
- wrapResponse: (response: Response | ResponseResult) => T,
5850
- ): T => this.bareError(err, ctx, finalize, wrapResponse)
5851
-
5852
- const runFinish = <T>(
5853
- result: unknown,
5854
- ctx: RawContext,
5855
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5856
- wrapResponse: (response: Response | ResponseResult) => T,
5857
- ): MaybePromise<T> => {
5858
- if (after === undefined) {
5859
- try {
5860
- return finalize(result, responseSet(ctx), ctx)
5861
- } catch (err) {
5862
- return logError(err, ctx, finalize, wrapResponse)
5863
- }
5864
- }
5865
- let transformed: unknown
5866
- try {
5867
- transformed = after(result, ctx)
5868
- } catch (err) {
5869
- return logError(err, ctx, finalize, wrapResponse)
5889
+ return this.fusedWebRouteFail(err, ctx, route)
5870
5890
  }
5871
- if (transformed instanceof Promise) {
5872
- return transformed.then(
5873
- (value) => {
5874
- try {
5875
- return finalize(value, responseSet(ctx), ctx)
5876
- } catch (err) {
5877
- return logError(err, ctx, finalize, wrapResponse)
5878
- }
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)
5879
5898
  },
5880
- (err) => logError(err, ctx, finalize, wrapResponse),
5899
+ (err) => this.fusedWebRouteFail(err, ctx, route),
5881
5900
  )
5882
5901
  }
5883
- try {
5884
- return finalize(transformed, responseSet(ctx), ctx)
5885
- } catch (err) {
5886
- return logError(err, ctx, finalize, wrapResponse)
5887
- }
5902
+ if (validation.issues !== undefined)
5903
+ return this.wrapWebResponse(plainValidationError(validation.issues))
5904
+ ctx.query = validation.value
5905
+ return runDerive(ctx, route, 0)
5888
5906
  }
5907
+ }
5889
5908
 
5890
- 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>(
5891
5920
  result: unknown,
5892
5921
  ctx: RawContext,
5922
+ route: FusedHooks,
5893
5923
  finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5894
5924
  wrapResponse: (response: Response | ResponseResult) => T,
5895
- ): T => {
5925
+ ): MaybePromise<T> => {
5896
5926
  try {
5897
5927
  return finalize(result, responseSet(ctx), ctx)
5898
5928
  } catch (err) {
5899
- return logError(err, ctx, finalize, wrapResponse)
5929
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse)
5900
5930
  }
5901
5931
  }
5902
5932
 
5903
- 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>(
5904
5937
  ctx: RawContext,
5905
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5906
- wrapResponse: (response: Response | ResponseResult) => T,
5907
- ): 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) => {
5908
6000
  let result: unknown
5909
6001
  try {
5910
- result = handler(ctx)
6002
+ result = route.handler(ctx)
5911
6003
  } catch (err) {
5912
- return logError(err, ctx, finalize, wrapResponse)
6004
+ return this.fusedFail(err, ctx, route, finalize, wrapResponse)
5913
6005
  }
5914
6006
  if (result instanceof Promise) {
5915
6007
  return result.then(
5916
- (value) => runFinish(value, ctx, finalize, wrapResponse),
5917
- (err) => logError(err, ctx, finalize, wrapResponse),
6008
+ (value) => runAfter(value, ctx, route, finalize, wrapResponse, 0),
6009
+ (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse),
5918
6010
  )
5919
6011
  }
5920
- return runFinish(result, ctx, finalize, wrapResponse)
6012
+ return runAfter(result, ctx, route, finalize, wrapResponse, 0)
5921
6013
  }
5922
6014
 
5923
- const runBefore = <T>(
5924
- ctx: RawContext,
5925
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5926
- wrapResponse: (response: Response | ResponseResult) => T,
5927
- ): MaybePromise<T> => {
5928
- let early: unknown
5929
- try {
5930
- early = before(ctx)
5931
- } catch (err) {
5932
- 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)
5933
6037
  }
5934
- if (early instanceof Promise) {
5935
- return early.then(
5936
- (settled) =>
5937
- settled === undefined
5938
- ? runHandler(ctx, finalize, wrapResponse)
5939
- : finalizeEarly(settled, ctx, finalize, wrapResponse),
5940
- (err) => logError(err, ctx, finalize, wrapResponse),
5941
- )
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
+ }
5942
6064
  }
5943
- if (early === undefined) return runHandler(ctx, finalize, wrapResponse)
5944
- return finalizeEarly(early, ctx, finalize, wrapResponse)
5945
6065
  }
5946
6066
 
5947
- const runDerive = <T>(
5948
- ctx: RawContext,
5949
- finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5950
- wrapResponse: (response: Response | ResponseResult) => T,
5951
- ): MaybePromise<T> => {
5952
- let derived: unknown
5953
- try {
5954
- derived = derive(ctx)
5955
- } catch (err) {
5956
- 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)
5957
6103
  }
5958
- if (derived instanceof Promise) {
5959
- return derived.then(
5960
- (settled) => {
5961
- if (isResponseResult(settled) || settled instanceof Response) {
5962
- return finalizeEarly(settled, ctx, finalize, wrapResponse)
5963
- }
5964
- Object.assign(ctx, settled as object)
5965
- return runBefore(ctx, finalize, wrapResponse)
5966
- },
5967
- (err) => logError(err, ctx, finalize, wrapResponse),
5968
- )
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)
5969
6194
  }
5970
- if (isResponseResult(derived) || derived instanceof Response) {
5971
- 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
+ )
5972
6236
  }
5973
- Object.assign(ctx, derived as object)
5974
- return runBefore(ctx, finalize, wrapResponse)
5975
6237
  }
5976
6238
 
5977
- const onParsed = <T>(
5978
- parsed: unknown,
6239
+ const runValidated = <T>(
6240
+ validation: StandardResult<unknown> | Promise<StandardResult<unknown>>,
5979
6241
  ctx: RawContext,
5980
6242
  finalize: (result: unknown, set: CtxSet, ctx: RawContext) => T,
5981
6243
  wrapResponse: (response: Response | ResponseResult) => T,
5982
6244
  ): MaybePromise<T> => {
5983
- let validation: StandardResult<unknown> | Promise<StandardResult<unknown>>
5984
- try {
5985
- validation = bodySchema["~standard"].validate(parsed)
5986
- } catch (err) {
5987
- return logError(err, ctx, finalize, wrapResponse)
5988
- }
5989
6245
  if (validation instanceof Promise) {
5990
6246
  return validation.then(
5991
6247
  (settled) => {
5992
6248
  if (settled.issues !== undefined)
5993
6249
  return wrapResponse(plainValidationError(settled.issues))
5994
- ctx.body = settled.value
5995
- return runDerive(ctx, finalize, wrapResponse)
6250
+ ctx.query = settled.value
6251
+ return runDerive(ctx, route, finalize, wrapResponse, 0)
5996
6252
  },
5997
- (err) => logError(err, ctx, finalize, wrapResponse),
6253
+ (err) => this.fusedFail(err, ctx, route, finalize, wrapResponse),
5998
6254
  )
5999
6255
  }
6000
6256
  if (validation.issues !== undefined)
6001
6257
  return wrapResponse(plainValidationError(validation.issues))
6002
- ctx.body = validation.value
6003
- return runDerive(ctx, finalize, wrapResponse)
6258
+ ctx.query = validation.value
6259
+ return runDerive(ctx, route, finalize, wrapResponse, 0)
6004
6260
  }
6005
6261
 
6006
6262
  return <T>(
@@ -6026,16 +6282,73 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6026
6282
  maxBodyBytes,
6027
6283
  this.protoPoisoning,
6028
6284
  )
6029
- return readBodyFramed(
6030
- source,
6031
- maxBodyBytes,
6032
- this.protoPoisoning,
6033
- (parsed) => onParsed(parsed, ctx, finalize, wrapResponse),
6034
- wrapResponse,
6035
- (err) => logError(err, ctx, finalize, wrapResponse),
6036
- bodySchema,
6037
- this.formOrigins,
6038
- )
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)
6039
6352
  }
6040
6353
  }
6041
6354
 
@@ -6822,17 +7135,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6822
7135
  * semantics remain in the plan; this closure only supplies native params and deadline fallback. */
6823
7136
  private compileBunNativeHandler(
6824
7137
  entry: RouteEntry,
7138
+ method: string,
6825
7139
  paramNames: readonly string[],
6826
7140
  fused: FusedWebRunner | undefined,
6827
7141
  signal: AbortSignal | undefined,
6828
7142
  budget: RequestBudget | undefined,
6829
7143
  peer: PeerPlatform,
6830
7144
  fallback: BunNativeHandler,
7145
+ hookedPlatform?: (request: Request, server: BunPeerServer) => Platform<EnvOf<Ctx>>,
6831
7146
  ): BunNativeHandler {
6832
7147
  // This callback is reached only from Bun's compiled native route table. Bun has already parsed
6833
7148
  // the HTTP framing, so the JSON lane may retain its native fused `json()` parse without weakening
6834
- // the portable/adapted source contract (those sources are never marked).
6835
- 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
6836
7153
  // The direct native lanes bypass `runMatched`, so the route's transport byte cap must be
6837
7154
  // marked here too - otherwise a Bun `listen()` fused route would leave direct `c.req` body
6838
7155
  // reads uncapped. The fallback branches go through `fetchMatched` -> `runMatched`, which marks.
@@ -6861,8 +7178,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6861
7178
  this.wrapWebResponse,
6862
7179
  )
6863
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.
6864
7183
  const capped: FusedWebRunner | undefined =
6865
- inner === undefined || bodyLimit === undefined
7184
+ inner === undefined || bodyLimit === undefined || method === "GET" || method === "HEAD"
6866
7185
  ? inner
6867
7186
  : (source, params, search, fusedSignal, fusedBudget, platform, nativeContext) => {
6868
7187
  const method = source.method
@@ -6899,6 +7218,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6899
7218
  : finishNative(request, outcome)
6900
7219
  }
6901
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
+ }
6902
7232
  if (capped === undefined) {
6903
7233
  return (request) => {
6904
7234
  markFramed(request)
@@ -6923,6 +7253,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6923
7253
  paramNames.length === 1
6924
7254
  ? (params: Record<string, string>) => unroutedParam(params[paramNames[0]!])
6925
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
+ }
6926
7265
  if (capped === undefined) {
6927
7266
  return (request, server) => {
6928
7267
  markFramed(request)
@@ -6949,9 +7288,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6949
7288
  }
6950
7289
  }
6951
7290
 
6952
- /** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
6953
- * hooks or WebSockets retain the single portable dispatcher because those features must run before
6954
- * 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.
6955
7294
  *
6956
7295
  * Bun and the portable router agree on which of two static-and-`:param` paths wins a request, so
6957
7296
  * the table is kept to those paths. Where Bun would otherwise pass a request on to a less specific
@@ -6960,21 +7299,21 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6960
7299
  private buildBunNativeRoutes(
6961
7300
  fallback: BunNativeHandler,
6962
7301
  peer: PeerPlatform,
7302
+ platformFor?: (request: Request, server: BunPeerServer) => Platform<EnvOf<Ctx>>,
6963
7303
  ): BunNativeRoutes | undefined {
6964
- // A `clientIp` trust declaration must run the resolver in `dispatch`, which the fused native lane
6965
- // bypasses - so an app that declares trust routes through the fetch lane (where `c.clientIp`
6966
- // resolves) instead of Bun's native table. The allocation-free default keeps native fusion.
7304
+ const hooked = this.onRequestHooks.length > 0 && !this.bunNativeRequestHooksSafe
6967
7305
  if (
6968
- (this.onRequestHooks.length > 0 && !this.bunNativeRequestHooksSafe) ||
6969
- this.wsRoutes.length > 0 ||
6970
- this.preRouteMountCount > 0 ||
6971
- 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()
6972
7310
  ) {
6973
7311
  return undefined
6974
7312
  }
6975
7313
 
6976
7314
  const routes: BunNativeRoutes = Object.create(null) as BunNativeRoutes
6977
7315
  const mayUseFusedNative =
7316
+ !hooked &&
6978
7317
  this.requestTimeoutMs === 0 &&
6979
7318
  (this.onResponseHooks.length === 0 || this.bunNativeResponseHeadersOnly) &&
6980
7319
  this.onResponseFinalizedHooks.length === 0 &&
@@ -6985,29 +7324,50 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
6985
7324
  const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined
6986
7325
  let count = 0
6987
7326
  const all = this.catalog.entries()
7327
+ const preRouteMounts = this.fetchMounts.filter((mount) => mount.beforeRoutes)
6988
7328
  // A path with a wildcard or a part-literal segment cannot go in the table: Bun reads `/:name.json`
6989
7329
  // as one parameter named `name.json`. These are filed under each run of static segments they
6990
7330
  // start with, which is where a table path they could outrank looks for them.
6991
- 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)
6992
7350
  const lead = all.map((route) => {
6993
7351
  const segments = route.pattern.segments
6994
7352
  const plain = segments.every(
6995
7353
  (segment) => segment.kind === "static" || segment.kind === "param",
6996
7354
  )
7355
+ if (!plain) fileUnder(segments)
6997
7356
  let prefix = ""
6998
7357
  for (const segment of segments) {
6999
- if (!plain) {
7000
- let bucket = under.get(prefix)
7001
- if (bucket === undefined) {
7002
- bucket = []
7003
- under.set(prefix, bucket)
7004
- }
7005
- bucket.push(route)
7006
- }
7007
7358
  if (segment.kind !== "static") break
7008
7359
  prefix += `/${segment.value}`
7009
7360
  }
7010
- 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
7011
7371
  })
7012
7372
  // A preflight hook is intentionally handled by the fallback fetch path. Keeping OPTIONS out
7013
7373
  // of the native lane means Bun dispatches it through CORS's onRequest hook even when the
@@ -7037,7 +7397,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7037
7397
  entry.idempotent !== undefined ||
7038
7398
  !BUN_TABLE_METHODS.includes(method as Method) ||
7039
7399
  (preflightOnFallback && method === "OPTIONS") ||
7040
- 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)))
7041
7402
  ) {
7042
7403
  continue
7043
7404
  }
@@ -7045,19 +7406,23 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7045
7406
  const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined
7046
7407
  const handler = this.compileBunNativeHandler(
7047
7408
  entry,
7409
+ method,
7048
7410
  paramNames,
7049
7411
  fused,
7050
7412
  unboundedSignal,
7051
7413
  unboundedBudget,
7052
7414
  peer,
7053
7415
  fallback,
7416
+ hooked ? platformFor : undefined,
7054
7417
  )
7055
7418
  // The table matched the target as sent; one `fetch` refuses or rewrites goes through it. Left
7056
7419
  // off, `validateHost` costs nothing here: the table holds no wildcard, a `..` parameter falls
7057
7420
  // back, and reading the Host on every request measured about 6% of a bare route.
7058
7421
  tableAt(path, pattern.segments)[method as Method] = this.validateHost
7059
7422
  ? (request, server) =>
7060
- requestTarget(request, true) ? fallback(request, server) : handler(request, server)
7423
+ requestTarget(request, true, this.normalizeTarget)
7424
+ ? fallback(request, server)
7425
+ : handler(request, server)
7061
7426
  : handler
7062
7427
  count += 1
7063
7428
  if (paramNames.length > 0) {
@@ -7109,11 +7474,17 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7109
7474
  * nothing - so any endpoint slower than 10s (a render, an export, a long upstream call) has its
7110
7475
  * connection cut mid-flight regardless of `requestTimeoutMs`. Apps with such endpoints must raise
7111
7476
  * this above their slowest expected response, which is why it is a first-class option and not a
7112
- * 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.
7113
7479
  *
7114
7480
  * `tls` serves HTTPS directly: `app.listen(443, { tls: { cert, key } })` with PEM text or bytes.
7115
7481
  * Requests then arrive with `https:` URLs. Behind a proxy or a platform that terminates TLS, leave
7116
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.
7117
7488
  */
7118
7489
  listen(
7119
7490
  port: number,
@@ -7122,6 +7493,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7122
7493
  readonly hostname?: string
7123
7494
  readonly idleTimeoutSec?: number
7124
7495
  readonly tls?: ListenTlsOptions
7496
+ readonly static?: (request: Request) => MaybePromise<Response | undefined>
7125
7497
  },
7126
7498
  ): RunningServer {
7127
7499
  if (typeof Bun === "undefined") {
@@ -7185,6 +7557,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7185
7557
  return jsonError(500, "internal_error")
7186
7558
  },
7187
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)
7188
7564
  const fallback: BunNativeHandler = (req, server) => {
7189
7565
  // Bun has already framed and bounded this request body in its HTTP parser. Mark the
7190
7566
  // source before the portable fallback runs so body schemas use Bun's native `json()`
@@ -7192,27 +7568,73 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext, Hook
7192
7568
  // Requests. Native route handlers mark themselves in compileBunNativeHandler; this
7193
7569
  // covers every request the native lane does not serve.
7194
7570
  markTrustedBodyFraming(req)
7195
- 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
+ )
7196
7575
  }
7197
7576
  // Bun names the socket peer of a request when its server is asked. A request served from the
7198
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
7199
7583
  const peer: PeerPlatform = {
7200
- [PLATFORM_PEER]: (request) =>
7201
- (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
+ }
7202
7625
  }
7203
- const nativeRoutes =
7204
- wsHandlers === undefined ? this.buildBunNativeRoutes(fallback, peer) : undefined
7205
7626
  const sockets = new Set<{ close(code?: number): void }>()
7206
7627
  this.bunSockets = sockets
7207
7628
  const running = (wsHandlers === undefined
7208
7629
  ? Bun.serve({
7209
7630
  ...listening,
7210
7631
  ...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
7211
- fetch: fallback,
7632
+ fetch: before(fallback),
7212
7633
  })
7213
7634
  : Bun.serve<BunWsData>({
7214
7635
  ...listening,
7215
- fetch: (req, server) => this.bunFetchWithWebSocket(req, server),
7636
+ ...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
7637
+ fetch: before((req, server) => this.bunFetchWithWebSocket(req, server, setIdleTimeout)),
7216
7638
  // Bun's `ServerWebSocket<BunWsData>` is runtime-compatible with the handlers' structural
7217
7639
  // `BunSocket` view (kept local so `Bun.*` types never leak into the published .d.ts); the
7218
7640
  // `unknown` params bridge a TS structural-variance quirk. Round-trip covered by websocket.test.ts.