Skip to content

feat(runtime): experimental: load functions in a separate process #147

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jul 6, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions __tests__/runtime/__snapshots__/integration.test.ts.snap
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`with an express app Assets integration tests hello.js should match snapshot 1`] = `
exports[`with an express app with inline function handling Assets integration tests hello.js should match snapshot 1`] = `
Object {
"body": Object {},
"headers": Object {
Expand All @@ -17,7 +17,7 @@ Object {
}
`;

exports[`with an express app Function integration tests basic-twiml.js should match snapshot 1`] = `
exports[`with an express app with inline function handling Function integration tests basic-twiml.js should match snapshot 1`] = `
Object {
"body": Object {},
"headers": Object {
Expand Down
158 changes: 93 additions & 65 deletions __tests__/runtime/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,76 +58,104 @@ function responseToSnapshotJson(response: InternalResponse) {
describe('with an express app', () => {
let app: Express;

beforeAll(async () => {
app = await createServer(9000, {
baseDir: TEST_DIR,
env: TEST_ENV,
logs: false,
} as StartCliConfig);
});
describe('with inline function handling', () => {
beforeAll(async () => {
app = await createServer(9000, {
baseDir: TEST_DIR,
env: TEST_ENV,
logs: false,
} as StartCliConfig);
});

describe('Function integration tests', () => {
for (const testFnCode of availableFunctions) {
test(`${testFnCode.name} should match snapshot`, async () => {
const response = await request(app).get(testFnCode.url);
if (response.status === 500) {
expect(response.text).toMatch(/Error/);
} else {
const result = responseToSnapshotJson(response as InternalResponse);
expect(result).toMatchSnapshot();
}
});
}
});

describe('Function integration tests', () => {
for (const testFnCode of availableFunctions) {
test(`${testFnCode.name} should match snapshot`, async () => {
const response = await request(app).get(testFnCode.url);
if (response.status === 500) {
expect(response.text).toMatch(/Error/);
} else {
describe('Assets integration tests', () => {
for (const testAsset of availableAssets) {
test(`${testAsset.name} should match snapshot`, async () => {
const response = await request(app).get(testAsset.url);
const result = responseToSnapshotJson(response as InternalResponse);
expect(result).toMatchSnapshot();
}
});
}
});
});

describe('Assets integration tests', () => {
for (const testAsset of availableAssets) {
test(`${testAsset.name} should match snapshot`, async () => {
const response = await request(app).get(testAsset.url);
const result = responseToSnapshotJson(response as InternalResponse);
expect(result).toMatchSnapshot();
});
test(`OPTIONS request to ${testAsset.name} should return CORS headers and no body`, async () => {
const response = (await request(app).options(
testAsset.url
)) as InternalResponse;
expect(response.headers['access-control-allow-origin']).toEqual('*');
expect(response.headers['access-control-allow-headers']).toEqual(
'Accept, Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since'
);
expect(response.headers['access-control-allow-methods']).toEqual(
'GET, POST, OPTIONS'
);
expect(response.headers['access-control-expose-headers']).toEqual(
'ETag'
);
expect(response.headers['access-control-max-age']).toEqual('86400');
expect(response.headers['access-control-allow-credentials']).toEqual(
'true'
);
expect(response.text).toEqual('');
});

test(`OPTIONS request to ${testAsset.name} should return CORS headers and no body`, async () => {
const response = (await request(app).options(
testAsset.url
)) as InternalResponse;
expect(response.headers['access-control-allow-origin']).toEqual('*');
expect(response.headers['access-control-allow-headers']).toEqual(
'Accept, Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since'
);
expect(response.headers['access-control-allow-methods']).toEqual(
'GET, POST, OPTIONS'
);
expect(response.headers['access-control-expose-headers']).toEqual(
'ETag'
);
expect(response.headers['access-control-max-age']).toEqual('86400');
expect(response.headers['access-control-allow-credentials']).toEqual(
'true'
);
expect(response.text).toEqual('');
});
test(`GET request to ${testAsset.name} should not return CORS headers`, async () => {
const response = (await request(app).get(
testAsset.url
)) as InternalResponse;
expect(
response.headers['access-control-allow-origin']
).toBeUndefined();
expect(
response.headers['access-control-allow-headers']
).toBeUndefined();
expect(
response.headers['access-control-allow-methods']
).toBeUndefined();
expect(
response.headers['access-control-expose-headers']
).toBeUndefined();
expect(response.headers['access-control-max-age']).toBeUndefined();
expect(
response.headers['access-control-allow-credentials']
).toBeUndefined();
});
}
});
});
xdescribe('with forked process function handling', () => {
beforeAll(async () => {
app = await createServer(9000, {
baseDir: TEST_DIR,
env: TEST_ENV,
logs: false,
forkProcess: true,
} as StartCliConfig);
});

test(`GET request to ${testAsset.name} should not return CORS headers`, async () => {
const response = (await request(app).get(
testAsset.url
)) as InternalResponse;
expect(response.headers['access-control-allow-origin']).toBeUndefined();
expect(
response.headers['access-control-allow-headers']
).toBeUndefined();
expect(
response.headers['access-control-allow-methods']
).toBeUndefined();
expect(
response.headers['access-control-expose-headers']
).toBeUndefined();
expect(response.headers['access-control-max-age']).toBeUndefined();
expect(
response.headers['access-control-allow-credentials']
).toBeUndefined();
});
}
describe('Function integration tests', () => {
for (const testFnCode of availableFunctions) {
test(`${testFnCode.name} should match snapshot`, async () => {
const response = await request(app).get(testFnCode.url);
if (response.status === 500) {
expect(response.text).toMatch(/Error/);
} else {
const result = responseToSnapshotJson(response as InternalResponse);
expect(result).toMatchSnapshot();
}
});
}
});
});
});
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"normalize.css": "^8.0.1",
"ora": "^3.3.1",
"pkg-install": "^1.0.0",
"serialize-error": "^7.0.1",
"terminal-link": "^1.3.0",
"title": "^3.4.1",
"twilio": "^3.43.1",
Expand Down
6 changes: 6 additions & 0 deletions src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ export const cliInfo: CliInfo = {
type: 'string',
describe: 'Specific folder name to be used for static functions',
},
'experimental-fork-process': {
type: 'boolean',
describe:
'Enable forking function processes to emulate production environment',
default: false,
},
},
};

Expand Down
3 changes: 3 additions & 0 deletions src/config/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export type StartCliConfig = {
appName: string;
assetsFolderName?: string;
functionsFolderName?: string;
forkProcess: boolean;
};

export type StartCliFlags = Arguments<
Expand All @@ -54,6 +55,7 @@ export type StartCliFlags = Arguments<
legacyMode: boolean;
assetsFolder?: string;
functionsFolder?: string;
experimentalForkProcess: boolean;
}
>;

Expand Down Expand Up @@ -174,6 +176,7 @@ export async function getConfigFromCli(
config.appName = 'twilio-run';
config.assetsFolderName = cli.assetsFolder;
config.functionsFolderName = cli.functionsFolder;
config.forkProcess = cli.experimentalForkProcess;

return config;
}
87 changes: 87 additions & 0 deletions src/runtime/internal/functionRunner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { isTwiml } from '../route';
import { Response } from './response';
import { serializeError } from 'serialize-error';
import { ServerlessCallback } from '@twilio-labs/serverless-runtime-types/types';
import { constructGlobalScope, constructContext } from '../route';

const sendDebugMessage = (debugMessage: string, ...debugArgs: any) => {
process.send && process.send({ debugMessage, debugArgs });
};

export type Reply = {
body?: string | number | boolean | object;
headers?: { [key: string]: number | string };
statusCode: number;
};

const handleError = (err: Error | string | object) => {
if (err) {
process.send && process.send({ err: serializeError(err) });
}
};

const handleSuccess = (responseObject?: string | number | boolean | object) => {
let reply: Reply = { statusCode: 200 };
if (typeof responseObject === 'string') {
sendDebugMessage('Sending basic string response');
reply.headers = { 'Content-Type': 'text/plain' };
reply.body = responseObject;
} else if (
responseObject &&
typeof responseObject === 'object' &&
isTwiml(responseObject)
) {
sendDebugMessage('Sending TwiML response as XML string');
reply.headers = { 'Content-Type': 'text/xml' };
reply.body = responseObject.toString();
} else if (responseObject && responseObject instanceof Response) {
sendDebugMessage('Sending custom response');
reply = responseObject.serialize();
} else {
sendDebugMessage('Sending JSON response');
reply.body = responseObject;
reply.headers = { 'Content-Type': 'application/json' };
}

if (process.send) {
process.send({ reply });
}
};

process.on('message', ({ functionPath, event, config, path }) => {
const { handler } = require(functionPath);
try {
constructGlobalScope(config);
const context = constructContext(config, path);
sendDebugMessage('Context for %s: %p', path, context);
sendDebugMessage('Event for %s: %o', path, event);
let run_timings: { start: [number, number]; end: [number, number] } = {
start: [0, 0],
end: [0, 0],
};

const callback: ServerlessCallback = (err, responseObject) => {
run_timings.end = process.hrtime();
sendDebugMessage('Function execution %s finished', path);
sendDebugMessage(
`(Estimated) Total Execution Time: ${(run_timings.end[0] * 1e9 +
run_timings.end[1] -
(run_timings.start[0] * 1e9 + run_timings.start[1])) /
1e6}ms`
);
if (err) {
handleError(err);
} else {
handleSuccess(responseObject);
}
};

sendDebugMessage('Calling function for %s', path);
run_timings.start = process.hrtime();
handler(context, event, callback);
} catch (err) {
if (process.send) {
process.send({ err: serializeError(err) });
}
}
});
8 changes: 8 additions & 0 deletions src/runtime/internal/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,12 @@ export class Response implements TwilioResponse {
res.set(this.headers);
res.send(this.body);
}

serialize() {
return {
statusCode: this.statusCode,
body: this.body.toString(),
headers: this.headers,
};
}
}
49 changes: 49 additions & 0 deletions src/runtime/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import { getDebugFunction } from '../utils/logger';
import { cleanUpStackTrace } from '../utils/stack-trace/clean-up';
import { Response } from './internal/response';
import * as Runtime from './internal/runtime';
import { fork } from 'child_process';
import { deserializeError } from 'serialize-error';
import { Reply } from './internal/functionRunner';
import { join } from 'path';

const { VoiceResponse, MessagingResponse, FaxResponse } = twiml;

Expand Down Expand Up @@ -135,6 +139,51 @@ export function handleSuccess(
res.send(responseObject);
}

export function functionPathToRoute(
functionPath: string,
config: StartCliConfig
) {
return function twilioFunctionHandler(
req: ExpressRequest,
res: ExpressResponse,
next: NextFunction
) {
const event = constructEvent(req);
const forked = fork(join(__dirname, 'internal', 'functionRunner'));
forked.on(
'message',
({
err,
reply,
debugMessage,
debugArgs = [],
}: {
err?: Error | number | string;
reply?: Reply;
debugMessage?: string;
debugArgs?: any[];
}) => {
if (debugMessage) {
debug(debugMessage, ...debugArgs);
return;
}
if (err) {
const error = deserializeError(err);
handleError(error, req, res, functionPath);
}
if (reply) {
res.status(reply.statusCode);
res.set(reply.headers);
res.send(reply.body);
}
forked.kill();
}
);

forked.send({ functionPath, event, config, path: req.path });
};
}

export function functionToRoute(
fn: ServerlessFunctionSignature,
config: StartCliConfig,
Expand Down
Loading