@crawlee/browser-pool 4.0.0-beta.20 → 4.0.0-beta.200

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 (82) hide show
  1. package/README.md +14 -14
  2. package/abstract-classes/browser-controller.d.ts +100 -29
  3. package/abstract-classes/browser-controller.js +70 -24
  4. package/abstract-classes/browser-plugin.d.ts +54 -23
  5. package/abstract-classes/browser-plugin.js +75 -13
  6. package/anonymize-proxy.d.ts +6 -2
  7. package/anonymize-proxy.js +9 -5
  8. package/browser-pool.d.ts +122 -49
  9. package/browser-pool.js +319 -147
  10. package/events.d.ts +2 -4
  11. package/events.js +0 -2
  12. package/fingerprinting/hooks.d.ts +0 -1
  13. package/fingerprinting/hooks.js +26 -10
  14. package/fingerprinting/types.d.ts +3 -34
  15. package/fingerprinting/types.js +0 -2
  16. package/fingerprinting/utils.d.ts +0 -1
  17. package/fingerprinting/utils.js +5 -6
  18. package/index.d.ts +14 -7
  19. package/index.js +5 -3
  20. package/launch-context.d.ts +21 -5
  21. package/launch-context.js +21 -10
  22. package/package.json +9 -9
  23. package/playwright/playwright-browser.d.ts +3 -6
  24. package/playwright/playwright-browser.js +17 -17
  25. package/playwright/playwright-controller.d.ts +1 -1
  26. package/playwright/playwright-controller.js +10 -6
  27. package/playwright/playwright-plugin.d.ts +12 -7
  28. package/playwright/playwright-plugin.js +42 -16
  29. package/proxy-server.d.ts +0 -1
  30. package/proxy-server.js +0 -1
  31. package/puppeteer/puppeteer-controller.d.ts +1 -1
  32. package/puppeteer/puppeteer-controller.js +29 -20
  33. package/puppeteer/puppeteer-plugin.d.ts +9 -3
  34. package/puppeteer/puppeteer-plugin.js +87 -46
  35. package/remote-browser-pool.d.ts +178 -0
  36. package/remote-browser-pool.js +206 -0
  37. package/remote-browser-provider.d.ts +83 -0
  38. package/remote-browser-provider.js +67 -0
  39. package/utils.d.ts +7 -1
  40. package/utils.js +19 -1
  41. package/abstract-classes/browser-controller.d.ts.map +0 -1
  42. package/abstract-classes/browser-controller.js.map +0 -1
  43. package/abstract-classes/browser-plugin.d.ts.map +0 -1
  44. package/abstract-classes/browser-plugin.js.map +0 -1
  45. package/anonymize-proxy.d.ts.map +0 -1
  46. package/anonymize-proxy.js.map +0 -1
  47. package/browser-pool.d.ts.map +0 -1
  48. package/browser-pool.js.map +0 -1
  49. package/container-proxy-server.d.ts +0 -12
  50. package/container-proxy-server.d.ts.map +0 -1
  51. package/container-proxy-server.js +0 -43
  52. package/container-proxy-server.js.map +0 -1
  53. package/events.d.ts.map +0 -1
  54. package/events.js.map +0 -1
  55. package/fingerprinting/hooks.d.ts.map +0 -1
  56. package/fingerprinting/hooks.js.map +0 -1
  57. package/fingerprinting/types.d.ts.map +0 -1
  58. package/fingerprinting/types.js.map +0 -1
  59. package/fingerprinting/utils.d.ts.map +0 -1
  60. package/fingerprinting/utils.js.map +0 -1
  61. package/index.d.ts.map +0 -1
  62. package/index.js.map +0 -1
  63. package/launch-context.d.ts.map +0 -1
  64. package/launch-context.js.map +0 -1
  65. package/logger.d.ts +0 -3
  66. package/logger.d.ts.map +0 -1
  67. package/logger.js +0 -5
  68. package/logger.js.map +0 -1
  69. package/playwright/playwright-browser.d.ts.map +0 -1
  70. package/playwright/playwright-browser.js.map +0 -1
  71. package/playwright/playwright-controller.d.ts.map +0 -1
  72. package/playwright/playwright-controller.js.map +0 -1
  73. package/playwright/playwright-plugin.d.ts.map +0 -1
  74. package/playwright/playwright-plugin.js.map +0 -1
  75. package/proxy-server.d.ts.map +0 -1
  76. package/proxy-server.js.map +0 -1
  77. package/puppeteer/puppeteer-controller.d.ts.map +0 -1
  78. package/puppeteer/puppeteer-controller.js.map +0 -1
  79. package/puppeteer/puppeteer-plugin.d.ts.map +0 -1
  80. package/puppeteer/puppeteer-plugin.js.map +0 -1
  81. package/utils.d.ts.map +0 -1
  82. package/utils.js.map +0 -1
package/browser-pool.js CHANGED
@@ -1,15 +1,35 @@
1
+ import { AsyncResource } from 'node:async_hooks';
2
+ import { SessionError, serviceLocator } from '@crawlee/core';
3
+ import { parseArgument, schemas } from '@crawlee/utils/internal';
1
4
  import { FingerprintGenerator } from 'fingerprint-generator';
2
5
  import { FingerprintInjector } from 'fingerprint-injector';
3
6
  import { nanoid } from 'nanoid';
4
- import ow from 'ow';
5
7
  import pLimit from 'p-limit';
6
8
  import QuickLRU from 'quick-lru';
7
9
  import { TypedEmitter } from 'tiny-typed-emitter';
10
+ import { z } from 'zod';
8
11
  import { addTimeoutToPromise, tryCancel } from '@apify/timeout';
