@apifuse/provider-sdk 2.2.0-beta.10 → 2.2.0-beta.12

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 (83) hide show
  1. package/AUTHORING.md +37 -0
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +18 -0
  4. package/bin/apifuse-pack-smoke.ts +14 -0
  5. package/bin/apifuse-pack-types.ts +11 -1
  6. package/dist/config/loader.d.ts +9 -1
  7. package/dist/config/loader.js +9 -0
  8. package/dist/errors.d.ts +5 -0
  9. package/dist/errors.js +15 -0
  10. package/dist/index.d.ts +3 -3
  11. package/dist/index.js +1 -1
  12. package/dist/runtime/stealth.js +113 -47
  13. package/dist/server/index.d.ts +1 -1
  14. package/dist/server/index.js +1 -1
  15. package/dist/server/serve.d.ts +98 -2
  16. package/dist/server/serve.js +485 -23
  17. package/dist/stateful/errors.d.ts +14 -0
  18. package/dist/stateful/errors.js +14 -0
  19. package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
  20. package/dist/stateful/http-provider-event-emitter.js +237 -0
  21. package/dist/stateful/http-session-owner-registry.d.ts +44 -0
  22. package/dist/stateful/http-session-owner-registry.js +210 -0
  23. package/dist/stateful/index.d.ts +18 -0
  24. package/dist/stateful/index.js +18 -0
  25. package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
  26. package/dist/stateful/provider-event-delivery-failures.js +43 -0
  27. package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
  28. package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
  29. package/dist/stateful/provider-event-pipeline.d.ts +50 -0
  30. package/dist/stateful/provider-event-pipeline.js +1 -0
  31. package/dist/stateful/provider-events.d.ts +101 -0
  32. package/dist/stateful/provider-events.js +289 -0
  33. package/dist/stateful/session-key.d.ts +15 -0
  34. package/dist/stateful/session-key.js +86 -0
  35. package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
  36. package/dist/stateful/stateful-provider-adapter-context.js +42 -0
  37. package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
  38. package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
  39. package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
  40. package/dist/stateful/stateful-provider-adapter.js +287 -0
  41. package/dist/stateful/stateful-provider-observability.d.ts +62 -0
  42. package/dist/stateful/stateful-provider-observability.js +161 -0
  43. package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
  44. package/dist/stateful/stateful-provider-owner-forwarder.js +207 -0
  45. package/dist/stateful/stateful-provider-runtime-context.d.ts +32 -0
  46. package/dist/stateful/stateful-provider-runtime-context.js +60 -0
  47. package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
  48. package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
  49. package/dist/stateful/stateful-provider-session-routing.d.ts +71 -0
  50. package/dist/stateful/stateful-provider-session-routing.js +353 -0
  51. package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
  52. package/dist/stateful/stateful-provider-session-runtime.js +245 -0
  53. package/dist/stateful-signing.d.ts +18 -0
  54. package/dist/stateful-signing.js +27 -0
  55. package/dist/types.d.ts +39 -7
  56. package/package.json +7 -1
  57. package/src/config/loader.ts +22 -1
  58. package/src/errors.ts +15 -0
  59. package/src/index.ts +8 -1
  60. package/src/runtime/stealth.ts +127 -48
  61. package/src/server/index.ts +13 -1
  62. package/src/server/serve.ts +691 -25
  63. package/src/stateful/README.md +146 -0
  64. package/src/stateful/errors.ts +23 -0
  65. package/src/stateful/http-provider-event-emitter.ts +314 -0
  66. package/src/stateful/http-session-owner-registry.ts +306 -0
  67. package/src/stateful/index.ts +18 -0
  68. package/src/stateful/provider-event-delivery-failures.ts +80 -0
  69. package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
  70. package/src/stateful/provider-event-pipeline.ts +61 -0
  71. package/src/stateful/provider-events.ts +462 -0
  72. package/src/stateful/session-key.ts +111 -0
  73. package/src/stateful/stateful-provider-adapter-context.ts +59 -0
  74. package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
  75. package/src/stateful/stateful-provider-adapter.ts +562 -0
  76. package/src/stateful/stateful-provider-observability.ts +261 -0
  77. package/src/stateful/stateful-provider-owner-forwarder.ts +279 -0
  78. package/src/stateful/stateful-provider-runtime-context.ts +92 -0
  79. package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
  80. package/src/stateful/stateful-provider-session-routing.ts +555 -0
  81. package/src/stateful/stateful-provider-session-runtime.ts +403 -0
  82. package/src/stateful-signing.ts +46 -0
  83. package/src/types.ts +41 -7
@@ -55,6 +55,13 @@ import { createStealthClient } from "../runtime/stealth.js";
55
55
  import { createSttClientFromEnv } from "../runtime/stt.js";
56
56
  import { createTraceContext } from "../runtime/trace.js";
57
57
  import { parseSchema } from "../schema.js";
58
+ import {
59
+ STATEFUL_NONCE_HEADER as STATEFUL_FORWARDING_NONCE_HEADER,
60
+ STATEFUL_SIGNATURE_HEADER as STATEFUL_FORWARDING_SIGNATURE_HEADER,
61
+ STATEFUL_TIMESTAMP_HEADER as STATEFUL_FORWARDING_TIMESTAMP_HEADER,
62
+ verifyStatefulRequestSignature,
63
+ } from "../stateful-signing.js";
64
+ import { StatefulRoutingDeadlineError } from "../stateful/stateful-provider-session-routing.js";
58
65
  import { getStealthProfile } from "../stealth/profiles.js";
