From c65e7fa88361ff6740b12a87363cd2f77dfc8f08 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Thu, 17 Feb 2022 20:36:37 -0800 Subject: [PATCH] [node-bridge] Move helpers to node-bridge (#7451) --- .eslintignore | 2 + packages/node-bridge/.gitignore | 2 + packages/node-bridge/build.js | 62 ++ packages/node-bridge/helpers.ts | 309 ++++++++ packages/node-bridge/package.json | 6 +- packages/node-bridge/test/helpers.test.js | 842 ++++++++++++++++++++++ packages/node-bridge/types.ts | 25 +- 7 files changed, 1245 insertions(+), 3 deletions(-) create mode 100644 packages/node-bridge/.gitignore create mode 100644 packages/node-bridge/build.js create mode 100644 packages/node-bridge/helpers.ts create mode 100644 packages/node-bridge/test/helpers.test.js diff --git a/.eslintignore b/.eslintignore index e631d0700..ed9efefb2 100644 --- a/.eslintignore +++ b/.eslintignore @@ -14,4 +14,6 @@ packages/client/lib packages/node/src/bridge.ts packages/node-bridge/bridge.js packages/node-bridge/launcher.js +packages/node-bridge/helpers.js +packages/node-bridge/source-map-support.js packages/middleware/src/entries.js diff --git a/packages/node-bridge/.gitignore b/packages/node-bridge/.gitignore new file mode 100644 index 000000000..479cf90fd --- /dev/null +++ b/packages/node-bridge/.gitignore @@ -0,0 +1,2 @@ +/helpers.js +/source-map-support.js diff --git a/packages/node-bridge/build.js b/packages/node-bridge/build.js new file mode 100644 index 000000000..c2f8fdfd9 --- /dev/null +++ b/packages/node-bridge/build.js @@ -0,0 +1,62 @@ +#!/usr/bin/env node +const fs = require('fs-extra'); +const execa = require('execa'); +const { join } = require('path'); + +async function main() { + // Build TypeScript files + await execa('tsc', [], { + stdio: 'inherit', + }); + + // Bundle `helpers.ts` with ncc + await fs.remove(join(__dirname, 'helpers.js')); + const helpersDir = join(__dirname, 'helpers'); + await execa( + 'ncc', + [ + 'build', + join(__dirname, 'helpers.ts'), + '-e', + '@vercel/node-bridge', + '-e', + '@vercel/build-utils', + '-e', + 'typescript', + '-o', + helpersDir, + ], + { stdio: 'inherit' } + ); + await fs.rename(join(helpersDir, 'index.js'), join(__dirname, 'helpers.js')); + await fs.remove(helpersDir); + + // Bundle `source-map-support/register` with ncc for source maps + const sourceMapSupportDir = join(__dirname, 'source-map-support'); + await execa( + 'ncc', + [ + 'build', + join(__dirname, '../../node_modules/source-map-support/register'), + '-e', + '@vercel/node-bridge', + '-e', + '@vercel/build-utils', + '-e', + 'typescript', + '-o', + sourceMapSupportDir, + ], + { stdio: 'inherit' } + ); + await fs.rename( + join(sourceMapSupportDir, 'index.js'), + join(__dirname, 'source-map-support.js') + ); + await fs.remove(sourceMapSupportDir); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/packages/node-bridge/helpers.ts b/packages/node-bridge/helpers.ts new file mode 100644 index 000000000..c712a6f05 --- /dev/null +++ b/packages/node-bridge/helpers.ts @@ -0,0 +1,309 @@ +import type { + VercelRequest, + VercelResponse, + VercelRequestCookies, + VercelRequestQuery, + VercelRequestBody, +} from './types'; +import { Server } from 'http'; +import type { Bridge } from './bridge'; + +function getBodyParser(req: VercelRequest, body: Buffer) { + return function parseBody(): VercelRequestBody { + if (!req.headers['content-type']) { + return undefined; + } + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { parse: parseContentType } = require('content-type'); + const { type } = parseContentType(req.headers['content-type']); + + if (type === 'application/json') { + try { + const str = body.toString(); + return str ? JSON.parse(str) : {}; + } catch (error) { + throw new ApiError(400, 'Invalid JSON'); + } + } + + if (type === 'application/octet-stream') { + return body; + } + + if (type === 'application/x-www-form-urlencoded') { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { parse: parseQS } = require('querystring'); + // note: querystring.parse does not produce an iterable object + // https://nodejs.org/api/querystring.html#querystring_querystring_parse_str_sep_eq_options + return parseQS(body.toString()); + } + + if (type === 'text/plain') { + return body.toString(); + } + + return undefined; + }; +} + +function getQueryParser({ url = '/' }: VercelRequest) { + return function parseQuery(): VercelRequestQuery { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { parse: parseURL } = require('url'); + return parseURL(url, true).query; + }; +} + +function getCookieParser(req: VercelRequest) { + return function parseCookie(): VercelRequestCookies { + const header: undefined | string | string[] = req.headers.cookie; + + if (!header) { + return {}; + } + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { parse } = require('cookie'); + return parse(Array.isArray(header) ? header.join(';') : header); + }; +} + +function status(res: VercelResponse, statusCode: number): VercelResponse { + res.statusCode = statusCode; + return res; +} + +function redirect( + res: VercelResponse, + statusOrUrl: string | number, + url?: string +): VercelResponse { + if (typeof statusOrUrl === 'string') { + url = statusOrUrl; + statusOrUrl = 307; + } + if (typeof statusOrUrl !== 'number' || typeof url !== 'string') { + throw new Error( + `Invalid redirect arguments. Please use a single argument URL, e.g. res.redirect('/destination') or use a status code and URL, e.g. res.redirect(307, '/destination').` + ); + } + res.writeHead(statusOrUrl, { Location: url }).end(); + return res; +} + +function setCharset(type: string, charset: string) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { parse, format } = require('content-type'); + const parsed = parse(type); + parsed.parameters.charset = charset; + return format(parsed); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function createETag(body: any, encoding: 'utf8' | undefined) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const etag = require('etag'); + const buf = !Buffer.isBuffer(body) ? Buffer.from(body, encoding) : body; + return etag(buf, { weak: true }); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function send( + req: VercelRequest, + res: VercelResponse, + body: any +): VercelResponse { + let chunk: unknown = body; + let encoding: 'utf8' | undefined; + + switch (typeof chunk) { + // string defaulting to html + case 'string': + if (!res.getHeader('content-type')) { + res.setHeader('content-type', 'text/html'); + } + break; + case 'boolean': + case 'number': + case 'object': + if (chunk === null) { + chunk = ''; + } else if (Buffer.isBuffer(chunk)) { + if (!res.getHeader('content-type')) { + res.setHeader('content-type', 'application/octet-stream'); + } + } else { + return json(req, res, chunk); + } + break; + } + + // write strings in utf-8 + if (typeof chunk === 'string') { + encoding = 'utf8'; + + // reflect this in content-type + const type = res.getHeader('content-type'); + if (typeof type === 'string') { + res.setHeader('content-type', setCharset(type, 'utf-8')); + } + } + + // populate Content-Length + let len: number | undefined; + if (chunk !== undefined) { + if (Buffer.isBuffer(chunk)) { + // get length of Buffer + len = chunk.length; + } else if (typeof chunk === 'string') { + if (chunk.length < 1000) { + // just calculate length small chunk + len = Buffer.byteLength(chunk, encoding); + } else { + // convert chunk to Buffer and calculate + const buf = Buffer.from(chunk, encoding); + len = buf.length; + chunk = buf; + encoding = undefined; + } + } else { + throw new Error( + '`body` is not a valid string, object, boolean, number, Stream, or Buffer' + ); + } + + if (len !== undefined) { + res.setHeader('content-length', len); + } + } + + // populate ETag + let etag: string | undefined; + if ( + !res.getHeader('etag') && + len !== undefined && + (etag = createETag(chunk, encoding)) + ) { + res.setHeader('etag', etag); + } + + // strip irrelevant headers + if (204 === res.statusCode || 304 === res.statusCode) { + res.removeHeader('Content-Type'); + res.removeHeader('Content-Length'); + res.removeHeader('Transfer-Encoding'); + chunk = ''; + } + + if (req.method === 'HEAD') { + // skip body for HEAD + res.end(); + } else if (encoding) { + // respond with encoding + res.end(chunk, encoding); + } else { + // respond without encoding + res.end(chunk); + } + + return res; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function json( + req: VercelRequest, + res: VercelResponse, + jsonBody: any +): VercelResponse { + const body = JSON.stringify(jsonBody); + + // content-type + if (!res.getHeader('content-type')) { + res.setHeader('content-type', 'application/json; charset=utf-8'); + } + + return send(req, res, body); +} + +export class ApiError extends Error { + readonly statusCode: number; + + constructor(statusCode: number, message: string) { + super(message); + this.statusCode = statusCode; + } +} + +export function sendError( + res: VercelResponse, + statusCode: number, + message: string +) { + res.statusCode = statusCode; + res.statusMessage = message; + res.end(); +} + +function setLazyProp(req: VercelRequest, prop: string, getter: () => T) { + const opts = { configurable: true, enumerable: true }; + const optsReset = { ...opts, writable: true }; + + Object.defineProperty(req, prop, { + ...opts, + get: () => { + const value = getter(); + // we set the property on the object to avoid recalculating it + Object.defineProperty(req, prop, { ...optsReset, value }); + return value; + }, + set: value => { + Object.defineProperty(req, prop, { ...optsReset, value }); + }, + }); +} + +export function createServerWithHelpers( + handler: (req: VercelRequest, res: VercelResponse) => void | Promise, + bridge: Bridge +) { + const server = new Server(async (_req, _res) => { + const req = _req as VercelRequest; + const res = _res as VercelResponse; + + try { + const reqId = req.headers['x-now-bridge-request-id']; + + // don't expose this header to the client + delete req.headers['x-now-bridge-request-id']; + + if (typeof reqId !== 'string') { + throw new ApiError(500, 'Internal Server Error'); + } + + const event = bridge.consumeEvent(reqId); + + setLazyProp(req, 'cookies', getCookieParser(req)); + setLazyProp(req, 'query', getQueryParser(req)); + setLazyProp( + req, + 'body', + getBodyParser(req, event.body) + ); + + res.status = statusCode => status(res, statusCode); + res.redirect = (statusOrUrl, url) => redirect(res, statusOrUrl, url); + res.send = body => send(req, res, body); + res.json = jsonBody => json(req, res, jsonBody); + + await handler(req, res); + } catch (err) { + if (err instanceof ApiError) { + sendError(res, err.statusCode, err.message); + } else { + throw err; + } + } + }); + + return server; +} diff --git a/packages/node-bridge/package.json b/packages/node-bridge/package.json index d0d30c77c..4cab9398c 100644 --- a/packages/node-bridge/package.json +++ b/packages/node-bridge/package.json @@ -11,10 +11,12 @@ "files": [ "bridge.*", "launcher.*", - "index.js" + "index.js", + "helpers.js", + "source-map-support.js" ], "scripts": { - "build": "tsc", + "build": "node build.js", "test-unit": "jest --env node --verbose --runInBand --bail" }, "devDependencies": { diff --git a/packages/node-bridge/test/helpers.test.js b/packages/node-bridge/test/helpers.test.js new file mode 100644 index 000000000..5af8b53a6 --- /dev/null +++ b/packages/node-bridge/test/helpers.test.js @@ -0,0 +1,842 @@ +const fetch = require('node-fetch'); +const listen = require('test-listen'); +const qs = require('querystring'); + +const { createServerWithHelpers } = require('../helpers'); + +const mockListener = jest.fn(); +const consumeEventMock = jest.fn(); +const mockBridge = { consumeEvent: consumeEventMock }; + +let server; +let url; + +async function fetchWithProxyReq(_url, opts = {}) { + if (opts.body) { + // eslint-disable-next-line + opts = { ...opts, body: Buffer.from(opts.body) }; + } + + consumeEventMock.mockImplementationOnce(() => opts); + + return fetch(_url, { + ...opts, + headers: { ...opts.headers, 'x-now-bridge-request-id': '2' }, + }); +} + +beforeEach(async () => { + mockListener.mockClear(); + consumeEventMock.mockClear(); + + mockListener.mockImplementation((req, res) => { + res.send('hello'); + }); + consumeEventMock.mockImplementation(() => ({})); + + server = createServerWithHelpers(mockListener, mockBridge); + url = await listen(server); +}); + +afterEach(async () => { + await server.close(); +}); + +describe('contract with @vercel/node-bridge', () => { + test('should call consumeEvent with the correct reqId', async () => { + await fetchWithProxyReq(`${url}/`); + + expect(consumeEventMock).toHaveBeenLastCalledWith('2'); + }); + + test('should not expose the request id header', async () => { + await fetchWithProxyReq(`${url}/`, { headers: { 'x-test-header': 'ok' } }); + + const [{ headers }] = mockListener.mock.calls[0]; + + expect(headers['x-now-bridge-request-id']).toBeUndefined(); + expect(headers['x-test-header']).toBe('ok'); + }); +}); + +describe('all helpers', () => { + const nowHelpers = [ + ['query', 0], + ['cookies', 0], + ['body', 0], + ['status', 1], + ['redirect', 1], + ['send', 1], + ['json', 1], + ]; + + test('should not recalculate req properties twice', async () => { + const spy = jest.fn(() => {}); + + const nowReqHelpers = nowHelpers.filter(([, i]) => i === 0); + + mockListener.mockImplementation((req, res) => { + spy(...nowReqHelpers.map(h => req[h])); + spy(...nowReqHelpers.map(h => req[h])); + res.end(); + }); + + await fetchWithProxyReq(`${url}/?who=bill`, { + method: 'POST', + body: JSON.stringify({ who: 'mike' }), + headers: { 'content-type': 'application/json', cookie: 'who=jim' }, + }); + + // here we test that bodySpy is called twice with exactly the same arguments + for (let i = 0; i < 3; i += 1) { + expect(spy.mock.calls[0][i]).toBe(spy.mock.calls[1][i]); + } + }); + + test('should be able to overwrite request properties', async () => { + const spy = jest.fn(() => {}); + + mockListener.mockImplementation((...args) => { + nowHelpers.forEach(([prop, n]) => { + /* eslint-disable */ + args[n][prop] = 'ok'; + args[n][prop] = 'ok2'; + spy(args[n][prop]); + }); + + args[1].end(); + }); + + await fetchWithProxyReq(url); + + nowHelpers.forEach((_, i) => expect(spy.mock.calls[i][0]).toBe('ok2')); + }); + + test('should be able to reconfig request properties', async () => { + const spy = jest.fn(() => {}); + + mockListener.mockImplementation((...args) => { + nowHelpers.forEach(([prop, n]) => { + // eslint-disable-next-line + Object.defineProperty(args[n], prop, { value: 'ok' }); + Object.defineProperty(args[n], prop, { value: 'ok2' }); + spy(args[n][prop]); + }); + + args[1].end(); + }); + + await fetchWithProxyReq(url); + + nowHelpers.forEach((_, i) => expect(spy.mock.calls[i][0]).toBe('ok2')); + }); +}); + +describe('req.query', () => { + test('req.query should reflect querystring in the url', async () => { + await fetchWithProxyReq(`${url}/?who=bill&where=us`); + + expect(mockListener.mock.calls[0][0].query).toMatchObject({ + who: 'bill', + where: 'us', + }); + }); + + test('req.query should turn multiple params with same name into an array', async () => { + await fetchWithProxyReq(`${url}/?a=2&a=1`); + + expect(mockListener.mock.calls[0][0].query).toMatchObject({ + a: ['2', '1'], + }); + }); + + test('req.query should be {} when there is no querystring', async () => { + await fetchWithProxyReq(url); + const [{ query }] = mockListener.mock.calls[0]; + expect(Object.keys(query).length).toBe(0); + }); +}); + +describe('req.cookies', () => { + test('req.cookies should reflect req.cookie header', async () => { + await fetchWithProxyReq(url, { + headers: { + cookie: 'who=bill; where=us', + }, + }); + + expect(mockListener.mock.calls[0][0].cookies).toMatchObject({ + who: 'bill', + where: 'us', + }); + }); +}); + +describe('req.body', () => { + test('req.body should be undefined by default', async () => { + await fetchWithProxyReq(url); + expect(mockListener.mock.calls[0][0].body).toBe(undefined); + }); + + test('req.body should be undefined if content-type is not defined', async () => { + await fetchWithProxyReq(url, { + method: 'POST', + body: 'hello', + }); + expect(mockListener.mock.calls[0][0].body).toBe(undefined); + }); + + test('req.body should be a string when content-type is `text/plain`', async () => { + await fetchWithProxyReq(url, { + method: 'POST', + body: 'hello', + headers: { 'content-type': 'text/plain' }, + }); + + expect(mockListener.mock.calls[0][0].body).toBe('hello'); + }); + + test('req.body should be a buffer when content-type is `application/octet-stream`', async () => { + await fetchWithProxyReq(url, { + method: 'POST', + body: 'hello', + headers: { 'content-type': 'application/octet-stream' }, + }); + + const [{ body }] = mockListener.mock.calls[0]; + + const str = body.toString(); + + expect(Buffer.isBuffer(body)).toBe(true); + expect(str).toBe('hello'); + }); + + test('req.body should be an object when content-type is `application/x-www-form-urlencoded`', async () => { + const obj = { who: 'mike' }; + + await fetchWithProxyReq(url, { + method: 'POST', + body: qs.encode(obj), + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + }); + + expect(mockListener.mock.calls[0][0].body).toMatchObject(obj); + }); + + test('req.body should be an object when content-type is `application/json`', async () => { + const json = { + who: 'bill', + where: 'us', + }; + + await fetchWithProxyReq(url, { + method: 'POST', + body: JSON.stringify(json), + headers: { 'content-type': 'application/json' }, + }); + + expect(mockListener.mock.calls[0][0].body).toMatchObject(json); + }); + + test('should work when body is empty and content-type is `application/json`', async () => { + mockListener.mockImplementation((req, res) => { + console.log(req.body); + res.end(); + }); + + const res = await fetchWithProxyReq(url, { + method: 'POST', + body: '', + headers: { 'content-type': 'application/json' }, + }); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({}); + }); + + test('should be able to try/catch parse errors', async () => { + const bodySpy = jest.fn(() => {}); + + mockListener.mockImplementation((req, res) => { + try { + if (req.body === undefined) res.status(400); + } catch (error) { + bodySpy(error); + } finally { + res.end(); + } + }); + + await fetchWithProxyReq(url, { + method: 'POST', + body: '{"wrong":"json"', + headers: { 'content-type': 'application/json' }, + }); + + expect(bodySpy).toHaveBeenCalled(); + + const [error] = bodySpy.mock.calls[0]; + expect(error.message).toMatch(/invalid json/i); + expect(error.statusCode).toBe(400); + }); +}); + +describe('res.status', () => { + test('res.status() should set the status code', async () => { + mockListener.mockImplementation((req, res) => { + res.status(404); + res.end(); + }); + + const res = await fetchWithProxyReq(url); + + expect(res.status).toBe(404); + }); + + test('res.status() should be chainable', async () => { + const spy = jest.fn(); + + mockListener.mockImplementation((req, res) => { + spy(res, res.status(404)); + res.end(); + }); + + await fetchWithProxyReq(url); + + const [a, b] = spy.mock.calls[0]; + expect(a).toBe(b); + }); +}); + +describe('res.redirect', () => { + test('should redirect to login', async () => { + mockListener.mockImplementation((req, res) => { + res.redirect('/login'); + res.end(); + }); + + const res = await fetchWithProxyReq(url, { redirect: 'manual' }); + + expect(res.status).toBe(307); + expect(res.headers.get('location')).toBe(url + '/login'); + }); + test('should redirect with status code 301', async () => { + mockListener.mockImplementation((req, res) => { + res.redirect(301, '/login'); + res.end(); + }); + const res = await fetchWithProxyReq(url, { redirect: 'manual' }); + expect(res.status).toBe(301); + expect(res.headers.get('location')).toBe(url + '/login'); + }); + test('should show friendly error for invalid redirect', async () => { + let error; + mockListener.mockImplementation((req, res) => { + try { + res.redirect(307); + } catch (err) { + error = err; + } + res.end(); + }); + await fetchWithProxyReq(url, { redirect: 'manual' }); + expect(error.message).toBe( + `Invalid redirect arguments. Please use a single argument URL, e.g. res.redirect('/destination') or use a status code and URL, e.g. res.redirect(307, '/destination').` + ); + }); + test('should show friendly error in case of passing null as first argument redirect', async () => { + let error; + mockListener.mockImplementation((req, res) => { + try { + res.redirect(null); + } catch (err) { + error = err; + } + res.end(); + }); + await fetchWithProxyReq(url, { redirect: 'manual' }); + expect(error.message).toBe( + `Invalid redirect arguments. Please use a single argument URL, e.g. res.redirect('/destination') or use a status code and URL, e.g. res.redirect(307, '/destination').` + ); + }); +}); + +// tests based on expressjs test suite +// see https://github.com/expressjs/express/blob/master/test/res.send.js +describe('res.send', () => { + test('should be chainable', async () => { + const spy = jest.fn(); + + mockListener.mockImplementation((req, res) => { + spy(res, res.send('hello')); + }); + + await fetchWithProxyReq(url); + + const [a, b] = spy.mock.calls[0]; + expect(a).toBe(b); + }); + + describe('res.send()', () => { + test('should set body to ""', async () => { + mockListener.mockImplementation((req, res) => { + res.send(); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(await res.text()).toBe(''); + }); + }); + + describe('.send(null)', () => { + test('should set body to ""', async () => { + mockListener.mockImplementation((req, res) => { + res.send(null); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('content-length')).toBe('0'); + expect(await res.text()).toBe(''); + }); + }); + + describe('.send(undefined)', () => { + test('should set body to ""', async () => { + mockListener.mockImplementation((req, res) => { + res.send(undefined); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(await res.text()).toBe(''); + }); + }); + + describe('.send(String)', () => { + test('should send as html', async () => { + mockListener.mockImplementation((req, res) => { + res.send('

hey

'); + }); + + const res = await fetchWithProxyReq(url); + expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8'); + expect(await res.text()).toBe('

hey

'); + }); + + test('should set Content-Length', async () => { + mockListener.mockImplementation((req, res) => { + res.send('½ + ¼ = ¾'); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(Number(res.headers.get('content-length'))).toBe(12); + expect(await res.text()).toBe('½ + ¼ = ¾'); + }); + + test('should set ETag', async () => { + mockListener.mockImplementation((req, res) => { + res.send(Array(1000).join('-')); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('ETag')).toBe( + 'W/"3e7-qPnkJ3CVdVhFJQvUBfF10TmVA7g"' + ); + }); + + test('should not override Content-Type', async () => { + mockListener.mockImplementation((req, res) => { + res.setHeader('Content-Type', 'text/plain'); + res.send('hey'); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('text/plain; charset=utf-8'); + expect(await res.text()).toBe('hey'); + }); + + test('should override charset in Content-Type', async () => { + mockListener.mockImplementation((req, res) => { + res.setHeader('Content-Type', 'text/plain; charset=iso-8859-1'); + res.send('hey'); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('text/plain; charset=utf-8'); + expect(await res.text()).toBe('hey'); + }); + }); + + describe('.send(Buffer)', () => { + test('should keep charset in Content-Type', async () => { + mockListener.mockImplementation((req, res) => { + res.setHeader('Content-Type', 'text/plain; charset=iso-8859-1'); + res.send(Buffer.from('hi')); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe( + 'text/plain; charset=iso-8859-1' + ); + expect(await res.text()).toBe('hi'); + }); + + test('should set Content-Length', async () => { + mockListener.mockImplementation((req, res) => { + res.send(Buffer.from('½ + ¼ = ¾')); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(Number(res.headers.get('content-length'))).toBe(12); + expect(await res.text()).toBe('½ + ¼ = ¾'); + }); + + test('should send as octet-stream', async () => { + mockListener.mockImplementation((req, res) => { + res.send(Buffer.from('hello')); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('application/octet-stream'); + expect((await res.buffer()).toString('hex')).toBe( + Buffer.from('hello').toString('hex') + ); + }); + + test('should set ETag', async () => { + mockListener.mockImplementation((req, res) => { + res.send(Buffer.alloc(999, '-')); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('ETag')).toBe( + 'W/"3e7-qPnkJ3CVdVhFJQvUBfF10TmVA7g"' + ); + }); + + test('should not override Content-Type', async () => { + mockListener.mockImplementation((req, res) => { + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.send(Buffer.from('hey')); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('text/plain; charset=utf-8'); + expect(await res.text()).toBe('hey'); + }); + + test('should not override ETag', async () => { + mockListener.mockImplementation((req, res) => { + res.setHeader('ETag', '"foo"'); + res.send(Buffer.from('hey')); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('ETag')).toBe('"foo"'); + expect(await res.text()).toBe('hey'); + }); + }); + + describe('.send(Object)', () => { + test('should send as application/json', async () => { + mockListener.mockImplementation((req, res) => { + res.send({ name: 'tobi' }); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe( + 'application/json; charset=utf-8' + ); + expect(await res.text()).toBe('{"name":"tobi"}'); + }); + }); + + describe('when the request method is HEAD', () => { + test('should ignore the body', async () => { + mockListener.mockImplementation((req, res) => { + res.send('yay'); + }); + + // TODO: fix this test + // node-fetch is automatically ignoring the body so this test will never fail + const res = await fetchWithProxyReq(url, { method: 'HEAD' }); + expect(res.status).toBe(200); + expect((await res.buffer()).toString()).toBe(''); + }); + }); + + describe('when .statusCode is 204', () => { + test('should strip Content-* fields, Transfer-Encoding field, and body', async () => { + mockListener.mockImplementation((req, res) => { + res.statusCode = 204; + res.setHeader('Transfer-Encoding', 'chunked'); + res.send('foo'); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(204); + expect(res.headers.get('Content-Type')).toBe(null); + expect(res.headers.get('Content-Length')).toBe(null); + expect(res.headers.get('Transfer-Encoding')).toBe(null); + expect(await res.text()).toBe(''); + }); + }); + + describe('when .statusCode is 304', () => { + test('should strip Content-* fields, Transfer-Encoding field, and body', async () => { + mockListener.mockImplementation((req, res) => { + res.statusCode = 304; + res.setHeader('Transfer-Encoding', 'chunked'); + res.send('foo'); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(304); + expect(res.headers.get('Content-Type')).toBe(null); + expect(res.headers.get('Content-Length')).toBe(null); + expect(res.headers.get('Transfer-Encoding')).toBe(null); + expect(await res.text()).toBe(''); + }); + }); + + // test('should always check regardless of length', async () => { + // const etag = '"asdf"'; + + // mockListener.mockImplementation((req, res) => { + // res.setHeader('ETag', etag); + // res.send('hey'); + // }); + + // const res = await fetchWithProxyReq(url, { + // headers: { 'If-None-Match': etag }, + // }); + // expect(res.status).toBe(304); + // }); + + // test('should respond with 304 Not Modified when fresh', async () => { + // const etag = '"asdf"'; + + // mockListener.mockImplementation((req, res) => { + // res.setHeader('ETag', etag); + // res.send(Array(1000).join('-')); + // }); + + // const res = await fetchWithProxyReq(url, { + // headers: { 'If-None-Match': etag }, + // }); + // expect(res.status).toBe(304); + // }); + + // test('should not perform freshness check unless 2xx or 304', async () => { + // const etag = '"asdf"'; + + // mockListener.mockImplementation((req, res) => { + // res.status(500); + // res.setHeader('ETag', etag); + // res.send('hey'); + // }); + + // const res = await fetchWithProxyReq(url, { + // headers: { 'If-None-Match': etag }, + // }); + // expect(res.status).toBe(500); + // expect(await res.text()).toBe('hey'); + // }); + + describe('etag', () => { + test('should send ETag', async () => { + mockListener.mockImplementation((req, res) => { + res.send('kajdslfkasdf'); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('ETag')).toBe('W/"c-IgR/L5SF7CJQff4wxKGF/vfPuZ0"'); + }); + + test('should send ETag for empty string response', async () => { + mockListener.mockImplementation((req, res) => { + res.send(''); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('ETag')).toBe('W/"0-2jmj7l5rSw0yVb/vlWAYkK/YBwk"'); + }); + + test('should send ETag for long response', async () => { + mockListener.mockImplementation((req, res) => { + res.send(Array(1000).join('-')); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('ETag')).toBe( + 'W/"3e7-qPnkJ3CVdVhFJQvUBfF10TmVA7g"' + ); + }); + + test('should not override ETag when manually set', async () => { + mockListener.mockImplementation((req, res) => { + res.setHeader('etag', '"asdf"'); + res.send('hello'); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('ETag')).toBe('"asdf"'); + }); + + test('should not send ETag for res.send()', async () => { + mockListener.mockImplementation((req, res) => { + res.send(); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('ETag')).toBe(null); + }); + }); +}); + +// tests based on expressjs test suite +// see https://github.com/expressjs/express/blob/master/test/res.json.js +describe('res.json', () => { + test('should send be chainable', async () => { + const spy = jest.fn(); + + mockListener.mockImplementation((req, res) => { + spy(res, res.json({ hello: 'world' })); + }); + + await fetchWithProxyReq(url); + + const [a, b] = spy.mock.calls[0]; + expect(a).toBe(b); + }); + + test('res.json() should send an empty body', async () => { + mockListener.mockImplementation((req, res) => { + res.json(); + }); + + await fetchWithProxyReq(url); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe( + 'application/json; charset=utf-8' + ); + expect(await res.text()).toBe(''); + }); + + describe('.json(object)', () => { + test('should not override previous Content-Types', async () => { + mockListener.mockImplementation((req, res) => { + res.setHeader('content-type', 'application/vnd.example+json'); + res.json({ hello: 'world' }); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe( + 'application/vnd.example+json; charset=utf-8' + ); + expect(await res.text()).toBe('{"hello":"world"}'); + }); + + test('should set Content-Length and Content-Type', async () => { + mockListener.mockImplementation((req, res) => { + res.json({ hello: '½ + ¼ = ¾' }); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe( + 'application/json; charset=utf-8' + ); + expect(Number(res.headers.get('content-length'))).toBe(24); + expect(await res.text()).toBe('{"hello":"½ + ¼ = ¾"}'); + }); + + describe('when given primitives', () => { + test('should respond with json for null', async () => { + mockListener.mockImplementation((req, res) => { + res.json(null); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe( + 'application/json; charset=utf-8' + ); + expect(await res.text()).toBe('null'); + }); + + test('should respond with json for Number', async () => { + mockListener.mockImplementation((req, res) => { + res.json(300); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe( + 'application/json; charset=utf-8' + ); + expect(await res.text()).toBe('300'); + }); + + test('should respond with json for String', async () => { + mockListener.mockImplementation((req, res) => { + res.json('str'); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe( + 'application/json; charset=utf-8' + ); + expect(await res.text()).toBe('"str"'); + }); + }); + + test('should respond with json when given an array', async () => { + mockListener.mockImplementation((req, res) => { + res.json(['foo', 'bar', 'baz']); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe( + 'application/json; charset=utf-8' + ); + expect(await res.text()).toBe('["foo","bar","baz"]'); + }); + + test('should respond with json when given an object', async () => { + mockListener.mockImplementation((req, res) => { + res.json({ name: 'tobi' }); + }); + + const res = await fetchWithProxyReq(url); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe( + 'application/json; charset=utf-8' + ); + expect(await res.text()).toBe('{"name":"tobi"}'); + }); + }); +}); diff --git a/packages/node-bridge/types.ts b/packages/node-bridge/types.ts index 299a5962d..cc6419f4d 100644 --- a/packages/node-bridge/types.ts +++ b/packages/node-bridge/types.ts @@ -1,5 +1,11 @@ /// -import { Server, IncomingHttpHeaders, OutgoingHttpHeaders } from 'http'; +import { + Server, + IncomingHttpHeaders, + OutgoingHttpHeaders, + ServerResponse, + IncomingMessage, +} from 'http'; export interface VercelProxyEvent { Action: string; body: string; @@ -37,3 +43,20 @@ export type LauncherConfiguration = { awsLambdaHandler?: string; useRequire?: boolean; }; + +export type VercelRequestCookies = { [key: string]: string }; +export type VercelRequestQuery = { [key: string]: string | string[] }; +export type VercelRequestBody = any; + +export type VercelRequest = IncomingMessage & { + query: VercelRequestQuery; + cookies: VercelRequestCookies; + body: VercelRequestBody; +}; + +export type VercelResponse = ServerResponse & { + send: (body: any) => VercelResponse; + json: (jsonBody: any) => VercelResponse; + status: (statusCode: number) => VercelResponse; + redirect: (statusOrUrl: string | number, url?: string) => VercelResponse; +};