diff --git a/packages/function/src/index.js b/packages/function/src/index.js index 31cb1cff9..73c19ca4d 100644 --- a/packages/function/src/index.js +++ b/packages/function/src/index.js @@ -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, { @@ -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) { diff --git a/packages/function/src/template.js b/packages/function/src/template.js index dd6f1da91..6ea64fb6e 100644 --- a/packages/function/src/template.js +++ b/packages/function/src/template.js @@ -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() } diff --git a/packages/function/test/index.js b/packages/function/test/index.js index 4512b2977..e22699464 100644 --- a/packages/function/test/index.js +++ b/packages/function/test/index.js @@ -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 }) diff --git a/packages/function/test/template.js b/packages/function/test/template.js index 1d6ad6a62..f72b5d4ab 100644 --- a/packages/function/test/template.js +++ b/packages/function/test/template.js @@ -1,3 +1,5 @@ +/* eslint-disable no-new-func */ + 'use strict' const { spawnSync } = require('child_process') @@ -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 = `