@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.
- package/README.md +14 -14
- package/abstract-classes/browser-controller.d.ts +100 -29
- package/abstract-classes/browser-controller.js +70 -24
- package/abstract-classes/browser-plugin.d.ts +54 -23
- package/abstract-classes/browser-plugin.js +75 -13
- package/anonymize-proxy.d.ts +6 -2
- package/anonymize-proxy.js +9 -5
- package/browser-pool.d.ts +123 -49
- package/browser-pool.js +320 -147
- package/events.d.ts +2 -4
- package/events.js +0 -2
- package/fingerprinting/hooks.d.ts +0 -1
- package/fingerprinting/hooks.js +26 -10
- package/fingerprinting/types.d.ts +3 -34
- package/fingerprinting/types.js +0 -2
- package/fingerprinting/utils.d.ts +0 -1
- package/fingerprinting/utils.js +5 -6
- package/index.d.ts +14 -7
- package/index.js +5 -3
- package/launch-context.d.ts +21 -5
- package/launch-context.js +21 -10
- package/package.json +9 -9
- package/playwright/playwright-browser.d.ts +3 -6
- package/playwright/playwright-browser.js +17 -17
- package/playwright/playwright-controller.d.ts +1 -1
- package/playwright/playwright-controller.js +10 -6
- package/playwright/playwright-plugin.d.ts +12 -7
- package/playwright/playwright-plugin.js +42 -16
- package/proxy-server.d.ts +0 -1
- package/proxy-server.js +0 -1
- package/puppeteer/puppeteer-controller.d.ts +1 -1
- package/puppeteer/puppeteer-controller.js +29 -20
- package/puppeteer/puppeteer-plugin.d.ts +9 -3
- package/puppeteer/puppeteer-plugin.js +87 -46
- package/remote-browser-pool.d.ts +178 -0
- package/remote-browser-pool.js +206 -0
- package/remote-browser-provider.d.ts +83 -0
- package/remote-browser-provider.js +67 -0
- package/utils.d.ts +7 -1
- package/utils.js +19 -1
- package/abstract-classes/browser-controller.d.ts.map +0 -1
- package/abstract-classes/browser-controller.js.map +0 -1
- package/abstract-classes/browser-plugin.d.ts.map +0 -1
- package/abstract-classes/browser-plugin.js.map +0 -1
- package/anonymize-proxy.d.ts.map +0 -1
- package/anonymize-proxy.js.map +0 -1
- package/browser-pool.d.ts.map +0 -1
- package/browser-pool.js.map +0 -1
- package/container-proxy-server.d.ts +0 -12
- package/container-proxy-server.d.ts.map +0 -1
- package/container-proxy-server.js +0 -43
- package/container-proxy-server.js.map +0 -1
- package/events.d.ts.map +0 -1
- package/events.js.map +0 -1
- package/fingerprinting/hooks.d.ts.map +0 -1
- package/fingerprinting/hooks.js.map +0 -1
- package/fingerprinting/types.d.ts.map +0 -1
- package/fingerprinting/types.js.map +0 -1
- package/fingerprinting/utils.d.ts.map +0 -1
- package/fingerprinting/utils.js.map +0 -1
- package/index.d.ts.map +0 -1
- package/index.js.map +0 -1
- package/launch-context.d.ts.map +0 -1
- package/launch-context.js.map +0 -1
- package/logger.d.ts +0 -3
- package/logger.d.ts.map +0 -1
- package/logger.js +0 -5
- package/logger.js.map +0 -1
- package/playwright/playwright-browser.d.ts.map +0 -1
- package/playwright/playwright-browser.js.map +0 -1
- package/playwright/playwright-controller.d.ts.map +0 -1
- package/playwright/playwright-controller.js.map +0 -1
- package/playwright/playwright-plugin.d.ts.map +0 -1
- package/playwright/playwright-plugin.js.map +0 -1
- package/proxy-server.d.ts.map +0 -1
- package/proxy-server.js.map +0 -1
- package/puppeteer/puppeteer-controller.d.ts.map +0 -1
- package/puppeteer/puppeteer-controller.js.map +0 -1
- package/puppeteer/puppeteer-plugin.d.ts.map +0 -1
- package/puppeteer/puppeteer-plugin.js.map +0 -1
- package/utils.d.ts.map +0 -1
- 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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
closeInactiveBrowserAfterMillis;
|
|
66
|
-
useFingerprints;
|
|
82
|
+
/** @internal */
|
|
83
|
+
maxOpenBrowsers;
|
|
84
|
+
/** @internal */
|
|
67
85
|
fingerprintOptions;
|
|
68
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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.
|
|
90
|
-
|
|
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.
|
|
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
|
|
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
|
|
144
|
+
this.#browserRetireInterval.unref();
|
|
130
145
|
// hooks
|
|
131
|
-
this
|
|
132
|
-
this
|
|
133
|
-
this
|
|
134
|
-
this
|
|
135
|
-
this
|
|
136
|
-
this
|
|
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 (
|
|
139
|
-
this.
|
|
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.
|
|
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
|
|
157
|
-
let browserController = this.
|
|
201
|
+
return this.#limiter(AsyncResource.bind(async () => {
|
|
202
|
+
let browserController = this.pickBrowserWithFreeCapacity(browserPlugin, { proxyUrl });
|
|
158
203
|
if (!browserController)
|
|
159
|
-
browserController = await this.
|
|
204
|
+
browserController = await this.launchBrowser(id, {
|
|
205
|
+
browserPlugin,
|
|
206
|
+
proxyUrl,
|
|
207
|
+
ignoreTlsErrors,
|
|
208
|
+
});
|
|
160
209
|
tryCancel();
|
|
161
|
-
return await this.
|
|
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.
|
|
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.
|
|
223
|
+
const browserController = await this.launchBrowser(id, { launchOptions, browserPlugin });
|
|
175
224
|
tryCancel();
|
|
176
|
-
return await this.
|
|
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
|
|
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
|
|
289
|
+
return this.#pageIds.get(page);
|
|
241
290
|
}
|
|
242
|
-
async
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
260
|
-
this
|
|
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
|
|
316
|
+
if (browserController.totalPages >= this.#retireBrowserAfterPageCount) {
|
|
263
317
|
this.retireBrowserController(browserController);
|
|
264
318
|
}
|
|
265
|
-
this.
|
|
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.
|
|
325
|
+
await this.executeHooks(this.#postPageCreateHooks, page, browserController);
|
|
272
326
|
tryCancel();
|
|
273
|
-
this.emit(
|
|
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
|
|
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(
|
|
289
|
-
this
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
456
|
+
getAllBrowserControllers() {
|
|
339
457
|
return new Set([
|
|
340
|
-
...this
|
|
458
|
+
...this.#startingBrowserControllers,
|
|
341
459
|
...this.activeBrowserControllers,
|
|
342
460
|
...this.retiredBrowserControllers,
|
|
343
461
|
]);
|
|
344
462
|
}
|
|
345
|
-
async
|
|
346
|
-
const { browserPlugin, launchOptions,
|
|
463
|
+
async launchBrowser(pageId, options) {
|
|
464
|
+
const { browserPlugin, launchOptions, proxyUrl, ignoreTlsErrors } = options;
|
|
347
465
|
const browserController = browserPlugin.createController();
|
|
348
|
-
this
|
|
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.
|
|
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
|
|
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.
|
|
499
|
+
await this.executeHooks(this.#postLaunchHooks, pageId, browserController);
|
|
375
500
|
}
|
|
376
501
|
catch (err) {
|
|
377
|
-
this
|
|
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
|
|
512
|
+
this.#startingBrowserControllers.delete(browserController);
|
|
388
513
|
this.activeBrowserControllers.add(browserController);
|
|
389
|
-
this.emit(
|
|
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
|
-
|
|
397
|
-
const pluginIndex = this
|
|
398
|
-
this
|
|
520
|
+
pickBrowserPlugin() {
|
|
521
|
+
const pluginIndex = this.#pageCounter % this.browserPlugins.length;
|
|
522
|
+
this.#pageCounter++;
|
|
399
523
|
return this.browserPlugins[pluginIndex];
|
|
400
524
|
}
|
|
401
|
-
|
|
525
|
+
pickBrowserWithFreeCapacity(browserPlugin, options) {
|
|
402
526
|
return [...this.activeBrowserControllers].find((controller) => {
|
|
403
|
-
const hasCapacity = controller.activePages < this
|
|
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
|
-
(
|
|
410
|
-
(options?.proxyTier && isCorrectProxyTier) ||
|
|
532
|
+
(!controller.launchContext.browserPerProxy ||
|
|
411
533
|
(options?.proxyUrl && isSameProxyUrl) ||
|
|
412
|
-
(!options?.proxyUrl && !
|
|
534
|
+
(!options?.proxyUrl && !controller.proxyUrl)));
|
|
413
535
|
});
|
|
414
536
|
}
|
|
415
|
-
async
|
|
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
|
|
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
|
-
|
|
558
|
+
overridePageClose(page) {
|
|
437
559
|
const originalPageClose = page.close;
|
|
438
|
-
const browserController = this
|
|
560
|
+
const browserController = this.#pageToBrowserController.get(page);
|
|
439
561
|
const pageId = this.getPageId(page);
|
|
440
562
|
page.close = async (...args) => {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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.
|
|
448
|
-
this.emit(
|
|
603
|
+
this.closeRetiredBrowserWithNoPages(browserController);
|
|
604
|
+
this.emit(BROWSER_POOL_EVENTS.PAGE_CLOSED, page);
|
|
449
605
|
};
|
|
450
606
|
}
|
|
451
|
-
async
|
|
607
|
+
async executeHooks(hooks, ...args) {
|
|
452
608
|
for (const hook of hooks) {
|
|
453
609
|
await hook(...args);
|
|
454
610
|
}
|
|
455
611
|
}
|
|
456
|
-
|
|
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
|
-
|
|
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
|