12
+ import { BROWSER_POOL_EVENTS } from './events.js';
9
13
  import { createFingerprintPreLaunchHook, createPostPageCreateHook, createPrePageCreateHook, } from './fingerprinting/hooks.js';
10
- import { log } from './logger.js';
14
+ const PAGE_CLOSE_TIMEOUT_MILLIS = 5000;
11
15
  const PAGE_CLOSE_KILL_TIMEOUT_MILLIS = 1000;
12
16
  const BROWSER_KILLER_INTERVAL_MILLIS = 10 * 1000;
17
+ const browserPoolOptionsSchema = z.strictObject({
18
+ browserPlugins: schemas.anyArray.refine((value) => value.length >= 1, 'Expected a non-empty array'),
19
+ maxOpenPagesPerBrowser: schemas.anyNumber.default(20),
20
+ retireBrowserAfterPageCount: schemas.anyNumber.default(100),
21
+ operationTimeoutSecs: schemas.anyNumber.default(15),
22
+ closeInactiveBrowserAfterSecs: schemas.anyNumber.default(300),
23
+ retireInactiveBrowserAfterSecs: schemas.anyNumber.default(10),
24
+ preLaunchHooks: schemas.anyArray.default(() => []),
25
+ postLaunchHooks: schemas.anyArray.default(() => []),
26
+ prePageCreateHooks: schemas.anyArray.default(() => []),
27
+ postPageCreateHooks: schemas.anyArray.default(() => []),
28
+ prePageCloseHooks: schemas.anyArray.default(() => []),
29
+ postPageCloseHooks: schemas.anyArray.default(() => []),
30
+ useFingerprints: z.boolean().default(true),
31
+ fingerprintOptions: schemas.anyObject.default(() => ({})),
32
+ });
13
33
  /**
14
34
  * The `BrowserPool` class is the most important class of the `browser-pool` module.
15
35
  * It manages opening and closing of browsers and their pages and its constructor
@@ -59,51 +79,42 @@ const BROWSER_KILLER_INTERVAL_MILLIS = 10 * 1000;
59
79
  */
