@apifuse/provider-sdk 2.2.0-beta.48 → 2.2.0-beta.49

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 (108) hide show
  1. package/AUTHORING.md +91 -36
  2. package/CHANGELOG.md +4 -0
  3. package/README.md +11 -9
  4. package/SUBMISSION.md +1 -1
  5. package/bin/apifuse-dev.ts +24 -13
  6. package/bin/apifuse-migrate-operation-declaration.ts +55 -0
  7. package/bin/apifuse-pack-smoke.ts +1 -1
  8. package/bin/apifuse-pack-types.ts +2 -1
  9. package/bin/apifuse-record.ts +30 -16
  10. package/bin/apifuse-submit-check.ts +20 -35
  11. package/dist/cli/commands.d.ts +1 -1
  12. package/dist/cli/commands.js +11 -0
  13. package/dist/cli/migrate-operation-declaration.d.ts +59 -0
  14. package/dist/cli/migrate-operation-declaration.js +1178 -0
  15. package/dist/cli/templates/provider/README.md.tpl +3 -3
  16. package/dist/cli/templates/provider/operations/ping.ts.tpl +2 -0
  17. package/dist/config/loader.d.ts +2 -0
  18. package/dist/config/loader.js +18 -7
  19. package/dist/contract-types.d.ts +11 -5
  20. package/dist/contract.js +21 -10
  21. package/dist/define.d.ts +25 -22
  22. package/dist/define.js +49 -75
  23. package/dist/dev.d.ts +3 -0
  24. package/dist/dev.js +1 -1
  25. package/dist/engine.d.ts +78 -0
  26. package/dist/engine.js +133 -0
  27. package/dist/index.d.ts +3 -2
  28. package/dist/index.js +2 -1
  29. package/dist/lint.d.ts +7 -15
  30. package/dist/lint.js +45 -70
  31. package/dist/provider.d.ts +3 -1
  32. package/dist/provider.js +1 -0
  33. package/dist/runtime/chrome149-header-order.d.ts +58 -0
  34. package/dist/runtime/chrome149-header-order.js +289 -0
  35. package/dist/runtime/env.js +12 -0
  36. package/dist/runtime/executor.d.ts +2 -1
  37. package/dist/runtime/executor.js +3 -36
  38. package/dist/runtime/insights.js +2 -2
  39. package/dist/runtime/otlp.d.ts +71 -2
  40. package/dist/runtime/otlp.js +397 -16
  41. package/dist/runtime/resolver-vendors/capsolver.js +3 -3
  42. package/dist/runtime/resolver.js +3 -3
  43. package/dist/runtime/stealth.d.ts +13 -4
  44. package/dist/runtime/stealth.js +362 -85
  45. package/dist/runtime/trace-config.js +2 -1
  46. package/dist/runtime/trace.d.ts +5 -0
  47. package/dist/runtime/trace.js +43 -10
  48. package/dist/server/self-test.d.ts +1 -3
  49. package/dist/server/self-test.js +2 -12
  50. package/dist/server/serve-implementation.d.ts +6 -1
  51. package/dist/server/serve-implementation.js +55 -40
  52. package/dist/server/trace-output.d.ts +3 -1
  53. package/dist/server/trace-output.js +61 -2
  54. package/dist/stealth/profiles.d.ts +9 -8
  55. package/dist/stealth/profiles.js +123 -286
  56. package/dist/types.d.ts +111 -108
  57. package/package.json +2 -1
  58. package/src/cli/__tests__/fixtures/migrate-operation-declaration/approval-override.ts.txt +6 -0
  59. package/src/cli/__tests__/fixtures/migrate-operation-declaration/codemod-syntax.ts.txt +3 -0
  60. package/src/cli/__tests__/fixtures/migrate-operation-declaration/connection-precedence.ts.txt +10 -0
  61. package/src/cli/__tests__/fixtures/migrate-operation-declaration/docs-conflict.ts.txt +8 -0
  62. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-map.ts.txt +5 -0
  63. package/src/cli/__tests__/fixtures/migrate-operation-declaration/examples-operation.ts.txt +16 -0
  64. package/src/cli/__tests__/fixtures/migrate-operation-declaration/factory-map.ts.txt +3 -0
  65. package/src/cli/__tests__/fixtures/migrate-operation-declaration/hoist-all.ts.txt +31 -0
  66. package/src/cli/__tests__/fixtures/migrate-operation-declaration/hoisted-const.ts.txt +11 -0
  67. package/src/cli/__tests__/fixtures/migrate-operation-declaration/imported-spread.ts.txt +11 -0
  68. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-map.ts.txt +11 -0
  69. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-cast-tail.ts.txt +21 -0
  70. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-ekitan.ts.txt +11 -0
  71. package/src/cli/__tests__/fixtures/migrate-operation-declaration/inline-spread-override.ts.txt +14 -0
  72. package/src/cli/__tests__/fixtures/migrate-operation-declaration/missing-english-locale.ts.txt +7 -0
  73. package/src/cli/__tests__/fixtures/migrate-operation-declaration/no-safety.ts.txt +6 -0
  74. package/src/cli/__tests__/fixtures/migrate-operation-declaration/non-literal.ts.txt +7 -0
  75. package/src/cli/__tests__/fixtures/migrate-operation-declaration/redundant-approval.ts.txt +6 -0
  76. package/src/cli/__tests__/fixtures/migrate-operation-declaration/safety-conflict.ts.txt +7 -0
  77. package/src/cli/__tests__/fixtures/migrate-operation-declaration/stream.ts.txt +7 -0
  78. package/src/cli/__tests__/fixtures/migrate-operation-declaration/tool-router-spread.ts.txt +15 -0
  79. package/src/cli/__tests__/fixtures/migrate-operation-declaration/unparseable.ts.txt +4 -0
  80. package/src/cli/__tests__/fixtures/migrate-operation-declaration/verbatim-template.ts.txt +12 -0
  81. package/src/cli/commands.ts +13 -0
  82. package/src/cli/migrate-operation-declaration.ts +1654 -0
  83. package/src/cli/templates/provider/README.md.tpl +3 -3
  84. package/src/cli/templates/provider/operations/ping.ts.tpl +2 -0
  85. package/src/config/loader.ts +31 -6
  86. package/src/contract-types.ts +11 -5
  87. package/src/contract.ts +21 -10
  88. package/src/define.ts +107 -119
  89. package/src/dev.ts +4 -1
  90. package/src/engine.ts +279 -0
  91. package/src/index.ts +13 -5
  92. package/src/lint.ts +58 -92
  93. package/src/provider.ts +25 -3
  94. package/src/runtime/chrome149-header-order.ts +330 -0
  95. package/src/runtime/env.ts +13 -0
  96. package/src/runtime/executor.ts +7 -40
  97. package/src/runtime/insights.ts +2 -2
  98. package/src/runtime/otlp.ts +467 -21
  99. package/src/runtime/resolver-vendors/capsolver.ts +4 -3
  100. package/src/runtime/resolver.ts +3 -3
  101. package/src/runtime/stealth.ts +435 -103
  102. package/src/runtime/trace-config.ts +3 -2
  103. package/src/runtime/trace.ts +57 -17
  104. package/src/server/self-test.ts +2 -9
  105. package/src/server/serve-implementation.ts +89 -72
  106. package/src/server/trace-output.ts +99 -2
  107. package/src/stealth/profiles.ts +169 -327
  108. package/src/types.ts +109 -137
