Skip to content

Commit 324d956

Browse files
committed
Fix ensures that request stream can be read by consumers
1 parent a9de5dd commit 324d956

File tree

4 files changed

+98
-54
lines changed

4 files changed

+98
-54
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
},
5555
"dependencies": {
5656
"@vercel/node": "^1.6.1",
57+
"clone-response": "^1.0.2",
5758
"content-type": "^1.0.4",
5859
"cookie": "^0.4.1",
5960
"micro": "^9.3.4",

src/__tests__/vercel-node-server.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,10 @@ it('body - json', async () => {
139139
describe('request handling', () => {
140140
it('body - invalid json', async () => {
141141
// ARRANGE
142-
route = () => {};
142+
route = req => {
143+
// @ts-expect-error
144+
const foo = req.body;
145+
};
143146

144147
expect.assertions(2);
145148

src/index.ts

Lines changed: 81 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,9 @@ import {
66
NowResponse,
77
} from '@vercel/node';
88
import { IncomingMessage, ServerResponse, Server, RequestListener } from 'http';
9-
import { parse } from 'cookie';
10-
import { parse as parseContentType } from 'content-type';
11-
import { parse as parseQS } from 'querystring';
12-
import { URL } from 'url';
139
import micro, { buffer, send } from 'micro';
10+
// @ts-expect-error
11+
import cloneResponse from 'clone-response';
1412

1513
export class ApiError extends Error {
1614
readonly statusCode: number;
@@ -21,69 +19,98 @@ export class ApiError extends Error {
2119
}
2220
}
2321

24-
function parseBody(req: IncomingMessage, body: Buffer): NowRequestBody {
25-
if (!req.headers['content-type']) {
26-
return undefined;
27-
}
28-
29-
const { type } = parseContentType(req.headers['content-type']);
22+
function getBodyParser(req: NowRequest, body: Buffer | string) {
23+
return function parseBody(): NowRequestBody {
24+
if (!req.headers['content-type']) {
25+
return undefined;
26+
}
27+
// eslint-disable-next-line @typescript-eslint/no-var-requires
28+
const { parse: parseContentType } = require('content-type');
29+
const { type } = parseContentType(req.headers['content-type']);
30+
31+
if (type === 'application/json') {
32+
try {
33+
const str = body.toString();
34+
return str ? JSON.parse(str) : {};
35+
} catch (error) {
36+
throw new ApiError(400, 'Invalid JSON');
37+
}
38+
}
3039

31-
if (type === 'application/json') {
32-
try {
33-
return JSON.parse(body.toString());
34-
} catch (error) {
35-
throw new ApiError(400, 'Invalid JSON');
40+
if (type === 'application/octet-stream') {
41+
return body;
3642
}
37-
}
3843

39-
if (type === 'application/octet-stream') {
40-
return body;
41-
}
44+
if (type === 'application/x-www-form-urlencoded') {
45+
// eslint-disable-next-line @typescript-eslint/no-var-requires
46+
const { parse: parseQS } = require('querystring');
47+
// note: querystring.parse does not produce an iterable object
48+
// https://nodejs.org/api/querystring.html#querystring_querystring_parse_str_sep_eq_options
49+
return parseQS(body.toString());
50+
}
4251

43-
if (type === 'application/x-www-form-urlencoded') {
44-
// note: querystring.parse does not produce an iterable object
45-
// https://nodejs.org/api/querystring.html#querystring_querystring_parse_str_sep_eq_options
46-
return parseQS(body.toString());
47-
}
52+
if (type === 'text/plain') {
53+
return body.toString();
54+
}
4855

49-
if (type === 'text/plain') {
50-
return body.toString();
51-
}
56+
return undefined;
57+
};
58+
}
5259

53-
return undefined;
60+
function getQueryParser({ url = '/' }: NowRequest) {
61+
return function parseQuery(): NowRequestQuery {
62+
// eslint-disable-next-line @typescript-eslint/no-var-requires
63+
const { parse: parseURL } = require('url');
64+
return parseURL(url, true).query;
65+
};
5466
}
5567

56-
function parseQuery({ url = '/' }: IncomingMessage): NowRequestQuery {
57-
// we provide a placeholder base url because we only want searchParams
58-
const params = new URL(url, 'https://n').searchParams;
68+
function getCookieParser(req: NowRequest) {
69+
return function parseCookie(): NowRequestCookies {
70+
const header: undefined | string | string[] = req.headers.cookie;
71+
72+
if (!header) {
73+
return {};
74+
}
75+
76+
// eslint-disable-next-line @typescript-eslint/no-var-requires
77+
const { parse } = require('cookie');
78+
return parse(Array.isArray(header) ? header.join(';') : header);
79+
};
80+
}
5981

60-
const query: { [key: string]: string | string[] } = {};
61-
params.forEach((value, name) => {
62-
query[name] = value;
82+
function setLazyProp<T>(req: NowRequest, prop: string, getter: () => T) {
83+
const opts = { configurable: true, enumerable: true };
84+
const optsReset = { ...opts, writable: true };
85+
86+
Object.defineProperty(req, prop, {
87+
...opts,
88+
get: () => {
89+
const value = getter();
90+
// we set the property on the object to avoid recalculating it
91+
Object.defineProperty(req, prop, { ...optsReset, value });
92+
return value;
93+
},
94+
set: value => {
95+
Object.defineProperty(req, prop, { ...optsReset, value });
96+
},
6397
});
64-
return query;
6598
}
6699

67-
function parseCookie(req: IncomingMessage): NowRequestCookies {
68-
const header: undefined | string | string[] = req.headers.cookie;
69-
if (!header) {
70-
return {};
100+
export const enhanceRequest = async (req: NowRequest): Promise<NowRequest> => {
101+
// We clone the request, so that we can read the incoming stream but then
102+
// still allow subsequent consumers to do the same
103+
const reqClone = cloneResponse(req);
104+
const newReq = cloneResponse(req);
105+
const body = await buffer(reqClone);
106+
107+
setLazyProp<NowRequestCookies>(newReq, 'cookies', getCookieParser(newReq));
108+
setLazyProp<NowRequestQuery>(newReq, 'query', getQueryParser(newReq));
109+
if (body != null) {
110+
setLazyProp<NowRequestBody>(newReq, 'body', getBodyParser(newReq, body));
71111
}
72-
return parse(Array.isArray(header) ? header.join(';') : header);
73-
}
74112

75-
export const enhanceRequest = async (
76-
req: IncomingMessage
77-
): Promise<NowRequest> => {
78-
const bufferOrString = await buffer(req);
79-
return Object.assign(req, {
80-
body:
81-
typeof bufferOrString === 'string'
82-
? bufferOrString
83-
: parseBody(req, bufferOrString),
84-
cookies: parseCookie(req),
85-
query: parseQuery(req),
86-
});
113+
return newReq;
87114
};
88115

89116
export const enhanceResponse = (res: ServerResponse): NowResponse => {
@@ -132,6 +159,7 @@ export const createServer = <C extends Config = DefaultConfig>(
132159
return new Server(route);
133160
} else {
134161
return micro(async (req: IncomingMessage, res: ServerResponse) => {
162+
// @ts-expect-error
135163
const nowReq = await enhanceRequest(req);
136164
const nowRes = enhanceResponse(res);
137165
return await route(nowReq, nowRes);

yarn.lock

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2236,6 +2236,13 @@ cliui@^6.0.0:
22362236
strip-ansi "^6.0.0"
22372237
wrap-ansi "^6.2.0"
22382238

2239+
clone-response@^1.0.2:
2240+
version "1.0.2"
2241+
resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b"
2242+
integrity sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=
2243+
dependencies:
2244+
mimic-response "^1.0.0"
2245+
22392246
clone@^1.0.2:
22402247
version "1.0.4"
22412248
resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
@@ -5129,6 +5136,11 @@ mimic-fn@^2.1.0:
51295136
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
51305137
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
51315138

5139+
mimic-response@^1.0.0:
5140+
version "1.0.1"
5141+
resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b"
5142+
integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==
5143+
51325144
minimatch@^3.0.4:
51335145
version "3.0.4"
51345146
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"

0 commit comments

Comments
 (0)