60
80
  export class BrowserPool extends TypedEmitter {
61
81
  browserPlugins;
62
- maxOpenPagesPerBrowser;
63
- retireBrowserAfterPageCount;
64
- operationTimeoutMillis;
65
- closeInactiveBrowserAfterMillis;
66
- useFingerprints;
82
+ /** @internal */
83
+ maxOpenBrowsers;
84
+ /** @internal */
67
85
  fingerprintOptions;
68
- preLaunchHooks;
69
- postLaunchHooks;
70
- prePageCreateHooks;
71
- postPageCreateHooks;
72
- prePageCloseHooks;
73
- postPageCloseHooks;
74
- pageCounter = 0;
75
- pages = new Map();
76
- pageIds = new WeakMap();
77
- startingBrowserControllers = new Set();
78
- activeBrowserControllers = new Set();
79
- retiredBrowserControllers = new Set();
80
- pageToBrowserController = new WeakMap();
86
+ /** @internal */
81
87
  fingerprintInjector;
82
88
  fingerprintGenerator;
89
+ /** @internal */
83
90
  fingerprintCache;
84
- browserKillerInterval = setInterval(async () => this._closeInactiveRetiredBrowsers(), BROWSER_KILLER_INTERVAL_MILLIS);
85
- browserRetireInterval;
86
- limiter = pLimit(1);
91
+ #maxOpenPagesPerBrowser;
92
+ #retireBrowserAfterPageCount;
93
+ #operationTimeoutMillis;
94
+ #closeInactiveBrowserAfterMillis;
95
+ #preLaunchHooks;
96
+ #postLaunchHooks;
97
+ #prePageCreateHooks;
98
+ #postPageCreateHooks;
99
+ #prePageCloseHooks;
100
+ #postPageCloseHooks;
101
+ #pageCounter = 0;
102
+ // TS-private rather than `#`: tests observe page tracking and controller retirement directly
103
+ pages = new Map();
104
+ #pageIds = new WeakMap();
105
+ #startingBrowserControllers = new Set();
106
+ activeBrowserControllers = new Set();
107
+ retiredBrowserControllers = new Set();
108
+ #pageToBrowserController = new WeakMap();
109
+ // kept as TS-private: tests replace this interval through bracket access
110
+ browserKillerInterval;
111
+ #browserRetireInterval;
112
+ #limiter = pLimit(1);
113
+ #log;
87
114
  constructor(options) {
88
115
  super();
89
- this.browserKillerInterval.unref();
90
- ow(options, ow.object.exactShape({
91
- browserPlugins: ow.array.minLength(1),
92
- maxOpenPagesPerBrowser: ow.optional.number,
93
- retireBrowserAfterPageCount: ow.optional.number,
94
- operationTimeoutSecs: ow.optional.number,
95
- closeInactiveBrowserAfterSecs: ow.optional.number,
96
- retireInactiveBrowserAfterSecs: ow.optional.number,
97
- preLaunchHooks: ow.optional.array,
98
- postLaunchHooks: ow.optional.array,
99
- prePageCreateHooks: ow.optional.array,
100
- postPageCreateHooks: ow.optional.array,
101
- prePageCloseHooks: ow.optional.array,
102
- postPageCloseHooks: ow.optional.array,
103
- useFingerprints: ow.optional.boolean,
104
- fingerprintOptions: ow.optional.object,
105
- }));
106
- const { browserPlugins, maxOpenPagesPerBrowser = 20, retireBrowserAfterPageCount = 100, operationTimeoutSecs = 15, closeInactiveBrowserAfterSecs = 300, retireInactiveBrowserAfterSecs = 10, preLaunchHooks = [], postLaunchHooks = [], prePageCreateHooks = [], postPageCreateHooks = [], prePageCloseHooks = [], postPageCloseHooks = [], useFingerprints = true, fingerprintOptions = {}, } = options;
116
+ this.#log = serviceLocator.getLogger().child({ prefix: 'BrowserPool' });
117
+ const { browserPlugins, maxOpenPagesPerBrowser, retireBrowserAfterPageCount, operationTimeoutSecs, closeInactiveBrowserAfterSecs, retireInactiveBrowserAfterSecs, preLaunchHooks, postLaunchHooks, prePageCreateHooks, postPageCreateHooks, prePageCloseHooks, postPageCloseHooks, useFingerprints, fingerprintOptions, } = parseArgument(options, browserPoolOptionsSchema);
107
118
  const firstPluginConstructor = browserPlugins[0].constructor;
108
119
  for (let i = 1; i < browserPlugins.length; i++) {
109
120
  const providedPlugin = browserPlugins[i];
@@ -114,52 +125,90 @@ export class BrowserPool extends TypedEmitter {
114
125
  }
115
126
  }
116
127
  this.browserPlugins = browserPlugins;
117
- this.maxOpenPagesPerBrowser = maxOpenPagesPerBrowser;
118
- this.retireBrowserAfterPageCount = retireBrowserAfterPageCount;
119
- this.operationTimeoutMillis = operationTimeoutSecs * 1000;
120
- this.closeInactiveBrowserAfterMillis = closeInactiveBrowserAfterSecs * 1000;
121
- this.useFingerprints = useFingerprints;
128
+ this.maxOpenBrowsers = Infinity;
122
129
  this.fingerprintOptions = fingerprintOptions;
123
- this.browserRetireInterval = setInterval(async () => this.activeBrowserControllers.forEach((controller) => {
130
+ this.#maxOpenPagesPerBrowser = maxOpenPagesPerBrowser;
131
+ this.#retireBrowserAfterPageCount = retireBrowserAfterPageCount;
132
+ this.#operationTimeoutMillis = operationTimeoutSecs * 1000;
133
+ this.#closeInactiveBrowserAfterMillis = closeInactiveBrowserAfterSecs * 1000;
134
+ // Sweeping slower than the window it enforces would round any sub-10s
135
+ // `closeInactiveBrowserAfterSecs` up to the sweep period.
136
+ this.browserKillerInterval = setInterval(async () => this.closeInactiveRetiredBrowsers(), Math.min(BROWSER_KILLER_INTERVAL_MILLIS, this.#closeInactiveBrowserAfterMillis));
137
+ this.browserKillerInterval.unref();
138
+ this.#browserRetireInterval = setInterval(async () => this.activeBrowserControllers.forEach((controller) => {
124
139
  if (controller.activePages === 0 &&
125
140
  controller.lastPageOpenedAt < Date.now() - retireInactiveBrowserAfterSecs * 1000) {
126
141
  this.retireBrowserController(controller);
127
142
  }
128
143
  }), retireInactiveBrowserAfterSecs * 1000);
129
- this.browserRetireInterval.unref();
144
+ this.#browserRetireInterval.unref();
130
145
  // hooks
131
- this.preLaunchHooks = preLaunchHooks;
132
- this.postLaunchHooks = postLaunchHooks;
133
- this.prePageCreateHooks = prePageCreateHooks;
134
- this.postPageCreateHooks = postPageCreateHooks;
135
- this.prePageCloseHooks = prePageCloseHooks;
136
- this.postPageCloseHooks = postPageCloseHooks;
146
+ this.#preLaunchHooks = preLaunchHooks;
147
+ this.#postLaunchHooks = postLaunchHooks;
148
+ this.#prePageCreateHooks = prePageCreateHooks;
149
+ this.#postPageCreateHooks = postPageCreateHooks;
150
+ this.#prePageCloseHooks = prePageCloseHooks;
151
+ this.#postPageCloseHooks = postPageCloseHooks;
137
152
  // fingerprinting
138
- if (this.useFingerprints) {
139
- this._initializeFingerprinting();
153
+ if (useFingerprints) {
154
+ this.initializeFingerprinting();
155
+ // The fingerprint pre-launch hook goes last because of the fingerprint cache.
156
+ // It is usual to generate proxy per browser and we want to know the proxyUrl for the caching.
157
+ this.#preLaunchHooks = [...this.#preLaunchHooks, createFingerprintPreLaunchHook(this)];
158
+ this.#prePageCreateHooks = [createPrePageCreateHook(), ...this.#prePageCreateHooks];
159
+ this.#postPageCreateHooks = [
160
+ createPostPageCreateHook(this.fingerprintInjector),
161
+ ...this.#postPageCreateHooks,
162
+ ];
140
163
  }
141
164
  }
142
165
  /**
143
166
  * Opens a new page in one of the running browsers or launches
144
167
  * a new browser and opens a page there, if no browsers are active,
145
168
  * or their page limits have been exceeded.
169
+ *
170
+ * **Session injection (best-effort):** When a {@link NewPageOptions.session|session} is
171
+ * provided, this implementation uses it as a cache key for browser fingerprints (when
172
+ * fingerprinting is enabled) and reads
173
+ * {@link ProxyInfo.url|session.proxyInfo.url} /
174
+ * {@link ProxyInfo.ignoreTlsErrors|session.proxyInfo.ignoreTlsErrors} as defaults
175
+ * for `proxyUrl` and `ignoreTlsErrors` respectively. Explicit `proxyUrl` /
176
+ * `ignoreTlsErrors` values in the options take precedence.
177
+ *
178
+ * Beyond fingerprint caching and proxy configuration, no other session
179
+ * properties are consumed — cookie and header injection remain the
180
+ * crawler's responsibility.
146
181
  */
147
182
  async newPage(options = {}) {
148
- const { id = nanoid(), pageOptions, browserPlugin = this._pickBrowserPlugin(), proxyUrl, proxyTier } = options;
183
+ const { id = nanoid(), pageOptions, browserPlugin = this.pickBrowserPlugin(), session, proxyUrl = session?.proxyInfo?.url, ignoreTlsErrors = session?.proxyInfo?.ignoreTlsErrors, } = options;
149
184
  if (this.pages.has(id)) {
150
185
  throw new Error(`Page with ID: ${id} already exists.`);
151
186
  }
152
187
  if (browserPlugin && !this.browserPlugins.includes(browserPlugin)) {
153
188
  throw new Error('Provided browserPlugin is not one of the plugins used by BrowserPool.');
154
189
  }
190
+ // Bind the limiter callback to the current async-hooks context. p-limit
191
+ // otherwise resumes queued callbacks in the previous task's
192
+ // AsyncLocalStorage context, leaking aborted cancelTasks across unrelated
193
+ // requests (https://github.com/apify/crawlee/issues/3670). Mirrors the
194
+ // fix p-limit landed upstream in v5 (sindresorhus/p-limit#71); v5 is an
195
+ // ESM-only rewrite, so we can't bump it in Crawlee v3.
196
+ // Besides the cancelTask leak, the wrapper also keeps the per-request *storage transaction*
197
+ // ALS-scoped: without it, a queued callback would resume in the previous request's async
198
+ // context and run request B's storage writes inside request A's transaction.
199
+ // TODO(crawlee@v4): bump p-limit to v5 and drop this AsyncResource.bind wrapper.
155
200
  // Limiter is necessary - https://github.com/apify/crawlee/issues/1126
156
- return this.limiter(async () => {
157
- let browserController = this._pickBrowserWithFreeCapacity(browserPlugin, { proxyTier, proxyUrl });
201
+ return this.#limiter(AsyncResource.bind(async () => {
202
+ let browserController = this.pickBrowserWithFreeCapacity(browserPlugin, { proxyUrl });
158
203
  if (!browserController)
159
- browserController = await this._launchBrowser(id, { browserPlugin, proxyTier, proxyUrl });
204
+ browserController = await this.launchBrowser(id, {
205
+ browserPlugin,
206
+ proxyUrl,
207
+ ignoreTlsErrors,
208
+ });
160
209
  tryCancel();
161
- return await this._createPageForBrowser(id, browserController, pageOptions, proxyUrl);
162
- });
210
+ return await this.createPageForBrowser(id, browserController, pageOptions, proxyUrl, ignoreTlsErrors);
211
+ }));
163
212
  }
