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

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 (478) hide show
  1. package/AUTHORING.md +943 -51
  2. package/CHANGELOG.md +273 -1
  3. package/LICENSE +21 -0
  4. package/README.md +117 -29
  5. package/SUBMISSION.md +3 -2
  6. package/bin/apifuse-check.ts +293 -13
  7. package/bin/apifuse-dev.ts +42 -19
  8. package/bin/apifuse-migrate-deployment.ts +200 -0
  9. package/bin/apifuse-migrate-operation-declaration.ts +55 -0
  10. package/bin/apifuse-migrate-shape.ts +202 -0
  11. package/bin/apifuse-pack-check.ts +19 -6
  12. package/bin/apifuse-pack-smoke.ts +57 -2
  13. package/bin/apifuse-pack-types.ts +736 -38
  14. package/bin/apifuse-perf.ts +59 -23
  15. package/bin/apifuse-record.ts +923 -94
  16. package/bin/apifuse-submit-check.ts +2313 -281
  17. package/bin/apifuse-test.ts +3 -1
  18. package/bin/submit-check-delimited-text.ts +50 -0
  19. package/dist/auth-turn/index.d.ts +3 -3
  20. package/dist/auth-turn/index.js +1 -1
  21. package/dist/auth.d.ts +14 -0
  22. package/dist/auth.js +67 -0
  23. package/dist/ceremonies/index.d.ts +16 -0
  24. package/dist/ceremonies/index.js +141 -36
  25. package/dist/cli/commands.d.ts +1 -1
  26. package/dist/cli/commands.js +30 -0
  27. package/dist/cli/create.d.ts +1 -1
  28. package/dist/cli/create.js +8 -2
  29. package/dist/cli/deployment-intent.d.ts +178 -0
  30. package/dist/cli/deployment-intent.js +277 -0
  31. package/dist/cli/local-connection.d.ts +111 -0
  32. package/dist/cli/local-connection.js +151 -0
  33. package/dist/cli/migrate-deployment-intent.d.ts +132 -0
  34. package/dist/cli/migrate-deployment-intent.js +1552 -0
  35. package/dist/cli/migrate-operation-declaration.d.ts +70 -0
  36. package/dist/cli/migrate-operation-declaration.js +2206 -0
  37. package/dist/cli/migrate-operation-shape.d.ts +44 -0
  38. package/dist/cli/migrate-operation-shape.js +113 -0
  39. package/dist/cli/migrate-provider-shape.d.ts +56 -0
  40. package/dist/cli/migrate-provider-shape.js +580 -0
  41. package/dist/cli/resolver-runtime.d.ts +21 -0
  42. package/dist/cli/resolver-runtime.js +31 -0
  43. package/dist/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +141 -23
  44. package/dist/cli/templates/provider/Dockerfile.tpl +1 -1
  45. package/dist/cli/templates/provider/README.md.tpl +41 -19
  46. package/dist/cli/templates/provider/index.ts.tpl +6 -3
  47. package/dist/cli/templates/provider/operations/ping.ts.tpl +4 -1
  48. package/dist/cli/templates/provider/provider.json.tpl +6 -0
  49. package/dist/config/loader.d.ts +202 -16
  50. package/dist/config/loader.js +491 -141
  51. package/dist/contract-serialization.js +4 -8
  52. package/dist/contract-types.d.ts +12 -5
  53. package/dist/contract.js +24 -10
  54. package/dist/declaration-validation.d.ts +34 -0
  55. package/dist/declaration-validation.js +305 -0
  56. package/dist/define.d.ts +71 -29
  57. package/dist/define.js +807 -69
  58. package/dist/dev.d.ts +5 -0
  59. package/dist/dev.js +8 -1
  60. package/dist/engine.d.ts +129 -0
  61. package/dist/engine.js +209 -0
  62. package/dist/error-observability.d.ts +7 -0
  63. package/dist/error-observability.js +61 -0
  64. package/dist/error-resolution.d.ts +18 -0
  65. package/dist/error-resolution.js +243 -0
  66. package/dist/errors.d.ts +74 -0
  67. package/dist/errors.js +99 -0
  68. package/dist/fixture-sanitization.d.ts +28 -0
  69. package/dist/fixture-sanitization.js +371 -0
  70. package/dist/handle-meta.d.ts +90 -0
  71. package/dist/handle-meta.js +73 -0
  72. package/dist/handle.d.ts +226 -0
  73. package/dist/handle.js +345 -0
  74. package/dist/health-scenario.d.ts +1901 -0
  75. package/dist/health-scenario.js +653 -0
  76. package/dist/i18n/catalog.js +21 -1
  77. package/dist/i18n/error-messages.d.ts +86 -0
  78. package/dist/i18n/error-messages.js +192 -0
  79. package/dist/i18n/index.d.ts +1 -0
  80. package/dist/i18n/index.js +1 -0
  81. package/dist/i18n/keys.d.ts +12 -0
  82. package/dist/i18n/keys.js +76 -5
  83. package/dist/i18n/operation-locale-namespace.d.ts +2 -0
  84. package/dist/i18n/operation-locale-namespace.js +7 -0
  85. package/dist/index.d.ts +40 -15
  86. package/dist/index.js +29 -11
  87. package/dist/lint.d.ts +43 -11
  88. package/dist/lint.js +2512 -177
  89. package/dist/native-address.d.ts +43 -0
  90. package/dist/native-address.js +281 -0
  91. package/dist/native-egress-policy.d.ts +31 -0
  92. package/dist/native-egress-policy.js +288 -0
  93. package/dist/observability.d.ts +5 -2
  94. package/dist/observability.js +48 -1
  95. package/dist/provider.d.ts +18 -8
  96. package/dist/provider.js +11 -6
  97. package/dist/runtime/auth-flow.d.ts +6 -1
  98. package/dist/runtime/auth-flow.js +8 -0
  99. package/dist/runtime/bounded-expiring-map.d.ts +21 -0
  100. package/dist/runtime/bounded-expiring-map.js +64 -0
  101. package/dist/runtime/browser-telemetry-binding.d.ts +5 -0
  102. package/dist/runtime/browser-telemetry-binding.js +83 -0
  103. package/dist/runtime/browser-telemetry.d.ts +76 -0
  104. package/dist/runtime/browser-telemetry.js +165 -0
  105. package/dist/runtime/browser.d.ts +4 -0
  106. package/dist/runtime/browser.js +688 -83
  107. package/dist/runtime/cache-telemetry.d.ts +71 -0
  108. package/dist/runtime/cache-telemetry.js +177 -0
  109. package/dist/runtime/cache.d.ts +3 -0
  110. package/dist/runtime/cache.js +245 -23
  111. package/dist/runtime/capability-response.d.ts +3 -0
  112. package/dist/runtime/capability-response.js +56 -0
  113. package/dist/runtime/capability-telemetry.d.ts +46 -0
  114. package/dist/runtime/capability-telemetry.js +68 -0
  115. package/dist/runtime/chrome149-header-order.d.ts +58 -0
  116. package/dist/runtime/chrome149-header-order.js +289 -0
  117. package/dist/runtime/diagnostic-env-values.d.ts +5 -0
  118. package/dist/runtime/diagnostic-env-values.js +57 -0
  119. package/dist/runtime/diagnostic-env.d.ts +14 -0
  120. package/dist/runtime/diagnostic-env.js +44 -0
  121. package/dist/runtime/diagnostic-redactor.d.ts +54 -0
  122. package/dist/runtime/diagnostic-redactor.js +571 -0
  123. package/dist/runtime/egress-lease.d.ts +65 -0
  124. package/dist/runtime/egress-lease.js +236 -0
  125. package/dist/runtime/engine-mode.d.ts +74 -0
  126. package/dist/runtime/engine-mode.js +152 -0
  127. package/dist/runtime/env.js +14 -1
  128. package/dist/runtime/executor.d.ts +3 -2
  129. package/dist/runtime/executor.js +23 -26
  130. package/dist/runtime/handle-wordlist.d.ts +43 -0
  131. package/dist/runtime/handle-wordlist.js +256 -0
  132. package/dist/runtime/handle.d.ts +71 -0
  133. package/dist/runtime/handle.js +908 -0
  134. package/dist/runtime/http-telemetry-binding.d.ts +10 -0
  135. package/dist/runtime/http-telemetry-binding.js +152 -0
  136. package/dist/runtime/http-telemetry-guard.d.ts +16 -0
  137. package/dist/runtime/http-telemetry-guard.js +202 -0
  138. package/dist/runtime/http-telemetry.d.ts +104 -0
  139. package/dist/runtime/http-telemetry.js +190 -0
  140. package/dist/runtime/http.d.ts +34 -0
  141. package/dist/runtime/http.js +680 -121
  142. package/dist/runtime/insights.js +2 -2
  143. package/dist/runtime/instrumentation.d.ts +2 -2
  144. package/dist/runtime/instrumentation.js +462 -19
  145. package/dist/runtime/native-network-errors.d.ts +33 -0
  146. package/dist/runtime/native-network-errors.js +69 -0
  147. package/dist/runtime/native-network.d.ts +105 -0
  148. package/dist/runtime/native-network.js +1433 -0
  149. package/dist/runtime/native-telemetry.d.ts +142 -0
  150. package/dist/runtime/native-telemetry.js +231 -0
  151. package/dist/runtime/ocr-telemetry.d.ts +82 -0
  152. package/dist/runtime/ocr-telemetry.js +192 -0
  153. package/dist/runtime/ocr.d.ts +29 -0
  154. package/dist/runtime/ocr.js +468 -0
  155. package/dist/runtime/otlp.d.ts +71 -2
  156. package/dist/runtime/otlp.js +398 -16
  157. package/dist/runtime/provider.d.ts +7 -0
  158. package/dist/runtime/provider.js +14 -0
  159. package/dist/runtime/proxy-errors.js +6 -2
  160. package/dist/runtime/proxy-nodemaven.d.ts +56 -0
  161. package/dist/runtime/proxy-nodemaven.js +147 -0
  162. package/dist/runtime/proxy-retry-policy.d.ts +4 -1
  163. package/dist/runtime/proxy-retry-policy.js +11 -2
  164. package/dist/runtime/proxy-telemetry.d.ts +133 -3
  165. package/dist/runtime/proxy-telemetry.js +218 -70
  166. package/dist/runtime/redirect-hop-headers.d.ts +19 -0
  167. package/dist/runtime/redirect-hop-headers.js +58 -0
  168. package/dist/runtime/redirects.d.ts +61 -0
  169. package/dist/runtime/redirects.js +149 -0
  170. package/dist/runtime/redis.d.ts +1 -1
  171. package/dist/runtime/redis.js +4 -2
  172. package/dist/runtime/request-options.d.ts +75 -1
  173. package/dist/runtime/request-options.js +588 -0
  174. package/dist/runtime/request-telemetry.d.ts +70 -0
  175. package/dist/runtime/request-telemetry.js +229 -0
  176. package/dist/runtime/resolver-config.d.ts +13 -0
  177. package/dist/runtime/resolver-config.js +14 -0
  178. package/dist/runtime/resolver-public.d.ts +3 -0
  179. package/dist/runtime/resolver-public.js +1 -0
  180. package/dist/runtime/resolver-runtime-options.d.ts +14 -0
  181. package/dist/runtime/resolver-runtime-options.js +33 -0
  182. package/dist/runtime/resolver-shared.d.ts +24 -0
  183. package/dist/runtime/resolver-shared.js +69 -0
  184. package/dist/runtime/resolver-span-attributes.d.ts +34 -0
  185. package/dist/runtime/resolver-span-attributes.js +93 -0
  186. package/dist/runtime/resolver-telemetry.d.ts +135 -0
  187. package/dist/runtime/resolver-telemetry.js +204 -0
  188. package/dist/runtime/resolver-usage.d.ts +32 -0
  189. package/dist/runtime/resolver-usage.js +54 -0
  190. package/dist/runtime/resolver-vendors/bindings.d.ts +53 -0
  191. package/dist/runtime/resolver-vendors/bindings.js +45 -0
  192. package/dist/runtime/resolver-vendors/browser.d.ts +24 -0
  193. package/dist/runtime/resolver-vendors/browser.js +423 -0
  194. package/dist/runtime/resolver-vendors/capsolver.d.ts +22 -0
  195. package/dist/runtime/resolver-vendors/capsolver.js +544 -0
  196. package/dist/runtime/resolver-vendors/hosts.d.ts +23 -0
  197. package/dist/runtime/resolver-vendors/hosts.js +68 -0
  198. package/dist/runtime/resolver-vendors/hypersolutions.d.ts +27 -0
  199. package/dist/runtime/resolver-vendors/hypersolutions.js +369 -0
  200. package/dist/runtime/resolver-vendors/twocaptcha.d.ts +24 -0
  201. package/dist/runtime/resolver-vendors/twocaptcha.js +425 -0
  202. package/dist/runtime/resolver-vendors/types.d.ts +125 -0
  203. package/dist/runtime/resolver-vendors/types.js +106 -0
  204. package/dist/runtime/resolver.d.ts +79 -0
  205. package/dist/runtime/resolver.js +1094 -0
  206. package/dist/runtime/state-telemetry.d.ts +79 -0
  207. package/dist/runtime/state-telemetry.js +194 -0
  208. package/dist/runtime/state.d.ts +10 -1
  209. package/dist/runtime/state.js +322 -82
  210. package/dist/runtime/stealth-akamai-sbsd.d.ts +54 -0
  211. package/dist/runtime/stealth-akamai-sbsd.js +142 -0
  212. package/dist/runtime/stealth-cookies.d.ts +24 -0
  213. package/dist/runtime/stealth-cookies.js +131 -0
  214. package/dist/runtime/stealth-owned-headers.d.ts +39 -0
  215. package/dist/runtime/stealth-owned-headers.js +57 -0
  216. package/dist/runtime/stealth-telemetry.d.ts +92 -0
  217. package/dist/runtime/stealth-telemetry.js +179 -0
  218. package/dist/runtime/stealth.d.ts +45 -6
  219. package/dist/runtime/stealth.js +1775 -320
  220. package/dist/runtime/stt-telemetry.d.ts +77 -0
  221. package/dist/runtime/stt-telemetry.js +176 -0
  222. package/dist/runtime/stt.js +18 -19
  223. package/dist/runtime/timeout.d.ts +5 -0
  224. package/dist/runtime/timeout.js +12 -0
  225. package/dist/runtime/trace-config.d.ts +12 -0
  226. package/dist/runtime/trace-config.js +62 -0
  227. package/dist/runtime/trace.d.ts +17 -0
  228. package/dist/runtime/trace.js +176 -25
  229. package/dist/runtime-boundary-lint.d.ts +137 -0
  230. package/dist/runtime-boundary-lint.js +590 -0
  231. package/dist/schema.d.ts +20 -0
  232. package/dist/schema.js +72 -2
  233. package/dist/serve.d.ts +1 -1
  234. package/dist/serve.js +1 -1
  235. package/dist/server/error-observability.d.ts +1 -0
  236. package/dist/server/error-observability.js +1 -0
  237. package/dist/server/error-stack-frames.d.ts +35 -0
  238. package/dist/server/error-stack-frames.js +219 -0
  239. package/dist/server/index.d.ts +21 -2
  240. package/dist/server/index.js +11 -2
  241. package/dist/server/runtime-diagnostics.d.ts +165 -0
  242. package/dist/server/runtime-diagnostics.js +360 -0
  243. package/dist/server/sdk-version.d.ts +3 -0
  244. package/dist/server/sdk-version.js +19 -0
  245. package/dist/server/self-test-input-tokens.d.ts +2 -1
  246. package/dist/server/self-test-input-tokens.js +18 -14
  247. package/dist/server/self-test-redaction.js +8 -33
  248. package/dist/server/self-test.d.ts +18 -3
  249. package/dist/server/self-test.js +139 -76
  250. package/dist/server/sensitive-values.d.ts +26 -0
  251. package/dist/server/sensitive-values.js +137 -0
  252. package/dist/server/serve-implementation.d.ts +285 -0
  253. package/dist/server/serve-implementation.js +3172 -0
  254. package/dist/server/serve.d.ts +1 -75
  255. package/dist/server/serve.js +1 -1170
  256. package/dist/server/state-backend-report.d.ts +70 -0
  257. package/dist/server/state-backend-report.js +113 -0
  258. package/dist/server/trace-output.d.ts +7 -0
  259. package/dist/server/trace-output.js +111 -0
  260. package/dist/server/types.d.ts +37 -5
  261. package/dist/server/types.js +34 -1
  262. package/dist/stateful/errors.d.ts +19 -0
  263. package/dist/stateful/errors.js +24 -0
  264. package/dist/stateful/http-provider-event-emitter.d.ts +40 -0
  265. package/dist/stateful/http-provider-event-emitter.js +237 -0
  266. package/dist/stateful/http-session-owner-registry.d.ts +44 -0
  267. package/dist/stateful/http-session-owner-registry.js +210 -0
  268. package/dist/stateful/index.d.ts +18 -0
  269. package/dist/stateful/index.js +18 -0
  270. package/dist/stateful/provider-event-delivery-failures.d.ts +32 -0
  271. package/dist/stateful/provider-event-delivery-failures.js +43 -0
  272. package/dist/stateful/provider-event-pipeline-metrics.d.ts +46 -0
  273. package/dist/stateful/provider-event-pipeline-metrics.js +48 -0
  274. package/dist/stateful/provider-event-pipeline.d.ts +50 -0
  275. package/dist/stateful/provider-event-pipeline.js +1 -0
  276. package/dist/stateful/provider-events.d.ts +101 -0
  277. package/dist/stateful/provider-events.js +289 -0
  278. package/dist/stateful/session-key.d.ts +15 -0
  279. package/dist/stateful/session-key.js +86 -0
  280. package/dist/stateful/stateful-provider-adapter-context.d.ts +5 -0
  281. package/dist/stateful/stateful-provider-adapter-context.js +42 -0
  282. package/dist/stateful/stateful-provider-adapter-metrics.d.ts +15 -0
  283. package/dist/stateful/stateful-provider-adapter-metrics.js +21 -0
  284. package/dist/stateful/stateful-provider-adapter.d.ts +98 -0
  285. package/dist/stateful/stateful-provider-adapter.js +287 -0
  286. package/dist/stateful/stateful-provider-observability.d.ts +62 -0
  287. package/dist/stateful/stateful-provider-observability.js +161 -0
  288. package/dist/stateful/stateful-provider-owner-forwarder.d.ts +41 -0
  289. package/dist/stateful/stateful-provider-owner-forwarder.js +237 -0
  290. package/dist/stateful/stateful-provider-runtime-context.d.ts +35 -0
  291. package/dist/stateful/stateful-provider-runtime-context.js +61 -0
  292. package/dist/stateful/stateful-provider-runtime-executor.d.ts +34 -0
  293. package/dist/stateful/stateful-provider-runtime-executor.js +52 -0
  294. package/dist/stateful/stateful-provider-session-routing.d.ts +67 -0
  295. package/dist/stateful/stateful-provider-session-routing.js +345 -0
  296. package/dist/stateful/stateful-provider-session-runtime.d.ts +98 -0
  297. package/dist/stateful/stateful-provider-session-runtime.js +245 -0
  298. package/dist/stateful-signing.d.ts +18 -0
  299. package/dist/stateful-signing.js +27 -0
  300. package/dist/stealth/profiles.d.ts +15 -4
  301. package/dist/stealth/profiles.js +169 -211
  302. package/dist/stream-evidence.d.ts +74 -0
  303. package/dist/stream-evidence.js +785 -0
  304. package/dist/stream.js +7 -1
  305. package/dist/testing/index.d.ts +2 -1
  306. package/dist/testing/index.js +2 -1
  307. package/dist/testing/run.d.ts +32 -2
  308. package/dist/testing/run.js +509 -25
  309. package/dist/trace-sanitization.d.ts +6 -0
  310. package/dist/trace-sanitization.js +68 -0
  311. package/dist/types.d.ts +899 -210
  312. package/dist/types.js +1 -0
  313. package/dist/typescript-module.d.ts +39 -0
  314. package/dist/typescript-module.js +72 -0
  315. package/package.json +52 -6
  316. package/src/auth-turn/index.ts +1 -1
  317. package/src/auth.ts +118 -0
  318. package/src/ceremonies/index.ts +189 -46
  319. package/src/cli/commands.ts +37 -0
  320. package/src/cli/create.ts +8 -2
  321. package/src/cli/deployment-intent.ts +364 -0
  322. package/src/cli/local-connection.ts +221 -0
  323. package/src/cli/migrate-deployment-intent.ts +1908 -0
  324. package/src/cli/migrate-operation-declaration.ts +3026 -0
  325. package/src/cli/migrate-operation-shape.ts +184 -0
  326. package/src/cli/migrate-provider-shape.ts +778 -0
  327. package/src/cli/resolver-runtime.ts +72 -0
  328. package/src/cli/templates/provider/.agents/skills/health-checks-and-fail-closed/SKILL.md.tpl +141 -23
  329. package/src/cli/templates/provider/Dockerfile.tpl +1 -1
  330. package/src/cli/templates/provider/README.md.tpl +41 -19
  331. package/src/cli/templates/provider/index.ts.tpl +6 -3
  332. package/src/cli/templates/provider/operations/ping.ts.tpl +4 -1
  333. package/src/cli/templates/provider/provider.json.tpl +6 -0
  334. package/src/config/loader.ts +774 -181
  335. package/src/contract-serialization.ts +5 -7
  336. package/src/contract-types.ts +12 -5
  337. package/src/contract.ts +24 -10
  338. package/src/declaration-validation.ts +376 -0
  339. package/src/define.ts +1105 -131
  340. package/src/dev.ts +13 -1
  341. package/src/engine.ts +390 -0
  342. package/src/error-observability.ts +64 -0
  343. package/src/error-resolution.ts +269 -0
  344. package/src/errors.ts +157 -0
  345. package/src/fixture-sanitization.ts +422 -0
  346. package/src/handle-meta.ts +143 -0
  347. package/src/handle.ts +694 -0
  348. package/src/health-scenario.ts +960 -0
  349. package/src/i18n/catalog.ts +23 -1
  350. package/src/i18n/error-messages.ts +255 -0
  351. package/src/i18n/index.ts +1 -0
  352. package/src/i18n/keys.ts +80 -6
  353. package/src/i18n/operation-locale-namespace.ts +10 -0
  354. package/src/index.ts +390 -23
  355. package/src/lint.ts +2975 -234
  356. package/src/native-address.ts +340 -0
  357. package/src/native-egress-policy.ts +358 -0
  358. package/src/observability.ts +51 -1
  359. package/src/provider.ts +207 -27
  360. package/src/runtime/auth-flow.ts +15 -0
  361. package/src/runtime/bounded-expiring-map.ts +67 -0
  362. package/src/runtime/browser-telemetry-binding.ts +92 -0
  363. package/src/runtime/browser-telemetry.ts +289 -0
  364. package/src/runtime/browser.ts +882 -99
  365. package/src/runtime/cache-telemetry.ts +247 -0
  366. package/src/runtime/cache.ts +258 -22
  367. package/src/runtime/capability-response.ts +53 -0
  368. package/src/runtime/capability-telemetry.ts +101 -0
  369. package/src/runtime/chrome149-header-order.ts +330 -0
  370. package/src/runtime/diagnostic-env-values.ts +56 -0
  371. package/src/runtime/diagnostic-env.ts +65 -0
  372. package/src/runtime/diagnostic-redactor.ts +668 -0
  373. package/src/runtime/egress-lease.ts +341 -0
  374. package/src/runtime/engine-mode.ts +233 -0
  375. package/src/runtime/env.ts +15 -1
  376. package/src/runtime/executor.ts +39 -32
  377. package/src/runtime/handle-wordlist.ts +266 -0
  378. package/src/runtime/handle.ts +1180 -0
  379. package/src/runtime/http-telemetry-binding.ts +162 -0
  380. package/src/runtime/http-telemetry-guard.ts +208 -0
  381. package/src/runtime/http-telemetry.ts +292 -0
  382. package/src/runtime/http.ts +896 -150
  383. package/src/runtime/insights.ts +2 -2
  384. package/src/runtime/instrumentation.ts +628 -28
  385. package/src/runtime/native-network-errors.ts +99 -0
  386. package/src/runtime/native-network.ts +1853 -0
  387. package/src/runtime/native-telemetry.ts +366 -0
  388. package/src/runtime/ocr-telemetry.ts +308 -0
  389. package/src/runtime/ocr.ts +564 -0
  390. package/src/runtime/otlp.ts +468 -21
  391. package/src/runtime/provider.ts +15 -0
  392. package/src/runtime/proxy-errors.ts +12 -4
  393. package/src/runtime/proxy-nodemaven.ts +222 -0
  394. package/src/runtime/proxy-retry-policy.ts +12 -1
  395. package/src/runtime/proxy-telemetry.ts +358 -103
  396. package/src/runtime/redirect-hop-headers.ts +71 -0
  397. package/src/runtime/redirects.ts +215 -0
  398. package/src/runtime/redis.ts +7 -2
  399. package/src/runtime/request-options.ts +728 -1
  400. package/src/runtime/request-telemetry.ts +375 -0
  401. package/src/runtime/resolver-config.ts +18 -0
  402. package/src/runtime/resolver-public.ts +44 -0
  403. package/src/runtime/resolver-runtime-options.ts +50 -0
  404. package/src/runtime/resolver-shared.ts +113 -0
  405. package/src/runtime/resolver-span-attributes.ts +139 -0
  406. package/src/runtime/resolver-telemetry.ts +362 -0
  407. package/src/runtime/resolver-usage.ts +99 -0
  408. package/src/runtime/resolver-vendors/bindings.ts +61 -0
  409. package/src/runtime/resolver-vendors/browser.ts +591 -0
  410. package/src/runtime/resolver-vendors/capsolver.ts +727 -0
  411. package/src/runtime/resolver-vendors/hosts.ts +81 -0
  412. package/src/runtime/resolver-vendors/hypersolutions.ts +480 -0
  413. package/src/runtime/resolver-vendors/twocaptcha.ts +555 -0
  414. package/src/runtime/resolver-vendors/types.ts +261 -0
  415. package/src/runtime/resolver.ts +1543 -0
  416. package/src/runtime/state-telemetry.ts +298 -0
  417. package/src/runtime/state.ts +435 -89
  418. package/src/runtime/stealth-akamai-sbsd.ts +207 -0
  419. package/src/runtime/stealth-cookies.ts +155 -0
  420. package/src/runtime/stealth-owned-headers.ts +67 -0
  421. package/src/runtime/stealth-telemetry.ts +309 -0
  422. package/src/runtime/stealth.ts +2275 -372
  423. package/src/runtime/stt-telemetry.ts +282 -0
  424. package/src/runtime/stt.ts +96 -91
  425. package/src/runtime/timeout.ts +18 -0
  426. package/src/runtime/trace-config.ts +78 -0
  427. package/src/runtime/trace.ts +257 -35
  428. package/src/runtime-boundary-lint.ts +707 -0
  429. package/src/schema.ts +80 -2
  430. package/src/serve.ts +6 -1
  431. package/src/server/error-observability.ts +1 -0
  432. package/src/server/error-stack-frames.ts +214 -0
  433. package/src/server/index.ts +184 -2
  434. package/src/server/runtime-diagnostics.ts +522 -0
  435. package/src/server/sdk-version.ts +22 -0
  436. package/src/server/self-test-input-tokens.ts +29 -14
  437. package/src/server/self-test-redaction.ts +10 -40
  438. package/src/server/self-test.ts +205 -89
  439. package/src/server/sensitive-values.ts +211 -0
  440. package/src/server/serve-implementation.ts +4596 -0
  441. package/src/server/serve.ts +1 -1685
  442. package/src/server/state-backend-report.ts +185 -0
  443. package/src/server/trace-output.ts +189 -0
  444. package/src/server/types.ts +34 -1
  445. package/src/stateful/README.md +146 -0
  446. package/src/stateful/errors.ts +35 -0
  447. package/src/stateful/http-provider-event-emitter.ts +314 -0
  448. package/src/stateful/http-session-owner-registry.ts +306 -0
  449. package/src/stateful/index.ts +18 -0
  450. package/src/stateful/provider-event-delivery-failures.ts +80 -0
  451. package/src/stateful/provider-event-pipeline-metrics.ts +95 -0
  452. package/src/stateful/provider-event-pipeline.ts +61 -0
  453. package/src/stateful/provider-events.ts +462 -0
  454. package/src/stateful/session-key.ts +111 -0
  455. package/src/stateful/stateful-provider-adapter-context.ts +59 -0
  456. package/src/stateful/stateful-provider-adapter-metrics.ts +48 -0
  457. package/src/stateful/stateful-provider-adapter.ts +562 -0
  458. package/src/stateful/stateful-provider-observability.ts +261 -0
  459. package/src/stateful/stateful-provider-owner-forwarder.ts +310 -0
  460. package/src/stateful/stateful-provider-runtime-context.ts +96 -0
  461. package/src/stateful/stateful-provider-runtime-executor.ts +96 -0
  462. package/src/stateful/stateful-provider-session-routing.ts +546 -0
  463. package/src/stateful/stateful-provider-session-runtime.ts +403 -0
  464. package/src/stateful-signing.ts +46 -0
  465. package/src/stealth/profiles.ts +212 -231
  466. package/src/stream-evidence.ts +988 -0
  467. package/src/stream.ts +8 -1
  468. package/src/testing/index.ts +10 -1
  469. package/src/testing/run.ts +685 -19
  470. package/src/trace-sanitization.ts +110 -0
  471. package/src/types.ts +1014 -273
  472. package/src/typescript-module.ts +100 -0
  473. package/dist/choice-token.d.ts +0 -24
  474. package/dist/choice-token.js +0 -74
  475. package/dist/runtime/choice.d.ts +0 -15
  476. package/dist/runtime/choice.js +0 -413
  477. package/src/choice-token.ts +0 -165
  478. package/src/runtime/choice.ts +0 -686
