@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.
- package/AUTHORING.md +37 -0
- package/CHANGELOG.md +8 -0
- package/README.md +18 -0
- package/bin/apifuse-pack-smoke.ts +14 -0
- package/bin/apifuse-pack-types.ts +11 -1
- package/dist/config/loader.d.ts +9 -1
- package/dist/config/loader.js +9 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +15 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/runtime/stealth.js +113 -47
- 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/dist/types.d.ts +39 -7
- package/package.json +7 -1
- package/src/config/loader.ts +22 -1
- package/src/errors.ts +15 -0
- package/src/index.ts +8 -1
- package/src/runtime/stealth.ts +127 -48
- 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/src/types.ts +41 -7
package/src/server/serve.ts
CHANGED
|
@@ -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
|
-
:
|
|
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 =
|
|
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
|
-
}) =>
|
|
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<
|
|
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
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
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
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
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
|
-
|
|
2361
|
+
return value;
|
|
1696
2362
|
}
|