expediate 1.0.4 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +16 -16
  2. package/README.md +417 -30
  3. package/dist/apis.d.ts +138 -21
  4. package/dist/apis.d.ts.map +1 -1
  5. package/dist/apis.js +172 -79
  6. package/dist/apis.js.map +1 -1
  7. package/dist/cjs/apis.js +327 -0
  8. package/dist/cjs/git.js +293 -0
  9. package/dist/cjs/index.js +2583 -0
  10. package/dist/cjs/jwt-auth.js +532 -0
  11. package/dist/cjs/middleware.js +511 -0
  12. package/dist/cjs/mimetypes.json +1 -0
  13. package/dist/cjs/misc.js +787 -0
  14. package/dist/cjs/openapi.js +485 -0
  15. package/dist/cjs/package.json +1 -0
  16. package/dist/cjs/router.js +898 -0
  17. package/dist/cjs/static.js +669 -0
  18. package/dist/git.d.ts +71 -8
  19. package/dist/git.d.ts.map +1 -1
  20. package/dist/git.js +127 -72
  21. package/dist/git.js.map +1 -1
  22. package/dist/index.d.ts +17 -13
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +14 -24
  25. package/dist/index.js.map +1 -1
  26. package/dist/jwt-auth.d.ts +147 -57
  27. package/dist/jwt-auth.d.ts.map +1 -1
  28. package/dist/jwt-auth.js +445 -205
  29. package/dist/jwt-auth.js.map +1 -1
  30. package/dist/middleware.d.ts +476 -0
  31. package/dist/middleware.d.ts.map +1 -0
  32. package/dist/middleware.js +647 -0
  33. package/dist/middleware.js.map +1 -0
  34. package/dist/mimetypes.json +1 -1
  35. package/dist/misc.d.ts +112 -5
  36. package/dist/misc.d.ts.map +1 -1
  37. package/dist/misc.js +235 -102
  38. package/dist/misc.js.map +1 -1
  39. package/dist/openapi.d.ts +290 -0
  40. package/dist/openapi.d.ts.map +1 -0
  41. package/dist/openapi.js +481 -0
  42. package/dist/openapi.js.map +1 -0
  43. package/dist/router.d.ts +405 -46
  44. package/dist/router.d.ts.map +1 -1
  45. package/dist/router.js +658 -153
  46. package/dist/router.js.map +1 -1
  47. package/dist/static.d.ts +1 -1
  48. package/dist/static.d.ts.map +1 -1
  49. package/dist/static.js +88 -84
  50. package/dist/static.js.map +1 -1
  51. package/package.json +21 -4
  52. package/.npmignore +0 -16
