@crawlee/browser-pool 4.0.0-beta.2 → 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 (83) hide show
  1. package/README.md +17 -13
  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 -48
  9. package/browser-pool.js +326 -143
  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 +11 -11
  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 +24 -13
  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/tsconfig.build.tsbuildinfo +0 -1
  82. package/utils.d.ts.map +0 -1
  83. 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,50 +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
- activeBrowserControllers = new Set();
78
- retiredBrowserControllers = new Set();
79
- pageToBrowserController = new WeakMap();
86
+ /** @internal */
80
87
  fingerprintInjector;
81
88
  fingerprintGenerator;
89
+ /** @internal */
82
90
  fingerprintCache;
83
- browserKillerInterval = setInterval(async () => this._closeInactiveRetiredBrowsers(), BROWSER_KILLER_INTERVAL_MILLIS);
84
- browserRetireInterval;
85
- 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;
86
114
  constructor(options) {
87
115
  super();
88
- this.browserKillerInterval.unref();
89
- ow(options, ow.object.exactShape({
90
- browserPlugins: ow.array.minLength(1),
91
- maxOpenPagesPerBrowser: ow.optional.number,
92
- retireBrowserAfterPageCount: ow.optional.number,
93
- operationTimeoutSecs: ow.optional.number,
94
- closeInactiveBrowserAfterSecs: ow.optional.number,
95
- retireInactiveBrowserAfterSecs: ow.optional.number,
96
- preLaunchHooks: ow.optional.array,
97
- postLaunchHooks: ow.optional.array,
98
- prePageCreateHooks: ow.optional.array,
99
- postPageCreateHooks: ow.optional.array,
100
- prePageCloseHooks: ow.optional.array,
101
- postPageCloseHooks: ow.optional.array,
102
- useFingerprints: ow.optional.boolean,
103
- fingerprintOptions: ow.optional.object,
104
- }));
105
- 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);
106
118
  const firstPluginConstructor = browserPlugins[0].constructor;
107
119
  for (let i = 1; i < browserPlugins.length; i++) {
108
120
  const providedPlugin = browserPlugins[i];
@@ -113,52 +125,90 @@ export class BrowserPool extends TypedEmitter {
113
125
  }
114
126
  }
115
127
  this.browserPlugins = browserPlugins;
116
- this.maxOpenPagesPerBrowser = maxOpenPagesPerBrowser;
117
- this.retireBrowserAfterPageCount = retireBrowserAfterPageCount;
118
- this.operationTimeoutMillis = operationTimeoutSecs * 1000;
119
- this.closeInactiveBrowserAfterMillis = closeInactiveBrowserAfterSecs * 1000;
120
- this.useFingerprints = useFingerprints;
128
+ this.maxOpenBrowsers = Infinity;
121
129
  this.fingerprintOptions = fingerprintOptions;
122
- 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) => {
123
139
  if (controller.activePages === 0 &&
124
140
  controller.lastPageOpenedAt < Date.now() - retireInactiveBrowserAfterSecs * 1000) {
125
141
  this.retireBrowserController(controller);
126
142
  }
127
143
  }), retireInactiveBrowserAfterSecs * 1000);
128
- this.browserRetireInterval.unref();
144
+ this.#browserRetireInterval.unref();
129
145
  // hooks
130
- this.preLaunchHooks = preLaunchHooks;
131
- this.postLaunchHooks = postLaunchHooks;
132
- this.prePageCreateHooks = prePageCreateHooks;
133
- this.postPageCreateHooks = postPageCreateHooks;
134
- this.prePageCloseHooks = prePageCloseHooks;
135
- 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;
136
152
  // fingerprinting
137
- if (this.useFingerprints) {
138
- 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
+ ];
139
163
  }
140
164
  }
141
165
  /**
142
166
  * Opens a new page in one of the running browsers or launches
143
167
  * a new browser and opens a page there, if no browsers are active,
144
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.
145
181
  */
