expediate 1.0.1 → 1.0.3

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