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 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?: { force?: boolean }) => Promise<void>
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.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.1",
40
- "@browserless/pdf": "14.1.1",
41
- "@browserless/screenshot": "14.1.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": "0c8d09fa9aa1d210a9a3e1906048909b5bd102b6"
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 _contextPromise = createBrowserContext(contextOpts)
112
- let isDestroyedForced = false
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 || isDestroyedForced) throw new AbortError()
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 = createBrowserContext(contextOpts)
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 ({ force = false } = {}) => {
240
- if (force) isDestroyedForced = true
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
- const [browserProcess, browserContext] = await Promise.all([
243
- getBrowser(),
244
- getBrowserContext()
245
- ])
246
- await pReflect(browserContext.close())
247
- debug('destroyContext', { pid: driver.pid(browserProcess), force, id: browserContext.id })
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 }