@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/server/index.d.ts +1 -1
  3. package/dist/server/index.js +1 -1
  4. package/dist/server/serve.d.ts +98 -2
  5. package/dist/server/serve.js +485 -23
  6. package/dist/stateful/errors.d.ts +14 -0
  7. package/dist/stateful/errors.js +14 -0
  8. package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
  9. package/dist/stateful/http-provider-event-emitter.js +237 -0
  10. package/dist/stateful/http-session-owner-registry.d.ts +44 -0
  11. package/dist/stateful/http-session-owner-registry.js +210 -0
  12. package/dist/stateful/index.d.ts +18 -0
  13. package/dist/stateful/index.js +18 -0
  14. package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
  15. package/dist/stateful/provider-event-delivery-failures.js +43 -0
  16. package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
  17. package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
  18. package/dist/stateful/provider-event-pipeline.d.ts +50 -0
  19. package/dist/stateful/provider-event-pipeline.js +1 -0
  20. package/dist/stateful/provider-events.d.ts +101 -0
  21. package/dist/stateful/provider-events.js +289 -0
  22. package/dist/stateful/session-key.d.ts +15 -0
  23. package/dist/stateful/session-key.js +86 -0
  24. package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
  25. package/dist/stateful/stateful-provider-adapter-context.js +42 -0
  26. package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
  27. package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
  28. package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
  29. package/dist/stateful/stateful-provider-adapter.js +287 -0
  30. package/dist/stateful/stateful-provider-observability.d.ts +62 -0
  31. package/dist/stateful/stateful-provider-observability.js +161 -0
  32. package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
  33. package/dist/stateful/stateful-provider-owner-forwarder.js +207 -0
  34. package/dist/stateful/stateful-provider-runtime-context.d.ts +32 -0
  35. package/dist/stateful/stateful-provider-runtime-context.js +60 -0
  36. package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
  37. package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
  38. package/dist/stateful/stateful-provider-session-routing.d.ts +71 -0
  39. package/dist/stateful/stateful-provider-session-routing.js +353 -0
  40. package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
  41. package/dist/stateful/stateful-provider-session-runtime.js +245 -0
  42. package/dist/stateful-signing.d.ts +18 -0
  43. package/dist/stateful-signing.js +27 -0
  44. package/package.json +6 -1
  45. package/src/server/index.ts +13 -1
  46. package/src/server/serve.ts +691 -25
  47. package/src/stateful/README.md +146 -0
  48. package/src/stateful/errors.ts +23 -0
  49. package/src/stateful/http-provider-event-emitter.ts +314 -0
  50. package/src/stateful/http-session-owner-registry.ts +306 -0
  51. package/src/stateful/index.ts +18 -0
  52. package/src/stateful/provider-event-delivery-failures.ts +80 -0
  53. package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
  54. package/src/stateful/provider-event-pipeline.ts +61 -0
  55. package/src/stateful/provider-events.ts +462 -0
  56. package/src/stateful/session-key.ts +111 -0
  57. package/src/stateful/stateful-provider-adapter-context.ts +59 -0
  58. package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
  59. package/src/stateful/stateful-provider-adapter.ts +562 -0
  60. package/src/stateful/stateful-provider-observability.ts +261 -0
  61. package/src/stateful/stateful-provider-owner-forwarder.ts +279 -0
  62. package/src/stateful/stateful-provider-runtime-context.ts +92 -0
  63. package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
  64. package/src/stateful/stateful-provider-session-routing.ts +555 -0
  65. package/src/stateful/stateful-provider-session-runtime.ts +403 -0
  66. package/src/stateful-signing.ts +46 -0
@@ -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
- : "internal_error";
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 = await executeOperation(provider, operationId, ctx, request.input);
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
- bunRuntime.serve({
1160
- port: options.port ?? DEFAULT_PORT,
1161
- hostname: options.host ?? DEFAULT_HOST,
1162
- fetch: app.fetch,
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: selfTestApp.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
- await Promise.resolve();
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 {};