expediate 0.0.3 → 1.0.1

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.
package/src/misc.ts ADDED
@@ -0,0 +1,734 @@
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
+
23
+ import zlib from 'zlib';
24
+ import type { RouterRequest, RouterResponse, Middleware } from './router.js';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Types
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * A JSON reviver function, matching the second parameter of `JSON.parse`.
32
+ * Receives each key/value pair during parsing and may return a transformed
33
+ * value.
34
+ */
35
+ type Reviver = (key: string, value: unknown) => unknown;
36
+
37
+ /**
38
+ * Options shared by all body-parsing middleware factories
39
+ * ({@link json}, {@link formData}, {@link parseBody}).
40
+ */
41
+ export interface BodyOptions {
42
+ /**
43
+ * When `true` (default), compressed request bodies (`gzip`, `deflate`) are
44
+ * automatically decompressed before parsing. Set to `false` to reject
45
+ * compressed bodies with 415 Unsupported Media Type.
46
+ */
47
+ inflate?: boolean;
48
+ /**
49
+ * Maximum accepted body size. Accepts a number (bytes) or a human-readable
50
+ * string such as `'100kb'`, `'2mb'`, or `'1gb'`.
51
+ * Defaults to `'100kb'` (102 400 bytes).
52
+ */
53
+ limit?: string | number;
54
+ /**
55
+ * Optional JSON reviver passed as the second argument to `JSON.parse`.
56
+ * Only meaningful for {@link json} and {@link parseBody} when the body is
57
+ * `application/json`.
58
+ */
59
+ reviver?: Reviver | null;
60
+ /**
61
+ * When `true` (default), JSON parsing is restricted to objects and arrays.
62
+ * Bare primitives (strings, numbers, booleans) at the top level are
63
+ * rejected. Has no effect on non-JSON bodies.
64
+ *
65
+ * @remarks Currently reserved for future enforcement; not yet applied.
66
+ */
67
+ strict?: boolean;
68
+ }
69
+
70
+ /** Resolved body options with all fields guaranteed to be present. */
71
+ type ResolvedBodyOptions = Required<BodyOptions>;
72
+
73
+ /**
74
+ * Options for the {@link logger} middleware factory.
75
+ */
76
+ export interface LoggerOptions {
77
+ /**
78
+ * When `true`, starts a timer per request. If the response is not finished
79
+ * within {@link trackTimeout} milliseconds, a `LOST` warning line is logged.
80
+ * Recommended for development only — keeping a `setTimeout` per request has
81
+ * a non-trivial memory cost in production.
82
+ * Defaults to `false`.
83
+ */
84
+ track?: boolean;
85
+ /**
86
+ * Timeout in milliseconds before a tracked request is considered lost.
87
+ * Only used when {@link track} is `true`.
88
+ * Defaults to `30 000` ms (30 seconds).
89
+ */
90
+ trackTimeout?: number;
91
+ /**
92
+ * Extract a user identity string from the request for inclusion in the log
93
+ * line. Receives the augmented `RouterRequest` and should return a short
94
+ * string (e.g. a username, session ID, or `'-'` for anonymous).
95
+ * Defaults to always returning `'-'`.
96
+ *
97
+ * @example
98
+ * ```ts
99
+ * user: (req) => (req as any).authUser ?? '-'
100
+ * ```
101
+ */
102
+ user?: (req: RouterRequest) => string;
103
+ /**
104
+ * BCP 47 locale tag used when formatting the request timestamp.
105
+ * Passed as the first argument to `Intl.DateTimeFormat`.
106
+ * Defaults to `'en-GB'`.
107
+ */
108
+ locale?: string;
109
+ /**
110
+ * Date/time format options passed as the second argument to
111
+ * `Intl.DateTimeFormat`. Override this to change which fields are shown in
112
+ * the timestamp.
113
+ * Defaults to `{ month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }`.
114
+ */
115
+ dateFormat?: Intl.DateTimeFormatOptions;
116
+ /**
117
+ * Custom logging function. Receives the fully-formatted log line as a
118
+ * single string. Defaults to `console.log`.
119
+ */
120
+ logger?: (msg: string) => void;
121
+ }
122
+
123
+ /**
124
+ * A single part extracted from a `multipart/form-data` body.
125
+ */
126
+ export interface FormPart {
127
+ /**
128
+ * Raw part headers (e.g. `Content-Disposition`, `Content-Type`).
129
+ * Keys are lowercased; values are trimmed.
130
+ */
131
+ headers: Record<string, string>;
132
+ /**
133
+ * Raw binary content of the part (everything after the blank header line).
134
+ */
135
+ content: Buffer;
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Decompression algorithm registry
140
+ // ---------------------------------------------------------------------------
141
+
142
+ /**
143
+ * Maps a supported `Content-Encoding` value to its corresponding `zlib`
144
+ * decompression function. Only `gzip` and `deflate` are supported.
145
+ */
146
+ const DECOMPRESS_ALGO: Record<string, (buf: Buffer, cb: zlib.CompressCallback) => void> = {
147
+ gzip: zlib.gunzip,
148
+ deflate: zlib.inflate,
149
+ };
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Internal utilities
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /**
156
+ * Parse a human-readable byte-size string into a number of bytes.
157
+ *
158
+ * Supported suffixes (case-insensitive): `b`, `kb`, `mb`, `gb`.
159
+ * The suffix is optional; a bare number is treated as bytes.
160
+ *
161
+ * @param value - The size string to parse (e.g. `'100kb'`, `'2.5mb'`).
162
+ * @returns The size in bytes, or `0` if the input cannot be parsed.
163
+ *
164
+ * @example
165
+ * ```ts
166
+ * readSize('100kb') // 102400
167
+ * readSize('2mb') // 2097152
168
+ * readSize('1024') // 1024
169
+ * readSize('bad') // 0
170
+ * ```
171
+ */
172
+ function readSize(value: string | number): number {
173
+ if (typeof value === 'number') return value;
174
+ const fmt = /^(\d+(\.\d+)?)([kmg]?b?)?$/i.exec(value);
175
+ if (!fmt) return 0;
176
+ const num = parseFloat(fmt[1] ?? '0');
177
+ const sfx = (fmt[3] ?? 'b').toLowerCase();
178
+ if (sfx[0] === 'k') return num * 1024;
179
+ if (sfx[0] === 'm') return num * 1024 * 1024;
180
+ if (sfx[0] === 'g') return num * 1024 * 1024 * 1024;
181
+ return num;
182
+ }
183
+
184
+ /**
185
+ * Split a `Buffer` on every occurrence of a `delimiter` buffer, returning an
186
+ * array of sub-buffers between delimiters (the delimiters themselves are not
187
+ * included in the output).
188
+ *
189
+ * Behaves like `String.prototype.split` but operates on raw binary data,
190
+ * making it safe for multipart bodies that may contain arbitrary byte
191
+ * sequences.
192
+ *
193
+ * @param buffer - The source buffer to split.
194
+ * @param delimiter - The byte sequence to split on.
195
+ * @returns An array of buffer slices; always contains at least one element.
196
+ */
197
+ function splitBuffer(buffer: Buffer, delimiter: Buffer): Buffer[] {
198
+ const result: Buffer[] = [];
199
+ let start = 0;
200
+ let index: number;
201
+
202
+ while ((index = buffer.indexOf(delimiter, start)) !== -1) {
203
+ result.push(buffer.slice(start, index));
204
+ start = index + delimiter.length;
205
+ }
206
+
207
+ result.push(buffer.slice(start));
208
+ return result;
209
+ }
210
+
211
+ /**
212
+ * Extract the `charset` parameter from a `Content-Type` header value.
213
+ *
214
+ * Handles optional whitespace around the semicolon-separated parameters.
215
+ * Falls back to `'utf8'` when no `charset` parameter is found.
216
+ *
217
+ * @param contentType - The raw `Content-Type` header value
218
+ * (e.g. `'text/plain; charset=iso-8859-1'`).
219
+ * @returns A Node.js-compatible encoding name (e.g. `'utf8'`, `'iso-8859-1'`).
220
+ */
221
+ function extractCharset(contentType: string): string {
222
+ // BUG FIX: the original regex `'s+$` was a literal quote followed by `s+$`
223
+ // instead of `\s+$`. The trim therefore left leading/trailing whitespace on
224
+ // every parameter, breaking charset detection. Corrected to `\s+$`.
225
+ const param = contentType
226
+ .split(';')
227
+ .map((s) => s.replace(/^\s+|\s+$/g, ''))
228
+ .find((s) => s.startsWith('charset='));
229
+ return param ? param.substring('charset='.length) : 'utf8';
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Body collection
234
+ // ---------------------------------------------------------------------------
235
+
236
+ /**
237
+ * Collect the full request body into a single `Buffer`, enforce size limits,
238
+ * optionally decompress, and validate the `Content-Type` against an expected
239
+ * MIME type.
240
+ *
241
+ * When the body is successfully collected and validated, `callback` is invoked
242
+ * with the raw `Content-Type` header value and the (possibly decompressed)
243
+ * body buffer. In all error cases the appropriate HTTP error response is sent
244
+ * and `callback` is never called.
245
+ *
246
+ * Callers that need to pass control to the next middleware when there is no
247
+ * body should rely on the `next()` call that this function makes when
248
+ * `Content-Length` is `0` or absent.
249
+ *
250
+ * @param req - The incoming request.
251
+ * @param res - The outgoing response.
252
+ * @param opts - Resolved body-parsing options.
253
+ * @param mimetype - Expected MIME type (e.g. `'application/json'`), or `null`
254
+ * to accept any content type.
255
+ * @param next - The next middleware callback; called when the body is
256
+ * empty or absent.
257
+ * @param callback - Invoked with `(contentType, body)` on success.
258
+ */
259
+ function readBody(
260
+ req: RouterRequest,
261
+ res: RouterResponse,
262
+ opts: ResolvedBodyOptions,
263
+ mimetype: string | null,
264
+ next: () => void,
265
+ callback: (contentType: string, body: Buffer) => void,
266
+ ): void {
267
+ // BUG FIX: HTTP/1.1 header names are always lowercased by Node.js.
268
+ // The original code used mixed-case keys ('Content-Length', 'Content-Encoding',
269
+ // 'Content-Type') which always evaluated to undefined.
270
+
271
+ const length = parseInt((req.headers['content-length'] as string) ?? '0', 10);
272
+
273
+ // No body declared — skip to next middleware.
274
+ if (!length || length === 0) return next();
275
+
276
+ const maxLength = readSize(opts.limit) || 102_400;
277
+
278
+ if (length > maxLength)
279
+ return void res.status(413).send('Content Too Large');
280
+
281
+ // Compression handling.
282
+ const encoding = req.headers['content-encoding'] as string | undefined;
283
+ if (encoding && (opts.inflate === false || !DECOMPRESS_ALGO[encoding]))
284
+ return void res.status(415).send('Unsupported Media Type: Wrong Content-Encoding');
285
+
286
+ // eslint-disable-next-line @typescript-eslint/ban-types
287
+ const decompress =
288
+ (encoding ? DECOMPRESS_ALGO[encoding] : undefined) ?? ((d: Buffer, c: zlib.CompressCallback) => c(null, d as any));
289
+
290
+ // Content-Type validation.
291
+ const contentType = (req.headers['content-type'] as string) ?? '';
292
+ if (mimetype && contentType.split(';')[0].trim() !== mimetype)
293
+ return void res.status(415).send('Unsupported Media Type: Wrong Content-Type');
294
+
295
+ // Stream collection.
296
+ let data: Buffer | null = Buffer.alloc(0);
297
+
298
+ req.on('data', (chunk: Buffer) => {
299
+ if (data === null) return; // already aborted
300
+
301
+ // BUG FIX: the size check must happen AFTER concatenating the new chunk,
302
+ // not before. Checking before allowed a stream of (maxLength-1)-byte chunks
303
+ // to bypass the limit entirely.
304
+ // BUG FIX: the original code referenced `chink` (typo) instead of `chunk`.
305
+ const next_ = Buffer.concat([data, chunk]);
306
+ if (next_.length > maxLength) {
307
+ data = null;
308
+ res.status(413).send('Content Too Large');
309
+ return;
310
+ }
311
+ data = next_;
312
+ });
313
+
314
+ req.on('end', () => {
315
+ if (data === null) return; // aborted during streaming
316
+
317
+ // zlib types require NonSharedBuffer; Buffer satisfies this at runtime.
318
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
319
+ decompress(data as any, (err, decompressed) => {
320
+ if (err) return void res.status(500).send(err.message);
321
+ callback(contentType, decompressed as Buffer);
322
+ });
323
+ });
324
+ }
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // Body parsers
328
+ // ---------------------------------------------------------------------------
329
+
330
+ /**
331
+ * Parse a collected body buffer as plain text, decode it using the charset
332
+ * declared in `contentType`, and assign the result to `req.body`.
333
+ *
334
+ * On success, calls `next()`. On failure, sends a 500 Internal Server Error.
335
+ *
336
+ * @param req - The incoming request (mutated: `req.body` is set).
337
+ * @param res - The outgoing response.
338
+ * @param next - Called on successful parsing.
339
+ * @param contentType - The raw `Content-Type` header value.
340
+ * @param data - The raw body buffer.
341
+ */
342
+ function readBodyAsPlainText(
343
+ req: RouterRequest,
344
+ res: RouterResponse,
345
+ next: () => void,
346
+ contentType: string,
347
+ data: Buffer,
348
+ ): void {
349
+ const charset = extractCharset(contentType);
350
+ try {
351
+ (req as any).body = data.toString(charset as BufferEncoding);
352
+ next();
353
+ } catch (ex) {
354
+ res.status(500).send((ex as Error).message);
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Parse a collected body buffer as JSON, decode it using the charset declared
360
+ * in `contentType`, apply an optional `reviver`, and assign the result to
361
+ * `req.body`.
362
+ *
363
+ * On success, calls `next()`. On failure (invalid JSON or unsupported charset),
364
+ * sends a 500 Internal Server Error.
365
+ *
366
+ * @param req - The incoming request (mutated: `req.body` is set).
367
+ * @param res - The outgoing response.
368
+ * @param next - Called on successful parsing.
369
+ * @param opts - Resolved options; `opts.reviver` is passed to `JSON.parse`.
370
+ * @param contentType - The raw `Content-Type` header value.
371
+ * @param data - The raw body buffer.
372
+ */
373
+ function readBodyAsJson(
374
+ req: RouterRequest,
375
+ res: RouterResponse,
376
+ next: () => void,
377
+ opts: ResolvedBodyOptions,
378
+ contentType: string,
379
+ data: Buffer,
380
+ ): void {
381
+ const charset = extractCharset(contentType);
382
+ try {
383
+ (req as any).body = JSON.parse(
384
+ data.toString(charset as BufferEncoding),
385
+ opts.reviver ?? undefined,
386
+ );
387
+ next();
388
+ } catch (ex) {
389
+ res.status(500).send((ex as Error).message);
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Parse a collected body buffer as `multipart/form-data`, split it on the
395
+ * boundary declared in `contentType`, parse each part's headers, and assign
396
+ * an array of {@link FormPart} objects to `req.body`.
397
+ *
398
+ * On success, calls `next()`. On failure (missing boundary, malformed parts),
399
+ * sends a 500 Internal Server Error.
400
+ *
401
+ * **Multipart wire format recap:**
402
+ * ```
403
+ * --boundary\r\n
404
+ * Header: value\r\n
405
+ * \r\n
406
+ * <binary content>
407
+ * --boundary\r\n
408
+ * ...
409
+ * --boundary--\r\n
410
+ * ```
411
+ * The boundary string in `Content-Type` does **not** include the leading `--`;
412
+ * actual part delimiters on the wire are `\r\n--boundary`.
413
+ *
414
+ * @param req - The incoming request (mutated: `req.body` is set).
415
+ * @param res - The outgoing response.
416
+ * @param next - Called on successful parsing.
417
+ * @param contentType - The raw `Content-Type` header value (must include
418
+ * `boundary=<value>`).
419
+ * @param data - The raw body buffer.
420
+ */
421
+ function readBodyAsFormData(
422
+ req: RouterRequest,
423
+ res: RouterResponse,
424
+ next: () => void,
425
+ contentType: string,
426
+ data: Buffer,
427
+ ): void {
428
+ // BUG FIX: the original regex used `'s+$` (literal quote) instead of `\s+$`.
429
+ const boundary = contentType
430
+ .split(';')
431
+ .map((s) => s.replace(/^\s+|\s+$/g, ''))
432
+ .find((s) => s.startsWith('boundary='))
433
+ ?.substring('boundary='.length);
434
+
435
+ if (!boundary)
436
+ return void res.status(400).send('Bad Request: missing multipart boundary');
437
+
438
+ try {
439
+ // Wire-level delimiter: each part (after the preamble) is preceded by
440
+ // \r\n--boundary. We split on this sequence so every resulting slice is
441
+ // the raw content of one part (headers + blank line + body), without any
442
+ // leading delimiter bytes.
443
+ // BUG FIX: the original code passed `buffer` (undefined) instead of `data`.
444
+ const delimiter = Buffer.from(`\r\n--${boundary}`);
445
+
446
+ // Prepend \r\n so the very first part is also cleanly split.
447
+ const normalized = Buffer.concat([Buffer.from('\r\n'), data]);
448
+ const rawParts = splitBuffer(normalized, delimiter);
449
+
450
+ const parts: FormPart[] = [];
451
+
452
+ for (const part of rawParts) {
453
+ // The closing delimiter ends with '--'; skip it.
454
+ // BUG FIX: the original check `buf.length == 2 && buf.toString() == '--'`
455
+ // was incorrect. After splitting on \r\n--boundary, the terminal entry
456
+ // is '--\r\n' (or just '--'), not a 2-byte '--'. Use startsWith.
457
+ if (part.toString('utf8', 0, 2) === '--') continue;
458
+
459
+ // Each part begins with \r\n (from after the delimiter), then headers,
460
+ // then \r\n\r\n (blank line), then content.
461
+ // Skip the leading \r\n.
462
+ const partContent = part.slice(2);
463
+ const blankLine = Buffer.from('\r\n\r\n');
464
+ const blankIdx = partContent.indexOf(blankLine);
465
+
466
+ if (blankIdx === -1) continue; // malformed part — skip
467
+
468
+ const headerSection = partContent.slice(0, blankIdx).toString('utf8');
469
+ const content = partContent.slice(blankIdx + blankLine.length);
470
+ const headers: Record<string, string> = {};
471
+
472
+ for (const line of headerSection.split('\r\n')) {
473
+ if (!line) continue;
474
+ const colonIdx = line.indexOf(':');
475
+ if (colonIdx === -1) continue;
476
+ const key = line.substring(0, colonIdx).replace(/^\s+|\s+$/g, '').toLowerCase();
477
+ const value = line.substring(colonIdx + 1).replace(/^\s+|\s+$/g, '');
478
+ headers[key] = value;
479
+ }
480
+
481
+ parts.push({ headers, content });
482
+ }
483
+
484
+ (req as any).body = parts;
485
+ next();
486
+ } catch (ex) {
487
+ res.status(500).send((ex as Error).message);
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Dispatch table mapping a MIME type to its body-parser function.
493
+ * Used by {@link parseBody} to select the appropriate parser at runtime.
494
+ */
495
+ const BODY_READERS: Record<
496
+ string,
497
+ (req: RouterRequest, res: RouterResponse, next: () => void, opts: ResolvedBodyOptions, contentType: string, data: Buffer) => void
498
+ > = {
499
+ 'multipart/form-data': (req, res, next, _opts, ct, data) =>
500
+ readBodyAsFormData(req, res, next, ct, data),
501
+ 'application/json': (req, res, next, opts, ct, data) =>
502
+ readBodyAsJson(req, res, next, opts, ct, data),
503
+ 'text/plain': (req, res, next, _opts, ct, data) =>
504
+ readBodyAsPlainText(req, res, next, ct, data),
505
+ };
506
+
507
+ // ---------------------------------------------------------------------------
508
+ // Public middleware factories
509
+ // ---------------------------------------------------------------------------
510
+
511
+ /**
512
+ * Middleware factory that parses a `application/json` request body and
513
+ * assigns the parsed value to `req.body`.
514
+ *
515
+ * Also attaches a `res.json(data)` helper to the response object so that
516
+ * handlers can send JSON responses conveniently:
517
+ * ```ts
518
+ * res.json({ ok: true });
519
+ * ```
520
+ *
521
+ * Behaviour:
522
+ * - Requests without a body (`Content-Length: 0` or absent) are passed
523
+ * through to `next()` without touching `req.body`.
524
+ * - Bodies larger than `opts.limit` receive **413 Content Too Large**.
525
+ * - Bodies with an unsupported `Content-Encoding` receive
526
+ * **415 Unsupported Media Type**.
527
+ * - Bodies whose `Content-Type` is not `application/json` receive
528
+ * **415 Unsupported Media Type**.
529
+ * - Parse errors receive **500 Internal Server Error**.
530
+ *
531
+ * @param opts - Optional configuration (see {@link BodyOptions}).
532
+ * @returns An Express-compatible middleware function.
533
+ */
534
+ export function json(opts?: BodyOptions): Middleware {
535
+ const resolved: ResolvedBodyOptions = {
536
+ inflate: true,
537
+ limit: '100kb',
538
+ reviver: null,
539
+ strict: true,
540
+ ...opts,
541
+ };
542
+
543
+ return (req: RouterRequest, res: RouterResponse, next: () => void): void => {
544
+ // Attach a JSON response helper so handlers can call res.json(data).
545
+ (res as any).json = (data: unknown): void => {
546
+ res.write(JSON.stringify(data));
547
+ res.end();
548
+ };
549
+ readBody(req, res, resolved, 'application/json', next, (contentType, body) => {
550
+ readBodyAsJson(req, res, next, resolved, contentType, body);
551
+ });
552
+ };
553
+ }
554
+
555
+ /**
556
+ * Middleware factory that parses a `multipart/form-data` request body and
557
+ * assigns an array of {@link FormPart} objects to `req.body`.
558
+ *
559
+ * Each element in `req.body` exposes:
560
+ * - `headers` — the part's MIME headers (e.g. `Content-Disposition`).
561
+ * - `content` — the raw binary content of the part as a `Buffer`.
562
+ *
563
+ * Behaviour:
564
+ * - Requests without a body are passed through to `next()`.
565
+ * - Bodies larger than `opts.limit` receive **413 Content Too Large**.
566
+ * - Bodies with a missing or malformed `boundary` parameter receive
567
+ * **400 Bad Request**.
568
+ * - Parse errors receive **500 Internal Server Error**.
569
+ *
570
+ * @param opts - Optional configuration (see {@link BodyOptions}).
571
+ * @returns An Express-compatible middleware function.
572
+ */
573
+ export function formData(opts?: BodyOptions): Middleware {
574
+ const resolved: ResolvedBodyOptions = {
575
+ inflate: true,
576
+ limit: '100kb',
577
+ reviver: null,
578
+ strict: true,
579
+ ...opts,
580
+ };
581
+
582
+ return (req: RouterRequest, res: RouterResponse, next: () => void): void => {
583
+ readBody(req, res, resolved, 'multipart/form-data', next, (contentType, body) => {
584
+ readBodyAsFormData(req, res, next, contentType, body);
585
+ });
586
+ };
587
+ }
588
+
589
+ /**
590
+ * Middleware factory that auto-detects the `Content-Type` of the request body
591
+ * and parses it using the appropriate parser.
592
+ *
593
+ * Supported MIME types:
594
+ * - `application/json` → parsed as JSON; result is a JS value.
595
+ * - `multipart/form-data` → parsed as multipart; result is `FormPart[]`.
596
+ * - `text/plain` → decoded as text; result is a string.
597
+ *
598
+ * Requests with an unsupported MIME type receive **415 Unsupported Media Type**.
599
+ * All other error conditions behave identically to {@link json} and
600
+ * {@link formData}.
601
+ *
602
+ * @param opts - Optional configuration (see {@link BodyOptions}).
603
+ * @returns An Express-compatible middleware function.
604
+ */
605
+ export function parseBody(opts?: BodyOptions): Middleware {
606
+ const resolved: ResolvedBodyOptions = {
607
+ inflate: true,
608
+ limit: '100kb',
609
+ reviver: null,
610
+ strict: true,
611
+ ...opts,
612
+ };
613
+
614
+ return (req: RouterRequest, res: RouterResponse, next: () => void): void => {
615
+ readBody(req, res, resolved, null, next, (contentType, body) => {
616
+ const mimetype = contentType.split(';')[0].trim();
617
+ if (!BODY_READERS[mimetype])
618
+ return res.status(415).send('Unsupported Media Type');
619
+ BODY_READERS[mimetype](req, res, next, resolved, contentType, body);
620
+ });
621
+ };
622
+ }
623
+
624
+ // ---------------------------------------------------------------------------
625
+ // Logger middleware
626
+ // ---------------------------------------------------------------------------
627
+
628
+ /**
629
+ * ANSI terminal colour codes indexed by HTTP status class (1xx–5xx).
630
+ *
631
+ * Index 0 is unused (no HTTP 0xx class).
632
+ * - 1xx → yellow (`\x1b[33m`)
633
+ * - 2xx → green (`\x1b[32m`)
634
+ * - 3xx → yellow (`\x1b[33m`)
635
+ * - 4xx → red (`\x1b[31m`)
636
+ * - 5xx → bright red (`\x1b[91m`)
637
+ */
638
+ const STATUS_COLORS: string[] = [
639
+ '\x1b[0m', // 0 — fallback / unknown
640
+ '\x1b[33m', // 1xx — informational (yellow)
641
+ '\x1b[32m', // 2xx — success (green)
642
+ '\x1b[33m', // 3xx — redirection (yellow)
643
+ '\x1b[31m', // 4xx — client error (red)
644
+ '\x1b[91m', // 5xx — server error (bright red)
645
+ ];
646
+
647
+ /** ANSI reset escape sequence. */
648
+ const ANSI_RESET = '\x1b[0m';
649
+
650
+ /**
651
+ * Middleware factory that logs one line per completed HTTP request to the
652
+ * console (or a custom logger function).
653
+ *
654
+ * Each log line contains:
655
+ * - Timestamp (formatted with `Intl.DateTimeFormat`).
656
+ * - HTTP status code (ANSI-coloured by status class).
657
+ * - HTTP method and request path.
658
+ * - Client IP address (honours `X-Forwarded-For`).
659
+ * - User identity (from `opts.user` or `'-'`).
660
+ * - Elapsed time in milliseconds.
661
+ * - Response `Content-Length` (or `'-'` when absent).
662
+ *
663
+ * **Lost-request tracking:** when `opts.track` is `true`, a timer is started
664
+ * for each request. If the response is not finished within `opts.trackTimeout`
665
+ * milliseconds, a `LOST` warning line is emitted. This is useful in
666
+ * development to surface handlers that never call `res.end()`.
667
+ *
668
+ * @param opts - Optional configuration (see {@link LoggerOptions}).
669
+ * @returns An Express-compatible middleware function.
670
+ *
671
+ * @example
672
+ * ```ts
673
+ * app.use('/', logger({
674
+ * track: true,
675
+ * trackTimeout: 30_000,
676
+ * user: (req) => (req as any).authUser ?? '-',
677
+ * locale: 'en-US',
678
+ * logger: (msg) => process.stderr.write(msg + '\n'),
679
+ * }));
680
+ * ```
681
+ */
682
+ export function logger(opts?: LoggerOptions): Middleware {
683
+ const options = opts ?? {};
684
+ const log = options.logger ?? console.log;
685
+
686
+ const formatter = new Intl.DateTimeFormat(
687
+ options.locale ?? 'en-GB',
688
+ options.dateFormat ?? {
689
+ month: 'short',
690
+ day: '2-digit',
691
+ hour: '2-digit',
692
+ minute: '2-digit',
693
+ },
694
+ );
695
+
696
+ return (req: RouterRequest, res: RouterResponse, next: () => void): void => {
697
+ // Capture timestamp and path at request-arrival time so they are stable
698
+ // even if later middleware mutates req.path (e.g. prefix stripping).
699
+ const timestamp = formatter.format(new Date());
700
+ const requestPath = req.path ?? (req as any).url ?? '/';
701
+ const ip = (req.headers['x-forwarded-for'] as string | undefined)
702
+ ?? (req as any).socket?.remoteAddress
703
+ ?? '-';
704
+ const user = options.user?.(req) ?? '-';
705
+ const receivedAt = Date.now();
706
+
707
+ // Lost-request tracking.
708
+ const tracker =
709
+ options.track === true
710
+ ? setTimeout(() => {
711
+ log(`${timestamp} LOST ${req.method} ${requestPath} ${ip} <${user}>`);
712
+ }, options.trackTimeout ?? 30_000)
713
+ : null;
714
+
715
+ res.on('finish', () => {
716
+ if (tracker !== null) clearTimeout(tracker);
717
+
718
+ const elapsed = Date.now() - receivedAt;
719
+ // BUG FIX: Math.floor is more explicit and correct than parseInt for
720
+ // integer division. Both work, but parseInt(200/100) has a subtle
721
+ // coercion to string first. Math.floor is semantically clearer.
722
+ const statusClass = Math.floor(res.statusCode / 100);
723
+ const colour = STATUS_COLORS[statusClass] ?? STATUS_COLORS[0];
724
+ const statusStr = `${colour}${res.statusCode}${ANSI_RESET}`;
725
+ const contentLen = res.getHeader('content-length') ?? '-';
726
+
727
+ log(`${timestamp} ${statusStr} ${req.method} ${requestPath} ${ip} <${user}> ${elapsed}ms (${contentLen})`);
728
+ });
729
+
730
+ next();
731
+ };
732
+ }
733
+
734
+ export default { json, formData, parseBody, logger };