@nifrajs/core 1.8.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,11 @@
1
+ import {
2
+ admitDeadline,
3
+ createRequestBudget,
4
+ createUnboundedRequestBudget,
5
+ type DeadlineAdmissionOptions,
6
+ NIFRA_DEADLINE_HEADER,
7
+ type RequestBudget,
8
+ } from "@nifrajs/budget"
1
9
  import { FrameworkError, RouteConfigError } from "../errors.ts"
2
10
  import {
3
11
  type AssuranceDeclaration,
@@ -115,6 +123,7 @@ interface RawContext {
115
123
  readonly [CONTEXT_SET]: () => CtxSet | undefined
116
124
  readonly [CONTEXT_SEARCH]: string
117
125
  readonly signal: AbortSignal
126
+ readonly budget: RequestBudget
118
127
  readonly env: unknown
119
128
  readonly waitUntil: (promise: Promise<unknown>) => void
120
129
  readonly boundedBody: (maxBytes?: number) => Promise<Uint8Array>
@@ -192,9 +201,11 @@ interface RouteEntry {
192
201
  type FusedWebRunner = (
193
202
  source: RequestSource,
194
203
  params: Record<string, string>,
195
- search: string,
204
+ search: string | undefined,
196
205
  signal: AbortSignal,
206
+ budget: RequestBudget,
197
207
  platform: Platform | undefined,
208
+ nativeContext: boolean,
198
209
  ) => MaybePromise<Response>
199
210
 
200
211
  /** A registered WebSocket route — just its handler; matching reuses {@link Router} under the GET verb. */
@@ -207,6 +218,11 @@ interface BunUpgradeServer {
207
218
  upgrade(request: Request, options?: { data?: BunWsData }): boolean
208
219
  }
209
220
 
221
+ type BunNativeHandler = (request: Request) => MaybePromise<Response>
222
+ type BunNativeMethodTable = Partial<Record<Method, BunNativeHandler>>
223
+ type BunNativeRoutes = Record<string, BunNativeMethodTable>
224
+ type BunRequestWithParams = Request & { readonly params?: Record<string, string> }
225
+
210
226
  const WS_PASS: WebSocketUpgradeOutcome = { kind: "pass" }
211
227
 
212
228
  /** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
@@ -238,6 +254,27 @@ export type Handler<
238
254
  Ctx = EmptyContext,
239
255
  > = (ctx: Context<Path, S> & Ctx) => MaybePromise<ResponseOf<S>>
240
256
 
257
+ /**
258
+ * The outcome of a capacity-admission decision. `admitted` requests carry a `release` the server calls
259
+ * exactly once when the response is finalized; a shed request carries a ready `429` Response.
260
+ */
261
+ export type AdmissionDecision =
262
+ | { readonly admitted: true; release(): void }
263
+ | { readonly admitted: false; readonly response: Response }
264
+
265
+ /**
266
+ * A capacity-admission gate. Decides, per request, whether the instance has capacity to run it now —
267
+ * bounding *concurrency*, which rate limits (frequency) and deadlines (duration) do not. Provide an
268
+ * implementation (see `@nifrajs/middleware`'s `createAdmissionController`) as {@link ServerOptions.admission}.
269
+ *
270
+ * Enabling it trades the fused native-route lane for native matching + this gate (the gate cannot be a
271
+ * per-request `onRequest` hook without disabling native routes entirely); when unset, the request path
272
+ * is untouched.
273
+ */
274
+ export interface AdmissionController {
275
+ admit(req: Request): AdmissionDecision | Promise<AdmissionDecision>
276
+ }
277
+
241
278
  export interface ServerOptions {
242
279
  /**
243
280
  * Max request body size (bytes), enforced **only when a route declares a body schema** — the cap
@@ -255,8 +292,27 @@ export interface ServerOptions {
255
292
  readonly wsMaxPayloadBytes?: number
256
293
  /** Per-request timeout (ms): a slower request gets a 503 and `ctx.signal` aborts. 0 disables (default). */
257
294
  readonly requestTimeoutMs?: number
295
+ /**
296
+ * Admit the public `x-nifra-deadline` header and let it shorten local work. Disabled by default:
297
+ * services that participate in trusted cross-service deadline propagation opt in explicitly,
298
+ * while ordinary/public HTTP routes pay no header-admission tax and ignore hostile client values.
299
+ */
300
+ readonly acceptInboundDeadlines?: boolean
301
+ /**
302
+ * Maximum time accepted from an inbound `x-nifra-deadline`, in milliseconds. An incoming absolute
303
+ * deadline may shorten this cap (and `requestTimeoutMs`, when configured), never extend it.
304
+ * Default 30_000; applies only when `acceptInboundDeadlines` is enabled and the header is present.
305
+ */
306
+ readonly maxInboundDeadlineMs?: number
258
307
  /** When `listen()`ing, install SIGTERM/SIGINT handlers that gracefully `stop()`. Default false. */
259
308
  readonly gracefulSignals?: boolean
309
+ /**
310
+ * Capacity-admission gate (see {@link AdmissionController}). Bounds concurrent in-flight work so a
311
+ * healthy instance sheds load (`429` + `Retry-After`) instead of accepting more than it can finish.
312
+ * Off by default. Enabling it disables the fused native-route fast path (requests route through the
313
+ * shared matched lane where the gate runs); when unset, the request path pays nothing.
314
+ */
315
+ readonly admission?: AdmissionController
260
316
  /** Structured logger for framework events (redacts secrets). Default: JSON to stderr. */
261
317
  readonly logger?: Logger
262
318
  /**
@@ -475,10 +531,15 @@ const TEXT_DECODER = new TextDecoder()
475
531
  * reused at zero per-request cost.
476
532
  */
477
533
  let neverAbortSignal: AbortSignal | undefined
534
+ let unboundedRequestBudget: RequestBudget | undefined
478
535
  const getNeverAbortSignal = (): AbortSignal => {
479
536
  neverAbortSignal ??= new AbortController().signal
480
537
  return neverAbortSignal
481
538
  }
539
+ const getUnboundedRequestBudget = (): RequestBudget => {
540
+ unboundedRequestBudget ??= createUnboundedRequestBudget(getNeverAbortSignal())
541
+ return unboundedRequestBudget
542
+ }
482
543
 
483
544
  function jsonError(status: number, error: string, headers?: Record<string, string>): Response {
484
545
  return Response.json(
@@ -679,6 +740,13 @@ function decodeParams(raw: Record<string, string>): Record<string, string> | nul
679
740
  return out ?? raw
680
741
  }
681
742
 
743
+ function hasReplacementParam(params: Record<string, string>): boolean {
744
+ for (const key in params) {
745
+ if (params[key]!.includes("\uFFFD")) return true
746
+ }
747
+ return false
748
+ }
749
+
682
750
  /** `ctx.set` carrying the lazy backings (`_headers`, `_cookies`) so `toResponse` can skip allocating
683
751
  * anything when no handler touched `c.set.*`. Server-internal. */
684
752
  type CtxSet = ResponseControls & {
@@ -742,51 +810,92 @@ function statusInit(init?: ResponseInit | number): ResponseInit | undefined {
742
810
  }
743
811
 
744
812
  class RequestContext implements RawContext {
745
- readonly params: Record<string, string>
746
- readonly signal: AbortSignal
747
- readonly env: unknown
748
- readonly waitUntil: (promise: Promise<unknown>) => void
749
- body: unknown
750
- readonly [CONTEXT_SEARCH]: string
751
-
752
- private setValue: CtxSet | undefined
753
- private queryValue: unknown
754
- private queryReady: boolean
755
- private cookiesValue: Readonly<Record<string, string>> | undefined
756
- private readonly source: RequestSource
757
- private readonly maxBodyBytes: number
758
-
813
+ // `declare` keeps TypeScript's class-field emit from first writing `undefined` to every slot; the
814
+ // constructor initializes only the eager request state, while lazy fields remain absent until used.
815
+ declare readonly params: Record<string, string>
816
+ declare body: unknown
817
+ private declare searchValue: string | undefined
818
+ private declare signalValue: AbortSignal | undefined
819
+ private declare budgetValue: RequestBudget | undefined
820
+ private declare platformValue: Platform | undefined
821
+
822
+ private declare setValue: CtxSet | undefined
823
+ private declare queryValue: unknown
824
+ private declare queryReady: boolean
825
+ private declare cookiesValue: Readonly<Record<string, string>> | undefined
826
+ private declare readonly source: RequestSource
827
+ private declare readonly maxBodyBytes: number
828
+
829
+ constructor(source: RequestSource, params: Record<string, string>, maxBodyBytes: number)
759
830
  constructor(
760
831
  source: RequestSource,
761
832
  params: Record<string, string>,
762
- search: string,
833
+ search: string | undefined,
763
834
  signal: AbortSignal,
835
+ budget: RequestBudget,
764
836
  platform: Platform | undefined,
765
837
  maxBodyBytes: number,
838
+ )
839
+ constructor(
840
+ source: RequestSource,
841
+ params: Record<string, string>,
842
+ searchOrMaxBodyBytes: string | number | undefined,
843
+ signal?: AbortSignal,
844
+ budget?: RequestBudget,
845
+ platform?: Platform,
846
+ maxBodyBytes?: number,
766
847
  ) {
767
848
  this.source = source
768
849
  this.params = params
769
- this[CONTEXT_SEARCH] = search
770
- this.signal = signal
771
- this.env = platform?.env
772
- this.waitUntil = platform?.waitUntil ?? fallbackWaitUntil
773
- this.maxBodyBytes = maxBodyBytes
774
- this.setValue = undefined
775
- this.body = undefined
776
- this.queryValue = undefined
777
- this.queryReady = false
778
- this.cookiesValue = undefined
850
+ if (typeof searchOrMaxBodyBytes === "number") {
851
+ this.maxBodyBytes = searchOrMaxBodyBytes
852
+ return
853
+ }
854
+ if (searchOrMaxBodyBytes !== undefined) this.searchValue = searchOrMaxBodyBytes
855
+ this.signalValue = signal
856
+ this.budgetValue = budget
857
+ if (platform !== undefined) this.platformValue = platform
858
+ this.maxBodyBytes = maxBodyBytes as number
859
+ }
860
+
861
+ static native(
862
+ source: RequestSource,
863
+ params: Record<string, string>,
864
+ maxBodyBytes: number,
865
+ ): RequestContext {
866
+ return new RequestContext(source, params, maxBodyBytes)
779
867
  }
780
868
 
781
869
  [CONTEXT_SET](): CtxSet | undefined {
782
870
  return this.setValue
783
871
  }
784
872
 
873
+ get [CONTEXT_SEARCH](): string {
874
+ this.searchValue ??= searchOf(this.source.url)
875
+ return this.searchValue
876
+ }
877
+
785
878
  get set(): CtxSet {
786
879
  this.setValue ??= new LazyResponseControls()
787
880
  return this.setValue
788
881
  }
789
882
 
883
+ get signal(): AbortSignal {
884
+ return this.signalValue ?? getNeverAbortSignal()
885
+ }
886
+
887
+ get budget(): RequestBudget {
888
+ return this.budgetValue ?? getUnboundedRequestBudget()
889
+ }
890
+
891
+ get env(): unknown {
892
+ return this.platformValue?.env
893
+ }
894
+
895
+ get waitUntil(): (promise: Promise<unknown>) => void {
896
+ return this.platformValue?.waitUntil ?? fallbackWaitUntil
897
+ }
898
+
790
899
  get req(): Request {
791
900
  return requestOf(this.source)
792
901
  }
@@ -922,8 +1031,9 @@ const JSON_CT_HEADERS = new Headers({
922
1031
  })
923
1032
  const JSON_INIT_200: ResponseInit = { status: 200, headers: JSON_CT_HEADERS }
924
1033
 
925
- /** Fused-lane respond when the handler ran with NO context (so `c.set` can't exist): the fast
926
- * JSON respond directly, with `toResponse` + the empty controls as the exact-semantics fallback. */
1034
+ /** Fused-lane respond when `c.set` is untouched. Bun 1.3's native `Response.json` now beats the
1035
+ * older hand-inlined stringify + Response construction on this lane while preserving the exact
1036
+ * body/content-type contract; keep the generic fallback for non-JSON values. */
927
1037
  function fusedRespondNoSet(result: unknown): Response {
928
1038
  if (
929
1039
  result !== undefined &&
@@ -932,8 +1042,7 @@ function fusedRespondNoSet(result: unknown): Response {
932
1042
  result !== null &&
933
1043
  !isResponseResult(result)
934
1044
  ) {
935
- const body = JSON.stringify(result) as string | undefined
936
- if (body !== undefined) return new Response(body, JSON_INIT_200)
1045
+ return Response.json(result)
937
1046
  }
938
1047
  return toResponse(result as HandlerResult, EMPTY_RESPONSE_CONTROLS)
939
1048
  }
@@ -1118,7 +1227,12 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1118
1227
  private readonly maxBodyBytes: number
1119
1228
  private readonly wsMaxPayloadBytes: number
1120
1229
  private readonly requestTimeoutMs: number
1230
+ private readonly acceptInboundDeadlines: boolean
1231
+ private readonly maxInboundDeadlineMs: number
1232
+ private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
1121
1233
  private readonly gracefulSignals: boolean
1234
+ /** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
1235
+ private readonly capacityGate: AdmissionController | undefined
1122
1236
  private readonly logger: Logger
1123
1237
  /** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
1124
1238
  private readonly defaultOnValidationError?: RouteSchema["onValidationError"]
@@ -1165,7 +1279,20 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1165
1279
  this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
1166
1280
  this.wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? this.maxBodyBytes
1167
1281
  this.requestTimeoutMs = options.requestTimeoutMs ?? 0
1282
+ this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false
1283
+ this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000
1284
+ this.deadlineAdmissionOptions = Object.freeze({
1285
+ localTimeoutMs: this.requestTimeoutMs,
1286
+ maxInboundDeadlineMs: this.maxInboundDeadlineMs,
1287
+ })
1288
+ if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs < 0) {
1289
+ throw new RangeError("requestTimeoutMs must be a finite non-negative number")
1290
+ }
1291
+ if (!Number.isFinite(this.maxInboundDeadlineMs) || this.maxInboundDeadlineMs <= 0) {
1292
+ throw new RangeError("maxInboundDeadlineMs must be a finite positive number")
1293
+ }
1168
1294
  this.gracefulSignals = options.gracefulSignals ?? false
1295
+ this.capacityGate = options.admission
1169
1296
  this.logger = options.logger ?? jsonLogger()
1170
1297
  this.defaultOnValidationError = options.onValidationError
1171
1298
  this.bunServer = undefined
@@ -1759,6 +1886,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1759
1886
  private fetchSource(
1760
1887
  source: RequestSource,
1761
1888
  platform?: Platform<EnvOf<Ctx>>,
1889
+ ): MaybePromise<Response> {
1890
+ // Off path (default): straight through — one property check, no closure, no promise.
1891
+ if (this.capacityGate === undefined) return this.fetchSourceInner(source, platform)
1892
+ return this.admitGated(requestOf(source), () => this.fetchSourceInner(source, platform))
1893
+ }
1894
+
1895
+ private fetchSourceInner(
1896
+ source: RequestSource,
1897
+ platform?: Platform<EnvOf<Ctx>>,
1762
1898
  ): MaybePromise<Response> {
1763
1899
  // Non-`async` on purpose: `dispatch` may return a `Response` *synchronously* (the bare-route fast
1764
1900
  // path, see RouteEntry.bare), and an `async fetch` would wrap every such result in a redundant
@@ -1784,6 +1920,88 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1784
1920
  : this.applyOnResponseAndFinalize(outcome, this.takeResponseRequest(source))
1785
1921
  }
1786
1922
 
1923
+ /** Web response path when Bun already matched the route. The lifecycle and response hooks remain
1924
+ * exactly the same as {@link fetchSource}; only portable URL scanning + trie lookup are skipped. */
1925
+ private fetchMatched(
1926
+ source: RequestSource,
1927
+ entry: RouteEntry,
1928
+ params: Record<string, string>,
1929
+ ): MaybePromise<Response> {
1930
+ if (this.capacityGate === undefined) return this.fetchMatchedInner(source, entry, params)
1931
+ return this.admitGated(requestOf(source), () => this.fetchMatchedInner(source, entry, params))
1932
+ }
1933
+
1934
+ private fetchMatchedInner(
1935
+ source: RequestSource,
1936
+ entry: RouteEntry,
1937
+ params: Record<string, string>,
1938
+ ): MaybePromise<Response> {
1939
+ const outcome = this.runMatched(
1940
+ source,
1941
+ undefined,
1942
+ entry,
1943
+ params,
1944
+ undefined,
1945
+ toResponse,
1946
+ IDENTITY_RESPONSE,
1947
+ RESPONSE_TIMEOUT,
1948
+ true,
1949
+ )
1950
+ if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
1951
+ return outcome
1952
+ }
1953
+ return outcome instanceof Promise
1954
+ ? outcome.then((response) => this.applyOnResponseAndFinalize(response, requestOf(source)))
1955
+ : this.applyOnResponseAndFinalize(outcome, requestOf(source))
1956
+ }
1957
+
1958
+ /**
1959
+ * Run `produce` under the capacity gate: admit → run → release exactly once when the response is
1960
+ * produced (or the run throws). Only reached when {@link capacityGate} is set, so the off path pays
1961
+ * nothing. The slot is held for the duration of handler execution, not the streaming of the body —
1962
+ * capacity here bounds concurrent *work*, matching how in-flight is counted.
1963
+ */
1964
+ private admitGated(req: Request, produce: () => MaybePromise<Response>): MaybePromise<Response> {
1965
+ const decision = (this.capacityGate as AdmissionController).admit(req)
1966
+ return decision instanceof Promise
1967
+ ? decision.then((settled) => this.runAdmitted(settled, produce))
1968
+ : this.runAdmitted(decision, produce)
1969
+ }
1970
+
1971
+ private runAdmitted(
1972
+ decision: AdmissionDecision,
1973
+ produce: () => MaybePromise<Response>,
1974
+ ): MaybePromise<Response> {
1975
+ if (!decision.admitted) return decision.response // shed: ready 429, no slot held
1976
+ let released = false
1977
+ const release = (): void => {
1978
+ if (released) return
1979
+ released = true
1980
+ decision.release()
1981
+ }
1982
+ let outcome: MaybePromise<Response>
1983
+ try {
1984
+ outcome = produce()
1985
+ } catch (error) {
1986
+ release()
1987
+ throw error
1988
+ }
1989
+ if (outcome instanceof Promise) {
1990
+ return outcome.then(
1991
+ (response) => {
1992
+ release()
1993
+ return response
1994
+ },
1995
+ (error) => {
1996
+ release()
1997
+ throw error
1998
+ },
1999
+ )
2000
+ }
2001
+ release()
2002
+ return outcome
2003
+ }
2004
+
1787
2005
  /**
1788
2006
  * Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` — the
1789
2007
  * `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
@@ -1801,8 +2019,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1801
2019
  source: RequestSource,
1802
2020
  platform?: Platform<EnvOf<Ctx>>,
1803
2021
  ): MaybePromise<NodeServeOutcome> {
1804
- // onResponse hooks transform a Response, so they force the Web path; wrap its result.
1805
- if (this.onResponseHooks.length > 0 || this.onResponseFinalizedHooks.length > 0) {
2022
+ // onResponse hooks transform a Response, and the capacity gate wraps the Web response path — both
2023
+ // force the Web path here (the gated `fetchSource` admits/sheds/releases); wrap its result.
2024
+ if (
2025
+ this.onResponseHooks.length > 0 ||
2026
+ this.onResponseFinalizedHooks.length > 0 ||
2027
+ this.capacityGate !== undefined
2028
+ ) {
1806
2029
  const response = this.fetchSource(source, platform)
1807
2030
  return response instanceof Promise
1808
2031
  ? response.then((settled) => ({ kind: "response", response: settled }))
@@ -1838,10 +2061,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1838
2061
  const url = urlPartsOf(req.url)
1839
2062
  const match = this.wsRouter.find("GET", url.pathname)
1840
2063
  if (!match.found) return WS_PASS // upgrade header, no WS route here → normal routing decides
1841
- const params =
1842
- match.params === EMPTY_PARAMS || !url.pathname.includes("%")
1843
- ? match.params
1844
- : decodeParams(match.params)
2064
+ // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
2065
+ // already did and made every plain dynamic route pay for unrelated static path bytes.
2066
+ const params = match.params === EMPTY_PARAMS ? match.params : decodeParams(match.params)
1845
2067
  if (params === null) return { kind: "reject", response: jsonError(400, "malformed_path") }
1846
2068
  const handler = match.payload.handler
1847
2069
  // Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ the runtime created the registry.
@@ -1866,11 +2088,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1866
2088
  if (handler.upgrade === undefined) {
1867
2089
  return { kind: "upgrade", handler, data: undefined, pubsub }
1868
2090
  }
2091
+ const upgradeSignal = getNeverAbortSignal()
1869
2092
  const ctx = new RequestContext(
1870
2093
  req,
1871
2094
  params,
1872
2095
  url.search,
1873
- getNeverAbortSignal(),
2096
+ upgradeSignal,
2097
+ createUnboundedRequestBudget(upgradeSignal),
1874
2098
  platform,
1875
2099
  this.maxBodyBytes,
1876
2100
  )
@@ -2042,38 +2266,95 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2042
2266
  return wrapResponse(jsonError(404, "not_found"))
2043
2267
  }
2044
2268
 
2045
- const params =
2046
- match.params === EMPTY_PARAMS || !url.pathname.includes("%")
2047
- ? match.params
2048
- : decodeParams(match.params)
2269
+ // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
2270
+ // already did and made every plain dynamic route pay for unrelated static path bytes.
2271
+ const params = match.params === EMPTY_PARAMS ? match.params : decodeParams(match.params)
2049
2272
  if (params === null) {
2050
2273
  return wrapResponse(jsonError(400, "malformed_path"))
2051
2274
  }
2052
2275
 
2053
- const entry = match.payload
2054
- // A per-request cancellation signal. Only allocate a real controller when a
2055
- // timeout is armed; otherwise share a never-aborting signal so the (common)
2056
- // no-timeout path pays nothing.
2276
+ return this.runMatched(
2277
+ source,
2278
+ platform,
2279
+ match.payload,
2280
+ params,
2281
+ url.search,
2282
+ finalize,
2283
+ wrapResponse,
2284
+ onTimeout,
2285
+ webFast,
2286
+ )
2287
+ }
2288
+
2289
+ /** Run a route that has already been matched by the runtime or Nifra's portable router. */
2290
+ private runMatched<T>(
2291
+ source: RequestSource,
2292
+ platform: Platform | undefined,
2293
+ entry: RouteEntry,
2294
+ params: Record<string, string>,
2295
+ search: string | undefined,
2296
+ finalize: (result: unknown, set: CtxSet) => T,
2297
+ wrapResponse: (response: Response) => T,
2298
+ onTimeout: () => T,
2299
+ webFast: boolean,
2300
+ ): MaybePromise<T> {
2301
+ // Translate the absolute wire deadline once, clamp it to local policy, then use the resulting
2302
+ // duration for both c.signal and c.budget. A client can only shorten work, never extend it.
2303
+ // Most requests have neither a local timeout nor a propagated deadline. Detect that case with
2304
+ // one header lookup and skip policy validation, wall-clock sampling, and admission objects. A
2305
+ // present wire deadline still goes through the full fail-closed parser/clamp below.
2306
+ const admission = !this.acceptInboundDeadlines
2307
+ ? this.requestTimeoutMs === 0
2308
+ ? undefined
2309
+ : {
2310
+ ok: true as const,
2311
+ inherited: false,
2312
+ timeoutMs: this.requestTimeoutMs,
2313
+ deadline: Math.floor(Date.now() + this.requestTimeoutMs),
2314
+ }
2315
+ : this.requestTimeoutMs === 0 && headerOf(source, NIFRA_DEADLINE_HEADER) === null
2316
+ ? undefined
2317
+ : admitDeadline(source.headers, this.deadlineAdmissionOptions)
2318
+ if (admission !== undefined && !admission.ok) {
2319
+ return wrapResponse(jsonError(admission.status, admission.reason))
2320
+ }
2321
+ const effectiveTimeoutMs = admission?.timeoutMs ?? 0
2322
+
2323
+ // Only allocate a controller for a finite budget; the historical no-timeout path remains
2324
+ // allocation-light and exposes an unbounded budget that is never propagated on the wire.
2057
2325
  let controller: AbortController | undefined
2058
2326
  let signal = getNeverAbortSignal()
2059
- if (this.requestTimeoutMs > 0) {
2327
+ if (effectiveTimeoutMs > 0) {
2060
2328
  controller = new AbortController()
2061
2329
  signal = controller.signal
2062
2330
  }
2331
+ const budget =
2332
+ controller === undefined
2333
+ ? getUnboundedRequestBudget()
2334
+ : createRequestBudget({ deadline: admission!.deadline as number, signal })
2063
2335
  // A bare route runs synchronously (no schema/derives/hooks); everything else keeps the full async
2064
2336
  // lifecycle. `runBare` returns a value directly for a sync handler — no promise.
2065
2337
  let outcome: MaybePromise<T>
2066
2338
  if (webFast && entry.fusedWeb !== undefined) {
2067
2339
  // Fused Web lane (bare route, no around): one closure end to end. Sound cast — webFast is
2068
2340
  // passed only by the `fetch` path, where T = Response by construction.
2069
- outcome = entry.fusedWeb(source, params, url.search, signal, platform) as MaybePromise<T>
2341
+ outcome = entry.fusedWeb(
2342
+ source,
2343
+ params,
2344
+ search,
2345
+ signal,
2346
+ budget,
2347
+ platform,
2348
+ false,
2349
+ ) as MaybePromise<T>
2070
2350
  } else if (entry.contextlessBare) {
2071
2351
  outcome = this.runContextlessBare(
2072
2352
  entry,
2073
2353
  source,
2074
2354
  params,
2075
- url.search,
2355
+ search,
2076
2356
  signal,
2357
+ budget,
2077
2358
  platform,
2078
2359
  finalize,
2079
2360
  wrapResponse,
@@ -2084,8 +2365,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2084
2365
  const ctx = new RequestContext(
2085
2366
  source,
2086
2367
  params,
2087
- url.search,
2368
+ search,
2088
2369
  signal,
2370
+ budget,
2089
2371
  platform,
2090
2372
  this.maxBodyBytes,
2091
2373
  )
@@ -2118,7 +2400,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2118
2400
  // The request timeout only bounds work that is actually pending — a synchronous (bare) result is
2119
2401
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
2120
2402
  if (controller !== undefined && outcome instanceof Promise) {
2121
- return this.withTimeout(outcome, controller, onTimeout)
2403
+ const timedOut =
2404
+ admission?.inherited === true
2405
+ ? () => wrapResponse(jsonError(504, "deadline_exceeded"))
2406
+ : onTimeout
2407
+ return this.withTimeout(
2408
+ outcome,
2409
+ controller,
2410
+ timedOut,
2411
+ Math.max(0, Math.ceil(budget.remaining())),
2412
+ )
2122
2413
  }
2123
2414
  return outcome
2124
2415
  }
@@ -2129,8 +2420,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2129
2420
  entry: RouteEntry,
2130
2421
  source: RequestSource,
2131
2422
  params: Record<string, string>,
2132
- search: string,
2423
+ search: string | undefined,
2133
2424
  signal: AbortSignal,
2425
+ budget: RequestBudget,
2134
2426
  platform: Platform | undefined,
2135
2427
  finalize: (result: unknown, set: CtxSet) => T,
2136
2428
  wrapResponse: (response: Response) => T,
@@ -2139,13 +2431,31 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2139
2431
  try {
2140
2432
  result = (entry.handler as unknown as ContextlessHandler)()
2141
2433
  } catch (err) {
2142
- return this.contextlessBareError(err, source, params, search, signal, platform, wrapResponse)
2434
+ return this.contextlessBareError(
2435
+ err,
2436
+ source,
2437
+ params,
2438
+ search,
2439
+ signal,
2440
+ budget,
2441
+ platform,
2442
+ wrapResponse,
2443
+ )
2143
2444
  }
2144
2445
  if (result instanceof Promise) {
2145
2446
  return result.then(
2146
2447
  (value) => finalize(value, EMPTY_RESPONSE_CONTROLS),
2147
2448
  (err) =>
2148
- this.contextlessBareError(err, source, params, search, signal, platform, wrapResponse),
2449
+ this.contextlessBareError(
2450
+ err,
2451
+ source,
2452
+ params,
2453
+ search,
2454
+ signal,
2455
+ budget,
2456
+ platform,
2457
+ wrapResponse,
2458
+ ),
2149
2459
  )
2150
2460
  }
2151
2461
  return finalize(result, EMPTY_RESPONSE_CONTROLS)
@@ -2155,13 +2465,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2155
2465
  err: unknown,
2156
2466
  source: RequestSource,
2157
2467
  params: Record<string, string>,
2158
- search: string,
2468
+ search: string | undefined,
2159
2469
  signal: AbortSignal,
2470
+ budget: RequestBudget,
2160
2471
  platform: Platform | undefined,
2161
2472
  wrapResponse: (response: Response) => T,
2162
2473
  ): T {
2163
2474
  if (err instanceof Response) return wrapResponse(err)
2164
- const ctx = new RequestContext(source, params, search, signal, platform, this.maxBodyBytes)
2475
+ const ctx = new RequestContext(
2476
+ source,
2477
+ params,
2478
+ search,
2479
+ signal,
2480
+ budget,
2481
+ platform,
2482
+ this.maxBodyBytes,
2483
+ )
2165
2484
  this.logRequestError(err, ctx)
2166
2485
  return wrapResponse(jsonError(500, "internal_error"))
2167
2486
  }
@@ -2221,29 +2540,43 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2221
2540
  // `() => ...` can't observe the context — skip allocating one entirely (errors still build
2222
2541
  // one for the structured log, exactly like runContextlessBare).
2223
2542
  const contextlessHandler = handler as unknown as ContextlessHandler
2224
- return (source, params, search, signal, platform) => {
2543
+ return (source, params, search, signal, budget, platform, nativeContext) => {
2225
2544
  let result: unknown
2226
2545
  try {
2227
2546
  result = contextlessHandler()
2228
2547
  } catch (err) {
2229
2548
  return logError(
2230
2549
  err,
2231
- new RequestContext(source, params, search, signal, platform, maxBodyBytes),
2550
+ nativeContext
2551
+ ? RequestContext.native(source, params, maxBodyBytes)
2552
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes),
2232
2553
  )
2233
2554
  }
2234
2555
  if (result instanceof Promise) {
2235
2556
  return result.then(fusedRespondNoSet, (err) =>
2236
2557
  logError(
2237
2558
  err,
2238
- new RequestContext(source, params, search, signal, platform, maxBodyBytes),
2559
+ nativeContext
2560
+ ? RequestContext.native(source, params, maxBodyBytes)
2561
+ : new RequestContext(
2562
+ source,
2563
+ params,
2564
+ search,
2565
+ signal,
2566
+ budget,
2567
+ platform,
2568
+ maxBodyBytes,
2569
+ ),
2239
2570
  ),
2240
2571
  )
2241
2572
  }
2242
2573
  return fusedRespondNoSet(result)
2243
2574
  }
2244
2575
  }
2245
- return (source, params, search, signal, platform) => {
2246
- const ctx = new RequestContext(source, params, search, signal, platform, maxBodyBytes)
2576
+ return (source, params, search, signal, budget, platform, nativeContext) => {
2577
+ const ctx = nativeContext
2578
+ ? RequestContext.native(source, params, maxBodyBytes)
2579
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes)
2247
2580
  if (decorations !== undefined) Object.assign(ctx, decorations)
2248
2581
  let result: unknown
2249
2582
  try {
@@ -2688,13 +3021,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2688
3021
  work: Promise<T>,
2689
3022
  controller: AbortController,
2690
3023
  onTimeout: () => T,
3024
+ timeoutMs: number,
2691
3025
  ): Promise<T> {
2692
3026
  let timer: ReturnType<typeof setTimeout> | undefined
2693
3027
  const timeout = new Promise<T>((resolve) => {
2694
3028
  timer = setTimeout(() => {
2695
3029
  controller.abort()
2696
3030
  resolve(onTimeout())
2697
- }, this.requestTimeoutMs)
3031
+ }, timeoutMs)
2698
3032
  })
2699
3033
  try {
2700
3034
  return await Promise.race([work, timeout])
@@ -2853,6 +3187,166 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2853
3187
  return readBoundedJsonSource(req, this.maxBodyBytes)
2854
3188
  }
2855
3189
 
3190
+ /** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
3191
+ * hooks or WebSockets retain the single portable dispatcher because those features must run before
3192
+ * route selection/upgrade. Named wildcards also stay on the fallback until Bun exposes their raw
3193
+ * capture semantics; static and `:param` routes take the native lane. */
3194
+ private buildBunNativeRoutes(): BunNativeRoutes | undefined {
3195
+ if (this.onRequestHooks.length > 0 || this.wsRouteCount > 0) return undefined
3196
+
3197
+ const routes: BunNativeRoutes = Object.create(null) as BunNativeRoutes
3198
+ const mayUseFusedNative =
3199
+ this.requestTimeoutMs === 0 &&
3200
+ this.onResponseHooks.length === 0 &&
3201
+ this.onResponseFinalizedHooks.length === 0 &&
3202
+ // The capacity gate must wrap every request; the fused lane bypasses fetchMatched, so enabling
3203
+ // admission drops fusion (native matching stays) and routes through the gated matched lane.
3204
+ this.capacityGate === undefined
3205
+ const unboundedSignal = mayUseFusedNative ? getNeverAbortSignal() : undefined
3206
+ const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined
3207
+ let count = 0
3208
+ for (const { method, path, entry } of this.rawEntries) {
3209
+ if (path.includes("*")) continue
3210
+ let methods = routes[path]
3211
+ if (methods === undefined) {
3212
+ methods = Object.create(null) as BunNativeMethodTable
3213
+ routes[path] = methods
3214
+ }
3215
+ const paramNames = path
3216
+ .split("/")
3217
+ .filter((segment) => segment.charCodeAt(0) === 58 /* : */)
3218
+ .map((segment) => segment.slice(1))
3219
+ const fused = mayUseFusedNative ? entry.fusedWeb : undefined
3220
+
3221
+ if (fused !== undefined) {
3222
+ // Deliberately choose deadline-aware vs zero-admission closures HERE instead of branching on
3223
+ // `acceptInboundDeadlines` per request. JSC can then flatten the default closure through the
3224
+ // handler + Response.json; retaining that route-invariant branch cost ~7% on a dynamic GET.
3225
+ if (paramNames.length === 0) {
3226
+ methods[method] = this.acceptInboundDeadlines
3227
+ ? (request) =>
3228
+ request.headers.get(NIFRA_DEADLINE_HEADER) !== null
3229
+ ? this.fetchMatched(request, entry, EMPTY_PARAMS)
3230
+ : fused(
3231
+ request,
3232
+ EMPTY_PARAMS,
3233
+ undefined,
3234
+ unboundedSignal as AbortSignal,
3235
+ unboundedBudget as RequestBudget,
3236
+ undefined,
3237
+ true,
3238
+ )
3239
+ : (request) =>
3240
+ fused(
3241
+ request,
3242
+ EMPTY_PARAMS,
3243
+ undefined,
3244
+ unboundedSignal as AbortSignal,
3245
+ unboundedBudget as RequestBudget,
3246
+ undefined,
3247
+ true,
3248
+ )
3249
+ } else if (paramNames.length === 1) {
3250
+ const paramName = paramNames[0]!
3251
+ const handler = entry.handler
3252
+ const decorations = entry.hasDecorations ? entry.decorations : undefined
3253
+ const fail = (error: unknown, ctx: RawContext): Response => {
3254
+ if (error instanceof Response) return error
3255
+ this.logRequestError(error, ctx)
3256
+ return jsonError(500, "internal_error")
3257
+ }
3258
+ methods[method] = this.acceptInboundDeadlines
3259
+ ? (request) => {
3260
+ const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
3261
+ if (params[paramName]?.includes("\uFFFD") === true) return this.fetchSource(request)
3262
+ if (request.headers.get(NIFRA_DEADLINE_HEADER) !== null) {
3263
+ return this.fetchMatched(request, entry, params)
3264
+ }
3265
+ const ctx = RequestContext.native(request, params, this.maxBodyBytes)
3266
+ if (decorations !== undefined) Object.assign(ctx, decorations)
3267
+ let result: unknown
3268
+ try {
3269
+ result = handler(ctx)
3270
+ } catch (error) {
3271
+ return fail(error, ctx)
3272
+ }
3273
+ return result instanceof Promise
3274
+ ? result.then(
3275
+ (value) => fusedRespond(value, ctx),
3276
+ (error) => fail(error, ctx),
3277
+ )
3278
+ : fusedRespond(result, ctx)
3279
+ }
3280
+ : (request) => {
3281
+ const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
3282
+ if (params[paramName]?.includes("\uFFFD") === true) return this.fetchSource(request)
3283
+ const ctx = RequestContext.native(request, params, this.maxBodyBytes)
3284
+ if (decorations !== undefined) Object.assign(ctx, decorations)
3285
+ let result: unknown
3286
+ try {
3287
+ result = handler(ctx)
3288
+ } catch (error) {
3289
+ return fail(error, ctx)
3290
+ }
3291
+ return result instanceof Promise
3292
+ ? result.then(
3293
+ (value) => fusedRespond(value, ctx),
3294
+ (error) => fail(error, ctx),
3295
+ )
3296
+ : fusedRespond(result, ctx)
3297
+ }
3298
+ } else {
3299
+ methods[method] = this.acceptInboundDeadlines
3300
+ ? (request) => {
3301
+ const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
3302
+ if (hasReplacementParam(params)) return this.fetchSource(request)
3303
+ return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
3304
+ ? this.fetchMatched(request, entry, params)
3305
+ : fused(
3306
+ request,
3307
+ params,
3308
+ undefined,
3309
+ unboundedSignal as AbortSignal,
3310
+ unboundedBudget as RequestBudget,
3311
+ undefined,
3312
+ true,
3313
+ )
3314
+ }
3315
+ : (request) => {
3316
+ const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
3317
+ if (hasReplacementParam(params)) return this.fetchSource(request)
3318
+ return fused(
3319
+ request,
3320
+ params,
3321
+ undefined,
3322
+ unboundedSignal as AbortSignal,
3323
+ unboundedBudget as RequestBudget,
3324
+ undefined,
3325
+ true,
3326
+ )
3327
+ }
3328
+ }
3329
+ } else if (paramNames.length === 0) {
3330
+ methods[method] = (request) => this.fetchMatched(request, entry, EMPTY_PARAMS)
3331
+ } else if (paramNames.length === 1) {
3332
+ const paramName = paramNames[0]!
3333
+ methods[method] = (request) => {
3334
+ const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
3335
+ if (params[paramName]?.includes("\uFFFD") === true) return this.fetchSource(request)
3336
+ return this.fetchMatched(request, entry, params)
3337
+ }
3338
+ } else {
3339
+ methods[method] = (request) => {
3340
+ const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
3341
+ if (hasReplacementParam(params)) return this.fetchSource(request)
3342
+ return this.fetchMatched(request, entry, params)
3343
+ }
3344
+ }
3345
+ count += 1
3346
+ }
3347
+ return count === 0 ? undefined : routes
3348
+ }
3349
+
2856
3350
  /**
2857
3351
  * Start a `Bun.serve` instance bound to `port` (use `0` for an ephemeral port).
2858
3352
  *
@@ -2889,8 +3383,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2889
3383
  ? undefined
2890
3384
  : (getWsRuntime() as WsRuntime).bunHandlers(this.topics as TopicRegistry)
2891
3385
  const reusePort = options?.reusePort === true
3386
+ const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes() : undefined
2892
3387
  const running = (wsHandlers === undefined
2893
- ? Bun.serve({ port, reusePort, fetch: (req: Request) => this.fetch(req) })
3388
+ ? Bun.serve({
3389
+ port,
3390
+ reusePort,
3391
+ ...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
3392
+ fetch: (req: Request) => this.fetch(req),
3393
+ })
2894
3394
  : Bun.serve<BunWsData>({
2895
3395
  port,
2896
3396
  reusePort,