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

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 (59) hide show
  1. package/AUTHORING.md +238 -0
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +44 -2
  4. package/bin/apifuse-pack-smoke.ts +14 -0
  5. package/bin/apifuse-pack-types.ts +40 -1
  6. package/bin/apifuse-record.ts +622 -57
  7. package/bin/apifuse-submit-check.ts +43 -10
  8. package/dist/config/loader.d.ts +9 -1
  9. package/dist/config/loader.js +9 -0
  10. package/dist/define.d.ts +2 -1
  11. package/dist/define.js +61 -3
  12. package/dist/errors.d.ts +5 -0
  13. package/dist/errors.js +15 -0
  14. package/dist/fixture-sanitization.d.ts +26 -0
  15. package/dist/fixture-sanitization.js +216 -0
  16. package/dist/index.d.ts +4 -3
  17. package/dist/index.js +2 -1
  18. package/dist/provider.d.ts +2 -1
  19. package/dist/provider.js +1 -0
  20. package/dist/runtime/http.js +86 -32
  21. package/dist/runtime/instrumentation.js +295 -9
  22. package/dist/runtime/native-network.d.ts +53 -0
  23. package/dist/runtime/native-network.js +477 -0
  24. package/dist/runtime/proxy-nodemaven.d.ts +14 -0
  25. package/dist/runtime/proxy-nodemaven.js +20 -2
  26. package/dist/runtime/request-options.d.ts +68 -1
  27. package/dist/runtime/request-options.js +548 -0
  28. package/dist/runtime/stealth.d.ts +3 -1
  29. package/dist/runtime/stealth.js +352 -86
  30. package/dist/server/index.d.ts +1 -1
  31. package/dist/server/index.js +1 -1
  32. package/dist/server/self-test-input-tokens.d.ts +2 -1
  33. package/dist/server/self-test-input-tokens.js +18 -14
  34. package/dist/stream-evidence.d.ts +74 -0
  35. package/dist/stream-evidence.js +785 -0
  36. package/dist/testing/index.d.ts +1 -1
  37. package/dist/testing/index.js +1 -1
  38. package/dist/testing/run.d.ts +32 -2
  39. package/dist/testing/run.js +451 -19
  40. package/dist/types.d.ts +201 -7
  41. package/package.json +3 -1
  42. package/src/config/loader.ts +22 -1
  43. package/src/define.ts +81 -3
  44. package/src/errors.ts +15 -0
  45. package/src/fixture-sanitization.ts +247 -0
  46. package/src/index.ts +45 -1
  47. package/src/provider.ts +37 -0
  48. package/src/runtime/http.ts +144 -38
  49. package/src/runtime/instrumentation.ts +424 -8
  50. package/src/runtime/native-network.ts +600 -0
  51. package/src/runtime/proxy-nodemaven.ts +37 -2
  52. package/src/runtime/request-options.ts +680 -1
  53. package/src/runtime/stealth.ts +420 -88
  54. package/src/server/index.ts +4 -1
  55. package/src/server/self-test-input-tokens.ts +29 -14
  56. package/src/stream-evidence.ts +988 -0
  57. package/src/testing/index.ts +9 -1
  58. package/src/testing/run.ts +608 -12
  59. package/src/types.ts +235 -7
@@ -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,
@@ -47,7 +50,15 @@ import {
47
50
  shouldRetryProxyTransportAttempt,
48
51
  validateUnsafeProxyTransportRetryMethods,
49
52
  } from "./proxy-retry-policy.js";
50
- import { appendQueryParams } from "./request-options.js";
53
+ import {
54
+ isSensitiveKey,
55
+ redactSensitiveError,
56
+ redactSensitiveRequestError,
57
+ redactSensitiveText,
58
+ redactUrlQueryParams,
59
+ normalizeSensitiveParams,
60
+ serializeRequestUrl,
61
+ } from "./request-options.js";
51
62
 
52
63
  const DEFAULT_PROFILE = "chrome-146";
53
64
 
@@ -62,6 +73,14 @@ const PROXY_AUTH_DIAGNOSTIC_URL = "http://example.com/";
62
73
  const PROXY_AUTH_DIAGNOSTIC_TIMEOUT_MS = 5_000;
63
74
  const STEALTH_PROXY_TRANSPORT_RETRY_ERROR_CODES = [PROXY_CONNECT_FAILURE_CODE] as const;
64
75
 