@@ -18,16 +18,23 @@ import {
18
18
  vendorFromResolvedSource,
19
19
  } from "../config/loader.js";
20
20
  import { SDKError, TransportError } from "../errors.js";
21
- import { getStealthProfile, getStealthProfileIntentAlias } from "../stealth/profiles.js";
21
+ import {
22
+ DEFAULT_STEALTH_BROWSER,
23
+ DEFAULT_STEALTH_OS,
24
+ getStealthProfile,
25
+ resolveStealthProfileSelection,
26
+ } from "../stealth/profiles.js";
22
27
  import type {
23
28
  HttpMethod,
24
29
  StealthClient,
25
30
  StealthFetchOptions,
31
+ StealthProfileDescriptor,
32
+ StealthProfileSelection,
26
33
  StealthRedirectHop,
27
34
  StealthResponse,
28
35
  StealthSession,
29
36
  } from "../types.js";
30
- import { StealthCookieJar } from "./stealth-cookies.js";
37
+ import { chrome149HeaderOrder } from "./chrome149-header-order.js";
31
38
  import {
32
39
  createProxyAuthIpDeniedError,
33
40
  createProxyEdgeAuthRejectedError,
@@ -66,8 +73,12 @@ import {
66
73
  redactUrlQueryParams,
67
74
  serializeRequestUrl,
68
75
  } from "./request-options.js";
76
+ import { StealthCookieJar } from "./stealth-cookies.js";
69
77
 
70
- export const DEFAULT_PROFILE = "chrome-desktop";
78
+ export const DEFAULT_STEALTH_PROFILE: StealthProfileDescriptor = Object.freeze({
79
+ browser: DEFAULT_STEALTH_BROWSER,
80
+ os: DEFAULT_STEALTH_OS,
81
+ });
71
82
 
72
83
  const MISSING_PROXY_WARNING =
73
84
  "[provider-sdk] Provider requested proxy routing, but no proxy URL was configured. Continuing without proxy.";
@@ -103,6 +114,15 @@ export type StealthClientOptions = ProxyResolutionOptions & {
103
114
  warn?: (message: string) => void;
104
115
  /** Abort all requests issued by this client. */
105
116
  signal?: AbortSignal;
117
+ /** Browser identity and declaration-wide HTTP language defaults. */
118
+ stealth?: StealthProfileSelection & {
119
+ /**
120
+ * Default Accept-Language value, emitted where Chrome's network layer appends
121
+ * it (after Accept-Encoding). Requests may override it through `headers`, in
122
+ * which case it is placed like any other caller header.
123
+ */
124
+ acceptLanguage?: string;
125
+ };
106
126
  /**
107
127
  * Proxy-only stealth transport overrides. Use only for upstream proxy products
108
128
  * that terminate CONNECT with a private CA instead of tunneling the origin
@@ -111,20 +131,6 @@ export type StealthClientOptions = ProxyResolutionOptions & {
111
131
  proxyStealth?: { insecureSkipVerify?: boolean };
112
132
  };
113
133
 
114
- const REMOVED_CHROME_PROFILE_NAMES = new Set([
115
- "chrome-120",
116
- "chrome-124",
117
- "chrome-129",
118
- "chrome-130",
119
- "chrome-131",
120
- "chrome-133",
121
- "chrome-144",
122
- "chrome-146-psk",
123
- "chrome-131-psk",
124
- "chrome-130-psk",
125
- "edge-131",
126
- ]);
127
-
128
134
  type StealthTransportHeaders = {
129
135
  entries(): IterableIterator<[string, string]>;
130
136
  get(name: string): string | null;
@@ -226,64 +232,21 @@ function closestWreqProfile(
226
232
  return closest?.name;
227
233
  }
228
234
 
229
- function resolveDefaultWreqProfileMapping(): { identifier: string; os: EmulationOS } {
230
- let profile: ReturnType<typeof getStealthProfile>;
231
- try {
232
- profile = getStealthProfile(DEFAULT_PROFILE);
233
- } catch (error) {
234
- throw new SDKError(
235
- `Default stealth profile "${DEFAULT_PROFILE}" cannot be mapped to a wreq-js browser profile.`,
236
- { cause: error instanceof Error ? error : undefined },
237
- );
238
- }
239
-
240
- const identifier = profile.tlsClientIdentifier?.toLowerCase() ?? "";
241
- if (!parseProfileIdentifier(identifier)) {
242
- throw new SDKError(
243
- `Default stealth profile "${DEFAULT_PROFILE}" cannot be mapped to a wreq-js browser profile.`,
244
- );
245
- }
246
- return { identifier, os: profile.platform };
247
- }
248
-
249
- let defaultWreqProfileMapping: ReturnType<typeof resolveDefaultWreqProfileMapping> | undefined;
250
-
251
- function getDefaultWreqProfileMapping(): ReturnType<typeof resolveDefaultWreqProfileMapping> {
252
- defaultWreqProfileMapping ??= resolveDefaultWreqProfileMapping();
253
- return defaultWreqProfileMapping;
254
- }
255
-
256
235
  export function resolveWreqProfile(
257
- profileName: string,
236
+ selection: StealthProfileSelection,
258
237
  wreqProfiles: readonly BrowserProfile[],
259
238
  ): {
260
239
  browser: BrowserProfile;
261
240
  os: EmulationOS;
262
241
  } {
263
- if (REMOVED_CHROME_PROFILE_NAMES.has(profileName)) {
264
- throw new SDKError(`Unknown stealth profile: ${profileName}`);
265
- }
266
-
267
- let identifier: string;
268
- let os: EmulationOS;
269
- try {
270
- const profile = getStealthProfile(profileName);
271
- identifier = profile.tlsClientIdentifier?.toLowerCase() ?? "";
272
- os = profile.platform;
273
- } catch {
274
- // Preserve the previous ctx.stealth.fetch() compatibility behavior: unknown
275
- // profile strings still run with the transport default instead of failing
276
- // before the request starts. Removed built-in profile aliases above remain
277
- // explicit errors so callers do not accidentally pin retired fingerprints.
278
- const defaultMapping = getDefaultWreqProfileMapping();
279
- identifier = defaultMapping.identifier;
280
- os = defaultMapping.os;
281
- }
242
+ const profile = getStealthProfile(selection);
243
+ const identifier = profile.tlsClientIdentifier?.toLowerCase() ?? "";
244
+ const os = profile.os;
282
245
 
283
246
  const browser = closestWreqProfile(identifier, wreqProfiles);
284
247
  if (!browser) {
285
248
  throw new SDKError(
286
- `Stealth profile "${profileName}" cannot be mapped to a wreq-js browser profile.`,
249
+ `Stealth profile ${profile.browser}/${profile.os} cannot be mapped to a wreq-js browser profile.`,
287
250
  );
288
251
  }
289
252
  return { browser, os };
@@ -297,15 +260,337 @@ function headerEntriesFromHeaders(headers: StealthTransportHeaders): [string, st
297
260
  return Array.from(headers.entries());
298
261
  }
299
262
 
263
+ type HeaderTuple = [string, string];
264
+
300
265
  function normalizeHeaders(
301
266
  headers: Record<string, string | string[] | undefined>,
302
267
  ): Record<string, string> {
303
- const normalized: Record<string, string> = {};
304
- for (const [name, value] of Object.entries(headers)) {
268
+ return Object.fromEntries(normalizedHeaderEntries(headers));
269
+ }
270
+
271
+ function trimOuterHttpWhitespace(value: string): string {
272
+ return value.replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, "");
273
+ }
274
+
275
+ function normalizedHeaderEntries(
276
+ headers: Record<string, string | string[] | undefined>,
277
+ ): HeaderTuple[] {
278
+ const entries: HeaderTuple[] = [];
279
+ const indices = new Map<string, number>();
280
+ for (const [originalName, originalValue] of Object.entries(headers)) {
281
+ if (originalValue === undefined) continue;
282
+ const name = originalName.toLowerCase();
283
+ const value = (Array.isArray(originalValue) ? originalValue : [originalValue])
284
+ .map(trimOuterHttpWhitespace)
285
+ .join(", ");
286
+ const existingIndex = indices.get(name);
287
+ if (existingIndex === undefined) {
288
+ indices.set(name, entries.length);
289
+ entries.push([name, value]);
290
+ continue;
291
+ }
292
+ const existing = entries[existingIndex];
293
+ if (existing) existing[1] = `${existing[1]}, ${value}`;
294
+ }
295
+ return entries;
296
+ }
297
+
298
+ const SDK_OWNED_EXACT_CHROME_HEADERS = new Set([
299
+ "host",
300
+ "connection",
301
+ "user-agent",
302
+ "sec-ch-ua",
303
+ "sec-ch-ua-mobile",
304
+ "sec-ch-ua-platform",
305
+ "accept-encoding",
306
+ ]);
307
+
308
+ // Non-pseudo-header order of real Chrome 149. The fixture captures
309
+ // (chrome-ground-truth-capture.json, chrome-extended-capture.json,
310
+ // h1-casing-capture.json) were taken through Playwright's `locale` option, which
311
+ // installs Accept-Language via DevTools next to User-Agent; real Chrome only
312
+ // receives it from //net (URLRequestHttpJob::AddExtraHeaders), after
313
+ // Accept-Encoding and before Cookie, as al-placement-capture.json B/C show.
314
+ const CHROME_HEADER_ORDERS = {
315
+ navigation: [
316
+ "sec-ch-ua",
317
+ "sec-ch-ua-mobile",
318
+ "sec-ch-ua-platform",
319
+ "upgrade-insecure-requests",
320
+ "user-agent",
321
+ "accept",
322
+ "sec-fetch-site",
323
+ "sec-fetch-mode",
324
+ "sec-fetch-user",
325
+ "sec-fetch-dest",
326
+ "accept-encoding",
327
+ "accept-language",
328
+ "cookie",
329
+ "priority",
330
+ ],
331
+ // xhr has no fixed table: it is emulated by chrome149HeaderOrder.
332
+ post: [
333
+ "content-length",
334
+ "sec-ch-ua-platform",
335
+ "user-agent",
336
+ "sec-ch-ua",
337
+ "content-type",
338
+ "sec-ch-ua-mobile",
339
+ "accept",
340
+ "origin",
341
+ "sec-fetch-site",
342
+ "sec-fetch-mode",
343
+ "sec-fetch-dest",
344
+ "referer",
345
+ "accept-encoding",
346
+ "accept-language",
347
+ "cookie",
348
+ "priority",
349
+ ],
350
+ } as const;
351
+
352
+ const CHROME_H1_HEADER_ORDERS = {
353
+ navigation: [
354
+ "host",
355
+ "connection",
356
+ "sec-ch-ua",
357
+ "sec-ch-ua-mobile",
358
+ "sec-ch-ua-platform",
359
+ "upgrade-insecure-requests",
360
+ "user-agent",
361
+ "accept",
362
+ "sec-fetch-site",
363
+ "sec-fetch-mode",
364
+ "sec-fetch-user",
365
+ "sec-fetch-dest",
366
+ "accept-encoding",
367
+ "accept-language",
368
+ "cookie",
369
+ ],
370
+ xhr: [
371
+ "host",
372
+ "connection",
373
+ "sec-ch-ua-platform",
374
+ "cache-control",
375
+ "x-requested-with",
376
+ "user-agent",
377
+ "sec-ch-ua",
378
+ "sec-ch-ua-mobile",
379
+ "accept",
380
+ "sec-fetch-site",
381
+ "sec-fetch-mode",
382
+ "sec-fetch-dest",
383
+ "referer",
384
+ "accept-encoding",
385
+ "accept-language",
386
+ "cookie",
387
+ ],
388
+ post: [
389
+ "host",
390
+ "connection",
391
+ "content-length",
392
+ "sec-ch-ua-platform",
393
+ "user-agent",
394
+ "sec-ch-ua",
395
+ "content-type",
396
+ "sec-ch-ua-mobile",
397
+ "accept",
398
+ "origin",
399
+ "sec-fetch-site",
400
+ "sec-fetch-mode",
401
+ "sec-fetch-dest",
402
+ "referer",
403
+ "accept-encoding",
404
+ "accept-language",
405
+ "cookie",
406
+ ],
407
+ } as const;
408
+
409
+ const CHROME_H1_HEADER_NAMES: Record<string, string> = {
410
+ host: "Host",
411
+ connection: "Connection",
412
+ "upgrade-insecure-requests": "Upgrade-Insecure-Requests",
413
+ "user-agent": "User-Agent",
414
+ "accept-language": "Accept-Language",
415
+ accept: "Accept",
416
+ "sec-fetch-site": "Sec-Fetch-Site",
417
+ "sec-fetch-mode": "Sec-Fetch-Mode",
418
+ "sec-fetch-user": "Sec-Fetch-User",
419
+ "sec-fetch-dest": "Sec-Fetch-Dest",
420
+ "accept-encoding": "Accept-Encoding",
421
+ cookie: "Cookie",
422
+ "cache-control": "Cache-Control",
423
+ "x-requested-with": "X-Requested-With",
424
+ referer: "Referer",
425
+ "content-length": "Content-Length",
426
+ "content-type": "Content-Type",
427
+ origin: "Origin",
428
+ range: "Range",
429
+ };
430
+
431
+ type ChromeRequestClass = keyof typeof CHROME_HEADER_ORDERS | "xhr";
432
+
433
+ function chromeHeaderOrder(
434
+ requestClass: ChromeRequestClass,
435
+ isHttp1: boolean,
436
+ caller: ReadonlyMap<string, string>,
437
+ ): readonly string[] {
438
+ if (isHttp1) return CHROME_H1_HEADER_ORDERS[requestClass];
439
+ if (requestClass !== "xhr") return CHROME_HEADER_ORDERS[requestClass];
440
+ // Cookie and Referer are forbidden Fetch headers, so they never enter the page
441
+ // Fetch Headers map the emulator models. Range does occupy a map bucket
442
+ // (m1-capture.json range_*), but HttpCache::Transaction removes it from the
443
+ // request headers and PartialData re-adds it at the tail.
444
+ const order = chrome149HeaderOrder(
445
+ [...caller.keys()].filter((name) => name !== "cookie" && name !== "referer"),
446
+ );
447
+ if (caller.has("range")) order.splice(order.indexOf("range"), 1);
448
+ // //net appends Accept-Encoding and, unless the caller supplied one,
449
+ // Accept-Language (URLRequestHttpJob::AddExtraHeaders), then writes the Cookie
450
+ // header (SetCookieHeaderAndStart); the HTTP cache re-adds a caller Range after
451
+ // that, and the HTTP/2 Priority header stays last.
452
+ let insertAt = order.indexOf("accept-encoding") + 1;
453
+ if (order[insertAt] === "accept-language") insertAt += 1;
454
+ for (const name of ["cookie", "range"] as const) {
455
+ if (!caller.has(name)) continue;
456
+ order.splice(insertAt, 0, name);
457
+ insertAt += 1;
458
+ }
459
+ return order;
460
+ }
461
+
462
+ function normalizedCallerHeaderEntries(
463
+ headers: Record<string, string | string[] | undefined>,
464
+ ): HeaderTuple[] {
465
+ const entries = normalizedHeaderEntries(headers);
466
+ assertCallerHeadersSupported(entries);
467
+ return entries;
468
+ }
469
+
470
+ function normalizedCallerHeaderEntriesFromRecord(headers: Record<string, string>): HeaderTuple[] {
471
+ // The record has been through normalizeHeaders (lowercase, merged names); the
472
+ // lowercase here only guards later insertions such as the cookie jar's header.
473
+ const entries: HeaderTuple[] = Object.entries(headers).map(([name, value]) => [
474
+ name.toLowerCase(),
475
+ value,
476
+ ]);
477
+ assertCallerHeadersSupported(entries);
478
+ return entries;
479
+ }
480
+
481
+ function assertCallerHeadersSupported(entries: readonly HeaderTuple[]): void {
482
+ for (const [name] of entries) {
483
+ if (SDK_OWNED_EXACT_CHROME_HEADERS.has(name) || name.startsWith("sec-fetch-")) {
484
+ throw new SDKError(`Stealth transport owns the "${name}" header; remove it from headers.`, {
485
+ code: "STEALTH_HEADER_OVERRIDE_UNSUPPORTED",
486
+ });
487
+ }
488
+ }
489
+ }
490
+
491
+ function chromeRequestClass(
492
+ method: StealthMethod,
493
+ requestedClass?: ChromeRequestClass,
494
+ ): ChromeRequestClass {
495
+ if (requestedClass) return requestedClass;
496
+ if (method === "POST") return "post";
497
+ return "navigation";
498
+ }
499
+
500
+ function secFetchSite(requestUrl: string, referer: string | undefined): string {
501
+ if (!referer) return "none";
502
+ try {
503
+ return new URL(referer).origin === new URL(requestUrl).origin ? "same-origin" : "cross-site";
504
+ } catch {
505
+ return "cross-site";
506
+ }
507
+ }
508
+
509
+ function requiredEmulationHeader(headers: ReadonlyMap<string, string>, name: string): string {
510
+ const value = headers.get(name);
511
+ if (value !== undefined) return value;
512
+ throw new SDKError(`wreq-js Chrome emulation exposes no ${name} header.`, {
513
+ code: "STEALTH_PROFILE_UNAVAILABLE",
514
+ });
515
+ }
516
+
517
+ function buildChromeHeaderTuples(options: {
518
+ emulationHeaders: Iterable<[string, string]>;
519
+ method: StealthMethod;
520
+ body?: string;
521
+ headers: Record<string, string>;
522
+ requestUrl: string;
523
+ acceptLanguage?: string;
524
+ requestClass?: ChromeRequestClass;
525
+ }): HeaderTuple[] {
526
+ const callerEntries = normalizedCallerHeaderEntriesFromRecord(options.headers);
527
+ const caller = new Map(callerEntries);
528
+ const emulation = new Map(
529
+ Array.from(
530
+ options.emulationHeaders,
531
+ ([name, value]) => [name.toLowerCase(), value] as HeaderTuple,
532
+ ),
533
+ );
534
+ const requestClass = chromeRequestClass(options.method, options.requestClass);
535
+ const referer = caller.get("referer");
536
+ const fetchSite = secFetchSite(options.requestUrl, referer);
537
+ const isNavigation = requestClass === "navigation";
538
+ const values = new Map<string, string>([
539
+ ["sec-ch-ua", requiredEmulationHeader(emulation, "sec-ch-ua")],
540
+ ["sec-ch-ua-mobile", requiredEmulationHeader(emulation, "sec-ch-ua-mobile")],
541
+ ["sec-ch-ua-platform", requiredEmulationHeader(emulation, "sec-ch-ua-platform")],
542
+ ["user-agent", requiredEmulationHeader(emulation, "user-agent")],
543
+ [
544
+ "accept-language",
545
+ caller.get("accept-language") ??
546
+ options.acceptLanguage ??
547
+ requiredEmulationHeader(emulation, "accept-language"),
548
+ ],
549
+ ["accept", isNavigation ? requiredEmulationHeader(emulation, "accept") : "*/*"],
550
+ [
551
+ "accept-encoding",
552
+ caller.has("range") ? "identity" : requiredEmulationHeader(emulation, "accept-encoding"),
553
+ ],
554
+ ["priority", isNavigation ? requiredEmulationHeader(emulation, "priority") : "u=1, i"],
555
+ ["sec-fetch-site", fetchSite],
556
+ ["sec-fetch-mode", isNavigation ? "navigate" : "cors"],
557
+ ["sec-fetch-dest", isNavigation ? "document" : "empty"],
558
+ ]);
559
+ if (isNavigation) {
560
+ values.set("upgrade-insecure-requests", "1");
561
+ values.set("sec-fetch-user", "?1");
562
+ }
563
+ for (const [name, value] of callerEntries) values.set(name, value);
564
+ for (const name of ["content-type", "origin", "referer"] as const) {
565
+ const value = caller.get(name);
566
+ if (value !== undefined) values.set(name, value);
567
+ }
568
+ if (requestClass === "post") {
569
+ values.set(
570
+ "content-length",
571
+ caller.get("content-length") ?? String(Buffer.byteLength(options.body ?? "")),
572
+ );
573
+ }
574
+
575
+ const isHttp1 = new URL(options.requestUrl).protocol === "http:";
576
+ if (isHttp1) {
577
+ values.set("host", new URL(options.requestUrl).host);
578
+ values.set("connection", "keep-alive");
579
+ }
580
+ const order = chromeHeaderOrder(requestClass, isHttp1, caller);
581
+ const tuples: HeaderTuple[] = [];
582
+ const placed = new Set<string>();
583
+ for (const name of order) {
584
+ const value = values.get(name);
305
585
  if (value === undefined) continue;
306
- normalized[name] = Array.isArray(value) ? value.join(", ") : value;
586
+ tuples.push([isHttp1 ? (CHROME_H1_HEADER_NAMES[name] ?? name) : name, value]);
587
+ placed.add(name);
588
+ }
589
+ for (const [name, value] of callerEntries) {
590
+ if (placed.has(name)) continue;
591
+ tuples.push([isHttp1 ? (CHROME_H1_HEADER_NAMES[name] ?? name) : name, value]);
307
592
  }
