@apifuse/provider-sdk 2.1.0-beta.1 → 2.1.0-beta.10

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 (212) hide show
  1. package/AUTHORING.md +208 -2
  2. package/CHANGELOG.md +47 -0
  3. package/README.md +114 -10
  4. package/SUBMISSION.md +87 -0
  5. package/bin/apifuse-check.ts +86 -4
  6. package/bin/apifuse-dev.ts +87 -13
  7. package/bin/apifuse-pack-check.ts +80 -0
  8. package/bin/apifuse-pack-smoke.ts +303 -2
  9. package/bin/apifuse-perf.ts +142 -49
  10. package/bin/apifuse-record.ts +182 -104
  11. package/bin/apifuse-submit-check.ts +2538 -0
  12. package/bin/apifuse.ts +1 -1
  13. package/dist/ceremonies/index.d.ts +41 -0
  14. package/dist/ceremonies/index.js +490 -0
  15. package/dist/choice-token.d.ts +24 -0
  16. package/dist/choice-token.js +74 -0
  17. package/dist/cli/commands.d.ts +10 -0
  18. package/dist/cli/commands.js +80 -0
  19. package/dist/cli/create.d.ts +47 -0
  20. package/dist/cli/create.js +762 -0
  21. package/dist/cli/templates/provider/.dockerignore.tpl +22 -0
  22. package/dist/cli/templates/provider/.gitignore.tpl +22 -0
  23. package/dist/cli/templates/provider/Dockerfile.tpl +7 -0
  24. package/dist/cli/templates/provider/README.md.tpl +160 -0
  25. package/dist/cli/templates/provider/dev.ts.tpl +5 -0
  26. package/dist/cli/templates/provider/domain/README.md.tpl +3 -0
  27. package/dist/cli/templates/provider/index.test.ts.tpl +13 -0
  28. package/dist/cli/templates/provider/index.ts.tpl +15 -0
  29. package/dist/cli/templates/provider/mappers/README.md.tpl +3 -0
  30. package/dist/cli/templates/provider/meta.ts.tpl +7 -0
  31. package/dist/cli/templates/provider/operations/index.ts.tpl +5 -0
  32. package/dist/cli/templates/provider/operations/ping.ts.tpl +24 -0
  33. package/dist/cli/templates/provider/schemas/ping.ts.tpl +24 -0
  34. package/dist/cli/templates/provider/start.ts.tpl +5 -0
  35. package/dist/cli/templates/provider/upstream/README.md.tpl +3 -0
  36. package/dist/config/loader.d.ts +107 -0
  37. package/dist/config/loader.js +935 -0
  38. package/dist/contract-json.d.ts +9 -0
  39. package/dist/contract-json.js +51 -0
  40. package/dist/contract-serialization.d.ts +4 -0
  41. package/dist/contract-serialization.js +78 -0
  42. package/dist/contract-types.d.ts +49 -0
  43. package/dist/contract-types.js +1 -0
  44. package/dist/contract.d.ts +6 -0
  45. package/dist/contract.js +155 -0
  46. package/dist/define.d.ts +97 -0
  47. package/dist/define.js +1320 -0
  48. package/dist/dev.d.ts +9 -0
  49. package/dist/dev.js +15 -0
  50. package/dist/errors.d.ts +59 -0
  51. package/dist/errors.js +97 -0
  52. package/dist/i18n/catalog.d.ts +29 -0
  53. package/dist/i18n/catalog.js +159 -0
  54. package/dist/i18n/index.d.ts +2 -0
  55. package/dist/i18n/index.js +2 -0
  56. package/dist/i18n/keys.d.ts +10 -0
  57. package/dist/i18n/keys.js +34 -0
  58. package/dist/index.d.ts +41 -0
  59. package/dist/index.js +37 -0
  60. package/dist/lint.d.ts +73 -0
  61. package/dist/lint.js +702 -0
  62. package/dist/observability.d.ts +5 -0
  63. package/dist/observability.js +39 -0
  64. package/dist/provider.d.ts +9 -0
  65. package/dist/provider.js +8 -0
  66. package/dist/public-schema-field-lint.d.ts +2 -0
  67. package/dist/public-schema-field-lint.js +158 -0
  68. package/dist/recipes/gov-api.d.ts +19 -0
  69. package/dist/recipes/gov-api.js +72 -0
  70. package/dist/recipes/rest-api.d.ts +21 -0
  71. package/dist/recipes/rest-api.js +115 -0
  72. package/dist/runtime/auth-flow.d.ts +14 -0
  73. package/dist/runtime/auth-flow.js +44 -0
  74. package/dist/runtime/browser.d.ts +25 -0
  75. package/dist/runtime/browser.js +1034 -0
  76. package/dist/runtime/cache.d.ts +10 -0
  77. package/dist/runtime/cache.js +372 -0
  78. package/dist/runtime/choice.d.ts +15 -0
  79. package/dist/runtime/choice.js +435 -0
  80. package/dist/runtime/credential.d.ts +8 -0
  81. package/dist/runtime/credential.js +61 -0
  82. package/dist/runtime/env.d.ts +2 -0
  83. package/dist/runtime/env.js +10 -0
  84. package/dist/runtime/executor.d.ts +16 -0
  85. package/dist/runtime/executor.js +51 -0
  86. package/dist/runtime/http.d.ts +8 -0
  87. package/dist/runtime/http.js +706 -0
  88. package/dist/runtime/insights.d.ts +9 -0
  89. package/dist/runtime/insights.js +324 -0
  90. package/dist/runtime/instrumentation.d.ts +8 -0
  91. package/dist/runtime/instrumentation.js +269 -0
  92. package/dist/runtime/key-derivation.d.ts +24 -0
  93. package/dist/runtime/key-derivation.js +73 -0
  94. package/dist/runtime/keyring.d.ts +25 -0
  95. package/dist/runtime/keyring.js +93 -0
  96. package/dist/runtime/namespace.d.ts +9 -0
  97. package/dist/runtime/namespace.js +19 -0
  98. package/dist/runtime/otlp.d.ts +39 -0
  99. package/dist/runtime/otlp.js +103 -0
  100. package/dist/runtime/perf.d.ts +12 -0
  101. package/dist/runtime/perf.js +52 -0
  102. package/dist/runtime/prevalidate.d.ts +12 -0
  103. package/dist/runtime/prevalidate.js +173 -0
  104. package/dist/runtime/provider.d.ts +2 -0
  105. package/dist/runtime/provider.js +11 -0
  106. package/dist/runtime/proxy-errors.d.ts +21 -0
  107. package/dist/runtime/proxy-errors.js +83 -0
  108. package/dist/runtime/proxy-telemetry.d.ts +8 -0
  109. package/dist/runtime/proxy-telemetry.js +174 -0
  110. package/dist/runtime/redis.d.ts +17 -0
  111. package/dist/runtime/redis.js +82 -0
  112. package/dist/runtime/request-options.d.ts +3 -0
  113. package/dist/runtime/request-options.js +42 -0
  114. package/dist/runtime/state.d.ts +17 -0
  115. package/dist/runtime/state.js +344 -0
  116. package/dist/runtime/stealth.d.ts +18 -0
  117. package/dist/runtime/stealth.js +834 -0
  118. package/dist/runtime/stt.d.ts +22 -0
  119. package/dist/runtime/stt.js +480 -0
  120. package/dist/runtime/trace.d.ts +26 -0
  121. package/dist/runtime/trace.js +142 -0
  122. package/dist/runtime/waterfall.d.ts +12 -0
  123. package/dist/runtime/waterfall.js +147 -0
  124. package/dist/schema.d.ts +74 -0
  125. package/dist/schema.js +243 -0
  126. package/dist/serve.d.ts +1 -0
  127. package/dist/serve.js +1 -0
  128. package/dist/server/index.d.ts +3 -0
  129. package/dist/server/index.js +2 -0
  130. package/dist/server/serve.d.ts +64 -0
  131. package/dist/server/serve.js +1110 -0
  132. package/dist/server/types.d.ts +136 -0
  133. package/dist/server/types.js +86 -0
  134. package/dist/stealth/profiles.d.ts +4 -0
  135. package/dist/stealth/profiles.js +259 -0
  136. package/dist/stream.d.ts +44 -0
  137. package/dist/stream.js +151 -0
  138. package/dist/testing/helpers.d.ts +23 -0
  139. package/dist/testing/helpers.js +95 -0
  140. package/dist/testing/index.d.ts +2 -0
  141. package/dist/testing/index.js +2 -0
  142. package/dist/testing/run.d.ts +34 -0
  143. package/dist/testing/run.js +303 -0
  144. package/dist/types.d.ts +1326 -0
  145. package/dist/types.js +61 -0
  146. package/dist/utils/date.d.ts +6 -0
  147. package/dist/utils/date.js +101 -0
  148. package/dist/utils/parse.d.ts +16 -0
  149. package/dist/utils/parse.js +51 -0
  150. package/dist/utils/text.d.ts +4 -0
  151. package/dist/utils/text.js +14 -0
  152. package/dist/utils/transform.d.ts +8 -0
  153. package/dist/utils/transform.js +48 -0
  154. package/package.json +57 -30
  155. package/src/ceremonies/index.ts +8 -2
  156. package/src/choice-token.ts +165 -0
  157. package/src/cli/commands.ts +34 -11
  158. package/src/cli/create.ts +214 -52
  159. package/src/cli/templates/provider/.dockerignore.tpl +22 -0
  160. package/src/cli/templates/provider/.gitignore.tpl +22 -0
  161. package/src/cli/templates/provider/README.md.tpl +120 -1
  162. package/src/cli/templates/provider/dev.ts.tpl +1 -1
  163. package/src/cli/templates/provider/domain/README.md.tpl +3 -0
  164. package/src/cli/templates/provider/index.ts.tpl +5 -48
  165. package/src/cli/templates/provider/mappers/README.md.tpl +3 -0
  166. package/src/cli/templates/provider/meta.ts.tpl +7 -0
  167. package/src/cli/templates/provider/operations/index.ts.tpl +5 -0
  168. package/src/cli/templates/provider/operations/ping.ts.tpl +24 -0
  169. package/src/cli/templates/provider/schemas/ping.ts.tpl +24 -0
  170. package/src/cli/templates/provider/start.ts.tpl +1 -1
  171. package/src/cli/templates/provider/upstream/README.md.tpl +3 -0
  172. package/src/config/loader.ts +1224 -9
  173. package/src/contract-json.ts +75 -0
  174. package/src/contract-serialization.ts +89 -0
  175. package/src/contract-types.ts +52 -0
  176. package/src/contract.ts +215 -0
  177. package/src/define.ts +1688 -48
  178. package/src/errors.ts +27 -0
  179. package/src/i18n/catalog.ts +277 -0
  180. package/src/i18n/index.ts +2 -0
  181. package/src/i18n/keys.ts +64 -0
  182. package/src/index.ts +174 -9
  183. package/src/lint.ts +547 -73
  184. package/src/observability.ts +41 -0
  185. package/src/provider.ts +104 -4
  186. package/src/public-schema-field-lint.ts +237 -0
  187. package/src/runtime/auth-flow.ts +7 -0
  188. package/src/runtime/browser.ts +762 -51
  189. package/src/runtime/cache.ts +528 -0
  190. package/src/runtime/choice.ts +760 -0
  191. package/src/runtime/executor.ts +32 -3
  192. package/src/runtime/http.ts +939 -195
  193. package/src/runtime/insights.ts +11 -11
  194. package/src/runtime/instrumentation.ts +12 -4
  195. package/src/runtime/key-derivation.ts +1 -1
  196. package/src/runtime/keyring.ts +4 -3
  197. package/src/runtime/proxy-errors.ts +132 -0
  198. package/src/runtime/proxy-telemetry.ts +253 -0
  199. package/src/runtime/redis.ts +116 -0
  200. package/src/runtime/request-options.ts +66 -0
  201. package/src/runtime/state.ts +563 -0
  202. package/src/runtime/stealth.ts +1159 -0
  203. package/src/runtime/stt.ts +629 -0
  204. package/src/runtime/trace.ts +1 -1
  205. package/src/schema.ts +363 -1
  206. package/src/server/serve.ts +1157 -75
  207. package/src/server/types.ts +37 -0
  208. package/src/stream.ts +210 -0
  209. package/src/testing/run.ts +31 -5
  210. package/src/types.ts +1107 -59
  211. package/src/runtime/tls.ts +0 -434
  212. package/src/types/playwright-stealth.d.ts +0 -9