76
+ function sensitiveQueryParamNames(url: string): string[] {
77
+ const queryStart = url.indexOf("?");
78
+ if (queryStart === -1) return [];
79
+ const fragmentStart = url.indexOf("#", queryStart);
80
+ const query = url.slice(queryStart + 1, fragmentStart === -1 ? undefined : fragmentStart);
81
+ return [...new URLSearchParams(query).keys()].filter(isSensitiveKey);
82
+ }
83
+
65
84
  export type StealthClientOptions = ProxyResolutionOptions & {
66
85
  warn?: (message: string) => void;
67
86
  /**
@@ -114,6 +133,8 @@ type StealthTransportResponse = Pick<
114
133
  ImpitResponse,
115
134
  "arrayBuffer" | "headers" | "json" | "ok" | "status" | "text"
116
135
  > & {
136
+ body?: ReadableStream<Uint8Array>;
137
+ abort?: () => void;
117
138
  url?: string;
118
139
  redirected?: boolean;
119
140
  };
@@ -127,81 +148,145 @@ function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
127
148
  return typeof value === "object" && value !== null;
128
149
  }
129
150
 
151
+ const LEGACY_COOKIE_ORIGIN = "https://legacy-cookie.invalid/";
152
+
130
153
  class CookieJarImpl implements CookieJar {
131
- private readonly cookies: Record<string, string>;
154
+ private cookies: ToughCookieJar;
155
+ private readonly defaultUrl: string;
132
156
 
133
- constructor(cookieStrings: string[]) {
134
- this.cookies = {};
157
+ constructor(cookieStrings: readonly string[], defaultUrl = LEGACY_COOKIE_ORIGIN) {
158
+ this.cookies = new ToughCookieJar(undefined, {
159
+ allowSecureOnLocal: false,
160
+ rejectPublicSuffixes: true,
161
+ });
162
+ this.defaultUrl = this.normalizeUrl(defaultUrl) ?? LEGACY_COOKIE_ORIGIN;
135
163
  this.setFromCookieStrings(cookieStrings);
136
164
  }
137
165
 
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
- }
166
+ /**
167
+ * URL-less legacy operations are scoped to this jar's default URL. Session
168
+ * jars use the client's base URL and response jars use the response URL. A
169
+ * flat restore has no attributes to recover, so it creates host-only Path=/
170
+ * cookies for that default URL instead of making them visible to every host.
171
+ */
172
+ setFromCookieStrings(cookieStrings: readonly string[], url = this.defaultUrl): void {
173
+ const cookieUrl = this.normalizeUrl(url);
174
+ if (!cookieUrl) return;
149
175
 
150
- const name = nameValue.slice(0, separatorIndex).trim();
151
- const value = nameValue.slice(separatorIndex + 1).trim();
152
- if (name) this.cookies[name] = value;
176
+ for (const cookieString of cookieStrings) {
177
+ this.cookies.setCookieSync(cookieString, cookieUrl, { ignoreError: true });
153
178
  }
154
179
  }
155
180
 
156
- get(name: string): string | undefined {
157
- return this.cookies[name];
181
+ get(name: string, url?: string): string | undefined {
182
+ return this.getAll(url)[name];
158
183
  }
159
184
 
160
- getAll(): Record<string, string> {
161
- return { ...this.cookies };
185
+ getAll(url?: string): Record<string, string> {
186
+ return Object.fromEntries(
187
+ this.getUniqueCookies(url ?? this.defaultUrl).map((cookie) => [cookie.key, cookie.value]),
188
+ );
162
189
  }
163
190
 
164
- has(name: string): boolean {
165
- return Object.hasOwn(this.cookies, name);
191
+ has(name: string, url?: string): boolean {
192
+ return Object.hasOwn(this.getAll(url), name);
166
193
  }
167
194
 
168
- toString(): string {
169
- return Object.entries(this.cookies)
170
- .map(([name, value]) => `${name}=${value}`)
195
+ toString(url?: string): string {
196
+ return this.getUniqueCookies(url ?? this.defaultUrl)
197
+ .map((cookie) => cookie.cookieString())
171
198
  .join("; ");
172
199
  }
173
200
 
174
- toHeader(): string {
175
- return this.toString();
201
+ toHeader(url?: string): string {
202
+ return this.toString(url);
176
203
  }
177
204
 
178
205
  snapshot(): Record<string, string> {
179
- return this.getAll();
206
+ // This compatibility view deliberately enumerates the serialized store,
207
+ // not getAll(defaultUrl): persistence must include sibling hosts and paths.
208
+ // Duplicate names still collapse because a flat map cannot represent them.
209
+ const entries: [string, string][] = [];
210
+ for (const cookie of this.serialize().jar.cookies) {
211
+ if (typeof cookie.key === "string" && typeof cookie.value === "string" && cookie.key) {
212
+ entries.push([cookie.key, cookie.value]);
213
+ }
214
+ }
215
+ return Object.fromEntries(entries);
180
216
  }
181
217
 
182
218
  restore(cookies: Record<string, string>): void {
183
219
  this.clear();
184
220
  for (const [name, value] of Object.entries(cookies)) {
185
- if (name) this.cookies[name] = value;
221
+ if (!name) continue;
222
+ this.cookies.setCookieSync(new Cookie({ key: name, path: "/", value }), this.defaultUrl, {
223
+ ignoreError: true,
224
+ });
186
225
  }
187
226
  }
188
227
 
189
- clear(): void {
190
- for (const name of Object.keys(this.cookies)) {
191
- delete this.cookies[name];
228
+ serialize(): StealthCookieStoreV1 {
229
+ const jar = this.cookies.serializeSync();
230
+ if (!jar) {
231
+ throw new SDKError("Stealth cookie store could not be serialized", {
232
+ code: "stealth_cookie_store_serialize_failed",
233
+ });
192
234
  }
235
+ return { version: 1, jar };
193
236
  }
194
237
 
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;
238
+ deserialize(state: StealthCookieStore): void {
239
+ const version = isRecord(state) ? state.version : undefined;
240
+ if (version !== 1) {
241
+ throw new StealthCookieStoreVersionError(version);
242
+ }
243
+
244
+ // Deserialize into a new jar first so invalid state cannot partially clear
245
+ // or replace a live session. tough-cookie restores the cookie attributes and
246
+ // matching semantics represented in its own serialized format.
247
+ const restored = ToughCookieJar.deserializeSync(state.jar);
248
+ // tough-cookie 6 does not include this option in serializeSync(). Preserve
249
+ // the SDK's stricter setting across restoration.
250
+ Reflect.set(restored, "allowSecureOnLocal", false);
251
+ this.cookies = restored;
252
+ }
253
+
254
+ clear(): void {
255
+ this.cookies.removeAllCookiesSync();
256
+ }
257
+
258
+ find(predicate: (cookie: string) => boolean, url?: string): string | undefined {
259
+ for (const cookie of this.getUniqueCookies(url ?? this.defaultUrl)) {
260
+ const cookieString = cookie.cookieString();
261
+ if (predicate(cookieString)) {
262
+ return cookieString;
200
263
  }
201
264
  }
202
265
 
203
266
  return undefined;
204
267
  }
268
+
269
+ private normalizeUrl(url: string): string | undefined {
270
+ try {
271
+ return new URL(url).toString();
272
+ } catch {
273
+ return undefined;
274
+ }
275
+ }
276
+
277
+ private getUniqueCookies(url: string): Cookie[] {
278
+ const cookieUrl = this.normalizeUrl(url);
279
+ if (!cookieUrl) return [];
280
+
281
+ // tough-cookie returns longer (more-specific) paths first. Keeping the
282
+ // first cookie for each name prevents ambiguous duplicate-name headers.
283
+ const names = new Set<string>();
284
+ return this.cookies.getCookiesSync(cookieUrl).filter((cookie) => {
285
+ if (names.has(cookie.key)) return false;
286
+ names.add(cookie.key);
287
+ return true;
288
+ });
289
+ }
205
290
  }
206
291
 
207
292
  function closestImpitBrowser(
@@ -282,12 +367,12 @@ function hasOwn(object: object, key: string): boolean {
282
367
  }
283
368
  function toImpitCookieJar(cookieJar: CookieJarImpl): NonNullable<ImpitOptions["cookieJar"]> {
284
369
  return {
285
- setCookie(cookie: string, _url: string, cb?: (error?: unknown) => void) {
286
- cookieJar.setFromCookieStrings([cookie]);
370
+ setCookie(cookie: string, url: string, cb?: (error?: unknown) => void) {
371
+ cookieJar.setFromCookieStrings([cookie], url);
287
372
  if (typeof cb === "function") cb();
288
373
  },
289
- getCookieString(_url: string) {
290
- return cookieJar.toString();
374
+ getCookieString(url: string) {
375
+ return cookieJar.toHeader(url);
291
376
  },
292
377
  };
293
378
  }
@@ -339,10 +424,17 @@ function splitCombinedSetCookieHeader(headerValue: string): string[] {
339
424
  export async function normalizeResponse(
340
425
  response: StealthTransportResponse,
341
426
  requestUrl?: string,
427
+ maxBodyBytes?: number,
342
428
  ): Promise<StealthResponse> {
343
429
  const headers = Object.fromEntries(response.headers.entries());
344
- const cookies = new CookieJarImpl(setCookieHeadersFromResponse(response.headers));
345
- const bodyBytes = await response.arrayBuffer();
430
+ const cookies = new CookieJarImpl(
431
+ setCookieHeadersFromResponse(response.headers),
432
+ response.url ?? requestUrl,
433
+ );
434
+ const bodyBytes =
435
+ maxBodyBytes === undefined
436
+ ? await response.arrayBuffer()
437
+ : await readResponseBodyWithLimit(response, maxBodyBytes);
346
438
  const body = new TextDecoder().decode(bodyBytes);
347
439
 
348
440
  return {
@@ -370,6 +462,83 @@ export async function normalizeResponse(
370
462
  };
371
463
  }
372
464
 
465
+ function responseTooLargeError(maxBodyBytes: number, observedBytes: number): TransportError {
466
+ return new TransportError(
467
+ `Response body exceeded maxBodyBytes limit of ${maxBodyBytes} bytes (observed ${observedBytes} bytes)`,
468
+ {
469
+ code: "response_too_large",
470
+ category: "upstream_http",
471
+ retryable: false,
472
+ status: 0,
473
+ },
474
+ );
475
+ }
476
+
477
+ function declaredContentLength(headers: Headers): number | undefined {
478
+ const contentLength = headers.get("content-length")?.trim();
479
+ if (!contentLength || !/^\d+$/.test(contentLength)) return undefined;
480
+ const parsed = Number(contentLength);
481
+ return Number.isFinite(parsed) ? parsed : undefined;
482
+ }
483
+
484
+ function abortTransportResponse(response: StealthTransportResponse): boolean {
485
+ if (!response.abort) return false;
486
+ try {
487
+ response.abort();
488
+ } catch {
489
+ // The size error remains the primary failure if impit has already closed the response.
490
+ }
491
+ return true;
492
+ }
493
+
494
+ async function readResponseBodyWithLimit(
495
+ response: StealthTransportResponse,
496
+ maxBodyBytes: number,
497
+ ): Promise<ArrayBuffer> {
498
+ const contentLength = declaredContentLength(response.headers);
499
+ if (contentLength !== undefined && contentLength > maxBodyBytes) {
500
+ if (!abortTransportResponse(response)) {
501
+ await response.body?.cancel().catch(() => undefined);
502
+ }
503
+ throw responseTooLargeError(maxBodyBytes, contentLength);
504
+ }
505
+
506
+ if (!response.body) {
507
+ throw new TransportError("Response body stream is unavailable", {
508
+ code: "transport_stream_unavailable",
509
+ category: "upstream_http",
510
+ status: 0,
511
+ });
512
+ }
513
+
514
+ const reader = response.body.getReader();
515
+ const chunks: Uint8Array[] = [];
516
+ let receivedBytes = 0;
517
+ try {
518
+ while (true) {
519
+ const { done, value } = await reader.read();
520
+ if (done) break;
521
+ receivedBytes += value.byteLength;
522
+ if (receivedBytes > maxBodyBytes) {
523
+ await reader.cancel().catch(() => undefined);
524
+ abortTransportResponse(response);
525
+ throw responseTooLargeError(maxBodyBytes, receivedBytes);
526
+ }
527
+ chunks.push(value);
528
+ }
529
+ } finally {
530
+ reader.releaseLock();
531
+ }
532
+
533
+ const bodyBytes = new Uint8Array(receivedBytes);
534
+ let offset = 0;
535
+ for (const chunk of chunks) {
536
+ bodyBytes.set(chunk, offset);
537
+ offset += chunk.byteLength;
538
+ }
539
+ return bodyBytes.buffer;
540
+ }
541
+
373
542
  function normalizeBody(body: StealthFetchOptions["body"]): string {
374
543
  if (body === undefined) {
375
544
  return "";
@@ -588,7 +757,7 @@ function createSessionFetcher(
588
757
  let closed = false;
589
758
  let hasWarnedMissingProxy = false;
590
759
  const warn = clientOptions.warn ?? console.warn;
591
- const cookieJar = new CookieJarImpl([]);
760
+ const cookieJar = new CookieJarImpl([], baseUrl);
592
761
  const impitCookieJar = toImpitCookieJar(cookieJar);
593
762
 
594
763
  function getClient(
@@ -652,22 +821,29 @@ function createSessionFetcher(
652
821
 
653
822
  const session: StealthSession = {
654
823
  async fetch(url, options: StealthFetchOptions = {}) {
655
- const method = normalizeMethod(options.method ?? "GET");
656
- const hasExplicitRetryPolicy = options.retry !== undefined;
657
- const stealthRetryOptions =
658
- normalizeProxyTransportRetryOptions(options.retry, {
659
- extraErrorCodes: STEALTH_PROXY_TRANSPORT_RETRY_ERROR_CODES,
660
- label: "Stealth",
661
- }) ??
662
- (hasExplicitRetryPolicy
663
- ? undefined
664
- : createDefaultProxyTransportRetryOptions({
824
+ const { hasExplicitRetryPolicy, method, stealthRetryOptions } = (() => {
825
+ try {
826
+ const method = normalizeMethod(options.method ?? "GET");
827
+ const hasExplicitRetryPolicy = options.retry !== undefined;
828
+ const stealthRetryOptions =
829
+ normalizeProxyTransportRetryOptions(options.retry, {
665
830
  extraErrorCodes: STEALTH_PROXY_TRANSPORT_RETRY_ERROR_CODES,
666
831
  label: "Stealth",
667
- }));
668
- if (stealthRetryOptions) {
669
- validateUnsafeProxyTransportRetryMethods(stealthRetryOptions, "Stealth");
670
- }
832
+ }) ??
833
+ (hasExplicitRetryPolicy
834
+ ? undefined
835
+ : createDefaultProxyTransportRetryOptions({
836
+ extraErrorCodes: STEALTH_PROXY_TRANSPORT_RETRY_ERROR_CODES,
837
+ label: "Stealth",
838
+ }));
839
+ if (stealthRetryOptions) {
840
+ validateUnsafeProxyTransportRetryMethods(stealthRetryOptions, "Stealth");
841
+ }
842
+ return { hasExplicitRetryPolicy, method, stealthRetryOptions };
843
+ } catch (error) {
844
+ throw redactSensitiveRequestError(error, url, options.sensitiveParams);
845
+ }
846
+ })();
671
847
  const hasPolicyProxy = isPolicyManagedProxy(clientOptions);
672
848
  const usesPolicyAllocator = hasPolicyProxy && !options.proxy && !clientOptions.proxy;
673
849
  const retryAttemptCap = Math.max(1, stealthRetryOptions?.attempts ?? 1);
@@ -712,6 +888,11 @@ function createSessionFetcher(
712
888
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
713
889
  let proxy: string | undefined;
714
890
  let attemptProxy: ResolvedAttemptProxy | undefined;
891
+ // Reuse the exact serialization used by this outbound attempt in its catch path.
892
+ let serializedUrl: ReturnType<typeof serializeRequestUrl> | undefined;
893
+ let fallbackSensitiveValues: readonly string[] = [];
894
+ let fallbackRequestUrl: string | undefined;
895
+ let fallbackRedactedUrl: string | undefined;
715
896
  const attemptStartedAt = Date.now();
716
897
  let attemptRecorded = false;
717
898
  const recordProxyAttempt = (
@@ -735,6 +916,16 @@ function createSessionFetcher(
735
916
  });
736
917
  };
737
918
  try {
919
+ const sensitiveParams = normalizeSensitiveParams(options.sensitiveParams);
920
+ const structural = redactUrlQueryParams(url, Object.keys(sensitiveParams ?? {}));
921
+ fallbackSensitiveValues = [
922
+ ...new Set([
923
+ ...Object.values(sensitiveParams ?? {}).map(String),
924
+ ...structural.sensitiveValues,
925
+ ]),
926
+ ].filter((value) => value !== "");
927
+ fallbackRequestUrl = url;
928
+ fallbackRedactedUrl = structural.redactedUrl;
738
929
  assertNoUnsupportedFingerprintOverrides(options);
739
930
  attemptProxy = await resolveRequestProxy(options, attempt, refreshAttempt);
740
931
  proxy = attemptProxy.url;
@@ -754,10 +945,15 @@ function createSessionFetcher(
754
945
  (!hasPolicyProxy && proxy && clientOptions.proxyStealth?.insecureSkipVerify),
755
946
  );
756
947
  const profileName = options.profile ?? defaultProfile;
757
- const requestUrl = appendQueryParams(resolveUrl(baseUrl, url), options.params);
948
+ serializedUrl = serializeRequestUrl(
949
+ resolveUrl(baseUrl, url),
950
+ options.params,
951
+ sensitiveParams,
952
+ );
953
+ const { requestUrl } = serializedUrl;
758
954
  const headers = { ...(options.headers ?? {}) };
759
955
  if (!hasHeader(headers, "Cookie")) {
760
- const cookieHeader = cookieJar.toString();
956
+ const cookieHeader = cookieJar.toHeader(requestUrl);
761
957
  if (cookieHeader) headers.Cookie = cookieHeader;
762
958
  }
763
959
  const requestInit: StealthRequestInit = {
@@ -773,8 +969,11 @@ function createSessionFetcher(
773
969
  requestUrl,
774
970
  requestInit,
775
971
  );
776
- const normalized = await normalizeResponse(response, requestUrl);
777
- cookieJar.setFromCookieStrings(setCookieHeadersFromResponse(response.headers));
972
+ const normalized = await normalizeResponse(response, requestUrl, options.maxBodyBytes);
973
+ cookieJar.setFromCookieStrings(
974
+ setCookieHeadersFromResponse(response.headers),
975
+ response.url ?? requestUrl,
976
+ );
778
977
 
779
978
  if (proxy && isProxyConnectFailureResponse(response, normalized.body)) {
780
979
  throw createProxyConnectFailureError(normalized.body);
@@ -818,16 +1017,36 @@ function createSessionFetcher(
818
1017
  recordProxyAttempt("ok", undefined, response.status);
819
1018
  return normalized;
820
1019
  } catch (error) {
821
- const normalizedError = normalizeStealthTransportError(error);
1020
+ const sensitiveValues = serializedUrl?.sensitiveValues ?? fallbackSensitiveValues;
1021
+ let normalizedError: TransportError;
1022
+ try {
1023
+ normalizedError = normalizeStealthTransportError(error);
1024
+ } catch (normalizationError) {
1025
+ throw redactSensitiveError(
1026
+ normalizationError,
1027
+ sensitiveValues,
1028
+ serializedUrl?.requestUrl ?? fallbackRequestUrl,
1029
+ serializedUrl?.redactedUrl ?? fallbackRedactedUrl,
1030
+ );
1031
+ }
1032
+ const retryErrorCode = proxyAttemptErrorCode(normalizedError);
1033
+ const refreshableProxyError = isProxyPoolRefreshableError(normalizedError);
1034
+ const runProxyAuthDiagnostic = shouldRunProxyAuthDiagnostic(normalizedError);
1035
+ normalizedError = redactSensitiveError(
1036
+ normalizedError,
1037
+ sensitiveValues,
1038
+ serializedUrl?.requestUrl ?? fallbackRequestUrl,
1039
+ serializedUrl?.redactedUrl ?? fallbackRedactedUrl,
1040
+ );
822
1041
  recordProxyAttempt(
823
1042
  "error",
824
1043
  proxyAttemptErrorCode(normalizedError),
825
1044
  proxyAttemptStatus(normalizedError),
826
1045
  );
827
1046
  lastError = normalizedError;
828
- if (proxy && rotatesRegistryChain && isProxyPoolRefreshableError(normalizedError)) {
1047
+ if (proxy && rotatesRegistryChain && refreshableProxyError) {
829
1048
  stalePoolError = normalizedError;
830
- if (shouldRunProxyAuthDiagnostic(normalizedError)) {
1049
+ if (runProxyAuthDiagnostic) {
831
1050
  stalePoolDiagnosticProxy = proxy;
832
1051
  }
833
1052
  if (attempt + 1 < maxAttempts) {
@@ -858,7 +1077,7 @@ function createSessionFetcher(
858
1077
  if (
859
1078
  attempt + 1 < transportRetryCap &&
860
1079
  shouldRetryProxyTransportAttempt({
861
- error: normalizedError,
1080
+ error: { code: retryErrorCode },
862
1081
  explicitRetry: hasExplicitRetryPolicy,
863
1082
  method,
864
1083
  options: stealthRetryOptions,
@@ -928,25 +1147,99 @@ function createSessionFetcher(
928
1147
  options.maxHops === undefined || !Number.isFinite(options.maxHops)
929
1148
  ? 10
930
1149
  : Math.max(0, Math.floor(options.maxHops));
1150
+ const {
1151
+ url: _url,
1152
+ maxHops: _maxHops,
1153
+ stopWhen,
1154
+ params,
1155
+ sensitiveParams,
1156
+ ...fetchOptions
1157
+ } = options;
931
1158
  const hops: StealthRedirectHop[] = [];
932
- let currentUrl = resolveUrl(baseUrl, options.url);
933
1159
  let method = normalizeMethod(options.method ?? "GET");
934
1160
  let body = options.body;
935
1161
  let response: StealthResponse | undefined;
936
1162
  const visitedRequests = new Set<string>();
937
-
938
- const { url: _url, maxHops: _maxHops, stopWhen, params, ...fetchOptions } = options;
1163
+ const initialParams = params
1164
+ ? Object.fromEntries(
1165
+ Object.entries(params).map(([key, value]) => [
1166
+ key,
1167
+ Array.isArray(value) ? [...value] : value,
1168
+ ]),
1169
+ )
1170
+ : undefined;
1171
+ const normalizedSensitiveParams = normalizeSensitiveParams(sensitiveParams);
1172
+ const initialSensitiveParams = normalizedSensitiveParams
1173
+ ? { ...normalizedSensitiveParams }
1174
+ : undefined;
1175
+ const sensitiveParamNames = initialSensitiveParams
1176
+ ? Object.keys(initialSensitiveParams)
1177
+ : [];
1178
+ const callerStructural = redactUrlQueryParams(options.url, sensitiveParamNames);
1179
+ const sensitiveValues = new Set(
1180
+ [
1181
+ ...Object.values(initialSensitiveParams ?? {}),
1182
+ ...callerStructural.sensitiveValues,
1183
+ ].filter((value) => value !== ""),
1184
+ );
1185
+ const redactRedirectUrl = (value: string): string => {
1186
+ const structural = redactUrlQueryParams(value, [
1187
+ ...new Set([...sensitiveParamNames, ...sensitiveQueryParamNames(value)]),
1188
+ ]);
1189
+ for (const sensitiveValue of structural.sensitiveValues) {
1190
+ sensitiveValues.add(sensitiveValue);
1191
+ }
1192
+ return redactSensitiveText(structural.redactedUrl, [...sensitiveValues]);
1193
+ };
1194
+ let currentUrl: string;
1195
+ let initialUrl: ReturnType<typeof serializeRequestUrl>;
1196
+ try {
1197
+ currentUrl = resolveUrl(baseUrl, options.url);
1198
+ redactRedirectUrl(currentUrl);
1199
+ initialUrl = serializeRequestUrl(currentUrl, initialParams, initialSensitiveParams);
1200
+ for (const value of initialUrl.sensitiveValues) {
1201
+ if (value !== "") sensitiveValues.add(value);
1202
+ }
1203
+ } catch (error) {
1204
+ throw redactSensitiveError(
1205
+ error,
1206
+ [...sensitiveValues],
1207
+ options.url,
1208
+ redactRedirectUrl(options.url),
1209
+ );
1210
+ }
939
1211
 
940
1212
  for (let hopIndex = 0; hopIndex <= maxHops; hopIndex += 1) {
941
- visitedRequests.add(`${method} ${currentUrl}`);
942
- response = await session.fetch(currentUrl, {
943
- ...fetchOptions,
944
- body,
945
- method,
946
- ...(hopIndex === 0 && params ? { params } : {}),
947
- redirect: "manual",
948
- throwOnHttpError: false,
949
- });
1213
+ const outboundUrl =
1214
+ hopIndex === 0 ? initialUrl.requestUrl : serializeRequestUrl(currentUrl).requestUrl;
1215
+ // Preserve params-only loop bookkeeping from before sensitiveParams:
1216
+ // the first visited key is the caller's resolved URL, not its expanded query.
1217
+ const visitedUrl = hopIndex === 0 && !initialSensitiveParams ? currentUrl : outboundUrl;
1218
+ visitedRequests.add(`${method} ${visitedUrl}`);
1219
+ try {
1220
+ response = await session.fetch(currentUrl, {
1221
+ ...fetchOptions,
1222
+ body,
1223
+ method,
1224
+ ...(hopIndex === 0 && initialParams ? { params: initialParams } : {}),
1225
+ ...(hopIndex === 0 && initialSensitiveParams
1226
+ ? { sensitiveParams: initialSensitiveParams }
1227
+ : {}),
1228
+ redirect: "manual",
1229
+ throwOnHttpError: false,
1230
+ });
1231
+ } catch (error) {
1232
+ throw redactSensitiveError(
1233
+ error,
1234
+ [...sensitiveValues],
1235
+ outboundUrl,
1236
+ redactRedirectUrl(outboundUrl),
1237
+ );
1238
+ }
1239
+ // StealthResponse.url is programmatic metadata and remains raw. Only the
1240
+ // redirect hop emitted below is a diagnostic surface.
1241
+ const responseUrl =
1242
+ response.url ?? (hopIndex === 0 && initialSensitiveParams ? outboundUrl : currentUrl);
950
1243
 
951
1244
  if (!isRedirectStatus(response.status)) {
952
1245
  return {
@@ -954,28 +1247,63 @@ function createSessionFetcher(
954
1247
  hops,
955
1248
  reason: "completed",
956
1249
  cookies: cookieJar.snapshot(),
1250
+ cookieStore: cookieJar.serialize(),
957
1251
  };
958
1252
  }
959
1253
 
960
1254
  const location = locationHeader(response.headers);
961
- const nextUrl = location
962
- ? new URL(location, response.url ?? currentUrl).toString()
963
- : undefined;
964
- const hop: StealthRedirectHop = {
965
- url: response.url ?? currentUrl,
1255
+ const redactedResponseUrl = redactRedirectUrl(responseUrl);
1256
+ const redactedLocation = location ? redactRedirectUrl(location) : undefined;
1257
+ let nextUrl: string | undefined;
1258
+ try {
1259
+ nextUrl = location ? new URL(location, responseUrl).toString() : undefined;
1260
+ } catch (error) {
1261
+ throw redactSensitiveError(error, [...sensitiveValues], location, redactedLocation);
1262
+ }
1263
+ const realHop: StealthRedirectHop = {
1264
+ url: responseUrl,
966
1265
  status: response.status,
967
1266
  method,
968
1267
  ...(location ? { location } : {}),
969
1268
  ...(nextUrl ? { nextUrl } : {}),
970
1269
  };
1270
+ const hop: StealthRedirectHop = {
1271
+ ...realHop,
1272
+ url: redactedResponseUrl,
1273
+ ...(redactedLocation ? { location: redactedLocation } : {}),
1274
+ ...(nextUrl ? { nextUrl: redactRedirectUrl(nextUrl) } : {}),
1275
+ };
971
1276
  hops.push(hop);
972
1277
 
973
- if (stopWhen && (await stopWhen(hop))) {
1278
+ let shouldStop = false;
1279
+ if (stopWhen) {
1280
+ try {
1281
+ shouldStop = await stopWhen(realHop);
1282
+ } catch (error) {
1283
+ let sanitizedError: unknown = error;
1284
+ for (const [rawUrl, safeUrl] of [
1285
+ [responseUrl, redactedResponseUrl],
1286
+ [location, redactedLocation],
1287
+ [nextUrl, nextUrl ? redactRedirectUrl(nextUrl) : undefined],
1288
+ ] as const) {
1289
+ if (!rawUrl || !safeUrl) continue;
1290
+ sanitizedError = redactSensitiveError(
1291
+ sanitizedError,
1292
+ [...sensitiveValues],
1293
+ rawUrl,
1294
+ safeUrl,
1295
+ );
1296
+ }
1297
+ throw sanitizedError;
1298
+ }
1299
+ }
1300
+ if (shouldStop) {
974
1301
  return {
975
1302
  final: response,
976
1303
  hops,
977
1304
  reason: "stopped",
978
1305
  cookies: cookieJar.snapshot(),
1306
+ cookieStore: cookieJar.serialize(),
979
1307
  };
980
1308
  }
981
1309
 
@@ -985,6 +1313,7 @@ function createSessionFetcher(
985
1313
  hops,
986
1314
  reason: "missing_location",
987
1315
  cookies: cookieJar.snapshot(),
1316
+ cookieStore: cookieJar.serialize(),
988
1317
  };
989
1318
  }
990
1319
 
@@ -994,6 +1323,7 @@ function createSessionFetcher(
994
1323
  hops,
995
1324
  reason: "max_hops",
996
1325
  cookies: cookieJar.snapshot(),
1326
+ cookieStore: cookieJar.serialize(),
997
1327
  };
998
1328
  }
999
1329
 
@@ -1007,6 +1337,7 @@ function createSessionFetcher(
1007
1337
  hops,
1008
1338
  reason: "loop",
1009
1339
  cookies: cookieJar.snapshot(),
1340
+ cookieStore: cookieJar.serialize(),
1010
1341
  };
1011
1342
  }
1012
1343
  method = nextMethod;
@@ -1028,6 +1359,7 @@ function createSessionFetcher(
1028
1359
  hops,
1029
1360
  reason: "max_hops",
1030
1361
  cookies: cookieJar.snapshot(),
1362
+ cookieStore: cookieJar.serialize(),
1031
1363
  };
1032
1364
  },
1033
1365
  },