@apifuse/provider-sdk 2.2.0-beta.27 → 2.2.0-beta.29

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 (98) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/bin/apifuse-dev.ts +34 -5
  3. package/bin/apifuse-pack-smoke.ts +1 -1
  4. package/bin/apifuse-pack-types.ts +26 -2
  5. package/bin/apifuse-perf.ts +2 -4
  6. package/bin/apifuse-record.ts +39 -6
  7. package/dist/auth-turn/index.d.ts +1 -1
  8. package/dist/auth-turn/index.js +1 -1
  9. package/dist/auth.d.ts +14 -0
  10. package/dist/auth.js +38 -0
  11. package/dist/ceremonies/index.js +52 -11
  12. package/dist/config/loader.d.ts +3 -1
  13. package/dist/config/loader.js +4 -2
  14. package/dist/index.d.ts +8 -7
  15. package/dist/index.js +5 -7
  16. package/dist/provider.d.ts +2 -1
  17. package/dist/provider.js +1 -1
  18. package/dist/runtime/auth-flow.js +1 -1
  19. package/dist/runtime/browser.js +45 -2
  20. package/dist/runtime/http.d.ts +1 -0
  21. package/dist/runtime/http.js +135 -12
  22. package/dist/runtime/instrumentation.js +1 -1
  23. package/dist/runtime/native-network-errors.d.ts +33 -0
  24. package/dist/runtime/native-network-errors.js +69 -0
  25. package/dist/runtime/native-network.d.ts +2 -33
  26. package/dist/runtime/native-network.js +2 -68
  27. package/dist/runtime/proxy-telemetry.js +3 -0
  28. package/dist/runtime/redis.d.ts +1 -1
  29. package/dist/runtime/redis.js +4 -2
  30. package/dist/runtime/resolver-config.d.ts +6 -0
  31. package/dist/runtime/resolver-config.js +6 -0
  32. package/dist/runtime/resolver-public.d.ts +1 -0
  33. package/dist/runtime/resolver-public.js +1 -0
  34. package/dist/runtime/resolver-shared.d.ts +3 -0
  35. package/dist/runtime/resolver-shared.js +12 -0
  36. package/dist/runtime/resolver-vendors/browser.js +14 -4
  37. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +2 -1
  38. package/dist/runtime/resolver-vendors/twocaptcha.js +157 -53
  39. package/dist/runtime/resolver-vendors/types.d.ts +2 -2
  40. package/dist/runtime/resolver-vendors/types.js +3 -1
  41. package/dist/runtime/resolver.d.ts +15 -10
  42. package/dist/runtime/resolver.js +92 -23
  43. package/dist/runtime/state.js +5 -115
  44. package/dist/runtime/stealth-cookies.d.ts +20 -0
  45. package/dist/runtime/stealth-cookies.js +111 -0
  46. package/dist/runtime/stealth.d.ts +1 -0
  47. package/dist/runtime/stealth.js +8 -131
  48. package/dist/serve.d.ts +1 -1
  49. package/dist/serve.js +1 -1
  50. package/dist/server/index.d.ts +1 -1
  51. package/dist/server/index.js +1 -1
  52. package/dist/server/self-test.d.ts +13 -0
  53. package/dist/server/self-test.js +124 -46
  54. package/dist/server/serve-implementation.d.ts +199 -0
  55. package/dist/server/serve-implementation.js +2072 -0
  56. package/dist/server/serve.d.ts +1 -187
  57. package/dist/server/serve.js +1 -1827
  58. package/dist/stateful/errors.d.ts +5 -0
  59. package/dist/stateful/errors.js +10 -0
  60. package/dist/stateful/stateful-provider-session-routing.d.ts +1 -5
  61. package/dist/stateful/stateful-provider-session-routing.js +2 -10
  62. package/dist/stream.js +7 -1
  63. package/dist/testing/index.d.ts +1 -0
  64. package/dist/testing/index.js +1 -0
  65. package/package.json +27 -2
  66. package/src/auth-turn/index.ts +1 -1
  67. package/src/auth.ts +78 -0
  68. package/src/ceremonies/index.ts +68 -18
  69. package/src/config/loader.ts +8 -2
  70. package/src/index.ts +18 -24
  71. package/src/provider.ts +12 -14
  72. package/src/runtime/auth-flow.ts +1 -1
  73. package/src/runtime/browser.ts +50 -2
  74. package/src/runtime/http.ts +155 -11
  75. package/src/runtime/instrumentation.ts +1 -1
  76. package/src/runtime/native-network-errors.ts +99 -0
  77. package/src/runtime/native-network.ts +16 -97
  78. package/src/runtime/proxy-telemetry.ts +5 -0
  79. package/src/runtime/redis.ts +7 -2
  80. package/src/runtime/resolver-config.ts +6 -0
  81. package/src/runtime/resolver-public.ts +18 -0
  82. package/src/runtime/resolver-shared.ts +17 -0
  83. package/src/runtime/resolver-vendors/browser.ts +14 -4
  84. package/src/runtime/resolver-vendors/twocaptcha.ts +190 -56
  85. package/src/runtime/resolver-vendors/types.ts +8 -2
  86. package/src/runtime/resolver.ts +140 -28
  87. package/src/runtime/state.ts +5 -144
  88. package/src/runtime/stealth-cookies.ts +132 -0
  89. package/src/runtime/stealth.ts +15 -158
  90. package/src/serve.ts +6 -1
  91. package/src/server/index.ts +1 -0
  92. package/src/server/self-test.ts +184 -59
  93. package/src/server/serve-implementation.ts +3042 -0
  94. package/src/server/serve.ts +1 -2661
  95. package/src/stateful/errors.ts +12 -0
  96. package/src/stateful/stateful-provider-session-routing.ts +2 -11
  97. package/src/stream.ts +8 -1
  98. package/src/testing/index.ts +1 -0
