@apifuse/provider-sdk 2.2.0-beta.10 → 2.2.0-beta.11
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/CHANGELOG.md +4 -0
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/serve.d.ts +98 -2
- package/dist/server/serve.js +485 -23
- package/dist/stateful/errors.d.ts +14 -0
- package/dist/stateful/errors.js +14 -0
- package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
- package/dist/stateful/http-provider-event-emitter.js +237 -0
- package/dist/stateful/http-session-owner-registry.d.ts +44 -0
- package/dist/stateful/http-session-owner-registry.js +210 -0
- package/dist/stateful/index.d.ts +18 -0
- package/dist/stateful/index.js +18 -0
- package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
- package/dist/stateful/provider-event-delivery-failures.js +43 -0
- package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
- package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
- package/dist/stateful/provider-event-pipeline.d.ts +50 -0
- package/dist/stateful/provider-event-pipeline.js +1 -0
- package/dist/stateful/provider-events.d.ts +101 -0
- package/dist/stateful/provider-events.js +289 -0
- package/dist/stateful/session-key.d.ts +15 -0
- package/dist/stateful/session-key.js +86 -0
- package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
- package/dist/stateful/stateful-provider-adapter-context.js +42 -0
- package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
- package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
- package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
- package/dist/stateful/stateful-provider-adapter.js +287 -0
- package/dist/stateful/stateful-provider-observability.d.ts +62 -0
- package/dist/stateful/stateful-provider-observability.js +161 -0
- package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
- package/dist/stateful/stateful-provider-owner-forwarder.js +207 -0
- package/dist/stateful/stateful-provider-runtime-context.d.ts +32 -0
- package/dist/stateful/stateful-provider-runtime-context.js +60 -0
- package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
- package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
- package/dist/stateful/stateful-provider-session-routing.d.ts +71 -0
- package/dist/stateful/stateful-provider-session-routing.js +353 -0
- package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
- package/dist/stateful/stateful-provider-session-runtime.js +245 -0
- package/dist/stateful-signing.d.ts +18 -0
- package/dist/stateful-signing.js +27 -0
- package/package.json +6 -1
- package/src/server/index.ts +13 -1
- package/src/server/serve.ts +691 -25
- package/src/stateful/README.md +146 -0
- package/src/stateful/errors.ts +23 -0
- package/src/stateful/http-provider-event-emitter.ts +314 -0
- package/src/stateful/http-session-owner-registry.ts +306 -0
- package/src/stateful/index.ts +18 -0
- package/src/stateful/provider-event-delivery-failures.ts +80 -0
- package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
- package/src/stateful/provider-event-pipeline.ts +61 -0
- package/src/stateful/provider-events.ts +462 -0
- package/src/stateful/session-key.ts +111 -0
- package/src/stateful/stateful-provider-adapter-context.ts +59 -0
- package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
- package/src/stateful/stateful-provider-adapter.ts +562 -0
- package/src/stateful/stateful-provider-observability.ts +261 -0
- package/src/stateful/stateful-provider-owner-forwarder.ts +279 -0
- package/src/stateful/stateful-provider-runtime-context.ts +92 -0
- package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
- package/src/stateful/stateful-provider-session-routing.ts +555 -0
- package/src/stateful/stateful-provider-session-runtime.ts +403 -0
- package/src/stateful-signing.ts +46 -0
package/dist/server/serve.js
CHANGED
|
@@ -24,15 +24,45 @@ import { createStealthClient } from "../runtime/stealth.js";
|
|
|
24
24
|
import { createSttClientFromEnv } from "../runtime/stt.js";
|
|
25
25
|
import { createTraceContext } from "../runtime/trace.js";
|
|
26
26
|
import { parseSchema } from "../schema.js";
|
|
27
|
+
import { STATEFUL_NONCE_HEADER as STATEFUL_FORWARDING_NONCE_HEADER, STATEFUL_SIGNATURE_HEADER as STATEFUL_FORWARDING_SIGNATURE_HEADER, STATEFUL_TIMESTAMP_HEADER as STATEFUL_FORWARDING_TIMESTAMP_HEADER, verifyStatefulRequestSignature, } from "../stateful-signing.js";
|
|
28
|
+
import { StatefulRoutingDeadlineError } from "../stateful/stateful-provider-session-routing.js";
|
|
27
29
|
import { getStealthProfile } from "../stealth/profiles.js";
|
|
28
30
|
import { APIFUSE_STREAM_DONE_EVENT, APIFUSE_STREAM_ERROR_EVENT, encodeSseEvent, error as streamError, } from "../stream.js";
|
|
29
31
|
import { createSelfTestApp, createSelfTestAuthFlowInvoke, createSelfTestInvoke, resolveSelfTestPort, } from "./self-test.js";
|
|
30
32
|
import { resolveSelfTestMasterSecrets } from "./self-test-token.js";
|
|
31
|
-
import { AuthFlowRequestSchema, OperationRequestSchema, } from "./types.js";
|
|
33
|
+
import { AuthFlowRequestSchema, OperationConnectionSchema, OperationRequestSchema, } from "./types.js";
|
|
32
34
|
const DEFAULT_HOST = "0.0.0.0";
|
|
33
35
|
const DEFAULT_PORT = 3000;
|
|
34
36
|
const AUTH_FLOW_LOCALES = ["en", "ko", "ja"];
|
|
35
37
|
const retryResponseMeta = new WeakMap();
|
|
38
|
+
const STATEFUL_INTERNAL_OPERATIONS_ROUTE = "/__apifuse/stateful/operations";
|
|
39
|
+
const STATEFUL_FORWARDING_SOURCE_POD_HEADER = "x-apifuse-stateful-source-pod";
|
|
40
|
+
const DEFAULT_STATEFUL_FORWARDING_MAX_SKEW_MS = 5 * 60_000;
|
|
41
|
+
const DEFAULT_STATEFUL_FORWARDING_REPLAY_CACHE_MAX_ENTRIES = 10_000;
|
|
42
|
+
const STATEFUL_FORWARDING_REPLAY_BUCKET_MS = 10_000;
|
|
43
|
+
const STATEFUL_FORWARDING_REPLAY_RETRY_AFTER_SECONDS = Math.ceil(STATEFUL_FORWARDING_REPLAY_BUCKET_MS / 1_000);
|
|
44
|
+
export const ProviderServerStatefulForwardEnvelopeSchema = z
|
|
45
|
+
.object({
|
|
46
|
+
requestId: z.string().min(1),
|
|
47
|
+
providerId: z.string().min(1),
|
|
48
|
+
operationId: z.string().min(1),
|
|
49
|
+
sessionKey: z.string().min(1),
|
|
50
|
+
connectionId: z.string().min(1),
|
|
51
|
+
serviceAccountId: z.string().min(1),
|
|
52
|
+
ownerPodId: z.string().min(1),
|
|
53
|
+
generation: z.number().int().positive(),
|
|
54
|
+
sourcePodId: z.string().min(1),
|
|
55
|
+
forwardedAt: z.string().refine((value) => Number.isFinite(Date.parse(value))),
|
|
56
|
+
deadlineAt: z
|
|
57
|
+
.string()
|
|
58
|
+
.refine((value) => Number.isFinite(Date.parse(value)))
|
|
59
|
+
.optional(),
|
|
60
|
+
idempotencyKey: z.string().min(1).optional(),
|
|
61
|
+
operationRequest: OperationRequestSchema.extend({
|
|
62
|
+
connection: OperationConnectionSchema.strict().optional(),
|
|
63
|
+
}).strict(),
|
|
64
|
+
})
|
|
65
|
+
.strict();
|
|
36
66
|
function createAuthStub() {
|
|
37
67
|
return {
|
|
38
68
|
async requestField(name) {
|
|
@@ -290,6 +320,16 @@ function zodDetails(error) {
|
|
|
290
320
|
}));
|
|
291
321
|
}
|
|
292
322
|
function toErrorResponse(error, requestId) {
|
|
323
|
+
if (error instanceof StatefulRoutingDeadlineError) {
|
|
324
|
+
return {
|
|
325
|
+
error: {
|
|
326
|
+
code: "STATEFUL_FORWARDING_DEADLINE_EXPIRED",
|
|
327
|
+
message: "Stateful forwarding deadline expired.",
|
|
328
|
+
...(requestId ? { requestId } : {}),
|
|
329
|
+
details: { retryable: false },
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
|
293
333
|
if (isProviderError(error)) {
|
|
294
334
|
const details = publicProviderErrorDetails(error);
|
|
295
335
|
return {
|
|
@@ -438,6 +478,9 @@ function toStatusCode(error) {
|
|
|
438
478
|
if (error instanceof z.ZodError) {
|
|
439
479
|
return 400;
|
|
440
480
|
}
|
|
481
|
+
if (error instanceof StatefulRoutingDeadlineError) {
|
|
482
|
+
return 504;
|
|
483
|
+
}
|
|
441
484
|
if (isTransportError(error)) {
|
|
442
485
|
return error.code === "transport_timeout" ? 504 : 502;
|
|
443
486
|
}
|
|
@@ -463,6 +506,7 @@ function toStatusCode(error) {
|
|
|
463
506
|
return 502;
|
|
464
507
|
case "STT_UNAVAILABLE":
|
|
465
508
|
case "UNSUPPORTED_STT_BACKEND":
|
|
509
|
+
case "STATEFUL_FORWARDING_REPLAY_CACHE_FULL":
|
|
466
510
|
return 503;
|
|
467
511
|
}
|
|
468
512
|
return 400;
|
|
@@ -481,7 +525,9 @@ function logProviderError(logger, provider, kind, route, requestId, error, statu
|
|
|
481
525
|
? (error.code ?? "provider_error")
|
|
482
526
|
: error instanceof z.ZodError
|
|
483
527
|
? "invalid_request"
|
|
484
|
-
:
|
|
528
|
+
: error instanceof StatefulRoutingDeadlineError
|
|
529
|
+
? "STATEFUL_FORWARDING_DEADLINE_EXPIRED"
|
|
530
|
+
: "internal_error";
|
|
485
531
|
const errorClass = error instanceof Error ? error.name : typeof error;
|
|
486
532
|
const message = error instanceof Error ? error.message : String(error);
|
|
487
533
|
const details = isProviderError(error) ? providerObservabilityDetails(error) : undefined;
|
|
@@ -879,7 +925,14 @@ async function handleOperation(provider, request, operationId, options = {}, sta
|
|
|
879
925
|
}
|
|
880
926
|
};
|
|
881
927
|
try {
|
|
882
|
-
const result =
|
|
928
|
+
const result = options.operationExecutor
|
|
929
|
+
? await options.operationExecutor({
|
|
930
|
+
provider,
|
|
931
|
+
operationId,
|
|
932
|
+
ctx,
|
|
933
|
+
request,
|
|
934
|
+
})
|
|
935
|
+
: await executeOperation(provider, operationId, ctx, request.input);
|
|
883
936
|
if (streaming && operation) {
|
|
884
937
|
return toStreamingResponse(operation, result, cleanup, request.requestId);
|
|
885
938
|
}
|
|
@@ -961,9 +1014,123 @@ async function handleAuthFlow(provider, request, route, options = {}, signal) {
|
|
|
961
1014
|
context.stealth.close?.();
|
|
962
1015
|
}
|
|
963
1016
|
}
|
|
1017
|
+
class StatefulForwardingReplayCache {
|
|
1018
|
+
maxEntries;
|
|
1019
|
+
#nonces = new Map();
|
|
1020
|
+
#expiryBuckets = new Map();
|
|
1021
|
+
#nextExpiryBucket;
|
|
1022
|
+
#latestExpiryBucket;
|
|
1023
|
+
constructor(maxEntries) {
|
|
1024
|
+
this.maxEntries = maxEntries;
|
|
1025
|
+
}
|
|
1026
|
+
claim(nonce, expiresAtMs, nowMs) {
|
|
1027
|
+
this.dropExpiredBuckets(nowMs);
|
|
1028
|
+
if (this.#nonces.has(nonce))
|
|
1029
|
+
return "replayed";
|
|
1030
|
+
if (this.#nonces.size >= this.maxEntries)
|
|
1031
|
+
return "full";
|
|
1032
|
+
const expiryBucket = Math.ceil(expiresAtMs / STATEFUL_FORWARDING_REPLAY_BUCKET_MS) *
|
|
1033
|
+
STATEFUL_FORWARDING_REPLAY_BUCKET_MS;
|
|
1034
|
+
this.#nonces.set(nonce, expiryBucket);
|
|
1035
|
+
const bucket = this.#expiryBuckets.get(expiryBucket) ?? new Set();
|
|
1036
|
+
bucket.add(nonce);
|
|
1037
|
+
this.#expiryBuckets.set(expiryBucket, bucket);
|
|
1038
|
+
this.#nextExpiryBucket = Math.min(this.#nextExpiryBucket ?? expiryBucket, expiryBucket);
|
|
1039
|
+
this.#latestExpiryBucket = Math.max(this.#latestExpiryBucket ?? expiryBucket, expiryBucket);
|
|
1040
|
+
return "accepted";
|
|
1041
|
+
}
|
|
1042
|
+
dropExpiredBuckets(nowMs) {
|
|
1043
|
+
if (this.#nextExpiryBucket === undefined || this.#latestExpiryBucket === undefined)
|
|
1044
|
+
return;
|
|
1045
|
+
if (nowMs >= this.#latestExpiryBucket) {
|
|
1046
|
+
this.#nonces.clear();
|
|
1047
|
+
this.#expiryBuckets.clear();
|
|
1048
|
+
this.#nextExpiryBucket = undefined;
|
|
1049
|
+
this.#latestExpiryBucket = undefined;
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
while (this.#nextExpiryBucket <= nowMs) {
|
|
1053
|
+
const bucket = this.#expiryBuckets.get(this.#nextExpiryBucket);
|
|
1054
|
+
if (bucket) {
|
|
1055
|
+
for (const cachedNonce of bucket)
|
|
1056
|
+
this.#nonces.delete(cachedNonce);
|
|
1057
|
+
this.#expiryBuckets.delete(this.#nextExpiryBucket);
|
|
1058
|
+
}
|
|
1059
|
+
this.#nextExpiryBucket += STATEFUL_FORWARDING_REPLAY_BUCKET_MS;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
function verifyStatefulForwardingRequest(input) {
|
|
1064
|
+
const config = input.options.statefulForwarding;
|
|
1065
|
+
if (!config?.secret) {
|
|
1066
|
+
throw new ProviderError("Stateful forwarding is not configured.", {
|
|
1067
|
+
code: "STATEFUL_FORWARDING_NOT_CONFIGURED",
|
|
1068
|
+
});
|
|
1069
|
+
}
|
|
1070
|
+
const timestamp = input.headers.get(STATEFUL_FORWARDING_TIMESTAMP_HEADER) ?? "";
|
|
1071
|
+
const signature = input.headers.get(STATEFUL_FORWARDING_SIGNATURE_HEADER) ?? "";
|
|
1072
|
+
const nonce = input.headers.get(STATEFUL_FORWARDING_NONCE_HEADER) ?? "";
|
|
1073
|
+
if (!timestamp || !signature || !nonce) {
|
|
1074
|
+
throw new ProviderError("Stateful forwarding signature headers are missing.", {
|
|
1075
|
+
code: "STATEFUL_FORWARDING_SIGNATURE_MISSING",
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
if (nonce.length > 256) {
|
|
1079
|
+
throw new ProviderError("Stateful forwarding nonce is invalid.", {
|
|
1080
|
+
code: "STATEFUL_FORWARDING_NONCE_INVALID",
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
const timestampMs = Date.parse(timestamp);
|
|
1084
|
+
const maxSkewMs = config.maxSkewMs ?? DEFAULT_STATEFUL_FORWARDING_MAX_SKEW_MS;
|
|
1085
|
+
if (!Number.isFinite(timestampMs) || Math.abs(Date.now() - timestampMs) > maxSkewMs) {
|
|
1086
|
+
throw new ProviderError("Stateful forwarding signature timestamp is outside the allowed skew.", { code: "STATEFUL_FORWARDING_TIMESTAMP_INVALID" });
|
|
1087
|
+
}
|
|
1088
|
+
if (!verifyStatefulRequestSignature({
|
|
1089
|
+
secret: config.secret,
|
|
1090
|
+
timestamp,
|
|
1091
|
+
rawBody: input.rawBody,
|
|
1092
|
+
method: input.method,
|
|
1093
|
+
path: input.path,
|
|
1094
|
+
nonce,
|
|
1095
|
+
signature,
|
|
1096
|
+
})) {
|
|
1097
|
+
throw new ProviderError("Stateful forwarding signature is invalid.", {
|
|
1098
|
+
code: "STATEFUL_FORWARDING_SIGNATURE_INVALID",
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
const replayResult = input.replayCache.claim(nonce, timestampMs + maxSkewMs, Date.now());
|
|
1102
|
+
if (replayResult === "replayed") {
|
|
1103
|
+
throw new ProviderError("Stateful forwarding nonce has already been used.", {
|
|
1104
|
+
code: "STATEFUL_FORWARDING_REPLAY_DETECTED",
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
if (replayResult === "full") {
|
|
1108
|
+
throw new ProviderError("Stateful forwarding replay cache is at capacity.", {
|
|
1109
|
+
code: "STATEFUL_FORWARDING_REPLAY_CACHE_FULL",
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
function operationRequestFromForwardingEnvelope(envelope) {
|
|
1114
|
+
return {
|
|
1115
|
+
...envelope.operationRequest,
|
|
1116
|
+
...(envelope.deadlineAt !== undefined ? { deadlineAt: envelope.deadlineAt } : {}),
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
function parseStatefulForwardingEnvelope(rawBody) {
|
|
1120
|
+
const parsed = ProviderServerStatefulForwardEnvelopeSchema.safeParse(rawBody);
|
|
1121
|
+
if (parsed.success)
|
|
1122
|
+
return parsed.data;
|
|
1123
|
+
throw new ProviderError("Stateful forwarding envelope is invalid.", {
|
|
1124
|
+
code: "STATEFUL_FORWARDING_ENVELOPE_INVALID",
|
|
1125
|
+
details: zodDetails(parsed.error),
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
964
1128
|
export function createServerApp(provider, options = {}) {
|
|
1129
|
+
validateStatefulServerConfig(options);
|
|
965
1130
|
const app = new Hono();
|
|
966
1131
|
const logger = options.logger ?? defaultProviderServerLogger;
|
|
1132
|
+
const statefulForwardingReplayCache = new StatefulForwardingReplayCache(options.statefulForwarding?.replayCacheMaxEntries ??
|
|
1133
|
+
DEFAULT_STATEFUL_FORWARDING_REPLAY_CACHE_MAX_ENTRIES);
|
|
967
1134
|
const state = options.state ??
|
|
968
1135
|
createProviderRuntimeStateFromEnv({
|
|
969
1136
|
providerId: provider.id,
|
|
@@ -994,6 +1161,120 @@ export function createServerApp(provider, options = {}) {
|
|
|
994
1161
|
provider: provider.id,
|
|
995
1162
|
version: provider.version,
|
|
996
1163
|
}));
|
|
1164
|
+
app.post(STATEFUL_INTERNAL_OPERATIONS_ROUTE, async (c) => {
|
|
1165
|
+
let rawBodyText = "";
|
|
1166
|
+
let rawBody;
|
|
1167
|
+
const operation = "stateful-internal";
|
|
1168
|
+
const requestCost = startRequestCost();
|
|
1169
|
+
try {
|
|
1170
|
+
if (!options.internalOperationExecutor) {
|
|
1171
|
+
throw new ProviderError("Stateful internal operation executor is not configured.", {
|
|
1172
|
+
code: "STATEFUL_INTERNAL_EXECUTOR_NOT_CONFIGURED",
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
rawBodyText = await c.req.raw.clone().text();
|
|
1176
|
+
verifyStatefulForwardingRequest({
|
|
1177
|
+
options,
|
|
1178
|
+
rawBody: rawBodyText,
|
|
1179
|
+
headers: c.req.raw.headers,
|
|
1180
|
+
method: c.req.raw.method,
|
|
1181
|
+
path: STATEFUL_INTERNAL_OPERATIONS_ROUTE,
|
|
1182
|
+
replayCache: statefulForwardingReplayCache,
|
|
1183
|
+
});
|
|
1184
|
+
try {
|
|
1185
|
+
rawBody = JSON.parse(rawBodyText);
|
|
1186
|
+
}
|
|
1187
|
+
catch {
|
|
1188
|
+
throw new ProviderError("Stateful forwarding envelope is not valid JSON.", {
|
|
1189
|
+
code: "STATEFUL_FORWARDING_ENVELOPE_INVALID",
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
const envelope = parseStatefulForwardingEnvelope(rawBody);
|
|
1193
|
+
if (envelope.providerId !== provider.id) {
|
|
1194
|
+
throw new ProviderError("Stateful forwarding envelope providerId does not match the served provider.", { code: "STATEFUL_FORWARDING_PROVIDER_MISMATCH" });
|
|
1195
|
+
}
|
|
1196
|
+
if (envelope.requestId !== envelope.operationRequest.requestId) {
|
|
1197
|
+
throw new ProviderError("Stateful forwarding requestId values do not match.", {
|
|
1198
|
+
code: "STATEFUL_FORWARDING_ENVELOPE_INVALID",
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
if (envelope.sourcePodId !==
|
|
1202
|
+
(c.req.raw.headers.get(STATEFUL_FORWARDING_SOURCE_POD_HEADER) ?? "")) {
|
|
1203
|
+
throw new ProviderError("Stateful forwarding source pod does not match its header.", {
|
|
1204
|
+
code: "STATEFUL_FORWARDING_SOURCE_POD_MISMATCH",
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
if (envelope.forwardedAt !== (c.req.raw.headers.get(STATEFUL_FORWARDING_TIMESTAMP_HEADER) ?? "")) {
|
|
1208
|
+
throw new ProviderError("Stateful forwarding forwardedAt does not match its signature timestamp.", { code: "STATEFUL_FORWARDING_ENVELOPE_INVALID" });
|
|
1209
|
+
}
|
|
1210
|
+
const deadlineAtMs = envelope.deadlineAt ? Date.parse(envelope.deadlineAt) : undefined;
|
|
1211
|
+
if (deadlineAtMs !== undefined && deadlineAtMs <= Date.now()) {
|
|
1212
|
+
throw new StatefulRoutingDeadlineError(envelope.requestId, envelope.deadlineAt);
|
|
1213
|
+
}
|
|
1214
|
+
const remainingDeadlineMs = deadlineAtMs === undefined ? undefined : deadlineAtMs - Date.now();
|
|
1215
|
+
const deadlineSignal = remainingDeadlineMs === undefined ? undefined : AbortSignal.timeout(remainingDeadlineMs);
|
|
1216
|
+
const signal = deadlineSignal
|
|
1217
|
+
? AbortSignal.any([c.req.raw.signal, deadlineSignal])
|
|
1218
|
+
: c.req.raw.signal;
|
|
1219
|
+
const ownerFenceValidation = Promise.resolve(options.statefulForwarding?.validateOwnerFence({
|
|
1220
|
+
providerId: envelope.providerId,
|
|
1221
|
+
sessionKey: envelope.sessionKey,
|
|
1222
|
+
ownerPodId: envelope.ownerPodId,
|
|
1223
|
+
generation: envelope.generation,
|
|
1224
|
+
sourcePodId: envelope.sourcePodId,
|
|
1225
|
+
forwardedAt: envelope.forwardedAt,
|
|
1226
|
+
requestId: envelope.requestId,
|
|
1227
|
+
...(envelope.idempotencyKey ? { idempotencyKey: envelope.idempotencyKey } : {}),
|
|
1228
|
+
}, signal));
|
|
1229
|
+
let ownerFenceValid;
|
|
1230
|
+
try {
|
|
1231
|
+
ownerFenceValid = deadlineSignal
|
|
1232
|
+
? await Promise.race([
|
|
1233
|
+
ownerFenceValidation,
|
|
1234
|
+
new Promise((_resolve, reject) => {
|
|
1235
|
+
deadlineSignal.addEventListener("abort", () => reject(new StatefulRoutingDeadlineError(envelope.requestId, envelope.deadlineAt)), { once: true });
|
|
1236
|
+
}),
|
|
1237
|
+
])
|
|
1238
|
+
: await ownerFenceValidation;
|
|
1239
|
+
}
|
|
1240
|
+
catch (error) {
|
|
1241
|
+
if (deadlineSignal?.aborted) {
|
|
1242
|
+
throw new StatefulRoutingDeadlineError(envelope.requestId, envelope.deadlineAt);
|
|
1243
|
+
}
|
|
1244
|
+
throw error;
|
|
1245
|
+
}
|
|
1246
|
+
if (ownerFenceValid !== true) {
|
|
1247
|
+
throw new ProviderError("Stateful forwarding owner fence is no longer current.", {
|
|
1248
|
+
code: "STATEFUL_FORWARDING_OWNER_FENCE_INVALID",
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
const request = operationRequestFromForwardingEnvelope(envelope);
|
|
1252
|
+
const operationId = envelope.operationId;
|
|
1253
|
+
const ctx = createProviderContext(provider, request, operationId, options, state);
|
|
1254
|
+
if (deadlineAtMs !== undefined && deadlineAtMs <= Date.now()) {
|
|
1255
|
+
throw new StatefulRoutingDeadlineError(envelope.requestId, envelope.deadlineAt);
|
|
1256
|
+
}
|
|
1257
|
+
const output = await options.internalOperationExecutor({
|
|
1258
|
+
provider,
|
|
1259
|
+
operationId,
|
|
1260
|
+
ctx,
|
|
1261
|
+
request,
|
|
1262
|
+
internalStatefulForward: envelope,
|
|
1263
|
+
signal,
|
|
1264
|
+
});
|
|
1265
|
+
logProviderSuccess(logger, provider, "operation", operationId || operation, request.requestId, 200, finishRequestCost(requestCost));
|
|
1266
|
+
return c.json({ data: output });
|
|
1267
|
+
}
|
|
1268
|
+
catch (error) {
|
|
1269
|
+
const status = toStatusCode(error);
|
|
1270
|
+
if (isProviderError(error) && error.code === "STATEFUL_FORWARDING_REPLAY_CACHE_FULL") {
|
|
1271
|
+
c.header("Retry-After", String(STATEFUL_FORWARDING_REPLAY_RETRY_AFTER_SECONDS));
|
|
1272
|
+
}
|
|
1273
|
+
const requestId = extractRequestId(rawBody);
|
|
1274
|
+
logProviderError(logger, provider, "operation", operation, requestId, error, status, finishRequestCost(requestCost));
|
|
1275
|
+
return c.json(toErrorResponse(error, requestId), status);
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
997
1278
|
app.post("/v1/:operation", async (c) => {
|
|
998
1279
|
let rawBody;
|
|
999
1280
|
const operation = c.req.param("operation");
|
|
@@ -1130,6 +1411,28 @@ export function createServerApp(provider, options = {}) {
|
|
|
1130
1411
|
});
|
|
1131
1412
|
return app;
|
|
1132
1413
|
}
|
|
1414
|
+
function validateStatefulServerConfig(options) {
|
|
1415
|
+
if (options.statefulForwarding && !options.internalOperationExecutor) {
|
|
1416
|
+
throw new Error("Invalid provider server configuration: statefulForwarding requires internalOperationExecutor; missing option internalOperationExecutor.");
|
|
1417
|
+
}
|
|
1418
|
+
if (options.internalOperationExecutor && !options.statefulForwarding?.secret) {
|
|
1419
|
+
throw new Error("Invalid provider server configuration: internalOperationExecutor requires statefulForwarding.secret; missing option statefulForwarding.secret.");
|
|
1420
|
+
}
|
|
1421
|
+
if (options.statefulForwarding &&
|
|
1422
|
+
typeof options.statefulForwarding.validateOwnerFence !== "function") {
|
|
1423
|
+
throw new Error("Invalid provider server configuration: statefulForwarding requires validateOwnerFence.");
|
|
1424
|
+
}
|
|
1425
|
+
if (options.statefulForwarding?.maxSkewMs !== undefined &&
|
|
1426
|
+
(!Number.isFinite(options.statefulForwarding.maxSkewMs) ||
|
|
1427
|
+
options.statefulForwarding.maxSkewMs <= 0)) {
|
|
1428
|
+
throw new Error("Invalid provider server configuration: maxSkewMs must be positive.");
|
|
1429
|
+
}
|
|
1430
|
+
if (options.statefulForwarding?.replayCacheMaxEntries !== undefined &&
|
|
1431
|
+
(!Number.isInteger(options.statefulForwarding.replayCacheMaxEntries) ||
|
|
1432
|
+
options.statefulForwarding.replayCacheMaxEntries <= 0)) {
|
|
1433
|
+
throw new Error("Invalid provider server configuration: replayCacheMaxEntries must be a positive integer.");
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1133
1436
|
function getBunServeRuntime() {
|
|
1134
1437
|
const bunValue = Object.getOwnPropertyDescriptor(globalThis, "Bun")?.value;
|
|
1135
1438
|
if (!bunValue || typeof bunValue !== "object") {
|
|
@@ -1145,6 +1448,9 @@ function getBunServeRuntime() {
|
|
|
1145
1448
|
},
|
|
1146
1449
|
};
|
|
1147
1450
|
}
|
|
1451
|
+
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000;
|
|
1452
|
+
const DEFAULT_SHUTDOWN_SIGNALS = ["SIGTERM", "SIGINT"];
|
|
1453
|
+
const processSignalCoordinators = new Map();
|
|
1148
1454
|
export async function serve(provider, options = {}) {
|
|
1149
1455
|
const bunRuntime = getBunServeRuntime();
|
|
1150
1456
|
if (bunRuntime === undefined) {
|
|
@@ -1152,30 +1458,186 @@ export async function serve(provider, options = {}) {
|
|
|
1152
1458
|
code: "RUNTIME_UNSUPPORTED",
|
|
1153
1459
|
});
|
|
1154
1460
|
}
|
|
1461
|
+
const logger = options.logger ?? defaultProviderServerLogger;
|
|
1462
|
+
const configuredTimeoutMs = shutdownTimeout(options.shutdown?.timeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS);
|
|
1463
|
+
const configuredSignals = resolveShutdownSignals(options.shutdown?.signals ?? true);
|
|
1155
1464
|
const app = createServerApp(provider, {
|
|
1156
1465
|
logger: options.logger,
|
|
1157
1466
|
stt: options.stt,
|
|
1467
|
+
state: options.state,
|
|
1468
|
+
allowMemoryStateFallback: options.allowMemoryStateFallback,
|
|
1469
|
+
operationExecutor: options.operationExecutor,
|
|
1470
|
+
internalOperationExecutor: options.internalOperationExecutor,
|
|
1471
|
+
statefulForwarding: options.statefulForwarding,
|
|
1158
1472
|
});
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
});
|
|
1164
|
-
// Internal self-test listener (health dependency inversion): a SEPARATE
|
|
1165
|
-
// socket the tenant-facing gateway never dials. Off by default — it only
|
|
1166
|
-
// starts when the shared self-test master secret env is present.
|
|
1167
|
-
const selfTestSecrets = resolveSelfTestMasterSecrets();
|
|
1168
|
-
if (selfTestSecrets) {
|
|
1169
|
-
const selfTestApp = createSelfTestApp(provider, {
|
|
1170
|
-
secrets: selfTestSecrets,
|
|
1171
|
-
invoke: createSelfTestInvoke(app),
|
|
1172
|
-
authFlow: createSelfTestAuthFlowInvoke(app),
|
|
1173
|
-
});
|
|
1174
|
-
bunRuntime.serve({
|
|
1175
|
-
port: options.selfTestPort ?? resolveSelfTestPort(),
|
|
1473
|
+
const servers = [];
|
|
1474
|
+
try {
|
|
1475
|
+
servers.push(bunRuntime.serve({
|
|
1476
|
+
port: options.port ?? DEFAULT_PORT,
|
|
1176
1477
|
hostname: options.host ?? DEFAULT_HOST,
|
|
1177
|
-
fetch:
|
|
1178
|
-
});
|
|
1478
|
+
fetch: app.fetch,
|
|
1479
|
+
}));
|
|
1480
|
+
// Internal self-test listener (health dependency inversion): a SEPARATE
|
|
1481
|
+
// socket the tenant-facing gateway never dials. Off by default — it only
|
|
1482
|
+
// starts when the shared self-test master secret env is present.
|
|
1483
|
+
const selfTestSecrets = resolveSelfTestMasterSecrets();
|
|
1484
|
+
if (selfTestSecrets) {
|
|
1485
|
+
const selfTestApp = createSelfTestApp(provider, {
|
|
1486
|
+
secrets: selfTestSecrets,
|
|
1487
|
+
invoke: createSelfTestInvoke(app),
|
|
1488
|
+
authFlow: createSelfTestAuthFlowInvoke(app),
|
|
1489
|
+
});
|
|
1490
|
+
servers.push(bunRuntime.serve({
|
|
1491
|
+
port: options.selfTestPort ?? resolveSelfTestPort(),
|
|
1492
|
+
hostname: options.host ?? DEFAULT_HOST,
|
|
1493
|
+
fetch: selfTestApp.fetch,
|
|
1494
|
+
}));
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
catch (error) {
|
|
1498
|
+
await Promise.allSettled(servers.map((startedServer) => startedServer.stop(true)));
|
|
1499
|
+
throw error;
|
|
1500
|
+
}
|
|
1501
|
+
const server = servers[0];
|
|
1502
|
+
if (!server)
|
|
1503
|
+
throw new Error("Provider server failed to create its primary listener.");
|
|
1504
|
+
let closePromise;
|
|
1505
|
+
let unregisterSignals = () => { };
|
|
1506
|
+
const close = (closeOptions = {}) => {
|
|
1507
|
+
if (closePromise)
|
|
1508
|
+
return closePromise;
|
|
1509
|
+
const timeoutMs = shutdownTimeout(closeOptions.timeoutMs ?? configuredTimeoutMs);
|
|
1510
|
+
closePromise = closeProviderServers({
|
|
1511
|
+
servers,
|
|
1512
|
+
hooks: options.shutdown?.hooks ?? [],
|
|
1513
|
+
timeoutMs,
|
|
1514
|
+
logger,
|
|
1515
|
+
providerId: provider.id,
|
|
1516
|
+
}).finally(() => unregisterSignals());
|
|
1517
|
+
return closePromise;
|
|
1518
|
+
};
|
|
1519
|
+
try {
|
|
1520
|
+
unregisterSignals = registerForProcessSignals(configuredSignals, () => close({ timeoutMs: configuredTimeoutMs }));
|
|
1521
|
+
}
|
|
1522
|
+
catch (error) {
|
|
1523
|
+
unregisterSignals();
|
|
1524
|
+
await Promise.allSettled(servers.map((startedServer) => startedServer.stop(true)));
|
|
1525
|
+
throw error;
|
|
1526
|
+
}
|
|
1527
|
+
return { port: server.port, close };
|
|
1528
|
+
}
|
|
1529
|
+
function registerForProcessSignals(signals, close) {
|
|
1530
|
+
if (signals.length === 0)
|
|
1531
|
+
return () => { };
|
|
1532
|
+
const registration = {
|
|
1533
|
+
signals: new Set(signals),
|
|
1534
|
+
close,
|
|
1535
|
+
};
|
|
1536
|
+
let registered = true;
|
|
1537
|
+
const unregister = () => {
|
|
1538
|
+
if (!registered)
|
|
1539
|
+
return;
|
|
1540
|
+
registered = false;
|
|
1541
|
+
for (const signal of registration.signals) {
|
|
1542
|
+
const coordinator = processSignalCoordinators.get(signal);
|
|
1543
|
+
if (!coordinator)
|
|
1544
|
+
continue;
|
|
1545
|
+
coordinator.registrations.delete(registration);
|
|
1546
|
+
if (coordinator.registrations.size === 0 && !coordinator.handling) {
|
|
1547
|
+
process.removeListener(signal, coordinator.listener);
|
|
1548
|
+
processSignalCoordinators.delete(signal);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
};
|
|
1552
|
+
try {
|
|
1553
|
+
for (const signal of signals) {
|
|
1554
|
+
let coordinator = processSignalCoordinators.get(signal);
|
|
1555
|
+
if (!coordinator) {
|
|
1556
|
+
const created = {
|
|
1557
|
+
registrations: new Set(),
|
|
1558
|
+
handling: false,
|
|
1559
|
+
listener: () => handleCoordinatedSignal(signal, created),
|
|
1560
|
+
};
|
|
1561
|
+
coordinator = created;
|
|
1562
|
+
processSignalCoordinators.set(signal, coordinator);
|
|
1563
|
+
process.on(signal, coordinator.listener);
|
|
1564
|
+
}
|
|
1565
|
+
coordinator.registrations.add(registration);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
catch (error) {
|
|
1569
|
+
unregister();
|
|
1570
|
+
throw error;
|
|
1571
|
+
}
|
|
1572
|
+
return unregister;
|
|
1573
|
+
}
|
|
1574
|
+
function handleCoordinatedSignal(signal, coordinator) {
|
|
1575
|
+
if (coordinator.handling)
|
|
1576
|
+
return;
|
|
1577
|
+
coordinator.handling = true;
|
|
1578
|
+
const registrations = [...coordinator.registrations];
|
|
1579
|
+
void Promise.allSettled(registrations.map((registration) => registration.close())).finally(() => {
|
|
1580
|
+
if (processSignalCoordinators.get(signal) === coordinator) {
|
|
1581
|
+
process.removeListener(signal, coordinator.listener);
|
|
1582
|
+
processSignalCoordinators.delete(signal);
|
|
1583
|
+
}
|
|
1584
|
+
try {
|
|
1585
|
+
process.kill(process.pid, signal);
|
|
1586
|
+
}
|
|
1587
|
+
catch {
|
|
1588
|
+
process.exitCode = 1;
|
|
1589
|
+
}
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
async function closeProviderServers(input) {
|
|
1593
|
+
const deadline = Date.now() + input.timeoutMs;
|
|
1594
|
+
const gracefulStops = input.servers.map((server) => server.stop(false));
|
|
1595
|
+
for (const gracefulStop of gracefulStops)
|
|
1596
|
+
gracefulStop.catch(() => undefined);
|
|
1597
|
+
for (const [hookIndex, hook] of input.hooks.entries()) {
|
|
1598
|
+
try {
|
|
1599
|
+
await withinShutdownBudget(Promise.resolve().then(hook), deadline);
|
|
1600
|
+
}
|
|
1601
|
+
catch (error) {
|
|
1602
|
+
try {
|
|
1603
|
+
input.logger({
|
|
1604
|
+
level: "error",
|
|
1605
|
+
event: "provider_shutdown_hook_failed",
|
|
1606
|
+
providerId: input.providerId,
|
|
1607
|
+
hookIndex,
|
|
1608
|
+
errorClass: error instanceof Error ? error.name : "UnknownError",
|
|
1609
|
+
message: error instanceof Error ? error.message : "Shutdown hook failed.",
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
catch { }
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
const forcedStops = input.servers.map((server) => server.stop(true));
|
|
1616
|
+
await withinShutdownBudget(Promise.allSettled([...gracefulStops, ...forcedStops]).then(() => undefined), deadline).catch(() => undefined);
|
|
1617
|
+
}
|
|
1618
|
+
async function withinShutdownBudget(promise, deadline) {
|
|
1619
|
+
promise.catch(() => undefined);
|
|
1620
|
+
const remainingMs = Math.max(0, deadline - Date.now());
|
|
1621
|
+
let timer;
|
|
1622
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
1623
|
+
timer = setTimeout(() => reject(new Error("Provider server shutdown timed out.")), remainingMs);
|
|
1624
|
+
});
|
|
1625
|
+
try {
|
|
1626
|
+
return await Promise.race([promise, timeout]);
|
|
1627
|
+
}
|
|
1628
|
+
finally {
|
|
1629
|
+
if (timer)
|
|
1630
|
+
clearTimeout(timer);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
function resolveShutdownSignals(signals) {
|
|
1634
|
+
if (signals === false)
|
|
1635
|
+
return [];
|
|
1636
|
+
return [...new Set(signals === true ? DEFAULT_SHUTDOWN_SIGNALS : signals)];
|
|
1637
|
+
}
|
|
1638
|
+
function shutdownTimeout(value) {
|
|
1639
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
1640
|
+
throw new Error("Provider server shutdown timeoutMs must be a non-negative finite number.");
|
|
1179
1641
|
}
|
|
1180
|
-
|
|
1642
|
+
return value;
|
|
1181
1643
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type StatefulControlPlaneOperation = "resolve" | "acquire" | "renew" | "release";
|
|
2
|
+
export declare class StatefulControlPlaneError extends Error {
|
|
3
|
+
readonly code: string;
|
|
4
|
+
readonly operation: StatefulControlPlaneOperation;
|
|
5
|
+
readonly status?: number;
|
|
6
|
+
readonly cause?: unknown;
|
|
7
|
+
constructor(input: {
|
|
8
|
+
readonly code: string;
|
|
9
|
+
readonly message: string;
|
|
10
|
+
readonly operation: StatefulControlPlaneOperation;
|
|
11
|
+
readonly status?: number;
|
|
12
|
+
readonly cause?: unknown;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export class StatefulControlPlaneError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
operation;
|
|
4
|
+
status;
|
|
5
|
+
cause;
|
|
6
|
+
constructor(input) {
|
|
7
|
+
super(input.message);
|
|
8
|
+
this.name = "StatefulControlPlaneError";
|
|
9
|
+
this.code = input.code;
|
|
10
|
+
this.operation = input.operation;
|
|
11
|
+
this.status = input.status;
|
|
12
|
+
this.cause = input.cause;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ProviderEventDeliveryFailureRecorder } from "./provider-event-delivery-failures.js";
|
|
2
|
+
import type { ProviderEventPublisher, ProviderEventPublishOptions, PublishAck } from "./provider-event-pipeline.js";
|
|
3
|
+
import { type ProviderEventMetricEmitter } from "./provider-event-pipeline-metrics.js";
|
|
4
|
+
import type { ProviderEvent } from "./provider-events.js";
|
|
5
|
+
type FetchTransport = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
6
|
+
export type HttpProviderEventEmitterOptions = {
|
|
7
|
+
readonly baseUrl: string;
|
|
8
|
+
readonly secret: string;
|
|
9
|
+
readonly fetch?: FetchTransport;
|
|
10
|
+
readonly clock?: () => Date;
|
|
11
|
+
readonly random?: () => number;
|
|
12
|
+
readonly metricEmitter?: ProviderEventMetricEmitter;
|
|
13
|
+
readonly failureRecorder?: ProviderEventDeliveryFailureRecorder;
|
|
14
|
+
readonly maxBufferedEvents?: number;
|
|
15
|
+
readonly retryBaseMs?: number;
|
|
16
|
+
readonly retryMaxMs?: number;
|
|
17
|
+
readonly maxAttempts?: number;
|
|
18
|
+
readonly jitterRatio?: number;
|
|
19
|
+
};
|
|
20
|
+
export type ProviderEventFlushReport = {
|
|
21
|
+
/** Events delivered during this emitter's lifetime. */
|
|
22
|
+
readonly delivered: number;
|
|
23
|
+
/** Events permanently dropped during this emitter's lifetime. */
|
|
24
|
+
readonly failed: number;
|
|
25
|
+
/** Events still buffered when flush returned. */
|
|
26
|
+
readonly pending: number;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Provider-pod HTTP publication with a bounded best-effort delivery guarantee and at-least-once
|
|
30
|
+
* transport retries. This is not durable at-least-once delivery: buffered events can be lost if
|
|
31
|
+
* the process is killed. Graceful shutdown should drain the in-memory buffer with flush().
|
|
32
|
+
*/
|
|
33
|
+
export declare class HttpProviderEventEmitter implements ProviderEventPublisher {
|
|
34
|
+
#private;
|
|
35
|
+
constructor(options: HttpProviderEventEmitterOptions);
|
|
36
|
+
publish(event: ProviderEvent, options: ProviderEventPublishOptions): PublishAck;
|
|
37
|
+
pendingCount(): number;
|
|
38
|
+
flush(timeoutMs?: number): Promise<ProviderEventFlushReport>;
|
|
39
|
+
}
|
|
40
|
+
export {};
|