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