164
213
  /**
165
214
  * Unlike {@link newPage}, `newPageInNewBrowser` always launches a new
@@ -167,13 +216,13 @@ export class BrowserPool extends TypedEmitter {
167
216
  * configure the new browser.
168
217
  */
169
218
  async newPageInNewBrowser(options = {}) {
170
- const { id = nanoid(), pageOptions, launchOptions, browserPlugin = this._pickBrowserPlugin() } = options;
219
+ const { id = nanoid(), pageOptions, launchOptions, browserPlugin = this.pickBrowserPlugin() } = options;
171
220
  if (this.pages.has(id)) {
172
221
  throw new Error(`Page with ID: ${id} already exists.`);
173
222
  }
174
- const browserController = await this._launchBrowser(id, { launchOptions, browserPlugin });
223
+ const browserController = await this.launchBrowser(id, { launchOptions, browserPlugin });
175
224
  tryCancel();
176
- return await this._createPageForBrowser(id, browserController, pageOptions);
225
+ return await this.createPageForBrowser(id, browserController, pageOptions);
177
226
  }
178
227
  /**
179
228
  * Opens new pages with all available plugins and returns an array
@@ -219,7 +268,7 @@ export class BrowserPool extends TypedEmitter {
219
268
  * @param page - Browser plugin page
220
269
  */
221
270
  getBrowserControllerByPage(page) {
222
- return this.pageToBrowserController.get(page);
271
+ return this.#pageToBrowserController.get(page);
223
272
  }
224
273
  /**
225
274
  * If you provided a custom ID to one of your pages or saved the
@@ -237,40 +286,45 @@ export class BrowserPool extends TypedEmitter {
237
286
  * until it's closed.
238
287
  */
239
288
  getPageId(page) {
240
- return this.pageIds.get(page);
289
+ return this.#pageIds.get(page);
241
290
  }