@@ -1,7 +1,24 @@
1
+ import { createHash, randomUUID } from "node:crypto";
1
2
  import { existsSync } from "node:fs";
2
3
  import path from "node:path";
3
4
 
4
- import type { TraceConfig } from "../types";
5
+ import Redis from "ioredis";
6
+
7
+ import type { ProviderProxyPolicy, TraceConfig } from "../types";
8
+
9
+ export const SMARTPROXY_APP_KEY_ENV = "APIFUSE__PROXY__SMARTPROXY_APP_KEY";
10
+ export const SMARTPROXY_MAX_LIFETIME_MINUTES = 2000;
11
+ export const DEFAULT_SMARTPROXY_POOL_SIZE = 20;
12
+ export const SMARTPROXY_MAX_POOL_SIZE = 20;
13
+ export const DEFAULT_PROXY_PROVIDER_ENV = "APIFUSE__PROXY__PROVIDER";
14
+ export const DEFAULT_PROXY_COUNTRY_ENV = "APIFUSE__PROXY__DEFAULT_COUNTRY";
15
+ export const DEFAULT_PROXY_LIFETIME_ENV =
16
+ "APIFUSE__PROXY__DEFAULT_LIFETIME_MINUTES";
17
+ export const PROVIDER_CACHE_REDIS_URL_ENV =
18
+ "APIFUSE__PROVIDER__CACHE_REDIS_URL";
19
+ export const PROVIDER_STATE_REDIS_URL_ENV =
20
+ "APIFUSE__PROVIDER__STATE_REDIS_URL";
21
+ export const REDIS_URL_ENV = "APIFUSE__REDIS__URL";
5
22
 
