@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,6 @@
1
+ import { admitDeadline, createRequestBudget, createUnboundedRequestBudget, NIFRA_DEADLINE_HEADER, } from "@nifrajs/budget";
1
2
  import { FrameworkError, RouteConfigError } from "../errors.js";
3
+ import { assuranceDeclarationsOf, assuranceEvidenceFor, NIFRA_ASSURANCE_IDS, } from "../internal/route-assurance.js";
2
4
  import { EMPTY_PARAMS, Router } from "../router/router.js";
3
5
  import { drainCapped, parseContentLength, readBoundedBytes } from "./body.js";
4
6
  import { parseCookies, serializeCookie } from "./cookies.js";
@@ -106,10 +108,15 @@ const TEXT_DECODER = new TextDecoder();
106
108
  * reused at zero per-request cost.
107
109
  */
108
110
  let neverAbortSignal;
111
+ let unboundedRequestBudget;
109
112
  const getNeverAbortSignal = () => {
110
113
  neverAbortSignal ??= new AbortController().signal;
111
114
  return neverAbortSignal;
112
115
  };
116
+ const getUnboundedRequestBudget = () => {
117
+ unboundedRequestBudget ??= createUnboundedRequestBudget(getNeverAbortSignal());
118
+ return unboundedRequestBudget;
119
+ };
113
120
  function jsonError(status, error, headers) {
114
121
  return Response.json({ ok: false, error }, headers !== undefined ? { status, headers } : { status });
115
122
  }
@@ -281,6 +288,13 @@ function decodeParams(raw) {
281
288
  }
282
289
  return out ?? raw;