308
- return normalized;
593
+ return tuples;
309
594
  }
310
595
 
311
596
  function hasOwn(object: object, key: string): boolean {
@@ -322,7 +607,7 @@ function assertNoUnsupportedFingerprintOverrides(options: unknown): void {
322
607
  if (unsupported.length === 0) return;
323
608
 
324
609
  throw new SDKError(
325
- `ctx.stealth.fetch uses transport-managed browser fingerprints and no longer accepts low-level stealth overrides: ${unsupported.join(", ")}. Use the profile option instead.`,
610
+ `ctx.stealth.fetch uses transport-managed browser fingerprints and no longer accepts low-level stealth overrides: ${unsupported.join(", ")}. Use stealth.browser and stealth.os instead.`,
326
611
  );
327
612
  }
328
613
 
@@ -811,11 +1096,17 @@ async function fetchStealthRedirectChain(
811
1096
  method: StealthMethod,
812
1097
  options: StealthFetchOptions,
813
1098
  signal?: AbortSignal,
1099
+ buildHeaders?: (
1100
+ url: string,
1101
+ method: StealthMethod,
1102
+ body: string | undefined,
1103
+ headers: Record<string, string>,
1104
+ ) => HeaderTuple[],
814
1105
  ): Promise<{ normalized: StealthResponse; response: StealthTransportResponse }> {
815
1106
  let currentUrl = requestUrl;
816
1107
  let currentMethod = method;
817
1108
  let currentBody = options.body === undefined ? undefined : normalizeBody(options.body);
818
- let currentHeaders = { ...(options.headers ?? {}) };
1109
+ let currentHeaders = normalizeHeaders({ ...(options.headers ?? {}) });
819
1110
  let followedHops = 0;
820
1111
  let response: StealthTransportResponse;
821
1112
  const deadline = options.timeout ? performance.now() + options.timeout : undefined;
@@ -823,14 +1114,17 @@ async function fetchStealthRedirectChain(
823
1114
  while (true) {
824
1115
  throwIfAmbientAborted(signal);
825
1116
  const headers = { ...currentHeaders };
826
- if (!hasHeader(headers, "Cookie")) {
1117
+ if (!hasHeader(headers, "cookie")) {
827
1118
  const cookieHeader = cookieJar.toHeader(currentUrl);
828
- if (cookieHeader) headers.Cookie = cookieHeader;
1119
+ if (cookieHeader) headers.cookie = cookieHeader;
829
1120
  }
830
1121
  const requestInit: StealthRequestInit = {
831
- headers: normalizeHeaders(headers),
1122
+ headers: buildHeaders
1123
+ ? buildHeaders(currentUrl, currentMethod, currentBody, headers)
1124
+ : headers,
832
1125
  method: currentMethod,
833
1126
  redirect: "manual",
1127
+ ...(new URL(currentUrl).protocol === "http:" ? { disableDefaultHeaders: true } : {}),
834
1128
  ...(signal ? { signal } : {}),
835
1129
  };
836
1130
  if (currentBody !== undefined) requestInit.body = currentBody;
@@ -901,38 +1195,40 @@ async function fetchStealthRedirectChain(
901
1195
 
902
1196
  function createSessionFetcher(
903
1197
  baseUrl: string,
904
- defaultProfile: string,
1198
+ defaultProfile: StealthProfileDescriptor,
905
1199
  clientOptions: StealthClientOptions,
906
1200
  ): StealthSession {
907
1201
  const clients = new Map<string, WreqSessionCacheEntry>();
908
1202
  let closed = false;
909
1203
  let hasWarnedMissingProxy = false;
910
1204
  const warn = clientOptions.warn ?? console.warn;
911
- const intentAlias = getStealthProfileIntentAlias(defaultProfile);
912
- if (intentAlias) {
913
- warn(
914
- `[provider-sdk] Stealth profile "${defaultProfile}" pins a browser version and is deprecated. Use the intent profile "${intentAlias}" so TLS, headers, and User-Agent stay aligned with SDK updates; derive an explicit User-Agent with getStealthProfile("${intentAlias}").userAgent instead of hardcoding one.`,
915
- );
916
- }
917
1205
  const cookieJar = new StealthCookieJar([], baseUrl);
918
1206
 
919
1207
  async function getClientEntry(
920
- profileName: string,
1208
+ profile: StealthProfileDescriptor,
921
1209
  proxyUrl: string | undefined,
922
1210
  ignoreTlsErrors: boolean,
1211
+ defaultHeaders?: HeaderTuple[],
923
1212
  ): Promise<WreqSessionCacheEntry> {
924
1213
  if (closed) {
925
1214
  throw new TransportError("Stealth session is closed", { status: 0 });
926
1215
  }
927
1216
  const wreq = await getWreqModule();
928
- const { browser, os } = resolveWreqProfile(profileName, wreq.getProfiles());
929
- const cacheKey = JSON.stringify({ browser, proxyUrl, ignoreTlsErrors });
1217
+ const { browser, os } = resolveWreqProfile(profile, wreq.getProfiles());
1218
+ const cacheKey = JSON.stringify({
1219
+ browser,
1220
+ os,
1221
+ proxyUrl,
1222
+ ignoreTlsErrors,
1223
+ headerOrder: defaultHeaders?.map(([name]) => name),
1224
+ });
930
1225
  let entry = clients.get(cacheKey);
931
1226
  if (!entry) {
932
1227
  entry = {
933
1228
  session: wreq.createSession({
934
1229
  browser,
935
1230
  os,
1231
+ ...(defaultHeaders ? { defaultHeaders } : {}),
936
1232
  ...(proxyUrl ? { proxy: proxyUrl } : {}),
937
1233
  ...(ignoreTlsErrors ? { insecure: true } : {}),
938
1234
  timeout: 30_000,
@@ -945,14 +1241,15 @@ function createSessionFetcher(
945
1241
  }
946
1242
 
947
1243
  async function withClient<T>(
948
- profileName: string,
1244
+ profile: StealthProfileDescriptor,
949
1245
  proxyUrl: string | undefined,
950
1246
  ignoreTlsErrors: boolean,
951
1247
  operation: (client: WreqSession) => Promise<T>,
952
1248
  signal?: AbortSignal,
1249
+ defaultHeaders?: HeaderTuple[],
953
1250
  ): Promise<T> {
954
1251
  throwIfAmbientAborted(signal);
955
- const entry = await getClientEntry(profileName, proxyUrl, ignoreTlsErrors);
1252
+ const entry = await getClientEntry(profile, proxyUrl, ignoreTlsErrors, defaultHeaders);
956
1253
  throwIfAmbientAborted(signal);
957
1254
  const previous = entry.tail;
958
1255
  let release!: () => void;
@@ -1038,9 +1335,11 @@ function createSessionFetcher(
1038
1335
 
1039
1336
  const session: StealthSession = {
1040
1337
  async fetch(url, options: StealthFetchOptions = {}) {
1338
+ const requestProfile = resolveStealthProfileSelection(options.stealth, defaultProfile);
1041
1339
  const { hasExplicitRetryPolicy, method, stealthRetryOptions } = (() => {
1042
1340
  try {
1043
1341
  const method = normalizeMethod(options.method ?? "GET");
1342
+ normalizedCallerHeaderEntries(options.headers ?? {});
1044
1343
  const hasExplicitRetryPolicy = options.retry !== undefined;
1045
1344
  const stealthRetryOptions =
1046
1345
  normalizeProxyTransportRetryOptions(options.retry, {
@@ -1163,15 +1462,50 @@ function createSessionFetcher(
1163
1462
  options.stealth?.insecureSkipVerify ??
1164
1463
  (!hasPolicyProxy && proxy && clientOptions.proxyStealth?.insecureSkipVerify),
1165
1464
  );
1166
- const profileName = options.profile ?? defaultProfile;
1167
1465
  serializedUrl = serializeRequestUrl(
1168
1466
  resolveUrl(baseUrl, url),
1169
1467
  options.params,
1170
1468
  sensitiveParams,
1171
1469
  );
1172
1470
  const { requestUrl } = serializedUrl;
1471
+ const wreq = await getWreqModule();
1472
+ const mapping = resolveWreqProfile(requestProfile, wreq.getProfiles());
1473
+ const chromeEmulationHeaders = mapping.browser.startsWith("chrome_")
1474
+ ? Array.from(
1475
+ wreq.getEmulationHeaders(mapping.browser, mapping.os),
1476
+ ([name, value]) => [String(name), String(value)] as HeaderTuple,
1477
+ )
1478
+ : undefined;
1479
+ const buildOrderedHeaders = chromeEmulationHeaders
1480
+ ? (
1481
+ currentUrl: string,
1482
+ currentMethod: StealthMethod,
1483
+ currentBody: string | undefined,
1484
+ currentHeaders: Record<string, string>,
1485
+ ) =>
1486
+ buildChromeHeaderTuples({
1487
+ emulationHeaders: chromeEmulationHeaders,
1488
+ method: currentMethod,
1489
+ body: currentBody,
1490
+ headers: currentHeaders,
1491
+ requestUrl: currentUrl,
1492
+ acceptLanguage: clientOptions.stealth?.acceptLanguage,
1493
+ requestClass: options.stealth?.requestClass,
1494
+ })
1495
+ : undefined;
1496
+ const initialHeaders = normalizeHeaders({ ...(options.headers ?? {}) });
1497
+ if (!hasHeader(initialHeaders, "cookie")) {
1498
+ const cookieHeader = cookieJar.toHeader(requestUrl);
1499
+ if (cookieHeader) initialHeaders.cookie = cookieHeader;
1500
+ }
1501
+ const defaultHeaders = buildOrderedHeaders?.(
1502
+ requestUrl,
1503
+ method,
1504
+ options.body === undefined ? undefined : normalizeBody(options.body),
1505
+ initialHeaders,
1506
+ );
1173
1507
  const { normalized, response } = await withClient(
1174
- profileName,
1508
+ requestProfile,
1175
1509
  proxy,
1176
1510
  ignoreTlsErrors,
1177
1511
  (transport) =>
@@ -1182,8 +1516,10 @@ function createSessionFetcher(
1182
1516
  method,
1183
1517
  options,
1184
1518
  clientOptions.signal,
1519
+ buildOrderedHeaders,
1185
1520
  ),
1186
1521
  clientOptions.signal,
1522
+ defaultHeaders,
1187
1523
  );
1188
1524
 
1189
1525
  if (proxy && isProxyConnectFailureResponse(response, normalized.body)) {
@@ -1337,10 +1673,7 @@ function createSessionFetcher(
1337
1673
 
1338
1674
  const proxyAuthDiagnostic =
1339
1675
  stalePoolError && stalePoolDiagnosticProxy
1340
- ? await classifyProxyAuthDiagnostic(
1341
- options.profile ?? defaultProfile,
1342
- stalePoolDiagnosticProxy,
1343
- )
1676
+ ? await classifyProxyAuthDiagnostic(requestProfile, stalePoolDiagnosticProxy)
1344
1677
  : undefined;
1345
1678
  if (proxyAuthDiagnostic === "source_ip_denied") {
1346
1679
  throw createProxyAuthIpDeniedError(
@@ -1587,12 +1920,12 @@ function createSessionFetcher(
1587
1920
  return session;
1588
1921
 
1589
1922
  async function classifyProxyAuthDiagnostic(
1590
- profileName: string,
1923
+ profile: StealthProfileDescriptor,
1591
1924
  proxy: string,
1592
1925
  ): Promise<"source_ip_denied" | "edge_auth_rejected" | undefined> {
1593
1926
  try {
1594
1927
  return await withClient(
1595
- profileName,
1928
+ profile,
1596
1929
  proxy,
1597
1930
  false,
1598
1931
  async (client) => {
@@ -1654,18 +1987,17 @@ function hasHeader(headers: Record<string, string>, name: string): boolean {
1654
1987
 
1655
1988
  export function createStealthClient(
1656
1989
  baseUrl: string,
1657
- defaultProfileOrOptions: string | StealthClientOptions = DEFAULT_PROFILE,
1658
1990
  clientOptions: StealthClientOptions = {},
1659
1991
  ): StealthClient {
1660
- const defaultProfile =
1661
- typeof defaultProfileOrOptions === "string" ? defaultProfileOrOptions : DEFAULT_PROFILE;
1662
- const resolvedClientOptions =
1663
- typeof defaultProfileOrOptions === "string" ? clientOptions : defaultProfileOrOptions;
1992
+ if (typeof clientOptions === "string") {
1993
+ resolveStealthProfileSelection(clientOptions as unknown as StealthProfileSelection);
1994
+ }
1995
+ const defaultProfile = resolveStealthProfileSelection(clientOptions.stealth);
1664
1996
  let sharedSession: StealthSession | null = null;
1665
1997
 
1666
1998
  function getSharedSession(): StealthSession {
1667
1999
  if (!sharedSession) {
1668
- sharedSession = createSessionFetcher(baseUrl, defaultProfile, resolvedClientOptions);
2000
+ sharedSession = createSessionFetcher(baseUrl, defaultProfile, clientOptions);
1669
2001
  }
1670
2002
 
1671
2003
  return sharedSession;
@@ -1675,9 +2007,9 @@ export function createStealthClient(
1675
2007
  fetch(url: string, options?: StealthFetchOptions) {
1676
2008
  return getSharedSession().fetch(url, options);
1677
2009
  },
1678
- createSession(opts?: { profile?: string }) {
1679
- const sessionProfile = opts?.profile ?? defaultProfile;
1680
- return createSessionFetcher(baseUrl, sessionProfile, resolvedClientOptions);
2010
+ createSession(opts?: { stealth?: StealthProfileSelection }) {
2011
+ const sessionProfile = resolveStealthProfileSelection(opts?.stealth, defaultProfile);
2012
+ return createSessionFetcher(baseUrl, sessionProfile, clientOptions);
1681
2013
  },
1682
2014
  close() {
1683
2015
  sharedSession?.close();