@crawlee/browser-pool 4.0.0-beta.22 → 4.0.0-beta.221

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 +15 -15
  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 +113 -51
  9. package/browser-pool.js +323 -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 +10 -10
  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 +11 -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 +170 -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,44 @@ 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
+ // 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;
87
116
  constructor(options) {
88
117
  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;
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);
107
120
  const firstPluginConstructor = browserPlugins[0].constructor;
108
121
  for (let i = 1; i < browserPlugins.length; i++) {
109
122
  const providedPlugin = browserPlugins[i];
@@ -114,52 +127,90 @@ export class BrowserPool extends TypedEmitter {
114
127
  }
115
128
  }
116
129
  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;
130
+ this.maxOpenBrowsers = Infinity;
122
131
  this.fingerprintOptions = fingerprintOptions;
123
- 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) => {
124
141
  if (controller.activePages === 0 &&
125
142
  controller.lastPageOpenedAt < Date.now() - retireInactiveBrowserAfterSecs * 1000) {
126
143
  this.retireBrowserController(controller);
127
144
  }
128
145
  }), retireInactiveBrowserAfterSecs * 1000);
129
- this.browserRetireInterval.unref();
146
+ this.#browserRetireInterval.unref();
130
147
  // 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;
148
+ this.#preLaunchHooks = preLaunchHooks;
149
+ this.#postLaunchHooks = postLaunchHooks;
150
+ this.#prePageCreateHooks = prePageCreateHooks;
151
+ this.#postPageCreateHooks = postPageCreateHooks;
152
+ this.#prePageCloseHooks = prePageCloseHooks;
153
+ this.#postPageCloseHooks = postPageCloseHooks;
137
154
  // fingerprinting
138
- if (this.useFingerprints) {
139
- 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
+ ];
140
165
  }
141
166
  }
142
167
  /**
143
168
  * Opens a new page in one of the running browsers or launches
144
169
  * a new browser and opens a page there, if no browsers are active,
145
170
  * or their page limits have been exceeded.
171
+ *
172
+ * **Session injection (best-effort):** When a {@link NewPageOptions.session|session} is
173
+ * provided, this implementation uses it as a cache key for browser fingerprints (when
174
+ * fingerprinting is enabled) and reads
175
+ * {@link ProxyInfo.url|session.proxyInfo.url} /
176
+ * {@link ProxyInfo.ignoreTlsErrors|session.proxyInfo.ignoreTlsErrors} as defaults
177
+ * for `proxyUrl` and `ignoreTlsErrors` respectively. Explicit `proxyUrl` /
178
+ * `ignoreTlsErrors` values in the options take precedence.
179
+ *
180
+ * Beyond fingerprint caching and proxy configuration, no other session
181
+ * properties are consumed — cookie and header injection remain the
182
+ * crawler's responsibility.
146
183
  */
147
184
  async newPage(options = {}) {
148
- const { id = nanoid(), pageOptions, browserPlugin = this._pickBrowserPlugin(), proxyUrl, proxyTier } = options;
185
+ const { id = nanoid(), pageOptions, browserPlugin = this.#pickBrowserPlugin(), session, proxyUrl = session?.proxyInfo?.url, ignoreTlsErrors = session?.proxyInfo?.ignoreTlsErrors, } = options;
149
186
  if (this.pages.has(id)) {
150
187
  throw new Error(`Page with ID: ${id} already exists.`);
151
188
  }
152
189
  if (browserPlugin && !this.browserPlugins.includes(browserPlugin)) {
153
190
  throw new Error('Provided browserPlugin is not one of the plugins used by BrowserPool.');
154
191
  }
192
+ // Bind the limiter callback to the current async-hooks context. p-limit
193
+ // otherwise resumes queued callbacks in the previous task's
194
+ // AsyncLocalStorage context, leaking aborted cancelTasks across unrelated
195
+ // requests (https://github.com/apify/crawlee/issues/3670). Mirrors the
196
+ // fix p-limit landed upstream in v5 (sindresorhus/p-limit#71); v5 is an
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.
201
+ // TODO(crawlee@v4): bump p-limit to v5 and drop this AsyncResource.bind wrapper.
155
202
  // Limiter is necessary - https://github.com/apify/crawlee/issues/1126
156
- return this.limiter(async () => {
157
- let browserController = this._pickBrowserWithFreeCapacity(browserPlugin, { proxyTier, proxyUrl });
203
+ return this.#limiter(AsyncResource.bind(async () => {
204
+ let browserController = this.#pickBrowserWithFreeCapacity(browserPlugin, { proxyUrl });
158
205
  if (!browserController)
159
- browserController = await this._launchBrowser(id, { browserPlugin, proxyTier, proxyUrl });
206
+ browserController = await this.#launchBrowser(id, {
207
+ browserPlugin,
208
+ proxyUrl,
209
+ ignoreTlsErrors,
210
+ });
160
211
  tryCancel();
161
- return await this._createPageForBrowser(id, browserController, pageOptions, proxyUrl);
162
- });
212
+ return await this.#createPageForBrowser(id, browserController, pageOptions, proxyUrl, ignoreTlsErrors);
213
+ }));
163
214
  }