283
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
+ }
284
298
  class LazyResponseControls {
285
299
  status;
286
300
  _headers;
@@ -328,39 +342,47 @@ function statusInit(init) {
328
342
  return typeof init === "number" ? { status: init } : init;
329
343
  }
330
344
  class RequestContext {
331
- params;
332
- signal;
333
- env;
334
- waitUntil;
335
- body;
336
- [CONTEXT_SEARCH];
337
- setValue;
338
- queryValue;
339
- queryReady;
340
- cookiesValue;
341
- source;
342
- maxBodyBytes;
343
- constructor(source, params, search, signal, platform, maxBodyBytes) {
345
+ constructor(source, params, searchOrMaxBodyBytes, signal, budget, platform, maxBodyBytes) {
344
346
  this.source = source;
345
347
  this.params = params;
346
- this[CONTEXT_SEARCH] = search;
347
- this.signal = signal;
348
- this.env = platform?.env;
349
- 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;
350
358
  this.maxBodyBytes = maxBodyBytes;
351
- this.setValue = undefined;
352
- this.body = undefined;
353
- this.queryValue = undefined;
354
- this.queryReady = false;
355
- this.cookiesValue = undefined;
359
+ }
360
+ static native(source, params, maxBodyBytes) {
361
+ return new RequestContext(source, params, maxBodyBytes);
356
362
  }
357
363
  [CONTEXT_SET]() {
358
364
  return this.setValue;
359
365
  }
366
+ get [CONTEXT_SEARCH]() {
367
+ this.searchValue ??= searchOf(this.source.url);
368
+ return this.searchValue;
369
+ }
360
370
  get set() {
361
371
  this.setValue ??= new LazyResponseControls();
362
372
  return this.setValue;
363
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
+ }
364
386
  get req() {
365
387
  return requestOf(this.source);
366
388
  }
@@ -481,17 +503,16 @@ const JSON_CT_HEADERS = new Headers({
481
503
  "content-type": "application/json;charset=utf-8",
482
504
  });
483
505
  const JSON_INIT_200 = { status: 200, headers: JSON_CT_HEADERS };
484
- /** Fused-lane respond when the handler ran with NO context (so `c.set` can't exist): the fast
485
- * 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. */
486
509
  function fusedRespondNoSet(result) {
487
510
  if (result !== undefined &&
488
511
  !(result instanceof Response) &&
489
512
  typeof result === "object" &&
490
513
  result !== null &&
491
514
  !isResponseResult(result)) {
492
- const body = JSON.stringify(result);
493
- if (body !== undefined)
494
- return new Response(body, JSON_INIT_200);
515
+ return Response.json(result);
495
516
  }
496
517
  return toResponse(result, EMPTY_RESPONSE_CONTROLS);
497
518
  }
@@ -633,6 +654,9 @@ export class Server {
633
654
  maxBodyBytes;
634
655
  wsMaxPayloadBytes;
635
656
  requestTimeoutMs;
657
+ acceptInboundDeadlines;
658
+ maxInboundDeadlineMs;
659
+ deadlineAdmissionOptions;
636
660
  gracefulSignals;
637
661
  logger;
638
662
  /** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
@@ -650,6 +674,10 @@ export class Server {
650
674
  responseRequests;
651
675
  /** Names of plugins/middleware already applied via `use` — for idempotent dedupe. */
652
676
  appliedPlugins;
677
+ /** Order-scoped evidence captured by routes registered after an assured plugin. */
678
+ activeAssurance;
679
+ /** App-wide evidence from global hooks; applies retroactively to every route. */
680
+ globalAssurance;
653
681
  /** App-declared MCP resources / prompts (via {@link resource} / {@link prompt}), read by `nifra mcp`. */
654
682
  mcpResourceList;
655
683
  mcpPromptList;
@@ -669,6 +697,18 @@ export class Server {
669
697
  this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
670
698
  this.wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? this.maxBodyBytes;
671
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
+ }
672
712
  this.gracefulSignals = options.gracefulSignals ?? false;
673
713
  this.logger = options.logger ?? jsonLogger();
674
714
  this.defaultOnValidationError = options.onValidationError;
@@ -684,6 +724,8 @@ export class Server {
684
724
  this.onResponseFinalizedHooks = [];
685
725
  this.responseRequests = new WeakMap();
686
726
  this.appliedPlugins = new Set();
727
+ this.activeAssurance = [];
728
+ this.globalAssurance = [];
687
729
  this.mcpResourceList = [];
688
730
  this.mcpPromptList = [];
689
731
  this.rawEntries = [];
@@ -748,13 +790,35 @@ export class Server {
748
790
  return this; // idempotent: already applied
749
791
  this.appliedPlugins.add(name);
750
792
  }
751
- return arg(this);
793
+ const evidence = assuranceDeclarationsOf(arg);
794
+ const pluginOnly = evidence.filter((item) => item.scope === "plugin");
795
+ this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"));
796
+ this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"));
797
+ this.activeAssurance.push(...pluginOnly);
798
+ try {
799
+ return arg(this);
800
+ }
801
+ finally {
802
+ // Remove only this plugin's temporary evidence. Nested assured plugins may deliberately leave
803
+ // subsequent evidence active, so truncating the whole array would lose real ordering semantics.
804
+ for (const item of pluginOnly) {
805
+ const index = this.activeAssurance.indexOf(item);
806
+ if (index !== -1)
807
+ this.activeAssurance.splice(index, 1);
808
+ }
809
+ }
752
810
  }
753
811
  if (arg.name !== undefined) {
754
812
  if (this.appliedPlugins.has(arg.name))
755
813
  return this;
756
814
  this.appliedPlugins.add(arg.name);
757
815
  }
816
+ const evidence = assuranceDeclarationsOf(arg);
817
+ if (evidence.some((item) => item.scope === "plugin")) {
818
+ throw new Error('route assurance: scope "plugin" may only annotate a plugin function');
819
+ }
820
+ this.globalAssurance.push(...evidence.filter((item) => item.scope === "global"));
821
+ this.activeAssurance.push(...evidence.filter((item) => item.scope === "subsequent"));
758
822
  if (arg.onRequest !== undefined)
759
823
  this.onRequest(arg.onRequest);
760
824
  if (arg.around !== undefined)
@@ -966,7 +1030,21 @@ export class Server {
966
1030
  this.router.add(method, path, entry);
967
1031
  const descriptor = { method, path, schema };
968
1032
  this.routeList.push(descriptor);
969
- this.rawEntries.push({ method, path, entry, descriptor });
1033
+ const routeAssurance = [...this.activeAssurance];
1034
+ if (schema?.body !== undefined) {
1035
+ routeAssurance.push(Object.freeze({
1036
+ id: NIFRA_ASSURANCE_IDS.BODY_BOUNDED,
1037
+ source: "route-schema",
1038
+ scope: "plugin",
1039
+ }));
1040
+ }
1041
+ this.rawEntries.push({
1042
+ method,
1043
+ path,
1044
+ entry,
1045
+ descriptor,
1046
+ assurance: Object.freeze(routeAssurance),
1047
+ });
970
1048
  }
971
1049
  /**
972
1050
  * Merge another server's routes into this one — the composition escape hatch for large apps.
@@ -992,14 +1070,15 @@ export class Server {
992
1070
  if (source.wsRouteCount > 0) {
993
1071
  throw new RouteConfigError("INVALID_PATH", "merge() does not carry WebSocket routes — register .ws() routes on the parent server");
994
1072
  }
995
- for (const { method, path, entry, descriptor } of source.rawEntries) {
1073
+ for (const { method, path, entry, descriptor, assurance } of source.rawEntries) {
996
1074
  this.router.add(method, path, entry); // throws RouteConfigError on a duplicate — fail closed
997
1075
  this.routeList.push(descriptor); // shared ref on purpose: carries later .tool() tagging
998
- this.rawEntries.push({ method, path, entry, descriptor });
1076
+ this.rawEntries.push({ method, path, entry, descriptor, assurance });
999
1077
  }
1000
1078
  this.onRequestHooks.push(...source.onRequestHooks);
1001
1079
  this.onResponseHooks.push(...source.onResponseHooks);
1002
1080
  this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks);
1081
+ this.globalAssurance.push(...source.globalAssurance);
1003
1082
  this.mcpResourceList.push(...source.mcpResourceList);
1004
1083
  this.mcpPromptList.push(...source.mcpPromptList);
1005
1084
  return this;
@@ -1010,7 +1089,15 @@ export class Server {
1010
1089
  * longer holds the original patterns.
1011
1090
  */
1012
1091
  routes() {
1013
- return this.routeList;
1092
+ if (this.activeAssurance.length === 0 && this.globalAssurance.length === 0) {
1093
+ const anyRouteEvidence = this.rawEntries.some((entry) => entry.assurance.length > 0);
1094
+ if (!anyRouteEvidence)
1095
+ return this.routeList;
1096
+ }
1097
+ return this.rawEntries.map(({ method, path, descriptor, assurance }) => {
1098
+ const effective = assuranceEvidenceFor([...assurance, ...this.globalAssurance], method, path);
1099
+ return effective.length > 0 ? { ...descriptor, assurance: effective } : descriptor;
1100
+ });
1014
1101
  }
1015
1102
  /**
1016
1103
  * Resolve a `Request` to a `Response` — the whole lifecycle, testable without a port. The
@@ -1037,6 +1124,17 @@ export class Server {
1037
1124
  ? outcome.then((response) => this.applyOnResponseAndFinalize(response, this.takeResponseRequest(source)))
1038
1125
  : this.applyOnResponseAndFinalize(outcome, this.takeResponseRequest(source));
1039
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
+ }
1040
1138
  /**
1041
1139
  * Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` — the
1042
1140
  * `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
@@ -1079,9 +1177,9 @@ export class Server {
1079
1177
  const match = this.wsRouter.find("GET", url.pathname);
1080
1178
  if (!match.found)
1081
1179
  return WS_PASS; // upgrade header, no WS route here → normal routing decides
1082
- const params = match.params === EMPTY_PARAMS || !url.pathname.includes("%")
1083
- ? match.params
1084
- : 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);
1085
1183
  if (params === null)
1086
1184
  return { kind: "reject", response: jsonError(400, "malformed_path") };
1087
1185
  const handler = match.payload.handler;
@@ -1108,7 +1206,8 @@ export class Server {
1108
1206
  if (handler.upgrade === undefined) {
1109
1207
  return { kind: "upgrade", handler, data: undefined, pubsub };
1110
1208
  }
1111
- 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);
1112
1211
  const settle = (value) => value instanceof Response
1113
1212
  ? { kind: "reject", response: value }
1114
1213
  : { kind: "upgrade", handler, data: value, pubsub };
@@ -1231,37 +1330,63 @@ export class Server {
1231
1330
  }
1232
1331
  return wrapResponse(jsonError(404, "not_found"));
1233
1332
  }
1234
- const params = match.params === EMPTY_PARAMS || !url.pathname.includes("%")
1235
- ? match.params
1236
- : 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);
1237
1336
  if (params === null) {
1238
1337
  return wrapResponse(jsonError(400, "malformed_path"));
1239
1338
  }
1240
- const entry = match.payload;
1241
- // A per-request cancellation signal. Only allocate a real controller when a
1242
- // timeout is armed; otherwise share a never-aborting signal so the (common)
1243
- // 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.
1244
1366
  let controller;
1245
1367
  let signal = getNeverAbortSignal();
1246
- if (this.requestTimeoutMs > 0) {
1368
+ if (effectiveTimeoutMs > 0) {
1247
1369
  controller = new AbortController();
1248
1370
  signal = controller.signal;
1249
1371
  }
1372
+ const budget = controller === undefined
1373
+ ? getUnboundedRequestBudget()
1374
+ : createRequestBudget({ deadline: admission.deadline, signal });
1250
1375
  // A bare route runs synchronously (no schema/derives/hooks); everything else keeps the full async
1251
1376
  // lifecycle. `runBare` returns a value directly for a sync handler — no promise.
1252
1377
  let outcome;
1253
1378
  if (webFast && entry.fusedWeb !== undefined) {
1254
1379
  // Fused Web lane (bare route, no around): one closure end to end. Sound cast — webFast is
1255
1380
  // passed only by the `fetch` path, where T = Response by construction.
1256
- outcome = entry.fusedWeb(source, params, url.search, signal, platform);
1381
+ outcome = entry.fusedWeb(source, params, search, signal, budget, platform, false);
1257
1382
  }
1258
1383
  else if (entry.contextlessBare) {
1259
- 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);
1260
1385
  }
1261
1386
  else {
1262
1387
  // Build the context up front so `onError` always has it. Query, cookies, headers, and body helpers
1263
1388
  // stay lazy, but their accessors live on the prototype instead of allocating per-request closures.
1264
- 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);
1265
1390
  if (entry.around.length === 0) {
1266
1391
  outcome = entry.bare
1267
1392
  ? this.runBare(entry, ctx, finalize, wrapResponse)
@@ -1284,29 +1409,32 @@ export class Server {
1284
1409
  // The request timeout only bounds work that is actually pending — a synchronous (bare) result is
1285
1410
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
1286
1411
  if (controller !== undefined && outcome instanceof Promise) {
1287
- 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())));
1288
1416
  }
1289
1417
  return outcome;
1290
1418
  }
1291
1419
  /** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
1292
1420
  * successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
1293
- runContextlessBare(entry, source, params, search, signal, platform, finalize, wrapResponse) {
1421
+ runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse) {
1294
1422
  let result;
1295
1423
  try {
1296
1424
  result = entry.handler();
1297
1425
  }
1298
1426
  catch (err) {
1299
- return this.contextlessBareError(err, source, params, search, signal, platform, wrapResponse);
1427
+ return this.contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse);
1300
1428
  }
1301
1429
  if (result instanceof Promise) {
1302
- 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));
1303
1431
  }
1304
1432
  return finalize(result, EMPTY_RESPONSE_CONTROLS);
1305
1433
  }
1306
- contextlessBareError(err, source, params, search, signal, platform, wrapResponse) {
1434
+ contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse) {
1307
1435
  if (err instanceof Response)
1308
1436
  return wrapResponse(err);
1309
- const ctx = new RequestContext(source, params, search, signal, platform, this.maxBodyBytes);
1437
+ const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
1310
1438
  this.logRequestError(err, ctx);
1311
1439
  return wrapResponse(jsonError(500, "internal_error"));
1312
1440
  }
@@ -1355,22 +1483,28 @@ export class Server {
1355
1483
  // `() => ...` can't observe the context — skip allocating one entirely (errors still build
1356
1484
  // one for the structured log, exactly like runContextlessBare).
1357
1485
  const contextlessHandler = handler;
1358
- return (source, params, search, signal, platform) => {
1486
+ return (source, params, search, signal, budget, platform, nativeContext) => {
1359
1487
  let result;
1360
1488
  try {
1361
1489
  result = contextlessHandler();
1362
1490
  }
1363
1491
  catch (err) {
1364
- 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));
1365
1495
  }
1366
1496
  if (result instanceof Promise) {
1367
- 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)));
1368
1500
  }
1369
1501
  return fusedRespondNoSet(result);
1370
1502
  };
1371
1503
  }
1372
- return (source, params, search, signal, platform) => {
1373
- 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);
1374
1508
  if (decorations !== undefined)
1375
1509
  Object.assign(ctx, decorations);
1376
1510
  let result;
@@ -1686,13 +1820,13 @@ export class Server {
1686
1820
  * handlers can bail) and return 503; the in-flight work keeps running but its
1687
1821
  * result is discarded — JS can't forcibly cancel a promise.
1688
1822
  */
1689
- async withTimeout(work, controller, onTimeout) {
1823
+ async withTimeout(work, controller, onTimeout, timeoutMs) {
1690
1824
  let timer;
1691
1825
  const timeout = new Promise((resolve) => {
1692
1826
  timer = setTimeout(() => {
1693
1827
  controller.abort();
1694
1828
  resolve(onTimeout());
1695
- }, this.requestTimeoutMs);
1829
+ }, timeoutMs);
1696
1830
  });
1697
1831
  try {
1698
1832
  return await Promise.race([work, timeout]);
@@ -1836,6 +1970,137 @@ export class Server {
1836
1970
  async readBoundedJson(req) {
1837
1971
  return readBoundedJsonSource(req, this.maxBodyBytes);
1838
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
+ }
1839
2104
  /**
1840
2105
  * Start a `Bun.serve` instance bound to `port` (use `0` for an ephemeral port).
1841
2106
  *
@@ -1868,8 +2133,14 @@ export class Server {
1868
2133
  ? undefined
1869
2134
  : getWsRuntime().bunHandlers(this.topics);
1870
2135
  const reusePort = options?.reusePort === true;
2136
+ const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes() : undefined;
1871
2137
  const running = (wsHandlers === undefined
1872
- ? 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
+ })
1873
2144
  : Bun.serve({
1874
2145
  port,
1875
2146
  reusePort,