@apifuse/provider-sdk 2.1.0-beta.0 → 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 (213) hide show
  1. package/AUTHORING.md +218 -21
  2. package/CHANGELOG.md +54 -0
  3. package/README.md +147 -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 +120 -0
  8. package/bin/apifuse-pack-smoke.ts +423 -0
  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 -29
  155. package/src/ceremonies/index.ts +30 -3
  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 +134 -2
  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 -44
  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 +1282 -7
  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 +1726 -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 -15
  183. package/src/lint.ts +547 -73
  184. package/src/observability.ts +41 -0
  185. package/src/provider.ts +104 -5
  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 +945 -185
  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 +1172 -76
  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 +1118 -44
  211. package/src/composite.ts +0 -43
  212. package/src/runtime/tls.ts +0 -425
  213. 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,24 +49,415 @@ 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>;
142
+ };
143
+
144
+ type SmartproxyAllocationResult = {
145
+ pool: CachedProxyPool;
146
+ telemetry: ProxyResolutionTelemetryEvent;
42
147
  };
43
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
- return normalized ? normalized : undefined;
384
+ return normalized ? applyStickyProxySession(normalized) : undefined;
385
+ }
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
+
396
+ function applyStickyProxySession(proxyUrl: string): string {
397
+ let parsed: URL;
398
+ try {
399
+ parsed = new URL(proxyUrl);
400
+ } catch {
401
+ return proxyUrl;
402
+ }
403
+
404
+ if (!parsed.hostname || !parsed.username || !parsed.password) {
405
+ return proxyUrl;
406
+ }
407
+
408
+ const host = parsed.hostname.toLowerCase();
409
+ if (!host.includes("smartproxy") && !host.includes("decodo")) {
410
+ return proxyUrl;
411
+ }
412
+
413
+ const username = decodeURIComponent(parsed.username);
414
+ const sessionId =
415
+ process.env.APIFUSE__PROXY__SESSION_ID?.trim() || "apifuse-shared";
416
+ const sessionDuration = readPositiveIntegerEnv(
417
+ "APIFUSE__PROXY__SESSION_DURATION",
418
+ );
419
+ const stickyUsername = host.includes("smartproxy")
420
+ ? buildSmartproxyUsername(username, sessionId, sessionDuration)
421
+ : buildDecodoUsername(username, sessionId, sessionDuration ?? "60");
422
+
423
+ parsed.username = stickyUsername;
424
+ return parsed.toString();
425
+ }
426
+
427
+ function buildSmartproxyUsername(
428
+ username: string,
429
+ sessionId: string,
430
+ sessionDuration?: string,
431
+ ): string {
432
+ const parts = username.split("_");
433
+ const configuredLife = parts
434
+ .find((part) => part.startsWith("life-"))
435
+ ?.slice("life-".length);
436
+ const baseUsername = parts
437
+ .filter((part) => !part.startsWith("session-") && !part.startsWith("life-"))
438
+ .join("_");
439
+ return `${baseUsername}_session-${sessionId}_life-${sessionDuration ?? configuredLife ?? "60"}`;
440
+ }
441
+
442
+ function buildDecodoUsername(
443
+ username: string,
444
+ sessionId: string,
445
+ sessionDuration: string,
446
+ ): string {
447
+ const withoutSticky = username.replace(
448
+ /-session-.+-sessionduration-\d+$/,
449
+ "",
450
+ );
451
+ const baseUsername = withoutSticky.startsWith("user-")
452
+ ? withoutSticky
453
+ : `user-${withoutSticky}`;
454
+ return `${baseUsername}-session-${sessionId}-sessionduration-${sessionDuration}`;
47
455
  }
48
456
 
49
457
  function syncProxyEnv(config: ApiFuseConfig): void {
50
458
  const configProxyUrl = normalizeProxyUrl(config.proxy?.url);
51
- if (!process.env.APIFUSE_PROXY_URL && configProxyUrl) {
52
- process.env.APIFUSE_PROXY_URL = configProxyUrl;
459
+ if (!process.env.APIFUSE__PROXY__URL && configProxyUrl) {
460
+ process.env.APIFUSE__PROXY__URL = configProxyUrl;
53
461
  }
54
462
  }
55
463
 
@@ -61,11 +469,18 @@ export function resolveProxyConfig(
61
469
  return { shouldWarn: false, url: explicitProxyUrl };
62
470
  }
63
471
 
64
- if (!options.upstream?.proxy) {
472
+ const policy = resolvePolicy(options);
473
+ if (policy?.mode === "disabled") {
65
474
  return { shouldWarn: false };
66
475
  }
67
476
 
68
- const envProxyUrl = normalizeProxyUrl(process.env.APIFUSE_PROXY_URL);
477
+ const legacyProxyRequested =
478
+ options.upstream?.proxy === true || (!policy && options.upstream?.proxy);
479
+ if (!legacyProxyRequested) {
480
+ return { shouldWarn: false };
481
+ }
482
+
483
+ const envProxyUrl = normalizeProxyUrl(process.env.APIFUSE__PROXY__URL);
69
484
  if (envProxyUrl) {
70
485
  return { shouldWarn: false, url: envProxyUrl };
71
486
  }
@@ -80,6 +495,866 @@ export function resolveProxyConfig(
80
495
  return { shouldWarn: true };
81
496
  }
82
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
+
83
1358
  export function defineConfig(config: ApiFuseConfig): ApiFuseConfig {
84
1359
  return config;
85
1360
  }