164
215
  /**
165
216
  * Unlike {@link newPage}, `newPageInNewBrowser` always launches a new
@@ -167,13 +218,13 @@ export class BrowserPool extends TypedEmitter {
167
218
  * configure the new browser.
168
219
  */
169
220
  async newPageInNewBrowser(options = {}) {
170
- const { id = nanoid(), pageOptions, launchOptions, browserPlugin = this._pickBrowserPlugin() } = options;
221
+ const { id = nanoid(), pageOptions, launchOptions, browserPlugin = this.#pickBrowserPlugin() } = options;
171
222
  if (this.pages.has(id)) {
172
223
  throw new Error(`Page with ID: ${id} already exists.`);
173
224
  }
174
- const browserController = await this._launchBrowser(id, { launchOptions, browserPlugin });
225
+ const browserController = await this.#launchBrowser(id, { launchOptions, browserPlugin });
175
226
  tryCancel();
176
- return await this._createPageForBrowser(id, browserController, pageOptions);
227
+ return await this.#createPageForBrowser(id, browserController, pageOptions);
177
228
  }
178
229
  /**
179
230
  * Opens new pages with all available plugins and returns an array
@@ -219,7 +270,7 @@ export class BrowserPool extends TypedEmitter {
219
270
  * @param page - Browser plugin page
220
271
  */
221
272
  getBrowserControllerByPage(page) {
222
- return this.pageToBrowserController.get(page);
273
+ return this.#pageToBrowserController.get(page);
223
274
  }
224
275
  /**
225
276
  * If you provided a custom ID to one of your pages or saved the
@@ -237,40 +288,45 @@ export class BrowserPool extends TypedEmitter {
237
288
  * until it's closed.
238
289
  */
239
290
  getPageId(page) {
240
- return this.pageIds.get(page);
291
+ return this.#pageIds.get(page);
241
292
  }
242
- async _createPageForBrowser(pageId, browserController, pageOptions = {}, proxyUrl) {
293
+ async #createPageForBrowser(pageId, browserController, pageOptions = {}, proxyUrl, ignoreTlsErrors) {
243
294
  // This is needed for concurrent newPage calls to wait for the browser launch.
244
295
  // 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'];
296
+ await browserController.waitForActive();
247
297
  tryCancel();
248
298
  const finalPageOptions = browserController.launchContext.useIncognitoPages ? pageOptions : undefined;
249
299
  if (finalPageOptions) {
250
300
  Object.assign(finalPageOptions, browserController.normalizeProxyOptions(proxyUrl, pageOptions));
301
+ if (ignoreTlsErrors) {
302
+ Object.assign(finalPageOptions, {
303
+ ignoreHTTPSErrors: true,
304
+ acceptInsecureCerts: true,
305
+ });
306
+ }
251
307
  }
252
- await this._executeHooks(this.prePageCreateHooks, pageId, browserController, finalPageOptions);
308
+ await this.#executeHooks(this.#prePageCreateHooks, pageId, browserController, finalPageOptions);
253
309
  tryCancel();
254
310
  let page;
255
311
  try {
256
- 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.'));
257
313
  tryCancel();
258
314
  this.pages.set(pageId, page);
259
- this.pageIds.set(page, pageId);
260
- this.pageToBrowserController.set(page, browserController);
315
+ this.#pageIds.set(page, pageId);
316
+ this.#pageToBrowserController.set(page, browserController);
261
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.
262
- if (browserController.totalPages >= this.retireBrowserAfterPageCount) {
318
+ if (browserController.totalPages >= this.#retireBrowserAfterPageCount) {
263
319
  this.retireBrowserController(browserController);
264
320
  }
265
- this._overridePageClose(page);
321
+ this.#overridePageClose(page);
266
322
  }
267
323
  catch (err) {
268
324
  this.retireBrowserController(browserController);
269
325
  throw new Error(`browserController.newPage() failed: ${browserController.id}\nCause:${err.message}.`);
270
326
  }
271
- await this._executeHooks(this.postPageCreateHooks, page, browserController);
327
+ await this.#executeHooks(this.#postPageCreateHooks, page, browserController);
272
328
  tryCancel();
273
- this.emit("pageCreated" /* BROWSER_POOL_EVENTS.PAGE_CREATED */, page);
329
+ this.emit(BROWSER_POOL_EVENTS.PAGE_CREATED, page);
274
330
  return page;
275
331
  }
276
332
  /**
@@ -279,14 +335,14 @@ export class BrowserPool extends TypedEmitter {
279
335
  *
280
336
  */
281
337
  retireBrowserController(browserController) {
282
- const isStarting = this.startingBrowserControllers.has(browserController);
338
+ const isStarting = this.#startingBrowserControllers.has(browserController);
283
339
  const isActive = this.activeBrowserControllers.has(browserController);
284
340
  const hasBeenRetiredOrKilled = !isStarting && !isActive;
285
341
  if (hasBeenRetiredOrKilled)
286
342
  return;
287
343
  this.retiredBrowserControllers.add(browserController);
288
- this.emit("browserRetired" /* BROWSER_POOL_EVENTS.BROWSER_RETIRED */, browserController);
289
- this.startingBrowserControllers.delete(browserController);
344
+ this.emit(BROWSER_POOL_EVENTS.BROWSER_RETIRED, browserController);
345
+ this.#startingBrowserControllers.delete(browserController);
290
346
  this.activeBrowserControllers.delete(browserController);
291
347
  }
292
348
  /**
@@ -298,12 +354,68 @@ export class BrowserPool extends TypedEmitter {
298
354
  if (browserController)
299
355
  this.retireBrowserController(browserController);
300
356
  }
357
+ /**
358
+ * Releases a page back to the pool. The page is closed and, if the
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,
362
+ * storage, etc.) cannot leak into future sessions.
363
+ *
364
+ * This is the primary way the crawler should return pages to the pool.
365
+ *
366
+ * @param page The page to release.
367
+ * @param options.error The error that caused the page to be released, if any.
368
+ */
369
+ async closePage(page, options) {
370
+ if (options?.error instanceof SessionError) {
371
+ this.retireBrowserByPage(page);
372
+ }
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();
377
+ }
378
+ /**
379
+ * Extracts the relevant state (currently just cookies) from a page via its
380
+ * owning {@link BrowserController}. Returns empty state when the page is
381
+ * no longer associated with a controller.
382
+ *
383
+ * As with {@link BrowserPool.injectPageState}, cookies are isolated per
384
+ * page only when the pool is configured with `useIncognitoPages: true`.
385
+ * With the default `useIncognitoPages: false`, the extracted cookies
386
+ * include those set by any sibling page sharing the same browser.
387
+ */
388
+ async extractPageState(page) {
389
+ const controller = this.getBrowserControllerByPage(page);
390
+ if (!controller) {
391
+ return { cookies: [] };
392
+ }
393
+ return { cookies: await controller.getCookies(page) };
394
+ }
395
+ /**
396
+ * Injects state into a page via its owning {@link BrowserController}.
397
+ *
398
+ * No-op when the page is no longer associated with a controller.
399
+ *
400
+ * Note that cookies are isolated per page only when the pool is configured
401
+ * with `useIncognitoPages: true` — each page then gets its own browser
402
+ * context. With the default `useIncognitoPages: false`, all pages in a
403
+ * browser share a single context, so injected cookies are visible to every
404
+ * page served by that browser.
405
+ */
406
+ async injectPageState(page, state) {
407
+ const controller = this.getBrowserControllerByPage(page);
408
+ if (!controller) {
409
+ return;
410
+ }
411
+ await controller.setCookies(page, state.cookies);
412
+ }
301
413
  /**
302
414
  * Removes all active browsers from the pool. The browsers will be
303
415
  * closed after all their pages are closed.
304
416
  */
305
417
  retireAllBrowsers() {
306
- [...this.startingBrowserControllers, ...this.activeBrowserControllers].forEach((controller) => {
418
+ [...this.#startingBrowserControllers, ...this.activeBrowserControllers].forEach((controller) => {
307
419
  this.retireBrowserController(controller);
308
420
  });
309
421
  }
@@ -312,71 +424,86 @@ export class BrowserPool extends TypedEmitter {
312
424
  * @return {Promise<void>}
313
425
  */
314
426
  async closeAllBrowsers() {
315
- const controllers = this._getAllBrowserControllers();
427
+ const controllers = this.#getAllBrowserControllers();
316
428
  const promises = [...controllers]
317
429
  .filter((controller) => controller.isActive)
318
430
  .map(async (controller) => controller.close());
319
431
  await Promise.all(promises);
320
432
  }
433
+ async [Symbol.asyncDispose]() {
434
+ await this.destroy();
435
+ }
321
436
  /**
322
- * 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.
323
439
  */
324
- async destroy() {
325
- clearInterval(this.browserKillerInterval);
326
- clearInterval(this.browserRetireInterval);
327
- this.browserKillerInterval = undefined;
328
- this.browserRetireInterval = undefined;
440
+ async releaseAllBrowsers() {
329
441
  await this.closeAllBrowsers();
330
- this._teardown();
331
- }
332
- _teardown() {
333
- this.startingBrowserControllers.clear();
442
+ this.#startingBrowserControllers.clear();
334
443
  this.activeBrowserControllers.clear();
335
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();
336
456
  this.removeAllListeners();
337
457
  }
338
- _getAllBrowserControllers() {
458
+ #getAllBrowserControllers() {
339
459
  return new Set([
340
- ...this.startingBrowserControllers,
460
+ ...this.#startingBrowserControllers,
341
461
  ...this.activeBrowserControllers,
342
462
  ...this.retiredBrowserControllers,
343
463
  ]);
344
464
  }
345
- async _launchBrowser(pageId, options) {
346
- const { browserPlugin, launchOptions, proxyTier, proxyUrl } = options;
465
+ async #launchBrowser(pageId, options) {
466
+ const { browserPlugin, launchOptions, proxyUrl, ignoreTlsErrors } = options;
347
467
  const browserController = browserPlugin.createController();
348
- this.startingBrowserControllers.add(browserController);
468
+ this.#startingBrowserControllers.add(browserController);
349
469
  const launchContext = browserPlugin.createLaunchContext({
350
470
  id: pageId,
351
471
  launchOptions,
352
- proxyTier,
353
472
  proxyUrl,
354
473
  });
474
+ // Disable SSL verification for MITM proxies
475
+ if (ignoreTlsErrors) {
476
+ /**
477
+ * @see https://playwright.dev/docs/api/class-browser/#browser-new-context
478
+ * @see https://github.com/puppeteer/puppeteer/blob/main/docs/api.md
479
+ */
480
+ launchContext.launchOptions.ignoreHTTPSErrors = true;
481
+ launchContext.launchOptions.acceptInsecureCerts = true;
482
+ }
355
483
  try {
356
484
  // If the hooks or the launch fails, we need to delete the controller,
357
485
  // because otherwise it would be stuck in limbo without a browser.
358
- await this._executeHooks(this.preLaunchHooks, pageId, launchContext);
486
+ await this.#executeHooks(this.#preLaunchHooks, pageId, launchContext);
359
487
  tryCancel();
360
488
  const browser = await browserPlugin.launch(launchContext);
361
489
  tryCancel();
362
490
  browserController.assignBrowser(browser, launchContext);
363
491
  }
364
492
  catch (err) {
365
- this.startingBrowserControllers.delete(browserController);
493
+ this.#startingBrowserControllers.delete(browserController);
366
494
  throw err;
367
495
  }
368
- log.debug('Launched new browser.', { id: browserController.id });
369
- browserController.proxyTier = proxyTier;
496
+ this.#log.debug('Launched new browser.', { id: browserController.id });
370
497
  browserController.proxyUrl = proxyUrl;
371
498
  try {
372
499
  // If the launch fails on the post-launch hooks, we need to clean up
373
500
  // both the controller and the browser before throwing.
374
- await this._executeHooks(this.postLaunchHooks, pageId, browserController);
501
+ await this.#executeHooks(this.#postLaunchHooks, pageId, browserController);
375
502
  }
376
503
  catch (err) {
377
- this.startingBrowserControllers.delete(browserController);
504
+ this.#startingBrowserControllers.delete(browserController);
378
505
  browserController.close().catch((closeErr) => {
379
- 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}`, {
380
507
  id: browserController.id,
381
508
  });
382
509
  });
@@ -384,105 +511,154 @@ export class BrowserPool extends TypedEmitter {
384
511
  }
385
512
  tryCancel();
386
513
  browserController.activate();
387
- this.startingBrowserControllers.delete(browserController);
514
+ this.#startingBrowserControllers.delete(browserController);
388
515
  this.activeBrowserControllers.add(browserController);
389
- this.emit("browserLaunched" /* BROWSER_POOL_EVENTS.BROWSER_LAUNCHED */, browserController);
516
+ this.emit(BROWSER_POOL_EVENTS.BROWSER_LAUNCHED, browserController);
390
517
  return browserController;
391
518
  }
392
519
  /**
393
520
  * Picks plugins round robin.
394
- * @private
395
521
  */
396
- _pickBrowserPlugin() {
397
- const pluginIndex = this.pageCounter % this.browserPlugins.length;
398
- this.pageCounter++;
522
+ #pickBrowserPlugin() {
523
+ const pluginIndex = this.#pageCounter % this.browserPlugins.length;
524
+ this.#pageCounter++;
399
525
  return this.browserPlugins[pluginIndex];
400
526
  }
401
- _pickBrowserWithFreeCapacity(browserPlugin, options) {
527
+ #pickBrowserWithFreeCapacity(browserPlugin, options) {
402
528
  return [...this.activeBrowserControllers].find((controller) => {
403
- const hasCapacity = controller.activePages < this.maxOpenPagesPerBrowser;
529
+ const hasCapacity = controller.activePages < this.#maxOpenPagesPerBrowser;
404
530
  const isCorrectPlugin = controller.browserPlugin === browserPlugin;
405
531
  const isSameProxyUrl = controller.proxyUrl === options?.proxyUrl;
406
- const isCorrectProxyTier = controller.proxyTier === options?.proxyTier;
407
532
  return (isCorrectPlugin &&
408
533
  hasCapacity &&
409
- ((!controller.launchContext.browserPerProxy && !options?.proxyTier) ||
410
- (options?.proxyTier && isCorrectProxyTier) ||
534
+ (!controller.launchContext.browserPerProxy ||
411
535
  (options?.proxyUrl && isSameProxyUrl) ||
412
- (!options?.proxyUrl && !options?.proxyTier && !controller.proxyUrl && !controller.proxyTier)));
536
+ (!options?.proxyUrl && !controller.proxyUrl)));
413
537
  });
414
538
  }
415
- async _closeInactiveRetiredBrowsers() {
539
+ // oxlint-disable-next-line crawlee/prefer-private-fields -- accessed by tests
540
+ async closeInactiveRetiredBrowsers() {
416
541
  const closedBrowserIds = [];
417
542
  for (const controller of this.retiredBrowserControllers) {
418
543
  const millisSinceLastPageOpened = Date.now() - controller.lastPageOpenedAt;
419
- const isBrowserIdle = millisSinceLastPageOpened >= this.closeInactiveBrowserAfterMillis;
544
+ const isBrowserIdle = millisSinceLastPageOpened >= this.#closeInactiveBrowserAfterMillis;
420
545
  const isBrowserEmpty = controller.activePages === 0;
421
546
  if (isBrowserIdle || isBrowserEmpty) {
422
547
  const { id } = controller;
423
- log.debug('Closing retired browser.', { id });
548
+ this.#log.debug('Closing retired browser.', { id });
424
549
  await controller.close();
425
550
  this.retiredBrowserControllers.delete(controller);
426
551
  closedBrowserIds.push(id);
427
552
  }
428
553
  }
429
554
  if (closedBrowserIds.length) {
430
- log.debug('Closed retired browsers.', {
555
+ this.#log.debug('Closed retired browsers.', {
431
556
  count: closedBrowserIds.length,
432
557
  closedBrowserIds,
433
558
  });
434
559
  }
435
560
  }
436
- _overridePageClose(page) {
561
+ #overridePageClose(page) {
437
562
  const originalPageClose = page.close;
438
- const browserController = this.pageToBrowserController.get(page);
563
+ const browserController = this.#pageToBrowserController.get(page);
439
564
  const pageId = this.getPageId(page);
440
565
  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);
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);
446
605
  this.pages.delete(pageId);
447
- this._closeRetiredBrowserWithNoPages(browserController);
448
- this.emit("pageClosed" /* BROWSER_POOL_EVENTS.PAGE_CLOSED */, page);
606
+ this.#closeRetiredBrowserWithNoPages(browserController);
607
+ this.emit(BROWSER_POOL_EVENTS.PAGE_CLOSED, page);
449
608
  };
450
609
  }
451
- async _executeHooks(hooks, ...args) {
610
+ async #executeHooks(hooks, ...args) {
452
611
  for (const hook of hooks) {
453
612
  await hook(...args);
454
613
  }
455
614
  }
456
- _closeRetiredBrowserWithNoPages(browserController) {
615
+ #closeRetiredBrowserWithNoPages(browserController) {
457
616
  if (browserController.activePages === 0 && this.retiredBrowserControllers.has(browserController)) {
458
617
  // Run this with a delay, otherwise page.close()
459
618
  // might fail with "Protocol error (Target.closeTarget): Target closed."
460
619
  setTimeout(() => {
461
- 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 });
462
621
  void browserController.close().finally(() => {
463
622
  this.retiredBrowserControllers.delete(browserController);
464
623
  });
465
624
  }, PAGE_CLOSE_KILL_TIMEOUT_MILLIS);
466
625
  }
467
626
  }
468
- _initializeFingerprinting() {
627
+ /**
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
637
+ */
638
+ hasFreeBrowserSlot() {
639
+ const total = this.#startingBrowserControllers.size +
640
+ this.activeBrowserControllers.size +
641
+ this.retiredBrowserControllers.size;
642
+ return total < this.maxOpenBrowsers;
643
+ }
644
+ /**
645
+ * Returns `true` if any active browser has room for another page.
646
+ *
647
+ * @internal
648
+ */
649
+ hasActiveBrowserWithFreeCapacity() {
650
+ for (const controller of this.activeBrowserControllers) {
651
+ if (controller.activePages < this.#maxOpenPagesPerBrowser)
652
+ return true;
653
+ }
654
+ return false;
655
+ }
656
+ #initializeFingerprinting() {
469
657
  const { useFingerprintCache = true, fingerprintCacheSize = 10_000 } = this.fingerprintOptions;
470
658
  this.fingerprintGenerator = new FingerprintGenerator(this.fingerprintOptions.fingerprintGeneratorOptions);
471
659
  this.fingerprintInjector = new FingerprintInjector();
472
660
  if (useFingerprintCache) {
473
661
  this.fingerprintCache = new QuickLRU({ maxSize: fingerprintCacheSize });
474
662
  }
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
663
  }
487
664
  }
488
- //# sourceMappingURL=browser-pool.js.map