jumpy-lion 0.1.6-beta.7 → 1.0.0-beta.0

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 (232) hide show
  1. package/README.md +1 -607
  2. package/browser/fonts/macos-bundle/40-macos-aliases.conf +515 -0
  3. package/browser/fonts/macos-bundle/README.md +24 -0
  4. package/browser/fonts/macos-bundle/install.sh +83 -0
  5. package/dist/browser-controller.d.ts +47 -4
  6. package/dist/browser-controller.d.ts.map +1 -1
  7. package/dist/browser-controller.js +219 -278
  8. package/dist/browser-controller.js.map +1 -1
  9. package/dist/browser-plugin.d.ts +82 -110
  10. package/dist/browser-plugin.d.ts.map +1 -1
  11. package/dist/browser-plugin.js +376 -379
  12. package/dist/browser-plugin.js.map +1 -1
  13. package/dist/browser-process/align-fingerprint-config.d.ts +36 -0
  14. package/dist/browser-process/align-fingerprint-config.d.ts.map +1 -0
  15. package/dist/browser-process/align-fingerprint-config.js +74 -0
  16. package/dist/browser-process/align-fingerprint-config.js.map +1 -0
  17. package/dist/browser-process/anti-detect-browser.d.ts +2 -2
  18. package/dist/browser-process/anti-detect-browser.js +7 -7
  19. package/dist/browser-process/anti-detect-config.d.ts +131 -2
  20. package/dist/browser-process/anti-detect-config.d.ts.map +1 -1
  21. package/dist/browser-process/anti-detect-config.js +337 -127
  22. package/dist/browser-process/anti-detect-config.js.map +1 -1
  23. package/dist/browser-process/browser.d.ts +25 -92
  24. package/dist/browser-process/browser.d.ts.map +1 -1
  25. package/dist/browser-process/browser.js +429 -73
  26. package/dist/browser-process/browser.js.map +1 -1
  27. package/dist/browser-process/fingerprint-config.d.ts +146 -0
  28. package/dist/browser-process/fingerprint-config.d.ts.map +1 -0
  29. package/dist/browser-process/fingerprint-config.js +308 -0
  30. package/dist/browser-process/fingerprint-config.js.map +1 -0
  31. package/dist/browser-process/get-chrome-executable.d.ts +6 -0
  32. package/dist/browser-process/get-chrome-executable.d.ts.map +1 -1
  33. package/dist/browser-process/get-chrome-executable.js +79 -4
  34. package/dist/browser-process/get-chrome-executable.js.map +1 -1
  35. package/dist/browser-process/gpu-family-profiles.d.ts +58 -0
  36. package/dist/browser-process/gpu-family-profiles.d.ts.map +1 -0
  37. package/dist/browser-process/gpu-family-profiles.js +480 -0
  38. package/dist/browser-process/gpu-family-profiles.js.map +1 -0
  39. package/dist/browser-process/launch-options.d.ts +301 -0
  40. package/dist/browser-process/launch-options.d.ts.map +1 -0
  41. package/dist/browser-process/launch-options.js +2 -0
  42. package/dist/browser-process/launch-options.js.map +1 -0
  43. package/dist/browser-process/port-allocator.d.ts +64 -0
  44. package/dist/browser-process/port-allocator.d.ts.map +1 -0
  45. package/dist/browser-process/port-allocator.js +198 -0
  46. package/dist/browser-process/port-allocator.js.map +1 -0
  47. package/dist/browser-process/process.d.ts +85 -0
  48. package/dist/browser-process/process.d.ts.map +1 -1
  49. package/dist/browser-process/process.js +235 -19
  50. package/dist/browser-process/process.js.map +1 -1
  51. package/dist/browser-process/signal-cleanup.d.ts +40 -0
  52. package/dist/browser-process/signal-cleanup.d.ts.map +1 -0
  53. package/dist/browser-process/signal-cleanup.js +105 -0
  54. package/dist/browser-process/signal-cleanup.js.map +1 -0
  55. package/dist/browser-process/widevine-cdm.d.ts +144 -0
  56. package/dist/browser-process/widevine-cdm.d.ts.map +1 -0
  57. package/dist/browser-process/widevine-cdm.js +298 -0
  58. package/dist/browser-process/widevine-cdm.js.map +1 -0
  59. package/dist/browser-profiles/chrome/populate-profile.d.ts.map +1 -1
  60. package/dist/browser-profiles/chrome/populate-profile.js +21 -22
  61. package/dist/browser-profiles/chrome/populate-profile.js.map +1 -1
  62. package/dist/connection/cdp-reconnection.d.ts +50 -0
  63. package/dist/connection/cdp-reconnection.d.ts.map +1 -0
  64. package/dist/connection/cdp-reconnection.js +75 -0
  65. package/dist/connection/cdp-reconnection.js.map +1 -0
  66. package/dist/connection/page-session.d.ts +114 -0
  67. package/dist/connection/page-session.d.ts.map +1 -0
  68. package/dist/connection/page-session.js +237 -0
  69. package/dist/connection/page-session.js.map +1 -0
  70. package/dist/connection/session-client.d.ts +34 -0
  71. package/dist/connection/session-client.d.ts.map +1 -0
  72. package/dist/connection/session-client.js +129 -0
  73. package/dist/connection/session-client.js.map +1 -0
  74. package/dist/crawler.d.ts +9 -88
  75. package/dist/crawler.d.ts.map +1 -1
  76. package/dist/crawler.js +157 -8
  77. package/dist/crawler.js.map +1 -1
  78. package/dist/fingerprinting/fingerprint-injector.d.ts +24 -114
  79. package/dist/fingerprinting/fingerprint-injector.d.ts.map +1 -1
  80. package/dist/fingerprinting/fingerprint-injector.js +305 -611
  81. package/dist/fingerprinting/fingerprint-injector.js.map +1 -1
  82. package/dist/fingerprinting/fingerprint-overrides/datadome-bypass.d.ts +1 -1
  83. package/dist/fingerprinting/fingerprint-overrides/datadome-bypass.d.ts.map +1 -1
  84. package/dist/fingerprinting/fingerprint-overrides/datadome-bypass.js +16 -14
  85. package/dist/fingerprinting/fingerprint-overrides/datadome-bypass.js.map +1 -1
  86. package/dist/fingerprinting/fingerprint-overrides/index.d.ts +6 -2
  87. package/dist/fingerprinting/fingerprint-overrides/index.d.ts.map +1 -1
  88. package/dist/fingerprinting/fingerprint-overrides/index.js +6 -2
  89. package/dist/fingerprinting/fingerprint-overrides/index.js.map +1 -1
  90. package/dist/fingerprinting/fingerprint-overrides/keyboard-humanization.d.ts +1 -1
  91. package/dist/fingerprinting/fingerprint-overrides/keyboard-humanization.js +4 -4
  92. package/dist/fingerprinting/fingerprint-overrides/keyboard-humanization.js.map +1 -1
  93. package/dist/fingerprinting/fingerprint-overrides/locale-spoofing.js +4 -4
  94. package/dist/fingerprinting/fingerprint-overrides/navigator-override.d.ts +18 -0
  95. package/dist/fingerprinting/fingerprint-overrides/navigator-override.d.ts.map +1 -0
  96. package/dist/fingerprinting/fingerprint-overrides/navigator-override.js +136 -0
  97. package/dist/fingerprinting/fingerprint-overrides/navigator-override.js.map +1 -0
  98. package/dist/fingerprinting/fingerprint-overrides/override.d.ts +151 -0
  99. package/dist/fingerprinting/fingerprint-overrides/override.d.ts.map +1 -0
  100. package/dist/fingerprinting/fingerprint-overrides/override.js +14 -0
  101. package/dist/fingerprinting/fingerprint-overrides/override.js.map +1 -0
  102. package/dist/fingerprinting/fingerprint-overrides/platform-consistency.d.ts.map +1 -1
  103. package/dist/fingerprinting/fingerprint-overrides/platform-consistency.js +46 -15
  104. package/dist/fingerprinting/fingerprint-overrides/platform-consistency.js.map +1 -1
  105. package/dist/fingerprinting/fingerprint-overrides/registry.d.ts +43 -0
  106. package/dist/fingerprinting/fingerprint-overrides/registry.d.ts.map +1 -0
  107. package/dist/fingerprinting/fingerprint-overrides/registry.js +342 -0
  108. package/dist/fingerprinting/fingerprint-overrides/registry.js.map +1 -0
  109. package/dist/fingerprinting/fingerprint-overrides/screen-override.d.ts +16 -0
  110. package/dist/fingerprinting/fingerprint-overrides/screen-override.d.ts.map +1 -0
  111. package/dist/fingerprinting/fingerprint-overrides/screen-override.js +175 -0
  112. package/dist/fingerprinting/fingerprint-overrides/screen-override.js.map +1 -0
  113. package/dist/fingerprinting/fingerprint-overrides/scroll-humanization.d.ts +1 -1
  114. package/dist/fingerprinting/fingerprint-overrides/scroll-humanization.js +4 -4
  115. package/dist/fingerprinting/fingerprint-overrides/scroll-humanization.js.map +1 -1
  116. package/dist/fingerprinting/fingerprint-overrides/stealth-script.d.ts.map +1 -1
  117. package/dist/fingerprinting/fingerprint-overrides/stealth-script.js +75 -9
  118. package/dist/fingerprinting/fingerprint-overrides/stealth-script.js.map +1 -1
  119. package/dist/fingerprinting/fingerprint-overrides/ua-ch.d.ts +1 -1
  120. package/dist/fingerprinting/fingerprint-overrides/ua-ch.d.ts.map +1 -1
  121. package/dist/fingerprinting/fingerprint-overrides/ua-ch.js +34 -20
  122. package/dist/fingerprinting/fingerprint-overrides/ua-ch.js.map +1 -1
  123. package/dist/fingerprinting/fingerprint-overrides/webgl-spoofing.d.ts.map +1 -1
  124. package/dist/fingerprinting/fingerprint-overrides/webgl-spoofing.js +25 -6
  125. package/dist/fingerprinting/fingerprint-overrides/webgl-spoofing.js.map +1 -1
  126. package/dist/fingerprinting/fingerprint-overrides/webrtc-spoofing.js +1 -1
  127. package/dist/fingerprinting/hardware-realism.d.ts +83 -0
  128. package/dist/fingerprinting/hardware-realism.d.ts.map +1 -0
  129. package/dist/fingerprinting/hardware-realism.js +156 -0
  130. package/dist/fingerprinting/hardware-realism.js.map +1 -0
  131. package/dist/fingerprinting/injection-planner.d.ts +133 -0
  132. package/dist/fingerprinting/injection-planner.d.ts.map +1 -0
  133. package/dist/fingerprinting/injection-planner.js +497 -0
  134. package/dist/fingerprinting/injection-planner.js.map +1 -0
  135. package/dist/fingerprinting/locale-resolver.d.ts +8 -1
  136. package/dist/fingerprinting/locale-resolver.d.ts.map +1 -1
  137. package/dist/fingerprinting/locale-resolver.js +26 -3
  138. package/dist/fingerprinting/locale-resolver.js.map +1 -1
  139. package/dist/fingerprinting/non-apify-fingerprint-generator.d.ts +4 -4
  140. package/dist/fingerprinting/non-apify-fingerprint-generator.d.ts.map +1 -1
  141. package/dist/fingerprinting/non-apify-fingerprint-generator.js +24 -58
  142. package/dist/fingerprinting/non-apify-fingerprint-generator.js.map +1 -1
  143. package/dist/fingerprinting/non-apify-profiles.json +10515 -8494
  144. package/dist/fingerprinting/os-consistency.d.ts +28 -0
  145. package/dist/fingerprinting/os-consistency.d.ts.map +1 -0
  146. package/dist/fingerprinting/os-consistency.js +45 -0
  147. package/dist/fingerprinting/os-consistency.js.map +1 -0
  148. package/dist/fingerprinting/profile-quality.d.ts +25 -0
  149. package/dist/fingerprinting/profile-quality.d.ts.map +1 -0
  150. package/dist/fingerprinting/profile-quality.js +310 -0
  151. package/dist/fingerprinting/profile-quality.js.map +1 -0
  152. package/dist/fingerprinting/profile-selector.d.ts +102 -0
  153. package/dist/fingerprinting/profile-selector.d.ts.map +1 -0
  154. package/dist/fingerprinting/profile-selector.js +168 -0
  155. package/dist/fingerprinting/profile-selector.js.map +1 -0
  156. package/dist/fingerprinting/ua-alignment.d.ts +83 -0
  157. package/dist/fingerprinting/ua-alignment.d.ts.map +1 -0
  158. package/dist/fingerprinting/ua-alignment.js +197 -0
  159. package/dist/fingerprinting/ua-alignment.js.map +1 -0
  160. package/dist/fingerprinting/ua-ch-brands.d.ts +88 -0
  161. package/dist/fingerprinting/ua-ch-brands.d.ts.map +1 -0
  162. package/dist/fingerprinting/ua-ch-brands.js +174 -0
  163. package/dist/fingerprinting/ua-ch-brands.js.map +1 -0
  164. package/dist/index.d.ts +5 -0
  165. package/dist/index.d.ts.map +1 -1
  166. package/dist/index.js +6 -0
  167. package/dist/index.js.map +1 -1
  168. package/dist/input/dropdown-selector.d.ts +90 -0
  169. package/dist/input/dropdown-selector.d.ts.map +1 -0
  170. package/dist/input/dropdown-selector.js +394 -0
  171. package/dist/input/dropdown-selector.js.map +1 -0
  172. package/dist/input/element-target.d.ts +152 -0
  173. package/dist/input/element-target.d.ts.map +1 -0
  174. package/dist/input/element-target.js +461 -0
  175. package/dist/input/element-target.js.map +1 -0
  176. package/dist/input/input-emulator.d.ts +85 -0
  177. package/dist/input/input-emulator.d.ts.map +1 -0
  178. package/dist/input/input-emulator.js +325 -0
  179. package/dist/input/input-emulator.js.map +1 -0
  180. package/dist/input/input-transport.d.ts +60 -0
  181. package/dist/input/input-transport.d.ts.map +1 -0
  182. package/dist/input/input-transport.js +28 -0
  183. package/dist/input/input-transport.js.map +1 -0
  184. package/dist/input/recording-transport.d.ts +32 -0
  185. package/dist/input/recording-transport.d.ts.map +1 -0
  186. package/dist/input/recording-transport.js +43 -0
  187. package/dist/input/recording-transport.js.map +1 -0
  188. package/dist/keyboard-layout.d.ts +2 -2
  189. package/dist/keyboard-layout.js +2 -2
  190. package/dist/navigation/page-navigation.d.ts +67 -0
  191. package/dist/navigation/page-navigation.d.ts.map +1 -0
  192. package/dist/navigation/page-navigation.js +122 -0
  193. package/dist/navigation/page-navigation.js.map +1 -0
  194. package/dist/network/network-watch.d.ts +110 -0
  195. package/dist/network/network-watch.d.ts.map +1 -0
  196. package/dist/network/network-watch.js +223 -0
  197. package/dist/network/network-watch.js.map +1 -0
  198. package/dist/page.d.ts +118 -104
  199. package/dist/page.d.ts.map +1 -1
  200. package/dist/page.js +311 -1130
  201. package/dist/page.js.map +1 -1
  202. package/dist/session/inject-hook.d.ts +24 -0
  203. package/dist/session/inject-hook.d.ts.map +1 -0
  204. package/dist/session/inject-hook.js +80 -0
  205. package/dist/session/inject-hook.js.map +1 -0
  206. package/dist/session/save.d.ts +21 -0
  207. package/dist/session/save.d.ts.map +1 -0
  208. package/dist/session/save.js +163 -0
  209. package/dist/session/save.js.map +1 -0
  210. package/dist/session/tar-data-dir.d.ts +18 -0
  211. package/dist/session/tar-data-dir.d.ts.map +1 -0
  212. package/dist/session/tar-data-dir.js +105 -0
  213. package/dist/session/tar-data-dir.js.map +1 -0
  214. package/dist/session/types.d.ts +80 -0
  215. package/dist/session/types.d.ts.map +1 -0
  216. package/dist/session/types.js +29 -0
  217. package/dist/session/types.js.map +1 -0
  218. package/dist/session-profile.d.ts +102 -0
  219. package/dist/session-profile.d.ts.map +1 -0
  220. package/dist/session-profile.js +163 -0
  221. package/dist/session-profile.js.map +1 -0
  222. package/dist/tsconfig.build.tsbuildinfo +1 -1
  223. package/package.json +17 -6
  224. package/scripts/postinstall.cjs +58 -0
  225. package/dist/fingerprinting/custom-fingerprint-injector.d.ts +0 -87
  226. package/dist/fingerprinting/custom-fingerprint-injector.d.ts.map +0 -1
  227. package/dist/fingerprinting/custom-fingerprint-injector.js +0 -342
  228. package/dist/fingerprinting/custom-fingerprint-injector.js.map +0 -1
  229. package/dist/launcher-wrap.d.ts +0 -10
  230. package/dist/launcher-wrap.d.ts.map +0 -1
  231. package/dist/launcher-wrap.js +0 -11
  232. package/dist/launcher-wrap.js.map +0 -1
