browserless 14.1.1 → 14.1.3
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/index.d.ts +4 -1
- package/package.json +6 -5
- package/src/index.js +43 -15
- package/src/proxy-forwarder.js +50 -0
package/index.d.ts
CHANGED
|
@@ -40,7 +40,10 @@ export interface Context {
|
|
|
40
40
|
text: (url: string, opts?: GotoOptions) => Promise<string>
|
|
41
41
|
getDevice: (deviceName: string) => Viewport | undefined
|
|
42
42
|
report: (opts?: { benchmark?: boolean }) => Promise<HardwareInfo>
|
|
43
|
-
destroyContext: (opts?: {
|
|
43
|
+
destroyContext: (opts?: {
|
|
44
|
+
/** @deprecated A destroyed context never retries, so `force` has no effect. */
|
|
45
|
+
force?: boolean
|
|
46
|
+
}) => Promise<void>
|
|
44
47
|
withPage: <T>(fn: (page: Page, goto: unknown) => Promise<T>, opts?: { timeout?: number, keepPage?: boolean }) => Promise<T>
|
|
45
48
|
}
|
|
46
49
|
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "browserless",
|
|
3
3
|
"description": "The headless Chrome/Chromium driver on top of Puppeteer. Take screenshots, generate PDFs, extract text and HTML with a production-ready API.",
|
|
4
4
|
"homepage": "https://browserless.js.org",
|
|
5
|
-
"version": "14.1.
|
|
5
|
+
"version": "14.1.3",
|
|
6
6
|
"types": "index.d.ts",
|
|
7
7
|
"main": "src/index.js",
|
|
8
8
|
"author": {
|
|
@@ -36,14 +36,15 @@
|
|
|
36
36
|
],
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@browserless/errors": "14.0.22",
|
|
39
|
-
"@browserless/goto": "14.1.
|
|
40
|
-
"@browserless/pdf": "14.1.
|
|
41
|
-
"@browserless/screenshot": "14.1.
|
|
39
|
+
"@browserless/goto": "14.1.2",
|
|
40
|
+
"@browserless/pdf": "14.1.2",
|
|
41
|
+
"@browserless/screenshot": "14.1.2",
|
|
42
42
|
"debug-logfmt": "~1.4.15",
|
|
43
43
|
"kill-process-group": "~1.0.16",
|
|
44
44
|
"p-reflect": "~2.1.0",
|
|
45
45
|
"p-retry": "~4.6.1",
|
|
46
46
|
"p-timeout": "~4.1.0",
|
|
47
|
+
"proxy-chain": "~3.0.1",
|
|
47
48
|
"require-one-of": "~1.0.25",
|
|
48
49
|
"superlock": "~1.4.2"
|
|
49
50
|
},
|
|
@@ -69,5 +70,5 @@
|
|
|
69
70
|
"timeout": "2m",
|
|
70
71
|
"workerThreads": false
|
|
71
72
|
},
|
|
72
|
-
"gitHead": "
|
|
73
|
+
"gitHead": "c45e62a88191f36685a98af337762bc0c7a72953"
|
|
73
74
|
}
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const { ensureError, browserTimeout } = require('@browserless/errors')
|
|
3
|
+
const { ensureError, browserTimeout, contextDisconnected } = require('@browserless/errors')
|
|
4
4
|
const createScreenshot = require('@browserless/screenshot')
|
|
5
5
|
const debug = require('debug-logfmt')('browserless')
|
|
6
6
|
const createGoto = require('@browserless/goto')
|
|
@@ -12,6 +12,7 @@ const pRetry = require('p-retry')
|
|
|
12
12
|
|
|
13
13
|
const { AbortError } = pRetry
|
|
14
14
|
|
|
15
|
+
const { forwardProxy, closeForwarder } = require('./proxy-forwarder')
|
|
15
16
|
const driver = require('./driver')
|
|
16
17
|
|
|
17
18
|
// Chrome can stop answering CDP while `connected` stays true, so the
|
|
@@ -33,9 +34,11 @@ module.exports = ({ timeout: globalTimeout = 30000, ...launchOpts } = {}) => {
|
|
|
33
34
|
const { defaultViewport } = goto
|
|
34
35
|
|
|
35
36
|
let isClosed = false
|
|
37
|
+
const forwarders = new Set()
|
|
36
38
|
|
|
37
|
-
const close = opts => {
|
|
39
|
+
const close = async opts => {
|
|
38
40
|
isClosed = true
|
|
41
|
+
await Promise.all([...forwarders].map(closeForwarder))
|
|
39
42
|
return browserProcessPromise.then(browserProcess => driver.close(browserProcess, opts))
|
|
40
43
|
}
|
|
41
44
|
|
|
@@ -108,11 +111,21 @@ module.exports = ({ timeout: globalTimeout = 30000, ...launchOpts } = {}) => {
|
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
const createContext = async ({ retry = 2, timeout: contextTimeout, ...contextOpts } = {}) => {
|
|
111
|
-
let
|
|
112
|
-
|
|
114
|
+
let forwardedContextOpts
|
|
115
|
+
const forwardContextOpts = () => {
|
|
116
|
+
forwardedContextOpts = isClosed
|
|
117
|
+
? Promise.resolve(contextOpts)
|
|
118
|
+
: forwardProxy(contextOpts, { mode: launchOpts.mode })
|
|
119
|
+
forwarders.add(forwardedContextOpts)
|
|
120
|
+
return forwardedContextOpts
|
|
121
|
+
}
|
|
122
|
+
const newBrowserContext = () =>
|
|
123
|
+
(forwardedContextOpts ?? forwardContextOpts()).then(createBrowserContext)
|
|
124
|
+
let _contextPromise = newBrowserContext()
|
|
125
|
+
let destroyCount = 0
|
|
113
126
|
const pageMetadata = new WeakMap()
|
|
114
127
|
|
|
115
|
-
const getBrowserContext = () => _contextPromise
|
|
128
|
+
const getBrowserContext = () => (_contextPromise ??= newBrowserContext())
|
|
116
129
|
|
|
117
130
|
const createPage = async name => {
|
|
118
131
|
const duration = debug.duration('createPage')
|
|
@@ -144,6 +157,8 @@ module.exports = ({ timeout: globalTimeout = 30000, ...launchOpts } = {}) => {
|
|
|
144
157
|
|
|
145
158
|
return async (...args) => {
|
|
146
159
|
let isRejected = false
|
|
160
|
+
const destroysAtStart = destroyCount
|
|
161
|
+
const isAbandoned = () => isClosed || destroyCount !== destroysAtStart
|
|
147
162
|
|
|
148
163
|
async function run () {
|
|
149
164
|
let page
|
|
@@ -162,9 +177,11 @@ module.exports = ({ timeout: globalTimeout = 30000, ...launchOpts } = {}) => {
|
|
|
162
177
|
}
|
|
163
178
|
|
|
164
179
|
try {
|
|
180
|
+
if (isAbandoned()) throw contextDisconnected()
|
|
165
181
|
page = await createPage(name)
|
|
166
182
|
closePageTimeout = startCloseTimeout()
|
|
167
183
|
const value = await fn(page, goto)(...args)
|
|
184
|
+
if (isAbandoned()) throw contextDisconnected()
|
|
168
185
|
// A timeout already rejected the caller, who never received this
|
|
169
186
|
// page. Retaining it would clear the in-call watchdog and arm a
|
|
170
187
|
// new one with nobody holding the page.
|
|
@@ -190,17 +207,18 @@ module.exports = ({ timeout: globalTimeout = 30000, ...launchOpts } = {}) => {
|
|
|
190
207
|
onFailedAttempt: async error => {
|
|
191
208
|
debug('onFailedAttempt', { name: error.name, code: error.code, isRejected })
|
|
192
209
|
if (error.name === 'AbortError') throw error
|
|
193
|
-
if (isRejected
|
|
210
|
+
if (isRejected) throw new AbortError()
|
|
194
211
|
const isRetryable =
|
|
195
212
|
error.code === 'EBRWSRCONTEXTCONNRESET' || error.code === 'EPROTOCOL'
|
|
196
213
|
if (!isRetryable) throw error
|
|
214
|
+
if (isAbandoned()) throw new AbortError()
|
|
197
215
|
const { message, attemptNumber, retriesLeft } = error
|
|
198
216
|
// Every attempt builds its own page, so both codes are retryable
|
|
199
217
|
// inside the existing context: `EBRWSRCONTEXTCONNRESET` covers a
|
|
200
218
|
// closed page, a detached frame and a destroyed execution context,
|
|
201
219
|
// which a fresh page recovers from.
|
|
202
220
|
const previousContextPromise = _contextPromise
|
|
203
|
-
_contextPromise =
|
|
221
|
+
_contextPromise = newBrowserContext()
|
|
204
222
|
await pReflect(previousContextPromise.then(ctx => ctx.close()))
|
|
205
223
|
debug('retry', { attemptNumber, retriesLeft, message })
|
|
206
224
|
}
|
|
@@ -236,15 +254,25 @@ module.exports = ({ timeout: globalTimeout = 30000, ...launchOpts } = {}) => {
|
|
|
236
254
|
)
|
|
237
255
|
}
|
|
238
256
|
|
|
239
|
-
const destroyContext = async (
|
|
240
|
-
|
|
257
|
+
const destroyContext = async () => {
|
|
258
|
+
destroyCount++
|
|
259
|
+
const closingContextPromise = _contextPromise
|
|
260
|
+
const closingContextOpts = forwardedContextOpts
|
|
261
|
+
_contextPromise = undefined
|
|
262
|
+
forwardedContextOpts = undefined
|
|
263
|
+
if (!closingContextPromise) return
|
|
241
264
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
265
|
+
try {
|
|
266
|
+
const [browserProcess, browserContext] = await Promise.all([
|
|
267
|
+
getBrowser(),
|
|
268
|
+
closingContextPromise
|
|
269
|
+
])
|
|
270
|
+
await pReflect(browserContext.close())
|
|
271
|
+
debug('destroyContext', { pid: driver.pid(browserProcess), id: browserContext.id })
|
|
272
|
+
} finally {
|
|
273
|
+
forwarders.delete(closingContextOpts)
|
|
274
|
+
await closeForwarder(closingContextOpts)
|
|
275
|
+
}
|
|
248
276
|
}
|
|
249
277
|
|
|
250
278
|
return {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { anonymizeProxy, closeAnonymizedProxy } = require('proxy-chain')
|
|
4
|
+
|
|
5
|
+
const REDACTED = '***'
|
|
6
|
+
const DEFAULT_SCHEME = 'http://'
|
|
7
|
+
const CONNECT_MODE_ERROR =
|
|
8
|
+
"A proxyServer with credentials needs `mode: 'launch'`: a connected browser cannot reach the local forwarder"
|
|
9
|
+
|
|
10
|
+
const forwardedProxyServers = new Set()
|
|
11
|
+
|
|
12
|
+
const parseProxyServer = proxyServer =>
|
|
13
|
+
URL.parse(proxyServer.includes('://') ? proxyServer : `${DEFAULT_SCHEME}${proxyServer}`)
|
|
14
|
+
|
|
15
|
+
const hasCredentials = proxyUrl => Boolean(proxyUrl?.username || proxyUrl?.password)
|
|
16
|
+
|
|
17
|
+
const redactSecrets = (text, secrets) =>
|
|
18
|
+
secrets.reduce((redacted, secret) => redacted.replaceAll(secret, REDACTED), text)
|
|
19
|
+
|
|
20
|
+
const withoutCredentials = (error, proxyUrl) => {
|
|
21
|
+
const redactedUrl = new URL(proxyUrl)
|
|
22
|
+
if (proxyUrl.username) redactedUrl.username = REDACTED
|
|
23
|
+
if (proxyUrl.password) redactedUrl.password = REDACTED
|
|
24
|
+
const secrets = [proxyUrl.username, proxyUrl.password].filter(Boolean)
|
|
25
|
+
error.message = error.message
|
|
26
|
+
.split(proxyUrl.href)
|
|
27
|
+
.map(part => redactSecrets(part, secrets))
|
|
28
|
+
.join(redactedUrl.href)
|
|
29
|
+
return error
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const forwardProxy = async (contextOpts, { mode } = {}) => {
|
|
33
|
+
const proxyUrl = contextOpts.proxyServer && parseProxyServer(contextOpts.proxyServer)
|
|
34
|
+
if (!hasCredentials(proxyUrl)) return contextOpts
|
|
35
|
+
if (mode === 'connect') throw new TypeError(CONNECT_MODE_ERROR)
|
|
36
|
+
try {
|
|
37
|
+
const proxyServer = await anonymizeProxy(proxyUrl.href)
|
|
38
|
+
forwardedProxyServers.add(proxyServer)
|
|
39
|
+
return { ...contextOpts, proxyServer }
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw withoutCredentials(error, proxyUrl)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const closeForwarder = async contextOptsPromise => {
|
|
46
|
+
const { proxyServer } = await contextOptsPromise.catch(() => ({}))
|
|
47
|
+
if (forwardedProxyServers.delete(proxyServer)) await closeAnonymizedProxy(proxyServer, true)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { forwardProxy, closeForwarder }
|