@apifuse/provider-sdk 2.2.0-beta.11 → 2.2.0-beta.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AUTHORING.md CHANGED
@@ -517,6 +517,43 @@ const credentialsAuth = defineCredentialsAuth({
517
517
  `bunx playwright install chromium`, or set
518
518
  `APIFUSE__CDP_POOL__URL` for remote browser debugging.
519
519
 
520
+ ### Persisting stealth session cookies
521
+
522
+ Persist `session.cookies.serialize()` as JSON when an authenticated session must
523
+ survive a restart or move to another replica. The returned
524
+ `StealthCookieStoreV1` has an explicit version and retains every cookie together
525
+ with its Domain, Path, Secure, expiry, host-only, and other cookie attributes.
526
+ Restore it with `session.cookies.deserialize()`. Unsupported future versions
527
+ fail explicitly instead of being accepted as a partial cookie jar.
528
+
529
+ Credential values are strings, so stringify the store at the credential
530
+ boundary and parse it when rebuilding the session:
531
+
532
+ ```ts
533
+ // After login (including any redirects across sibling hosts):
534
+ const result = await session.redirects.run({ url: loginUrl });
535
+ return {
536
+ credential: {
537
+ cookieStore: JSON.stringify(result.cookieStore),
538
+ },
539
+ };
540
+
541
+ // In a later operation or replica:
542
+ const persisted = ctx.credential.get("cookieStore");
543
+ if (persisted) {
544
+ session.cookies.deserialize(JSON.parse(persisted));
545
+ }
546
+ ```
547
+
548
+ `snapshot()` and `restore()` remain only for backward compatibility with flat
549
+ `Record<string, string>` credentials. `snapshot()` enumerates cookies across all
550
+ hosts and paths, but the flat shape is inherently lossy: duplicate names
551
+ collapse and Domain, Path, Secure, expiry, and host-only attributes cannot be
552
+ represented. `restore()` therefore recreates host-only `Path=/` cookies on the
553
+ session base origin. Do not use the flat form for new persistence code. Cookie
554
+ headers remain origin-filtered: use `toHeader(url)` for a particular request and
555
+ never build a request header from serialized or snapshotted persistence data.
556
+
520
557
  ### Running the pre-submission report
521
558
 
522
559
  ```bash
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # @apifuse/provider-sdk Changelog
2
2
 
3
+ ## 2.2.0-beta.12
4
+
5
+ - Release candidate for main commit c66789c4745c72fc94ad3c10b3e0d7e5ed83fd25.
6
+
3
7
  ## 2.2.0-beta.11
4
8
 
5
9
  - Release candidate for main commit f6f739bd5265afe714bbace9900edc2695fcf826.
package/README.md CHANGED
@@ -146,6 +146,24 @@ the bad request path; provider/runtime failures include `code`, `message`, and
146
146
  `profile` such as `chrome-146`; do not tune JA3, HTTP/2 SETTINGS, or
147
147
  pseudo-header order in provider code. Chrome/Firefox-style profiles are
148
148
  supported; use `ctx.browser` when Safari-specific behavior is required.
149
+ - **Proxy URLs for non-stealth consumers**: use `resolveProxy()` when a
150
+ provider-owned client outside `ctx.stealth` needs the provider's proxy, such
151
+ as a CAPTCHA solver that must use matching egress. Pass the provider proxy
152
+ policy used by the provider and consume the returned `url`; the SDK owns
153
+ vendor selection, allocation, failover, and URL formats. Never call proxy
154
+ allocator APIs or hardcode proxy vendor hostnames in provider code.
155
+
156
+ ```ts
157
+ import { resolveProxy } from "@apifuse/provider-sdk"
158
+
159
+ const resolvedProxy = await resolveProxy({
160
+ proxyPolicy,
161
+ affinityKey: connectionId,
162
+ })
163
+
164
+ if (!resolvedProxy.url) throw new Error("This login requires proxy egress")
165
+ const captchaTask = { proxy: resolvedProxy.url }
166
+ ```
149
167
  - **Browser providers**: for TypeScript Providers use `runtime: "browser"` plus
150
168
  `browser.engine: "playwright-stealth"`; `nodriver` is a Python-runtime path.
151
169
  Install local browser assets with `bunx playwright install chromium` when
@@ -59,6 +59,20 @@ try {
59
59
  );
60
60
 
61
61
  run("bun", ["install"], consumerDir);
62
+ run(
63
+ "bun",
64
+ [
65
+ "--eval",
66
+ [
67
+ 'import { resolveProxy } from "@apifuse/provider-sdk";',
68
+ 'if (typeof resolveProxy !== "function") throw new Error("resolveProxy is not exported");',
69
+ 'const resolved = await resolveProxy({ proxy: "http://127.0.0.1:8080" });',
70
+ 'if (resolved.url !== "http://127.0.0.1:8080") throw new Error("resolveProxy returned the wrong URL");',
71
+ 'console.log("packed root resolveProxy export OK");',
72
+ ].join("\n"),
73
+ ],
74
+ consumerDir,
75
+ );
62
76
 
63
77
  const cliBin = join(consumerDir, "node_modules", ".bin", "apifuse");
64
78
  if (!existsSync(cliBin)) {
@@ -123,7 +123,8 @@ function setUpFixtureConsumer(consumerDir: string, tarballPath: string): void {
123
123
  writeFileSync(
124
124
  join(consumerDir, "consumer.ts"),
125
125
  [
126
- 'import { ProviderError, SessionExpiredError, z } from "@apifuse/provider-sdk";',
126
+ 'import { ProviderError, resolveProxy, SessionExpiredError, z } from "@apifuse/provider-sdk";',
127
+ 'import type { ProxyProtocol, ProxyResolutionOptions, ProxyResolutionSource, ProxyVendorName, ResolvedProxyConfig } from "@apifuse/provider-sdk";',
127
128
  'import { defineCredentialsAuth } from "@apifuse/provider-sdk/provider";',
128
129
  'import { extractProviderContract } from "@apifuse/provider-sdk/contract";',
129
130
  'import { AUTH_TURN_SCHEMA } from "@apifuse/provider-sdk/auth-turn";',
@@ -143,6 +144,11 @@ function setUpFixtureConsumer(consumerDir: string, tarballPath: string): void {
143
144
  "// Re-exported zod must keep .refine() callback parameter inference.",
144
145
  "const refined = z.object({ shopId: z.string() }).refine((value) => value.shopId.length > 0);",
145
146
  'const refinedString = z.string().refine((value) => value.startsWith("tabelog:"));',
147
+ 'const proxyOptions: ProxyResolutionOptions = { proxyPolicy: { mode: "disabled" } };',
148
+ 'const proxyProtocol: ProxyProtocol = "http";',
149
+ "const proxyResult: Promise<ResolvedProxyConfig> = resolveProxy(proxyOptions);",
150
+ 'const proxySource: ProxyResolutionSource = "smartproxy-allocator";',
151
+ 'const proxyVendor: ProxyVendorName = "smartproxy";',
146
152
  "",
147
153
  "export const witnesses = {",
148
154
  " inheritedName,",
@@ -152,6 +158,10 @@ function setUpFixtureConsumer(consumerDir: string, tarballPath: string): void {
152
158
  " sessionExpired,",
153
159
  " refined,",
154
160
  " refinedString,",
161
+ " proxyResult,",
162
+ " proxyProtocol,",
163
+ " proxySource,",
164
+ " proxyVendor,",
155
165
  " defineCredentialsAuth,",
156
166
  " extractProviderContract,",
157
167
  " AUTH_TURN_SCHEMA,",
@@ -112,10 +112,13 @@ export type ProxyTelemetrySink = {
112
112
  recordProxyAttempt?(event: ProxyAttemptTelemetryEvent): void;
113
113
  recordProxyVendorFailover?(event: ProxyVendorFailoverTelemetryEvent): void;
114
114
  };
115
+ export type ProxyResolutionSource = "explicit" | "env" | "config" | "smartproxy-allocator" | "nodemaven-gateway";
115
116
  export type ResolvedProxyConfig = {
116
117
  shouldWarn: boolean;
117
118
  url?: string;
118
- source?: "explicit" | "env" | "config" | "smartproxy-allocator" | "nodemaven-gateway";
119
+ /** SDK-native vendor that supplied the URL, when applicable. */
120
+ vendor?: ProxyVendorName;
121
+ source?: ProxyResolutionSource;
119
122
  protocol?: ProxyProtocol;
120
123
  diagnostics?: Record<string, string | number | boolean>;
121
124
  };
@@ -142,6 +145,11 @@ export declare function __setProxyRedisForTests(redis: ProxyRedisClient | undefi
142
145
  export declare function __setSmartproxyAllocatorDeadlineMsForTests(deadlineMs: number | undefined): void;
143
146
  export declare function resolveProxyConfig(options?: ProxyResolutionOptions): ResolvedProxyConfig;
144
147
  export declare function resolveProxyConfigAsync(options?: ProxyResolutionOptions): Promise<ResolvedProxyConfig>;
148
+ /**
149
+ * Resolve the proxy URL for a provider-owned consumer such as a CAPTCHA solver.
150
+ * Vendor allocation and failover remain owned by the SDK.
151
+ */
152
+ export declare function resolveProxy(options?: ProxyResolutionOptions): Promise<ResolvedProxyConfig>;
145
153
  /**
146
154
  * Guard the No-MITM invariant: a resolved proxy URL must use a tunnelling scheme
147
155
  * (http CONNECT or socks5) so the client TLS handshake reaches the origin
@@ -400,6 +400,15 @@ export async function resolveProxyConfigAsync(options = {}) {
400
400
  }
401
401
  return { shouldWarn: true };
402
402
  }
403
+ /**
404
+ * Resolve the proxy URL for a provider-owned consumer such as a CAPTCHA solver.
405
+ * Vendor allocation and failover remain owned by the SDK.
406
+ */
407
+ export async function resolveProxy(options = {}) {
408
+ const resolved = await resolveProxyConfigAsync(options);
409
+ const vendor = vendorFromResolvedSource(resolved.source);
410
+ return vendor ? { ...resolved, vendor } : resolved;
411
+ }
403
412
  /**
404
413
  * Each vendor's default egress protocol, chosen from live KR benchmarks. HTTP
405
414
  * CONNECT wins for nodemaven (socks5 adds ~500ms through the gateway) and ties
package/dist/errors.d.ts CHANGED
@@ -17,6 +17,11 @@ export declare class ProviderError extends Error {
17
17
  export declare class SDKError extends ProviderError {
18
18
  constructor(message: string, options?: ProviderErrorOptions);
19
19
  }
20
+ /** Raised when persisted stealth cookies use a store version this SDK cannot read. */
21
+ export declare class StealthCookieStoreVersionError extends SDKError {
22
+ readonly version: unknown;
23
+ constructor(version: unknown);
24
+ }
20
25
  export declare class AuthError extends ProviderError {
21
26
  constructor(message: string, options?: ProviderErrorOptions);
22
27
  }
package/dist/errors.js CHANGED
@@ -57,6 +57,21 @@ export class SDKError extends ProviderError {
57
57
  this.name = "SDKError";
58
58
  }
59
59
  }
60
+ /** Raised when persisted stealth cookies use a store version this SDK cannot read. */
61
+ export class StealthCookieStoreVersionError extends SDKError {
62
+ version;
63
+ constructor(version) {
64
+ const displayedVersion = typeof version === "string" || typeof version === "number"
65
+ ? String(version)
66
+ : "missing or invalid";
67
+ super(`Unsupported stealth cookie store version: ${displayedVersion}`, {
68
+ code: "unsupported_stealth_cookie_store_version",
69
+ details: { receivedVersion: version, supportedVersions: [1] },
70
+ });
71
+ this.version = version;
72
+ this.name = "StealthCookieStoreVersionError";
73
+ }
74
+ }
60
75
  export class AuthError extends ProviderError {
61
76
  constructor(message, options) {
62
77
  super(message, options);
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  export * from "./auth.js";
2
2
  export * from "./ceremonies/index.js";
3
3
  export * from "./choice-token.js";
4
- export type { ApiFuseConfig, BrowserConfig, ProxyConfig, SessionConfig, } from "./config/loader.js";
5
- export { defineConfig, loadApiFuseConfig } from "./config/loader.js";
4
+ export type { ApiFuseConfig, BrowserConfig, ProxyConfig, ProxyProtocol, ProxyResolutionOptions, ProxyResolutionSource, ProxyVendorName, ResolvedProxyConfig, SessionConfig, } from "./config/loader.js";
5
+ export { defineConfig, loadApiFuseConfig, resolveProxy } from "./config/loader.js";
6
6
  export { canonicalJson, digestProviderContract, extractProviderContract, type JsonPrimitive, type JsonValue, PROVIDER_CONTRACT_SCHEMA_VERSION, type ProviderContractOperation, type ProviderContractSnapshot, } from "./contract.js";
7
7
  export { centered, delayed, defineHealthJourney, defineOperation, defineProvider, defineSmsOtpMatcher, defineStreamOperation, every, type ProviderConfig, } from "./define.js";
8
8
  export type { DevServerOptions } from "./dev.js";
@@ -36,7 +36,7 @@ export { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_REDACTION_MARKER, APIFUSE_SEN
36
36
  export { createServerApp, type ServeOptions, serve } from "./server/index.js";
37
37
  export { getStealthProfile, listStealthProfiles } from "./stealth/profiles.js";
38
38
  export * from "./stream.js";
39
- export type { ApiFuseResponse, AuthConfig, AuthContext, AuthFlowDefinition, AuthFlowInputHandler, AuthFlowStartHandler, AuthMode, AuthTurn, Bcp47Locale, BrowserEngine, BrowserOptions, BrowserResourceBody, BrowserResourceDecision, BrowserResourceMethod, BrowserResourcePolicy, BrowserResourceRequest, BrowserResourceRoute, ConnectionMode, ContextDeclaration, CookieJar, ContextScratchpad, CredentialContext, CredentialDeclaration, E164PhoneNumber, EnvContext, FlowContext, FlowContextStore, HealthCheckAssertionContext, HealthCheckCase, HealthCheckCaseResult, HealthCheckSuite, HealthCheckUnsupported, HealthJourneyDefinition, HealthJourneyEventContext, HealthJourneyGatewayContext, HealthJourneyJournalContext, HealthJourneyManualTriggerPolicy, HealthJourneyRunContext, HealthJourneyRunResult, HealthJourneySchedule, HealthScheduleRandomization, HealthJourneySmsContext, HealthJourneyStep, HttpClient, HttpMethod, HttpResponse, HttpRetryOptions, HttpRetrySummary, HttpStreamResponse, IanaTimeZone, InferSchemaOutput, Iso3166Alpha2CountryCode, Iso4217CurrencyCode, Iso8601Duration, OperationAnnotations, OperationApprovalPolicy, OperationContractMetadata, OperationDefinition, OperationDeprecationMetadata, OperationDocMeta, OperationErrorCode, OperationHandlerResult, OperationInputExample, OperationLifecycle, OperationObservabilityConfig, OperationObservabilitySensitiveConfig, OperationRelationships, OperationRiskClass, OperationSensitivePath, OperationToolRouterMetadata, OperationTransport, OperationTransportKind, ProbeInterval, ProviderAccessConfig, ProviderAccessVisibility, ProviderCache, ProviderCacheGetOrSetOptions, ProviderCacheKeyOptions, ProviderCacheLookupMeta, ProviderCacheResponseMeta, ProviderCacheResult, ProviderChoiceBindingOptions, ProviderChoiceContext, ProviderChoiceIssueOptions, ProviderChoiceParseOptions, ProviderContext, ProviderDefinition, ProviderDeploymentOverrides, ProviderHealthMonitorConfig, ProviderHealthProbeConfig, ProviderLocale, ProviderLocaleKey, ProviderLocaleKeyInput, ProviderLogoProfile, ProviderLogoSource, ProviderMeta, ProviderProxyConfig, ProviderProxyMode, ProviderProxyPolicy, ProviderProxyProvider, ProviderProxySessionAffinity, ProviderPublicConnectionMode, ProviderPublicProfile, ProviderReviewed, ProviderRuntimeState, ProviderSecretDeclaration, ProviderStateDurationString, ProviderStateNamespace, ProviderStreamEvent, ProviderSttConfig, ProviderSttMode, ProviderSupportLevel, RequestOptions, Rfc3339Instant, SchemaLike, SmsOrigin, SmsOtpExtractionPattern, SmsOtpMatcherDefinition, SseMessage, StandardSchemaV1, StateCasResult, StateNamespaceOptions, StateValue, StateWriteOptions, StealthClient, StealthFetchOptions, StealthPlatform, StealthProfile, StealthRedirectHop, StealthRedirectRunOptions, StealthRedirectRunResult, StealthResponse, StealthSession, StealthSessionCookies, SttAudioInput, SttContext, SttPromptPolicy, SttSegment, SttTranscribeMode, SttTranscribeRequest, SttTranscript, SttUnsupportedOptionPolicy, SttUsage, SttVerificationCodeOptions, SttWarning, TraceConfig, TraceSpan, VerificationCodeCandidate, VerificationCodeCandidateSource, VerificationCodeExtractionResult, } from "./types.js";
39
+ export type { ApiFuseResponse, AuthConfig, AuthContext, AuthFlowDefinition, AuthFlowInputHandler, AuthFlowStartHandler, AuthMode, AuthTurn, Bcp47Locale, BrowserEngine, BrowserOptions, BrowserResourceBody, BrowserResourceDecision, BrowserResourceMethod, BrowserResourcePolicy, BrowserResourceRequest, BrowserResourceRoute, ConnectionMode, ContextDeclaration, CookieJar, ContextScratchpad, CredentialContext, CredentialDeclaration, E164PhoneNumber, EnvContext, FlowContext, FlowContextStore, HealthCheckAssertionContext, HealthCheckCase, HealthCheckCaseResult, HealthCheckSuite, HealthCheckUnsupported, HealthJourneyDefinition, HealthJourneyEventContext, HealthJourneyGatewayContext, HealthJourneyJournalContext, HealthJourneyManualTriggerPolicy, HealthJourneyRunContext, HealthJourneyRunResult, HealthJourneySchedule, HealthScheduleRandomization, HealthJourneySmsContext, HealthJourneyStep, HttpClient, HttpMethod, HttpResponse, HttpRetryOptions, HttpRetrySummary, HttpStreamResponse, IanaTimeZone, InferSchemaOutput, Iso3166Alpha2CountryCode, Iso4217CurrencyCode, Iso8601Duration, OperationAnnotations, OperationApprovalPolicy, OperationContractMetadata, OperationDefinition, OperationDeprecationMetadata, OperationDocMeta, OperationErrorCode, OperationHandlerResult, OperationInputExample, OperationLifecycle, OperationObservabilityConfig, OperationObservabilitySensitiveConfig, OperationRelationships, OperationRiskClass, OperationSensitivePath, OperationToolRouterMetadata, OperationTransport, OperationTransportKind, ProbeInterval, ProviderAccessConfig, ProviderAccessVisibility, ProviderCache, ProviderCacheGetOrSetOptions, ProviderCacheKeyOptions, ProviderCacheLookupMeta, ProviderCacheResponseMeta, ProviderCacheResult, ProviderChoiceBindingOptions, ProviderChoiceContext, ProviderChoiceIssueOptions, ProviderChoiceParseOptions, ProviderContext, ProviderDefinition, ProviderDeploymentOverrides, ProviderHealthMonitorConfig, ProviderHealthProbeConfig, ProviderLocale, ProviderLocaleKey, ProviderLocaleKeyInput, ProviderLogoProfile, ProviderLogoSource, ProviderMeta, ProviderProxyConfig, ProviderProxyMode, ProviderProxyPolicy, ProviderProxyProvider, ProviderProxySessionAffinity, ProviderPublicConnectionMode, ProviderPublicProfile, ProviderReviewed, ProviderRuntimeState, ProviderSecretDeclaration, ProviderStateDurationString, ProviderStateNamespace, ProviderStreamEvent, ProviderSttConfig, ProviderSttMode, ProviderSupportLevel, RequestOptions, Rfc3339Instant, SchemaLike, SmsOrigin, SmsOtpExtractionPattern, SmsOtpMatcherDefinition, SseMessage, StandardSchemaV1, StateCasResult, StateNamespaceOptions, StateValue, StateWriteOptions, StealthClient, StealthCookieStore, StealthCookieStoreV1, StealthFetchOptions, StealthPlatform, StealthProfile, StealthRedirectHop, StealthRedirectRunOptions, StealthRedirectRunResult, StealthResponse, StealthSession, StealthSessionCookies, SttAudioInput, SttContext, SttPromptPolicy, SttSegment, SttTranscribeMode, SttTranscribeRequest, SttTranscript, SttUnsupportedOptionPolicy, SttUsage, SttVerificationCodeOptions, SttWarning, TraceConfig, TraceSpan, VerificationCodeCandidate, VerificationCodeCandidateSource, VerificationCodeExtractionResult, } from "./types.js";
40
40
  export { DEFAULT_OPERATION_TRANSPORT, HttpRetryAfterPolicy, HttpRetryDelayStrategy, HttpRetryJitter, HttpRetryPreset, HttpRetryUnsafeMethodPolicy, PROBE_INTERVALS, STREAM_CHUNK_BYTES_MAX, STREAM_CHUNK_BYTES_MIN, STREAM_HEARTBEAT_MS_MAX, STREAM_HEARTBEAT_MS_MIN, STREAM_IDLE_TIMEOUT_MS_MAX, STREAM_IDLE_TIMEOUT_MS_MIN, STREAM_MAX_DURATION_MS_MAX, STREAM_MAX_DURATION_MS_MIN, } from "./types.js";
41
41
  export * from "./utils/date.js";
42
42
  export * from "./utils/parse.js";
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  export * from "./auth.js";
3
3
  export * from "./ceremonies/index.js";
4
4
  export * from "./choice-token.js";
5
- export { defineConfig, loadApiFuseConfig } from "./config/loader.js";
5
+ export { defineConfig, loadApiFuseConfig, resolveProxy } from "./config/loader.js";
6
6
  export { canonicalJson, digestProviderContract, extractProviderContract, PROVIDER_CONTRACT_SCHEMA_VERSION, } from "./contract.js";
7
7
  export { centered, delayed, defineHealthJourney, defineOperation, defineProvider, defineSmsOtpMatcher, defineStreamOperation, every, } from "./define.js";
8
8
  export { createDevServer, startDevServer } from "./dev.js";
@@ -1,7 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { Impit } from "impit";
3
- import { DEFAULT_SMARTPROXY_POOL_SIZE, invalidateProxyResolutionCacheAsync, policyResolvesRegistryVendorChain, ProxyResolutionError, resolvePolicyProxyPoolSpan, resolvePolicyTransportAttemptCap, resolveProxyConfigAsync, vendorFromResolvedSource, } from "../config/loader.js";
4
- import { SDKError, TransportError } from "../errors.js";
3
+ import { Cookie, CookieJar as ToughCookieJar } from "tough-cookie";
4
+ import { DEFAULT_SMARTPROXY_POOL_SIZE, invalidateProxyResolutionCacheAsync, ProxyResolutionError, policyResolvesRegistryVendorChain, resolvePolicyProxyPoolSpan, resolvePolicyTransportAttemptCap, resolveProxyConfigAsync, vendorFromResolvedSource, } from "../config/loader.js";
5
+ import { SDKError, StealthCookieStoreVersionError, TransportError } from "../errors.js";
5
6
  import { getStealthProfile } from "../stealth/profiles.js";
6
7
  import { createProxyAuthIpDeniedError, createProxyEdgeAuthRejectedError, createProxyEdgeTlsRejectedError, createProxyPoolExhaustedError, createProxyPoolStaleError, isProxyAuthIpDeniedMessage, isProxyEdgeAuthRejectedMessage, isProxyEdgeTlsRejectedResponse, isProxyPoolRefreshableError, isProxyPoolStaleMessage, isProxyPoolStaleStatus, PROXY_EDGE_AUTH_REJECTED_CODE, PROXY_POOL_STALE_CODE, } from "./proxy-errors.js";
7
8
  import { computeProxyAttemptIndex, computeProxyTransportRetryDelayMs, createDefaultProxyTransportRetryOptions, normalizeProxyTransportRetryOptions, shouldRetryProxyTransportAttempt, validateUnsafeProxyTransportRetryMethods, } from "./proxy-retry-policy.js";
@@ -49,69 +50,128 @@ const FIREFOX_IMPIT_BY_MAJOR = {
49
50
  function isRecord(value) {
50
51
  return typeof value === "object" && value !== null;
51
52
  }
53
+ const LEGACY_COOKIE_ORIGIN = "https://legacy-cookie.invalid/";
52
54
  class CookieJarImpl {
53
55
  cookies;
54
- constructor(cookieStrings) {
55
- this.cookies = {};
56
+ defaultUrl;
57
+ constructor(cookieStrings, defaultUrl = LEGACY_COOKIE_ORIGIN) {
58
+ this.cookies = new ToughCookieJar(undefined, {
59
+ allowSecureOnLocal: false,
60
+ rejectPublicSuffixes: true,
61
+ });
62
+ this.defaultUrl = this.normalizeUrl(defaultUrl) ?? LEGACY_COOKIE_ORIGIN;
56
63
  this.setFromCookieStrings(cookieStrings);
57
64
  }
58
- setFromCookieStrings(cookieStrings) {
65
+ /**
66
+ * URL-less legacy operations are scoped to this jar's default URL. Session
67
+ * jars use the client's base URL and response jars use the response URL. A
68
+ * flat restore has no attributes to recover, so it creates host-only Path=/
69
+ * cookies for that default URL instead of making them visible to every host.
70
+ */
71
+ setFromCookieStrings(cookieStrings, url = this.defaultUrl) {
72
+ const cookieUrl = this.normalizeUrl(url);
73
+ if (!cookieUrl)
74
+ return;
59
75
  for (const cookieString of cookieStrings) {
60
- const [nameValue] = cookieString.split(";");
61
- if (!nameValue) {
62
- continue;
63
- }
64
- const separatorIndex = nameValue.indexOf("=");
65
- if (separatorIndex === -1) {
66
- continue;
67
- }
68
- const name = nameValue.slice(0, separatorIndex).trim();
69
- const value = nameValue.slice(separatorIndex + 1).trim();
70
- if (name)
71
- this.cookies[name] = value;
76
+ this.cookies.setCookieSync(cookieString, cookieUrl, { ignoreError: true });
72
77
  }
73
78
  }
74
- get(name) {
75
- return this.cookies[name];
79
+ get(name, url) {
80
+ return this.getAll(url)[name];
76
81
  }
77
- getAll() {
78
- return { ...this.cookies };
82
+ getAll(url) {
83
+ return Object.fromEntries(this.getUniqueCookies(url ?? this.defaultUrl).map((cookie) => [cookie.key, cookie.value]));
79
84
  }
80
- has(name) {
81
- return Object.hasOwn(this.cookies, name);
85
+ has(name, url) {
86
+ return Object.hasOwn(this.getAll(url), name);
82
87
  }
83
- toString() {
84
- return Object.entries(this.cookies)
85
- .map(([name, value]) => `${name}=${value}`)
88
+ toString(url) {
89
+ return this.getUniqueCookies(url ?? this.defaultUrl)
90
+ .map((cookie) => cookie.cookieString())
86
91
  .join("; ");
87
92
  }
88
- toHeader() {
89
- return this.toString();
93
+ toHeader(url) {
94
+ return this.toString(url);
90
95
  }
91
96
  snapshot() {
92
- return this.getAll();
97
+ // This compatibility view deliberately enumerates the serialized store,
98
+ // not getAll(defaultUrl): persistence must include sibling hosts and paths.
99
+ // Duplicate names still collapse because a flat map cannot represent them.
100
+ const entries = [];
101
+ for (const cookie of this.serialize().jar.cookies) {
102
+ if (typeof cookie.key === "string" && typeof cookie.value === "string" && cookie.key) {
103
+ entries.push([cookie.key, cookie.value]);
104
+ }
105
+ }
106
+ return Object.fromEntries(entries);
93
107
  }
94
108
  restore(cookies) {
95
109
  this.clear();
96
110
  for (const [name, value] of Object.entries(cookies)) {
97
- if (name)
98
- this.cookies[name] = value;
111
+ if (!name)
112
+ continue;
113
+ this.cookies.setCookieSync(new Cookie({ key: name, path: "/", value }), this.defaultUrl, {
114
+ ignoreError: true,
115
+ });
99
116
  }
100
117
  }
101
- clear() {
102
- for (const name of Object.keys(this.cookies)) {
103
- delete this.cookies[name];
118
+ serialize() {
119
+ const jar = this.cookies.serializeSync();
120
+ if (!jar) {
121
+ throw new SDKError("Stealth cookie store could not be serialized", {
122
+ code: "stealth_cookie_store_serialize_failed",
123
+ });
104
124
  }
125
+ return { version: 1, jar };
105
126
  }
106
- find(predicate) {
107
- for (const [name, value] of Object.entries(this.cookies)) {
108
- const cookie = `${name}=${value}`;
109
- if (predicate(cookie)) {
110
- return cookie;
127
+ deserialize(state) {
128
+ const version = isRecord(state) ? state.version : undefined;
129
+ if (version !== 1) {
130
+ throw new StealthCookieStoreVersionError(version);
131
+ }
132
+ // Deserialize into a new jar first so invalid state cannot partially clear
133
+ // or replace a live session. tough-cookie restores the cookie attributes and
134
+ // matching semantics represented in its own serialized format.
135
+ const restored = ToughCookieJar.deserializeSync(state.jar);
136
+ // tough-cookie 6 does not include this option in serializeSync(). Preserve
137
+ // the SDK's stricter setting across restoration.
138
+ Reflect.set(restored, "allowSecureOnLocal", false);
139
+ this.cookies = restored;
140
+ }
141
+ clear() {
142
+ this.cookies.removeAllCookiesSync();
143
+ }
144
+ find(predicate, url) {
145
+ for (const cookie of this.getUniqueCookies(url ?? this.defaultUrl)) {
146
+ const cookieString = cookie.cookieString();
147
+ if (predicate(cookieString)) {
148
+ return cookieString;
111
149
  }
112
150
  }
113
151
  return undefined;
114
152
  }
153
+ normalizeUrl(url) {
154
+ try {
155
+ return new URL(url).toString();
156
+ }
157
+ catch {
158
+ return undefined;
159
+ }
160
+ }
161
+ getUniqueCookies(url) {
162
+ const cookieUrl = this.normalizeUrl(url);
163
+ if (!cookieUrl)
164
+ return [];
165
+ // tough-cookie returns longer (more-specific) paths first. Keeping the
166
+ // first cookie for each name prevents ambiguous duplicate-name headers.
167
+ const names = new Set();
168
+ return this.cookies.getCookiesSync(cookieUrl).filter((cookie) => {
169
+ if (names.has(cookie.key))
170
+ return false;
171
+ names.add(cookie.key);
172
+ return true;
173
+ });
174
+ }
115
175
  }
116
176
  function closestImpitBrowser(major, candidates) {
117
177
  let closestMajor;
@@ -175,13 +235,13 @@ function hasOwn(object, key) {
175
235
  }
176
236
  function toImpitCookieJar(cookieJar) {
177
237
  return {
178
- setCookie(cookie, _url, cb) {
179
- cookieJar.setFromCookieStrings([cookie]);
238
+ setCookie(cookie, url, cb) {
239
+ cookieJar.setFromCookieStrings([cookie], url);
180
240
  if (typeof cb === "function")
181
241
  cb();
182
242
  },
183
- getCookieString(_url) {
184
- return cookieJar.toString();
243
+ getCookieString(url) {
244
+ return cookieJar.toHeader(url);
185
245
  },
186
246
  };
187
247
  }
@@ -235,7 +295,7 @@ function splitCombinedSetCookieHeader(headerValue) {
235
295
  }
236
296
  export async function normalizeResponse(response, requestUrl) {
237
297
  const headers = Object.fromEntries(response.headers.entries());
238
- const cookies = new CookieJarImpl(setCookieHeadersFromResponse(response.headers));
298
+ const cookies = new CookieJarImpl(setCookieHeadersFromResponse(response.headers), response.url ?? requestUrl);
239
299
  const bodyBytes = await response.arrayBuffer();
240
300
  const body = new TextDecoder().decode(bodyBytes);
241
301
  return {
@@ -441,7 +501,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
441
501
  let closed = false;
442
502
  let hasWarnedMissingProxy = false;
443
503
  const warn = clientOptions.warn ?? console.warn;
444
- const cookieJar = new CookieJarImpl([]);
504
+ const cookieJar = new CookieJarImpl([], baseUrl);
445
505
  const impitCookieJar = toImpitCookieJar(cookieJar);
446
506
  function getClient(profileName, proxyUrl, ignoreTlsErrors) {
447
507
  if (closed) {
@@ -580,7 +640,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
580
640
  const requestUrl = appendQueryParams(resolveUrl(baseUrl, url), options.params);
581
641
  const headers = { ...(options.headers ?? {}) };
582
642
  if (!hasHeader(headers, "Cookie")) {
583
- const cookieHeader = cookieJar.toString();
643
+ const cookieHeader = cookieJar.toHeader(requestUrl);
584
644
  if (cookieHeader)
585
645
  headers.Cookie = cookieHeader;
586
646
  }
@@ -595,7 +655,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
595
655
  }
596
656
  const response = await getClient(profileName, proxy, ignoreTlsErrors).fetch(requestUrl, requestInit);
597
657
  const normalized = await normalizeResponse(response, requestUrl);
598
- cookieJar.setFromCookieStrings(setCookieHeadersFromResponse(response.headers));
658
+ cookieJar.setFromCookieStrings(setCookieHeadersFromResponse(response.headers), response.url ?? requestUrl);
599
659
  if (proxy && isProxyConnectFailureResponse(response, normalized.body)) {
600
660
  throw createProxyConnectFailureError(normalized.body);
601
661
  }
@@ -735,6 +795,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
735
795
  hops,
736
796
  reason: "completed",
737
797
  cookies: cookieJar.snapshot(),
798
+ cookieStore: cookieJar.serialize(),
738
799
  };
739
800
  }
740
801
  const location = locationHeader(response.headers);
@@ -755,6 +816,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
755
816
  hops,
756
817
  reason: "stopped",
757
818
  cookies: cookieJar.snapshot(),
819
+ cookieStore: cookieJar.serialize(),
758
820
  };
759
821
  }
760
822
  if (!nextUrl) {
@@ -763,6 +825,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
763
825
  hops,
764
826
  reason: "missing_location",
765
827
  cookies: cookieJar.snapshot(),
828
+ cookieStore: cookieJar.serialize(),
766
829
  };
767
830
  }
768
831
  if (hops.length > maxHops) {
@@ -771,6 +834,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
771
834
  hops,
772
835
  reason: "max_hops",
773
836
  cookies: cookieJar.snapshot(),
837
+ cookieStore: cookieJar.serialize(),
774
838
  };
775
839
  }
776
840
  const nextMethod = nextRedirectMethod(response.status, method);
@@ -783,6 +847,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
783
847
  hops,
784
848
  reason: "loop",
785
849
  cookies: cookieJar.snapshot(),
850
+ cookieStore: cookieJar.serialize(),
786
851
  };
787
852
  }
788
853
  method = nextMethod;
@@ -803,6 +868,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
803
868
  hops,
804
869
  reason: "max_hops",
805
870
  cookies: cookieJar.snapshot(),
871
+ cookieStore: cookieJar.serialize(),
806
872
  };
807
873
  },
808
874
  },
package/dist/types.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type ms from "ms";
2
+ import type { SerializedCookieJar } from "tough-cookie";
2
3
  import type { infer as ZodInfer, ZodType } from "zod";
3
4
  /** Minimal Standard Schema v1 shape accepted by provider operations. */
4
5
  export interface StandardSchemaV1<Input = unknown, Output = Input> {
@@ -872,17 +873,42 @@ export interface StealthFetchOptions extends RequestOptions {
872
873
  };
873
874
  }
874
875
  export interface CookieJar {
875
- get(name: string): string | undefined;
876
- getAll(): Record<string, string>;
877
- toString(): string;
878
- find?(predicate: (cookie: string) => boolean): string | undefined;
876
+ /** URL-less reads use the jar's response URL or session base URL. */
877
+ get(name: string, url?: string): string | undefined;
878
+ getAll(url?: string): Record<string, string>;
879
+ toString(url?: string): string;
880
+ find?(predicate: (cookie: string) => boolean, url?: string): string | undefined;
879
881
  }
882
+ /**
883
+ * Version 1 of the JSON-safe, attribute-preserving stealth cookie store.
884
+ * The nested jar is tough-cookie's serialized form and retains cookie origin,
885
+ * Path, Secure, expiry, host-only, and other RFC attributes.
886
+ */
887
+ export interface StealthCookieStoreV1 {
888
+ readonly version: 1;
889
+ readonly jar: SerializedCookieJar;
890
+ }
891
+ /** Cookie persistence formats understood by this SDK version. */
892
+ export type StealthCookieStore = StealthCookieStoreV1;
880
893
  export interface StealthSessionCookies extends CookieJar {
881
- has(name: string): boolean;
882
- setFromCookieStrings(cookieStrings: readonly string[]): void;
883
- toHeader(): string;
894
+ has(name: string, url?: string): boolean;
895
+ /** URL-less writes are scoped to the session base URL. */
896
+ setFromCookieStrings(cookieStrings: readonly string[], url?: string): void;
897
+ toHeader(url?: string): string;
898
+ /**
899
+ * Returns every cookie as a flat name/value map, collapsing duplicate names.
900
+ * @deprecated Use serialize() for lossless, attribute-preserving persistence.
901
+ */
884
902
  snapshot(): Record<string, string>;
903
+ /**
904
+ * Restores flat values as host-only, Path=/ cookies on the session base URL.
905
+ * @deprecated Use deserialize() with state produced by serialize().
906
+ */
885
907
  restore(cookies: Record<string, string>): void;
908
+ /** Returns a versioned, JSON-safe, attribute-preserving representation of every cookie. */
909
+ serialize(): StealthCookieStoreV1;
910
+ /** Replaces the jar with a previously serialized, attribute-preserving cookie store. */
911
+ deserialize(state: StealthCookieStore): void;
886
912
  clear(): void;
887
913
  }
888
914
  export interface DeclarativeStealthResponse {
@@ -925,7 +951,13 @@ export interface StealthRedirectRunResult {
925
951
  final: StealthResponse;
926
952
  hops: StealthRedirectHop[];
927
953
  reason: "completed" | "stopped" | "max_hops" | "missing_location" | "loop";
954
+ /**
955
+ * Complete flat view across all redirect hosts. Attributes and duplicate names are lost.
956
+ * @deprecated Use cookieStore for lossless persistence.
957
+ */
928
958
  cookies: Record<string, string>;
959
+ /** Versioned, attribute-preserving cookie state accumulated across the redirect chain. */
960
+ cookieStore: StealthCookieStoreV1;
929
961
  }
930
962
  export interface StealthSession {
931
963
  fetch(url: string, options?: StealthFetchOptions): Promise<StealthResponse>;
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0-beta.11",
2
+ "version": "2.2.0-beta.12",
3
3
  "name": "@apifuse/provider-sdk",
4
4
  "private": false,
5
5
  "type": "module",
@@ -114,6 +114,7 @@
114
114
  "puppeteer-extra-plugin-stealth": "^2.11.2",
115
115
  "re2-wasm": "^1.0",
116
116
  "safe-regex": "^2.1",
117
+ "tough-cookie": "^6.0.2",
117
118
  "zod": "^4.4.3"
118
119
  },
119
120
  "repository": {
@@ -156,10 +156,19 @@ export type ProxyTelemetrySink = {
156
156
  recordProxyVendorFailover?(event: ProxyVendorFailoverTelemetryEvent): void;
157
157
  };
158
158
 
159
+ export type ProxyResolutionSource =
160
+ | "explicit"
161
+ | "env"
162
+ | "config"
163
+ | "smartproxy-allocator"
164
+ | "nodemaven-gateway";
165
+
159
166
  export type ResolvedProxyConfig = {
160
167
  shouldWarn: boolean;
161
168
  url?: string;
162
- source?: "explicit" | "env" | "config" | "smartproxy-allocator" | "nodemaven-gateway";
169
+ /** SDK-native vendor that supplied the URL, when applicable. */
170
+ vendor?: ProxyVendorName;
171
+ source?: ProxyResolutionSource;
163
172
  protocol?: ProxyProtocol;
164
173
  diagnostics?: Record<string, string | number | boolean>;
165
174
  };
@@ -656,6 +665,18 @@ export async function resolveProxyConfigAsync(
656
665
  return { shouldWarn: true };
657
666
  }
658
667
 
668
+ /**
669
+ * Resolve the proxy URL for a provider-owned consumer such as a CAPTCHA solver.
670
+ * Vendor allocation and failover remain owned by the SDK.
671
+ */
672
+ export async function resolveProxy(
673
+ options: ProxyResolutionOptions = {},
674
+ ): Promise<ResolvedProxyConfig> {
675
+ const resolved = await resolveProxyConfigAsync(options);
676
+ const vendor = vendorFromResolvedSource(resolved.source);
677
+ return vendor ? { ...resolved, vendor } : resolved;
678
+ }
679
+
659
680
  /**
660
681
  * Each vendor's default egress protocol, chosen from live KR benchmarks. HTTP
661
682
  * CONNECT wins for nodemaven (socks5 adds ~500ms through the gateway) and ties
package/src/errors.ts CHANGED
@@ -79,6 +79,21 @@ export class SDKError extends ProviderError {
79
79
  }
80
80
  }
81
81
 
82
+ /** Raised when persisted stealth cookies use a store version this SDK cannot read. */
83
+ export class StealthCookieStoreVersionError extends SDKError {
84
+ constructor(public readonly version: unknown) {
85
+ const displayedVersion =
86
+ typeof version === "string" || typeof version === "number"
87
+ ? String(version)
88
+ : "missing or invalid";
89
+ super(`Unsupported stealth cookie store version: ${displayedVersion}`, {
90
+ code: "unsupported_stealth_cookie_store_version",
91
+ details: { receivedVersion: version, supportedVersions: [1] },
92
+ });
93
+ this.name = "StealthCookieStoreVersionError";
94
+ }
95
+ }
96
+
82
97
  export class AuthError extends ProviderError {
83
98
  constructor(message: string, options?: ProviderErrorOptions) {
84
99
  super(message, options);
package/src/index.ts CHANGED
@@ -7,9 +7,14 @@ export type {
7
7
  ApiFuseConfig,
8
8
  BrowserConfig,
9
9
  ProxyConfig,
10
+ ProxyProtocol,
11
+ ProxyResolutionOptions,
12
+ ProxyResolutionSource,
13
+ ProxyVendorName,
14
+ ResolvedProxyConfig,
10
15
  SessionConfig,
11
16
  } from "./config/loader.js";
12
- export { defineConfig, loadApiFuseConfig } from "./config/loader.js";
17
+ export { defineConfig, loadApiFuseConfig, resolveProxy } from "./config/loader.js";
13
18
  export {
14
19
  canonicalJson,
15
20
  digestProviderContract,
@@ -245,6 +250,8 @@ export type {
245
250
  StateValue,
246
251
  StateWriteOptions,
247
252
  StealthClient,
253
+ StealthCookieStore,
254
+ StealthCookieStoreV1,
248
255
  StealthFetchOptions,
249
256
  StealthPlatform,
250
257
  StealthProfile,
@@ -1,24 +1,27 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { Browser, ImpitOptions, ImpitResponse, RequestInit } from "impit";
3
3
  import { Impit } from "impit";
4
+ import { Cookie, CookieJar as ToughCookieJar } from "tough-cookie";
4
5
 
5
6
  import type { ProxyResolutionOptions, ProxyVendorName } from "../config/loader.js";
6
7
  import {
7
8
  DEFAULT_SMARTPROXY_POOL_SIZE,
8
9
  invalidateProxyResolutionCacheAsync,
9
- policyResolvesRegistryVendorChain,
10
10
  ProxyResolutionError,
11
+ policyResolvesRegistryVendorChain,
11
12
  resolvePolicyProxyPoolSpan,
12
13
  resolvePolicyTransportAttemptCap,
13
14
  resolveProxyConfigAsync,
14
15
  vendorFromResolvedSource,
15
16
  } from "../config/loader.js";
16
- import { SDKError, TransportError } from "../errors.js";
17
+ import { SDKError, StealthCookieStoreVersionError, TransportError } from "../errors.js";
17
18
  import { getStealthProfile } from "../stealth/profiles.js";
18
19
  import type {
19
20
  CookieJar,
20
21
  HttpMethod,
21
22
  StealthClient,
23
+ StealthCookieStore,
24
+ StealthCookieStoreV1,
22
25
  StealthFetchOptions,
23
26
  StealthRedirectHop,
24
27
  StealthResponse,
@@ -127,81 +130,145 @@ function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
127
130
  return typeof value === "object" && value !== null;
128
131
  }
129
132
 
133
+ const LEGACY_COOKIE_ORIGIN = "https://legacy-cookie.invalid/";
134
+
130
135
  class CookieJarImpl implements CookieJar {
131
- private readonly cookies: Record<string, string>;
136
+ private cookies: ToughCookieJar;
137
+ private readonly defaultUrl: string;
132
138
 
133
- constructor(cookieStrings: string[]) {
134
- this.cookies = {};
139
+ constructor(cookieStrings: readonly string[], defaultUrl = LEGACY_COOKIE_ORIGIN) {
140
+ this.cookies = new ToughCookieJar(undefined, {
141
+ allowSecureOnLocal: false,
142
+ rejectPublicSuffixes: true,
143
+ });
144
+ this.defaultUrl = this.normalizeUrl(defaultUrl) ?? LEGACY_COOKIE_ORIGIN;
135
145
  this.setFromCookieStrings(cookieStrings);
136
146
  }
137
147
 
138
- setFromCookieStrings(cookieStrings: readonly string[]): void {
139
- for (const cookieString of cookieStrings) {
140
- const [nameValue] = cookieString.split(";");
141
- if (!nameValue) {
142
- continue;
143
- }
144
-
145
- const separatorIndex = nameValue.indexOf("=");
146
- if (separatorIndex === -1) {
147
- continue;
148
- }
148
+ /**
149
+ * URL-less legacy operations are scoped to this jar's default URL. Session
150
+ * jars use the client's base URL and response jars use the response URL. A
151
+ * flat restore has no attributes to recover, so it creates host-only Path=/
152
+ * cookies for that default URL instead of making them visible to every host.
153
+ */
154
+ setFromCookieStrings(cookieStrings: readonly string[], url = this.defaultUrl): void {
155
+ const cookieUrl = this.normalizeUrl(url);
156
+ if (!cookieUrl) return;
149
157
 
150
- const name = nameValue.slice(0, separatorIndex).trim();
151
- const value = nameValue.slice(separatorIndex + 1).trim();
152
- if (name) this.cookies[name] = value;
158
+ for (const cookieString of cookieStrings) {
159
+ this.cookies.setCookieSync(cookieString, cookieUrl, { ignoreError: true });
153
160
  }
154
161
  }
155
162
 
156
- get(name: string): string | undefined {
157
- return this.cookies[name];
163
+ get(name: string, url?: string): string | undefined {
164
+ return this.getAll(url)[name];
158
165
  }
159
166
 
160
- getAll(): Record<string, string> {
161
- return { ...this.cookies };
167
+ getAll(url?: string): Record<string, string> {
168
+ return Object.fromEntries(
169
+ this.getUniqueCookies(url ?? this.defaultUrl).map((cookie) => [cookie.key, cookie.value]),
170
+ );
162
171
  }
163
172
 
164
- has(name: string): boolean {
165
- return Object.hasOwn(this.cookies, name);
173
+ has(name: string, url?: string): boolean {
174
+ return Object.hasOwn(this.getAll(url), name);
166
175
  }
167
176
 
168
- toString(): string {
169
- return Object.entries(this.cookies)
170
- .map(([name, value]) => `${name}=${value}`)
177
+ toString(url?: string): string {
178
+ return this.getUniqueCookies(url ?? this.defaultUrl)
179
+ .map((cookie) => cookie.cookieString())
171
180
  .join("; ");
172
181
  }
173
182
 
174
- toHeader(): string {
175
- return this.toString();
183
+ toHeader(url?: string): string {
184
+ return this.toString(url);
176
185
  }
177
186
 
178
187
  snapshot(): Record<string, string> {
179
- return this.getAll();
188
+ // This compatibility view deliberately enumerates the serialized store,
189
+ // not getAll(defaultUrl): persistence must include sibling hosts and paths.
190
+ // Duplicate names still collapse because a flat map cannot represent them.
191
+ const entries: [string, string][] = [];
192
+ for (const cookie of this.serialize().jar.cookies) {
193
+ if (typeof cookie.key === "string" && typeof cookie.value === "string" && cookie.key) {
194
+ entries.push([cookie.key, cookie.value]);
195
+ }
196
+ }
197
+ return Object.fromEntries(entries);
180
198
  }
181
199
 
182
200
  restore(cookies: Record<string, string>): void {
183
201
  this.clear();
184
202
  for (const [name, value] of Object.entries(cookies)) {
185
- if (name) this.cookies[name] = value;
203
+ if (!name) continue;
204
+ this.cookies.setCookieSync(new Cookie({ key: name, path: "/", value }), this.defaultUrl, {
205
+ ignoreError: true,
206
+ });
186
207
  }
187
208
  }
188
209
 
189
- clear(): void {
190
- for (const name of Object.keys(this.cookies)) {
191
- delete this.cookies[name];
210
+ serialize(): StealthCookieStoreV1 {
211
+ const jar = this.cookies.serializeSync();
212
+ if (!jar) {
213
+ throw new SDKError("Stealth cookie store could not be serialized", {
214
+ code: "stealth_cookie_store_serialize_failed",
215
+ });
192
216
  }
217
+ return { version: 1, jar };
193
218
  }
194
219
 
195
- find(predicate: (cookie: string) => boolean): string | undefined {
196
- for (const [name, value] of Object.entries(this.cookies)) {
197
- const cookie = `${name}=${value}`;
198
- if (predicate(cookie)) {
199
- return cookie;
220
+ deserialize(state: StealthCookieStore): void {
221
+ const version = isRecord(state) ? state.version : undefined;
222
+ if (version !== 1) {
223
+ throw new StealthCookieStoreVersionError(version);
224
+ }
225
+
226
+ // Deserialize into a new jar first so invalid state cannot partially clear
227
+ // or replace a live session. tough-cookie restores the cookie attributes and
228
+ // matching semantics represented in its own serialized format.
229
+ const restored = ToughCookieJar.deserializeSync(state.jar);
230
+ // tough-cookie 6 does not include this option in serializeSync(). Preserve
231
+ // the SDK's stricter setting across restoration.
232
+ Reflect.set(restored, "allowSecureOnLocal", false);
233
+ this.cookies = restored;
234
+ }
235
+
236
+ clear(): void {
237
+ this.cookies.removeAllCookiesSync();
238
+ }
239
+
240
+ find(predicate: (cookie: string) => boolean, url?: string): string | undefined {
241
+ for (const cookie of this.getUniqueCookies(url ?? this.defaultUrl)) {
242
+ const cookieString = cookie.cookieString();
243
+ if (predicate(cookieString)) {
244
+ return cookieString;
200
245
  }
201
246
  }
202
247
 
203
248
  return undefined;
204
249
  }
250
+
251
+ private normalizeUrl(url: string): string | undefined {
252
+ try {
253
+ return new URL(url).toString();
254
+ } catch {
255
+ return undefined;
256
+ }
257
+ }
258
+
259
+ private getUniqueCookies(url: string): Cookie[] {
260
+ const cookieUrl = this.normalizeUrl(url);
261
+ if (!cookieUrl) return [];
262
+
263
+ // tough-cookie returns longer (more-specific) paths first. Keeping the
264
+ // first cookie for each name prevents ambiguous duplicate-name headers.
265
+ const names = new Set<string>();
266
+ return this.cookies.getCookiesSync(cookieUrl).filter((cookie) => {
267
+ if (names.has(cookie.key)) return false;
268
+ names.add(cookie.key);
269
+ return true;
270
+ });
271
+ }
205
272
  }
206
273
 
207
274
  function closestImpitBrowser(
@@ -282,12 +349,12 @@ function hasOwn(object: object, key: string): boolean {
282
349
  }
283
350
  function toImpitCookieJar(cookieJar: CookieJarImpl): NonNullable<ImpitOptions["cookieJar"]> {
284
351
  return {
285
- setCookie(cookie: string, _url: string, cb?: (error?: unknown) => void) {
286
- cookieJar.setFromCookieStrings([cookie]);
352
+ setCookie(cookie: string, url: string, cb?: (error?: unknown) => void) {
353
+ cookieJar.setFromCookieStrings([cookie], url);
287
354
  if (typeof cb === "function") cb();
288
355
  },
289
- getCookieString(_url: string) {
290
- return cookieJar.toString();
356
+ getCookieString(url: string) {
357
+ return cookieJar.toHeader(url);
291
358
  },
292
359
  };
293
360
  }
@@ -341,7 +408,10 @@ export async function normalizeResponse(
341
408
  requestUrl?: string,
342
409
  ): Promise<StealthResponse> {
343
410
  const headers = Object.fromEntries(response.headers.entries());
344
- const cookies = new CookieJarImpl(setCookieHeadersFromResponse(response.headers));
411
+ const cookies = new CookieJarImpl(
412
+ setCookieHeadersFromResponse(response.headers),
413
+ response.url ?? requestUrl,
414
+ );
345
415
  const bodyBytes = await response.arrayBuffer();
346
416
  const body = new TextDecoder().decode(bodyBytes);
347
417
 
@@ -588,7 +658,7 @@ function createSessionFetcher(
588
658
  let closed = false;
589
659
  let hasWarnedMissingProxy = false;
590
660
  const warn = clientOptions.warn ?? console.warn;
591
- const cookieJar = new CookieJarImpl([]);
661
+ const cookieJar = new CookieJarImpl([], baseUrl);
592
662
  const impitCookieJar = toImpitCookieJar(cookieJar);
593
663
 
594
664
  function getClient(
@@ -757,7 +827,7 @@ function createSessionFetcher(
757
827
  const requestUrl = appendQueryParams(resolveUrl(baseUrl, url), options.params);
758
828
  const headers = { ...(options.headers ?? {}) };
759
829
  if (!hasHeader(headers, "Cookie")) {
760
- const cookieHeader = cookieJar.toString();
830
+ const cookieHeader = cookieJar.toHeader(requestUrl);
761
831
  if (cookieHeader) headers.Cookie = cookieHeader;
762
832
  }
763
833
  const requestInit: StealthRequestInit = {
@@ -774,7 +844,10 @@ function createSessionFetcher(
774
844
  requestInit,
775
845
  );
776
846
  const normalized = await normalizeResponse(response, requestUrl);
777
- cookieJar.setFromCookieStrings(setCookieHeadersFromResponse(response.headers));
847
+ cookieJar.setFromCookieStrings(
848
+ setCookieHeadersFromResponse(response.headers),
849
+ response.url ?? requestUrl,
850
+ );
778
851
 
779
852
  if (proxy && isProxyConnectFailureResponse(response, normalized.body)) {
780
853
  throw createProxyConnectFailureError(normalized.body);
@@ -954,6 +1027,7 @@ function createSessionFetcher(
954
1027
  hops,
955
1028
  reason: "completed",
956
1029
  cookies: cookieJar.snapshot(),
1030
+ cookieStore: cookieJar.serialize(),
957
1031
  };
958
1032
  }
959
1033
 
@@ -976,6 +1050,7 @@ function createSessionFetcher(
976
1050
  hops,
977
1051
  reason: "stopped",
978
1052
  cookies: cookieJar.snapshot(),
1053
+ cookieStore: cookieJar.serialize(),
979
1054
  };
980
1055
  }
981
1056
 
@@ -985,6 +1060,7 @@ function createSessionFetcher(
985
1060
  hops,
986
1061
  reason: "missing_location",
987
1062
  cookies: cookieJar.snapshot(),
1063
+ cookieStore: cookieJar.serialize(),
988
1064
  };
989
1065
  }
990
1066
 
@@ -994,6 +1070,7 @@ function createSessionFetcher(
994
1070
  hops,
995
1071
  reason: "max_hops",
996
1072
  cookies: cookieJar.snapshot(),
1073
+ cookieStore: cookieJar.serialize(),
997
1074
  };
998
1075
  }
999
1076
 
@@ -1007,6 +1084,7 @@ function createSessionFetcher(
1007
1084
  hops,
1008
1085
  reason: "loop",
1009
1086
  cookies: cookieJar.snapshot(),
1087
+ cookieStore: cookieJar.serialize(),
1010
1088
  };
1011
1089
  }
1012
1090
  method = nextMethod;
@@ -1028,6 +1106,7 @@ function createSessionFetcher(
1028
1106
  hops,
1029
1107
  reason: "max_hops",
1030
1108
  cookies: cookieJar.snapshot(),
1109
+ cookieStore: cookieJar.serialize(),
1031
1110
  };
1032
1111
  },
1033
1112
  },
package/src/types.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type ms from "ms";
2
+ import type { SerializedCookieJar } from "tough-cookie";
2
3
 
3
4
  import type { infer as ZodInfer, ZodType } from "zod";
4
5
 
@@ -1051,18 +1052,45 @@ export interface StealthFetchOptions extends RequestOptions {
1051
1052
  }
1052
1053
 
1053
1054
  export interface CookieJar {
1054
- get(name: string): string | undefined;
1055
- getAll(): Record<string, string>;
1056
- toString(): string;
1057
- find?(predicate: (cookie: string) => boolean): string | undefined;
1055
+ /** URL-less reads use the jar's response URL or session base URL. */
1056
+ get(name: string, url?: string): string | undefined;
1057
+ getAll(url?: string): Record<string, string>;
1058
+ toString(url?: string): string;
1059
+ find?(predicate: (cookie: string) => boolean, url?: string): string | undefined;
1060
+ }
1061
+
1062
+ /**
1063
+ * Version 1 of the JSON-safe, attribute-preserving stealth cookie store.
1064
+ * The nested jar is tough-cookie's serialized form and retains cookie origin,
1065
+ * Path, Secure, expiry, host-only, and other RFC attributes.
1066
+ */
1067
+ export interface StealthCookieStoreV1 {
1068
+ readonly version: 1;
1069
+ readonly jar: SerializedCookieJar;
1058
1070
  }
1059
1071
 
1072
+ /** Cookie persistence formats understood by this SDK version. */
1073
+ export type StealthCookieStore = StealthCookieStoreV1;
1074
+
1060
1075
  export interface StealthSessionCookies extends CookieJar {
1061
- has(name: string): boolean;
1062
- setFromCookieStrings(cookieStrings: readonly string[]): void;
1063
- toHeader(): string;
1076
+ has(name: string, url?: string): boolean;
1077
+ /** URL-less writes are scoped to the session base URL. */
1078
+ setFromCookieStrings(cookieStrings: readonly string[], url?: string): void;
1079
+ toHeader(url?: string): string;
1080
+ /**
1081
+ * Returns every cookie as a flat name/value map, collapsing duplicate names.
1082
+ * @deprecated Use serialize() for lossless, attribute-preserving persistence.
1083
+ */
1064
1084
  snapshot(): Record<string, string>;
1085
+ /**
1086
+ * Restores flat values as host-only, Path=/ cookies on the session base URL.
1087
+ * @deprecated Use deserialize() with state produced by serialize().
1088
+ */
1065
1089
  restore(cookies: Record<string, string>): void;
1090
+ /** Returns a versioned, JSON-safe, attribute-preserving representation of every cookie. */
1091
+ serialize(): StealthCookieStoreV1;
1092
+ /** Replaces the jar with a previously serialized, attribute-preserving cookie store. */
1093
+ deserialize(state: StealthCookieStore): void;
1066
1094
  clear(): void;
1067
1095
  }
1068
1096
 
@@ -1108,7 +1136,13 @@ export interface StealthRedirectRunResult {
1108
1136
  final: StealthResponse;
1109
1137
  hops: StealthRedirectHop[];
1110
1138
  reason: "completed" | "stopped" | "max_hops" | "missing_location" | "loop";
1139
+ /**
1140
+ * Complete flat view across all redirect hosts. Attributes and duplicate names are lost.
1141
+ * @deprecated Use cookieStore for lossless persistence.
1142
+ */
1111
1143
  cookies: Record<string, string>;
1144
+ /** Versioned, attribute-preserving cookie state accumulated across the redirect chain. */
1145
+ cookieStore: StealthCookieStoreV1;
1112
1146
  }
1113
1147
 
1114
1148
  export interface StealthSession {