@crawlee/browser-pool 4.0.0-beta.20 → 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 +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 +122 -49
- package/browser-pool.js +319 -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,67 @@ 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}, 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
|
+
}
|
|
301
410
|
/**
|
|
302
411
|
* Removes all active browsers from the pool. The browsers will be
|
|
303
412
|
* closed after all their pages are closed.
|
|
304
413
|
*/
|
|
305
414
|
retireAllBrowsers() {
|
|
306
|
-
[...this
|
|
415
|
+
[...this.#startingBrowserControllers, ...this.activeBrowserControllers].forEach((controller) => {
|
|
307
416
|
this.retireBrowserController(controller);
|
|
308
417
|
});
|
|
309
418
|
}
|
|
@@ -312,71 +421,86 @@ export class BrowserPool extends TypedEmitter {
|
|
|
312
421
|
* @return {Promise<void>}
|
|
313
422
|
*/
|
|
314
423
|
async closeAllBrowsers() {
|
|
315
|
-
const controllers = this.
|
|
424
|
+
const controllers = this.getAllBrowserControllers();
|
|
316
425
|
const promises = [...controllers]
|
|
317
426
|
.filter((controller) => controller.isActive)
|
|
318
427
|
.map(async (controller) => controller.close());
|
|
319
428
|
await Promise.all(promises);
|
|
320
429
|
}
|
|
430
|
+
async [Symbol.asyncDispose]() {
|
|
431
|
+
await this.destroy();
|
|
432
|
+
}
|
|
321
433
|
/**
|
|
322
|
-
* 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.
|
|
323
436
|
*/
|
|
324
|
-
async
|
|
325
|
-
clearInterval(this.browserKillerInterval);
|
|
326
|
-
clearInterval(this.browserRetireInterval);
|
|
327
|
-
this.browserKillerInterval = undefined;
|
|
328
|
-
this.browserRetireInterval = undefined;
|
|
437
|
+
async releaseAllBrowsers() {
|
|
329
438
|
await this.closeAllBrowsers();
|
|
330
|
-
this.
|
|
331
|
-
}
|
|
332
|
-
_teardown() {
|
|
333
|
-
this.startingBrowserControllers.clear();
|
|
439
|
+
this.#startingBrowserControllers.clear();
|
|
334
440
|
this.activeBrowserControllers.clear();
|
|
335
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();
|
|
336
453
|
this.removeAllListeners();
|
|
337
454
|
}
|
|
338
|
-
|
|
455
|
+
getAllBrowserControllers() {
|
|
339
456
|
return new Set([
|
|
340
|
-
...this
|
|
457
|
+
...this.#startingBrowserControllers,
|
|
341
458
|
...this.activeBrowserControllers,
|
|
342
459
|
...this.retiredBrowserControllers,
|
|
343
460
|
]);
|
|
344
461
|
}
|
|
345
|
-
async
|
|
346
|
-
const { browserPlugin, launchOptions,
|
|
462
|
+
async launchBrowser(pageId, options) {
|
|
463
|
+
const { browserPlugin, launchOptions, proxyUrl, ignoreTlsErrors } = options;
|
|
347
464
|
const browserController = browserPlugin.createController();
|
|
348
|
-
this
|
|
465
|
+
this.#startingBrowserControllers.add(browserController);
|
|
349
466
|
const launchContext = browserPlugin.createLaunchContext({
|
|
350
467
|
id: pageId,
|
|
351
468
|
launchOptions,
|
|
352
|
-
proxyTier,
|
|
353
469
|
proxyUrl,
|
|
354
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
|
+
}
|
|
355
480
|
try {
|
|
356
481
|
// If the hooks or the launch fails, we need to delete the controller,
|
|
357
482
|
// because otherwise it would be stuck in limbo without a browser.
|
|
358
|
-
await this.
|
|
483
|
+
await this.executeHooks(this.#preLaunchHooks, pageId, launchContext);
|
|
359
484
|
tryCancel();
|
|
360
485
|
const browser = await browserPlugin.launch(launchContext);
|
|
361
486
|
tryCancel();
|
|
362
487
|
browserController.assignBrowser(browser, launchContext);
|
|
363
488
|
}
|
|
364
489
|
catch (err) {
|
|
365
|
-
this
|
|
490
|
+
this.#startingBrowserControllers.delete(browserController);
|
|
366
491
|
throw err;
|
|
367
492
|
}
|
|
368
|
-
log.debug('Launched new browser.', { id: browserController.id });
|
|
369
|
-
browserController.proxyTier = proxyTier;
|
|
493
|
+
this.#log.debug('Launched new browser.', { id: browserController.id });
|
|
370
494
|
browserController.proxyUrl = proxyUrl;
|
|
371
495
|
try {
|
|
372
496
|
// If the launch fails on the post-launch hooks, we need to clean up
|
|
373
497
|
// both the controller and the browser before throwing.
|
|
374
|
-
await this.
|
|
498
|
+
await this.executeHooks(this.#postLaunchHooks, pageId, browserController);
|
|
375
499
|
}
|
|
376
500
|
catch (err) {
|
|
377
|
-
this
|
|
501
|
+
this.#startingBrowserControllers.delete(browserController);
|
|
378
502
|
browserController.close().catch((closeErr) => {
|
|
379
|
-
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}`, {
|
|
380
504
|
id: browserController.id,
|
|
381
505
|
});
|
|
382
506
|
});
|
|
@@ -384,105 +508,153 @@ export class BrowserPool extends TypedEmitter {
|
|
|
384
508
|
}
|
|
385
509
|
tryCancel();
|
|
386
510
|
browserController.activate();
|
|
387
|
-
this
|
|
511
|
+
this.#startingBrowserControllers.delete(browserController);
|
|
388
512
|
this.activeBrowserControllers.add(browserController);
|
|
389
|
-
this.emit(
|
|
513
|
+
this.emit(BROWSER_POOL_EVENTS.BROWSER_LAUNCHED, browserController);
|
|
390
514
|
return browserController;
|
|
391
515
|
}
|
|
392
516
|
/**
|
|
393
517
|
* Picks plugins round robin.
|
|
394
|
-
* @private
|
|
395
518
|
*/
|
|
396
|
-
|
|
397
|
-
const pluginIndex = this
|
|
398
|
-
this
|
|
519
|
+
pickBrowserPlugin() {
|
|
520
|
+
const pluginIndex = this.#pageCounter % this.browserPlugins.length;
|
|
521
|
+
this.#pageCounter++;
|
|
399
522
|
return this.browserPlugins[pluginIndex];
|
|
400
523
|
}
|
|
401
|
-
|
|
524
|
+
pickBrowserWithFreeCapacity(browserPlugin, options) {
|
|
402
525
|
return [...this.activeBrowserControllers].find((controller) => {
|
|
403
|
-
const hasCapacity = controller.activePages < this
|
|
526
|
+
const hasCapacity = controller.activePages < this.#maxOpenPagesPerBrowser;
|
|
404
527
|
const isCorrectPlugin = controller.browserPlugin === browserPlugin;
|
|
405
528
|
const isSameProxyUrl = controller.proxyUrl === options?.proxyUrl;
|
|
406
|
-
const isCorrectProxyTier = controller.proxyTier === options?.proxyTier;
|
|
407
529
|
return (isCorrectPlugin &&
|
|
408
530
|
hasCapacity &&
|
|
409
|
-
(
|
|
410
|
-
(options?.proxyTier && isCorrectProxyTier) ||
|
|
531
|
+
(!controller.launchContext.browserPerProxy ||
|
|
411
532
|
(options?.proxyUrl && isSameProxyUrl) ||
|
|
412
|
-
(!options?.proxyUrl && !
|
|
533
|
+
(!options?.proxyUrl && !controller.proxyUrl)));
|
|
413
534
|
});
|
|
414
535
|
}
|
|
415
|
-
async
|
|
536
|
+
async closeInactiveRetiredBrowsers() {
|
|
416
537
|
const closedBrowserIds = [];
|
|
417
538
|
for (const controller of this.retiredBrowserControllers) {
|
|
418
539
|
const millisSinceLastPageOpened = Date.now() - controller.lastPageOpenedAt;
|
|
419
|
-
const isBrowserIdle = millisSinceLastPageOpened >= this
|
|
540
|
+
const isBrowserIdle = millisSinceLastPageOpened >= this.#closeInactiveBrowserAfterMillis;
|
|
420
541
|
const isBrowserEmpty = controller.activePages === 0;
|
|
421
542
|
if (isBrowserIdle || isBrowserEmpty) {
|
|
422
543
|
const { id } = controller;
|
|
423
|
-
log.debug('Closing retired browser.', { id });
|
|
544
|
+
this.#log.debug('Closing retired browser.', { id });
|
|
424
545
|
await controller.close();
|
|
425
546
|
this.retiredBrowserControllers.delete(controller);
|
|
426
547
|
closedBrowserIds.push(id);
|
|
427
548
|
}
|
|
428
549
|
}
|
|
429
550
|
if (closedBrowserIds.length) {
|
|
430
|
-
log.debug('Closed retired browsers.', {
|
|
551
|
+
this.#log.debug('Closed retired browsers.', {
|
|
431
552
|
count: closedBrowserIds.length,
|
|
432
553
|
closedBrowserIds,
|
|
433
554
|
});
|
|
434
555
|
}
|
|
435
556
|
}
|
|
436
|
-
|
|
557
|
+
overridePageClose(page) {
|
|
437
558
|
const originalPageClose = page.close;
|
|
438
|
-
const browserController = this
|
|
559
|
+
const browserController = this.#pageToBrowserController.get(page);
|
|
439
560
|
const pageId = this.getPageId(page);
|
|
440
561
|
page.close = async (...args) => {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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);
|
|
446
601
|
this.pages.delete(pageId);
|
|
447
|
-
this.
|
|
448
|
-
this.emit(
|
|
602
|
+
this.closeRetiredBrowserWithNoPages(browserController);
|
|
603
|
+
this.emit(BROWSER_POOL_EVENTS.PAGE_CLOSED, page);
|
|
449
604
|
};
|
|
450
605
|
}
|
|
451
|
-
async
|
|
606
|
+
async executeHooks(hooks, ...args) {
|
|
452
607
|
for (const hook of hooks) {
|
|
453
608
|
await hook(...args);
|
|
454
609
|
}
|
|
455
610
|
}
|
|
456
|
-
|
|
611
|
+
closeRetiredBrowserWithNoPages(browserController) {
|
|
457
612
|
if (browserController.activePages === 0 && this.retiredBrowserControllers.has(browserController)) {
|
|
458
613
|
// Run this with a delay, otherwise page.close()
|
|
459
614
|
// might fail with "Protocol error (Target.closeTarget): Target closed."
|
|
460
615
|
setTimeout(() => {
|
|
461
|
-
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 });
|
|
462
617
|
void browserController.close().finally(() => {
|
|
463
618
|
this.retiredBrowserControllers.delete(browserController);
|
|
464
619
|
});
|
|
465
620
|
}, PAGE_CLOSE_KILL_TIMEOUT_MILLIS);
|
|
466
621
|
}
|
|
467
622
|
}
|
|
468
|
-
|
|
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() {
|
|
469
653
|
const { useFingerprintCache = true, fingerprintCacheSize = 10_000 } = this.fingerprintOptions;
|
|
470
654
|
this.fingerprintGenerator = new FingerprintGenerator(this.fingerprintOptions.fingerprintGeneratorOptions);
|
|
471
655
|
this.fingerprintInjector = new FingerprintInjector();
|
|
472
656
|
if (useFingerprintCache) {
|
|
473
657
|
this.fingerprintCache = new QuickLRU({ maxSize: fingerprintCacheSize });
|
|
474
658
|
}
|
|
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
659
|
}
|
|
487
660
|
}
|
|
488
|
-
//# sourceMappingURL=browser-pool.js.map
|