[node] Add streaming support for vc dev (#9745)

Until now, the user code response it's buffered and serialized. This is
mismatching how Vercel works these days.

This PR enables streaming response in `vc dev` for Edge/Serverless.

As part of the implementation, the `node-bridge` which spawns a process
to consume the user code is not necessary anymore.

Some necessary files (like HTTP server helpers) have been moved to live
in node builder package instead.

---------

Co-authored-by: Ethan Arrowood <ethan.arrowood@vercel.com>
Co-authored-by: Sean Massa <EndangeredMassa@gmail.com>
This commit is contained in:
Kiko Beats
2023-04-19 23:56:41 +02:00
committed by GitHub
parent a63b9d960b
commit 6dded87426
42 changed files with 593 additions and 2799 deletions

View File

@@ -26,12 +26,6 @@ packages/hydrogen/edge-entry.js
packages/next/test/integration/middleware
packages/next/test/integration/middleware-eval
# node-bridge
packages/node-bridge/bridge.js
packages/node-bridge/launcher.js
packages/node-bridge/helpers.js
packages/node-bridge/source-map-support.js
# middleware
packages/middleware/src/entries.js

2
.github/CODEOWNERS vendored
View File

@@ -4,7 +4,6 @@
* @TooTallNate @EndangeredMassa @styfle @cb1kenobi @Ethan-Arrowood
/.github/workflows @TooTallNate @EndangeredMassa @styfle @cb1kenobi @Ethan-Arrowood @ijjk
/packages/fs-detectors @TooTallNate @EndangeredMassa @styfle @cb1kenobi @Ethan-Arrowood @agadzik @chloetedder
/packages/node-bridge @TooTallNate @EndangeredMassa @styfle @cb1kenobi @Ethan-Arrowood @ijjk
/packages/next @TooTallNate @EndangeredMassa @styfle @cb1kenobi @Ethan-Arrowood @ijjk
/packages/routing-utils @TooTallNate @EndangeredMassa @styfle @cb1kenobi @Ethan-Arrowood @ijjk
/packages/edge @vercel/edge-compute
@@ -14,3 +13,4 @@
/examples/hugo @styfle
/examples/jekyll @styfle
/examples/zola @styfle
/packages/node @Kikobeats

View File

@@ -10,7 +10,7 @@ export default async function edge(request, event) {
return new Response(
JSON.stringify({
headerContentType: request.headers.get('content-type'),
headers: Object.fromEntries(request.headers),
url: request.url,
method: request.method,
body: requestBody,

View File

@@ -1,3 +1,19 @@
module.exports = (req, res) => {
res.send(req.body);
const rawBody = stream =>
new Promise((resolve, reject) => {
const chunks = []
let bytes = 0
stream
.on('error', reject)
.on('end', () => resolve(Buffer.concat(chunks, bytes)))
.on('data', chunk => {
chunks.push(chunk)
bytes += chunk.length
})
})
module.exports = async (req, res) => {
res.json({
body: req.body,
readBody: JSON.parse((await rawBody(req)).toString())
})
};

View File

@@ -0,0 +1,7 @@
module.exports = (req, res) => {
res.json({
url: req.url,
method: req.method,
headers: req.headers,
})
}

View File

@@ -0,0 +1,3 @@
{
"private": true
}

View File

@@ -15,6 +15,22 @@ const {
validateResponseHeaders,
} = require('./utils.js');
test('[verdel dev] should support serverless functions', async () => {
const dir = fixture('serverless-function');
const { dev, port, readyResolver } = await testFixture(dir, {});
try {
await readyResolver;
let res = await fetch(`http://localhost:${port}/api?foo=bar`);
validateResponseHeaders(res);
const payload = await res.json();
expect(payload).toMatchObject({ url: '/api?foo=bar', method: 'GET' });
expect(payload.headers.host).toBe(payload.headers['x-forwarded-host']);
} finally {
await dev.kill();
}
});
test('[vercel dev] should support edge functions', async () => {
const dir = fixture('edge-function');
const { dev, port, readyResolver } = await testFixture(dir, {
@@ -39,8 +55,9 @@ test('[vercel dev] should support edge functions', async () => {
// support for edge functions has to manually ensure that these properties
// are set up; so, we test that they are all passed through properly
expect(await res.json()).toMatchObject({
headerContentType: 'application/json',
const payload = await res.json();
expect(payload).toMatchObject({
headers: { 'content-type': 'application/json' },
url: `http://localhost:${port}/api/edge-success`,
method: 'POST',
body: '{"hello":"world"}',
@@ -49,6 +66,7 @@ test('[vercel dev] should support edge functions', async () => {
optionalChaining: 'fallback',
ENV_VAR_IN_EDGE: '1',
});
expect(payload.headers.host).toBe(payload.headers['x-forwarded-host']);
} finally {
await dev.kill();
}
@@ -364,7 +382,7 @@ test('[vercel dev] should support request body', async () => {
body: JSON.stringify(body),
});
validateResponseHeaders(res);
expect(await res.json()).toMatchObject(body);
expect(await res.json()).toMatchObject({ body, readBody: body });
// Test that `req` "data" events work in dev
res = await fetch(`http://localhost:${port}/api/data-events`, {

View File

@@ -1,2 +0,0 @@
/helpers.js
/source-map-support.js

View File

@@ -1,18 +0,0 @@
/// <reference types="node" />
import { Server } from 'http';
import {
VercelProxyRequest,
VercelProxyResponse,
VercelProxyEvent,
ServerLike,
} from './types';
export declare class Bridge {
constructor(server?: ServerLike, shouldStoreEvents?: boolean);
setServer(server: ServerLike): void;
setStoreEvents(shouldStoreEvents: boolean): void;
listen(): void | Server;
launcher(event: VercelProxyEvent, context: any): Promise<VercelProxyResponse>;
consumeEvent(reqId: string): VercelProxyRequest;
}
export {};

View File

@@ -1,481 +0,0 @@
const { URL } = require('url');
const { request } = require('http');
const { Socket } = require('net');
const { createCipheriv } = require('crypto');
const { pipeline, Transform } = require('stream');
const CRLF = `\r\n`;
/**
* If the `http.Server` handler function throws an error asynchronously,
* then it ends up being an unhandled rejection which doesn't kill the node
* process which causes the HTTP request to hang indefinitely. So print the
* error here and force the process to exit so that the lambda invocation
* returns an Unhandled error quickly.
*/
process.on('unhandledRejection', err => {
console.error('Unhandled rejection:', err);
process.exit(1);
});
/**
* @param {import('./types').VercelProxyEvent} event
*/
function normalizeProxyEvent(event) {
let bodyBuffer;
/**
* @type {import('./types').VercelProxyRequest}
*/
const payload = JSON.parse(event.body);
const {
method,
path,
headers,
encoding,
body,
payloads,
responseCallbackCipher,
responseCallbackCipherIV,
responseCallbackCipherKey,
responseCallbackStream,
responseCallbackUrl,
features,
} = payload;
/**
*
* @param {string | Buffer} body
* @returns Buffer
*/
const normalizeBody = body => {
if (body) {
if (typeof body === 'string' && encoding === 'base64') {
bodyBuffer = Buffer.from(body, encoding);
} else if (encoding === undefined) {
bodyBuffer = Buffer.from(body);
} else {
throw new Error(`Unsupported encoding: ${encoding}`);
}
} else {
bodyBuffer = Buffer.alloc(0);
}
return bodyBuffer;
};
if (payloads) {
for (const targetPayload of payloads) {
targetPayload.features = features;
targetPayload.body = normalizeBody(payload.body);
}
}
bodyBuffer = normalizeBody(body);
return {
isApiGateway: false,
method,
path,
headers,
body: bodyBuffer,
payloads,
features,
responseCallbackCipher,
responseCallbackCipherIV,
responseCallbackCipherKey,
responseCallbackStream,
responseCallbackUrl,
};
}
/**
* @param {import('./types').VercelProxyEvent } event
* @return {import('./types').VercelProxyRequest }
*/
function normalizeEvent(event) {
if (event.Action === 'Invoke') return normalizeProxyEvent(event);
throw new Error(`Unexpected event.Action: ${event.Action}`);
}
class Bridge {
/**
* @param {import('./types').ServerLike | null} server
* @param {boolean} shouldStoreEvents
*/
constructor(server = null, shouldStoreEvents = false) {
this.server = server;
this.shouldStoreEvents = shouldStoreEvents;
this.launcher = this.launcher.bind(this);
this.reqIdSeed = 1;
/**
* @type {{ [key: string]: import('./types').VercelProxyRequest }}
*/
this.events = {};
this.listening = new Promise(resolve => {
this.resolveListening = resolve;
});
}
/**
* @param {import('./types').ServerLike} server
*/
setServer(server) {
this.server = server;
}
/**
* @param {boolean} shouldStoreEvents
*/
setStoreEvents(shouldStoreEvents) {
this.shouldStoreEvents = shouldStoreEvents;
}
listen() {
const { server, resolveListening } = this;
if (!server) {
throw new Error('Server has not been set!');
}
if (typeof server.timeout === 'number' && server.timeout > 0) {
// Disable timeout (usually 2 minutes until Node 13).
// Instead, user should assign function `maxDuration`.
server.timeout = 0;
}
return server.listen(
{
host: '127.0.0.1',
port: 0,
},
function listeningCallback() {
if (!this || typeof this.address !== 'function') {
throw new Error(
'Missing server.address() function on `this` in server.listen()'
);
}
const addr = this.address();
if (!addr) {
throw new Error('`server.address()` returned `null`');
}
if (typeof addr === 'string') {
throw new Error(
`Unexpected string for \`server.address()\`: ${addr}`
);
}
resolveListening(addr);
}
);
}
/**
*
* @param {import('./types').VercelProxyEvent} event
* @param {import('aws-lambda').Context} context
* @return {Promise<import('./types').VercelProxyResponse>}
*/
async launcher(event, context) {
context.callbackWaitsForEmptyEventLoop = false;
const normalizedEvent = normalizeEvent(event);
if (
'payloads' in normalizedEvent &&
Array.isArray(normalizedEvent.payloads)
) {
let statusCode = 200;
/**
* @type {import('http').IncomingHttpHeaders}
*/
let headers = {};
/**
* @type {string}
*/
let combinedBody = '';
const multipartBoundary = 'payload-separator';
const CLRF = '\r\n';
/**
* @type {Record<string, any>[]}
*/
const separateHeaders = [];
/**
* @type {Set<string>}
*/
const allHeaderKeys = new Set();
// we execute the payloads one at a time to ensure
// lambda semantics
for (let i = 0; i < normalizedEvent.payloads.length; i++) {
const currentPayload = normalizedEvent.payloads[i];
const response = await this.handleEvent(currentPayload);
// build a combined body using multipart
// https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html
combinedBody += `--${multipartBoundary}${CLRF}`;
combinedBody += `content-type: ${
response.headers['content-type'] || 'text/plain'
}${CLRF}${CLRF}`;
combinedBody += response.body || '';
combinedBody += CLRF;
if (i === normalizedEvent.payloads.length - 1) {
combinedBody += `--${multipartBoundary}--${CLRF}`;
}
// pass non-200 status code in header so it can be handled
// separately from other payloads e.g. HTML payload redirects
// (307) but data payload does not (200)
if (response.statusCode !== 200) {
headers[`x-vercel-payload-${i + 1}-status`] =
response.statusCode + '';
}
separateHeaders.push(response.headers);
Object.keys(response.headers).forEach(key => allHeaderKeys.add(key));
}
allHeaderKeys.forEach(curKey => {
/**
* @type string | string[] | undefined
*/
const curValue = separateHeaders[0] && separateHeaders[0][curKey];
const canDedupe = separateHeaders.every(
headers => headers[curKey] === curValue
);
if (canDedupe) {
headers[curKey] = curValue;
} else {
// if a header is unique per payload ensure it is prefixed
// so it can be parsed and provided separately
separateHeaders.forEach((curHeaders, idx) => {
if (curHeaders[curKey]) {
headers[`x-vercel-payload-${idx + 1}-${curKey}`] =
curHeaders[curKey];
}
});
}
});
headers[
'content-type'
] = `multipart/mixed; boundary="${multipartBoundary}"`;
return {
headers,
statusCode,
body: combinedBody,
encoding: 'base64',
};
} else {
// TODO We expect this to error as it is possible to resolve to empty.
// For now it is not very important as we will only pass
// `responseCallbackUrl` in production.
// @ts-ignore
return this.handleEvent(normalizedEvent);
}
}
/**
*
* @param {ReturnType<typeof normalizeEvent>} normalizedEvent
* @return {Promise<import('./types').VercelProxyResponse | import('./types').VercelStreamProxyResponse>}
*/
async handleEvent(normalizedEvent) {
const { port } = await this.listening;
const {
body,
headers,
isApiGateway,
method,
responseCallbackCipher,
responseCallbackCipherIV,
responseCallbackCipherKey,
responseCallbackStream,
responseCallbackUrl,
} = normalizedEvent;
let { path } = normalizedEvent;
if (this.shouldStoreEvents) {
const reqId = `${this.reqIdSeed++}`;
this.events[reqId] = normalizedEvent;
headers['x-now-bridge-request-id'] = reqId;
}
return new Promise((resolve, reject) => {
let socket;
let cipher;
let url;
if (responseCallbackUrl) {
socket = new Socket();
url = new URL(responseCallbackUrl);
socket.connect(parseInt(url.port, 10), url.hostname);
socket.write(`${responseCallbackStream}${CRLF}`);
}
if (
responseCallbackCipher &&
responseCallbackCipherKey &&
responseCallbackCipherIV
) {
cipher = createCipheriv(
responseCallbackCipher,
Buffer.from(responseCallbackCipherKey, 'base64'),
Buffer.from(responseCallbackCipherIV, 'base64')
);
}
// if the path is improperly encoded we need to encode it or
// http.request will throw an error (related check: https://github.com/nodejs/node/blob/4ece669c6205ec78abfdadfe78869bbb8411463e/lib/_http_client.js#L84)
if (path && /[^\u0021-\u00ff]/.test(path)) {
path = encodeURI(path);
}
const req = request(
{ hostname: '127.0.0.1', port, path, method },
socket && url && cipher
? getStreamResponseCallback({ url, socket, cipher, resolve, reject })
: getResponseCallback({ isApiGateway, resolve, reject })
);
req.on('error', error => {
setTimeout(() => {
// this lets express print the true error of why the connection was closed.
// it is probably 'Cannot set headers after they are sent to the client'
reject(error);
}, 2);
});
for (const [name, value] of getHeadersIterator(headers)) {
try {
req.setHeader(name, value);
} catch (/** @type any */ err) {
console.error(`Skipping HTTP request header: "${name}: ${value}"`);
console.error(err.message);
}
}
if (body) req.write(body);
req.end();
});
}
/**
* @param {string} reqId
* @return {import('./types').VercelProxyRequest}
*/
consumeEvent(reqId) {
const event = this.events[reqId];
delete this.events[reqId];
return event;
}
}
/**
* Generates the streaming response callback which writes in the given socket client a raw
* HTTP Request message to later pipe the response body into the socket. It will pass request
* headers namespace and an additional header with the status code. Once everything is
* written it will destroy the socket and resolve to an empty object. If a cipher is given
* it will be used to pipe bytes.
*
* @type {(params: {
* url: import('url').URL,
* socket: import('net').Socket,
* cipher: import('crypto').Cipher
* resolve: (result: (Record<string, never>)) => void,
* reject: (err: Error) => void
* }) => (response: import("http").IncomingMessage) => void}
*/
function getStreamResponseCallback({ url, socket, cipher, resolve, reject }) {
return response => {
const chunked = new Transform();
chunked._transform = function (chunk, _, callback) {
this.push(Buffer.byteLength(chunk).toString(16) + CRLF);
this.push(chunk);
this.push(CRLF);
callback();
};
let headers = `Host: ${url.host}${CRLF}`;
headers += `transfer-encoding: chunked${CRLF}`;
headers += `x-vercel-status-code: ${response.statusCode || 200}${CRLF}`;
for (const [name, value] of getHeadersIterator(response.headers)) {
if (!['connection', 'transfer-encoding'].includes(name)) {
if (typeof value === 'string') {
headers += `x-vercel-header-${name}: ${value}${CRLF}`;
} else {
for (const val of value) {
headers += `x-vercel-header-${name}: ${val}${CRLF}`;
}
}
}
}
cipher.write(`POST ${url.pathname} HTTP/1.1${CRLF}${headers}${CRLF}`);
pipeline(response, chunked, cipher, socket, err => {
if (err) return reject(err);
resolve({});
});
};
}
/**
* Generates the normal response callback which waits until the body is fully
* received before resolving the promise. It caches the entire body and resolve
* with an object that describes the response.
*
* @type {(params: {
* isApiGateway: boolean,
* resolve: (result: (import('./types').VercelProxyResponse)) => void,
* reject: (err: Error) => void
* }) => (response: import("http").IncomingMessage) => void}
*/
function getResponseCallback({ isApiGateway, resolve, reject }) {
return response => {
/**
* @type {Buffer[]}
*/
const respBodyChunks = [];
response.on('data', chunk => respBodyChunks.push(Buffer.from(chunk)));
response.on('error', reject);
response.on('end', () => {
const bodyBuffer = Buffer.concat(respBodyChunks);
delete response.headers.connection;
if (isApiGateway) {
delete response.headers['content-length'];
} else if (response.headers['content-length']) {
response.headers['content-length'] = String(bodyBuffer.length);
}
resolve({
statusCode: response.statusCode || 200,
headers: response.headers,
body: bodyBuffer.toString('base64'),
encoding: 'base64',
});
});
};
}
/**
* Get an iterator for the headers object and yield the name and value when
* the value is not undefined only.
*
* @type {(headers: import('http').IncomingHttpHeaders) =>
* Generator<[string, string | string[]], void, unknown>}
*/
function* getHeadersIterator(headers) {
for (const [name, value] of Object.entries(headers)) {
if (value === undefined) {
console.error(
`Skipping HTTP request header "${name}" because value is undefined`
);
continue;
}
yield [name, value];
}
}
module.exports = { Bridge };

View File

@@ -1,62 +0,0 @@
#!/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);
});

View File

@@ -1,3 +0,0 @@
const path = require('path');
module.exports = path.join(__dirname, 'bridge.js');

View File

@@ -1,16 +0,0 @@
import { Bridge } from './bridge';
import { LauncherConfiguration } from './types';
export declare function makeVercelLauncher(
config: LauncherConfiguration
): string;
export declare function getVercelLauncher({
entrypointPath,
helpersPath,
shouldAddHelpers,
}: LauncherConfiguration): () => Bridge;
export declare function makeAwsLauncher(config: LauncherConfiguration): string;
export declare function getAwsLauncher({
entrypointPath,
awsLambdaHandler,
}: LauncherConfiguration): (e: any, context: any, callback: any) => any;
export {};

View File

@@ -1,199 +0,0 @@
const { parse, pathToFileURL } = require('url');
const { createServer, Server } = require('http');
const { isAbsolute } = require('path');
const { Bridge } = require('./bridge.js');
/**
* @param {import('./types').LauncherConfiguration} config
*/
function makeVercelLauncher(config) {
const {
entrypointPath,
bridgePath,
helpersPath,
sourcemapSupportPath,
shouldAddHelpers = false,
shouldAddSourcemapSupport = false,
} = config;
return `
const { parse, pathToFileURL } = require('url');
const { createServer, Server } = require('http');
const { isAbsolute } = require('path');
const { Bridge } = require(${JSON.stringify(bridgePath)});
${
shouldAddSourcemapSupport
? `require(${JSON.stringify(sourcemapSupportPath)});`
: ''
}
const entrypointPath = ${JSON.stringify(entrypointPath)};
const shouldAddHelpers = ${JSON.stringify(shouldAddHelpers)};
const helpersPath = ${JSON.stringify(helpersPath)};
const useRequire = false;
const func = (${getVercelLauncher(config).toString()})();
exports.launcher = func.launcher;`;
}
/**
* @param {import('./types').LauncherConfiguration} config
*/
function getVercelLauncher({
entrypointPath,
helpersPath,
shouldAddHelpers = false,
useRequire = false,
}) {
return function () {
const bridge = new Bridge();
let isServerListening = false;
const originalListen = Server.prototype.listen;
Server.prototype.listen = function listen() {
isServerListening = true;
console.log('Legacy server listening...');
bridge.setServer(this);
Server.prototype.listen = originalListen;
bridge.listen();
return this;
};
if (!process.env.NODE_ENV) {
const region = process.env.VERCEL_REGION || process.env.NOW_REGION;
process.env.NODE_ENV = region === 'dev1' ? 'development' : 'production';
}
/**
* @param {string} p - entrypointPath
*/
async function getListener(p) {
let listener = useRequire
? require(p)
: await import(isAbsolute(p) ? pathToFileURL(p).href : p);
// In some cases we might have nested default props due to TS => JS
for (let i = 0; i < 5; i++) {
if (listener.default) listener = listener.default;
}
return listener;
}
getListener(entrypointPath)
.then(listener => {
if (typeof listener.listen === 'function') {
Server.prototype.listen = originalListen;
const server = listener;
bridge.setServer(server);
bridge.listen();
} else if (typeof listener === 'function') {
Server.prototype.listen = originalListen;
if (shouldAddHelpers) {
bridge.setStoreEvents(true);
import(helpersPath).then(helper => {
const h = helper.default || helper;
const server = h.createServerWithHelpers(listener, bridge);
bridge.setServer(server);
bridge.listen();
});
} else {
const server = createServer(listener);
bridge.setServer(server);
bridge.listen();
}
} else if (
typeof listener === 'object' &&
Object.keys(listener).length === 0
) {
setTimeout(() => {
if (!isServerListening) {
console.error('No exports found in module %j.', entrypointPath);
console.error('Did you forget to export a function or a server?');
process.exit(1);
}
}, 5000);
} else {
console.error('Invalid export found in module %j.', entrypointPath);
console.error('The default export must be a function or server.');
}
})
.catch(err => {
if (err.code === 'MODULE_NOT_FOUND') {
console.error(err.message);
console.error(
'Did you forget to add it to "dependencies" in `package.json`?'
);
} else {
console.error(err);
}
process.exit(1);
});
return bridge;
};
}
/**
* @param {import('./types').LauncherConfiguration} config
*/
function makeAwsLauncher(config) {
const { entrypointPath, awsLambdaHandler = '' } = config;
return `const { parse } = require("url");
const funcName = ${JSON.stringify(awsLambdaHandler.split('.').pop())};
const entrypointPath = ${JSON.stringify(entrypointPath)};
exports.launcher = ${getAwsLauncher(config).toString()}`;
}
/**
* @param {import('./types').LauncherConfiguration} config
*/
function getAwsLauncher({ entrypointPath, awsLambdaHandler = '' }) {
const funcName = awsLambdaHandler.split('.').pop() || '';
if (typeof funcName !== 'string') {
throw new TypeError('Expected "string"');
}
/**
* @param {import('aws-lambda').APIGatewayProxyEvent} event
* @param {import('aws-lambda').Context} context
* @param {() => void} callback
*/
function internal(event, context, callback) {
const {
path,
method: httpMethod,
body,
headers,
} = JSON.parse(event.body || '{}');
const { query } = parse(path, true);
/**
* @type {{[key: string]: string}}
*/
const queryStringParameters = {};
for (const [key, value] of Object.entries(query)) {
if (typeof value === 'string') {
queryStringParameters[key] = value;
}
}
const awsGatewayEvent = {
resource: '/{proxy+}',
path: path,
httpMethod: httpMethod,
body: body,
isBase64Encoded: true,
queryStringParameters: queryStringParameters,
multiValueQueryStringParameters: query,
headers: headers,
};
const mod = require(entrypointPath);
return mod[funcName](awsGatewayEvent, context, callback);
}
return internal;
}
module.exports = {
makeVercelLauncher,
getVercelLauncher,
makeAwsLauncher,
getAwsLauncher,
};

View File

@@ -1,35 +0,0 @@
{
"name": "@vercel/node-bridge",
"version": "4.0.1",
"license": "Apache-2.0",
"main": "./index.js",
"repository": {
"type": "git",
"url": "https://github.com/vercel/vercel.git",
"directory": "packages/node-bridge"
},
"files": [
"bridge.*",
"launcher.*",
"index.js",
"helpers.js",
"source-map-support.js"
],
"scripts": {
"build": "node build.js",
"test": "jest --env node --verbose --runInBand --bail",
"test-unit": "pnpm test"
},
"devDependencies": {
"@types/aws-lambda": "8.10.19",
"@types/node": "14.18.33",
"content-type": "1.0.4",
"cookie": "0.4.0",
"etag": "1.8.1",
"execa": "3.2.0",
"fs-extra": "10.0.0",
"jsonlines": "0.1.1",
"test-listen": "1.1.0",
"typescript": "4.3.4"
}
}

View File

@@ -1,467 +0,0 @@
const assert = require('assert');
const crypto = require('crypto');
const jsonlines = require('jsonlines');
const { Server } = require('http');
const { Bridge } = require('../bridge');
const { runServer } = require('./run-test-server');
const { runTcpServer } = require('./run-test-server');
test('port binding', async () => {
const server = new Server();
const bridge = new Bridge(server);
bridge.listen();
// Test port binding
const info = await bridge.listening;
assert.strictEqual(info.address, '127.0.0.1');
assert.strictEqual(typeof info.port, 'number');
server.close();
});
test('`NowProxyEvent` normalizing', async () => {
const server = new Server((req, res) =>
res.end(
JSON.stringify({
method: req.method,
path: req.url,
headers: req.headers,
})
)
);
let features;
class CustomBridge extends Bridge {
handleEvent(normalizedEvent) {
features = normalizedEvent.features;
return super.handleEvent(normalizedEvent);
}
}
const bridge = new CustomBridge(server);
bridge.listen();
const context = { callbackWaitsForEmptyEventLoop: true };
const result = await bridge.launcher(
{
Action: 'Invoke',
body: JSON.stringify({
method: 'POST',
headers: { foo: 'baz' },
features: { enabled: true },
path: '/nowproxy',
body: 'body=1',
}),
},
context
);
assert.deepStrictEqual(features, { enabled: true });
assert.strictEqual(result.encoding, 'base64');
assert.strictEqual(result.statusCode, 200);
const body = JSON.parse(Buffer.from(result.body, 'base64').toString());
assert.strictEqual(body.method, 'POST');
assert.strictEqual(body.path, '/nowproxy');
assert.strictEqual(body.headers.foo, 'baz');
assert.strictEqual(context.callbackWaitsForEmptyEventLoop, false);
server.close();
});
test('multi-payload handling', async () => {
const server = new Server((req, res) => {
if (req.url === '/redirect') {
res.setHeader('Location', '/somewhere');
res.statusCode = 307;
res.end('/somewhere');
return;
}
res.setHeader(
'content-type',
req.url.includes('_next/data') ? 'application/json' : 'text/html'
);
res.end(
JSON.stringify({
method: req.method,
path: req.url,
headers: req.headers,
})
);
});
const bridge = new Bridge(server);
bridge.listen();
const context = { callbackWaitsForEmptyEventLoop: true };
const result = await bridge.launcher(
{
Action: 'Invoke',
body: JSON.stringify({
payloads: [
{
method: 'GET',
headers: { foo: 'baz' },
path: '/nowproxy',
},
{
method: 'GET',
headers: { foo: 'baz' },
path: '/_next/data/build-id/nowproxy.json',
},
{
method: 'GET',
headers: { foo: 'baz' },
path: '/redirect',
},
],
}),
},
context
);
assert.strictEqual(result.encoding, 'base64');
assert.strictEqual(result.statusCode, 200);
assert.strictEqual(
result.headers['content-type'],
'multipart/mixed; boundary="payload-separator"'
);
const bodies = [];
const payloadParts = result.body.split('\r\n');
payloadParts.forEach(item => {
if (
item.trim() &&
!item.startsWith('content-type:') &&
!item.startsWith('--payload')
) {
const content = Buffer.from(
item.split('--payload-separator')[0],
'base64'
).toString();
bodies.push(content.startsWith('{') ? JSON.parse(content) : content);
}
});
// ensure content-type is always specified as is required for
// proper parsing of the multipart body
assert(payloadParts.some(part => part.includes('content-type: text/plain')));
assert.strictEqual(bodies[0].method, 'GET');
assert.strictEqual(bodies[0].path, '/nowproxy');
assert.strictEqual(bodies[0].headers.foo, 'baz');
assert.strictEqual(bodies[1].method, 'GET');
assert.strictEqual(bodies[1].path, '/_next/data/build-id/nowproxy.json');
assert.strictEqual(bodies[1].headers.foo, 'baz');
assert.strictEqual(bodies[2], '/somewhere');
assert.strictEqual(result.headers['x-vercel-payload-3-status'], '307');
assert.strictEqual(result.headers['x-vercel-payload-2-status'], undefined);
assert.strictEqual(result.headers['x-vercel-payload-1-status'], undefined);
assert.strictEqual(
result.headers['x-vercel-payload-1-content-type'],
'text/html'
);
assert.strictEqual(
result.headers['x-vercel-payload-2-content-type'],
'application/json'
);
assert.strictEqual(
result.headers['x-vercel-payload-3-content-type'],
undefined
);
assert.strictEqual(
result.headers['x-vercel-payload-3-location'],
'/somewhere'
);
assert.strictEqual(result.headers['x-vercel-payload-2-location'], undefined);
assert.strictEqual(context.callbackWaitsForEmptyEventLoop, false);
server.close();
});
test('consumeEvent', async () => {
const mockListener = jest.fn((_, res) => {
res.end('hello');
});
const server = new Server(mockListener);
const bridge = new Bridge(server, true);
bridge.listen();
const context = { callbackWaitsForEmptyEventLoop: true };
await bridge.launcher(
{
Action: 'Invoke',
body: JSON.stringify({
method: 'POST',
headers: { foo: 'baz' },
path: '/nowproxy',
body: 'body=1',
}),
},
context
);
const headers = mockListener.mock.calls[0][0].headers;
const reqId = headers['x-now-bridge-request-id'];
expect(reqId).toBeTruthy();
const event = bridge.consumeEvent(reqId);
expect(event.body.toString()).toBe('body=1');
// an event can't be consumed multiple times
// to avoid memory leaks
expect(bridge.consumeEvent(reqId)).toBeUndefined();
server.close();
});
test('consumeEvent and handle decoded path', async () => {
const mockListener = jest.fn((_, res) => {
res.end('hello');
});
const server = new Server(mockListener);
const bridge = new Bridge(server, true);
bridge.listen();
const context = { callbackWaitsForEmptyEventLoop: true };
await bridge.launcher(
{
Action: 'Invoke',
body: JSON.stringify({
method: 'POST',
headers: { foo: 'baz' },
path: '/now proxy',
body: 'body=1',
}),
},
context
);
const headers = mockListener.mock.calls[0][0].headers;
const reqId = headers['x-now-bridge-request-id'];
expect(reqId).toBeTruthy();
const event = bridge.consumeEvent(reqId);
expect(event.body.toString()).toBe('body=1');
// an event can't be consumed multiple times
// to avoid memory leaks
expect(bridge.consumeEvent(reqId)).toBeUndefined();
server.close();
});
test('invalid request headers', async () => {
const server = new Server((req, res) =>
res.end(
JSON.stringify({
method: req.method,
path: req.url,
headers: req.headers,
})
)
);
const bridge = new Bridge(server);
bridge.listen();
const context = { callbackWaitsForEmptyEventLoop: true };
const result = await bridge.launcher(
{
Action: 'Invoke',
body: JSON.stringify({
method: 'GET',
headers: { foo: 'baz\n', ok: 'true' },
path: '/nowproxy',
body: 'body=1',
}),
},
context
);
assert.strictEqual(result.encoding, 'base64');
assert.strictEqual(result.statusCode, 200);
const body = JSON.parse(Buffer.from(result.body, 'base64').toString());
assert.strictEqual(body.method, 'GET');
assert.strictEqual(body.path, '/nowproxy');
assert.strictEqual(body.headers.ok, 'true');
assert(!body.headers.foo);
assert.strictEqual(context.callbackWaitsForEmptyEventLoop, false);
server.close();
});
test('`NowProxyEvent` proxy streaming with a sync handler', async () => {
const cipherParams = {
cipher: 'aes-256-ctr',
cipherIV: crypto.randomBytes(16),
cipherKey: crypto.randomBytes(32),
};
const effects = {
callbackPayload: undefined,
callbackStream: undefined,
};
const { deferred, resolve } = createDeferred();
const httpServer = await runServer({
handler: (req, res) => {
const chunks = [];
req.on('data', chunk => {
chunks.push(chunk.toString());
});
req.on('close', () => {
effects.callbackPayload = chunks;
res.writeHead(200, 'OK', { 'content-type': 'application/json' });
res.end();
resolve();
});
},
});
const tcpServerCallback = await runTcpServer({
cipherParams,
effects,
httpServer,
});
const server = new Server((req, res) => {
res.setHeader('content-type', 'text/html');
res.end('hello');
});
const bridge = new Bridge(server);
bridge.listen();
const context = { callbackWaitsForEmptyEventLoop: true };
const result = await bridge.launcher(
{
Action: 'Invoke',
body: JSON.stringify({
method: 'POST',
responseCallbackCipher: cipherParams.cipher,
responseCallbackCipherIV: cipherParams.cipherIV.toString('base64'),
responseCallbackCipherKey: cipherParams.cipherKey.toString('base64'),
responseCallbackStream: 'abc',
responseCallbackUrl: String(tcpServerCallback.url),
headers: { foo: 'bar' },
path: '/nowproxy',
body: 'body=1',
}),
},
context
);
await deferred;
expect(result).toEqual({});
expect(context.callbackWaitsForEmptyEventLoop).toEqual(false);
expect(effects.callbackStream).toEqual('abc');
expect(effects.callbackPayload).toEqual(['hello']);
server.close();
await httpServer.close();
await tcpServerCallback.close();
});
test('`NowProxyEvent` proxy streaming with an async handler', async () => {
const effects = {
callbackHeaders: undefined,
callbackMethod: undefined,
callbackPayload: undefined,
callbackStream: undefined,
};
const cipherParams = {
cipher: 'aes-256-ctr',
cipherIV: crypto.randomBytes(16),
cipherKey: crypto.randomBytes(32),
};
const { deferred, resolve } = createDeferred();
const jsonParser = jsonlines.parse();
const httpServer = await runServer({
handler: (req, res) => {
const chunks = [];
req.pipe(jsonParser);
jsonParser.on('data', chunk => {
chunks.push(chunk);
});
req.on('close', () => {
effects.callbackMethod = req.method;
effects.callbackHeaders = req.headers;
effects.callbackPayload = chunks;
res.writeHead(200, 'OK', { 'content-type': 'application/json' });
res.end();
resolve();
});
},
});
const tcpServerCallback = await runTcpServer({
cipherParams,
httpServer,
effects,
});
const jsonStringifier = jsonlines.stringify();
const server = new Server((req, res) => {
res.setHeader('x-test', 'hello');
res.setHeader('content-type', 'text/html');
jsonStringifier.pipe(res);
jsonStringifier.write({ method: req.method });
jsonStringifier.write({ path: req.url });
setTimeout(() => {
jsonStringifier.write({ headers: req.headers });
res.end();
}, 100);
});
const bridge = new Bridge(server);
bridge.listen();
const context = { callbackWaitsForEmptyEventLoop: true };
const result = await bridge.launcher(
{
Action: 'Invoke',
body: JSON.stringify({
method: 'POST',
responseCallbackCipher: cipherParams.cipher,
responseCallbackCipherIV: cipherParams.cipherIV.toString('base64'),
responseCallbackCipherKey: cipherParams.cipherKey.toString('base64'),
responseCallbackStream: 'abc',
responseCallbackUrl: String(tcpServerCallback.url),
headers: { foo: 'bar' },
path: '/nowproxy',
body: 'body=1',
}),
},
context
);
await deferred;
expect(result).toEqual({});
expect(context.callbackWaitsForEmptyEventLoop).toEqual(false);
expect(effects.callbackStream).toEqual('abc');
expect(effects.callbackMethod).toEqual('POST');
expect(effects.callbackHeaders).toMatchObject({
'x-vercel-status-code': '200',
'x-vercel-header-x-test': 'hello',
'x-vercel-header-content-type': 'text/html',
});
expect(effects.callbackPayload).toMatchObject([
{ method: 'POST' },
{ path: '/nowproxy' },
{ headers: { foo: 'bar' } },
]);
server.close();
httpServer.close();
tcpServerCallback.close();
});
function createDeferred() {
let resolve;
const deferred = new Promise(_resolve => {
resolve = _resolve;
});
return { deferred, resolve };
}

View File

@@ -1,842 +0,0 @@
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('<p>hey</p>');
});
const res = await fetchWithProxyReq(url);
expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8');
expect(await res.text()).toBe('<p>hey</p>');
});
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"}');
});
});
});

View File

@@ -1,78 +0,0 @@
const { createServer } = require('net');
const { Server } = require('http');
const { Socket } = require('net');
const { URL } = require('url');
const crypto = require('crypto');
const listen = require('test-listen');
exports.runServer = async function runServer({ handler }) {
const server = new Server(handler);
const url = await listen(server);
return { url: new URL(url), close: getKillServer(server) };
};
function getKillServer(server) {
let sockets = [];
server.on('connection', socket => {
sockets.push(socket);
socket.once('close', () => {
sockets.splice(sockets.indexOf(socket), 1);
});
});
return () => {
return new Promise((resolve, reject) => {
server.close(err => {
if (err) {
return reject(err);
}
resolve();
});
sockets.forEach(function (socket) {
socket.destroy();
});
sockets = [];
});
};
}
exports.runTcpServer = async function runTcpServer({
effects,
httpServer,
cipherParams,
}) {
const server = createServer();
server.on('connection', connection => {
const socket = new Socket();
socket.connect(parseInt(httpServer.url.port, 10), httpServer.hostname);
const decipher = crypto.createDecipheriv(
cipherParams.cipher,
cipherParams.cipherKey,
cipherParams.cipherIV
);
decipher.pipe(socket);
const CRLF = Buffer.from('\r\n');
let accBuffer = Buffer.from([]);
connection.on('data', function onConnectionData(chunk) {
accBuffer = Buffer.concat([accBuffer, chunk]);
const idx = accBuffer.indexOf(CRLF);
if (idx !== -1) {
effects.callbackStream = accBuffer.slice(0, idx).toString();
connection.off('data', onConnectionData);
decipher.write(accBuffer.slice(idx + 2));
connection.pipe(decipher);
decipher.on('close', () => {
socket.end();
});
}
});
});
const url = await listen(server);
return { url: new URL(url), close: getKillServer(server) };
};

View File

@@ -1,16 +0,0 @@
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"lib": ["ES2020"],
"noEmit": true,
"noImplicitReturns": true,
"strict": true,
"target": "ES2020",
"declaration": true,
"module": "commonjs",
"skipLibCheck": true
},
"include": ["helpers.ts", "bridge.js", "launcher.js"],
"exclude": ["node_modules"]
}

View File

@@ -1,9 +0,0 @@
{
"$schema": "https://turborepo.org/schema.json",
"extends": ["//"],
"pipeline": {
"build": {
"outputs": ["helpers.js", "source-map-support.js"]
}
}
}

View File

@@ -1,72 +0,0 @@
/// <reference types="node" />
import type { CipherCCMTypes } from 'crypto';
import type {
Server,
IncomingHttpHeaders,
OutgoingHttpHeaders,
ServerResponse,
IncomingMessage,
} from 'http';
export interface VercelProxyEvent {
Action: string;
body: string;
}
export interface VercelProxyRequest {
isApiGateway: boolean;
method: string;
path: string;
headers: IncomingHttpHeaders;
body: Buffer;
encoding?: string;
payloads?: Array<VercelProxyRequest>;
features?: Record<string, boolean>;
responseCallbackCipher?: CipherCCMTypes;
responseCallbackCipherIV?: string;
responseCallbackCipherKey?: string;
responseCallbackStream?: string;
responseCallbackUrl?: string;
}
export interface VercelProxyResponse {
statusCode: number;
headers: OutgoingHttpHeaders;
body: string;
encoding: BufferEncoding;
}
export type VercelStreamProxyResponse = Record<string, never>;
export interface ServerLike {
timeout?: number;
listen: (
opts: {
host?: string;
port?: number;
},
callback: (this: Server | null) => void
) => Server | void;
}
export type LauncherConfiguration = {
entrypointPath: string;
bridgePath: string;
helpersPath: string;
sourcemapSupportPath: string;
shouldAddHelpers?: boolean;
shouldAddSourcemapSupport?: boolean;
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;
};

View File

@@ -10,10 +10,6 @@ const setupFiles = async (entrypoint, shouldAddHelpers) => {
join(__dirname, '../dist/helpers.js'),
join(__dirname, 'lambda/helpers.js')
);
await fs.copyFile(
require.resolve('@vercel/node-bridge/bridge'),
join(__dirname, 'lambda/bridge.js')
);
await fs.copyFile(
join(process.cwd(), entrypoint),
join(__dirname, 'lambda/entrypoint.js')

View File

@@ -3,13 +3,6 @@ const fs = require('fs-extra');
const execa = require('execa');
const { join } = require('path');
async function copyToDist(sourcePath, outDir) {
return fs.copyFile(
join(__dirname, sourcePath),
join(outDir, 'edge-functions/edge-handler-template.js')
);
}
async function main() {
const srcDir = join(__dirname, 'src');
const outDir = join(__dirname, 'dist');
@@ -54,7 +47,15 @@ async function main() {
join(__dirname, 'test/fixtures/15-helpers/ts/types.d.ts')
);
await copyToDist('src/edge-functions/edge-handler-template.js', outDir);
await fs.copyFile(
join(__dirname, 'src/serverless-functions/dynamic-import.js'),
join(outDir, 'serverless-functions/dynamic-import.js')
);
await fs.copyFile(
join(__dirname, 'src/edge-functions/edge-handler-template.js'),
join(outDir, 'edge-functions/edge-handler-template.js')
);
}
main().catch(err => {

View File

@@ -11,7 +11,7 @@
},
"scripts": {
"build": "node build",
"test": "jest --env node --verbose --bail --runInBand",
"test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --env node --verbose --bail --runInBand",
"test-unit": "pnpm test test/unit",
"test-e2e": "pnpm test test/integration"
},
@@ -22,9 +22,11 @@
"@edge-runtime/vm": "2.0.0",
"@types/node": "14.18.33",
"@vercel/build-utils": "6.7.1",
"@vercel/error-utils": "1.0.8",
"@vercel/node-bridge": "4.0.1",
"@vercel/static-config": "2.0.16",
"edge-runtime": "2.0.0",
"async-listen": "1.2.0",
"edge-runtime": "2.1.4",
"esbuild": "0.14.47",
"exit-hook": "2.2.1",
"node-fetch": "2.6.7",
@@ -42,12 +44,13 @@
"@types/cookie": "0.3.3",
"@types/etag": "1.8.0",
"@types/jest": "27.4.1",
"@types/node-fetch": "^2.6.1",
"@types/test-listen": "1.1.0",
"@vercel/ncc": "0.24.0",
"@vercel/nft": "0.22.5",
"content-type": "1.0.4",
"cookie": "0.4.0",
"@types/node-fetch": "^2.6.1",
"cross-env": "7.0.3",
"etag": "1.8.1",
"execa": "3.2.0",
"fs-extra": "11.1.0",

View File

@@ -5,36 +5,24 @@ if (!entrypoint) {
throw new Error('`VERCEL_DEV_ENTRYPOINT` must be defined');
}
delete process.env.TS_NODE_TRANSPILE_ONLY;
delete process.env.TS_NODE_COMPILER_OPTIONS;
import { join } from 'path';
const useRequire = process.env.VERCEL_DEV_IS_ESM !== '1';
import { createServer, Server, IncomingMessage, ServerResponse } from 'http';
import { VercelProxyResponse } from '@vercel/node-bridge/types';
import type { Headers } from 'node-fetch';
import type { VercelProxyResponse } from './types';
import { Config } from '@vercel/build-utils';
import { createEdgeEventHandler } from './edge-functions/edge-handler';
import { createServer, IncomingMessage, ServerResponse } from 'http';
import { createServerlessEventHandler } from './serverless-functions/serverless-handler';
import { EdgeRuntimes, isEdgeRuntime, logError } from './utils';
import { getConfig } from '@vercel/static-config';
import { Project } from 'ts-morph';
import { EdgeRuntimes, isEdgeRuntime, logError } from './utils';
import { createEdgeEventHandler } from './edge-functions/edge-handler';
import { createServerlessEventHandler } from './serverless-functions/serverless-handler';
import listen from 'async-listen';
function listen(server: Server, port: number, host: string): Promise<void> {
return new Promise(resolve => {
server.listen(port, host, () => {
resolve();
});
});
}
const parseConfig = (entryPointPath: string) =>
getConfig(new Project(), entryPointPath);
function parseRuntime(
entrypoint: string,
entryPointPath: string
): string | undefined {
const project = new Project();
const staticConfig = getConfig(project, entryPointPath);
const runtime = staticConfig?.runtime;
function getRuntime(runtime: string | undefined, entrypoint: string) {
if (runtime && !isEdgeRuntime(runtime)) {
throw new Error(
`Invalid function runtime "${runtime}" for "${entrypoint}". Valid runtimes are: ${JSON.stringify(
@@ -42,7 +30,6 @@ function parseRuntime(
)}. Learn more: https://vercel.link/creating-edge-functions`
);
}
return runtime;
}
@@ -52,7 +39,8 @@ async function createEventHandler(
options: { shouldAddHelpers: boolean }
): Promise<(request: IncomingMessage) => Promise<VercelProxyResponse>> {
const entrypointPath = join(process.cwd(), entrypoint!);
const runtime = parseRuntime(entrypoint, entrypointPath);
const staticConfig = parseConfig(entrypointPath);
const runtime = getRuntime(staticConfig?.runtime, entrypoint);
// `middleware.js`/`middleware.ts` file is always run as
// an Edge Function, otherwise needs to be opted-in via
@@ -67,6 +55,7 @@ async function createEventHandler(
}
return createServerlessEventHandler(entrypointPath, {
mode: staticConfig?.supportsResponseStreaming ? 'streaming' : 'buffer',
shouldAddHelpers: options.shouldAddHelpers,
useRequire,
});
@@ -87,7 +76,7 @@ async function main() {
);
const proxyServer = createServer(onDevRequest);
await listen(proxyServer, 0, '127.0.0.1');
await listen(proxyServer, { host: '127.0.0.1', port: 0 });
try {
handleEvent = await createEventHandler(entrypoint!, config, {
@@ -124,14 +113,19 @@ export async function onDevRequest(
}
try {
const result = await handleEvent(req);
res.statusCode = result.statusCode;
for (const [key, value] of Object.entries(result.headers)) {
if (typeof value !== 'undefined') {
const { headers, body, status } = await handleEvent(req);
res.statusCode = status;
for (const [key, value] of headers as unknown as Headers) {
if (value !== undefined) {
res.setHeader(key, value);
}
}
res.end(Buffer.from(result.body, result.encoding));
if (body instanceof Buffer) {
res.end(body);
} else {
body.pipe(res);
}
} catch (error: any) {
res.statusCode = 500;
res.end(error.stack);

View File

@@ -1,49 +1,29 @@
// provided by the edge runtime:
/* global addEventListener */
function buildUrl(requestDetails) {
const host = requestDetails.headers['x-forwarded-host'] || '127.0.0.1';
const path = requestDetails.url || '/';
const allProtocols = requestDetails.headers['x-forwarded-proto'];
let proto;
if (allProtocols) {
// handle multi-protocol like: https,http://...
proto = allProtocols.split(/\b/).shift();
} else {
proto = 'http';
}
return `${proto}://${host}${path}`;
function getUrl(url, headers) {
const urlObj = new URL(url);
const protocol = headers.get('x-forwarded-proto');
if (protocol) urlObj.protocol = protocol.split(/\b/).shift();
urlObj.host = headers.get('x-forwarded-host');
urlObj.port = headers.get('x-forwarded-port');
return urlObj.toString();
}
async function respond(
userEdgeHandler,
requestDetails,
event,
options,
dependencies
) {
async function respond(userEdgeHandler, event, options, dependencies) {
const { Request, Response } = dependencies;
const { isMiddleware } = options;
let body;
if (requestDetails.method !== 'GET' && requestDetails.method !== 'HEAD') {
if (requestDetails.body) {
body = Uint8Array.from(atob(requestDetails.body), c => c.charCodeAt(0));
}
}
const request = new Request(buildUrl(requestDetails), {
headers: requestDetails.headers,
method: requestDetails.method,
body: body,
});
event.request = request;
let response = await userEdgeHandler(event.request, event);
event.request.headers.set(
'host',
event.request.headers.get('x-forwarded-host')
);
let response = await userEdgeHandler(
new Request(
getUrl(event.request.url, event.request.headers),
event.request
),
event
);
if (!response) {
if (isMiddleware) {
@@ -85,10 +65,8 @@ async function parseRequestEvent(event) {
function registerFetchListener(userEdgeHandler, options, dependencies) {
addEventListener('fetch', async event => {
try {
const requestDetails = await parseRequestEvent(event);
const response = await respond(
userEdgeHandler,
requestDetails,
event,
options,
dependencies
@@ -100,11 +78,10 @@ function registerFetchListener(userEdgeHandler, options, dependencies) {
});
}
// for testing:
module.exports = {
buildUrl,
respond,
toResponseError,
getUrl,
parseRequestEvent,
registerFetchListener,
respond,
toResponseError,
};

View File

@@ -1,18 +1,19 @@
import { IncomingMessage } from 'http';
import { VercelProxyResponse } from '@vercel/node-bridge/types';
import { streamToBuffer } from '@vercel/build-utils';
import exitHook from 'exit-hook';
import { EdgeRuntime, runServer } from 'edge-runtime';
import type { EdgeContext } from '@edge-runtime/vm';
import esbuild from 'esbuild';
import fetch from 'node-fetch';
import { createEdgeWasmPlugin, WasmAssets } from './edge-wasm-plugin';
import { entrypointToOutputPath, logError } from '../utils';
import { readFileSync } from 'fs';
import {
createNodeCompatPlugin,
NodeCompatBindings,
} from './edge-node-compat-plugin';
import { EdgeRuntime, runServer } from 'edge-runtime';
import fetch, { Headers } from 'node-fetch';
import { isError } from '@vercel/error-utils';
import { readFileSync } from 'fs';
import { serializeBody, entrypointToOutputPath, logError } from '../utils';
import esbuild from 'esbuild';
import exitHook from 'exit-hook';
import type { HeadersInit } from 'node-fetch';
import type { VercelProxyResponse } from '../types';
import type { IncomingMessage } from 'http';
import { pathToFileURL } from 'url';
const NODE_VERSION_MAJOR = process.version.match(/^v(\d+)\.\d+/)?.[1];
const NODE_VERSION_IDENTIFIER = `node${NODE_VERSION_MAJOR}`;
@@ -26,17 +27,6 @@ const edgeHandlerTemplate = readFileSync(
`${__dirname}/edge-handler-template.js`
);
async function serializeRequest(message: IncomingMessage) {
const bodyBuffer = await streamToBuffer(message);
const body = bodyBuffer.toString('base64');
return JSON.stringify({
url: message.url,
method: message.method,
headers: message.headers,
body,
});
}
async function compileUserCode(
entrypointFullPath: string,
entrypointRelativePath: string,
@@ -63,7 +53,23 @@ async function compileUserCode(
sourcemap: 'inline',
legalComments: 'none',
bundle: true,
plugins: [edgeWasmPlugin, nodeCompatPlugin.plugin],
plugins: [
edgeWasmPlugin,
nodeCompatPlugin.plugin,
{
name: 'import.meta.url',
setup({ onLoad }) {
onLoad({ filter: /\.[cm]?js$/, namespace: 'file' }, args => {
let code = readFileSync(args.path, 'utf8');
code = code.replace(
/\bimport\.meta\.url\b/g,
JSON.stringify(pathToFileURL(__filename))
);
return { contents: code };
});
},
},
],
entryPoints: [entrypointFullPath],
write: false, // operate in memory
format: 'cjs',
@@ -95,14 +101,8 @@ async function compileUserCode(
// edge handler
${edgeHandlerTemplate};
const dependencies = {
Request,
Response
};
const options = {
isMiddleware,
entrypointLabel
};
const dependencies = { Request, Response };
const options = { isMiddleware, entrypointLabel };
registerFetchListener(userEdgeHandler, options, dependencies);
`;
@@ -111,16 +111,16 @@ async function compileUserCode(
wasmAssets,
nodeCompatBindings: nodeCompatPlugin.bindings,
};
} catch (error) {
} catch (error: unknown) {
// We can't easily show a meaningful stack trace from ncc -> edge-runtime.
// So, stick with just the message for now.
console.error(`Failed to compile user code for edge runtime.`);
logError(error);
if (isError(error)) logError(error);
return undefined;
}
}
async function createEdgeRuntime(params?: {
async function createEdgeRuntimeServer(params?: {
userCode: string;
wasmAssets: WasmAssets;
nodeCompatBindings: NodeCompatBindings;
@@ -133,9 +133,9 @@ async function createEdgeRuntime(params?: {
const wasmBindings = await params.wasmAssets.getContext();
const nodeCompatBindings = params.nodeCompatBindings.getContext();
const edgeRuntime = new EdgeRuntime({
const runtime = new EdgeRuntime({
initialCode: params.userCode,
extend: (context: EdgeContext) => {
extend: context => {
Object.assign(context, {
// This is required for esbuild wrapping logic to resolve
module: {},
@@ -158,11 +158,10 @@ async function createEdgeRuntime(params?: {
},
});
const server = await runServer({ runtime: edgeRuntime });
exitHook(server.close);
const server = await runServer({ runtime });
exitHook(() => server.close());
return server;
} catch (error) {
} catch (error: any) {
// We can't easily show a meaningful stack trace from ncc -> edge-runtime.
// So, stick with just the message for now.
console.error('Failed to instantiate edge runtime.');
@@ -182,7 +181,7 @@ export async function createEdgeEventHandler(
entrypointRelativePath,
isMiddleware
);
const server = await createEdgeRuntime(userCode);
const server = await createEdgeRuntimeServer(userCode);
return async function (request: IncomingMessage) {
if (!server) {
@@ -192,17 +191,23 @@ export async function createEdgeEventHandler(
process.exit(1);
}
const response = await fetch(server.url, {
redirect: 'manual',
method: 'post',
body: await serializeRequest(request),
});
const headers = new Headers(request.headers as HeadersInit);
const body: Buffer | string | undefined = await serializeBody(request);
if (body !== undefined) headers.set('content-length', String(body.length));
const body = await response.text();
const url = new URL(request.url ?? '/', server.url);
const response = await fetch(url, {
body,
headers,
method: request.method,
redirect: 'manual',
});
const isUserError =
response.headers.get('x-vercel-failed') === 'edge-wrapper';
if (isUserError && response.status >= 500) {
const body = await response.text();
// We can't currently get a real stack trace from the Edge Function error,
// but we can fake a basic one that is still usefult to the user.
const fakeStackTrace = ` at (${entrypointRelativePath})`;
@@ -210,6 +215,7 @@ export async function createEdgeEventHandler(
entrypointRelativePath,
isZeroConfig
);
console.log(
`Error from API Route ${requestPath}: ${body}\n${fakeStackTrace}`
);
@@ -220,9 +226,9 @@ export async function createEdgeEventHandler(
}
return {
statusCode: response.status,
headers: response.headers.raw(),
body,
status: response.status,
headers: response.headers,
body: response.body,
encoding: 'utf8',
};
};

View File

@@ -1,3 +1,4 @@
import { isErrnoException } from '@vercel/error-utils';
import url from 'url';
import { spawn } from 'child_process';
import {
@@ -247,13 +248,16 @@ async function compile(
fsCache.set(relPath, entry);
sourceCache.set(relPath, source);
return source;
} catch (e) {
if (e.code === 'ENOENT' || e.code === 'EISDIR') {
} catch (error: unknown) {
if (
isErrnoException(error) &&
(error.code === 'ENOENT' || error.code === 'EISDIR')
) {
// `null` represents a not found
sourceCache.set(relPath, null);
return null;
}
throw e;
throw error;
}
},
}
@@ -549,8 +553,8 @@ export const startDevServer: StartDevServer = async opts => {
filename: 'package.json',
});
const pkg = pathToPkg ? require_(pathToPkg) : {};
const isTypescript = ['.ts', '.tsx', '.mts', '.cts'].includes(ext);
const maybeTranspile = isTypescript || !['.cjs', '.mjs'].includes(ext);
const isTypeScript = ['.ts', '.tsx', '.mts', '.cts'].includes(ext);
const maybeTranspile = isTypeScript || !['.cjs', '.mjs'].includes(ext);
const isEsm =
ext === '.mjs' ||
ext === '.mts' ||
@@ -588,10 +592,10 @@ export const startDevServer: StartDevServer = async opts => {
if (pathToTsConfig) {
try {
tsConfig = ts.readConfigFile(pathToTsConfig, ts.sys.readFile).config;
} catch (err) {
if (err.code !== 'ENOENT') {
} catch (error: unknown) {
if (isErrnoException(error) && error.code !== 'ENOENT') {
console.error(`Error while parsing "${pathToTsConfig}"`);
throw err;
throw error;
}
}
}
@@ -616,7 +620,7 @@ export const startDevServer: StartDevServer = async opts => {
entrypoint,
require_,
isEsm,
isTypeScript: isTypescript,
isTypeScript,
maybeTranspile,
meta,
tsConfig,
@@ -627,7 +631,7 @@ export const startDevServer: StartDevServer = async opts => {
if (message.state === 'message') {
// "message" event
if (isTypescript) {
if (isTypeScript) {
// Invoke `tsc --noEmit` asynchronously in the background, so
// that the HTTP request is not blocked by the type checking.
doTypeCheck(opts, pathToTsConfig).catch((err: Error) => {
@@ -674,11 +678,8 @@ async function doTypeCheck(
const json = JSON.stringify(tsconfig, null, '\t');
await fsp.mkdir(entrypointCacheDir, { recursive: true });
await fsp.writeFile(tsconfigPath, json, { flag: 'wx' });
} catch (err) {
// Don't throw if the file already exists
if (err.code !== 'EEXIST') {
throw err;
}
} catch (error: unknown) {
if (isErrnoException(error) && error.code !== 'EEXIST') throw error;
}
const child = spawn(

View File

@@ -0,0 +1,13 @@
'use strict';
const { pathToFileURL } = require('url');
const { isAbsolute } = require('path');
function dynamicImport(filepath) {
const id = isAbsolute(filepath) ? pathToFileURL(filepath).href : filepath;
return import(id);
}
module.exports = {
dynamicImport,
};

View File

@@ -1,21 +1,36 @@
import type {
VercelRequest,
VercelResponse,
VercelRequestCookies,
VercelRequestQuery,
VercelRequestBody,
} from './types';
import { Server } from 'http';
import type { Bridge } from './bridge';
import type { ServerResponse, IncomingMessage } from 'http';
import { serializeBody } from '../utils';
import { PassThrough } from 'stream';
function getBodyParser(req: VercelRequest, body: Buffer) {
type VercelRequestCookies = { [key: string]: string };
type VercelRequestQuery = { [key: string]: string | string[] };
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;
};
class ApiError extends Error {
readonly statusCode: number;
constructor(statusCode: number, message: string) {
super(message);
this.statusCode = statusCode;
}
}
function getBodyParser(body: Buffer, contentType: string | undefined) {
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']);
const { type } = parseContentType(contentType);
if (type === 'application/json') {
try {
@@ -26,43 +41,32 @@ function getBodyParser(req: VercelRequest, body: Buffer) {
}
}
if (type === 'application/octet-stream') {
return body;
}
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();
}
if (type === 'text/plain') return body.toString();
return undefined;
};
}
function getQueryParser({ url = '/' }: VercelRequest) {
function getQueryParser({ url = '/' }: IncomingMessage) {
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) {
function getCookieParser(req: IncomingMessage) {
return function parseCookie(): VercelRequestCookies {
const header: undefined | string | string[] = req.headers.cookie;
if (!header) {
return {};
}
// eslint-disable-next-line @typescript-eslint/no-var-requires
if (!header) return {};
const { parse } = require('cookie');
return parse(Array.isArray(header) ? header.join(';') : header);
};
@@ -73,6 +77,13 @@ function status(res: VercelResponse, statusCode: number): VercelResponse {
return res;
}
function setCharset(type: string, charset: string) {
const { parse, format } = require('content-type');
const parsed = parse(type);
parsed.parameters.charset = charset;
return format(parsed);
}
function redirect(
res: VercelResponse,
statusOrUrl: string | number,
@@ -91,23 +102,42 @@ function redirect(
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);
function setLazyProp<T>(req: IncomingMessage, 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 });
},
});
}
// 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 json(
req: VercelRequest,
res: VercelResponse,
jsonBody: any
): VercelResponse {
const body = JSON.stringify(jsonBody);
if (!res.getHeader('content-type')) {
res.setHeader('content-type', 'application/json; charset=utf-8');
}
return send(req, res, body);
}
function send(
req: VercelRequest,
res: VercelResponse,
@@ -209,103 +239,37 @@ function send(
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);
function restoreBody(req: IncomingMessage, body: Buffer) {
const replicateBody = new PassThrough();
const on = replicateBody.on.bind(replicateBody);
const originalOn = req.on.bind(req);
req.read = replicateBody.read.bind(replicateBody);
req.on = req.addListener = (name, cb) =>
// @ts-expect-error
name === 'data' || name === 'end' ? on(name, cb) : originalOn(name, cb);
replicateBody.write(body);
replicateBody.end();
}
export class ApiError extends Error {
readonly statusCode: number;
constructor(statusCode: number, message: string) {
super(message);
this.statusCode = statusCode;
}
async function readBody(req: IncomingMessage) {
const body = (await serializeBody(req)) || Buffer.from('');
restoreBody(req, body);
return body;
}
export function sendError(
res: VercelResponse,
statusCode: number,
message: string
) {
res.statusCode = statusCode;
res.statusMessage = message;
res.end();
}
function setLazyProp<T>(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<void>,
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<VercelRequestCookies>(req, 'cookies', getCookieParser(req));
setLazyProp<VercelRequestQuery>(req, 'query', getQueryParser(req));
setLazyProp<VercelRequestBody>(
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);
try {
await handler(req, res);
} catch (err) {
console.log(`Error from API Route ${req.url}: ${err.stack}`);
process.exit(1);
}
} catch (err) {
console.log(`Error while handling ${req.url}: ${err.message}`);
process.exit(1);
}
});
return server;
export async function addHelpers(_req: IncomingMessage, _res: ServerResponse) {
const req = _req as VercelRequest;
const res = _res as VercelResponse;
setLazyProp<VercelRequestCookies>(req, 'cookies', getCookieParser(req));
setLazyProp<VercelRequestQuery>(req, 'query', getQueryParser(req));
const contentType = req.headers['content-type'];
const body =
contentType === undefined ? Buffer.from('') : await readBody(req);
setLazyProp<VercelRequestBody>(req, 'body', getBodyParser(body, contentType));
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);
}

View File

@@ -1,58 +1,92 @@
import { IncomingMessage } from 'http';
import { Readable } from 'stream';
import type { Bridge } from '@vercel/node-bridge/bridge';
import { getVercelLauncher } from '@vercel/node-bridge/launcher.js';
import { VercelProxyResponse } from '@vercel/node-bridge/types';
import { addHelpers } from './helpers';
import { createServer } from 'http';
// @ts-expect-error
import { dynamicImport } from './dynamic-import.js';
import { serializeBody } from '../utils';
import { streamToBuffer } from '@vercel/build-utils';
import exitHook from 'exit-hook';
import fetch from 'node-fetch';
import listen from 'async-listen';
import type { HeadersInit } from 'node-fetch';
import type { ServerResponse, IncomingMessage } from 'http';
import type { VercelProxyResponse } from '../types';
import type { VercelRequest, VercelResponse } from './helpers';
function rawBody(readable: Readable): Promise<Buffer> {
return new Promise((resolve, reject) => {
let bytes = 0;
const chunks: Buffer[] = [];
readable.on('error', reject);
readable.on('data', chunk => {
chunks.push(chunk);
bytes += chunk.length;
});
readable.on('end', () => {
resolve(Buffer.concat(chunks, bytes));
});
type ServerlessServerOptions = {
shouldAddHelpers: boolean;
useRequire: boolean;
mode: 'streaming' | 'buffer';
};
type ServerlessFunctionSignature = (
req: IncomingMessage | VercelRequest,
res: ServerResponse | VercelResponse
) => void;
async function createServerlessServer(
userCode: ServerlessFunctionSignature,
options: ServerlessServerOptions
) {
const server = createServer(async (req, res) => {
if (options.shouldAddHelpers) await addHelpers(req, res);
return userCode(req, res);
});
exitHook(() => server.close());
return { url: await listen(server) };
}
async function compileUserCode(
entrypointPath: string,
options: ServerlessServerOptions
) {
let fn = options.useRequire
? require(entrypointPath)
: await dynamicImport(entrypointPath);
/**
* In some cases we might have nested default props due to TS => JS
*/
for (let i = 0; i < 5; i++) {
if (fn.default) fn = fn.default;
}
return fn;
}
export async function createServerlessEventHandler(
entrypoint: string,
options: {
shouldAddHelpers: boolean;
useRequire: boolean;
}
entrypointPath: string,
options: ServerlessServerOptions
): Promise<(request: IncomingMessage) => Promise<VercelProxyResponse>> {
const launcher = getVercelLauncher({
entrypointPath: entrypoint,
helpersPath: './helpers.js',
shouldAddHelpers: options.shouldAddHelpers,
useRequire: options.useRequire,
// not used
bridgePath: '',
sourcemapSupportPath: '',
});
const bridge: Bridge = launcher();
const userCode = await compileUserCode(entrypointPath, options);
const server = await createServerlessServer(userCode, options);
return async function (request: IncomingMessage) {
const body = await rawBody(request);
const event = {
Action: 'Invoke',
body: JSON.stringify({
method: request.method,
path: request.url,
headers: request.headers,
encoding: 'base64',
body: body.toString('base64'),
}),
};
return bridge.launcher(event, {
callbackWaitsForEmptyEventLoop: false,
const url = new URL(request.url ?? '/', server.url);
const response = await fetch(url, {
body: await serializeBody(request),
headers: {
...request.headers,
host: request.headers['x-forwarded-host'],
} as unknown as HeadersInit,
method: request.method,
redirect: 'manual',
});
let body;
if (options.mode === 'streaming') {
body = response.body;
} else {
body = await streamToBuffer(response.body);
response.headers.delete('transfer-encoding');
//@ts-expect-error
response.headers.set('content-length', body.length);
}
return {
status: response.status,
headers: response.headers,
body,
encoding: 'utf8',
};
};
}

View File

@@ -1,4 +1,5 @@
import { ServerResponse, IncomingMessage } from 'http';
import type { Headers } from 'node-fetch';
export type VercelRequestCookies = { [key: string]: string };
export type VercelRequestQuery = { [key: string]: string | string[] };
@@ -39,3 +40,10 @@ export type NowResponse = VercelResponse;
/** @deprecated Use VercelApiHandler instead. */
export type NowApiHandler = VercelApiHandler;
export interface VercelProxyResponse {
status: number;
headers: Headers;
body: Buffer | NodeJS.ReadableStream;
encoding: BufferEncoding;
}

View File

@@ -1,6 +1,7 @@
import { extname } from 'path';
import { debug, streamToBuffer } from '@vercel/build-utils';
import { pathToRegexp } from 'path-to-regexp';
import { debug } from '@vercel/build-utils';
import type { IncomingMessage } from 'http';
import { extname } from 'path';
export function getRegExpFromMatchers(matcherOrMatchers: unknown): string {
if (!matcherOrMatchers) {
@@ -79,3 +80,11 @@ export function isEdgeRuntime(runtime?: string): runtime is EdgeRuntimes {
Object.values(EdgeRuntimes).includes(runtime as EdgeRuntimes)
);
}
export async function serializeBody(
request: IncomingMessage
): Promise<Buffer | undefined> {
return request.method !== 'GET' && request.method !== 'HEAD'
? await streamToBuffer(request)
: undefined;
}

View File

@@ -1,6 +1,5 @@
{
"private": true,
"type": "module",
"packageManager": "yarn@1.22.19",
"scripts": {
"test": "rm -rf dist && tsc && cat dist/api/index.js"

View File

@@ -24,7 +24,6 @@ function testForkDevServer(entrypoint: string) {
test('runs an edge function that uses `buffer`', async () => {
const child = testForkDevServer('./edge-buffer.js');
try {
const result = await readMessage(child);
if (result.state !== 'message') {
@@ -63,12 +62,12 @@ test('runs a mjs endpoint', async () => {
);
expect({
status: response.status,
headers: response.headers.raw(),
headers: Object.fromEntries(response.headers),
text: await response.text(),
}).toEqual({
status: 200,
headers: expect.objectContaining({
'x-hello': ['world'],
'x-hello': 'world',
}),
text: 'Hello, world!',
});
@@ -96,12 +95,12 @@ test('runs a esm typescript endpoint', async () => {
);
expect({
status: response.status,
headers: response.headers.raw(),
headers: Object.fromEntries(response.headers),
text: await response.text(),
}).toEqual({
status: 200,
headers: expect.objectContaining({
'x-hello': ['world'],
'x-hello': 'world',
}),
text: 'Hello, world!',
});

View File

@@ -1,101 +0,0 @@
import { Response, Request } from 'node-fetch';
import {
buildUrl,
respond,
// @ts-ignore - this is a special patch file to allow importing from the template
} from '../../../src/edge-functions/edge-handler-template.js';
describe('edge-handler-template', () => {
describe('buildUrl()', () => {
test('works with basic proto', async () => {
const url = buildUrl({
url: '/api/add',
headers: {
'x-forwarded-proto': 'https',
'x-forwarded-host': 'somewhere.com',
},
});
expect(url).toBe('https://somewhere.com/api/add');
});
test('works with multi proto', async () => {
const url = buildUrl({
url: '/api/add',
headers: {
'x-forwarded-proto': 'https,http',
'x-forwarded-host': 'somewhere.com',
},
});
expect(url).toBe('https://somewhere.com/api/add');
});
test('url falls back to `/`', async () => {
const url = buildUrl({
// missing url
headers: {
'x-forwarded-proto': 'https',
'x-forwarded-host': 'somewhere.com',
},
});
expect(url).toBe('https://somewhere.com/');
});
test('host header falls back to `127.0.0.1`', async () => {
const url = buildUrl({
url: '/api/add',
headers: {
'x-forwarded-proto': 'https',
// missing 'x-forwarded-host'
},
});
expect(url).toBe('https://127.0.0.1/api/add');
});
test('proto header falls back to `http`', async () => {
const url = buildUrl({
url: '/api/add',
headers: {
// missing 'x-forwarded-proto'
'x-forwarded-host': 'somewhere.com',
},
});
expect(url).toBe('http://somewhere.com/api/add');
});
});
describe('respond()', () => {
test('works', async () => {
const request = {
url: '/api/add',
headers: {
'x-forwarded-proto': 'https',
'x-forwarded-host': 'somewhere.com',
},
};
function userEdgeHandler(req: Request) {
return new Response(`hello from: ${req.url}`);
}
const event = {};
const isMiddleware = false;
const entrypointLabel = 'api/add.js';
const response = await respond(
userEdgeHandler,
request,
event,
{
isMiddleware,
entrypointLabel,
},
{
Request,
Response,
}
);
expect(await response.text()).toBe(
'hello from: https://somewhere.com/api/add'
);
});
});
});

View File

@@ -0,0 +1,108 @@
import { Headers, Response, Request } from 'node-fetch';
import {
getUrl,
respond,
// @ts-ignore - this is a special patch file to allow importing from the template
} from '../../../src/edge-functions/edge-handler-template.js';
describe('edge-handler-template', () => {
describe('getUrl()', () => {
test('single `x-forwarded-proto` value', async () => {
expect(
getUrl(
'http://127.0.0.1:51126/api/add',
new Headers({
'x-forwarded-port': '',
'x-forwarded-proto': 'https',
'x-forwarded-host': 'somewhere.com',
})
)
).toBe('https://somewhere.com/api/add');
});
test('multiple `x-forwarded-proto` value', async () => {
expect(
getUrl(
'https://127.0.0.1:51126/api/add',
new Headers({
'x-forwarded-port': '',
'x-forwarded-proto': 'https,http',
'x-forwarded-host': 'somewhere.com',
})
)
).toBe('https://somewhere.com/api/add');
});
test('keep the path as part of the URL', async () => {
expect(
getUrl(
'https://127.0.0.1:51126/',
new Headers({
'x-forwarded-port': '',
'x-forwarded-proto': 'https,http',
'x-forwarded-host': 'somewhere.com',
})
)
).toBe('https://somewhere.com/');
});
test('respect `x-forwarded-host` with no `x-forwarded-proto`', async () => {
expect(
getUrl(
'https://127.0.0.1:51126/api/add',
new Headers({
'x-forwarded-host': 'somewhere.com',
'x-forwarded-port': '',
})
)
).toBe('https://somewhere.com/api/add');
});
});
describe('respond()', () => {
test("don't expose internal proxy details", async () => {
function userEdgeHandler(req: Request) {
return new Response(`hello from: ${req.url}`);
}
const event = {
request: new Request('http://127.0.0.1:60705/api/add', {
headers: {
accept: '*/*',
'accept-encoding': 'gzip,deflate',
connection: 'close',
host: '127.0.0.1:60705',
'user-agent': 'curl/7.86.0',
'x-forwarded-for': '::ffff:127.0.0.1',
'x-forwarded-host': 'somewhere.com',
'x-forwarded-port': '',
'x-forwarded-proto': 'https,http',
'x-real-ip': '::ffff:127.0.0.1',
'x-vercel-deployment-url': 'localhost:1337',
'x-vercel-forwarded-for': '::ffff:127.0.0.1',
'x-vercel-id': 'dev1::dev1::iaq68-1681934030421-110d3964f516',
},
}),
};
const isMiddleware = false;
const entrypointLabel = 'api/add.js';
const response = await respond(
userEdgeHandler,
event,
{
isMiddleware,
entrypointLabel,
},
{
Request,
Response,
}
);
expect(await response.text()).toBe(
'hello from: https://somewhere.com/api/add'
);
});
});
});

View File

@@ -0,0 +1,33 @@
// @ts-expect-error
import { dynamicImport } from '../../../src/serverless-functions/dynamic-import.js';
import { resolve } from 'path';
describe('dynamic-import', () => {
test('load esm code', async () => {
const entrypointPath = resolve(
__dirname,
'../../dev-fixtures/esm-module.mjs'
);
const fn = await dynamicImport(entrypointPath);
let buffer = '';
const headers: Record<string, string> = {};
const res = {
send: (data: string) => {
buffer = data;
return res;
},
setHeader: (key: string, value: string) => (headers[key] = value),
end: () => {},
};
const req = {};
fn.default(req, res);
expect(buffer).toBe('Hello, world!');
expect(headers).toStrictEqual({ 'x-hello': 'world' });
});
});

93
pnpm-lock.yaml generated
View File

@@ -837,13 +837,16 @@ importers:
'@types/node-fetch': ^2.6.1
'@types/test-listen': 1.1.0
'@vercel/build-utils': 6.7.1
'@vercel/error-utils': 1.0.8
'@vercel/ncc': 0.24.0
'@vercel/nft': 0.22.5
'@vercel/node-bridge': 4.0.1
'@vercel/static-config': 2.0.16
async-listen: 1.2.0
content-type: 1.0.4
cookie: 0.4.0
edge-runtime: 2.0.0
cross-env: 7.0.3
edge-runtime: 2.1.4
esbuild: 0.14.47
etag: 1.8.1
execa: 3.2.0
@@ -860,9 +863,11 @@ importers:
'@edge-runtime/vm': 2.0.0
'@types/node': 14.18.33
'@vercel/build-utils': link:../build-utils
'@vercel/node-bridge': link:../node-bridge
'@vercel/error-utils': 1.0.8
'@vercel/node-bridge': 4.0.1
'@vercel/static-config': link:../static-config
edge-runtime: 2.0.0
async-listen: 1.2.0
edge-runtime: 2.1.4
esbuild: 0.14.47
exit-hook: 2.2.1
node-fetch: 2.6.7
@@ -885,36 +890,13 @@ importers:
'@vercel/nft': 0.22.5
content-type: 1.0.4
cookie: 0.4.0
cross-env: 7.0.3
etag: 1.8.1
execa: 3.2.0
fs-extra: 11.1.0
source-map-support: 0.5.12
test-listen: 1.1.0
packages/node-bridge:
specifiers:
'@types/aws-lambda': 8.10.19
'@types/node': 14.18.33
content-type: 1.0.4
cookie: 0.4.0
etag: 1.8.1
execa: 3.2.0
fs-extra: 10.0.0
jsonlines: 0.1.1
test-listen: 1.1.0
typescript: 4.3.4
devDependencies:
'@types/aws-lambda': 8.10.19
'@types/node': 14.18.33
content-type: 1.0.4
cookie: 0.4.0
etag: 1.8.1
execa: 3.2.0
fs-extra: 10.0.0
jsonlines: 0.1.1
test-listen: 1.1.0
typescript: 4.3.4
packages/python:
specifiers:
'@types/execa': ^0.9.0
@@ -2475,8 +2457,9 @@ packages:
dependencies:
'@jridgewell/trace-mapping': 0.3.9
/@edge-runtime/format/1.1.0:
resolution: {integrity: sha512-MkLDDtPhXZIMx83NykdFmOpF7gVWIdd6GBHYb8V/E+PKWvD2pK/qWx9B30oN1iDJ2XBm0SGDjz02S8nDHI9lMQ==}
/@edge-runtime/format/2.0.1:
resolution: {integrity: sha512-aE+9DtBvQyg349srixtXEUNauWtIv5HTKPy8Q9dvG1NvpldVIvvhcDBI+SuvDVM8kQl8phbYnp2NTNloBCn/Yg==}
engines: {node: '>=14'}
dev: false
/@edge-runtime/jest-environment/2.0.0:
@@ -2493,11 +2476,23 @@ packages:
/@edge-runtime/primitives/2.0.0:
resolution: {integrity: sha512-AXqUq1zruTJAICrllUvZcgciIcEGHdF6KJ3r6FM0n4k8LpFxZ62tPWVIJ9HKm+xt+ncTBUZxwgUaQ73QMUQEKw==}
/@edge-runtime/primitives/2.1.2:
resolution: {integrity: sha512-SR04SMDybALlhIYIi0hiuEUwIl0b7Sn+RKwQkX6hydg4+AKMzBNDFhj2nqHDD1+xkHArV9EhmJIb6iGjShwSzg==}
engines: {node: '>=14'}
dev: false
/@edge-runtime/vm/2.0.0:
resolution: {integrity: sha512-BOLrAX8IWHRXu1siZocwLguKJPEUv7cr+rG8tI4hvHgMdIsBWHJlLeB8EjuUVnIURFrUiM49lVKn8DRrECmngw==}
dependencies:
'@edge-runtime/primitives': 2.0.0
/@edge-runtime/vm/2.1.2:
resolution: {integrity: sha512-j4H5S26NJhYOyjVMN8T/YJuwwslfnEX1P0j6N2Rq1FaubgNowdYunA9nlO7lg8Rgjv6dqJ2zKuM7GD1HFtNSGw==}
engines: {node: '>=14'}
dependencies:
'@edge-runtime/primitives': 2.1.2
dev: false
/@emotion/hash/0.9.0:
resolution: {integrity: sha512-14FtKiHhy2QoPIzdTcvh//8OyBlknNs2nXRwIhG904opCby3l+9Xaf/wuPvICBF0rc1ZCNBd3nKe9cd2mecVkQ==}
dev: false
@@ -6442,6 +6437,10 @@ packages:
resolution: {integrity: sha512-wUYa8eUyTg1jPGRCrjpIxJm1r6hQE7ccbECWzDCAikuWG4iadS2zWrF7bsAcuUj7fTMf8sNFhmsknTJgyN3B3g==}
dev: false
/@vercel/error-utils/1.0.8:
resolution: {integrity: sha512-s+f7jP2oH1koICbQ8e3K9hOpOeUct7rbCnF9qsNwXemq850wAh2e90tp9R6oYBM0BNpiLRRm+oG5zD2sCIm3HQ==}
dev: false
/@vercel/frameworks/1.3.0:
resolution: {integrity: sha512-guXALpQLhL0bCvIjUhHbYFyS8XusZQ6RtjqCTq0eJM6p8QLun4DI1TToqbIah/o7DY3s+RAyC2OUyOAY91qH4w==}
dependencies:
@@ -6501,6 +6500,10 @@ packages:
- encoding
- supports-color
/@vercel/node-bridge/4.0.1:
resolution: {integrity: sha512-XEfKfnLGzlIBpad7eGNPql1HnMhoSTv9q3uDNC4axdaAC/kI5yvl8kXjuCPAXYvpbJnVQPpcSUC5/r5ap8F3jA==}
dev: false
/@vercel/remix-run-dev/1.15.0_@types+node@14.18.33:
resolution: {integrity: sha512-pQTM5WmOzrvhpPSHFDShwqX71YnLaTUxffhnly4MxVNKJ2WKV9zqx8bGQ/7cLfpEu9JfY2c+pVjYYb3wAMBt+Q==}
engines: {node: '>=14'}
@@ -7219,7 +7222,11 @@ packages:
/async-listen/1.2.0:
resolution: {integrity: sha512-CcEtRh/oc9Jc4uWeUwdpG/+Mb2YUHKmdaTf0gUr7Wa+bfp4xx70HOb3RuSTJMvqKNB1TkdTfjLdrcz2X4rkkZA==}
dev: true
/async-listen/2.0.3:
resolution: {integrity: sha512-WVLi/FGIQaXyfYyNvmkwKT1RZbkzszLLnmW/gFCc5lbVvN/0QQCWpBwRBk2OWSdkkmKRBc8yD6BrKsjA3XKaSw==}
engines: {node: '>= 14'}
dev: false
/async-retry/1.1.3:
resolution: {integrity: sha512-fiAB2uaoAoUS5Ua75XFGoMKF4hmQ5H4u4gsINUjwPNof5dygJS1zyL9mh0SOmIkzAwGijwG4ybLNc8yG2OGpEQ==}
@@ -8449,6 +8456,14 @@ packages:
prompts: 2.4.2
dev: true
/cross-env/7.0.3:
resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
hasBin: true
dependencies:
cross-spawn: 7.0.3
dev: true
/cross-spawn/5.1.0:
resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==}
dependencies:
@@ -8957,13 +8972,15 @@ packages:
safer-buffer: 2.1.2
dev: true
/edge-runtime/2.0.0:
resolution: {integrity: sha512-TmRJhKi4mlM1e+zgF4CSzVU5gJ1sWj7ia+XhVgZ8PYyYUxk4PPjJU8qScpSLsAbdSxoBghLxdMuwuCzdYLd1sQ==}
/edge-runtime/2.1.4:
resolution: {integrity: sha512-SertKByzAmjm+MkLbFl1q0ko+/90V24dhZgQM8fcdguQaDYVEVtb6okEBGeg8IQgL1/JUP8oSlUIxSI/bvsVRQ==}
engines: {node: '>=14'}
hasBin: true
dependencies:
'@edge-runtime/format': 1.1.0
'@edge-runtime/vm': 2.0.0
'@edge-runtime/format': 2.0.1
'@edge-runtime/vm': 2.1.2
async-listen: 2.0.3
exit-hook: 2.2.1
http-status: 1.5.3
mri: 1.2.0
picocolors: 1.0.0
pretty-bytes: 5.6.0
@@ -11564,11 +11581,6 @@ packages:
sshpk: 1.17.0
dev: true
/http-status/1.5.3:
resolution: {integrity: sha512-jCClqdnnwigYslmtfb28vPplOgoiZ0siP2Z8C5Ua+3UKbx410v+c+jT+jh1bbI4TvcEySuX0vd/CfFZFbDkJeQ==}
engines: {node: '>= 0.4.0'}
dev: false
/http2-wrapper/1.0.3:
resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==}
engines: {node: '>=10.19.0'}
@@ -18412,6 +18424,7 @@ packages:
/which/2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
dependencies:
isexe: 2.0.0

1
utils/run.js vendored
View File

@@ -10,7 +10,6 @@ const allPackages = [
'static-config',
'client',
'next',
'node-bridge',
'node',
'go',
'python',