@crawlee/browser-pool 4.0.0-beta.99 → 4.0.0-rc.1

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