Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions packages/function/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ const runFunction = require('./function')

const stringify = fn => fn.toString().trim().replace(/;$/, '')

const serializeResponse = response => ({
status: response.status(),
statusText: response.statusText(),
url: response.url(),
ok: response.ok(),
headers: response.headers(),
remoteAddress: response.remoteAddress(),
timing: response.timing(),
fromCache: response.fromCache(),
fromServiceWorker: response.fromServiceWorker()
})

module.exports = (
fn,
{
Expand Down Expand Up @@ -36,14 +48,15 @@ module.exports = (
const browserless = await browser.createContext()

return browserless.withPage((page, goto) => async () => {
const { device } = await goto(page, { url, timeout, ...gotoOpts })
const { device, response } = await goto(page, { url, timeout, ...gotoOpts })

const runFunctionOpts = {
url,
code,
device,
...opts,
...fnOpts
...fnOpts,
...(response && { _response: serializeResponse(response) })
}

if (runFunctionOpts.code === code) {
Expand Down
18 changes: 16 additions & 2 deletions packages/function/src/template.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,30 @@ const isUsingPage = code => {
return result
}

// _response is a plain JSON object serialized via isolated-function;
// wrap each value as a method to match Puppeteer's HTTPResponse API
const withResponse = `
const { _response: _r, ...rest } = opts
const response = _r
? Object.fromEntries(Object.entries(_r).map(([k, v]) => [k, () => v]))
: undefined`

const template = (code, usesPage = isUsingPage(code)) => {
if (!usesPage) return `async (url, _, opts) => (${code})(opts)`
if (!usesPage) {
return `async (url, _, opts) => {
${withResponse}
return (${code})({ response, ...rest })
}`
}
return `
async (url, browserWSEndpoint, opts) => {
${withResponse}
const puppeteer = require('@cloudflare/puppeteer')
const browser = await puppeteer.connect({ browserWSEndpoint })
const pages = await browser.pages()
const page = pages[pages.length - 1]
try {
return await (${code})({ page, ...opts })
return await (${code})({ page, response, ...rest })
} finally {
await browser.disconnect()
}
Expand Down
171 changes: 171 additions & 0 deletions packages/function/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,177 @@ test('interact with npm modules', async t => {
t.true(!!logging)
})

test('access to response', async t => {
const code = ({ response }) => response.status()
const myFn = browserlessFunction(code, opts)
const { profiling, logging, ...result } = await myFn('https://example.com')

t.true(result.isFulfilled)
t.is(result.value, 200)
t.true(!!profiling)
})

test('access to response (with page)', async t => {
const code = async ({ page, response }) => ({
title: await page.title(),
status: response.status()
})

const myFn = browserlessFunction(code, opts)
const { profiling, logging, ...result } = await myFn('https://example.com')

t.true(result.isFulfilled)
t.is(result.value.title, 'Example Domain')
t.is(result.value.status, 200)
t.true(!!profiling)
t.true(!!logging)
})

test('response is serialized and reconstructed with callable methods', t => {
const browserlessFunctionPath = require.resolve('..')
const script = `
const Module = require('module')
const originalLoad = Module._load

Module._load = function (request, parent, isMain) {
if (request === 'isolated-function') {
return (source) => {
const fn = new Function('return (' + source + ')')()
return [
async (...args) => {
try {
return { isFulfilled: true, value: await fn(...args) }
} catch (error) {
return { isFulfilled: false, value: { message: error.message } }
}
},
async () => {}
]
}
}
return originalLoad(request, parent, isMain)
}

const browserlessFunction = require(${JSON.stringify(browserlessFunctionPath)})

const fakeResponse = {
status: () => 200,
statusText: () => 'OK',
url: () => 'https://example.com',
ok: () => true,
headers: () => ({ 'content-type': 'text/html' }),
remoteAddress: () => ({ ip: '93.184.216.34', port: 443 }),
timing: () => null,
fromCache: () => false,
fromServiceWorker: () => false
}

const fakeBrowserless = {
withPage: fn => async () =>
fn({}, async () => ({
device: { viewport: {}, userAgent: 'ua' },
response: fakeResponse
}))()
}

const fn = browserlessFunction(
({ response }) => ({
status: response.status(),
ok: response.ok(),
url: response.url(),
headers: response.headers()
}),
{
getBrowserless: async () => ({
createContext: async () => fakeBrowserless
})
}
)

Promise.resolve()
.then(() => fn('https://example.com'))
.then(result => process.stdout.write(JSON.stringify(result)))
.catch(error => {
process.stderr.write(String(error && error.stack ? error.stack : error))
process.exit(1)
})
`

const { status, stdout, stderr } = spawnSync(process.execPath, ['-e', script], {
encoding: 'utf8'
})

t.is(status, 0, stderr)
const result = JSON.parse(stdout.trim())
t.true(result.isFulfilled)
t.deepEqual(result.value, {
status: 200,
ok: true,
url: 'https://example.com',
headers: { 'content-type': 'text/html' }
})
})

test('response is undefined when goto returns no response', t => {
const browserlessFunctionPath = require.resolve('..')
const script = `
const Module = require('module')
const originalLoad = Module._load

Module._load = function (request, parent, isMain) {
if (request === 'isolated-function') {
return (source) => {
const fn = new Function('return (' + source + ')')()
return [
async (...args) => {
try {
return { isFulfilled: true, value: await fn(...args) }
} catch (error) {
return { isFulfilled: false, value: { message: error.message } }
}
},
async () => {}
]
}
}
return originalLoad(request, parent, isMain)
}

const browserlessFunction = require(${JSON.stringify(browserlessFunctionPath)})

const fakeBrowserless = {
withPage: fn => async () =>
fn({}, async () => ({ device: { viewport: {}, userAgent: 'ua' } }))()
}

const fn = browserlessFunction(
({ response }) => response === undefined,
{
getBrowserless: async () => ({
createContext: async () => fakeBrowserless
})
}
)

Promise.resolve()
.then(() => fn('https://example.com'))
.then(result => process.stdout.write(JSON.stringify(result)))
.catch(error => {
process.stderr.write(String(error && error.stack ? error.stack : error))
process.exit(1)
})
`

const { status, stdout, stderr } = spawnSync(process.execPath, ['-e', script], {
encoding: 'utf8'
})

t.is(status, 0, stderr)
const result = JSON.parse(stdout.trim())
t.true(result.isFulfilled)
t.is(result.value, true)
})

test('throws error when browser is launched with pipe mode', async t => {
const createTestUtil = require('@browserless/test/create')
const { getBrowser } = createTestUtil({ pipe: true })
Expand Down
39 changes: 39 additions & 0 deletions packages/function/test/template.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/* eslint-disable no-new-func */

'use strict'

const { spawnSync } = require('child_process')
Expand Down Expand Up @@ -53,6 +55,43 @@ test('require puppeteer if page is used', t => {
}
})

test('non-page template reconstructs response methods from _response', async t => {
const code = '({ response }) => ({ status: response.status(), ok: response.ok() })'
const source = template(code)
const fn = new Function(`return (${source})`)()
const result = await fn('https://example.com', undefined, {
_response: { status: 200, ok: true }
})
t.deepEqual(result, { status: 200, ok: true })
})

test('response is undefined when _response is absent', async t => {
const code = '({ response }) => response'
const source = template(code)
const fn = new Function(`return (${source})`)()
const result = await fn('https://example.com', undefined, {})
t.is(result, undefined)
})

test('_response is not leaked to user function opts', async t => {
const code = '(opts) => Object.keys(opts).sort()'
const source = template(code)
const fn = new Function(`return (${source})`)()
const result = await fn('https://example.com', undefined, {
_response: { status: 200 },
query: { foo: 'bar' }
})
t.deepEqual(result, ['query', 'response'])
})

test('page template includes response in function call', t => {
const code = '({ page, response }) => response.status()'
const source = template(code)
t.true(source.includes('response'))
t.true(source.includes('_response'))
t.true(source.includes('...rest'))
})

test('reuse page usage analysis to avoid parsing code twice', t => {
const templatePath = require.resolve('../src/template')
const script = `
Expand Down