Skip to content

Commit 0a4f542

Browse files
authored
feat(runtime): experimental: load functions in a separate process (#147)
1 parent 7411e33 commit 0a4f542

File tree

9 files changed

+263
-69
lines changed

9 files changed

+263
-69
lines changed

__tests__/runtime/__snapshots__/integration.test.ts.snap

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Jest Snapshot v1, https://goo.gl/fbAQLP
22

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

20-
exports[`with an express app Function integration tests basic-twiml.js should match snapshot 1`] = `
20+
exports[`with an express app with inline function handling Function integration tests basic-twiml.js should match snapshot 1`] = `
2121
Object {
2222
"body": Object {},
2323
"headers": Object {

__tests__/runtime/integration.test.ts

+93-65
Original file line numberDiff line numberDiff line change
@@ -58,76 +58,104 @@ function responseToSnapshotJson(response: InternalResponse) {
5858
describe('with an express app', () => {
5959
let app: Express;
6060

61-
beforeAll(async () => {
62-
app = await createServer(9000, {
63-
baseDir: TEST_DIR,
64-
env: TEST_ENV,
65-
logs: false,
66-
} as StartCliConfig);
67-
});
61+
describe('with inline function handling', () => {
62+
beforeAll(async () => {
63+
app = await createServer(9000, {
64+
baseDir: TEST_DIR,
65+
env: TEST_ENV,
66+
logs: false,
67+
} as StartCliConfig);
68+
});
69+
70+
describe('Function integration tests', () => {
71+
for (const testFnCode of availableFunctions) {
72+
test(`${testFnCode.name} should match snapshot`, async () => {
73+
const response = await request(app).get(testFnCode.url);
74+
if (response.status === 500) {
75+
expect(response.text).toMatch(/Error/);
76+
} else {
77+
const result = responseToSnapshotJson(response as InternalResponse);
78+
expect(result).toMatchSnapshot();
79+
}
80+
});
81+
}
82+
});
6883

69-
describe('Function integration tests', () => {
70-
for (const testFnCode of availableFunctions) {
71-
test(`${testFnCode.name} should match snapshot`, async () => {
72-
const response = await request(app).get(testFnCode.url);
73-
if (response.status === 500) {
74-
expect(response.text).toMatch(/Error/);
75-
} else {
84+
describe('Assets integration tests', () => {
85+
for (const testAsset of availableAssets) {
86+
test(`${testAsset.name} should match snapshot`, async () => {
87+
const response = await request(app).get(testAsset.url);
7688
const result = responseToSnapshotJson(response as InternalResponse);
7789
expect(result).toMatchSnapshot();
78-
}
79-
});
80-
}
81-
});
90+
});
8291

83-
describe('Assets integration tests', () => {
84-
for (const testAsset of availableAssets) {
85-
test(`${testAsset.name} should match snapshot`, async () => {
86-
const response = await request(app).get(testAsset.url);
87-
const result = responseToSnapshotJson(response as InternalResponse);
88-
expect(result).toMatchSnapshot();
89-
});
92+
test(`OPTIONS request to ${testAsset.name} should return CORS headers and no body`, async () => {
93+
const response = (await request(app).options(
94+
testAsset.url
95+
)) as InternalResponse;
96+
expect(response.headers['access-control-allow-origin']).toEqual('*');
97+
expect(response.headers['access-control-allow-headers']).toEqual(
98+
'Accept, Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since'
99+
);
100+
expect(response.headers['access-control-allow-methods']).toEqual(
101+
'GET, POST, OPTIONS'
102+
);
103+
expect(response.headers['access-control-expose-headers']).toEqual(
104+
'ETag'
105+
);
106+
expect(response.headers['access-control-max-age']).toEqual('86400');
107+
expect(response.headers['access-control-allow-credentials']).toEqual(
108+
'true'
109+
);
110+
expect(response.text).toEqual('');
111+
});
90112

91-
test(`OPTIONS request to ${testAsset.name} should return CORS headers and no body`, async () => {
92-
const response = (await request(app).options(
93-
testAsset.url
94-
)) as InternalResponse;
95-
expect(response.headers['access-control-allow-origin']).toEqual('*');
96-
expect(response.headers['access-control-allow-headers']).toEqual(
97-
'Accept, Authorization, Content-Type, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since'
98-
);
99-
expect(response.headers['access-control-allow-methods']).toEqual(
100-
'GET, POST, OPTIONS'
101-
);
102-
expect(response.headers['access-control-expose-headers']).toEqual(
103-
'ETag'
104-
);
105-
expect(response.headers['access-control-max-age']).toEqual('86400');
106-
expect(response.headers['access-control-allow-credentials']).toEqual(
107-
'true'
108-
);
109-
expect(response.text).toEqual('');
110-
});
113+
test(`GET request to ${testAsset.name} should not return CORS headers`, async () => {
114+
const response = (await request(app).get(
115+
testAsset.url
116+
)) as InternalResponse;
117+
expect(
118+
response.headers['access-control-allow-origin']
119+
).toBeUndefined();
120+
expect(
121+
response.headers['access-control-allow-headers']
122+
).toBeUndefined();
123+
expect(
124+
response.headers['access-control-allow-methods']
125+
).toBeUndefined();
126+
expect(
127+
response.headers['access-control-expose-headers']
128+
).toBeUndefined();
129+
expect(response.headers['access-control-max-age']).toBeUndefined();
130+
expect(
131+
response.headers['access-control-allow-credentials']
132+
).toBeUndefined();
133+
});
134+
}
135+
});
136+
});
137+
xdescribe('with forked process function handling', () => {
138+
beforeAll(async () => {
139+
app = await createServer(9000, {
140+
baseDir: TEST_DIR,
141+
env: TEST_ENV,
142+
logs: false,
143+
forkProcess: true,
144+
} as StartCliConfig);
145+
});
111146

112-
test(`GET request to ${testAsset.name} should not return CORS headers`, async () => {
113-
const response = (await request(app).get(
114-
testAsset.url
115-
)) as InternalResponse;
116-
expect(response.headers['access-control-allow-origin']).toBeUndefined();
117-
expect(
118-
response.headers['access-control-allow-headers']
119-
).toBeUndefined();
120-
expect(
121-
response.headers['access-control-allow-methods']
122-
).toBeUndefined();
123-
expect(
124-
response.headers['access-control-expose-headers']
125-
).toBeUndefined();
126-
expect(response.headers['access-control-max-age']).toBeUndefined();
127-
expect(
128-
response.headers['access-control-allow-credentials']
129-
).toBeUndefined();
130-
});
131-
}
147+
describe('Function integration tests', () => {
148+
for (const testFnCode of availableFunctions) {
149+
test(`${testFnCode.name} should match snapshot`, async () => {
150+
const response = await request(app).get(testFnCode.url);
151+
if (response.status === 500) {
152+
expect(response.text).toMatch(/Error/);
153+
} else {
154+
const result = responseToSnapshotJson(response as InternalResponse);
155+
expect(result).toMatchSnapshot();
156+
}
157+
});
158+
}
159+
});
132160
});
133161
});

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
"normalize.css": "^8.0.1",
7272
"ora": "^3.3.1",
7373
"pkg-install": "^1.0.0",
74+
"serialize-error": "^7.0.1",
7475
"terminal-link": "^1.3.0",
7576
"title": "^3.4.1",
7677
"twilio": "^3.43.1",

src/commands/start.ts

+6
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,12 @@ export const cliInfo: CliInfo = {
178178
type: 'string',
179179
describe: 'Specific folder name to be used for static functions',
180180
},
181+
'experimental-fork-process': {
182+
type: 'boolean',
183+
describe:
184+
'Enable forking function processes to emulate production environment',
185+
default: false,
186+
},
181187
},
182188
};
183189

src/config/start.ts

+3
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export type StartCliConfig = {
3636
appName: string;
3737
assetsFolderName?: string;
3838
functionsFolderName?: string;
39+
forkProcess: boolean;
3940
};
4041

4142
export type StartCliFlags = Arguments<
@@ -54,6 +55,7 @@ export type StartCliFlags = Arguments<
5455
legacyMode: boolean;
5556
assetsFolder?: string;
5657
functionsFolder?: string;
58+
experimentalForkProcess: boolean;
5759
}
5860
>;
5961

@@ -174,6 +176,7 @@ export async function getConfigFromCli(
174176
config.appName = 'twilio-run';
175177
config.assetsFolderName = cli.assetsFolder;
176178
config.functionsFolderName = cli.functionsFolder;
179+
config.forkProcess = cli.experimentalForkProcess;
177180

178181
return config;
179182
}
+87
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { isTwiml } from '../route';
2+
import { Response } from './response';
3+
import { serializeError } from 'serialize-error';
4+
import { ServerlessCallback } from '@twilio-labs/serverless-runtime-types/types';
5+
import { constructGlobalScope, constructContext } from '../route';
6+
7+
const sendDebugMessage = (debugMessage: string, ...debugArgs: any) => {
8+
process.send && process.send({ debugMessage, debugArgs });
9+
};
10+
11+
export type Reply = {
12+
body?: string | number | boolean | object;
13+
headers?: { [key: string]: number | string };
14+
statusCode: number;
15+
};
16+
17+
const handleError = (err: Error | string | object) => {
18+
if (err) {
19+
process.send && process.send({ err: serializeError(err) });
20+
}
21+
};
22+
23+
const handleSuccess = (responseObject?: string | number | boolean | object) => {
24+
let reply: Reply = { statusCode: 200 };
25+
if (typeof responseObject === 'string') {
26+
sendDebugMessage('Sending basic string response');
27+
reply.headers = { 'Content-Type': 'text/plain' };
28+
reply.body = responseObject;
29+
} else if (
30+
responseObject &&
31+
typeof responseObject === 'object' &&
32+
isTwiml(responseObject)
33+
) {
34+
sendDebugMessage('Sending TwiML response as XML string');
35+
reply.headers = { 'Content-Type': 'text/xml' };
36+
reply.body = responseObject.toString();
37+
} else if (responseObject && responseObject instanceof Response) {
38+
sendDebugMessage('Sending custom response');
39+
reply = responseObject.serialize();
40+
} else {
41+
sendDebugMessage('Sending JSON response');
42+
reply.body = responseObject;
43+
reply.headers = { 'Content-Type': 'application/json' };
44+
}
45+
46+
if (process.send) {
47+
process.send({ reply });
48+
}
49+
};
50+
51+
process.on('message', ({ functionPath, event, config, path }) => {
52+
const { handler } = require(functionPath);
53+
try {
54+
constructGlobalScope(config);
55+
const context = constructContext(config, path);
56+
sendDebugMessage('Context for %s: %p', path, context);
57+
sendDebugMessage('Event for %s: %o', path, event);
58+
let run_timings: { start: [number, number]; end: [number, number] } = {
59+
start: [0, 0],
60+
end: [0, 0],
61+
};
62+
63+
const callback: ServerlessCallback = (err, responseObject) => {
64+
run_timings.end = process.hrtime();
65+
sendDebugMessage('Function execution %s finished', path);
66+
sendDebugMessage(
67+
`(Estimated) Total Execution Time: ${(run_timings.end[0] * 1e9 +
68+
run_timings.end[1] -
69+
(run_timings.start[0] * 1e9 + run_timings.start[1])) /
70+
1e6}ms`
71+
);
72+
if (err) {
73+
handleError(err);
74+
} else {
75+
handleSuccess(responseObject);
76+
}
77+
};
78+
79+
sendDebugMessage('Calling function for %s', path);
80+
run_timings.start = process.hrtime();
81+
handler(context, event, callback);
82+
} catch (err) {
83+
if (process.send) {
84+
process.send({ err: serializeError(err) });
85+
}
86+
}
87+
});

src/runtime/internal/response.ts

+8
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,12 @@ export class Response implements TwilioResponse {
5454
res.set(this.headers);
5555
res.send(this.body);
5656
}
57+
58+
serialize() {
59+
return {
60+
statusCode: this.statusCode,
61+
body: this.body.toString(),
62+
headers: this.headers,
63+
};
64+
}
5765
}

src/runtime/route.ts

+49
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ import { getDebugFunction } from '../utils/logger';
1717
import { cleanUpStackTrace } from '../utils/stack-trace/clean-up';
1818
import { Response } from './internal/response';
1919
import * as Runtime from './internal/runtime';
20+
import { fork } from 'child_process';
21+
import { deserializeError } from 'serialize-error';
22+
import { Reply } from './internal/functionRunner';
23+
import { join } from 'path';
2024

2125
const { VoiceResponse, MessagingResponse, FaxResponse } = twiml;
2226

@@ -135,6 +139,51 @@ export function handleSuccess(
135139
res.send(responseObject);
136140
}
137141

142+
export function functionPathToRoute(
143+
functionPath: string,
144+
config: StartCliConfig
145+
) {
146+
return function twilioFunctionHandler(
147+
req: ExpressRequest,
148+
res: ExpressResponse,
149+
next: NextFunction
150+
) {
151+
const event = constructEvent(req);
152+
const forked = fork(join(__dirname, 'internal', 'functionRunner'));
153+
forked.on(
154+
'message',
155+
({
156+
err,
157+
reply,
158+
debugMessage,
159+
debugArgs = [],
160+
}: {
161+
err?: Error | number | string;
162+
reply?: Reply;
163+
debugMessage?: string;
164+
debugArgs?: any[];
165+
}) => {
166+
if (debugMessage) {
167+
debug(debugMessage, ...debugArgs);
168+
return;
169+
}
170+
if (err) {
171+
const error = deserializeError(err);
172+
handleError(error, req, res, functionPath);
173+
}
174+
if (reply) {
175+
res.status(reply.statusCode);
176+
res.set(reply.headers);
177+
res.send(reply.body);
178+
}
179+
forked.kill();
180+
}
181+
);
182+
183+
forked.send({ functionPath, event, config, path: req.path });
184+
};
185+
}
186+
138187
export function functionToRoute(
139188
fn: ServerlessFunctionSignature,
140189
config: StartCliConfig,

0 commit comments

Comments
 (0)