Files
vercel/packages/node-bridge/helpers.ts
Sean Massa d628880942 [cli][node][node-bridge] improve edge/serverless function error messages (#9410)
The error messages shown during `vc dev` when an Edge or Serverless functions returns a promise that ends up being rejected could be better.

Main changes:

- removed "socket hang up" error messages because they were not helpful to the user
- changed serverless function error handling to log explicitly and exit
- changed serverless function error message to include the request path
- changed edge function error message to include request path and a faked (but useful) stack trace

## Current

### In Production

**Serverless Function:** In production, for a serverless function rejected promise, you'll see this in the logs:

```
Unhandled Promise Rejection 	{
  "errorType": "Runtime.UnhandledPromiseRejection",
  "errorMessage": "Error: intentional break!",
  "reason": {
    "errorType": "Error",
    "errorMessage": "intentional break!",
    "stack": [
      "Error: intentional break!",
      "    at handler (/var/task/api/node.js:3:9)",
      "    at Server.<anonymous> (/var/task/___vc/__helpers.js:813:19)",
      "    at Server.emit (node:events:527:28)",
      "    at parserOnIncoming (node:_http_server:956:12)",
      "    at HTTPParser.parserOnHeadersComplete (node:_http_common:128:17)"
    ]
  },
  "promise": {},
  "stack": [
    "Runtime.UnhandledPromiseRejection: Error: intentional break!",
    "    at process.<anonymous> (file:///var/runtime/index.mjs:1194:17)",
    "    at process.emit (node:events:539:35)",
    "    at process.emit (/var/task/___vc/__sourcemap_support.js:559:21)",
    "    at emit (node:internal/process/promises:140:20)",
    "    at processPromiseRejections (node:internal/process/promises:274:27)",
    "    at processTicksAndRejections (node:internal/process/task_queues:97:32)"
  ]
}
Unknown application error occurred
Runtime.Unknown
```

**Edge Function:** In production, for an edge function rejected promise, you'll see this in the logs:

```
Error: intentional break!
    at (api/edge.js:10:10)
```

In both cases, in the browser, you see the "This Serverless/Edge Function has crashed." template with no error message or stack trace.


### In `vc dev`


**Serverless Function:** In `vc dev`, for a serverless function rejected promise, you'll see this in the output:

```
Unhandled rejection: Error: intentional break!
    at handler (/Users/smassa/source/demo/edge-errors/api/node.js:3:9)
    at Server.<anonymous> (/Users/smassa/source/vercel/vercel/packages/node-bridge/helpers.js:813:19)
    at Server.emit (node:events:513:28)
    at Server.emit (node:domain:489:12)
    at parserOnIncoming (node:_http_server:998:12)
    at HTTPParser.parserOnHeadersComplete (node:_http_common:128:17)
Error: Failed to complete request to /api/node: Error: socket hang up
```

**Edge Function:** In `vc dev`, for an edge function rejected promise, you'll see this in the output:

```
Unhandled rejection: intentional break!
Error: Failed to complete request to /api/edge: Error: socket hang up
```

## After Changes

### In `vc dev`


**Serverless Function:** In `vc dev`, for a serverless function rejected promise, you'll see this in the output:

```
Rejected Promise returned from /api/node: Error: intentional break!
    at handler (/Users/smassa/source/demo/edge-errors/api/node.js:3:9)
    at Server.<anonymous> (/Users/smassa/source/vercel/vercel/packages/node-bridge/helpers.js:824:19)
    at Server.emit (node:events:513:28)
    at Server.emit (node:domain:489:12)
    at parserOnIncoming (node:_http_server:998:12)
    at HTTPParser.parserOnHeadersComplete (node:_http_common:128:17)
```

**Edge Function:** In `vc dev`, for an edge function rejected promise, you'll see this in the output:

```
Rejected Promise returned from api/edge: intentional break!
    at (api/edge.ts)
```

We can't show the real stack trace for Edge Functions because the bundling + VM execution mangles it. What's rendered here is a fake one, but it's still useful to the user.

If we currently showed the real stack trace for edge functions, it would look like:

```
Rejected Promise returned from api/edge: intentional break!
    at edge (evalmachine.<anonymous>:35:9)
    at FetchEvent.<anonymous> (evalmachine.<anonymous>:87:26)
    at processTicksAndRejections (node:internal/process/task_queues:96:5)
    at async EdgeRuntime.dispatchFetch (evalmachine.<anonymous>:29:7)
    at async Server.handler (/Users/smassa/source/vercel/vercel/node_modules/.pnpm/edge-runtime@2.0.0/node_modules/edge-runtime/src/server/create-handler.ts:46:26)
```

## Follow Up

We'll look into improving the Edge Function error stack traces later.
2023-02-11 00:14:58 +00:00

312 lines
8.3 KiB
TypeScript

import type {
VercelRequest,
VercelResponse,
VercelRequestCookies,
VercelRequestQuery,
VercelRequestBody,
} from './types';
import { Server } from 'http';
import type { Bridge } from './bridge';
function getBodyParser(req: VercelRequest, body: Buffer) {
return function parseBody(): VercelRequestBody {
if (!req.headers['content-type']) {
return undefined;
}
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { parse: parseContentType } = require('content-type');
const { type } = parseContentType(req.headers['content-type']);
if (type === 'application/json') {
try {
const str = body.toString();
return str ? JSON.parse(str) : {};
} catch (error) {
throw new ApiError(400, 'Invalid JSON');
}
}
if (type === 'application/octet-stream') {
return body;
}
if (type === 'application/x-www-form-urlencoded') {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { parse: parseQS } = require('querystring');
// note: querystring.parse does not produce an iterable object
// https://nodejs.org/api/querystring.html#querystring_querystring_parse_str_sep_eq_options
return parseQS(body.toString());
}
if (type === 'text/plain') {
return body.toString();
}
return undefined;
};
}
function getQueryParser({ url = '/' }: VercelRequest) {
return function parseQuery(): VercelRequestQuery {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { parse: parseURL } = require('url');
return parseURL(url, true).query;
};
}
function getCookieParser(req: VercelRequest) {
return function parseCookie(): VercelRequestCookies {
const header: undefined | string | string[] = req.headers.cookie;
if (!header) {
return {};
}
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { parse } = require('cookie');
return parse(Array.isArray(header) ? header.join(';') : header);
};
}
function status(res: VercelResponse, statusCode: number): VercelResponse {
res.statusCode = statusCode;
return res;
}
function redirect(
res: VercelResponse,
statusOrUrl: string | number,
url?: string
): VercelResponse {
if (typeof statusOrUrl === 'string') {
url = statusOrUrl;
statusOrUrl = 307;
}
if (typeof statusOrUrl !== 'number' || typeof url !== 'string') {
throw new Error(
`Invalid redirect arguments. Please use a single argument URL, e.g. res.redirect('/destination') or use a status code and URL, e.g. res.redirect(307, '/destination').`
);
}
res.writeHead(statusOrUrl, { Location: url }).end();
return res;
}
function setCharset(type: string, charset: string) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { parse, format } = require('content-type');
const parsed = parse(type);
parsed.parameters.charset = charset;
return format(parsed);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function createETag(body: any, encoding: 'utf8' | undefined) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const etag = require('etag');
const buf = !Buffer.isBuffer(body) ? Buffer.from(body, encoding) : body;
return etag(buf, { weak: true });
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function send(
req: VercelRequest,
res: VercelResponse,
body: any
): VercelResponse {
let chunk: unknown = body;
let encoding: 'utf8' | undefined;
switch (typeof chunk) {
// string defaulting to html
case 'string':
if (!res.getHeader('content-type')) {
res.setHeader('content-type', 'text/html');
}
break;
case 'boolean':
case 'number':
case 'object':
if (chunk === null) {
chunk = '';
} else if (Buffer.isBuffer(chunk)) {
if (!res.getHeader('content-type')) {
res.setHeader('content-type', 'application/octet-stream');
}
} else {
return json(req, res, chunk);
}
break;
}
// write strings in utf-8
if (typeof chunk === 'string') {
encoding = 'utf8';
// reflect this in content-type
const type = res.getHeader('content-type');
if (typeof type === 'string') {
res.setHeader('content-type', setCharset(type, 'utf-8'));
}
}
// populate Content-Length
let len: number | undefined;
if (chunk !== undefined) {
if (Buffer.isBuffer(chunk)) {
// get length of Buffer
len = chunk.length;
} else if (typeof chunk === 'string') {
if (chunk.length < 1000) {
// just calculate length small chunk
len = Buffer.byteLength(chunk, encoding);
} else {
// convert chunk to Buffer and calculate
const buf = Buffer.from(chunk, encoding);
len = buf.length;
chunk = buf;
encoding = undefined;
}
} else {
throw new Error(
'`body` is not a valid string, object, boolean, number, Stream, or Buffer'
);
}
if (len !== undefined) {
res.setHeader('content-length', len);
}
}
// populate ETag
let etag: string | undefined;
if (
!res.getHeader('etag') &&
len !== undefined &&
(etag = createETag(chunk, encoding))
) {
res.setHeader('etag', etag);
}
// strip irrelevant headers
if (204 === res.statusCode || 304 === res.statusCode) {
res.removeHeader('Content-Type');
res.removeHeader('Content-Length');
res.removeHeader('Transfer-Encoding');
chunk = '';
}
if (req.method === 'HEAD') {
// skip body for HEAD
res.end();
} else if (encoding) {
// respond with encoding
res.end(chunk, encoding);
} else {
// respond without encoding
res.end(chunk);
}
return res;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function json(
req: VercelRequest,
res: VercelResponse,
jsonBody: any
): VercelResponse {
const body = JSON.stringify(jsonBody);
// content-type
if (!res.getHeader('content-type')) {
res.setHeader('content-type', 'application/json; charset=utf-8');
}
return send(req, res, body);
}
export class ApiError extends Error {
readonly statusCode: number;
constructor(statusCode: number, message: string) {
super(message);
this.statusCode = statusCode;
}
}
export function sendError(
res: VercelResponse,
statusCode: number,
message: string
) {
res.statusCode = statusCode;
res.statusMessage = message;
res.end();
}
function setLazyProp<T>(req: VercelRequest, prop: string, getter: () => T) {
const opts = { configurable: true, enumerable: true };
const optsReset = { ...opts, writable: true };
Object.defineProperty(req, prop, {
...opts,
get: () => {
const value = getter();
// we set the property on the object to avoid recalculating it
Object.defineProperty(req, prop, { ...optsReset, value });
return value;
},
set: value => {
Object.defineProperty(req, prop, { ...optsReset, value });
},
});
}
export function createServerWithHelpers(
handler: (req: VercelRequest, res: VercelResponse) => void | Promise<void>,
bridge: Bridge
) {
const server = new Server(async (_req, _res) => {
const req = _req as VercelRequest;
const res = _res as VercelResponse;
try {
const reqId = req.headers['x-now-bridge-request-id'];
// don't expose this header to the client
delete req.headers['x-now-bridge-request-id'];
if (typeof reqId !== 'string') {
throw new ApiError(500, 'Internal Server Error');
}
const event = bridge.consumeEvent(reqId);
setLazyProp<VercelRequestCookies>(req, 'cookies', getCookieParser(req));
setLazyProp<VercelRequestQuery>(req, 'query', getQueryParser(req));
setLazyProp<VercelRequestBody>(
req,
'body',
getBodyParser(req, event.body)
);
res.status = statusCode => status(res, statusCode);
res.redirect = (statusOrUrl, url) => redirect(res, statusOrUrl, url);
res.send = body => send(req, res, body);
res.json = jsonBody => json(req, res, jsonBody);
try {
await handler(req, res);
} catch (err) {
console.log(`Error from API Route ${req.url}: ${err.stack}`);
process.exit(1);
}
} catch (err) {
console.log(`Error while handling ${req.url}: ${err.message}`);
process.exit(1);
}
});
return server;
}