@apifuse/provider-sdk 2.2.0-beta.58 → 2.2.0-beta.59

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 (106) hide show
  1. package/AUTHORING.md +9 -0
  2. package/CHANGELOG.md +4 -0
  3. package/README.md +18 -2
  4. package/bin/apifuse-pack-types.ts +49 -0
  5. package/bin/apifuse-record.ts +6 -0
  6. package/dist/cli/templates/provider/README.md.tpl +15 -3
  7. package/dist/config/loader.d.ts +7 -0
  8. package/dist/config/loader.js +30 -6
  9. package/dist/declaration-validation.d.ts +2 -1
  10. package/dist/declaration-validation.js +39 -0
  11. package/dist/define.d.ts +4 -4
  12. package/dist/define.js +25 -1
  13. package/dist/engine.d.ts +16 -0
  14. package/dist/engine.js +56 -1
  15. package/dist/error-resolution.js +13 -0
  16. package/dist/index.d.ts +6 -4
  17. package/dist/index.js +4 -2
  18. package/dist/lint.js +2 -2
  19. package/dist/provider.d.ts +3 -3
  20. package/dist/provider.js +2 -2
  21. package/dist/runtime/bounded-expiring-map.d.ts +21 -0
  22. package/dist/runtime/bounded-expiring-map.js +64 -0
  23. package/dist/runtime/cache.d.ts +2 -0
  24. package/dist/runtime/cache.js +8 -1
  25. package/dist/runtime/egress-lease.d.ts +63 -0
  26. package/dist/runtime/egress-lease.js +233 -0
  27. package/dist/runtime/http.js +1 -0
  28. package/dist/runtime/proxy-retry-policy.d.ts +4 -1
  29. package/dist/runtime/proxy-retry-policy.js +11 -2
  30. package/dist/runtime/request-telemetry.d.ts +7 -9
  31. package/dist/runtime/resolver-config.d.ts +7 -0
  32. package/dist/runtime/resolver-config.js +8 -0
  33. package/dist/runtime/resolver-public.d.ts +3 -1
  34. package/dist/runtime/resolver-public.js +1 -1
  35. package/dist/runtime/resolver-shared.d.ts +9 -0
  36. package/dist/runtime/resolver-shared.js +57 -0
  37. package/dist/runtime/resolver-telemetry.d.ts +134 -0
  38. package/dist/runtime/resolver-telemetry.js +204 -0
  39. package/dist/runtime/resolver-usage.d.ts +24 -0
  40. package/dist/runtime/resolver-usage.js +60 -0
  41. package/dist/runtime/resolver-vendors/bindings.d.ts +5 -0
  42. package/dist/runtime/resolver-vendors/bindings.js +5 -0
  43. package/dist/runtime/resolver-vendors/capsolver.d.ts +2 -2
  44. package/dist/runtime/resolver-vendors/capsolver.js +21 -9
  45. package/dist/runtime/resolver-vendors/hypersolutions.d.ts +27 -0
  46. package/dist/runtime/resolver-vendors/hypersolutions.js +369 -0
  47. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +2 -2
  48. package/dist/runtime/resolver-vendors/twocaptcha.js +26 -10
  49. package/dist/runtime/resolver-vendors/types.d.ts +35 -5
  50. package/dist/runtime/resolver-vendors/types.js +20 -10
  51. package/dist/runtime/resolver.d.ts +5 -1
  52. package/dist/runtime/resolver.js +397 -109
  53. package/dist/runtime/stealth-akamai-sbsd.d.ts +54 -0
  54. package/dist/runtime/stealth-akamai-sbsd.js +142 -0
  55. package/dist/runtime/stealth.js +547 -53
  56. package/dist/schema.d.ts +9 -0
  57. package/dist/schema.js +16 -0
  58. package/dist/server/index.d.ts +2 -0
  59. package/dist/server/index.js +1 -0
  60. package/dist/server/self-test.d.ts +4 -0
  61. package/dist/server/self-test.js +10 -1
  62. package/dist/server/serve-implementation.d.ts +7 -1
  63. package/dist/server/serve-implementation.js +181 -53
  64. package/dist/server/types.d.ts +6 -0
  65. package/dist/server/types.js +14 -0
  66. package/dist/stealth/profiles.d.ts +7 -1
  67. package/dist/stealth/profiles.js +9 -0
  68. package/dist/testing/run.js +6 -0
  69. package/dist/types.d.ts +106 -10
  70. package/package.json +1 -1
  71. package/src/cli/templates/provider/README.md.tpl +15 -3
  72. package/src/config/loader.ts +34 -6
  73. package/src/declaration-validation.ts +47 -1
  74. package/src/define.ts +45 -3
  75. package/src/engine.ts +75 -1
  76. package/src/error-resolution.ts +13 -0
  77. package/src/index.ts +32 -1
  78. package/src/lint.ts +2 -2
  79. package/src/provider.ts +9 -0
  80. package/src/runtime/bounded-expiring-map.ts +67 -0
  81. package/src/runtime/cache.ts +10 -1
  82. package/src/runtime/egress-lease.ts +336 -0
  83. package/src/runtime/http.ts +1 -0
  84. package/src/runtime/proxy-retry-policy.ts +12 -1
  85. package/src/runtime/request-telemetry.ts +5 -7
  86. package/src/runtime/resolver-config.ts +12 -0
  87. package/src/runtime/resolver-public.ts +21 -0
  88. package/src/runtime/resolver-shared.ts +75 -0
  89. package/src/runtime/resolver-telemetry.ts +362 -0
  90. package/src/runtime/resolver-usage.ts +96 -0
  91. package/src/runtime/resolver-vendors/bindings.ts +5 -0
  92. package/src/runtime/resolver-vendors/capsolver.ts +22 -3
  93. package/src/runtime/resolver-vendors/hypersolutions.ts +480 -0
  94. package/src/runtime/resolver-vendors/twocaptcha.ts +26 -12
  95. package/src/runtime/resolver-vendors/types.ts +60 -12
  96. package/src/runtime/resolver.ts +465 -124
  97. package/src/runtime/stealth-akamai-sbsd.ts +207 -0
  98. package/src/runtime/stealth.ts +727 -95
  99. package/src/schema.ts +17 -0
  100. package/src/server/index.ts +21 -0
  101. package/src/server/self-test.ts +13 -0
  102. package/src/server/serve-implementation.ts +268 -48
  103. package/src/server/types.ts +14 -0
  104. package/src/stealth/profiles.ts +10 -0
  105. package/src/testing/run.ts +6 -0
  106. package/src/types.ts +124 -19