@@ -1,13 +1,13 @@
1
1
  import { createHash } from "node:crypto";
2
- import { Cookie, CookieJar as ToughCookieJar } from "tough-cookie";
3
2
  import { DEFAULT_SMARTPROXY_POOL_SIZE, invalidateProxyResolutionCacheAsync, ProxyResolutionError, policyResolvesRegistryVendorChain, resolvePolicyProxyPoolSpan, resolvePolicyTransportAttemptCap, resolveProxyConfigAsync, vendorFromResolvedSource, } from "../config/loader.js";
4
- import { SDKError, StealthCookieStoreVersionError, TransportError } from "../errors.js";
3
+ import { SDKError, TransportError } from "../errors.js";
5
4
  import { getStealthProfile } from "../stealth/profiles.js";
5
+ import { StealthCookieJar } from "./stealth-cookies.js";
6
6
  import { createProxyAuthIpDeniedError, createProxyEdgeAuthRejectedError, createProxyEdgeTlsRejectedError, createProxyPoolExhaustedError, createProxyPoolStaleError, isProxyAuthIpDeniedMessage, isProxyEdgeAuthRejectedMessage, isProxyEdgeTlsRejectedResponse, isProxyPoolRefreshableError, isProxyPoolStaleMessage, isProxyPoolStaleStatus, PROXY_EDGE_AUTH_REJECTED_CODE, PROXY_POOL_STALE_CODE, } from "./proxy-errors.js";
7
7
  import { computeProxyAttemptIndex, computeProxyTransportRetryDelayMs, createDefaultProxyTransportRetryOptions, normalizeProxyTransportRetryOptions, shouldRetryProxyTransportAttempt, validateUnsafeProxyTransportRetryMethods, } from "./proxy-retry-policy.js";
8
8
  import { evaluateRedirectHop, isRedirectStatus, nextRedirectMethod, resolveRedirectUrl, } from "./redirects.js";
9
9
  import { isSensitiveKey, normalizeSensitiveParams, redactSensitiveError, redactSensitiveRequestError, redactSensitiveText, redactUrlQueryParams, serializeRequestUrl, } from "./request-options.js";
10
- const DEFAULT_PROFILE = "chrome-146";
10
+ export const DEFAULT_PROFILE = "chrome-146";
11
11
  const MISSING_PROXY_WARNING = "[provider-sdk] Provider requested proxy routing, but no proxy URL was configured. Continuing without proxy.";
12
12
  const MAX_POLICY_PROXY_POOL_REFRESHES = 1;
13
13
  const PROXY_CONNECT_FAILURE_CODE = "proxy_connect_failed";
@@ -22,6 +22,9 @@ const REDIRECT_BODY_HEADERS = new Set([
22
22
  "content-location",
23
23
  "content-type",
24
24
  ]);
