UNPKG

5.53 kBPlain TextView Raw
1// Native
2import { Stream, Readable } from 'stream';
3// Packages
4import contentType from 'content-type';
5import getRawBody from 'raw-body';
6import type { RawBodyError } from 'raw-body';
7//Types
8import type { IncomingMessage, ServerResponse, RequestListener } from 'http';
9
10// slight modification of is-stream https://github.com/sindresorhus/is-stream/blob/c918e3795ea2451b5265f331a00fb6a8aaa27816/license
11function isStream(stream: unknown): stream is Stream {
12 return (
13 stream !== null &&
14 typeof stream === 'object' &&
15 stream instanceof Stream &&
16 typeof stream.pipe === 'function'
17 );
18}
19
20function readable(stream: unknown): stream is Readable {
21 return (
22 isStream(stream) && // TODO: maybe this isn't needed because we could use only the checks below
23 stream instanceof Readable &&
24 stream.readable
25 );
26}
27
28export type RequestHandler = (
29 req: IncomingMessage,
30 res: ServerResponse,
31) => unknown;
32
33type Serve = (fn: RequestHandler) => RequestListener;
34
35export const serve: Serve = (fn) => (req, res) => run(req, res, fn);
36
37export class HttpError extends Error {
38 constructor(message: string) {
39 super(message);
40 Object.setPrototypeOf(this, HttpError.prototype);
41 }
42
43 statusCode?: number;
44 originalError?: Error;
45}
46
47function isError(error: unknown): error is Error | HttpError {
48 return error instanceof Error || error instanceof HttpError;
49}
50
51export const createError = (code: number, message: string, original: Error) => {
52 const err = new HttpError(message);
53
54 err.statusCode = code;
55 err.originalError = original;
56
57 return err;
58};
59
60export const send = (
61 res: ServerResponse,
62 code: number,
63 obj: unknown = null,
64) => {
65 res.statusCode = code;
66
67 if (obj === null) {
68 res.end();
69 return;
70 }
71
72 if (Buffer.isBuffer(obj)) {
73 if (!res.getHeader('Content-Type')) {
74 res.setHeader('Content-Type', 'application/octet-stream');
75 }
76
77 res.setHeader('Content-Length', obj.length);
78 res.end(obj);
79 return;
80 }
81
82 if (obj instanceof Stream || readable(obj)) {
83 //TODO: Wouldn't (obj instanceof Stream) be the only check here? Do we specifically need a Readable stream or a Stream object that's not of NodeJS Stream?
84 if (!res.getHeader('Content-Type')) {
85 res.setHeader('Content-Type', 'application/octet-stream');
86 }
87
88 obj.pipe(res);
89 return;
90 }
91
92 let str = obj;
93
94 if (typeof obj === 'object' || typeof obj === 'number') {
95 // We stringify before setting the header
96 // in case `JSON.stringify` throws and a
97 // 500 has to be sent instead
98 str = JSON.stringify(obj);
99
100 if (!res.getHeader('Content-Type')) {
101 res.setHeader('Content-Type', 'application/json; charset=utf-8');
102 }
103 }
104
105 if (typeof str === 'string') {
106 res.setHeader('Content-Length', Buffer.byteLength(str));
107 }
108
109 res.end(str);
110};
111
112export const sendError = (
113 req: IncomingMessage,
114 res: ServerResponse,
115 errorObj: Error | HttpError,
116) => {
117 if ('statusCode' in errorObj && errorObj.statusCode) {
118 send(res, errorObj.statusCode, errorObj.message);
119 } else send(res, 500, 'Internal Server Error');
120
121 if (errorObj instanceof Error) {
122 // eslint-disable-next-line no-console
123 console.error(errorObj.stack);
124 } else {
125 // eslint-disable-next-line no-console
126 console.warn('thrown error must be an instance Error');
127 }
128};
129
130export const run = (
131 req: IncomingMessage,
132 res: ServerResponse,
133 fn: RequestHandler,
134) =>
135 new Promise((resolve) => {
136 resolve(fn(req, res));
137 })
138 .then((val) => {
139 if (val === null) {
140 send(res, 204, null);
141 return;
142 }
143
144 // Send value if it is not undefined, otherwise assume res.end
145 // will be called later
146 if (val !== undefined) {
147 send(res, res.statusCode || 200, val);
148 }
149 })
150 .catch((err: unknown) => {
151 if (isError(err)) {
152 sendError(req, res, err);
153 }
154 });
155
156// Maps requests to buffered raw bodies so that
157// multiple calls to `json` work as expected
158const rawBodyMap = new WeakMap<IncomingMessage, Buffer>();
159
160const parseJSON = (str: string): unknown => {
161 try {
162 return JSON.parse(str);
163 } catch (err: unknown) {
164 throw createError(400, 'Invalid JSON', err as Error);
165 }
166};
167
168export interface BufferInfo {
169 limit?: string | number | undefined;
170 encoding?: BufferEncoding;
171}
172
173function isRawBodyError(error: unknown): error is RawBodyError {
174 return 'type' in (error as RawBodyError);
175}
176
177export const buffer = (
178 req: IncomingMessage,
179 { limit = '1mb', encoding }: BufferInfo = {},
180) =>
181 Promise.resolve().then(() => {
182 const type = req.headers['content-type'] || 'text/plain';
183 const length = req.headers['content-length'];
184
185 const body = rawBodyMap.get(req);
186
187 if (body) {
188 return body;
189 }
190
191 return getRawBody(req, {
192 limit,
193 length,
194 encoding: encoding ?? contentType.parse(type).parameters.charset,
195 })
196 .then((buf) => {
197 rawBodyMap.set(req, buf);
198 return buf;
199 })
200 .catch((err) => {
201 if (isRawBodyError(err) && err.type === 'entity.too.large') {
202 throw createError(413, `Body exceeded ${limit} limit`, err);
203 } else {
204 throw createError(400, 'Invalid body', err as Error);
205 }
206 });
207 });
208
209export const text = (
210 req: IncomingMessage,
211 { limit, encoding }: BufferInfo = {},
212) => buffer(req, { limit, encoding }).then((body) => body.toString(encoding));
213
214export const json = (req: IncomingMessage, opts: BufferInfo = {}) =>
215 text(req, opts).then((body) => parseJSON(body));