@@ -1,6 +1,18 @@
1
+ import type { ProviderChallengeKind } from "../types.js";
2
+
1
3
  export const APIFUSE__RESOLVER__2CAPTCHA__API_KEY = "APIFUSE__RESOLVER__2CAPTCHA__API_KEY";
2
4
  export const APIFUSE__RESOLVER__CAPSOLVER__API_KEY = "APIFUSE__RESOLVER__CAPSOLVER__API_KEY";
3
5
  export const APIFUSE__RESOLVER__CAPMONSTER__API_KEY = "APIFUSE__RESOLVER__CAPMONSTER__API_KEY";
6
+ export const APIFUSE__RESOLVER__HYPERSOLUTIONS__API_KEY =
7
+ "APIFUSE__RESOLVER__HYPERSOLUTIONS__API_KEY";
4
8
  export const APIFUSE__RESOLVER__TIMEOUT_MS = "APIFUSE__RESOLVER__TIMEOUT_MS";
5
9
  export const APIFUSE__CDP_POOL__URL = "APIFUSE__CDP_POOL__URL";
6
10
  export const DEFAULT_RESOLVER_TIMEOUT_MS = 180_000;
11
+
12
+ /**
13
+ * SBSD is solved on the provider's own admitted session, so the transport profile must be
14
+ * declared up front. Other kinds, including `akamai_sensor`, keep the profile optional.
15
+ */
16
+ export function kindRequiresClientProfile(kind: ProviderChallengeKind): boolean {
17
+ return kind === "akamai_sbsd";
18
+ }
@@ -3,6 +3,7 @@ export {
3
3
  APIFUSE__RESOLVER__2CAPTCHA__API_KEY,
4
4
  APIFUSE__RESOLVER__CAPMONSTER__API_KEY,
5
5
  APIFUSE__RESOLVER__CAPSOLVER__API_KEY,
6
+ APIFUSE__RESOLVER__HYPERSOLUTIONS__API_KEY,
6
7
  APIFUSE__RESOLVER__TIMEOUT_MS,
7
8
  bindResolverSignal,
8
9
  createResolverClient,
@@ -21,3 +22,23 @@ export {
21
22
  type ResolverRuntimeOptions,
22
23
  type ResolverSolutionSource,
23
24
  } from "./resolver.js";
25
+ export {
26
+ type ResolverCacheReadTelemetryEvent,
27
+ type ResolverCacheWriteTelemetryEvent,
28
+ type ResolverFailoverTelemetryEvent,
29
+ type ResolverIdentityTelemetryEvent,
30
+ type ResolverOutcomeTelemetryEvent,
31
+ type ResolverTelemetryAttemptOutcome,
32
+ type ResolverTelemetryCacheStatus,
33
+ type ResolverTelemetryCacheWriteReason,
34
+ type ResolverTelemetryErrorClass,
35
+ type ResolverTelemetryIdentitySource,
36
+ type ResolverTelemetryOutcome,
37
+ type ResolverTelemetryPhase,
38
+ type ResolverTelemetrySink,
39
+ type ResolverVendorAttemptTelemetryEvent,
40
+ } from "./resolver-telemetry.js";
41
+ export type {
42
+ ResolverPaidUsageContext,
43
+ ResolverVendorUnavailableReason,
44
+ } from "./resolver-vendors/types.js";
@@ -1,6 +1,81 @@
1
1
  import { ProviderError } from "../errors.js";
2
2
  import type { ChallengeSolution, ProviderChallenge, ResolverContext } from "../types.js";
3
3
  import type { TraceRecorder } from "./trace.js";
4
+ import type { ResolverTelemetryOutcome, ResolverTelemetrySink } from "./resolver-telemetry.js";
5
+
6
+ type ResolverTelemetryBinding = (sink: ResolverTelemetrySink) => ResolverContext;
7
+ const telemetryBindings = new WeakMap<ResolverContext, ResolverTelemetryBinding>();
8
+
9
+ /** Internal contract: reconstruct a chain with a new request sink, without mutating the host chain. */
10
+ export function registerResolverTelemetryBinding(
11
+ resolver: ResolverContext,
12
+ bind: ResolverTelemetryBinding,
13
+ ): void {
14
+ telemetryBindings.set(resolver, bind);
15
+ }
16
+
17
+ /** Preserve reconstruction across SDK wrappers without capturing their current request sink. */
18
+ export function inheritResolverTelemetryBinding(
19
+ resolver: ResolverContext,
20
+ wrapper: ResolverContext,
21
+ ): void {
22
+ const bind = telemetryBindings.get(resolver);
23
+ if (bind) telemetryBindings.set(wrapper, bind);
24
+ }
25
+
26
+ /** SDK chains expose each vendor invocation; opaque host resolvers expose one custom invocation. */
27
+ export function bindResolverTelemetry(
28
+ resolver: ResolverContext,
29
+ sink: ResolverTelemetrySink | undefined,
30
+ ): ResolverContext {
31
+ if (!sink) return resolver;
32
+ const metadata = (
33
+ resolver as ResolverContext & {
34
+ readonly [RESOLVER_INSTRUMENTATION_METADATA]?: { readonly target: ResolverContext };
35
+ }
36
+ )[RESOLVER_INSTRUMENTATION_METADATA];
37
+ if (metadata) return bindResolverTelemetry(metadata.target, sink);
38
+ const bind = telemetryBindings.get(resolver);
39
+ if (bind) return bind(sink);
40
+ const wrapper: ResolverSolveWithRecorder = {
41
+ async solve(challenge, signal, recorder) {
42
+ const startedAt = Date.now();
43
+ let outcome: ResolverTelemetryOutcome = "error";
44
+ try {
45
+ const solution = await (resolver as ResolverSolveWithRecorder).solve(
46
+ challenge,
47
+ signal,
48
+ recorder,
49
+ );
50
+ outcome = "solved";
51
+ return solution;
52
+ } catch (error) {
53
+ outcome = signal?.aborted
54
+ ? "aborted"
55
+ : error instanceof ProviderError && error.code === "RESOLVER_CHAIN_EXHAUSTED"
56
+ ? "exhausted"
57
+ : "error";
58
+ throw error;
59
+ } finally {
60
+ const ms = Date.now() - startedAt;
61
+ sink.recordVendorAttempt({
62
+ vendor: "custom",
63
+ phase: outcome === "solved" ? "poll_result" : "create_task",
64
+ outcome: outcome === "solved" ? "ok" : "error",
65
+ ms,
66
+ });
67
+ sink.recordOutcome({ outcome, solveMs: ms, challengeKind: challenge.kind });
68
+ }
69
+ },
70
+ };
71
+ // Rebinding this wrapper returns to the original host, never to its previous sink.
72
+ registerResolverTelemetryBinding(wrapper, createOpaqueResolverTelemetryBinding(resolver));
73
+ return wrapper;
74
+ }
75
+
76
+ function createOpaqueResolverTelemetryBinding(resolver: ResolverContext): ResolverTelemetryBinding {
77
+ return (sink) => bindResolverTelemetry(resolver, sink);
78
+ }
4
79
 
5
80
  export const RESOLVER_INSTRUMENTATION_METADATA = Symbol.for(
6
81
  "@apifuse/provider-sdk/runtime/resolver-instrumentation-metadata",
@@ -0,0 +1,362 @@
1
+ import { Buffer } from "node:buffer";
2
+ import type { ProviderChallengeKind, ProviderResolverVendor } from "../types.js";
3
+ import { closedEnum, type ClosedEnum, type TelemetryContributor } from "./request-telemetry.js";
4
+ import type { ResolverVendorUnavailableReason } from "./resolver-vendors/types.js";
5
+
6
+ /**
7
+ * Result of the resolver call. `solved` means the adapter returned a solution;
8
+ * for SBSD this means payload acceptance, even when `verified` is false.
9
+ * It does not assert that a later protected request succeeded. Stealth reports
10
+ * that verification separately through the refetch status and challenge outcome.
11
+ */
12
+ export type ResolverTelemetryOutcome = "solved" | "cached" | "exhausted" | "aborted" | "error";
13
+ export type ResolverTelemetryCacheStatus = "hit" | "miss" | "disabled" | "not_cacheable";
14
+ export type ResolverTelemetryIdentitySource = "declared" | "defaulted" | "none";
15
+ export type ResolverTelemetryPhase =
16
+ | "create_task"
17
+ | "poll_result"
18
+ | "cleanup"
19
+ | "measure_ip"
20
+ | "fetch_script"
21
+ | "generate_payload"
22
+ | "post_payload";
23
+ export type ResolverTelemetryAttemptOutcome = "ok" | "error";
24
+ export type ResolverTelemetryErrorClass =
25
+ | ResolverVendorUnavailableReason
26
+ | "aborted"
27
+ | "unexpected";
28
+ export type ResolverTelemetryCacheWriteReason = "no_expires" | "not_cacheable" | "error";
29
+
30
+ export type ResolverIdentityTelemetryEvent = {
31
+ readonly source: ResolverTelemetryIdentitySource;
32
+ readonly failure?: ResolverVendorUnavailableReason;
33
+ };
34
+
35
+ export type ResolverCacheReadTelemetryEvent = {
36
+ readonly status: ResolverTelemetryCacheStatus;
37
+ readonly challengeKind: ProviderChallengeKind;
38
+ };
39
+
40
+ export type ResolverCacheWriteTelemetryEvent = {
41
+ readonly written: boolean;
42
+ readonly reason?: ResolverTelemetryCacheWriteReason;
43
+ };
44
+
45
+ export type ResolverVendorAttemptTelemetryEvent = {
46
+ readonly vendor: ProviderResolverVendor;
47
+ readonly phase: ResolverTelemetryPhase;
48
+ readonly outcome: ResolverTelemetryAttemptOutcome;
49
+ readonly ms: number;
50
+ readonly pollCount?: number;
51
+ readonly errorClass?: ResolverTelemetryErrorClass;
52
+ readonly vendorErrorCode?: string;
53
+ readonly vendorErrorDescription?: string;
54
+ /** Operator-only diagnostics; free text is redacted, detached and bounded by the collector. */
55
+ readonly diagnostics?: {
56
+ readonly cause?: { readonly name: string; readonly message: string };
57
+ readonly upstreamHost?: string;
58
+ readonly missingFields?: readonly string[];
59
+ readonly round?: number;
60
+ /** One-based position in the supporting vendor chain. */
61
+ readonly attemptIndex?: number;
62
+ /** Actual adapter phase, including phases outside the header's closed enum. */
63
+ readonly phase?: string;
64
+ };
65
+ };
66
+
67
+ export type ResolverFailoverTelemetryEvent = {
68
+ readonly from: ProviderResolverVendor;
69
+ readonly to: ProviderResolverVendor;
70
+ readonly reason: ResolverVendorUnavailableReason;
71
+ };
72
+
73
+ export type ResolverOutcomeTelemetryEvent = {
74
+ readonly outcome: ResolverTelemetryOutcome;
75
+ readonly solveMs: number;
76
+ readonly challengeKind: ProviderChallengeKind;
77
+ };
78
+
79
+ export interface ResolverTelemetrySink {
80
+ recordIdentity(event: ResolverIdentityTelemetryEvent): void;
81
+ recordCacheRead(event: ResolverCacheReadTelemetryEvent): void;
82
+ recordCacheWrite(event: ResolverCacheWriteTelemetryEvent): void;
83
+ recordVendorAttempt(event: ResolverVendorAttemptTelemetryEvent): void;
84
+ recordFailover(event: ResolverFailoverTelemetryEvent): void;
85
+ recordOutcome(event: ResolverOutcomeTelemetryEvent): void;
86
+ }
87
+
88
+ export type ResolverAttemptSample = {
89
+ v: ProviderResolverVendor;
90
+ p: ResolverTelemetryPhase;
91
+ o: ResolverTelemetryAttemptOutcome;
92
+ ms: number;
93
+ c?: string;
94
+ e?: ResolverTelemetryErrorClass;
95
+ diagnostics?: ResolverVendorAttemptTelemetryEvent["diagnostics"];
96
+ };
97
+
98
+ export type ResolverTelemetryLogPayload = {
99
+ /** Adapter result; `solved` includes unverified payload acceptance. Read the protected refetch status/challenge outcome for verification. */
100
+ outcome?: ResolverTelemetryOutcome;
101
+ challengeKind?: ProviderChallengeKind;
102
+ cacheStatus?: ResolverTelemetryCacheStatus;
103
+ cacheWrite?: {
104
+ written: boolean;
105
+ reason?: ResolverTelemetryCacheWriteReason;
106
+ };
107
+ identitySource?: ResolverTelemetryIdentitySource;
108
+ identityFailure?: ResolverVendorUnavailableReason;
109
+ solveMs?: number;
110
+ attempts: number;
111
+ failovers: number;
112
+ vendorChain: ProviderResolverVendor[];
113
+ vendorUsed?: ProviderResolverVendor;
114
+ pollCount: number;
115
+ attemptSamples?: ResolverAttemptSample[];
116
+ attemptSamplesDropped?: number;
117
+ lastVendorErrorDescription?: string;
118
+ };
119
+
120
+ export type ResolverTelemetryHeaderPayload = {
121
+ /** Same adapter-result semantics as the log outcome; `solved` does not imply protected-refetch success. */
122
+ outcome?: ClosedEnum<ResolverTelemetryOutcome>;
123
+ cacheStatus?: ClosedEnum<ResolverTelemetryCacheStatus>;
124
+ solveMs?: number;
125
+ attempts: number;
126
+ failovers: number;
127
+ vendorUsed?: ClosedEnum<ProviderResolverVendor>;
128
+ vendorChain: ClosedEnum<ProviderResolverVendor>[];
129
+ pollCount: number;
130
+ identitySource?: ClosedEnum<ResolverTelemetryIdentitySource>;
131
+ attemptSamples?: {
132
+ v: ClosedEnum<ProviderResolverVendor>;
133
+ p: ClosedEnum<ResolverTelemetryPhase>;
134
+ o: ClosedEnum<ResolverTelemetryAttemptOutcome>;
135
+ ms: number;
136
+ c?: ClosedEnum<string>;
137
+ }[];
138
+ };
139
+
140
+ const MAX_RESOLVER_ATTEMPT_SAMPLES = 24;
141
+ const MAX_RESOLVER_VENDOR_CHAIN = 64;
142
+ const MAX_DIAGNOSTIC_TEXT = 300;
143
+ const MAX_MISSING_FIELDS = 24;
144
+ const HEADER_TOKEN = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/;
145
+
146
+ function boundedInteger(value: number): number {
147
+ return Math.max(0, Math.floor(Number.isFinite(value) ? value : 0));
148
+ }
149
+
150
+ function boundedText(value: string, redact?: (text: string) => string): string {
151
+ let result = value;
152
+ try {
153
+ result = redact?.(value) ?? value;
154
+ } catch {
155
+ return "[REDACTION_FAILED]";
156
+ }
157
+ // Copy code units through an independent UTF-16 buffer. A sliced string can
158
+ // otherwise keep a multi-megabyte vendor response alive in both JSC and V8.
159
+ // Preserve lone surrogates deliberately, including a pair split at the limit.
160
+ const length = Math.min(result.length, MAX_DIAGNOSTIC_TEXT);
161
+ const units = Buffer.alloc(length * 2);
162
+ for (let index = 0; index < length; index += 1) {
163
+ units.writeUInt16LE(result.charCodeAt(index), index * 2);
164
+ }
165
+ return units.toString("utf16le");
166
+ }
167
+
168
+ function boundedDiagnostics(
169
+ diagnostics: NonNullable<ResolverVendorAttemptTelemetryEvent["diagnostics"]>,
170
+ redact?: (text: string) => string,
171
+ ): NonNullable<ResolverAttemptSample["diagnostics"]> {
172
+ return {
173
+ ...(diagnostics.cause
174
+ ? {
175
+ cause: {
176
+ name: boundedText(diagnostics.cause.name, redact),
177
+ message: boundedText(diagnostics.cause.message, redact),
178
+ },
179
+ }
180
+ : {}),
181
+ ...(diagnostics.upstreamHost
182
+ ? { upstreamHost: boundedText(diagnostics.upstreamHost, redact) }
183
+ : {}),
184
+ ...(diagnostics.missingFields
185
+ ? {
186
+ missingFields: diagnostics.missingFields
187
+ .slice(0, MAX_MISSING_FIELDS)
188
+ .map((field) => boundedText(field, redact)),
189
+ }
190
+ : {}),
191
+ ...(diagnostics.round === undefined ? {} : { round: boundedInteger(diagnostics.round) }),
192
+ ...(diagnostics.attemptIndex === undefined
193
+ ? {}
194
+ : { attemptIndex: boundedInteger(diagnostics.attemptIndex) }),
195
+ ...(diagnostics.phase ? { phase: boundedText(diagnostics.phase, redact) } : {}),
196
+ };
197
+ }
198
+
199
+ export class ResolverTelemetryCollector
200
+ implements
201
+ ResolverTelemetrySink,
202
+ TelemetryContributor<ResolverTelemetryLogPayload, ResolverTelemetryHeaderPayload>
203
+ {
204
+ readonly key = "resolver" as const;
205
+ readonly #redact: ((text: string) => string) | undefined;
206
+ #identity: ResolverIdentityTelemetryEvent | undefined;
207
+ #cacheRead: ResolverCacheReadTelemetryEvent | undefined;
208
+ #cacheWrite: ResolverCacheWriteTelemetryEvent | undefined;
209
+ #outcome: ResolverOutcomeTelemetryEvent | undefined;
210
+ #attempts = 0;
211
+ #failovers = 0;
212
+ #pollCount = 0;
213
+ #vendorChain: ProviderResolverVendor[] = [];
214
+ #vendorUsed: ProviderResolverVendor | undefined;
215
+ #attemptSamples: ResolverAttemptSample[] = [];
216
+ #attemptSamplesDropped = 0;
217
+ #lastVendorErrorDescription: string | undefined;
218
+
219
+ constructor(options: { redact?: (text: string) => string } = {}) {
220
+ this.#redact = options.redact;
221
+ }
222
+
223
+ recordIdentity(event: ResolverIdentityTelemetryEvent): void {
224
+ this.#identity = { ...event };
225
+ }
226
+
227
+ recordCacheRead(event: ResolverCacheReadTelemetryEvent): void {
228
+ this.#cacheRead = { ...event };
229
+ }
230
+
231
+ recordCacheWrite(event: ResolverCacheWriteTelemetryEvent): void {
232
+ this.#cacheWrite = { ...event };
233
+ }
234
+
235
+ recordVendorAttempt(event: ResolverVendorAttemptTelemetryEvent): void {
236
+ const ms = boundedInteger(event.ms);
237
+ const pollCount = event.pollCount === undefined ? 0 : boundedInteger(event.pollCount);
238
+ this.#pollCount += pollCount;
239
+ if (event.phase !== "cleanup") {
240
+ this.#attempts += 1;
241
+ if (
242
+ this.#vendorChain.length < MAX_RESOLVER_VENDOR_CHAIN &&
243
+ this.#vendorChain.at(-1) !== event.vendor
244
+ ) {
245
+ this.#vendorChain.push(event.vendor);
246
+ }
247
+ if (event.outcome === "ok") this.#vendorUsed = event.vendor;
248
+ }
249
+ if (this.#attemptSamples.length < MAX_RESOLVER_ATTEMPT_SAMPLES) {
250
+ this.#attemptSamples.push({
251
+ v: event.vendor,
252
+ p: event.phase,
253
+ o: event.outcome,
254
+ ms,
255
+ ...(event.vendorErrorCode ? { c: boundedText(event.vendorErrorCode, this.#redact) } : {}),
256
+ ...(event.errorClass ? { e: event.errorClass } : {}),
257
+ ...(event.diagnostics
258
+ ? { diagnostics: boundedDiagnostics(event.diagnostics, this.#redact) }
259
+ : {}),
260
+ });
261
+ } else {
262
+ this.#attemptSamplesDropped += 1;
263
+ }
264
+ if (event.vendorErrorDescription) {
265
+ this.#lastVendorErrorDescription = boundedText(event.vendorErrorDescription, this.#redact);
266
+ }
267
+ }
268
+
269
+ recordFailover(_event: ResolverFailoverTelemetryEvent): void {
270
+ this.#failovers += 1;
271
+ }
272
+
273
+ recordOutcome(event: ResolverOutcomeTelemetryEvent): void {
274
+ this.#outcome = { ...event, solveMs: boundedInteger(event.solveMs) };
275
+ }
276
+
277
+ toLogPayload(): ResolverTelemetryLogPayload | undefined {
278
+ if (
279
+ !this.#identity &&
280
+ !this.#cacheRead &&
281
+ !this.#cacheWrite &&
282
+ !this.#outcome &&
283
+ this.#attempts === 0 &&
284
+ this.#attemptSamples.length === 0 &&
285
+ this.#failovers === 0
286
+ ) {
287
+ return undefined;
288
+ }
289
+ return {
290
+ ...(this.#outcome
291
+ ? {
292
+ outcome: this.#outcome.outcome,
293
+ challengeKind: this.#outcome.challengeKind,
294
+ solveMs: this.#outcome.solveMs,
295
+ }
296
+ : this.#cacheRead
297
+ ? { challengeKind: this.#cacheRead.challengeKind }
298
+ : {}),
299
+ ...(this.#cacheRead ? { cacheStatus: this.#cacheRead.status } : {}),
300
+ ...(this.#cacheWrite ? { cacheWrite: { ...this.#cacheWrite } } : {}),
301
+ ...(this.#identity
302
+ ? {
303
+ identitySource: this.#identity.source,
304
+ ...(this.#identity.failure ? { identityFailure: this.#identity.failure } : {}),
305
+ }
306
+ : {}),
307
+ attempts: this.#attempts,
308
+ failovers: this.#failovers,
309
+ vendorChain: [...this.#vendorChain],
310
+ ...(this.#vendorUsed ? { vendorUsed: this.#vendorUsed } : {}),
311
+ pollCount: this.#pollCount,
312
+ ...(this.#attemptSamples.length > 0
313
+ ? {
314
+ attemptSamples: this.#attemptSamples.map((sample) => ({
315
+ ...sample,
316
+ ...(sample.diagnostics
317
+ ? {
318
+ diagnostics: {
319
+ ...sample.diagnostics,
320
+ ...(sample.diagnostics.cause
321
+ ? { cause: { ...sample.diagnostics.cause } }
322
+ : {}),
323
+ ...(sample.diagnostics.missingFields
324
+ ? { missingFields: [...sample.diagnostics.missingFields] }
325
+ : {}),
326
+ },
327
+ }
328
+ : {}),
329
+ })),
330
+ }
331
+ : {}),
332
+ ...(this.#attemptSamplesDropped > 0
333
+ ? { attemptSamplesDropped: this.#attemptSamplesDropped }
334
+ : {}),
335
+ ...(this.#lastVendorErrorDescription
336
+ ? { lastVendorErrorDescription: this.#lastVendorErrorDescription }
337
+ : {}),
338
+ };
339
+ }
340
+
341
+ toHeaderPayload(log: ResolverTelemetryLogPayload): ResolverTelemetryHeaderPayload {
342
+ const attemptSamples = log.attemptSamples?.map((sample) => ({
343
+ v: closedEnum(sample.v),
344
+ p: closedEnum(sample.p),
345
+ o: closedEnum(sample.o),
346
+ ms: sample.ms,
347
+ ...(sample.c && HEADER_TOKEN.test(sample.c) ? { c: closedEnum(sample.c) } : {}),
348
+ }));
349
+ return {
350
+ ...(log.outcome ? { outcome: closedEnum(log.outcome) } : {}),
351
+ ...(log.cacheStatus ? { cacheStatus: closedEnum(log.cacheStatus) } : {}),
352
+ ...(log.solveMs === undefined ? {} : { solveMs: log.solveMs }),
353
+ attempts: log.attempts,
354
+ failovers: log.failovers,
355
+ ...(log.vendorUsed ? { vendorUsed: closedEnum(log.vendorUsed) } : {}),
356
+ vendorChain: log.vendorChain.map((vendor) => closedEnum(vendor)),
357
+ pollCount: log.pollCount,
358
+ ...(log.identitySource ? { identitySource: closedEnum(log.identitySource) } : {}),
359
+ ...(attemptSamples && attemptSamples.length > 0 ? { attemptSamples } : {}),
360
+ };
361
+ }
362
+ }
@@ -0,0 +1,96 @@
1
+ import type { ProviderChallengeKind, ProviderResolverVendor } from "../types.js";
2
+ import type {
3
+ ResolverPaidUsageContext,
4
+ ResolverVendorUnavailableError,
5
+ } from "./resolver-vendors/types.js";
6
+ import type { TraceRecorder } from "./trace.js";
7
+
8
+ export const RESOLVER_PAID_USAGE_SPAN = "resolver.usage";
9
+
10
+ export type ResolverPaidUsageOutcome = "success" | "vendor_error" | "timeout" | "abandoned";
11
+
12
+ export type ResolverPaidUsageEndpoint =
13
+ | "capsolver:create_task"
14
+ | "twocaptcha:create_task"
15
+ | "hyper:ip"
16
+ | "hyper:sbsd_create";
17
+
18
+ /**
19
+ * How one call to the endpoint is counted. `metered` endpoints are the vendors' task or
20
+ * payload creation calls: the SDK counts one unit per call (the vendor ledger stays
21
+ * authoritative for refunds of failed tasks). Hyper's `/ip` reflector is authenticated
22
+ * but has no published price, so it is recorded with 0 units and `billing: "unconfirmed"`
23
+ * rather than invented as a paid unit; the span still proves the call was made.
24
+ */
25
+ const ENDPOINT_BILLING: Record<
26
+ ResolverPaidUsageEndpoint,
27
+ { readonly billable_units: 0 | 1; readonly billing: "metered" | "unconfirmed" }
28
+ > = {
29
+ "capsolver:create_task": { billable_units: 1, billing: "metered" },
30
+ "twocaptcha:create_task": { billable_units: 1, billing: "metered" },
31
+ "hyper:ip": { billable_units: 0, billing: "unconfirmed" },
32
+ "hyper:sbsd_create": { billable_units: 1, billing: "metered" },
33
+ };
34
+
35
+ function errorOutcome(error: unknown, signal: AbortSignal): ResolverPaidUsageOutcome {
36
+ const signalReason = signal.reason;
37
+ const errorName = error instanceof Error ? error.name : "";
38
+ const reasonName = signalReason instanceof Error ? signalReason.name : "";
39
+ const unavailableReason = (error as Partial<ResolverVendorUnavailableError> | undefined)?.reason;
40
+ if (
41
+ unavailableReason === "timeout" ||
42
+ /timeout/iu.test(errorName) ||
43
+ /timeout/iu.test(reasonName)
44
+ ) {
45
+ return "timeout";
46
+ }
47
+ if (signal.aborted) return "abandoned";
48
+ return "vendor_error";
49
+ }
50
+
51
+ const warnedSinks = new WeakSet<(message: string) => void>();
52
+
53
+ /**
54
+ * Records one `resolver.usage` span around a paid vendor call. Callers validate credentials
55
+ * and transport first, so a span means a potentially billable request was attempted (a
56
+ * failed create the vendor may still bill is a span too). Without a trace recorder the call
57
+ * still runs; the lost billing record is warned once per warn sink.
58
+ */
59
+ export async function recordPaidResolverCreate<T>(options: {
60
+ readonly traceRecorder?: TraceRecorder;
61
+ readonly vendor: ProviderResolverVendor;
62
+ readonly kind: ProviderChallengeKind;
63
+ readonly endpoint: ResolverPaidUsageEndpoint;
64
+ /** 1-based round for endpoints called more than once per solve (Hyper payload rounds). */
65
+ readonly round?: number;
66
+ readonly signal: AbortSignal;
67
+ readonly usage?: ResolverPaidUsageContext;
68
+ readonly warn?: (message: string) => void;
69
+ readonly create: () => Promise<T>;
70
+ }): Promise<T> {
71
+ if (!options.traceRecorder) {
72
+ const warn = options.warn ?? console.warn;
73
+ if (!warnedSinks.has(warn)) {
74
+ warnedSinks.add(warn);
75
+ warn(
76
+ `[provider-sdk] Paid resolver call ${options.endpoint} ran without a trace recorder; its resolver.usage record is lost (reported once).`,
77
+ );
78
+ }
79
+ return options.create();
80
+ }
81
+ const baseAttributes = {
82
+ vendor: options.vendor,
83
+ challenge_kind: options.kind,
84
+ endpoint: options.endpoint,
85
+ ...ENDPOINT_BILLING[options.endpoint],
86
+ // Only the resolver chain knows the vendor's position; a direct adapter call has none.
87
+ ...(options.usage ? { vendor_index: options.usage.vendorIndex } : {}),
88
+ ...(options.round === undefined ? {} : { round: options.round }),
89
+ resolver_identity_scope: options.usage?.resolverIdentityScope,
90
+ };
91
+ return options.traceRecorder.runSpan(RESOLVER_PAID_USAGE_SPAN, options.create, {
92
+ attributes: baseAttributes,
93
+ onSuccess: () => ({ outcome: "success" }),
94
+ onError: (error) => ({ outcome: errorOutcome(error, options.signal) }),
95
+ });
96
+ }
@@ -31,6 +31,11 @@ export const RESOLVER_CHALLENGE_BINDINGS = {
31
31
  identityBinding: "identity_scoped",
32
32
  directCacheable: false,
33
33
  },
34
+ akamai_sbsd: {
35
+ cacheable: false,
36
+ identityBinding: "identity_scoped",
37
+ directCacheable: false,
38
+ },
34
39
  } as const satisfies Readonly<Record<ProviderChallengeKind, ResolverChallengeBinding>>;
35
40
 
36
41
  export function resolverChallengeIsCacheable(challenge: ProviderChallenge): boolean {
@@ -2,13 +2,17 @@ import { getStealthProfile } from "../../stealth/profiles.js";
2
2
  import type { ChallengeSolution, ProviderChallenge, ProviderChallengeKind } from "../../types.js";
3
3
  import { redactSensitiveText } from "../request-options.js";
4
4
  import { DEFAULT_STEALTH_PROFILE } from "../stealth.js";
5
+ import { recordPaidResolverCreate } from "../resolver-usage.js";
5
6
  import type { TraceRecorder } from "../trace.js";
6
7
  import { assertResolverHostAllowed } from "./hosts.js";
7
8
  import {
8
9
  ResolverChallengeVerdictError,
9
10
  type ResolverIdentity,
11
+ type ResolverPaidUsageContext,
10
12
  type ResolverVendorAdapter,
13
+ type ResolverVendorTransport,
11
14
  ResolverVendorUnavailableError,
15
+ recordResolverVendorPoll,
12
16
  type ResolverVendorUnavailableReason,
13
17
  resolverVendorSupports,
14
18
  } from "./types.js";
@@ -46,6 +50,8 @@ export interface CapsolverResolverVendorAdapter extends ResolverVendorAdapter {
46
50
  identity: ResolverIdentity | undefined,
47
51
  signal: AbortSignal,
48
52
  traceRecorder?: TraceRecorder,
53
+ transport?: ResolverVendorTransport,
54
+ usage?: ResolverPaidUsageContext,
49
55
  ): Promise<ChallengeSolution>;
50
56
  }
51
57
 
@@ -455,7 +461,7 @@ export function createCapsolverResolverVendorAdapter(
455
461
  return resolverVendorSupports(CAPSOLVER_VENDOR_ID, kind);
456
462
  },
457
463
 
458
- async solve(challenge, identity, callerSignal, traceRecorder) {
464
+ async solve(challenge, identity, callerSignal, traceRecorder, _transport, usage) {
459
465
  const apiKey = options.apiKey?.trim();
460
466
  if (!apiKey) {
461
467
  throw new ResolverVendorUnavailableError(CAPSOLVER_VENDOR_ID, "missing_credentials", {
@@ -569,8 +575,18 @@ export function createCapsolverResolverVendorAdapter(
569
575
  : {}),
570
576
  }
571
577
  : (() => {
572
- throw new TypeError(`Capsolver resolver does not support ${challenge.kind}`);
578
+ throw new TypeError(
579
+ `Capsolver resolver does not support ${challenge.kind}`,
580
+ );
573
581
  })();
582
+ return await recordPaidResolverCreate({
583
+ traceRecorder,
584
+ vendor: CAPSOLVER_VENDOR_ID,
585
+ kind: challenge.kind,
586
+ endpoint: "capsolver:create_task",
587
+ signal: solveController.signal,
588
+ usage,
589
+ create: async () => {
574
590
  const createResult = await postJson(
575
591
  fetchImpl,
576
592
  endpoint(baseUrl, "createTask"),
@@ -585,6 +601,8 @@ export function createCapsolverResolverVendorAdapter(
585
601
  throw unavailableForPayload(createResult.payload, phase, sensitiveValues);
586
602
  }
587
603
  return taskId;
604
+ },
605
+ });
588
606
  };
589
607
  const taskId = traceRecorder
590
608
  ? await traceRecorder.runSpan("resolver.vendor.create_task", createTask, {
@@ -606,6 +624,7 @@ export function createCapsolverResolverVendorAdapter(
606
624
  callerSignal.throwIfAborted();
607
625
  if (now() - startedAt >= timeoutMs) throw new CapsolverSolveTimeoutError();
608
626
 
627
+ recordResolverVendorPoll(traceRecorder);
609
628
  const result = await postJson(
610
629
  fetchImpl,
611
630
  endpoint(baseUrl, "getTaskResult"),
@@ -633,7 +652,7 @@ export function createCapsolverResolverVendorAdapter(
633
652
  const solutionValue =
634
653
  challenge.kind === "aws_waf"
635
654
  ? result.payload.solution?.cookie
636
- : result.payload.solution?.token ?? result.payload.solution?.gRecaptchaResponse;
655
+ : (result.payload.solution?.token ?? result.payload.solution?.gRecaptchaResponse);
637
656
  if (challenge.kind === "cloudflare_interstitial") {
638
657
  const cookies = result.payload.solution?.cookies;
639
658
  const clearance = cookies?.cf_clearance ?? solutionValue;