@@ -1,26 +1,49 @@
1
1
  import { createHash } from "node:crypto";
2
- import type { Browser, ImpitOptions, ImpitResponse, RequestInit } from "impit";
3
- import { Impit } from "impit";
2
+ import { Cookie } from "tough-cookie";
3
+ import type {
4
+ BrowserProfile,
5
+ EmulationOS,
6
+ RequestInit as WreqRequestInit,
7
+ Session as WreqSession,
8
+ } from "wreq-js";
4
9
 
5
- import type { ProxyResolutionOptions } from "../config/loader.js";
10
+ import type { ProxyResolutionOptions, ProxyVendorName } from "../config/loader.js";
6
11
  import {
7
12
  DEFAULT_SMARTPROXY_POOL_SIZE,
8
13
  invalidateProxyResolutionCacheAsync,
9
14
  ProxyResolutionError,
15
+ policyResolvesRegistryVendorChain,
16
+ resolvePolicyProxyPoolSpan,
17
+ resolvePolicyTransportAttemptCap,
10
18
  resolveProxyConfigAsync,
11
- SMARTPROXY_MAX_POOL_SIZE,
19
+ vendorFromResolvedSource,
12
20
  } from "../config/loader.js";
13
21
  import { SDKError, TransportError } from "../errors.js";
14
- import { getStealthProfile } from "../stealth/profiles.js";
22
+ import {
23
+ DEFAULT_STEALTH_BROWSER,
24
+ DEFAULT_STEALTH_OS,
25
+ getStealthProfile,
26
+ resolverClientProfileFamily,
27
+ resolveStealthProfileSelection,
28
+ } from "../stealth/profiles.js";
15
29
  import type {
16
- CookieJar,
17
30
  HttpMethod,
18
31
  StealthClient,
19
32
  StealthFetchOptions,
33
+ StealthProfileDescriptor,
34
+ StealthProfileSelection,
20
35
  StealthRedirectHop,
21
36
  StealthResponse,
22
37
  StealthSession,
23
38
  } from "../types.js";
39
+ import { chrome149HeaderOrder } from "./chrome149-header-order.js";
40
+ import { registerDiagnosticValue } from "./diagnostic-env.js";
41
+ import { assertCallerHeadersSupported } from "./stealth-owned-headers.js";
42
+ import {
43
+ ENGINE_CEREMONY_EGRESS_LEASE,
44
+ type CeremonyEgressBinding,
45
+ type CeremonyEgressLeaseRuntime,
46
+ } from "./egress-lease.js";
24
47
  import {
25
48
  createProxyAuthIpDeniedError,
26
49
  createProxyEdgeAuthRejectedError,
@@ -40,18 +63,64 @@ import {
40
63
  computeProxyAttemptIndex,
41
64
  computeProxyTransportRetryDelayMs,
42
65
  createDefaultProxyTransportRetryOptions,
66
+ isProxyTransportRetryMethod,
43
67
  normalizeProxyTransportRetryOptions,
44
68
  shouldRetryProxyTransportAttempt,
45
69
  validateUnsafeProxyTransportRetryMethods,
46
70
  } from "./proxy-retry-policy.js";
47
- import { appendQueryParams } from "./request-options.js";
71
+ import { redirectHopHeaders } from "./redirect-hop-headers.js";
72
+ import {
73
+ evaluateRedirectHop,
74
+ invalidRedirectPolicy,
75
+ isRedirectStatus,
76
+ nextRedirectMethod,
77
+ nextSameOriginRedirectHop,
78
+ normalizeRedirectPolicy,
79
+ type RedirectPolicyTransport,
80
+ resolveRedirectUrl,
81
+ } from "./redirects.js";
82
+ import {
83
+ isSensitiveKey,
84
+ normalizeSensitiveParams,
85
+ redactSensitiveError,
86
+ redactSensitiveRequestError,
87
+ redactSensitiveText,
88
+ redactUrlQueryParams,
89
+ serializeRequestUrl,
90
+ } from "./request-options.js";
91
+ import type { ResolverVendorTransport } from "./resolver-vendors/types.js";
92
+ import {
93
+ type AkamaiSbsdSessionState,
94
+ akamaiSbsdChallengeKey,
95
+ detectAkamaiSbsdChallenge,
96
+ type StealthChallengeRuntime,
97
+ } from "./stealth-akamai-sbsd.js";
98
+ import { StealthCookieJar } from "./stealth-cookies.js";
99
+
100
+ export type {
101
+ StealthTelemetryAttemptEvent,
102
+ StealthTelemetryAttemptKind,
103
+ StealthTelemetryDiagnostics,
104
+ StealthTelemetryErrorCode,
105
+ StealthTelemetryRequestClass,
106
+ StealthTelemetrySbsdEvent,
107
+ StealthTelemetrySbsdOutcome,
108
+ StealthTelemetrySink,
109
+ } from "./stealth-telemetry.js";
110
+ import type {
111
+ StealthTelemetryAttemptKind,
112
+ StealthTelemetryErrorCode,
113
+ StealthTelemetrySink,
114
+ } from "./stealth-telemetry.js";
48
115
 
49
- const DEFAULT_PROFILE = "chrome-146";
116
+ export const DEFAULT_STEALTH_PROFILE: StealthProfileDescriptor = Object.freeze({
117
+ browser: DEFAULT_STEALTH_BROWSER,
118
+ os: DEFAULT_STEALTH_OS,
119
+ });
50
120
 
51
121
  const MISSING_PROXY_WARNING =
52
122
  "[provider-sdk] Provider requested proxy routing, but no proxy URL was configured. Continuing without proxy.";
53
123
 
54
- const MAX_POLICY_PROXY_RETRY_ATTEMPTS = SMARTPROXY_MAX_POOL_SIZE;
55
124
  const MAX_POLICY_PROXY_POOL_REFRESHES = 1;
56
125
  const PROXY_CONNECT_FAILURE_CODE = "proxy_connect_failed";
57
126
  const PROXY_CONNECT_FAILURE_BODY_PATTERN =
@@ -59,9 +128,54 @@ const PROXY_CONNECT_FAILURE_BODY_PATTERN =
59
128
  const PROXY_AUTH_DIAGNOSTIC_URL = "http://example.com/";
60
129
  const PROXY_AUTH_DIAGNOSTIC_TIMEOUT_MS = 5_000;
61
130
  const STEALTH_PROXY_TRANSPORT_RETRY_ERROR_CODES = [PROXY_CONNECT_FAILURE_CODE] as const;
131
+ // Failures that mean the bound ceremony endpoint did not carry the request to the origin.
132
+ // Proxy-level refusals are covered by isProxyPoolRefreshableError; PROXY_AUTH_IP_DENIED is
133
+ // deliberately absent because another endpoint of the same vendor is denied just the same.
134
+ const BOUND_EGRESS_TRANSPORT_FAILURE_CODES: ReadonlySet<string> = new Set([
135
+ PROXY_CONNECT_FAILURE_CODE,
136
+ "transport_network_error",
137
+ "transport_timeout",
138
+ ]);
139
+
140
+ function isBoundEgressTransportFailure(error: TransportError): boolean {
141
+ return (
142
+ isProxyPoolRefreshableError(error) ||
143
+ (error.code !== undefined && BOUND_EGRESS_TRANSPORT_FAILURE_CODES.has(error.code))
144
+ );
145
+ }
146
+ const MAX_STEALTH_REDIRECT_HOPS = 10;
147
+ const STEALTH_REDIRECT_POLICY_TRANSPORT: RedirectPolicyTransport = {
148
+ label: "ctx.stealth",
149
+ maxHops: MAX_STEALTH_REDIRECT_HOPS,
150
+ };
151
+
152
+ function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
153
+ return typeof value === "object" && value !== null;
154
+ }
155
+
156
+ function sensitiveQueryParamNames(url: string): string[] {
157
+ const queryStart = url.indexOf("?");
158
+ if (queryStart === -1) return [];
159
+ const fragmentStart = url.indexOf("#", queryStart);
160
+ const query = url.slice(queryStart + 1, fragmentStart === -1 ? undefined : fragmentStart);
161
+ return [...new URLSearchParams(query).keys()].filter(isSensitiveKey);
162
+ }
62
163
 
