@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,4 @@
1
+ import { admitDeadline, createRequestBudget, createUnboundedRequestBudget, NIFRA_DEADLINE_HEADER, } from "@nifrajs/budget";
1
2
  import { FrameworkError, RouteConfigError } from "../errors.js";
2
3
  import { assuranceDeclarationsOf, assuranceEvidenceFor, NIFRA_ASSURANCE_IDS, } from "../internal/route-assurance.js";
3
4
  import { EMPTY_PARAMS, Router } from "../router/router.js";
@@ -107,10 +108,15 @@ const TEXT_DECODER = new TextDecoder();
107
108
  * reused at zero per-request cost.
108
109
  */
109
110
  let neverAbortSignal;
111
+ let unboundedRequestBudget;
110
112
  const getNeverAbortSignal = () => {
111
113
  neverAbortSignal ??= new AbortController().signal;
112
114
  return neverAbortSignal;
113
115
  };
116
+ const getUnboundedRequestBudget = () => {
117
+ unboundedRequestBudget ??= createUnboundedRequestBudget(getNeverAbortSignal());
118
+ return unboundedRequestBudget;
119
+ };
114
120
  function jsonError(status, error, headers) {
115
121
  return Response.json({ ok: false, error }, headers !== undefined ? { status, headers } : { status });
116
122
  }
@@ -282,6 +288,13 @@ function decodeParams(raw) {
282
288
  }
283
289
  return out ?? raw;
284
290
  }