@@ -1,13 +1,39 @@
1
- import net from 'net';
1
+ import { promises as fs } from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import CDP from 'chrome-remote-interface';
2
5
  import { BrowserPlugin, log } from 'crawlee';
3
6
  import CDPBrowserController from './browser-controller.js';
4
7
  import { launchChromeBrowserWithNewProfile } from './browser-process/browser.js';
5
- import { generateFingerprintConfig } from './browser-process/anti-detect-config.js';
6
- import { getChromeBinaryVersion, getAntiDetectBrowserPath, resolveAntiDetectBrowserPath } from './browser-process/get-chrome-executable.js';
7
- import { generateNonApifyFingerprint } from './fingerprinting/non-apify-fingerprint-generator.js';
8
+ import { prepareForLaunch } from './browser-process/fingerprint-config.js';
9
+ import { resolveAntiDetectBrowserPath } from './browser-process/get-chrome-executable.js';
10
+ import { PortAllocator } from './browser-process/port-allocator.js';
11
+ import { ensureWidevineCdm } from './browser-process/widevine-cdm.js';
8
12
  import { fetchProxyCountry, resolveLocale } from './fingerprinting/locale-resolver.js';
9
- import { CdpLauncher } from './launcher-wrap.js';
10
13
  import { anonymizeProxySugar } from './proxy/anonymize-proxy.js';
