mirror of
https://github.com/LukeHagar/vercel.git
synced 2025-12-06 04:22:01 +00:00
[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:
@@ -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
2
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = (req, res) => {
|
||||
res.json({
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"private": true
|
||||
}
|
||||
@@ -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`, {
|
||||
|
||||
2
packages/node-bridge/.gitignore
vendored
2
packages/node-bridge/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
/helpers.js
|
||||
/source-map-support.js
|
||||
18
packages/node-bridge/bridge.d.ts
vendored
18
packages/node-bridge/bridge.d.ts
vendored
@@ -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 {};
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = path.join(__dirname, 'bridge.js');
|
||||
16
packages/node-bridge/launcher.d.ts
vendored
16
packages/node-bridge/launcher.d.ts
vendored
@@ -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 {};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
467
packages/node-bridge/test/bridge.test.js
vendored
467
packages/node-bridge/test/bridge.test.js
vendored
@@ -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 };
|
||||
}
|
||||
842
packages/node-bridge/test/helpers.test.js
vendored
842
packages/node-bridge/test/helpers.test.js
vendored
@@ -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"}');
|
||||
});
|
||||
});
|
||||
});
|
||||
78
packages/node-bridge/test/run-test-server.js
vendored
78
packages/node-bridge/test/run-test-server.js
vendored
@@ -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) };
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"$schema": "https://turborepo.org/schema.json",
|
||||
"extends": ["//"],
|
||||
"pipeline": {
|
||||
"build": {
|
||||
"outputs": ["helpers.js", "source-map-support.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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')
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
13
packages/node/src/serverless-functions/dynamic-import.js
Normal file
13
packages/node/src/serverless-functions/dynamic-import.js
Normal 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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "yarn@1.22.19",
|
||||
"scripts": {
|
||||
"test": "rm -rf dist && tsc && cat dist/api/index.js"
|
||||
|
||||
@@ -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!',
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
93
pnpm-lock.yaml
generated
@@ -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
1
utils/run.js
vendored
@@ -10,7 +10,6 @@ const allPackages = [
|
||||
'static-config',
|
||||
'client',
|
||||
'next',
|
||||
'node-bridge',
|
||||
'node',
|
||||
'go',
|
||||
'python',
|
||||
|
||||
Reference in New Issue
Block a user