146
182
  async newPage(options = {}) {
147
- 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;
148
184
  if (this.pages.has(id)) {
149
185
  throw new Error(`Page with ID: ${id} already exists.`);
150
186
  }
151
187
  if (browserPlugin && !this.browserPlugins.includes(browserPlugin)) {
152
188
  throw new Error('Provided browserPlugin is not one of the plugins used by BrowserPool.');
153
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.
154
200
  // Limiter is necessary - https://github.com/apify/crawlee/issues/1126
155
- return this.limiter(async () => {
156
- let browserController = this._pickBrowserWithFreeCapacity(browserPlugin, { proxyTier, proxyUrl });
201
+ return this.#limiter(AsyncResource.bind(async () => {
202
+ let browserController = this.pickBrowserWithFreeCapacity(browserPlugin, { proxyUrl });
157
203
  if (!browserController)
158
- browserController = await this._launchBrowser(id, { browserPlugin, proxyTier, proxyUrl });
204
+ browserController = await this.launchBrowser(id, {
205
+ browserPlugin,
206
+ proxyUrl,
207
+ ignoreTlsErrors,
208
+ });
159
209
  tryCancel();
160
- return await this._createPageForBrowser(id, browserController, pageOptions, proxyUrl);
161
- });
210
+ return await this.createPageForBrowser(id, browserController, pageOptions, proxyUrl, ignoreTlsErrors);
211
+ }));
162
212
  }
163
213
  /**
164
214
  * Unlike {@link newPage}, `newPageInNewBrowser` always launches a new
@@ -166,13 +216,13 @@ export class BrowserPool extends TypedEmitter {
166
216
  * configure the new browser.
167
217
  */
168
218
  async newPageInNewBrowser(options = {}) {
169
- const { id = nanoid(), pageOptions, launchOptions, browserPlugin = this._pickBrowserPlugin() } = options;
219
+ const { id = nanoid(), pageOptions, launchOptions, browserPlugin = this.pickBrowserPlugin() } = options;
170
220
  if (this.pages.has(id)) {
171
221
  throw new Error(`Page with ID: ${id} already exists.`);
172
222
  }
173
- const browserController = await this._launchBrowser(id, { launchOptions, browserPlugin });
223
+ const browserController = await this.launchBrowser(id, { launchOptions, browserPlugin });
174
224
  tryCancel();
175
- return await this._createPageForBrowser(id, browserController, pageOptions);
225
+ return await this.createPageForBrowser(id, browserController, pageOptions);
176
226
  }
177
227
  /**
178
228
  * Opens new pages with all available plugins and returns an array
@@ -218,7 +268,7 @@ export class BrowserPool extends TypedEmitter {
218
268
  * @param page - Browser plugin page
219
269
  */
220
270
  getBrowserControllerByPage(page) {
221
- return this.pageToBrowserController.get(page);
271
+ return this.#pageToBrowserController.get(page);
222
272
  }
223
273
  /**
224
274
  * If you provided a custom ID to one of your pages or saved the
@@ -236,40 +286,45 @@ export class BrowserPool extends TypedEmitter {
236
286
  * until it's closed.
237
287
  */
238
288
  getPageId(page) {
239
- return this.pageIds.get(page);
289
+ return this.#pageIds.get(page);
240
290
  }