59
66
  import {
60
67
  APIFUSE_STREAM_DONE_EVENT,
@@ -91,6 +98,7 @@ import {
91
98
  AuthFlowRequestSchema,
92
99
  type AuthFlowResponse,
93
100
  type AuthFlowSuccessResponse,
101
+ OperationConnectionSchema,
94
102
  type OperationErrorResponse,
95
103
  type OperationRequest,
96
104
  OperationRequestSchema,
@@ -102,6 +110,73 @@ const DEFAULT_HOST = "0.0.0.0";
102
110
  const DEFAULT_PORT = 3000;
103
111
  const AUTH_FLOW_LOCALES = ["en", "ko", "ja"] as const;
104
112
  const retryResponseMeta = new WeakMap<ProviderContext, HttpRetrySummary>();
113
+ const STATEFUL_INTERNAL_OPERATIONS_ROUTE = "/__apifuse/stateful/operations";
114
+ const STATEFUL_FORWARDING_SOURCE_POD_HEADER = "x-apifuse-stateful-source-pod";
115
+ const DEFAULT_STATEFUL_FORWARDING_MAX_SKEW_MS = 5 * 60_000;
116
+ const DEFAULT_STATEFUL_FORWARDING_REPLAY_CACHE_MAX_ENTRIES = 10_000;
117
+ const STATEFUL_FORWARDING_REPLAY_BUCKET_MS = 10_000;
118
+ const STATEFUL_FORWARDING_REPLAY_RETRY_AFTER_SECONDS = Math.ceil(
119
+ STATEFUL_FORWARDING_REPLAY_BUCKET_MS / 1_000,
120
+ );
121
+
122
+ export const ProviderServerStatefulForwardEnvelopeSchema = z
123
+ .object({
124
+ requestId: z.string().min(1),
125
+ providerId: z.string().min(1),
126
+ operationId: z.string().min(1),
127
+ sessionKey: z.string().min(1),
128
+ connectionId: z.string().min(1),
129
+ serviceAccountId: z.string().min(1),
130
+ ownerPodId: z.string().min(1),
131
+ generation: z.number().int().positive(),
132
+ sourcePodId: z.string().min(1),
133
+ forwardedAt: z.string().refine((value) => Number.isFinite(Date.parse(value))),
134
+ deadlineAt: z
135
+ .string()
136
+ .refine((value) => Number.isFinite(Date.parse(value)))
137
+ .optional(),
138
+ idempotencyKey: z.string().min(1).optional(),
139
+ operationRequest: OperationRequestSchema.extend({
140
+ connection: OperationConnectionSchema.strict().optional(),
141
+ }).strict(),
142
+ })
143
+ .strict();
144
+
145
+ export type ProviderServerStatefulForwardEnvelope = Readonly<
146
+ z.infer<typeof ProviderServerStatefulForwardEnvelopeSchema>
147
+ >;
148
+
149
+ export type ProviderServerStatefulOwnerFence = Readonly<
150
+ Pick<
151
+ ProviderServerStatefulForwardEnvelope,
152
+ | "providerId"
153
+ | "sessionKey"
154
+ | "ownerPodId"
155
+ | "generation"
156
+ | "sourcePodId"
157
+ | "forwardedAt"
158
+ | "requestId"
159
+ | "idempotencyKey"
160
+ >
161
+ >;
162
+
163
+ export type ProviderServerStatefulOwnerFenceValidator = (
164
+ fence: ProviderServerStatefulOwnerFence,
165
+ signal: AbortSignal,
166
+ ) => boolean | Promise<boolean>;
167
+
168
+ export type ProviderServerOperationExecutorInput = {
169
+ readonly provider: ProviderDefinition;
170
+ readonly operationId: string;
171
+ readonly ctx: ProviderContext;
172
+ readonly request: OperationRequest & { readonly deadlineAt?: string };
173
+ readonly signal?: AbortSignal;
174
+ readonly internalStatefulForward?: ProviderServerStatefulForwardEnvelope;
175
+ };
176
+
177
+ export type ProviderServerOperationExecutor = (
178
+ input: ProviderServerOperationExecutorInput,
179
+ ) => Promise<unknown>;
105
180
 
106
181
  type RequestCleanup = () => void | Promise<void>;
107
182
 
@@ -429,18 +504,59 @@ export type ProviderServerLogEvent =
429
504
  resource: "browser" | "stealth";
430
505
  errorClass: string;
431
506
  message: string;
507
+ }
508
+ | {
509
+ level: "error";
510
+ event: "provider_shutdown_hook_failed";
511
+ providerId: string;
512
+ hookIndex: number;
513
+ errorClass: string;
514
+ message: string;
432
515
  };
433
516
 
434
517
  export type ProviderServerLogger = (event: ProviderServerLogEvent) => void;
435
518
 
436
519
  export type ProviderServerOptions = {
437
520
  logger?: ProviderServerLogger;
521
+ /** Optional provider-specific operation executor. Stateful providers use this to preserve provider-local runtime semantics. */
522
+ operationExecutor?: ProviderServerOperationExecutor;
523
+ /** Optional signed internal executor for stateful owner forwarding. */
524
+ internalOperationExecutor?: ProviderServerOperationExecutor;
525
+ statefulForwarding?: {
526
+ readonly secret: string;
527
+ readonly maxSkewMs?: number;
528
+ readonly replayCacheMaxEntries?: number;
529
+ /** Required fail-closed check against the SDK/runtime owner registry. */
530
+ readonly validateOwnerFence: ProviderServerStatefulOwnerFenceValidator;
531
+ };
438
532
  /** Optional STT override for tests or custom hosts; local/prod normally resolves from env. */
439
533
  stt?: SttContext;
440
534
  /** Optional runtime state override for tests or custom hosts. Production resolves Redis from env and fails closed when unavailable. */
441
535
  state?: ProviderRuntimeState;
442
536
  /** Allow process-local runtime state only for local development and tests. */
443
537
  allowMemoryStateFallback?: boolean;
538
+ /**
539
+ * Graceful process shutdown. Hooks run in declaration order after listeners stop accepting work.
540
+ *
541
+ * @example
542
+ * ```ts
543
+ * await serve(provider, {
544
+ * shutdown: {
545
+ * hooks: [
546
+ * async () => { await emitter.flush(); },
547
+ * async () => { await sessionManager.closeAll("server-shutdown"); },
548
+ * async () => { await lease.release(); },
549
+ * async () => { await router.close(); },
550
+ * ],
551
+ * },
552
+ * });
553
+ * ```
554
+ */
555
+ shutdown?: {
556
+ readonly hooks?: Array<() => Promise<void>>;
557
+ readonly signals?: boolean | NodeJS.Signals[];
558
+ readonly timeoutMs?: number;
559
+ };
444
560
  };
445
561
 
446
562
  const defaultProviderServerLogger: ProviderServerLogger = (event) => {
@@ -488,6 +604,17 @@ function zodDetails(error: z.ZodError): Array<{
488
604
  }
489
605
 
490
606
  function toErrorResponse(error: unknown, requestId?: string): OperationErrorResponse {
607
+ if (error instanceof StatefulRoutingDeadlineError) {
608
+ return {
609
+ error: {
610
+ code: "STATEFUL_FORWARDING_DEADLINE_EXPIRED",
611
+ message: "Stateful forwarding deadline expired.",
612
+ ...(requestId ? { requestId } : {}),
613
+ details: { retryable: false },
614
+ },
615
+ };
616
+ }
617
+
491
618
  if (isProviderError(error)) {
492
619
  const details = publicProviderErrorDetails(error);
493
620
  return {
@@ -652,6 +779,9 @@ function toStatusCode(error: unknown): 400 | 401 | 404 | 429 | 500 | 502 | 503 |
652
779
  if (error instanceof z.ZodError) {
653
780
  return 400;
654
781
  }
782
+ if (error instanceof StatefulRoutingDeadlineError) {
783
+ return 504;
784
+ }
655
785
 
656
786
  if (isTransportError(error)) {
657
787
  return error.code === "transport_timeout" ? 504 : 502;
@@ -679,6 +809,7 @@ function toStatusCode(error: unknown): 400 | 401 | 404 | 429 | 500 | 502 | 503 |
679
809
  return 502;
680
810
  case "STT_UNAVAILABLE":
681
811
  case "UNSUPPORTED_STT_BACKEND":
812
+ case "STATEFUL_FORWARDING_REPLAY_CACHE_FULL":
682
813
  return 503;
683
814
  }
684
815
 
@@ -711,7 +842,9 @@ function logProviderError(
711
842
  ? (error.code ?? "provider_error")
712
843
  : error instanceof z.ZodError
713
844
  ? "invalid_request"
714
- : "internal_error";
845
+ : error instanceof StatefulRoutingDeadlineError
846
+ ? "STATEFUL_FORWARDING_DEADLINE_EXPIRED"
847
+ : "internal_error";
715
848
  const errorClass = error instanceof Error ? error.name : typeof error;
716
849
  const message = error instanceof Error ? error.message : String(error);
717
850
  const details = isProviderError(error) ? providerObservabilityDetails(error) : undefined;
@@ -1218,7 +1351,14 @@ async function handleOperation(
1218
1351
  }
1219
1352
  };
1220
1353
  try {
1221
- const result = await executeOperation(provider, operationId, ctx, request.input);
1354
+ const result = options.operationExecutor
1355
+ ? await options.operationExecutor({
1356
+ provider,
1357
+ operationId,
1358
+ ctx,
1359
+ request,
1360
+ })
1361
+ : await executeOperation(provider, operationId, ctx, request.input);
1222
1362
  if (streaming && operation) {
1223
1363
  return toStreamingResponse(operation, result, cleanup, request.requestId);
1224
1364
  }
@@ -1313,12 +1453,142 @@ async function handleAuthFlow(
1313
1453
  }
1314
1454
  }
1315
1455
 
1456
+ class StatefulForwardingReplayCache {
1457
+ readonly #nonces = new Map<string, number>();
1458
+ readonly #expiryBuckets = new Map<number, Set<string>>();
1459
+ #nextExpiryBucket?: number;
1460
+ #latestExpiryBucket?: number;
1461
+
1462
+ constructor(private readonly maxEntries: number) {}
1463
+
1464
+ claim(nonce: string, expiresAtMs: number, nowMs: number): "accepted" | "replayed" | "full" {
1465
+ this.dropExpiredBuckets(nowMs);
1466
+ if (this.#nonces.has(nonce)) return "replayed";
1467
+ if (this.#nonces.size >= this.maxEntries) return "full";
1468
+ const expiryBucket =
1469
+ Math.ceil(expiresAtMs / STATEFUL_FORWARDING_REPLAY_BUCKET_MS) *
1470
+ STATEFUL_FORWARDING_REPLAY_BUCKET_MS;
1471
+ this.#nonces.set(nonce, expiryBucket);
1472
+ const bucket = this.#expiryBuckets.get(expiryBucket) ?? new Set<string>();
1473
+ bucket.add(nonce);
1474
+ this.#expiryBuckets.set(expiryBucket, bucket);
1475
+ this.#nextExpiryBucket = Math.min(this.#nextExpiryBucket ?? expiryBucket, expiryBucket);
1476
+ this.#latestExpiryBucket = Math.max(this.#latestExpiryBucket ?? expiryBucket, expiryBucket);
1477
+ return "accepted";
1478
+ }
1479
+
1480
+ private dropExpiredBuckets(nowMs: number): void {
1481
+ if (this.#nextExpiryBucket === undefined || this.#latestExpiryBucket === undefined) return;
1482
+ if (nowMs >= this.#latestExpiryBucket) {
1483
+ this.#nonces.clear();
1484
+ this.#expiryBuckets.clear();
1485
+ this.#nextExpiryBucket = undefined;
1486
+ this.#latestExpiryBucket = undefined;
1487
+ return;
1488
+ }
1489
+ while (this.#nextExpiryBucket <= nowMs) {
1490
+ const bucket = this.#expiryBuckets.get(this.#nextExpiryBucket);
1491
+ if (bucket) {
1492
+ for (const cachedNonce of bucket) this.#nonces.delete(cachedNonce);
1493
+ this.#expiryBuckets.delete(this.#nextExpiryBucket);
1494
+ }
1495
+ this.#nextExpiryBucket += STATEFUL_FORWARDING_REPLAY_BUCKET_MS;
1496
+ }
1497
+ }
1498
+ }
1499
+
1500
+ function verifyStatefulForwardingRequest(input: {
1501
+ readonly options: ProviderServerOptions;
1502
+ readonly rawBody: string;
1503
+ readonly headers: Headers;
1504
+ readonly method: string;
1505
+ readonly path: string;
1506
+ readonly replayCache: StatefulForwardingReplayCache;
1507
+ }): void {
1508
+ const config = input.options.statefulForwarding;
1509
+ if (!config?.secret) {
1510
+ throw new ProviderError("Stateful forwarding is not configured.", {
1511
+ code: "STATEFUL_FORWARDING_NOT_CONFIGURED",
1512
+ });
1513
+ }
1514
+ const timestamp = input.headers.get(STATEFUL_FORWARDING_TIMESTAMP_HEADER) ?? "";
1515
+ const signature = input.headers.get(STATEFUL_FORWARDING_SIGNATURE_HEADER) ?? "";
1516
+ const nonce = input.headers.get(STATEFUL_FORWARDING_NONCE_HEADER) ?? "";
1517
+ if (!timestamp || !signature || !nonce) {
1518
+ throw new ProviderError("Stateful forwarding signature headers are missing.", {
1519
+ code: "STATEFUL_FORWARDING_SIGNATURE_MISSING",
1520
+ });
1521
+ }
1522
+ if (nonce.length > 256) {
1523
+ throw new ProviderError("Stateful forwarding nonce is invalid.", {
1524
+ code: "STATEFUL_FORWARDING_NONCE_INVALID",
1525
+ });
1526
+ }
1527
+ const timestampMs = Date.parse(timestamp);
1528
+ const maxSkewMs = config.maxSkewMs ?? DEFAULT_STATEFUL_FORWARDING_MAX_SKEW_MS;
1529
+ if (!Number.isFinite(timestampMs) || Math.abs(Date.now() - timestampMs) > maxSkewMs) {
1530
+ throw new ProviderError(
1531
+ "Stateful forwarding signature timestamp is outside the allowed skew.",
1532
+ { code: "STATEFUL_FORWARDING_TIMESTAMP_INVALID" },
1533
+ );
1534
+ }
1535
+ if (
1536
+ !verifyStatefulRequestSignature({
1537
+ secret: config.secret,
1538
+ timestamp,
1539
+ rawBody: input.rawBody,
1540
+ method: input.method,
1541
+ path: input.path,
1542
+ nonce,
1543
+ signature,
1544
+ })
1545
+ ) {
1546
+ throw new ProviderError("Stateful forwarding signature is invalid.", {
1547
+ code: "STATEFUL_FORWARDING_SIGNATURE_INVALID",
1548
+ });
1549
+ }
1550
+ const replayResult = input.replayCache.claim(nonce, timestampMs + maxSkewMs, Date.now());
1551
+ if (replayResult === "replayed") {
1552
+ throw new ProviderError("Stateful forwarding nonce has already been used.", {
1553
+ code: "STATEFUL_FORWARDING_REPLAY_DETECTED",
1554
+ });
1555
+ }
1556
+ if (replayResult === "full") {
1557
+ throw new ProviderError("Stateful forwarding replay cache is at capacity.", {
1558
+ code: "STATEFUL_FORWARDING_REPLAY_CACHE_FULL",
1559
+ });
1560
+ }
1561
+ }
1562
+
1563
+ function operationRequestFromForwardingEnvelope(
1564
+ envelope: ProviderServerStatefulForwardEnvelope,
1565
+ ): OperationRequest & { readonly deadlineAt?: string } {
1566
+ return {
1567
+ ...envelope.operationRequest,
1568
+ ...(envelope.deadlineAt !== undefined ? { deadlineAt: envelope.deadlineAt } : {}),
1569
+ };
1570
+ }
1571
+
1572
+ function parseStatefulForwardingEnvelope(rawBody: unknown): ProviderServerStatefulForwardEnvelope {
1573
+ const parsed = ProviderServerStatefulForwardEnvelopeSchema.safeParse(rawBody);
1574
+ if (parsed.success) return parsed.data;
1575
+ throw new ProviderError("Stateful forwarding envelope is invalid.", {
1576
+ code: "STATEFUL_FORWARDING_ENVELOPE_INVALID",
1577
+ details: zodDetails(parsed.error),
1578
+ });
1579
+ }
1580
+
1316
1581
  export function createServerApp(
1317
1582
  provider: ProviderDefinition,
1318
1583
  options: ProviderServerOptions = {},
1319
1584
  ): Hono {
1585
+ validateStatefulServerConfig(options);
1320
1586
  const app = new Hono();
1321
1587
  const logger = options.logger ?? defaultProviderServerLogger;
1588
+ const statefulForwardingReplayCache = new StatefulForwardingReplayCache(
1589
+ options.statefulForwarding?.replayCacheMaxEntries ??
1590
+ DEFAULT_STATEFUL_FORWARDING_REPLAY_CACHE_MAX_ENTRIES,
1591
+ );
1322
1592
  const state =
1323
1593
  options.state ??
1324
1594
  createProviderRuntimeStateFromEnv({
@@ -1364,6 +1634,162 @@ export function createServerApp(
1364
1634
  }),
1365
1635
  );
1366
1636
 
1637
+ app.post(STATEFUL_INTERNAL_OPERATIONS_ROUTE, async (c) => {
1638
+ let rawBodyText = "";
1639
+ let rawBody: unknown;
1640
+ const operation = "stateful-internal";
1641
+ const requestCost = startRequestCost();
1642
+ try {
1643
+ if (!options.internalOperationExecutor) {
1644
+ throw new ProviderError("Stateful internal operation executor is not configured.", {
1645
+ code: "STATEFUL_INTERNAL_EXECUTOR_NOT_CONFIGURED",
1646
+ });
1647
+ }
1648
+ rawBodyText = await c.req.raw.clone().text();
1649
+ verifyStatefulForwardingRequest({
1650
+ options,
1651
+ rawBody: rawBodyText,
1652
+ headers: c.req.raw.headers,
1653
+ method: c.req.raw.method,
1654
+ path: STATEFUL_INTERNAL_OPERATIONS_ROUTE,
1655
+ replayCache: statefulForwardingReplayCache,
1656
+ });
1657
+ try {
1658
+ rawBody = JSON.parse(rawBodyText);
1659
+ } catch {
1660
+ throw new ProviderError("Stateful forwarding envelope is not valid JSON.", {
1661
+ code: "STATEFUL_FORWARDING_ENVELOPE_INVALID",
1662
+ });
1663
+ }
1664
+ const envelope = parseStatefulForwardingEnvelope(rawBody);
1665
+ if (envelope.providerId !== provider.id) {
1666
+ throw new ProviderError(
1667
+ "Stateful forwarding envelope providerId does not match the served provider.",
1668
+ { code: "STATEFUL_FORWARDING_PROVIDER_MISMATCH" },
1669
+ );
1670
+ }
1671
+ if (envelope.requestId !== envelope.operationRequest.requestId) {
1672
+ throw new ProviderError("Stateful forwarding requestId values do not match.", {
1673
+ code: "STATEFUL_FORWARDING_ENVELOPE_INVALID",
1674
+ });
1675
+ }
1676
+ if (
1677
+ envelope.sourcePodId !==
1678
+ (c.req.raw.headers.get(STATEFUL_FORWARDING_SOURCE_POD_HEADER) ?? "")
1679
+ ) {
1680
+ throw new ProviderError("Stateful forwarding source pod does not match its header.", {
1681
+ code: "STATEFUL_FORWARDING_SOURCE_POD_MISMATCH",
1682
+ });
1683
+ }
1684
+ if (
1685
+ envelope.forwardedAt !== (c.req.raw.headers.get(STATEFUL_FORWARDING_TIMESTAMP_HEADER) ?? "")
1686
+ ) {
1687
+ throw new ProviderError(
1688
+ "Stateful forwarding forwardedAt does not match its signature timestamp.",
1689
+ { code: "STATEFUL_FORWARDING_ENVELOPE_INVALID" },
1690
+ );
1691
+ }
1692
+ const deadlineAtMs = envelope.deadlineAt ? Date.parse(envelope.deadlineAt) : undefined;
1693
+ if (deadlineAtMs !== undefined && deadlineAtMs <= Date.now()) {
1694
+ throw new StatefulRoutingDeadlineError(envelope.requestId, envelope.deadlineAt as string);
1695
+ }
1696
+ const remainingDeadlineMs =
1697
+ deadlineAtMs === undefined ? undefined : deadlineAtMs - Date.now();
1698
+ const deadlineSignal =
1699
+ remainingDeadlineMs === undefined ? undefined : AbortSignal.timeout(remainingDeadlineMs);
1700
+ const signal = deadlineSignal
1701
+ ? AbortSignal.any([c.req.raw.signal, deadlineSignal])
1702
+ : c.req.raw.signal;
1703
+ const ownerFenceValidation = Promise.resolve(
1704
+ options.statefulForwarding?.validateOwnerFence(
1705
+ {
1706
+ providerId: envelope.providerId,
1707
+ sessionKey: envelope.sessionKey,
1708
+ ownerPodId: envelope.ownerPodId,
1709
+ generation: envelope.generation,
1710
+ sourcePodId: envelope.sourcePodId,
1711
+ forwardedAt: envelope.forwardedAt,
1712
+ requestId: envelope.requestId,
1713
+ ...(envelope.idempotencyKey ? { idempotencyKey: envelope.idempotencyKey } : {}),
1714
+ },
1715
+ signal,
1716
+ ),
1717
+ );
1718
+ let ownerFenceValid: boolean | undefined;
1719
+ try {
1720
+ ownerFenceValid = deadlineSignal
1721
+ ? await Promise.race([
1722
+ ownerFenceValidation,
1723
+ new Promise<never>((_resolve, reject) => {
1724
+ deadlineSignal.addEventListener(
1725
+ "abort",
1726
+ () =>
1727
+ reject(
1728
+ new StatefulRoutingDeadlineError(
1729
+ envelope.requestId,
1730
+ envelope.deadlineAt as string,
1731
+ ),
1732
+ ),
1733
+ { once: true },
1734
+ );
1735
+ }),
1736
+ ])
1737
+ : await ownerFenceValidation;
1738
+ } catch (error) {
1739
+ if (deadlineSignal?.aborted) {
1740
+ throw new StatefulRoutingDeadlineError(envelope.requestId, envelope.deadlineAt as string);
1741
+ }
1742
+ throw error;
1743
+ }
1744
+ if (ownerFenceValid !== true) {
1745
+ throw new ProviderError("Stateful forwarding owner fence is no longer current.", {
1746
+ code: "STATEFUL_FORWARDING_OWNER_FENCE_INVALID",
1747
+ });
1748
+ }
1749
+ const request = operationRequestFromForwardingEnvelope(envelope);
1750
+ const operationId = envelope.operationId;
1751
+ const ctx = createProviderContext(provider, request, operationId, options, state);
1752
+ if (deadlineAtMs !== undefined && deadlineAtMs <= Date.now()) {
1753
+ throw new StatefulRoutingDeadlineError(envelope.requestId, envelope.deadlineAt as string);
1754
+ }
1755
+ const output = await options.internalOperationExecutor({
1756
+ provider,
1757
+ operationId,
1758
+ ctx,
1759
+ request,
1760
+ internalStatefulForward: envelope,
1761
+ signal,
1762
+ });
1763
+ logProviderSuccess(
1764
+ logger,
1765
+ provider,
1766
+ "operation",
1767
+ operationId || operation,
1768
+ request.requestId,
1769
+ 200,
1770
+ finishRequestCost(requestCost),
1771
+ );
1772
+ return c.json({ data: output });
1773
+ } catch (error) {
1774
+ const status = toStatusCode(error);
1775
+ if (isProviderError(error) && error.code === "STATEFUL_FORWARDING_REPLAY_CACHE_FULL") {
1776
+ c.header("Retry-After", String(STATEFUL_FORWARDING_REPLAY_RETRY_AFTER_SECONDS));
1777
+ }
1778
+ const requestId = extractRequestId(rawBody);
1779
+ logProviderError(
1780
+ logger,
1781
+ provider,
1782
+ "operation",
1783
+ operation,
1784
+ requestId,
1785
+ error,
1786
+ status,
1787
+ finishRequestCost(requestCost),
1788
+ );
1789
+ return c.json(toErrorResponse(error, requestId), status);
1790
+ }
1791
+ });
1792
+
1367
1793
  app.post("/v1/:operation", async (c) => {
1368
1794
  let rawBody: unknown;
1369
1795
  const operation = c.req.param("operation");
@@ -1616,12 +2042,54 @@ export function createServerApp(
1616
2042
  return app;
1617
2043
  }
1618
2044
 
2045
+ function validateStatefulServerConfig(options: ProviderServerOptions): void {
2046
+ if (options.statefulForwarding && !options.internalOperationExecutor) {
2047
+ throw new Error(
2048
+ "Invalid provider server configuration: statefulForwarding requires internalOperationExecutor; missing option internalOperationExecutor.",
2049
+ );
2050
+ }
2051
+ if (options.internalOperationExecutor && !options.statefulForwarding?.secret) {
2052
+ throw new Error(
2053
+ "Invalid provider server configuration: internalOperationExecutor requires statefulForwarding.secret; missing option statefulForwarding.secret.",
2054
+ );
2055
+ }
2056
+ if (
2057
+ options.statefulForwarding &&
2058
+ typeof options.statefulForwarding.validateOwnerFence !== "function"
2059
+ ) {
2060
+ throw new Error(
2061
+ "Invalid provider server configuration: statefulForwarding requires validateOwnerFence.",
2062
+ );
2063
+ }
2064
+ if (
2065
+ options.statefulForwarding?.maxSkewMs !== undefined &&
2066
+ (!Number.isFinite(options.statefulForwarding.maxSkewMs) ||
2067
+ options.statefulForwarding.maxSkewMs <= 0)
2068
+ ) {
2069
+ throw new Error("Invalid provider server configuration: maxSkewMs must be positive.");
2070
+ }
2071
+ if (
2072
+ options.statefulForwarding?.replayCacheMaxEntries !== undefined &&
2073
+ (!Number.isInteger(options.statefulForwarding.replayCacheMaxEntries) ||
2074
+ options.statefulForwarding.replayCacheMaxEntries <= 0)
2075
+ ) {
2076
+ throw new Error(
2077
+ "Invalid provider server configuration: replayCacheMaxEntries must be a positive integer.",
2078
+ );
2079
+ }
2080
+ }
2081
+
1619
2082
  type BunServeRuntime = {
1620
2083
  serve: (options: {
1621
2084
  port: number;
1622
2085
  hostname: string;
1623
2086
  fetch: (request: Request) => Response | Promise<Response>;
1624
- }) => unknown;
2087
+ }) => BunServerHandle;
2088
+ };
2089
+
2090
+ type BunServerHandle = {
2091
+ readonly port: number;
2092
+ stop(closeActiveConnections?: boolean): Promise<void>;
1625
2093
  };
1626
2094
 
1627
2095
  function getBunServeRuntime(): BunServeRuntime | undefined {
@@ -1637,11 +2105,20 @@ function getBunServeRuntime(): BunServeRuntime | undefined {
1637
2105
 
1638
2106
  return {
1639
2107
  serve(options) {
1640
- return serve(options);
2108
+ return serve(options) as BunServerHandle;
1641
2109
  },
1642
2110
  };
1643
2111
  }
1644
2112
 
2113
+ export type ProviderServerCloseOptions = {
2114
+ readonly timeoutMs?: number;
2115
+ };
2116
+
2117
+ export type ProviderServerHandle = {
2118
+ readonly port: number;
2119
+ close(options?: ProviderServerCloseOptions): Promise<void>;
2120
+ };
2121
+
1645
2122
  export interface ServeOptions extends ProviderServerOptions {
1646
2123
  host?: string;
1647
2124
  port?: number;
@@ -1653,10 +2130,26 @@ export interface ServeOptions extends ProviderServerOptions {
1653
2130
  selfTestPort?: number;
1654
2131
  }
1655
2132
 
2133
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000;
2134
+ const DEFAULT_SHUTDOWN_SIGNALS: NodeJS.Signals[] = ["SIGTERM", "SIGINT"];
2135
+
2136
+ type SignalServerRegistration = {
2137
+ readonly signals: ReadonlySet<NodeJS.Signals>;
2138
+ readonly close: () => Promise<void>;
2139
+ };
2140
+
2141
+ type ProcessSignalCoordinator = {
2142
+ readonly registrations: Set<SignalServerRegistration>;
2143
+ readonly listener: () => void;
2144
+ handling: boolean;
2145
+ };
2146
+
2147
+ const processSignalCoordinators = new Map<NodeJS.Signals, ProcessSignalCoordinator>();
2148
+
1656
2149
  export async function serve(
1657
2150
  provider: ProviderDefinition,
1658
2151
  options: ServeOptions = {},
1659
- ): Promise<void> {
2152
+ ): Promise<ProviderServerHandle> {
1660
2153
  const bunRuntime = getBunServeRuntime();
1661
2154
 
1662
2155
  if (bunRuntime === undefined) {
@@ -1664,33 +2157,206 @@ export async function serve(
1664
2157
  code: "RUNTIME_UNSUPPORTED",
1665
2158
  });
1666
2159
  }
2160
+ const logger = options.logger ?? defaultProviderServerLogger;
2161
+ const configuredTimeoutMs = shutdownTimeout(
2162
+ options.shutdown?.timeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS,
2163
+ );
2164
+ const configuredSignals = resolveShutdownSignals(options.shutdown?.signals ?? true);
1667
2165
 
1668
2166
  const app = createServerApp(provider, {
1669
2167
  logger: options.logger,
1670
2168
  stt: options.stt,
2169
+ state: options.state,
2170
+ allowMemoryStateFallback: options.allowMemoryStateFallback,
2171
+ operationExecutor: options.operationExecutor,
2172
+ internalOperationExecutor: options.internalOperationExecutor,
2173
+ statefulForwarding: options.statefulForwarding,
2174
+ });
2175
+
2176
+ const servers: BunServerHandle[] = [];
2177
+ try {
2178
+ servers.push(
2179
+ bunRuntime.serve({
2180
+ port: options.port ?? DEFAULT_PORT,
2181
+ hostname: options.host ?? DEFAULT_HOST,
2182
+ fetch: app.fetch,
2183
+ }),
2184
+ );
2185
+
2186
+ // Internal self-test listener (health dependency inversion): a SEPARATE
2187
+ // socket the tenant-facing gateway never dials. Off by default — it only
2188
+ // starts when the shared self-test master secret env is present.
2189
+ const selfTestSecrets = resolveSelfTestMasterSecrets();
2190
+ if (selfTestSecrets) {
2191
+ const selfTestApp = createSelfTestApp(provider, {
2192
+ secrets: selfTestSecrets,
2193
+ invoke: createSelfTestInvoke(app),
2194
+ authFlow: createSelfTestAuthFlowInvoke(app),
2195
+ });
2196
+ servers.push(
2197
+ bunRuntime.serve({
2198
+ port: options.selfTestPort ?? resolveSelfTestPort(),
2199
+ hostname: options.host ?? DEFAULT_HOST,
2200
+ fetch: selfTestApp.fetch,
2201
+ }),
2202
+ );
2203
+ }
2204
+ } catch (error) {
2205
+ await Promise.allSettled(servers.map((startedServer) => startedServer.stop(true)));
2206
+ throw error;
2207
+ }
2208
+
2209
+ const server = servers[0];
2210
+ if (!server) throw new Error("Provider server failed to create its primary listener.");
2211
+ let closePromise: Promise<void> | undefined;
2212
+ let unregisterSignals = () => {};
2213
+
2214
+ const close = (closeOptions: ProviderServerCloseOptions = {}): Promise<void> => {
2215
+ if (closePromise) return closePromise;
2216
+ const timeoutMs = shutdownTimeout(closeOptions.timeoutMs ?? configuredTimeoutMs);
2217
+ closePromise = closeProviderServers({
2218
+ servers,
2219
+ hooks: options.shutdown?.hooks ?? [],
2220
+ timeoutMs,
2221
+ logger,
2222
+ providerId: provider.id,
2223
+ }).finally(() => unregisterSignals());
2224
+ return closePromise;
2225
+ };
2226
+
2227
+ try {
2228
+ unregisterSignals = registerForProcessSignals(configuredSignals, () =>
2229
+ close({ timeoutMs: configuredTimeoutMs }),
2230
+ );
2231
+ } catch (error) {
2232
+ unregisterSignals();
2233
+ await Promise.allSettled(servers.map((startedServer) => startedServer.stop(true)));
2234
+ throw error;
2235
+ }
2236
+
2237
+ return { port: server.port, close };
2238
+ }
2239
+
2240
+ function registerForProcessSignals(
2241
+ signals: NodeJS.Signals[],
2242
+ close: () => Promise<void>,
2243
+ ): () => void {
2244
+ if (signals.length === 0) return () => {};
2245
+ const registration: SignalServerRegistration = {
2246
+ signals: new Set(signals),
2247
+ close,
2248
+ };
2249
+ let registered = true;
2250
+ const unregister = () => {
2251
+ if (!registered) return;
2252
+ registered = false;
2253
+ for (const signal of registration.signals) {
2254
+ const coordinator = processSignalCoordinators.get(signal);
2255
+ if (!coordinator) continue;
2256
+ coordinator.registrations.delete(registration);
2257
+ if (coordinator.registrations.size === 0 && !coordinator.handling) {
2258
+ process.removeListener(signal, coordinator.listener);
2259
+ processSignalCoordinators.delete(signal);
2260
+ }
2261
+ }
2262
+ };
2263
+ try {
2264
+ for (const signal of signals) {
2265
+ let coordinator = processSignalCoordinators.get(signal);
2266
+ if (!coordinator) {
2267
+ const created: ProcessSignalCoordinator = {
2268
+ registrations: new Set(),
2269
+ handling: false,
2270
+ listener: () => handleCoordinatedSignal(signal, created),
2271
+ };
2272
+ coordinator = created;
2273
+ processSignalCoordinators.set(signal, coordinator);
2274
+ process.on(signal, coordinator.listener);
2275
+ }
2276
+ coordinator.registrations.add(registration);
2277
+ }
2278
+ } catch (error) {
2279
+ unregister();
2280
+ throw error;
2281
+ }
2282
+ return unregister;
2283
+ }
2284
+
2285
+ function handleCoordinatedSignal(
2286
+ signal: NodeJS.Signals,
2287
+ coordinator: ProcessSignalCoordinator,
2288
+ ): void {
2289
+ if (coordinator.handling) return;
2290
+ coordinator.handling = true;
2291
+ const registrations = [...coordinator.registrations];
2292
+ void Promise.allSettled(registrations.map((registration) => registration.close())).finally(() => {
2293
+ if (processSignalCoordinators.get(signal) === coordinator) {
2294
+ process.removeListener(signal, coordinator.listener);
2295
+ processSignalCoordinators.delete(signal);
2296
+ }
2297
+ try {
2298
+ process.kill(process.pid, signal);
2299
+ } catch {
2300
+ process.exitCode = 1;
2301
+ }
1671
2302
  });
2303
+ }
1672
2304
 
1673
- bunRuntime.serve({
1674
- port: options.port ?? DEFAULT_PORT,
1675
- hostname: options.host ?? DEFAULT_HOST,
1676
- fetch: app.fetch,
2305
+ async function closeProviderServers(input: {
2306
+ readonly servers: BunServerHandle[];
2307
+ readonly hooks: Array<() => Promise<void>>;
2308
+ readonly timeoutMs: number;
2309
+ readonly logger: ProviderServerLogger;
2310
+ readonly providerId: string;
2311
+ }): Promise<void> {
2312
+ const deadline = Date.now() + input.timeoutMs;
2313
+ const gracefulStops = input.servers.map((server) => server.stop(false));
2314
+ for (const gracefulStop of gracefulStops) gracefulStop.catch(() => undefined);
2315
+ for (const [hookIndex, hook] of input.hooks.entries()) {
2316
+ try {
2317
+ await withinShutdownBudget(Promise.resolve().then(hook), deadline);
2318
+ } catch (error) {
2319
+ try {
2320
+ input.logger({
2321
+ level: "error",
2322
+ event: "provider_shutdown_hook_failed",
2323
+ providerId: input.providerId,
2324
+ hookIndex,
2325
+ errorClass: error instanceof Error ? error.name : "UnknownError",
2326
+ message: error instanceof Error ? error.message : "Shutdown hook failed.",
2327
+ });
2328
+ } catch {}
2329
+ }
2330
+ }
2331
+ const forcedStops = input.servers.map((server) => server.stop(true));
2332
+ await withinShutdownBudget(
2333
+ Promise.allSettled([...gracefulStops, ...forcedStops]).then(() => undefined),
2334
+ deadline,
2335
+ ).catch(() => undefined);
2336
+ }
2337
+
2338
+ async function withinShutdownBudget<T>(promise: Promise<T>, deadline: number): Promise<T> {
2339
+ promise.catch(() => undefined);
2340
+ const remainingMs = Math.max(0, deadline - Date.now());
2341
+ let timer: ReturnType<typeof setTimeout> | undefined;
2342
+ const timeout = new Promise<never>((_resolve, reject) => {
2343
+ timer = setTimeout(() => reject(new Error("Provider server shutdown timed out.")), remainingMs);
1677
2344
  });
2345
+ try {
2346
+ return await Promise.race([promise, timeout]);
2347
+ } finally {
2348
+ if (timer) clearTimeout(timer);
2349
+ }
2350
+ }
1678
2351
 
1679
- // Internal self-test listener (health dependency inversion): a SEPARATE
1680
- // socket the tenant-facing gateway never dials. Off by default — it only
1681
- // starts when the shared self-test master secret env is present.
1682
- const selfTestSecrets = resolveSelfTestMasterSecrets();
1683
- if (selfTestSecrets) {
1684
- const selfTestApp = createSelfTestApp(provider, {
1685
- secrets: selfTestSecrets,
1686
- invoke: createSelfTestInvoke(app),
1687
- authFlow: createSelfTestAuthFlowInvoke(app),
1688
- });
1689
- bunRuntime.serve({
1690
- port: options.selfTestPort ?? resolveSelfTestPort(),
1691
- hostname: options.host ?? DEFAULT_HOST,
1692
- fetch: selfTestApp.fetch,
1693
- });
2352
+ function resolveShutdownSignals(signals: boolean | NodeJS.Signals[]): NodeJS.Signals[] {
2353
+ if (signals === false) return [];
2354
+ return [...new Set(signals === true ? DEFAULT_SHUTDOWN_SIGNALS : signals)];
2355
+ }
2356
+
2357
+ function shutdownTimeout(value: number): number {
2358
+ if (!Number.isFinite(value) || value < 0) {
2359
+ throw new Error("Provider server shutdown timeoutMs must be a non-negative finite number.");
1694
2360
  }
1695
- await Promise.resolve();
2361
+ return value;
1696
2362
  }