@nifrajs/core 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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. */
@@ -255,6 +271,18 @@ export interface ServerOptions {
255
271
  readonly wsMaxPayloadBytes?: number
256
272
  /** Per-request timeout (ms): a slower request gets a 503 and `ctx.signal` aborts. 0 disables (default). */
257
273
  readonly requestTimeoutMs?: number
274
+ /**
275
+ * Admit the public `x-nifra-deadline` header and let it shorten local work. Disabled by default:
276
+ * services that participate in trusted cross-service deadline propagation opt in explicitly,
277
+ * while ordinary/public HTTP routes pay no header-admission tax and ignore hostile client values.
278
+ */
279
+ readonly acceptInboundDeadlines?: boolean
280
+ /**
281
+ * Maximum time accepted from an inbound `x-nifra-deadline`, in milliseconds. An incoming absolute
282
+ * deadline may shorten this cap (and `requestTimeoutMs`, when configured), never extend it.
283
+ * Default 30_000; applies only when `acceptInboundDeadlines` is enabled and the header is present.
284
+ */
285
+ readonly maxInboundDeadlineMs?: number
258
286
  /** When `listen()`ing, install SIGTERM/SIGINT handlers that gracefully `stop()`. Default false. */
259
287
  readonly gracefulSignals?: boolean
260
288
  /** Structured logger for framework events (redacts secrets). Default: JSON to stderr. */
@@ -475,10 +503,15 @@ const TEXT_DECODER = new TextDecoder()
475
503
  * reused at zero per-request cost.
476
504
  */
477
505
  let neverAbortSignal: AbortSignal | undefined
506
+ let unboundedRequestBudget: RequestBudget | undefined
478
507
  const getNeverAbortSignal = (): AbortSignal => {
479
508
  neverAbortSignal ??= new AbortController().signal
480
509
  return neverAbortSignal
481
510
  }
511
+ const getUnboundedRequestBudget = (): RequestBudget => {
512
+ unboundedRequestBudget ??= createUnboundedRequestBudget(getNeverAbortSignal())
513
+ return unboundedRequestBudget
514
+ }
482
515
 
483
516
  function jsonError(status: number, error: string, headers?: Record<string, string>): Response {
484
517
  return Response.json(
@@ -679,6 +712,13 @@ function decodeParams(raw: Record<string, string>): Record<string, string> | nul
679
712
  return out ?? raw
680
713
  }
681
714
 
715
+ function hasReplacementParam(params: Record<string, string>): boolean {
716
+ for (const key in params) {
717
+ if (params[key]!.includes("\uFFFD")) return true
718
+ }
719
+ return false
720
+ }
721
+
682
722
  /** `ctx.set` carrying the lazy backings (`_headers`, `_cookies`) so `toResponse` can skip allocating
683
723
  * anything when no handler touched `c.set.*`. Server-internal. */
684
724
  type CtxSet = ResponseControls & {
@@ -742,51 +782,92 @@ function statusInit(init?: ResponseInit | number): ResponseInit | undefined {
742
782
  }
743
783
 
744
784
  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
-
785
+ // `declare` keeps TypeScript's class-field emit from first writing `undefined` to every slot; the
786
+ // constructor initializes only the eager request state, while lazy fields remain absent until used.
787
+ declare readonly params: Record<string, string>
788
+ declare body: unknown
789
+ private declare searchValue: string | undefined
790
+ private declare signalValue: AbortSignal | undefined
791
+ private declare budgetValue: RequestBudget | undefined
792
+ private declare platformValue: Platform | undefined
793
+
794
+ private declare setValue: CtxSet | undefined
795
+ private declare queryValue: unknown
796
+ private declare queryReady: boolean
797
+ private declare cookiesValue: Readonly<Record<string, string>> | undefined
798
+ private declare readonly source: RequestSource
799
+ private declare readonly maxBodyBytes: number
800
+
801
+ constructor(source: RequestSource, params: Record<string, string>, maxBodyBytes: number)
759
802
  constructor(
760
803
  source: RequestSource,
761
804
  params: Record<string, string>,
762
- search: string,
805
+ search: string | undefined,
763
806
  signal: AbortSignal,
807
+ budget: RequestBudget,
764
808
  platform: Platform | undefined,
765
809
  maxBodyBytes: number,
810
+ )
811
+ constructor(
812
+ source: RequestSource,
813
+ params: Record<string, string>,
814
+ searchOrMaxBodyBytes: string | number | undefined,
815
+ signal?: AbortSignal,
816
+ budget?: RequestBudget,
817
+ platform?: Platform,
818
+ maxBodyBytes?: number,
766
819
  ) {
767
820
  this.source = source
768
821
  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
822
+ if (typeof searchOrMaxBodyBytes === "number") {
823
+ this.maxBodyBytes = searchOrMaxBodyBytes
824
+ return
825
+ }
826
+ if (searchOrMaxBodyBytes !== undefined) this.searchValue = searchOrMaxBodyBytes
827
+ this.signalValue = signal
828
+ this.budgetValue = budget
829
+ if (platform !== undefined) this.platformValue = platform
830
+ this.maxBodyBytes = maxBodyBytes as number
831
+ }
832
+
833
+ static native(
834
+ source: RequestSource,
835
+ params: Record<string, string>,
836
+ maxBodyBytes: number,
837
+ ): RequestContext {
838
+ return new RequestContext(source, params, maxBodyBytes)
779
839
  }
780
840
 
781
841
  [CONTEXT_SET](): CtxSet | undefined {
782
842
  return this.setValue
783
843
  }
784
844
 
845
+ get [CONTEXT_SEARCH](): string {
846
+ this.searchValue ??= searchOf(this.source.url)
847
+ return this.searchValue
848
+ }
849
+
785
850
  get set(): CtxSet {
786
851
  this.setValue ??= new LazyResponseControls()
787
852
  return this.setValue
788
853
  }
789
854
 
855
+ get signal(): AbortSignal {
856
+ return this.signalValue ?? getNeverAbortSignal()
857
+ }
858
+
859
+ get budget(): RequestBudget {
860
+ return this.budgetValue ?? getUnboundedRequestBudget()
861
+ }
862
+
863
+ get env(): unknown {
864
+ return this.platformValue?.env
865
+ }
866
+
867
+ get waitUntil(): (promise: Promise<unknown>) => void {
868
+ return this.platformValue?.waitUntil ?? fallbackWaitUntil
869
+ }
870
+
790
871
  get req(): Request {
791
872
  return requestOf(this.source)
792
873
  }
@@ -922,8 +1003,9 @@ const JSON_CT_HEADERS = new Headers({
922
1003
  })
923
1004
  const JSON_INIT_200: ResponseInit = { status: 200, headers: JSON_CT_HEADERS }
924
1005
 
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. */
1006
+ /** Fused-lane respond when `c.set` is untouched. Bun 1.3's native `Response.json` now beats the
1007
+ * older hand-inlined stringify + Response construction on this lane while preserving the exact
1008
+ * body/content-type contract; keep the generic fallback for non-JSON values. */
927
1009
  function fusedRespondNoSet(result: unknown): Response {
928
1010
  if (
929
1011
  result !== undefined &&
@@ -932,8 +1014,7 @@ function fusedRespondNoSet(result: unknown): Response {
932
1014
  result !== null &&
933
1015
  !isResponseResult(result)
934
1016
  ) {
935
- const body = JSON.stringify(result) as string | undefined
936
- if (body !== undefined) return new Response(body, JSON_INIT_200)
1017
+ return Response.json(result)
937
1018
  }
938
1019
  return toResponse(result as HandlerResult, EMPTY_RESPONSE_CONTROLS)
939
1020
  }
@@ -1118,6 +1199,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1118
1199
  private readonly maxBodyBytes: number
1119
1200
  private readonly wsMaxPayloadBytes: number
1120
1201
  private readonly requestTimeoutMs: number
1202
+ private readonly acceptInboundDeadlines: boolean
1203
+ private readonly maxInboundDeadlineMs: number
1204
+ private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
1121
1205
  private readonly gracefulSignals: boolean
1122
1206
  private readonly logger: Logger
1123
1207
  /** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
@@ -1165,6 +1249,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1165
1249
  this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
1166
1250
  this.wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? this.maxBodyBytes
1167
1251
  this.requestTimeoutMs = options.requestTimeoutMs ?? 0
1252
+ this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false
1253
+ this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000
1254
+ this.deadlineAdmissionOptions = Object.freeze({
1255
+ localTimeoutMs: this.requestTimeoutMs,
1256
+ maxInboundDeadlineMs: this.maxInboundDeadlineMs,
1257
+ })
1258
+ if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs < 0) {
1259
+ throw new RangeError("requestTimeoutMs must be a finite non-negative number")
1260
+ }
1261
+ if (!Number.isFinite(this.maxInboundDeadlineMs) || this.maxInboundDeadlineMs <= 0) {
1262
+ throw new RangeError("maxInboundDeadlineMs must be a finite positive number")
1263
+ }
1168
1264
  this.gracefulSignals = options.gracefulSignals ?? false
1169
1265
  this.logger = options.logger ?? jsonLogger()
1170
1266
  this.defaultOnValidationError = options.onValidationError
@@ -1784,6 +1880,32 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1784
1880
  : this.applyOnResponseAndFinalize(outcome, this.takeResponseRequest(source))
1785
1881
  }
1786
1882
 
1883
+ /** Web response path when Bun already matched the route. The lifecycle and response hooks remain
1884
+ * exactly the same as {@link fetchSource}; only portable URL scanning + trie lookup are skipped. */
1885
+ private fetchMatched(
1886
+ source: RequestSource,
1887
+ entry: RouteEntry,
1888
+ params: Record<string, string>,
1889
+ ): MaybePromise<Response> {
1890
+ const outcome = this.runMatched(
1891
+ source,
1892
+ undefined,
1893
+ entry,
1894
+ params,
1895
+ undefined,
1896
+ toResponse,
1897
+ IDENTITY_RESPONSE,
1898
+ RESPONSE_TIMEOUT,
1899
+ true,
1900
+ )
1901
+ if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
1902
+ return outcome
1903
+ }
1904
+ return outcome instanceof Promise
1905
+ ? outcome.then((response) => this.applyOnResponseAndFinalize(response, requestOf(source)))
1906
+ : this.applyOnResponseAndFinalize(outcome, requestOf(source))
1907
+ }
1908
+
1787
1909
  /**
1788
1910
  * Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` — the
1789
1911
  * `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
@@ -1838,10 +1960,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1838
1960
  const url = urlPartsOf(req.url)
1839
1961
  const match = this.wsRouter.find("GET", url.pathname)
1840
1962
  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)
1963
+ // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
1964
+ // already did and made every plain dynamic route pay for unrelated static path bytes.
1965
+ const params = match.params === EMPTY_PARAMS ? match.params : decodeParams(match.params)
1845
1966
  if (params === null) return { kind: "reject", response: jsonError(400, "malformed_path") }
1846
1967
  const handler = match.payload.handler
1847
1968
  // Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ the runtime created the registry.
@@ -1866,11 +1987,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1866
1987
  if (handler.upgrade === undefined) {
1867
1988
  return { kind: "upgrade", handler, data: undefined, pubsub }
1868
1989
  }
1990
+ const upgradeSignal = getNeverAbortSignal()
1869
1991
  const ctx = new RequestContext(
1870
1992
  req,
1871
1993
  params,
1872
1994
  url.search,
1873
- getNeverAbortSignal(),
1995
+ upgradeSignal,
1996
+ createUnboundedRequestBudget(upgradeSignal),
1874
1997
  platform,
1875
1998
  this.maxBodyBytes,
1876
1999
  )
@@ -2042,38 +2165,95 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2042
2165
  return wrapResponse(jsonError(404, "not_found"))
2043
2166
  }
2044
2167
 
2045
- const params =
2046
- match.params === EMPTY_PARAMS || !url.pathname.includes("%")
2047
- ? match.params
2048
- : decodeParams(match.params)
2168
+ // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
2169
+ // already did and made every plain dynamic route pay for unrelated static path bytes.
2170
+ const params = match.params === EMPTY_PARAMS ? match.params : decodeParams(match.params)
2049
2171
  if (params === null) {
2050
2172
  return wrapResponse(jsonError(400, "malformed_path"))
2051
2173
  }
2052
2174
 
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.
2175
+ return this.runMatched(
2176
+ source,
2177
+ platform,
2178
+ match.payload,
2179
+ params,
2180
+ url.search,
2181
+ finalize,
2182
+ wrapResponse,
2183
+ onTimeout,
2184
+ webFast,
2185
+ )
2186
+ }
2187
+
2188
+ /** Run a route that has already been matched by the runtime or Nifra's portable router. */
2189
+ private runMatched<T>(
2190
+ source: RequestSource,
2191
+ platform: Platform | undefined,
2192
+ entry: RouteEntry,
2193
+ params: Record<string, string>,
2194
+ search: string | undefined,
2195
+ finalize: (result: unknown, set: CtxSet) => T,
2196
+ wrapResponse: (response: Response) => T,
2197
+ onTimeout: () => T,
2198
+ webFast: boolean,
2199
+ ): MaybePromise<T> {
2200
+ // Translate the absolute wire deadline once, clamp it to local policy, then use the resulting
2201
+ // duration for both c.signal and c.budget. A client can only shorten work, never extend it.
2202
+ // Most requests have neither a local timeout nor a propagated deadline. Detect that case with
2203
+ // one header lookup and skip policy validation, wall-clock sampling, and admission objects. A
2204
+ // present wire deadline still goes through the full fail-closed parser/clamp below.
2205
+ const admission = !this.acceptInboundDeadlines
2206
+ ? this.requestTimeoutMs === 0
2207
+ ? undefined
2208
+ : {
2209
+ ok: true as const,
2210
+ inherited: false,
2211
+ timeoutMs: this.requestTimeoutMs,
2212
+ deadline: Math.floor(Date.now() + this.requestTimeoutMs),
2213
+ }
2214
+ : this.requestTimeoutMs === 0 && headerOf(source, NIFRA_DEADLINE_HEADER) === null
2215
+ ? undefined
2216
+ : admitDeadline(source.headers, this.deadlineAdmissionOptions)
2217
+ if (admission !== undefined && !admission.ok) {
2218
+ return wrapResponse(jsonError(admission.status, admission.reason))
2219
+ }
2220
+ const effectiveTimeoutMs = admission?.timeoutMs ?? 0
2221
+
2222
+ // Only allocate a controller for a finite budget; the historical no-timeout path remains
2223
+ // allocation-light and exposes an unbounded budget that is never propagated on the wire.
2057
2224
  let controller: AbortController | undefined
2058
2225
  let signal = getNeverAbortSignal()
2059
- if (this.requestTimeoutMs > 0) {
2226
+ if (effectiveTimeoutMs > 0) {
2060
2227
  controller = new AbortController()
2061
2228
  signal = controller.signal
2062
2229
  }
2230
+ const budget =
2231
+ controller === undefined
2232
+ ? getUnboundedRequestBudget()
2233
+ : createRequestBudget({ deadline: admission!.deadline as number, signal })
2063
2234
  // A bare route runs synchronously (no schema/derives/hooks); everything else keeps the full async
2064
2235
  // lifecycle. `runBare` returns a value directly for a sync handler — no promise.
2065
2236
  let outcome: MaybePromise<T>
2066
2237
  if (webFast && entry.fusedWeb !== undefined) {
2067
2238
  // Fused Web lane (bare route, no around): one closure end to end. Sound cast — webFast is
2068
2239
  // passed only by the `fetch` path, where T = Response by construction.
2069
- outcome = entry.fusedWeb(source, params, url.search, signal, platform) as MaybePromise<T>
2240
+ outcome = entry.fusedWeb(
2241
+ source,
2242
+ params,
2243
+ search,
2244
+ signal,
2245
+ budget,
2246
+ platform,
2247
+ false,
2248
+ ) as MaybePromise<T>
2070
2249
  } else if (entry.contextlessBare) {
2071
2250
  outcome = this.runContextlessBare(
2072
2251
  entry,
2073
2252
  source,
2074
2253
  params,
2075
- url.search,
2254
+ search,
2076
2255
  signal,
2256
+ budget,
2077
2257
  platform,
2078
2258
  finalize,
2079
2259
  wrapResponse,
@@ -2084,8 +2264,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2084
2264
  const ctx = new RequestContext(
2085
2265
  source,
2086
2266
  params,
2087
- url.search,
2267
+ search,
2088
2268
  signal,
2269
+ budget,
2089
2270
  platform,
2090
2271
  this.maxBodyBytes,
2091
2272
  )
@@ -2118,7 +2299,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2118
2299
  // The request timeout only bounds work that is actually pending — a synchronous (bare) result is
2119
2300
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
2120
2301
  if (controller !== undefined && outcome instanceof Promise) {
2121
- return this.withTimeout(outcome, controller, onTimeout)
2302
+ const timedOut =
2303
+ admission?.inherited === true
2304
+ ? () => wrapResponse(jsonError(504, "deadline_exceeded"))
2305
+ : onTimeout
2306
+ return this.withTimeout(
2307
+ outcome,
2308
+ controller,
2309
+ timedOut,
2310
+ Math.max(0, Math.ceil(budget.remaining())),
2311
+ )
2122
2312
  }
2123
2313
  return outcome
2124
2314
  }
@@ -2129,8 +2319,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2129
2319
  entry: RouteEntry,
2130
2320
  source: RequestSource,
2131
2321
  params: Record<string, string>,
2132
- search: string,
2322
+ search: string | undefined,
2133
2323
  signal: AbortSignal,
2324
+ budget: RequestBudget,
2134
2325
  platform: Platform | undefined,
2135
2326
  finalize: (result: unknown, set: CtxSet) => T,
2136
2327
  wrapResponse: (response: Response) => T,
@@ -2139,13 +2330,31 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2139
2330
  try {
2140
2331
  result = (entry.handler as unknown as ContextlessHandler)()
2141
2332
  } catch (err) {
2142
- return this.contextlessBareError(err, source, params, search, signal, platform, wrapResponse)
2333
+ return this.contextlessBareError(
2334
+ err,
2335
+ source,
2336
+ params,
2337
+ search,
2338
+ signal,
2339
+ budget,
2340
+ platform,
2341
+ wrapResponse,
2342
+ )
2143
2343
  }
2144
2344
  if (result instanceof Promise) {
2145
2345
  return result.then(
2146
2346
  (value) => finalize(value, EMPTY_RESPONSE_CONTROLS),
2147
2347
  (err) =>
2148
- this.contextlessBareError(err, source, params, search, signal, platform, wrapResponse),
2348
+ this.contextlessBareError(
2349
+ err,
2350
+ source,
2351
+ params,
2352
+ search,
2353
+ signal,
2354
+ budget,
2355
+ platform,
2356
+ wrapResponse,
2357
+ ),
2149
2358
  )
2150
2359
  }
2151
2360
  return finalize(result, EMPTY_RESPONSE_CONTROLS)
@@ -2155,13 +2364,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2155
2364
  err: unknown,
2156
2365
  source: RequestSource,
2157
2366
  params: Record<string, string>,
2158
- search: string,
2367
+ search: string | undefined,
2159
2368
  signal: AbortSignal,
2369
+ budget: RequestBudget,
2160
2370
  platform: Platform | undefined,
2161
2371
  wrapResponse: (response: Response) => T,
2162
2372
  ): T {
2163
2373
  if (err instanceof Response) return wrapResponse(err)
2164
- const ctx = new RequestContext(source, params, search, signal, platform, this.maxBodyBytes)
2374
+ const ctx = new RequestContext(
2375
+ source,
2376
+ params,
2377
+ search,
2378
+ signal,
2379
+ budget,
2380
+ platform,
2381
+ this.maxBodyBytes,
2382
+ )
2165
2383
  this.logRequestError(err, ctx)
2166
2384
  return wrapResponse(jsonError(500, "internal_error"))
2167
2385
  }
@@ -2221,29 +2439,43 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2221
2439
  // `() => ...` can't observe the context — skip allocating one entirely (errors still build
2222
2440
  // one for the structured log, exactly like runContextlessBare).
2223
2441
  const contextlessHandler = handler as unknown as ContextlessHandler
2224
- return (source, params, search, signal, platform) => {
2442
+ return (source, params, search, signal, budget, platform, nativeContext) => {
2225
2443
  let result: unknown
2226
2444
  try {
2227
2445
  result = contextlessHandler()
2228
2446
  } catch (err) {
2229
2447
  return logError(
2230
2448
  err,
2231
- new RequestContext(source, params, search, signal, platform, maxBodyBytes),
2449
+ nativeContext
2450
+ ? RequestContext.native(source, params, maxBodyBytes)
2451
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes),
2232
2452
  )
2233
2453
  }
2234
2454
  if (result instanceof Promise) {
2235
2455
  return result.then(fusedRespondNoSet, (err) =>
2236
2456
  logError(
2237
2457
  err,
2238
- new RequestContext(source, params, search, signal, platform, maxBodyBytes),
2458
+ nativeContext
2459
+ ? RequestContext.native(source, params, maxBodyBytes)
2460
+ : new RequestContext(
2461
+ source,
2462
+ params,
2463
+ search,
2464
+ signal,
2465
+ budget,
2466
+ platform,
2467
+ maxBodyBytes,
2468
+ ),
2239
2469
  ),
2240
2470
  )
2241
2471
  }
2242
2472
  return fusedRespondNoSet(result)
2243
2473
  }
2244
2474
  }
2245
- return (source, params, search, signal, platform) => {
2246
- const ctx = new RequestContext(source, params, search, signal, platform, maxBodyBytes)
2475
+ return (source, params, search, signal, budget, platform, nativeContext) => {
2476
+ const ctx = nativeContext
2477
+ ? RequestContext.native(source, params, maxBodyBytes)
2478
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes)
2247
2479
  if (decorations !== undefined) Object.assign(ctx, decorations)
2248
2480
  let result: unknown
2249
2481
  try {
@@ -2688,13 +2920,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2688
2920
  work: Promise<T>,
2689
2921
  controller: AbortController,
2690
2922
  onTimeout: () => T,
2923
+ timeoutMs: number,
2691
2924
  ): Promise<T> {
2692
2925
  let timer: ReturnType<typeof setTimeout> | undefined
2693
2926
  const timeout = new Promise<T>((resolve) => {
2694
2927
  timer = setTimeout(() => {
2695
2928
  controller.abort()
2696
2929
  resolve(onTimeout())
2697
- }, this.requestTimeoutMs)
2930
+ }, timeoutMs)
2698
2931
  })
2699
2932
  try {
2700
2933
  return await Promise.race([work, timeout])
@@ -2853,6 +3086,163 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2853
3086
  return readBoundedJsonSource(req, this.maxBodyBytes)
2854
3087
  }
2855
3088
 
3089
+ /** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
3090
+ * hooks or WebSockets retain the single portable dispatcher because those features must run before
3091
+ * route selection/upgrade. Named wildcards also stay on the fallback until Bun exposes their raw
3092
+ * capture semantics; static and `:param` routes take the native lane. */
3093
+ private buildBunNativeRoutes(): BunNativeRoutes | undefined {
3094
+ if (this.onRequestHooks.length > 0 || this.wsRouteCount > 0) return undefined
3095
+
3096
+ const routes: BunNativeRoutes = Object.create(null) as BunNativeRoutes
3097
+ const mayUseFusedNative =
3098
+ this.requestTimeoutMs === 0 &&
3099
+ this.onResponseHooks.length === 0 &&
3100
+ this.onResponseFinalizedHooks.length === 0
3101
+ const unboundedSignal = mayUseFusedNative ? getNeverAbortSignal() : undefined
3102
+ const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined
3103
+ let count = 0
3104
+ for (const { method, path, entry } of this.rawEntries) {
3105
+ if (path.includes("*")) continue
3106
+ let methods = routes[path]
3107
+ if (methods === undefined) {
3108
+ methods = Object.create(null) as BunNativeMethodTable
3109
+ routes[path] = methods
3110
+ }
3111
+ const paramNames = path
3112
+ .split("/")
3113
+ .filter((segment) => segment.charCodeAt(0) === 58 /* : */)
3114
+ .map((segment) => segment.slice(1))
3115
+ const fused = mayUseFusedNative ? entry.fusedWeb : undefined
3116
+
3117
+ if (fused !== undefined) {
3118
+ // Deliberately choose deadline-aware vs zero-admission closures HERE instead of branching on
3119
+ // `acceptInboundDeadlines` per request. JSC can then flatten the default closure through the
3120
+ // handler + Response.json; retaining that route-invariant branch cost ~7% on a dynamic GET.
3121
+ if (paramNames.length === 0) {
3122
+ methods[method] = this.acceptInboundDeadlines
3123
+ ? (request) =>
3124
+ request.headers.get(NIFRA_DEADLINE_HEADER) !== null
3125
+ ? this.fetchMatched(request, entry, EMPTY_PARAMS)
3126
+ : fused(
3127
+ request,
3128
+ EMPTY_PARAMS,
3129
+ undefined,
3130
+ unboundedSignal as AbortSignal,
3131
+ unboundedBudget as RequestBudget,
3132
+ undefined,
3133
+ true,
3134
+ )
3135
+ : (request) =>
3136
+ fused(
3137
+ request,
3138
+ EMPTY_PARAMS,
3139
+ undefined,
3140
+ unboundedSignal as AbortSignal,
3141
+ unboundedBudget as RequestBudget,
3142
+ undefined,
3143
+ true,
3144
+ )
3145
+ } else if (paramNames.length === 1) {
3146
+ const paramName = paramNames[0]!
3147
+ const handler = entry.handler
3148
+ const decorations = entry.hasDecorations ? entry.decorations : undefined
3149
+ const fail = (error: unknown, ctx: RawContext): Response => {
3150
+ if (error instanceof Response) return error
3151
+ this.logRequestError(error, ctx)
3152
+ return jsonError(500, "internal_error")
3153
+ }
3154
+ methods[method] = this.acceptInboundDeadlines
3155
+ ? (request) => {
3156
+ const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
3157
+ if (params[paramName]?.includes("\uFFFD") === true) return this.fetchSource(request)
3158
+ if (request.headers.get(NIFRA_DEADLINE_HEADER) !== null) {
3159
+ return this.fetchMatched(request, entry, params)
3160
+ }
3161
+ const ctx = RequestContext.native(request, params, this.maxBodyBytes)
3162
+ if (decorations !== undefined) Object.assign(ctx, decorations)
3163
+ let result: unknown
3164
+ try {
3165
+ result = handler(ctx)
3166
+ } catch (error) {
3167
+ return fail(error, ctx)
3168
+ }
3169
+ return result instanceof Promise
3170
+ ? result.then(
3171
+ (value) => fusedRespond(value, ctx),
3172
+ (error) => fail(error, ctx),
3173
+ )
3174
+ : fusedRespond(result, ctx)
3175
+ }
3176
+ : (request) => {
3177
+ const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
3178
+ if (params[paramName]?.includes("\uFFFD") === true) return this.fetchSource(request)
3179
+ const ctx = RequestContext.native(request, params, this.maxBodyBytes)
3180
+ if (decorations !== undefined) Object.assign(ctx, decorations)
3181
+ let result: unknown
3182
+ try {
3183
+ result = handler(ctx)
3184
+ } catch (error) {
3185
+ return fail(error, ctx)
3186
+ }
3187
+ return result instanceof Promise
3188
+ ? result.then(
3189
+ (value) => fusedRespond(value, ctx),
3190
+ (error) => fail(error, ctx),
3191
+ )
3192
+ : fusedRespond(result, ctx)
3193
+ }
3194
+ } else {
3195
+ methods[method] = this.acceptInboundDeadlines
3196
+ ? (request) => {
3197
+ const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
3198
+ if (hasReplacementParam(params)) return this.fetchSource(request)
3199
+ return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
3200
+ ? this.fetchMatched(request, entry, params)
3201
+ : fused(
3202
+ request,
3203
+ params,
3204
+ undefined,
3205
+ unboundedSignal as AbortSignal,
3206
+ unboundedBudget as RequestBudget,
3207
+ undefined,
3208
+ true,
3209
+ )
3210
+ }
3211
+ : (request) => {
3212
+ const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
3213
+ if (hasReplacementParam(params)) return this.fetchSource(request)
3214
+ return fused(
3215
+ request,
3216
+ params,
3217
+ undefined,
3218
+ unboundedSignal as AbortSignal,
3219
+ unboundedBudget as RequestBudget,
3220
+ undefined,
3221
+ true,
3222
+ )
3223
+ }
3224
+ }
3225
+ } else if (paramNames.length === 0) {
3226
+ methods[method] = (request) => this.fetchMatched(request, entry, EMPTY_PARAMS)
3227
+ } else if (paramNames.length === 1) {
3228
+ const paramName = paramNames[0]!
3229
+ methods[method] = (request) => {
3230
+ const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
3231
+ if (params[paramName]?.includes("\uFFFD") === true) return this.fetchSource(request)
3232
+ return this.fetchMatched(request, entry, params)
3233
+ }
3234
+ } else {
3235
+ methods[method] = (request) => {
3236
+ const params = (request as BunRequestWithParams).params ?? EMPTY_PARAMS
3237
+ if (hasReplacementParam(params)) return this.fetchSource(request)
3238
+ return this.fetchMatched(request, entry, params)
3239
+ }
3240
+ }
3241
+ count += 1
3242
+ }
3243
+ return count === 0 ? undefined : routes
3244
+ }
3245
+
2856
3246
  /**
2857
3247
  * Start a `Bun.serve` instance bound to `port` (use `0` for an ephemeral port).
2858
3248
  *
@@ -2889,8 +3279,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2889
3279
  ? undefined
2890
3280
  : (getWsRuntime() as WsRuntime).bunHandlers(this.topics as TopicRegistry)
2891
3281
  const reusePort = options?.reusePort === true
3282
+ const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes() : undefined
2892
3283
  const running = (wsHandlers === undefined
2893
- ? Bun.serve({ port, reusePort, fetch: (req: Request) => this.fetch(req) })
3284
+ ? Bun.serve({
3285
+ port,
3286
+ reusePort,
3287
+ ...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
3288
+ fetch: (req: Request) => this.fetch(req),
3289
+ })
2894
3290
  : Bun.serve<BunWsData>({
2895
3291
  port,
2896
3292
  reusePort,