241
- async _createPageForBrowser(pageId, browserController, pageOptions = {}, proxyUrl) {
291
+ async createPageForBrowser(pageId, browserController, pageOptions = {}, proxyUrl, ignoreTlsErrors) {
242
292
  // This is needed for concurrent newPage calls to wait for the browser launch.
243
293
  // It's not ideal though, we need to come up with a better API.
244
- // eslint-disable-next-line dot-notation -- accessing private property
245
- await browserController['isActivePromise'];
294
+ await browserController.waitForActive();
246
295
  tryCancel();
247
296
  const finalPageOptions = browserController.launchContext.useIncognitoPages ? pageOptions : undefined;
248
297
  if (finalPageOptions) {
249
298
  Object.assign(finalPageOptions, browserController.normalizeProxyOptions(proxyUrl, pageOptions));
299
+ if (ignoreTlsErrors) {
300
+ Object.assign(finalPageOptions, {
301
+ ignoreHTTPSErrors: true,
302
+ acceptInsecureCerts: true,
303
+ });
304
+ }
250
305
  }
251
- await this._executeHooks(this.prePageCreateHooks, pageId, browserController, finalPageOptions);
306
+ await this.executeHooks(this.#prePageCreateHooks, pageId, browserController, finalPageOptions);
252
307
  tryCancel();
253
308
  let page;
254
309
  try {
255
- 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.'));
256
311
  tryCancel();
257
312
  this.pages.set(pageId, page);
258
- this.pageIds.set(page, pageId);
259
- this.pageToBrowserController.set(page, browserController);
313
+ this.#pageIds.set(page, pageId);
314
+ this.#pageToBrowserController.set(page, browserController);
260
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.
261
- if (browserController.totalPages >= this.retireBrowserAfterPageCount) {
316
+ if (browserController.totalPages >= this.#retireBrowserAfterPageCount) {
262
317
  this.retireBrowserController(browserController);
263
318
  }
264
- this._overridePageClose(page);
319
+ this.overridePageClose(page);
265
320
  }
266
321
  catch (err) {
267
322
  this.retireBrowserController(browserController);
268
323
  throw new Error(`browserController.newPage() failed: ${browserController.id}\nCause:${err.message}.`);
269
324
  }
270
- await this._executeHooks(this.postPageCreateHooks, page, browserController);
325
+ await this.executeHooks(this.#postPageCreateHooks, page, browserController);
271
326
  tryCancel();
272
- this.emit("pageCreated" /* BROWSER_POOL_EVENTS.PAGE_CREATED */, page);
327
+ this.emit(BROWSER_POOL_EVENTS.PAGE_CREATED, page);
273
328
  return page;
274
329
  }
275
330
  /**
@@ -278,11 +333,14 @@ export class BrowserPool extends TypedEmitter {
278
333
  *
279
334
  */
280
335
  retireBrowserController(browserController) {
281
- const hasBeenRetiredOrKilled = !this.activeBrowserControllers.has(browserController);
336
+ const isStarting = this.#startingBrowserControllers.has(browserController);
337
+ const isActive = this.activeBrowserControllers.has(browserController);
338
+ const hasBeenRetiredOrKilled = !isStarting && !isActive;
282
339
  if (hasBeenRetiredOrKilled)
283
340
  return;
284
341
  this.retiredBrowserControllers.add(browserController);
285
- this.emit("browserRetired" /* BROWSER_POOL_EVENTS.BROWSER_RETIRED */, browserController);
342
+ this.emit(BROWSER_POOL_EVENTS.BROWSER_RETIRED, browserController);
343
+ this.#startingBrowserControllers.delete(browserController);
286
344
  this.activeBrowserControllers.delete(browserController);
287
345
  }
288
346
  /**
@@ -294,12 +352,67 @@ export class BrowserPool extends TypedEmitter {
294
352
  if (browserController)
295
353
  this.retireBrowserController(browserController);
296
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
+ }
297
410
  /**
298
411
  * Removes all active browsers from the pool. The browsers will be
299
412
  * closed after all their pages are closed.
300
413
  */
301
414
  retireAllBrowsers() {
302
- this.activeBrowserControllers.forEach((controller) => {
415
+ [...this.#startingBrowserControllers, ...this.activeBrowserControllers].forEach((controller) => {
303
416
  this.retireBrowserController(controller);
304
417
  });
305
418
  }
@@ -308,66 +421,86 @@ export class BrowserPool extends TypedEmitter {
308
421
  * @return {Promise<void>}
309
422
  */
310
423
  async closeAllBrowsers() {
311
- const controllers = this._getAllBrowserControllers();
424
+ const controllers = this.getAllBrowserControllers();
312
425
  const promises = [...controllers]
313
426
  .filter((controller) => controller.isActive)
314
427
  .map(async (controller) => controller.close());
315
428
  await Promise.all(promises);
316
429
  }
430
+ async [Symbol.asyncDispose]() {
431
+ await this.destroy();
432
+ }
317
433
  /**
318
- * 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.
319
436
  */
320
- async destroy() {
321
- clearInterval(this.browserKillerInterval);
322
- clearInterval(this.browserRetireInterval);
323
- this.browserKillerInterval = undefined;
324
- this.browserRetireInterval = undefined;
437
+ async releaseAllBrowsers() {
325
438
  await this.closeAllBrowsers();
326
- this._teardown();
327
- }
328
- _teardown() {
439
+ this.#startingBrowserControllers.clear();
329
440
  this.activeBrowserControllers.clear();
330
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();
331
453
  this.removeAllListeners();
332
454
  }
333
- _getAllBrowserControllers() {
334
- return new Set([...this.activeBrowserControllers, ...this.retiredBrowserControllers]);
455
+ getAllBrowserControllers() {
456
+ return new Set([
457
+ ...this.#startingBrowserControllers,
458
+ ...this.activeBrowserControllers,
459
+ ...this.retiredBrowserControllers,
460
+ ]);
335
461
  }
336
- async _launchBrowser(pageId, options) {
337
- const { browserPlugin, launchOptions, proxyTier, proxyUrl } = options;
462
+ async launchBrowser(pageId, options) {
463
+ const { browserPlugin, launchOptions, proxyUrl, ignoreTlsErrors } = options;
338
464
  const browserController = browserPlugin.createController();
339
- this.activeBrowserControllers.add(browserController);
465
+ this.#startingBrowserControllers.add(browserController);
340
466
  const launchContext = browserPlugin.createLaunchContext({
341
467
  id: pageId,
342
468
  launchOptions,
343
- proxyTier,
344
469
  proxyUrl,
345
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
+ }
346
480
  try {
347
481
  // If the hooks or the launch fails, we need to delete the controller,
348
482
  // because otherwise it would be stuck in limbo without a browser.
349
- await this._executeHooks(this.preLaunchHooks, pageId, launchContext);
483
+ await this.executeHooks(this.#preLaunchHooks, pageId, launchContext);
350
484
  tryCancel();
351
485
  const browser = await browserPlugin.launch(launchContext);
352
486
  tryCancel();
353
487
  browserController.assignBrowser(browser, launchContext);
354
488
  }
355
489
  catch (err) {
356
- this.activeBrowserControllers.delete(browserController);
490
+ this.#startingBrowserControllers.delete(browserController);
357
491
  throw err;
358
492
  }
359
- log.debug('Launched new browser.', { id: browserController.id });
360
- browserController.proxyTier = proxyTier;
493
+ this.#log.debug('Launched new browser.', { id: browserController.id });
361
494
  browserController.proxyUrl = proxyUrl;
362
495
  try {
363
496
  // If the launch fails on the post-launch hooks, we need to clean up
364
497
  // both the controller and the browser before throwing.
365
- await this._executeHooks(this.postLaunchHooks, pageId, browserController);
498
+ await this.executeHooks(this.#postLaunchHooks, pageId, browserController);
366
499
  }
367
500
  catch (err) {
368
- this.activeBrowserControllers.delete(browserController);
501
+ this.#startingBrowserControllers.delete(browserController);
369
502
  browserController.close().catch((closeErr) => {
370
- 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}`, {
371
504
  id: browserController.id,
372
505
  });
373
506
  });
@@ -375,103 +508,153 @@ export class BrowserPool extends TypedEmitter {
375
508
  }
376
509
  tryCancel();
377
510
  browserController.activate();
378
- this.emit("browserLaunched" /* BROWSER_POOL_EVENTS.BROWSER_LAUNCHED */, browserController);
511
+ this.#startingBrowserControllers.delete(browserController);
512
+ this.activeBrowserControllers.add(browserController);
513
+ this.emit(BROWSER_POOL_EVENTS.BROWSER_LAUNCHED, browserController);
379
514
  return browserController;
380
515
  }
381
516
  /**
382
517
  * Picks plugins round robin.
383
- * @private
384
518
  */
385
- _pickBrowserPlugin() {
386
- const pluginIndex = this.pageCounter % this.browserPlugins.length;
387
- this.pageCounter++;
519
+ pickBrowserPlugin() {
520
+ const pluginIndex = this.#pageCounter % this.browserPlugins.length;
521
+ this.#pageCounter++;
388
522
  return this.browserPlugins[pluginIndex];
389
523
  }
390
- _pickBrowserWithFreeCapacity(browserPlugin, options) {
524
+ pickBrowserWithFreeCapacity(browserPlugin, options) {
391
525
  return [...this.activeBrowserControllers].find((controller) => {
392
- const hasCapacity = controller.activePages < this.maxOpenPagesPerBrowser;
526
+ const hasCapacity = controller.activePages < this.#maxOpenPagesPerBrowser;
393
527
  const isCorrectPlugin = controller.browserPlugin === browserPlugin;
394
528
  const isSameProxyUrl = controller.proxyUrl === options?.proxyUrl;
395
- const isCorrectProxyTier = controller.proxyTier === options?.proxyTier;
396
529
  return (isCorrectPlugin &&
397
530
  hasCapacity &&
398
- ((!controller.launchContext.browserPerProxy && !options?.proxyTier) ||
399
- (options?.proxyTier && isCorrectProxyTier) ||
531
+ (!controller.launchContext.browserPerProxy ||
400
532
  (options?.proxyUrl && isSameProxyUrl) ||
401
- (!options?.proxyUrl && !options?.proxyTier && !controller.proxyUrl && !controller.proxyTier)));
533
+ (!options?.proxyUrl && !controller.proxyUrl)));
402
534
  });
403
535
  }
404
- async _closeInactiveRetiredBrowsers() {
536
+ async closeInactiveRetiredBrowsers() {
405
537
  const closedBrowserIds = [];
406
538
  for (const controller of this.retiredBrowserControllers) {
407
539
  const millisSinceLastPageOpened = Date.now() - controller.lastPageOpenedAt;
408
- const isBrowserIdle = millisSinceLastPageOpened >= this.closeInactiveBrowserAfterMillis;
540
+ const isBrowserIdle = millisSinceLastPageOpened >= this.#closeInactiveBrowserAfterMillis;
409
541
  const isBrowserEmpty = controller.activePages === 0;
410
542
  if (isBrowserIdle || isBrowserEmpty) {
411
543
  const { id } = controller;
412
- log.debug('Closing retired browser.', { id });
544
+ this.#log.debug('Closing retired browser.', { id });
413
545
  await controller.close();
414
546
  this.retiredBrowserControllers.delete(controller);
415
547
  closedBrowserIds.push(id);
416
548
  }
417
549
  }
418
550
  if (closedBrowserIds.length) {
419
- log.debug('Closed retired browsers.', {
551
+ this.#log.debug('Closed retired browsers.', {
420
552
  count: closedBrowserIds.length,
421
553
  closedBrowserIds,
422
554
  });
423
555
  }
424
556
  }
425
- _overridePageClose(page) {
557
+ overridePageClose(page) {
426
558
  const originalPageClose = page.close;
427
- const browserController = this.pageToBrowserController.get(page);
559
+ const browserController = this.#pageToBrowserController.get(page);
428
560
  const pageId = this.getPageId(page);
429
561
  page.close = async (...args) => {
430
- await this._executeHooks(this.prePageCloseHooks, page, browserController);
431
- await originalPageClose.apply(page, args).catch((err) => {
432
- log.debug(`Could not close page.\nCause:${err.message}`, { id: browserController.id });
433
- });
434
- 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);
435
601
  this.pages.delete(pageId);
436
- this._closeRetiredBrowserWithNoPages(browserController);
437
- this.emit("pageClosed" /* BROWSER_POOL_EVENTS.PAGE_CLOSED */, page);
602
+ this.closeRetiredBrowserWithNoPages(browserController);
603
+ this.emit(BROWSER_POOL_EVENTS.PAGE_CLOSED, page);
438
604
  };
439
605
  }
440
- async _executeHooks(hooks, ...args) {
606
+ async executeHooks(hooks, ...args) {
441
607
  for (const hook of hooks) {
442
608
  await hook(...args);
443
609
  }
444
610
  }
445
- _closeRetiredBrowserWithNoPages(browserController) {
611
+ closeRetiredBrowserWithNoPages(browserController) {
446
612
  if (browserController.activePages === 0 && this.retiredBrowserControllers.has(browserController)) {
447
613
  // Run this with a delay, otherwise page.close()
448
614
  // might fail with "Protocol error (Target.closeTarget): Target closed."
449
615
  setTimeout(() => {
450
- 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 });
451
617
  void browserController.close().finally(() => {
452
618
  this.retiredBrowserControllers.delete(browserController);
453
619
  });
454
620
  }, PAGE_CLOSE_KILL_TIMEOUT_MILLIS);
455
621
  }
456
622
  }
457
- _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() {
458
653
  const { useFingerprintCache = true, fingerprintCacheSize = 10_000 } = this.fingerprintOptions;
459
654
  this.fingerprintGenerator = new FingerprintGenerator(this.fingerprintOptions.fingerprintGeneratorOptions);
460
655
  this.fingerprintInjector = new FingerprintInjector();
461
656
  if (useFingerprintCache) {
462
657
  this.fingerprintCache = new QuickLRU({ maxSize: fingerprintCacheSize });
463
658
  }
464
- this._addFingerprintHooks();
465
- }
466
- _addFingerprintHooks() {
467
- this.preLaunchHooks = [
468
- ...this.preLaunchHooks,
469
- // This is flipped because of the fingerprint cache.
470
- // It is usual to generate proxy per browser and we want to know the proxyUrl for the caching.
471
- createFingerprintPreLaunchHook(this),
472
- ];
473
- this.prePageCreateHooks = [createPrePageCreateHook(), ...this.prePageCreateHooks];
474
- this.postPageCreateHooks = [createPostPageCreateHook(this.fingerprintInjector), ...this.postPageCreateHooks];
475
659
  }
476
660
  }
477
- //# sourceMappingURL=browser-pool.js.map