Files
vercel/packages/node-bridge/test/bridge.test.js
Nathan Rajlich 301bcf58fb [node-bridge] Support streaming response for Serverless Function (#8795)
Adds streaming response support for React Server Components with Next 13.
2022-11-02 02:16:37 +00:00

479 lines
13 KiB
JavaScript
Vendored

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.equal(info.address, '127.0.0.1');
assert.equal(typeof info.port, 'number');
server.close();
});
test('`APIGatewayProxyEvent` normalizing', 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 = {};
const result = await bridge.launcher(
{
httpMethod: 'GET',
headers: { foo: 'bar' },
path: '/apigateway',
body: null,
},
context
);
assert.equal(result.encoding, 'base64');
assert.equal(result.statusCode, 200);
const body = JSON.parse(Buffer.from(result.body, 'base64').toString());
assert.equal(body.method, 'GET');
assert.equal(body.path, '/apigateway');
assert.equal(body.headers.foo, 'bar');
assert.equal(context.callbackWaitsForEmptyEventLoop, false);
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,
})
)
);
const bridge = new Bridge(server);
bridge.listen();
const context = { callbackWaitsForEmptyEventLoop: true };
const result = await bridge.launcher(
{
Action: 'Invoke',
body: JSON.stringify({
method: 'POST',
headers: { foo: 'baz' },
path: '/nowproxy',
body: 'body=1',
}),
},
context
);
assert.equal(result.encoding, 'base64');
assert.equal(result.statusCode, 200);
const body = JSON.parse(Buffer.from(result.body, 'base64').toString());
assert.equal(body.method, 'POST');
assert.equal(body.path, '/nowproxy');
assert.equal(body.headers.foo, 'baz');
assert.equal(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.equal(result.encoding, 'base64');
assert.equal(result.statusCode, 200);
assert.equal(
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.equal(bodies[0].method, 'GET');
assert.equal(bodies[0].path, '/nowproxy');
assert.equal(bodies[0].headers.foo, 'baz');
assert.equal(bodies[1].method, 'GET');
assert.equal(bodies[1].path, '/_next/data/build-id/nowproxy.json');
assert.equal(bodies[1].headers.foo, 'baz');
assert.equal(bodies[2], '/somewhere');
assert.equal(result.headers['x-vercel-payload-3-status'], '307');
assert.equal(result.headers['x-vercel-payload-2-status'], undefined);
assert.equal(result.headers['x-vercel-payload-1-status'], undefined);
assert.equal(result.headers['x-vercel-payload-1-content-type'], 'text/html');
assert.equal(
result.headers['x-vercel-payload-2-content-type'],
'application/json'
);
assert.equal(result.headers['x-vercel-payload-3-content-type'], undefined);
assert.equal(result.headers['x-vercel-payload-3-location'], '/somewhere');
assert.equal(result.headers['x-vercel-payload-2-location'], undefined);
assert.equal(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.equal(result.encoding, 'base64');
assert.equal(result.statusCode, 200);
const body = JSON.parse(Buffer.from(result.body, 'base64').toString());
assert.equal(body.method, 'GET');
assert.equal(body.path, '/nowproxy');
assert.equal(body.headers.ok, 'true');
assert(!body.headers.foo);
assert.equal(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 };
}