@apifuse/provider-sdk 2.1.0-beta.10 → 2.1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @apifuse/provider-sdk Changelog
2
2
 
3
+ ## 2.1.0-beta.12
4
+
5
+ - Release candidate for main commit 2bc1061c6a68facaa2efde08bee31bf5cd96945e.
6
+
7
+ ## 2.1.0-beta.11
8
+
9
+ - Release candidate for main commit b98ddc5024698f8c79e05e3295f7c8c8e8fe5a8a.
10
+
3
11
  ## 2.1.0-beta.9
4
12
 
5
13
  - Preserve raw stealth response bytes through the public SDK response wrapper.
package/README.md CHANGED
@@ -124,12 +124,16 @@ the bad request path; provider/runtime failures include `code`, `message`, and
124
124
  `flowId`; preserve any returned `contextPatch` in the next local request's
125
125
  `context` object.
126
126
  - **Stealth-sensitive providers**: use `ctx.http` for normal JSON/REST calls and
127
- `ctx.stealth.fetch()` when you need browser-like session or cookie control. `ctx.stealth.fetch()` uses the impit-backed browser stealth transport and accepts request
128
- controls for `params`, `proxy`, `timeout`, `profile`, `throwOnHttpError`, and
129
- `stealth.insecureSkipVerify`. Select an SDK stealth `profile` such as
130
- `chrome-146`; do not tune JA3, HTTP/2 SETTINGS, or pseudo-header order in
131
- provider code. Chrome/Firefox-style profiles are supported; use `ctx.browser`
132
- when Safari-specific behavior is required.
127
+ `ctx.stealth.fetch()` when you need browser-like session or cookie control.
128
+ `ctx.stealth.fetch()` uses the impit-backed browser stealth transport and
129
+ accepts request controls for `params`, `proxy`, `timeout`, `profile`,
130
+ `redirect`, `throwOnHttpError`, and `stealth.insecureSkipVerify`. For login
131
+ flows that must inspect intermediate `Location`/`Set-Cookie` headers, create
132
+ a session with `ctx.stealth.createSession()` and use `session.redirects.run()`;
133
+ inspect accumulated cookies through `session.cookies`. Select an SDK stealth
134
+ `profile` such as `chrome-146`; do not tune JA3, HTTP/2 SETTINGS, or
135
+ pseudo-header order in provider code. Chrome/Firefox-style profiles are
136
+ supported; use `ctx.browser` when Safari-specific behavior is required.
133
137
  - **Browser providers**: for TypeScript Providers use `runtime: "browser"` plus
134
138
  `browser.engine: "playwright-stealth"`; `nodriver` is a Python-runtime path.
135
139
  Install local browser assets with `bunx playwright install chromium` when
