@apifuse/provider-sdk 2.2.0-beta.7 → 2.2.0-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @apifuse/provider-sdk Changelog
2
2
 
3
+ ## 2.2.0-beta.8
4
+
5
+ - Release candidate for main commit 9e8a3f028ee78b9cab29d4aa3f5494ac9cffa65f.
6
+
3
7
  ## 2.2.0-beta.7
4
8
 
5
9
  - Release candidate for main commit 2ce4ea4bd36ce333eba8b3b474bf6e82b5e9216c.
@@ -87,7 +87,7 @@ export declare const AUTH_TURN_SCHEMA: {
87
87
  };
88
88
  readonly $defs: {
89
89
  readonly completeTurnData: {
90
- readonly title: 'Terminal payload for kind "complete"';
90
+ readonly title: "Terminal payload for kind \"complete\"";
91
91
  readonly description: "data payload of a complete turn. The gateway extracts data.credential for persistence; complete turns are never echoed to browsers.";
92
92
  readonly type: "object";
93
93
  readonly additionalProperties: true;
@@ -104,7 +104,7 @@ export declare const AUTH_TURN_SCHEMA: {
104
104
  };
105
105
  };
106
106
  readonly abortTurnData: {
107
- readonly title: 'Terminal payload for kind "abort"';
107
+ readonly title: "Terminal payload for kind \"abort\"";
108
108
  readonly description: "data payload of an abort turn. code, when present, is the machine-readable abort reason.";
109
109
  readonly type: "object";
110
110
  readonly additionalProperties: true;
@@ -1,5 +1,9 @@
1
1
  import { Redis } from "ioredis";
2
2
  import type { ProviderProxyPolicy, TraceConfig } from "../types.js";
3
+ import { type ProxyProtocol } from "../runtime/proxy-nodemaven.js";
4
+ export type { ProxyProtocol } from "../runtime/proxy-nodemaven.js";
5
+ /** Proxy vendors the SDK resolves natively (as opposed to the static env path). */
6
+ export type ProxyVendorName = "smartproxy" | "nodemaven";
3
7
  export declare const SMARTPROXY_APP_KEY_ENV = "APIFUSE__PROXY__SMARTPROXY_APP_KEY";
4
8
  export declare const SMARTPROXY_MAX_LIFETIME_MINUTES = 2000;
5
9
  export declare const DEFAULT_SMARTPROXY_POOL_SIZE = 20;
@@ -42,12 +46,33 @@ export type ProxyResolutionOptions = {
42
46
  affinityKey?: string;
43
47
  /** Zero-based proxy-pool attempt index used by SDK transports for failover. */
44
48
  proxyAttempt?: number;
49
+ /**
50
+ * Tunnelling protocols the calling transport can use. When a resolved
51
+ * protocol is not in this set the resolver fails with
52
+ * `PROXY_PROTOCOL_UNSUPPORTED` instead of silently downgrading. Unset means
53
+ * permissive (both protocols allowed).
54
+ */
55
+ transportProtocols?: readonly ProxyProtocol[];
56
+ /**
57
+ * Explicit protocol override. Internal — for the verification harness and
58
+ * tests, or an advanced caller. Normal callers omit it and each vendor uses
59
+ * its own benchmarked default protocol (see VENDOR_DEFAULT_PROTOCOL). Not an
60
+ * env var and not a provider-policy field.
61
+ */
62
+ protocol?: ProxyProtocol;
63
+ /**
64
+ * Gateway pool "refresh" generation. Bumped by transports on pool refresh to
65
+ * derive a fresh gateway session set (ignored by allocation-style vendors,
66
+ * whose refresh is driven by cache invalidation).
67
+ */
68
+ proxyRefreshEpoch?: number;
45
69
  telemetry?: ProxyTelemetrySink;
46
70
  };
47
71
  export type ProxyCacheStatus = "memory_hit" | "redis_hit" | "allocator" | "soft_stale_refresh" | "lock_wait" | "redis_error" | "redis_corrupt" | "disabled";
48
72
  export type SmartproxyAllocatorBodyClass = "network_error" | "http_error" | "empty" | "json_without_proxies" | "text_without_proxies" | "usable_proxy_endpoints";
49
73
  export type ProxyResolutionTelemetryEvent = {
50
- provider: "smartproxy";
74
+ provider: ProxyVendorName;
75
+ protocol?: ProxyProtocol;
51
76
  cacheStatus: ProxyCacheStatus;
52
77
  cacheHit: boolean;
53
78
  resolutionMs: number;
@@ -64,7 +89,7 @@ export type ProxyResolutionTelemetryEvent = {
64
89
  refreshes?: number;
65
90
  };
66
91
  export type ProxyAttemptTelemetryEvent = {
67
- provider: "smartproxy";
92
+ provider: ProxyVendorName;
68
93
  attempt: number;
69
94
  poolIndex?: number;
70
95
  proxyHash?: string;
@@ -73,22 +98,40 @@ export type ProxyAttemptTelemetryEvent = {
73
98
  status?: number;
74
99
  durationMs?: number;
75
100
  };
101
+ export type ProxyVendorFailoverTelemetryEvent = {
102
+ /** Vendor that failed or was skipped. */
103
+ vendor: ProxyVendorName;
104
+ /** Vendor tried next, or undefined when the chain is exhausted. */
105
+ nextVendor?: ProxyVendorName;
106
+ phase: "resolution" | "transport";
107
+ reason: "no_credentials" | "allocation_failed" | "pool_exhausted" | "protocol_unsupported";
108
+ attempt?: number;
109
+ };
76
110
  export type ProxyTelemetrySink = {
77
111
  recordProxyResolution(event: ProxyResolutionTelemetryEvent): void;
78
112
  recordProxyAttempt?(event: ProxyAttemptTelemetryEvent): void;
113
+ recordProxyVendorFailover?(event: ProxyVendorFailoverTelemetryEvent): void;
79
114
  };
80
115
  export type ResolvedProxyConfig = {
81
116
  shouldWarn: boolean;
82
117
  url?: string;
83
- source?: "explicit" | "env" | "config" | "smartproxy-allocator";
118
+ source?: "explicit" | "env" | "config" | "smartproxy-allocator" | "nodemaven-gateway";
119
+ protocol?: ProxyProtocol;
84
120
  diagnostics?: Record<string, string | number | boolean>;
85
121
  };
122
+ export type ProxyResolutionErrorCode = "PROXY_REQUIRED" | "PROXY_ALLOCATION_FAILED" | "PROXY_PROTOCOL_UNSUPPORTED";
86
123
  export declare class ProxyResolutionError extends Error {
87
- readonly code: "PROXY_REQUIRED" | "PROXY_ALLOCATION_FAILED";
124
+ readonly code: ProxyResolutionErrorCode;
88
125
  readonly telemetry?: ProxyResolutionTelemetryEvent;
89
- constructor(code: "PROXY_REQUIRED" | "PROXY_ALLOCATION_FAILED", message: string, options?: {
126
+ readonly vendor?: ProxyVendorName;
127
+ readonly vendorChain?: ProxyVendorName[];
128
+ readonly protocol?: ProxyProtocol;
129
+ constructor(code: ProxyResolutionErrorCode, message: string, options?: {
90
130
  cause?: unknown;
91
131
  telemetry?: ProxyResolutionTelemetryEvent;
132
+ vendor?: ProxyVendorName;
133
+ vendorChain?: ProxyVendorName[];
134
+ protocol?: ProxyProtocol;
92
135
  });
93
136
  }
94
137
  type ProxyRedisClient = Pick<Redis, "connect" | "del" | "eval" | "get" | "on" | "pttl" | "set" | "status">;
@@ -99,9 +142,39 @@ export declare function __setProxyRedisForTests(redis: ProxyRedisClient | undefi
99
142
  export declare function __setSmartproxyAllocatorDeadlineMsForTests(deadlineMs: number | undefined): void;
100
143
  export declare function resolveProxyConfig(options?: ProxyResolutionOptions): ResolvedProxyConfig;
101
144
  export declare function resolveProxyConfigAsync(options?: ProxyResolutionOptions): Promise<ResolvedProxyConfig>;
145
+ /**
146
+ * Guard the No-MITM invariant: a resolved proxy URL must use a tunnelling scheme
147
+ * (http CONNECT or socks5) so the client TLS handshake reaches the origin
148
+ * end-to-end. Anything else would intercept TLS and break fingerprinting.
149
+ */
150
+ export declare function assertTunnelingScheme(url: string): void;
151
+ /**
152
+ * Ordered list of SDK-native proxy vendors declared by the policy. `providers`
153
+ * takes precedence over the legacy singular `provider`; the platform default
154
+ * env is the final fallback. Non-registry names (decodo/custom) are dropped so
155
+ * an all-static chain falls through to the legacy env-URL path unchanged.
156
+ */
157
+ export declare function resolveVendorChain(policy: ProviderProxyPolicy): ProxyVendorName[];
158
+ /**
159
+ * Total attempt span across a policy's vendor chain — the sum of each vendor's
160
+ * pool size. Transports use this so successive attempts rotate a vendor's pool
161
+ * and then fail over to the next vendor via the flat attempt index. With one
162
+ * vendor this equals that vendor's pool size (today's behaviour).
163
+ */
164
+ export declare function resolvePolicyProxyPoolSpan(policy: ProviderProxyPolicy): number;
165
+ /** Map a resolved proxy source label to the vendor that served it. */
166
+ export declare function vendorFromResolvedSource(source: ResolvedProxyConfig["source"]): ProxyVendorName | undefined;
167
+ /**
168
+ * Map a flat attempt index into (vendorIndex, poolIndex) by concatenating each
169
+ * vendor's pool space in chain order. With a single vendor this reduces to
170
+ * `attempt % poolSize`, preserving today's behaviour exactly.
171
+ */
172
+ export declare function mapFlatAttempt(flat: number, sizes: readonly number[]): {
173
+ vendorIndex: number;
174
+ poolIndex: number;
175
+ };
102
176
  export declare function clearProxyResolutionCache(): void;
103
177
  export declare function invalidateProxyResolutionCache(options?: ProxyResolutionOptions): boolean;
104
178
  export declare function invalidateProxyResolutionCacheAsync(options?: ProxyResolutionOptions): Promise<boolean>;
105
179
  export declare function defineConfig(config: ApiFuseConfig): ApiFuseConfig;
106
180
  export declare function loadApiFuseConfig(dir?: string): Promise<ApiFuseConfig>;
107
- export {};
@@ -2,6 +2,11 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { Redis } from "ioredis";
5
+ import { NODEMAVEN_DEFAULT_PROTOCOL, hasNodemavenCredentials, nodemavenPoolSize, synthesizeNodemavenProxy, } from "../runtime/proxy-nodemaven.js";
6
+ // "smartproxy" here is api.smartproxy.org — a residential proxy with an IP
7
+ // extraction API (app_key → raw ip:port pool). It is NOT the company formerly
8
+ // named Smartproxy (smartproxy.com), which rebranded to Decodo in 2025 and is
9
+ // modelled separately as the `decodo` gateway vendor. Do not conflate them.
5
10
  export const SMARTPROXY_APP_KEY_ENV = "APIFUSE__PROXY__SMARTPROXY_APP_KEY";
6
11
  export const SMARTPROXY_MAX_LIFETIME_MINUTES = 2000;
7
12
  export const DEFAULT_SMARTPROXY_POOL_SIZE = 20;
@@ -15,11 +20,17 @@ export const REDIS_URL_ENV = "APIFUSE__REDIS__URL";
15
20
  export class ProxyResolutionError extends Error {
16
21
  code;
17
22
  telemetry;
23
+ vendor;
24
+ vendorChain;
25
+ protocol;
18
26
  constructor(code, message, options) {
19
27
  super(message, options);
20
28
  this.name = "ProxyResolutionError";
21
29
  this.code = code;
22
30
  this.telemetry = options?.telemetry;
31
+ this.vendor = options?.vendor;
32
+ this.vendorChain = options?.vendorChain;
33
+ this.protocol = options?.protocol;
23
34
  }
24
35
  }
25
36
  const proxyCache = new Map();
@@ -232,6 +243,11 @@ function applyStickyProxySession(proxyUrl) {
232
243
  if (!parsed.hostname || !parsed.username || !parsed.password) {
233
244
  return proxyUrl;
234
245
  }
246
+ // This rewrites sticky-session usernames for a bring-your-own *gateway* URL
247
+ // (APIFUSE__PROXY__URL). The `smartproxy` host here means a smartproxy.com /
248
+ // Decodo-family gateway that authenticates by username — NOT the
249
+ // api.smartproxy.org allocation vendor, whose endpoints are raw ip:port with
250
+ // no credentials and therefore return early above.
235
251
  const host = parsed.hostname.toLowerCase();
236
252
  if (!host.includes("smartproxy") && !host.includes("decodo")) {
237
253
  return proxyUrl;
@@ -299,47 +315,173 @@ export async function resolveProxyConfigAsync(options = {}) {
299
315
  if (policy.mode === "disabled") {
300
316
  return { shouldWarn: false };
301
317
  }
302
- const provider = resolveProxyProvider(policy);
303
- if (provider !== "smartproxy") {
318
+ const chain = resolveVendorChain(policy);
319
+ if (chain.length === 0) {
320
+ // decodo/custom/env-static providers keep the legacy static-URL path.
304
321
  return resolveProxyConfig({
305
322
  ...options,
306
323
  upstream: { proxy: true },
307
324
  });
308
325
  }
309
- const appKey = process.env[SMARTPROXY_APP_KEY_ENV]?.trim();
310
- if (!appKey) {
311
- if (policy.mode === "required") {
312
- throw new ProxyResolutionError("PROXY_REQUIRED", `Smartproxy egress is required but ${SMARTPROXY_APP_KEY_ENV} is not configured.`);
326
+ // Protocol is chosen per vendor (each vendor's benchmarked-best), with an
327
+ // optional explicit override for the harness/tests. Both are tunnelling
328
+ // schemes. transportProtocols is what the calling transport can actually use.
329
+ const transportProtocols = options.transportProtocols ?? ["http", "socks5"];
330
+ const sizes = chain.map((vendor) => vendorPoolSize(vendor, policy));
331
+ const total = sizes.reduce((sum, size) => sum + size, 0);
332
+ const normalizedAttempt = normalizeAttemptIndex(options.proxyAttempt);
333
+ const { vendorIndex: startVendorIndex, poolIndex: startPoolIndex } = mapFlatAttempt(total > 0 ? normalizedAttempt % total : 0, sizes);
334
+ const refreshEpoch = normalizeAttemptIndex(options.proxyRefreshEpoch);
335
+ let lastError;
336
+ let blockedProtocol;
337
+ for (let vendorIndex = startVendorIndex; vendorIndex < chain.length; vendorIndex++) {
338
+ const vendor = chain[vendorIndex];
339
+ const nextVendor = chain[vendorIndex + 1];
340
+ const poolIndex = vendorIndex === startVendorIndex ? startPoolIndex : 0;
341
+ const protocol = options.protocol ?? VENDOR_DEFAULT_PROTOCOL[vendor];
342
+ if (!vendorHasCredentials(vendor)) {
343
+ options.telemetry?.recordProxyVendorFailover?.({
344
+ vendor,
345
+ nextVendor,
346
+ phase: "resolution",
347
+ reason: "no_credentials",
348
+ });
349
+ continue;
350
+ }
351
+ // The calling transport must be able to use this vendor's protocol; if not,
352
+ // fail over to the next vendor rather than silently downgrading.
353
+ if (!transportProtocols.includes(protocol)) {
354
+ blockedProtocol = protocol;
355
+ options.telemetry?.recordProxyVendorFailover?.({
356
+ vendor,
357
+ nextVendor,
358
+ phase: "resolution",
359
+ reason: "protocol_unsupported",
360
+ });
361
+ continue;
362
+ }
363
+ try {
364
+ return await resolveWithVendor(vendor, policy, options, {
365
+ protocol,
366
+ poolIndex,
367
+ refreshEpoch,
368
+ });
369
+ }
370
+ catch (error) {
371
+ // Config/programming errors (invalid filter, etc.) are not vendor
372
+ // outages — propagate them rather than failing over.
373
+ if (!(error instanceof ProxyResolutionError)) {
374
+ throw error;
375
+ }
376
+ if (error.telemetry) {
377
+ options.telemetry?.recordProxyResolution(error.telemetry);
378
+ }
379
+ lastError = error;
380
+ options.telemetry?.recordProxyVendorFailover?.({
381
+ vendor,
382
+ nextVendor,
383
+ phase: "resolution",
384
+ reason: "allocation_failed",
385
+ });
313
386
  }
314
- return { shouldWarn: true };
315
387
  }
316
- const lifetimeMinutes = resolveSmartproxyLifetime(policy);
388
+ if (policy.mode === "required") {
389
+ if (lastError) {
390
+ throw lastError instanceof ProxyResolutionError
391
+ ? lastError
392
+ : new ProxyResolutionError("PROXY_ALLOCATION_FAILED", `All proxy vendors [${chain.join(", ")}] failed for required proxy egress.`, { cause: lastError, vendorChain: chain });
393
+ }
394
+ if (blockedProtocol) {
395
+ throw new ProxyResolutionError("PROXY_PROTOCOL_UNSUPPORTED", `No proxy vendor in [${chain.join(", ")}] could serve a protocol supported by this transport (supports: ${transportProtocols.join(", ")}; vendor wanted "${blockedProtocol}"). Route this provider through the stealth transport.`, { protocol: blockedProtocol, vendorChain: chain });
396
+ }
397
+ throw new ProxyResolutionError("PROXY_REQUIRED", `Proxy egress is required but no vendor credentials are configured. Missing: ${chain
398
+ .map((vendor) => `${missingCredentialEnv(vendor)} (${vendor})`)
399
+ .join(", ")}.`, { vendorChain: chain });
400
+ }
401
+ return { shouldWarn: true };
402
+ }
403
+ /**
404
+ * Each vendor's default egress protocol, chosen from live KR benchmarks. HTTP
405
+ * CONNECT wins for nodemaven (socks5 adds ~500ms through the gateway) and ties
406
+ * for smartproxy, and is the only protocol ctx.http (Bun native fetch) supports.
407
+ * Override per call via ProxyResolutionOptions.protocol (harness/tests).
408
+ */
409
+ const VENDOR_DEFAULT_PROTOCOL = {
410
+ smartproxy: "http",
411
+ nodemaven: NODEMAVEN_DEFAULT_PROTOCOL,
412
+ };
413
+ /**
414
+ * Guard the No-MITM invariant: a resolved proxy URL must use a tunnelling scheme
415
+ * (http CONNECT or socks5) so the client TLS handshake reaches the origin
416
+ * end-to-end. Anything else would intercept TLS and break fingerprinting.
417
+ */
418
+ export function assertTunnelingScheme(url) {
419
+ let scheme;
317
420
  try {
318
- const allocated = await allocateSmartproxy(policy, appKey, lifetimeMinutes, options.affinityKey);
319
- options.telemetry?.recordProxyResolution(allocated.telemetry);
320
- const poolIndex = selectProxyPoolIndex(allocated.pool.urls.length, options.proxyAttempt);
421
+ scheme = new URL(url).protocol.replace(/:$/, "").toLowerCase();
422
+ }
423
+ catch {
424
+ throw new ProxyResolutionError("PROXY_ALLOCATION_FAILED", `Malformed proxy URL: ${url}`);
425
+ }
426
+ if (scheme !== "http" && scheme !== "socks5") {
427
+ throw new ProxyResolutionError("PROXY_ALLOCATION_FAILED", `Resolved proxy scheme "${scheme}" is not a tunnelling scheme (expected http or socks5). Refusing to route TLS through a non-tunnelling proxy.`);
428
+ }
429
+ }
430
+ async function resolveWithVendor(vendor, policy, options, context) {
431
+ if (vendor === "nodemaven") {
432
+ const startedAt = Date.now();
433
+ const synthesized = synthesizeNodemavenProxy({
434
+ policy,
435
+ affinityKey: options.affinityKey,
436
+ protocol: context.protocol,
437
+ poolIndex: context.poolIndex,
438
+ refreshEpoch: context.refreshEpoch,
439
+ country: resolveSmartproxyCountry(policy),
440
+ });
441
+ options.telemetry?.recordProxyResolution({
442
+ provider: "nodemaven",
443
+ protocol: synthesized.protocol,
444
+ cacheStatus: "disabled",
445
+ cacheHit: false,
446
+ resolutionMs: Math.max(0, Date.now() - startedAt),
447
+ attempts: 1,
448
+ });
449
+ assertTunnelingScheme(synthesized.url);
321
450
  return {
322
451
  shouldWarn: false,
323
- url: allocated.pool.urls[poolIndex],
324
- source: "smartproxy-allocator",
452
+ url: synthesized.url,
453
+ source: "nodemaven-gateway",
454
+ protocol: synthesized.protocol,
325
455
  diagnostics: {
326
- ...allocated.pool.diagnostics,
327
- poolSize: allocated.pool.urls.length,
328
- poolIndex,
456
+ ...synthesized.diagnostics,
457
+ poolIndex: context.poolIndex,
329
458
  },
330
459
  };
331
460
  }
332
- catch (error) {
333
- if (error instanceof ProxyResolutionError && error.telemetry) {
334
- options.telemetry?.recordProxyResolution(error.telemetry);
335
- }
336
- if (policy.mode === "required") {
337
- throw error instanceof ProxyResolutionError
338
- ? error
339
- : new ProxyResolutionError("PROXY_ALLOCATION_FAILED", "Smartproxy allocator failed for required proxy egress.", { cause: error });
340
- }
341
- return { shouldWarn: true };
461
+ // smartproxy allocation-style vendor.
462
+ const appKey = process.env[SMARTPROXY_APP_KEY_ENV]?.trim();
463
+ if (!appKey) {
464
+ // Guarded by vendorHasCredentials; treated as a vendor-internal failure.
465
+ throw new ProxyResolutionError("PROXY_ALLOCATION_FAILED", `${SMARTPROXY_APP_KEY_ENV} is not configured.`, { vendor: "smartproxy" });
342
466
  }
467
+ const lifetimeMinutes = resolveSmartproxyLifetime(policy);
468
+ const allocated = await allocateSmartproxy(policy, appKey, lifetimeMinutes, options.affinityKey, context.protocol);
469
+ options.telemetry?.recordProxyResolution({ ...allocated.telemetry, protocol: context.protocol });
470
+ const poolIndex = selectProxyPoolIndex(allocated.pool.urls.length, context.poolIndex);
471
+ const url = allocated.pool.urls[poolIndex];
472
+ if (url)
473
+ assertTunnelingScheme(url);
474
+ return {
475
+ shouldWarn: false,
476
+ url,
477
+ source: "smartproxy-allocator",
478
+ protocol: context.protocol,
479
+ diagnostics: {
480
+ ...allocated.pool.diagnostics,
481
+ poolSize: allocated.pool.urls.length,
482
+ poolIndex,
483
+ },
484
+ };
343
485
  }
344
486
  function resolvePolicy(options) {
345
487
  if (options.proxyPolicy) {
@@ -351,8 +493,80 @@ function resolvePolicy(options) {
351
493
  }
352
494
  return undefined;
353
495
  }
354
- function resolveProxyProvider(policy) {
355
- return (policy.provider ?? process.env[DEFAULT_PROXY_PROVIDER_ENV]?.trim().toLowerCase() ?? "custom");
496
+ function isRegistryVendor(name) {
497
+ return name === "smartproxy" || name === "nodemaven";
498
+ }
499
+ /**
500
+ * Ordered list of SDK-native proxy vendors declared by the policy. `providers`
501
+ * takes precedence over the legacy singular `provider`; the platform default
502
+ * env is the final fallback. Non-registry names (decodo/custom) are dropped so
503
+ * an all-static chain falls through to the legacy env-URL path unchanged.
504
+ */
505
+ export function resolveVendorChain(policy) {
506
+ const declared = policy.providers?.length
507
+ ? policy.providers
508
+ : [policy.provider ?? envDefaultProvider()];
509
+ const chain = [];
510
+ for (const name of declared) {
511
+ if (isRegistryVendor(name) && !chain.includes(name)) {
512
+ chain.push(name);
513
+ }
514
+ }
515
+ return chain;
516
+ }
517
+ function envDefaultProvider() {
518
+ const raw = process.env[DEFAULT_PROXY_PROVIDER_ENV]?.trim().toLowerCase();
519
+ return raw ?? undefined;
520
+ }
521
+ function vendorHasCredentials(vendor) {
522
+ if (vendor === "nodemaven")
523
+ return hasNodemavenCredentials();
524
+ return Boolean(process.env[SMARTPROXY_APP_KEY_ENV]?.trim());
525
+ }
526
+ function missingCredentialEnv(vendor) {
527
+ return vendor === "nodemaven" ? "APIFUSE__PROXY__NODEMAVEN_USERNAME" : SMARTPROXY_APP_KEY_ENV;
528
+ }
529
+ function vendorPoolSize(vendor, policy) {
530
+ return vendor === "nodemaven" ? nodemavenPoolSize(policy) : resolveSmartproxyPoolSize(policy);
531
+ }
532
+ /**
533
+ * Total attempt span across a policy's vendor chain — the sum of each vendor's
534
+ * pool size. Transports use this so successive attempts rotate a vendor's pool
535
+ * and then fail over to the next vendor via the flat attempt index. With one
536
+ * vendor this equals that vendor's pool size (today's behaviour).
537
+ */
538
+ export function resolvePolicyProxyPoolSpan(policy) {
539
+ const chain = resolveVendorChain(policy);
540
+ if (chain.length === 0)
541
+ return resolveSmartproxyPoolSize(policy);
542
+ return chain.reduce((sum, vendor) => sum + vendorPoolSize(vendor, policy), 0);
543
+ }
544
+ /** Map a resolved proxy source label to the vendor that served it. */
545
+ export function vendorFromResolvedSource(source) {
546
+ if (source === "nodemaven-gateway")
547
+ return "nodemaven";
548
+ if (source === "smartproxy-allocator")
549
+ return "smartproxy";
550
+ return undefined;
551
+ }
552
+ function normalizeAttemptIndex(attempt) {
553
+ return Number.isFinite(attempt) ? Math.max(0, Math.floor(attempt)) : 0;
554
+ }
555
+ /**
556
+ * Map a flat attempt index into (vendorIndex, poolIndex) by concatenating each
557
+ * vendor's pool space in chain order. With a single vendor this reduces to
558
+ * `attempt % poolSize`, preserving today's behaviour exactly.
559
+ */
560
+ export function mapFlatAttempt(flat, sizes) {
561
+ let cursor = flat;
562
+ for (let vendorIndex = 0; vendorIndex < sizes.length; vendorIndex++) {
563
+ const size = Math.max(1, sizes[vendorIndex] ?? 1);
564
+ if (cursor < size) {
565
+ return { vendorIndex, poolIndex: cursor };
566
+ }
567
+ cursor -= size;
568
+ }
569
+ return { vendorIndex: 0, poolIndex: 0 };
356
570
  }
357
571
  function resolveSmartproxyCountry(policy) {
358
572
  return (policy.geo?.country ?? process.env[DEFAULT_PROXY_COUNTRY_ENV]?.trim().toUpperCase() ?? undefined);
@@ -381,10 +595,11 @@ function selectProxyPoolIndex(poolSize, attempt = 0) {
381
595
  const normalizedAttempt = Number.isFinite(attempt) ? Math.max(0, Math.floor(attempt)) : 0;
382
596
  return normalizedAttempt % poolSize;
383
597
  }
384
- function buildSmartproxyCacheKey(policy, affinityKey, lifetimeMinutes) {
598
+ function buildSmartproxyCacheKey(policy, affinityKey, lifetimeMinutes, protocol) {
385
599
  const poolSize = resolveSmartproxyPoolSize(policy);
386
600
  return JSON.stringify({
387
601
  provider: "smartproxy",
602
+ protocol,
388
603
  country: resolveSmartproxyCountry(policy),
389
604
  affinity: policy.session?.affinity ?? "request",
390
605
  affinityKey: (policy.session?.affinity ?? "request") === "request" ? undefined : affinityKey,
@@ -392,8 +607,8 @@ function buildSmartproxyCacheKey(policy, affinityKey, lifetimeMinutes) {
392
607
  poolSize,
393
608
  });
394
609
  }
395
- async function allocateSmartproxy(policy, appKey, lifetimeMinutes, affinityKey) {
396
- const cacheKey = buildSmartproxyCacheKey(policy, affinityKey, lifetimeMinutes);
610
+ async function allocateSmartproxy(policy, appKey, lifetimeMinutes, affinityKey, protocol) {
611
+ const cacheKey = buildSmartproxyCacheKey(policy, affinityKey, lifetimeMinutes, protocol);
397
612
  const startedAt = Date.now();
398
613
  const now = startedAt;
399
614
  const invalidatedUntil = invalidatedProxyKeys.get(cacheKey) ?? 0;
@@ -401,7 +616,7 @@ async function allocateSmartproxy(policy, appKey, lifetimeMinutes, affinityKey)
401
616
  const cached = proxyCache.get(cacheKey);
402
617
  if (!skipCached && cached && isFresh(cached, now)) {
403
618
  if (shouldSoftRefresh(cached, now)) {
404
- void refreshSmartproxyPool(cacheKey, policy, appKey, lifetimeMinutes);
619
+ void refreshSmartproxyPool(cacheKey, policy, appKey, lifetimeMinutes, protocol);
405
620
  return {
406
621
  pool: cached,
407
622
  telemetry: telemetryForPool(cached, "soft_stale_refresh", startedAt, {
@@ -429,7 +644,7 @@ async function allocateSmartproxy(policy, appKey, lifetimeMinutes, affinityKey)
429
644
  }),
430
645
  };
431
646
  }
432
- const promise = allocateSmartproxyShared(cacheKey, policy, appKey, lifetimeMinutes, startedAt).finally(() => {
647
+ const promise = allocateSmartproxyShared(cacheKey, policy, appKey, lifetimeMinutes, startedAt, protocol).finally(() => {
433
648
  proxyInflight.delete(cacheKey);
434
649
  });
435
650
  proxyInflight.set(cacheKey, promise);
@@ -460,9 +675,9 @@ async function readSmartproxyRedisPool(cacheKey, startedAt) {
460
675
  telemetry: telemetryForPool(pool, "redis_hit", startedAt, { redisReadMs }),
461
676
  };
462
677
  }
463
- async function refreshSmartproxyPool(cacheKey, policy, appKey, lifetimeMinutes) {
678
+ async function refreshSmartproxyPool(cacheKey, policy, appKey, lifetimeMinutes, protocol) {
464
679
  try {
465
- await allocateSmartproxyShared(cacheKey, policy, appKey, lifetimeMinutes, Date.now(), {
680
+ await allocateSmartproxyShared(cacheKey, policy, appKey, lifetimeMinutes, Date.now(), protocol, {
466
681
  background: true,
467
682
  });
468
683
  }
@@ -470,10 +685,10 @@ async function refreshSmartproxyPool(cacheKey, policy, appKey, lifetimeMinutes)
470
685
  // Soft refresh is opportunistic; current fresh pool remains usable.
471
686
  }
472
687
  }
473
- async function allocateSmartproxyShared(cacheKey, policy, appKey, lifetimeMinutes, startedAt, options = {}) {
688
+ async function allocateSmartproxyShared(cacheKey, policy, appKey, lifetimeMinutes, startedAt, protocol, options = {}) {
474
689
  const redis = getProxyRedis();
475
690
  if (!redis || !(await ensureRedisReady(redis))) {
476
- return await allocateAndStoreSmartproxyPool(cacheKey, policy, appKey, lifetimeMinutes, startedAt, { cacheStatus: "allocator" });
691
+ return await allocateAndStoreSmartproxyPool(cacheKey, policy, appKey, lifetimeMinutes, startedAt, { cacheStatus: "allocator", protocol });
477
692
  }
478
693
  const poolKey = smartproxyRedisPoolKey(cacheKey);
479
694
  const lockKey = smartproxyRedisLockKey(cacheKey);
@@ -487,6 +702,7 @@ async function allocateSmartproxyShared(cacheKey, policy, appKey, lifetimeMinute
487
702
  cacheStatus: options.background ? "soft_stale_refresh" : "allocator",
488
703
  redis,
489
704
  poolKey,
705
+ protocol,
490
706
  });
491
707
  }
492
708
  finally {
@@ -599,7 +815,7 @@ async function readSmartproxyAllocatorBodyWithDeadline(response, signal) {
599
815
  }
600
816
  async function allocateAndStoreSmartproxyPool(cacheKey, policy, appKey, lifetimeMinutes, startedAt, options) {
601
817
  const poolSize = resolveSmartproxyPoolSize(policy);
602
- const allocatorUrl = buildSmartproxyAllocatorUrl(policy, appKey, lifetimeMinutes, poolSize);
818
+ const allocatorUrl = buildSmartproxyAllocatorUrl(policy, appKey, lifetimeMinutes, poolSize, options.protocol);
603
819
  const allocatorStartedAt = Date.now();
604
820
  const allocatorDeadlineAt = allocatorStartedAt + smartproxyAllocatorDeadlineMs();
605
821
  let allocation;
@@ -609,7 +825,7 @@ async function allocateAndStoreSmartproxyPool(cacheKey, policy, appKey, lifetime
609
825
  lastFailure = smartproxyAllocatorDeadlineFailure(attempt);
610
826
  break;
611
827
  }
612
- const attemptResult = await fetchSmartproxyAllocatorAttempt(allocatorUrl, attempt, allocatorDeadlineAt);
828
+ const attemptResult = await fetchSmartproxyAllocatorAttempt(allocatorUrl, attempt, allocatorDeadlineAt, options.protocol);
613
829
  if (attemptResult.ok) {
614
830
  allocation = attemptResult;
615
831
  break;
@@ -692,7 +908,7 @@ async function allocateAndStoreSmartproxyPool(cacheKey, policy, appKey, lifetime
692
908
  }),
693
909
  };
694
910
  }
695
- async function fetchSmartproxyAllocatorAttempt(allocatorUrl, attempt, deadlineAt) {
911
+ async function fetchSmartproxyAllocatorAttempt(allocatorUrl, attempt, deadlineAt, protocol) {
696
912
  const { controller, dispose } = createDeadlineAbortController(deadlineAt);
697
913
  let response;
698
914
  try {
@@ -735,7 +951,7 @@ async function fetchSmartproxyAllocatorAttempt(allocatorUrl, attempt, deadlineAt
735
951
  bodyClass: "http_error",
736
952
  };
737
953
  }
738
- const urls = parseSmartproxyAllocatorProxies(body);
954
+ const urls = parseSmartproxyAllocatorProxies(body, protocol);
739
955
  const bodyClass = classifySmartproxyAllocatorBody(body, urls);
740
956
  if (urls.length === 0) {
741
957
  return {
@@ -770,13 +986,20 @@ function smartproxyAllocatorFailureMessage(failure) {
770
986
  }
771
987
  return "Smartproxy allocator response did not contain a usable proxy endpoint.";
772
988
  }
773
- function buildSmartproxyAllocatorUrl(policy, appKey, lifetimeMinutes, poolSize) {
989
+ // Smartproxy get-ip-v3 `protocol` param: 1 = HTTP. The SOCKS5 value ("2") is a
990
+ // best-effort mapping pending live vendor confirmation; http is the default and
991
+ // the only value exercised in production today.
992
+ const SMARTPROXY_PROTOCOL_PARAM = {
993
+ http: "1",
994
+ socks5: "2",
995
+ };
996
+ function buildSmartproxyAllocatorUrl(policy, appKey, lifetimeMinutes, poolSize, protocol) {
774
997
  const params = new URLSearchParams({
775
998
  app_key: appKey,
776
999
  pt: "9",
777
1000
  num: String(poolSize),
778
1001
  life: String(lifetimeMinutes),
779
- protocol: "1",
1002
+ protocol: SMARTPROXY_PROTOCOL_PARAM[protocol],
780
1003
  format: "txt",
781
1004
  lb: "\\n",
782
1005
  });
@@ -788,7 +1011,8 @@ function buildSmartproxyAllocatorUrl(policy, appKey, lifetimeMinutes, poolSize)
788
1011
  // old path 404s into the marketing site); the API lives on the api host.
789
1012
  return `https://api.smartproxy.org/web_v1/ip/get-ip-v3?${params.toString()}`;
790
1013
  }
791
- function parseSmartproxyAllocatorProxies(body) {
1014
+ function parseSmartproxyAllocatorProxies(body, protocol) {
1015
+ const scheme = protocol === "socks5" ? "socks5" : "http";
792
1016
  const trimmed = body.trim();
793
1017
  if (!trimmed) {
794
1018
  return [];
@@ -807,7 +1031,7 @@ function parseSmartproxyAllocatorProxies(body) {
807
1031
  const port = "port" in item && (typeof item.port === "string" || typeof item.port === "number")
808
1032
  ? item.port
809
1033
  : "";
810
- return ip && port ? `http://${ip}:${port}` : null;
1034
+ return ip && port ? `${scheme}://${ip}:${port}` : null;
811
1035
  })
812
1036
  .filter((url) => url !== null);
813
1037
  }
@@ -819,7 +1043,7 @@ function parseSmartproxyAllocatorProxies(body) {
819
1043
  .split(/\r?\n/)
820
1044
  .map((item) => item.trim())
821
1045
  .filter((item) => /^\d{1,3}(?:\.\d{1,3}){3}:\d{2,5}$/.test(item))
822
- .map((line) => `http://${line}`);
1046
+ .map((line) => `${scheme}://${line}`);
823
1047
  }
824
1048
  function classifySmartproxyAllocatorBody(body, urls) {
825
1049
  if (urls.length > 0) {
@@ -847,11 +1071,11 @@ function markSmartproxyCacheInvalidated(options = {}) {
847
1071
  if (!policy || policy.mode === "disabled") {
848
1072
  return undefined;
849
1073
  }
850
- if (resolveProxyProvider(policy) !== "smartproxy") {
1074
+ if (!resolveVendorChain(policy).includes("smartproxy")) {
851
1075
  return undefined;
852
1076
  }
853
1077
  const lifetimeMinutes = resolveSmartproxyLifetime(policy);
854
- const cacheKey = buildSmartproxyCacheKey(policy, options.affinityKey, lifetimeMinutes);
1078
+ const cacheKey = buildSmartproxyCacheKey(policy, options.affinityKey, lifetimeMinutes, options.protocol ?? VENDOR_DEFAULT_PROTOCOL.smartproxy);
855
1079
  invalidatedProxyKeys.set(cacheKey, Date.now() + SMARTPROXY_INVALIDATION_SKIP_REDIS_MS);
856
1080
  proxyCache.delete(cacheKey);
857
1081
  proxyInflight.delete(cacheKey);