@nifrajs/core 1.9.1 → 1.11.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/assurance.d.ts +18 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js +28 -1
- package/dist/assurance.js.map +1 -1
- package/dist/budget.d.ts +91 -0
- package/dist/budget.d.ts.map +1 -0
- package/dist/budget.js +172 -0
- package/dist/budget.js.map +1 -0
- package/dist/capabilities.d.ts +146 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +283 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/classification.d.ts +39 -0
- package/dist/classification.d.ts.map +1 -0
- package/dist/classification.js +153 -0
- package/dist/classification.js.map +1 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/idempotency.d.ts +137 -0
- package/dist/idempotency.d.ts.map +1 -0
- package/dist/idempotency.js +306 -0
- package/dist/idempotency.js.map +1 -0
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/capability-runtime.d.ts +16 -0
- package/dist/internal/capability-runtime.d.ts.map +1 -0
- package/dist/internal/capability-runtime.js +27 -0
- package/dist/internal/capability-runtime.js.map +1 -0
- package/dist/internal/route-assurance.d.ts +1 -0
- package/dist/internal/route-assurance.d.ts.map +1 -1
- package/dist/internal/route-assurance.js +1 -0
- package/dist/internal/route-assurance.js.map +1 -1
- package/dist/invariants.d.ts +66 -0
- package/dist/invariants.d.ts.map +1 -0
- package/dist/invariants.js +483 -0
- package/dist/invariants.js.map +1 -0
- package/dist/ledger.d.ts +154 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +271 -0
- package/dist/ledger.js.map +1 -0
- package/dist/manifest.d.ts +81 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +394 -0
- package/dist/manifest.js.map +1 -0
- package/dist/mount.d.ts +18 -0
- package/dist/mount.d.ts.map +1 -0
- package/dist/mount.js +11 -0
- package/dist/mount.js.map +1 -0
- package/dist/reflection.d.ts +4 -0
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +9 -0
- package/dist/reflection.js.map +1 -1
- package/dist/server/context.d.ts +50 -1
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +8 -1
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js +11 -1
- package/dist/server/contract.js.map +1 -1
- package/dist/server/server.d.ts +56 -2
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +294 -17
- package/dist/server/server.js.map +1 -1
- package/package.json +36 -4
- package/src/assurance.ts +48 -1
- package/src/budget.ts +267 -0
- package/src/capabilities.ts +486 -0
- package/src/classification.ts +189 -0
- package/src/errors.ts +2 -0
- package/src/idempotency.ts +420 -0
- package/src/index.ts +8 -1
- package/src/internal/capability-runtime.ts +49 -0
- package/src/internal/route-assurance.ts +1 -0
- package/src/invariants.ts +600 -0
- package/src/ledger.ts +430 -0
- package/src/manifest.ts +543 -0
- package/src/mount.ts +24 -0
- package/src/reflection.ts +18 -0
- package/src/server/context.ts +54 -1
- package/src/server/contract.ts +19 -2
- package/src/server/server.ts +463 -26
package/dist/server/server.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { admitDeadline, createRequestBudget, createUnboundedRequestBudget, NIFRA_DEADLINE_HEADER, } from "
|
|
1
|
+
import { admitDeadline, createRequestBudget, createUnboundedRequestBudget, NIFRA_DEADLINE_HEADER, } from "../budget.js";
|
|
2
2
|
import { FrameworkError, RouteConfigError } from "../errors.js";
|
|
3
|
+
import { canonicalizeIdempotencyBody, computeIdempotencyFingerprint, DEFAULT_IDEMPOTENCY_HEADER, DEFAULT_IDEMPOTENCY_TTL_MS, IdempotencyResponseTooLargeError, MemoryIdempotencyStore, responseFromStored, serializeResponse, validIdempotencyKey, validIdempotencyNamespace, } from "../idempotency.js";
|
|
4
|
+
import { CAPABILITY_GUARD, createCapabilityGuard, normalizeRouteCapabilities, } from "../internal/capability-runtime.js";
|
|
3
5
|
import { assuranceDeclarationsOf, assuranceEvidenceFor, NIFRA_ASSURANCE_IDS, } from "../internal/route-assurance.js";
|
|
6
|
+
import { attachEffectLedger, createRequestLedger, DEFAULT_MAX_ENTRIES, } from "../ledger.js";
|
|
4
7
|
import { EMPTY_PARAMS, Router } from "../router/router.js";
|
|
5
8
|
import { drainCapped, parseContentLength, readBoundedBytes } from "./body.js";
|
|
6
9
|
import { parseCookies, serializeCookie } from "./cookies.js";
|
|
@@ -660,6 +663,12 @@ export class Server {
|
|
|
660
663
|
gracefulSignals;
|
|
661
664
|
/** Capacity-admission gate; `undefined` = off (the request path pays nothing). */
|
|
662
665
|
capacityGate;
|
|
666
|
+
onCapabilityUse;
|
|
667
|
+
/** Effect-ledger defaults pinned at construction; per-route `ledgered` copies add route identity. */
|
|
668
|
+
effectLedger;
|
|
669
|
+
/** App-wide idempotency store (explicit option, else a lazily-created in-process default). */
|
|
670
|
+
configuredIdempotencyStore;
|
|
671
|
+
lazyIdempotencyStore;
|
|
663
672
|
logger;
|
|
664
673
|
/** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
|
|
665
674
|
defaultOnValidationError;
|
|
@@ -713,6 +722,27 @@ export class Server {
|
|
|
713
722
|
}
|
|
714
723
|
this.gracefulSignals = options.gracefulSignals ?? false;
|
|
715
724
|
this.capacityGate = options.admission;
|
|
725
|
+
this.onCapabilityUse = options.onCapabilityUse;
|
|
726
|
+
if (options.effectLedger !== undefined) {
|
|
727
|
+
const ledger = options.effectLedger;
|
|
728
|
+
if (typeof ledger.sink !== "function") {
|
|
729
|
+
throw new TypeError("effectLedger.sink must be a function");
|
|
730
|
+
}
|
|
731
|
+
const maxEntries = ledger.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
732
|
+
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
|
733
|
+
throw new RangeError("effectLedger.maxEntries must be a positive safe integer");
|
|
734
|
+
}
|
|
735
|
+
this.effectLedger = Object.freeze({
|
|
736
|
+
sink: ledger.sink,
|
|
737
|
+
maxEntries,
|
|
738
|
+
chain: ledger.chain ?? false,
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
else {
|
|
742
|
+
this.effectLedger = undefined;
|
|
743
|
+
}
|
|
744
|
+
this.configuredIdempotencyStore = options.idempotencyStore;
|
|
745
|
+
this.lazyIdempotencyStore = undefined;
|
|
716
746
|
this.logger = options.logger ?? jsonLogger();
|
|
717
747
|
this.defaultOnValidationError = options.onValidationError;
|
|
718
748
|
this.bunServer = undefined;
|
|
@@ -989,24 +1019,50 @@ export class Server {
|
|
|
989
1019
|
* is the "compiled", order-scoped per-route chain.
|
|
990
1020
|
*/
|
|
991
1021
|
register(method, path, schema, handler) {
|
|
1022
|
+
const capabilities = normalizeRouteCapabilities(schema?.capabilities);
|
|
1023
|
+
const handlerAssurance = assuranceDeclarationsOf(handler);
|
|
1024
|
+
const invalidHandlerScope = handlerAssurance.find((declaration) => declaration.scope !== "plugin");
|
|
1025
|
+
if (invalidHandlerScope !== undefined) {
|
|
1026
|
+
throw new RouteConfigError("INVALID_ASSURANCE", `route handler assurance must use plugin scope (received ${invalidHandlerScope.scope})`);
|
|
1027
|
+
}
|
|
1028
|
+
const authenticated = assuranceEvidenceFor([...this.activeAssurance, ...handlerAssurance, ...this.globalAssurance], method, path).some((evidence) => evidence.id === NIFRA_ASSURANCE_IDS.AUTHENTICATED);
|
|
1029
|
+
const routeDecorations = { ...this.decorations };
|
|
1030
|
+
if (capabilities.length > 0) {
|
|
1031
|
+
routeDecorations[CAPABILITY_GUARD] = createCapabilityGuard(capabilities, method, path, this.onCapabilityUse);
|
|
1032
|
+
}
|
|
1033
|
+
const hasDecorations = Reflect.ownKeys(routeDecorations).length > 0;
|
|
1034
|
+
// An idempotency route runs a dedupe lane that must buffer the body and capture the response, so it
|
|
1035
|
+
// never takes the fused/native fast path — force it onto the portable matched lane (which routes
|
|
1036
|
+
// through `fetchMatched`, where the dedupe wrapper lives).
|
|
1037
|
+
const idempotent = this.resolveIdempotency(schema, authenticated);
|
|
1038
|
+
// A ledgered route (capabilities declared + `server({ effectLedger })`) needs a per-request
|
|
1039
|
+
// context to carry the ledger and a settle step to seal + sink it, so it too leaves the
|
|
1040
|
+
// fused/contextless fast path. Resolved per route, at registration — like the capability guard.
|
|
1041
|
+
const ledgered = capabilities.length > 0 && this.effectLedger !== undefined
|
|
1042
|
+
? Object.freeze({ ...this.effectLedger, method, path, declared: capabilities })
|
|
1043
|
+
: undefined;
|
|
992
1044
|
const bare = schema?.body === undefined &&
|
|
993
1045
|
schema?.query === undefined &&
|
|
1046
|
+
idempotent === undefined &&
|
|
1047
|
+
ledgered === undefined &&
|
|
994
1048
|
this.derives.length === 0 &&
|
|
995
1049
|
this.beforeHandleHooks.length === 0 &&
|
|
996
1050
|
this.afterHandleHooks.length === 0 &&
|
|
997
1051
|
this.onErrorHooks.length === 0;
|
|
998
1052
|
const fusedWeb = bare && this.aroundHooks.length === 0
|
|
999
|
-
? this.buildFusedWeb(handler,
|
|
1053
|
+
? this.buildFusedWeb(handler, hasDecorations ? routeDecorations : undefined, isContextlessNoArgArrow(handler))
|
|
1000
1054
|
: undefined;
|
|
1001
1055
|
const entry = {
|
|
1002
1056
|
// (context: never) => unknown -> InternalHandler: the framework invokes it
|
|
1003
1057
|
// with the concrete RawContext the typed handler expects, so this is sound.
|
|
1004
1058
|
handler: handler,
|
|
1005
1059
|
schema,
|
|
1060
|
+
idempotent,
|
|
1061
|
+
ledgered,
|
|
1006
1062
|
fusedWeb,
|
|
1007
1063
|
derives: [...this.derives],
|
|
1008
|
-
decorations:
|
|
1009
|
-
hasDecorations
|
|
1064
|
+
decorations: routeDecorations,
|
|
1065
|
+
hasDecorations,
|
|
1010
1066
|
beforeHandle: [...this.beforeHandleHooks],
|
|
1011
1067
|
afterHandle: [...this.afterHandleHooks],
|
|
1012
1068
|
onError: [...this.onErrorHooks],
|
|
@@ -1031,9 +1087,14 @@ export class Server {
|
|
|
1031
1087
|
this.onErrorHooks.length === 0,
|
|
1032
1088
|
};
|
|
1033
1089
|
this.router.add(method, path, entry);
|
|
1034
|
-
const descriptor = {
|
|
1090
|
+
const descriptor = {
|
|
1091
|
+
method,
|
|
1092
|
+
path,
|
|
1093
|
+
schema,
|
|
1094
|
+
...(capabilities.length > 0 ? { capabilities } : {}),
|
|
1095
|
+
};
|
|
1035
1096
|
this.routeList.push(descriptor);
|
|
1036
|
-
const routeAssurance = [...this.activeAssurance];
|
|
1097
|
+
const routeAssurance = [...this.activeAssurance, ...handlerAssurance];
|
|
1037
1098
|
if (schema?.body !== undefined) {
|
|
1038
1099
|
routeAssurance.push(Object.freeze({
|
|
1039
1100
|
id: NIFRA_ASSURANCE_IDS.BODY_BOUNDED,
|
|
@@ -1041,6 +1102,15 @@ export class Server {
|
|
|
1041
1102
|
scope: "plugin",
|
|
1042
1103
|
}));
|
|
1043
1104
|
}
|
|
1105
|
+
// Declaring `schema.idempotency` is evidence for request replay only. It deliberately never proves
|
|
1106
|
+
// durable command execution; that stronger evidence belongs to a command/outbox adapter.
|
|
1107
|
+
if (schema?.idempotency !== undefined) {
|
|
1108
|
+
routeAssurance.push(Object.freeze({
|
|
1109
|
+
id: NIFRA_ASSURANCE_IDS.IDEMPOTENCY_KEY,
|
|
1110
|
+
source: "route-schema",
|
|
1111
|
+
scope: "plugin",
|
|
1112
|
+
}));
|
|
1113
|
+
}
|
|
1044
1114
|
this.rawEntries.push({
|
|
1045
1115
|
method,
|
|
1046
1116
|
path,
|
|
@@ -1049,6 +1119,58 @@ export class Server {
|
|
|
1049
1119
|
assurance: Object.freeze(routeAssurance),
|
|
1050
1120
|
});
|
|
1051
1121
|
}
|
|
1122
|
+
/** Resolve a route's declared idempotency into a pinned store + defaults, or `undefined` when off. */
|
|
1123
|
+
resolveIdempotency(schema, authenticated) {
|
|
1124
|
+
const config = schema?.idempotency;
|
|
1125
|
+
if (config === undefined)
|
|
1126
|
+
return undefined;
|
|
1127
|
+
if (schema?.sse !== undefined) {
|
|
1128
|
+
throw new RouteConfigError("INVALID_IDEMPOTENCY", "streaming/SSE responses cannot be used on an idempotency route");
|
|
1129
|
+
}
|
|
1130
|
+
const store = config.store ?? this.defaultIdempotencyStore();
|
|
1131
|
+
if (config.scope === "durable" && store.durability !== "durable") {
|
|
1132
|
+
throw new RouteConfigError("INVALID_IDEMPOTENCY", 'durable idempotency requires a durable store (store.durability must be "durable")');
|
|
1133
|
+
}
|
|
1134
|
+
const ttlMs = config.ttlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS;
|
|
1135
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
|
|
1136
|
+
throw new RouteConfigError("INVALID_IDEMPOTENCY", "idempotency ttlMs must be a finite positive number");
|
|
1137
|
+
}
|
|
1138
|
+
const headerName = (config.headerName ?? DEFAULT_IDEMPOTENCY_HEADER).toLowerCase();
|
|
1139
|
+
try {
|
|
1140
|
+
new Headers().set(headerName, "probe");
|
|
1141
|
+
}
|
|
1142
|
+
catch {
|
|
1143
|
+
throw new RouteConfigError("INVALID_IDEMPOTENCY", `invalid idempotency header name ${JSON.stringify(headerName)}`);
|
|
1144
|
+
}
|
|
1145
|
+
const namespace = config.namespace;
|
|
1146
|
+
if (namespace === undefined) {
|
|
1147
|
+
throw new RouteConfigError("INVALID_IDEMPOTENCY", "idempotency namespace is required; use a principal resolver or an explicit shared scope");
|
|
1148
|
+
}
|
|
1149
|
+
if (typeof namespace === "string" && !validIdempotencyNamespace(namespace)) {
|
|
1150
|
+
throw new RouteConfigError("INVALID_IDEMPOTENCY", `invalid idempotency namespace ${JSON.stringify(namespace)}`);
|
|
1151
|
+
}
|
|
1152
|
+
if (authenticated && typeof namespace !== "function") {
|
|
1153
|
+
throw new RouteConfigError("INVALID_IDEMPOTENCY", "authenticated idempotency routes require a principal namespace resolver");
|
|
1154
|
+
}
|
|
1155
|
+
const maxResponseBytes = config.maxResponseBytes ?? this.maxBodyBytes;
|
|
1156
|
+
if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes < 1) {
|
|
1157
|
+
throw new RouteConfigError("INVALID_IDEMPOTENCY", "idempotency maxResponseBytes must be a positive integer");
|
|
1158
|
+
}
|
|
1159
|
+
return Object.freeze({
|
|
1160
|
+
store,
|
|
1161
|
+
ttlMs,
|
|
1162
|
+
headerName,
|
|
1163
|
+
namespace,
|
|
1164
|
+
maxResponseBytes,
|
|
1165
|
+
});
|
|
1166
|
+
}
|
|
1167
|
+
/** The app-wide idempotency store: the configured one, else a lazily-created shared in-process store. */
|
|
1168
|
+
defaultIdempotencyStore() {
|
|
1169
|
+
if (this.configuredIdempotencyStore !== undefined)
|
|
1170
|
+
return this.configuredIdempotencyStore;
|
|
1171
|
+
this.lazyIdempotencyStore ??= new MemoryIdempotencyStore();
|
|
1172
|
+
return this.lazyIdempotencyStore;
|
|
1173
|
+
}
|
|
1052
1174
|
/**
|
|
1053
1175
|
* Merge another server's routes into this one — the composition escape hatch for large apps.
|
|
1054
1176
|
*
|
|
@@ -1179,15 +1301,11 @@ export class Server {
|
|
|
1179
1301
|
release();
|
|
1180
1302
|
throw error;
|
|
1181
1303
|
}
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
release();
|
|
1188
|
-
throw error;
|
|
1189
|
-
});
|
|
1190
|
-
}
|
|
1304
|
+
// Release the slot once the response settles — on resolve OR reject — via `finally`, which passes
|
|
1305
|
+
// the value/rejection through unchanged. (A single settle hook, rather than separate then-arms: the
|
|
1306
|
+
// request pipeline resolves handler errors to a Response, so a rejection arm would be unreachable.)
|
|
1307
|
+
if (outcome instanceof Promise)
|
|
1308
|
+
return outcome.finally(release);
|
|
1191
1309
|
release();
|
|
1192
1310
|
return outcome;
|
|
1193
1311
|
}
|
|
@@ -1329,7 +1447,7 @@ export class Server {
|
|
|
1329
1447
|
const originalRequest = requestOf(source);
|
|
1330
1448
|
let current = source;
|
|
1331
1449
|
for (let i = 0; i < hooks.length; i++) {
|
|
1332
|
-
const outcome = hooks[i](requestOf(current));
|
|
1450
|
+
const outcome = hooks[i](requestOf(current), platform);
|
|
1333
1451
|
if (outcome instanceof Promise) {
|
|
1334
1452
|
return outcome.then((early) => this.continueOnRequest(early, i + 1, originalRequest, current, platform, finalize, wrapResponse, onTimeout, webFast));
|
|
1335
1453
|
}
|
|
@@ -1361,7 +1479,7 @@ export class Server {
|
|
|
1361
1479
|
}
|
|
1362
1480
|
if (index >= this.onRequestHooks.length)
|
|
1363
1481
|
break;
|
|
1364
|
-
const outcome = this.onRequestHooks[index](requestOf(current));
|
|
1482
|
+
const outcome = this.onRequestHooks[index](requestOf(current), platform);
|
|
1365
1483
|
early = outcome instanceof Promise ? await outcome : outcome;
|
|
1366
1484
|
index++;
|
|
1367
1485
|
}
|
|
@@ -1399,6 +1517,118 @@ export class Server {
|
|
|
1399
1517
|
}
|
|
1400
1518
|
/** Run a route that has already been matched by the runtime or Nifra's portable router. */
|
|
1401
1519
|
runMatched(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
|
|
1520
|
+
// An idempotency route runs its dedupe lane first; on a fresh key it delegates to the normal lanes
|
|
1521
|
+
// (with the body buffered). All non-idempotent routes skip straight to the lanes — no added cost.
|
|
1522
|
+
if (entry.idempotent !== undefined) {
|
|
1523
|
+
return this.runIdempotent(source, platform, entry, params, search, wrapResponse);
|
|
1524
|
+
}
|
|
1525
|
+
return this.runMatchedLanes(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast);
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* Idempotency dedupe lane. Reads the `Idempotency-Key`, fingerprints the request, and consults the
|
|
1529
|
+
* store: a fresh key runs the handler (via {@link runMatchedLanes} with the body buffered) and stores
|
|
1530
|
+
* the response; a repeat key replays it without re-running; a key reused with a different body or an
|
|
1531
|
+
* in-flight duplicate is rejected. Every branch materializes a `Response`, then adapts to `T` via the
|
|
1532
|
+
* caller's `wrapResponse`, so it works on the Web fetch path and the Node-native outcome path alike.
|
|
1533
|
+
*/
|
|
1534
|
+
async runIdempotent(source, platform, entry, params, search, wrapResponse) {
|
|
1535
|
+
const config = entry.idempotent;
|
|
1536
|
+
const req = requestOf(source);
|
|
1537
|
+
const key = req.headers.get(config.headerName);
|
|
1538
|
+
if (key === null || !validIdempotencyKey(key)) {
|
|
1539
|
+
return wrapResponse(jsonError(400, "idempotency_key_required"));
|
|
1540
|
+
}
|
|
1541
|
+
const namespace = typeof config.namespace === "function"
|
|
1542
|
+
? await config.namespace(req.clone(), platform)
|
|
1543
|
+
: config.namespace;
|
|
1544
|
+
if (!validIdempotencyNamespace(namespace)) {
|
|
1545
|
+
return wrapResponse(jsonError(500, "idempotency_namespace_invalid"));
|
|
1546
|
+
}
|
|
1547
|
+
const read = await readBoundedBytes(req, this.maxBodyBytes);
|
|
1548
|
+
if (!read.ok) {
|
|
1549
|
+
return wrapResponse(jsonError(read.status, read.status === 413 ? "payload_too_large" : "bad_request"));
|
|
1550
|
+
}
|
|
1551
|
+
const url = urlPartsOf(req.url);
|
|
1552
|
+
const canonicalBody = canonicalizeIdempotencyBody(read.bytes, req.headers.get("content-type"));
|
|
1553
|
+
const fingerprint = await computeIdempotencyFingerprint(req.method, `${url.pathname}${url.search}`, canonicalBody, req.headers.get("content-type") ?? "");
|
|
1554
|
+
const begin = await config.store.begin({ namespace, key, fingerprint, ttlMs: config.ttlMs });
|
|
1555
|
+
if (begin.state === "replay") {
|
|
1556
|
+
return wrapResponse(responseFromStored(begin.response, { maxBytes: config.maxResponseBytes }));
|
|
1557
|
+
}
|
|
1558
|
+
if (begin.state === "mismatch")
|
|
1559
|
+
return wrapResponse(jsonError(409, "idempotency_key_reused"));
|
|
1560
|
+
if (begin.state === "in-flight") {
|
|
1561
|
+
return wrapResponse(jsonError(409, "idempotency_in_progress", { "Retry-After": "1" }));
|
|
1562
|
+
}
|
|
1563
|
+
if (begin.state === "capacity") {
|
|
1564
|
+
return wrapResponse(jsonError(503, "idempotency_store_capacity", { "Retry-After": "1" }));
|
|
1565
|
+
}
|
|
1566
|
+
const reservation = begin.reservation;
|
|
1567
|
+
// Fresh key: re-expose the buffered body as a Request so the normal lanes can read + validate it,
|
|
1568
|
+
// then run those lanes to a concrete Response.
|
|
1569
|
+
const bufferedInit = {
|
|
1570
|
+
method: req.method,
|
|
1571
|
+
headers: req.headers,
|
|
1572
|
+
signal: req.signal,
|
|
1573
|
+
};
|
|
1574
|
+
if (read.bytes.byteLength > 0)
|
|
1575
|
+
bufferedInit.body = read.bytes;
|
|
1576
|
+
const buffered = new Request(req.url, bufferedInit);
|
|
1577
|
+
let response;
|
|
1578
|
+
try {
|
|
1579
|
+
response = await this.runMatchedLanes(buffered, platform, entry, params, search, toResponse, IDENTITY_RESPONSE, RESPONSE_TIMEOUT, false);
|
|
1580
|
+
}
|
|
1581
|
+
catch (err) {
|
|
1582
|
+
// The lanes never throw (errors resolve to a 500 Response), but stay fail-safe: release the
|
|
1583
|
+
// reservation so a retry isn't wedged, then re-throw.
|
|
1584
|
+
await config.store.abandon({ namespace, key, reservation });
|
|
1585
|
+
throw err;
|
|
1586
|
+
}
|
|
1587
|
+
// Cache only successful responses; release the key on any error so the client can retry.
|
|
1588
|
+
if (response.status >= 200 && response.status < 300) {
|
|
1589
|
+
let storedResponse;
|
|
1590
|
+
try {
|
|
1591
|
+
storedResponse = await serializeResponse(response, { maxBytes: config.maxResponseBytes });
|
|
1592
|
+
}
|
|
1593
|
+
catch (error) {
|
|
1594
|
+
if (!(error instanceof IdempotencyResponseTooLargeError))
|
|
1595
|
+
throw error;
|
|
1596
|
+
// The effect may already have happened, so never abandon and permit a duplicate execution.
|
|
1597
|
+
// Commit a small terminal response under the winning key and return that same response now.
|
|
1598
|
+
response = jsonError(507, "idempotency_response_too_large");
|
|
1599
|
+
try {
|
|
1600
|
+
storedResponse = await serializeResponse(response, {
|
|
1601
|
+
maxBytes: config.maxResponseBytes,
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
catch (terminalError) {
|
|
1605
|
+
if (!(terminalError instanceof IdempotencyResponseTooLargeError))
|
|
1606
|
+
throw terminalError;
|
|
1607
|
+
// Even an intentionally tiny bound must remain truthful. An empty 507 preserves terminal
|
|
1608
|
+
// status + replay safety without silently storing more bytes than the route permits.
|
|
1609
|
+
response = new Response(null, { status: 507 });
|
|
1610
|
+
storedResponse = await serializeResponse(response, {
|
|
1611
|
+
maxBytes: config.maxResponseBytes,
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
const completed = await config.store.complete({
|
|
1616
|
+
namespace,
|
|
1617
|
+
key,
|
|
1618
|
+
reservation,
|
|
1619
|
+
response: storedResponse,
|
|
1620
|
+
});
|
|
1621
|
+
if (!completed) {
|
|
1622
|
+
return wrapResponse(jsonError(503, "idempotency_reservation_lost", { "Retry-After": "1" }));
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
else {
|
|
1626
|
+
await config.store.abandon({ namespace, key, reservation });
|
|
1627
|
+
}
|
|
1628
|
+
return wrapResponse(response);
|
|
1629
|
+
}
|
|
1630
|
+
/** Run a matched route through its selected execution lane (fused/bare/bodyOnly/queryOnly/lifecycle). */
|
|
1631
|
+
runMatchedLanes(source, platform, entry, params, search, finalize, wrapResponse, onTimeout, webFast) {
|
|
1402
1632
|
// Translate the absolute wire deadline once, clamp it to local policy, then use the resulting
|
|
1403
1633
|
// duration for both c.signal and c.budget. A client can only shorten work, never extend it.
|
|
1404
1634
|
// Most requests have neither a local timeout nor a propagated deadline. Detect that case with
|
|
@@ -1446,6 +1676,20 @@ export class Server {
|
|
|
1446
1676
|
// Build the context up front so `onError` always has it. Query, cookies, headers, and body helpers
|
|
1447
1677
|
// stay lazy, but their accessors live on the prototype instead of allocating per-request closures.
|
|
1448
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
|
+
}
|
|
1449
1693
|
if (entry.around.length === 0) {
|
|
1450
1694
|
outcome = entry.bare
|
|
1451
1695
|
? this.runBare(entry, ctx, finalize, wrapResponse)
|
|
@@ -1464,6 +1708,14 @@ export class Server {
|
|
|
1464
1708
|
? this.runQueryOnly(entry, ctx, finalize, wrapResponse)
|
|
1465
1709
|
: this.runLifecycle(entry, source, ctx, finalize, wrapResponse), finalize, wrapResponse);
|
|
1466
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
|
+
}
|
|
1467
1719
|
}
|
|
1468
1720
|
// The request timeout only bounds work that is actually pending — a synchronous (bare) result is
|
|
1469
1721
|
// already complete and can't time out, so it's returned as-is (no 503 race, no promise).
|
|
@@ -1475,6 +1727,31 @@ export class Server {
|
|
|
1475
1727
|
}
|
|
1476
1728
|
return outcome;
|
|
1477
1729
|
}
|
|
1730
|
+
/**
|
|
1731
|
+
* Seal a ledgered request and deliver the sealed ledger to the sink. An empty ledger (declared
|
|
1732
|
+
* capabilities, none exercised) is not delivered — there is nothing to audit. This post-effect
|
|
1733
|
+
* ledger is observational: transactional/durable audit belongs in
|
|
1734
|
+
* the owned effect transaction, so a sink outage must not turn a successful effect into a retryable
|
|
1735
|
+
* 500 that can duplicate it.
|
|
1736
|
+
*/
|
|
1737
|
+
async settleEffectLedger(ledger, resolved, value) {
|
|
1738
|
+
const sealed = await ledger.seal();
|
|
1739
|
+
if (sealed.entries.length === 0)
|
|
1740
|
+
return value;
|
|
1741
|
+
try {
|
|
1742
|
+
await resolved.sink(sealed);
|
|
1743
|
+
}
|
|
1744
|
+
catch (err) {
|
|
1745
|
+
// Token-only logging: the route pattern and the failure name — never entry payloads (there are
|
|
1746
|
+
// none) and never the request.
|
|
1747
|
+
this.logger.error("effect ledger sink failed", {
|
|
1748
|
+
method: resolved.method,
|
|
1749
|
+
path: resolved.path,
|
|
1750
|
+
name: err instanceof Error ? err.name : "Error",
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
return value;
|
|
1754
|
+
}
|
|
1478
1755
|
/** The narrowest bare route: a syntactic `() => ...` handler cannot observe the context argument, so
|
|
1479
1756
|
* successful requests can skip allocating `RequestContext`. Errors still allocate one for logging. */
|
|
1480
1757
|
runContextlessBare(entry, source, params, search, signal, budget, platform, finalize, wrapResponse) {
|