6
23
  export type ProxyOptions = {
7
24
  url: string;
@@ -32,20 +49,350 @@ export type ApiFuseConfig = {
32
49
 
33
50
  export type ProxyResolutionOptions = {
34
51
  proxy?: string;
35
- upstream?: { proxy?: boolean };
52
+ upstream?: { proxy?: boolean | ProviderProxyPolicy };
36
53
  apifuseConfig?: Pick<ApiFuseConfig, "proxy">;
54
+ proxyPolicy?: ProviderProxyPolicy;
55
+ affinityKey?: string;
56
+ /** Zero-based proxy-pool attempt index used by SDK transports for failover. */
57
+ proxyAttempt?: number;
58
+ telemetry?: ProxyTelemetrySink;
59
+ };
60
+
61
+ export type ProxyCacheStatus =
62
+ | "memory_hit"
63
+ | "redis_hit"
64
+ | "allocator"
65
+ | "soft_stale_refresh"
66
+ | "lock_wait"
67
+ | "redis_error"
68
+ | "redis_corrupt"
69
+ | "disabled";
70
+
71
+ export type SmartproxyAllocatorBodyClass =
72
+ | "network_error"
73
+ | "http_error"
74
+ | "empty"
75
+ | "json_without_proxies"
76
+ | "text_without_proxies"
77
+ | "usable_proxy_endpoints";
78
+
79
+ export type ProxyResolutionTelemetryEvent = {
80
+ provider: "smartproxy";
81
+ cacheStatus: ProxyCacheStatus;
82
+ cacheHit: boolean;
83
+ resolutionMs: number;
84
+ allocatorMs?: number;
85
+ allocatorStatus?: number;
86
+ allocatorBodyClass?: SmartproxyAllocatorBodyClass;
87
+ allocatorAttempts?: number;
88
+ lockWaitMs?: number;
89
+ redisReadMs?: number;
90
+ redisWriteMs?: number;
91
+ poolAgeMs?: number;
92
+ poolExpiresInMs?: number;
93
+ attempts: number;
94
+ refreshes?: number;
95
+ };
96
+
97
+ export type ProxyAttemptTelemetryEvent = {
98
+ provider: "smartproxy";
99
+ attempt: number;
100
+ poolIndex?: number;
101
+ proxyHash?: string;
102
+ outcome: "ok" | "error";
103
+ errorCode?: string;
104
+ status?: number;
105
+ durationMs?: number;
106
+ };
107
+
108
+ export type ProxyTelemetrySink = {
109
+ recordProxyResolution(event: ProxyResolutionTelemetryEvent): void;
110
+ recordProxyAttempt?(event: ProxyAttemptTelemetryEvent): void;
37
111
  };
38
112
 
39
113
  export type ResolvedProxyConfig = {
40
114
  shouldWarn: boolean;
41
115
  url?: string;
116
+ source?: "explicit" | "env" | "config" | "smartproxy-allocator";
117
+ diagnostics?: Record<string, string | number | boolean>;
118
+ };
119
+
120
+ export class ProxyResolutionError extends Error {
121
+ readonly code: "PROXY_REQUIRED" | "PROXY_ALLOCATION_FAILED";
122
+ readonly telemetry?: ProxyResolutionTelemetryEvent;
123
+
124
+ constructor(
125
+ code: "PROXY_REQUIRED" | "PROXY_ALLOCATION_FAILED",
126
+ message: string,
127
+ options?: { cause?: unknown; telemetry?: ProxyResolutionTelemetryEvent },
128
+ ) {
129
+ super(message, options);
130
+ this.name = "ProxyResolutionError";
131
+ this.code = code;
132
+ this.telemetry = options?.telemetry;
133
+ }
134
+ }
135
+
136
+ type CachedProxyPool = {
137
+ urls: string[];
138
+ allocatedAt: number;
139
+ refreshAfter: number;
140
+ expiresAt: number;
141
+ diagnostics?: Record<string, string | number | boolean>;
42
142
  };
43
143
 
144
+ type SmartproxyAllocationResult = {
145
+ pool: CachedProxyPool;
146
+ telemetry: ProxyResolutionTelemetryEvent;
147
+ };
148
+
149
+ type ProxyRedisClient = Pick<
150
+ Redis,
151
+ "connect" | "del" | "eval" | "get" | "on" | "pttl" | "set" | "status"
152
+ >;
153
+
154
+ const proxyCache = new Map<string, CachedProxyPool>();
155
+ const proxyInflight = new Map<string, Promise<SmartproxyAllocationResult>>();
156
+ const invalidatedProxyKeys = new Map<string, number>();
157
+ const redisClients = new Map<string, ProxyRedisClient>();
158
+ let proxyRedisForTests: ProxyRedisClient | undefined;
159
+ let smartproxyAllocatorDeadlineMsForTests: number | undefined;
160
+
161
+ const PROXY_CACHE_PREFIX = "apifuse:proxy:smartproxy:v1";
162
+ const REDIS_TIMEOUT_MS = 150;
163
+ const SMARTPROXY_LOCK_TTL_MS = 10_000;
164
+ const SMARTPROXY_LOCK_POLL_MAX_MS = 9_000;
165
+ const SMARTPROXY_DEADLINE_MARGIN_MS = 1_000;
166
+ const SMARTPROXY_INVALIDATION_SKIP_REDIS_MS = 30_000;
167
+ const SMARTPROXY_ALLOCATOR_MAX_ATTEMPTS = 3;
168
+ const SMARTPROXY_ALLOCATOR_DEADLINE_MS =
169
+ SMARTPROXY_LOCK_TTL_MS - SMARTPROXY_DEADLINE_MARGIN_MS;
170
+ const SMARTPROXY_ALLOCATOR_RETRY_BASE_MS = 25;
171
+ // Smartproxy API extraction returns fresh IP:port candidates; the `life`
172
+ // parameter controls session duration intent, not a hard endpoint lease. Keep
173
+ // successful extractions only briefly to collapse concurrent requests and avoid
174
+ // reusing stale raw CONNECT endpoints as if they were valid for `life` minutes.
175
+ const SMARTPROXY_EXTRACTION_CACHE_TTL_MS = 15_000;
176
+ const SMARTPROXY_EXTRACTION_SOFT_REFRESH_MS = 10_000;
177
+
178
+ function redisUrlFromEnv(): string | undefined {
179
+ return (
180
+ process.env.APIFUSE__PROVIDER__CACHE_REDIS_URL?.trim() ||
181
+ process.env[REDIS_URL_ENV]?.trim() ||
182
+ undefined
183
+ );
184
+ }
185
+
186
+ export function providerCacheRedisUrlFromEnv(): string | undefined {
187
+ return redisUrlFromEnv();
188
+ }
189
+
190
+ export function providerStateRedisUrlFromEnv(): string | undefined {
191
+ return (
192
+ process.env[PROVIDER_STATE_REDIS_URL_ENV]?.trim() ||
193
+ process.env[PROVIDER_CACHE_REDIS_URL_ENV]?.trim() ||
194
+ process.env[REDIS_URL_ENV]?.trim() ||
195
+ undefined
196
+ );
197
+ }
198
+
199
+ /** @internal Test-only hook for exercising shared proxy-cache behavior. */
200
+ export function __setProxyRedisForTests(
201
+ redis: ProxyRedisClient | undefined,
202
+ ): void {
203
+ proxyRedisForTests = redis;
204
+ }
205
+
206
+ export function __setSmartproxyAllocatorDeadlineMsForTests(
207
+ deadlineMs: number | undefined,
208
+ ): void {
209
+ smartproxyAllocatorDeadlineMsForTests = deadlineMs;
210
+ }
211
+
212
+ function getProxyRedis(): ProxyRedisClient | undefined {
213
+ if (proxyRedisForTests) return proxyRedisForTests;
214
+ const redisUrl = redisUrlFromEnv();
215
+ if (!redisUrl) return undefined;
216
+ const existing = redisClients.get(redisUrl);
217
+ if (existing) return existing;
218
+
219
+ const redis = new Redis(redisUrl, {
220
+ connectTimeout: REDIS_TIMEOUT_MS,
221
+ enableOfflineQueue: false,
222
+ lazyConnect: true,
223
+ maxRetriesPerRequest: 0,
224
+ retryStrategy: () => null,
225
+ });
226
+ redis.on("error", () => {
227
+ // Fail-open to allocator/memory; Redis connectivity must not choose direct egress.
228
+ });
229
+ redisClients.set(redisUrl, redis);
230
+ return redis;
231
+ }
232
+
233
+ async function withRedisTimeout<T>(
234
+ operation: () => Promise<T>,
235
+ ): Promise<T | undefined> {
236
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
237
+ try {
238
+ const timeout = new Promise<undefined>((resolve) => {
239
+ timeoutId = setTimeout(() => resolve(undefined), REDIS_TIMEOUT_MS);
240
+ });
241
+ return await Promise.race([operation().catch(() => undefined), timeout]);
242
+ } finally {
243
+ if (timeoutId) clearTimeout(timeoutId);
244
+ }
245
+ }
246
+
247
+ function redisStatus(redis: ProxyRedisClient): string {
248
+ return redis.status;
249
+ }
250
+
251
+ async function ensureRedisReady(redis: ProxyRedisClient): Promise<boolean> {
252
+ if (redisStatus(redis) === "ready") return true;
253
+ if (redisStatus(redis) === "wait" || redisStatus(redis) === "end") {
254
+ const connected = await withRedisTimeout(async () => {
255
+ await redis.connect();
256
+ return true;
257
+ });
258
+ return connected === true && redisStatus(redis) === "ready";
259
+ }
260
+ return false;
261
+ }
262
+
263
+ function sha256(value: string): string {
264
+ return createHash("sha256").update(value).digest("hex");
265
+ }
266
+
267
+ function smartproxyRedisPoolKey(cacheKey: string): string {
268
+ return `${PROXY_CACHE_PREFIX}:pool:${sha256(cacheKey)}`;
269
+ }
270
+
271
+ function smartproxyRedisLockKey(cacheKey: string): string {
272
+ return `${PROXY_CACHE_PREFIX}:lock:${sha256(cacheKey)}`;
273
+ }
274
+
275
+ function isFresh(pool: CachedProxyPool, now: number): boolean {
276
+ return pool.expiresAt > now;
277
+ }
278
+
279
+ function shouldSoftRefresh(pool: CachedProxyPool, now: number): boolean {
280
+ return isFresh(pool, now) && pool.refreshAfter <= now;
281
+ }
282
+
283
+ function telemetryForPool(
284
+ pool: CachedProxyPool,
285
+ cacheStatus: ProxyCacheStatus,
286
+ startedAt: number,
287
+ extra: Partial<ProxyResolutionTelemetryEvent> = {},
288
+ ): ProxyResolutionTelemetryEvent {
289
+ const now = Date.now();
290
+ return {
291
+ provider: "smartproxy",
292
+ cacheStatus,
293
+ cacheHit: cacheStatus !== "allocator",
294
+ resolutionMs: Math.max(0, now - startedAt),
295
+ poolAgeMs: Math.max(0, now - pool.allocatedAt),
296
+ poolExpiresInMs: Math.max(0, pool.expiresAt - now),
297
+ attempts: 1,
298
+ ...extra,
299
+ };
300
+ }
301
+
302
+ function telemetryForFailure(
303
+ cacheStatus: ProxyCacheStatus,
304
+ startedAt: number,
305
+ extra: Partial<ProxyResolutionTelemetryEvent> = {},
306
+ ): ProxyResolutionTelemetryEvent {
307
+ return {
308
+ provider: "smartproxy",
309
+ cacheStatus,
310
+ cacheHit: false,
311
+ resolutionMs: Math.max(0, Date.now() - startedAt),
312
+ attempts: 1,
313
+ ...extra,
314
+ };
315
+ }
316
+
317
+ function isRecord(value: unknown): value is Record<string, unknown> {
318
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
319
+ }
320
+
321
+ function toProxyDiagnostics(
322
+ value: unknown,
323
+ ): Record<string, string | number | boolean> | undefined {
324
+ if (!isRecord(value)) return undefined;
325
+ const diagnostics: Record<string, string | number | boolean> = {};
326
+ for (const [key, item] of Object.entries(value)) {
327
+ if (
328
+ typeof item === "string" ||
329
+ typeof item === "number" ||
330
+ typeof item === "boolean"
331
+ ) {
332
+ diagnostics[key] = item;
333
+ }
334
+ }
335
+ return Object.keys(diagnostics).length > 0 ? diagnostics : undefined;
336
+ }
337
+
338
+ function safeParseSmartproxyPool(raw: string | null): CachedProxyPool | null {
339
+ if (!raw) return null;
340
+ try {
341
+ const parsed: unknown = JSON.parse(raw);
342
+ if (!isRecord(parsed)) return null;
343
+ const record = parsed;
344
+ if (
345
+ record.version !== 1 ||
346
+ record.proxyProvider !== "smartproxy" ||
347
+ !Array.isArray(record.urls) ||
348
+ typeof record.allocatedAt !== "number" ||
349
+ typeof record.refreshAfter !== "number" ||
350
+ typeof record.expiresAt !== "number"
351
+ ) {
352
+ return null;
353
+ }
354
+ const urls = record.urls.filter(
355
+ (url): url is string => typeof url === "string" && url.startsWith("http"),
356
+ );
357
+ if (urls.length === 0) return null;
358
+ return {
359
+ urls,
360
+ allocatedAt: record.allocatedAt,
361
+ refreshAfter: record.refreshAfter,
362
+ expiresAt: record.expiresAt,
363
+ diagnostics: toProxyDiagnostics(record.diagnostics),
364
+ };
365
+ } catch {
366
+ return null;
367
+ }
368
+ }
369
+
370
+ function serializeSmartproxyPool(pool: CachedProxyPool): string {
371
+ return JSON.stringify({
372
+ version: 1,
373
+ proxyProvider: "smartproxy",
374
+ urls: pool.urls,
375
+ allocatedAt: pool.allocatedAt,
376
+ refreshAfter: pool.refreshAfter,
377
+ expiresAt: pool.expiresAt,
378
+ diagnostics: pool.diagnostics,
379
+ });
380
+ }
381
+
44
382
  function normalizeProxyUrl(url?: string): string | undefined {
45
383
  const normalized = url?.trim();
46
384
  return normalized ? applyStickyProxySession(normalized) : undefined;
47
385
  }
48
386
 
387
+ function readPositiveIntegerEnv(name: string): string | undefined {
388
+ const raw = process.env[name]?.trim();
389
+ if (!raw) return undefined;
390
+ if (!/^[1-9]\d*$/.test(raw)) {
391
+ throw new Error(`${name} must be a positive integer`);
392
+ }
393
+ return raw;
394
+ }
395
+
49
396
  function applyStickyProxySession(proxyUrl: string): string {
50
397
  let parsed: URL;
51
398
  try {
@@ -65,9 +412,10 @@ function applyStickyProxySession(proxyUrl: string): string {
65
412
 
66
413
  const username = decodeURIComponent(parsed.username);
67
414
  const sessionId =
68
- process.env.APIFUSE_PROXY_SESSION_ID?.trim() || "apifuse-shared";
69
- const sessionDuration =
70
- process.env.APIFUSE_PROXY_SESSION_DURATION?.trim() || undefined;
415
+ process.env.APIFUSE__PROXY__SESSION_ID?.trim() || "apifuse-shared";
416
+ const sessionDuration = readPositiveIntegerEnv(
417
+ "APIFUSE__PROXY__SESSION_DURATION",
418
+ );
71
419
  const stickyUsername = host.includes("smartproxy")
72
420
  ? buildSmartproxyUsername(username, sessionId, sessionDuration)
73
421
  : buildDecodoUsername(username, sessionId, sessionDuration ?? "60");
@@ -108,8 +456,8 @@ function buildDecodoUsername(
108
456
 
109
457
  function syncProxyEnv(config: ApiFuseConfig): void {
110
458
  const configProxyUrl = normalizeProxyUrl(config.proxy?.url);
111
- if (!process.env.APIFUSE_PROXY_URL && configProxyUrl) {
112
- process.env.APIFUSE_PROXY_URL = configProxyUrl;
459
+ if (!process.env.APIFUSE__PROXY__URL && configProxyUrl) {
460
+ process.env.APIFUSE__PROXY__URL = configProxyUrl;
113
461
  }
114
462
  }
115
463
 
@@ -121,11 +469,18 @@ export function resolveProxyConfig(
121
469
  return { shouldWarn: false, url: explicitProxyUrl };
122
470
  }
123
471
 
124
- if (!options.upstream?.proxy) {
472
+ const policy = resolvePolicy(options);
473
+ if (policy?.mode === "disabled") {
474
+ return { shouldWarn: false };
475
+ }
476
+
477
+ const legacyProxyRequested =
478
+ options.upstream?.proxy === true || (!policy && options.upstream?.proxy);
479
+ if (!legacyProxyRequested) {
125
480
  return { shouldWarn: false };
126
481
  }
127
482
 
128
- const envProxyUrl = normalizeProxyUrl(process.env.APIFUSE_PROXY_URL);
483
+ const envProxyUrl = normalizeProxyUrl(process.env.APIFUSE__PROXY__URL);
129
484
  if (envProxyUrl) {
130
485
  return { shouldWarn: false, url: envProxyUrl };
131
486
  }
@@ -140,6 +495,866 @@ export function resolveProxyConfig(
140
495
  return { shouldWarn: true };
141
496
  }
142
497
 
498
+ export async function resolveProxyConfigAsync(
499
+ options: ProxyResolutionOptions = {},
500
+ ): Promise<ResolvedProxyConfig> {
501
+ const explicitProxyUrl = normalizeProxyUrl(options.proxy);
502
+ if (explicitProxyUrl) {
503
+ return { shouldWarn: false, url: explicitProxyUrl, source: "explicit" };
504
+ }
505
+
506
+ const policy = resolvePolicy(options);
507
+ if (!policy) {
508
+ return resolveProxyConfig(options);
509
+ }
510
+ if (policy.mode === "disabled") {
511
+ return { shouldWarn: false };
512
+ }
513
+
514
+ const provider = resolveProxyProvider(policy);
515
+ if (provider !== "smartproxy") {
516
+ return resolveProxyConfig({
517
+ ...options,
518
+ upstream: { proxy: true },
519
+ });
520
+ }
521
+
522
+ const appKey = process.env[SMARTPROXY_APP_KEY_ENV]?.trim();
523
+ if (!appKey) {
524
+ if (policy.mode === "required") {
525
+ throw new ProxyResolutionError(
526
+ "PROXY_REQUIRED",
527
+ `Smartproxy egress is required but ${SMARTPROXY_APP_KEY_ENV} is not configured.`,
528
+ );
529
+ }
530
+ return { shouldWarn: true };
531
+ }
532
+ const lifetimeMinutes = resolveSmartproxyLifetime(policy);
533
+
534
+ try {
535
+ const allocated = await allocateSmartproxy(
536
+ policy,
537
+ appKey,
538
+ lifetimeMinutes,
539
+ options.affinityKey,
540
+ );
541
+ options.telemetry?.recordProxyResolution(allocated.telemetry);
542
+ const poolIndex = selectProxyPoolIndex(
543
+ allocated.pool.urls.length,
544
+ options.proxyAttempt,
545
+ );
546
+ return {
547
+ shouldWarn: false,
548
+ url: allocated.pool.urls[poolIndex],
549
+ source: "smartproxy-allocator",
550
+ diagnostics: {
551
+ ...allocated.pool.diagnostics,
552
+ poolSize: allocated.pool.urls.length,
553
+ poolIndex,
554
+ },
555
+ };
556
+ } catch (error) {
557
+ if (error instanceof ProxyResolutionError && error.telemetry) {
558
+ options.telemetry?.recordProxyResolution(error.telemetry);
559
+ }
560
+ if (policy.mode === "required") {
561
+ throw error instanceof ProxyResolutionError
562
+ ? error
563
+ : new ProxyResolutionError(
564
+ "PROXY_ALLOCATION_FAILED",
565
+ "Smartproxy allocator failed for required proxy egress.",
566
+ { cause: error },
567
+ );
568
+ }
569
+ return { shouldWarn: true };
570
+ }
571
+ }
572
+
573
+ function resolvePolicy(
574
+ options: ProxyResolutionOptions,
575
+ ): ProviderProxyPolicy | undefined {
576
+ if (options.proxyPolicy) {
577
+ return options.proxyPolicy;
578
+ }
579
+ const upstreamProxy = options.upstream?.proxy;
580
+ if (upstreamProxy && typeof upstreamProxy === "object") {
581
+ return upstreamProxy;
582
+ }
583
+ return undefined;
584
+ }
585
+
586
+ function resolveProxyProvider(policy: ProviderProxyPolicy): string {
587
+ return (
588
+ policy.provider ??
589
+ process.env[DEFAULT_PROXY_PROVIDER_ENV]?.trim().toLowerCase() ??
590
+ "custom"
591
+ );
592
+ }
593
+
594
+ function resolveSmartproxyCountry(
595
+ policy: ProviderProxyPolicy,
596
+ ): string | undefined {
597
+ return (
598
+ policy.geo?.country ??
599
+ process.env[DEFAULT_PROXY_COUNTRY_ENV]?.trim().toUpperCase() ??
600
+ undefined
601
+ );
602
+ }
603
+
604
+ function resolveSmartproxyLifetime(policy: ProviderProxyPolicy): number {
605
+ const configuredLifetime =
606
+ policy.session?.lifetimeMinutes ??
607
+ readPositiveNumberEnv(DEFAULT_PROXY_LIFETIME_ENV, 30);
608
+ return Math.min(
609
+ SMARTPROXY_MAX_LIFETIME_MINUTES,
610
+ Math.max(1, Math.floor(configuredLifetime)),
611
+ );
612
+ }
613
+
614
+ function readPositiveNumberEnv(name: string, fallback: number): number {
615
+ const raw = process.env[name]?.trim();
616
+ if (!raw) return fallback;
617
+ const parsed = Number(raw);
618
+ if (!Number.isFinite(parsed) || parsed <= 0) {
619
+ throw new Error(`${name} must be a positive number`);
620
+ }
621
+ return parsed;
622
+ }
623
+
624
+ function resolveSmartproxyPoolSize(policy: ProviderProxyPolicy): number {
625
+ return Math.min(
626
+ SMARTPROXY_MAX_POOL_SIZE,
627
+ Math.max(
628
+ 1,
629
+ Math.floor(policy.session?.poolSize ?? DEFAULT_SMARTPROXY_POOL_SIZE),
630
+ ),
631
+ );
632
+ }
633
+
634
+ function selectProxyPoolIndex(poolSize: number, attempt = 0): number {
635
+ if (poolSize <= 1) {
636
+ return 0;
637
+ }
638
+ const normalizedAttempt = Number.isFinite(attempt)
639
+ ? Math.max(0, Math.floor(attempt))
640
+ : 0;
641
+ return normalizedAttempt % poolSize;
642
+ }
643
+
644
+ function buildSmartproxyCacheKey(
645
+ policy: ProviderProxyPolicy,
646
+ affinityKey: string | undefined,
647
+ lifetimeMinutes: number,
648
+ ): string {
649
+ const poolSize = resolveSmartproxyPoolSize(policy);
650
+ return JSON.stringify({
651
+ provider: "smartproxy",
652
+ country: resolveSmartproxyCountry(policy),
653
+ affinity: policy.session?.affinity ?? "request",
654
+ affinityKey:
655
+ (policy.session?.affinity ?? "request") === "request"
656
+ ? undefined
657
+ : affinityKey,
658
+ lifetimeMinutes,
659
+ poolSize,
660
+ });
661
+ }
662
+
663
+ async function allocateSmartproxy(
664
+ policy: ProviderProxyPolicy,
665
+ appKey: string,
666
+ lifetimeMinutes: number,
667
+ affinityKey: string | undefined,
668
+ ): Promise<SmartproxyAllocationResult> {
669
+ const cacheKey = buildSmartproxyCacheKey(
670
+ policy,
671
+ affinityKey,
672
+ lifetimeMinutes,
673
+ );
674
+ const startedAt = Date.now();
675
+ const now = startedAt;
676
+ const invalidatedUntil = invalidatedProxyKeys.get(cacheKey) ?? 0;
677
+ const skipCached = invalidatedUntil > now;
678
+ const cached = proxyCache.get(cacheKey);
679
+ if (!skipCached && cached && isFresh(cached, now)) {
680
+ if (shouldSoftRefresh(cached, now)) {
681
+ void refreshSmartproxyPool(cacheKey, policy, appKey, lifetimeMinutes);
682
+ return {
683
+ pool: cached,
684
+ telemetry: telemetryForPool(cached, "soft_stale_refresh", startedAt, {
685
+ refreshes: 1,
686
+ }),
687
+ };
688
+ }
689
+ return {
690
+ pool: cached,
691
+ telemetry: telemetryForPool(cached, "memory_hit", startedAt),
692
+ };
693
+ }
694
+
695
+ if (!skipCached) {
696
+ const redisResult = await readSmartproxyRedisPool(cacheKey, startedAt);
697
+ if (redisResult) return redisResult;
698
+ }
699
+
700
+ const existingInflight = proxyInflight.get(cacheKey);
701
+ if (existingInflight) {
702
+ const result = await existingInflight;
703
+ return {
704
+ pool: result.pool,
705
+ telemetry: telemetryForPool(result.pool, "lock_wait", startedAt, {
706
+ lockWaitMs: Math.max(0, Date.now() - startedAt),
707
+ }),
708
+ };
709
+ }
710
+
711
+ const promise = allocateSmartproxyShared(
712
+ cacheKey,
713
+ policy,
714
+ appKey,
715
+ lifetimeMinutes,
716
+ startedAt,
717
+ ).finally(() => {
718
+ proxyInflight.delete(cacheKey);
719
+ });
720
+ proxyInflight.set(cacheKey, promise);
721
+ const result = await promise;
722
+ invalidatedProxyKeys.delete(cacheKey);
723
+ return result;
724
+ }
725
+
726
+ async function readSmartproxyRedisPool(
727
+ cacheKey: string,
728
+ startedAt: number,
729
+ ): Promise<SmartproxyAllocationResult | null> {
730
+ const redis = getProxyRedis();
731
+ if (!redis || !(await ensureRedisReady(redis))) return null;
732
+ const redisStartedAt = Date.now();
733
+ const raw = await withRedisTimeout(() =>
734
+ redis.get(smartproxyRedisPoolKey(cacheKey)),
735
+ );
736
+ const redisReadMs = Math.max(0, Date.now() - redisStartedAt);
737
+ if (typeof raw !== "string") return null;
738
+ const pool = safeParseSmartproxyPool(raw);
739
+ if (!pool) {
740
+ await withRedisTimeout(() => redis.del(smartproxyRedisPoolKey(cacheKey)));
741
+ return null;
742
+ }
743
+ const now = Date.now();
744
+ if (!isFresh(pool, now)) return null;
745
+ proxyCache.set(cacheKey, pool);
746
+ return {
747
+ pool,
748
+ telemetry: telemetryForPool(pool, "redis_hit", startedAt, { redisReadMs }),
749
+ };
750
+ }
751
+
752
+ async function refreshSmartproxyPool(
753
+ cacheKey: string,
754
+ policy: ProviderProxyPolicy,
755
+ appKey: string,
756
+ lifetimeMinutes: number,
757
+ ): Promise<void> {
758
+ try {
759
+ await allocateSmartproxyShared(
760
+ cacheKey,
761
+ policy,
762
+ appKey,
763
+ lifetimeMinutes,
764
+ Date.now(),
765
+ {
766
+ background: true,
767
+ },
768
+ );
769
+ } catch {
770
+ // Soft refresh is opportunistic; current fresh pool remains usable.
771
+ }
772
+ }
773
+
774
+ async function allocateSmartproxyShared(
775
+ cacheKey: string,
776
+ policy: ProviderProxyPolicy,
777
+ appKey: string,
778
+ lifetimeMinutes: number,
779
+ startedAt: number,
780
+ options: { background?: boolean } = {},
781
+ ): Promise<SmartproxyAllocationResult> {
782
+ const redis = getProxyRedis();
783
+ if (!redis || !(await ensureRedisReady(redis))) {
784
+ return await allocateAndStoreSmartproxyPool(
785
+ cacheKey,
786
+ policy,
787
+ appKey,
788
+ lifetimeMinutes,
789
+ startedAt,
790
+ { cacheStatus: "allocator" },
791
+ );
792
+ }
793
+
794
+ const poolKey = smartproxyRedisPoolKey(cacheKey);
795
+ const lockKey = smartproxyRedisLockKey(cacheKey);
796
+ const waitStartedAt = Date.now();
797
+
798
+ while (Date.now() - waitStartedAt < SMARTPROXY_LOCK_POLL_MAX_MS) {
799
+ const token = randomUUID();
800
+ const acquired = await withRedisTimeout(() =>
801
+ redis.set(lockKey, token, "PX", SMARTPROXY_LOCK_TTL_MS, "NX"),
802
+ );
803
+ if (acquired === "OK") {
804
+ try {
805
+ return await allocateAndStoreSmartproxyPool(
806
+ cacheKey,
807
+ policy,
808
+ appKey,
809
+ lifetimeMinutes,
810
+ startedAt,
811
+ {
812
+ cacheStatus: options.background
813
+ ? "soft_stale_refresh"
814
+ : "allocator",
815
+ redis,
816
+ poolKey,
817
+ },
818
+ );
819
+ } finally {
820
+ await releaseSmartproxyRedisLock(redis, lockKey, token);
821
+ }
822
+ }
823
+
824
+ const redisResult = await readSmartproxyRedisPool(cacheKey, startedAt);
825
+ if (redisResult) {
826
+ return {
827
+ pool: redisResult.pool,
828
+ telemetry: {
829
+ ...redisResult.telemetry,
830
+ cacheStatus: "lock_wait",
831
+ cacheHit: true,
832
+ lockWaitMs: Math.max(0, Date.now() - waitStartedAt),
833
+ },
834
+ };
835
+ }
836
+
837
+ const pttl = await withRedisTimeout(() => redis.pttl(lockKey));
838
+ if (typeof pttl === "number" && pttl <= 0) {
839
+ continue;
840
+ }
841
+ if (
842
+ Date.now() - startedAt >
843
+ SMARTPROXY_LOCK_POLL_MAX_MS - SMARTPROXY_DEADLINE_MARGIN_MS
844
+ ) {
845
+ break;
846
+ }
847
+ await sleep(
848
+ Math.min(500, Math.max(50, typeof pttl === "number" ? pttl : 100)),
849
+ );
850
+ }
851
+
852
+ throw new ProxyResolutionError(
853
+ "PROXY_ALLOCATION_FAILED",
854
+ "Smartproxy allocator lock did not produce a usable proxy pool before the request deadline.",
855
+ {
856
+ telemetry: telemetryForFailure("lock_wait", startedAt, {
857
+ lockWaitMs: Math.max(0, Date.now() - waitStartedAt),
858
+ }),
859
+ },
860
+ );
861
+ }
862
+
863
+ async function releaseSmartproxyRedisLock(
864
+ redis: ProxyRedisClient,
865
+ lockKey: string,
866
+ token: string,
867
+ ): Promise<void> {
868
+ await withRedisTimeout(() =>
869
+ redis.eval(
870
+ 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end',
871
+ 1,
872
+ lockKey,
873
+ token,
874
+ ),
875
+ );
876
+ }
877
+
878
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
879
+ if (signal?.aborted) return Promise.reject(signal.reason);
880
+ return new Promise((resolve, reject) => {
881
+ let timeout: ReturnType<typeof setTimeout>;
882
+ const onAbort = () => {
883
+ clearTimeout(timeout);
884
+ signal?.removeEventListener("abort", onAbort);
885
+ reject(signal?.reason);
886
+ };
887
+ timeout = setTimeout(() => {
888
+ signal?.removeEventListener("abort", onAbort);
889
+ resolve();
890
+ }, ms);
891
+ signal?.addEventListener("abort", onAbort, { once: true });
892
+ });
893
+ }
894
+
895
+ function smartproxyAllocatorDeadlineMs(): number {
896
+ return (
897
+ smartproxyAllocatorDeadlineMsForTests ?? SMARTPROXY_ALLOCATOR_DEADLINE_MS
898
+ );
899
+ }
900
+
901
+ function createDeadlineAbortController(deadlineAt: number): {
902
+ controller: AbortController;
903
+ dispose: () => void;
904
+ } {
905
+ const controller = new AbortController();
906
+ const remainingMs = deadlineAt - Date.now();
907
+ if (remainingMs <= 0) {
908
+ controller.abort(new Error("Smartproxy allocator deadline exceeded."));
909
+ return { controller, dispose: () => undefined };
910
+ }
911
+ const timeout = setTimeout(() => {
912
+ controller.abort(new Error("Smartproxy allocator deadline exceeded."));
913
+ }, remainingMs);
914
+ return {
915
+ controller,
916
+ dispose: () => clearTimeout(timeout),
917
+ };
918
+ }
919
+
920
+ function smartproxyAllocatorDeadlineFailure(
921
+ attempt: number,
922
+ ): SmartproxyAllocatorFailure {
923
+ return {
924
+ ok: false,
925
+ attempt,
926
+ bodyClass: "network_error",
927
+ cause: new Error("Smartproxy allocator deadline exceeded."),
928
+ };
929
+ }
930
+
931
+ function smartproxyAllocatorDeadlineError(): Error {
932
+ return new Error("Smartproxy allocator deadline exceeded.");
933
+ }
934
+
935
+ async function readSmartproxyAllocatorBodyWithDeadline(
936
+ response: Response,
937
+ signal: AbortSignal,
938
+ ): Promise<string> {
939
+ if (signal.aborted) {
940
+ throw signal.reason ?? smartproxyAllocatorDeadlineError();
941
+ }
942
+
943
+ return await new Promise<string>((resolve, reject) => {
944
+ let settled = false;
945
+ const cleanup = () => {
946
+ signal.removeEventListener("abort", onAbort);
947
+ };
948
+ const settle = (callback: () => void) => {
949
+ if (settled) return;
950
+ settled = true;
951
+ cleanup();
952
+ callback();
953
+ };
954
+ const onAbort = () => {
955
+ settle(() => reject(signal.reason ?? smartproxyAllocatorDeadlineError()));
956
+ };
957
+
958
+ signal.addEventListener("abort", onAbort, { once: true });
959
+
960
+ try {
961
+ void response.text().then(
962
+ (body) => settle(() => resolve(body)),
963
+ (error: unknown) => settle(() => reject(error)),
964
+ );
965
+ } catch (error) {
966
+ settle(() => reject(error));
967
+ }
968
+ });
969
+ }
970
+
971
+ async function allocateAndStoreSmartproxyPool(
972
+ cacheKey: string,
973
+ policy: ProviderProxyPolicy,
974
+ appKey: string,
975
+ lifetimeMinutes: number,
976
+ startedAt: number,
977
+ options: {
978
+ cacheStatus: ProxyCacheStatus;
979
+ redis?: ProxyRedisClient;
980
+ poolKey?: string;
981
+ },
982
+ ): Promise<SmartproxyAllocationResult> {
983
+ const poolSize = resolveSmartproxyPoolSize(policy);
984
+ const allocatorUrl = buildSmartproxyAllocatorUrl(
985
+ policy,
986
+ appKey,
987
+ lifetimeMinutes,
988
+ poolSize,
989
+ );
990
+ const allocatorStartedAt = Date.now();
991
+ const allocatorDeadlineAt =
992
+ allocatorStartedAt + smartproxyAllocatorDeadlineMs();
993
+ let allocation: SmartproxyAllocatorSuccess | undefined;
994
+ let lastFailure: SmartproxyAllocatorFailure | undefined;
995
+ for (
996
+ let attempt = 1;
997
+ attempt <= SMARTPROXY_ALLOCATOR_MAX_ATTEMPTS;
998
+ attempt += 1
999
+ ) {
1000
+ if (Date.now() >= allocatorDeadlineAt) {
1001
+ lastFailure = smartproxyAllocatorDeadlineFailure(attempt);
1002
+ break;
1003
+ }
1004
+ const attemptResult = await fetchSmartproxyAllocatorAttempt(
1005
+ allocatorUrl,
1006
+ attempt,
1007
+ allocatorDeadlineAt,
1008
+ );
1009
+ if (attemptResult.ok) {
1010
+ allocation = attemptResult;
1011
+ break;
1012
+ }
1013
+ lastFailure = attemptResult;
1014
+ if (attempt < SMARTPROXY_ALLOCATOR_MAX_ATTEMPTS) {
1015
+ if (Date.now() >= allocatorDeadlineAt) break;
1016
+ const { controller, dispose } =
1017
+ createDeadlineAbortController(allocatorDeadlineAt);
1018
+ try {
1019
+ await sleep(smartproxyAllocatorBackoffMs(attempt), controller.signal);
1020
+ } catch {
1021
+ break;
1022
+ } finally {
1023
+ dispose();
1024
+ }
1025
+ }
1026
+ }
1027
+
1028
+ const allocatorMs = Math.max(0, Date.now() - allocatorStartedAt);
1029
+ const allocatorAttempts =
1030
+ allocation?.attempt ??
1031
+ lastFailure?.attempt ??
1032
+ SMARTPROXY_ALLOCATOR_MAX_ATTEMPTS;
1033
+ const allocatorBodyClass =
1034
+ lastFailure?.bodyClass ?? allocation?.bodyClass ?? "usable_proxy_endpoints";
1035
+ const allocatorStatus = lastFailure ? lastFailure.status : allocation?.status;
1036
+
1037
+ if (!allocation) {
1038
+ throw new ProxyResolutionError(
1039
+ "PROXY_ALLOCATION_FAILED",
1040
+ smartproxyAllocatorFailureMessage(lastFailure),
1041
+ {
1042
+ cause: lastFailure?.cause,
1043
+ telemetry: telemetryForFailure(options.cacheStatus, startedAt, {
1044
+ allocatorMs,
1045
+ allocatorAttempts,
1046
+ allocatorBodyClass,
1047
+ ...(allocatorStatus === undefined ? {} : { allocatorStatus }),
1048
+ attempts: allocatorAttempts,
1049
+ }),
1050
+ },
1051
+ );
1052
+ }
1053
+
1054
+ const urls = allocation.urls;
1055
+
1056
+ const allocatedAt = Date.now();
1057
+ const ttlMs = SMARTPROXY_EXTRACTION_CACHE_TTL_MS;
1058
+ const refreshAfter = allocatedAt + SMARTPROXY_EXTRACTION_SOFT_REFRESH_MS;
1059
+ const result: CachedProxyPool = {
1060
+ urls,
1061
+ allocatedAt,
1062
+ refreshAfter,
1063
+ expiresAt: allocatedAt + ttlMs,
1064
+ diagnostics: {
1065
+ provider: "smartproxy",
1066
+ country: resolveSmartproxyCountry(policy) ?? "default",
1067
+ lifetimeMinutes,
1068
+ affinity: policy.session?.affinity ?? "request",
1069
+ rawConnect: true,
1070
+ },
1071
+ };
1072
+ proxyCache.set(cacheKey, result);
1073
+ if (options.redis && options.poolKey) {
1074
+ const redis = options.redis;
1075
+ const poolKey = options.poolKey;
1076
+ const redisWriteStartedAt = Date.now();
1077
+ await withRedisTimeout(() =>
1078
+ redis.set(
1079
+ poolKey,
1080
+ serializeSmartproxyPool(result),
1081
+ "PX",
1082
+ Math.max(1_000, result.expiresAt - Date.now()),
1083
+ ),
1084
+ );
1085
+ return {
1086
+ pool: result,
1087
+ telemetry: telemetryForPool(result, options.cacheStatus, startedAt, {
1088
+ allocatorMs,
1089
+ allocatorAttempts,
1090
+ allocatorBodyClass,
1091
+ ...(allocatorStatus === undefined ? {} : { allocatorStatus }),
1092
+ attempts: allocatorAttempts,
1093
+ redisWriteMs: Math.max(0, Date.now() - redisWriteStartedAt),
1094
+ }),
1095
+ };
1096
+ }
1097
+ return {
1098
+ pool: result,
1099
+ telemetry: telemetryForPool(result, options.cacheStatus, startedAt, {
1100
+ allocatorMs,
1101
+ allocatorAttempts,
1102
+ allocatorBodyClass,
1103
+ ...(allocatorStatus === undefined ? {} : { allocatorStatus }),
1104
+ attempts: allocatorAttempts,
1105
+ }),
1106
+ };
1107
+ }
1108
+
1109
+ type SmartproxyAllocatorAttemptResult =
1110
+ | SmartproxyAllocatorSuccess
1111
+ | SmartproxyAllocatorFailure;
1112
+
1113
+ type SmartproxyAllocatorSuccess = {
1114
+ ok: true;
1115
+ attempt: number;
1116
+ status: number;
1117
+ bodyClass: SmartproxyAllocatorBodyClass;
1118
+ urls: string[];
1119
+ };
1120
+
1121
+ type SmartproxyAllocatorFailure = {
1122
+ ok: false;
1123
+ attempt: number;
1124
+ status?: number;
1125
+ bodyClass: SmartproxyAllocatorBodyClass;
1126
+ cause?: unknown;
1127
+ };
1128
+
1129
+ async function fetchSmartproxyAllocatorAttempt(
1130
+ allocatorUrl: string,
1131
+ attempt: number,
1132
+ deadlineAt: number,
1133
+ ): Promise<SmartproxyAllocatorAttemptResult> {
1134
+ const { controller, dispose } = createDeadlineAbortController(deadlineAt);
1135
+ let response: Response;
1136
+ try {
1137
+ response = await fetch(allocatorUrl, {
1138
+ headers: { Accept: "application/json,text/plain;q=0.9,*/*;q=0.8" },
1139
+ signal: controller.signal,
1140
+ });
1141
+ } catch (error) {
1142
+ dispose();
1143
+ return {
1144
+ ok: false,
1145
+ attempt,
1146
+ bodyClass: "network_error",
1147
+ cause: error,
1148
+ };
1149
+ }
1150
+
1151
+ let body: string;
1152
+ try {
1153
+ body = await readSmartproxyAllocatorBodyWithDeadline(
1154
+ response,
1155
+ controller.signal,
1156
+ );
1157
+ } catch (error) {
1158
+ dispose();
1159
+ return {
1160
+ ok: false,
1161
+ attempt,
1162
+ status: response.status,
1163
+ bodyClass: "network_error",
1164
+ cause: error,
1165
+ };
1166
+ } finally {
1167
+ dispose();
1168
+ }
1169
+
1170
+ if (!response.ok) {
1171
+ return {
1172
+ ok: false,
1173
+ attempt,
1174
+ status: response.status,
1175
+ bodyClass: "http_error",
1176
+ };
1177
+ }
1178
+
1179
+ const urls = parseSmartproxyAllocatorProxies(body);
1180
+ const bodyClass = classifySmartproxyAllocatorBody(body, urls);
1181
+ if (urls.length === 0) {
1182
+ return {
1183
+ ok: false,
1184
+ attempt,
1185
+ status: response.status,
1186
+ bodyClass,
1187
+ };
1188
+ }
1189
+ return {
1190
+ ok: true,
1191
+ attempt,
1192
+ status: response.status,
1193
+ bodyClass,
1194
+ urls,
1195
+ };
1196
+ }
1197
+
1198
+ function smartproxyAllocatorBackoffMs(attempt: number): number {
1199
+ const base =
1200
+ SMARTPROXY_ALLOCATOR_RETRY_BASE_MS * 2 ** Math.max(0, attempt - 1);
1201
+ const jitter = Math.floor(Math.random() * SMARTPROXY_ALLOCATOR_RETRY_BASE_MS);
1202
+ return base + jitter;
1203
+ }
1204
+
1205
+ function smartproxyAllocatorFailureMessage(
1206
+ failure: SmartproxyAllocatorFailure | undefined,
1207
+ ): string {
1208
+ if (!failure) {
1209
+ return "Smartproxy allocator failed.";
1210
+ }
1211
+ if (failure.bodyClass === "network_error") {
1212
+ return "Smartproxy allocator request failed.";
1213
+ }
1214
+ if (failure.bodyClass === "http_error") {
1215
+ return `Smartproxy allocator returned HTTP ${failure.status ?? "error"}.`;
1216
+ }
1217
+ return "Smartproxy allocator response did not contain a usable proxy endpoint.";
1218
+ }
1219
+
1220
+ function buildSmartproxyAllocatorUrl(
1221
+ policy: ProviderProxyPolicy,
1222
+ appKey: string,
1223
+ lifetimeMinutes: number,
1224
+ poolSize: number,
1225
+ ): string {
1226
+ const params = new URLSearchParams({
1227
+ app_key: appKey,
1228
+ pt: "9",
1229
+ num: String(poolSize),
1230
+ life: String(lifetimeMinutes),
1231
+ protocol: "1",
1232
+ format: "txt",
1233
+ lb: "\\n",
1234
+ });
1235
+ const country = resolveSmartproxyCountry(policy);
1236
+ if (country) {
1237
+ params.set("cc", country);
1238
+ }
1239
+ return `https://www.smartproxy.org/web_v1/ip/get-ip-v3?${params.toString()}`;
1240
+ }
1241
+
1242
+ function parseSmartproxyAllocatorProxies(body: string): string[] {
1243
+ const trimmed = body.trim();
1244
+ if (!trimmed) {
1245
+ return [];
1246
+ }
1247
+
1248
+ try {
1249
+ const parsed: unknown = JSON.parse(trimmed);
1250
+ const data =
1251
+ parsed && typeof parsed === "object" && "data" in parsed
1252
+ ? parsed.data
1253
+ : undefined;
1254
+ const list =
1255
+ data && typeof data === "object" && "list" in data
1256
+ ? data.list
1257
+ : undefined;
1258
+ if (Array.isArray(list)) {
1259
+ return list
1260
+ .map((item) => {
1261
+ if (!item || typeof item !== "object") {
1262
+ return null;
1263
+ }
1264
+ const ip = "ip" in item && typeof item.ip === "string" ? item.ip : "";
1265
+ const port =
1266
+ "port" in item &&
1267
+ (typeof item.port === "string" || typeof item.port === "number")
1268
+ ? item.port
1269
+ : "";
1270
+ return ip && port ? `http://${ip}:${port}` : null;
1271
+ })
1272
+ .filter((url): url is string => url !== null);
1273
+ }
1274
+ } catch {
1275
+ // Text allocator format falls through.
1276
+ }
1277
+
1278
+ return trimmed
1279
+ .split(/\r?\n/)
1280
+ .map((item) => item.trim())
1281
+ .filter((item) => /^\d{1,3}(?:\.\d{1,3}){3}:\d{2,5}$/.test(item))
1282
+ .map((line) => `http://${line}`);
1283
+ }
1284
+
1285
+ function classifySmartproxyAllocatorBody(
1286
+ body: string,
1287
+ urls: string[],
1288
+ ): SmartproxyAllocatorBodyClass {
1289
+ if (urls.length > 0) {
1290
+ return "usable_proxy_endpoints";
1291
+ }
1292
+ const trimmed = body.trim();
1293
+ if (!trimmed) {
1294
+ return "empty";
1295
+ }
1296
+ try {
1297
+ JSON.parse(trimmed);
1298
+ return "json_without_proxies";
1299
+ } catch {
1300
+ return "text_without_proxies";
1301
+ }
1302
+ }
1303
+
1304
+ export function clearProxyResolutionCache(): void {
1305
+ proxyCache.clear();
1306
+ proxyInflight.clear();
1307
+ invalidatedProxyKeys.clear();
1308
+ }
1309
+
1310
+ function markSmartproxyCacheInvalidated(
1311
+ options: ProxyResolutionOptions = {},
1312
+ ): string | undefined {
1313
+ const policy = resolvePolicy(options);
1314
+ if (!policy || policy.mode === "disabled") {
1315
+ return undefined;
1316
+ }
1317
+ if (resolveProxyProvider(policy) !== "smartproxy") {
1318
+ return undefined;
1319
+ }
1320
+
1321
+ const lifetimeMinutes = resolveSmartproxyLifetime(policy);
1322
+ const cacheKey = buildSmartproxyCacheKey(
1323
+ policy,
1324
+ options.affinityKey,
1325
+ lifetimeMinutes,
1326
+ );
1327
+ invalidatedProxyKeys.set(
1328
+ cacheKey,
1329
+ Date.now() + SMARTPROXY_INVALIDATION_SKIP_REDIS_MS,
1330
+ );
1331
+ proxyCache.delete(cacheKey);
1332
+ proxyInflight.delete(cacheKey);
1333
+ return cacheKey;
1334
+ }
1335
+
1336
+ export function invalidateProxyResolutionCache(
1337
+ options: ProxyResolutionOptions = {},
1338
+ ): boolean {
1339
+ return markSmartproxyCacheInvalidated(options) !== undefined;
1340
+ }
1341
+
1342
+ export async function invalidateProxyResolutionCacheAsync(
1343
+ options: ProxyResolutionOptions = {},
1344
+ ): Promise<boolean> {
1345
+ const cacheKey = markSmartproxyCacheInvalidated(options);
1346
+ if (!cacheKey) return false;
1347
+ const redis = getProxyRedis();
1348
+ try {
1349
+ if (redis && (await ensureRedisReady(redis))) {
1350
+ await withRedisTimeout(() => redis.del(smartproxyRedisPoolKey(cacheKey)));
1351
+ }
1352
+ } catch {
1353
+ // Cache invalidation must not turn a stale proxy retry into a Redis outage.
1354
+ }
1355
+ return true;
1356
+ }
1357
+
143
1358
  export function defineConfig(config: ApiFuseConfig): ApiFuseConfig {
144
1359
  return config;
145
1360
  }