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 3 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
31 changes: 31 additions & 0 deletions __tests__/runtime/__snapshots__/integration.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,34 @@ Object {
"type": "text/xml",
}
`;

exports[`with an express app with inline function handling Assets integration tests hello.js should match snapshot 1`] = `
Object {
"body": Object {},
"headers": Object {
"accept-ranges": "bytes",
"cache-control": "public, max-age=0",
"connection": "close",
"content-type": "application/javascript; charset=UTF-8",
"x-powered-by": "Express",
},
"statusCode": 200,
"text": "alert('Hello world!');
",
"type": "application/javascript",
}
`;

exports[`with an express app with inline function handling Function integration tests basic-twiml.js should match snapshot 1`] = `
Object {
"body": Object {},
"headers": Object {
"connection": "close",
"content-type": "text/xml; charset=utf-8",
"x-powered-by": "Express",
},
"statusCode": 200,
"text": "<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?><Response><Message>Hello World</Message></Response>",
"type": "text/xml",
}
`;
156 changes: 91 additions & 65 deletions __tests__/runtime/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,76 +58,102 @@ 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();
});
}
});
});
describe('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',
},
'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;
forkProcess: 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.forkProcess;

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,
};
}
}
Loading