63
164
  export type StealthClientOptions = ProxyResolutionOptions & {
165
+ /** Request-scoped stealth transport observations. */
166
+ stealthTelemetry?: StealthTelemetrySink;
64
167
  warn?: (message: string) => void;
168
+ /** Abort all requests issued by this client. */
169
+ signal?: AbortSignal;
170
+ /** Browser identity and declaration-wide HTTP language defaults. */
171
+ stealth?: StealthProfileSelection & {
172
+ /**
173
+ * Default Accept-Language value, emitted where Chrome's network layer appends
174
+ * it (after Accept-Encoding). Requests may override it through `headers`, in
175
+ * which case it is placed like any other caller header.
176
+ */
177
+ acceptLanguage?: string;
178
+ };
65
179
  /**
66
180
  * Proxy-only stealth transport overrides. Use only for upstream proxy products
67
181
  * that terminate CONNECT with a private CA instead of tunneling the origin
@@ -70,225 +184,561 @@ export type StealthClientOptions = ProxyResolutionOptions & {
70
184
  proxyStealth?: { insecureSkipVerify?: boolean };
71
185
  };
72
186
 
73
- const REMOVED_CHROME_PROFILE_NAMES = new Set([
74
- "chrome-120",
75
- "chrome-124",
76
- "chrome-129",
77
- "chrome-130",
78
- "chrome-131",
79
- "chrome-133",
80
- "chrome-144",
81
- "chrome-146-psk",
82
- "chrome-131-psk",
83
- "chrome-130-psk",
84
- "edge-131",
85
- ]);
187
+ /** Server-attached challenge wiring and engine lease; kept off the public StealthClientOptions type. */
188
+ type StealthSessionClientOptions = StealthClientOptions & {
189
+ stealth?: StealthClientOptions["stealth"] & {
190
+ readonly challengeRuntime?: StealthChallengeRuntime;
191
+ };
192
+ readonly [ENGINE_CEREMONY_EGRESS_LEASE]?: CeremonyEgressLeaseRuntime;
193
+ };
194
+
195
+ type ChallengedReplayRecord = {
196
+ consumed: boolean;
197
+ /** Ceremony egress generation the challenged request ran on; a rebind retires the record. */
198
+ readonly generation: number;
199
+ replay(): Promise<StealthResponse>;
200
+ };
201
+
202
+ function assertAkamaiSbsdClientProfile(
203
+ declared: string | undefined,
204
+ session: StealthProfileDescriptor,
205
+ ): void {
206
+ if (declared === undefined || resolverClientProfileFamily(declared) === session.browser) return;
207
+ throw new SDKError(
208
+ `Resolver client profile "${declared}" does not match the initiating stealth session browser "${session.browser}"`,
209
+ {
210
+ code: "RESOLVER_CLIENT_PROFILE_MISMATCH",
211
+ fix: "Make resolver.clientProfile name the provider stealth browser family (chrome, firefox, or safari).",
212
+ },
213
+ );
214
+ }
86
215
 
87
- type ImpitBrowser = Browser;
88
- type ImpitRequestInit = RequestInit;
89
-
90
- const CHROME_IMPIT_BY_MAJOR: Record<number, ImpitBrowser> = {
91
- 100: "chrome100",
92
- 101: "chrome101",
93
- 104: "chrome104",
94
- 107: "chrome107",
95
- 110: "chrome110",
96
- 116: "chrome116",
97
- 124: "chrome124",
98
- 125: "chrome125",
99
- 131: "chrome131",
100
- 136: "chrome136",
101
- 142: "chrome142",
216
+ type StealthTransportHeaders = {
217
+ entries(): IterableIterator<[string, string]>;
218
+ get(name: string): string | null;
219
+ getSetCookie?: () => string[];
102
220
  };
103
221
 
104
- const FIREFOX_IMPIT_BY_MAJOR: Record<number, ImpitBrowser> = {
105
- 128: "firefox128",
106
- 133: "firefox133",
107
- 135: "firefox135",
108
- 144: "firefox144",
222
+ type StealthTransportBody = {
223
+ cancel(): Promise<void>;
224
+ getReader(): {
225
+ read(): Promise<{ done: boolean; value?: Uint8Array }>;
226
+ cancel(): Promise<void>;
227
+ releaseLock(): void;
228
+ };
109
229
  };
110
230
 
111
- type StealthTransportResponse = Pick<
112
- ImpitResponse,
113
- "arrayBuffer" | "headers" | "json" | "ok" | "status" | "text"
114
- > & {
231
+ type StealthTransportResponse = {
232
+ arrayBuffer(): Promise<ArrayBuffer>;
233
+ headers: StealthTransportHeaders;
234
+ status: number;
235
+ body?: StealthTransportBody | null;
115
236
  url?: string;
116
237
  redirected?: boolean;
117
238
  };
118
239
 
119
- type StealthMethod = NonNullable<ImpitRequestInit["method"]>;
120
- type StealthRequestInit = ImpitRequestInit & {
240
+ type StealthMethod = HttpMethod | "TRACE";
241
+ type StealthRequestInit = WreqRequestInit & {
121
242
  redirect?: NonNullable<StealthFetchOptions["redirect"]>;
122
243
  };
123
244
 
124
- function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
125
- return typeof value === "object" && value !== null;
126
- }
245
+ type WreqSessionCacheEntry = {
246
+ session: Promise<WreqSession>;
247
+ tail: Promise<void>;
248
+ };
249
+
250
+ type WreqModule = typeof import("wreq-js");
127
251
 
128
- class CookieJarImpl implements CookieJar {
129
- private readonly cookies: Record<string, string>;
252
+ let wreqModulePromise: Promise<WreqModule> | undefined;
130
253
 
131
- constructor(cookieStrings: string[]) {
132
- this.cookies = {};
133
- this.setFromCookieStrings(cookieStrings);
254
+ function getWreqModule(): Promise<WreqModule> {
255
+ if (!wreqModulePromise) {
256
+ wreqModulePromise = import("wreq-js").catch((error: unknown) => {
257
+ throw new SDKError(
258
+ `Stealth transport is unavailable on ${process.platform}-${process.arch}: the wreq-js native binary could not be loaded.`,
259
+ {
260
+ code: "stealth_transport_unavailable",
261
+ cause: error instanceof Error ? error : undefined,
262
+ },
263
+ );
264
+ });
134
265
  }
135
266
 
136
- setFromCookieStrings(cookieStrings: readonly string[]): void {
137
- for (const cookieString of cookieStrings) {
138
- const [nameValue] = cookieString.split(";");
139
- if (!nameValue) {
140
- continue;
141
- }
267
+ return wreqModulePromise;
268
+ }
142
269
 
143
- const separatorIndex = nameValue.indexOf("=");
144
- if (separatorIndex === -1) {
145
- continue;
146
- }
270
+ function parseProfileIdentifier(identifier: string): {
271
+ family: string;
272
+ version: number[];
273
+ } | null {
274
+ const match =
275
+ /^(safari_ios|safari_ipad|firefox_android|firefox_private|chrome|edge|firefox|opera|safari|okhttp)_(\d+(?:[._]\d+)*)$/.exec(
276
+ identifier.toLowerCase(),
277
+ );
278
+ if (!match?.[1] || !match[2]) return null;
279
+ return {
280
+ family: match[1],
281
+ version: match[2].split(/[._]/).map(Number),
282
+ };
283
+ }
147
284
 
148
- const name = nameValue.slice(0, separatorIndex).trim();
149
- const value = nameValue.slice(separatorIndex + 1).trim();
150
- if (name) this.cookies[name] = value;
151
- }
285
+ function compareVersionDistance(target: number[], left: number[], right: number[]): number {
286
+ const width = Math.max(target.length, left.length, right.length);
287
+ for (let index = 0; index < width; index += 1) {
288
+ const targetPart = target[index] ?? 0;
289
+ const leftDistance = Math.abs((left[index] ?? 0) - targetPart);
290
+ const rightDistance = Math.abs((right[index] ?? 0) - targetPart);
291
+ if (leftDistance !== rightDistance) return leftDistance - rightDistance;
152
292
  }
293
+ return 0;
294
+ }
153
295
 
154
- get(name: string): string | undefined {
155
- return this.cookies[name];
296
+ function closestWreqProfile(
297
+ identifier: string,
298
+ wreqProfiles: readonly BrowserProfile[],
299
+ ): BrowserProfile | undefined {
300
+ const requested = parseProfileIdentifier(identifier);
301
+ if (!requested) return undefined;
302
+
303
+ let closest: { name: BrowserProfile; version: number[] } | undefined;
304
+ for (const candidateName of wreqProfiles) {
305
+ const candidate = parseProfileIdentifier(candidateName);
306
+ if (!candidate || candidate.family !== requested.family) continue;
307
+ if (
308
+ !closest ||
309
+ compareVersionDistance(requested.version, candidate.version, closest.version) < 0
310
+ ) {
311
+ closest = { name: candidateName, version: candidate.version };
312
+ }
156
313
  }
314
+ return closest?.name;
315
+ }
157
316
 
158
- getAll(): Record<string, string> {
159
- return { ...this.cookies };
160
- }
317
+ export function resolveWreqProfile(
318
+ selection: StealthProfileSelection,
319
+ wreqProfiles: readonly BrowserProfile[],
320
+ ): {
321
+ browser: BrowserProfile;
322
+ os: EmulationOS;
323
+ } {
324
+ const profile = getStealthProfile(selection);
325
+ const identifier = profile.tlsClientIdentifier?.toLowerCase() ?? "";
326
+ const os = profile.os;
161
327
 
162
- has(name: string): boolean {
163
- return Object.hasOwn(this.cookies, name);
328
+ const browser = closestWreqProfile(identifier, wreqProfiles);
329
+ if (!browser) {
330
+ throw new SDKError(
331
+ `Stealth profile ${profile.browser}/${profile.os} cannot be mapped to a wreq-js browser profile.`,
332
+ );
164
333
  }
334
+ return { browser, os };
335
+ }
165
336
 
166
- toString(): string {
167
- return Object.entries(this.cookies)
168
- .map(([name, value]) => `${name}=${value}`)
169
- .join("; ");
170
- }
337
+ function resolveUrl(baseUrl: string, url: string): string {
338
+ return new URL(url, baseUrl).toString();
339
+ }
171
340
 
172
- toHeader(): string {
173
- return this.toString();
341
+ function resolveRequestUrl(baseUrl: string, url: string): string {
342
+ try {
343
+ return resolveUrl(baseUrl, url);
344
+ } catch (error) {
345
+ // Both inputs are caller-owned, so an unparsable URL is a programming error, never a
346
+ // transport condition: not retryable, not the upstream's fault. Classify it here rather
347
+ // than in normalizeStealthTransportError, which also sees ERR_INVALID_URL from malformed
348
+ // upstream redirect targets. The fixed message keeps the URL (and any inline query
349
+ // values) out of the diagnostic surface; the runtime TypeError stays in `cause`.
350
+ throw new TransportError("Invalid request URL", {
351
+ code: "transport_invalid_url",
352
+ status: 0,
353
+ category: "provider_error",
354
+ retryable: false,
355
+ ...(error instanceof Error ? { cause: error } : {}),
356
+ });
174
357
  }
358
+ }
175
359
 
176
- snapshot(): Record<string, string> {
177
- return this.getAll();
178
- }
360
+ function headerEntriesFromHeaders(headers: StealthTransportHeaders): [string, string][] {
361
+ return Array.from(headers.entries());
362
+ }
179
363
 
180
- restore(cookies: Record<string, string>): void {
181
- this.clear();
182
- for (const [name, value] of Object.entries(cookies)) {
183
- if (name) this.cookies[name] = value;
184
- }
185
- }
364
+ type HeaderTuple = [string, string];
186
365
 
187
- clear(): void {
188
- for (const name of Object.keys(this.cookies)) {
189
- delete this.cookies[name];
366
+ function normalizeHeaders(
367
+ headers: Record<string, string | string[] | undefined>,
368
+ ): Record<string, string> {
369
+ return Object.fromEntries(normalizedHeaderEntries(headers));
370
+ }
371
+
372
+ function trimOuterHttpWhitespace(value: string): string {
373
+ return value.replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, "");
374
+ }
375
+
376
+ function normalizedHeaderEntries(
377
+ headers: Record<string, string | string[] | undefined>,
378
+ ): HeaderTuple[] {
379
+ const entries: HeaderTuple[] = [];
380
+ const indices = new Map<string, number>();
381
+ for (const [originalName, originalValue] of Object.entries(headers)) {
382
+ if (originalValue === undefined) continue;
383
+ const name = originalName.toLowerCase();
384
+ const value = (Array.isArray(originalValue) ? originalValue : [originalValue])
385
+ .map(trimOuterHttpWhitespace)
386
+ .join(", ");
387
+ const existingIndex = indices.get(name);
388
+ if (existingIndex === undefined) {
389
+ indices.set(name, entries.length);
390
+ entries.push([name, value]);
391
+ continue;
190
392
  }
393
+ const existing = entries[existingIndex];
394
+ if (existing) existing[1] = `${existing[1]}, ${value}`;
191
395
  }
396
+ return entries;
397
+ }
192
398
 
193
- find(predicate: (cookie: string) => boolean): string | undefined {
194
- for (const [name, value] of Object.entries(this.cookies)) {
195
- const cookie = `${name}=${value}`;
196
- if (predicate(cookie)) {
197
- return cookie;
198
- }
199
- }
399
+ // Non-pseudo-header order of real Chrome 149. The fixture captures
400
+ // (chrome-ground-truth-capture.json, chrome-extended-capture.json,
401
+ // h1-casing-capture.json) were taken through Playwright's `locale` option, which
402
+ // installs Accept-Language via DevTools next to User-Agent; real Chrome only
403
+ // receives it from //net (URLRequestHttpJob::AddExtraHeaders), after
404
+ // Accept-Encoding and before Cookie, as al-placement-capture.json B/C show.
405
+ // chrome-form-post-capture.json was taken without that option and needs no
406
+ // adjustment.
407
+ const CHROME_HEADER_ORDERS = {
408
+ navigation: [
409
+ "sec-ch-ua",
410
+ "sec-ch-ua-mobile",
411
+ "sec-ch-ua-platform",
412
+ "upgrade-insecure-requests",
413
+ "user-agent",
414
+ "accept",
415
+ "sec-fetch-site",
416
+ "sec-fetch-mode",
417
+ "sec-fetch-user",
418
+ "sec-fetch-dest",
419
+ "accept-encoding",
420
+ "accept-language",
421
+ "cookie",
422
+ "priority",
423
+ ],
424
+ // xhr has no fixed table: it is emulated by chrome149HeaderOrder.
425
+ post: [
426
+ "content-length",
427
+ "sec-ch-ua-platform",
428
+ "user-agent",
429
+ "sec-ch-ua",
430
+ "content-type",
431
+ "sec-ch-ua-mobile",
432
+ "accept",
433
+ "origin",
434
+ "sec-fetch-site",
435
+ "sec-fetch-mode",
436
+ "sec-fetch-dest",
437
+ "referer",
438
+ "accept-encoding",
439
+ "accept-language",
440
+ "cookie",
441
+ "priority",
442
+ ],
443
+ "form-post": [
444
+ "content-length",
445
+ "cache-control",
446
+ "sec-ch-ua",
447
+ "sec-ch-ua-mobile",
448
+ "sec-ch-ua-platform",
449
+ "upgrade-insecure-requests",
450
+ "content-type",
451
+ "user-agent",
452
+ "origin",
453
+ "accept",
454
+ "sec-fetch-site",
455
+ "sec-fetch-mode",
456
+ "sec-fetch-user",
457
+ "sec-fetch-dest",
458
+ "referer",
459
+ "accept-encoding",
460
+ "accept-language",
461
+ "cookie",
462
+ "priority",
463
+ ],
464
+ } as const satisfies Record<Exclude<ChromeRequestClass, "xhr">, readonly string[]>;
465
+
466
+ const CHROME_H1_HEADER_ORDERS = {
467
+ navigation: [
468
+ "host",
469
+ "connection",
470
+ "sec-ch-ua",
471
+ "sec-ch-ua-mobile",
472
+ "sec-ch-ua-platform",
473
+ "upgrade-insecure-requests",
474
+ "user-agent",
475
+ "accept",
476
+ "sec-fetch-site",
477
+ "sec-fetch-mode",
478
+ "sec-fetch-user",
479
+ "sec-fetch-dest",
480
+ "accept-encoding",
481
+ "accept-language",
482
+ "cookie",
483
+ ],
484
+ xhr: [
485
+ "host",
486
+ "connection",
487
+ "sec-ch-ua-platform",
488
+ "cache-control",
489
+ "x-requested-with",
490
+ "user-agent",
491
+ "sec-ch-ua",
492
+ "sec-ch-ua-mobile",
493
+ "accept",
494
+ "sec-fetch-site",
495
+ "sec-fetch-mode",
496
+ "sec-fetch-dest",
497
+ "referer",
498
+ "accept-encoding",
499
+ "accept-language",
500
+ "cookie",
501
+ ],
502
+ post: [
503
+ "host",
504
+ "connection",
505
+ "content-length",
506
+ "sec-ch-ua-platform",
507
+ "user-agent",
508
+ "sec-ch-ua",
509
+ "content-type",
510
+ "sec-ch-ua-mobile",
511
+ "accept",
512
+ "origin",
513
+ "sec-fetch-site",
514
+ "sec-fetch-mode",
515
+ "sec-fetch-dest",
516
+ "referer",
517
+ "accept-encoding",
518
+ "accept-language",
519
+ "cookie",
520
+ ],
521
+ "form-post": [
522
+ "host",
523
+ "connection",
524
+ "content-length",
525
+ "cache-control",
526
+ "sec-ch-ua",
527
+ "sec-ch-ua-mobile",
528
+ "sec-ch-ua-platform",
529
+ "upgrade-insecure-requests",
530
+ "content-type",
531
+ "user-agent",
532
+ "origin",
533
+ "accept",
534
+ "sec-fetch-site",
535
+ "sec-fetch-mode",
536
+ "sec-fetch-user",
537
+ "sec-fetch-dest",
538
+ "referer",
539
+ "accept-encoding",
540
+ "accept-language",
541
+ "cookie",
542
+ ],
543
+ } as const satisfies Record<ChromeRequestClass, readonly string[]>;
544
+
545
+ const CHROME_H1_HEADER_NAMES: Record<string, string> = {
546
+ host: "Host",
547
+ connection: "Connection",
548
+ "upgrade-insecure-requests": "Upgrade-Insecure-Requests",
549
+ "user-agent": "User-Agent",
550
+ "accept-language": "Accept-Language",
551
+ accept: "Accept",
552
+ "sec-fetch-site": "Sec-Fetch-Site",
553
+ "sec-fetch-mode": "Sec-Fetch-Mode",
554
+ "sec-fetch-user": "Sec-Fetch-User",
555
+ "sec-fetch-dest": "Sec-Fetch-Dest",
556
+ "accept-encoding": "Accept-Encoding",
557
+ cookie: "Cookie",
558
+ "cache-control": "Cache-Control",
559
+ "x-requested-with": "X-Requested-With",
560
+ referer: "Referer",
561
+ "content-length": "Content-Length",
562
+ "content-type": "Content-Type",
563
+ origin: "Origin",
564
+ range: "Range",
565
+ };
200
566
 
201
- return undefined;
567
+ type ChromeRequestClass = NonNullable<NonNullable<StealthFetchOptions["stealth"]>["requestClass"]>;
568
+
569
+ /**
570
+ * What each request class sends besides its header order, one row per public
571
+ * `stealth.requestClass`:
572
+ *
573
+ * - `fetchMetadata: "navigate"` is a document navigation: `Sec-Fetch-Mode:
574
+ * navigate`, `Sec-Fetch-Dest: document`, `Sec-Fetch-User: ?1` unless
575
+ * `userActivation` is false, `Upgrade-Insecure-Requests: 1`, and the
576
+ * profile's `Accept` and `Priority`. `"cors"` is a script fetch: `cors` /
577
+ * `empty`, `Accept: *\/*`, `Priority: u=1, i`.
578
+ * - `bodyful` sends `Content-Length`.
579
+ * - `validateCache` sends `Cache-Control: max-age=0`, which Chrome adds to a
580
+ * form submission (chrome-form-post-capture.json).
581
+ */
582
+ const CHROME_REQUEST_CLASSES = {
583
+ navigation: { fetchMetadata: "navigate", bodyful: false, validateCache: false },
584
+ "form-post": { fetchMetadata: "navigate", bodyful: true, validateCache: true },
585
+ xhr: { fetchMetadata: "cors", bodyful: false, validateCache: false },
586
+ post: { fetchMetadata: "cors", bodyful: true, validateCache: false },
587
+ } as const satisfies Record<
588
+ ChromeRequestClass,
589
+ { fetchMetadata: "navigate" | "cors"; bodyful: boolean; validateCache: boolean }
590
+ >;
591
+
592
+ function chromeHeaderOrder(
593
+ requestClass: ChromeRequestClass,
594
+ isHttp1: boolean,
595
+ caller: ReadonlyMap<string, string>,
596
+ ): readonly string[] {
597
+ if (isHttp1) return CHROME_H1_HEADER_ORDERS[requestClass];
598
+ if (requestClass !== "xhr") return CHROME_HEADER_ORDERS[requestClass];
599
+ // Cookie and Referer are forbidden Fetch headers, so they never enter the page
600
+ // Fetch Headers map the emulator models. Range does occupy a map bucket
601
+ // (m1-capture.json range_*), but HttpCache::Transaction removes it from the
602
+ // request headers and PartialData re-adds it at the tail.
603
+ const order = chrome149HeaderOrder(
604
+ [...caller.keys()].filter((name) => name !== "cookie" && name !== "referer"),
605
+ );
606
+ if (caller.has("range")) order.splice(order.indexOf("range"), 1);
607
+ // //net appends Accept-Encoding and, unless the caller supplied one,
608
+ // Accept-Language (URLRequestHttpJob::AddExtraHeaders), then writes the Cookie
609
+ // header (SetCookieHeaderAndStart); the HTTP cache re-adds a caller Range after
610
+ // that, and the HTTP/2 Priority header stays last.
611
+ let insertAt = order.indexOf("accept-encoding") + 1;
612
+ if (order[insertAt] === "accept-language") insertAt += 1;
613
+ for (const name of ["cookie", "range"] as const) {
614
+ if (!caller.has(name)) continue;
615
+ order.splice(insertAt, 0, name);
616
+ insertAt += 1;
202
617
  }
618
+ return order;
203
619
  }
204
620
 
205
- function closestImpitBrowser(
206
- major: number,
207
- candidates: Record<number, ImpitBrowser>,
208
- ): ImpitBrowser {
209
- let closestMajor: number | undefined;
210
- let closestBrowser: ImpitBrowser | undefined;
211
- for (const [candidateMajorText, browser] of Object.entries(candidates)) {
212
- const candidateMajor = Number(candidateMajorText);
213
- if (
214
- closestMajor === undefined ||
215
- Math.abs(candidateMajor - major) < Math.abs(closestMajor - major)
216
- ) {
217
- closestMajor = candidateMajor;
218
- closestBrowser = browser;
219
- }
220
- }
221
- return closestBrowser ?? "chrome142";
621
+ function normalizedCallerHeaderEntriesFromRecord(headers: Record<string, string>): HeaderTuple[] {
622
+ // The record has been through normalizeHeaders (lowercase, merged names); the
623
+ // lowercase here only guards later insertions such as the cookie jar's header.
624
+ assertCallerHeadersSupported(headers);
625
+ return Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]);
222
626
  }
223
627
 
224
- function resolveImpitBrowser(profileName: string): ImpitBrowser {
225
- if (REMOVED_CHROME_PROFILE_NAMES.has(profileName)) {
226
- throw new SDKError(`Unknown stealth profile: ${profileName}`);
227
- }
628
+ function chromeRequestClass(
629
+ method: StealthMethod,
630
+ requestedClass?: ChromeRequestClass,
631
+ ): ChromeRequestClass {
632
+ if (requestedClass) return requestedClass;
633
+ if (method === "POST") return "post";
634
+ return "navigation";
635
+ }
228
636
 
229
- let profile: ReturnType<typeof getStealthProfile>;
637
+ function secFetchSite(requestUrl: string, referer: string | undefined): string {
638
+ if (!referer) return "none";
230
639
  try {
231
- profile = getStealthProfile(profileName);
640
+ return new URL(referer).origin === new URL(requestUrl).origin ? "same-origin" : "cross-site";
232
641
  } catch {
233
- // Preserve the previous ctx.stealth.fetch() compatibility behavior: unknown
234
- // profile strings still run with the transport default instead of failing
235
- // before the request starts. Removed built-in profile aliases above remain
236
- // explicit errors so callers do not accidentally pin retired fingerprints.
237
- return "chrome142";
642
+ return "cross-site";
238
643
  }
644
+ }
239
645
 
240
- const identifier = profile.tlsClientIdentifier?.toLowerCase() ?? "";
241
- const chromeMatch = /^(?:chrome|edge)_(\d+)/.exec(identifier);
242
- if (chromeMatch?.[1]) {
243
- return closestImpitBrowser(Number(chromeMatch[1]), CHROME_IMPIT_BY_MAJOR);
646
+ function requiredEmulationHeader(headers: ReadonlyMap<string, string>, name: string): string {
647
+ const value = headers.get(name);
648
+ if (value !== undefined) return value;
649
+ throw new SDKError(`wreq-js Chrome emulation exposes no ${name} header.`, {
650
+ code: "STEALTH_PROFILE_UNAVAILABLE",
651
+ });
652
+ }
653
+
654
+ function buildChromeHeaderTuples(options: {
655
+ emulationHeaders: Iterable<[string, string]>;
656
+ method: StealthMethod;
657
+ body?: string | Buffer;
658
+ headers: Record<string, string>;
659
+ requestUrl: string;
660
+ acceptLanguage?: string;
661
+ requestClass?: ChromeRequestClass;
662
+ userActivation?: boolean;
663
+ }): HeaderTuple[] {
664
+ const callerEntries = normalizedCallerHeaderEntriesFromRecord(options.headers);
665
+ const caller = new Map(callerEntries);
666
+ const emulation = new Map(
667
+ Array.from(
668
+ options.emulationHeaders,
669
+ ([name, value]) => [name.toLowerCase(), value] as HeaderTuple,
670
+ ),
671
+ );
672
+ const requestClass = chromeRequestClass(options.method, options.requestClass);
673
+ const shape = CHROME_REQUEST_CLASSES[requestClass];
674
+ const referer = caller.get("referer");
675
+ const fetchSite = secFetchSite(options.requestUrl, referer);
676
+ const isNavigation = shape.fetchMetadata === "navigate";
677
+ const values = new Map<string, string>([
678
+ ["sec-ch-ua", requiredEmulationHeader(emulation, "sec-ch-ua")],
679
+ ["sec-ch-ua-mobile", requiredEmulationHeader(emulation, "sec-ch-ua-mobile")],
680
+ ["sec-ch-ua-platform", requiredEmulationHeader(emulation, "sec-ch-ua-platform")],
681
+ ["user-agent", requiredEmulationHeader(emulation, "user-agent")],
682
+ [
683
+ "accept-language",
684
+ caller.get("accept-language") ??
685
+ options.acceptLanguage ??
686
+ requiredEmulationHeader(emulation, "accept-language"),
687
+ ],
688
+ ["accept", isNavigation ? requiredEmulationHeader(emulation, "accept") : "*/*"],
689
+ [
690
+ "accept-encoding",
691
+ caller.has("range") ? "identity" : requiredEmulationHeader(emulation, "accept-encoding"),
692
+ ],
693
+ ["priority", isNavigation ? requiredEmulationHeader(emulation, "priority") : "u=1, i"],
694
+ ["sec-fetch-site", fetchSite],
695
+ ["sec-fetch-mode", isNavigation ? "navigate" : "cors"],
696
+ ["sec-fetch-dest", isNavigation ? "document" : "empty"],
697
+ ]);
698
+ if (isNavigation) {
699
+ values.set("upgrade-insecure-requests", "1");
700
+ if (options.userActivation !== false) values.set("sec-fetch-user", "?1");
244
701
  }
245
- const firefoxMatch = /^firefox_(\d+)/.exec(identifier);
246
- if (firefoxMatch?.[1]) {
247
- return closestImpitBrowser(Number(firefoxMatch[1]), FIREFOX_IMPIT_BY_MAJOR);
702
+ if (shape.validateCache) values.set("cache-control", "max-age=0");
703
+ for (const [name, value] of callerEntries) values.set(name, value);
704
+ for (const name of ["content-type", "origin", "referer"] as const) {
705
+ const value = caller.get(name);
706
+ if (value !== undefined) values.set(name, value);
248
707
  }
249
- if (identifier.startsWith("safari_")) {
250
- throw new SDKError(
251
- `Stealth profile "${profileName}" uses a Safari stealth fingerprint, but TypeScript ctx.stealth uses impit which currently supports Chrome, Firefox, and OkHttp profiles only. Use a Chrome/Firefox stealth profile for ctx.stealth or ctx.browser for Safari-specific behavior.`,
708
+ // A bodyful class keeps its class across a 301/302/303 that rewrites the method
709
+ // to GET (a form submission's redirect stays a validated navigation), but the
710
+ // rewritten request has no body and Chrome sends no Content-Length for it.
711
+ if (shape.bodyful && options.method !== "GET" && options.method !== "HEAD") {
712
+ values.set(
713
+ "content-length",
714
+ caller.get("content-length") ?? String(Buffer.byteLength(options.body ?? "")),
252
715
  );
253
716
  }
254
- throw new SDKError(
255
- `Stealth profile "${profileName}" cannot be mapped to an impit browser profile.`,
256
- );
257
- }
258
-
259
- function resolveUrl(baseUrl: string, url: string): string {
260
- return new URL(url, baseUrl).toString();
261
- }
262
717
 
263
- function headerEntriesFromHeaders(headers: Headers): [string, string][] {
264
- return Array.from(headers.entries());
265
- }
266
-
267
- function normalizeHeaders(
268
- headers: Record<string, string | string[] | undefined>,
269
- ): Record<string, string> {
270
- const normalized: Record<string, string> = {};
271
- for (const [name, value] of Object.entries(headers)) {
718
+ const isHttp1 = new URL(options.requestUrl).protocol === "http:";
719
+ if (isHttp1) {
720
+ values.set("host", new URL(options.requestUrl).host);
721
+ values.set("connection", "keep-alive");
722
+ }
723
+ const order = chromeHeaderOrder(requestClass, isHttp1, caller);
724
+ const tuples: HeaderTuple[] = [];
725
+ const placed = new Set<string>();
726
+ for (const name of order) {
727
+ const value = values.get(name);
272
728
  if (value === undefined) continue;
273
- normalized[name] = Array.isArray(value) ? value.join(", ") : value;
729
+ tuples.push([isHttp1 ? (CHROME_H1_HEADER_NAMES[name] ?? name) : name, value]);
730
+ placed.add(name);
274
731
  }
275
- return normalized;
732
+ for (const [name, value] of callerEntries) {
733
+ if (placed.has(name)) continue;
734
+ tuples.push([isHttp1 ? (CHROME_H1_HEADER_NAMES[name] ?? name) : name, value]);
735
+ }
736
+ return tuples;
276
737
  }
277
738
 
278
739
  function hasOwn(object: object, key: string): boolean {
279
740
  return Object.hasOwn(object, key);
280
741
  }
281
- function toImpitCookieJar(cookieJar: CookieJarImpl): NonNullable<ImpitOptions["cookieJar"]> {
282
- return {
283
- setCookie(cookie: string, _url: string, cb?: (error?: unknown) => void) {
284
- cookieJar.setFromCookieStrings([cookie]);
285
- if (typeof cb === "function") cb();
286
- },
287
- getCookieString(_url: string) {
288
- return cookieJar.toString();
289
- },
290
- };
291
- }
292
742
 
293
743
  function assertNoUnsupportedFingerprintOverrides(options: unknown): void {
294
744
  if (!isRecord(options)) return;
@@ -300,23 +750,52 @@ function assertNoUnsupportedFingerprintOverrides(options: unknown): void {
300
750
  if (unsupported.length === 0) return;
301
751
 
302
752
  throw new SDKError(
303
- `ctx.stealth.fetch uses impit-managed browser fingerprints and no longer accepts low-level stealth overrides: ${unsupported.join(", ")}. Use the profile option instead.`,
753
+ `ctx.stealth.fetch uses transport-managed browser fingerprints and no longer accepts low-level stealth overrides: ${unsupported.join(", ")}. Use stealth.browser and stealth.os instead.`,
304
754
  );
305
755
  }
306
756
 
307
- function responseHeadersToRecord(headers: Headers): Record<string, string | string[] | undefined> {
757
+ function responseHeadersToRecord(
758
+ headers: StealthTransportHeaders,
759
+ ): Record<string, string | string[] | undefined> {
308
760
  const record: Record<string, string> = {};
309
761
  for (const [name, value] of headers.entries()) record[name] = value;
310
762
  return record;
311
763
  }
312
764
 
313
- function setCookieHeadersFromResponse(headers: Headers): string[] {
765
+ function setCookieHeadersFromResponse(headers: StealthTransportHeaders): string[] {
314
766
  const getSetCookie = headers.getSetCookie;
315
767
  if (typeof getSetCookie === "function") return getSetCookie.call(headers);
316
768
  const setCookie = headers.get("set-cookie");
317
769
  return setCookie ? splitCombinedSetCookieHeader(setCookie) : [];
318
770
  }
319
771
 
772
+ function resolverCookiesFromResponse(
773
+ response: StealthTransportResponse,
774
+ requestUrl: string,
775
+ ): Awaited<ReturnType<ResolverVendorTransport["fetch"]>>["cookies"] {
776
+ const responseUrl = response.url ?? requestUrl;
777
+ return setCookieHeadersFromResponse(response.headers).flatMap((cookieString) => {
778
+ const cookie = Cookie.parse(cookieString);
779
+ if (!cookie?.key) return [];
780
+ const expires =
781
+ cookie.expires instanceof Date && Number.isFinite(cookie.expires.getTime())
782
+ ? cookie.expires.getTime() / 1_000
783
+ : undefined;
784
+ return [
785
+ {
786
+ name: cookie.key,
787
+ value: cookie.value,
788
+ ...(expires === undefined ? {} : { expires }),
789
+ httpOnly: cookie.httpOnly,
790
+ secure: cookie.secure,
791
+ ...(cookie.domain ? { domain: cookie.domain } : { domain: new URL(responseUrl).hostname }),
792
+ ...(cookie.path ? { path: cookie.path } : {}),
793
+ ...(cookie.sameSite ? { sameSite: cookie.sameSite } : {}),
794
+ },
795
+ ];
796
+ });
797
+ }
798
+
320
799
  function splitCombinedSetCookieHeader(headerValue: string): string[] {
321
800
  const cookieNamePattern = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+=/;
322
801
  const cookieStrings: string[] = [];
@@ -337,10 +816,26 @@ function splitCombinedSetCookieHeader(headerValue: string): string[] {
337
816
  export async function normalizeResponse(
338
817
  response: StealthTransportResponse,
339
818
  requestUrl?: string,
819
+ maxBodyBytes?: number,
820
+ ): Promise<StealthResponse> {
821
+ return normalizeResponseWithSignal(response, requestUrl, maxBodyBytes);
822
+ }
823
+
824
+ async function normalizeResponseWithSignal(
825
+ response: StealthTransportResponse,
826
+ requestUrl?: string,
827
+ maxBodyBytes?: number,
828
+ signal?: AbortSignal,
340
829
  ): Promise<StealthResponse> {
341
830
  const headers = Object.fromEntries(response.headers.entries());
342
- const cookies = new CookieJarImpl(setCookieHeadersFromResponse(response.headers));
343
- const bodyBytes = await response.arrayBuffer();
831
+ const cookies = new StealthCookieJar(
832
+ setCookieHeadersFromResponse(response.headers),
833
+ response.url ?? requestUrl,
834
+ );
835
+ const bodyBytes =
836
+ maxBodyBytes === undefined
837
+ ? await readResponseArrayBuffer(response, signal)
838
+ : await readResponseBodyWithLimit(response, maxBodyBytes, signal);
344
839
  const body = new TextDecoder().decode(bodyBytes);
345
840
 
346
841
  return {
@@ -368,9 +863,147 @@ export async function normalizeResponse(
368
863
  };
369
864
  }
370
865
 
371
- function normalizeBody(body: StealthFetchOptions["body"]): string {
866
+ async function readResponseArrayBuffer(
867
+ response: StealthTransportResponse,
868
+ signal?: AbortSignal,
869
+ ): Promise<ArrayBuffer> {
870
+ if (!signal) return response.arrayBuffer();
871
+ throwIfAmbientAborted(signal);
872
+ return new Promise((resolve, reject) => {
873
+ let settled = false;
874
+ const settle = (operation: () => void) => {
875
+ if (settled) return;
876
+ settled = true;
877
+ signal.removeEventListener("abort", onAbort);
878
+ operation();
879
+ };
880
+ const onAbort = () => {
881
+ const error = toAmbientCancellationError(signal);
882
+ try {
883
+ void response.body?.cancel().catch(() => undefined);
884
+ } catch {
885
+ // Preserve the cancellation error if accessing or cancelling the body fails.
886
+ }
887
+ settle(() => reject(error));
888
+ };
889
+ signal.addEventListener("abort", onAbort, { once: true });
890
+ try {
891
+ void response.arrayBuffer().then(
892
+ (body) => settle(() => resolve(body)),
893
+ (error) => settle(() => reject(error)),
894
+ );
895
+ } catch (error) {
896
+ settle(() => reject(error));
897
+ }
898
+ if (signal.aborted) onAbort();
899
+ });
900
+ }
901
+
902
+ function responseTooLargeError(maxBodyBytes: number, observedBytes: number): TransportError {
903
+ return new TransportError(
904
+ `Response body exceeded maxBodyBytes limit of ${maxBodyBytes} bytes (observed ${observedBytes} bytes)`,
905
+ {
906
+ code: "response_too_large",
907
+ category: "upstream_http",
908
+ retryable: false,
909
+ status: 0,
910
+ },
911
+ );
912
+ }
913
+
914
+ function declaredContentLength(headers: StealthTransportHeaders): number | undefined {
915
+ const contentLength = headers.get("content-length")?.trim();
916
+ if (!contentLength || !/^\d+$/.test(contentLength)) return undefined;
917
+ const parsed = Number(contentLength);
918
+ return Number.isFinite(parsed) ? parsed : undefined;
919
+ }
920
+
921
+ async function readResponseBodyWithLimit(
922
+ response: StealthTransportResponse,
923
+ maxBodyBytes: number,
924
+ signal?: AbortSignal,
925
+ ): Promise<ArrayBuffer> {
926
+ throwIfAmbientAborted(signal);
927
+ const contentLength = declaredContentLength(response.headers);
928
+ if (contentLength !== undefined && contentLength > maxBodyBytes) {
929
+ await response.body?.cancel().catch(() => undefined);
930
+ throw responseTooLargeError(maxBodyBytes, contentLength);
931
+ }
932
+
933
+ if (!response.body) {
934
+ throw new TransportError("Response body stream is unavailable", {
935
+ code: "transport_stream_unavailable",
936
+ category: "upstream_http",
937
+ status: 0,
938
+ });
939
+ }
940
+
941
+ const reader = response.body.getReader();
942
+ const chunks: Uint8Array[] = [];
943
+ let receivedBytes = 0;
944
+ try {
945
+ while (true) {
946
+ const { done, value } = await readResponseBodyChunk(reader, signal);
947
+ if (done) break;
948
+ if (!value) continue;
949
+ receivedBytes += value.byteLength;
950
+ if (receivedBytes > maxBodyBytes) {
951
+ await reader.cancel().catch(() => undefined);
952
+ throw responseTooLargeError(maxBodyBytes, receivedBytes);
953
+ }
954
+ chunks.push(value);
955
+ }
956
+ } finally {
957
+ reader.releaseLock();
958
+ }
959
+
960
+ return concatenateResponseBodyChunks(chunks, receivedBytes);
961
+ }
962
+
963
+ function concatenateResponseBodyChunks(
964
+ chunks: readonly Uint8Array[],
965
+ receivedBytes: number,
966
+ ): ArrayBuffer {
967
+ const bodyBytes = new Uint8Array(receivedBytes);
968
+ let offset = 0;
969
+ for (const chunk of chunks) {
970
+ bodyBytes.set(chunk, offset);
971
+ offset += chunk.byteLength;
972
+ }
973
+ return bodyBytes.buffer;
974
+ }
975
+
976
+ function readResponseBodyChunk(
977
+ reader: ReturnType<StealthTransportBody["getReader"]>,
978
+ signal?: AbortSignal,
979
+ ): Promise<{ done: boolean; value?: Uint8Array }> {
980
+ if (!signal) return reader.read();
981
+ throwIfAmbientAborted(signal);
982
+ return new Promise((resolve, reject) => {
983
+ let settled = false;
984
+ const settle = (operation: () => void) => {
985
+ if (settled) return;
986
+ settled = true;
987
+ signal.removeEventListener("abort", onAbort);
988
+ operation();
989
+ };
990
+ const onAbort = () => {
991
+ const error = toAmbientCancellationError(signal);
992
+ void reader.cancel().catch(() => undefined);
993
+ settle(() => reject(error));
994
+ };
995
+ signal.addEventListener("abort", onAbort, { once: true });
996
+ void reader.read().then(
997
+ (chunk) => settle(() => resolve(chunk)),
998
+ (error) => settle(() => reject(error)),
999
+ );
1000
+ if (signal.aborted) onAbort();
1001
+ });
1002
+ }
1003
+
1004
+ function normalizeBody(body: unknown): string | Buffer | undefined {
372
1005
  if (body === undefined) {
373
- return "";
1006
+ return undefined;
374
1007
  }
375
1008
 
376
1009
  if (typeof body === "string") {
@@ -378,10 +1011,15 @@ function normalizeBody(body: StealthFetchOptions["body"]): string {
378
1011
  }
379
1012
 
380
1013
  if (Buffer.isBuffer(body)) {
381
- return body.toString();
1014
+ return Buffer.from(body);
382
1015
  }
383
1016
 
384
- return String(body);
1017
+ // `StealthFetchOptions.body` is `string | Buffer`; anything else is a provider fault,
1018
+ // caught before the first request so a later replay can send the same bytes.
1019
+ throw new SDKError("Stealth request bodies must be a string or Buffer", {
1020
+ code: "STEALTH_BODY_UNSUPPORTED",
1021
+ fix: "Supply a string or Buffer body; streams and typed arrays are not snapshotted.",
1022
+ });
385
1023
  }
386
1024
 
387
1025
  function isPolicyManagedProxy(options: StealthClientOptions): boolean {
@@ -426,6 +1064,9 @@ type ResolvedAttemptProxy = {
426
1064
  url?: string;
427
1065
  poolIndex?: number;
428
1066
  proxyHash?: string;
1067
+ vendor?: ProxyVendorName;
1068
+ refreshEpoch?: number;
1069
+ lifetimeMinutes?: number;
429
1070
  };
430
1071
 
431
1072
  function proxyPoolIndexFromDiagnostics(
@@ -438,6 +1079,14 @@ function proxyPoolIndexFromDiagnostics(
438
1079
  return Math.floor(value);
439
1080
  }
440
1081
 
1082
+ function positiveDiagnosticNumber(
1083
+ diagnostics: Record<string, string | number | boolean> | undefined,
1084
+ name: string,
1085
+ ): number | undefined {
1086
+ const value = diagnostics?.[name];
1087
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
1088
+ }
1089
+
441
1090
  function proxyEndpointHash(proxyUrl: string | undefined): string | undefined {
442
1091
  if (!proxyUrl) return undefined;
443
1092
  try {
@@ -532,8 +1181,41 @@ function normalizeStealthTransportError(error: unknown): TransportError {
532
1181
  });
533
1182
  }
534
1183
 
535
- function sleep(ms: number): Promise<void> {
536
- return new Promise((resolve) => setTimeout(resolve, ms));
1184
+ function toAmbientCancellationError(
1185
+ signal: AbortSignal,
1186
+ error: unknown = signal.reason,
1187
+ ): TransportError {
1188
+ if (error instanceof TransportError && error.code === "transport_cancelled") {
1189
+ return error;
1190
+ }
1191
+ return new TransportError("Request cancelled", {
1192
+ code: "transport_cancelled",
1193
+ status: 0,
1194
+ retryable: false,
1195
+ ...(error !== undefined
1196
+ ? { cause: error instanceof Error ? error : new Error(String(error)) }
1197
+ : {}),
1198
+ });
1199
+ }
1200
+
1201
+ function throwIfAmbientAborted(signal: AbortSignal | undefined): void {
1202
+ if (signal?.aborted) throw toAmbientCancellationError(signal);
1203
+ }
1204
+
1205
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
1206
+ if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
1207
+ throwIfAmbientAborted(signal);
1208
+ return new Promise((resolve, reject) => {
1209
+ const onAbort = () => {
1210
+ clearTimeout(timer);
1211
+ reject(toAmbientCancellationError(signal));
1212
+ };
1213
+ const timer = setTimeout(() => {
1214
+ signal.removeEventListener("abort", onAbort);
1215
+ resolve();
1216
+ }, ms);
1217
+ signal.addEventListener("abort", onAbort, { once: true });
1218
+ });
537
1219
  }
538
1220
 
539
1221
  function normalizeMethod(method: HttpMethod | string): StealthMethod {
@@ -559,16 +1241,6 @@ function normalizeMethod(method: HttpMethod | string): StealthMethod {
559
1241
  }
560
1242
  }
561
1243
 
562
- function isRedirectStatus(status: number): boolean {
563
- return [301, 302, 303, 307, 308].includes(status);
564
- }
565
-
566
- function nextRedirectMethod(status: number, method: StealthMethod): StealthMethod {
567
- if (status === 303 && method !== "HEAD") return "GET";
568
- if ((status === 301 || status === 302) && method === "POST") return "GET";
569
- return method;
570
- }
571
-
572
1244
  function locationHeader(headers: Record<string, string>): string | undefined {
573
1245
  for (const [name, value] of Object.entries(headers)) {
574
1246
  if (name.toLowerCase() === "location") return value;
@@ -576,57 +1248,535 @@ function locationHeader(headers: Record<string, string>): string | undefined {
576
1248
  return undefined;
577
1249
  }
578
1250
 
1251
+ /**
1252
+ * Snapshot a caller's `redirectPolicy` before the first send. The policy acts
1253
+ * on redirects the transport follows, so it requires the default
1254
+ * `redirect: "follow"`.
1255
+ */
1256
+ function snapshotStealthRedirectPolicy(options: StealthFetchOptions) {
1257
+ let policy: ReturnType<typeof normalizeRedirectPolicy>;
1258
+ try {
1259
+ policy = normalizeRedirectPolicy(options.redirectPolicy, STEALTH_REDIRECT_POLICY_TRANSPORT);
1260
+ } catch (error) {
1261
+ if (error instanceof TransportError) throw error;
1262
+ throw invalidRedirectPolicy(
1263
+ STEALTH_REDIRECT_POLICY_TRANSPORT,
1264
+ "could not be read safely",
1265
+ error instanceof Error ? error : undefined,
1266
+ );
1267
+ }
1268
+ if (policy && options.redirect !== undefined && options.redirect !== "follow") {
1269
+ throw invalidRedirectPolicy(
1270
+ STEALTH_REDIRECT_POLICY_TRANSPORT,
1271
+ `it applies to followed redirects and cannot be combined with redirect: "${options.redirect}"`,
1272
+ );
1273
+ }
1274
+ return policy;
1275
+ }
1276
+
1277
+ function assertStealthRedirectUrl(url: string): void {
1278
+ const protocol = new URL(url).protocol;
1279
+ if (protocol !== "http:" && protocol !== "https:") {
1280
+ throw new TransportError(`Stealth redirect target scheme "${protocol}" is not allowed`, {
1281
+ code: "transport_invalid_url",
1282
+ status: 0,
1283
+ });
1284
+ }
1285
+ }
1286
+
1287
+ function discardStealthRedirectBody(response: StealthTransportResponse): void {
1288
+ try {
1289
+ const cancellation = response.body?.cancel();
1290
+ if (cancellation) void cancellation.catch(() => undefined);
1291
+ } catch {
1292
+ // Redirect handling is decided from status and headers. A cancellation
1293
+ // failure must not replace or delay that decision.
1294
+ }
1295
+ }
1296
+
1297
+ /** Telemetry must never replace a transport result, including with caller-provided sinks. */
1298
+ function emitStealthTelemetry(
1299
+ sink: StealthTelemetrySink | undefined,
1300
+ record: (sink: StealthTelemetrySink) => void,
1301
+ ): void {
1302
+ if (!sink) return;
1303
+ try {
1304
+ record(sink);
1305
+ } catch {
1306
+ /* The request owns its result. */
1307
+ }
1308
+ }
1309
+
1310
+ type StealthExchangeTelemetry = {
1311
+ sink: StealthTelemetrySink;
1312
+ profileId: StealthProfileDescriptor;
1313
+ proxyUsed: boolean;
1314
+ kind: StealthTelemetryAttemptKind;
1315
+ cookieJar?: StealthCookieJar;
1316
+ };
1317
+
1318
+ function stripStealthCookieMaterial(text: string, cookieNames: readonly string[]): string {
1319
+ // Header-shaped Cookie/Set-Cookie material is redacted wholesale, including
1320
+ // foreign names. Bare pairs outside a header are limited to jar-known names:
1321
+ // a foreign bare name is indistinguishable from benign key=value prose and is
1322
+ // therefore covered only when layer 1 registered its value.
1323
+ let result = text.replace(
1324
+ /(^|[^\p{L}\p{N}_-])[\t ]*(?:cookie|set-cookie)[\t ]*(?::|=)[^\r\n]*/giu,
1325
+ "$1[REDACTED]",
1326
+ );
1327
+ result = result.replace(
1328
+ /"(?:cookie|set-cookie)"[\t ]*:[\t ]*"(?:\\.|[^"\\])*"/giu,
1329
+ "[REDACTED]",
1330
+ );
1331
+ for (const name of cookieNames) {
1332
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1333
+ result = result.replace(
1334
+ new RegExp(`(^|[;,&?\\s])${escaped}\\s*=\\s*[^;,\\s&\\r\\n]+`, "giu"),
1335
+ "$1[REDACTED]",
1336
+ );
1337
+ }
1338
+ return result;
1339
+ }
1340
+
1341
+ function startStealthExchange(
1342
+ telemetry: StealthExchangeTelemetry | undefined,
1343
+ method: StealthMethod,
1344
+ options: StealthFetchOptions,
1345
+ signal?: AbortSignal,
1346
+ ): (status?: number, error?: unknown) => void {
1347
+ if (!telemetry) return () => {};
1348
+ const startedAt = Date.now();
1349
+ const requestClass = chromeRequestClass(method, options.stealth?.requestClass);
1350
+ let recorded = false;
1351
+ return (status, error) => {
1352
+ if (recorded) return;
1353
+ recorded = true;
1354
+ emitStealthTelemetry(telemetry?.sink, (sink) => {
1355
+ let code: string | undefined;
1356
+ if (error !== undefined) {
1357
+ try {
1358
+ code = signal?.aborted
1359
+ ? "transport_cancelled"
1360
+ : normalizeStealthTransportError(error).code;
1361
+ } catch {
1362
+ code = "other";
1363
+ }
1364
+ }
1365
+ const knownCodes: readonly string[] = [
1366
+ "transport_network_error",
1367
+ "transport_timeout",
1368
+ "transport_cancelled",
1369
+ "upstream_http_error",
1370
+ "response_too_large",
1371
+ "proxy_connect_failed",
1372
+ "PROXY_POOL_STALE",
1373
+ "PROXY_EDGE_AUTH_REJECTED",
1374
+ "PROXY_AUTH_IP_DENIED",
1375
+ "PROXY_EDGE_TLS_REJECTED",
1376
+ "PROXY_REQUIRED",
1377
+ ];
1378
+ const errorCode = code
1379
+ ? knownCodes.includes(code)
1380
+ ? (code as StealthTelemetryErrorCode)
1381
+ : "other"
1382
+ : status !== undefined && status >= 400
1383
+ ? "upstream_http_error"
1384
+ : undefined;
1385
+ const strip = (text: string) =>
1386
+ stripStealthCookieMaterial(text, telemetry.cookieJar?.names() ?? []);
1387
+ sink.recordAttempt({
1388
+ ms: Date.now() - startedAt,
1389
+ profileId: telemetry.profileId,
1390
+ proxyUsed: telemetry.proxyUsed,
1391
+ kind: telemetry.kind,
1392
+ requestClass:
1393
+ requestClass === "navigation" && options.stealth?.userActivation === false
1394
+ ? "script_navigation"
1395
+ : requestClass === "form-post"
1396
+ ? "form_post"
1397
+ : requestClass,
1398
+ ...(status === undefined ? {} : { status }),
1399
+ ...(errorCode ? { errorCode } : {}),
1400
+ ...(error === undefined
1401
+ ? {}
1402
+ : {
1403
+ diagnostics: {
1404
+ name: strip(error instanceof Error ? error.name : "Error"),
1405
+ message: strip(error instanceof Error ? error.message : String(error)),
1406
+ ...(error instanceof Error && "code" in error && typeof error.code === "string"
1407
+ ? { code: strip(error.code) }
1408
+ : {}),
1409
+ },
1410
+ }),
1411
+ });
1412
+ });
1413
+ };
1414
+ }
1415
+
1416
+ async function fetchStealthRedirectChain(
1417
+ transport: WreqSession,
1418
+ cookieJar: StealthCookieJar,
1419
+ requestUrl: string,
1420
+ method: StealthMethod,
1421
+ options: StealthFetchOptions,
1422
+ signal?: AbortSignal,
1423
+ buildHeaders?: (
1424
+ url: string,
1425
+ method: StealthMethod,
1426
+ body: string | Buffer | undefined,
1427
+ headers: Record<string, string>,
1428
+ ) => HeaderTuple[],
1429
+ telemetry?: StealthExchangeTelemetry,
1430
+ ): Promise<{ normalized: StealthResponse; response: StealthTransportResponse }> {
1431
+ let currentUrl = requestUrl;
1432
+ let currentMethod = method;
1433
+ let currentBody = options.body === undefined ? undefined : normalizeBody(options.body);
1434
+ let currentHeaders = normalizeHeaders({ ...(options.headers ?? {}) });
1435
+ let followedHops = 0;
1436
+ let response: StealthTransportResponse;
1437
+ const deadline = options.timeout ? performance.now() + options.timeout : undefined;
1438
+ // Snapshotted and validated by session.fetch before the first send.
1439
+ const redirectPolicy = options.redirectPolicy;
1440
+ const initialOrigin = new URL(requestUrl).origin;
1441
+ const visitedRequests = new Set([`${method} ${requestUrl}`]);
1442
+
1443
+ let finishExchange: ReturnType<typeof startStealthExchange> | undefined;
1444
+ let status: number | undefined;
1445
+ let exchangeError: unknown;
1446
+ try {
1447
+ while (true) {
1448
+ throwIfAmbientAborted(signal);
1449
+ const headers = { ...currentHeaders };
1450
+ if (!hasHeader(headers, "cookie")) {
1451
+ const cookieHeader = cookieJar.toHeader(currentUrl);
1452
+ if (cookieHeader) headers.cookie = cookieHeader;
1453
+ }
1454
+ const requestInit: StealthRequestInit = {
1455
+ headers: buildHeaders
1456
+ ? buildHeaders(currentUrl, currentMethod, currentBody, headers)
1457
+ : headers,
1458
+ method: currentMethod,
1459
+ redirect: "manual",
1460
+ ...(new URL(currentUrl).protocol === "http:" ? { disableDefaultHeaders: true } : {}),
1461
+ ...(signal ? { signal } : {}),
1462
+ };
1463
+ if (currentBody !== undefined) requestInit.body = currentBody;
1464
+
1465
+ await transport.clearCookies();
1466
+ throwIfAmbientAborted(signal);
1467
+ const remainingTimeout =
1468
+ deadline === undefined ? undefined : Math.ceil(deadline - performance.now());
1469
+ if (remainingTimeout !== undefined && remainingTimeout <= 0) {
1470
+ throw new TransportError("Request timed out", {
1471
+ code: "transport_timeout",
1472
+ status: 0,
1473
+ });
1474
+ }
1475
+ if (remainingTimeout !== undefined) requestInit.timeout = remainingTimeout;
1476
+ status = undefined;
1477
+ finishExchange = startStealthExchange(telemetry, currentMethod, options, signal);
1478
+ response = await transport.fetch(currentUrl, requestInit);
1479
+ status = response.status;
1480
+ if (signal?.aborted) {
1481
+ discardStealthRedirectBody(response);
1482
+ throw toAmbientCancellationError(signal);
1483
+ }
1484
+ cookieJar.setFromCookieStrings(
1485
+ setCookieHeadersFromResponse(response.headers),
1486
+ response.url ?? currentUrl,
1487
+ );
1488
+
1489
+ if (!isRedirectStatus(response.status) || options.redirect === "manual") break;
1490
+ if (options.redirect === "error") {
1491
+ discardStealthRedirectBody(response);
1492
+ throw new TransportError("Stealth request encountered a redirect", {
1493
+ code: "transport_network_error",
1494
+ status: 0,
1495
+ });
1496
+ }
1497
+
1498
+ let nextUrl: string;
1499
+ let nextMethod: StealthMethod;
1500
+ if (redirectPolicy) {
1501
+ discardStealthRedirectBody(response);
1502
+ ({ nextUrl, nextMethod } = nextSameOriginRedirectHop({
1503
+ policy: redirectPolicy,
1504
+ initialOrigin,
1505
+ currentUrl,
1506
+ responseUrl: response.url ?? currentUrl,
1507
+ status: response.status,
1508
+ method: currentMethod,
1509
+ location: response.headers.get("location") ?? undefined,
1510
+ followedHops,
1511
+ visitedRequests,
1512
+ }));
1513
+ } else {
1514
+ const location = resolveRedirectUrl(
1515
+ response.headers.get("location") ?? undefined,
1516
+ response.url ?? currentUrl,
1517
+ );
1518
+ if (!location) break;
1519
+ if (followedHops >= MAX_STEALTH_REDIRECT_HOPS) {
1520
+ discardStealthRedirectBody(response);
1521
+ throw new TransportError(
1522
+ `Stealth request exceeded the ${MAX_STEALTH_REDIRECT_HOPS}-redirect limit`,
1523
+ { code: "transport_network_error", status: 0 },
1524
+ );
1525
+ }
1526
+ discardStealthRedirectBody(response);
1527
+ nextUrl = location;
1528
+ nextMethod = nextRedirectMethod(response.status, currentMethod);
1529
+ }
1530
+ assertStealthRedirectUrl(nextUrl);
1531
+ currentHeaders = redirectHopHeaders(
1532
+ { from: currentUrl, to: nextUrl, methodChanged: nextMethod !== currentMethod },
1533
+ currentHeaders,
1534
+ );
1535
+ if (nextMethod !== currentMethod) currentBody = undefined;
1536
+ currentMethod = nextMethod;
1537
+ currentUrl = nextUrl;
1538
+ followedHops += 1;
1539
+ visitedRequests.add(`${currentMethod} ${currentUrl}`);
1540
+ emitStealthTelemetry(telemetry?.sink, (sink) => sink.recordRedirectHop());
1541
+ finishExchange(status);
1542
+ finishExchange = undefined;
1543
+ }
1544
+
1545
+ const normalized = await normalizeResponseWithSignal(
1546
+ response,
1547
+ currentUrl,
1548
+ options.maxBodyBytes,
1549
+ signal,
1550
+ );
1551
+ if (followedHops > 0) normalized.redirected = true;
1552
+ return { normalized, response };
1553
+ } catch (error) {
1554
+ exchangeError = error;
1555
+ throw error;
1556
+ } finally {
1557
+ finishExchange?.(status, exchangeError);
1558
+ }
1559
+ }
1560
+
579
1561
  function createSessionFetcher(
580
1562
  baseUrl: string,
581
- defaultProfile: string,
582
- clientOptions: StealthClientOptions,
1563
+ defaultProfile: StealthProfileDescriptor,
1564
+ clientOptions: StealthSessionClientOptions,
583
1565
  ): StealthSession {
584
- const clients = new Map<string, Impit>();
1566
+ const clients = new Map<string, WreqSessionCacheEntry>();
585
1567
  let closed = false;
586
1568
  let hasWarnedMissingProxy = false;
587
1569
  const warn = clientOptions.warn ?? console.warn;
588
- const cookieJar = new CookieJarImpl([]);
589
- const impitCookieJar = toImpitCookieJar(cookieJar);
1570
+ const cookieJar = new StealthCookieJar([], baseUrl, registerDiagnosticValue);
1571
+ const akamaiSbsdState: AkamaiSbsdSessionState = { transactions: new Map() };
1572
+ const challengedReplays = new WeakMap<StealthResponse, ChallengedReplayRecord>();
1573
+ const ceremonyEgressLease = clientOptions[ENGINE_CEREMONY_EGRESS_LEASE];
1574
+ // The lease is shared by every session of this client; this is the lease generation this
1575
+ // session's challenge state and replay records were built against.
1576
+ let observedEgressGeneration = ceremonyEgressLease?.generation ?? 0;
1577
+ const automaticChallengeRefetchPolicy = {
1578
+ ...createDefaultProxyTransportRetryOptions({ label: "Stealth" }),
1579
+ methods: ["GET"],
1580
+ };
1581
+
1582
+ function clearAkamaiSbsdState(): void {
1583
+ akamaiSbsdState.rememberedScript = undefined;
1584
+ akamaiSbsdState.transactions.clear();
1585
+ akamaiSbsdState.completedSuccessKey = undefined;
1586
+ }
1587
+
1588
+ function currentEgressGeneration(): number {
1589
+ return ceremonyEgressLease?.generation ?? 0;
1590
+ }
1591
+
1592
+ /**
1593
+ * Whoever dropped the binding (this session by expiry or release, or another session of
1594
+ * the same client), the challenge state that was only valid on the retired endpoint goes
1595
+ * with it, and replay records made against the old generation stop matching.
1596
+ */
1597
+ function syncCeremonyEgress(): void {
1598
+ const generation = currentEgressGeneration();
1599
+ if (generation === observedEgressGeneration) return;
1600
+ observedEgressGeneration = generation;
1601
+ clearAkamaiSbsdState();
1602
+ }
1603
+
1604
+ /**
1605
+ * A binding past its vendor session lifetime is dropped, together with the challenge
1606
+ * state that was only valid on that endpoint, so this request selects and binds afresh.
1607
+ */
1608
+ function expireCeremonyEgressLease(): void {
1609
+ ceremonyEgressLease?.dropExpiredBinding();
1610
+ syncCeremonyEgress();
1611
+ }
590
1612
 
591
- function getClient(
592
- profileName: string,
1613
+ /**
1614
+ * The lease is best effort (ADR-0010: a raw Smartproxy endpoint is not a vendor-guaranteed
1615
+ * lease). When the attempt that ran on the bound endpoint fails at the transport (the
1616
+ * proxy refused it, the tunnel never opened, the socket died or timed out) the binding is
1617
+ * released so the ceremony can leave a dead endpoint before its lifetime elapses. Origin
1618
+ * responses, however bad, keep the binding: the endpoint is alive. Returns true when a
1619
+ * binding was released.
1620
+ */
1621
+ function releaseFailedCeremonyEgress(
1622
+ attemptProxyUrl: string | undefined,
1623
+ error: TransportError,
1624
+ ): boolean {
1625
+ const bound = ceremonyEgressLease?.binding;
1626
+ if (
1627
+ !ceremonyEgressLease ||
1628
+ !bound ||
1629
+ !attemptProxyUrl ||
1630
+ attemptProxyUrl !== bound.proxyUrl ||
1631
+ !isBoundEgressTransportFailure(error)
1632
+ ) {
1633
+ return false;
1634
+ }
1635
+ ceremonyEgressLease.dropBinding();
1636
+ syncCeremonyEgress();
1637
+ return true;
1638
+ }
1639
+
1640
+ async function getClientEntry(
1641
+ profile: StealthProfileDescriptor,
593
1642
  proxyUrl: string | undefined,
594
1643
  ignoreTlsErrors: boolean,
595
- ): Impit {
1644
+ defaultHeaders?: HeaderTuple[],
1645
+ ): Promise<WreqSessionCacheEntry> {
596
1646
  if (closed) {
597
1647
  throw new TransportError("Stealth session is closed", { status: 0 });
598
1648
  }
599
- const browser = resolveImpitBrowser(profileName);
600
- const cacheKey = JSON.stringify({ browser, proxyUrl, ignoreTlsErrors });
601
- let client = clients.get(cacheKey);
602
- if (!client) {
603
- client = new Impit({
604
- browser,
605
- cookieJar: impitCookieJar,
606
- ...(proxyUrl ? { proxyUrl } : {}),
607
- ...(ignoreTlsErrors ? { ignoreTlsErrors: true } : {}),
608
- timeout: 30_000,
609
- });
610
- clients.set(cacheKey, client);
1649
+ const wreq = await getWreqModule();
1650
+ const { browser, os } = resolveWreqProfile(profile, wreq.getProfiles());
1651
+ const cacheKey = JSON.stringify({
1652
+ browser,
1653
+ os,
1654
+ proxyUrl,
1655
+ ignoreTlsErrors,
1656
+ headerOrder: defaultHeaders?.map(([name]) => name),
1657
+ });
1658
+ let entry = clients.get(cacheKey);
1659
+ if (!entry) {
1660
+ entry = {
1661
+ // The header shape only partitions sessions; it is never handed to wreq as
1662
+ // session `defaultHeaders`. wreq merges those into every request that lacks
1663
+ // one of their names, so a header the SDK left off on purpose — a credential
1664
+ // dropped on a cross-origin redirect hop, the body headers after POST -> GET,
1665
+ // a cookie for another host — would come back with the first request's value.
1666
+ // Every request already sends its complete ordered header list.
1667
+ session: wreq.createSession({
1668
+ browser,
1669
+ os,
1670
+ ...(proxyUrl ? { proxy: proxyUrl } : {}),
1671
+ ...(ignoreTlsErrors ? { insecure: true } : {}),
1672
+ timeout: 30_000,
1673
+ }),
1674
+ tail: Promise.resolve(),
1675
+ };
1676
+ clients.set(cacheKey, entry);
1677
+ }
1678
+ return entry;
1679
+ }
1680
+
1681
+ async function withClient<T>(
1682
+ profile: StealthProfileDescriptor,
1683
+ proxyUrl: string | undefined,
1684
+ ignoreTlsErrors: boolean,
1685
+ operation: (client: WreqSession) => Promise<T>,
1686
+ signal?: AbortSignal,
1687
+ defaultHeaders?: HeaderTuple[],
1688
+ ): Promise<T> {
1689
+ throwIfAmbientAborted(signal);
1690
+ const entry = await getClientEntry(profile, proxyUrl, ignoreTlsErrors, defaultHeaders);
1691
+ throwIfAmbientAborted(signal);
1692
+ const previous = entry.tail;
1693
+ let release!: () => void;
1694
+ entry.tail = new Promise<void>((resolve) => {
1695
+ release = resolve;
1696
+ });
1697
+ let acquired = false;
1698
+ try {
1699
+ await waitForClientTurn(previous, signal);
1700
+ acquired = true;
1701
+ throwIfAmbientAborted(signal);
1702
+ const client = await entry.session;
1703
+ throwIfAmbientAborted(signal);
1704
+ const result = await operation(client);
1705
+ throwIfAmbientAborted(signal);
1706
+ return result;
1707
+ } finally {
1708
+ if (acquired) {
1709
+ release();
1710
+ } else {
1711
+ void previous.then(release, release);
1712
+ }
611
1713
  }
612
- return client;
1714
+ }
1715
+
1716
+ async function waitForClientTurn(previous: Promise<void>, signal?: AbortSignal): Promise<void> {
1717
+ if (!signal) {
1718
+ await previous;
1719
+ return;
1720
+ }
1721
+ throwIfAmbientAborted(signal);
1722
+ await new Promise<void>((resolve, reject) => {
1723
+ let settled = false;
1724
+ const settle = (operation: () => void) => {
1725
+ if (settled) return;
1726
+ settled = true;
1727
+ signal.removeEventListener("abort", onAbort);
1728
+ operation();
1729
+ };
1730
+ const onAbort = () => settle(() => reject(toAmbientCancellationError(signal)));
1731
+ signal.addEventListener("abort", onAbort, { once: true });
1732
+ void previous.then(
1733
+ () => settle(resolve),
1734
+ (error) => settle(() => reject(error)),
1735
+ );
1736
+ if (signal.aborted) onAbort();
1737
+ });
613
1738
  }
614
1739
 
615
1740
  async function resolveRequestProxy(
616
1741
  options?: StealthFetchOptions,
617
1742
  proxyAttempt?: number,
1743
+ refreshEpoch?: number,
618
1744
  ): Promise<ResolvedAttemptProxy> {
1745
+ const bound = ceremonyEgressLease?.binding;
1746
+ if (bound) {
1747
+ if (options?.proxy !== undefined || options?.proxyAttemptOffset !== undefined) {
1748
+ throw new SDKError(
1749
+ "A ceremony-bound egress cannot be overridden by provider request options",
1750
+ {
1751
+ code: "EGRESS_LEASE_BINDING_INVALID",
1752
+ fix: "Remove `proxy` and `proxyAttemptOffset` from stealth requests made inside an auth ceremony.",
1753
+ },
1754
+ );
1755
+ }
1756
+ return {
1757
+ url: bound.proxyUrl,
1758
+ poolIndex: bound.poolIndex,
1759
+ proxyHash: proxyEndpointHash(bound.proxyUrl),
1760
+ vendor: bound.vendor,
1761
+ refreshEpoch: bound.refreshEpoch,
1762
+ lifetimeMinutes: bound.lifetimeMinutes,
1763
+ };
1764
+ }
619
1765
  const resolvedProxy = await resolveProxyConfigAsync({
620
1766
  proxy: options?.proxy ?? clientOptions.proxy,
621
1767
  upstream: clientOptions.upstream,
622
- apifuseConfig: clientOptions.apifuseConfig,
623
1768
  affinityKey: clientOptions.affinityKey,
624
1769
  proxyAttempt: computeProxyAttemptIndex({
625
1770
  baseProxyAttempt: clientOptions.proxyAttempt,
626
1771
  proxyAttemptOffset: options?.proxyAttemptOffset,
627
1772
  retryAttemptOffset: proxyAttempt,
628
1773
  }),
1774
+ // The stealth transport tunnels both HTTP CONNECT and SOCKS5,
1775
+ // preserving the client TLS fingerprint end-to-end.
1776
+ transportProtocols: ["http", "socks5"],
1777
+ ...(refreshEpoch === undefined ? {} : { proxyRefreshEpoch: refreshEpoch }),
629
1778
  telemetry: clientOptions.telemetry,
1779
+ engineCredentials: clientOptions.engineCredentials,
630
1780
  });
631
1781
 
632
1782
  if (resolvedProxy.shouldWarn && !hasWarnedMissingProxy) {
@@ -638,42 +1788,141 @@ function createSessionFetcher(
638
1788
  url: resolvedProxy.url,
639
1789
  poolIndex: proxyPoolIndexFromDiagnostics(resolvedProxy.diagnostics),
640
1790
  proxyHash: proxyEndpointHash(resolvedProxy.url),
1791
+ vendor: vendorFromResolvedSource(resolvedProxy.source),
1792
+ refreshEpoch: refreshEpoch ?? 0,
1793
+ ...(positiveDiagnosticNumber(resolvedProxy.diagnostics, "lifetimeMinutes") === undefined
1794
+ ? {}
1795
+ : {
1796
+ lifetimeMinutes: positiveDiagnosticNumber(resolvedProxy.diagnostics, "lifetimeMinutes"),
1797
+ }),
641
1798
  };
642
1799
  }
643
1800
 
1801
+ function bindCeremonyEgress(attemptProxy: ResolvedAttemptProxy | undefined): void {
1802
+ const lease = ceremonyEgressLease;
1803
+ if (!lease || !attemptProxy?.url || !attemptProxy.vendor) return;
1804
+ if (lease.binding) {
1805
+ // A concurrent request on this session may have bound the ceremony while this one
1806
+ // was in flight on an endpoint it selected before the binding existed (pool
1807
+ // rotation after a transport failure). Its response reached the origin from an
1808
+ // egress the handle does not name; accepting it would split the ceremony identity.
1809
+ if (attemptProxy.url !== lease.binding.proxyUrl) {
1810
+ throw new SDKError(
1811
+ "The response arrived on an egress other than the ceremony's bound endpoint",
1812
+ {
1813
+ code: "EGRESS_LEASE_BINDING_INVALID",
1814
+ },
1815
+ );
1816
+ }
1817
+ return;
1818
+ }
1819
+ if (
1820
+ attemptProxy.poolIndex === undefined ||
1821
+ attemptProxy.lifetimeMinutes === undefined ||
1822
+ !clientOptions.affinityKey
1823
+ ) {
1824
+ // Registry vendors always report pool index and session lifetime; missing either
1825
+ // is an SDK fault, not something the caller can correct.
1826
+ throw new SDKError("The selected egress cannot be represented by a ceremony lease", {
1827
+ code: "EGRESS_LEASE_BINDING_INVALID",
1828
+ });
1829
+ }
1830
+ const binding: CeremonyEgressBinding = {
1831
+ vendor: attemptProxy.vendor,
1832
+ proxyUrl: attemptProxy.url,
1833
+ poolIndex: attemptProxy.poolIndex,
1834
+ affinityKey: clientOptions.affinityKey,
1835
+ refreshEpoch: attemptProxy.refreshEpoch ?? 0,
1836
+ lifetimeMinutes: attemptProxy.lifetimeMinutes,
1837
+ };
1838
+ lease.bind(binding);
1839
+ }
1840
+
644
1841
  const session: StealthSession = {
645
- async fetch(url, options: StealthFetchOptions = {}) {
646
- const method = normalizeMethod(options.method ?? "GET");
647
- const hasExplicitRetryPolicy = options.retry !== undefined;
648
- const stealthRetryOptions =
649
- normalizeProxyTransportRetryOptions(options.retry, {
650
- extraErrorCodes: STEALTH_PROXY_TRANSPORT_RETRY_ERROR_CODES,
651
- label: "Stealth",
652
- }) ??
653
- (hasExplicitRetryPolicy
654
- ? undefined
655
- : createDefaultProxyTransportRetryOptions({
1842
+ async fetch(url, callerOptions: StealthFetchOptions = {}) {
1843
+ expireCeremonyEgressLease();
1844
+ const requestBody = normalizeBody(callerOptions.body);
1845
+ // Snapshot the caller's headers with the body before the first send: the first
1846
+ // request and an explicit replay both use this copy, so a caller mutating its own
1847
+ // objects afterwards cannot make the two sends differ.
1848
+ const options: StealthFetchOptions = {
1849
+ ...callerOptions,
1850
+ ...(callerOptions.headers
1851
+ ? {
1852
+ headers: Object.fromEntries(
1853
+ Object.entries(callerOptions.headers).map(([name, value]) => [
1854
+ name,
1855
+ Array.isArray(value) ? [...value] : value,
1856
+ ]),
1857
+ ),
1858
+ }
1859
+ : {}),
1860
+ ...(requestBody === undefined ? {} : { body: requestBody }),
1861
+ };
1862
+ const requestProfile = resolveStealthProfileSelection(options.stealth, defaultProfile);
1863
+ let challengeSolveAttempted = false;
1864
+ let challengeRefetchAttempted = false;
1865
+ let challengeSolveFailure: { readonly error: unknown } | undefined;
1866
+ const { hasExplicitRetryPolicy, method, stealthRetryOptions } = (() => {
1867
+ try {
1868
+ const method = normalizeMethod(options.method ?? "GET");
1869
+ assertCallerHeadersSupported(options.headers ?? {});
1870
+ // Replace the caller's object with the validated copy: the walker, a
1871
+ // retry and an explicit replay all read this snapshot.
1872
+ const redirectPolicy = snapshotStealthRedirectPolicy(options);
1873
+ if (redirectPolicy) options.redirectPolicy = redirectPolicy;
1874
+ const hasExplicitRetryPolicy = options.retry !== undefined;
1875
+ const stealthRetryOptions =
1876
+ normalizeProxyTransportRetryOptions(options.retry, {
656
1877
  extraErrorCodes: STEALTH_PROXY_TRANSPORT_RETRY_ERROR_CODES,
657
1878
  label: "Stealth",
658
- }));
659
- if (stealthRetryOptions) {
660
- validateUnsafeProxyTransportRetryMethods(stealthRetryOptions, "Stealth");
661
- }
1879
+ }) ??
1880
+ (hasExplicitRetryPolicy
1881
+ ? undefined
1882
+ : createDefaultProxyTransportRetryOptions({
1883
+ extraErrorCodes: STEALTH_PROXY_TRANSPORT_RETRY_ERROR_CODES,
1884
+ label: "Stealth",
1885
+ }));
1886
+ if (stealthRetryOptions) {
1887
+ validateUnsafeProxyTransportRetryMethods(stealthRetryOptions, "Stealth");
1888
+ }
1889
+ return { hasExplicitRetryPolicy, method, stealthRetryOptions };
1890
+ } catch (error) {
1891
+ throw redactSensitiveRequestError(error, url, options.sensitiveParams);
1892
+ }
1893
+ })();
1894
+ throwIfAmbientAborted(clientOptions.signal);
662
1895
  const hasPolicyProxy = isPolicyManagedProxy(clientOptions);
663
1896
  const usesPolicyAllocator = hasPolicyProxy && !options.proxy && !clientOptions.proxy;
664
1897
  const retryAttemptCap = Math.max(1, stealthRetryOptions?.attempts ?? 1);
1898
+ // Span the whole vendor chain: successive attempts rotate one vendor's
1899
+ // pool, then fail over to the next vendor via the flat attempt index.
1900
+ const policyProxy =
1901
+ clientOptions.proxyPolicy ??
1902
+ (typeof clientOptions.upstream?.proxy === "object"
1903
+ ? clientOptions.upstream.proxy
1904
+ : undefined);
1905
+ // The pool span is already bounded by each vendor's max pool size
1906
+ // (smartproxy ≤20, nodemaven ≤50), so the configured span never exceeds
1907
+ // the chain's true maximum — a large NodeMaven pool stays fully
1908
+ // reachable rather than being truncated at an arbitrary ceiling.
665
1909
  const policyProxyAttemptCap = Math.max(
666
1910
  1,
667
- Math.min(
668
- MAX_POLICY_PROXY_RETRY_ATTEMPTS,
669
- clientOptions.proxyPolicy?.session?.poolSize ??
670
- (typeof clientOptions.upstream?.proxy === "object"
671
- ? clientOptions.upstream.proxy.session?.poolSize
672
- : undefined) ??
673
- DEFAULT_SMARTPROXY_POOL_SIZE,
674
- ),
1911
+ policyProxy ? resolvePolicyProxyPoolSpan(policyProxy) : DEFAULT_SMARTPROXY_POOL_SIZE,
675
1912
  );
676
- const maxAttempts = usesPolicyAllocator ? policyProxyAttemptCap : retryAttemptCap;
1913
+ // A registry vendor chain (smartproxy/nodemaven) is the only policy whose
1914
+ // successive attempts resolve a *different* endpoint, so it is the only one
1915
+ // that may widen the attempt cap to the pool span, de-duplicate endpoints,
1916
+ // and drive allocator stale-pool refresh. Deprecated custom/decodo policies
1917
+ // have no managed endpoint to rotate or refresh, so they follow the ordinary
1918
+ // transport-retry budget instead.
1919
+ const rotatesRegistryChain =
1920
+ usesPolicyAllocator && policyResolvesRegistryVendorChain(policyProxy);
1921
+ const unboundMaxAttempts = rotatesRegistryChain ? policyProxyAttemptCap : retryAttemptCap;
1922
+ // A bound ceremony has exactly one endpoint to try; releasing the binding after a
1923
+ // transport failure widens the budget back to the ordinary rotation span.
1924
+ let maxAttempts = ceremonyEgressLease?.binding ? 1 : unboundMaxAttempts;
1925
+ const dedupeAllocatorEndpoints = rotatesRegistryChain;
677
1926
  let lastError: unknown;
678
1927
 
679
1928
  for (
@@ -686,10 +1935,17 @@ function createSessionFetcher(
686
1935
  const attemptedProxies = new Set<string>();
687
1936
 
688
1937
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
1938
+ throwIfAmbientAborted(clientOptions.signal);
689
1939
  let proxy: string | undefined;
690
1940
  let attemptProxy: ResolvedAttemptProxy | undefined;
691
- const attemptStartedAt = Date.now();
1941
+ // Reuse the exact serialization used by this outbound attempt in its catch path.
1942
+ let serializedUrl: ReturnType<typeof serializeRequestUrl> | undefined;
1943
+ let fallbackSensitiveValues: readonly string[] = [];
1944
+ let fallbackRequestUrl: string | undefined;
1945
+ let fallbackRedactedUrl: string | undefined;
1946
+ let attemptStartedAt = Date.now();
692
1947
  let attemptRecorded = false;
1948
+ const attemptEgressGeneration = currentEgressGeneration();
693
1949
  const recordProxyAttempt = (
694
1950
  outcome: "ok" | "error",
695
1951
  errorCode?: string,
@@ -698,7 +1954,7 @@ function createSessionFetcher(
698
1954
  if (attemptRecorded || !proxy) return;
699
1955
  attemptRecorded = true;
700
1956
  clientOptions.telemetry?.recordProxyAttempt?.({
701
- provider: "smartproxy",
1957
+ provider: attemptProxy?.vendor ?? "smartproxy",
702
1958
  attempt: attempt + 1,
703
1959
  ...(attemptProxy?.poolIndex === undefined
704
1960
  ? {}
@@ -710,13 +1966,96 @@ function createSessionFetcher(
710
1966
  durationMs: Date.now() - attemptStartedAt,
711
1967
  });
712
1968
  };
1969
+ // Shared tail of the first fetch, the automatic refetch, and an explicit replay: a
1970
+ // classified challenge is returned whatever `throwOnHttpError` says (documented on
1971
+ // StealthFetchOptions), any other non-2xx honours it, and the proxy attempt is
1972
+ // recorded once per delivered response.
1973
+ const finalizeAttempt = (
1974
+ attemptResponse: StealthTransportResponse,
1975
+ attemptNormalized: StealthResponse,
1976
+ ): StealthResponse => {
1977
+ if (attemptNormalized.challenge)
1978
+ emitStealthTelemetry(clientOptions.stealthTelemetry, (sink) =>
1979
+ sink.recordSbsd(attemptNormalized.challenge!.outcome),
1980
+ );
1981
+ if (
1982
+ !attemptNormalized.challenge &&
1983
+ attemptResponse.status >= 400 &&
1984
+ options.throwOnHttpError !== false
1985
+ ) {
1986
+ throw new TransportError(
1987
+ `Upstream request failed with status ${attemptResponse.status}`,
1988
+ { code: "upstream_http_error", status: attemptResponse.status },
1989
+ );
1990
+ }
1991
+ recordProxyAttempt("ok", undefined, attemptResponse.status);
1992
+ return attemptNormalized;
1993
+ };
1994
+ const redactAttemptError = <T>(error: T): T =>
1995
+ redactSensitiveError(
1996
+ error,
1997
+ serializedUrl?.sensitiveValues ?? fallbackSensitiveValues,
1998
+ serializedUrl?.requestUrl ?? fallbackRequestUrl,
1999
+ serializedUrl?.redactedUrl ?? fallbackRedactedUrl,
2000
+ );
2001
+ // Cancellation and resolver failures leave the transport path here, redacted; any
2002
+ // other failure becomes the TransportError the retry decision reasons about.
2003
+ const normalizeAttemptError = (error: unknown): TransportError => {
2004
+ let normalizedError: TransportError;
2005
+ try {
2006
+ throwIfAmbientAborted(clientOptions.signal);
2007
+ normalizedError = normalizeStealthTransportError(error);
2008
+ } catch (normalizationError) {
2009
+ if (
2010
+ normalizationError instanceof TransportError &&
2011
+ normalizationError.code === "transport_cancelled"
2012
+ ) {
2013
+ recordProxyAttempt(
2014
+ "error",
2015
+ proxyAttemptErrorCode(normalizationError),
2016
+ proxyAttemptStatus(normalizationError),
2017
+ );
2018
+ }
2019
+ throw redactAttemptError(normalizationError);
2020
+ }
2021
+ if (challengeSolveFailure && error === challengeSolveFailure) {
2022
+ throw redactAttemptError(challengeSolveFailure.error);
2023
+ }
2024
+ return normalizedError;
2025
+ };
2026
+ const recordAttemptFailure = (normalizedError: TransportError): TransportError => {
2027
+ const redactedError = redactAttemptError(normalizedError);
2028
+ recordProxyAttempt(
2029
+ "error",
2030
+ proxyAttemptErrorCode(redactedError),
2031
+ proxyAttemptStatus(redactedError),
2032
+ );
2033
+ return redactedError;
2034
+ };
713
2035
  try {
2036
+ throwIfAmbientAborted(clientOptions.signal);
2037
+ const sensitiveParams = normalizeSensitiveParams(options.sensitiveParams);
2038
+ const structural = redactUrlQueryParams(url, Object.keys(sensitiveParams ?? {}));
2039
+ fallbackSensitiveValues = [
2040
+ ...new Set([
2041
+ ...Object.values(sensitiveParams ?? {}).map(String),
2042
+ ...structural.sensitiveValues,
2043
+ ]),
2044
+ ].filter((value) => value !== "");
2045
+ fallbackRequestUrl = url;
2046
+ fallbackRedactedUrl = structural.redactedUrl;
714
2047
  assertNoUnsupportedFingerprintOverrides(options);
715
- attemptProxy = await resolveRequestProxy(options, attempt);
2048
+ attemptProxy = await resolveRequestProxy(options, attempt, refreshAttempt);
2049
+ throwIfAmbientAborted(clientOptions.signal);
716
2050
  proxy = attemptProxy.url;
717
- if (proxy && usesPolicyAllocator) {
2051
+ if (proxy && dedupeAllocatorEndpoints) {
2052
+ // An under-filled allocation repeats endpoints (via the modulo
2053
+ // pool mapping) before the flat offset crosses into the next
2054
+ // vendor. Skip an already-tried endpoint and advance the offset
2055
+ // rather than breaking — breaking here would strand the request on
2056
+ // the primary vendor and never reach the fallback leg.
718
2057
  if (attemptedProxies.has(proxy)) {
719
- break;
2058
+ continue;
720
2059
  }
721
2060
  attemptedProxies.add(proxy);
722
2061
  }
@@ -724,81 +2063,428 @@ function createSessionFetcher(
724
2063
  options.stealth?.insecureSkipVerify ??
725
2064
  (!hasPolicyProxy && proxy && clientOptions.proxyStealth?.insecureSkipVerify),
726
2065
  );
727
- const profileName = options.profile ?? defaultProfile;
728
- const requestUrl = appendQueryParams(resolveUrl(baseUrl, url), options.params);
729
- const headers = { ...(options.headers ?? {}) };
730
- if (!hasHeader(headers, "Cookie")) {
731
- const cookieHeader = cookieJar.toString();
732
- if (cookieHeader) headers.Cookie = cookieHeader;
733
- }
734
- const requestInit: StealthRequestInit = {
735
- headers: normalizeHeaders(headers),
736
- method,
737
- ...(options.redirect ? { redirect: options.redirect } : {}),
738
- ...(options.timeout ? { timeout: options.timeout } : {}),
739
- };
740
- if (options.body !== undefined) {
741
- requestInit.body = normalizeBody(options.body);
2066
+ serializedUrl = serializeRequestUrl(
2067
+ resolveRequestUrl(baseUrl, url),
2068
+ options.params,
2069
+ sensitiveParams,
2070
+ );
2071
+ const { requestUrl } = serializedUrl;
2072
+ const wreq = await getWreqModule();
2073
+ const mapping = resolveWreqProfile(requestProfile, wreq.getProfiles());
2074
+ const emulationHeaders = Array.from(
2075
+ wreq.getEmulationHeaders(mapping.browser, mapping.os),
2076
+ ([name, value]) => [String(name), String(value)] as HeaderTuple,
2077
+ );
2078
+ const chromeEmulationHeaders = mapping.browser.startsWith("chrome_")
2079
+ ? emulationHeaders
2080
+ : undefined;
2081
+ const buildOrderedHeaders = chromeEmulationHeaders
2082
+ ? (
2083
+ currentUrl: string,
2084
+ currentMethod: StealthMethod,
2085
+ currentBody: string | Buffer | undefined,
2086
+ currentHeaders: Record<string, string>,
2087
+ ) =>
2088
+ buildChromeHeaderTuples({
2089
+ emulationHeaders: chromeEmulationHeaders,
2090
+ method: currentMethod,
2091
+ body: currentBody,
2092
+ headers: currentHeaders,
2093
+ requestUrl: currentUrl,
2094
+ acceptLanguage: clientOptions.stealth?.acceptLanguage,
2095
+ requestClass: options.stealth?.requestClass,
2096
+ userActivation: options.stealth?.userActivation,
2097
+ })
2098
+ : undefined;
2099
+ const initialHeaders = normalizeHeaders({ ...(options.headers ?? {}) });
2100
+ if (!hasHeader(initialHeaders, "cookie")) {
2101
+ const cookieHeader = cookieJar.toHeader(requestUrl);
2102
+ if (cookieHeader) initialHeaders.cookie = cookieHeader;
742
2103
  }
743
- const response = await getClient(profileName, proxy, ignoreTlsErrors).fetch(
2104
+ const defaultHeaders = buildOrderedHeaders?.(
744
2105
  requestUrl,
745
- requestInit,
2106
+ method,
2107
+ requestBody,
2108
+ initialHeaders,
746
2109
  );
747
- const normalized = await normalizeResponse(response, requestUrl);
748
- cookieJar.setFromCookieStrings(setCookieHeadersFromResponse(response.headers));
749
-
750
- if (proxy && isProxyConnectFailureResponse(response, normalized.body)) {
751
- throw createProxyConnectFailureError(normalized.body);
752
- }
753
-
754
- if (response.status >= 400) {
2110
+ const fetchOnBoundSession = (
2111
+ fetchUrl: string,
2112
+ fetchMethod: StealthMethod,
2113
+ fetchOptions: StealthFetchOptions,
2114
+ fetchSignal: AbortSignal | undefined,
2115
+ orderedHeaders = buildOrderedHeaders,
2116
+ sessionDefaultHeaders = defaultHeaders,
2117
+ kind: StealthTelemetryAttemptKind = "request",
2118
+ ) =>
2119
+ withClient(
2120
+ requestProfile,
2121
+ proxy,
2122
+ ignoreTlsErrors,
2123
+ (transport) =>
2124
+ fetchStealthRedirectChain(
2125
+ transport,
2126
+ cookieJar,
2127
+ fetchUrl,
2128
+ fetchMethod,
2129
+ fetchOptions,
2130
+ fetchSignal,
2131
+ orderedHeaders,
2132
+ clientOptions.stealthTelemetry
2133
+ ? {
2134
+ sink: clientOptions.stealthTelemetry,
2135
+ profileId: requestProfile,
2136
+ proxyUsed: Boolean(proxy),
2137
+ kind,
2138
+ cookieJar,
2139
+ }
2140
+ : undefined,
2141
+ ),
2142
+ fetchSignal,
2143
+ sessionDefaultHeaders,
2144
+ );
2145
+ const throwProxyTransportFault = (
2146
+ faultResponse: StealthTransportResponse,
2147
+ faultBody: string,
2148
+ ) => {
2149
+ if (!proxy) return;
2150
+ if (isProxyConnectFailureResponse(faultResponse, faultBody)) {
2151
+ throw createProxyConnectFailureError(faultBody);
2152
+ }
2153
+ if (faultResponse.status < 400) return;
755
2154
  if (
756
- proxy &&
757
2155
  usesPolicyAllocator &&
758
2156
  isProxyEdgeTlsRejectedResponse(
759
- response.status,
760
- [JSON.stringify(responseHeadersToRecord(response.headers)), normalized.body].join(
2157
+ faultResponse.status,
2158
+ [JSON.stringify(responseHeadersToRecord(faultResponse.headers)), faultBody].join(
761
2159
  "\n",
762
2160
  ),
763
2161
  )
764
2162
  ) {
765
- throw createProxyEdgeTlsRejectedError(response.status);
2163
+ throw createProxyEdgeTlsRejectedError(faultResponse.status);
766
2164
  }
767
- if (proxy && isProxyAuthIpDeniedMessage(normalized.body)) {
2165
+ if (isProxyAuthIpDeniedMessage(faultBody)) {
768
2166
  throw createProxyAuthIpDeniedError();
769
2167
  }
770
- if (proxy && isProxyEdgeAuthRejectedMessage(normalized.body)) {
2168
+ if (isProxyEdgeAuthRejectedMessage(faultBody)) {
771
2169
  throw createProxyEdgeAuthRejectedError();
772
2170
  }
773
2171
  if (
774
- proxy &&
775
- isProxyPoolStaleStatus(response.status) &&
776
- isProxyPoolStaleMessage(normalized.body)
2172
+ isProxyPoolStaleStatus(faultResponse.status) &&
2173
+ isProxyPoolStaleMessage(faultBody)
777
2174
  ) {
778
- throw createProxyPoolStaleError(response.status);
2175
+ throw createProxyPoolStaleError(faultResponse.status);
2176
+ }
2177
+ };
2178
+ let { normalized, response } = await fetchOnBoundSession(
2179
+ requestUrl,
2180
+ method,
2181
+ options,
2182
+ clientOptions.signal,
2183
+ );
2184
+ throwProxyTransportFault(response, normalized.body);
2185
+
2186
+ // Commit only after the selected endpoint reached the origin. A failed
2187
+ // proxy attempt may rotate pool index; the successful exact endpoint is
2188
+ // the one sealed into the ceremony handle.
2189
+ bindCeremonyEgress(attemptProxy);
2190
+
2191
+ const akamaiSbsd = clientOptions.stealth?.challengeRuntime?.akamaiSbsd;
2192
+ const detected = akamaiSbsd
2193
+ ? detectAkamaiSbsdChallenge(
2194
+ normalized,
2195
+ requestUrl,
2196
+ cookieJar,
2197
+ akamaiSbsd.allowedHosts,
2198
+ akamaiSbsdState,
2199
+ )
2200
+ : undefined;
2201
+ if (akamaiSbsd && !detected)
2202
+ emitStealthTelemetry(clientOptions.stealthTelemetry, (sink) =>
2203
+ sink.recordSbsd({ detected: false }),
2204
+ );
2205
+ if (detected && akamaiSbsd) {
2206
+ emitStealthTelemetry(clientOptions.stealthTelemetry, (sink) =>
2207
+ sink.recordSbsd("detected"),
2208
+ );
2209
+ if (challengeSolveAttempted || challengeRefetchAttempted) {
2210
+ normalized.challenge = {
2211
+ challenge: detected,
2212
+ outcome: "challenge_persisted",
2213
+ };
2214
+ return finalizeAttempt(response, normalized);
2215
+ }
2216
+ const automaticReplayEligible = isProxyTransportRetryMethod(
2217
+ method,
2218
+ automaticChallengeRefetchPolicy,
2219
+ { body: requestBody, headers: options.headers },
2220
+ );
2221
+ if (automaticReplayEligible && !akamaiSbsd.solve) {
2222
+ normalized.challenge = {
2223
+ challenge: detected,
2224
+ outcome: "resolver_unavailable",
2225
+ };
2226
+ return finalizeAttempt(response, normalized);
779
2227
  }
780
- }
781
2228
 
782
- if (response.status >= 400 && options.throwOnHttpError !== false) {
783
- throw new TransportError(`Upstream request failed with status ${response.status}`, {
784
- code: "upstream_http_error",
785
- status: response.status,
2229
+ const emulationHeaderMap = new Map(
2230
+ emulationHeaders.map(([name, value]) => [name.toLowerCase(), value] as const),
2231
+ );
2232
+ const initiatingHeaders = normalizeHeaders({ ...(options.headers ?? {}) });
2233
+ // Mirror what the protected request actually sent: `stealth.acceptLanguage` is
2234
+ // applied by the Chrome header builder only; Safari/Firefox sessions send the
2235
+ // emulation default.
2236
+ const sessionHeaders = {
2237
+ "User-Agent": requiredEmulationHeader(emulationHeaderMap, "user-agent"),
2238
+ "Accept-Language":
2239
+ initiatingHeaders["accept-language"] ??
2240
+ (chromeEmulationHeaders ? clientOptions.stealth?.acceptLanguage : undefined) ??
2241
+ requiredEmulationHeader(emulationHeaderMap, "accept-language"),
2242
+ };
2243
+ const resolverBuildHeaders = chromeEmulationHeaders
2244
+ ? (
2245
+ currentUrl: string,
2246
+ currentMethod: StealthMethod,
2247
+ currentBody: string | Buffer | undefined,
2248
+ currentHeaders: Record<string, string>,
2249
+ ) =>
2250
+ buildChromeHeaderTuples({
2251
+ emulationHeaders: chromeEmulationHeaders,
2252
+ method: currentMethod,
2253
+ body: currentBody,
2254
+ headers: currentHeaders,
2255
+ requestUrl: currentUrl,
2256
+ acceptLanguage: clientOptions.stealth?.acceptLanguage,
2257
+ })
2258
+ : undefined;
2259
+ const resolverTransport: ResolverVendorTransport = {
2260
+ sessionHeaders,
2261
+ getCookie(name, cookieUrl) {
2262
+ return cookieJar.get(name, cookieUrl);
2263
+ },
2264
+ async fetch(transportUrl, init) {
2265
+ const boundSignal = clientOptions.signal
2266
+ ? AbortSignal.any([clientOptions.signal, init.signal])
2267
+ : init.signal;
2268
+ const transportHeaders = { ...(init.headers ?? {}) };
2269
+ if (resolverBuildHeaders) {
2270
+ for (const name of Object.keys(transportHeaders)) {
2271
+ if (name.toLowerCase() === "user-agent") {
2272
+ delete transportHeaders[name];
2273
+ }
2274
+ }
2275
+ }
2276
+ // Session defaults come from this request's own shape, never from the
2277
+ // initiating request: its defaults carry the upstream Cookie header,
2278
+ // which wreq would otherwise merge into the Hyper /ip call.
2279
+ const result = await fetchOnBoundSession(
2280
+ transportUrl,
2281
+ init.method,
2282
+ {
2283
+ method: init.method,
2284
+ headers: transportHeaders,
2285
+ ...(init.body === undefined ? {} : { body: init.body }),
2286
+ redirect: "manual",
2287
+ throwOnHttpError: false,
2288
+ ...(init.maxBodyBytes === undefined
2289
+ ? {}
2290
+ : { maxBodyBytes: init.maxBodyBytes }),
2291
+ },
2292
+ boundSignal,
2293
+ resolverBuildHeaders,
2294
+ resolverBuildHeaders?.(
2295
+ transportUrl,
2296
+ init.method,
2297
+ init.body,
2298
+ normalizeHeaders(transportHeaders),
2299
+ ),
2300
+ "resolver",
2301
+ );
2302
+ return {
2303
+ status: result.normalized.status,
2304
+ headers: result.normalized.headers,
2305
+ body: result.normalized.body,
2306
+ cookies: resolverCookiesFromResponse(result.response, transportUrl),
2307
+ };
2308
+ },
2309
+ };
2310
+ // `options` is already the fetch-start snapshot; the replay gets its own copy
2311
+ // of the body bytes so the transport cannot alias the first send's buffer.
2312
+ const replayOptions: StealthFetchOptions = {
2313
+ ...options,
2314
+ ...(Buffer.isBuffer(requestBody) ? { body: Buffer.from(requestBody) } : {}),
2315
+ };
2316
+ const transactionKey = akamaiSbsdChallengeKey(detected, {
2317
+ wreqBrowser: mapping.browser,
2318
+ wreqOs: mapping.os,
2319
+ proxyUrl: proxy,
786
2320
  });
2321
+ const challenged = { normalized, response };
2322
+ const solveAndReplay = async (
2323
+ explicitReplay: boolean,
2324
+ ): Promise<{ normalized: StealthResponse; response: StealthTransportResponse }> => {
2325
+ if (!akamaiSbsd.solve) {
2326
+ throw new SDKError("No resolver is available for the challenged request", {
2327
+ code: "RESOLVER_UNAVAILABLE",
2328
+ });
2329
+ }
2330
+ // Only a solve that will actually run on this session needs the profile match.
2331
+ assertAkamaiSbsdClientProfile(akamaiSbsd.clientProfile, requestProfile);
2332
+ // A safe request that already solved this exact challenge on this identity
2333
+ // left the session cookies valid: the explicit replay reuses them once
2334
+ // instead of paying for a second solve.
2335
+ const reuseCompletedSuccess =
2336
+ explicitReplay && akamaiSbsdState.completedSuccessKey === transactionKey;
2337
+ if (reuseCompletedSuccess) akamaiSbsdState.completedSuccessKey = undefined;
2338
+ let ownsTransaction = false;
2339
+ if (!reuseCompletedSuccess) {
2340
+ let transaction = akamaiSbsdState.transactions.get(transactionKey);
2341
+ if (!transaction) {
2342
+ akamaiSbsdState.completedSuccessKey = undefined;
2343
+ challengeSolveAttempted = true;
2344
+ ownsTransaction = true;
2345
+ // The solve spans several round trips, so it runs under the client's ambient
2346
+ // signal and the resolver's own timeouts, not this fetch's per-request `timeout`.
2347
+ transaction = {
2348
+ result: akamaiSbsd
2349
+ .solve(
2350
+ detected,
2351
+ resolverTransport,
2352
+ clientOptions.signal ?? new AbortController().signal,
2353
+ )
2354
+ .then(
2355
+ () => ({ solved: true }) as const,
2356
+ (error: unknown) => ({ solved: false, error }) as const,
2357
+ )
2358
+ .finally(() => {
2359
+ akamaiSbsdState.transactions.delete(transactionKey);
2360
+ }),
2361
+ };
2362
+ akamaiSbsdState.transactions.set(transactionKey, transaction);
2363
+ }
2364
+ const transactionResult = await transaction.result;
2365
+ if (!transactionResult.solved) {
2366
+ emitStealthTelemetry(clientOptions.stealthTelemetry, (sink) =>
2367
+ sink.recordSbsd("solve_failed"),
2368
+ );
2369
+ if (ownsTransaction) {
2370
+ // The proxy delivered the challenged response; the resolver failed.
2371
+ // Surface that failure as-is: it is not a transport fault to normalize
2372
+ // or retry.
2373
+ if (!explicitReplay) {
2374
+ recordProxyAttempt("ok", undefined, challenged.response.status);
2375
+ }
2376
+ challengeSolveFailure = { error: transactionResult.error };
2377
+ throw challengeSolveFailure;
2378
+ }
2379
+ return {
2380
+ normalized: {
2381
+ ...challenged.normalized,
2382
+ challenge: { challenge: detected, outcome: "solve_failed" },
2383
+ },
2384
+ response: challenged.response,
2385
+ };
2386
+ }
2387
+ }
2388
+ challengeRefetchAttempted = true;
2389
+ // An explicit replay is a new transport exchange on the bound endpoint and
2390
+ // gets its own proxy-attempt record timed from here, not from the initiating
2391
+ // fetch (the caller's think time in between is not transport duration); the
2392
+ // automatic refetch stays within the initiating attempt's record.
2393
+ if (explicitReplay) {
2394
+ // The solve above may have taken seconds; if another session of this client
2395
+ // moved the ceremony meanwhile, the mutation must not go out on the retired
2396
+ // endpoint.
2397
+ if (currentEgressGeneration() !== attemptEgressGeneration) {
2398
+ throw new SDKError(
2399
+ "The ceremony's egress binding changed while the replay was being solved",
2400
+ { code: "REPLAY_SESSION_MISMATCH" },
2401
+ );
2402
+ }
2403
+ attemptRecorded = false;
2404
+ attemptStartedAt = Date.now();
2405
+ }
2406
+ if (!explicitReplay)
2407
+ emitStealthTelemetry(clientOptions.stealthTelemetry, (sink) =>
2408
+ sink.recordSafeRefetch(),
2409
+ );
2410
+ const replayed = await fetchOnBoundSession(
2411
+ requestUrl,
2412
+ method,
2413
+ replayOptions,
2414
+ clientOptions.signal,
2415
+ );
2416
+ throwProxyTransportFault(replayed.response, replayed.normalized.body);
2417
+ const persisted = detectAkamaiSbsdChallenge(
2418
+ replayed.normalized,
2419
+ requestUrl,
2420
+ cookieJar,
2421
+ akamaiSbsd.allowedHosts,
2422
+ akamaiSbsdState,
2423
+ );
2424
+ emitStealthTelemetry(clientOptions.stealthTelemetry, (sink) =>
2425
+ sink.recordSbsd(persisted ? "challenge_persisted" : "refetch_clear"),
2426
+ );
2427
+ if (persisted) {
2428
+ replayed.normalized.challenge = {
2429
+ challenge: persisted,
2430
+ outcome: "challenge_persisted",
2431
+ };
2432
+ } else if (ownsTransaction && !explicitReplay) {
2433
+ akamaiSbsdState.completedSuccessKey = transactionKey;
2434
+ }
2435
+ return replayed;
2436
+ };
2437
+
2438
+ if (!automaticReplayEligible) {
2439
+ normalized.challenge = {
2440
+ challenge: detected,
2441
+ outcome: "replay_required",
2442
+ };
2443
+ challengedReplays.set(normalized, {
2444
+ consumed: false,
2445
+ generation: attemptEgressGeneration,
2446
+ async replay() {
2447
+ try {
2448
+ const replayed = await solveAndReplay(true);
2449
+ return finalizeAttempt(replayed.response, replayed.normalized);
2450
+ } catch (error) {
2451
+ const normalizedError = normalizeAttemptError(error);
2452
+ releaseFailedCeremonyEgress(proxy, normalizedError);
2453
+ throw recordAttemptFailure(normalizedError);
2454
+ }
2455
+ },
2456
+ });
2457
+ return finalizeAttempt(response, normalized);
2458
+ }
2459
+ ({ normalized, response } = await solveAndReplay(false));
787
2460
  }
788
2461
 
789
- recordProxyAttempt("ok", undefined, response.status);
790
- return normalized;
2462
+ return finalizeAttempt(response, normalized);
791
2463
  } catch (error) {
792
- const normalizedError = normalizeStealthTransportError(error);
793
- recordProxyAttempt(
794
- "error",
795
- proxyAttemptErrorCode(normalizedError),
796
- proxyAttemptStatus(normalizedError),
797
- );
798
- lastError = normalizedError;
799
- if (proxy && usesPolicyAllocator && isProxyPoolRefreshableError(normalizedError)) {
800
- stalePoolError = normalizedError;
801
- if (shouldRunProxyAuthDiagnostic(normalizedError)) {
2464
+ const normalizedError = normalizeAttemptError(error);
2465
+ const retryErrorCode = proxyAttemptErrorCode(normalizedError);
2466
+ const refreshableProxyError = isProxyPoolRefreshableError(normalizedError);
2467
+ const runProxyAuthDiagnostic = shouldRunProxyAuthDiagnostic(normalizedError);
2468
+ const redactedError = recordAttemptFailure(normalizedError);
2469
+ lastError = redactedError;
2470
+ const releasedBoundEgress = releaseFailedCeremonyEgress(proxy, normalizedError);
2471
+ if (releasedBoundEgress) {
2472
+ // The request now proceeds as an unbound one with the full rotation budget;
2473
+ // the attempt spent on the dead endpoint does not count against it.
2474
+ maxAttempts = attempt + 1 + unboundMaxAttempts;
2475
+ }
2476
+ if (challengeSolveAttempted || challengeRefetchAttempted) {
2477
+ // The single refetch is spent (owner or waiter of a shared solve): another
2478
+ // transport attempt would replay the original request and may rotate the
2479
+ // proxy the solved jar is bound to.
2480
+ throw redactedError;
2481
+ }
2482
+ if (proxy && rotatesRegistryChain && refreshableProxyError) {
2483
+ // Also the path a released bound endpoint takes after a proxy-level refusal:
2484
+ // the stale-pool bookkeeping here is what drives allocator invalidation and the
2485
+ // refresh pass when the fresh selection lands on the same endpoint again.
2486
+ stalePoolError = redactedError;
2487
+ if (runProxyAuthDiagnostic) {
802
2488
  stalePoolDiagnosticProxy = proxy;
803
2489
  }
804
2490
  if (attempt + 1 < maxAttempts) {
@@ -807,12 +2493,40 @@ function createSessionFetcher(
807
2493
  break;
808
2494
  }
809
2495
  if (
810
- attempt + 1 <
811
- (stealthRetryOptions
812
- ? Math.min(maxAttempts, stealthRetryOptions.attempts)
813
- : maxAttempts) &&
2496
+ releasedBoundEgress &&
2497
+ attempt + 1 < maxAttempts &&
2498
+ retryErrorCode === PROXY_CONNECT_FAILURE_CODE
2499
+ ) {
2500
+ // The tunnel to the released endpoint never opened, so the origin never saw the
2501
+ // request: selecting and binding a fresh endpoint in this request is safe for
2502
+ // any method. Ambiguous failures (reset, timeout) fall through to the ordinary
2503
+ // method-aware retry decision below with the binding already released.
2504
+ continue;
2505
+ }
2506
+ // Cap the number of transport retries. For a policy-allocator chain,
2507
+ // every attempt resolves a *different* endpoint/vendor (poolIndex
2508
+ // rotates across the concatenated vendor pool spans), so a transport
2509
+ // failure is a signal to advance to the next endpoint — potentially
2510
+ // crossing into the fallback vendor — not to retry the same endpoint.
2511
+ // Truncating that rotation at the per-endpoint retry budget would
2512
+ // strand the request on the primary vendor and never reach the
2513
+ // fallback, since the crossover only happens once the flat attempt
2514
+ // index exceeds the primary vendor's pool size (~10-20).
2515
+ // resolvePolicyTransportAttemptCap widens to the full chain span only
2516
+ // for implicit, safe-method allocator requests; explicit retry
2517
+ // policies (their documented `attempts` ceiling), unsafe methods, and
2518
+ // static/non-registry vendors keep the per-endpoint retry budget.
2519
+ const transportRetryCap = resolvePolicyTransportAttemptCap({
2520
+ policy: policyProxy,
2521
+ usesPolicyAllocator,
2522
+ retryAttempts: stealthRetryOptions?.attempts ?? 1,
2523
+ explicitRetry: hasExplicitRetryPolicy,
2524
+ method,
2525
+ });
2526
+ if (
2527
+ attempt + 1 < transportRetryCap &&
814
2528
  shouldRetryProxyTransportAttempt({
815
- error: normalizedError,
2529
+ error: { code: retryErrorCode },
816
2530
  explicitRetry: hasExplicitRetryPolicy,
817
2531
  method,
818
2532
  options: stealthRetryOptions,
@@ -820,33 +2534,37 @@ function createSessionFetcher(
820
2534
  })
821
2535
  ) {
822
2536
  if (stealthRetryOptions) {
823
- await sleep(computeProxyTransportRetryDelayMs(stealthRetryOptions!, attempt + 1));
2537
+ await sleep(
2538
+ computeProxyTransportRetryDelayMs(stealthRetryOptions, attempt + 1),
2539
+ clientOptions.signal,
2540
+ );
824
2541
  }
2542
+ throwIfAmbientAborted(clientOptions.signal);
825
2543
  continue;
826
2544
  }
827
- throw normalizedError;
2545
+ throw redactedError;
828
2546
  }
829
2547
  }
830
2548
 
831
2549
  if (
832
- usesPolicyAllocator &&
2550
+ rotatesRegistryChain &&
833
2551
  stalePoolError &&
834
2552
  refreshAttempt < MAX_POLICY_PROXY_POOL_REFRESHES
835
2553
  ) {
2554
+ throwIfAmbientAborted(clientOptions.signal);
2555
+ emitStealthTelemetry(clientOptions.stealthTelemetry, (sink) => sink.recordPoolRefresh());
836
2556
  await invalidateProxyResolutionCacheAsync({
837
2557
  proxyPolicy: clientOptions.proxyPolicy,
838
2558
  upstream: clientOptions.upstream,
839
2559
  affinityKey: clientOptions.affinityKey,
840
2560
  });
2561
+ throwIfAmbientAborted(clientOptions.signal);
841
2562
  continue;
842
2563
  }
843
2564
 
844
2565
  const proxyAuthDiagnostic =
845
2566
  stalePoolError && stalePoolDiagnosticProxy
846
- ? await classifyProxyAuthDiagnostic(
847
- options.profile ?? defaultProfile,
848
- stalePoolDiagnosticProxy,
849
- )
2567
+ ? await classifyProxyAuthDiagnostic(requestProfile, stalePoolDiagnosticProxy)
850
2568
  : undefined;
851
2569
  if (proxyAuthDiagnostic === "source_ip_denied") {
852
2570
  throw createProxyAuthIpDeniedError(
@@ -873,8 +2591,38 @@ function createSessionFetcher(
873
2591
  break;
874
2592
  }
875
2593
 
2594
+ throwIfAmbientAborted(clientOptions.signal);
876
2595
  throw normalizeStealthTransportError(lastError);
877
2596
  },
2597
+ async replayChallenged(challengedResponse) {
2598
+ // Lease expiry is evaluated when a fetch starts, not here: the replay belongs to the
2599
+ // exchange that was challenged on the bound endpoint (Akamai state is IP-bound), so a
2600
+ // lifetime boundary crossed during the solve does not move it elsewhere. The next
2601
+ // fetch drops the expired binding and rebinds.
2602
+ const replay = challengedReplays.get(challengedResponse);
2603
+ if (!replay || challengedResponse.challenge?.outcome !== "replay_required") {
2604
+ throw new SDKError("The challenged response does not belong to this stealth session", {
2605
+ code: "REPLAY_SESSION_MISMATCH",
2606
+ });
2607
+ }
2608
+ syncCeremonyEgress();
2609
+ if (replay.generation !== currentEgressGeneration()) {
2610
+ // The ceremony rebound to another egress after this request ran (in this session or
2611
+ // another of the same client); solving and replaying on the retired endpoint would
2612
+ // split the identity the handle names.
2613
+ throw new SDKError(
2614
+ "The challenged response predates the ceremony's current egress binding",
2615
+ { code: "REPLAY_SESSION_MISMATCH" },
2616
+ );
2617
+ }
2618
+ if (replay.consumed) {
2619
+ throw new SDKError("The challenged request has already used its one replay budget", {
2620
+ code: "REPLAY_ALREADY_ATTEMPTED",
2621
+ });
2622
+ }
2623
+ replay.consumed = true;
2624
+ return replay.replay();
2625
+ },
878
2626
  cookies: cookieJar,
879
2627
  redirects: {
880
2628
  async run(options) {
@@ -882,25 +2630,102 @@ function createSessionFetcher(
882
2630
  options.maxHops === undefined || !Number.isFinite(options.maxHops)
883
2631
  ? 10
884
2632
  : Math.max(0, Math.floor(options.maxHops));
2633
+ const {
2634
+ url: _url,
2635
+ maxHops: _maxHops,
2636
+ stopWhen,
2637
+ params,
2638
+ sensitiveParams,
2639
+ headers: initialHeaders,
2640
+ ...fetchOptions
2641
+ } = options;
885
2642
  const hops: StealthRedirectHop[] = [];
886
- let currentUrl = resolveUrl(baseUrl, options.url);
887
2643
  let method = normalizeMethod(options.method ?? "GET");
888
2644
  let body = options.body;
2645
+ let headers = initialHeaders;
889
2646
  let response: StealthResponse | undefined;
890
2647
  const visitedRequests = new Set<string>();
891
-
892
- const { url: _url, maxHops: _maxHops, stopWhen, params, ...fetchOptions } = options;
2648
+ const initialParams = params
2649
+ ? Object.fromEntries(
2650
+ Object.entries(params).map(([key, value]) => [
2651
+ key,
2652
+ Array.isArray(value) ? [...value] : value,
2653
+ ]),
2654
+ )
2655
+ : undefined;
2656
+ const normalizedSensitiveParams = normalizeSensitiveParams(sensitiveParams);
2657
+ const initialSensitiveParams = normalizedSensitiveParams
2658
+ ? { ...normalizedSensitiveParams }
2659
+ : undefined;
2660
+ const sensitiveParamNames = initialSensitiveParams
2661
+ ? Object.keys(initialSensitiveParams)
2662
+ : [];
2663
+ const callerStructural = redactUrlQueryParams(options.url, sensitiveParamNames);
2664
+ const sensitiveValues = new Set(
2665
+ [
2666
+ ...Object.values(initialSensitiveParams ?? {}),
2667
+ ...callerStructural.sensitiveValues,
2668
+ ].filter((value) => value !== ""),
2669
+ );
2670
+ const redactRedirectUrl = (value: string): string => {
2671
+ const structural = redactUrlQueryParams(value, [
2672
+ ...new Set([...sensitiveParamNames, ...sensitiveQueryParamNames(value)]),
2673
+ ]);
2674
+ for (const sensitiveValue of structural.sensitiveValues) {
2675
+ sensitiveValues.add(sensitiveValue);
2676
+ }
2677
+ return redactSensitiveText(structural.redactedUrl, [...sensitiveValues]);
2678
+ };
2679
+ let currentUrl: string;
2680
+ let initialUrl: ReturnType<typeof serializeRequestUrl>;
2681
+ try {
2682
+ currentUrl = resolveUrl(baseUrl, options.url);
2683
+ redactRedirectUrl(currentUrl);
2684
+ initialUrl = serializeRequestUrl(currentUrl, initialParams, initialSensitiveParams);
2685
+ for (const value of initialUrl.sensitiveValues) {
2686
+ if (value !== "") sensitiveValues.add(value);
2687
+ }
2688
+ } catch (error) {
2689
+ throw redactSensitiveError(
2690
+ error,
2691
+ [...sensitiveValues],
2692
+ options.url,
2693
+ redactRedirectUrl(options.url),
2694
+ );
2695
+ }
893
2696
 
894
2697
  for (let hopIndex = 0; hopIndex <= maxHops; hopIndex += 1) {
895
- visitedRequests.add(`${method} ${currentUrl}`);
896
- response = await session.fetch(currentUrl, {
897
- ...fetchOptions,
898
- body,
899
- method,
900
- ...(hopIndex === 0 && params ? { params } : {}),
901
- redirect: "manual",
902
- throwOnHttpError: false,
903
- });
2698
+ const outboundUrl =
2699
+ hopIndex === 0 ? initialUrl.requestUrl : serializeRequestUrl(currentUrl).requestUrl;
2700
+ // Preserve params-only loop bookkeeping from before sensitiveParams:
2701
+ // the first visited key is the caller's resolved URL, not its expanded query.
2702
+ const visitedUrl = hopIndex === 0 && !initialSensitiveParams ? currentUrl : outboundUrl;
2703
+ visitedRequests.add(`${method} ${visitedUrl}`);
2704
+ try {
2705
+ response = await session.fetch(currentUrl, {
2706
+ ...fetchOptions,
2707
+ ...(headers ? { headers } : {}),
2708
+ body,
2709
+ method,
2710
+ ...(hopIndex === 0 && initialParams ? { params: initialParams } : {}),
2711
+ ...(hopIndex === 0 && initialSensitiveParams
2712
+ ? { sensitiveParams: initialSensitiveParams }
2713
+ : {}),
2714
+ redirect: "manual",
2715
+ throwOnHttpError: false,
2716
+ });
2717
+ } catch (error) {
2718
+ throw redactSensitiveError(
2719
+ error,
2720
+ [...sensitiveValues],
2721
+ outboundUrl,
2722
+ redactRedirectUrl(outboundUrl),
2723
+ );
2724
+ }
2725
+ // StealthResponse.url is programmatic metadata and remains raw. Only the
2726
+ // redirect hop emitted below is a diagnostic surface.
2727
+ const responseUrl =
2728
+ response.url ?? (hopIndex === 0 && initialSensitiveParams ? outboundUrl : currentUrl);
904
2729
 
905
2730
  if (!isRedirectStatus(response.status)) {
906
2731
  return {
@@ -908,63 +2733,90 @@ function createSessionFetcher(
908
2733
  hops,
909
2734
  reason: "completed",
910
2735
  cookies: cookieJar.snapshot(),
2736
+ cookieStore: cookieJar.serialize(),
911
2737
  };
912
2738
  }
913
2739
 
914
2740
  const location = locationHeader(response.headers);
915
- const nextUrl = location
916
- ? new URL(location, response.url ?? currentUrl).toString()
917
- : undefined;
918
- const hop: StealthRedirectHop = {
919
- url: response.url ?? currentUrl,
2741
+ const redactedResponseUrl = redactRedirectUrl(responseUrl);
2742
+ const redactedLocation = location ? redactRedirectUrl(location) : undefined;
2743
+ let nextUrl: string | undefined;
2744
+ try {
2745
+ nextUrl = resolveRedirectUrl(location, responseUrl);
2746
+ } catch (error) {
2747
+ throw redactSensitiveError(error, [...sensitiveValues], location, redactedLocation);
2748
+ }
2749
+ const realHop: StealthRedirectHop = {
2750
+ url: responseUrl,
920
2751
  status: response.status,
921
2752
  method,
922
2753
  ...(location ? { location } : {}),
923
2754
  ...(nextUrl ? { nextUrl } : {}),
924
2755
  };
2756
+ const hop: StealthRedirectHop = {
2757
+ ...realHop,
2758
+ url: redactedResponseUrl,
2759
+ ...(redactedLocation ? { location: redactedLocation } : {}),
2760
+ ...(nextUrl ? { nextUrl: redactRedirectUrl(nextUrl) } : {}),
2761
+ };
925
2762
  hops.push(hop);
926
2763
 
927
- if (stopWhen && (await stopWhen(hop))) {
928
- return {
929
- final: response,
930
- hops,
931
- reason: "stopped",
932
- cookies: cookieJar.snapshot(),
933
- };
2764
+ let shouldStop = false;
2765
+ if (stopWhen) {
2766
+ try {
2767
+ shouldStop = await stopWhen(realHop);
2768
+ } catch (error) {
2769
+ let sanitizedError: unknown = error;
2770
+ for (const [rawUrl, safeUrl] of [
2771
+ [responseUrl, redactedResponseUrl],
2772
+ [location, redactedLocation],
2773
+ [nextUrl, nextUrl ? redactRedirectUrl(nextUrl) : undefined],
2774
+ ] as const) {
2775
+ if (!rawUrl || !safeUrl) continue;
2776
+ sanitizedError = redactSensitiveError(
2777
+ sanitizedError,
2778
+ [...sensitiveValues],
2779
+ rawUrl,
2780
+ safeUrl,
2781
+ );
2782
+ }
2783
+ throw sanitizedError;
2784
+ }
934
2785
  }
935
-
936
- if (!nextUrl) {
2786
+ const decision = evaluateRedirectHop({
2787
+ status: response.status,
2788
+ method,
2789
+ nextUrl,
2790
+ shouldStop,
2791
+ redirectCount: hops.length,
2792
+ maxHops,
2793
+ visitedRequests,
2794
+ });
2795
+ if (decision.kind === "stop") {
937
2796
  return {
938
2797
  final: response,
939
2798
  hops,
940
- reason: "missing_location",
2799
+ reason: decision.reason,
941
2800
  cookies: cookieJar.snapshot(),
2801
+ cookieStore: cookieJar.serialize(),
942
2802
  };
943
2803
  }
944
-
945
- if (hops.length > maxHops) {
946
- return {
947
- final: response,
948
- hops,
949
- reason: "max_hops",
950
- cookies: cookieJar.snapshot(),
951
- };
2804
+ if (headers) {
2805
+ headers = redirectHopHeaders(
2806
+ {
2807
+ from: currentUrl,
2808
+ to: decision.nextUrl,
2809
+ methodChanged: decision.nextMethod !== method,
2810
+ },
2811
+ headers,
2812
+ );
952
2813
  }
953
-
954
- const nextMethod = nextRedirectMethod(response.status, method);
955
- if (nextMethod !== method) {
2814
+ if (decision.nextMethod !== method) {
956
2815
  body = undefined;
957
2816
  }
958
- if (visitedRequests.has(`${nextMethod} ${nextUrl}`)) {
959
- return {
960
- final: response,
961
- hops,
962
- reason: "loop",
963
- cookies: cookieJar.snapshot(),
964
- };
965
- }
966
- method = nextMethod;
967
- currentUrl = nextUrl;
2817
+ emitStealthTelemetry(clientOptions.stealthTelemetry, (sink) => sink.recordRedirectHop());
2818
+ method = decision.nextMethod;
2819
+ currentUrl = decision.nextUrl;
968
2820
  }
969
2821
 
970
2822
  if (!response) {
@@ -982,28 +2834,80 @@ function createSessionFetcher(
982
2834
  hops,
983
2835
  reason: "max_hops",
984
2836
  cookies: cookieJar.snapshot(),
2837
+ cookieStore: cookieJar.serialize(),
985
2838
  };
986
2839
  },
987
2840
  },
988
2841
  close() {
989
2842
  closed = true;
2843
+ clearAkamaiSbsdState();
2844
+ for (const client of clients.values()) {
2845
+ void client.session
2846
+ .then((session) => session.close())
2847
+ .catch((error: unknown) => {
2848
+ const message = error instanceof Error ? error.message : String(error);
2849
+ warn(`[provider-sdk] Failed to close stealth transport session: ${message}`);
2850
+ });
2851
+ }
990
2852
  clients.clear();
991
2853
  },
992
2854
  };
993
2855
  return session;
994
2856
 
995
2857
  async function classifyProxyAuthDiagnostic(
996
- profileName: string,
2858
+ profile: StealthProfileDescriptor,
997
2859
  proxy: string,
998
2860
  ): Promise<"source_ip_denied" | "edge_auth_rejected" | undefined> {
999
2861
  try {
1000
- const response = await getClient(profileName, proxy, false).fetch(PROXY_AUTH_DIAGNOSTIC_URL, {
1001
- method: "GET",
1002
- timeout: PROXY_AUTH_DIAGNOSTIC_TIMEOUT_MS,
1003
- });
1004
- const normalized = await normalizeResponse(response);
1005
- return classifyProxyAuthDiagnosticMessage(normalized.body);
2862
+ return await withClient(
2863
+ profile,
2864
+ proxy,
2865
+ false,
2866
+ async (client) => {
2867
+ throwIfAmbientAborted(clientOptions.signal);
2868
+ await client.clearCookies();
2869
+ throwIfAmbientAborted(clientOptions.signal);
2870
+ const finishExchange = startStealthExchange(
2871
+ clientOptions.stealthTelemetry
2872
+ ? {
2873
+ sink: clientOptions.stealthTelemetry,
2874
+ profileId: profile,
2875
+ proxyUsed: true,
2876
+ kind: "proxy_diagnostic",
2877
+ cookieJar,
2878
+ }
2879
+ : undefined,
2880
+ "GET",
2881
+ {},
2882
+ clientOptions.signal,
2883
+ );
2884
+ let status: number | undefined;
2885
+ let exchangeError: unknown;
2886
+ try {
2887
+ const response = await client.fetch(PROXY_AUTH_DIAGNOSTIC_URL, {
2888
+ method: "GET",
2889
+ timeout: PROXY_AUTH_DIAGNOSTIC_TIMEOUT_MS,
2890
+ ...(clientOptions.signal ? { signal: clientOptions.signal } : {}),
2891
+ });
2892
+ status = response.status;
2893
+ const normalized = await normalizeResponseWithSignal(
2894
+ response,
2895
+ undefined,
2896
+ undefined,
2897
+ clientOptions.signal,
2898
+ );
2899
+ return classifyProxyAuthDiagnosticMessage(normalized.body);
2900
+ } catch (error) {
2901
+ exchangeError = error;
2902
+ throw error;
2903
+ } finally {
2904
+ finishExchange(status, exchangeError);
2905
+ }
2906
+ },
2907
+ clientOptions.signal,
2908
+ );
1006
2909
  } catch (error) {
2910
+ throwIfAmbientAborted(clientOptions.signal);
1007
2911
  const message =
1008
2912
  error instanceof Error
1009
2913
  ? [error.message, error.cause instanceof Error ? error.cause.message : ""]
@@ -1042,18 +2946,17 @@ function hasHeader(headers: Record<string, string>, name: string): boolean {
1042
2946
 
1043
2947
  export function createStealthClient(
1044
2948
  baseUrl: string,
1045
- defaultProfileOrOptions: string | StealthClientOptions = DEFAULT_PROFILE,
1046
2949
  clientOptions: StealthClientOptions = {},
1047
2950
  ): StealthClient {
1048
- const defaultProfile =
1049
- typeof defaultProfileOrOptions === "string" ? defaultProfileOrOptions : DEFAULT_PROFILE;
1050
- const resolvedClientOptions =
1051
- typeof defaultProfileOrOptions === "string" ? clientOptions : defaultProfileOrOptions;
2951
+ if (typeof clientOptions === "string") {
2952
+ resolveStealthProfileSelection(clientOptions as unknown as StealthProfileSelection);
2953
+ }
2954
+ const defaultProfile = resolveStealthProfileSelection(clientOptions.stealth);
1052
2955
  let sharedSession: StealthSession | null = null;
1053
2956
 
1054
2957
  function getSharedSession(): StealthSession {
1055
2958
  if (!sharedSession) {
1056
- sharedSession = createSessionFetcher(baseUrl, defaultProfile, resolvedClientOptions);
2959
+ sharedSession = createSessionFetcher(baseUrl, defaultProfile, clientOptions);
1057
2960
  }
1058
2961
 
1059
2962
  return sharedSession;
@@ -1063,9 +2966,9 @@ export function createStealthClient(
1063
2966
  fetch(url: string, options?: StealthFetchOptions) {
1064
2967
  return getSharedSession().fetch(url, options);
1065
2968
  },
1066
- createSession(opts?: { profile?: string }) {
1067
- const sessionProfile = opts?.profile ?? defaultProfile;
1068
- return createSessionFetcher(baseUrl, sessionProfile, resolvedClientOptions);
2969
+ createSession(opts?: { stealth?: StealthProfileSelection }) {
2970
+ const sessionProfile = resolveStealthProfileSelection(opts?.stealth, defaultProfile);
2971
+ return createSessionFetcher(baseUrl, sessionProfile, clientOptions);
1069
2972
  },
1070
2973
  close() {
1071
2974
  sharedSession?.close();