14
+ import { untarUserDataDir } from './session/tar-data-dir.js';
15
+ import { assertValidBundle } from './session/types.js';
16
+ import { resolveUseNativeStealth } from './session-profile.js';
17
+ /**
18
+ * Type-only marker satisfying Crawlee's BrowserPlugin generic. CDP has no
19
+ * Puppeteer-style library object - the connection happens directly in
20
+ * CDPBrowserPlugin._openCdpConnection, so `launch` is never invoked at runtime;
21
+ * the declaration exists purely so Crawlee can infer LibraryOptions /
22
+ * LaunchResult from the generic. Constructable (`new CdpLauncher()`) because the
23
+ * Crawler and the e2e harness pass an instance to the plugin constructor; the
24
+ * body is intentionally empty rather than the former throw-only stub.
25
+ */
26
+ export class CdpLauncher {
27
+ }
28
+ /**
29
+ * Overall budget for the browser to print its "DevTools listening on ws://…"
30
+ * line and become connectable. Covers a slow cold start (first launch of the
31
+ * anti-detect binary, cold page cache) while still failing in bounded time if
32
+ * the process is wedged without having exited. The process-exit rejection is
33
+ * handled separately by the endpoint promise itself, so this only fires for a
34
+ * genuine hang.
35
+ */
36
+ const CDP_READINESS_TIMEOUT_MS = 30000;
11
37
  class CDPBrowserPlugin extends BrowserPlugin {
12
38
  /**
13
39
  * Accept flat LaunchBrowserOptions directly (not nested inside BrowserPluginOptions).
@@ -17,117 +43,20 @@ class CDPBrowserPlugin extends BrowserPlugin {
17
43
  */
18
44
  constructor(library, launchOptions) {
19
45
  super(library, { launchOptions: (launchOptions ?? {}) });
20
- }
21
- async getNextAvailablePort() {
22
- const maxAttempts = CDPBrowserPlugin.PORT_RANGE.max - CDPBrowserPlugin.PORT_RANGE.min + 1; // Try all ports in range
23
- let attempts = 0;
24
- while (attempts < maxAttempts) {
25
- // Wait if another instance is currently assigning a port
26
- while (CDPBrowserPlugin.portLock) {
27
- await new Promise(resolve => setTimeout(resolve, 100 + Math.random() * 100));
28
- }
29
- try {
30
- // Set lock before port assignment
31
- CDPBrowserPlugin.portLock = true;
32
- // Increment the last assigned port, wrapping around if necessary
33
- CDPBrowserPlugin.lastAssignedPort++;
34
- if (CDPBrowserPlugin.lastAssignedPort > CDPBrowserPlugin.PORT_RANGE.max) {
35
- CDPBrowserPlugin.lastAssignedPort = CDPBrowserPlugin.PORT_RANGE.min;
36
- log.debug('Port range wrapped around, starting from beginning');
37
- }
38
- const candidatePort = CDPBrowserPlugin.lastAssignedPort;
39
- // Skip if port is already tracked as in use
40
- if (CDPBrowserPlugin.usedPorts.has(candidatePort)) {
41
- attempts++;
42
- continue;
43
- }
44
- // Check if port is actually available
45
- if (await this._isPortAvailable(candidatePort)) {
46
- CDPBrowserPlugin.usedPorts.add(candidatePort);
47
- log.debug(`Reserved port ${candidatePort} for browser instance (attempt ${attempts + 1})`);
48
- return candidatePort;
49
- }
50
- // Port is not available, mark it as used temporarily to avoid immediate retry
51
- CDPBrowserPlugin.usedPorts.add(candidatePort);
52
- setTimeout(() => {
53
- CDPBrowserPlugin.usedPorts.delete(candidatePort);
54
- }, 5000); // Remove from used ports after 5 seconds
55
- }
56
- finally {
57
- // Always release the lock
58
- CDPBrowserPlugin.portLock = false;
59
- }
60
- attempts++;
61
- }
62
- // If we've tried all ports and none are available, try to clean up stale ports
63
- log.warning(`All ports in range ${CDPBrowserPlugin.PORT_RANGE.min}-${CDPBrowserPlugin.PORT_RANGE.max} are in use. Attempting cleanup.`);
64
- await this._cleanupStalePorts();
65
- // Try once more after cleanup
66
- for (let port = CDPBrowserPlugin.PORT_RANGE.min; port <= CDPBrowserPlugin.PORT_RANGE.max; port++) {
67
- if (!CDPBrowserPlugin.usedPorts.has(port) && await this._isPortAvailable(port)) {
68
- CDPBrowserPlugin.usedPorts.add(port);
69
- log.debug(`Reserved port ${port} after cleanup`);
70
- return port;
71
- }
72
- }
73
- throw new Error(`No available ports found in the configured range ${CDPBrowserPlugin.PORT_RANGE.min}-${CDPBrowserPlugin.PORT_RANGE.max} after maximum attempts and cleanup`);
74
- }
75
- async _cleanupStalePorts() {
76
- log.debug('Cleaning up stale ports');
77
- const portsToCheck = Array.from(CDPBrowserPlugin.usedPorts);
78
- for (const port of portsToCheck) {
79
- if (await this._isPortAvailable(port)) {
80
- CDPBrowserPlugin.usedPorts.delete(port);
81
- log.debug(`Cleaned up stale port ${port}`);
82
- }
83
- }
84
- }
85
- async _isPortAvailable(port) {
86
- return new Promise((resolve) => {
87
- const server = net.createServer();
88
- let resolved = false;
89
- const cleanup = () => {
90
- if (!resolved) {
91
- resolved = true;
92
- try {
93
- server.close();
94
- }
95
- catch (e) {
96
- // Ignore errors during cleanup
97
- }
98
- }
99
- };
100
- server.once('error', () => {
101
- cleanup();
102
- resolve(false);
103
- });
104
- server.once('listening', () => {
105
- cleanup();
106
- resolve(true);
107
- });
108
- // Add timeout to prevent hanging
109
- setTimeout(() => {
110
- cleanup();
111
- resolve(false);
112
- }, 2000); // Reduced timeout
113
- try {
114
- server.listen(port, '127.0.0.1');
115
- }
116
- catch (e) {
117
- cleanup();
118
- resolve(false);
119
- }
46
+ /**
47
+ * Debug-port pool: acquisition serialization, wraparound scan, probe-busy
48
+ * quarantine, and stale-port sweep all live in PortAllocator. The plugin
49
+ * only acquires on launch and releases from the browser event handlers.
50
+ * One pool per process (matching the former static state): two crawlers in
51
+ * one process - e.g. a session-bundle restore - must not race the same range.
52
+ */
53
+ Object.defineProperty(this, "portAllocator", {
54
+ enumerable: true,
55
+ configurable: true,
56
+ writable: true,
57
+ value: CDPBrowserPlugin.sharedPortAllocator
120
58
  });
121
59
  }
122
- releasePort(port) {
123
- if (CDPBrowserPlugin.usedPorts.has(port)) {
124
- CDPBrowserPlugin.usedPorts.delete(port);
125
- log.debug(`Released port ${port} back to pool. Used ports: ${CDPBrowserPlugin.usedPorts.size}/${CDPBrowserPlugin.PORT_RANGE.max - CDPBrowserPlugin.PORT_RANGE.min + 1}`);
126
- }
127
- else {
128
- log.debug(`Attempted to release port ${port} that was not in use`);
129
- }
130
- }
131
60
  /**
132
61
  */
133
62
  async _addProxyToLaunchOptions() {
@@ -148,211 +77,149 @@ class CDPBrowserPlugin extends BrowserPlugin {
148
77
  * @private
149
78
  */
150
79
  async _launch(launchContext) {
151
- const debuggingPort = await this.getNextAvailablePort();
80
+ const debuggingPort = await this.portAllocator.acquire();
152
81
  let browserProcess;
82
+ // Idempotent teardown for the anonymize proxy server. Reassigned the
83
+ // moment the anonymize promise exists; a no-op until then so the outer
84
+ // catch can always call it even when we fail before proxy setup.
85
+ let closeAnonymizedProxyOnce = async () => { };
153
86
  try {
154
- const { launchOptions, useIncognitoPages, proxyUrl, fingerprint } = launchContext;
155
- if (!launchOptions.browserType) {
156
- launchOptions.browserType = "chrome";
157
- }
158
- // useNativeStealth may be set in three places (canonical, nested, or at
159
- // launchContext root) — normalize to launchOptions.useNativeStealth so the
160
- // rest of the pipeline reads one source of truth.
161
- const nativeStealthRequested = !!launchOptions.useNativeStealth ||
162
- !!launchOptions.fingerprintOptions?.useNativeStealth ||
163
- !!launchContext?.useNativeStealth;
164
- if (nativeStealthRequested) {
165
- launchOptions.useNativeStealth = true;
166
- }
167
- // Auto-resolve the anti-detect binary so consumers can simply pass
168
- // `useNativeStealth: true` without having to walk node_modules to find it.
169
- // If the user pinned an explicit executablePath, respect it.
170
- if (launchOptions.useNativeStealth && !launchOptions.executablePath) {
171
- launchOptions.executablePath = resolveAntiDetectBrowserPath();
172
- log.info('[AntiDetect] Auto-resolved anti-detect binary', {
173
- executablePath: launchOptions.executablePath,
174
- });
175
- }
176
- // Extract screen dimensions from fingerprint if available and not already set
177
- if (fingerprint?.fingerprint?.screen && !launchOptions.windowWidth && !launchOptions.windowHeight) {
178
- const screen = fingerprint.fingerprint.screen;
179
- launchOptions.windowWidth = screen.width || 1920;
180
- launchOptions.windowHeight = screen.height || 1080;
181
- log.info('Using fingerprint screen dimensions for browser window', {
182
- width: launchOptions.windowWidth,
183
- height: launchOptions.windowHeight,
184
- devicePixelRatio: screen.devicePixelRatio,
185
- });
186
- }
187
- else if (launchOptions.windowWidth && launchOptions.windowHeight) {
188
- log.info('Using explicitly set window dimensions', {
189
- width: launchOptions.windowWidth,
190
- height: launchOptions.windowHeight,
191
- });
192
- }
193
- else {
194
- log.warning('No fingerprint screen dimensions and no explicit dimensions - will use defaults or maximize');
195
- }
87
+ const { launchOptions, useIncognitoPages, proxyUrl } = launchContext;
88
+ this._normalizeLaunchIdentity(launchContext);
196
89
  let browser;
197
90
  launchOptions.useIncognitoPages = useIncognitoPages;
198
91
  log.debug("Launch options", { ...launchOptions, debuggingPort });
199
- const [anonymizedProxyUrl, close] = await anonymizeProxySugar(proxyUrl);
92
+ // If the caller handed us a previously-captured session bundle, hydrate
93
+ // launch state from it BEFORE any locale resolution / fingerprint
94
+ // generation runs - so the downstream code paths see the bundle's values
95
+ // as if the user had set them by hand and we never call the generators.
96
+ // This stays serial and ahead of the parallel block below because hydration
97
+ // may pin resolvedLocale / userDataDir that _ensureResolvedLocale then reads.
98
+ const { sessionBundle } = launchOptions;
99
+ if (sessionBundle) {
100
+ await this._hydrateFromSessionBundle(launchContext, sessionBundle);
101
+ }
102
+ // Proxy anonymization and locale resolution are independent launch I/O:
103
+ // both only READ the original proxyUrl and write to disjoint targets
104
+ // (anonymize returns [url, close]; ensureLocale writes launchOptions.resolvedLocale).
105
+ // Run them concurrently to cut one round-trip off the launch path.
106
+ //
107
+ // The anonymize close handle must be reachable from every cleanup path
108
+ // even if a *later* await in this method throws before we destructure
109
+ // the result - including the race where locale resolution rejects
110
+ // before anonymize has resolved. So we hold the anonymize promise in
111
+ // _launch scope and wrap its close in a single idempotent guard that
112
+ // awaits the promise itself: whoever fires first (inner catch, outer
113
+ // catch, or a browser close/error/disconnect handler) closes the
114
+ // server exactly once, and a proxy that resolves only after the
115
+ // failure is still torn down rather than leaked.
116
+ const anonymizePromise = anonymizeProxySugar(proxyUrl);
117
+ let proxyClosed = false;
118
+ closeAnonymizedProxyOnce = async () => {
119
+ if (proxyClosed)
120
+ return;
121
+ proxyClosed = true;
122
+ try {
123
+ const [, close] = await anonymizePromise;
124
+ await close();
125
+ }
126
+ catch {
127
+ // anonymizeProxySugar itself rejected - nothing was started,
128
+ // so there is nothing to close.
129
+ }
130
+ };
131
+ const [[anonymizedProxyUrl]] = await Promise.all([
132
+ anonymizePromise,
133
+ this._ensureResolvedLocale(launchOptions, proxyUrl, launchContext.fingerprint),
134
+ ]);
200
135
  if (anonymizedProxyUrl) {
201
136
  launchOptions.proxyUrl = anonymizedProxyUrl;
202
137
  }
203
- // Resolve the locale triple once per launch so native-stealth args, CDP overrides,
204
- // and the FingerprintConfigJson all read the same { locale, languages, timezone }.
205
- // Priority: explicit fingerprintOptions.locale → proxy country (via ipinfo.io) →
206
- // fingerprint-generator language → 'en-US'.
207
- {
208
- const fpOpts = launchOptions.fingerprintOptions;
209
- const fingerprintLanguage = fingerprint?.headers
210
- ? (fingerprint.headers['accept-language'] ?? fingerprint.headers['Accept-Language'])
211
- : undefined;
212
- // Only pay the ipinfo.io roundtrip if we actually need it — skip when the user
213
- // has already pinned an explicit locale.
214
- const proxyCountry = fpOpts?.locale ? undefined : await fetchProxyCountry(proxyUrl);
215
- launchOptions.resolvedLocale = resolveLocale({
216
- locale: fpOpts?.locale,
217
- timezone: fpOpts?.timezone,
218
- languages: fpOpts?.languages,
219
- proxyCountry,
220
- fingerprintLanguage: fingerprintLanguage?.split(',')[0]?.trim(),
221
- });
222
- log.debug('[Locale] Resolved locale triple', {
223
- locale: launchOptions.resolvedLocale.locale,
224
- timezone: launchOptions.resolvedLocale.timezone,
225
- languages: launchOptions.resolvedLocale.languages,
226
- source: fpOpts?.locale ? 'user' : proxyCountry ? 'proxy' : 'fingerprint',
227
- });
228
- }
229
138
  // Pre-generate a per-session C++ fingerprint config for the anti-detect browser.
230
- // The static default_fingerprint.json always uses the same GPU/screen/CPU — every
231
- // browser instance looks identical. By generating the config from the fingerprint
232
- // profile before launch, each session gets unique hardware-level C++ patches.
233
- if (launchOptions.useNativeStealth) {
234
- const _fpOpts = launchOptions.fingerprintOptions;
235
- const proxySeedKey = proxyUrl
236
- ? (proxyUrl.match(/session-([^,&]+)/i)?.[1] ?? null)
237
- : null;
238
- // If non-Apify real-device profiles are requested, generate the profile now
239
- // so C++ gets the same profile that the fingerprint injector will use.
240
- let fpForConfig = fingerprint;
241
- if (_fpOpts?.useNonApifyFingerprints) {
242
- // User-supplied fingerprintOptions.platform wins; falls back to
243
- // whatever the existing Crawlee-generated fingerprint says.
244
- const userPlatform = _fpOpts?.platform;
245
- const osHint = (() => {
246
- const p = (fingerprint?.fingerprint?.navigator?.platform || '').toLowerCase();
247
- if (p.includes('mac'))
248
- return 'macos';
249
- if (p.includes('linux'))
250
- return 'linux';
251
- return 'windows';
252
- })();
253
- const nonApifyFp = await generateNonApifyFingerprint({
254
- platform: userPlatform,
255
- os: osHint,
256
- proxySeedKey,
257
- });
258
- fpForConfig = nonApifyFp;
259
- // Store for browser-controller._newPage() — it will reuse this instead of
260
- // generating a different profile (session consistency).
261
- launchContext.fingerprint = fpForConfig;
262
- }
263
- if (fpForConfig) {
264
- try {
265
- const cfg = generateFingerprintConfig(fpForConfig, proxySeedKey ?? undefined, launchOptions.resolvedLocale);
266
- // Align UA version with actual binary before writing the config.
267
- const binaryPath = getAntiDetectBrowserPath() ?? launchOptions.executablePath;
268
- if (binaryPath) {
269
- const binaryVersion = getChromeBinaryVersion(binaryPath);
270
- if (binaryVersion) {
271
- const major = binaryVersion.split('.')[0];
272
- if (cfg.navigator.userAgent) {
273
- cfg.navigator.userAgent = cfg.navigator.userAgent.replace(/Chrome\/\d+\.\d+\.\d+\.\d+/, `Chrome/${major}.0.0.0`);
274
- }
275
- if (cfg.navigator.userAgentData) {
276
- cfg.navigator.userAgentData.uaFullVersion = binaryVersion;
277
- cfg.navigator.userAgentData.brands = cfg.navigator.userAgentData.brands.map(b => b.brand === 'Not_A Brand' ? b : { ...b, version: major, full_version: binaryVersion });
278
- cfg.navigator.userAgentData.fullVersionList = cfg.navigator.userAgentData.brands;
279
- }
280
- }
281
- }
282
- launchOptions.fingerprintConfig = cfg;
283
- log.debug('[AntiDetect] Pre-generated per-session fingerprint config', {
284
- platform: cfg.navigator.platform,
285
- gpu: cfg.webgl.renderer.substring(0, 60),
286
- screen: `${cfg.screen.width}x${cfg.screen.height}`,
287
- });
288
- }
289
- catch (cfgErr) {
290
- log.debug('[AntiDetect] Could not pre-generate fingerprint config:', { error: cfgErr });
291
- }
292
- }
139
+ // Skipped when a sessionBundle pre-supplied the config - that is the whole point
140
+ // of byte-for-byte replay.
141
+ if (launchOptions.useNativeStealth && !sessionBundle) {
142
+ await this._prepareNativeFingerprintConfig(launchContext);
293
143
  }
294
144
  browserProcess = await this._launchBrowser(debuggingPort, launchOptions);
295
145
  log.debug("Browser process started", { pid: browserProcess.pid, debuggingPort });
296
146
  try {
297
- browser = await this._connectWithRetry(debuggingPort, 10, 4000);
298
- // Add cleanup handlers
299
- browser.on('close', async () => {
300
- try {
301
- this.releasePort(debuggingPort);
302
- if (anonymizedProxyUrl) {
303
- await close();
304
- }
305
- log.debug(`Browser on port ${debuggingPort} closed cleanly`);
306
- }
307
- catch (error) {
308
- log.warning(`Error during browser close cleanup on port ${debuggingPort}:`, { error });
309
- // Still release the port even if other cleanup fails
310
- this.releasePort(debuggingPort);
311
- }
312
- });
313
- // Add error handler to ensure cleanup
314
- browser.on('error', async (error) => {
147
+ browser = await this._connectViaWsEndpoint(browserProcess, debuggingPort);
148
+ // Single once-guarded teardown wired to every terminal browser
149
+ // event (close / error / disconnect). Any of them can fire - and
150
+ // more than one can fire for the same instance - so the guard
151
+ // ensures the port is released and the anonymize proxy closed
152
+ // exactly once regardless of which event(s) arrive. Both
153
+ // portAllocator.release and closeAnonymizedProxyOnce are already
154
+ // idempotent; the guard additionally avoids duplicate log lines
155
+ // and redundant work.
156
+ let teardownDone = false;
157
+ const teardown = async (reason) => {
158
+ if (teardownDone)
159
+ return;
160
+ teardownDone = true;
315
161
  try {
316
- log.warning(`Browser error on port ${debuggingPort}:`, error);
317
- this.releasePort(debuggingPort);
318
- if (anonymizedProxyUrl) {
319
- await close();
320
- }
162
+ this.portAllocator.release(debuggingPort);
163
+ await closeAnonymizedProxyOnce();
164
+ log.debug(`Browser on port ${debuggingPort} cleaned up (${reason})`);
321
165
  }
322
166
  catch (cleanupError) {
323
- log.error(`Error during browser error cleanup on port ${debuggingPort}:`, { error: cleanupError });
324
- // Still release the port even if other cleanup fails
325
- this.releasePort(debuggingPort);
167
+ log.warning(`Error during browser ${reason} cleanup on port ${debuggingPort}:`, { error: cleanupError });
168
+ // Still release the port even if other cleanup fails.
169
+ this.portAllocator.release(debuggingPort);
326
170
  }
171
+ };
172
+ browser.on('close', async () => teardown('close'));
173
+ browser.on('error', async (error) => {
174
+ log.warning(`Browser error on port ${debuggingPort}:`, error);
175
+ return teardown('error');
327
176
  });
328
- // Add disconnect handler for CDP connection issues
329
177
  browser.on('disconnect', async () => {
330
- try {
331
- log.warning(`Browser disconnected on port ${debuggingPort}, cleaning up`);
332
- this.releasePort(debuggingPort);
333
- if (anonymizedProxyUrl) {
334
- await close();
335
- }
178
+ log.warning(`Browser disconnected on port ${debuggingPort}, cleaning up`);
179
+ return teardown('disconnect');
180
+ });
181
+ // Widevine, before the first navigation rather than after it. Real
182
+ // Chrome bundles the CDM and reports support immediately; ours has to
183
+ // fetch it, and only a FOREGROUND component check is served (#141).
184
+ // Doing it here means a page never observes the pre-CDM state where
185
+ // `requestMediaKeySystemAccess('com.widevine.alpha')` rejects - a
186
+ // divergence a detector needs one line to find. Cold cache costs this
187
+ // once per host; after that the profile is seeded before launch.
188
+ if (launchOptions.useNativeStealth) {
189
+ const widevine = await ensureWidevineCdm({
190
+ session: browser,
191
+ userDataDir: browserProcess.getUserDataDir(),
192
+ });
193
+ const detail = { ...widevine, port: debuggingPort };
194
+ if (widevine.usableThisSession) {
195
+ log.debug('[AntiDetect] Widevine CDM ready before first navigation', detail);
336
196
  }
337
- catch (cleanupError) {
338
- log.error(`Error during browser disconnect cleanup on port ${debuggingPort}:`, { error: cleanupError });
339
- // Still release the port even if other cleanup fails
340
- this.releasePort(debuggingPort);
197
+ else if (widevine.outcome === 'installed-pending-restart') {
198
+ // Linux binds the CDM at startup, so this session still reports
199
+ // no Widevine; the profile and the host cache now have it, so
200
+ // every later launch is correct from the first navigation.
201
+ log.debug('[AntiDetect] Widevine CDM fetched; active from the next launch (Linux startup binding)', detail);
341
202
  }
342
- });
203
+ else {
204
+ log.debug('[AntiDetect] Widevine CDM unavailable this session', detail);
205
+ }
206
+ }
343
207
  launchContext.debuggingPort = debuggingPort;
344
208
  launchContext.process = browserProcess;
345
209
  return browser;
346
210
  }
347
211
  catch (error) {
348
- if (anonymizedProxyUrl) {
349
- await close();
350
- }
212
+ await closeAnonymizedProxyOnce();
351
213
  throw error;
352
214
  }
353
215
  }
354
216
  catch (error) {
355
- this.releasePort(debuggingPort);
217
+ this.portAllocator.release(debuggingPort);
218
+ // Close the anonymize proxy on any failure before the browser's own
219
+ // handlers were wired - covers a locale-resolution / fingerprint /
220
+ // launch failure that rejected the outer try before we ever connected.
221
+ // Idempotent, so it is a no-op if the inner catch already ran it.
222
+ await closeAnonymizedProxyOnce();
356
223
  // Ensure browser process is killed if it was started
357
224
  if (browserProcess) {
358
225
  try {
@@ -365,6 +232,155 @@ class CDPBrowserPlugin extends BrowserPlugin {
365
232
  throw error;
366
233
  }
367
234
  }
235
+ /**
236
+ * Phase 1 of _launch - normalize the launch identity in place:
237
+ * default browserType, normalize useNativeStealth from its three legacy
238
+ * locations, auto-resolve the anti-detect binary, and adopt the
239
+ * fingerprint's screen dimensions when the caller didn't pin any.
240
+ */
241
+ _normalizeLaunchIdentity(launchContext) {
242
+ const { launchOptions, fingerprint } = launchContext;
243
+ if (!launchOptions.browserType) {
244
+ launchOptions.browserType = "chrome";
245
+ }
246
+ // Default to the non-Apify real-device fingerprint pool. Real Chrome
247
+ // device profiles unblock materially better than the synthetic Apify
248
+ // pool, so `useNonApifyFingerprints` defaults ON. Only fill it when the
249
+ // caller left it unset (explicit `false` is honored), and never for a
250
+ // session-bundle restore - that path pins its own captured fingerprint
251
+ // and must not re-roll from any pool. Clone the nested bag so we default
252
+ // on our per-launch copy, never the caller's object (issue #80).
253
+ if (!launchOptions.sessionBundle) {
254
+ const fpOpts = { ...(launchOptions.fingerprintOptions ?? {}) };
255
+ if (fpOpts.useNonApifyFingerprints === undefined) {
256
+ fpOpts.useNonApifyFingerprints = true;
257
+ }
258
+ launchOptions.fingerprintOptions = fpOpts;
259
+ }
260
+ // useNativeStealth may be set in three places (canonical, nested, or at
261
+ // launchContext root) - normalize to launchOptions.useNativeStealth so the
262
+ // rest of the pipeline reads one source of truth.
263
+ const nativeStealthRequested = resolveUseNativeStealth(launchOptions, launchContext);
264
+ if (nativeStealthRequested) {
265
+ launchOptions.useNativeStealth = true;
266
+ }
267
+ // Auto-resolve the anti-detect binary so consumers can simply pass
268
+ // `useNativeStealth: true` without having to walk node_modules to find it.
269
+ // If the user pinned an explicit executablePath, respect it.
270
+ if (launchOptions.useNativeStealth && !launchOptions.executablePath) {
271
+ launchOptions.executablePath = resolveAntiDetectBrowserPath();
272
+ log.debug('[AntiDetect] Auto-resolved anti-detect binary', {
273
+ executablePath: launchOptions.executablePath,
274
+ });
275
+ }
276
+ // Extract screen dimensions from fingerprint if available and not already set
277
+ if (fingerprint?.fingerprint?.screen && !launchOptions.windowWidth && !launchOptions.windowHeight) {
278
+ const { screen } = fingerprint.fingerprint;
279
+ launchOptions.windowWidth = screen.width || 1920;
280
+ launchOptions.windowHeight = screen.height || 1080;
281
+ log.info('Using fingerprint screen dimensions for browser window', {
282
+ width: launchOptions.windowWidth,
283
+ height: launchOptions.windowHeight,
284
+ devicePixelRatio: screen.devicePixelRatio,
285
+ });
286
+ }
287
+ else if (launchOptions.windowWidth && launchOptions.windowHeight) {
288
+ log.debug('Using explicitly set window dimensions', {
289
+ width: launchOptions.windowWidth,
290
+ height: launchOptions.windowHeight,
291
+ });
292
+ }
293
+ else {
294
+ log.warning('No fingerprint screen dimensions and no explicit dimensions - will use defaults or maximize');
295
+ }
296
+ }
297
+ /**
298
+ * Phase 2 of _launch - hydrate launch state from a previously-captured
299
+ * session bundle: untar the user-data-dir, pin the resolved locale +
300
+ * fingerprintConfig verbatim, and replace the fingerprint so no generator
301
+ * runs (byte-for-byte replay).
302
+ */
303
+ async _hydrateFromSessionBundle(launchContext, sessionBundle) {
304
+ const { launchOptions } = launchContext;
305
+ assertValidBundle(sessionBundle);
306
+ if (sessionBundle.userDataDir && !launchOptions.userDataDir) {
307
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cdp-crawler-session-'));
308
+ await untarUserDataDir(sessionBundle.userDataDir, tempDir);
309
+ launchOptions.userDataDir = tempDir;
310
+ log.info('[session] Restored user-data-dir from bundle', {
311
+ tempDir,
312
+ sizeBytes: sessionBundle.userDataDir.sizeBytes,
313
+ capturedFiles: sessionBundle.userDataDir.capturedFiles,
314
+ });
315
+ }
316
+ launchOptions.resolvedLocale = sessionBundle.resolvedLocale;
317
+ launchOptions.fingerprintConfig = sessionBundle.fingerprintConfig;
318
+ launchOptions.fingerprintOptions = {
319
+ ...(launchOptions.fingerprintOptions ?? {}),
320
+ ...sessionBundle.fingerprintInput,
321
+ useNonApifyFingerprints: false,
322
+ };
323
+ launchContext.fingerprint = sessionBundle.fingerprint;
324
+ log.info('[session] Hydrated launchContext from sessionBundle', {
325
+ cookies: sessionBundle.cookies.length,
326
+ localStorageOrigins: Object.keys(sessionBundle.localStorage).length,
327
+ sessionStorageOrigins: Object.keys(sessionBundle.sessionStorage).length,
328
+ locale: sessionBundle.resolvedLocale.locale,
329
+ timezone: sessionBundle.resolvedLocale.timezone,
330
+ });
331
+ }
332
+ /**
333
+ * Phase 3 of _launch - resolve the locale triple once per launch so
334
+ * native-stealth args, CDP overrides, and the FingerprintConfigJson all
335
+ * read the same { locale, languages, timezone }. No-op when a session
336
+ * bundle (or an earlier caller) already pinned the triple.
337
+ */
338
+ async _ensureResolvedLocale(launchOptions, proxyUrl, fingerprint) {
339
+ // Resolve the locale triple once per launch so native-stealth args, CDP overrides,
340
+ // and the FingerprintConfigJson all read the same { locale, languages, timezone }.
341
+ // Priority: explicit fingerprintOptions.locale → proxy country (via ipinfo.io) →
342
+ // fingerprint-generator language → 'en-US'. Skipped when a sessionBundle already
343
+ // pinned the triple above.
344
+ if (!launchOptions.resolvedLocale) {
345
+ const fpOpts = launchOptions.fingerprintOptions;
346
+ const fingerprintLanguage = fingerprint?.headers
347
+ ? (fingerprint.headers['accept-language'] ?? fingerprint.headers['Accept-Language'])
348
+ : undefined;
349
+ // Only pay the ipinfo.io roundtrip if we actually need it - skip when the user
350
+ // has already pinned an explicit locale.
351
+ const proxyCountry = fpOpts?.locale ? undefined : await fetchProxyCountry(proxyUrl);
352
+ launchOptions.resolvedLocale = resolveLocale({
353
+ locale: fpOpts?.locale,
354
+ timezone: fpOpts?.timezone,
355
+ languages: fpOpts?.languages,
356
+ proxyCountry,
357
+ fingerprintLanguage: fingerprintLanguage?.split(',')[0]?.trim(),
358
+ });
359
+ let localeSource;
360
+ if (fpOpts?.locale)
361
+ localeSource = 'user';
362
+ else if (proxyCountry)
363
+ localeSource = 'proxy';
364
+ else
365
+ localeSource = 'fingerprint';
366
+ log.debug('[Locale] Resolved locale triple', {
367
+ locale: launchOptions.resolvedLocale.locale,
368
+ timezone: launchOptions.resolvedLocale.timezone,
369
+ languages: launchOptions.resolvedLocale.languages,
370
+ source: localeSource,
371
+ });
372
+ }
373
+ }
374
+ /**
375
+ * Phase 4 of _launch - thin delegate to the FingerprintConfig module's
376
+ * pre-launch path. It selects a clean (uncontaminated, OS-matching) profile,
377
+ * generates + aligns the FingerprintConfigJson, stamps the config onto
378
+ * launchOptions for browser-process to write, and records the selection on
379
+ * the launch context for the post-launch path to reuse.
380
+ */
381
+ async _prepareNativeFingerprintConfig(launchContext) {
382
+ await prepareForLaunch(launchContext);
383
+ }
368
384
  /**
369
385
  * Creates a new browser controller.
370
386
  *
@@ -383,11 +399,27 @@ class CDPBrowserPlugin extends BrowserPlugin {
383
399
  async _launchBrowser(debuggingPort, launchOptions) {
384
400
  const { proxyUrl, browserType, ignoreHttpsErrors = true, headless, executablePath, useNativeStealth, useExperimentalFingerprints, fingerprintOptions, fingerprintConfig, maximizeWindow, windowWidth, windowHeight, userDataDir, keepUserDataDir, dnsOverHttpsServer, secureDnsMode, webrtcIpHandlingPolicy, } = launchOptions;
385
401
  if (executablePath) {
386
- log.info(`[AntiDetect] Launching browser binary: ${executablePath}`);
402
+ log.debug(`[AntiDetect] Launching browser binary: ${executablePath}`);
387
403
  }
388
404
  let browserProcess;
389
405
  switch (browserType) {
390
406
  case "chrome": {
407
+ // Anti-Detect 2026-05-18: when native stealth is active and the
408
+ // caller didn't pin an explicit policy, default to
409
+ // `disable_non_proxied_udp` - the strictest setting, which
410
+ // tells Chromium's WebRTC stack to refuse UDP traffic unless
411
+ // it's tunneled through the proxy. This prevents host/srflx
412
+ // ICE candidate GATHERING at the network layer (which would
413
+ // otherwise add the real LAN/public IP to
414
+ // peerConnection.localDescription.sdp regardless of any
415
+ // JS-visible event filter). Pairs with the renderer-side
416
+ // candidate filter in rtc_peer_connection.cc::
417
+ // DidGenerateICECandidate that catches anything the policy
418
+ // misses on the trickle-ICE event path. Detection vectors
419
+ // covered: browserleaks.com/webrtc, ipleak.net,
420
+ // FingerprintJS WebRTC panel.
421
+ const effectiveWebrtcPolicy = webrtcIpHandlingPolicy
422
+ ?? (useNativeStealth ? 'disable_non_proxied_udp' : undefined);
391
423
  browserProcess = await launchChromeBrowserWithNewProfile({
392
424
  debuggingPort,
393
425
  anonymizedProxyUrl: proxyUrl,
@@ -405,7 +437,7 @@ class CDPBrowserPlugin extends BrowserPlugin {
405
437
  keepUserDataDir,
406
438
  dnsOverHttpsServer,
407
439
  secureDnsMode,
408
- webrtcIpHandlingPolicy,
440
+ webrtcIpHandlingPolicy: effectiveWebrtcPolicy,
409
441
  });
410
442
  break;
411
443
  }
@@ -413,108 +445,73 @@ class CDPBrowserPlugin extends BrowserPlugin {
413
445
  throw new Error(`No browser found with this type: ${browserType}!`);
414
446
  }
415
447
  }
416
- return new Promise((resolve, reject) => {
417
- this._waitForPort(debuggingPort)
418
- .then(() => {
419
- log.info(`Browser is ready on port ${debuggingPort}`);
420
- resolve(browserProcess);
421
- })
422
- .catch((err) => {
423
- log.error(`Failed to connect to browser on port: ${debuggingPort}`, err);
424
- reject(new Error(`Failed to connect to browser on port: ${debuggingPort}`));
425
- });
426
- });
448
+ // Readiness is now driven off the browser's stderr (the "DevTools
449
+ // listening on ws://…" line), awaited by _connectViaWsEndpoint. No TCP
450
+ // port poll here - just hand back the live process.
451
+ return browserProcess;
427
452
  }
428
453
  /**
429
- * Waits for a port to be available.
430
- *
431
- * @private
454
+ * Open the raw CDP connection. The newPage guard is part of the public
455
+ * contract: pages are created through CDPBrowserController, never here.
456
+ * @protected for test seams.
432
457
  */
433
- async _waitForPort(port, timeout = 30000, retryDelay = 500) {
434
- const startTime = Date.now();
435
- return new Promise((resolve, reject) => {
436
- (function tryConnect() {
437
- const socket = net.createConnection(port, '127.0.0.1');
438
- socket.on('connect', () => {
439
- socket.end();
440
- resolve(true);
441
- }).on('error', (_err) => {
442
- if (Date.now() - startTime > timeout) {
443
- reject(new Error(`Timeout waiting for port ${port}`));
444
- }
445
- else {
446
- setTimeout(tryConnect, retryDelay); // Retry after a delay
447
- }
448
- });
449
- }());
450
- });
458
+ async _openCdpConnection(options) {
459
+ const cdp = await CDP(options);
460
+ // @ts-expect-error newPage is not part of the CDP client type
461
+ cdp.newPage = () => { throw new Error("Use BrowserController class for creating new page!"); };
462
+ return cdp;
451
463
  }
452
464
  /**
453
- * Tries to connect to the browser using CDP with retries.
465
+ * Readiness + connect, Puppeteer-style. Await the browser-level CDP
466
+ * WebSocket endpoint the launcher parsed from Chromium's stderr, then open
467
+ * the CDP connection straight to that ws URL as the target. The endpoint
468
+ * promise already rejects fast (with the stderr tail) if the process dies,
469
+ * so a genuinely dead binary fails immediately instead of spinning a retry
470
+ * loop; a slow cold start still wins as long as it comes up within
471
+ * CDP_READINESS_TIMEOUT_MS. The overall timeout only guards a wedged process
472
+ * that never prints the line and never exits.
454
473
  *
455
474
  * @private
456
475
  */
457
- async _connectWithRetry(port, maxRetries, retryDelay) {
458
- let attempt = 0;
459
- let lastError = null;
460
- // Add delay before first attempt to allow browser to fully start
461
- await new Promise(resolve => setTimeout(resolve, 1000));
462
- while (attempt < maxRetries) {
463
- try {
464
- const cdpLauncher = new CdpLauncher();
465
- const browser = await Promise.race([
466
- cdpLauncher.launch({ port }),
467
- new Promise((_, reject) => setTimeout(() => reject(new Error('Launch timeout')), 10000)),
468
- ]);
469
- log.info(`Connected to browser on port ${port} on attempt ${attempt + 1}`);
470
- return browser;
471
- }
472
- catch (error) {
473
- attempt++;
474
- if (error instanceof Error) {
475
- lastError = error;
476
- }
477
- else {
478
- lastError = new Error(String(error));
479
- }
480
- log.warning(`Failed to connect to browser on attempt ${attempt}. Error: ${lastError.message}`);
481
- if (attempt < maxRetries) {
482
- // Exponential backoff
483
- const backoffDelay = retryDelay * 2 ** (attempt - 1);
484
- await new Promise(resolve => setTimeout(resolve, backoffDelay));
485
- }
486
- }
476
+ async _connectViaWsEndpoint(browserProcess, debuggingPort) {
477
+ let timeoutHandle;
478
+ const timeout = new Promise((_, reject) => {
479
+ timeoutHandle = setTimeout(() => reject(new Error(`Timed out after ${CDP_READINESS_TIMEOUT_MS}ms waiting for the CDP WebSocket endpoint on port ${debuggingPort}`)), CDP_READINESS_TIMEOUT_MS);
480
+ });
481
+ try {
482
+ // Race the timeout against the WHOLE readiness+connect sequence, not
483
+ // just the ws-endpoint wait: chrome-remote-interface's protocol fetch
484
+ // and ws handshake have no built-in timeout, so a browser that prints
485
+ // the DevTools banner and then stalls the handshake (wedged renderer /
486
+ // resource exhaustion under high concurrency) would otherwise hang
487
+ // _launch forever and leak the port + Chromium process.
488
+ return await Promise.race([
489
+ (async () => {
490
+ const wsUrl = await browserProcess.wsEndpoint();
491
+ log.debug(`Browser CDP endpoint ready on port ${debuggingPort}`, { wsUrl });
492
+ // `local: true` makes chrome-remote-interface use its bundled
493
+ // protocol instead of fetching it over the DevTools HTTP
494
+ // endpoint. Without it the connect opens a second TCP socket
495
+ // (an HTTP /json/protocol GET kept alive) alongside the ws -
496
+ // an /json round-trip ADR 0003 set out to remove, and a
497
+ // second live socket on the single-socket transport. The
498
+ // reconnect path (browser-controller) already connects this way.
499
+ return this._openCdpConnection({ target: wsUrl, local: true });
500
+ })(),
501
+ timeout,
502
+ ]);
503
+ }
504
+ finally {
505
+ if (timeoutHandle)
506
+ clearTimeout(timeoutHandle);
487
507
  }
488
- // Always throw the wrapped error with our standard message
489
- throw new Error(`Failed to connect to browser after ${maxRetries} attempts.`);
490
508
  }
491
509
  }
492
- Object.defineProperty(CDPBrowserPlugin, "PORT_RANGE", {
493
- enumerable: true,
494
- configurable: true,
495
- writable: true,
496
- value: {
497
- min: 9200,
498
- max: 9600, // Give a range of 400 ports instead of 200
499
- }
500
- });
501
- Object.defineProperty(CDPBrowserPlugin, "usedPorts", {
502
- enumerable: true,
503
- configurable: true,
504
- writable: true,
505
- value: new Set()
506
- });
507
- Object.defineProperty(CDPBrowserPlugin, "lastAssignedPort", {
508
- enumerable: true,
509
- configurable: true,
510
- writable: true,
511
- value: CDPBrowserPlugin.PORT_RANGE.min - 1
512
- });
513
- Object.defineProperty(CDPBrowserPlugin, "portLock", {
510
+ Object.defineProperty(CDPBrowserPlugin, "sharedPortAllocator", {
514
511
  enumerable: true,
515
512
  configurable: true,
516
513
  writable: true,
517
- value: false
514
+ value: new PortAllocator({ min: 9200, max: 9600 })
518
515
  });
519
516
  export default CDPBrowserPlugin;
520
517
  //# sourceMappingURL=browser-plugin.js.map