291
+ function hasReplacementParam(params) {
292
+ for (const key in params) {
293
+ if (params[key].includes("\uFFFD"))
294
+ return true;
295
+ }
296
+ return false;
297
+ }
285
298
  class LazyResponseControls {
286
299
  status;
287
300
  _headers;
@@ -329,39 +342,47 @@ function statusInit(init) {
329
342
  return typeof init === "number" ? { status: init } : init;
330
343
  }
331
344
  class RequestContext {
332
- params;
333
- signal;
334
- env;
335
- waitUntil;
336
- body;
337
- [CONTEXT_SEARCH];
338
- setValue;
339
- queryValue;
340
- queryReady;
341
- cookiesValue;
342
- source;
343
- maxBodyBytes;
344
- constructor(source, params, search, signal, platform, maxBodyBytes) {
345
+ constructor(source, params, searchOrMaxBodyBytes, signal, budget, platform, maxBodyBytes) {
345
346
  this.source = source;
346
347
  this.params = params;
347
- this[CONTEXT_SEARCH] = search;
348
- this.signal = signal;
349
- this.env = platform?.env;
350
- this.waitUntil = platform?.waitUntil ?? fallbackWaitUntil;
348
+ if (typeof searchOrMaxBodyBytes === "number") {
349
+ this.maxBodyBytes = searchOrMaxBodyBytes;
350
+ return;
351
+ }
352
+ if (searchOrMaxBodyBytes !== undefined)
353
+ this.searchValue = searchOrMaxBodyBytes;
354
+ this.signalValue = signal;
355
+ this.budgetValue = budget;
356
+ if (platform !== undefined)
357
+ this.platformValue = platform;
351
358
  this.maxBodyBytes = maxBodyBytes;
352
- this.setValue = undefined;
353
- this.body = undefined;
354
- this.queryValue = undefined;
355
- this.queryReady = false;
356
- this.cookiesValue = undefined;
359
+ }
360
+ static native(source, params, maxBodyBytes) {
361
+ return new RequestContext(source, params, maxBodyBytes);
357
362
  }
358
363
  [CONTEXT_SET]() {
359
364
  return this.setValue;
360
365
  }
366
+ get [CONTEXT_SEARCH]() {
367
+ this.searchValue ??= searchOf(this.source.url);
368
+ return this.searchValue;
369
+ }
361
370
  get set() {
362
371
  this.setValue ??= new LazyResponseControls();
363
372
  return this.setValue;
364
373
  }
374
+ get signal() {
375
+ return this.signalValue ?? getNeverAbortSignal();
376
+ }
377
+ get budget() {
378
+ return this.budgetValue ?? getUnboundedRequestBudget();
379
+ }
380
+ get env() {
381
+ return this.platformValue?.env;
382
+ }
383
+ get waitUntil() {
384
+ return this.platformValue?.waitUntil ?? fallbackWaitUntil;
385
+ }
365
386
  get req() {
366
387
  return requestOf(this.source);
367
388
  }
@@ -482,17 +503,16 @@ const JSON_CT_HEADERS = new Headers({
482
503
  "content-type": "application/json;charset=utf-8",
483
504
  });
484
505
  const JSON_INIT_200 = { status: 200, headers: JSON_CT_HEADERS };
485
- /** Fused-lane respond when the handler ran with NO context (so `c.set` can't exist): the fast
486
- * JSON respond directly, with `toResponse` + the empty controls as the exact-semantics fallback. */
506
+ /** Fused-lane respond when `c.set` is untouched. Bun 1.3's native `Response.json` now beats the
507
+ * older hand-inlined stringify + Response construction on this lane while preserving the exact
508
+ * body/content-type contract; keep the generic fallback for non-JSON values. */
487
509
  function fusedRespondNoSet(result) {
488
510
  if (result !== undefined &&
489
511
  !(result instanceof Response) &&
490
512
  typeof result === "object" &&
491
513
  result !== null &&
492
514
  !isResponseResult(result)) {
493
- const body = JSON.stringify(result);
494
- if (body !== undefined)
495
- return new Response(body, JSON_INIT_200);
515
+ return Response.json(result);
496
516
  }
497
517
  return toResponse(result, EMPTY_RESPONSE_CONTROLS);
498
518
  }
@@ -634,6 +654,9 @@ export class Server {
634
654
  maxBodyBytes;
635
655
  wsMaxPayloadBytes;
636
656
  requestTimeoutMs;
657
+ acceptInboundDeadlines;
658
+ maxInboundDeadlineMs;
659
+ deadlineAdmissionOptions;
637
660
  gracefulSignals;
638
661
  logger;
639
662
  /** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
@@ -674,6 +697,18 @@ export class Server {
674
697
  this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
675
698
  this.wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? this.maxBodyBytes;
676
699
  this.requestTimeoutMs = options.requestTimeoutMs ?? 0;
700
+ this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false;
701
+ this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000;
702
+ this.deadlineAdmissionOptions = Object.freeze({
703
+ localTimeoutMs: this.requestTimeoutMs,
704
+ maxInboundDeadlineMs: this.maxInboundDeadlineMs,
705
+ });
706
+ if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs < 0) {
707
+ throw new RangeError("requestTimeoutMs must be a finite non-negative number");
708
+ }
709
+ if (!Number.isFinite(this.maxInboundDeadlineMs) || this.maxInboundDeadlineMs <= 0) {
710
+ throw new RangeError("maxInboundDeadlineMs must be a finite positive number");
711
+ }
677
712
  this.gracefulSignals = options.gracefulSignals ?? false;
678
713
  this.logger = options.logger ?? jsonLogger();
679
714
  this.defaultOnValidationError = options.onValidationError;
@@ -1089,6 +1124,17 @@ export class Server {
1089
1124
  ? outcome.then((response) => this.applyOnResponseAndFinalize(response, this.takeResponseRequest(source)))
1090
1125
  : this.applyOnResponseAndFinalize(outcome, this.takeResponseRequest(source));
1091
1126
  }
1127
+ /** Web response path when Bun already matched the route. The lifecycle and response hooks remain
1128
+ * exactly the same as {@link fetchSource}; only portable URL scanning + trie lookup are skipped. */
1129
+ fetchMatched(source, entry, params) {
1130
+ const outcome = this.runMatched(source, undefined, entry, params, undefined, toResponse, IDENTITY_RESPONSE, RESPONSE_TIMEOUT, true);
1131
+ if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
1132
+ return outcome;
1133
+ }
1134
+ return outcome instanceof Promise
1135
+ ? outcome.then((response) => this.applyOnResponseAndFinalize(response, requestOf(source)))
1136
+ : this.applyOnResponseAndFinalize(outcome, requestOf(source));
1137
+ }
1092
1138
  /**
1093
1139
  * Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` — the
1094
1140
  * `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
@@ -1131,9 +1177,9 @@ export class Server {
1131
1177
  const match = this.wsRouter.find("GET", url.pathname);
1132
1178
  if (!match.found)
1133
1179
  return WS_PASS; // upgrade header, no WS route here → normal routing decides
1134
- const params = match.params === EMPTY_PARAMS || !url.pathname.includes("%")
1135
- ? match.params
1136
- : decodeParams(match.params);
1180
+ // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
1181
+ // already did and made every plain dynamic route pay for unrelated static path bytes.
1182
+ const params = match.params === EMPTY_PARAMS ? match.params : decodeParams(match.params);
1137
1183
  if (params === null)
1138
1184
  return { kind: "reject", response: jsonError(400, "malformed_path") };
1139
1185
  const handler = match.payload.handler;
@@ -1160,7 +1206,8 @@ export class Server {
1160
1206
  if (handler.upgrade === undefined) {
1161
1207
  return { kind: "upgrade", handler, data: undefined, pubsub };
1162
1208
  }
1163
- const ctx = new RequestContext(req, params, url.search, getNeverAbortSignal(), platform, this.maxBodyBytes);
1209
+ const upgradeSignal = getNeverAbortSignal();
1210
+ const ctx = new RequestContext(req, params, url.search, upgradeSignal, createUnboundedRequestBudget(upgradeSignal), platform, this.maxBodyBytes);
1164
1211
  const settle = (value) => value instanceof Response
1165
1212
  ? { kind: "reject", response: value }
1166
1213
  : { kind: "upgrade", handler, data: value, pubsub };
@@ -1283,37 +1330,63 @@ export class Server {
1283
1330
  }
1284
1331
  return wrapResponse(jsonError(404, "not_found"));
1285
1332
  }
1286
- const params = match.params === EMPTY_PARAMS || !url.pathname.includes("%")
1287
- ? match.params
1288
- : decodeParams(match.params);
1333
+ // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
1334
+ // already did and made every plain dynamic route pay for unrelated static path bytes.
1335
+ const params = match.params === EMPTY_PARAMS ? match.params : decodeParams(match.params);
1289
1336
  if (params === null) {
1290
1337
  return wrapResponse(jsonError(400, "malformed_path"));
1291
1338
  }
1292
- const entry = match.payload;
1293
- // A per-request cancellation signal. Only allocate a real controller when a
1294
- // timeout is armed; otherwise share a never-aborting signal so the (common)
1295
- // no-timeout path pays nothing.
1339
+ return this.runMatched(source, platform, match.payload, params, url.search, finalize, wrapResponse, onTimeout, webFast);
1340
+ }
1341
+ /** Run a route that has already been matched by the runtime or Nifra's portable router. */
1342
+ runMatched(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
1343
+ // Translate the absolute wire deadline once, clamp it to local policy, then use the resulting
1344
+ // duration for both c.signal and c.budget. A client can only shorten work, never extend it.
1345
+ // Most requests have neither a local timeout nor a propagated deadline. Detect that case with
1346
+ // one header lookup and skip policy validation, wall-clock sampling, and admission objects. A
1347
+ // present wire deadline still goes through the full fail-closed parser/clamp below.
1348
+ const admission = !this.acceptInboundDeadlines
1349
+ ? this.requestTimeoutMs === 0
1350
+ ? undefined
1351
+ : {
1352
+ ok: true,
1353
+ inherited: false,
1354
+ timeoutMs: this.requestTimeoutMs,
1355
+ deadline: Math.floor(Date.now() + this.requestTimeoutMs),
1356
+ }
1357
+ : this.requestTimeoutMs === 0 && headerOf(source, NIFRA_DEADLINE_HEADER) === null
1358
+ ? undefined
1359
+ : admitDeadline(source.headers, this.deadlineAdmissionOptions);
1360
+ if (admission !== undefined && !admission.ok) {
1361
+ return wrapResponse(jsonError(admission.status, admission.reason));
1362
+ }
1363
+ const effectiveTimeoutMs = admission?.timeoutMs ?? 0;
1364
+ // Only allocate a controller for a finite budget; the historical no-timeout path remains
1365
+ // allocation-light and exposes an unbounded budget that is never propagated on the wire.
1296
1366
  let controller;
1297
1367
  let signal = getNeverAbortSignal();
1298
- if (this.requestTimeoutMs > 0) {
1368
+ if (effectiveTimeoutMs > 0) {
1299
1369
  controller = new AbortController();
1300
1370
  signal = controller.signal;
1301
1371
  }
1372
+ const budget = controller === undefined
1373
+ ? getUnboundedRequestBudget()
1374
+ : createRequestBudget({ deadline: admission.deadline, signal });
1302
1375
  // A bare route runs synchronously (no schema/derives/hooks); everything else keeps the full async
1303
1376
  // lifecycle. `runBare` returns a value directly for a sync handler — no promise.
1304
1377
  let outcome;
1305
1378
  if (webFast && entry.fusedWeb !== undefined) {
1306
1379
  // Fused Web lane (bare route, no around): one closure end to end. Sound cast — webFast is
1307
1380
  // passed only by the `fetch` path, where T = Response by construction.
1308
- outcome = entry.fusedWeb(source, params, url.search, signal, platform);
1381
+ outcome = entry.fusedWeb(source, params, search, signal, budget, platform, false);
1309
1382
  }
1310
1383
  else if (entry.contextlessBare) {
1311
- outcome = this.runContextlessBare(entry, source, params, url.search, signal, platform, finalize, wrapResponse);
1384
+ outcome = this.runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse);
1312
1385
  }
1313
1386
  else {
1314
1387
  // Build the context up front so `onError` always has it. Query, cookies, headers, and body helpers
1315
1388
  // stay lazy, but their accessors live on the prototype instead of allocating per-request closures.
1316
- const ctx = new RequestContext(source, params, url.search, signal, platform, this.maxBodyBytes);
1389
+ const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
1317
1390
  if (entry.around.length === 0) {
1318
1391
  outcome = entry.bare
1319
1392
  ? this.runBare(entry, ctx, finalize, wrapResponse)
@@ -1336,29 +1409,32 @@ export class Server {
1336
1409
  // The request timeout only bounds work that is actually pending — a synchronous (bare) result is
1337
1410
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
1338
1411
  if (controller !== undefined && outcome instanceof Promise) {
1339
- return this.withTimeout(outcome, controller, onTimeout);
1412
+ const timedOut = admission?.inherited === true
1413
+ ? () => wrapResponse(jsonError(504, "deadline_exceeded"))
1414
+ : onTimeout;
1415
+ return this.withTimeout(outcome, controller, timedOut, Math.max(0, Math.ceil(budget.remaining())));
1340
1416
  }
1341
1417
  return outcome;
1342
1418
  }
1343
1419
  /** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
1344
1420
  * successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
1345
- runContextlessBare(entry, source, params, search, signal, platform, finalize, wrapResponse) {
1421
+ runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse) {
1346
1422
  let result;
1347
1423
  try {
1348
1424
  result = entry.handler();
1349
1425
  }
1350
1426
  catch (err) {
1351
- return this.contextlessBareError(err, source, params, search, signal, platform, wrapResponse);
1427
+ return this.contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse);
1352
1428
  }
1353
1429
  if (result instanceof Promise) {
1354
- return result.then((value) => finalize(value, EMPTY_RESPONSE_CONTROLS), (err) => this.contextlessBareError(err, source, params, search, signal, platform, wrapResponse));
1430
+ return result.then((value) => finalize(value, EMPTY_RESPONSE_CONTROLS), (err) => this.contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse));
1355
1431
  }
1356
1432
  return finalize(result, EMPTY_RESPONSE_CONTROLS);
1357
1433
  }
1358
- contextlessBareError(err, source, params, search, signal, platform, wrapResponse) {
1434
+ contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse) {
1359
1435
  if (err instanceof Response)
1360
1436
  return wrapResponse(err);
1361
- const ctx = new RequestContext(source, params, search, signal, platform, this.maxBodyBytes);
1437
+ const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
1362
1438
  this.logRequestError(err, ctx);
1363
1439
  return wrapResponse(jsonError(500, "internal_error"));
1364
1440
  }
@@ -1407,22 +1483,28 @@ export class Server {
1407
1483
  // `() => ...` can't observe the context — skip allocating one entirely (errors still build
1408
1484
  // one for the structured log, exactly like runContextlessBare).
1409
1485
  const contextlessHandler = handler;
1410
- return (source, params, search, signal, platform) => {
1486
+ return (source, params, search, signal, budget, platform, nativeContext) => {
1411
1487
  let result;
1412
1488
  try {
1413
1489
  result = contextlessHandler();
1414
1490
  }
1415
1491
  catch (err) {
1416
- return logError(err, new RequestContext(source, params, search, signal, platform, maxBodyBytes));
1492
+ return logError(err, nativeContext
1493
+ ? RequestContext.native(source, params, maxBodyBytes)
1494
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes));
1417
1495
  }
1418
1496
  if (result instanceof Promise) {
1419
- return result.then(fusedRespondNoSet, (err) => logError(err, new RequestContext(source, params, search, signal, platform, maxBodyBytes)));
1497
+ return result.then(fusedRespondNoSet, (err) => logError(err, nativeContext
1498
+ ? RequestContext.native(source, params, maxBodyBytes)
1499
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes)));
1420
1500
  }
1421
1501
  return fusedRespondNoSet(result);
1422
1502
  };
1423
1503
  }
1424
- return (source, params, search, signal, platform) => {
1425
- const ctx = new RequestContext(source, params, search, signal, platform, maxBodyBytes);
1504
+ return (source, params, search, signal, budget, platform, nativeContext) => {
1505
+ const ctx = nativeContext
1506
+ ? RequestContext.native(source, params, maxBodyBytes)
1507
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes);
1426
1508
  if (decorations !== undefined)
1427
1509
  Object.assign(ctx, decorations);
1428
1510
  let result;
@@ -1738,13 +1820,13 @@ export class Server {
1738
1820
  * handlers can bail) and return 503; the in-flight work keeps running but its
1739
1821
  * result is discarded — JS can't forcibly cancel a promise.
1740
1822
  */
1741
- async withTimeout(work, controller, onTimeout) {
1823
+ async withTimeout(work, controller, onTimeout, timeoutMs) {
1742
1824
  let timer;
1743
1825
  const timeout = new Promise((resolve) => {
1744
1826
  timer = setTimeout(() => {
1745
1827
  controller.abort();
1746
1828
  resolve(onTimeout());
1747
- }, this.requestTimeoutMs);
1829
+ }, timeoutMs);
1748
1830
  });
1749
1831
  try {
1750
1832
  return await Promise.race([work, timeout]);
@@ -1888,6 +1970,137 @@ export class Server {
1888
1970
  async readBoundedJson(req) {
1889
1971
  return readBoundedJsonSource(req, this.maxBodyBytes);
1890
1972
  }
1973
+ /** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
1974
+ * hooks or WebSockets retain the single portable dispatcher because those features must run before
1975
+ * route selection/upgrade. Named wildcards also stay on the fallback until Bun exposes their raw
1976
+ * capture semantics; static and `:param` routes take the native lane. */
1977
+ buildBunNativeRoutes() {
1978
+ if (this.onRequestHooks.length > 0 || this.wsRouteCount > 0)
1979
+ return undefined;
1980
+ const routes = Object.create(null);
1981
+ const mayUseFusedNative = this.requestTimeoutMs === 0 &&
1982
+ this.onResponseHooks.length === 0 &&
1983
+ this.onResponseFinalizedHooks.length === 0;
1984
+ const unboundedSignal = mayUseFusedNative ? getNeverAbortSignal() : undefined;
1985
+ const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined;
1986
+ let count = 0;
1987
+ for (const { method, path, entry } of this.rawEntries) {
1988
+ if (path.includes("*"))
1989
+ continue;
1990
+ let methods = routes[path];
1991
+ if (methods === undefined) {
1992
+ methods = Object.create(null);
1993
+ routes[path] = methods;
1994
+ }
1995
+ const paramNames = path
1996
+ .split("/")
1997
+ .filter((segment) => segment.charCodeAt(0) === 58 /* : */)
1998
+ .map((segment) => segment.slice(1));
1999
+ const fused = mayUseFusedNative ? entry.fusedWeb : undefined;
2000
+ if (fused !== undefined) {
2001
+ // Deliberately choose deadline-aware vs zero-admission closures HERE instead of branching on
2002
+ // `acceptInboundDeadlines` per request. JSC can then flatten the default closure through the
2003
+ // handler + Response.json; retaining that route-invariant branch cost ~7% on a dynamic GET.
2004
+ if (paramNames.length === 0) {
2005
+ methods[method] = this.acceptInboundDeadlines
2006
+ ? (request) => request.headers.get(NIFRA_DEADLINE_HEADER) !== null
2007
+ ? this.fetchMatched(request, entry, EMPTY_PARAMS)
2008
+ : fused(request, EMPTY_PARAMS, undefined, unboundedSignal, unboundedBudget, undefined, true)
2009
+ : (request) => fused(request, EMPTY_PARAMS, undefined, unboundedSignal, unboundedBudget, undefined, true);
2010
+ }
2011
+ else if (paramNames.length === 1) {
2012
+ const paramName = paramNames[0];
2013
+ const handler = entry.handler;
2014
+ const decorations = entry.hasDecorations ? entry.decorations : undefined;
2015
+ const fail = (error, ctx) => {
2016
+ if (error instanceof Response)
2017
+ return error;
2018
+ this.logRequestError(error, ctx);
2019
+ return jsonError(500, "internal_error");
2020
+ };
2021
+ methods[method] = this.acceptInboundDeadlines
2022
+ ? (request) => {
2023
+ const params = request.params ?? EMPTY_PARAMS;
2024
+ if (params[paramName]?.includes("\uFFFD") === true)
2025
+ return this.fetchSource(request);
2026
+ if (request.headers.get(NIFRA_DEADLINE_HEADER) !== null) {
2027
+ return this.fetchMatched(request, entry, params);
2028
+ }
2029
+ const ctx = RequestContext.native(request, params, this.maxBodyBytes);
2030
+ if (decorations !== undefined)
2031
+ Object.assign(ctx, decorations);
2032
+ let result;
2033
+ try {
2034
+ result = handler(ctx);
2035
+ }
2036
+ catch (error) {
2037
+ return fail(error, ctx);
2038
+ }
2039
+ return result instanceof Promise
2040
+ ? result.then((value) => fusedRespond(value, ctx), (error) => fail(error, ctx))
2041
+ : fusedRespond(result, ctx);
2042
+ }
2043
+ : (request) => {
2044
+ const params = request.params ?? EMPTY_PARAMS;
2045
+ if (params[paramName]?.includes("\uFFFD") === true)
2046
+ return this.fetchSource(request);
2047
+ const ctx = RequestContext.native(request, params, this.maxBodyBytes);
2048
+ if (decorations !== undefined)
2049
+ Object.assign(ctx, decorations);
2050
+ let result;
2051
+ try {
2052
+ result = handler(ctx);
2053
+ }
2054
+ catch (error) {
2055
+ return fail(error, ctx);
2056
+ }
2057
+ return result instanceof Promise
2058
+ ? result.then((value) => fusedRespond(value, ctx), (error) => fail(error, ctx))
2059
+ : fusedRespond(result, ctx);
2060
+ };
2061
+ }
2062
+ else {
2063
+ methods[method] = this.acceptInboundDeadlines
2064
+ ? (request) => {
2065
+ const params = request.params ?? EMPTY_PARAMS;
2066
+ if (hasReplacementParam(params))
2067
+ return this.fetchSource(request);
2068
+ return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
2069
+ ? this.fetchMatched(request, entry, params)
2070
+ : fused(request, params, undefined, unboundedSignal, unboundedBudget, undefined, true);
2071
+ }
2072
+ : (request) => {
2073
+ const params = request.params ?? EMPTY_PARAMS;
2074
+ if (hasReplacementParam(params))
2075
+ return this.fetchSource(request);
2076
+ return fused(request, params, undefined, unboundedSignal, unboundedBudget, undefined, true);
2077
+ };
2078
+ }
2079
+ }
2080
+ else if (paramNames.length === 0) {
2081
+ methods[method] = (request) => this.fetchMatched(request, entry, EMPTY_PARAMS);
2082
+ }
2083
+ else if (paramNames.length === 1) {
2084
+ const paramName = paramNames[0];
2085
+ methods[method] = (request) => {
2086
+ const params = request.params ?? EMPTY_PARAMS;
2087
+ if (params[paramName]?.includes("\uFFFD") === true)
2088
+ return this.fetchSource(request);
2089
+ return this.fetchMatched(request, entry, params);
2090
+ };
2091
+ }
2092
+ else {
2093
+ methods[method] = (request) => {
2094
+ const params = request.params ?? EMPTY_PARAMS;
2095
+ if (hasReplacementParam(params))
2096
+ return this.fetchSource(request);
2097
+ return this.fetchMatched(request, entry, params);
2098
+ };
2099
+ }
2100
+ count += 1;
2101
+ }
2102
+ return count === 0 ? undefined : routes;
2103
+ }
1891
2104
  /**
1892
2105
  * Start a `Bun.serve` instance bound to `port` (use `0` for an ephemeral port).
1893
2106
  *
@@ -1920,8 +2133,14 @@ export class Server {
1920
2133
  ? undefined
1921
2134
  : getWsRuntime().bunHandlers(this.topics);
1922
2135
  const reusePort = options?.reusePort === true;
2136
+ const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes() : undefined;
1923
2137
  const running = (wsHandlers === undefined
1924
- ? Bun.serve({ port, reusePort, fetch: (req) => this.fetch(req) })
2138
+ ? Bun.serve({
2139
+ port,
2140
+ reusePort,
2141
+ ...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
2142
+ fetch: (req) => this.fetch(req),
2143
+ })
1925
2144
  : Bun.serve({
1926
2145
  port,
1927
2146
  reusePort,