@crawlee/browser-pool 4.0.0-beta.21 → 4.0.0-beta.210

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