@crawlee/browser-pool 4.0.0-beta.19 → 4.0.0-beta.190

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