25
+ function isRecord(value) {
26
+ return typeof value === "object" && value !== null;
27
+ }
25
28
  function sensitiveQueryParamNames(url) {
26
29
  const queryStart = url.indexOf("?");
27
30
  if (queryStart === -1)
@@ -43,132 +46,6 @@ const REMOVED_CHROME_PROFILE_NAMES = new Set([
43
46
  "chrome-130-psk",
44
47
  "edge-131",
45
48
  ]);
46
- function isRecord(value) {
47
- return typeof value === "object" && value !== null;
48
- }
49
- const LEGACY_COOKIE_ORIGIN = "https://legacy-cookie.invalid/";
50
- class CookieJarImpl {
51
- cookies;
52
- defaultUrl;
53
- constructor(cookieStrings, defaultUrl = LEGACY_COOKIE_ORIGIN) {
54
- this.cookies = new ToughCookieJar(undefined, {
55
- allowSecureOnLocal: false,
56
- rejectPublicSuffixes: true,
57
- });
58
- this.defaultUrl = this.normalizeUrl(defaultUrl) ?? LEGACY_COOKIE_ORIGIN;
59
- this.setFromCookieStrings(cookieStrings);
60
- }
61
- /**
62
- * URL-less legacy operations are scoped to this jar's default URL. Session
63
- * jars use the client's base URL and response jars use the response URL. A
64
- * flat restore has no attributes to recover, so it creates host-only Path=/
65
- * cookies for that default URL instead of making them visible to every host.
66
- */
67
- setFromCookieStrings(cookieStrings, url = this.defaultUrl) {
68
- const cookieUrl = this.normalizeUrl(url);
69
- if (!cookieUrl)
70
- return;
71
- for (const cookieString of cookieStrings) {
72
- this.cookies.setCookieSync(cookieString, cookieUrl, { ignoreError: true });
73
- }
74
- }
75
- get(name, url) {
76
- return this.getAll(url)[name];
77
- }
78
- getAll(url) {
79
- return Object.fromEntries(this.getUniqueCookies(url ?? this.defaultUrl).map((cookie) => [cookie.key, cookie.value]));
80
- }
81
- has(name, url) {
82
- return Object.hasOwn(this.getAll(url), name);
83
- }
84
- toString(url) {
85
- return this.getUniqueCookies(url ?? this.defaultUrl)
86
- .map((cookie) => cookie.cookieString())
87
- .join("; ");
88
- }
89
- toHeader(url) {
90
- return this.toString(url);
91
- }
92
- snapshot() {
93
- // This compatibility view deliberately enumerates the serialized store,
94
- // not getAll(defaultUrl): persistence must include sibling hosts and paths.
95
- // Duplicate names still collapse because a flat map cannot represent them.
96
- const entries = [];
97
- for (const cookie of this.serialize().jar.cookies) {
98
- if (typeof cookie.key === "string" && typeof cookie.value === "string" && cookie.key) {
99
- entries.push([cookie.key, cookie.value]);
100
- }
101
- }
102
- return Object.fromEntries(entries);
103
- }
104
- restore(cookies) {
105
- this.clear();
106
- for (const [name, value] of Object.entries(cookies)) {
107
- if (!name)
108
- continue;
109
- this.cookies.setCookieSync(new Cookie({ key: name, path: "/", value }), this.defaultUrl, {
110
- ignoreError: true,
111
- });
112
- }
113
- }
114
- serialize() {
115
- const jar = this.cookies.serializeSync();
116
- if (!jar) {
117
- throw new SDKError("Stealth cookie store could not be serialized", {
118
- code: "stealth_cookie_store_serialize_failed",
119
- });
120
- }
121
- return { version: 1, jar };
122
- }
123
- deserialize(state) {
124
- const version = isRecord(state) ? state.version : undefined;
125
- if (version !== 1) {
126
- throw new StealthCookieStoreVersionError(version);
127
- }
128
- // Deserialize into a new jar first so invalid state cannot partially clear
129
- // or replace a live session. tough-cookie restores the cookie attributes and
130
- // matching semantics represented in its own serialized format.
131
- const restored = ToughCookieJar.deserializeSync(state.jar);
132
- // tough-cookie 6 does not include this option in serializeSync(). Preserve
133
- // the SDK's stricter setting across restoration.
134
- Reflect.set(restored, "allowSecureOnLocal", false);
135
- this.cookies = restored;
136
- }
137
- clear() {
138
- this.cookies.removeAllCookiesSync();
139
- }
140
- find(predicate, url) {
141
- for (const cookie of this.getUniqueCookies(url ?? this.defaultUrl)) {
142
- const cookieString = cookie.cookieString();
143
- if (predicate(cookieString)) {
144
- return cookieString;
145
- }
146
- }
147
- return undefined;
148
- }
149
- normalizeUrl(url) {
150
- try {
151
- return new URL(url).toString();
152
- }
153
- catch {
154
- return undefined;
155
- }
156
- }
157
- getUniqueCookies(url) {
158
- const cookieUrl = this.normalizeUrl(url);
159
- if (!cookieUrl)
160
- return [];
161
- // tough-cookie returns longer (more-specific) paths first. Keeping the
162
- // first cookie for each name prevents ambiguous duplicate-name headers.
163
- const names = new Set();
164
- return this.cookies.getCookiesSync(cookieUrl).filter((cookie) => {
165
- if (names.has(cookie.key))
166
- return false;
167
- names.add(cookie.key);
168
- return true;
169
- });
170
- }
171
- }
172
49
  let wreqModulePromise;
173
50
  function getWreqModule() {
174
51
  if (!wreqModulePromise) {
@@ -325,7 +202,7 @@ function splitCombinedSetCookieHeader(headerValue) {
325
202
  }
326
203
  export async function normalizeResponse(response, requestUrl, maxBodyBytes) {
327
204
  const headers = Object.fromEntries(response.headers.entries());
328
- const cookies = new CookieJarImpl(setCookieHeadersFromResponse(response.headers), response.url ?? requestUrl);
205
+ const cookies = new StealthCookieJar(setCookieHeadersFromResponse(response.headers), response.url ?? requestUrl);
329
206
  const bodyBytes = maxBodyBytes === undefined
330
207
  ? await response.arrayBuffer()
331
208
  : await readResponseBodyWithLimit(response, maxBodyBytes);
@@ -669,7 +546,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
669
546
  let closed = false;
670
547
  let hasWarnedMissingProxy = false;
671
548
  const warn = clientOptions.warn ?? console.warn;
672
- const cookieJar = new CookieJarImpl([], baseUrl);
549
+ const cookieJar = new StealthCookieJar([], baseUrl);
673
550
  async function getClientEntry(profileName, proxyUrl, ignoreTlsErrors) {
674
551
  if (closed) {
675
552
  throw new TransportError("Stealth session is closed", { status: 0 });
package/dist/serve.d.ts CHANGED
@@ -1 +1 @@
1
- export { createServerApp, type ServeOptions, serve } from "./server/serve.js";
1
+ export { createServerApp, createServerAppAsync, type ServeOptions, serve, } from "./server/serve.js";
package/dist/serve.js CHANGED
@@ -1 +1 @@
1
- export { createServerApp, serve } from "./server/serve.js";
1
+ export { createServerApp, createServerAppAsync, serve, } from "./server/serve.js";
@@ -1,4 +1,4 @@
1
- export { createServerApp, ERROR_OBSERVABILITY_HEADER, type ErrorObservabilityDetails, type ProviderServerCloseOptions, type ProviderServerHandle, type ProviderServerLogEvent, type ProviderServerLogger, type ProviderServerOperationExecutor, type ProviderServerOperationExecutorInput, type ProviderServerOptions, type ProviderServerStatefulForwardEnvelope, type ServeOptions, serve, } from "./serve.js";
1
+ export { createServerApp, createServerAppAsync, ERROR_OBSERVABILITY_HEADER, type ErrorObservabilityDetails, type ProviderServerCloseOptions, type ProviderServerHandle, type ProviderServerLogEvent, type ProviderServerLogger, type ProviderServerOperationExecutor, type ProviderServerOperationExecutorInput, type ProviderServerOptions, type ProviderServerStatefulForwardEnvelope, type ServeOptions, serve, } from "./serve.js";
2
2
  export { computeSelfTestPlanDigest, createSelfTestApp, createSelfTestAuthFlowInvoke, createSelfTestInvoke, DEFAULT_SELF_TEST_REQUEST_BUDGET_MS, isSelfTestReadOnlyOperation, PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV, resolveSelfTestPort, SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON, SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON, SELF_TEST_HEALTHZ_PATH, SELF_TEST_PATH, SELF_TEST_SCHEMA_VERSION, type SelfTestAppOptions, type SelfTestAuthFlowInvoke, type SelfTestAuthFlowRoute, type SelfTestCaseResult, type SelfTestCaseStatus, type SelfTestOperationInvoke, type SelfTestRequest, SelfTestRequestSchema, type SelfTestResponse, } from "./self-test.js";
3
3
  export { type InputDateTokenCalendar, resolveHealthCheckInputDateTokens, } from "./self-test-input-tokens.js";
4
4
  export { collectSelfTestSensitiveValues, redactSelfTestText, SELF_TEST_MAX_TEXT_LENGTH, SELF_TEST_REDACTED_PLACEHOLDER, } from "./self-test-redaction.js";
@@ -1,4 +1,4 @@
1
- export { createServerApp, ERROR_OBSERVABILITY_HEADER, serve, } from "./serve.js";
1
+ export { createServerApp, createServerAppAsync, ERROR_OBSERVABILITY_HEADER, serve, } from "./serve.js";
2
2
  export { computeSelfTestPlanDigest, createSelfTestApp, createSelfTestAuthFlowInvoke, createSelfTestInvoke, DEFAULT_SELF_TEST_REQUEST_BUDGET_MS, isSelfTestReadOnlyOperation, PROVIDER_RUNTIME_SELF_TEST_REQUEST_BUDGET_MS_ENV, resolveSelfTestPort, SELF_TEST_AUTH_FLOW_MULTI_TURN_SKIP_REASON, SELF_TEST_AUTH_FLOW_REJECTED_SKIP_REASON, SELF_TEST_HEALTHZ_PATH, SELF_TEST_PATH, SELF_TEST_SCHEMA_VERSION, SelfTestRequestSchema, } from "./self-test.js";
3
3
  export { resolveHealthCheckInputDateTokens, } from "./self-test-input-tokens.js";
4
4
  export { collectSelfTestSensitiveValues, redactSelfTestText, SELF_TEST_MAX_TEXT_LENGTH, SELF_TEST_REDACTED_PLACEHOLDER, } from "./self-test-redaction.js";
@@ -51,11 +51,21 @@ export interface SelfTestResponse {
51
51
  result?: SelfTestCaseResult;
52
52
  results: SelfTestCaseResult[];
53
53
  }
54
+ export interface SelfTestCancellationLogEvent {
55
+ level: "info";
56
+ event: "self_test_run_cancelled";
57
+ providerId: string;
58
+ requestId: string;
59
+ operationId: string;
60
+ caseName: string;
61
+ reason: string;
62
+ }
54
63
  export type SelfTestOperationInvoke = (args: {
55
64
  operationId: string;
56
65
  input: unknown;
57
66
  connection?: OperationConnection;
58
67
  requestId: string;
68
+ signal?: AbortSignal;
59
69
  }) => Promise<{
60
70
  status: number;
61
71
  data: unknown;
@@ -78,6 +88,7 @@ export type SelfTestAuthFlowInvoke = (args: {
78
88
  externalRef?: string;
79
89
  input?: Record<string, unknown>;
80
90
  context?: Record<string, unknown>;
91
+ signal?: AbortSignal;
81
92
  }) => Promise<{
82
93
  status: number;
83
94
  body: unknown;
@@ -112,6 +123,8 @@ export interface SelfTestAppOptions {
112
123
  requestBudgetMs?: number;
113
124
  /** Env override for secret collection + budget resolution (tests). */
114
125
  env?: Readonly<Record<string, string | undefined>>;
126
+ /** Structured server logger; defaults to the same JSON console shape as the provider server. */
127
+ logger?: (event: SelfTestCancellationLogEvent) => void;
115
128
  }
116
129
  /**
117
130
  * Stable sha256 over the provider's declared health plan (operations, case
@@ -132,8 +132,8 @@ export function isSelfTestReadOnlyOperation(operation) {
132
132
  }
133
133
  /** Binds the self-test executor to a tenant app's /v1 pipeline in-process. */
134
134
  export function createSelfTestInvoke(app) {
135
- return async ({ operationId, input, connection, requestId }) => {
136
- const response = await app.request(`/v1/${encodeURIComponent(operationId)}`, {
135
+ return async ({ operationId, input, connection, requestId, signal }) => {
136
+ const responsePromise = Promise.resolve(app.request(`/v1/${encodeURIComponent(operationId)}`, {
137
137
  method: "POST",
138
138
  headers: { "content-type": "application/json" },
139
139
  body: JSON.stringify({
@@ -141,7 +141,14 @@ export function createSelfTestInvoke(app) {
141
141
  input: input ?? {},
142
142
  ...(connection ? { connection } : {}),
143
143
  }),
144
- });
144
+ ...(signal ? { signal } : {}),
145
+ }));
146
+ const response = await responsePromise;
147
+ if (signal?.aborted) {
148
+ await cancelSelfTestResponse(response);
149
+ signal.throwIfAborted();
150
+ }
151
+ // An abort after body consumption starts may let response.text() finish.
145
152
  const text = await response.text();
146
153
  let body = text;
147
154
  try {
@@ -161,9 +168,36 @@ export function createSelfTestInvoke(app) {
161
168
  return { status: response.status, data: body };
162
169
  };
163
170
  }
171
+ async function cancelSelfTestResponse(response) {
172
+ await response.body?.cancel().catch(() => undefined);
173
+ }
174
+ function selfTestAbortReason(reason, sensitiveValues) {
175
+ const text = reason instanceof Error
176
+ ? reason.message || reason.name
177
+ : reason === undefined
178
+ ? "aborted"
179
+ : String(reason);
180
+ return redactSelfTestText(text, sensitiveValues);
181
+ }
182
+ function logSelfTestCancellation(provider, options, requestId, selected, signal, sensitiveValues) {
183
+ const event = {
184
+ level: "info",
185
+ event: "self_test_run_cancelled",
186
+ providerId: provider.id,
187
+ requestId,
188
+ operationId: selected.operationId,
189
+ caseName: selected.healthCase.name,
190
+ reason: selfTestAbortReason(signal.reason, sensitiveValues),
191
+ };
192
+ if (options.logger) {
193
+ options.logger(event);
194
+ return;
195
+ }
196
+ console.log(JSON.stringify(event));
197
+ }
164
198
  /** Binds the self-test auth-flow driver to a tenant app's /auth pipeline in-process. */
165
199
  export function createSelfTestAuthFlowInvoke(app) {
166
- return async ({ route, requestId, flowId, connectionId, externalRef, input, context }) => {
200
+ return async ({ route, requestId, flowId, connectionId, externalRef, input, context, signal, }) => {
167
201
  const response = await app.request(`/auth/${route}`, {
168
202
  method: "POST",
169
203
  headers: { "content-type": "application/json" },
@@ -175,6 +209,7 @@ export function createSelfTestAuthFlowInvoke(app) {
175
209
  ...(input ? { input } : {}),
176
210
  ...(context ? { context } : {}),
177
211
  }),
212
+ ...(signal ? { signal } : {}),
178
213
  });
179
214
  const text = await response.text();
180
215
  let body = text;
@@ -193,19 +228,32 @@ class SelfTestCaseTimeoutError extends Error {
193
228
  this.name = "SelfTestCaseTimeoutError";
194
229
  }
195
230
  }
196
- async function withCaseTimeout(run, timeoutMs) {
231
+ async function withCaseTimeout(run, timeoutMs, controller) {
197
232
  let timer;
233
+ let onAbort;
198
234
  try {
199
235
  return await Promise.race([
200
236
  run(),
201
237
  new Promise((_, reject) => {
202
- timer = setTimeout(() => reject(new SelfTestCaseTimeoutError(timeoutMs)), timeoutMs);
238
+ timer = setTimeout(() => {
239
+ const error = new SelfTestCaseTimeoutError(timeoutMs);
240
+ controller.abort(error);
241
+ reject(error);
242
+ }, timeoutMs);
243
+ }),
244
+ new Promise((_, reject) => {
245
+ onAbort = () => reject(controller.signal.reason);
246
+ controller.signal.addEventListener("abort", onAbort, { once: true });
247
+ if (controller.signal.aborted)
248
+ onAbort();
203
249
  }),
204
250
  ]);
205
251
  }
206
252
  finally {
207
253
  if (timer !== undefined)
208
254
  clearTimeout(timer);
255
+ if (onAbort)
256
+ controller.signal.removeEventListener("abort", onAbort);
209
257
  }
210
258
  }
211
259
  function objectProperty(value, key) {
@@ -389,6 +437,7 @@ async function materializeFlowCredential(execution, inputs, options = {}) {
389
437
  flowId,
390
438
  ...(options.connectionId ? { connectionId: options.connectionId } : {}),
391
439
  ...(options.externalRef ? { externalRef: options.externalRef } : {}),
440
+ ...(options.signal ? { signal: options.signal } : {}),
392
441
  }));
393
442
  if (!started.ok) {
394
443
  return { kind: "flow_error", code: started.code, message: started.message };
@@ -460,6 +509,7 @@ async function materializeFlowCredential(execution, inputs, options = {}) {
460
509
  ...(options.externalRef ? { externalRef: options.externalRef } : {}),
461
510
  input: submitInputs,
462
511
  ...(Object.keys(flowContext).length > 0 ? { context: flowContext } : {}),
512
+ ...(options.signal ? { signal: options.signal } : {}),
463
513
  }));
464
514
  if (!continued.ok) {
465
515
  // Providers built with defineCredentialsAuth cannot return a retry
@@ -613,6 +663,7 @@ async function resolveSelfTestConnection(execution, operationId, suite, options
613
663
  ...(options.isAbandoned !== undefined ? { isAbandoned: options.isAbandoned } : {}),
614
664
  connectionId,
615
665
  externalRef: `${execution.provider.id}-${operationId}-self-test`,
666
+ ...(options.signal ? { signal: options.signal } : {}),
616
667
  });
617
668
  if (!("credential" in materialized)) {
618
669
  // Only the multi-turn SKIP is negative-cached. Flow ERRORS
@@ -632,10 +683,9 @@ async function resolveSelfTestConnection(execution, operationId, suite, options
632
683
  }
633
684
  return materialized;
634
685
  }
635
- // A flow that outlived the case deadline still completes here (the timeout
636
- // only races the promise, it cannot cancel it). The case already reported
637
- // self_test_timeout caching this credential would let the next probe
638
- // reuse a login whose latency just failed the case, hiding the failure.
686
+ // Abort is cooperative: a flow may still complete after the case deadline
687
+ // if provider code ignores the signal. Never cache that late credential or
688
+ // let the next probe hide the timed-out login.
639
689
  if (options.isAbandoned?.() === true) {
640
690
  return {
641
691
  kind: "flow_error",
@@ -659,7 +709,7 @@ async function resolveSelfTestConnection(execution, operationId, suite, options
659
709
  function isAuthFailureCaseResult(result) {
660
710
  return result.status === "failed" && (result.httpStatus === 401 || result.httpStatus === 403);
661
711
  }
662
- async function executeSelfTestCase(execution, operationId, suite, healthCase) {
712
+ async function executeSelfTestCase(execution, operationId, suite, healthCase, caseController) {
663
713
  const { provider, invoke } = execution;
664
714
  // execution.sensitiveValues may grow while the case runs (flow-issued
665
715
  // secrets); redact always reads the live array.
@@ -671,6 +721,7 @@ async function executeSelfTestCase(execution, operationId, suite, healthCase) {
671
721
  // a 30s case must never take ~4×30s across its stages.
672
722
  const caseDeadlineAtMs = performance.now() + timeoutMs;
673
723
  const remainingCaseTimeoutMs = () => Math.max(1, Math.ceil(caseDeadlineAtMs - performance.now()));
724
+ const runWithCaseTimeout = (run, remainingMs) => withCaseTimeout(run, remainingMs, caseController);
674
725
  const beginCase = () => {
675
726
  const startedAt = new Date().toISOString();
676
727
  const startedAtMs = performance.now();
@@ -695,14 +746,15 @@ async function executeSelfTestCase(execution, operationId, suite, healthCase) {
695
746
  });
696
747
  }
697
748
  const resolveConnection = async (forceLogin) => {
698
- // The timeout only races the flow promise it cannot cancel it. Once
699
- // the deadline fires, the still-running resolution is marked abandoned
700
- // so its late completion cannot write the session cache.
749
+ // The deadline aborts the in-process flow request. Keep the abandonment
750
+ // guard as a backstop for provider code that ignores cancellation so a
751
+ // late completion still cannot write the session cache.
701
752
  let abandoned = false;
702
753
  try {
703
- return await withCaseTimeout(() => resolveSelfTestConnection(execution, operationId, suite, {
754
+ return await runWithCaseTimeout(() => resolveSelfTestConnection(execution, operationId, suite, {
704
755
  forceLogin,
705
756
  isAbandoned: () => abandoned,
757
+ signal: caseController.signal,
706
758
  }), remainingCaseTimeoutMs());
707
759
  }
708
760
  catch (error) {
@@ -739,7 +791,7 @@ async function executeSelfTestCase(execution, operationId, suite, healthCase) {
739
791
  // operation attempt, or a slow login reads as a fast healthy case.
740
792
  const { startedAtMs, finish } = caseScope;
741
793
  try {
742
- return await withCaseTimeout(async () => {
794
+ return await runWithCaseTimeout(async () => {
743
795
  const resolvedInput = resolveHealthCheckInputDateTokens(healthCase.input);
744
796
  const preparedInput = healthCase.prepareInput
745
797
  ? await healthCase.prepareInput({
@@ -758,6 +810,7 @@ async function executeSelfTestCase(execution, operationId, suite, healthCase) {
758
810
  input: gatewayInput,
759
811
  connection,
760
812
  requestId: `${execution.requestId}-prepare-${randomUUID()}`,
813
+ signal: caseController.signal,
761
814
  });
762
815
  if (executed.status === 401 || executed.status === 403) {
763
816
  prepareAuthStatus = executed.status;
@@ -777,6 +830,7 @@ async function executeSelfTestCase(execution, operationId, suite, healthCase) {
777
830
  input: preparedInput,
778
831
  connection,
779
832
  requestId: `${execution.requestId}-${randomUUID()}`,
833
+ signal: caseController.signal,
780
834
  });
781
835
  const durationMs = performance.now() - startedAtMs;
782
836
  if (executed.status < 200 || executed.status >= 300) {
@@ -1054,41 +1108,65 @@ export function createSelfTestApp(provider, options) {
1054
1108
  };
1055
1109
  const deadline = performance.now() + requestBudgetMs;
1056
1110
  const results = [];
1111
+ const runSignal = c.req.raw.signal;
1112
+ let interruptedCase;
1057
1113
  // Sequential execution (parallelism 1): self-tests run on serving pods
1058
1114
  // and must never compete with themselves for upstream quota.
1059
1115
  for (const selected of selection.cases) {
1060
- if (performance.now() >= deadline) {
1061
- const now = new Date().toISOString();
1062
- results.push({
1063
- operationId: selected.operationId,
1064
- caseName: selected.healthCase.name,
1065
- status: "skipped",
1066
- label: selected.healthCase.name,
1067
- responseTimeMs: 0,
1068
- skipReason: "budget_exhausted",
1069
- startedAt: now,
1070
- finishedAt: now,
1071
- });
1072
- continue;
1116
+ const caseController = new AbortController();
1117
+ const abortFromRun = () => caseController.abort(runSignal.reason);
1118
+ runSignal.addEventListener("abort", abortFromRun, { once: true });
1119
+ if (runSignal.aborted)
1120
+ abortFromRun();
1121
+ try {
1122
+ if (runSignal.aborted) {
1123
+ interruptedCase = selected;
1124
+ break;
1125
+ }
1126
+ if (performance.now() >= deadline) {
1127
+ const now = new Date().toISOString();
1128
+ results.push({
1129
+ operationId: selected.operationId,
1130
+ caseName: selected.healthCase.name,
1131
+ status: "skipped",
1132
+ label: selected.healthCase.name,
1133
+ responseTimeMs: 0,
1134
+ skipReason: "budget_exhausted",
1135
+ startedAt: now,
1136
+ finishedAt: now,
1137
+ });
1138
+ continue;
1139
+ }
1140
+ if (!isSelfTestReadOnlyOperation(selected.operation)) {
1141
+ const now = new Date().toISOString();
1142
+ results.push({
1143
+ operationId: selected.operationId,
1144
+ caseName: selected.healthCase.name,
1145
+ status: "error",
1146
+ label: selected.healthCase.name,
1147
+ responseTimeMs: 0,
1148
+ error: {
1149
+ code: "operation_not_read_only",
1150
+ message: `Operation "${selected.operationId}" is not classified read-only; self-test refuses to execute it.`,
1151
+ },
1152
+ startedAt: now,
1153
+ finishedAt: now,
1154
+ });
1155
+ continue;
1156
+ }
1157
+ results.push(await executeSelfTestCase(execution, selected.operationId, selected.suite, selected.healthCase, caseController));
1158
+ if (runSignal.aborted) {
1159
+ interruptedCase = selected;
1160
+ break;
1161
+ }
1073
1162
  }
1074
- if (!isSelfTestReadOnlyOperation(selected.operation)) {
1075
- const now = new Date().toISOString();
1076
- results.push({
1077
- operationId: selected.operationId,
1078
- caseName: selected.healthCase.name,
1079
- status: "error",
1080
- label: selected.healthCase.name,
1081
- responseTimeMs: 0,
1082
- error: {
1083
- code: "operation_not_read_only",
1084
- message: `Operation "${selected.operationId}" is not classified read-only; self-test refuses to execute it.`,
1085
- },
1086
- startedAt: now,
1087
- finishedAt: now,
1088
- });
1089
- continue;
1163
+ finally {
1164
+ runSignal.removeEventListener("abort", abortFromRun);
1165
+ caseController.abort();
1090
1166
  }
1091
- results.push(await executeSelfTestCase(execution, selected.operationId, selected.suite, selected.healthCase));
1167
+ }
1168
+ if (interruptedCase) {
1169
+ logSelfTestCancellation(provider, options, request.requestId, interruptedCase, runSignal, execution.sensitiveValues);
1092
1170
  }
1093
1171
  const singleCase = request.operationId !== undefined && request.caseName !== undefined;
1094
1172
  const response = {