@@ -544,8 +544,8 @@ function renderAuthBlock(authMode) {
544
544
  return `{
545
545
  mode: "credentials",
546
546
  flow: {
547
- start: async () => ({
548
- kind: "input",
547
+ start: async (_ctx) => ({
548
+ kind: "form",
549
549
  turnId: crypto.randomUUID(),
550
550
  expectedInput: {
551
551
  schema: {
@@ -588,7 +588,7 @@ function renderAuthBlock(authMode) {
588
588
  return `{
589
589
  mode: "oauth2",
590
590
  flow: {
591
- start: async () => ({
591
+ start: async (_ctx) => ({
592
592
  kind: "redirect",
593
593
  turnId: crypto.randomUUID(),
594
594
  data: {
package/dist/index.d.ts CHANGED
@@ -33,7 +33,7 @@ export { APIFUSE_DESCRIPTION_KEY_META_KEY, APIFUSE_REDACTION_MARKER, APIFUSE_SEN
33
33
  export { createServerApp, type ServeOptions, serve } from "./server";
34
34
  export { getStealthProfile, listStealthProfiles } from "./stealth/profiles";
35
35
  export * from "./stream";
36
- export type { ApiFuseResponse, AuthConfig, AuthContext, AuthFlowDefinition, AuthFlowInputHandler, AuthFlowStartHandler, AuthMode, AuthTurn, Bcp47Locale, BrowserEngine, BrowserOptions, ConnectionMode, ContextDeclaration, ContextScratchpad, CredentialContext, CredentialDeclaration, E164PhoneNumber, EnvContext, FlowContext, FlowContextStore, HealthCheckAssertionContext, HealthCheckCase, HealthCheckCaseResult, HealthCheckSuite, HealthCheckUnsupported, HealthJourneyDefinition, HealthJourneyEventContext, HealthJourneyGatewayContext, HealthJourneyJournalContext, HealthJourneyManualTriggerPolicy, HealthJourneyRunContext, HealthJourneyRunResult, HealthJourneySchedule, 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, ProviderHealthMonitorConfig, 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, StealthResponse, StealthSession, SttAudioInput, SttContext, SttPromptPolicy, SttSegment, SttTranscribeMode, SttTranscribeRequest, SttTranscript, SttUnsupportedOptionPolicy, SttUsage, SttVerificationCodeOptions, SttWarning, TraceConfig, TraceSpan, VerificationCodeCandidate, VerificationCodeCandidateSource, VerificationCodeExtractionResult, } from "./types";
36
+ export type { ApiFuseResponse, AuthConfig, AuthContext, AuthFlowDefinition, AuthFlowInputHandler, AuthFlowStartHandler, AuthMode, AuthTurn, Bcp47Locale, BrowserEngine, BrowserOptions, ConnectionMode, ContextDeclaration, CookieJar, ContextScratchpad, CredentialContext, CredentialDeclaration, E164PhoneNumber, EnvContext, FlowContext, FlowContextStore, HealthCheckAssertionContext, HealthCheckCase, HealthCheckCaseResult, HealthCheckSuite, HealthCheckUnsupported, HealthJourneyDefinition, HealthJourneyEventContext, HealthJourneyGatewayContext, HealthJourneyJournalContext, HealthJourneyManualTriggerPolicy, HealthJourneyRunContext, HealthJourneyRunResult, HealthJourneySchedule, 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, ProviderHealthMonitorConfig, 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";
37
37
  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";
38
38
  export * from "./utils/date";
39
39
  export * from "./utils/parse";
@@ -12,7 +12,10 @@ export type StealthClientOptions = ProxyResolutionOptions & {
12
12
  insecureSkipVerify?: boolean;
13
13
  };
14
14
  };
15
- type StealthTransportResponse = Pick<ImpitResponse, "arrayBuffer" | "headers" | "json" | "ok" | "status" | "text" | "url">;
16
- export declare function normalizeResponse(response: StealthTransportResponse): Promise<StealthResponse>;
15
+ type StealthTransportResponse = Pick<ImpitResponse, "arrayBuffer" | "headers" | "json" | "ok" | "status" | "text"> & {
16
+ url?: string;
17
+ redirected?: boolean;
18
+ };
19
+ export declare function normalizeResponse(response: StealthTransportResponse, requestUrl?: string): Promise<StealthResponse>;
17
20
  export declare function createStealthClient(baseUrl: string, defaultProfileOrOptions?: string | StealthClientOptions, clientOptions?: StealthClientOptions): StealthClient;
18
21
  export {};
@@ -102,11 +102,32 @@ class CookieJarImpl {
102
102
  getAll() {
103
103
  return { ...this.cookies };
104
104
  }
105
+ has(name) {
106
+ return Object.hasOwn(this.cookies, name);
107
+ }
105
108
  toString() {
106
109
  return Object.entries(this.cookies)
107
110
  .map(([name, value]) => `${name}=${value}`)
108
111
  .join("; ");
109
112
  }
113
+ toHeader() {
114
+ return this.toString();
115
+ }
116
+ snapshot() {
117
+ return this.getAll();
118
+ }
119
+ restore(cookies) {
120
+ this.clear();
121
+ for (const [name, value] of Object.entries(cookies)) {
122
+ if (name)
123
+ this.cookies[name] = value;
124
+ }
125
+ }
126
+ clear() {
127
+ for (const name of Object.keys(this.cookies)) {
128
+ delete this.cookies[name];
129
+ }
130
+ }
110
131
  find(predicate) {
111
132
  for (const [name, value] of Object.entries(this.cookies)) {
112
133
  const cookie = `${name}=${value}`;
@@ -237,7 +258,7 @@ function splitCombinedSetCookieHeader(headerValue) {
237
258
  cookieStrings.push(finalCookie);
238
259
  return cookieStrings;
239
260
  }
240
- export async function normalizeResponse(response) {
261
+ export async function normalizeResponse(response, requestUrl) {
241
262
  const headers = Object.fromEntries(response.headers.entries());
242
263
  const cookies = new CookieJarImpl(setCookieHeadersFromResponse(response.headers));
243
264
  const bodyBytes = await response.arrayBuffer();
@@ -245,6 +266,12 @@ export async function normalizeResponse(response) {
245
266
  return {
246
267
  status: response.status,
247
268
  ok: response.status >= 200 && response.status < 300,
269
+ ...(response.url ? { url: response.url } : {}),
270
+ ...(response.redirected !== undefined
271
+ ? { redirected: response.redirected }
272
+ : requestUrl && response.url
273
+ ? { redirected: response.url !== requestUrl }
274
+ : {}),
248
275
  headers,
249
276
  rawHeaders: headerEntriesFromHeaders(response.headers),
250
277
  body,
@@ -537,6 +564,23 @@ function normalizeMethod(method) {
537
564
  throw new SDKError(`Unsupported stealth method: ${method}`);
538
565
  }
539
566
  }
567
+ function isRedirectStatus(status) {
568
+ return [301, 302, 303, 307, 308].includes(status);
569
+ }
570
+ function nextRedirectMethod(status, method) {
571
+ if (status === 303 && method !== "HEAD")
572
+ return "GET";
573
+ if ((status === 301 || status === 302) && method === "POST")
574
+ return "GET";
575
+ return method;
576
+ }
577
+ function locationHeader(headers) {
578
+ for (const [name, value] of Object.entries(headers)) {
579
+ if (name.toLowerCase() === "location")
580
+ return value;
581
+ }
582
+ return undefined;
583
+ }
540
584
  function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
541
585
  const clients = new Map();
542
586
  let closed = false;
@@ -588,7 +632,7 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
588
632
  proxyHash: proxyEndpointHash(resolvedProxy.url),
589
633
  };
590
634
  }
591
- return {
635
+ const session = {
592
636
  async fetch(url, options = {}) {
593
637
  const method = normalizeMethod(options.method ?? "GET");
594
638
  const hasExplicitRetryPolicy = options.retry !== undefined;
@@ -656,13 +700,14 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
656
700
  const requestInit = {
657
701
  headers: normalizeHeaders(headers),
658
702
  method,
703
+ ...(options.redirect ? { redirect: options.redirect } : {}),
659
704
  ...(options.timeout ? { timeout: options.timeout } : {}),
660
705
  };
661
706
  if (options.body !== undefined) {
662
707
  requestInit.body = normalizeBody(options.body);
663
708
  }
664
709
  const response = await getClient(profileName, proxy, ignoreTlsErrors).fetch(requestUrl, requestInit);
665
- const normalized = await normalizeResponse(response);
710
+ const normalized = await normalizeResponse(response, requestUrl);
666
711
  cookieJar.setFromCookieStrings(setCookieHeadersFromResponse(response.headers));
667
712
  if (proxy &&
668
713
  isProxyConnectFailureResponse(response, normalized.body)) {
@@ -758,11 +803,112 @@ function createSessionFetcher(baseUrl, defaultProfile, clientOptions) {
758
803
  }
759
804
  throw normalizeStealthTransportError(lastError);
760
805
  },
806
+ cookies: cookieJar,
807
+ redirects: {
808
+ async run(options) {
809
+ const maxHops = options.maxHops === undefined || !Number.isFinite(options.maxHops)
810
+ ? 10
811
+ : Math.max(0, Math.floor(options.maxHops));
812
+ const hops = [];
813
+ let currentUrl = resolveUrl(baseUrl, options.url);
814
+ let method = normalizeMethod(options.method ?? "GET");
815
+ let body = options.body;
816
+ let response;
817
+ const visitedRequests = new Set();
818
+ const { url: _url, maxHops: _maxHops, stopWhen, params, ...fetchOptions } = options;
819
+ for (let hopIndex = 0; hopIndex <= maxHops; hopIndex += 1) {
820
+ visitedRequests.add(`${method} ${currentUrl}`);
821
+ response = await session.fetch(currentUrl, {
822
+ ...fetchOptions,
823
+ body,
824
+ method,
825
+ ...(hopIndex === 0 && params ? { params } : {}),
826
+ redirect: "manual",
827
+ throwOnHttpError: false,
828
+ });
829
+ if (!isRedirectStatus(response.status)) {
830
+ return {
831
+ final: response,
832
+ hops,
833
+ reason: "completed",
834
+ cookies: cookieJar.snapshot(),
835
+ };
836
+ }
837
+ const location = locationHeader(response.headers);
838
+ const nextUrl = location
839
+ ? new URL(location, response.url ?? currentUrl).toString()
840
+ : undefined;
841
+ const hop = {
842
+ url: response.url ?? currentUrl,
843
+ status: response.status,
844
+ method,
845
+ ...(location ? { location } : {}),
846
+ ...(nextUrl ? { nextUrl } : {}),
847
+ };
848
+ hops.push(hop);
849
+ if (stopWhen && (await stopWhen(hop))) {
850
+ return {
851
+ final: response,
852
+ hops,
853
+ reason: "stopped",
854
+ cookies: cookieJar.snapshot(),
855
+ };
856
+ }
857
+ if (!nextUrl) {
858
+ return {
859
+ final: response,
860
+ hops,
861
+ reason: "missing_location",
862
+ cookies: cookieJar.snapshot(),
863
+ };
864
+ }
865
+ if (hops.length > maxHops) {
866
+ return {
867
+ final: response,
868
+ hops,
869
+ reason: "max_hops",
870
+ cookies: cookieJar.snapshot(),
871
+ };
872
+ }
873
+ const nextMethod = nextRedirectMethod(response.status, method);
874
+ if (nextMethod !== method) {
875
+ body = undefined;
876
+ }
877
+ if (visitedRequests.has(`${nextMethod} ${nextUrl}`)) {
878
+ return {
879
+ final: response,
880
+ hops,
881
+ reason: "loop",
882
+ cookies: cookieJar.snapshot(),
883
+ };
884
+ }
885
+ method = nextMethod;
886
+ currentUrl = nextUrl;
887
+ }
888
+ if (!response) {
889
+ response = await session.fetch(currentUrl, {
890
+ ...fetchOptions,
891
+ body,
892
+ method,
893
+ ...(params ? { params } : {}),
894
+ redirect: "manual",
895
+ throwOnHttpError: false,
896
+ });
897
+ }
898
+ return {
899
+ final: response,
900
+ hops,
901
+ reason: "max_hops",
902
+ cookies: cookieJar.snapshot(),
903
+ };
904
+ },
905
+ },
761
906
  close() {
762
907
  closed = true;
763
908
  clients.clear();
764
909
  },
765
910
  };
911
+ return session;
766
912
  async function classifyProxyAuthDiagnostic(profileName, proxy) {
767
913
  try {
768
914
  const response = await getClient(profileName, proxy, false).fetch(PROXY_AUTH_DIAGNOSTIC_URL, {
package/dist/types.d.ts CHANGED
@@ -805,6 +805,7 @@ export type HttpMethod = "HEAD" | "head" | "GET" | "get" | "POST" | "post" | "PU
805
805
  export interface StealthFetchOptions extends RequestOptions {
806
806
  method?: HttpMethod;
807
807
  body?: string | Buffer;
808
+ redirect?: "follow" | "manual" | "error";
808
809
  /**
809
810
  * Offsets policy-managed proxy pool selection for caller-managed retries.
810
811
  * Use when a request receives an upstream challenge page rather than a
@@ -829,9 +830,19 @@ export interface CookieJar {
829
830
  toString(): string;
830
831
  find?(predicate: (cookie: string) => boolean): string | undefined;
831
832
  }
833
+ export interface StealthSessionCookies extends CookieJar {
834
+ has(name: string): boolean;
835
+ setFromCookieStrings(cookieStrings: readonly string[]): void;
836
+ toHeader(): string;
837
+ snapshot(): Record<string, string>;
838
+ restore(cookies: Record<string, string>): void;
839
+ clear(): void;
840
+ }
832
841
  export interface DeclarativeStealthResponse {
833
842
  status: number;
834
843
  ok: boolean;
844
+ url?: string;
845
+ redirected?: boolean;
835
846
  headers: Record<string, string>;
836
847
  rawHeaders: [string, string][];
837
848
  body: string;
@@ -851,8 +862,30 @@ export type RequestWithMethodOptions = RequestOptions & {
851
862
  method?: string;
852
863
  body?: unknown;
853
864
  };
865
+ export interface StealthRedirectHop {
866
+ url: string;
867
+ status: number;
868
+ method: string;
869
+ location?: string;
870
+ nextUrl?: string;
871
+ }
872
+ export interface StealthRedirectRunOptions extends Omit<StealthFetchOptions, "redirect"> {
873
+ url: string;
874
+ maxHops?: number;
875
+ stopWhen?: (hop: StealthRedirectHop) => boolean | Promise<boolean>;
876
+ }
877
+ export interface StealthRedirectRunResult {
878
+ final: StealthResponse;
879
+ hops: StealthRedirectHop[];
880
+ reason: "completed" | "stopped" | "max_hops" | "missing_location" | "loop";
881
+ cookies: Record<string, string>;
882
+ }
854
883
  export interface StealthSession {
855
884
  fetch(url: string, options?: StealthFetchOptions): Promise<StealthResponse>;
885
+ cookies: StealthSessionCookies;
886
+ redirects: {
887
+ run(options: StealthRedirectRunOptions): Promise<StealthRedirectRunResult>;
888
+ };
856
889
  close(): void;
857
890
  }
858
891
  export interface ApiFuseResponse<T> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
+ "version": "2.1.0-beta.12",
2
3
  "name": "@apifuse/provider-sdk",
3
- "version": "2.1.0-beta.10",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "APIFuse Provider SDK — Build providers with zero architectural constraints",
package/src/cli/create.ts CHANGED
@@ -734,8 +734,8 @@ function renderAuthBlock(authMode: CreateAuthMode): string {
734
734
  return `{
735
735
  mode: "credentials",
736
736
  flow: {
737
- start: async () => ({
738
- kind: "input",
737
+ start: async (_ctx) => ({
738
+ kind: "form",
739
739
  turnId: crypto.randomUUID(),
740
740
  expectedInput: {
741
741
  schema: {
@@ -778,7 +778,7 @@ function renderAuthBlock(authMode: CreateAuthMode): string {
778
778
  return `{
779
779
  mode: "oauth2",
780
780
  flow: {
781
- start: async () => ({
781
+ start: async (_ctx) => ({
782
782
  kind: "redirect",
783
783
  turnId: crypto.randomUUID(),
784
784
  data: {
package/src/index.ts CHANGED
@@ -124,6 +124,7 @@ export type {
124
124
  BrowserOptions,
125
125
  ConnectionMode,
126
126
  ContextDeclaration,
127
+ CookieJar,
127
128
  ContextScratchpad,
128
129
  CredentialContext,
129
130
  CredentialDeclaration,
@@ -229,8 +230,12 @@ export type {
229
230
  StealthFetchOptions,
230
231
  StealthPlatform,
231
232
  StealthProfile,
233
+ StealthRedirectHop,
234
+ StealthRedirectRunOptions,
235
+ StealthRedirectRunResult,
232
236
  StealthResponse,
233
237
  StealthSession,
238
+ StealthSessionCookies,
234
239
  SttAudioInput,
235
240
  SttContext,
236
241
  SttPromptPolicy,
@@ -18,6 +18,7 @@ import type {
18
18
  HttpRetryOptions,
19
19
  StealthClient,
20
20
  StealthFetchOptions,
21
+ StealthRedirectHop,
21
22
  StealthResponse,
22
23
  StealthSession,
23
24
  } from "../types";
@@ -128,10 +129,16 @@ const FIREFOX_IMPIT_BY_MAJOR: Record<number, ImpitBrowser> = {
128
129
 
129
130
  type StealthTransportResponse = Pick<
130
131
  ImpitResponse,
131
- "arrayBuffer" | "headers" | "json" | "ok" | "status" | "text" | "url"
132
- >;
132
+ "arrayBuffer" | "headers" | "json" | "ok" | "status" | "text"
133
+ > & {
134
+ url?: string;
135
+ redirected?: boolean;
136
+ };
133
137
 
134
138
  type StealthMethod = NonNullable<ImpitRequestInit["method"]>;
139
+ type StealthRequestInit = ImpitRequestInit & {
140
+ redirect?: NonNullable<StealthFetchOptions["redirect"]>;
141
+ };
135
142
  type NormalizedStealthRetryOptions = {
136
143
  attempts: number;
137
144
  methods: readonly string[];
@@ -177,12 +184,37 @@ class CookieJarImpl implements CookieJar {
177
184
  return { ...this.cookies };
178
185
  }
179
186
 
187
+ has(name: string): boolean {
188
+ return Object.hasOwn(this.cookies, name);
189
+ }
190
+
180
191
  toString(): string {
181
192
  return Object.entries(this.cookies)
182
193
  .map(([name, value]) => `${name}=${value}`)
183
194
  .join("; ");
184
195
  }
185
196
 
197
+ toHeader(): string {
198
+ return this.toString();
199
+ }
200
+
201
+ snapshot(): Record<string, string> {
202
+ return this.getAll();
203
+ }
204
+
205
+ restore(cookies: Record<string, string>): void {
206
+ this.clear();
207
+ for (const [name, value] of Object.entries(cookies)) {
208
+ if (name) this.cookies[name] = value;
209
+ }
210
+ }
211
+
212
+ clear(): void {
213
+ for (const name of Object.keys(this.cookies)) {
214
+ delete this.cookies[name];
215
+ }
216
+ }
217
+
186
218
  find(predicate: (cookie: string) => boolean): string | undefined {
187
219
  for (const [name, value] of Object.entries(this.cookies)) {
188
220
  const cookie = `${name}=${value}`;
@@ -335,6 +367,7 @@ function splitCombinedSetCookieHeader(headerValue: string): string[] {
335
367
 
336
368
  export async function normalizeResponse(
337
369
  response: StealthTransportResponse,
370
+ requestUrl?: string,
338
371
  ): Promise<StealthResponse> {
339
372
  const headers = Object.fromEntries(response.headers.entries());
340
373
  const cookies = new CookieJarImpl(
@@ -346,6 +379,12 @@ export async function normalizeResponse(
346
379
  return {
347
380
  status: response.status,
348
381
  ok: response.status >= 200 && response.status < 300,
382
+ ...(response.url ? { url: response.url } : {}),
383
+ ...(response.redirected !== undefined
384
+ ? { redirected: response.redirected }
385
+ : requestUrl && response.url
386
+ ? { redirected: response.url !== requestUrl }
387
+ : {}),
349
388
  headers,
350
389
  rawHeaders: headerEntriesFromHeaders(response.headers),
351
390
  body,
@@ -743,6 +782,26 @@ function normalizeMethod(method: HttpMethod | string): StealthMethod {
743
782
  }
744
783
  }
745
784
 
785
+ function isRedirectStatus(status: number): boolean {
786
+ return [301, 302, 303, 307, 308].includes(status);
787
+ }
788
+
789
+ function nextRedirectMethod(
790
+ status: number,
791
+ method: StealthMethod,
792
+ ): StealthMethod {
793
+ if (status === 303 && method !== "HEAD") return "GET";
794
+ if ((status === 301 || status === 302) && method === "POST") return "GET";
795
+ return method;
796
+ }
797
+
798
+ function locationHeader(headers: Record<string, string>): string | undefined {
799
+ for (const [name, value] of Object.entries(headers)) {
800
+ if (name.toLowerCase() === "location") return value;
801
+ }
802
+ return undefined;
803
+ }
804
+
746
805
  function createSessionFetcher(
747
806
  baseUrl: string,
748
807
  defaultProfile: string,
@@ -811,7 +870,7 @@ function createSessionFetcher(
811
870
  };
812
871
  }
813
872
 
814
- return {
873
+ const session: StealthSession = {
815
874
  async fetch(url, options: StealthFetchOptions = {}) {
816
875
  const method = normalizeMethod(options.method ?? "GET");
817
876
  const hasExplicitRetryPolicy = options.retry !== undefined;
@@ -896,9 +955,10 @@ function createSessionFetcher(
896
955
  const cookieHeader = cookieJar.toString();
897
956
  if (cookieHeader) headers.Cookie = cookieHeader;
898
957
  }
899
- const requestInit: ImpitRequestInit = {
958
+ const requestInit: StealthRequestInit = {
900
959
  headers: normalizeHeaders(headers),
901
960
  method,
961
+ ...(options.redirect ? { redirect: options.redirect } : {}),
902
962
  ...(options.timeout ? { timeout: options.timeout } : {}),
903
963
  };
904
964
  if (options.body !== undefined) {
@@ -909,7 +969,7 @@ function createSessionFetcher(
909
969
  proxy,
910
970
  ignoreTlsErrors,
911
971
  ).fetch(requestUrl, requestInit);
912
- const normalized = await normalizeResponse(response);
972
+ const normalized = await normalizeResponse(response, requestUrl);
913
973
  cookieJar.setFromCookieStrings(
914
974
  setCookieHeadersFromResponse(response.headers),
915
975
  );
@@ -1052,11 +1112,128 @@ function createSessionFetcher(
1052
1112
 
1053
1113
  throw normalizeStealthTransportError(lastError);
1054
1114
  },
1115
+ cookies: cookieJar,
1116
+ redirects: {
1117
+ async run(options) {
1118
+ const maxHops =
1119
+ options.maxHops === undefined || !Number.isFinite(options.maxHops)
1120
+ ? 10
1121
+ : Math.max(0, Math.floor(options.maxHops));
1122
+ const hops: StealthRedirectHop[] = [];
1123
+ let currentUrl = resolveUrl(baseUrl, options.url);
1124
+ let method = normalizeMethod(options.method ?? "GET");
1125
+ let body = options.body;
1126
+ let response: StealthResponse | undefined;
1127
+ const visitedRequests = new Set<string>();
1128
+
1129
+ const {
1130
+ url: _url,
1131
+ maxHops: _maxHops,
1132
+ stopWhen,
1133
+ params,
1134
+ ...fetchOptions
1135
+ } = options;
1136
+
1137
+ for (let hopIndex = 0; hopIndex <= maxHops; hopIndex += 1) {
1138
+ visitedRequests.add(`${method} ${currentUrl}`);
1139
+ response = await session.fetch(currentUrl, {
1140
+ ...fetchOptions,
1141
+ body,
1142
+ method,
1143
+ ...(hopIndex === 0 && params ? { params } : {}),
1144
+ redirect: "manual",
1145
+ throwOnHttpError: false,
1146
+ });
1147
+
1148
+ if (!isRedirectStatus(response.status)) {
1149
+ return {
1150
+ final: response,
1151
+ hops,
1152
+ reason: "completed",
1153
+ cookies: cookieJar.snapshot(),
1154
+ };
1155
+ }
1156
+
1157
+ const location = locationHeader(response.headers);
1158
+ const nextUrl = location
1159
+ ? new URL(location, response.url ?? currentUrl).toString()
1160
+ : undefined;
1161
+ const hop: StealthRedirectHop = {
1162
+ url: response.url ?? currentUrl,
1163
+ status: response.status,
1164
+ method,
1165
+ ...(location ? { location } : {}),
1166
+ ...(nextUrl ? { nextUrl } : {}),
1167
+ };
1168
+ hops.push(hop);
1169
+
1170
+ if (stopWhen && (await stopWhen(hop))) {
1171
+ return {
1172
+ final: response,
1173
+ hops,
1174
+ reason: "stopped",
1175
+ cookies: cookieJar.snapshot(),
1176
+ };
1177
+ }
1178
+
1179
+ if (!nextUrl) {
1180
+ return {
1181
+ final: response,
1182
+ hops,
1183
+ reason: "missing_location",
1184
+ cookies: cookieJar.snapshot(),
1185
+ };
1186
+ }
1187
+
1188
+ if (hops.length > maxHops) {
1189
+ return {
1190
+ final: response,
1191
+ hops,
1192
+ reason: "max_hops",
1193
+ cookies: cookieJar.snapshot(),
1194
+ };
1195
+ }
1196
+
1197
+ const nextMethod = nextRedirectMethod(response.status, method);
1198
+ if (nextMethod !== method) {
1199
+ body = undefined;
1200
+ }
1201
+ if (visitedRequests.has(`${nextMethod} ${nextUrl}`)) {
1202
+ return {
1203
+ final: response,
1204
+ hops,
1205
+ reason: "loop",
1206
+ cookies: cookieJar.snapshot(),
1207
+ };
1208
+ }
1209
+ method = nextMethod;
1210
+ currentUrl = nextUrl;
1211
+ }
1212
+
1213
+ if (!response) {
1214
+ response = await session.fetch(currentUrl, {
1215
+ ...fetchOptions,
1216
+ body,
1217
+ method,
1218
+ ...(params ? { params } : {}),
1219
+ redirect: "manual",
1220
+ throwOnHttpError: false,
1221
+ });
1222
+ }
1223
+ return {
1224
+ final: response,
1225
+ hops,
1226
+ reason: "max_hops",
1227
+ cookies: cookieJar.snapshot(),
1228
+ };
1229
+ },
1230
+ },
1055
1231
  close() {
1056
1232
  closed = true;
1057
1233
  clients.clear();
1058
1234
  },
1059
1235
  };
1236
+ return session;
1060
1237
 
1061
1238
  async function classifyProxyAuthDiagnostic(
1062
1239
  profileName: string,
package/src/types.ts CHANGED
@@ -979,6 +979,7 @@ export type HttpMethod =
979
979
  export interface StealthFetchOptions extends RequestOptions {
980
980
  method?: HttpMethod;
981
981
  body?: string | Buffer;
982
+ redirect?: "follow" | "manual" | "error";
982
983
  /**
983
984
  * Offsets policy-managed proxy pool selection for caller-managed retries.
984
985
  * Use when a request receives an upstream challenge page rather than a
@@ -1005,9 +1006,20 @@ export interface CookieJar {
1005
1006
  find?(predicate: (cookie: string) => boolean): string | undefined;
1006
1007
  }
1007
1008
 
1009
+ export interface StealthSessionCookies extends CookieJar {
1010
+ has(name: string): boolean;
1011
+ setFromCookieStrings(cookieStrings: readonly string[]): void;
1012
+ toHeader(): string;
1013
+ snapshot(): Record<string, string>;
1014
+ restore(cookies: Record<string, string>): void;
1015
+ clear(): void;
1016
+ }
1017
+
1008
1018
  export interface DeclarativeStealthResponse {
1009
1019
  status: number;
1010
1020
  ok: boolean;
1021
+ url?: string;
1022
+ redirected?: boolean;
1011
1023
  headers: Record<string, string>;
1012
1024
  rawHeaders: [string, string][];
1013
1025
  body: string;
@@ -1026,8 +1038,34 @@ export type RequestWithMethodOptions = RequestOptions & {
1026
1038
  body?: unknown;
1027
1039
  };
1028
1040
 
1041
+ export interface StealthRedirectHop {
1042
+ url: string;
1043
+ status: number;
1044
+ method: string;
1045
+ location?: string;
1046
+ nextUrl?: string;
1047
+ }
1048
+
1049
+ export interface StealthRedirectRunOptions
1050
+ extends Omit<StealthFetchOptions, "redirect"> {
1051
+ url: string;
1052
+ maxHops?: number;
1053
+ stopWhen?: (hop: StealthRedirectHop) => boolean | Promise<boolean>;
1054
+ }
1055
+
1056
+ export interface StealthRedirectRunResult {
1057
+ final: StealthResponse;
1058
+ hops: StealthRedirectHop[];
1059
+ reason: "completed" | "stopped" | "max_hops" | "missing_location" | "loop";
1060
+ cookies: Record<string, string>;
1061
+ }
1062
+
1029
1063
  export interface StealthSession {
1030
1064
  fetch(url: string, options?: StealthFetchOptions): Promise<StealthResponse>;
1065
+ cookies: StealthSessionCookies;
1066
+ redirects: {
1067
+ run(options: StealthRedirectRunOptions): Promise<StealthRedirectRunResult>;
1068
+ };
1031
1069
  close(): void;
1032
1070
  }
1033
1071