browserless
Version:
The headless Chrome/Chromium performance driver for Node.js
208 lines (175 loc) • 6.27 kB
JavaScript
const { ensureError, browserTimeout } = require('@browserless/errors')
const createScreenshot = require('@browserless/screenshot')
const debug = require('debug-logfmt')('browserless')
const createGoto = require('@browserless/goto')
const createPdf = require('@browserless/pdf')
const { withLock } = require('superlock')
const pReflect = require('p-reflect')
const pTimeout = require('p-timeout')
const pRetry = require('p-retry')
const { AbortError } = pRetry
const driver = require('./driver')
const lock = withLock()
module.exports = ({ timeout: globalTimeout = 30000, ...launchOpts } = {}) => {
const goto = createGoto({ timeout: globalTimeout, ...launchOpts })
const { defaultViewport } = goto
let isClosed = false
const close = opts => {
isClosed = true
return browserProcessPromise.then(browserProcess => driver.close(browserProcess, opts))
}
const respawn = () =>
!isClosed &&
Promise.all([
browserProcessPromise.then(driver.close),
(browserProcessPromise = spawn({ respawn: true }))
])
const spawn = ({ respawn: isRespawn = false } = {}) => {
const promise = driver.spawn({
defaultViewport,
handleSIGINT: false,
handleSIGTERM: false,
handleSIGHUP: false,
timeout: globalTimeout,
...launchOpts
})
promise.then(async browser => {
if (launchOpts.mode !== 'connect') browser.once('disconnected', getBrowser)
debug('spawn', {
respawn: isRespawn,
pid: driver.pid(browser) || launchOpts.mode,
version: await browser.version().catch(() => {})
})
})
return promise
}
let browserProcessPromise = spawn()
const createBrowserContext = contextOpts =>
getBrowser().then(browser => browser.createBrowserContext(contextOpts))
const getBrowser = async () => {
if (isClosed) return browserProcessPromise
const browserProcess = await lock(async () => {
const browserProcess = await browserProcessPromise
if (browserProcess.isConnected()) return browserProcess
respawn()
})
return browserProcess || getBrowser()
}
const createContext = async ({ retry = 2, timeout: contextTimeout, ...contextOpts } = {}) => {
let _contextPromise = createBrowserContext(contextOpts)
let isDestroyedForced = false
const getBrowserContext = () => _contextPromise
const createPage = async name => {
const duration = debug.duration('createPage')
const [browserProcess, browserContext] = await Promise.all([
getBrowser(),
getBrowserContext()
])
const page = await browserContext.newPage()
duration({
name,
id: page._client().id(),
contextId: browserContext.id,
browserPid: driver.pid(browserProcess)
})
return page
}
const closePage = async (page, name) => {
if (page && !page.isClosed()) {
const duration = debug.duration('closePage')
if (page.disableAdblock) page.disableAdblock()
const [browserProcess, browserContext] = await Promise.all([
getBrowser(),
getBrowserContext(),
pReflect(page.close())
])
duration({
name,
id: page._client().id(),
contextId: browserContext.id,
browserPid: driver.pid(browserProcess)
})
}
}
const withPage = (fn, { timeout: evaluateTimeout } = {}) => {
const name = fn.name || 'anonymous'
return async (...args) => {
let isRejected = false
async function run () {
let page
try {
page = await createPage(name)
setTimeout(() => closePage(page, name), timeout).unref()
const value = await fn(page, goto)(...args)
await closePage(page, `${name}:success`)
return value
} catch (error) {
await closePage(page, `${name}:error`)
if (!isRejected) throw ensureError(error)
}
}
const task = () =>
pRetry(run, {
retries: retry,
onFailedAttempt: async error => {
debug('onFailedAttempt', { name: error.name, code: error.code, isRejected })
if (error.name === 'AbortError') throw error
if (isRejected || isDestroyedForced) throw new AbortError()
if (error.code === 'EBRWSRCONTEXTCONNRESET') {
_contextPromise = createBrowserContext(contextOpts)
}
const { message, attemptNumber, retriesLeft } = error
debug('retry', { attemptNumber, retriesLeft, message })
}
})
const timeout = evaluateTimeout || contextTimeout || globalTimeout
return pTimeout(task(), timeout, () => {
isRejected = true
throw browserTimeout({ timeout })
})
}
}
const evaluate = (fn, gotoOpts) =>
withPage(
Object.defineProperty(
(page, goto) => async (url, opts) => {
const { response, error } = await goto(page, { url, ...gotoOpts, ...opts })
return fn(page, response, error)
},
'name',
{
value: fn.name || 'evaluate',
writable: false
}
),
gotoOpts
)
const destroyContext = async ({ force = false } = {}) => {
if (force) isDestroyedForced = true
const [browserProcess, browserContext] = await Promise.all([
getBrowser(),
getBrowserContext()
])
await pReflect(browserContext.close())
debug('destroyContext', { pid: driver.pid(browserProcess), force, id: browserContext.id })
}
return {
respawn,
context: getBrowserContext,
browser: getBrowser,
evaluate,
goto,
html: evaluate(page => page.content()),
page: createPage,
pdf: withPage(createPdf({ goto })),
screenshot: withPage(createScreenshot({ goto })),
text: evaluate(page => page.evaluate(() => document.body.innerText)),
getDevice: goto.getDevice,
destroyContext,
withPage
}
}
return { createContext, respawn, browser: getBrowser, close, isClosed: () => isClosed }
}
module.exports.driver = driver