@apifuse/provider-sdk 2.2.0-beta.57 → 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 (107) hide show
  1. package/AUTHORING.md +9 -0
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +22 -5
  4. package/SUBMISSION.md +2 -1
  5. package/bin/apifuse-pack-types.ts +49 -0
  6. package/bin/apifuse-record.ts +6 -0
  7. package/dist/cli/templates/provider/README.md.tpl +19 -5
  8. package/dist/config/loader.d.ts +7 -0
  9. package/dist/config/loader.js +30 -6
  10. package/dist/declaration-validation.d.ts +2 -1
  11. package/dist/declaration-validation.js +39 -0
  12. package/dist/define.d.ts +5 -4
  13. package/dist/define.js +25 -1
  14. package/dist/engine.d.ts +16 -0
  15. package/dist/engine.js +58 -1
  16. package/dist/error-resolution.js +13 -0
  17. package/dist/index.d.ts +6 -4
  18. package/dist/index.js +4 -2
  19. package/dist/lint.js +3 -3
  20. package/dist/provider.d.ts +3 -3
  21. package/dist/provider.js +2 -2
  22. package/dist/runtime/bounded-expiring-map.d.ts +21 -0
  23. package/dist/runtime/bounded-expiring-map.js +64 -0
  24. package/dist/runtime/cache.d.ts +2 -0
  25. package/dist/runtime/cache.js +8 -1
  26. package/dist/runtime/egress-lease.d.ts +63 -0
  27. package/dist/runtime/egress-lease.js +233 -0
  28. package/dist/runtime/http.js +1 -0
  29. package/dist/runtime/proxy-retry-policy.d.ts +4 -1
  30. package/dist/runtime/proxy-retry-policy.js +11 -2
  31. package/dist/runtime/request-telemetry.d.ts +7 -9
  32. package/dist/runtime/resolver-config.d.ts +7 -0
  33. package/dist/runtime/resolver-config.js +8 -0
  34. package/dist/runtime/resolver-public.d.ts +3 -1
  35. package/dist/runtime/resolver-public.js +1 -1
  36. package/dist/runtime/resolver-shared.d.ts +9 -0
  37. package/dist/runtime/resolver-shared.js +57 -0
  38. package/dist/runtime/resolver-telemetry.d.ts +134 -0
  39. package/dist/runtime/resolver-telemetry.js +204 -0
  40. package/dist/runtime/resolver-usage.d.ts +24 -0
  41. package/dist/runtime/resolver-usage.js +60 -0
  42. package/dist/runtime/resolver-vendors/bindings.d.ts +5 -0
  43. package/dist/runtime/resolver-vendors/bindings.js +5 -0
  44. package/dist/runtime/resolver-vendors/capsolver.d.ts +2 -2
  45. package/dist/runtime/resolver-vendors/capsolver.js +21 -9
  46. package/dist/runtime/resolver-vendors/hypersolutions.d.ts +27 -0
  47. package/dist/runtime/resolver-vendors/hypersolutions.js +369 -0
  48. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +2 -2
  49. package/dist/runtime/resolver-vendors/twocaptcha.js +26 -10
  50. package/dist/runtime/resolver-vendors/types.d.ts +35 -5
  51. package/dist/runtime/resolver-vendors/types.js +20 -10
  52. package/dist/runtime/resolver.d.ts +5 -1
  53. package/dist/runtime/resolver.js +397 -109
  54. package/dist/runtime/stealth-akamai-sbsd.d.ts +54 -0
  55. package/dist/runtime/stealth-akamai-sbsd.js +142 -0
  56. package/dist/runtime/stealth.js +547 -53
  57. package/dist/schema.d.ts +9 -0
  58. package/dist/schema.js +16 -0
  59. package/dist/server/index.d.ts +2 -0
  60. package/dist/server/index.js +1 -0
  61. package/dist/server/self-test.d.ts +4 -0
  62. package/dist/server/self-test.js +10 -1
  63. package/dist/server/serve-implementation.d.ts +7 -1
  64. package/dist/server/serve-implementation.js +181 -53
  65. package/dist/server/types.d.ts +6 -0
  66. package/dist/server/types.js +14 -0
  67. package/dist/stealth/profiles.d.ts +7 -1
  68. package/dist/stealth/profiles.js +9 -0
  69. package/dist/testing/run.js +6 -0
  70. package/dist/types.d.ts +115 -12
  71. package/package.json +1 -1
  72. package/src/cli/templates/provider/README.md.tpl +19 -5
  73. package/src/config/loader.ts +34 -6
  74. package/src/declaration-validation.ts +47 -1
  75. package/src/define.ts +46 -3
  76. package/src/engine.ts +76 -1
  77. package/src/error-resolution.ts +13 -0
  78. package/src/index.ts +32 -1
  79. package/src/lint.ts +3 -3
  80. package/src/provider.ts +9 -0
  81. package/src/runtime/bounded-expiring-map.ts +67 -0
  82. package/src/runtime/cache.ts +10 -1
  83. package/src/runtime/egress-lease.ts +336 -0
  84. package/src/runtime/http.ts +1 -0
  85. package/src/runtime/proxy-retry-policy.ts +12 -1
  86. package/src/runtime/request-telemetry.ts +5 -7
  87. package/src/runtime/resolver-config.ts +12 -0
  88. package/src/runtime/resolver-public.ts +21 -0
  89. package/src/runtime/resolver-shared.ts +75 -0
  90. package/src/runtime/resolver-telemetry.ts +362 -0
  91. package/src/runtime/resolver-usage.ts +96 -0
  92. package/src/runtime/resolver-vendors/bindings.ts +5 -0
  93. package/src/runtime/resolver-vendors/capsolver.ts +22 -3
  94. package/src/runtime/resolver-vendors/hypersolutions.ts +480 -0
  95. package/src/runtime/resolver-vendors/twocaptcha.ts +26 -12
  96. package/src/runtime/resolver-vendors/types.ts +60 -12
  97. package/src/runtime/resolver.ts +465 -124
  98. package/src/runtime/stealth-akamai-sbsd.ts +207 -0
  99. package/src/runtime/stealth.ts +727 -95
  100. package/src/schema.ts +17 -0
  101. package/src/server/index.ts +21 -0
  102. package/src/server/self-test.ts +13 -0
  103. package/src/server/serve-implementation.ts +268 -48
  104. package/src/server/types.ts +14 -0
  105. package/src/stealth/profiles.ts +10 -0
  106. package/src/testing/run.ts +6 -0
  107. package/src/types.ts +130 -22
