@nifrajs/core 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +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 +25 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +275 -56
- package/dist/server/server.js.map +1 -1
- package/package.json +4 -1
- package/src/index.ts +1 -1
- package/src/server/context.ts +6 -0
- package/src/server/server.ts +456 -60
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,6 +654,9 @@ export class Server {
|
|
|
634
654
|
maxBodyBytes;
|
|
635
655
|
wsMaxPayloadBytes;
|
|
636
656
|
requestTimeoutMs;
|
|
657
|
+
acceptInboundDeadlines;
|
|
658
|
+
maxInboundDeadlineMs;
|
|
659
|
+
deadlineAdmissionOptions;
|
|
637
660
|
gracefulSignals;
|
|
638
661
|
logger;
|
|
639
662
|
/** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
|
|
@@ -674,6 +697,18 @@ export class Server {
|
|
|
674
697
|
this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
675
698
|
this.wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? this.maxBodyBytes;
|
|
676
699
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 0;
|
|
700
|
+
this.acceptInboundDeadlines = options.acceptInboundDeadlines ?? false;
|
|
701
|
+
this.maxInboundDeadlineMs = options.maxInboundDeadlineMs ?? 30_000;
|
|
702
|
+
this.deadlineAdmissionOptions = Object.freeze({
|
|
703
|
+
localTimeoutMs: this.requestTimeoutMs,
|
|
704
|
+
maxInboundDeadlineMs: this.maxInboundDeadlineMs,
|
|
705
|
+
});
|
|
706
|
+
if (!Number.isFinite(this.requestTimeoutMs) || this.requestTimeoutMs < 0) {
|
|
707
|
+
throw new RangeError("requestTimeoutMs must be a finite non-negative number");
|
|
708
|
+
}
|
|
709
|
+
if (!Number.isFinite(this.maxInboundDeadlineMs) || this.maxInboundDeadlineMs <= 0) {
|
|
710
|
+
throw new RangeError("maxInboundDeadlineMs must be a finite positive number");
|
|
711
|
+
}
|
|
677
712
|
this.gracefulSignals = options.gracefulSignals ?? false;
|
|
678
713
|
this.logger = options.logger ?? jsonLogger();
|
|
679
714
|
this.defaultOnValidationError = options.onValidationError;
|
|
@@ -1089,6 +1124,17 @@ export class Server {
|
|
|
1089
1124
|
? outcome.then((response) => this.applyOnResponseAndFinalize(response, this.takeResponseRequest(source)))
|
|
1090
1125
|
: this.applyOnResponseAndFinalize(outcome, this.takeResponseRequest(source));
|
|
1091
1126
|
}
|
|
1127
|
+
/** Web response path when Bun already matched the route. The lifecycle and response hooks remain
|
|
1128
|
+
* exactly the same as {@link fetchSource}; only portable URL scanning + trie lookup are skipped. */
|
|
1129
|
+
fetchMatched(source, entry, params) {
|
|
1130
|
+
const outcome = this.runMatched(source, undefined, entry, params, undefined, toResponse, IDENTITY_RESPONSE, RESPONSE_TIMEOUT, true);
|
|
1131
|
+
if (this.onResponseHooks.length === 0 && this.onResponseFinalizedHooks.length === 0) {
|
|
1132
|
+
return outcome;
|
|
1133
|
+
}
|
|
1134
|
+
return outcome instanceof Promise
|
|
1135
|
+
? outcome.then((response) => this.applyOnResponseAndFinalize(response, requestOf(source)))
|
|
1136
|
+
: this.applyOnResponseAndFinalize(outcome, requestOf(source));
|
|
1137
|
+
}
|
|
1092
1138
|
/**
|
|
1093
1139
|
* Like {@link fetch}, but renders a plain-data result **without** building a Web `Response` — the
|
|
1094
1140
|
* `@nifrajs/node` adapter serializes the returned primitives straight to the socket, skipping the undici
|
|
@@ -1131,9 +1177,9 @@ export class Server {
|
|
|
1131
1177
|
const match = this.wsRouter.find("GET", url.pathname);
|
|
1132
1178
|
if (!match.found)
|
|
1133
1179
|
return WS_PASS; // upgrade header, no WS route here → normal routing decides
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1180
|
+
// Inspect only captured values for escapes. Scanning the full pathname repeated work the router
|
|
1181
|
+
// already did and made every plain dynamic route pay for unrelated static path bytes.
|
|
1182
|
+
const params = match.params === EMPTY_PARAMS ? match.params : decodeParams(match.params);
|
|
1137
1183
|
if (params === null)
|
|
1138
1184
|
return { kind: "reject", response: jsonError(400, "malformed_path") };
|
|
1139
1185
|
const handler = match.payload.handler;
|
|
@@ -1160,7 +1206,8 @@ export class Server {
|
|
|
1160
1206
|
if (handler.upgrade === undefined) {
|
|
1161
1207
|
return { kind: "upgrade", handler, data: undefined, pubsub };
|
|
1162
1208
|
}
|
|
1163
|
-
const
|
|
1209
|
+
const upgradeSignal = getNeverAbortSignal();
|
|
1210
|
+
const ctx = new RequestContext(req, params, url.search, upgradeSignal, createUnboundedRequestBudget(upgradeSignal), platform, this.maxBodyBytes);
|
|
1164
1211
|
const settle = (value) => value instanceof Response
|
|
1165
1212
|
? { kind: "reject", response: value }
|
|
1166
1213
|
: { kind: "upgrade", handler, data: value, pubsub };
|
|
@@ -1283,37 +1330,63 @@ export class Server {
|
|
|
1283
1330
|
}
|
|
1284
1331
|
return wrapResponse(jsonError(404, "not_found"));
|
|
1285
1332
|
}
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1333
|
+
// Inspect only captured values for escapes. Scanning the full pathname repeated work the router
|
|
1334
|
+
// already did and made every plain dynamic route pay for unrelated static path bytes.
|
|
1335
|
+
const params = match.params === EMPTY_PARAMS ? match.params : decodeParams(match.params);
|
|
1289
1336
|
if (params === null) {
|
|
1290
1337
|
return wrapResponse(jsonError(400, "malformed_path"));
|
|
1291
1338
|
}
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1339
|
+
return this.runMatched(source, platform, match.payload, params, url.search, finalize, wrapResponse, onTimeout, webFast);
|
|
1340
|
+
}
|
|
1341
|
+
/** Run a route that has already been matched by the runtime or Nifra's portable router. */
|
|
1342
|
+
runMatched(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
|
|
1343
|
+
// Translate the absolute wire deadline once, clamp it to local policy, then use the resulting
|
|
1344
|
+
// duration for both c.signal and c.budget. A client can only shorten work, never extend it.
|
|
1345
|
+
// Most requests have neither a local timeout nor a propagated deadline. Detect that case with
|
|
1346
|
+
// one header lookup and skip policy validation, wall-clock sampling, and admission objects. A
|
|
1347
|
+
// present wire deadline still goes through the full fail-closed parser/clamp below.
|
|
1348
|
+
const admission = !this.acceptInboundDeadlines
|
|
1349
|
+
? this.requestTimeoutMs === 0
|
|
1350
|
+
? undefined
|
|
1351
|
+
: {
|
|
1352
|
+
ok: true,
|
|
1353
|
+
inherited: false,
|
|
1354
|
+
timeoutMs: this.requestTimeoutMs,
|
|
1355
|
+
deadline: Math.floor(Date.now() + this.requestTimeoutMs),
|
|
1356
|
+
}
|
|
1357
|
+
: this.requestTimeoutMs === 0 && headerOf(source, NIFRA_DEADLINE_HEADER) === null
|
|
1358
|
+
? undefined
|
|
1359
|
+
: admitDeadline(source.headers, this.deadlineAdmissionOptions);
|
|
1360
|
+
if (admission !== undefined && !admission.ok) {
|
|
1361
|
+
return wrapResponse(jsonError(admission.status, admission.reason));
|
|
1362
|
+
}
|
|
1363
|
+
const effectiveTimeoutMs = admission?.timeoutMs ?? 0;
|
|
1364
|
+
// Only allocate a controller for a finite budget; the historical no-timeout path remains
|
|
1365
|
+
// allocation-light and exposes an unbounded budget that is never propagated on the wire.
|
|
1296
1366
|
let controller;
|
|
1297
1367
|
let signal = getNeverAbortSignal();
|
|
1298
|
-
if (
|
|
1368
|
+
if (effectiveTimeoutMs > 0) {
|
|
1299
1369
|
controller = new AbortController();
|
|
1300
1370
|
signal = controller.signal;
|
|
1301
1371
|
}
|
|
1372
|
+
const budget = controller === undefined
|
|
1373
|
+
? getUnboundedRequestBudget()
|
|
1374
|
+
: createRequestBudget({ deadline: admission.deadline, signal });
|
|
1302
1375
|
// A bare route runs synchronously (no schema/derives/hooks); everything else keeps the full async
|
|
1303
1376
|
// lifecycle. `runBare` returns a value directly for a sync handler — no promise.
|
|
1304
1377
|
let outcome;
|
|
1305
1378
|
if (webFast && entry.fusedWeb !== undefined) {
|
|
1306
1379
|
// Fused Web lane (bare route, no around): one closure end to end. Sound cast — webFast is
|
|
1307
1380
|
// passed only by the `fetch` path, where T = Response by construction.
|
|
1308
|
-
outcome = entry.fusedWeb(source, params,
|
|
1381
|
+
outcome = entry.fusedWeb(source, params, search, signal, budget, platform, false);
|
|
1309
1382
|
}
|
|
1310
1383
|
else if (entry.contextlessBare) {
|
|
1311
|
-
outcome = this.runContextlessBare(entry, source, params,
|
|
1384
|
+
outcome = this.runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse);
|
|
1312
1385
|
}
|
|
1313
1386
|
else {
|
|
1314
1387
|
// Build the context up front so `onError` always has it. Query, cookies, headers, and body helpers
|
|
1315
1388
|
// stay lazy, but their accessors live on the prototype instead of allocating per-request closures.
|
|
1316
|
-
const ctx = new RequestContext(source, params,
|
|
1389
|
+
const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
|
|
1317
1390
|
if (entry.around.length === 0) {
|
|
1318
1391
|
outcome = entry.bare
|
|
1319
1392
|
? this.runBare(entry, ctx, finalize, wrapResponse)
|
|
@@ -1336,29 +1409,32 @@ export class Server {
|
|
|
1336
1409
|
// The request timeout only bounds work that is actually pending — a synchronous (bare) result is
|
|
1337
1410
|
// already complete and can't time out, so it's returned as-is (no 503 race, no promise).
|
|
1338
1411
|
if (controller !== undefined && outcome instanceof Promise) {
|
|
1339
|
-
|
|
1412
|
+
const timedOut = admission?.inherited === true
|
|
1413
|
+
? () => wrapResponse(jsonError(504, "deadline_exceeded"))
|
|
1414
|
+
: onTimeout;
|
|
1415
|
+
return this.withTimeout(outcome, controller, timedOut, Math.max(0, Math.ceil(budget.remaining())));
|
|
1340
1416
|
}
|
|
1341
1417
|
return outcome;
|
|
1342
1418
|
}
|
|
1343
1419
|
/** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
|
|
1344
1420
|
* successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
|
|
1345
|
-
runContextlessBare(entry, source, params, search, signal, platform, finalize, wrapResponse) {
|
|
1421
|
+
runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse) {
|
|
1346
1422
|
let result;
|
|
1347
1423
|
try {
|
|
1348
1424
|
result = entry.handler();
|
|
1349
1425
|
}
|
|
1350
1426
|
catch (err) {
|
|
1351
|
-
return this.contextlessBareError(err, source, params, search, signal, platform, wrapResponse);
|
|
1427
|
+
return this.contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse);
|
|
1352
1428
|
}
|
|
1353
1429
|
if (result instanceof Promise) {
|
|
1354
|
-
return result.then((value) => finalize(value, EMPTY_RESPONSE_CONTROLS), (err) => this.contextlessBareError(err, source, params, search, signal, platform, wrapResponse));
|
|
1430
|
+
return result.then((value) => finalize(value, EMPTY_RESPONSE_CONTROLS), (err) => this.contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse));
|
|
1355
1431
|
}
|
|
1356
1432
|
return finalize(result, EMPTY_RESPONSE_CONTROLS);
|
|
1357
1433
|
}
|
|
1358
|
-
contextlessBareError(err, source, params, search, signal, platform, wrapResponse) {
|
|
1434
|
+
contextlessBareError(err, source, params, search, signal, budget, platform, wrapResponse) {
|
|
1359
1435
|
if (err instanceof Response)
|
|
1360
1436
|
return wrapResponse(err);
|
|
1361
|
-
const ctx = new RequestContext(source, params, search, signal, platform, this.maxBodyBytes);
|
|
1437
|
+
const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
|
|
1362
1438
|
this.logRequestError(err, ctx);
|
|
1363
1439
|
return wrapResponse(jsonError(500, "internal_error"));
|
|
1364
1440
|
}
|
|
@@ -1407,22 +1483,28 @@ export class Server {
|
|
|
1407
1483
|
// `() => ...` can't observe the context — skip allocating one entirely (errors still build
|
|
1408
1484
|
// one for the structured log, exactly like runContextlessBare).
|
|
1409
1485
|
const contextlessHandler = handler;
|
|
1410
|
-
return (source, params, search, signal, platform) => {
|
|
1486
|
+
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
1411
1487
|
let result;
|
|
1412
1488
|
try {
|
|
1413
1489
|
result = contextlessHandler();
|
|
1414
1490
|
}
|
|
1415
1491
|
catch (err) {
|
|
1416
|
-
return logError(err,
|
|
1492
|
+
return logError(err, nativeContext
|
|
1493
|
+
? RequestContext.native(source, params, maxBodyBytes)
|
|
1494
|
+
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes));
|
|
1417
1495
|
}
|
|
1418
1496
|
if (result instanceof Promise) {
|
|
1419
|
-
return result.then(fusedRespondNoSet, (err) => logError(err,
|
|
1497
|
+
return result.then(fusedRespondNoSet, (err) => logError(err, nativeContext
|
|
1498
|
+
? RequestContext.native(source, params, maxBodyBytes)
|
|
1499
|
+
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes)));
|
|
1420
1500
|
}
|
|
1421
1501
|
return fusedRespondNoSet(result);
|
|
1422
1502
|
};
|
|
1423
1503
|
}
|
|
1424
|
-
return (source, params, search, signal, platform) => {
|
|
1425
|
-
const ctx =
|
|
1504
|
+
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
1505
|
+
const ctx = nativeContext
|
|
1506
|
+
? RequestContext.native(source, params, maxBodyBytes)
|
|
1507
|
+
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes);
|
|
1426
1508
|
if (decorations !== undefined)
|
|
1427
1509
|
Object.assign(ctx, decorations);
|
|
1428
1510
|
let result;
|
|
@@ -1738,13 +1820,13 @@ export class Server {
|
|
|
1738
1820
|
* handlers can bail) and return 503; the in-flight work keeps running but its
|
|
1739
1821
|
* result is discarded — JS can't forcibly cancel a promise.
|
|
1740
1822
|
*/
|
|
1741
|
-
async withTimeout(work, controller, onTimeout) {
|
|
1823
|
+
async withTimeout(work, controller, onTimeout, timeoutMs) {
|
|
1742
1824
|
let timer;
|
|
1743
1825
|
const timeout = new Promise((resolve) => {
|
|
1744
1826
|
timer = setTimeout(() => {
|
|
1745
1827
|
controller.abort();
|
|
1746
1828
|
resolve(onTimeout());
|
|
1747
|
-
},
|
|
1829
|
+
}, timeoutMs);
|
|
1748
1830
|
});
|
|
1749
1831
|
try {
|
|
1750
1832
|
return await Promise.race([work, timeout]);
|
|
@@ -1888,6 +1970,137 @@ export class Server {
|
|
|
1888
1970
|
async readBoundedJson(req) {
|
|
1889
1971
|
return readBoundedJsonSource(req, this.maxBodyBytes);
|
|
1890
1972
|
}
|
|
1973
|
+
/** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
|
|
1974
|
+
* hooks or WebSockets retain the single portable dispatcher because those features must run before
|
|
1975
|
+
* route selection/upgrade. Named wildcards also stay on the fallback until Bun exposes their raw
|
|
1976
|
+
* capture semantics; static and `:param` routes take the native lane. */
|
|
1977
|
+
buildBunNativeRoutes() {
|
|
1978
|
+
if (this.onRequestHooks.length > 0 || this.wsRouteCount > 0)
|
|
1979
|
+
return undefined;
|
|
1980
|
+
const routes = Object.create(null);
|
|
1981
|
+
const mayUseFusedNative = this.requestTimeoutMs === 0 &&
|
|
1982
|
+
this.onResponseHooks.length === 0 &&
|
|
1983
|
+
this.onResponseFinalizedHooks.length === 0;
|
|
1984
|
+
const unboundedSignal = mayUseFusedNative ? getNeverAbortSignal() : undefined;
|
|
1985
|
+
const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined;
|
|
1986
|
+
let count = 0;
|
|
1987
|
+
for (const { method, path, entry } of this.rawEntries) {
|
|
1988
|
+
if (path.includes("*"))
|
|
1989
|
+
continue;
|
|
1990
|
+
let methods = routes[path];
|
|
1991
|
+
if (methods === undefined) {
|
|
1992
|
+
methods = Object.create(null);
|
|
1993
|
+
routes[path] = methods;
|
|
1994
|
+
}
|
|
1995
|
+
const paramNames = path
|
|
1996
|
+
.split("/")
|
|
1997
|
+
.filter((segment) => segment.charCodeAt(0) === 58 /* : */)
|
|
1998
|
+
.map((segment) => segment.slice(1));
|
|
1999
|
+
const fused = mayUseFusedNative ? entry.fusedWeb : undefined;
|
|
2000
|
+
if (fused !== undefined) {
|
|
2001
|
+
// Deliberately choose deadline-aware vs zero-admission closures HERE instead of branching on
|
|
2002
|
+
// `acceptInboundDeadlines` per request. JSC can then flatten the default closure through the
|
|
2003
|
+
// handler + Response.json; retaining that route-invariant branch cost ~7% on a dynamic GET.
|
|
2004
|
+
if (paramNames.length === 0) {
|
|
2005
|
+
methods[method] = this.acceptInboundDeadlines
|
|
2006
|
+
? (request) => request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
2007
|
+
? this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
2008
|
+
: fused(request, EMPTY_PARAMS, undefined, unboundedSignal, unboundedBudget, undefined, true)
|
|
2009
|
+
: (request) => fused(request, EMPTY_PARAMS, undefined, unboundedSignal, unboundedBudget, undefined, true);
|
|
2010
|
+
}
|
|
2011
|
+
else if (paramNames.length === 1) {
|
|
2012
|
+
const paramName = paramNames[0];
|
|
2013
|
+
const handler = entry.handler;
|
|
2014
|
+
const decorations = entry.hasDecorations ? entry.decorations : undefined;
|
|
2015
|
+
const fail = (error, ctx) => {
|
|
2016
|
+
if (error instanceof Response)
|
|
2017
|
+
return error;
|
|
2018
|
+
this.logRequestError(error, ctx);
|
|
2019
|
+
return jsonError(500, "internal_error");
|
|
2020
|
+
};
|
|
2021
|
+
methods[method] = this.acceptInboundDeadlines
|
|
2022
|
+
? (request) => {
|
|
2023
|
+
const params = request.params ?? EMPTY_PARAMS;
|
|
2024
|
+
if (params[paramName]?.includes("\uFFFD") === true)
|
|
2025
|
+
return this.fetchSource(request);
|
|
2026
|
+
if (request.headers.get(NIFRA_DEADLINE_HEADER) !== null) {
|
|
2027
|
+
return this.fetchMatched(request, entry, params);
|
|
2028
|
+
}
|
|
2029
|
+
const ctx = RequestContext.native(request, params, this.maxBodyBytes);
|
|
2030
|
+
if (decorations !== undefined)
|
|
2031
|
+
Object.assign(ctx, decorations);
|
|
2032
|
+
let result;
|
|
2033
|
+
try {
|
|
2034
|
+
result = handler(ctx);
|
|
2035
|
+
}
|
|
2036
|
+
catch (error) {
|
|
2037
|
+
return fail(error, ctx);
|
|
2038
|
+
}
|
|
2039
|
+
return result instanceof Promise
|
|
2040
|
+
? result.then((value) => fusedRespond(value, ctx), (error) => fail(error, ctx))
|
|
2041
|
+
: fusedRespond(result, ctx);
|
|
2042
|
+
}
|
|
2043
|
+
: (request) => {
|
|
2044
|
+
const params = request.params ?? EMPTY_PARAMS;
|
|
2045
|
+
if (params[paramName]?.includes("\uFFFD") === true)
|
|
2046
|
+
return this.fetchSource(request);
|
|
2047
|
+
const ctx = RequestContext.native(request, params, this.maxBodyBytes);
|
|
2048
|
+
if (decorations !== undefined)
|
|
2049
|
+
Object.assign(ctx, decorations);
|
|
2050
|
+
let result;
|
|
2051
|
+
try {
|
|
2052
|
+
result = handler(ctx);
|
|
2053
|
+
}
|
|
2054
|
+
catch (error) {
|
|
2055
|
+
return fail(error, ctx);
|
|
2056
|
+
}
|
|
2057
|
+
return result instanceof Promise
|
|
2058
|
+
? result.then((value) => fusedRespond(value, ctx), (error) => fail(error, ctx))
|
|
2059
|
+
: fusedRespond(result, ctx);
|
|
2060
|
+
};
|
|
2061
|
+
}
|
|
2062
|
+
else {
|
|
2063
|
+
methods[method] = this.acceptInboundDeadlines
|
|
2064
|
+
? (request) => {
|
|
2065
|
+
const params = request.params ?? EMPTY_PARAMS;
|
|
2066
|
+
if (hasReplacementParam(params))
|
|
2067
|
+
return this.fetchSource(request);
|
|
2068
|
+
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
2069
|
+
? this.fetchMatched(request, entry, params)
|
|
2070
|
+
: fused(request, params, undefined, unboundedSignal, unboundedBudget, undefined, true);
|
|
2071
|
+
}
|
|
2072
|
+
: (request) => {
|
|
2073
|
+
const params = request.params ?? EMPTY_PARAMS;
|
|
2074
|
+
if (hasReplacementParam(params))
|
|
2075
|
+
return this.fetchSource(request);
|
|
2076
|
+
return fused(request, params, undefined, unboundedSignal, unboundedBudget, undefined, true);
|
|
2077
|
+
};
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
else if (paramNames.length === 0) {
|
|
2081
|
+
methods[method] = (request) => this.fetchMatched(request, entry, EMPTY_PARAMS);
|
|
2082
|
+
}
|
|
2083
|
+
else if (paramNames.length === 1) {
|
|
2084
|
+
const paramName = paramNames[0];
|
|
2085
|
+
methods[method] = (request) => {
|
|
2086
|
+
const params = request.params ?? EMPTY_PARAMS;
|
|
2087
|
+
if (params[paramName]?.includes("\uFFFD") === true)
|
|
2088
|
+
return this.fetchSource(request);
|
|
2089
|
+
return this.fetchMatched(request, entry, params);
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
else {
|
|
2093
|
+
methods[method] = (request) => {
|
|
2094
|
+
const params = request.params ?? EMPTY_PARAMS;
|
|
2095
|
+
if (hasReplacementParam(params))
|
|
2096
|
+
return this.fetchSource(request);
|
|
2097
|
+
return this.fetchMatched(request, entry, params);
|
|
2098
|
+
};
|
|
2099
|
+
}
|
|
2100
|
+
count += 1;
|
|
2101
|
+
}
|
|
2102
|
+
return count === 0 ? undefined : routes;
|
|
2103
|
+
}
|
|
1891
2104
|
/**
|
|
1892
2105
|
* Start a `Bun.serve` instance bound to `port` (use `0` for an ephemeral port).
|
|
1893
2106
|
*
|
|
@@ -1920,8 +2133,14 @@ export class Server {
|
|
|
1920
2133
|
? undefined
|
|
1921
2134
|
: getWsRuntime().bunHandlers(this.topics);
|
|
1922
2135
|
const reusePort = options?.reusePort === true;
|
|
2136
|
+
const nativeRoutes = wsHandlers === undefined ? this.buildBunNativeRoutes() : undefined;
|
|
1923
2137
|
const running = (wsHandlers === undefined
|
|
1924
|
-
? Bun.serve({
|
|
2138
|
+
? Bun.serve({
|
|
2139
|
+
port,
|
|
2140
|
+
reusePort,
|
|
2141
|
+
...(nativeRoutes === undefined ? {} : { routes: nativeRoutes }),
|
|
2142
|
+
fetch: (req) => this.fetch(req),
|
|
2143
|
+
})
|
|
1925
2144
|
: Bun.serve({
|
|
1926
2145
|
port,
|
|
1927
2146
|
reusePort,
|