@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.
- package/README.md +25 -1
- package/dist/assurance.d.ts +80 -0
- package/dist/assurance.d.ts.map +1 -0
- package/dist/assurance.js +162 -0
- package/dist/assurance.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/route-assurance.d.ts +36 -0
- package/dist/internal/route-assurance.d.ts.map +1 -0
- package/dist/internal/route-assurance.js +118 -0
- package/dist/internal/route-assurance.js.map +1 -0
- package/dist/reflection.d.ts +2 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +12 -1
- package/dist/reflection.js.map +1 -1
- package/dist/server/context.d.ts +6 -0
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/registry.d.ts +14 -8
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/server.d.ts +32 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +332 -61
- package/dist/server/server.js.map +1 -1
- package/package.json +9 -1
- package/src/assurance.ts +263 -0
- package/src/index.ts +2 -1
- package/src/internal/route-assurance.ts +166 -0
- package/src/reflection.ts +13 -1
- package/src/server/context.ts +6 -0
- package/src/server/registry.ts +14 -8
- package/src/server/server.ts +521 -65
package/dist/server/server.js
CHANGED
|
@@ -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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
|
485
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
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
|
|
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
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
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
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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 (
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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 =
|
|
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
|
-
},
|
|
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({
|
|
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,
|