@nifrajs/core 1.12.0 → 1.13.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/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/router/pattern.d.ts +42 -0
- package/dist/router/pattern.d.ts.map +1 -0
- package/dist/router/pattern.js +127 -0
- package/dist/router/pattern.js.map +1 -0
- package/dist/router/router.d.ts +2 -1
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/router.js +11 -48
- package/dist/router/router.js.map +1 -1
- package/dist/server/contract.d.ts +23 -6
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +25 -6
- package/dist/server/contract.js.map +1 -1
- package/dist/server/server.d.ts +28 -13
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +281 -253
- package/dist/server/server.js.map +1 -1
- package/package.json +6 -1
- package/src/index.ts +2 -1
- package/src/router/pattern.ts +176 -0
- package/src/router/router.ts +11 -69
- package/src/server/contract.ts +42 -18
- package/src/server/server.ts +465 -347
package/dist/server/server.js
CHANGED
|
@@ -4,6 +4,7 @@ import { canonicalizeIdempotencyBody, computeIdempotencyFingerprint, DEFAULT_IDE
|
|
|
4
4
|
import { CAPABILITY_GUARD, createCapabilityGuard, normalizeRouteCapabilities, } from "../internal/capability-runtime.js";
|
|
5
5
|
import { assuranceDeclarationsOf, assuranceEvidenceFor, NIFRA_ASSURANCE_IDS, } from "../internal/route-assurance.js";
|
|
6
6
|
import { attachEffectLedger, createRequestLedger, DEFAULT_MAX_ENTRIES, } from "../ledger.js";
|
|
7
|
+
import { compileRoutePattern, decodeRouteParams, } from "../router/pattern.js";
|
|
7
8
|
import { EMPTY_PARAMS, Router } from "../router/router.js";
|
|
8
9
|
import { drainCapped, parseContentLength, readBoundedBytes } from "./body.js";
|
|
9
10
|
import { parseCookies, serializeCookie } from "./cookies.js";
|
|
@@ -35,6 +36,57 @@ function isResponseResult(value) {
|
|
|
35
36
|
const fallbackWaitUntil = (promise) => {
|
|
36
37
|
void promise.catch(() => { });
|
|
37
38
|
};
|
|
39
|
+
/**
|
|
40
|
+
* Runtime route catalog. Single-route registration mutates directly; multi-route registration replays
|
|
41
|
+
* the existing catalog plus the candidate batch into a staged router, then swaps the complete state only
|
|
42
|
+
* after every route validates. Failed `implement()`/`merge()` batches therefore leave matching and
|
|
43
|
+
* reflection unchanged.
|
|
44
|
+
*/
|
|
45
|
+
class RouteCatalog {
|
|
46
|
+
matcher = new Router();
|
|
47
|
+
records = [];
|
|
48
|
+
/** Allocation-free reflection view for the common no-assurance case. Derived only at commit time. */
|
|
49
|
+
descriptors = [];
|
|
50
|
+
assurancePresent = false;
|
|
51
|
+
add(route) {
|
|
52
|
+
this.matcher.add(route.method, route.pattern, route.entry);
|
|
53
|
+
this.records.push(route);
|
|
54
|
+
this.descriptors.push(route.descriptor);
|
|
55
|
+
if (route.assurance.length > 0)
|
|
56
|
+
this.assurancePresent = true;
|
|
57
|
+
}
|
|
58
|
+
addBatch(routes) {
|
|
59
|
+
if (routes.length === 0)
|
|
60
|
+
return;
|
|
61
|
+
const nextRecords = this.records.concat(routes);
|
|
62
|
+
const nextDescriptors = this.descriptors.concat(routes.map(({ descriptor }) => descriptor));
|
|
63
|
+
const nextAssurancePresent = this.assurancePresent || routes.some((route) => route.assurance.length > 0);
|
|
64
|
+
const staged = new Router();
|
|
65
|
+
for (const route of this.records)
|
|
66
|
+
staged.add(route.method, route.pattern, route.entry);
|
|
67
|
+
for (const route of routes)
|
|
68
|
+
staged.add(route.method, route.pattern, route.entry);
|
|
69
|
+
this.matcher = staged;
|
|
70
|
+
this.records = nextRecords;
|
|
71
|
+
this.descriptors = nextDescriptors;
|
|
72
|
+
this.assurancePresent = nextAssurancePresent;
|
|
73
|
+
}
|
|
74
|
+
find(method, path) {
|
|
75
|
+
return this.matcher.find(method, path);
|
|
76
|
+
}
|
|
77
|
+
entries() {
|
|
78
|
+
return this.records;
|
|
79
|
+
}
|
|
80
|
+
routeDescriptors() {
|
|
81
|
+
return this.descriptors;
|
|
82
|
+
}
|
|
83
|
+
lastDescriptor() {
|
|
84
|
+
return this.records[this.records.length - 1]?.descriptor;
|
|
85
|
+
}
|
|
86
|
+
hasAssurance() {
|
|
87
|
+
return this.assurancePresent;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
38
90
|
const WS_PASS = { kind: "pass" };
|
|
39
91
|
/** `app.ws()` (and everything downstream of it) needs the runtime `@nifrajs/core/ws` registers. */
|
|
40
92
|
function requireWsRuntime() {
|
|
@@ -270,27 +322,6 @@ async function readBoundedForm(req, maxBytes) {
|
|
|
270
322
|
}
|
|
271
323
|
return out;
|
|
272
324
|
}
|
|
273
|
-
function decodeParams(raw) {
|
|
274
|
-
let out;
|
|
275
|
-
for (const key in raw) {
|
|
276
|
-
const value = raw[key];
|
|
277
|
-
// `decodeURIComponent` only changes strings containing `%`; for the common
|
|
278
|
-
// unencoded param (`/users/123`) skip the call and its throw-check entirely —
|
|
279
|
-
// semantically identical (no `%` ⇒ no escapes to decode), ~3× cheaper on that path. If every
|
|
280
|
-
// param is already plain, return the router's per-request object directly and skip a clone.
|
|
281
|
-
if (!value.includes("%")) {
|
|
282
|
-
continue;
|
|
283
|
-
}
|
|
284
|
-
try {
|
|
285
|
-
out ??= { ...raw };
|
|
286
|
-
out[key] = decodeURIComponent(value);
|
|
287
|
-
}
|
|
288
|
-
catch {
|
|
289
|
-
return null;
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
return out ?? raw;
|
|
293
|
-
}
|
|
294
325
|
function hasReplacementParam(params) {
|
|
295
326
|
for (const key in params) {
|
|
296
327
|
if (params[key].includes("\uFFFD"))
|
|
@@ -645,7 +676,7 @@ function appendCookiesToNodeHeaders(headers, cookies) {
|
|
|
645
676
|
* .get("/me", (c) => c.user) // c.user + c.db are typed
|
|
646
677
|
*/
|
|
647
678
|
export class Server {
|
|
648
|
-
|
|
679
|
+
catalog;
|
|
649
680
|
/** WebSocket routes, matched separately at upgrade time (a GET + `Upgrade: websocket`). */
|
|
650
681
|
wsRouter;
|
|
651
682
|
wsRouteCount;
|
|
@@ -653,7 +684,6 @@ export class Server {
|
|
|
653
684
|
* Created by the first `app.ws()` via the `@nifrajs/core/ws` runtime — `undefined` until then, so a
|
|
654
685
|
* no-WebSocket app never constructs (or bundles) it. */
|
|
655
686
|
topics;
|
|
656
|
-
routeList;
|
|
657
687
|
maxBodyBytes;
|
|
658
688
|
wsMaxPayloadBytes;
|
|
659
689
|
requestTimeoutMs;
|
|
@@ -673,6 +703,7 @@ export class Server {
|
|
|
673
703
|
/** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
|
|
674
704
|
defaultOnValidationError;
|
|
675
705
|
bunServer;
|
|
706
|
+
sealed;
|
|
676
707
|
derives;
|
|
677
708
|
decorations;
|
|
678
709
|
beforeHandleHooks;
|
|
@@ -692,19 +723,11 @@ export class Server {
|
|
|
692
723
|
/** App-declared MCP resources / prompts (via {@link resource} / {@link prompt}), read by `nifra mcp`. */
|
|
693
724
|
mcpResourceList;
|
|
694
725
|
mcpPromptList;
|
|
695
|
-
/**
|
|
696
|
-
* Every registered route's raw router entry + descriptor, in order — the replay source for
|
|
697
|
-
* {@link merge}. The entry already carries the chains captured at registration (derives,
|
|
698
|
-
* decorations, before/after/around/onError), so a merged route behaves exactly as it did on
|
|
699
|
-
* the server that defined it.
|
|
700
|
-
*/
|
|
701
|
-
rawEntries;
|
|
702
726
|
constructor(options = {}) {
|
|
703
|
-
this.
|
|
727
|
+
this.catalog = new RouteCatalog();
|
|
704
728
|
this.wsRouter = new Router();
|
|
705
729
|
this.wsRouteCount = 0;
|
|
706
730
|
this.topics = undefined;
|
|
707
|
-
this.routeList = [];
|
|
708
731
|
this.maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
709
732
|
this.wsMaxPayloadBytes = options.wsMaxPayloadBytes ?? this.maxBodyBytes;
|
|
710
733
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 0;
|
|
@@ -746,6 +769,7 @@ export class Server {
|
|
|
746
769
|
this.logger = options.logger ?? jsonLogger();
|
|
747
770
|
this.defaultOnValidationError = options.onValidationError;
|
|
748
771
|
this.bunServer = undefined;
|
|
772
|
+
this.sealed = false;
|
|
749
773
|
this.derives = [];
|
|
750
774
|
this.decorations = {};
|
|
751
775
|
this.beforeHandleHooks = [];
|
|
@@ -761,15 +785,21 @@ export class Server {
|
|
|
761
785
|
this.globalAssurance = [];
|
|
762
786
|
this.mcpResourceList = [];
|
|
763
787
|
this.mcpPromptList = [];
|
|
764
|
-
|
|
788
|
+
}
|
|
789
|
+
assertConfigurable(operation) {
|
|
790
|
+
if (this.sealed) {
|
|
791
|
+
throw new FrameworkError("SERVER_SEALED", `server configuration is sealed after listen(); call ${operation} before listen()`);
|
|
792
|
+
}
|
|
765
793
|
}
|
|
766
794
|
/** Add a per-request, computed context extension for subsequent routes. */
|
|
767
795
|
derive(fn) {
|
|
796
|
+
this.assertConfigurable("derive()");
|
|
768
797
|
this.derives.push(fn);
|
|
769
798
|
return this;
|
|
770
799
|
}
|
|
771
800
|
/** Add a static context value for subsequent routes. */
|
|
772
801
|
decorate(key, value) {
|
|
802
|
+
this.assertConfigurable("decorate()");
|
|
773
803
|
this.decorations[key] = value;
|
|
774
804
|
return this;
|
|
775
805
|
}
|
|
@@ -778,11 +808,13 @@ export class Server {
|
|
|
778
808
|
* `Request` to continue routing with a rewritten method/URL/headers. Global.
|
|
779
809
|
*/
|
|
780
810
|
onRequest(fn) {
|
|
811
|
+
this.assertConfigurable("onRequest()");
|
|
781
812
|
this.onRequestHooks.push(fn);
|
|
782
813
|
return this;
|
|
783
814
|
}
|
|
784
815
|
/** Run after validation, before the handler; a non-`undefined` return short-circuits. Order-scoped. */
|
|
785
816
|
beforeHandle(fn) {
|
|
817
|
+
this.assertConfigurable("beforeHandle()");
|
|
786
818
|
this.beforeHandleHooks.push(fn);
|
|
787
819
|
return this;
|
|
788
820
|
}
|
|
@@ -792,30 +824,36 @@ export class Server {
|
|
|
792
824
|
* a Web `Response`. The first registered wrapper is outermost.
|
|
793
825
|
*/
|
|
794
826
|
around(fn) {
|
|
827
|
+
this.assertConfigurable("around()");
|
|
795
828
|
this.aroundHooks.push(fn);
|
|
796
829
|
return this;
|
|
797
830
|
}
|
|
798
831
|
/** Transform the handler's result before it is serialized. Order-scoped. */
|
|
799
832
|
afterHandle(fn) {
|
|
833
|
+
this.assertConfigurable("afterHandle()");
|
|
800
834
|
this.afterHandleHooks.push(fn);
|
|
801
835
|
return this;
|
|
802
836
|
}
|
|
803
837
|
/** Handle a thrown error; a non-`undefined` return becomes the response (else the default 500). Order-scoped. */
|
|
804
838
|
onError(fn) {
|
|
839
|
+
this.assertConfigurable("onError()");
|
|
805
840
|
this.onErrorHooks.push(fn);
|
|
806
841
|
return this;
|
|
807
842
|
}
|
|
808
843
|
/** Transform every outgoing response — success, error, 404, 405, short-circuit. Global. */
|
|
809
844
|
onResponse(fn) {
|
|
845
|
+
this.assertConfigurable("onResponse()");
|
|
810
846
|
this.onResponseHooks.push(fn);
|
|
811
847
|
return this;
|
|
812
848
|
}
|
|
813
849
|
/** Observe the terminal response after all transformations. Observers are ordered and fail-open. */
|
|
814
850
|
onResponseFinalized(fn) {
|
|
851
|
+
this.assertConfigurable("onResponseFinalized()");
|
|
815
852
|
this.onResponseFinalizedHooks.push(fn);
|
|
816
853
|
return this;
|
|
817
854
|
}
|
|
818
855
|
use(arg) {
|
|
856
|
+
this.assertConfigurable("use()");
|
|
819
857
|
if (typeof arg === "function") {
|
|
820
858
|
const name = arg.pluginName;
|
|
821
859
|
if (name !== undefined) {
|
|
@@ -918,7 +956,7 @@ export class Server {
|
|
|
918
956
|
this.register("POST", path, routeSchema, run);
|
|
919
957
|
// Tag the just-registered descriptor as an MCP tool. `tool` is readonly on RouteDescriptor (an
|
|
920
958
|
// introspection field), so write it through a narrow mutable view — not `any`.
|
|
921
|
-
const lastRoute = this.
|
|
959
|
+
const lastRoute = this.catalog.lastDescriptor();
|
|
922
960
|
if (lastRoute) {
|
|
923
961
|
;
|
|
924
962
|
lastRoute.tool = {
|
|
@@ -936,6 +974,7 @@ export class Server {
|
|
|
936
974
|
* for the resource half of MCP.
|
|
937
975
|
*/
|
|
938
976
|
resource(uri, config, read) {
|
|
977
|
+
this.assertConfigurable("resource()");
|
|
939
978
|
this.mcpResourceList.push({
|
|
940
979
|
uri,
|
|
941
980
|
name: config.name,
|
|
@@ -950,6 +989,7 @@ export class Server {
|
|
|
950
989
|
* `handler` receives the caller's arguments and returns the rendered messages.
|
|
951
990
|
*/
|
|
952
991
|
prompt(name, config, handler) {
|
|
992
|
+
this.assertConfigurable("prompt()");
|
|
953
993
|
this.mcpPromptList.push({
|
|
954
994
|
name,
|
|
955
995
|
description: config.description,
|
|
@@ -976,6 +1016,7 @@ export class Server {
|
|
|
976
1016
|
* app.ws("/chat", { open: (ws) => ws.send("hi"), message: (ws, data) => ws.send(data) })
|
|
977
1017
|
*/
|
|
978
1018
|
ws(path, handler) {
|
|
1019
|
+
this.assertConfigurable("ws()");
|
|
979
1020
|
// Boot-time guard: the WS runtime is a subpath (`@nifrajs/core/ws`) so no-WebSocket apps don't
|
|
980
1021
|
// bundle it. Registration is the loud, early failure point — never the first connection.
|
|
981
1022
|
const runtime = requireWsRuntime();
|
|
@@ -1019,6 +1060,18 @@ export class Server {
|
|
|
1019
1060
|
* is the "compiled", order-scoped per-route chain.
|
|
1020
1061
|
*/
|
|
1021
1062
|
register(method, path, schema, handler) {
|
|
1063
|
+
this.assertConfigurable("route registration");
|
|
1064
|
+
this.catalog.add(this.prepareRoute(method, path, schema, handler));
|
|
1065
|
+
}
|
|
1066
|
+
/** Register a contract/group route batch atomically. Every route captures the same current chain it
|
|
1067
|
+
* would capture through {@link register}; no route becomes visible unless the full batch validates. */
|
|
1068
|
+
registerBatch(routes) {
|
|
1069
|
+
this.assertConfigurable("route registration");
|
|
1070
|
+
const staged = routes.map(({ method, path, schema, handler }) => this.prepareRoute(method, path, schema, handler));
|
|
1071
|
+
this.catalog.addBatch(staged);
|
|
1072
|
+
}
|
|
1073
|
+
prepareRoute(method, path, schema, handler) {
|
|
1074
|
+
const pattern = compileRoutePattern(path);
|
|
1022
1075
|
const capabilities = normalizeRouteCapabilities(schema?.capabilities);
|
|
1023
1076
|
const handlerAssurance = assuranceDeclarationsOf(handler);
|
|
1024
1077
|
const invalidHandlerScope = handlerAssurance.find((declaration) => declaration.scope !== "plugin");
|
|
@@ -1052,6 +1105,25 @@ export class Server {
|
|
|
1052
1105
|
const fusedWeb = bare && this.aroundHooks.length === 0
|
|
1053
1106
|
? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler))
|
|
1054
1107
|
: undefined;
|
|
1108
|
+
const contextless = bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler);
|
|
1109
|
+
const lane = bare
|
|
1110
|
+
? "bare"
|
|
1111
|
+
: schema?.body !== undefined &&
|
|
1112
|
+
schema.query === undefined &&
|
|
1113
|
+
this.derives.length === 0 &&
|
|
1114
|
+
this.beforeHandleHooks.length === 0 &&
|
|
1115
|
+
this.afterHandleHooks.length === 0 &&
|
|
1116
|
+
this.onErrorHooks.length === 0
|
|
1117
|
+
? "body"
|
|
1118
|
+
: schema?.body === undefined &&
|
|
1119
|
+
schema?.query !== undefined &&
|
|
1120
|
+
this.derives.length === 0 &&
|
|
1121
|
+
this.beforeHandleHooks.length === 0 &&
|
|
1122
|
+
this.afterHandleHooks.length === 0 &&
|
|
1123
|
+
this.onErrorHooks.length === 0
|
|
1124
|
+
? "query"
|
|
1125
|
+
: "lifecycle";
|
|
1126
|
+
const execution = this.compileExecutionPlan(lane, contextless, this.aroundHooks.length > 0, ledgered !== undefined, fusedWeb);
|
|
1055
1127
|
const entry = {
|
|
1056
1128
|
// (context: never) => unknown -> InternalHandler: the framework invokes it
|
|
1057
1129
|
// with the concrete RawContext the typed handler expects, so this is sound.
|
|
@@ -1059,7 +1131,6 @@ export class Server {
|
|
|
1059
1131
|
schema,
|
|
1060
1132
|
idempotent,
|
|
1061
1133
|
ledgered,
|
|
1062
|
-
fusedWeb,
|
|
1063
1134
|
derives: [...this.derives],
|
|
1064
1135
|
decorations: routeDecorations,
|
|
1065
1136
|
hasDecorations,
|
|
@@ -1067,33 +1138,14 @@ export class Server {
|
|
|
1067
1138
|
afterHandle: [...this.afterHandleHooks],
|
|
1068
1139
|
onError: [...this.onErrorHooks],
|
|
1069
1140
|
around: [...this.aroundHooks],
|
|
1070
|
-
|
|
1071
|
-
// decorations and generic around wrappers are fine: around wraps the inner bare route in
|
|
1072
|
-
// `routeAndRun`, while the inner validation/derive/before/after/onError lifecycle remains empty.
|
|
1073
|
-
// Most GET/no-schema routes qualify.
|
|
1074
|
-
bare,
|
|
1075
|
-
contextlessBare: bare && this.aroundHooks.length === 0 && isContextlessNoArgArrow(handler),
|
|
1076
|
-
bodyOnly: schema?.body !== undefined &&
|
|
1077
|
-
schema.query === undefined &&
|
|
1078
|
-
this.derives.length === 0 &&
|
|
1079
|
-
this.beforeHandleHooks.length === 0 &&
|
|
1080
|
-
this.afterHandleHooks.length === 0 &&
|
|
1081
|
-
this.onErrorHooks.length === 0,
|
|
1082
|
-
queryOnly: schema?.body === undefined &&
|
|
1083
|
-
schema?.query !== undefined &&
|
|
1084
|
-
this.derives.length === 0 &&
|
|
1085
|
-
this.beforeHandleHooks.length === 0 &&
|
|
1086
|
-
this.afterHandleHooks.length === 0 &&
|
|
1087
|
-
this.onErrorHooks.length === 0,
|
|
1141
|
+
execution,
|
|
1088
1142
|
};
|
|
1089
|
-
this.router.add(method, path, entry);
|
|
1090
1143
|
const descriptor = {
|
|
1091
1144
|
method,
|
|
1092
1145
|
path,
|
|
1093
1146
|
schema,
|
|
1094
1147
|
...(capabilities.length > 0 ? { capabilities } : {}),
|
|
1095
1148
|
};
|
|
1096
|
-
this.routeList.push(descriptor);
|
|
1097
1149
|
const routeAssurance = [...this.activeAssurance, ...handlerAssurance];
|
|
1098
1150
|
if (schema?.body !== undefined) {
|
|
1099
1151
|
routeAssurance.push(Object.freeze({
|
|
@@ -1111,13 +1163,62 @@ export class Server {
|
|
|
1111
1163
|
scope: "plugin",
|
|
1112
1164
|
}));
|
|
1113
1165
|
}
|
|
1114
|
-
|
|
1166
|
+
return {
|
|
1115
1167
|
method,
|
|
1116
1168
|
path,
|
|
1169
|
+
pattern,
|
|
1117
1170
|
entry,
|
|
1118
1171
|
descriptor,
|
|
1119
1172
|
assurance: Object.freeze(routeAssurance),
|
|
1120
|
-
}
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
/** Collapse route-invariant lifecycle decisions into one runner at registration. The request path
|
|
1176
|
+
* performs no eligibility ladder: it supplies request state to this already-selected plan. */
|
|
1177
|
+
compileExecutionPlan(lane, contextless, hasAround, hasLedger, fusedWeb) {
|
|
1178
|
+
if (contextless) {
|
|
1179
|
+
const run = (runtime, entry, source, params, search, signal, budget, platform, finalize, wrapResponse) => runtime.runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse);
|
|
1180
|
+
return Object.freeze({ run, fusedWeb });
|
|
1181
|
+
}
|
|
1182
|
+
let inner;
|
|
1183
|
+
switch (lane) {
|
|
1184
|
+
case "bare":
|
|
1185
|
+
inner = (runtime, entry, _source, ctx, finalize, wrapResponse) => runtime.runBare(entry, ctx, finalize, wrapResponse);
|
|
1186
|
+
break;
|
|
1187
|
+
case "body":
|
|
1188
|
+
inner = (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runBodyOnly(entry, source, ctx, finalize, wrapResponse);
|
|
1189
|
+
break;
|
|
1190
|
+
case "query":
|
|
1191
|
+
inner = (runtime, entry, _source, ctx, finalize, wrapResponse) => runtime.runQueryOnly(entry, ctx, finalize, wrapResponse);
|
|
1192
|
+
break;
|
|
1193
|
+
default:
|
|
1194
|
+
inner = (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runLifecycle(entry, source, ctx, finalize, wrapResponse);
|
|
1195
|
+
}
|
|
1196
|
+
const execute = hasAround
|
|
1197
|
+
? (runtime, entry, source, ctx, finalize, wrapResponse) => runtime.runWithAround(entry, ctx, () => inner(runtime, entry, source, ctx, finalize, wrapResponse), finalize, wrapResponse)
|
|
1198
|
+
: inner;
|
|
1199
|
+
const run = (runtime, entry, source, params, search, signal, budget, platform, finalize, wrapResponse) => {
|
|
1200
|
+
const ctx = new RequestContext(source, params, search, signal, budget, platform, runtime.maxBodyBytes);
|
|
1201
|
+
let ledger;
|
|
1202
|
+
if (hasLedger) {
|
|
1203
|
+
const resolved = entry.ledgered;
|
|
1204
|
+
ledger = createRequestLedger({
|
|
1205
|
+
method: resolved.method,
|
|
1206
|
+
path: resolved.path,
|
|
1207
|
+
declared: resolved.declared,
|
|
1208
|
+
maxEntries: resolved.maxEntries,
|
|
1209
|
+
chain: resolved.chain,
|
|
1210
|
+
});
|
|
1211
|
+
attachEffectLedger(ctx, ledger);
|
|
1212
|
+
}
|
|
1213
|
+
let outcome = execute(runtime, entry, source, ctx, finalize, wrapResponse);
|
|
1214
|
+
if (ledger !== undefined) {
|
|
1215
|
+
const active = ledger;
|
|
1216
|
+
const resolved = entry.ledgered;
|
|
1217
|
+
outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) => runtime.settleEffectLedger(active, resolved, value));
|
|
1218
|
+
}
|
|
1219
|
+
return outcome;
|
|
1220
|
+
};
|
|
1221
|
+
return Object.freeze({ run, fusedWeb });
|
|
1121
1222
|
}
|
|
1122
1223
|
/** Resolve a route's declared idempotency into a pinned store + defaults, or `undefined` when off. */
|
|
1123
1224
|
resolveIdempotency(schema, authenticated) {
|
|
@@ -1191,15 +1292,12 @@ export class Server {
|
|
|
1191
1292
|
* those on the parent).
|
|
1192
1293
|
*/
|
|
1193
1294
|
merge(other) {
|
|
1295
|
+
this.assertConfigurable("merge()");
|
|
1194
1296
|
const source = other;
|
|
1195
1297
|
if (source.wsRouteCount > 0) {
|
|
1196
1298
|
throw new RouteConfigError("INVALID_PATH", "merge() does not carry WebSocket routes — register .ws() routes on the parent server");
|
|
1197
1299
|
}
|
|
1198
|
-
|
|
1199
|
-
this.router.add(method, path, entry); // throws RouteConfigError on a duplicate — fail closed
|
|
1200
|
-
this.routeList.push(descriptor); // shared ref on purpose: carries later .tool() tagging
|
|
1201
|
-
this.rawEntries.push({ method, path, entry, descriptor, assurance });
|
|
1202
|
-
}
|
|
1300
|
+
this.catalog.addBatch(source.catalog.entries().map((route) => this.bindFusedRuntime(route)));
|
|
1203
1301
|
this.onRequestHooks.push(...source.onRequestHooks);
|
|
1204
1302
|
this.onResponseHooks.push(...source.onResponseHooks);
|
|
1205
1303
|
this.onResponseFinalizedHooks.push(...source.onResponseFinalizedHooks);
|
|
@@ -1208,6 +1306,21 @@ export class Server {
|
|
|
1208
1306
|
this.mcpPromptList.push(...source.mcpPromptList);
|
|
1209
1307
|
return this;
|
|
1210
1308
|
}
|
|
1309
|
+
/** A fused renderer closes over runtime services to keep its seven-argument JSC fast path. Merging
|
|
1310
|
+
* rebinds that closure once to the executing server; generic plans already receive the runtime. */
|
|
1311
|
+
bindFusedRuntime(route) {
|
|
1312
|
+
const { entry } = route;
|
|
1313
|
+
if (entry.execution.fusedWeb === undefined)
|
|
1314
|
+
return route;
|
|
1315
|
+
const fusedWeb = this.buildFusedWeb(entry.handler, entry.hasDecorations ? entry.decorations : undefined, isContextlessNoArgArrow(entry.handler));
|
|
1316
|
+
return {
|
|
1317
|
+
...route,
|
|
1318
|
+
entry: {
|
|
1319
|
+
...entry,
|
|
1320
|
+
execution: Object.freeze({ ...entry.execution, fusedWeb }),
|
|
1321
|
+
},
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1211
1324
|
/**
|
|
1212
1325
|
* Enumerate the registered routes (method, path, input schemas), in registration
|
|
1213
1326
|
* order. Powers `toOpenAPI` and other introspection; the router trie itself no
|
|
@@ -1215,11 +1328,11 @@ export class Server {
|
|
|
1215
1328
|
*/
|
|
1216
1329
|
routes() {
|
|
1217
1330
|
if (this.activeAssurance.length === 0 && this.globalAssurance.length === 0) {
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1331
|
+
if (!this.catalog.hasAssurance()) {
|
|
1332
|
+
return this.catalog.routeDescriptors();
|
|
1333
|
+
}
|
|
1221
1334
|
}
|
|
1222
|
-
return this.
|
|
1335
|
+
return this.catalog.entries().map(({ method, path, descriptor, assurance }) => {
|
|
1223
1336
|
const effective = assuranceEvidenceFor([...assurance, ...this.globalAssurance], method, path);
|
|
1224
1337
|
return effective.length > 0 ? { ...descriptor, assurance: effective } : descriptor;
|
|
1225
1338
|
});
|
|
@@ -1242,7 +1355,7 @@ export class Server {
|
|
|
1242
1355
|
}
|
|
1243
1356
|
fetchSourceInner(source, platform) {
|
|
1244
1357
|
// Non-`async` on purpose: `dispatch` may return a `Response` *synchronously* (the bare-route fast
|
|
1245
|
-
// path,
|
|
1358
|
+
// path, selected by the compiled execution plan), and an `async fetch` would wrap every such result in a redundant
|
|
1246
1359
|
// promise + microtask. Returning `Response | Promise<Response>` matches Web/edge handlers, while
|
|
1247
1360
|
// `await app.fetch(...)` continues to work exactly as before.
|
|
1248
1361
|
const outcome = this.dispatch(source, platform, toResponse, IDENTITY_RESPONSE, RESPONSE_TIMEOUT, true);
|
|
@@ -1332,7 +1445,7 @@ export class Server {
|
|
|
1332
1445
|
? response.then((settled) => ({ kind: "response", response: settled }))
|
|
1333
1446
|
: { kind: "response", response };
|
|
1334
1447
|
}
|
|
1335
|
-
// May resolve **synchronously** for a bare route + sync handler
|
|
1448
|
+
// May resolve **synchronously** for a compiled bare route + sync handler - the `@nifrajs/node`
|
|
1336
1449
|
// adapter `await`s the result, so it transparently handles either; the sync case allocates no promise
|
|
1337
1450
|
// at all on the Node hot path.
|
|
1338
1451
|
return this.dispatch(source, platform, toNodeOutcome, NODE_FROM_RESPONSE, NODE_TIMEOUT, false);
|
|
@@ -1356,7 +1469,7 @@ export class Server {
|
|
|
1356
1469
|
return WS_PASS; // upgrade header, no WS route here → normal routing decides
|
|
1357
1470
|
// Inspect only captured values for escapes. Scanning the full pathname repeated work the router
|
|
1358
1471
|
// already did and made every plain dynamic route pay for unrelated static path bytes.
|
|
1359
|
-
const params = match.params === EMPTY_PARAMS ? match.params :
|
|
1472
|
+
const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params);
|
|
1360
1473
|
if (params === null)
|
|
1361
1474
|
return { kind: "reject", response: jsonError(400, "malformed_path") };
|
|
1362
1475
|
const handler = match.payload.handler;
|
|
@@ -1495,12 +1608,12 @@ export class Server {
|
|
|
1495
1608
|
}
|
|
1496
1609
|
/**
|
|
1497
1610
|
* Route → build context → run. Synchronous through to the handler for a **bare** route
|
|
1498
|
-
* (
|
|
1611
|
+
* (selected by its compiled plan), so a sync handler produces its result with zero promise allocations;
|
|
1499
1612
|
* routes with validation/hooks keep the full async {@link runLifecycle}, unchanged.
|
|
1500
1613
|
*/
|
|
1501
1614
|
routeAndRun(source, platform, finalize, wrapResponse, onTimeout, webFast) {
|
|
1502
1615
|
const url = urlPartsOf(source.url);
|
|
1503
|
-
const match = this.
|
|
1616
|
+
const match = this.catalog.find(source.method, url.pathname);
|
|
1504
1617
|
if (!match.found) {
|
|
1505
1618
|
if (match.reason === "method-not-allowed") {
|
|
1506
1619
|
return wrapResponse(jsonError(405, "method_not_allowed", { Allow: match.allowed.join(", ") }));
|
|
@@ -1509,7 +1622,7 @@ export class Server {
|
|
|
1509
1622
|
}
|
|
1510
1623
|
// Inspect only captured values for escapes. Scanning the full pathname repeated work the router
|
|
1511
1624
|
// already did and made every plain dynamic route pay for unrelated static path bytes.
|
|
1512
|
-
const params = match.params === EMPTY_PARAMS ? match.params :
|
|
1625
|
+
const params = match.params === EMPTY_PARAMS ? match.params : decodeRouteParams(match.params);
|
|
1513
1626
|
if (params === null) {
|
|
1514
1627
|
return wrapResponse(jsonError(400, "malformed_path"));
|
|
1515
1628
|
}
|
|
@@ -1627,7 +1740,7 @@ export class Server {
|
|
|
1627
1740
|
}
|
|
1628
1741
|
return wrapResponse(response);
|
|
1629
1742
|
}
|
|
1630
|
-
/**
|
|
1743
|
+
/** Supply request-specific deadline state to the route's precompiled execution plan. */
|
|
1631
1744
|
runMatchedLanes(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
|
|
1632
1745
|
// Translate the absolute wire deadline once, clamp it to local policy, then use the resulting
|
|
1633
1746
|
// duration for both c.signal and c.budget. A client can only shorten work, never extend it.
|
|
@@ -1661,62 +1774,10 @@ export class Server {
|
|
|
1661
1774
|
const budget = controller === undefined
|
|
1662
1775
|
? getUnboundedRequestBudget()
|
|
1663
1776
|
: createRequestBudget({ deadline: admission.deadline, signal });
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
// Fused Web lane (bare route, no around): one closure end to end. Sound cast — webFast is
|
|
1669
|
-
// passed only by the `fetch` path, where T = Response by construction.
|
|
1670
|
-
outcome = entry.fusedWeb(source, params, search, signal, budget, platform, false);
|
|
1671
|
-
}
|
|
1672
|
-
else if (entry.contextlessBare) {
|
|
1673
|
-
outcome = this.runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse);
|
|
1674
|
-
}
|
|
1675
|
-
else {
|
|
1676
|
-
// Build the context up front so `onError` always has it. Query, cookies, headers, and body helpers
|
|
1677
|
-
// stay lazy, but their accessors live on the prototype instead of allocating per-request closures.
|
|
1678
|
-
const ctx = new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
|
|
1679
|
-
// Ledgered routes (capabilities + effectLedger) carry a bounded per-request ledger; the
|
|
1680
|
-
// `useCapability` beacon appends to it, and the settle step below seals + sinks it. The route
|
|
1681
|
-
// was forced off the fused/contextless lanes at registration, so this branch always runs.
|
|
1682
|
-
let ledger;
|
|
1683
|
-
if (entry.ledgered !== undefined) {
|
|
1684
|
-
ledger = createRequestLedger({
|
|
1685
|
-
method: entry.ledgered.method,
|
|
1686
|
-
path: entry.ledgered.path,
|
|
1687
|
-
declared: entry.ledgered.declared,
|
|
1688
|
-
maxEntries: entry.ledgered.maxEntries,
|
|
1689
|
-
chain: entry.ledgered.chain,
|
|
1690
|
-
});
|
|
1691
|
-
attachEffectLedger(ctx, ledger);
|
|
1692
|
-
}
|
|
1693
|
-
if (entry.around.length === 0) {
|
|
1694
|
-
outcome = entry.bare
|
|
1695
|
-
? this.runBare(entry, ctx, finalize, wrapResponse)
|
|
1696
|
-
: entry.bodyOnly
|
|
1697
|
-
? this.runBodyOnly(entry, source, ctx, finalize, wrapResponse)
|
|
1698
|
-
: entry.queryOnly
|
|
1699
|
-
? this.runQueryOnly(entry, ctx, finalize, wrapResponse)
|
|
1700
|
-
: this.runLifecycle(entry, source, ctx, finalize, wrapResponse);
|
|
1701
|
-
}
|
|
1702
|
-
else {
|
|
1703
|
-
outcome = this.runWithAround(entry, ctx, () => entry.bare
|
|
1704
|
-
? this.runBare(entry, ctx, finalize, wrapResponse)
|
|
1705
|
-
: entry.bodyOnly
|
|
1706
|
-
? this.runBodyOnly(entry, source, ctx, finalize, wrapResponse)
|
|
1707
|
-
: entry.queryOnly
|
|
1708
|
-
? this.runQueryOnly(entry, ctx, finalize, wrapResponse)
|
|
1709
|
-
: this.runLifecycle(entry, source, ctx, finalize, wrapResponse), finalize, wrapResponse);
|
|
1710
|
-
}
|
|
1711
|
-
// Settle step: seal the ledger and hand it to the sink BEFORE the response is released, on
|
|
1712
|
-
// success and error responses alike (partial work is exactly what an audit must capture). The
|
|
1713
|
-
// lanes above resolve handler errors to responses, so a plain resolve arm is sufficient.
|
|
1714
|
-
if (ledger !== undefined) {
|
|
1715
|
-
const resolved = entry.ledgered;
|
|
1716
|
-
const active = ledger;
|
|
1717
|
-
outcome = (outcome instanceof Promise ? outcome : Promise.resolve(outcome)).then((value) => this.settleEffectLedger(active, resolved, value));
|
|
1718
|
-
}
|
|
1719
|
-
}
|
|
1777
|
+
const plan = entry.execution;
|
|
1778
|
+
const outcome = webFast && plan.fusedWeb !== undefined
|
|
1779
|
+
? plan.fusedWeb(source, params, search, signal, budget, platform, false)
|
|
1780
|
+
: plan.run(this, entry, source, params, search, signal, budget, platform, finalize, wrapResponse);
|
|
1720
1781
|
// The request timeout only bounds work that is actually pending — a synchronous (bare) result is
|
|
1721
1782
|
// already complete and can't time out, so it's returned as-is (no 503 race, no promise).
|
|
1722
1783
|
if (controller !== undefined && outcome instanceof Promise) {
|
|
@@ -1775,7 +1836,7 @@ export class Server {
|
|
|
1775
1836
|
return wrapResponse(jsonError(500, "internal_error"));
|
|
1776
1837
|
}
|
|
1777
1838
|
/**
|
|
1778
|
-
* The synchronous fast path
|
|
1839
|
+
* The synchronous fast path selected by a route's execution plan: apply static decorations, call the
|
|
1779
1840
|
* handler, render the result — **no `await`** unless the handler itself returns a promise. It mirrors
|
|
1780
1841
|
* the bare slice of {@link runLifecycle} (which a bare route would otherwise no-op through) and shares
|
|
1781
1842
|
* {@link logRequestError}; a bare route has no `onError` hooks, so error handling is fully synchronous
|
|
@@ -1801,14 +1862,13 @@ export class Server {
|
|
|
1801
1862
|
/** Bare-route error rendering — identical to {@link runLifecycle}'s catch minus the (absent) onError
|
|
1802
1863
|
* loop: a thrown `Response` is returned as deliberate control flow; anything else is logged + 500. */
|
|
1803
1864
|
/**
|
|
1804
|
-
* Build a route's fused Web
|
|
1865
|
+
* Build a route's fused Web renderer. Composition happens once at
|
|
1805
1866
|
* registration; the returned closure is what every request to the route runs. Behavior is
|
|
1806
1867
|
* byte-identical to the generic `runBare`/`runContextlessBare` + `toResponse` pair — same
|
|
1807
1868
|
* decoration order, same error routing (thrown `Response` = control flow; anything else logs and
|
|
1808
1869
|
* 500s), same respond semantics (the lifecycle parity suite pins it).
|
|
1809
1870
|
*/
|
|
1810
1871
|
buildFusedWeb(handler, decorations, contextless) {
|
|
1811
|
-
const maxBodyBytes = this.maxBodyBytes;
|
|
1812
1872
|
const logError = (err, ctx) => {
|
|
1813
1873
|
if (err instanceof Response)
|
|
1814
1874
|
return err;
|
|
@@ -1826,21 +1886,21 @@ export class Server {
|
|
|
1826
1886
|
}
|
|
1827
1887
|
catch (err) {
|
|
1828
1888
|
return logError(err, nativeContext
|
|
1829
|
-
? RequestContext.native(source, params, maxBodyBytes)
|
|
1830
|
-
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes));
|
|
1889
|
+
? RequestContext.native(source, params, this.maxBodyBytes)
|
|
1890
|
+
: new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes));
|
|
1831
1891
|
}
|
|
1832
1892
|
if (result instanceof Promise) {
|
|
1833
1893
|
return result.then(fusedRespondNoSet, (err) => logError(err, nativeContext
|
|
1834
|
-
? RequestContext.native(source, params, maxBodyBytes)
|
|
1835
|
-
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes)));
|
|
1894
|
+
? RequestContext.native(source, params, this.maxBodyBytes)
|
|
1895
|
+
: new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes)));
|
|
1836
1896
|
}
|
|
1837
1897
|
return fusedRespondNoSet(result);
|
|
1838
1898
|
};
|
|
1839
1899
|
}
|
|
1840
1900
|
return (source, params, search, signal, budget, platform, nativeContext) => {
|
|
1841
1901
|
const ctx = nativeContext
|
|
1842
|
-
? RequestContext.native(source, params, maxBodyBytes)
|
|
1843
|
-
: new RequestContext(source, params, search, signal, budget, platform, maxBodyBytes);
|
|
1902
|
+
? RequestContext.native(source, params, this.maxBodyBytes)
|
|
1903
|
+
: new RequestContext(source, params, search, signal, budget, platform, this.maxBodyBytes);
|
|
1844
1904
|
if (decorations !== undefined)
|
|
1845
1905
|
Object.assign(ctx, decorations);
|
|
1846
1906
|
let result;
|
|
@@ -2180,7 +2240,7 @@ export class Server {
|
|
|
2180
2240
|
async runLifecycle(entry, source, ctx, finalize, wrapResponse) {
|
|
2181
2241
|
try {
|
|
2182
2242
|
if (entry.schema?.body !== undefined) {
|
|
2183
|
-
const bodyError = await this.readAndValidateBody(source, entry
|
|
2243
|
+
const bodyError = await this.readAndValidateBody(source, entry, ctx);
|
|
2184
2244
|
if (bodyError !== undefined)
|
|
2185
2245
|
return wrapResponse(bodyError);
|
|
2186
2246
|
}
|
|
@@ -2190,9 +2250,9 @@ export class Server {
|
|
|
2190
2250
|
// validateStandard wrapper alloc.
|
|
2191
2251
|
const validation = entry.schema.query["~standard"].validate(queryObjectOf(ctx[CONTEXT_SEARCH]));
|
|
2192
2252
|
const result = validation instanceof Promise ? await validation : validation;
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2253
|
+
const queryError = await this.applyLifecycleValidation(entry, result, ctx, "query");
|
|
2254
|
+
if (queryError !== undefined)
|
|
2255
|
+
return wrapResponse(queryError);
|
|
2196
2256
|
}
|
|
2197
2257
|
// Context extensions: static decorations, then per-request derives.
|
|
2198
2258
|
// Each hook below only awaits when it actually returns a Promise — a sync hook
|
|
@@ -2262,7 +2322,7 @@ export class Server {
|
|
|
2262
2322
|
stack: err instanceof Error ? err.stack : undefined,
|
|
2263
2323
|
});
|
|
2264
2324
|
}
|
|
2265
|
-
async readAndValidateBody(req,
|
|
2325
|
+
async readAndValidateBody(req, entry, ctx) {
|
|
2266
2326
|
const contentType = headerOf(req, "content-type") ?? "";
|
|
2267
2327
|
let parsed;
|
|
2268
2328
|
if (contentType === "application/json" || contentType.includes("application/json")) {
|
|
@@ -2282,11 +2342,38 @@ export class Server {
|
|
|
2282
2342
|
// fit a value schema; use a schema-less route + @nifrajs/uploads helpers for those.
|
|
2283
2343
|
return jsonError(415, "unsupported_media_type");
|
|
2284
2344
|
}
|
|
2285
|
-
const validation = schema["~standard"].validate(parsed);
|
|
2345
|
+
const validation = entry.schema.body["~standard"].validate(parsed);
|
|
2286
2346
|
const result = validation instanceof Promise ? await validation : validation;
|
|
2287
|
-
|
|
2347
|
+
return this.applyLifecycleValidation(entry, result, ctx, "body");
|
|
2348
|
+
}
|
|
2349
|
+
/** Apply validation and its recovery hook on the generic lifecycle lane. Recovery is completed
|
|
2350
|
+
* before derives/beforeHandle run, matching the body-only and query-only execution lanes. */
|
|
2351
|
+
async applyLifecycleValidation(entry, result, ctx, kind) {
|
|
2352
|
+
if (result.issues === undefined) {
|
|
2353
|
+
if (kind === "body")
|
|
2354
|
+
ctx.body = result.value;
|
|
2355
|
+
else
|
|
2356
|
+
ctx.query = result.value;
|
|
2357
|
+
return undefined;
|
|
2358
|
+
}
|
|
2359
|
+
const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError;
|
|
2360
|
+
if (hook === undefined)
|
|
2288
2361
|
return validationError(result.issues);
|
|
2289
|
-
|
|
2362
|
+
const attempted = hook(result.issues, ctx, kind);
|
|
2363
|
+
const recovery = attempted instanceof Promise ? await attempted : attempted;
|
|
2364
|
+
if (recovery === undefined)
|
|
2365
|
+
return validationError(result.issues);
|
|
2366
|
+
if (recovery instanceof Response)
|
|
2367
|
+
return recovery;
|
|
2368
|
+
const schema = kind === "body" ? entry.schema?.body : entry.schema?.query;
|
|
2369
|
+
const retried = schema["~standard"].validate(recovery);
|
|
2370
|
+
const settled = retried instanceof Promise ? await retried : retried;
|
|
2371
|
+
if (settled.issues !== undefined)
|
|
2372
|
+
return validationError(settled.issues);
|
|
2373
|
+
if (kind === "body")
|
|
2374
|
+
ctx.body = settled.value;
|
|
2375
|
+
else
|
|
2376
|
+
ctx.query = settled.value;
|
|
2290
2377
|
return undefined;
|
|
2291
2378
|
}
|
|
2292
2379
|
/**
|
|
@@ -2306,6 +2393,48 @@ export class Server {
|
|
|
2306
2393
|
async readBoundedJson(req) {
|
|
2307
2394
|
return readBoundedJsonSource(req, this.maxBodyBytes);
|
|
2308
2395
|
}
|
|
2396
|
+
/** Adapt one route's compiled execution plan to Bun's already-matched request shape. Route
|
|
2397
|
+
* semantics remain in the plan; this closure only supplies native params and deadline fallback. */
|
|
2398
|
+
compileBunNativeHandler(entry, paramNames, fused, signal, budget) {
|
|
2399
|
+
if (paramNames.length === 0) {
|
|
2400
|
+
if (fused === undefined) {
|
|
2401
|
+
return (request) => this.fetchMatched(request, entry, EMPTY_PARAMS);
|
|
2402
|
+
}
|
|
2403
|
+
if (this.acceptInboundDeadlines) {
|
|
2404
|
+
return (request) => request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
2405
|
+
? this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
2406
|
+
: fused(request, EMPTY_PARAMS, undefined, signal, budget, undefined, true);
|
|
2407
|
+
}
|
|
2408
|
+
return (request) => fused(request, EMPTY_PARAMS, undefined, signal, budget, undefined, true);
|
|
2409
|
+
}
|
|
2410
|
+
const malformed = paramNames.length === 1
|
|
2411
|
+
? (params) => params[paramNames[0]]?.includes("\uFFFD") === true
|
|
2412
|
+
: hasReplacementParam;
|
|
2413
|
+
if (fused === undefined) {
|
|
2414
|
+
return (request) => {
|
|
2415
|
+
const params = request.params ?? EMPTY_PARAMS;
|
|
2416
|
+
if (malformed(params))
|
|
2417
|
+
return this.fetchSource(request);
|
|
2418
|
+
return this.fetchMatched(request, entry, params);
|
|
2419
|
+
};
|
|
2420
|
+
}
|
|
2421
|
+
if (this.acceptInboundDeadlines) {
|
|
2422
|
+
return (request) => {
|
|
2423
|
+
const params = request.params ?? EMPTY_PARAMS;
|
|
2424
|
+
if (malformed(params))
|
|
2425
|
+
return this.fetchSource(request);
|
|
2426
|
+
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
2427
|
+
? this.fetchMatched(request, entry, params)
|
|
2428
|
+
: fused(request, params, undefined, signal, budget, undefined, true);
|
|
2429
|
+
};
|
|
2430
|
+
}
|
|
2431
|
+
return (request) => {
|
|
2432
|
+
const params = request.params ?? EMPTY_PARAMS;
|
|
2433
|
+
if (malformed(params))
|
|
2434
|
+
return this.fetchSource(request);
|
|
2435
|
+
return fused(request, params, undefined, signal, budget, undefined, true);
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
2309
2438
|
/** Compile portable route registrations into Bun's native route table. Apps with request-rewrite
|
|
2310
2439
|
* hooks or WebSockets retain the single portable dispatcher because those features must run before
|
|
2311
2440
|
* route selection/upgrade. Named wildcards also stay on the fallback until Bun exposes their raw
|
|
@@ -2323,119 +2452,17 @@ export class Server {
|
|
|
2323
2452
|
const unboundedSignal = mayUseFusedNative ? getNeverAbortSignal() : undefined;
|
|
2324
2453
|
const unboundedBudget = mayUseFusedNative ? getUnboundedRequestBudget() : undefined;
|
|
2325
2454
|
let count = 0;
|
|
2326
|
-
for (const { method, path, entry } of this.
|
|
2327
|
-
if (
|
|
2455
|
+
for (const { method, path, pattern, entry } of this.catalog.entries()) {
|
|
2456
|
+
if (pattern.segments.some((segment) => segment.kind === "wildcard"))
|
|
2328
2457
|
continue;
|
|
2329
2458
|
let methods = routes[path];
|
|
2330
2459
|
if (methods === undefined) {
|
|
2331
2460
|
methods = Object.create(null);
|
|
2332
2461
|
routes[path] = methods;
|
|
2333
2462
|
}
|
|
2334
|
-
const paramNames =
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
.map((segment) => segment.slice(1));
|
|
2338
|
-
const fused = mayUseFusedNative ? entry.fusedWeb : undefined;
|
|
2339
|
-
if (fused !== undefined) {
|
|
2340
|
-
// Deliberately choose deadline-aware vs zero-admission closures HERE instead of branching on
|
|
2341
|
-
// `acceptInboundDeadlines` per request. JSC can then flatten the default closure through the
|
|
2342
|
-
// handler + Response.json; retaining that route-invariant branch cost ~7% on a dynamic GET.
|
|
2343
|
-
if (paramNames.length === 0) {
|
|
2344
|
-
methods[method] = this.acceptInboundDeadlines
|
|
2345
|
-
? (request) => request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
2346
|
-
? this.fetchMatched(request, entry, EMPTY_PARAMS)
|
|
2347
|
-
: fused(request, EMPTY_PARAMS, undefined, unboundedSignal, unboundedBudget, undefined, true)
|
|
2348
|
-
: (request) => fused(request, EMPTY_PARAMS, undefined, unboundedSignal, unboundedBudget, undefined, true);
|
|
2349
|
-
}
|
|
2350
|
-
else if (paramNames.length === 1) {
|
|
2351
|
-
const paramName = paramNames[0];
|
|
2352
|
-
const handler = entry.handler;
|
|
2353
|
-
const decorations = entry.hasDecorations ? entry.decorations : undefined;
|
|
2354
|
-
const fail = (error, ctx) => {
|
|
2355
|
-
if (error instanceof Response)
|
|
2356
|
-
return error;
|
|
2357
|
-
this.logRequestError(error, ctx);
|
|
2358
|
-
return jsonError(500, "internal_error");
|
|
2359
|
-
};
|
|
2360
|
-
methods[method] = this.acceptInboundDeadlines
|
|
2361
|
-
? (request) => {
|
|
2362
|
-
const params = request.params ?? EMPTY_PARAMS;
|
|
2363
|
-
if (params[paramName]?.includes("\uFFFD") === true)
|
|
2364
|
-
return this.fetchSource(request);
|
|
2365
|
-
if (request.headers.get(NIFRA_DEADLINE_HEADER) !== null) {
|
|
2366
|
-
return this.fetchMatched(request, entry, params);
|
|
2367
|
-
}
|
|
2368
|
-
const ctx = RequestContext.native(request, params, this.maxBodyBytes);
|
|
2369
|
-
if (decorations !== undefined)
|
|
2370
|
-
Object.assign(ctx, decorations);
|
|
2371
|
-
let result;
|
|
2372
|
-
try {
|
|
2373
|
-
result = handler(ctx);
|
|
2374
|
-
}
|
|
2375
|
-
catch (error) {
|
|
2376
|
-
return fail(error, ctx);
|
|
2377
|
-
}
|
|
2378
|
-
return result instanceof Promise
|
|
2379
|
-
? result.then((value) => fusedRespond(value, ctx), (error) => fail(error, ctx))
|
|
2380
|
-
: fusedRespond(result, ctx);
|
|
2381
|
-
}
|
|
2382
|
-
: (request) => {
|
|
2383
|
-
const params = request.params ?? EMPTY_PARAMS;
|
|
2384
|
-
if (params[paramName]?.includes("\uFFFD") === true)
|
|
2385
|
-
return this.fetchSource(request);
|
|
2386
|
-
const ctx = RequestContext.native(request, params, this.maxBodyBytes);
|
|
2387
|
-
if (decorations !== undefined)
|
|
2388
|
-
Object.assign(ctx, decorations);
|
|
2389
|
-
let result;
|
|
2390
|
-
try {
|
|
2391
|
-
result = handler(ctx);
|
|
2392
|
-
}
|
|
2393
|
-
catch (error) {
|
|
2394
|
-
return fail(error, ctx);
|
|
2395
|
-
}
|
|
2396
|
-
return result instanceof Promise
|
|
2397
|
-
? result.then((value) => fusedRespond(value, ctx), (error) => fail(error, ctx))
|
|
2398
|
-
: fusedRespond(result, ctx);
|
|
2399
|
-
};
|
|
2400
|
-
}
|
|
2401
|
-
else {
|
|
2402
|
-
methods[method] = this.acceptInboundDeadlines
|
|
2403
|
-
? (request) => {
|
|
2404
|
-
const params = request.params ?? EMPTY_PARAMS;
|
|
2405
|
-
if (hasReplacementParam(params))
|
|
2406
|
-
return this.fetchSource(request);
|
|
2407
|
-
return request.headers.get(NIFRA_DEADLINE_HEADER) !== null
|
|
2408
|
-
? this.fetchMatched(request, entry, params)
|
|
2409
|
-
: fused(request, params, undefined, unboundedSignal, unboundedBudget, undefined, true);
|
|
2410
|
-
}
|
|
2411
|
-
: (request) => {
|
|
2412
|
-
const params = request.params ?? EMPTY_PARAMS;
|
|
2413
|
-
if (hasReplacementParam(params))
|
|
2414
|
-
return this.fetchSource(request);
|
|
2415
|
-
return fused(request, params, undefined, unboundedSignal, unboundedBudget, undefined, true);
|
|
2416
|
-
};
|
|
2417
|
-
}
|
|
2418
|
-
}
|
|
2419
|
-
else if (paramNames.length === 0) {
|
|
2420
|
-
methods[method] = (request) => this.fetchMatched(request, entry, EMPTY_PARAMS);
|
|
2421
|
-
}
|
|
2422
|
-
else if (paramNames.length === 1) {
|
|
2423
|
-
const paramName = paramNames[0];
|
|
2424
|
-
methods[method] = (request) => {
|
|
2425
|
-
const params = request.params ?? EMPTY_PARAMS;
|
|
2426
|
-
if (params[paramName]?.includes("\uFFFD") === true)
|
|
2427
|
-
return this.fetchSource(request);
|
|
2428
|
-
return this.fetchMatched(request, entry, params);
|
|
2429
|
-
};
|
|
2430
|
-
}
|
|
2431
|
-
else {
|
|
2432
|
-
methods[method] = (request) => {
|
|
2433
|
-
const params = request.params ?? EMPTY_PARAMS;
|
|
2434
|
-
if (hasReplacementParam(params))
|
|
2435
|
-
return this.fetchSource(request);
|
|
2436
|
-
return this.fetchMatched(request, entry, params);
|
|
2437
|
-
};
|
|
2438
|
-
}
|
|
2463
|
+
const paramNames = pattern.paramNames;
|
|
2464
|
+
const fused = mayUseFusedNative ? entry.execution.fusedWeb : undefined;
|
|
2465
|
+
methods[method] = this.compileBunNativeHandler(entry, paramNames, fused, unboundedSignal, unboundedBudget);
|
|
2439
2466
|
count += 1;
|
|
2440
2467
|
}
|
|
2441
2468
|
return count === 0 ? undefined : routes;
|
|
@@ -2507,6 +2534,7 @@ export class Server {
|
|
|
2507
2534
|
},
|
|
2508
2535
|
}));
|
|
2509
2536
|
this.bunServer = running;
|
|
2537
|
+
this.sealed = true;
|
|
2510
2538
|
if (this.gracefulSignals)
|
|
2511
2539
|
this.installSignalHandlers();
|
|
2512
2540
|
return running;
|