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