@nifrajs/core 1.8.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,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,7 +654,12 @@ export class Server {
634
654
  maxBodyBytes;
635
655
  wsMaxPayloadBytes;
636
656
  requestTimeoutMs;
657
+ acceptInboundDeadlines;
658
+ maxInboundDeadlineMs;
659
+ deadlineAdmissionOptions;
637
660
  gracefulSignals;
661
+ /** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
662
+ capacityGate;
638
663
  logger;
639
664
  /** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
640
665
  defaultOnValidationError;
@@ -674,7 +699,20 @@ export class Server {
674
699
  this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
675
700
  this.wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? this.maxBodyBytes;
676
701
  this.requestTimeoutMs = options.requestTimeoutMs ?? 0;
702
+ this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false;
703
+ this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000;
704
+ this.deadlineAdmissionOptions = Object.freeze({
705
+ localTimeoutMs: this.requestTimeoutMs,
706
+ maxInboundDeadlineMs: this.maxInboundDeadlineMs,
707
+ });
708
+ if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs < 0) {
709
+ throw new RangeError("requestTimeoutMs must be a finite non-negative number");
710
+ }
711
+ if (!Number.isFinite(this.maxInboundDeadlineMs) || this.maxInboundDeadlineMs <= 0) {
712
+ throw new RangeError("maxInboundDeadlineMs must be a finite positive number");
713
+ }
677
714
  this.gracefulSignals = options.gracefulSignals ?? false;
715
+ this.capacityGate = options.admission;
678
716
  this.logger = options.logger ?? jsonLogger();
679
717
  this.defaultOnValidationError = options.onValidationError;
680
718
  this.bunServer = undefined;
@@ -1075,6 +1113,12 @@ export class Server {
1075
1113
  return this.fetchSource(req, platform);
1076
1114
  }
1077
1115
  fetchSource(source, platform) {
1116
+ // Off path (default): straight through — one property check, no closure, no promise.
1117
+ if (this.capacityGate === undefined)
1118
+ return this.fetchSourceInner(source, platform);
1119
+ return this.admitGated(requestOf(source), () => this.fetchSourceInner(source, platform));
1120
+ }
1121
+ fetchSourceInner(source, platform) {
1078
1122
  // Non-`async` on purpose: `dispatch` may return a `Response` *synchronously* (the bare-route fast
1079
1123
  // path, see RouteEntry.bare), and an `async fetch` would wrap every such result in a redundant
1080
1124
  // promise + microtask. Returning `Response | Promise<Response>` matches Web/edge handlers, while
@@ -1089,6 +1133,64 @@ export class Server {
1089
1133
  ? outcome.then((response) => this.applyOnResponseAndFinalize(response, this.takeResponseRequest(source)))
1090
1134
  : this.applyOnResponseAndFinalize(outcome, this.takeResponseRequest(source));
1091
1135
  }
1136
+ /** Web response path when Bun already matched the route. The lifecycle and response hooks remain
1137
+ * exactly the same as {@link fetchSource}; only portable URL scanning + trie lookup are skipped. */
1138
+ fetchMatched(source, entry, params) {
1139
+ if (this.capacityGate === undefined)
1140
+ return this.fetchMatchedInner(source, entry, params);
1141
+ return this.admitGated(requestOf(source), () => this.fetchMatchedInner(source, entry, params));
1142
+ }
1143
+ fetchMatchedInner(source, entry, params) {
1144
+ const outcome = this.runMatched(source, undefined, entry, params, undefined, toResponse, IDENTITY_RESPONSE, RESPONSE_TIMEOUT, true);
1145
+ if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
1146
+ return outcome;
1147
+ }
1148
+ return outcome instanceof Promise
1149
+ ? outcome.then((response) => this.applyOnResponseAndFinalize(response, requestOf(source)))
1150
+ : this.applyOnResponseAndFinalize(outcome, requestOf(source));
1151
+ }
1152
+ /**
1153
+ * Run `produce` under the capacity gate: admit → run → release exactly once when the response is
1154
+ * produced (or the run throws). Only reached when {@link capacityGate} is set, so the off path pays
1155
+ * nothing. The slot is held for the duration of handler execution, not the streaming of the body —
1156
+ * capacity here bounds concurrent *work*, matching how in-flight is counted.
1157
+ */
1158
+ admitGated(req, produce) {
1159
+ const decision = this.capacityGate.admit(req);
1160
+ return decision instanceof Promise
1161
+ ? decision.then((settled) => this.runAdmitted(settled, produce))
1162
+ : this.runAdmitted(decision, produce);
1163
+ }
1164
+ runAdmitted(decision, produce) {
1165
+ if (!decision.admitted)
1166
+ return decision.response; // shed: ready 429, no slot held
1167
+ let released = false;
1168
+ const release = () => {
1169
+ if (released)
1170
+ return;
1171
+ released = true;
1172
+ decision.release();
1173
+ };
1174
+ let outcome;
1175
+ try {
1176
+ outcome = produce();
1177
+ }
1178
+ catch (error) {
1179
+ release();
1180
+ throw error;
1181
+ }
1182
+ if (outcome instanceof Promise) {
1183
+ return outcome.then((response) => {
1184
+ release();
1185
+ return response;
1186
+ }, (error) => {
1187
+ release();
1188
+ throw error;
1189
+ });
1190
+ }
1191
+ release();
1192
+ return outcome;
1193
+ }
1092
1194
  /**
1093
1195
  * Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` — the
1094
1196
  * `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
@@ -1102,8 +1204,11 @@ export class Server {
1102
1204
  return this.resolveNodeSource(req, platform);
1103
1205
  }
1104
1206
  resolveNodeSource(source, platform) {
1105
- // onResponse hooks transform a Response, so they force the Web path; wrap its result.
1106
- if (this.onResponseHooks.length > 0 || this.onResponseFinalizedHooks.length > 0) {
1207
+ // onResponse hooks transform a Response, and the capacity gate wraps the Web response path — both
1208
+ // force the Web path here (the gated `fetchSource` admits/sheds/releases); wrap its result.
1209
+ if (this.onResponseHooks.length > 0 ||
1210
+ this.onResponseFinalizedHooks.length > 0 ||
1211
+ this.capacityGate !== undefined) {
1107
1212
  const response = this.fetchSource(source, platform);
1108
1213
  return response instanceof Promise
1109
1214
  ? response.then((settled) => ({ kind: "response", response: settled }))
@@ -1131,9 +1236,9 @@ export class Server {
1131
1236
  const match = this.wsRouter.find("GET", url.pathname);
1132
1237
  if (!match.found)
1133
1238
  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);
1239
+ // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
1240
+ // already did and made every plain dynamic route pay for unrelated static path bytes.
1241
+ const params = match.params === EMPTY_PARAMS ? match.params : decodeParams(match.params);
1137
1242
  if (params === null)
1138
1243
  return { kind: "reject", response: jsonError(400, "malformed_path") };
1139
1244
  const handler = match.payload.handler;
@@ -1160,7 +1265,8 @@ export class Server {
1160
1265
  if (handler.upgrade === undefined) {
1161
1266
  return { kind: "upgrade", handler, data: undefined, pubsub };
1162
1267
  }
1163
- const ctx = new RequestContext(req, params, url.search, getNeverAbortSignal(), platform, this.maxBodyBytes);
1268
+ const upgradeSignal = getNeverAbortSignal();
1269
+ const ctx = new RequestContext(req, params, url.search, upgradeSignal, createUnboundedRequestBudget(upgradeSignal), platform, this.maxBodyBytes);
1164
1270
  const settle = (value) => value instanceof Response
1165
1271
  ? { kind: "reject", response: value }
1166
1272
  : { kind: "upgrade", handler, data: value, pubsub };
@@ -1283,37 +1389,63 @@ export class Server {
1283
1389
  }
1284
1390
  return wrapResponse(jsonError(404, "not_found"));
1285
1391
  }
1286
- const params = match.params === EMPTY_PARAMS || !url.pathname.includes("%")
1287
- ? match.params
1288
- : decodeParams(match.params);
1392
+ // Inspect only captured values for escapes. Scanning the full pathname repeated work the router
1393
+ // already did and made every plain dynamic route pay for unrelated static path bytes.
1394
+ const params = match.params === EMPTY_PARAMS ? match.params : decodeParams(match.params);
1289
1395
  if (params === null) {
1290
1396
  return wrapResponse(jsonError(400, "malformed_path"));
1291
1397
  }
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.
1398
+ return this.runMatched(source, platform, match.payload, params, url.search, finalize, wrapResponse, onTimeout, webFast);
1399
+ }
1400
+ /** Run a route that has already been matched by the runtime or Nifra's portable router. */
1401
+ runMatched(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
1402
+ // Translate the absolute wire deadline once, clamp it to local policy, then use the resulting
1403
+ // duration for both c.signal and c.budget. A client can only shorten work, never extend it.
1404
+ // Most requests have neither a local timeout nor a propagated deadline. Detect that case with
1405
+ // one header lookup and skip policy validation, wall-clock sampling, and admission objects. A
1406
+ // present wire deadline still goes through the full fail-closed parser/clamp below.
1407
+ const admission = !this.acceptInboundDeadlines
1408
+ ? this.requestTimeoutMs === 0
1409
+ ? undefined
1410
+ : {
1411
+ ok: true,
1412
+ inherited: false,
1413
+ timeoutMs: this.requestTimeoutMs,
1414
+ deadline: Math.floor(Date.now() + this.requestTimeoutMs),
1415
+ }
1416
+ : this.requestTimeoutMs === 0 && headerOf(source, NIFRA_DEADLINE_HEADER) === null
1417
+ ? undefined
1418
+ : admitDeadline(source.headers, this.deadlineAdmissionOptions);
1419
+ if (admission !== undefined && !admission.ok) {
1420
+ return wrapResponse(jsonError(admission.status, admission.reason));
1421
+ }
1422
+ const effectiveTimeoutMs = admission?.timeoutMs ?? 0;
1423
+ // Only allocate a controller for a finite budget; the historical no-timeout path remains
1424
+ // allocation-light and exposes an unbounded budget that is never propagated on the wire.
1296
1425
  let controller;
1297
1426
  let signal = getNeverAbortSignal();
1298
- if (this.requestTimeoutMs > 0) {
1427
+ if (effectiveTimeoutMs > 0) {
1299
1428
  controller = new AbortController();
1300
1429
  signal = controller.signal;
1301
1430
  }
1431
+ const budget = controller === undefined
1432
+ ? getUnboundedRequestBudget()
1433
+ : createRequestBudget({ deadline: admission.deadline, signal });
1302
1434
  // A bare route runs synchronously (no schema/derives/hooks); everything else keeps the full async
1303
1435
  // lifecycle. `runBare` returns a value directly for a sync handler — no promise.
1304
1436
  let outcome;
1305
1437
  if (webFast && entry.fusedWeb !== undefined) {
1306
1438
  // Fused Web lane (bare route, no around): one closure end to end. Sound cast — webFast is
1307
1439
  // passed only by the `fetch` path, where T = Response by construction.
1308
- outcome = entry.fusedWeb(source, params, url.search, signal, platform);
1440
+ outcome = entry.fusedWeb(source, params, search, signal, budget, platform, false);
1309
1441
  }
1310
1442
  else if (entry.contextlessBare) {
1311
- outcome = this.runContextlessBare(entry, source, params, url.search, signal, platform, finalize, wrapResponse);
1443
+ outcome = this.runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse);
1312
1444
  }
1313
1445
  else {
1314
1446
  // Build the context up front so `onError` always has it. Query, cookies, headers, and body helpers
1315
1447
  // 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);
1448
+ const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
1317
1449
  if (entry.around.length === 0) {
1318
1450
  outcome = entry.bare
1319
1451
  ? this.runBare(entry, ctx, finalize, wrapResponse)
@@ -1336,29 +1468,32 @@ export class Server {
1336
1468
  // The request timeout only bounds work that is actually pending — a synchronous (bare) result is
1337
1469
  // already complete and can't time out, so it's returned as-is (no 503 race, no promise).
1338
1470
  if (controller !== undefined && outcome instanceof Promise) {
1339
- return this.withTimeout(outcome, controller, onTimeout);
1471
+ const timedOut = admission?.inherited === true
1472
+ ? () => wrapResponse(jsonError(504, "deadline_exceeded"))
1473
+ : onTimeout;
1474
+ return this.withTimeout(outcome, controller, timedOut, Math.max(0, Math.ceil(budget.remaining())));
1340
1475
  }
1341
1476
  return outcome;
1342
1477
  }
1343
1478
  /** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
1344
1479
  * successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
1345
- runContextlessBare(entry, source, params, search, signal, platform, finalize, wrapResponse) {
1480
+ runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse) {
1346
1481
  let result;
1347
1482
  try {
1348
1483
  result = entry.handler();
1349
1484
  }
1350
1485
  catch (err) {
1351
- return this.contextlessBareError(err, source, params, search, signal, platform, wrapResponse);
1486
+ return this.contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse);
1352
1487
  }
1353
1488
  if (result instanceof Promise) {
1354
- return result.then((value) => finalize(value, EMPTY_RESPONSE_CONTROLS), (err) => this.contextlessBareError(err, source, params, search, signal, platform, wrapResponse));
1489
+ return result.then((value) => finalize(value, EMPTY_RESPONSE_CONTROLS), (err) => this.contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse));
1355
1490
  }
1356
1491
  return finalize(result, EMPTY_RESPONSE_CONTROLS);
1357
1492
  }
1358
- contextlessBareError(err, source, params, search, signal, platform, wrapResponse) {
1493
+ contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse) {
1359
1494
  if (err instanceof Response)
1360
1495
  return wrapResponse(err);
1361
- const ctx = new RequestContext(source, params, search, signal, platform, this.maxBodyBytes);
1496
+ const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
1362
1497
  this.logRequestError(err, ctx);
1363
1498
  return wrapResponse(jsonError(500, "internal_error"));
1364
1499
  }
@@ -1407,22 +1542,28 @@ export class Server {
1407
1542
  // `() => ...` can't observe the context — skip allocating one entirely (errors still build
1408
1543
  // one for the structured log, exactly like runContextlessBare).
1409
1544
  const contextlessHandler = handler;
1410
- return (source, params, search, signal, platform) => {
1545
+ return (source, params, search, signal, budget, platform, nativeContext) => {
1411
1546
  let result;
1412
1547
  try {
1413
1548
  result = contextlessHandler();
1414
1549
  }
1415
1550
  catch (err) {
1416
- return logError(err, new RequestContext(source, params, search, signal, platform, maxBodyBytes));
1551
+ return logError(err, nativeContext
1552
+ ? RequestContext.native(source, params, maxBodyBytes)
1553
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes));
1417
1554
  }
1418
1555
  if (result instanceof Promise) {
1419
- return result.then(fusedRespondNoSet, (err) => logError(err, new RequestContext(source, params, search, signal, platform, maxBodyBytes)));
1556
+ return result.then(fusedRespondNoSet, (err) => logError(err, nativeContext
1557
+ ? RequestContext.native(source, params, maxBodyBytes)
1558
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes)));
1420
1559
  }
1421
1560
  return fusedRespondNoSet(result);
1422
1561
  };
1423
1562
  }
1424
- return (source, params, search, signal, platform) => {
1425
- const ctx = new RequestContext(source, params, search, signal, platform, maxBodyBytes);
1563
+ return (source, params, search, signal, budget, platform, nativeContext) => {
1564
+ const ctx = nativeContext
1565
+ ? RequestContext.native(source, params, maxBodyBytes)
1566
+ : new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes);
1426
1567
  if (decorations !== undefined)
1427
1568
  Object.assign(ctx, decorations);
1428
1569
  let result;
@@ -1738,13 +1879,13 @@ export class Server {
1738
1879
  * handlers can bail) and return 503; the in-flight work keeps running but its
1739
1880
  * result is discarded — JS can't forcibly cancel a promise.
1740
1881
  */
1741
- async withTimeout(work, controller, onTimeout) {
1882
+ async withTimeout(work, controller, onTimeout, timeoutMs) {
1742
1883
  let timer;
1743
1884
  const timeout = new Promise((resolve) => {
1744
1885
  timer = setTimeout(() => {
1745
1886
  controller.abort();
1746
1887
  resolve(onTimeout());
1747
- }, this.requestTimeoutMs);
1888
+ }, timeoutMs);
1748
1889
  });
1749
1890
  try {
1750
1891
  return await Promise.race([work, timeout]);
@@ -1888,6 +2029,140 @@ export class Server {
1888
2029
  async readBoundedJson(req) {
1889
2030
  return readBoundedJsonSource(req, this.maxBodyBytes);
1890
2031
  }
2032
+ /** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
2033
+ * hooks or WebSockets retain the single portable dispatcher because those features must run before
2034
+ * route selection/upgrade. Named wildcards also stay on the fallback until Bun exposes their raw
2035
+ * capture semantics; static and `:param` routes take the native lane. */
2036
+ buildBunNativeRoutes() {
2037
+ if (this.onRequestHooks.length > 0 || this.wsRouteCount > 0)
2038
+ return undefined;
2039
+ const routes = Object.create(null);
2040
+ const mayUseFusedNative = this.requestTimeoutMs === 0 &&
2041
+ this.onResponseHooks.length === 0 &&
2042
+ this.onResponseFinalizedHooks.length === 0 &&
2043
+ // The capacity gate must wrap every request; the fused lane bypasses fetchMatched, so enabling
2044
+ // admission drops fusion (native matching stays) and routes through the gated matched lane.
2045
+ this.capacityGate === undefined;
2046
+ const unboundedSignal = mayUseFusedNative ? getNeverAbortSignal() : undefined;
2047
+ const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined;
2048
+ let count = 0;
2049
+ for (const { method, path, entry } of this.rawEntries) {
2050
+ if (path.includes("*"))
2051
+ continue;
2052
+ let methods = routes[path];
2053
+ if (methods === undefined) {
2054
+ methods = Object.create(null);
2055
+ routes[path] = methods;
2056
+ }
2057
+ const paramNames = path
2058
+ .split("/")
2059
+ .filter((segment) => segment.charCodeAt(0) === 58 /* : */)
2060
+ .map((segment) => segment.slice(1));
2061
+ const fused = mayUseFusedNative ? entry.fusedWeb : undefined;
2062
+ if (fused !== undefined) {
2063
+ // Deliberately choose deadline-aware vs zero-admission closures HERE instead of branching on
2064
+ // `acceptInboundDeadlines` per request. JSC can then flatten the default closure through the
2065
+ // handler + Response.json; retaining that route-invariant branch cost ~7% on a dynamic GET.
2066
+ if (paramNames.length === 0) {
2067
+ methods[method] = this.acceptInboundDeadlines
2068
+ ? (request) => request.headers.get(NIFRA_DEADLINE_HEADER) !== null
2069
+ ? this.fetchMatched(request, entry, EMPTY_PARAMS)
2070
+ : fused(request, EMPTY_PARAMS, undefined, unboundedSignal, unboundedBudget, undefined, true)
2071
+ : (request) => fused(request, EMPTY_PARAMS, undefined, unboundedSignal, unboundedBudget, undefined, true);
2072
+ }
2073
+ else if (paramNames.length === 1) {
2074
+ const paramName = paramNames[0];
2075
+ const handler = entry.handler;
2076
+ const decorations = entry.hasDecorations ? entry.decorations : undefined;
2077
+ const fail = (error, ctx) => {
2078
+ if (error instanceof Response)
2079
+ return error;
2080
+ this.logRequestError(error, ctx);
2081
+ return jsonError(500, "internal_error");
2082
+ };
2083
+ methods[method] = this.acceptInboundDeadlines
2084
+ ? (request) => {
2085
+ const params = request.params ?? EMPTY_PARAMS;
2086
+ if (params[paramName]?.includes("\uFFFD") === true)
2087
+ return this.fetchSource(request);
2088
+ if (request.headers.get(NIFRA_DEADLINE_HEADER) !== null) {
2089
+ return this.fetchMatched(request, entry, params);
2090
+ }
2091
+ const ctx = RequestContext.native(request, params, this.maxBodyBytes);
2092
+ if (decorations !== undefined)
2093
+ Object.assign(ctx, decorations);
2094
+ let result;
2095
+ try {
2096
+ result = handler(ctx);
2097
+ }
2098
+ catch (error) {
2099
+ return fail(error, ctx);
2100
+ }
2101
+ return result instanceof Promise
2102
+ ? result.then((value) => fusedRespond(value, ctx), (error) => fail(error, ctx))
2103
+ : fusedRespond(result, ctx);
2104
+ }
2105
+ : (request) => {
2106
+ const params = request.params ?? EMPTY_PARAMS;
2107
+ if (params[paramName]?.includes("\uFFFD") === true)
2108
+ return this.fetchSource(request);
2109
+ const ctx = RequestContext.native(request, params, this.maxBodyBytes);
2110
+ if (decorations !== undefined)
2111
+ Object.assign(ctx, decorations);
2112
+ let result;
2113
+ try {
2114
+ result = handler(ctx);
2115
+ }
2116
+ catch (error) {
2117
+ return fail(error, ctx);
2118
+ }
2119
+ return result instanceof Promise
2120
+ ? result.then((value) => fusedRespond(value, ctx), (error) => fail(error, ctx))
2121
+ : fusedRespond(result, ctx);
2122
+ };
2123
+ }
2124
+ else {
2125
+ methods[method] = this.acceptInboundDeadlines
2126
+ ? (request) => {
2127
+ const params = request.params ?? EMPTY_PARAMS;
2128
+ if (hasReplacementParam(params))
2129
+ return this.fetchSource(request);
2130
+ return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
2131
+ ? this.fetchMatched(request, entry, params)
2132
+ : fused(request, params, undefined, unboundedSignal, unboundedBudget, undefined, true);
2133
+ }
2134
+ : (request) => {
2135
+ const params = request.params ?? EMPTY_PARAMS;
2136
+ if (hasReplacementParam(params))
2137
+ return this.fetchSource(request);
2138
+ return fused(request, params, undefined, unboundedSignal, unboundedBudget, undefined, true);
2139
+ };
2140
+ }
2141
+ }
2142
+ else if (paramNames.length === 0) {
2143
+ methods[method] = (request) => this.fetchMatched(request, entry, EMPTY_PARAMS);
2144
+ }
2145
+ else if (paramNames.length === 1) {
2146
+ const paramName = paramNames[0];
2147
+ methods[method] = (request) => {
2148
+ const params = request.params ?? EMPTY_PARAMS;
2149
+ if (params[paramName]?.includes("\uFFFD") === true)
2150
+ return this.fetchSource(request);
2151
+ return this.fetchMatched(request, entry, params);
2152
+ };
2153
+ }
2154
+ else {
2155
+ methods[method] = (request) => {
2156
+ const params = request.params ?? EMPTY_PARAMS;
2157
+ if (hasReplacementParam(params))
2158
+ return this.fetchSource(request);
2159
+ return this.fetchMatched(request, entry, params);
2160
+ };
2161
+ }
2162
+ count += 1;
2163
+ }
2164
+ return count === 0 ? undefined : routes;
2165
+ }
1891
2166
  /**
1892
2167
  * Start a `Bun.serve` instance bound to `port` (use `0` for an ephemeral port).
1893
2168
  *
@@ -1920,8 +2195,14 @@ export class Server {
1920
2195
  ? undefined
1921
2196
  : getWsRuntime().bunHandlers(this.topics);
1922
2197
  const reusePort = options?.reusePort === true;
2198
+ const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes() : undefined;
1923
2199
  const running = (wsHandlers === undefined
1924
- ? Bun.serve({ port, reusePort, fetch: (req) => this.fetch(req) })
2200
+ ? Bun.serve({
2201
+ port,
2202
+ reusePort,
2203
+ ...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
2204
+ fetch: (req) => this.fetch(req),
2205
+ })
1925
2206
  : Bun.serve({
1926
2207
  port,
1927
2208
  reusePort,