@@ -17,7 +17,9 @@ import type {
17
17
  ProviderResolverVendor,
18
18
  ResolverContext,
19
19
  } from "../types.js";
20
+ import { isProviderCacheBypassed } from "./cache.js";
20
21
  import {
22
+ RESOLVER_CHALLENGE_BINDINGS,
21
23
  resolverChallengeAllowsDirectCache,
22
24
  resolverChallengeIsCacheable,
23
25
  resolverChallengeIsIdentityScoped,
@@ -25,21 +27,26 @@ import {
25
27
  } from "./resolver-vendors/bindings.js";
26
28
  import { createBrowserResolverVendorAdapter } from "./resolver-vendors/browser.js";
27
29
  import { createCapsolverResolverVendorAdapter } from "./resolver-vendors/capsolver.js";
30
+ import { createHypersolutionsResolverVendorAdapter } from "./resolver-vendors/hypersolutions.js";
28
31
  import { assertResolverHostAllowed } from "./resolver-vendors/hosts.js";
29
32
  import { createTwoCaptchaResolverVendorAdapter } from "./resolver-vendors/twocaptcha.js";
30
33
  import {
31
34
  RESOLVER_VENDOR_CAPABILITIES,
32
35
  type ResolverIdentity,
33
36
  type ResolverIssuingIdentity,
37
+ type ResolverPaidUsageContext,
34
38
  type ResolverVendorAdapter,
35
39
  type ResolverVendorTransport,
36
40
  ResolverVendorUnavailableError,
41
+ attachResolverVendorPollObserver,
37
42
  type ResolverVendorUnavailableReason,
38
43
  resolveProviderResolverVendors,
39
44
  resolverVendorSupports,
40
45
  } from "./resolver-vendors/types.js";
41
46
  import {
42
47
  createUnsupportedResolverClient,
48
+ inheritResolverTelemetryBinding,
49
+ registerResolverTelemetryBinding,
43
50
  RESOLVER_INSTRUMENTATION_METADATA,
44
51
  type ResolverSolveWithRecorder,
45
52
  } from "./resolver-shared.js";
@@ -48,11 +55,18 @@ import {
48
55
  APIFUSE__RESOLVER__2CAPTCHA__API_KEY,
49
56
  APIFUSE__RESOLVER__CAPMONSTER__API_KEY,
50
57
  APIFUSE__RESOLVER__CAPSOLVER__API_KEY,
58
+ APIFUSE__RESOLVER__HYPERSOLUTIONS__API_KEY,
51
59
  APIFUSE__RESOLVER__TIMEOUT_MS,
52
60
  DEFAULT_RESOLVER_TIMEOUT_MS,
61
+ kindRequiresClientProfile,
53
62
  } from "./resolver-config.js";
54
63
  import { DEFAULT_STEALTH_PROFILE } from "./stealth.js";
55
64
  import type { TraceRecorder } from "./trace.js";
65
+ import type {
66
+ ResolverTelemetryErrorClass,
67
+ ResolverTelemetryPhase,
68
+ ResolverTelemetrySink,
69
+ } from "./resolver-telemetry.js";
56
70
 
57
71
  export {
58
72
  createUnsupportedResolverClient,
@@ -63,6 +77,7 @@ export {
63
77
  APIFUSE__RESOLVER__2CAPTCHA__API_KEY,
64
78
  APIFUSE__RESOLVER__CAPMONSTER__API_KEY,
65
79
  APIFUSE__RESOLVER__CAPSOLVER__API_KEY,
80
+ APIFUSE__RESOLVER__HYPERSOLUTIONS__API_KEY,
66
81
  APIFUSE__RESOLVER__TIMEOUT_MS,
67
82
  DEFAULT_RESOLVER_TIMEOUT_MS,
68
83
  } from "./resolver-config.js";
@@ -109,6 +124,8 @@ type ResolverChainClient = ResolverContext & {
109
124
  export interface ResolverRuntimeOptions {
110
125
  readonly allowedHosts?: readonly string[];
111
126
  readonly cache?: ProviderCache;
127
+ /** Optional request-scoped observability sink; never used as resolver identity. */
128
+ readonly telemetry?: ResolverTelemetrySink;
112
129
  /** Inputs for SDK-owned lazy proxy resolution. The SDK never accepts a caller-built identity. */
113
130
  readonly proxyIntent?: {
114
131
  readonly mode: ProviderProxyMode;
@@ -231,6 +248,13 @@ const resolverAdapterRegistry: Partial<Record<ProviderResolverVendor, ResolverAd
231
248
  timeoutMs,
232
249
  });
233
250
  },
251
+ hypersolutions(configuration, timeoutMs, allowedHosts) {
252
+ return createHypersolutionsResolverVendorAdapter({
253
+ allowedHosts,
254
+ apiKey: configuration,
255
+ timeoutMs,
256
+ });
257
+ },
234
258
  };
235
259
 
236
260
  export const RESOLVER_ADAPTER_REGISTRY: Partial<
@@ -302,6 +326,12 @@ function assertDeclaredKind(
302
326
  requestedKind: ProviderChallengeKind,
303
327
  declaredKinds: readonly ProviderChallengeKind[],
304
328
  ): void {
329
+ if (!Object.hasOwn(RESOLVER_CHALLENGE_BINDINGS, requestedKind)) {
330
+ throw new ProviderError(`Unknown resolver kind "${requestedKind}"`, {
331
+ code: "RESOLVER_KIND_NOT_DECLARED",
332
+ fix: "Use a challenge kind exported by @apifuse/provider-sdk.",
333
+ });
334
+ }
305
335
  if (declaredKinds.includes(requestedKind)) return;
306
336
 
307
337
  const declared = declaredKinds.length > 0 ? declaredKinds.join(", ") : "none";
@@ -365,23 +395,26 @@ function throwUnsupportedKind(kind: ProviderChallengeKind, usingDefaultVendors:
365
395
  });
366
396
  }
