Skip to content

Commit 11a6ab2

Browse files
committed
feat(runtime): handle invalid account sid & new error page
This change will check for valid Account SIDs in local development mode and create an appropriate error message. It also introduces a new Error response page BREAKING CHANGE: Error page layout changed fix #45
1 parent 724455b commit 11a6ab2

File tree

9 files changed

+565
-12
lines changed

9 files changed

+565
-12
lines changed

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

+402-1
Large diffs are not rendered by default.

__tests__/runtime/route.test.ts

+10-4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
jest.mock('window-size');
2+
13
import '@twilio-labs/serverless-runtime-types';
24
import {
35
Request as ExpressRequest,
@@ -15,6 +17,7 @@ import {
1517
handleSuccess,
1618
isTwiml,
1719
} from '../../src/runtime/route';
20+
import { wrapErrorInHtml } from '../../src/utils/error-html';
1821

1922
const { VoiceResponse, MessagingResponse, FaxResponse } = twiml;
2023

@@ -26,7 +29,7 @@ describe('handleError function', () => {
2629
const err = new Error('Failed to execute');
2730
handleError(err, (mockResponse as unknown) as ExpressResponse);
2831
expect(mockResponse.status).toHaveBeenCalledWith(500);
29-
expect(mockResponse.send).toHaveBeenCalledWith(err.stack);
32+
expect(mockResponse.send).toHaveBeenCalledWith(wrapErrorInHtml(err));
3033
});
3134
});
3235

@@ -128,7 +131,7 @@ describe('constructContext function', () => {
128131
});
129132

