@nifrajs/core 1.7.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,4 +1,19 @@
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"
10
+ import {
11
+ type AssuranceDeclaration,
12
+ type AssuranceEvidence,
13
+ assuranceDeclarationsOf,
14
+ assuranceEvidenceFor,
15
+ NIFRA_ASSURANCE_IDS,
16
+ } from "../internal/route-assurance.ts"
2
17
  import { EMPTY_PARAMS, type Method, Router } from "../router/router.ts"
3
18
  import type {
4
19
  InferOutput,
@@ -108,6 +123,7 @@ interface RawContext {
108
123
  readonly [CONTEXT_SET]: () => CtxSet | undefined
109
124
  readonly [CONTEXT_SEARCH]: string
110
125
  readonly signal: AbortSignal
126
+ readonly budget: RequestBudget
111
127
  readonly env: unknown
112
128
  readonly waitUntil: (promise: Promise<unknown>) => void
113
129
  readonly boundedBody: (maxBytes?: number) => Promise<Uint8Array>
@@ -185,9 +201,11 @@ interface RouteEntry {
185
201
  type FusedWebRunner = (
186
202
  source: RequestSource,
187
203
  params: Record<string, string>,
188
- search: string,
204
+ search: string | undefined,
189
205
  signal: AbortSignal,
206
+ budget: RequestBudget,
190
207
  platform: Platform | undefined,
208
+ nativeContext: boolean,
191
209
  ) => MaybePromise<Response>
192
210
 
193
211
  /** A registered WebSocket route — just its handler; matching reuses {@link Router} under the GET verb. */
@@ -200,6 +218,11 @@ interface BunUpgradeServer {
200
218
  upgrade(request: Request, options?: { data?: BunWsData }): boolean
201
219
  }
202
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
+
203
226
  const WS_PASS: WebSocketUpgradeOutcome = { kind: "pass" }
204
227
 
205
228
  /** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
@@ -248,6 +271,18 @@ export interface ServerOptions {
248
271
  readonly wsMaxPayloadBytes?: number
249
272
  /** Per-request timeout (ms): a slower request gets a 503 and `ctx.signal` aborts. 0 disables (default). */
250
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
251
286
  /** When `listen()`ing, install SIGTERM/SIGINT handlers that gracefully `stop()`. Default false. */
252
287
  readonly gracefulSignals?: boolean
253
288
  /** Structured logger for framework events (redacts secrets). Default: JSON to stderr. */
@@ -289,6 +324,8 @@ export interface RouteDescriptor {
289
324
  readonly method: Method
290
325
  readonly path: string
291
326
  readonly schema: RouteSchema | undefined
327
+ /** Effective enforcement evidence, populated only during reflection/introspection. */
328
+ readonly assurance?: readonly AssuranceEvidence[]
292
329
  readonly tool?: {
293
330
  readonly name: string
294
331
  readonly description: string
@@ -466,10 +503,15 @@ const TEXT_DECODER = new TextDecoder()
466
503
  * reused at zero per-request cost.
467
504
  */
468
505
  let neverAbortSignal: AbortSignal | undefined
506
+ let unboundedRequestBudget: RequestBudget | undefined
469
507
  const getNeverAbortSignal = (): AbortSignal => {
470
508
  neverAbortSignal ??= new AbortController().signal
471
509
  return neverAbortSignal
472
510
  }
511
+ const getUnboundedRequestBudget = (): RequestBudget => {
512
+ unboundedRequestBudget ??= createUnboundedRequestBudget(getNeverAbortSignal())
513
+ return unboundedRequestBudget
514
+ }
473
515
 
474
516
  function jsonError(status: number, error: string, headers?: Record<string, string>): Response {
475
517
  return Response.json(
@@ -670,6 +712,13 @@ function decodeParams(raw: Record<string, string>): Record<string, string> | nul
670
712
  return out ?? raw
671
713
  }
672
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
+
673
722
  /** `ctx.set` carrying the lazy backings (`_headers`, `_cookies`) so `toResponse` can skip allocating
674
723
  * anything when no handler touched `c.set.*`. Server-internal. */
675
724
  type CtxSet = ResponseControls & {
@@ -733,51 +782,92 @@ function statusInit(init?: ResponseInit | number): ResponseInit | undefined {
733
782
  }
734
783
 
735
784
  class RequestContext implements RawContext {
736
- readonly params: Record<string, string>
737
- readonly signal: AbortSignal
738
- readonly env: unknown
739
- readonly waitUntil: (promise: Promise<unknown>) => void
740
- body: unknown
741
- readonly [CONTEXT_SEARCH]: string
742
-
743
- private setValue: CtxSet | undefined
744
- private queryValue: unknown
745
- private queryReady: boolean
746
- private cookiesValue: Readonly<Record<string, string>> | undefined
747
- private readonly source: RequestSource
748
- private readonly maxBodyBytes: number
749
-
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)
750
802
  constructor(
751
803
  source: RequestSource,
752
804
  params: Record<string, string>,
753
- search: string,
805
+ search: string | undefined,
754
806
  signal: AbortSignal,
807
+ budget: RequestBudget,
755
808
  platform: Platform | undefined,
756
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,
757
819
  ) {
758
820
  this.source = source
759
821
  this.params = params
760
- this[CONTEXT_SEARCH] = search
761
- this.signal = signal
762
- this.env = platform?.env
763
- this.waitUntil = platform?.waitUntil ?? fallbackWaitUntil
764
- this.maxBodyBytes = maxBodyBytes
765
- this.setValue = undefined
766
- this.body = undefined
767
- this.queryValue = undefined
768
- this.queryReady = false
769
- 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)
770
839
  }
771
840
 
772
841
  [CONTEXT_SET](): CtxSet | undefined {
773
842
  return this.setValue
774
843
  }
775
844
 
845
+ get [CONTEXT_SEARCH](): string {
846
+ this.searchValue ??= searchOf(this.source.url)
847
+ return this.searchValue
848
+ }
849
+
776
850
  get set(): CtxSet {
777
851
  this.setValue ??= new LazyResponseControls()
778
852
  return this.setValue
779
853
  }
780
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
+
781
871
  get req(): Request {
782
872
  return requestOf(this.source)
783
873
  }
@@ -913,8 +1003,9 @@ const JSON_CT_HEADERS = new Headers({
913
1003
  })
914
1004
  const JSON_INIT_200: ResponseInit = { status: 200, headers: JSON_CT_HEADERS }
915
1005
 
916
- /** Fused-lane respond when the handler ran with NO context (so `c.set` can't exist): the fast
917
- * 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. */
918
1009
  function fusedRespondNoSet(result: unknown): Response {
919
1010
  if (
920
1011
  result !== undefined &&
@@ -923,8 +1014,7 @@ function fusedRespondNoSet(result: unknown): Response {
923
1014
  result !== null &&
924
1015
  !isResponseResult(result)
925
1016
  ) {
926
- const body = JSON.stringify(result) as string | undefined
927
- if (body !== undefined) return new Response(body, JSON_INIT_200)
1017
+ return Response.json(result)
928
1018
  }
929
1019
  return toResponse(result as HandlerResult, EMPTY_RESPONSE_CONTROLS)
930
1020
  }
@@ -1109,6 +1199,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1109
1199
  private readonly maxBodyBytes: number
1110
1200
  private readonly wsMaxPayloadBytes: number
1111
1201
  private readonly requestTimeoutMs: number
1202
+ private readonly acceptInboundDeadlines: boolean
1203
+ private readonly maxInboundDeadlineMs: number
1204
+ private readonly deadlineAdmissionOptions: DeadlineAdmissionOptions
1112
1205
  private readonly gracefulSignals: boolean
1113
1206
  private readonly logger: Logger
1114
1207
  /** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
@@ -1126,6 +1219,10 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1126
1219
  private readonly responseRequests: WeakMap<Request, Request>
1127
1220
  /** Names of plugins/middleware already applied via `use` — for idempotent dedupe. */
1128
1221
  private readonly appliedPlugins: Set<string>
1222
+ /** Order-scoped evidence captured by routes registered after an assured plugin. */
1223
+ private readonly activeAssurance: AssuranceDeclaration[]
1224
+ /** App-wide evidence from global hooks; applies retroactively to every route. */
1225
+ private readonly globalAssurance: AssuranceDeclaration[]
1129
1226
  /** App-declared MCP resources / prompts (via {@link resource} / {@link prompt}), read by `nifra mcp`. */
1130
1227
  private readonly mcpResourceList: McpResourceDescriptor[]
1131
1228
  private readonly mcpPromptList: McpPromptDescriptor[]
@@ -1140,6 +1237,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1140
1237
  readonly path: string
1141
1238
  readonly entry: RouteEntry
1142
1239
  readonly descriptor: RouteDescriptor
1240
+ readonly assurance: readonly AssuranceDeclaration[]
1143
1241
  }>
1144
1242
 
1145
1243
  constructor(options: ServerOptions = {}) {
@@ -1151,6 +1249,18 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1151
1249
  this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
1152
1250
  this.wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? this.maxBodyBytes
1153
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
+ }
1154
1264
  this.gracefulSignals = options.gracefulSignals ?? false
1155
1265
  this.logger = options.logger ?? jsonLogger()
1156
1266
  this.defaultOnValidationError = options.onValidationError
@@ -1166,6 +1276,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1166
1276
  this.onResponseFinalizedHooks = []
1167
1277
  this.responseRequests = new WeakMap()
1168
1278
  this.appliedPlugins = new Set()
1279
+ this.activeAssurance = []
1280
+ this.globalAssurance = []
1169
1281
  this.mcpResourceList = []
1170
1282
  this.mcpPromptList = []
1171
1283
  this.rawEntries = []
@@ -1263,12 +1375,32 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1263
1375
  if (this.appliedPlugins.has(name)) return this // idempotent: already applied
1264
1376
  this.appliedPlugins.add(name)
1265
1377
  }
1266
- return arg(this)
1378
+ const evidence = assuranceDeclarationsOf(arg)
1379
+ const pluginOnly = evidence.filter((item) => item.scope === "plugin")
1380
+ this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"))
1381
+ this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"))
1382
+ this.activeAssurance.push(...pluginOnly)
1383
+ try {
1384
+ return arg(this)
1385
+ } finally {
1386
+ // Remove only this plugin's temporary evidence. Nested assured plugins may deliberately leave
1387
+ // subsequent evidence active, so truncating the whole array would lose real ordering semantics.
1388
+ for (const item of pluginOnly) {
1389
+ const index = this.activeAssurance.indexOf(item)
1390
+ if (index !== -1) this.activeAssurance.splice(index, 1)
1391
+ }
1392
+ }
1267
1393
  }
1268
1394
  if (arg.name !== undefined) {
1269
1395
  if (this.appliedPlugins.has(arg.name)) return this
1270
1396
  this.appliedPlugins.add(arg.name)
1271
1397
  }
1398
+ const evidence = assuranceDeclarationsOf(arg)
1399
+ if (evidence.some((item) => item.scope === "plugin")) {
1400
+ throw new Error('route assurance: scope "plugin" may only annotate a plugin function')
1401
+ }
1402
+ this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"))
1403
+ this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"))
1272
1404
  if (arg.onRequest !== undefined) this.onRequest(arg.onRequest)
1273
1405
  if (arg.around !== undefined) this.around(arg.around)
1274
1406
  if (arg.beforeHandle !== undefined) this.beforeHandle(arg.beforeHandle)
@@ -1633,7 +1765,23 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1633
1765
  this.router.add(method, path, entry)
1634
1766
  const descriptor: RouteDescriptor = { method, path, schema }
1635
1767
  this.routeList.push(descriptor)
1636
- this.rawEntries.push({ method, path, entry, descriptor })
1768
+ const routeAssurance: AssuranceDeclaration[] = [...this.activeAssurance]
1769
+ if (schema?.body !== undefined) {
1770
+ routeAssurance.push(
1771
+ Object.freeze({
1772
+ id: NIFRA_ASSURANCE_IDS.BODY_BOUNDED,
1773
+ source: "route-schema",
1774
+ scope: "plugin",
1775
+ }),
1776
+ )
1777
+ }
1778
+ this.rawEntries.push({
1779
+ method,
1780
+ path,
1781
+ entry,
1782
+ descriptor,
1783
+ assurance: Object.freeze(routeAssurance),
1784
+ })
1637
1785
  }
1638
1786
 
1639
1787
  /**
@@ -1663,14 +1811,15 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1663
1811
  "merge() does not carry WebSocket routes — register .ws() routes on the parent server",
1664
1812
  )
1665
1813
  }
1666
- for (const { method, path, entry, descriptor } of source.rawEntries) {
1814
+ for (const { method, path, entry, descriptor, assurance } of source.rawEntries) {
1667
1815
  this.router.add(method, path, entry) // throws RouteConfigError on a duplicate — fail closed
1668
1816
  this.routeList.push(descriptor) // shared ref on purpose: carries later .tool() tagging
1669
- this.rawEntries.push({ method, path, entry, descriptor })
1817
+ this.rawEntries.push({ method, path, entry, descriptor, assurance })
1670
1818
  }
1671
1819
  this.onRequestHooks.push(...source.onRequestHooks)
1672
1820
  this.onResponseHooks.push(...source.onResponseHooks)
1673
1821
  this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks)
1822
+ this.globalAssurance.push(...source.globalAssurance)
1674
1823
  this.mcpResourceList.push(...source.mcpResourceList)
1675
1824
  this.mcpPromptList.push(...source.mcpPromptList)
1676
1825
  return this as unknown as Server<R & R2, Ctx>
@@ -1682,7 +1831,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1682
1831
  * longer holds the original patterns.
1683
1832
  */
1684
1833
  routes(): ReadonlyArray<RouteDescriptor> {
1685
- return this.routeList
1834
+ if (this.activeAssurance.length === 0 && this.globalAssurance.length === 0) {
1835
+ const anyRouteEvidence = this.rawEntries.some((entry) => entry.assurance.length > 0)
1836
+ if (!anyRouteEvidence) return this.routeList
1837
+ }
1838
+ return this.rawEntries.map(({ method, path, descriptor, assurance }) => {
1839
+ const effective = assuranceEvidenceFor([...assurance, ...this.globalAssurance], method, path)
1840
+ return effective.length > 0 ? { ...descriptor, assurance: effective } : descriptor
1841
+ })
1686
1842
  }
1687
1843
 
1688
1844
  /**
@@ -1724,6 +1880,32 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1724
1880
  : this.applyOnResponseAndFinalize(outcome, this.takeResponseRequest(source))
1725
1881
  }
1726
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
+
1727
1909
  /**
1728
1910
  * Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` — the
1729
1911
  * `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
@@ -1778,10 +1960,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1778
1960
  const url = urlPartsOf(req.url)
1779
1961
  const match = this.wsRouter.find("GET", url.pathname)
1780
1962
  if (!match.found) return WS_PASS // upgrade header, no WS route here → normal routing decides
1781
- const params =
1782
- match.params === EMPTY_PARAMS || !url.pathname.includes("%")
1783
- ? match.params
1784
- : 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)
1785
1966
  if (params === null) return { kind: "reject", response: jsonError(400, "malformed_path") }
1786
1967
  const handler = match.payload.handler
1787
1968
  // Non-null: wsRouteCount > 0 ⇒ ws() ran ⇒ the runtime created the registry.
@@ -1806,11 +1987,13 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1806
1987
  if (handler.upgrade === undefined) {
1807
1988
  return { kind: "upgrade", handler, data: undefined, pubsub }
1808
1989
  }
1990
+ const upgradeSignal = getNeverAbortSignal()
1809
1991
  const ctx = new RequestContext(
1810
1992
  req,
1811
1993
  params,
1812
1994
  url.search,
1813
- getNeverAbortSignal(),
1995
+ upgradeSignal,
1996
+ createUnboundedRequestBudget(upgradeSignal),
1814
1997
  platform,
1815
1998
  this.maxBodyBytes,
1816
1999
  )
@@ -1982,38 +2165,95 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
1982
2165
  return wrapResponse(jsonError(404, "not_found"))
1983
2166
  }
1984
2167
 
1985
- const params =
1986
- match.params === EMPTY_PARAMS || !url.pathname.includes("%")
1987
- ? match.params
1988
- : 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)
1989
2171
  if (params === null) {
1990
2172
  return wrapResponse(jsonError(400, "malformed_path"))
1991
2173
  }
1992
2174
 
1993
- const entry = match.payload
1994
- // A per-request cancellation signal. Only allocate a real controller when a
1995
- // timeout is armed; otherwise share a never-aborting signal so the (common)
1996
- // 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.
1997
2224
  let controller: AbortController | undefined
1998
2225
  let signal = getNeverAbortSignal()
1999
- if (this.requestTimeoutMs > 0) {
2226
+ if (effectiveTimeoutMs > 0) {
2000
2227
  controller = new AbortController()
2001
2228
  signal = controller.signal
2002
2229
  }
2230
+ const budget =
2231
+ controller === undefined
2232
+ ? getUnboundedRequestBudget()
2233
+ : createRequestBudget({ deadline: admission!.deadline as number, signal })
2003
2234
  // A bare route runs synchronously (no schema/derives/hooks); everything else keeps the full async
2004
2235
  // lifecycle. `runBare` returns a value directly for a sync handler — no promise.
2005
2236
  let outcome: MaybePromise<T>
2006
2237
  if (webFast && entry.fusedWeb !== undefined) {
2007
2238
  // Fused Web lane (bare route, no around): one closure end to end. Sound cast — webFast is
2008
2239
  // passed only by the `fetch` path, where T = Response by construction.
2009
- 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>
2010
2249
  } else if (entry.contextlessBare) {
2011
2250
  outcome = this.runContextlessBare(
2012
2251
  entry,
2013
2252
  source,
2014
2253
  params,
2015
- url.search,
2254
+ search,
2016
2255
  signal,
2256
+ budget,
2017
2257
  platform,
2018
2258
  finalize,
2019
2259
  wrapResponse,
@@ -2024,8 +2264,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2024
2264
  const ctx = new RequestContext(
2025
2265
  source,
2026
2266
  params,
2027
- url.search,
2267
+ search,
2028
2268
  signal,
2269
+ budget,
2029
2270
  platform,
2030
2271
  this.maxBodyBytes,
2031
2272
  )
@@ -2058,7 +2299,16 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2058
2299
  // The request timeout only bounds work that is actually pending — a synchronous (bare) result is
2059
2300
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
2060
2301
  if (controller !== undefined && outcome instanceof Promise) {
2061
- 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
+ )
2062
2312
  }
2063
2313
  return outcome
2064
2314
  }
@@ -2069,8 +2319,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2069
2319
  entry: RouteEntry,
2070
2320
  source: RequestSource,
2071
2321
  params: Record<string, string>,
2072
- search: string,
2322
+ search: string | undefined,
2073
2323
  signal: AbortSignal,
2324
+ budget: RequestBudget,
2074
2325
  platform: Platform | undefined,
2075
2326
  finalize: (result: unknown, set: CtxSet) => T,
2076
2327
  wrapResponse: (response: Response) => T,
@@ -2079,13 +2330,31 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2079
2330
  try {
2080
2331
  result = (entry.handler as unknown as ContextlessHandler)()
2081
2332
  } catch (err) {
2082
- 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
+ )
2083
2343
  }
2084
2344
  if (result instanceof Promise) {
2085
2345
  return result.then(
2086
2346
  (value) => finalize(value, EMPTY_RESPONSE_CONTROLS),
2087
2347
  (err) =>
2088
- 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
+ ),
2089
2358
  )
2090
2359
  }
2091
2360
  return finalize(result, EMPTY_RESPONSE_CONTROLS)
@@ -2095,13 +2364,22 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2095
2364
  err: unknown,
2096
2365
  source: RequestSource,
2097
2366
  params: Record<string, string>,
2098
- search: string,
2367
+ search: string | undefined,
2099
2368
  signal: AbortSignal,
2369
+ budget: RequestBudget,
2100
2370
  platform: Platform | undefined,
2101
2371
  wrapResponse: (response: Response) => T,
2102
2372
  ): T {
2103
2373
  if (err instanceof Response) return wrapResponse(err)
2104
- 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
+ )
2105
2383
  this.logRequestError(err, ctx)
2106
2384
  return wrapResponse(jsonError(500, "internal_error"))
2107
2385
  }
@@ -2161,29 +2439,43 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2161
2439
  // `() => ...` can't observe the context — skip allocating one entirely (errors still build
2162
2440
  // one for the structured log, exactly like runContextlessBare).
2163
2441
  const contextlessHandler = handler as unknown as ContextlessHandler
2164
- return (source, params, search, signal, platform) => {
2442
+ return (source, params, search, signal, budget, platform, nativeContext) => {
2165
2443
  let result: unknown
2166
2444
  try {
2167
2445
  result = contextlessHandler()
2168
2446
  } catch (err) {
2169
2447
  return logError(
2170
2448
  err,
2171
- 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),
2172
2452
  )
2173
2453
  }
2174
2454
  if (result instanceof Promise) {
2175
2455
  return result.then(fusedRespondNoSet, (err) =>
2176
2456
  logError(
2177
2457
  err,
2178
- 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
+ ),
2179
2469
  ),
2180
2470
  )
2181
2471
  }
2182
2472
  return fusedRespondNoSet(result)
2183
2473
  }
2184
2474
  }
2185
- return (source, params, search, signal, platform) => {
2186
- 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)
2187
2479
  if (decorations !== undefined) Object.assign(ctx, decorations)
2188
2480
  let result: unknown
2189
2481
  try {
@@ -2628,13 +2920,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2628
2920
  work: Promise<T>,
2629
2921
  controller: AbortController,
2630
2922
  onTimeout: () => T,
2923
+ timeoutMs: number,
2631
2924
  ): Promise<T> {
2632
2925
  let timer: ReturnType<typeof setTimeout> | undefined
2633
2926
  const timeout = new Promise<T>((resolve) => {
2634
2927
  timer = setTimeout(() => {
2635
2928
  controller.abort()
2636
2929
  resolve(onTimeout())
2637
- }, this.requestTimeoutMs)
2930
+ }, timeoutMs)
2638
2931
  })
2639
2932
  try {
2640
2933
  return await Promise.race([work, timeout])
@@ -2793,6 +3086,163 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2793
3086
  return readBoundedJsonSource(req, this.maxBodyBytes)
2794
3087
  }
2795
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
+
2796
3246
  /**
2797
3247
  * Start a `Bun.serve` instance bound to `port` (use `0` for an ephemeral port).
2798
3248
  *
@@ -2829,8 +3279,14 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
2829
3279
  ? undefined
2830
3280
  : (getWsRuntime() as WsRuntime).bunHandlers(this.topics as TopicRegistry)
2831
3281
  const reusePort = options?.reusePort === true
3282
+ const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes() : undefined
2832
3283
  const running = (wsHandlers === undefined
2833
- ? 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
+ })
2834
3290
  : Bun.serve<BunWsData>({
2835
3291
  port,
2836
3292
  reusePort,