@@ -0,0 +1,787 @@
1
+ /* Copyright 2021 Fabien Bavent
2
+ *
3
+ * Permission is hereby granted, free of charge, to any person obtaining a
4
+ * copy of this software and associated documentation files (the "Software"),
5
+ * to deal in the Software without restriction, including without limitation
6
+ * the rights to use, copy, modify, merge, publish, distribute, sublicense,
7
+ * and/or sell copies of the Software, and to permit persons to whom the
8
+ * Software is furnished to do so, subject to the following conditions:
9
+ *
10
+ * The above copyright notice and this permission notice shall be included
11
+ * in all copies or substantial portions of the Software.
12
+ *
13
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
14
+ * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
16
+ * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
18
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
19
+ * DEALINGS IN THE SOFTWARE.
20
+ */
21
+ 'use strict';
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.extractCharset = extractCharset;
24
+ exports.readReqBody = readReqBody;
25
+ exports.parseMultipartBody = parseMultipartBody;
26
+ exports.json = json;
27
+ exports.formData = formData;
28
+ exports.formEncoded = formEncoded;
29
+ exports.parseBody = parseBody;
30
+ exports.streamFormData = streamFormData;
31
+ exports.logger = logger;
32
+ exports.cors = cors;
33
+ const stream_1 = require("stream");
34
+ const zlib_1 = require("zlib");
35
+ // ---------------------------------------------------------------------------
36
+ // Decompression algorithm registry
37
+ // ---------------------------------------------------------------------------
38
+ /**
39
+ * Maps a supported `Content-Encoding` value to its corresponding `zlib`
40
+ * decompression function. Only `gzip` and `deflate` are supported.
41
+ */
42
+ const DECOMPRESS_ALGO = {
43
+ gzip: zlib_1.default.gunzip,
44
+ deflate: zlib_1.default.inflate,
45
+ };
46
+ // ---------------------------------------------------------------------------
47
+ // Internal utilities
48
+ // ---------------------------------------------------------------------------
49
+ /**
50
+ * Parse a human-readable byte-size string into a number of bytes.
51
+ *
52
+ * Supported suffixes (case-insensitive): `b`, `kb`, `mb`, `gb`.
53
+ * The suffix is optional; a bare number is treated as bytes.
54
+ *
55
+ * @param value - The size string to parse (e.g. `'100kb'`, `'2.5mb'`).
56
+ * @returns The size in bytes, or `0` if the input cannot be parsed.
57
+ *
58
+ * @example
59
+ * ```ts
60
+ * readSize('100kb') // 102400
61
+ * readSize('2mb') // 2097152
62
+ * readSize('1024') // 1024
63
+ * readSize('bad') // 0
64
+ * ```
65
+ */
66
+ function readSize(value) {
67
+ if (typeof value === 'number')
68
+ return value;
69
+ const fmt = /^(\d+(\.\d+)?)([kmg]?b?)?$/i.exec(value);
70
+ if (!fmt)
71
+ return 0;
72
+ const num = parseFloat(fmt[1] ?? '0');
73
+ const sfx = (fmt[3] ?? 'b').toLowerCase();
74
+ if (sfx[0] === 'k')
75
+ return num * 1024;
76
+ if (sfx[0] === 'm')
77
+ return num * 1024 * 1024;
78
+ if (sfx[0] === 'g')
79
+ return num * 1024 * 1024 * 1024;
80
+ return num;
81
+ }
82
+ /**
83
+ * Split a `Buffer` on every occurrence of a `delimiter` buffer, returning an
84
+ * array of sub-buffers between delimiters (the delimiters themselves are not
85
+ * included in the output).
86
+ *
87
+ * Behaves like `String.prototype.split` but operates on raw binary data,
88
+ * making it safe for multipart bodies that may contain arbitrary byte
89
+ * sequences.
90
+ *
91
+ * @param buffer - The source buffer to split.
92
+ * @param delimiter - The byte sequence to split on.
93
+ * @returns An array of buffer slices; always contains at least one element.
94
+ */
95
+ function splitBuffer(buffer, delimiter) {
96
+ const result = [];
97
+ let start = 0;
98
+ let index;
99
+ while ((index = buffer.indexOf(delimiter, start)) !== -1) {
100
+ result.push(buffer.slice(start, index));
101
+ start = index + delimiter.length;
102
+ }
103
+ result.push(buffer.slice(start));
104
+ return result;
105
+ }
106
+ /**
107
+ * Extract the `charset` parameter from a `Content-Type` header value.
108
+ *
109
+ * Handles optional whitespace around the semicolon-separated parameters.
110
+ * Falls back to `'utf8'` when no `charset` parameter is found.
111
+ *
112
+ * @param contentType - The raw `Content-Type` header value
113
+ * (e.g. `'text/plain; charset=iso-8859-1'`).
114
+ * @returns A Node.js-compatible encoding name (e.g. `'utf8'`, `'iso-8859-1'`).
115
+ */
116
+ function extractCharset(contentType) {
117
+ const param = contentType
118
+ .split(';')
119
+ .map((s) => s.replace(/^\s+|\s+$/g, ''))
120
+ .find((s) => s.startsWith('charset='));
121
+ return param ? param.substring('charset='.length) : 'utf8';
122
+ }
123
+ // ---------------------------------------------------------------------------
124
+ // Body collection
125
+ // ---------------------------------------------------------------------------
126
+ /**
127
+ * Collect the full request body into a single `Buffer`, enforce size limits,
128
+ * optionally decompress, and validate the `Content-Type` against an expected
129
+ * MIME type.
130
+ *
131
+ * When the body is successfully collected and validated, `callback` is invoked
132
+ * with the raw `Content-Type` header value and the (possibly decompressed)
133
+ * body buffer. In all error cases the appropriate HTTP error response is sent
134
+ * and `callback` is never called.
135
+ *
136
+ * Callers that need to pass control to the next middleware when there is no
137
+ * body should rely on the `next()` call that this function makes when
138
+ * `Content-Length` is `0` or absent and `Transfer-Encoding` is not `chunked`.
139
+ *
140
+ * @param req - The incoming request.
141
+ * @param res - The outgoing response.
142
+ * @param opts - Resolved body-parsing options.
143
+ * @param mimetype - Expected MIME type (e.g. `'application/json'`), or `null`
144
+ * to accept any content type.
145
+ * @param next - The next middleware callback; called when the body is
146
+ * empty or absent.
147
+ * @param callback - Invoked with `(contentType, body)` on success.
148
+ */
149
+ function readBody(req, res, opts, mimetype, next, callback) {
150
+ const length = parseInt(req.headers['content-length'] ?? '0', 10);
151
+ // Detect chunked transfer encoding — no Content-Length is present in this case.
152
+ const isChunked = req.headers['transfer-encoding']
153
+ ?.split(',').map((v) => v.trim()).some((v) => v.toLowerCase() === 'chunked') ?? false;
154
+ // No body declared and not chunked — skip to next middleware.
155
+ if (!isChunked && (!length || length === 0))
156
+ return next();
157
+ const maxLength = readSize(opts.limit) || 102_400;
158
+ // For requests with a known Content-Length we can reject upfront.
159
+ if (!isChunked && length > maxLength)
160
+ return void res.status(413).send('Content Too Large');
161
+ // Compression handling.
162
+ const encoding = req.headers['content-encoding'];
163
+ if (encoding && (opts.inflate === false || !DECOMPRESS_ALGO[encoding]))
164
+ return void res.status(415).send('Unsupported Media Type: Wrong Content-Encoding');
165
+ // eslint-disable-next-line @typescript-eslint/ban-types
166
+ const decompress = (encoding ? DECOMPRESS_ALGO[encoding] : undefined) ?? ((d, c) => c(null, d));
167
+ // Content-Type validation.
168
+ const contentType = req.headers['content-type'] ?? '';
169
+ if (mimetype && contentType.split(';')[0].trim() !== mimetype)
170
+ return void res.status(415).send('Unsupported Media Type: Wrong Content-Type');
171
+ // Stream collection.
172
+ let data = Buffer.alloc(0);
173
+ req.on('data', (chunk) => {
174
+ if (data === null)
175
+ return; // already aborted
176
+ const next_ = Buffer.concat([data, chunk]);
177
+ if (next_.length > maxLength) {
178
+ data = null;
179
+ res.status(413).send('Content Too Large');
180
+ return;
181
+ }
182
+ data = next_;
183
+ });
184
+ req.on('end', () => {
185
+ if (data === null)
186
+ return; // aborted during streaming
187
+ // zlib types require NonSharedBuffer; Buffer satisfies this at runtime.
188
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
189
+ decompress(data, (err, decompressed) => {
190
+ if (err)
191
+ return void res.status(500).send(err.message);
192
+ callback(contentType, decompressed);
193
+ });
194
+ });
195
+ }
196
+ // TODO
197
+ function readReqBody(req, opts, mimetype) {
198
+ return new Promise((resolve, reject) => {
199
+ const length = parseInt(req.headers['content-length'] ?? '0', 10);
200
+ // Detect chunked transfer encoding — no Content-Length is present in this case.
201
+ const isChunked = req.headers['transfer-encoding']
202
+ ?.split(',').map((v) => v.trim()).some((v) => v.toLowerCase() === 'chunked') ?? false;
203
+ // No body declared and not chunked — resolve with null.
204
+ if (!isChunked && (!length || length === 0))
205
+ return resolve(null);
206
+ const maxLength = readSize(opts.limit) || 102_400;
207
+ // For requests with a known Content-Length we can reject upfront.
208
+ if (!isChunked && length > maxLength)
209
+ return reject({ status: 413, message: 'Content Too Large' });
210
+ // Compression handling.
211
+ const encoding = req.headers['content-encoding'];
212
+ if (encoding && (opts.inflate === false || !DECOMPRESS_ALGO[encoding]))
213
+ return reject({ status: 415, message: 'Unsupported Media Type: Wrong Content-Encoding' });
214
+ // eslint-disable-next-line @typescript-eslint/ban-types
215
+ const decompress = (encoding ? DECOMPRESS_ALGO[encoding] : undefined) ?? ((d, c) => c(null, d));
216
+ // Content-Type validation.
217
+ const contentType = req.headers['content-type'] ?? '';
218
+ if (mimetype && contentType.split(';')[0].trim() !== mimetype)
219
+ return reject({ status: 415, message: 'Unsupported Media Type: Wrong Content-Type' });
220
+ // Stream collection.
221
+ let data = Buffer.alloc(0);
222
+ req.on('data', (chunk) => {
223
+ if (data === null)
224
+ return; // already aborted
225
+ const next_ = Buffer.concat([data, chunk]);
226
+ if (next_.length > maxLength) {
227
+ data = null;
228
+ reject({ status: 413, message: 'Content Too Large' });
229
+ return;
230
+ }
231
+ data = next_;
232
+ });
233
+ req.on('end', () => {
234
+ if (data === null)
235
+ return; // aborted during streaming
236
+ // zlib types require NonSharedBuffer; Buffer satisfies this at runtime.
237
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
238
+ decompress(data, (err, decompressed) => {
239
+ if (err)
240
+ return reject({ status: 500, message: err.message });
241
+ resolve({ mimetype: contentType ?? '', content: decompressed });
242
+ });
243
+ });
244
+ });
245
+ }
246
+ // ---------------------------------------------------------------------------
247
+ // Body parsers
248
+ // ---------------------------------------------------------------------------
249
+ /**
250
+ * Parse a collected body buffer as plain text, decode it using the charset
251
+ * declared in `contentType`, and assign the result to `req.body`.
252
+ *
253
+ * On success, calls `next()`. On failure, sends a 500 Internal Server Error.
254
+ *
255
+ * @param req - The incoming request (mutated: `req.body` is set).
256
+ * @param res - The outgoing response.
257
+ * @param next - Called on successful parsing.
258
+ * @param contentType - The raw `Content-Type` header value.
259
+ * @param data - The raw body buffer.
260
+ */
261
+ function readBodyAsPlainText(req, res, next, contentType, data) {
262
+ const charset = extractCharset(contentType);
263
+ try {
264
+ req.body = data.toString(charset);
265
+ next();
266
+ }
267
+ catch (ex) {
268
+ res.status(500).send(ex.message);
269
+ }
270
+ }
271
+ /**
272
+ * Parse a collected body buffer as JSON, decode it using the charset declared
273
+ * in `contentType`, apply an optional `reviver`, and assign the result to
274
+ * `req.body`.
275
+ *
276
+ * On success, calls `next()`. On failure (invalid JSON or unsupported charset),
277
+ * sends a 500 Internal Server Error.
278
+ *
279
+ * @param req - The incoming request (mutated: `req.body` is set).
280
+ * @param res - The outgoing response.
281
+ * @param next - Called on successful parsing.
282
+ * @param opts - Resolved options; `opts.reviver` is passed to `JSON.parse`.
283
+ * @param contentType - The raw `Content-Type` header value.
284
+ * @param data - The raw body buffer.
285
+ */
286
+ function readBodyAsJson(req, res, next, opts, contentType, data) {
287
+ const charset = extractCharset(contentType);
288
+ try {
289
+ const parsed = JSON.parse(data.toString(charset), opts.reviver ?? undefined);
290
+ // FIX-10: strict mode — reject bare primitives (strings, numbers, booleans)
291
+ // at the top level; only objects and arrays are accepted.
292
+ if (opts.strict && (typeof parsed !== 'object' || parsed === null)) {
293
+ return void res.status(400).send('Bad Request: JSON body must be an object or array');
294
+ }
295
+ req.body = parsed;
296
+ next();
297
+ }
298
+ catch (ex) {
299
+ // Invalid JSON is a client error (400), not a server error.
300
+ res.status(400).send('Bad Request: ' + ex.message);
301
+ }
302
+ }
303
+ /**
304
+ * Parse a raw `multipart/form-data` body buffer into an array of
305
+ * {@link FormPart} objects.
306
+ *
307
+ * This is the shared parsing kernel used by both the {@link formData}
308
+ * middleware and the `req.formData()` extension method on the request object.
309
+ *
310
+ * **Multipart wire format recap:**
311
+ * ```
312
+ * --boundary\r\n
313
+ * Header: value\r\n
314
+ * \r\n
315
+ * <binary content>
316
+ * --boundary\r\n
317
+ * ...
318
+ * --boundary--\r\n
319
+ * ```
320
+ * The boundary string in `Content-Type` does **not** include the leading `--`;
321
+ * actual part delimiters on the wire are `\r\n--boundary`.
322
+ *
323
+ * @param contentType - The raw `Content-Type` header value (must include
324
+ * `boundary=<value>`).
325
+ * @param data - The fully-collected raw body buffer.
326
+ * @returns An array of parsed {@link FormPart} objects.
327
+ * @throws `{ status: 400, message }` when the `boundary` parameter is absent.
328
+ */
329
+ function parseMultipartBody(contentType, data) {
330
+ const boundary = contentType
331
+ .split(';')
332
+ .map((s) => s.replace(/^\s+|\s+$/g, ''))
333
+ .find((s) => s.startsWith('boundary='))
334
+ ?.substring('boundary='.length);
335
+ if (!boundary)
336
+ throw { status: 400, message: 'Bad Request: missing multipart boundary' };
337
+ // Wire-level delimiter: each part (after the preamble) is preceded by
338
+ // \r\n--boundary. We split on this sequence so every resulting slice is
339
+ // the raw content of one part (headers + blank line + body), without any
340
+ // leading delimiter bytes.
341
+ const delimiter = Buffer.from(`\r\n--${boundary}`);
342
+ // Prepend \r\n so the very first part is also cleanly split.
343
+ const normalized = Buffer.concat([Buffer.from('\r\n'), data]);
344
+ const rawParts = splitBuffer(normalized, delimiter);
345
+ const parts = [];
346
+ for (const part of rawParts) {
347
+ // The closing delimiter ends with '--'; skip it.
348
+ if (part.toString('utf8', 0, 2) === '--')
349
+ continue;
350
+ // Each part begins with \r\n (from after the delimiter), then headers,
351
+ // then \r\n\r\n (blank line), then content.
352
+ // Skip the leading \r\n.
353
+ const partContent = part.slice(2);
354
+ const blankLine = Buffer.from('\r\n\r\n');
355
+ const blankIdx = partContent.indexOf(blankLine);
356
+ if (blankIdx === -1)
357
+ continue; // malformed part — skip
358
+ const headerSection = partContent.slice(0, blankIdx).toString('utf8');
359
+ const content = partContent.slice(blankIdx + blankLine.length);
360
+ const headers = {};
361
+ for (const line of headerSection.split('\r\n')) {
362
+ if (!line)
363
+ continue;
364
+ const colonIdx = line.indexOf(':');
365
+ if (colonIdx === -1)
366
+ continue;
367
+ const key = line.substring(0, colonIdx).replace(/^\s+|\s+$/g, '').toLowerCase();
368
+ const value = line.substring(colonIdx + 1).replace(/^\s+|\s+$/g, '');
369
+ headers[key] = value;
370
+ }
371
+ parts.push({ headers, content });
372
+ }
373
+ return parts;
374
+ }
375
+ /**
376
+ * Parse a collected body buffer as `multipart/form-data`, split it on the
377
+ * boundary declared in `contentType`, parse each part's headers, and assign
378
+ * an array of {@link FormPart} objects to `req.body`.
379
+ *
380
+ * On success, calls `next()`. On failure (missing boundary, malformed parts),
381
+ * sends the appropriate HTTP error.
382
+ *
383
+ * @param req - The incoming request (mutated: `req.body` is set).
384
+ * @param res - The outgoing response.
385
+ * @param next - Called on successful parsing.
386
+ * @param contentType - The raw `Content-Type` header value (must include
387
+ * `boundary=<value>`).
388
+ * @param data - The raw body buffer.
389
+ */
390
+ function readBodyAsFormData(req, res, next, contentType, data) {
391
+ try {
392
+ req.body = parseMultipartBody(contentType, data);
393
+ next();
394
+ }
395
+ catch (ex) {
396
+ const status = ex.status ?? 500;
397
+ res.status(status).send(ex.message ?? String(ex));
398
+ }
399
+ }
400
+ /**
401
+ * Parse a collected body buffer as `application/x-www-form-urlencoded` and
402
+ * assign the result to `req.body`.
403
+ *
404
+ * Repeated keys (e.g. `tags=a&tags=b`) produce an array value:
405
+ * `{ tags: ['a', 'b'] }`. Single-occurrence keys produce a plain string.
406
+ *
407
+ * @param req - The incoming request (mutated: `req.body` is set).
408
+ * @param res - The outgoing response.
409
+ * @param next - Called on successful parsing.
410
+ * @param contentType - The raw `Content-Type` header value.
411
+ * @param data - The raw body buffer.
412
+ */
413
+ function readBodyAsFormEncoded(req, res, next, contentType, data) {
414
+ const charset = extractCharset(contentType);
415
+ try {
416
+ const params = new URLSearchParams(data.toString(charset));
417
+ const result = {};
418
+ for (const [key, value] of params.entries()) {
419
+ const existing = result[key];
420
+ if (existing === undefined) {
421
+ result[key] = value;
422
+ }
423
+ else if (Array.isArray(existing)) {
424
+ existing.push(value);
425
+ }
426
+ else {
427
+ result[key] = [existing, value];
428
+ }
429
+ }
430
+ req.body = result;
431
+ next();
432
+ }
433
+ catch (ex) {
434
+ res.status(400).send('Bad Request: ' + ex.message);
435
+ }
436
+ }
437
+ /**
438
+ * Dispatch table mapping a MIME type to its body-parser function.
439
+ * Used by {@link parseBody} to select the appropriate parser at runtime.
440
+ */
441
+ const BODY_READERS = {
442
+ 'multipart/form-data': (req, res, next, _opts, ct, data) => readBodyAsFormData(req, res, next, ct, data),
443
+ 'application/json': (req, res, next, opts, ct, data) => readBodyAsJson(req, res, next, opts, ct, data),
444
+ 'application/x-www-form-urlencoded': (req, res, next, _opts, ct, data) => readBodyAsFormEncoded(req, res, next, ct, data),
445
+ 'text/plain': (req, res, next, _opts, ct, data) => readBodyAsPlainText(req, res, next, ct, data),
446
+ };
447
+ // ---------------------------------------------------------------------------
448
+ // Public middleware factories
449
+ // ---------------------------------------------------------------------------
450
+ /**
451
+ * Middleware factory that parses a `application/json` request body and
452
+ * assigns the parsed value to `req.body`.
453
+ *
454
+ * Also attaches a `res.json(data)` helper to the response object so that
455
+ * handlers can send JSON responses conveniently:
456
+ * ```ts
457
+ * res.json({ ok: true });
458
+ * ```
459
+ *
460
+ * Behaviour:
461
+ * - Requests without a body (`Content-Length: 0` or absent) are passed
462
+ * through to `next()` without touching `req.body`.
463
+ * - Bodies larger than `opts.limit` receive **413 Content Too Large**.
464
+ * - Bodies with an unsupported `Content-Encoding` receive
465
+ * **415 Unsupported Media Type**.
466
+ * - Bodies whose `Content-Type` is not `application/json` receive
467
+ * **415 Unsupported Media Type**.
468
+ * - Parse errors receive **500 Internal Server Error**.
469
+ *
470
+ * @param opts - Optional configuration (see {@link BodyOptions}).
471
+ * @returns An Express-compatible middleware function.
472
+ */
473
+ function json(opts) {
474
+ const resolved = {
475
+ inflate: true,
476
+ limit: '100kb',
477
+ reviver: null,
478
+ strict: true,
479
+ ...opts,
480
+ };
481
+ return (req, res, next) => {
482
+ // Attach a JSON response helper so handlers can call res.json(data).
483
+ res.json = (data) => {
484
+ res.write(JSON.stringify(data));
485
+ res.end();
486
+ };
487
+ readBody(req, res, resolved, 'application/json', next, (contentType, body) => {
488
+ readBodyAsJson(req, res, next, resolved, contentType, body);
489
+ });
490
+ };
491
+ }
492
+ /**
493
+ * Middleware factory that parses a `multipart/form-data` request body and
494
+ * assigns an array of {@link FormPart} objects to `req.body`.
495
+ *
496
+ * Each element in `req.body` exposes:
497
+ * - `headers` — the part's MIME headers (e.g. `Content-Disposition`).
498
+ * - `content` — the raw binary content of the part as a `Buffer`.
499
+ *
500
+ * Behaviour:
501
+ * - Requests without a body are passed through to `next()`.
502
+ * - Bodies larger than `opts.limit` receive **413 Content Too Large**.
503
+ * - Bodies with a missing or malformed `boundary` parameter receive
504
+ * **400 Bad Request**.
505
+ * - Parse errors receive **500 Internal Server Error**.
506
+ *
507
+ * @param opts - Optional configuration (see {@link BodyOptions}).
508
+ * @returns An Express-compatible middleware function.
509
+ */
510
+ function formData(opts) {
511
+ const resolved = {
512
+ inflate: true,
513
+ limit: '100kb',
514
+ reviver: null,
515
+ strict: true,
516
+ ...opts,
517
+ };
518
+ return (req, res, next) => {
519
+ readBody(req, res, resolved, 'multipart/form-data', next, (contentType, body) => {
520
+ readBodyAsFormData(req, res, next, contentType, body);
521
+ });
522
+ };
523
+ }
524
+ /**
525
+ * Middleware factory that parses an `application/x-www-form-urlencoded`
526
+ * request body and assigns the decoded fields to `req.body`.
527
+ *
528
+ * Repeated keys (e.g. `tags=a&tags=b`) produce an array value:
529
+ * `{ tags: ['a', 'b'] }`. Single-occurrence keys produce a plain string.
530
+ *
531
+ * Behaviour:
532
+ * - Requests without a body are passed through to `next()`.
533
+ * - Bodies larger than `opts.limit` receive **413 Content Too Large**.
534
+ * - Bodies whose `Content-Type` is not `application/x-www-form-urlencoded`
535
+ * receive **415 Unsupported Media Type**.
536
+ * - Parse errors receive **400 Bad Request**.
537
+ *
538
+ * @param opts - Optional configuration (see {@link BodyOptions}).
539
+ * @returns An Express-compatible middleware function.
540
+ *
541
+ * @example
542
+ * ```ts
543
+ * app.post('/form', formEncoded(), (req, res) => {
544
+ * const { username, tags } = req.body as any;
545
+ * res.json({ username, tags }); // tags may be string | string[]
546
+ * });
547
+ * ```
548
+ */
549
+ function formEncoded(opts) {
550
+ const resolved = {
551
+ inflate: true,
552
+ limit: '100kb',
553
+ reviver: null,
554
+ strict: true,
555
+ ...opts,
556
+ };
557
+ return (req, res, next) => {
558
+ readBody(req, res, resolved, 'application/x-www-form-urlencoded', next, (contentType, body) => {
559
+ readBodyAsFormEncoded(req, res, next, contentType, body);
560
+ });
561
+ };
562
+ }
563
+ /**
564
+ * Middleware factory that auto-detects the `Content-Type` of the request body
565
+ * and parses it using the appropriate parser.
566
+ *
567
+ * Supported MIME types:
568
+ * - `application/json` → parsed as JSON; result is a JS value.
569
+ * - `multipart/form-data` → parsed as multipart; result is `FormPart[]`.
570
+ * - `application/x-www-form-urlencoded` → decoded as key/value pairs; result is
571
+ * `Record<string, string | string[]>`.
572
+ * - `text/plain` → decoded as text; result is a string.
573
+ *
574
+ * Requests with an unsupported MIME type receive **415 Unsupported Media Type**.
575
+ * All other error conditions behave identically to {@link json}, {@link formData},
576
+ * and {@link formEncoded}.
577
+ *
578
+ * @param opts - Optional configuration (see {@link BodyOptions}).
579
+ * @returns An Express-compatible middleware function.
580
+ */
581
+ function parseBody(opts) {
582
+ const resolved = {
583
+ inflate: true,
584
+ limit: '100kb',
585
+ reviver: null,
586
+ strict: true,
587
+ ...opts,
588
+ };
589
+ return (req, res, next) => {
590
+ readBody(req, res, resolved, null, next, (contentType, body) => {
591
+ const mimetype = contentType.split(';')[0].trim();
592
+ if (!BODY_READERS[mimetype])
593
+ return res.status(415).send('Unsupported Media Type');
594
+ BODY_READERS[mimetype](req, res, next, resolved, contentType, body);
595
+ });
596
+ };
597
+ }
598
+ /**
599
+ * Async generator that yields each part of a `multipart/form-data` request
600
+ * body as a {@link FormPartStream} object.
601
+ *
602
+ * Unlike {@link formData} (which must be installed as middleware before a
603
+ * handler), `streamFormData` can be called directly inside any handler and
604
+ * returns an async iterable of parts:
605
+ *
606
+ * ```ts
607
+ * app.post('/upload', async (req, res) => {
608
+ * for await (const part of streamFormData(req)) {
609
+ * const name = part.headers['content-disposition'];
610
+ * const chunks: Buffer[] = [];
611
+ * for await (const chunk of part.stream) chunks.push(chunk);
612
+ * const content = Buffer.concat(chunks);
613
+ * // ... process content
614
+ * }
615
+ * res.send('ok');
616
+ * });
617
+ * ```
618
+ *
619
+ * **Note:** the full request body is buffered before parts are yielded,
620
+ * because the multipart boundary must span the entire body. For very large
621
+ * uploads, prefer streaming directly from the raw request.
622
+ *
623
+ * @param req - The incoming request (must be a `multipart/form-data` request).
624
+ * @param opts - Optional configuration — only `limit` and `inflate` are used.
625
+ * @throws `{ status: 400, message }` when the `boundary` parameter is absent.
626
+ * @throws `{ status: 413, message }` when the body exceeds the size limit.
627
+ */
628
+ async function* streamFormData(req, opts) {
629
+ const maxSize = (opts?.limit !== undefined ? readSize(opts.limit) : 0) || 102_400;
630
+ // Collect all chunks from the raw request stream.
631
+ const chunks = [];
632
+ let totalSize = 0;
633
+ for await (const chunk of req) {
634
+ totalSize += chunk.length;
635
+ if (totalSize > maxSize)
636
+ throw { status: 413, message: 'Content Too Large' };
637
+ chunks.push(chunk);
638
+ }
639
+ const body = Buffer.concat(chunks);
640
+ const contentType = req.headers['content-type'] ?? '';
641
+ const parts = parseMultipartBody(contentType, body);
642
+ for (const part of parts) {
643
+ // Expose each part's Buffer content as a Readable stream so callers can
644
+ // pipe, pipeline, or iterate it uniformly.
645
+ yield { headers: part.headers, stream: stream_1.Readable.from(part.content) };
646
+ }
647
+ }
648
+ // ---------------------------------------------------------------------------
649
+ // Logger middleware
650
+ // ---------------------------------------------------------------------------
651
+ /**
652
+ * ANSI terminal colour codes indexed by HTTP status class (1xx–5xx).
653
+ *
654
+ * Index 0 is unused (no HTTP 0xx class).
655
+ * - 1xx → yellow (`\x1b[33m`)
656
+ * - 2xx → green (`\x1b[32m`)
657
+ * - 3xx → yellow (`\x1b[33m`)
658
+ * - 4xx → red (`\x1b[31m`)
659
+ * - 5xx → bright red (`\x1b[91m`)
660
+ */
661
+ const STATUS_COLORS = [
662
+ '\x1b[0m', // 0 — fallback / unknown
663
+ '\x1b[33m', // 1xx — informational (yellow)
664
+ '\x1b[32m', // 2xx — success (green)
665
+ '\x1b[33m', // 3xx — redirection (yellow)
666
+ '\x1b[31m', // 4xx — client error (red)
667
+ '\x1b[91m', // 5xx — server error (bright red)
668
+ ];
669
+ /** ANSI reset escape sequence. */
670
+ const ANSI_RESET = '\x1b[0m';
671
+ /**
672
+ * Middleware factory that logs one line per completed HTTP request to the
673
+ * console (or a custom logger function).
674
+ *
675
+ * Each log line contains:
676
+ * - Timestamp (formatted with `Intl.DateTimeFormat`).
677
+ * - HTTP status code (ANSI-coloured by status class).
678
+ * - HTTP method and request path.
679
+ * - Client IP address (honours `X-Forwarded-For`).
680
+ * - User identity (from `opts.user` or `'-'`).
681
+ * - Elapsed time in milliseconds.
682
+ * - Response `Content-Length` (or `'-'` when absent).
683
+ *
684
+ * **Lost-request tracking:** when `opts.track` is `true`, a timer is started
685
+ * for each request. If the response is not finished within `opts.trackTimeout`
686
+ * milliseconds, a `LOST` warning line is emitted. This is useful in
687
+ * development to surface handlers that never call `res.end()`.
688
+ *
689
+ * @param opts - Optional configuration (see {@link LoggerOptions}).
690
+ * @returns An Express-compatible middleware function.
691
+ *
692
+ * @example
693
+ * ```ts
694
+ * app.use('/', logger({
695
+ * track: true,
696
+ * trackTimeout: 30_000,
697
+ * user: (req) => (req as any).authUser ?? '-',
698
+ * locale: 'en-US',
699
+ * logger: (msg) => process.stderr.write(msg + '\n'),
700
+ * }));
701
+ * ```
702
+ */
703
+ function logger(opts) {
704
+ const options = opts ?? {};
705
+ const log = options.logger ?? console.log;
706
+ const formatter = new Intl.DateTimeFormat(options.locale ?? 'en-GB', options.dateFormat ?? {
707
+ month: 'short',
708
+ day: '2-digit',
709
+ hour: '2-digit',
710
+ minute: '2-digit',
711
+ });
712
+ return (req, res, next) => {
713
+ // Capture timestamp and path at request-arrival time so they are stable
714
+ // even if later middleware mutates req.path (e.g. prefix stripping).
715
+ const timestamp = formatter.format(new Date());
716
+ const requestPath = req.path ?? req.url ?? '/';
717
+ const ip = req.ip ?? '';
718
+ const user = options.user?.(req) ?? '-';
719
+ const receivedAt = Date.now();
720
+ // Lost-request tracking.
721
+ const tracker = options.track === true
722
+ ? setTimeout(() => {
723
+ log(`${timestamp} LOST ${req.method} ${requestPath} ${ip} <${user}>`);
724
+ }, options.trackTimeout ?? 30_000)
725
+ : null;
726
+ res.on('finish', () => {
727
+ if (tracker !== null)
728
+ clearTimeout(tracker);
729
+ const host = req.headers.host;
730
+ const elapsed = Date.now() - receivedAt;
731
+ const statusClass = Math.floor(res.statusCode / 100);
732
+ const colour = STATUS_COLORS[statusClass] ?? STATUS_COLORS[0];
733
+ const statusStr = `${colour}${res.statusCode}${ANSI_RESET}`;
734
+ const contentLen = res.getHeader('content-length') ?? '-';
735
+ if (options.json === true)
736
+ log({
737
+ timestamp,
738
+ status: res.statusCode,
739
+ method: req.method,
740
+ path: requestPath,
741
+ ip,
742
+ user,
743
+ elapsed,
744
+ host,
745
+ length: contentLen,
746
+ });
747
+ else
748
+ log(`${timestamp} ${statusStr} ${req.method} ${requestPath} ${ip} <${user}> ${elapsed}ms (${contentLen})`);
749
+ });
750
+ next();
751
+ };
752
+ }
753
+ function cors(opts) {
754
+ const options = {
755
+ origin: opts?.origin || '*',
756
+ allowHeaders: opts?.allowHeaders || 'Accept, Content-Type, Authorization',
757
+ allowMethods: opts?.allowMethods || 'GET,HEAD,PUT,PATCH,POST,DELETE',
758
+ allowCredentials: opts?.allowCredentials,
759
+ maxAge: opts?.maxAge,
760
+ vary: opts?.vary,
761
+ optionsStatus: opts?.optionsStatus || 204,
762
+ preflight: opts?.preflight,
763
+ };
764
+ return (req, res, next) => {
765
+ if (options.preflight && !options.preflight(req)) {
766
+ res.status(req.method == 'OPTIONS' ? 403 : 400).end();
767
+ return;
768
+ }
769
+ if (req.headers.origin) {
770
+ res.setHeader('Access-Control-Allow-Origin', options.origin);
771
+ if (options.vary !== undefined)
772
+ res.setHeader('Vary', options.vary);
773
+ }
774
+ if (req.method == 'OPTIONS') {
775
+ res.setHeader('Access-Control-Allow-Headers', options.allowHeaders);
776
+ res.setHeader('Access-Control-Allow-Methods', options.allowMethods);
777
+ if (options.allowCredentials !== undefined)
778
+ res.setHeader('Access-Control-Allow-Credentials', options.allowCredentials ? 'true' : 'false');
779
+ if (options.maxAge !== undefined)
780
+ res.setHeader('Access-Control-Max-Age', options.maxAge.toFixed(0));
781
+ res.status(options.optionsStatus).end();
782
+ return;
783
+ }
784
+ next();
785
+ };
786
+ }
787
+ exports.default = { json, formData, formEncoded, parseBody, logger, cors, streamFormData };