367
397
 
368
- function throwExhausted(attempts: readonly ResolverChainAttempt[]): never {
398
+ function throwExhausted(
399
+ challengeKind: ProviderChallengeKind,
400
+ attempts: readonly ResolverChainAttempt[],
401
+ executedAttemptCount = attempts.length,
402
+ ): never {
369
403
  const hasMissingChallengeInput = attempts.some(
370
404
  (attempt) => attempt.reason === "missing_challenge_input",
371
405
  );
372
- const summary = attempts
373
- .map(({ vendor, reason, missingFields }) =>
374
- missingFields === undefined || missingFields.length === 0
375
- ? `${vendor}: ${reason}`
376
- : `${vendor}: ${reason} (missing fields: ${missingFields.join(", ")})`,
377
- )
378
- .join(", ");
379
- throw new ProviderError(`Resolver vendor chain exhausted: ${summary}`, {
406
+ throw new ProviderError("The challenge could not be resolved.", {
380
407
  code: "RESOLVER_CHAIN_EXHAUSTED",
381
408
  fix: hasMissingChallengeInput
382
- ? "Capture the named challenge fields or configure another supporting resolver vendor."
383
- : "Configure another supporting resolver vendor or restore an unavailable vendor.",
384
- details: attempts,
409
+ ? "Capture all required challenge fields and retry the request."
410
+ : "Retry the request or contact support if the challenge continues.",
411
+ retryable: false,
412
+ details: {
413
+ challengeKind,
414
+ attempts: executedAttemptCount,
415
+ outcome: "exhausted",
416
+ retryable: false,
417
+ },
385
418
  });
386
419
  }
387
420
 
@@ -451,6 +484,16 @@ function restrictResolverTransport(
451
484
  allowedHosts: readonly string[],
452
485
  ): ResolverVendorTransport {
453
486
  return {
487
+ sessionHeaders: transport.sessionHeaders,
488
+ ...(transport.getCookie
489
+ ? {
490
+ getCookie(name, url) {
491
+ // Jar reads are gated like fetches: an adapter may only read state for declared hosts.
492
+ assertResolverHostAllowed(url, allowedHosts);
493
+ return transport.getCookie?.(name, url);
494
+ },
495
+ }
496
+ : {}),
454
497
  async fetch(url, init) {
455
498
  // Empty declarations remain deny-by-default, matching the adapter-factory/browser path.
456
499
  assertResolverHostAllowed(url, allowedHosts);
@@ -494,10 +537,21 @@ function safePhase(phase: string | undefined): string | undefined {
494
537
  return phase !== undefined && /^[a-z\d_.:-]{1,64}$/i.test(phase) ? phase : undefined;
495
538
  }
496
539
 
497
- function unavailableAttempt(error: ResolverVendorUnavailableError): ResolverChainAttempt {
498
- const cause = safeCause(error);
540
+ function unavailableAttempt(
541
+ error: ResolverVendorUnavailableError,
542
+ forTelemetry = false,
543
+ ): ResolverChainAttempt {
544
+ // Preserve the full input for the collector's redact-then-bound path. Span
545
+ // attributes retain their existing sanitization and phase validation.
546
+ const cause =
547
+ forTelemetry && error.cause !== undefined
548
+ ? {
549
+ name: error.cause instanceof Error ? error.cause.name : "Error",
550
+ message: error.cause instanceof Error ? error.cause.message : String(error.cause),
551
+ }
552
+ : safeCause(error);
499
553
  const upstreamHost = safeUpstreamHost(error.upstreamHost);
500
- const phase = safePhase(error.phase);
554
+ const phase = forTelemetry ? error.phase : safePhase(error.phase);
501
555
  const round =
502
556
  Number.isSafeInteger(error.round) && (error.round as number) > 0 ? error.round : undefined;
503
557
  return {
@@ -524,6 +578,125 @@ function unavailableSpanAttributes(error: ResolverVendorUnavailableError): Recor
524
578
  };
525
579
  }
526
580
 
581
+ type ResolverAttemptDiagnostics = {
582
+ phase?: string;
583
+ pollCount?: number;
584
+ vendorErrorCode?: string;
585
+ vendorErrorDescription?: string;
586
+ };
587
+
588
+ function resolverTelemetryPhase(value: unknown): ResolverTelemetryPhase | undefined {
589
+ return value === "create_task" ||
590
+ value === "poll_result" ||
591
+ value === "cleanup" ||
592
+ value === "measure_ip" ||
593
+ value === "fetch_script" ||
594
+ value === "generate_payload" ||
595
+ value === "post_payload"
596
+ ? value
597
+ : undefined;
598
+ }
599
+
600
+ function diagnosticString(value: unknown): string | undefined {
601
+ return typeof value === "string" && value.length > 0 ? value : undefined;
602
+ }
603
+
604
+ function diagnosticCount(value: unknown): number | undefined {
605
+ return typeof value === "number" && Number.isFinite(value)
606
+ ? Math.max(0, Math.floor(value))
607
+ : undefined;
608
+ }
609
+
610
+ function createResolverTelemetryTraceRecorder(input: {
611
+ base?: TraceRecorder;
612
+ vendor: ProviderResolverVendor;
613
+ attemptIndex: number;
614
+ telemetry?: ResolverTelemetrySink;
615
+ diagnostics: ResolverAttemptDiagnostics;
616
+ }): TraceRecorder | undefined {
617
+ if (!input.base && !input.telemetry) return undefined;
618
+ const recorder: TraceRecorder = {
619
+ async runSpan<T>(
620
+ name: string,
621
+ fn: () => Promise<T> | T,
622
+ spanOptions: {
623
+ attributes?: Record<string, unknown>;
624
+ onSuccess?: (value: T) => Record<string, unknown> | undefined;
625
+ onError?: (error: unknown) => Record<string, unknown> | undefined;
626
+ } = {},
627
+ ) {
628
+ const adapterPhase = name.startsWith("resolver.vendor.")
629
+ ? diagnosticString(name.replace("resolver.vendor.", ""))
630
+ : undefined;
631
+ const phase = resolverTelemetryPhase(adapterPhase);
632
+ const startedAt = Date.now();
633
+ let attributes: Record<string, unknown> | undefined;
634
+ let spanOutcome: "ok" | "error" = "ok";
635
+ const wrappedOptions = {
636
+ ...spanOptions,
637
+ onSuccess(value: T) {
638
+ attributes = spanOptions.onSuccess?.(value);
639
+ return attributes;
640
+ },
641
+ onError(error: unknown) {
642
+ attributes = spanOptions.onError?.(error);
643
+ return attributes;
644
+ },
645
+ };
646
+ try {
647
+ if (input.base) return await input.base.runSpan(name, fn, wrappedOptions);
648
+ const value = await fn();
649
+ attributes = spanOptions.onSuccess?.(value);
650
+ return value;
651
+ } catch (error) {
652
+ spanOutcome = "error";
653
+ if (!input.base) attributes = spanOptions.onError?.(error);
654
+ throw error;
655
+ } finally {
656
+ if (adapterPhase && adapterPhase !== "attempt") {
657
+ const reportedPhase = diagnosticString(attributes?.transport_phase) ?? adapterPhase;
658
+ const pollCount = diagnosticCount(attributes?.poll_count);
659
+ const vendorErrorCode = diagnosticString(attributes?.vendor_error_code);
660
+ const vendorErrorDescription = diagnosticString(
661
+ attributes?.vendor_error_description ?? attributes?.error_message,
662
+ );
663
+ if (phase === "cleanup") {
664
+ input.telemetry?.recordVendorAttempt({
665
+ vendor: input.vendor,
666
+ phase: "cleanup",
667
+ diagnostics: { phase: reportedPhase, attemptIndex: input.attemptIndex },
668
+ outcome: spanOutcome,
669
+ ms: Date.now() - startedAt,
670
+ ...(pollCount === undefined ? {} : { pollCount }),
671
+ ...(vendorErrorCode ? { vendorErrorCode } : {}),
672
+ ...(vendorErrorDescription ? { vendorErrorDescription } : {}),
673
+ ...(spanOutcome === "error" ? { errorClass: "unexpected" as const } : {}),
674
+ });
675
+ } else {
676
+ input.diagnostics.phase = reportedPhase;
677
+ if (pollCount !== undefined) input.diagnostics.pollCount = pollCount;
678
+ if (vendorErrorCode) input.diagnostics.vendorErrorCode = vendorErrorCode;
679
+ if (vendorErrorDescription) {
680
+ input.diagnostics.vendorErrorDescription = vendorErrorDescription;
681
+ }
682
+ }
683
+ }
684
+ }
685
+ },
686
+ };
687
+ return attachResolverVendorPollObserver(recorder, () => {
688
+ input.diagnostics.pollCount = (input.diagnostics.pollCount ?? 0) + 1;
689
+ });
690
+ }
691
+
692
+ function resolverAttemptErrorClass(
693
+ error: unknown,
694
+ signal: AbortSignal,
695
+ ): ResolverTelemetryErrorClass {
696
+ if (signal.aborted) return "aborted";
697
+ return error instanceof ResolverVendorUnavailableError ? error.reason : "unexpected";
698
+ }
699
+
527
700
  function challengeOrigin(challenge: ProviderChallenge): string {
528
701
  return new URL(challenge.pageUrl).origin;
529
702
  }
@@ -710,13 +883,13 @@ async function cacheResolverSolution(
710
883
  solution: ChallengeSolution,
711
884
  identity: ResolverIssuingIdentity,
712
885
  identityScope: string | undefined,
713
- ): Promise<void> {
714
- if (!resolverChallengeIsCacheable(challenge)) return;
886
+ ): Promise<{ written: boolean; reason?: "no_expires" | "not_cacheable" }> {
887
+ if (!resolverChallengeIsCacheable(challenge)) return { written: false, reason: "not_cacheable" };
715
888
  const expiresAtMs = solutionExpiryMs(solution);
716
889
  const now = Date.now();
717
- if (expiresAtMs === undefined) return;
890
+ if (expiresAtMs === undefined) return { written: false, reason: "no_expires" };
718
891
  const ttlMs = Math.floor(expiresAtMs - now);
719
- if (ttlMs <= MIN_RESOLVER_CACHE_TTL_MS) return;
892
+ if (ttlMs <= MIN_RESOLVER_CACHE_TTL_MS) return { written: false, reason: "no_expires" };
720
893
 
721
894
  const scopedDigest =
722
895
  identityScope !== undefined && resolverChallengeIsIdentityScoped(challenge)
@@ -727,7 +900,7 @@ async function cacheResolverSolution(
727
900
  scopedDigest === undefined &&
728
901
  identity.proxyUrl === undefined
729
902
  ) {
730
- return;
903
+ return { written: false, reason: "not_cacheable" };
731
904
  }
732
905
  const issuerDigest = scopedDigest ?? resolverIdentityDigest(identity);
733
906
  await cache.set(
@@ -736,7 +909,7 @@ async function cacheResolverSolution(
736
909
  { ttlMs },
737
910
  );
738
911
  rememberSolutionIssuer(solution, issuerDigest);
739
- if (scopedDigest !== undefined || identity.proxyUrl !== undefined) return;
912
+ if (scopedDigest !== undefined || identity.proxyUrl !== undefined) return { written: true };
740
913
 
741
914
  const indexKey = resolverSolutionIndexCacheKey(cache, challenge);
742
915
  const current = await cache.get(indexKey);
@@ -750,6 +923,7 @@ async function cacheResolverSolution(
750
923
  ],
751
924
  now,
752
925
  );
926
+ return { written: true };
753
927
  }
754
928
 
755
929
  async function invalidateResolverSolutionWithOutcome(
@@ -866,6 +1040,10 @@ async function resolveResolverIdentity(
866
1040
  }
867
1041
  }
868
1042
 
1043
+ function createChainTelemetryBinding(options: Parameters<typeof createResolverChainClient>[0]) {
1044
+ return (telemetry: ResolverTelemetrySink) => createResolverChainClient({ ...options, telemetry });
1045
+ }
1046
+
869
1047
  function createResolverChainClient(options: {
870
1048
  readonly kinds: readonly ProviderChallengeKind[];
871
1049
  readonly entries: readonly ResolverChainEntry[];
@@ -879,128 +1057,284 @@ function createResolverChainClient(options: {
879
1057
  readonly createTransport?: ResolverRuntimeOptions["createTransport"];
880
1058
  readonly clientProfile?: string;
881
1059
  readonly allowedHosts?: readonly string[];
1060
+ readonly telemetry?: ResolverTelemetrySink;
882
1061
  }): ResolverChainClient {
883
1062
  assertClientProfileTransportContract(options.clientProfile, options.transport);
1063
+ const cache =
1064
+ options.cache && !isProviderCacheBypassed(options.cache) ? options.cache : undefined;
884
1065
  const client: ResolverChainClient = {
885
1066
  async solve(
886
1067
  challenge: ProviderChallenge,
887
1068
  signal: AbortSignal = new AbortController().signal,
888
1069
  traceRecorder?: TraceRecorder,
889
1070
  ) {
890
- assertDeclaredKind(challenge.kind, options.kinds);
891
- if (options.unavailableReason) {
892
- throw new ProviderError(options.unavailableReason, {
893
- code: "RESOLVER_UNAVAILABLE",
894
- fix: "Configure at least one declared resolver vendor or provide a test ResolverContext override.",
895
- });
896
- }
1071
+ const solveStartedAt = Date.now();
1072
+ let terminalOutcome: "solved" | "cached" | "exhausted" | "aborted" | "error" = "error";
1073
+ try {
1074
+ assertDeclaredKind(challenge.kind, options.kinds);
1075
+ if (options.unavailableReason) {
1076
+ throw new ProviderError(options.unavailableReason, {
1077
+ code: "RESOLVER_UNAVAILABLE",
1078
+ fix: "Configure at least one declared resolver vendor or provide a test ResolverContext override.",
1079
+ });
1080
+ }
897
1081
 
898
- const supportingEntries = options.entries.filter((entry) => entry.supports(challenge.kind));
899
- if (supportingEntries.length === 0) {
900
- throwUnsupportedKind(challenge.kind, options.usingDefaultVendors ?? false);
901
- }
902
- signal.throwIfAborted();
903
- const identityResolution = options.proxyIntent
904
- ? await resolveResolverIdentity(options.proxyIntent)
905
- : { identity: options.identity };
906
- const identity = identityResolution.identity;
907
- signal.throwIfAborted();
908
- // Resolve a required proxy lease before consulting the cache. Solutions minted
909
- // under a previous release are shared and long-lived, but a portable cached token
910
- // must not bypass the upstream admission policy when no lease can be resolved.
911
- const requiredProxyIdentityMissing =
912
- options.proxyIntent?.mode === "required" && identity === undefined;
913
- if (requiredProxyIdentityMissing) {
914
- throwExhausted(
915
- supportingEntries.map((entry) => ({
916
- vendor: entry.id,
917
- reason: identityResolution.unavailableReason ?? "missing_proxy_identity",
918
- })),
919
- );
920
- }
921
- if (options.cache && resolverChallengeIsCacheable(challenge)) {
922
- const cached = await findCachedSolution(
923
- options.cache,
924
- challenge,
925
- identity,
926
- options.identityScope,
927
- );
928
- if (cached) return cached;
929
- }
930
- const attempts: ResolverChainAttempt[] = [];
931
- for (const entry of supportingEntries) {
932
- const adapter = entry.createAdapter();
933
- try {
934
- const solveAttempt = () => {
935
- const requiresTransport = adapterRequiresTransport(adapter, challenge.kind);
936
- const unrestrictedTransport =
937
- options.transport ??
938
- (requiresTransport
939
- ? options.createTransport?.({
940
- clientProfile: options.clientProfile,
941
- identityScope: options.identityScope,
942
- })
943
- : undefined);
944
- if (requiresTransport && unrestrictedTransport === undefined) {
945
- throw new ResolverVendorUnavailableError(adapter.id, "missing_transport");
1082
+ const supportingEntries = options.entries.filter((entry) => entry.supports(challenge.kind));
1083
+ if (supportingEntries.length === 0) {
1084
+ throwUnsupportedKind(challenge.kind, options.usingDefaultVendors ?? false);
1085
+ }
1086
+ if (kindRequiresClientProfile(challenge.kind) && !options.clientProfile?.trim()) {
1087
+ options.telemetry?.recordIdentity({ source: "none", failure: "missing_client_profile" });
1088
+ throwExhausted(
1089
+ challenge.kind,
1090
+ supportingEntries.map((entry) => ({
1091
+ vendor: entry.id,
1092
+ reason: "missing_client_profile",
1093
+ })),
1094
+ 0,
1095
+ );
1096
+ }
1097
+ signal.throwIfAborted();
1098
+ const identityResolution = options.proxyIntent
1099
+ ? await resolveResolverIdentity(options.proxyIntent)
1100
+ : { identity: options.identity };
1101
+ const identity = identityResolution.identity;
1102
+ options.telemetry?.recordIdentity({
1103
+ // For SBSD, "declared" identifies the configured client profile and its bound
1104
+ // session identity; the engine independently supplies solver credentials.
1105
+ source:
1106
+ kindRequiresClientProfile(challenge.kind) && options.clientProfile
1107
+ ? "declared"
1108
+ : options.proxyIntent
1109
+ ? (identityResolution.userAgentSource ?? "defaulted")
1110
+ : identity
1111
+ ? "declared"
1112
+ : "none",
1113
+ ...(identityResolution.unavailableReason
1114
+ ? { failure: identityResolution.unavailableReason }
1115
+ : {}),
1116
+ });
1117
+ signal.throwIfAborted();
1118
+ const requiredProxyIdentityMissing =
1119
+ options.proxyIntent?.mode === "required" && identity === undefined;
1120
+ if (requiredProxyIdentityMissing) {
1121
+ const reason = identityResolution.unavailableReason ?? "missing_proxy_identity";
1122
+ for (const [entryIndex, entry] of supportingEntries.entries()) {
1123
+ options.telemetry?.recordVendorAttempt({
1124
+ vendor: entry.id,
1125
+ phase: "create_task",
1126
+ diagnostics: { attemptIndex: entryIndex + 1 },
1127
+ outcome: "error",
1128
+ ms: 0,
1129
+ errorClass: reason,
1130
+ });
1131
+ const next = supportingEntries[entryIndex + 1];
1132
+ if (next) {
1133
+ options.telemetry?.recordFailover({ from: entry.id, to: next.id, reason });
946
1134
  }
947
- const transport = unrestrictedTransport
948
- ? restrictResolverTransport(unrestrictedTransport, options.allowedHosts ?? [])
949
- : undefined;
950
- return adapter.solve(challenge, identity, signal, traceRecorder, transport);
951
- };
952
- const solution = traceRecorder
953
- ? await traceRecorder.runSpan("resolver.vendor.attempt", solveAttempt, {
954
- attributes: {
955
- vendor: adapter.id,
956
- challenge_kind: challenge.kind,
957
- client_profile: options.clientProfile,
958
- resolver_identity_source: identityResolution.userAgentSource,
959
- },
960
- onError(error) {
961
- return error instanceof ResolverVendorUnavailableError
962
- ? unavailableSpanAttributes(error)
963
- : undefined;
964
- },
965
- })
966
- : await solveAttempt();
967
- if (
968
- options.cache &&
969
- resolverChallengeIsCacheable(challenge) &&
970
- solution.form === "cookies" &&
971
- solutionExpiryMs(solution) !== undefined
972
- ) {
973
- const issuingIdentity =
974
- adapter.getIssuingIdentity?.(solution, identity, challenge) ??
975
- resolverChallengeIssuingIdentity(challenge, {
976
- ...(identity ? { proxyUrl: identity.proxyUrl } : {}),
977
- userAgent: solution.userAgent,
978
- });
979
- if (issuingIdentity) {
980
- await cacheResolverSolution(
981
- options.cache,
1135
+ }
1136
+ throwExhausted(
1137
+ challenge.kind,
1138
+ supportingEntries.map((entry) => ({
1139
+ vendor: entry.id,
1140
+ reason,
1141
+ })),
1142
+ );
1143
+ }
1144
+ if (!cache) {
1145
+ options.telemetry?.recordCacheRead({ status: "disabled", challengeKind: challenge.kind });
1146
+ } else if (!resolverChallengeIsCacheable(challenge)) {
1147
+ options.telemetry?.recordCacheRead({
1148
+ status: "not_cacheable",
1149
+ challengeKind: challenge.kind,
1150
+ });
1151
+ } else {
1152
+ const cached = await findCachedSolution(
1153
+ cache,
1154
+ challenge,
1155
+ identity,
1156
+ options.identityScope,
1157
+ );
1158
+ options.telemetry?.recordCacheRead({
1159
+ status: cached ? "hit" : "miss",
1160
+ challengeKind: challenge.kind,
1161
+ });
1162
+ if (cached) {
1163
+ terminalOutcome = "cached";
1164
+ return cached;
1165
+ }
1166
+ }
1167
+ const attempts: ResolverChainAttempt[] = [];
1168
+ for (const [entryIndex, entry] of supportingEntries.entries()) {
1169
+ const adapter = entry.createAdapter();
1170
+ const attemptStartedAt = Date.now();
1171
+ const diagnostics: ResolverAttemptDiagnostics = {};
1172
+ const telemetryTraceRecorder = createResolverTelemetryTraceRecorder({
1173
+ base: traceRecorder,
1174
+ vendor: adapter.id,
1175
+ attemptIndex: entryIndex + 1,
1176
+ telemetry: options.telemetry,
1177
+ diagnostics,
1178
+ });
1179
+ let solution: ChallengeSolution;
1180
+ try {
1181
+ const solveAttempt = () => {
1182
+ const requiresTransport = adapterRequiresTransport(adapter, challenge.kind);
1183
+ const unrestrictedTransport =
1184
+ options.transport ??
1185
+ (requiresTransport
1186
+ ? options.createTransport?.({
1187
+ clientProfile: options.clientProfile,
1188
+ identityScope: options.identityScope,
1189
+ })
1190
+ : undefined);
1191
+ if (requiresTransport && unrestrictedTransport === undefined) {
1192
+ throw new ResolverVendorUnavailableError(adapter.id, "missing_transport");
1193
+ }
1194
+ const transport = unrestrictedTransport
1195
+ ? restrictResolverTransport(unrestrictedTransport, [
1196
+ ...(options.allowedHosts ?? []),
1197
+ ...(adapter.transportAllowedHosts ?? []),
1198
+ ])
1199
+ : undefined;
1200
+ const usage: ResolverPaidUsageContext = {
1201
+ // Position in the declared chain, not in the kind-filtered list.
1202
+ vendorIndex: options.entries.indexOf(entry) + 1,
1203
+ ...(options.identityScope
1204
+ ? {
1205
+ resolverIdentityScope: createHash("sha256")
1206
+ .update(options.identityScope, "utf8")
1207
+ .digest("hex"),
1208
+ }
1209
+ : {}),
1210
+ };
1211
+ return adapter.solve(
982
1212
  challenge,
983
- solution,
984
- issuingIdentity,
985
- options.identityScope,
1213
+ identity,
1214
+ signal,
1215
+ telemetryTraceRecorder,
1216
+ transport,
1217
+ usage,
986
1218
  );
1219
+ };
1220
+ solution = traceRecorder
1221
+ ? await traceRecorder.runSpan("resolver.vendor.attempt", solveAttempt, {
1222
+ attributes: {
1223
+ vendor: adapter.id,
1224
+ challenge_kind: challenge.kind,
1225
+ client_profile: options.clientProfile,
1226
+ resolver_identity_source: identityResolution.userAgentSource,
1227
+ },
1228
+ onError(error) {
1229
+ return error instanceof ResolverVendorUnavailableError
1230
+ ? unavailableSpanAttributes(error)
1231
+ : undefined;
1232
+ },
1233
+ })
1234
+ : await solveAttempt();
1235
+ } catch (error) {
1236
+ const unavailable = error instanceof ResolverVendorUnavailableError ? error : undefined;
1237
+ const attempt = unavailable ? unavailableAttempt(unavailable, true) : undefined;
1238
+ options.telemetry?.recordVendorAttempt({
1239
+ vendor: adapter.id,
1240
+ phase:
1241
+ resolverTelemetryPhase(unavailable?.phase) ??
1242
+ resolverTelemetryPhase(diagnostics.phase) ??
1243
+ "create_task",
1244
+ diagnostics: {
1245
+ ...attempt,
1246
+ attemptIndex: entryIndex + 1,
1247
+ phase: attempt?.phase ?? diagnostics.phase,
1248
+ },
1249
+ outcome: "error",
1250
+ ms: Date.now() - attemptStartedAt,
1251
+ errorClass: resolverAttemptErrorClass(error, signal),
1252
+ ...(diagnostics.pollCount === undefined ? {} : { pollCount: diagnostics.pollCount }),
1253
+ ...(diagnostics.vendorErrorCode
1254
+ ? { vendorErrorCode: diagnostics.vendorErrorCode }
1255
+ : {}),
1256
+ ...(diagnostics.vendorErrorDescription
1257
+ ? { vendorErrorDescription: diagnostics.vendorErrorDescription }
1258
+ : {}),
1259
+ });
1260
+ signal.throwIfAborted();
1261
+ if (!unavailable || !attempt) throw error;
1262
+ attempts.push(attempt);
1263
+ const next = supportingEntries[entryIndex + 1];
1264
+ if (next) {
1265
+ options.telemetry?.recordFailover({
1266
+ from: adapter.id,
1267
+ to: next.id,
1268
+ reason: unavailable.reason,
1269
+ });
1270
+ }
1271
+ continue;
1272
+ }
1273
+ options.telemetry?.recordVendorAttempt({
1274
+ vendor: adapter.id,
1275
+ phase: resolverTelemetryPhase(diagnostics.phase) ?? "poll_result",
1276
+ diagnostics: { attemptIndex: entryIndex + 1, phase: diagnostics.phase },
1277
+ outcome: "ok",
1278
+ ms: Date.now() - attemptStartedAt,
1279
+ ...(diagnostics.pollCount === undefined ? {} : { pollCount: diagnostics.pollCount }),
1280
+ });
1281
+ if (cache) {
1282
+ try {
1283
+ let cacheWrite: { written: boolean; reason?: "no_expires" | "not_cacheable" };
1284
+ if (!resolverChallengeIsCacheable(challenge)) {
1285
+ cacheWrite = { written: false, reason: "not_cacheable" };
1286
+ } else if (solution.form !== "cookies" || solutionExpiryMs(solution) === undefined) {
1287
+ cacheWrite = { written: false, reason: "no_expires" };
1288
+ } else {
1289
+ const issuingIdentity =
1290
+ adapter.getIssuingIdentity?.(solution, identity, challenge) ??
1291
+ resolverChallengeIssuingIdentity(challenge, {
1292
+ ...(identity ? { proxyUrl: identity.proxyUrl } : {}),
1293
+ userAgent: solution.userAgent,
1294
+ });
1295
+ cacheWrite = issuingIdentity
1296
+ ? await cacheResolverSolution(
1297
+ cache,
1298
+ challenge,
1299
+ solution,
1300
+ issuingIdentity,
1301
+ options.identityScope,
1302
+ )
1303
+ : { written: false, reason: "not_cacheable" };
1304
+ }
1305
+ options.telemetry?.recordCacheWrite(cacheWrite);
1306
+ } catch (error) {
1307
+ options.telemetry?.recordCacheWrite({ written: false, reason: "error" });
1308
+ throw error;
987
1309
  }
988
1310
  }
989
1311
  rememberSolutionSource(solution, "vendor");
1312
+ // Adapter success includes SBSD payload acceptance with verified:false.
1313
+ // The caller reports protected-refetch verification separately.
1314
+ terminalOutcome = "solved";
990
1315
  return solution;
991
- } catch (error) {
992
- signal.throwIfAborted();
993
- if (!(error instanceof ResolverVendorUnavailableError)) throw error;
994
- // Every vendor-unavailable result, including missing_challenge_input, falls
995
- // through so another adapter can solve with a different input contract.
996
- attempts.push(unavailableAttempt(error));
997
1316
  }
998
- }
999
1317
 
1000
- throwExhausted(attempts);
1318
+ throwExhausted(challenge.kind, attempts);
1319
+ } catch (error) {
1320
+ terminalOutcome = signal.aborted
1321
+ ? "aborted"
1322
+ : error instanceof ProviderError && error.code === "RESOLVER_CHAIN_EXHAUSTED"
1323
+ ? "exhausted"
1324
+ : "error";
1325
+ throw error;
1326
+ } finally {
1327
+ options.telemetry?.recordOutcome({
1328
+ outcome: terminalOutcome,
1329
+ solveMs: Date.now() - solveStartedAt,
1330
+ challengeKind: challenge.kind,
1331
+ });
1332
+ }
1001
1333
  },
1002
1334
  };
1003
- resolverCaches.set(client, options.cache ?? null);
1335
+ resolverCaches.set(client, cache ?? null);
1336
+ const reusableOptions = { ...options, telemetry: undefined };
1337
+ registerResolverTelemetryBinding(client, createChainTelemetryBinding(reusableOptions));
1004
1338
  return client;
1005
1339
  }
1006
1340
 
@@ -1015,6 +1349,7 @@ export function createResolverClient(options: {
1015
1349
  readonly createTransport?: ResolverRuntimeOptions["createTransport"];
1016
1350
  readonly clientProfile?: string;
1017
1351
  readonly allowedHosts?: readonly string[];
1352
+ readonly telemetry?: ResolverTelemetrySink;
1018
1353
  }): ResolverChainClient {
1019
1354
  return createResolverChainClient({
1020
1355
  kinds: options.kinds,
@@ -1031,6 +1366,7 @@ export function createResolverClient(options: {
1031
1366
  createTransport: options.createTransport,
1032
1367
  clientProfile: options.clientProfile,
1033
1368
  allowedHosts: options.allowedHosts,
1369
+ telemetry: options.telemetry,
1034
1370
  });
1035
1371
  }
1036
1372
 
@@ -1058,7 +1394,9 @@ function resolveVendorAvailability(
1058
1394
  ? APIFUSE__RESOLVER__2CAPTCHA__API_KEY
1059
1395
  : vendor === "capsolver"
1060
1396
  ? APIFUSE__RESOLVER__CAPSOLVER__API_KEY
1061
- : APIFUSE__RESOLVER__CAPMONSTER__API_KEY;
1397
+ : vendor === "capmonster"
1398
+ ? APIFUSE__RESOLVER__CAPMONSTER__API_KEY
1399
+ : APIFUSE__RESOLVER__HYPERSOLUTIONS__API_KEY;
1062
1400
 
1063
1401
  const configuration = normalizedEnvValue(env, envKey);
1064
1402
  return configuration
@@ -1083,6 +1421,7 @@ export function bindResolverSignal(
1083
1421
  if (resolverCaches.has(resolver)) {
1084
1422
  resolverCaches.set(boundResolver, resolverCaches.get(resolver) ?? null);
1085
1423
  }
1424
+ inheritResolverTelemetryBinding(resolver, boundResolver);
1086
1425
  return boundResolver;
1087
1426
  }
1088
1427
 
@@ -1103,6 +1442,7 @@ function createResolverClientFromEnvInternal(
1103
1442
  kinds: config.kinds,
1104
1443
  entries: [],
1105
1444
  unavailableReason: "Provider resolver vendor chain is empty",
1445
+ telemetry: options.telemetry,
1106
1446
  });
1107
1447
  }
1108
1448
 
@@ -1135,6 +1475,7 @@ function createResolverClientFromEnvInternal(
1135
1475
  createTransport: options.createTransport,
1136
1476
  clientProfile: config.clientProfile,
1137
1477
  allowedHosts,
1478
+ telemetry: options.telemetry,
1138
1479
  });
1139
1480
  }
1140
1481