expediate 0.0.3 → 1.0.0

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/static.js ADDED
@@ -0,0 +1,703 @@
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.mime = void 0;
27
+ exports.sendFile = sendFile;
28
+ exports.serveStatic = serveStatic;
29
+ exports.serveFile = serveFile;
30
+ const fs_1 = __importDefault(require("fs"));
31
+ const path_1 = __importDefault(require("path"));
32
+ const mime_types = new Map();
33
+ const mime_extensions = new Map();
34
+ function mime_define(map) {
35
+ for (var type in map) {
36
+ var exts = map[type];
37
+ for (var i = 0; i < exts.length; i++)
38
+ mime_types.set(exts[i], type);
39
+ if (!mime_extensions.has(type))
40
+ mime_extensions.set(type, exts[0]);
41
+ }
42
+ }
43
+ ;
44
+ exports.mime = {
45
+ lookup: (path, fallback = null) => mime_types.get(path.replace(/^.*[\.\/\\]/, '').toLowerCase()) ?? fallback ?? 'application/octet-stream',
46
+ charsets: (mimeType) => (/^text\/|^application\/(javascript|json)/).test(mimeType) ? 'UTF-8' : null,
47
+ };
48
+ mime_define(require('./mimetypes.json'));
49
+ // ---------------------------------------------------------------------------
50
+ // Constants
51
+ // ---------------------------------------------------------------------------
52
+ /**
53
+ * Regular expression that detects a directory-traversal component (`..`) in
54
+ * any position of a URL path, including encoded forms using back-slashes.
55
+ */
56
+ const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/;
57
+ /** Default security and caching options applied to every response. */
58
+ const DEFAULT_OPTIONS = {
59
+ headers: {
60
+ 'Content-Security-Policy': "default-src 'none'",
61
+ 'X-Content-Type-Options': 'nosniff',
62
+ },
63
+ fallthrough: false,
64
+ maxage: 0,
65
+ immutable: false,
66
+ etag: true,
67
+ lastModified: true,
68
+ contentType: null,
69
+ dotfiles: 'hide',
70
+ redirect: true,
71
+ indexOf: false,
72
+ };
73
+ // ---------------------------------------------------------------------------
74
+ // HTTP response helpers
75
+ // ---------------------------------------------------------------------------
76
+ /**
77
+ * Collection of pre-built HTTP error/success response helpers.
78
+ * Each helper sets the appropriate status code, merges the caller's custom
79
+ * headers, and terminates the response.
80
+ */
81
+ const HTTP = {
82
+ /** 304 Not Modified — sent for conditional GET cache hits. */
83
+ NOT_MODIFIED: (res, opts) => { res.status(304, opts.headers).end(); },
84
+ /** 403 Forbidden — sent for denied dot-files or path traversal attempts. */
85
+ FORBIDDEN: (res, opts) => res.status(403, opts.headers).send('Forbidden'),
86
+ /** 404 Not Found — sent when the requested file does not exist. */
87
+ NOT_FOUND: (res, opts) => res.status(404, opts.headers).send('Not Found'),
88
+ /**
89
+ * 405 Method Not Allowed — sent when the HTTP method is neither GET nor
90
+ * HEAD and {@link ResolvedOptions.fallthrough} is `false`.
91
+ * Always includes an `Allow: GET, HEAD` header per RFC 7231 §6.5.5.
92
+ */
93
+ NOT_ALLOWED: (res, opts) => { res.status(405, { ...opts.headers, Allow: 'GET, HEAD' }).end(); },
94
+ /** 412 Precondition Failed — sent when `If-Match` / `If-Unmodified-Since` fails. */
95
+ PRECONDITION_FAILS: (res, opts) => res.status(412, opts.headers).send('Precondition Failed'),
96
+ /** 500 Internal Server Error — sent on unexpected filesystem or stream errors. */
97
+ INTERNAL_ERROR: (res, opts, err) => res.status(500, opts.headers).send(`Internal error: ${err}`),
98
+ };
99
+ // ---------------------------------------------------------------------------
100
+ // Internal utilities
101
+ // ---------------------------------------------------------------------------
102
+ /**
103
+ * Safely destroy a `fs.ReadStream`, working around a Node.js core bug where
104
+ * streams that have not yet emitted `'open'` do not close their file
105
+ * descriptor when `destroy()` is called.
106
+ *
107
+ * @param stream - The readable stream to destroy.
108
+ */
109
+ function destroyReadStream(stream) {
110
+ stream.destroy();
111
+ if (typeof stream.close === 'function') {
112
+ // Node.js core bug work-around: if the stream has not yet opened the file,
113
+ // `destroy()` will not close the fd. Listening for 'open' ensures we close
114
+ // it as soon as the fd becomes available.
115
+ stream.on('open', () => {
116
+ if (typeof stream.fd === 'number')
117
+ stream.close();
118
+ });
119
+ }
120
+ }
121
+ /**
122
+ * Remove all `Content-*` headers from the response, **except**
123
+ * `Content-Location`.
124
+ *
125
+ * Called before sending a 304 Not Modified response, as RFC 7232 §4.1
126
+ * requires that the response body and most content metadata headers be
127
+ * omitted in that case.
128
+ *
129
+ * @param res - The server response whose headers will be mutated.
130
+ */
131
+ function removeContentHeaders(res) {
132
+ const keys = Object.keys(res.getHeaders() ?? {});
133
+ for (const key of keys) {
134
+ if (key.startsWith('content-') && key !== 'content-location')
135
+ res.removeHeader(key);
136
+ }
137
+ }
138
+ /**
139
+ * Build a weak ETag string from a file's `stat` metadata.
140
+ *
141
+ * The format is `W/"<size_hex>-<mtime_hex>"`, matching the convention used by
142
+ * Node's `serve-static` and compatible with all major HTTP clients.
143
+ *
144
+ * @param stat - The `fs.Stats` object for the file.
145
+ * @returns A weak ETag string.
146
+ */
147
+ function createETag(stat) {
148
+ const mtime = stat.mtime.getTime().toString(16);
149
+ const size = stat.size.toString(16);
150
+ return `W/"${size}-${mtime}"`;
151
+ }
152
+ /**
153
+ * Parse a comma-separated HTTP token list (e.g. the value of an `ETag` or
154
+ * `Accept` header) into an array of trimmed token strings.
155
+ *
156
+ * Follows the token-list grammar from RFC 7230 §3.2.6: tokens are separated
157
+ * by commas, leading/trailing spaces around each token are ignored.
158
+ *
159
+ * @param str - The raw header value string.
160
+ * @returns An array of individual token strings (may be empty).
161
+ */
162
+ function parseTokenList(str) {
163
+ const list = [];
164
+ let start = 0;
165
+ let end = 0;
166
+ for (let i = 0, len = str.length; i < len; i++) {
167
+ const ch = str.charCodeAt(i);
168
+ if (ch === 0x20 /* space */) {
169
+ if (start === end)
170
+ start = end = i + 1;
171
+ }
172
+ else if (ch === 0x2c /* comma */) {
173
+ list.push(str.substring(start, end));
174
+ start = end = i + 1;
175
+ }
176
+ else {
177
+ end = i + 1;
178
+ }
179
+ }
180
+ // Push the final token (there is always at least one).
181
+ list.push(str.substring(start, end));
182
+ return list;
183
+ }
184
+ /**
185
+ * Parse an HTTP-date string (e.g. `Thu, 01 Jan 1970 00:00:00 GMT`) into a
186
+ * Unix timestamp in milliseconds.
187
+ *
188
+ * @param date - The raw date string from an HTTP header.
189
+ * @returns A numeric timestamp, or `NaN` if parsing fails.
190
+ */
191
+ function parseHttpDate(date) {
192
+ const timestamp = date ? Date.parse(date) : NaN;
193
+ return typeof timestamp === 'number' ? timestamp : NaN;
194
+ }
195
+ /**
196
+ * Return `true` when the request carries at least one conditional header
197
+ * (`If-Match`, `If-Unmodified-Since`, `If-None-Match`, or
198
+ * `If-Modified-Since`).
199
+ *
200
+ * @param headers - The incoming request's header map.
201
+ */
202
+ function hasCondition(headers) {
203
+ return !!(headers['if-match'] ||
204
+ headers['if-unmodified-since'] ||
205
+ headers['if-none-match'] ||
206
+ headers['if-modified-since']);
207
+ }
208
+ /**
209
+ * Evaluate precondition headers (`If-Match` / `If-Unmodified-Since`) against
210
+ * the current response headers and return whether the precondition is
211
+ * satisfied.
212
+ *
213
+ * Used to decide whether to proceed with the response (`true`) or send
214
+ * 412 Precondition Failed (`false`).
215
+ *
216
+ * Implements the subset defined in RFC 7232 §6 (step 3 and step 5):
217
+ * - **`If-Match`** — matches when the response ETag equals the request ETag,
218
+ * or when the request value is `*`.
219
+ * - **`If-Unmodified-Since`** — matches when the file has not been modified
220
+ * since the given date.
221
+ *
222
+ * @param reqHeaders - Normalised (lowercase) request headers.
223
+ * @param resHeaders - Current response headers (as returned by
224
+ * `res.getHeaders()`).
225
+ * @returns `true` if the precondition is satisfied.
226
+ */
227
+ function conditionMatch(reqHeaders, resHeaders) {
228
+ // --- If-Match ---
229
+ const match = reqHeaders['if-match'];
230
+ if (match) {
231
+ const etag = resHeaders['etag'];
232
+ if (match === '*' || match === etag)
233
+ return true;
234
+ for (const tag of parseTokenList(match)) {
235
+ if (tag === etag || `W/${tag}` === etag || tag === `W/${etag}`)
236
+ return true;
237
+ }
238
+ // If-Match was present but none of the tags matched → precondition failed.
239
+ return false;
240
+ }
241
+ // --- If-Unmodified-Since ---
242
+ // BUG FIX: the original code read req['if-modified-since'] here, which is
243
+ // the wrong header. The correct header for this precondition is
244
+ // 'if-unmodified-since'.
245
+ const lastModified = parseHttpDate(resHeaders['last-modified']);
246
+ const unmodifiedSince = parseHttpDate(reqHeaders['if-unmodified-since']);
247
+ if (!isNaN(unmodifiedSince) && !isNaN(lastModified))
248
+ return lastModified <= unmodifiedSince;
249
+ return false;
250
+ }
251
+ /**
252
+ * Determine whether a cached response is still fresh by evaluating the
253
+ * `If-None-Match` and `If-Modified-Since` conditional headers.
254
+ *
255
+ * Returns `true` only when the response can safely be replaced by a
256
+ * 304 Not Modified. Returns `false` (stale) when:
257
+ * - No conditional header is present (unconditional request).
258
+ * - `Cache-Control: no-cache` is set.
259
+ * - The ETag or modification time no longer matches.
260
+ *
261
+ * Implements RFC 7232 §6 (steps 4 and 5) and RFC 7234 §5.2.
262
+ *
263
+ * @param reqHeaders - Normalised (lowercase) request headers.
264
+ * @param resHeaders - Current response headers.
265
+ * @returns `true` if the cached response is fresh and a 304 should be sent.
266
+ */
267
+ function isCacheFresh(reqHeaders, resHeaders) {
268
+ const CACHE_CONTROL_NO_CACHE_REGEXP = /(?:^|,)\s*?no-cache\s*?(?:,|$)/;
269
+ const modifiedSince = reqHeaders['if-modified-since'];
270
+ const noneMatch = reqHeaders['if-none-match'];
271
+ // Unconditional request — always treat as stale.
272
+ if (!modifiedSince && !noneMatch)
273
+ return false;
274
+ // Explicit no-cache directive forces revalidation even if ETags match.
275
+ const cacheControl = reqHeaders['cache-control'];
276
+ if (cacheControl && CACHE_CONTROL_NO_CACHE_REGEXP.test(cacheControl))
277
+ return false;
278
+ // --- If-None-Match ---
279
+ if (noneMatch && noneMatch !== '*') {
280
+ const etag = resHeaders['etag'];
281
+ if (!etag)
282
+ return false;
283
+ let etagStale = true;
284
+ for (const match of parseTokenList(noneMatch)) {
285
+ if (match === etag || `W/${match}` === etag || match === `W/${etag}`) {
286
+ etagStale = false;
287
+ break;
288
+ }
289
+ }
290
+ if (etagStale)
291
+ return false;
292
+ }
293
+ // --- If-Modified-Since ---
294
+ if (modifiedSince) {
295
+ const lastModified = resHeaders['last-modified'];
296
+ if (!lastModified)
297
+ return false;
298
+ if (!(parseHttpDate(lastModified) <= parseHttpDate(modifiedSince)))
299
+ return false;
300
+ }
301
+ return true;
302
+ }
303
+ // ---------------------------------------------------------------------------
304
+ // Options resolution
305
+ // ---------------------------------------------------------------------------
306
+ /**
307
+ * Validate and resolve the `root` path and caller-supplied `options` into a
308
+ * fully populated {@link ResolvedOptions} object.
309
+ *
310
+ * Applies the following normalisation steps:
311
+ * - `root` is resolved to an absolute path via `path.resolve()`.
312
+ * - `fallthrough` and `redirect` default to `false` only when explicitly set
313
+ * to `false`; any other value (including `undefined`) is treated as `true`.
314
+ * - `maxage` falls back to `maxAge` (camelCase alias) and then to `0`.
315
+ *
316
+ * @param root - The root directory or file path to serve from.
317
+ * @param options - Caller-supplied options (partial, merged with defaults).
318
+ * @returns A fully resolved options object.
319
+ * @throws {TypeError} When `root` is falsy or not a string.
320
+ */
321
+ function resolveOptions(root, options) {
322
+ if (!root)
323
+ throw new TypeError('root path required');
324
+ if (typeof root !== 'string')
325
+ throw new TypeError('root path must be a string');
326
+ // Deep-merge headers so caller-supplied headers *extend* the defaults
327
+ // rather than replacing them entirely.
328
+ const mergedHeaders = { ...DEFAULT_OPTIONS.headers, ...(options?.headers ?? {}) };
329
+ const opts = { ...DEFAULT_OPTIONS, ...options, headers: mergedHeaders };
330
+ opts.fallthrough = options?.fallthrough !== false;
331
+ opts.redirect = options?.redirect !== false;
332
+ opts.maxage = options?.maxage ?? options?.maxAge ?? 0;
333
+ opts.root = path_1.default.resolve(root);
334
+ return opts;
335
+ }
336
+ /**
337
+ * Asynchronously render an Apache-style directory-listing HTML page for the
338
+ * given directory and invoke `callback` with the result.
339
+ *
340
+ * The generated page lists every entry in `directoryPath`, with icons, names,
341
+ * last-modification timestamps, and file sizes. An optional parent-directory
342
+ * link is shown when `parentUrlPath` is provided.
343
+ *
344
+ * > **TODO** — Implement column sorting via `?C=N`, `?C=M`, `?C=S`, `?C=D`
345
+ * > (name / modified / size / description) combined with `?O=A` / `?O=D`
346
+ * > (ascending / descending). The current implementation always sorts by name
347
+ * > ascending.
348
+ *
349
+ * @param urlPath - The URL path displayed in the page title and heading.
350
+ * @param directoryPath - Absolute filesystem path of the directory to list.
351
+ * @param parentUrlPath - URL of the parent directory, or `null` when at root.
352
+ * @param callback - Called with `(html, null)` on success or
353
+ * `(null, err)` on failure.
354
+ */
355
+ function writeIndexOf(urlPath, directoryPath, parentUrlPath, callback) {
356
+ // BUG FIX: the original signature had a `queries` parameter as first arg and
357
+ // a `path` parameter (shadowing the `path` module) as second. Both were
358
+ // unused or misused. The signature is corrected here: `urlPath` is the
359
+ // display path, `directoryPath` is the fs path, `parentUrlPath` is optional.
360
+ fs_1.default.readdir(directoryPath, (err, files) => {
361
+ if (err)
362
+ return callback(null, err);
363
+ let html = '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">\n';
364
+ html += '<html>\n';
365
+ html += `<head><title>Index of ${urlPath}</title></head>\n`;
366
+ html += `<body><h1>Index of ${urlPath}</h1><table>\n`;
367
+ html += '<tr>'
368
+ + '<th valign="top"><img src="/icons/blank.gif" alt="[ICO]"></th>'
369
+ + '<th><a href="?C=N;O=D">Name</a></th>'
370
+ + '<th><a href="?C=M;O=A">Last modified</a></th>'
371
+ + '<th><a href="?C=S;O=A">Size</a></th>'
372
+ + '<th><a href="?C=D;O=A">Description</a></th>'
373
+ + '</tr>\n';
374
+ html += '<tr><th colspan="5"><hr></th></tr>\n';
375
+ if (parentUrlPath)
376
+ html += `<tr>`
377
+ + `<td valign="top"><img src="/icons/back.gif" alt="[PARENTDIR]"></td>`
378
+ + `<td><a href="${parentUrlPath}">Parent Directory</a></td>`
379
+ + `<td>&nbsp;</td><td align="right"> - </td><td>&nbsp;</td>`
380
+ + `</tr>\n`;
381
+ for (const file of files) {
382
+ // BUG FIX: the original code passed `pathname` (undefined in this scope)
383
+ // to mime.lookup(). The correct argument is the full path of the entry.
384
+ // BUG FIX: the original code used `path.join(...)` where `path` was
385
+ // shadowed by the parameter name. Use `nodePath` (the imported module).
386
+ const fullPath = path_1.default.join(directoryPath, file);
387
+ let stat;
388
+ try {
389
+ stat = fs_1.default.statSync(fullPath);
390
+ }
391
+ catch {
392
+ continue; // skip entries that disappeared between readdir and stat
393
+ }
394
+ const mimeType = exports.mime.lookup(fullPath, '');
395
+ const mediaType = mimeType.includes('/') ? mimeType.split('/')[0] : '';
396
+ const alt = stat.isDirectory() ? 'folder' : (mediaType || 'unknown');
397
+ const icon = `/icons/${alt}.gif`;
398
+ const name = file + (stat.isDirectory() ? '/' : '');
399
+ // BUG FIX: `stat.modified` does not exist on `fs.Stats`.
400
+ // The correct property is `stat.mtime`.
401
+ const modified = stat.mtime.toUTCString();
402
+ const size = stat.isDirectory() ? '-' : String(stat.size);
403
+ html += `<tr>`
404
+ + `<td valign="top"><img src="${icon}" alt="[${alt.toUpperCase()}]"></td>`
405
+ + `<td><a href="${name}">${name}</a></td>` // BUG FIX: link text was empty in original
406
+ + `<td align="right">${modified}</td>`
407
+ + `<td align="right">${size}</td>`
408
+ + `<td>&nbsp;</td>`
409
+ + `</tr>\n`;
410
+ }
411
+ html += '<tr><th colspan="5"><hr></th></tr>\n';
412
+ html += '</table><address>Expediate/1.0.0</address></body></html>\n';
413
+ // BUG FIX: the original code called go(html) without `return`, so execution
414
+ // continued to HTTP.NOT_FOUND immediately after — the callback was invoked
415
+ // and then the caller would also receive NOT_FOUND.
416
+ return callback(html, null);
417
+ });
418
+ }
419
+ // ---------------------------------------------------------------------------
420
+ // Core send logic
421
+ // ---------------------------------------------------------------------------
422
+ /**
423
+ * Set response headers and stream a file to the client.
424
+ *
425
+ * This is the inner send function shared by {@link sendFile} and the
426
+ * {@link serveStatic} middleware. It handles:
427
+ * - Custom and security headers from `opts`.
428
+ * - `Cache-Control` with optional `immutable` directive.
429
+ * - `Last-Modified` and `ETag` generation.
430
+ * - MIME-type detection via the `mime` package.
431
+ * - Conditional GET evaluation (`If-None-Match`, `If-Modified-Since`).
432
+ * - `HEAD` requests (headers sent, no body).
433
+ * - Streaming the file body with proper error handling and stream cleanup.
434
+ *
435
+ * @param req - The incoming router request.
436
+ * @param res - The outgoing router response.
437
+ * @param pathname - Absolute filesystem path of the file to send.
438
+ * @param stat - Pre-fetched `fs.Stats` for `pathname`.
439
+ * @param opts - Fully resolved static-serving options.
440
+ */
441
+ function sendIt(req, res, pathname, stat, opts) {
442
+ const len = stat.size;
443
+ const etag = createETag(stat);
444
+ // Apply caller-supplied headers first so they can be overridden by the
445
+ // cache/content headers below if necessary.
446
+ if (opts.headers) {
447
+ for (const [key, value] of Object.entries(opts.headers))
448
+ res.setHeader(key, value);
449
+ }
450
+ // Cache-Control
451
+ if (!res.getHeader('Cache-Control') && opts.maxage) {
452
+ let cacheControl = `public, max-age=${Math.floor(opts.maxage / 1000)}`;
453
+ if (opts.immutable)
454
+ cacheControl += ', immutable';
455
+ res.setHeader('Cache-Control', cacheControl);
456
+ }
457
+ // Last-Modified
458
+ if (!res.getHeader('Last-Modified') && opts.lastModified !== false)
459
+ res.setHeader('Last-Modified', stat.mtime.toUTCString());
460
+ // ETag
461
+ if (!res.getHeader('ETag') && opts.etag !== false)
462
+ res.setHeader('ETag', etag);
463
+ // Content-Type
464
+ if (!res.getHeader('Content-Type')) {
465
+ if (opts.contentType) {
466
+ res.setHeader('Content-Type', opts.contentType);
467
+ }
468
+ else {
469
+ const type = exports.mime.lookup(pathname, '');
470
+ if (type) {
471
+ const charset = exports.mime.charsets(type);
472
+ res.setHeader('Content-Type', charset ? `${type}; charset=${charset}` : type);
473
+ }
474
+ }
475
+ }
476
+ // Conditional GET — two independent RFC 7232 mechanisms evaluated in order.
477
+ // 1. Cache-validation headers (If-None-Match / If-Modified-Since).
478
+ // When the cached response is still fresh, respond with 304 Not Modified.
479
+ // This must be tested BEFORE the precondition headers so that a browser
480
+ // performing a normal ETag revalidation is not incorrectly rejected.
481
+ if (isCacheFresh(req.headers, res.getHeaders())) {
482
+ removeContentHeaders(res);
483
+ HTTP.NOT_MODIFIED(res, opts);
484
+ return;
485
+ }
486
+ // 2. Precondition headers (If-Match / If-Unmodified-Since).
487
+ // Only evaluate when these specific headers are present; If-None-Match and
488
+ // If-Modified-Since are handled above by isCacheFresh and must NOT trigger
489
+ // a 412 when conditionMatch returns false (they are cache-validation headers,
490
+ // not write-precondition headers per RFC 7232).
491
+ const hasPrecondition = !!(req.headers['if-match'] ||
492
+ req.headers['if-unmodified-since']);
493
+ if (hasPrecondition && !conditionMatch(req.headers, res.getHeaders())) {
494
+ HTTP.PRECONDITION_FAILS(res, opts);
495
+ return;
496
+ }
497
+ // --- Send the body ---
498
+ res.setHeader('Content-Length', len);
499
+ // HEAD requests: headers only, no body.
500
+ if (req.method === 'HEAD') {
501
+ res.end();
502
+ return;
503
+ }
504
+ let finished = false;
505
+ const stream = fs_1.default.createReadStream(pathname);
506
+ res.on('finish', () => {
507
+ finished = true;
508
+ destroyReadStream(stream);
509
+ });
510
+ stream.on('error', (err) => {
511
+ if (finished)
512
+ return;
513
+ console.warn('static stream error:', pathname, err);
514
+ HTTP.INTERNAL_ERROR(res, opts, err.code ?? 'UNKNOWN');
515
+ finished = true;
516
+ destroyReadStream(stream);
517
+ });
518
+ stream.on('end', () => res.end());
519
+ stream.pipe(res);
520
+ }
521
+ // ---------------------------------------------------------------------------
522
+ // Public API
523
+ // ---------------------------------------------------------------------------
524
+ /**
525
+ * Send a single file from the filesystem as an HTTP response.
526
+ *
527
+ * Unlike the {@link serveStatic} middleware, this function resolves the
528
+ * response for a **specific** `pathname` that has already been determined by
529
+ * the caller. It is useful for custom routing logic where the file path is
530
+ * computed dynamically.
531
+ *
532
+ * Behaviour:
533
+ * - When `pathname` refers to a **regular file**, it is served via
534
+ * {@link sendIt} (including ETag, Last-Modified, conditional GET, and
535
+ * MIME-type detection).
536
+ * - When `pathname` refers to a **directory** and `opts.indexOf` is `true`,
537
+ * an Apache-style directory listing is rendered.
538
+ * - Otherwise, a 404 Not Found response is sent.
539
+ *
540
+ * @param req - The incoming router request.
541
+ * @param res - The outgoing router response.
542
+ * @param pathname - Absolute filesystem path of the file to send.
543
+ * @param opts - Fully resolved static-serving options.
544
+ */
545
+ function sendFile(req, res, pathname, opts) {
546
+ fs_1.default.stat(pathname, (err, stat) => {
547
+ if (err) {
548
+ if (err.code === 'ENOENT' ||
549
+ err.code === 'ENAMETOOLONG' ||
550
+ err.code === 'ENOTDIR')
551
+ return HTTP.NOT_FOUND(res, opts);
552
+ return HTTP.INTERNAL_ERROR(res, opts, err.code ?? 'UNKNOWN');
553
+ }
554
+ if (stat.isDirectory()) {
555
+ if (opts.indexOf) {
556
+ const parentUrl = req.path !== '/' ? path_1.default.dirname(req.path) : null;
557
+ return writeIndexOf(req.path, pathname, parentUrl, (html, indexErr) => {
558
+ if (indexErr)
559
+ return HTTP.INTERNAL_ERROR(res, opts, indexErr.code ?? 'UNKNOWN');
560
+ // BUG FIX: the original code called `res.send(200, {...}).send(html)`.
561
+ // `res.send()` does not accept a status code as first argument; the
562
+ // correct call is `res.status(200, headers).send(html)`.
563
+ return res.status(200, opts.headers).send(html);
564
+ });
565
+ }
566
+ // Directory listing is disabled — treat as not found.
567
+ return HTTP.NOT_FOUND(res, opts);
568
+ }
569
+ sendIt(req, res, pathname, stat, opts);
570
+ });
571
+ }
572
+ /**
573
+ * Middleware factory that serves files from a directory on the filesystem.
574
+ *
575
+ * Mount this middleware with a path prefix to expose a public directory:
576
+ * ```ts
577
+ * app.use('/public', serveStatic('./dist'));
578
+ * ```
579
+ *
580
+ * Features:
581
+ * - **Method filtering** — only `GET` and `HEAD` are served; other methods
582
+ * receive 405 Method Not Allowed (or are forwarded to `next()` when
583
+ * `fallthrough` is `true`).
584
+ * - **Directory traversal protection** — any path containing `..` is
585
+ * rejected with 403 Forbidden.
586
+ * - **Dot-file handling** — configurable via the `dotfiles` option
587
+ * (`'allow'`, `'deny'`, or `'hide'`).
588
+ * - **Directory redirect** — a path resolving to a directory is automatically
589
+ * redirected to its `index.html` when `redirect` is `true`.
590
+ * - **Conditional GET** — ETag and Last-Modified headers enable browser and
591
+ * proxy caching with proper revalidation.
592
+ * - **Cache-Control** — configurable via `maxage` / `immutable` options.
593
+ * - **MIME-type detection** — via the `mime` npm package.
594
+ *
595
+ * @param root - Path to the directory containing the files to serve.
596
+ * Resolved to an absolute path internally.
597
+ * @param options - Optional configuration (see {@link StaticOptions}).
598
+ * @returns An Express-compatible middleware function.
599
+ * @throws {TypeError} When `root` is missing or not a string.
600
+ */
601
+ function serveStatic(root, options) {
602
+ // BUG FIX: `static` is a reserved word in strict mode (`'use strict'`).
603
+ // The function has been renamed to `serveStatic`.
604
+ const opts = resolveOptions(root, options);
605
+ return function (req, res, next) {
606
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
607
+ if (opts.fallthrough)
608
+ return next();
609
+ return HTTP.NOT_ALLOWED(res, opts);
610
+ }
611
+ // Decode the current path (after any prefix stripping by parent routers).
612
+ const originalUrl = decodeURIComponent(req.path ?? req.url ?? '/');
613
+ let pathname = originalUrl;
614
+ // When the URL ends without a trailing slash but the bare mount point was
615
+ // requested, clear the pathname so the root directory is considered.
616
+ if (pathname === '/' && !originalUrl.endsWith('/'))
617
+ pathname = '';
618
+ // Reject any path containing a directory-traversal component.
619
+ if (UP_PATH_REGEXP.test(pathname))
620
+ return HTTP.FORBIDDEN(res, opts);
621
+ // Resolve to an absolute filesystem path.
622
+ pathname = path_1.default.resolve(path_1.default.normalize(`${opts.root}/${pathname}`));
623
+ // Dot-file handling.
624
+ // BUG FIX: the original condition was `!opts.dotfiles != 'allow'` which
625
+ // always evaluates to `true` because `!opts.dotfiles` is a boolean and a
626
+ // boolean is never strictly/loosely equal to a string. The correct
627
+ // condition is `opts.dotfiles !== 'allow'`.
628
+ if (opts.dotfiles !== 'allow' && pathname.includes('/.')) {
629
+ if (opts.dotfiles === 'deny')
630
+ return HTTP.FORBIDDEN(res, opts);
631
+ return HTTP.NOT_FOUND(res, opts);
632
+ }
633
+ fs_1.default.stat(pathname, (err, stat) => {
634
+ if (err) {
635
+ if (err.code === 'ENOENT' ||
636
+ err.code === 'ENAMETOOLONG' ||
637
+ err.code === 'ENOTDIR') {
638
+ if (opts.fallthrough)
639
+ return next();
640
+ return HTTP.NOT_FOUND(res, opts);
641
+ }
642
+ return HTTP.INTERNAL_ERROR(res, opts, err.code ?? 'UNKNOWN');
643
+ }
644
+ // When the resolved path is a directory, redirect to its index.html.
645
+ if (stat.isDirectory()) {
646
+ if (!opts.redirect)
647
+ return HTTP.NOT_FOUND(res, opts);
648
+ return sendFile(req, res, path_1.default.join(pathname, 'index.html'), opts);
649
+ }
650
+ sendIt(req, res, pathname, stat, opts);
651
+ });
652
+ };
653
+ }
654
+ /**
655
+ * Middleware factory that serves a **single, fixed file** as the response to
656
+ * every request, regardless of the request path.
657
+ *
658
+ * Use this when you want every route to return the same file — for example,
659
+ * serving a compiled single-page application's `index.html` as a catch-all:
660
+ * ```ts
661
+ * app.get('/**', serveFile('./dist/index.html'));
662
+ * ```
663
+ *
664
+ * Features:
665
+ * - **Method filtering** — only `GET` and `HEAD` are served.
666
+ * - **Conditional GET** — ETag and Last-Modified are generated from the file
667
+ * metadata on every request.
668
+ * - **MIME-type detection** — via the `mime` npm package.
669
+ *
670
+ * @param filePath - Path to the file to serve. Resolved to an absolute path.
671
+ * @param options - Optional configuration (see {@link StaticOptions}).
672
+ * @returns An Express-compatible middleware function.
673
+ * @throws {TypeError} When `filePath` is missing or not a string.
674
+ */
675
+ function serveFile(filePath, options) {
676
+ const opts = resolveOptions(filePath, options);
677
+ return function (req, res, next) {
678
+ // BUG FIX: the original `file()` function had the method-check logic
679
+ // inverted. `methOk` was set to `true` when the method was NOT GET/HEAD
680
+ // (i.e. the invalid case), and then `if (!methOk)` blocked valid methods.
681
+ // The corrected logic: reject when the method is NOT GET or HEAD.
682
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
683
+ if (opts.fallthrough)
684
+ return next();
685
+ return HTTP.NOT_ALLOWED(res, opts);
686
+ }
687
+ const pathname = opts.root;
688
+ fs_1.default.stat(pathname, (err, stat) => {
689
+ if (err)
690
+ return HTTP.INTERNAL_ERROR(res, opts, err.code ?? 'UNKNOWN');
691
+ // BUG FIX: the original code referenced `err.code` inside the
692
+ // `stat.isDirectory()` branch, where `err` is guaranteed to be `null`
693
+ // (we only reach this branch when `fs.stat` succeeded). The correct
694
+ // response for a misconfigured directory path is a plain 500 with a
695
+ // descriptive message.
696
+ if (stat.isDirectory())
697
+ return HTTP.INTERNAL_ERROR(res, opts, 'EISDIR');
698
+ sendIt(req, res, pathname, stat, opts);
699
+ });
700
+ };
701
+ }
702
+ exports.default = { serveStatic, serveFile, sendFile };
703
+ //# sourceMappingURL=static.js.map