@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.
- package/README.md +5 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/server/context.d.ts +6 -0
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/server.d.ts +67 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +339 -58
- package/dist/server/server.js.map +1 -1
- package/package.json +4 -1
- package/src/index.ts +3 -1
- package/src/server/context.ts +6 -0
- package/src/server/server.ts +562 -62
package/dist/server/server.js
CHANGED
|
@@ -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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
|
486
|
-
*
|
|
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
|
-
|
|
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,
|
|
1106
|
-
|
|
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
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
|
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
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
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
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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 (
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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 =
|
|
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
|
-
},
|
|
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({
|
|
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,
|