242
- async _createPageForBrowser(pageId, browserController, pageOptions = {}, proxyUrl) {
291
+ async createPageForBrowser(pageId, browserController, pageOptions = {}, proxyUrl, ignoreTlsErrors) {
243
292
  // This is needed for concurrent newPage calls to wait for the browser launch.
244
293
  // It's not ideal though, we need to come up with a better API.
245
- // eslint-disable-next-line dot-notation -- accessing private property
246
- await browserController['isActivePromise'];
294
+ await browserController.waitForActive();
247
295
  tryCancel();
248
296
  const finalPageOptions = browserController.launchContext.useIncognitoPages ? pageOptions : undefined;
249
297
  if (finalPageOptions) {
250
298
  Object.assign(finalPageOptions, browserController.normalizeProxyOptions(proxyUrl, pageOptions));
299
+ if (ignoreTlsErrors) {
300
+ Object.assign(finalPageOptions, {
301
+ ignoreHTTPSErrors: true,
302
+ acceptInsecureCerts: true,
303
+ });
304
+ }
251
305
  }
252
- await this._executeHooks(this.prePageCreateHooks, pageId, browserController, finalPageOptions);
306
+ await this.executeHooks(this.#prePageCreateHooks, pageId, browserController, finalPageOptions);
253
307
  tryCancel();
254
308
  let page;
255
309
  try {
256
- page = (await addTimeoutToPromise(async () => browserController.newPage(finalPageOptions), this.operationTimeoutMillis, 'browserController.newPage() timed out.'));
310
+ page = (await addTimeoutToPromise(async () => browserController.newPage(finalPageOptions), this.#operationTimeoutMillis, 'browserController.newPage() timed out.'));
257
311
  tryCancel();
258
312
  this.pages.set(pageId, page);
259
- this.pageIds.set(page, pageId);
260
- this.pageToBrowserController.set(page, browserController);
313
+ this.#pageIds.set(page, pageId);
314
+ this.#pageToBrowserController.set(page, browserController);
261
315
  // if you synchronously trigger a lot of page launches, browser will not get retired soon enough. Not sure if it's a problem, let's monitor it.
262
- if (browserController.totalPages >= this.retireBrowserAfterPageCount) {
316
+ if (browserController.totalPages >= this.#retireBrowserAfterPageCount) {
263
317
  this.retireBrowserController(browserController);
264
318
  }
265
- this._overridePageClose(page);
319
+ this.overridePageClose(page);
266
320
  }
267
321
  catch (err) {
268
322
  this.retireBrowserController(browserController);
269
323
  throw new Error(`browserController.newPage() failed: ${browserController.id}\nCause:${err.message}.`);
270
324
  }
271
- await this._executeHooks(this.postPageCreateHooks, page, browserController);
325
+ await this.executeHooks(this.#postPageCreateHooks, page, browserController);
272
326
  tryCancel();
273
- this.emit("pageCreated" /* BROWSER_POOL_EVENTS.PAGE_CREATED */, page);
327
+ this.emit(BROWSER_POOL_EVENTS.PAGE_CREATED, page);
274
328
  return page;
275
329
  }
276
330
  /**
@@ -279,14 +333,14 @@ export class BrowserPool extends TypedEmitter {
279
333
  *
280
334
  */
281
335
  retireBrowserController(browserController) {
282
- const isStarting = this.startingBrowserControllers.has(browserController);
336
+ const isStarting = this.#startingBrowserControllers.has(browserController);
283
337
  const isActive = this.activeBrowserControllers.has(browserController);
284
338
  const hasBeenRetiredOrKilled = !isStarting && !isActive;
285
339
  if (hasBeenRetiredOrKilled)
286
340
  return;
287
341
  this.retiredBrowserControllers.add(browserController);
288
- this.emit("browserRetired" /* BROWSER_POOL_EVENTS.BROWSER_RETIRED */, browserController);
289
- this.startingBrowserControllers.delete(browserController);
342
+ this.emit(BROWSER_POOL_EVENTS.BROWSER_RETIRED, browserController);
343
+ this.#startingBrowserControllers.delete(browserController);
290
344
  this.activeBrowserControllers.delete(browserController);
291
345
  }
292
346
  /**
@@ -298,12 +352,67 @@ export class BrowserPool extends TypedEmitter {
298
352
  if (browserController)
299
353
  this.retireBrowserController(browserController);
300
354
  }
355
+ /**
356
+ * Releases a page back to the pool. The page is closed and, if the
357
+ * optional `error` is a {@link SessionError}, the browser controller
358
+ * that served the page is retired so that its tainted state (cookies,
359
+ * storage, etc.) cannot leak into future sessions.
360
+ *
361
+ * This is the primary way the crawler should return pages to the pool.
362
+ *
363
+ * @param page The page to release.
364
+ * @param options.error The error that caused the page to be released, if any.
365
+ */
366
+ async closePage(page, options) {
367
+ if (options?.error instanceof SessionError) {
368
+ this.retireBrowserByPage(page);
369
+ }
370
+ // The bound on this lives in `overridePageClose`, where it covers the pool's own
371
+ // bookkeeping as well. Wrapping it here instead would time out the override and abandon
372
+ // that bookkeeping, leaving the browser holding a slot it can never get back.
373
+ await page.close();
374
+ }
375
+ /**
376
+ * Extracts the relevant state (currently just cookies) from a page via its
377
+ * owning {@link BrowserController}. Returns empty state when the page is
378
+ * no longer associated with a controller.
379
+ *
380
+ * As with {@link BrowserPool.injectPageState}, cookies are isolated per
381
+ * page only when the pool is configured with `useIncognitoPages: true`.
382
+ * With the default `useIncognitoPages: false`, the extracted cookies
383
+ * include those set by any sibling page sharing the same browser.
384
+ */
385
+ async extractPageState(page) {
386
+ const controller = this.getBrowserControllerByPage(page);
387
+ if (!controller) {
388
+ return { cookies: [] };
389
+ }
390
+ return { cookies: await controller.getCookies(page) };
391
+ }
392
+ /**
393
+ * Injects state into a page via its owning {@link BrowserController}.
394
+ *
395
+ * No-op when the page is no longer associated with a controller.
396
+ *
397
+ * Note that cookies are isolated per page only when the pool is configured
398
+ * with `useIncognitoPages: true` — each page then gets its own browser
399
+ * context. With the default `useIncognitoPages: false`, all pages in a
400
+ * browser share a single context, so injected cookies are visible to every
401
+ * page served by that browser.
402
+ */
403
+ async injectPageState(page, state) {
404
+ const controller = this.getBrowserControllerByPage(page);
405
+ if (!controller) {
406
+ return;
407
+ }
408
+ await controller.setCookies(page, state.cookies);
409
+ }
301
410
  /**
302
411
  * Removes all active browsers from the pool. The browsers will be
303
412
  * closed after all their pages are closed.
304
413
  */
305
414
  retireAllBrowsers() {
306
- [...this.startingBrowserControllers, ...this.activeBrowserControllers].forEach((controller) => {
415
+ [...this.#startingBrowserControllers, ...this.activeBrowserControllers].forEach((controller) => {
307
416
  this.retireBrowserController(controller);
308
417
  });
309
418
  }
@@ -312,71 +421,86 @@ export class BrowserPool extends TypedEmitter {
312
421
  * @return {Promise<void>}
313
422
  */
314
423
  async closeAllBrowsers() {
315
- const controllers = this._getAllBrowserControllers();
424
+ const controllers = this.getAllBrowserControllers();
316
425
  const promises = [...controllers]
317
426
  .filter((controller) => controller.isActive)
318
427
  .map(async (controller) => controller.close());
319
428
  await Promise.all(promises);
320
429
  }
430
+ async [Symbol.asyncDispose]() {
431
+ await this.destroy();
432
+ }
321
433
  /**
322
- * Closes all managed browsers and tears down the pool.
434
+ * Closes every managed browser and empties the pool, which stays usable afterwards — a crawler releases its
435
+ * browsers when a run ends and may start another run on the same pool.
323
436
  */
324
- async destroy() {
325
- clearInterval(this.browserKillerInterval);
326
- clearInterval(this.browserRetireInterval);
327
- this.browserKillerInterval = undefined;
328
- this.browserRetireInterval = undefined;
437
+ async releaseAllBrowsers() {
329
438
  await this.closeAllBrowsers();
330
- this._teardown();
331
- }
332
- _teardown() {
333
- this.startingBrowserControllers.clear();
439
+ this.#startingBrowserControllers.clear();
334
440
  this.activeBrowserControllers.clear();
335
441
  this.retiredBrowserControllers.clear();
442
+ }
443
+ /**
444
+ * Closes all managed browsers and tears the pool down for good: its intervals are cleared without being
445
+ * re-armed and its listeners are dropped, so it cannot be used again.
446
+ */
447
+ async destroy() {
448
+ clearInterval(this.browserKillerInterval);
449
+ clearInterval(this.#browserRetireInterval);
450
+ this.browserKillerInterval = undefined;
451
+ this.#browserRetireInterval = undefined;
452
+ await this.releaseAllBrowsers();
336
453
  this.removeAllListeners();
337
454
  }
338
- _getAllBrowserControllers() {
455
+ getAllBrowserControllers() {
339
456
  return new Set([
340
- ...this.startingBrowserControllers,
457
+ ...this.#startingBrowserControllers,
341
458
  ...this.activeBrowserControllers,
342
459
  ...this.retiredBrowserControllers,
343
460
  ]);
344
461
  }
345
- async _launchBrowser(pageId, options) {
346
- const { browserPlugin, launchOptions, proxyTier, proxyUrl } = options;
462
+ async launchBrowser(pageId, options) {
463
+ const { browserPlugin, launchOptions, proxyUrl, ignoreTlsErrors } = options;
347
464
  const browserController = browserPlugin.createController();
348
- this.startingBrowserControllers.add(browserController);
465
+ this.#startingBrowserControllers.add(browserController);
349
466
  const launchContext = browserPlugin.createLaunchContext({
350
467
  id: pageId,
351
468
  launchOptions,
352
- proxyTier,
353
469
  proxyUrl,
354
470
  });
471
+ // Disable SSL verification for MITM proxies
472
+ if (ignoreTlsErrors) {
473
+ /**
474
+ * @see https://playwright.dev/docs/api/class-browser/#browser-new-context
475
+ * @see https://github.com/puppeteer/puppeteer/blob/main/docs/api.md
476
+ */
477
+ launchContext.launchOptions.ignoreHTTPSErrors = true;
478
+ launchContext.launchOptions.acceptInsecureCerts = true;
479
+ }
355
480
  try {
356
481
  // If the hooks or the launch fails, we need to delete the controller,
357
482
  // because otherwise it would be stuck in limbo without a browser.
358
- await this._executeHooks(this.preLaunchHooks, pageId, launchContext);
483
+ await this.executeHooks(this.#preLaunchHooks, pageId, launchContext);
359
484
  tryCancel();
360
485
  const browser = await browserPlugin.launch(launchContext);
361
486
  tryCancel();
362
487
  browserController.assignBrowser(browser, launchContext);
363
488
  }
364
489
  catch (err) {
365
- this.startingBrowserControllers.delete(browserController);
490
+ this.#startingBrowserControllers.delete(browserController);
366
491
  throw err;
367
492
  }
368
- log.debug('Launched new browser.', { id: browserController.id });
369
- browserController.proxyTier = proxyTier;
493
+ this.#log.debug('Launched new browser.', { id: browserController.id });
370
494
  browserController.proxyUrl = proxyUrl;
371
495
  try {
372
496
  // If the launch fails on the post-launch hooks, we need to clean up
373
497
  // both the controller and the browser before throwing.
374
- await this._executeHooks(this.postLaunchHooks, pageId, browserController);
498
+ await this.executeHooks(this.#postLaunchHooks, pageId, browserController);
375
499
  }
376
500
  catch (err) {
377
- this.startingBrowserControllers.delete(browserController);
501
+ this.#startingBrowserControllers.delete(browserController);
378
502
  browserController.close().catch((closeErr) => {
379
- log.error(`Could not close browser whose post-launch hooks failed.\nCause:${closeErr.message}`, {
503
+ this.#log.error(`Could not close browser whose post-launch hooks failed.\nCause:${closeErr.message}`, {
380
504
  id: browserController.id,
381
505
  });
382
506
  });
@@ -384,105 +508,153 @@ export class BrowserPool extends TypedEmitter {
384
508
  }
385
509
  tryCancel();
386
510
  browserController.activate();
387
- this.startingBrowserControllers.delete(browserController);
511
+ this.#startingBrowserControllers.delete(browserController);
388
512
  this.activeBrowserControllers.add(browserController);
389
- this.emit("browserLaunched" /* BROWSER_POOL_EVENTS.BROWSER_LAUNCHED */, browserController);
513
+ this.emit(BROWSER_POOL_EVENTS.BROWSER_LAUNCHED, browserController);
390
514
  return browserController;
391
515
  }
392
516
  /**
393
517
  * Picks plugins round robin.
394
- * @private
395
518
  */
396
- _pickBrowserPlugin() {
397
- const pluginIndex = this.pageCounter % this.browserPlugins.length;
398
- this.pageCounter++;
519
+ pickBrowserPlugin() {
520
+ const pluginIndex = this.#pageCounter % this.browserPlugins.length;
521
+ this.#pageCounter++;
399
522
  return this.browserPlugins[pluginIndex];
400
523
  }
401
- _pickBrowserWithFreeCapacity(browserPlugin, options) {
524
+ pickBrowserWithFreeCapacity(browserPlugin, options) {
402
525
  return [...this.activeBrowserControllers].find((controller) => {
403
- const hasCapacity = controller.activePages < this.maxOpenPagesPerBrowser;
526
+ const hasCapacity = controller.activePages < this.#maxOpenPagesPerBrowser;
404
527
  const isCorrectPlugin = controller.browserPlugin === browserPlugin;
405
528
  const isSameProxyUrl = controller.proxyUrl === options?.proxyUrl;
406
- const isCorrectProxyTier = controller.proxyTier === options?.proxyTier;
407
529
  return (isCorrectPlugin &&
408
530
  hasCapacity &&
409
- ((!controller.launchContext.browserPerProxy && !options?.proxyTier) ||
410
- (options?.proxyTier && isCorrectProxyTier) ||
531
+ (!controller.launchContext.browserPerProxy ||
411
532
  (options?.proxyUrl && isSameProxyUrl) ||
412
- (!options?.proxyUrl && !options?.proxyTier && !controller.proxyUrl && !controller.proxyTier)));
533
+ (!options?.proxyUrl && !controller.proxyUrl)));
413
534
  });
414
535
  }
415
- async _closeInactiveRetiredBrowsers() {
536
+ async closeInactiveRetiredBrowsers() {
416
537
  const closedBrowserIds = [];
417
538
  for (const controller of this.retiredBrowserControllers) {
418
539
  const millisSinceLastPageOpened = Date.now() - controller.lastPageOpenedAt;
419
- const isBrowserIdle = millisSinceLastPageOpened >= this.closeInactiveBrowserAfterMillis;
540
+ const isBrowserIdle = millisSinceLastPageOpened >= this.#closeInactiveBrowserAfterMillis;
420
541
  const isBrowserEmpty = controller.activePages === 0;
421
542
  if (isBrowserIdle || isBrowserEmpty) {
422
543
  const { id } = controller;
423
- log.debug('Closing retired browser.', { id });
544
+ this.#log.debug('Closing retired browser.', { id });
424
545
  await controller.close();
425
546
  this.retiredBrowserControllers.delete(controller);
426
547
  closedBrowserIds.push(id);
427
548
  }
428
549
  }
429
550
  if (closedBrowserIds.length) {
430
- log.debug('Closed retired browsers.', {
551
+ this.#log.debug('Closed retired browsers.', {
431
552
  count: closedBrowserIds.length,
432
553
  closedBrowserIds,
433
554
  });
434
555
  }
435
556
  }
436
- _overridePageClose(page) {
557
+ overridePageClose(page) {
437
558
  const originalPageClose = page.close;
438
- const browserController = this.pageToBrowserController.get(page);
559
+ const browserController = this.#pageToBrowserController.get(page);
439
560
  const pageId = this.getPageId(page);
440
561
  page.close = async (...args) => {
441
- await this._executeHooks(this.prePageCloseHooks, page, browserController);
442
- await originalPageClose.apply(page, args).catch((err) => {
443
- log.debug(`Could not close page.\nCause:${err.message}`, { id: browserController.id });
444
- });
445
- await this._executeHooks(this.postPageCloseHooks, pageId, browserController);
562
+ // A browser can acknowledge the close and then never destroy the target, in which case
563
+ // this never settles and the page's own `close` event never fires either (Chromium
564
+ // 536385539). Bound the whole sequence so nothing here can hang the caller, then
565
+ // reconcile the pool regardless of the outcome, so a page that refuses to close cannot
566
+ // leave the pool believing it is still open. `Promise.race` rather than
567
+ // `addTimeoutToPromise`: the latter inherits its AbortController from the calling frame,
568
+ // so a timeout here would cancel the task of whoever awaited `page.close()`.
569
+ let pageClosed = false;
570
+ const closing = (async () => {
571
+ await this.executeHooks(this.#prePageCloseHooks, page, browserController);
572
+ await originalPageClose.apply(page, args).catch((err) => {
573
+ this.#log.debug(`Could not close page.\nCause:${err.message}`, { id: browserController.id });
574
+ });
575
+ // Whether the page itself is gone. Tracked separately from the sequence finishing,
576
+ // so that a slow hook does not get the browser retired.
577
+ pageClosed = true;
578
+ await this.executeHooks(this.#postPageCloseHooks, pageId, browserController);
579
+ })();
580
+ let timeout;
581
+ const finished = await Promise.race([
582
+ closing.then(() => true, (err) => {
583
+ this.#log.warning(`Closing a page failed, releasing it from the pool anyway.\nCause:${err.message}`, { id: browserController.id, pageId });
584
+ return true;
585
+ }),
586
+ new Promise((resolve) => {
587
+ timeout = setTimeout(() => resolve(false), PAGE_CLOSE_TIMEOUT_MILLIS);
588
+ }),
589
+ ]);
590
+ clearTimeout(timeout);
591
+ if (!finished) {
592
+ this.#log.warning(`Closing a page did not finish within ${PAGE_CLOSE_TIMEOUT_MILLIS / 1000} seconds, ` +
593
+ 'releasing it from the pool anyway.', { id: browserController.id, pageId });
594
+ }
595
+ if (!pageClosed) {
596
+ // The page is still attached, so this browser cannot be trusted with more work.
597
+ // Retiring it lets the reclamation below close the process once its pages drain.
598
+ this.retireBrowserController(browserController);
599
+ }
600
+ browserController.registerPageClosed(page);
446
601
  this.pages.delete(pageId);
447
- this._closeRetiredBrowserWithNoPages(browserController);
448
- this.emit("pageClosed" /* BROWSER_POOL_EVENTS.PAGE_CLOSED */, page);
602
+ this.closeRetiredBrowserWithNoPages(browserController);
603
+ this.emit(BROWSER_POOL_EVENTS.PAGE_CLOSED, page);
449
604
  };
450
605
  }
451
- async _executeHooks(hooks, ...args) {
606
+ async executeHooks(hooks, ...args) {
452
607
  for (const hook of hooks) {
453
608
  await hook(...args);
454
609
  }
455
610
  }
456
- _closeRetiredBrowserWithNoPages(browserController) {
611
+ closeRetiredBrowserWithNoPages(browserController) {
457
612
  if (browserController.activePages === 0 && this.retiredBrowserControllers.has(browserController)) {
458
613
  // Run this with a delay, otherwise page.close()
459
614
  // might fail with "Protocol error (Target.closeTarget): Target closed."
460
615
  setTimeout(() => {
461
- log.debug('Closing retired browser because it has no active pages', { id: browserController.id });
616
+ this.#log.debug('Closing retired browser because it has no active pages', { id: browserController.id });
462
617
  void browserController.close().finally(() => {
463
618
  this.retiredBrowserControllers.delete(browserController);
464
619
  });
465
620
  }, PAGE_CLOSE_KILL_TIMEOUT_MILLIS);
466
621
  }
467
622
  }
468
- _initializeFingerprinting() {
623
+ /**
624
+ * Returns `true` if the pool can accept a new browser launch without exceeding `maxOpenBrowsers`.
625
+ * Counts starting, active, and retired browsers.
626
+ *
627
+ * A plain `BrowserPool` leaves `maxOpenBrowsers` at `Infinity`, so this only returns `false` when something
628
+ * has set a cap — {@link RemoteBrowserPool} does, from its own
629
+ * {@link RemoteBrowserPoolOptions.maxOpenBrowsers|`maxOpenBrowsers`} option. There is no
630
+ * `BrowserPoolOptions` key for it.
631
+ *
632
+ * @internal
633
+ */
634
+ hasFreeBrowserSlot() {
635
+ const total = this.#startingBrowserControllers.size +
636
+ this.activeBrowserControllers.size +
637
+ this.retiredBrowserControllers.size;
638
+ return total < this.maxOpenBrowsers;
639
+ }
640
+ /**
641
+ * Returns `true` if any active browser has room for another page.
642
+ *
643
+ * @internal
644
+ */
645
+ hasActiveBrowserWithFreeCapacity() {
646
+ for (const controller of this.activeBrowserControllers) {
647
+ if (controller.activePages < this.#maxOpenPagesPerBrowser)
648
+ return true;
649
+ }
650
+ return false;
651
+ }
652
+ initializeFingerprinting() {
469
653
  const { useFingerprintCache = true, fingerprintCacheSize = 10_000 } = this.fingerprintOptions;
470
654
  this.fingerprintGenerator = new FingerprintGenerator(this.fingerprintOptions.fingerprintGeneratorOptions);
471
655
  this.fingerprintInjector = new FingerprintInjector();
472
656
  if (useFingerprintCache) {
473
657
  this.fingerprintCache = new QuickLRU({ maxSize: fingerprintCacheSize });
474
658
  }
475
- this._addFingerprintHooks();
476
- }
477
- _addFingerprintHooks() {
478
- this.preLaunchHooks = [
479
- ...this.preLaunchHooks,
480
- // This is flipped because of the fingerprint cache.
481
- // It is usual to generate proxy per browser and we want to know the proxyUrl for the caching.
482
- createFingerprintPreLaunchHook(this),
483
- ];
484
- this.prePageCreateHooks = [createPrePageCreateHook(), ...this.prePageCreateHooks];
485
- this.postPageCreateHooks = [createPostPageCreateHook(this.fingerprintInjector), ...this.postPageCreateHooks];
486
659
  }
487
660
  }
488
- //# sourceMappingURL=browser-pool.js.map