130133
test('getTwilioClient calls twilio constructor', () => {
131-
const ACCOUNT_SID = 'ACxxxxx';
134+
const ACCOUNT_SID = 'ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
132135
const AUTH_TOKEN = 'xyz';
133136

134137
const config = {
@@ -138,12 +141,15 @@ describe('constructContext function', () => {
138141
const context = constructContext(config);
139142
const twilioFn = require('twilio');
140143
context.getTwilioClient();
141-
expect(twilioFn).toHaveBeenCalledWith('ACxxxxx', 'xyz');
144+
expect(twilioFn).toHaveBeenCalledWith(
145+
'ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
146+
'xyz'
147+
);
142148
});
143149
});
144150

145151
describe('constructGlobalScope function', () => {
146-
const ACCOUNT_SID = 'ACxxxxx';
152+
const ACCOUNT_SID = 'ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx';
147153
const AUTH_TOKEN = 'xyz';
148154
let config: StartCliConfig;
149155

package-lock.json

+5
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"log-symbols": "^2.2.0",
6363
"ngrok": "^3.0.1",
6464
"nocache": "^2.1.0",
65+
"normalize.css": "^8.0.1",
6566
"ora": "^3.3.1",
6667
"pkg-install": "^1.0.0",
6768
"prompts": "^2.0.4",

src/checks/check-account-sid.ts

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { stripIndent } from 'common-tags';
2+
import { errorMessage } from '../printers/utils';
3+
import chalk = require('chalk');
4+
5+
type Options = {
6+
shouldPrintMessage: boolean;
7+
shouldThrowError: boolean;
8+
functionName?: string;
9+
};
10+
11+
export function checkForValidAccountSid(
12+
accountSid: string | undefined,
13+
options: Options = { shouldPrintMessage: false, shouldThrowError: false }
14+
): boolean {
15+
if (accountSid && accountSid.length === 34 && accountSid.startsWith('AC')) {
16+
return true;
17+
}
18+
19+
let message = '';
20+
let title = '';
21+
if (!accountSid) {
22+
title = 'Missing Account SID';
23+
message = stripIndent`
24+
You are missing a Twilio Account SID. You can add one into your .env file:
25+
26+
${chalk.bold('ACCOUNT_SID=')}ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
27+
`;
28+
} else {
29+
title = 'Invalid Account SID';
30+
message = stripIndent`
31+
The value for your ACCOUNT_SID in your .env file is not a valid Twilio Account SID.
32+
33+
It should look like this: ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
34+
`;
35+
}
36+
37+
if (options.shouldPrintMessage && message) {
38+
console.error(errorMessage(title, message));
39+
}
40+
41+
if (options.shouldThrowError && message) {
42+
const err = new Error(title);
43+
err.name = 'INVALID_CONFIG';
44+
err.message = `${title}\n${message}`;
45+
let meta = '';
46+
if (options.functionName) {
47+
meta = `\n--- at ${options.functionName}`;
48+
}
49+
err.stack = `${err.message}${meta}`;
50+
throw err;
51+
}
52+
53+
return false;
54+
}

src/runtime/internal/runtime.ts

+8
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import twilio from 'twilio';
1111
import { ServiceContext } from 'twilio/lib/rest/sync/v1/service';
1212
import { SyncListListInstance } from 'twilio/lib/rest/sync/v1/service/syncList';
1313
import { SyncMapListInstance } from 'twilio/lib/rest/sync/v1/service/syncMap';
14+
import { checkForValidAccountSid } from '../../checks/check-account-sid';
1415
import { StartCliConfig } from '../cli/config';
1516

1617
const log = debug('twilio-run:runtime');
@@ -63,6 +64,13 @@ export function create({ env }: StartCliConfig): RuntimeInstance {
6364
const { serviceName } = options;
6465
delete options.serviceName;
6566

67+
checkForValidAccountSid(env.ACCOUNT_SID, {
68+
shouldPrintMessage: true,
69+
shouldThrowError: true,
70+
functionName: `Runtime.getSync(${[...arguments]
71+
.map((x: any) => JSON.stringify(x))
72+
.join(',')})`,
73+
});
6674
const client = twilio(env.ACCOUNT_SID, env.AUTH_TOKEN, options);
6775
const service = client.sync.services(
6876
serviceName || 'default'

src/runtime/route.ts

+23-6
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
Response as ExpressResponse,
1212
} from 'express';
1313
import twilio, { twiml } from 'twilio';
14+
import { checkForValidAccountSid } from '../checks/check-account-sid';
15+
import { wrapErrorInHtml } from '../utils/error-html';
1416
import { StartCliConfig } from './cli/config';
1517
import { Response } from './internal/response';
1618
import * as Runtime from './internal/runtime';
@@ -32,6 +34,12 @@ export function constructContext<T extends {} = {}>({
3234
[key: string]: string | undefined | Function;
3335
}> {
3436
function getTwilioClient(): twilio.Twilio {
37+
checkForValidAccountSid(env.ACCOUNT_SID, {
38+
shouldPrintMessage: true,
39+
shouldThrowError: true,
40+
functionName: 'context.getTwilioClient()',
41+
});
42+
3543
return twilio(env.ACCOUNT_SID, env.AUTH_TOKEN);
3644
}
3745
const DOMAIN_NAME = url.replace(/^https?:\/\//, '');
@@ -45,17 +53,25 @@ export function constructGlobalScope(config: StartCliConfig): void {
4553
(global as any)['Functions'] = GlobalRuntime.getFunctions();
4654
(global as any)['Response'] = Response;
4755

48-
if (config.env.ACCOUNT_SID && config.env.AUTH_TOKEN) {
56+
if (
57+
checkForValidAccountSid(config.env.ACCOUNT_SID) &&
58+
config.env.AUTH_TOKEN
59+
) {
4960
(global as any)['twilioClient'] = twilio(
5061
config.env.ACCOUNT_SID,
5162
config.env.AUTH_TOKEN
5263
);
5364
}
5465
}
5566

56-
export function handleError(err: Error, res: ExpressResponse) {
67+
export function handleError(
68+
err: Error,
69+
res: ExpressResponse,
70+
functionFilePath?: string
71+
) {
5772
res.status(500);
58-
res.send(err.stack);
73+
res.type('text/html');
74+
res.send(wrapErrorInHtml(err, functionFilePath));
5975
}
6076

6177
export function isTwiml(obj: object): boolean {
@@ -94,7 +110,8 @@ export function handleSuccess(
94110

95111
export function functionToRoute(
96112
fn: ServerlessFunctionSignature,
97-
config: StartCliConfig
113+
config: StartCliConfig,
114+
functionFilePath?: string
98115
): ExpressRequestHandler {
99116
constructGlobalScope(config);
100117

@@ -114,7 +131,7 @@ export function functionToRoute(
114131
) {
115132
log('Function execution %s finished', req.path);
116133
if (err) {
117-
handleError(err, res);
134+
handleError(err, res, functionFilePath);
118135
return;
119136
}
120137
handleSuccess(responseObject, res);
@@ -124,7 +141,7 @@ export function functionToRoute(
124141
try {
125142
fn(context, event, callback);
126143
} catch (err) {
127-
callback(err.message);
144+
callback(err);
128145
}
129146
};
130147
}

src/runtime/server.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export async function createServer(
104104
`Could not find a "handler" function in file ${functionPath}`
105105
);
106106
}
107-
functionToRoute(twilioFunction, config)(req, res, next);
107+
functionToRoute(twilioFunction, config, functionPath)(req, res, next);
108108
} catch (err) {
109109
log('Failed to retrieve function. %O', err);
110110
res.status(404).send(`Could not find function ${functionPath}`);

src/utils/error-html.ts

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { html } from 'common-tags';
2+
import { readFileSync } from 'fs';
3+
4+
let normalizeCss = '';
5+
const normalizeCssPath = require.resolve('normalize.css');
6+
7+
export function wrapErrorInHtml<T extends Error>(
8+
err: T,
9+
filePath?: string
10+
): string {
11+
if (!normalizeCss) {
12+
normalizeCss = readFileSync(normalizeCssPath, 'utf8');
13+
}
14+
15+
return html`
16+
<html>
17+
<style>
18+
${normalizeCss}/**/
19+
20+
body {
21+
font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
22+
color: #565b73;
23+
font-weight: 300;
24+
line-height: 1.8;
25+
margin: 10px 30px;
26+
}
27+
28+
h1 {
29+
color: #152748;
30+
font-weight: 500;
31+
font-size: 1.4em;
32+
}
33+
34+
h2,
35+
strong {
36+
color: #152748;
37+
font-size: 1em;
38+
font-weight: 500;
39+
}
40+
41+
.stack-trace {
42+
background: #233659;
43+
color: #fff;
44+
padding: 20px;
45+
border-radius: 5px;
46+
}
47+
</style>
48+
<h1>Runtime Error</h1>
49+
${filePath ? `<p>Error thrown in <code>${filePath}</code></p>` : ''}
50+
<hr />
51+
<p>
52+
${`<strong>${err.name}:</strong> ${err.message.replace(
53+
/\n/g,
54+
'<br/>'
55+
)}`}
56+
</p>
57+
<h2>Stack Trace</h2>
58+
${`<pre class="stack-trace">${err.stack}</pre>`}
59+
</html>
60+
`;
61+
}

0 commit comments

Comments
 (0)