koa-classic-server 3.1.0 → 4.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.
Files changed (3) hide show
  1. package/README.md +191 -747
  2. package/index.cjs +669 -263
  3. package/package.json +3 -2
package/index.cjs CHANGED
@@ -6,7 +6,7 @@ const crypto = require("crypto");
6
6
  const zlib = require("zlib");
7
7
  const util = require("util");
8
8
  const mime = require("mime-types");
9
- const { Readable } = require('stream');
9
+ const { Readable, pipeline } = require('stream');
10
10
 
11
11
  const _brotliCompressAsync = util.promisify(zlib.brotliCompress);
12
12
  const _gzipAsync = util.promisify(zlib.gzip);
@@ -151,6 +151,7 @@ function buildErrorHtml(title, heading, message) {
151
151
  const _NOT_FOUND_HTML = buildErrorHtml('URL not found', 'Not Found', 'The requested URL was not found on this server.');
152
152
  const _GATEWAY_TIMEOUT_HTML = buildErrorHtml('Gateway Timeout', 'Gateway Timeout', 'The template took too long to render.');
153
153
  const _TEMPLATE_ERROR_HTML = buildErrorHtml('Internal Server Error', 'Internal Server Error', 'Template rendering failed for the requested resource.');
154
+ const _INTERNAL_ERROR_HTML = buildErrorHtml('Internal Server Error', 'Internal Server Error', 'The server encountered an unexpected condition.');
154
155
 
155
156
  function sendNotFound(ctx) {
156
157
  setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
@@ -192,6 +193,23 @@ function warnPayload(logger, message) {
192
193
  : [message];
193
194
  }
194
195
 
196
+ // Config-deprecation warnings, deduplicated once-per-process (per distinct
197
+ // message) so creating many middleware instances doesn't repeat the same nag —
198
+ // same intent as the _showDirContentsDeprecationWarned flag, generalized to
199
+ // multiple messages. These options (urlPrefix, urlsReserved, ...) are v2-stable,
200
+ // so a malformed value is TOLERATED with a warning for now rather than thrown:
201
+ // throwing on a stable option would be a breaking change on a minor upgrade.
202
+ // The next major will flip `warnConfigDeprecation` into a hard throw (the call
203
+ // sites already carry the final message) — see docs/revisione_codice_v3.1.md #11.
204
+ const _configDeprecationsWarned = new Set();
205
+ function warnConfigDeprecation(logger, message) {
206
+ if (_configDeprecationsWarned.has(message)) return;
207
+ _configDeprecationsWarned.add(message);
208
+ logger.warn(...warnPayload(logger,
209
+ '[koa-classic-server] DEPRECATION: ' + message +
210
+ '\n This is tolerated for now and WILL throw in a future major version.'));
211
+ }
212
+
195
213
  // Sends an error response for a failed template render. If headers were already
196
214
  // flushed by the render itself, destroys the underlying socket instead (the
197
215
  // status/body can no longer be changed at that point).
@@ -387,6 +405,24 @@ function parseRangeHeader(rangeHeader, fileSize) {
387
405
  return { start, end };
388
406
  }
389
407
 
408
+ // Evaluate an If-None-Match header against our (strong) ETag, per RFC 9110 §13.1.2.
409
+ // The header is either "*" (matches any existing representation) or a comma-separated
410
+ // list of entity-tags. The comparison is the WEAK function: a leading "W/" is ignored
411
+ // on both sides (a proxy/CDN may have weakened the validator). Returns true when the
412
+ // precondition is satisfied, i.e. the client's cached representation is still current
413
+ // and the server should answer 304 (Not Modified) for a GET/HEAD.
414
+ function ifNoneMatchSatisfied(headerValue, etag) {
415
+ if (!headerValue) return false;
416
+ const trimmed = headerValue.trim();
417
+ if (trimmed === '*') return true; // "*" matches any existing representation
418
+ const target = etag.replace(/^W\//, ''); // our ETag is strong, but strip defensively
419
+ for (const part of trimmed.split(',')) {
420
+ const tag = part.trim().replace(/^W\//, '');
421
+ if (tag && tag === target) return true;
422
+ }
423
+ return false;
424
+ }
425
+
390
426
  // LFU cache with O(1) eviction using frequency buckets.
391
427
  // peek(key) — read without touching frequency (for staleness checks)
392
428
  // get(key) — read and increment frequency
@@ -420,10 +456,17 @@ class LFUCache {
420
456
  }
421
457
 
422
458
  set(key, entry) {
459
+ // An entry larger than the whole cache can never fit: bail out BEFORE the
460
+ // eviction loop, otherwise it would flush every other entry for nothing.
461
+ // Warn (throttled) so the operator learns the cache is undersized for
462
+ // this file instead of it silently never being cached.
463
+ if (entry.buffer.length > this.maxSize) {
464
+ this._warnThrottled(`[koa-classic-server] serverCache.${this.cacheLabel}: entry of ${entry.buffer.length} bytes exceeds maxSize (${this.maxSize} bytes) and will never be cached. Consider increasing maxSize.`);
465
+ return;
466
+ }
423
467
  while (this.currentSize + entry.buffer.length > this.maxSize && this._keyMap.size > 0) {
424
468
  this._evictOne();
425
469
  }
426
- if (this.currentSize + entry.buffer.length > this.maxSize) return; // entry too large for cache
427
470
 
428
471
  this._keyMap.set(key, { ...entry, freq: 1 });
429
472
  this._addToFreqBucket(key, 1);
@@ -499,16 +542,37 @@ class LFUCache {
499
542
  bucket.delete(evictKey);
500
543
  if (bucket.size === 0) this._freqMap.delete(this._minFreq);
501
544
 
502
- if (this.warnInterval !== false) {
503
- const now = Date.now();
504
- if (now - this._lastWarnAt >= this.warnInterval) {
505
- this.logger.warn(`[koa-classic-server] serverCache.${this.cacheLabel}: maxSize reached, evicting LFU entries. Consider increasing maxSize.`);
506
- this._lastWarnAt = now;
507
- }
545
+ this._warnThrottled(`[koa-classic-server] serverCache.${this.cacheLabel}: maxSize reached, evicting LFU entries. Consider increasing maxSize.`);
546
+ }
547
+
548
+ // Emits a warning at most once per warnInterval ms (0 = always, false = never).
549
+ _warnThrottled(message) {
550
+ if (this.warnInterval === false) return;
551
+ const now = Date.now();
552
+ if (now - this._lastWarnAt >= this.warnInterval) {
553
+ this.logger.warn(message);
554
+ this._lastWarnAt = now;
508
555
  }
509
556
  }
510
557
  }
511
558
 
559
+ // Single-flight job map helper: joins the in-flight job for `key`, or starts
560
+ // `work()` as the leader. Concurrent callers share the same Promise — including
561
+ // a rejection, so on failure every waiter falls back together instead of
562
+ // re-running the work. The entry is removed as soon as the job settles
563
+ // (success or failure), so the next request after a failure retries from
564
+ // scratch and the map only ever holds jobs actually in progress.
565
+ function singleFlight(map, key, work) {
566
+ let job = map.get(key);
567
+ if (!job) {
568
+ job = work();
569
+ map.set(key, job);
570
+ const clean = () => map.delete(key);
571
+ job.then(clean, clean);
572
+ }
573
+ return job;
574
+ }
575
+
512
576
  // Upserts a fresh entry into an LFUCache. When the previous entry was only
513
577
  // stale-by-age (mtime + size unchanged), updates in place so the existing
514
578
  // frequency counter survives — important for popular files refreshed by maxAge.
@@ -560,16 +624,36 @@ module.exports = function koaClassicServer(
560
624
  // when visible entries > entriesPerPage. Page index via
561
625
  // ?page=N (0-based); out-of-range values are clamped silently.
562
626
  // Must be a finite integer >= 0; 0 = disabled (no pagination).
627
+ trailingSlash: true, // Canonical trailing-slash enforcement (V4). Default true:
628
+ // GET /dir (directory, no slash) → 301 redirect to /dir/
629
+ // GET /file/ (file, trailing slash) → 404
630
+ // so relative links in an index page resolve against the
631
+ // directory. Set false for the v3 behavior (serve directories
632
+ // and files regardless of the trailing slash).
563
633
  },
564
- index: ["index.html"], // Index file name(s) - must be an ARRAY:
565
- // - Array of strings: ["index.html", "index.htm", "default.html"]
566
- // - Array of RegExp: [/index\.html/i, /default\.(html|htm)/i]
567
- // - Mixed array: ["index.html", /index\.[eE][jJ][sS]/]
568
- // Priority is determined by array order (first match wins)
569
- urlPrefix: "", // URL path prefix
570
- urlsReserved: [], // Reserved paths (first level only)
634
+ index: [], // Index file name(s) - must be an ARRAY.
635
+ // Default: [] — no index file is looked up; directories always
636
+ // show the listing (when dirListing.enabled). Configure explicitly
637
+ // for the classic index-file behavior, e.g. ["index.html"].
638
+ // - Array of strings: ["index.html", "index.htm", "default.html"]
639
+ // - Array of RegExp: [/index\.html/i, /default\.(html|htm)/i]
640
+ // - Mixed array: ["index.html", /index\.[eE][jJ][sS]/]
641
+ // Priority is determined by array order (first match wins)
642
+ urlPrefix: "", // URL path prefix. Should start with "/" and NOT end with "/"
643
+ // (e.g. "/static"); "" disables the prefix. A malformed value
644
+ // is tolerated with a deprecation warning for now (behavior
645
+ // unchanged) and WILL throw in the next major version.
646
+ urlsReserved: [], // Reserved first-level paths passed through to next().
647
+ // Each entry should be a single first-level path: a leading
648
+ // "/" plus one segment, no further "/" (e.g. "/admin").
649
+ // Malformed entries are tolerated with a deprecation warning
650
+ // for now and WILL throw in the next major version (a
651
+ // non-string entry is dropped to avoid a per-request 500).
571
652
  template: {
572
653
  render: undefined, // Template rendering function: async (ctx, next, filePath, rawBuffer, signal) => {}
654
+ // rawBuffer (4th arg, may be null) is READ-ONLY: the same Buffer
655
+ // instance is shared with the server cache and with concurrent
656
+ // requests — mutating it corrupts other responses.
573
657
  ext: [], // File extensions to process with template.render
574
658
  renderTimeout: 30000, // Max ms allowed for template.render (number ≥ 0; 0 = disabled).
575
659
  // On timeout responds 504 Gateway Timeout. The render receives an
@@ -628,6 +712,14 @@ module.exports = function koaClassicServer(
628
712
  enabled: true, // master switch (false = disable all compression)
629
713
  encodings: ['br', 'gzip'], // algorithms in priority order; [] = disable
630
714
  minFileSize: 1024, // min file size in bytes to compress; false = no minimum
715
+ maxFileSize: 10485760, // max file size (bytes) for the buffered high-quality
716
+ // compression path (whole file in RAM → brotli Q11 →
717
+ // result cached). Default: 10 MB; false = no cap.
718
+ // Larger files are STILL compressed, but via the
719
+ // bounded-RAM streaming mode (brotli Q4 / gzip 6, no
720
+ // Content-Length, not cached). This is a SAFETY NET
721
+ // against unbounded RAM/CPU on huge compressible files
722
+ // (multi-GB logs/JSON/CSV), not a serving restriction.
631
723
  mimeTypes: [], // compressible MIME types (replaces default list if provided)
632
724
  },
633
725
  // compression: false // shorthand to disable all compression
@@ -656,8 +748,31 @@ module.exports = function koaClassicServer(
656
748
 
657
749
  const normalizedRootDir = path.resolve(rootDir);
658
750
 
659
- const options = opts || {};
660
- options.template = opts.template || {};
751
+ // Options must be a plain object, or omitted entirely (the `opts = {}`
752
+ // default covers undefined). An explicit null — or any other non-object —
753
+ // is a configuration bug: surface it with a helpful error instead of a raw
754
+ // TypeError further down (or, worse, a silent fall-through to defaults).
755
+ if (opts === null || typeof opts !== 'object' || Array.isArray(opts)) {
756
+ throw new Error(
757
+ '[koa-classic-server] options must be a plain object (or omitted entirely). Got: ' +
758
+ (opts === null ? 'null' : Array.isArray(opts) ? 'an array' : typeof opts)
759
+ );
760
+ }
761
+
762
+ // Work on a copy: the factory normalizes options in place and must never
763
+ // mutate the caller's configuration object (reusing one config for two
764
+ // instances, or inspecting it after startup, would otherwise observe the
765
+ // rewritten values). Only the two nested objects the normalization writes
766
+ // into need their own copy — template (render/ext/renderTimeout) and
767
+ // hideExtension (ext/redirect); every other namespace is only read and
768
+ // normalized into new internal structures.
769
+ const options = { ...opts };
770
+ options.template = (opts.template && typeof opts.template === 'object' && !Array.isArray(opts.template))
771
+ ? { ...opts.template }
772
+ : {};
773
+ if (options.hideExtension && typeof options.hideExtension === 'object' && !Array.isArray(options.hideExtension)) {
774
+ options.hideExtension = { ...options.hideExtension };
775
+ }
661
776
 
662
777
  const _logger = normalizeLogger(options.logger);
663
778
 
@@ -732,6 +847,15 @@ module.exports = function koaClassicServer(
732
847
  'dirListing.entriesPerPage',
733
848
  100
734
849
  ),
850
+ // Canonical trailing-slash enforcement (V4). Default ON:
851
+ // - GET /dir (directory, no slash) → 301 redirect to /dir/
852
+ // - GET /file/ (file, trailing slash) → 404
853
+ // so relative links in an index page resolve against the directory and
854
+ // a file is only reachable at its slash-less URL. Set false to keep the
855
+ // v3 behavior (serve directories and files regardless of trailing slash).
856
+ trailingSlash: userDirListing && userDirListing.trailingSlash !== undefined
857
+ ? !!userDirListing.trailingSlash
858
+ : true,
735
859
  };
736
860
 
737
861
  // Normalize index option to array format
@@ -755,9 +879,68 @@ module.exports = function koaClassicServer(
755
879
  options.index = [];
756
880
  }
757
881
 
758
- options.urlPrefix = typeof options.urlPrefix === 'string' ? options.urlPrefix : "";
882
+ // ── urlPrefix / urlsReserved validation (V3.1, deprecation-warn) ──
883
+ // The request-time matcher depends on an implicit format for both options,
884
+ // and a malformed value fails SILENTLY: a urlPrefix with a stray
885
+ // leading/trailing slash makes the middleware serve nothing (it always falls
886
+ // through to next()), and a urlsReserved entry without a leading slash makes
887
+ // the reservation never match (the path is served instead of passed on).
888
+ // Both are v2-stable options, so instead of throwing (a breaking change on a
889
+ // minor upgrade — a mis-slashed value that "worked" only by falling through
890
+ // to a downstream handler would suddenly change behavior), we WARN and leave
891
+ // the runtime behavior exactly as it is today. The next major turns these
892
+ // warnings into throws. The one exception is a non-string urlsReserved entry:
893
+ // it would 500 on every request (value.substring is not a function), which is
894
+ // not working behavior, so it is dropped defensively (still warned).
895
+ if (options.urlPrefix === undefined) {
896
+ options.urlPrefix = "";
897
+ } else if (typeof options.urlPrefix !== 'string') {
898
+ // Unchanged behavior: pre-existing code already coerced non-string → "".
899
+ warnConfigDeprecation(_logger,
900
+ 'urlPrefix should be a string like "/static" (or "" for no prefix); got ' +
901
+ (options.urlPrefix === null ? 'null' : typeof options.urlPrefix) + ' — treating it as "".');
902
+ options.urlPrefix = "";
903
+ } else if (options.urlPrefix !== "" && (!options.urlPrefix.startsWith('/') || options.urlPrefix.endsWith('/'))) {
904
+ // Left as-is: the matcher behaves exactly as today (falls through to
905
+ // next() under this prefix). Warn only — no behavior change.
906
+ warnConfigDeprecation(_logger,
907
+ 'urlPrefix should start with "/" and not end with "/" (use "" to disable); got ' +
908
+ JSON.stringify(options.urlPrefix) + ' — it will not route correctly until corrected.');
909
+ }
759
910
  const _urlPrefixParts = options.urlPrefix.split("/");
760
- options.urlsReserved = Array.isArray(options.urlsReserved) ? options.urlsReserved : [];
911
+
912
+ if (options.urlsReserved === undefined) {
913
+ options.urlsReserved = [];
914
+ } else if (!Array.isArray(options.urlsReserved)) {
915
+ // Unchanged behavior: pre-existing code already coerced non-array → [].
916
+ warnConfigDeprecation(_logger,
917
+ 'urlsReserved should be an array of first-level paths like ["/admin"]; got ' +
918
+ (options.urlsReserved === null ? 'null' : typeof options.urlsReserved) + ' — treating it as [].');
919
+ options.urlsReserved = [];
920
+ } else {
921
+ const cleaned = [];
922
+ for (const value of options.urlsReserved) {
923
+ if (typeof value !== 'string') {
924
+ // Dropped defensively: a non-string entry would throw at match
925
+ // time (value.substring is not a function) → a 500 on every
926
+ // request. Dropping it can't break working code.
927
+ warnConfigDeprecation(_logger,
928
+ 'urlsReserved entries must be strings like "/admin"; dropping a non-string (' +
929
+ (value === null ? 'null' : typeof value) + ') entry.');
930
+ continue;
931
+ }
932
+ // Malformed but non-crashing (missing leading slash, extra segment,
933
+ // trailing slash, empty): kept as-is so the matcher behaves exactly
934
+ // as today (it simply won't match). Warn only.
935
+ if (value === '' || !value.startsWith('/') || value.indexOf('/', 1) !== -1) {
936
+ warnConfigDeprecation(_logger,
937
+ 'each urlsReserved entry should be a single first-level path — a leading "/" plus one ' +
938
+ 'segment, e.g. "/admin"; got ' + JSON.stringify(value) + ' — it will not match until corrected.');
939
+ }
940
+ cleaned.push(value);
941
+ }
942
+ options.urlsReserved = cleaned;
943
+ }
761
944
  options.template.render = (options.template.render === undefined || typeof options.template.render === 'function') ? options.template.render : undefined;
762
945
  options.template.ext = Array.isArray(options.template.ext) ? options.template.ext : [];
763
946
 
@@ -788,7 +971,21 @@ module.exports = function koaClassicServer(
788
971
  );
789
972
  }
790
973
 
791
- options.browserCacheMaxAge = typeof options.browserCacheMaxAge === 'number' && options.browserCacheMaxAge >= 0 ? options.browserCacheMaxAge : 3600;
974
+ // browserCacheMaxAge: non-negative integer seconds. An invalid value (negative, NaN,
975
+ // non-integer, Infinity, or a string) previously fell back to 3600 SILENTLY (#12).
976
+ // Consistent with #11: warn now and keep the fallback; a future major will throw
977
+ // (validateNonNegativeInt semantics) — so what warns here is exactly what will throw then.
978
+ if (options.browserCacheMaxAge === undefined) {
979
+ options.browserCacheMaxAge = 3600;
980
+ } else if (!(typeof options.browserCacheMaxAge === 'number'
981
+ && Number.isInteger(options.browserCacheMaxAge)
982
+ && options.browserCacheMaxAge >= 0)) {
983
+ warnConfigDeprecation(_logger,
984
+ 'browserCacheMaxAge must be a non-negative integer (seconds). Got: ' +
985
+ String(options.browserCacheMaxAge) + '. Falling back to the default 3600 for now.');
986
+ options.browserCacheMaxAge = 3600;
987
+ }
988
+ // else: valid — keep as-is
792
989
  options.browserCacheEnabled = typeof options.browserCacheEnabled === 'boolean' ? options.browserCacheEnabled : false;
793
990
  options.useOriginalUrl = typeof options.useOriginalUrl === 'boolean' ? options.useOriginalUrl : true;
794
991
 
@@ -1012,6 +1209,7 @@ module.exports = function koaClassicServer(
1012
1209
  enabled: true,
1013
1210
  encodings: ['br', 'gzip'], // priority order: brotli first, gzip as fallback
1014
1211
  minFileSize: 1024, // bytes; skip compression for files smaller than this
1212
+ maxFileSize: 10485760, // bytes; above this the buffered+cached path is skipped (streaming instead)
1015
1213
  mimeTypes: new Set(DEFAULT_COMPRESSIBLE_MIME_TYPES),
1016
1214
  };
1017
1215
  }
@@ -1034,11 +1232,17 @@ module.exports = function koaClassicServer(
1034
1232
  const minFileSize = compression.minFileSize === false ? false
1035
1233
  : (typeof compression.minFileSize === 'number' && compression.minFileSize >= 0 ? compression.minFileSize : 1024);
1036
1234
 
1235
+ // Cap for the buffered (whole-file-in-RAM, max-quality, cached) compression
1236
+ // path. false = no cap. Files above the cap still get compressed via the
1237
+ // bounded-RAM streaming mode — safety net, not a serving restriction.
1238
+ const maxFileSize = compression.maxFileSize === false ? false
1239
+ : (typeof compression.maxFileSize === 'number' && compression.maxFileSize > 0 ? compression.maxFileSize : 10485760);
1240
+
1037
1241
  const mimeTypes = Array.isArray(compression.mimeTypes) && compression.mimeTypes.length > 0
1038
1242
  ? compression.mimeTypes
1039
1243
  : DEFAULT_COMPRESSIBLE_MIME_TYPES;
1040
1244
 
1041
- return { enabled, encodings, minFileSize, mimeTypes: new Set(mimeTypes) };
1245
+ return { enabled, encodings, minFileSize, maxFileSize, mimeTypes: new Set(mimeTypes) };
1042
1246
  }
1043
1247
 
1044
1248
  // Normalize and validate the serverCache option into a clean internal structure.
@@ -1207,12 +1411,40 @@ module.exports = function koaClassicServer(
1207
1411
  _logger
1208
1412
  );
1209
1413
 
1210
- // Returns the client's preferred encoding based on Accept-Encoding header,
1211
- // filtered against the enabled encodings list. Returns null if no match.
1414
+ // Single-flight maps for cache population (thundering-herd protection):
1415
+ // N concurrent misses on the same key share ONE read (+ compression) instead
1416
+ // of N duplicated jobs. Entries live only while a job is in flight. Keys
1417
+ // include the stat'd mtime+size so requests that observed different versions
1418
+ // of the file never share a job (each response stays coherent with its own
1419
+ // ETag/Last-Modified).
1420
+ const _inflightRawReads = new Map(); // `${path}:${mtime}:${size}` → Promise<Buffer>
1421
+ const _inflightCompressions = new Map(); // `${path}:${encoding}:${mtime}:${size}` → Promise<Buffer>
1422
+
1423
+ // Returns the server-preferred enabled encoding the client is willing to accept.
1424
+ // Server preference order (compressionConfig.encodings) still wins; the
1425
+ // Accept-Encoding q-values are used only to EXCLUDE encodings the client refuses
1426
+ // (q=0), per RFC 9110 §12.5.3. Token match is exact, not substring ("x-gzip" is
1427
+ // NOT "gzip"). A "*" supplies the q-value for any encoding not explicitly listed.
1212
1428
  function getClientEncoding(acceptEncoding) {
1213
1429
  if (!acceptEncoding) return null;
1430
+ const qValues = new Map(); // token → q-value
1431
+ for (const part of acceptEncoding.split(',')) {
1432
+ const [tokenRaw, ...params] = part.split(';');
1433
+ const token = tokenRaw.trim().toLowerCase();
1434
+ if (!token) continue;
1435
+ let q = 1;
1436
+ for (const p of params) {
1437
+ const m = /^\s*q=(\d+(?:\.\d+)?)\s*$/i.exec(p);
1438
+ if (m) q = parseFloat(m[1]);
1439
+ }
1440
+ qValues.set(token, q);
1441
+ }
1442
+ const star = qValues.get('*');
1214
1443
  for (const enc of compressionConfig.encodings) {
1215
- if (acceptEncoding.includes(enc)) return enc;
1444
+ let q = qValues.get(enc);
1445
+ if (q === undefined) q = star; // not listed → fall back to "*" if present
1446
+ if (q === undefined) continue; // not listed and no "*" → not offered
1447
+ if (q > 0) return enc; // acceptable → pick it (server preference order)
1216
1448
  }
1217
1449
  return null;
1218
1450
  }
@@ -1308,6 +1540,14 @@ module.exports = function koaClassicServer(
1308
1540
  const urlToUse = options.useOriginalUrl ? ctx.originalUrl : ctx.url;
1309
1541
  const _origin = ctx.protocol + '://' + ctx.host;
1310
1542
  const fullUrl = _origin + urlToUse;
1543
+
1544
+ // Whether the client's request path ends with "/" — captured from the
1545
+ // raw originalUrl BEFORE the trailing slash is stripped for URL parsing
1546
+ // below. Drives the canonical trailing-slash redirect / 404 in the
1547
+ // directory / file branch. "/" (root) counts as ending with a slash, so
1548
+ // it is already canonical and never redirects.
1549
+ const _rawOriginalPath = ctx.originalUrl.split('?')[0];
1550
+ const _pathEndsWithSlash = _rawOriginalPath.endsWith('/');
1311
1551
  // Parse the request URL. `new URL()` throws on an invalid Host header
1312
1552
  // (e.g. "Host: bad host") — reject as 400 rather than letting it surface as 500.
1313
1553
  let pageHref = '';
@@ -1357,194 +1597,291 @@ module.exports = function koaClassicServer(
1357
1597
  }
1358
1598
  }
1359
1599
 
1360
- // Path traversal protection: build and validate safe file path
1361
- let requestedPath = "";
1362
- if (pageHrefOutPrefix.pathname === "/") {
1363
- requestedPath = "";
1364
- } else {
1365
- // decodeURIComponent() throws URIError on malformed percent-encoding
1366
- // (e.g. "/%", "/%zz", a truncated UTF-8 sequence) reject as 400
1367
- // rather than letting it surface as an unhandled 500.
1368
- try {
1369
- requestedPath = decodeURIComponent(pageHrefOutPrefix.pathname);
1370
- } catch {
1600
+ // From this point on the middleware OWNS the request: every early
1601
+ // pass-through above has already returned, and no next() is called below
1602
+ // (the template render's next goes through tryRenderTemplate's own catch).
1603
+ // Last-resort net: an unexpected failure on any unguarded path must not
1604
+ // leak to Koa's default handler (a plain-text 500 without the middleware's
1605
+ // security headers, logged outside the operator's `logger`). Errors from
1606
+ // downstream middleware are NOT masked they never reach this try.
1607
+ try {
1608
+ // Path traversal protection: build and validate safe file path
1609
+ let requestedPath = "";
1610
+ if (pageHrefOutPrefix.pathname === "/") {
1611
+ requestedPath = "";
1612
+ } else {
1613
+ // decodeURIComponent() throws URIError on malformed percent-encoding
1614
+ // (e.g. "/%", "/%zz", a truncated UTF-8 sequence) — reject as 400
1615
+ // rather than letting it surface as an unhandled 500.
1616
+ try {
1617
+ requestedPath = decodeURIComponent(pageHrefOutPrefix.pathname);
1618
+ } catch {
1619
+ sendBadRequest(ctx);
1620
+ return;
1621
+ }
1622
+ }
1623
+
1624
+ // Null byte guard: path.normalize() throws ERR_INVALID_ARG_VALUE for paths
1625
+ // containing \0. Reject early with 400 Bad Request before it reaches fs calls.
1626
+ if (requestedPath.includes('\0')) {
1371
1627
  sendBadRequest(ctx);
1372
1628
  return;
1373
1629
  }
1374
- }
1375
1630
 
1376
- // Null byte guard: path.normalize() throws ERR_INVALID_ARG_VALUE for paths
1377
- // containing \0. Reject early with 400 Bad Request before it reaches fs calls.
1378
- if (requestedPath.includes('\0')) {
1379
- sendBadRequest(ctx);
1380
- return;
1381
- }
1382
-
1383
- const normalizedPath = path.normalize(requestedPath);
1384
- const fullPath = path.join(normalizedRootDir, normalizedPath);
1385
-
1386
- // Security check: ensure resolved path is within rootDir. Uses the shared
1387
- // _isWithinRoot() helper, which is boundary-aware: it matches rootDir exactly
1388
- // or rootDir + path.sep, never a sibling (e.g. /srv/wwwsecret for root /srv/www)
1389
- // hardened defense in depth against a future change to how fullPath is built.
1390
- // Covers: ../ traversal, URL-encoded variants (%2e%2e%2f), and on Windows
1391
- // backslash sequences (path.normalize converts \ to / before the check).
1392
- // Returns 404 (not 403) so "outside root" is indistinguishable from "not found",
1393
- // matching the symlink-escape and hidden-entry outcomes.
1394
- if (!_isWithinRoot(fullPath, normalizedRootDir)) {
1395
- sendNotFound(ctx);
1396
- return;
1397
- }
1631
+ const normalizedPath = path.normalize(requestedPath);
1632
+ const fullPath = path.join(normalizedRootDir, normalizedPath);
1633
+
1634
+ // Security check: ensure resolved path is within rootDir. Uses the shared
1635
+ // _isWithinRoot() helper, which is boundary-aware: it matches rootDir exactly
1636
+ // or rootDir + path.sep, never a sibling (e.g. /srv/wwwsecret for root /srv/www) —
1637
+ // hardened defense in depth against a future change to how fullPath is built.
1638
+ // Covers: ../ traversal, URL-encoded variants (%2e%2e%2f), and on Windows
1639
+ // backslash sequences (path.normalize converts \ to / before the check).
1640
+ // Returns 404 (not 403) so "outside root" is indistinguishable from "not found",
1641
+ // matching the symlink-escape and hidden-entry outcomes.
1642
+ if (!_isWithinRoot(fullPath, normalizedRootDir)) {
1643
+ sendNotFound(ctx);
1644
+ return;
1645
+ }
1398
1646
 
1399
- // Hidden check: block requests that traverse a hidden directory.
1400
- // Stops at length-1 because the leaf (the file or dir being served) is
1401
- // checked separately by the file/listing path with its real stat.isDirectory().
1402
- if (requestedPath !== '') {
1403
- const segments = normalizedPath.split(path.sep).filter(Boolean);
1404
- for (let i = 0; i < segments.length - 1; i++) {
1405
- const segName = segments[i];
1406
- const segRelPath = segments.slice(0, i + 1).join('/');
1407
- if (isHiddenEntry(segName, segRelPath, true)) {
1408
- sendNotFound(ctx);
1409
- return;
1647
+ // Hidden check: block requests that traverse a hidden directory.
1648
+ // Stops at length-1 because the leaf (the file or dir being served) is
1649
+ // checked separately by the file/listing path with its real stat.isDirectory().
1650
+ if (requestedPath !== '') {
1651
+ const segments = normalizedPath.split(path.sep).filter(Boolean);
1652
+ for (let i = 0; i < segments.length - 1; i++) {
1653
+ const segName = segments[i];
1654
+ const segRelPath = segments.slice(0, i + 1).join('/');
1655
+ if (isHiddenEntry(segName, segRelPath, true)) {
1656
+ sendNotFound(ctx);
1657
+ return;
1658
+ }
1410
1659
  }
1411
1660
  }
1412
- }
1413
1661
 
1414
- let toOpen = fullPath;
1415
-
1416
- // hideExtension logic: redirect URLs with extension and resolve clean URLs
1417
- if (options.hideExtension) {
1418
- const hideExt = options.hideExtension.ext;
1419
- const hideRedirect = options.hideExtension.redirect;
1420
-
1421
- // Trailing slash check via string — avoids a full new URL() construction
1422
- const rawPath = urlToUse.split('?')[0];
1423
- const hadTrailingSlash = rawPath.length > 1 && rawPath.endsWith('/');
1424
-
1425
- // Strip a trailing slash before the extension check so URLs like /foo.html/
1426
- // still match (the slash is URL formality, not part of the filename) — without
1427
- // this, /foo.html/ would skip the redirect and 404 trying to open it as a dir.
1428
- const pathForExtCheck = hadTrailingSlash ? rawPath.slice(0, -1) : requestedPath;
1429
- if (pathForExtCheck.endsWith(hideExt)) {
1430
- // Build redirect target using ctx.originalUrl (always, regardless of useOriginalUrl)
1431
- const originalUrlObj = new URL(_origin + ctx.originalUrl);
1432
- let redirectPath = originalUrlObj.pathname;
1433
-
1434
- // Collapse leading slashes: a Location header starting with "//" (or "/\")
1435
- // is a protocol-relative URL and would let "GET //evil.com/foo.ejs" redirect
1436
- // off-origin. path.normalize() upstream already collapses these for the
1437
- // filesystem check, so the source-of-truth URL has a single leading slash.
1438
- if (redirectPath.length > 1 && (redirectPath.charCodeAt(1) === 0x2F || redirectPath.charCodeAt(1) === 0x5C)) {
1439
- redirectPath = '/' + redirectPath.replace(/^[/\\]+/, '');
1440
- }
1662
+ let toOpen = fullPath;
1663
+
1664
+ // hideExtension logic: redirect URLs with extension and resolve clean URLs
1665
+ if (options.hideExtension) {
1666
+ const hideExt = options.hideExtension.ext;
1667
+ const hideRedirect = options.hideExtension.redirect;
1668
+
1669
+ // Trailing slash check via string — avoids a full new URL() construction
1670
+ const rawPath = urlToUse.split('?')[0];
1671
+ const hadTrailingSlash = rawPath.length > 1 && rawPath.endsWith('/');
1672
+
1673
+ // Model B (V4): an extension URL is canonicalized to its clean form only when it
1674
+ // has NO trailing slash. A trailing slash means directory intent, so /foo.ejs/
1675
+ // falls through to the file/dir dispatch, where a file requested with a trailing
1676
+ // slash is a 404 (finding #3). We gate on _pathEndsWithSlash — the very flag that
1677
+ // #3's file-branch 404 uses — so "skip the redirect" and "404 downstream" are the
1678
+ // same condition. requestedPath is decoded (and already slash-stripped by the URL
1679
+ // parse), so a percent-encoded dot (/foo%2Eejs /foo.ejs) matches consistently (#14),
1680
+ // while /foo%2Eejs/ is excluded here by _pathEndsWithSlash and 404s downstream.
1681
+ if (!_pathEndsWithSlash && requestedPath.endsWith(hideExt)) {
1682
+ // Build redirect target using ctx.originalUrl (always, regardless of
1683
+ // useOriginalUrl). With useOriginalUrl: false the URL-parsing prologue
1684
+ // validated ctx.url (the rewritten one), NOT originalUrl a malformed
1685
+ // originalUrl (e.g. an absolute-form request target, legal in HTTP/1.1)
1686
+ // makes this constructor throw. Same treatment as the other malformed
1687
+ // client input: 400 Bad Request, not an unhandled error.
1688
+ let originalUrlObj;
1689
+ try {
1690
+ originalUrlObj = new URL(_origin + ctx.originalUrl);
1691
+ } catch {
1692
+ sendBadRequest(ctx);
1693
+ return;
1694
+ }
1695
+
1696
+ // Build the clean target in DECODED space: the extension may be percent-encoded
1697
+ // in the raw URL (e.g. "%2Eejs"), so decoding first makes it literal and
1698
+ // sliceable, then we re-encode per segment (#14, #20). The URL constructor
1699
+ // already validated the encoding, so decodeURIComponent cannot throw — but
1700
+ // guard for symmetry with the other malformed-client-input guards.
1701
+ let decodedPath;
1702
+ try {
1703
+ decodedPath = decodeURIComponent(originalUrlObj.pathname);
1704
+ } catch {
1705
+ sendBadRequest(ctx);
1706
+ return;
1707
+ }
1708
+
1709
+ let cleanPath = decodedPath.slice(0, decodedPath.length - hideExt.length);
1441
1710
 
1442
- redirectPath = redirectPath.slice(0, redirectPath.length - hideExt.length);
1443
-
1444
- // Special case: /index.ejs /, /sezione/index.ejs → /sezione/
1445
- const baseName = path.basename(redirectPath);
1446
- // Check if the remaining path points to an index file
1447
- if (options.index && options.index.length > 0) {
1448
- for (const pattern of options.index) {
1449
- if (typeof pattern === 'string' && (baseName + hideExt) === pattern) {
1450
- // Redirect to the directory (with trailing slash)
1451
- redirectPath = redirectPath.slice(0, redirectPath.length - baseName.length);
1452
- break;
1711
+ // Special case: /index.ejs → /, /sezione/index.ejs /sezione/
1712
+ const baseName = path.basename(cleanPath);
1713
+ if (options.index && options.index.length > 0) {
1714
+ for (const pattern of options.index) {
1715
+ if (typeof pattern === 'string' && (baseName + hideExt) === pattern) {
1716
+ // Redirect to the directory (with trailing slash)
1717
+ cleanPath = cleanPath.slice(0, cleanPath.length - baseName.length);
1718
+ break;
1719
+ }
1453
1720
  }
1454
1721
  }
1455
- }
1456
1722
 
1457
- // Preserve query string
1458
- const redirectUrl = redirectPath + (originalUrlObj.search || '');
1723
+ // Re-encode per path segment: round-trips spaces, "%", etc. back into a valid
1724
+ // URL. encodeURIComponent may over-encode sub-delims in exotic filenames, but
1725
+ // the result always resolves to the same path.
1726
+ let redirectPath = cleanPath.split('/').map(encodeURIComponent).join('/');
1727
+
1728
+ // Open-redirect guard LAST: a Location starting with "//" (or "/\") is a
1729
+ // protocol-relative URL that would navigate off-origin ("GET //evil.com/foo.ejs").
1730
+ // Placed after re-encoding because a decoded "%2F" can reintroduce a leading
1731
+ // "//"; collapse any run of leading slashes/backslashes to a single slash.
1732
+ if (redirectPath.length > 1 && (redirectPath.charCodeAt(1) === 0x2F || redirectPath.charCodeAt(1) === 0x5C)) {
1733
+ redirectPath = '/' + redirectPath.replace(/^[/\\]+/, '');
1734
+ }
1735
+
1736
+ // Preserve query string
1737
+ const redirectUrl = redirectPath + (originalUrlObj.search || '');
1459
1738
 
1460
- ctx.status = hideRedirect;
1461
- ctx.redirect(redirectUrl);
1462
- return;
1463
- }
1739
+ ctx.status = hideRedirect;
1740
+ ctx.redirect(redirectUrl);
1741
+ return;
1742
+ }
1464
1743
 
1465
- // Check if URL has no extension → try adding the configured extension
1466
- // Skip if original URL had trailing slash (trailing slash = directory intent)
1467
- const extOfRequested = path.extname(requestedPath);
1468
- if (!extOfRequested && requestedPath !== '' && !requestedPath.endsWith('/') && !hadTrailingSlash) {
1469
- const pathWithExt = fullPath + hideExt;
1744
+ // Check if URL has no extension → try adding the configured extension
1745
+ // Skip if original URL had trailing slash (trailing slash = directory intent)
1746
+ const extOfRequested = path.extname(requestedPath);
1747
+ if (!extOfRequested && requestedPath !== '' && !requestedPath.endsWith('/') && !hadTrailingSlash) {
1748
+ const pathWithExt = fullPath + hideExt;
1470
1749
 
1471
- // Security check: ensure resolved path is still within rootDir (boundary-aware)
1472
- if (_isWithinRoot(pathWithExt, normalizedRootDir)) {
1473
- try {
1474
- const statWithExt = await fs.promises.stat(pathWithExt);
1475
- if (statWithExt.isFile()) {
1476
- // File with extension exists, serve it
1477
- toOpen = pathWithExt;
1750
+ // Security check: ensure resolved path is still within rootDir (boundary-aware)
1751
+ if (_isWithinRoot(pathWithExt, normalizedRootDir)) {
1752
+ try {
1753
+ const statWithExt = await fs.promises.stat(pathWithExt);
1754
+ if (statWithExt.isFile()) {
1755
+ // File with extension exists, serve it
1756
+ toOpen = pathWithExt;
1757
+ }
1758
+ } catch {
1759
+ // File with extension doesn't exist, continue normal flow
1478
1760
  }
1479
- } catch {
1480
- // File with extension doesn't exist, continue normal flow
1481
1761
  }
1482
1762
  }
1483
1763
  }
1484
- }
1485
1764
 
1486
- // Check if path exists
1487
- let stat;
1488
- try {
1489
- stat = await fs.promises.stat(toOpen);
1490
- } catch {
1491
- // File/directory doesn't exist or can't be accessed
1492
- sendNotFound(ctx);
1493
- return;
1494
- }
1765
+ // Check if path exists
1766
+ let stat;
1767
+ try {
1768
+ stat = await fs.promises.stat(toOpen);
1769
+ } catch {
1770
+ // File/directory doesn't exist or can't be accessed
1771
+ sendNotFound(ctx);
1772
+ return;
1773
+ }
1774
+
1775
+ // Hidden check: block access to the requested file or directory itself
1776
+ if (requestedPath !== '') {
1777
+ const entryName = path.basename(toOpen);
1778
+ const entryRelPath = path.relative(normalizedRootDir, toOpen).split(path.sep).join('/');
1779
+ if (isHiddenEntry(entryName, entryRelPath, stat.isDirectory())) {
1780
+ sendNotFound(ctx);
1781
+ return;
1782
+ }
1783
+ }
1495
1784
 
1496
- // Hidden check: block access to the requested file or directory itself
1497
- if (requestedPath !== '') {
1498
- const entryName = path.basename(toOpen);
1499
- const entryRelPath = path.relative(normalizedRootDir, toOpen).split(path.sep).join('/');
1500
- if (isHiddenEntry(entryName, entryRelPath, stat.isDirectory())) {
1785
+ // Symlink boundary check (V-1): in protected modes reject any requested file
1786
+ // or directory whose realpath escapes rootDir (or, in 'deny' mode, any symlink
1787
+ // resolved below rootDir). The `_symlinkMode !== 'follow'` guard short-circuits
1788
+ // before any await so the default 'follow' mode stays truly zero-overhead.
1789
+ if (_symlinkMode !== 'follow' && !(await symlinkAllowed(toOpen))) {
1501
1790
  sendNotFound(ctx);
1502
1791
  return;
1503
1792
  }
1504
- }
1505
1793
 
1506
- // Symlink boundary check (V-1): in protected modes reject any requested file
1507
- // or directory whose realpath escapes rootDir (or, in 'deny' mode, any symlink
1508
- // resolved below rootDir). The `_symlinkMode !== 'follow'` guard short-circuits
1509
- // before any await so the default 'follow' mode stays truly zero-overhead.
1510
- if (_symlinkMode !== 'follow' && !(await symlinkAllowed(toOpen))) {
1511
- sendNotFound(ctx);
1512
- return;
1513
- }
1794
+ if (stat.isDirectory()) {
1795
+ // Handle directory
1796
+ if (options.dirListing.enabled) {
1797
+ // Canonical trailing-slash redirect (V4): a directory URL
1798
+ // without a trailing slash serves an index/listing whose
1799
+ // relative links would resolve against the parent. Redirect
1800
+ // /dir → /dir/ (301) BEFORE serving so the browser's base is
1801
+ // the directory itself.
1802
+ if (options.dirListing.trailingSlash && !_pathEndsWithSlash) {
1803
+ // Build the Location from the parsed originalUrl (same
1804
+ // defense as the hideExtension redirect): re-parsing forces
1805
+ // the target to be origin-relative — an absolute-form
1806
+ // request target (`GET http://evil/x`, legal in HTTP/1.1
1807
+ // and reachable under useOriginalUrl:false + rewriting)
1808
+ // would otherwise become an off-origin `Location` (open
1809
+ // redirect). .pathname keeps urlPrefix and percent-encoding.
1810
+ let originalUrlObj;
1811
+ try {
1812
+ originalUrlObj = new URL(_origin + ctx.originalUrl);
1813
+ } catch {
1814
+ sendBadRequest(ctx);
1815
+ return;
1816
+ }
1817
+ // Collapse a leading "//" / "/\" (protocol-relative) to a
1818
+ // single slash before appending the canonical trailing one.
1819
+ let redirectPath = originalUrlObj.pathname;
1820
+ if (redirectPath.length > 1 && (redirectPath.charCodeAt(1) === 0x2F || redirectPath.charCodeAt(1) === 0x5C)) {
1821
+ redirectPath = '/' + redirectPath.replace(/^[/\\]+/, '');
1822
+ }
1823
+ ctx.status = 301;
1824
+ ctx.redirect(redirectPath + '/' + originalUrlObj.search);
1825
+ return;
1826
+ }
1514
1827
 
1515
- if (stat.isDirectory()) {
1516
- // Handle directory
1517
- if (options.dirListing.enabled) {
1518
- // Search for index file matching configured patterns
1519
- if (options.index && options.index.length > 0) {
1520
- const indexFile = await findIndexFile(toOpen, options.index);
1521
- if (indexFile) {
1522
- const indexRelPath = path.relative(normalizedRootDir, path.join(toOpen, indexFile.name)).split(path.sep).join('/');
1523
- if (!isHiddenEntry(indexFile.name, indexRelPath, false)) {
1524
- const indexPath = path.join(toOpen, indexFile.name);
1525
- // Symlink boundary check (V-1): an index file may itself be a
1526
- // symlink escaping rootDir — validate before serving it.
1527
- // Guarded so the default 'follow' mode skips the await entirely.
1528
- if (_symlinkMode !== 'follow' && !(await symlinkAllowed(indexPath))) {
1529
- sendNotFound(ctx);
1828
+ // Search for index file matching configured patterns
1829
+ if (options.index && options.index.length > 0) {
1830
+ const indexFile = await findIndexFile(toOpen, options.index);
1831
+ if (indexFile) {
1832
+ const indexRelPath = path.relative(normalizedRootDir, path.join(toOpen, indexFile.name)).split(path.sep).join('/');
1833
+ if (!isHiddenEntry(indexFile.name, indexRelPath, false)) {
1834
+ const indexPath = path.join(toOpen, indexFile.name);
1835
+ // Symlink boundary check (V-1): an index file may itself be a
1836
+ // symlink escaping rootDir — validate before serving it.
1837
+ // Guarded so the default 'follow' mode skips the await entirely.
1838
+ if (_symlinkMode !== 'follow' && !(await symlinkAllowed(indexPath))) {
1839
+ sendNotFound(ctx);
1840
+ return;
1841
+ }
1842
+ await loadFile(indexPath, indexFile.stat);
1530
1843
  return;
1531
1844
  }
1532
- await loadFile(indexPath, indexFile.stat);
1533
- return;
1534
1845
  }
1535
1846
  }
1536
- }
1537
1847
 
1538
- // No index file found, show directory listing
1539
- ctx.body = await show_dir(toOpen, ctx);
1848
+ // No index file found, show directory listing
1849
+ ctx.body = await show_dir(toOpen, ctx);
1850
+ } else {
1851
+ // Directory listing disabled
1852
+ sendNotFound(ctx);
1853
+ }
1854
+ return;
1540
1855
  } else {
1541
- // Directory listing disabled
1542
- sendNotFound(ctx);
1856
+ // Canonical trailing-slash 404 (V4): a trailing slash means
1857
+ // "directory", but this path resolved to a FILE — a file is only
1858
+ // reachable at its slash-less URL. Return 404 (indistinguishable
1859
+ // from not-found) rather than serving the file at a non-canonical
1860
+ // URL. Disabled by dirListing.trailingSlash: false (v3 behavior).
1861
+ if (options.dirListing.trailingSlash && _pathEndsWithSlash) {
1862
+ sendNotFound(ctx);
1863
+ return;
1864
+ }
1865
+ await loadFile(toOpen, stat);
1866
+ return;
1543
1867
  }
1544
- return;
1545
- } else {
1546
- await loadFile(toOpen, stat);
1547
- return;
1868
+ } catch (err) {
1869
+ _logger.error('[koa-classic-server] Unexpected error while serving the request:', err);
1870
+ if (ctx.headerSent || ctx.res.writableEnded) {
1871
+ ctx.res.destroy(); // response already in flight — nothing sane left to send
1872
+ return;
1873
+ }
1874
+ // Scrub representation/caching headers a partially-built response may
1875
+ // have left behind: a stale Content-Encoding would corrupt the error
1876
+ // page, a public Cache-Control could get the 500 cached by proxies.
1877
+ for (const h of ['Content-Encoding', 'Content-Disposition', 'Content-Range', 'Vary', 'ETag', 'Last-Modified', 'Accept-Ranges']) {
1878
+ ctx.remove(h);
1879
+ }
1880
+ ctx.set('Cache-Control', 'no-store');
1881
+ ctx.set('Content-Type', 'text/html; charset=utf-8');
1882
+ setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
1883
+ ctx.status = 500;
1884
+ ctx.body = _INTERNAL_ERROR_HTML;
1548
1885
  }
1549
1886
 
1550
1887
  // Internal functions
@@ -1578,13 +1915,23 @@ module.exports = function koaClassicServer(
1578
1915
  rawBuffer = cached.buffer;
1579
1916
  } else {
1580
1917
  try {
1581
- rawBuffer = await fs.promises.readFile(toOpen);
1582
- refreshOrInsert(_rawFileCache, toOpen, {
1583
- buffer: rawBuffer,
1584
- mtime: fileStat.mtime.getTime(),
1585
- size: fileStat.size,
1586
- insertedAt: Date.now(),
1587
- }, cached, staleByAge);
1918
+ // Single-flight: concurrent misses on the same path share one
1919
+ // readFile + cache insert; only the leader (the closure below)
1920
+ // runs, waiters await the same Promise. The key includes the
1921
+ // stat'd mtime+size so a request that observed a DIFFERENT
1922
+ // version of the file starts its own job instead of adopting
1923
+ // bytes that don't match the validators it will emit.
1924
+ const inflightKey = `${toOpen}:${fileStat.mtime.getTime()}:${fileStat.size}`;
1925
+ rawBuffer = await singleFlight(_inflightRawReads, inflightKey, async () => {
1926
+ const buf = await fs.promises.readFile(toOpen);
1927
+ refreshOrInsert(_rawFileCache, toOpen, {
1928
+ buffer: buf,
1929
+ mtime: fileStat.mtime.getTime(),
1930
+ size: fileStat.size,
1931
+ insertedAt: Date.now(),
1932
+ }, cached, staleByAge);
1933
+ return buf;
1934
+ });
1588
1935
  } catch {
1589
1936
  rawBuffer = null; // Fall through to disk reads later
1590
1937
  }
@@ -1632,6 +1979,67 @@ module.exports = function koaClassicServer(
1632
1979
  }
1633
1980
  }
1634
1981
 
1982
+ // Determine MIME type and compression encoding for the full-file response
1983
+ const mimeType = mime.lookup(toOpen) || 'application/octet-stream';
1984
+ const filename = path.basename(toOpen);
1985
+
1986
+ // Resolve compression: enabled + compressible MIME + meets minFileSize + client supports it
1987
+ let encoding = null; // 'br' | 'gzip' | null
1988
+ let potentiallyCompressible = false; // response content-negotiates on Accept-Encoding
1989
+ if (compressionConfig.enabled && compressionConfig.encodings.length > 0) {
1990
+ const isCompressibleMime = compressionConfig.mimeTypes.has(mimeType);
1991
+ const meetsMinSize = compressionConfig.minFileSize === false
1992
+ || fileStat.size >= compressionConfig.minFileSize;
1993
+ if (isCompressibleMime && meetsMinSize) {
1994
+ potentiallyCompressible = true; // even if this client gets identity
1995
+ encoding = getClientEncoding(ctx.get('Accept-Encoding'));
1996
+ }
1997
+ }
1998
+
1999
+ // fullEtag is encoding-specific to avoid false 304 hits across representations.
2000
+ // Proxies use Vary: Accept-Encoding to cache separate versions per encoding.
2001
+ const etagSuffix = encoding === 'br' ? '-br' : encoding === 'gzip' ? '-gz' : '';
2002
+ const fullEtag = `"${fileStat.mtime.getTime()}-${fileStat.size}${etagSuffix}"`;
2003
+
2004
+ // Vary: Accept-Encoding as soon as the resource is *potentially* compressible —
2005
+ // regardless of whether THIS client gets a compressed variant, and regardless
2006
+ // of browserCacheEnabled. RFC 9110 §15.4.5: the 304 below must carry the same
2007
+ // Vary the 200 would; and a shared proxy must not serve the identity variant to
2008
+ // a client that would have received the compressed one (#7).
2009
+ if (potentiallyCompressible) {
2010
+ ctx.set('Vary', 'Accept-Encoding');
2011
+ }
2012
+
2013
+ // Preconditions are evaluated BEFORE the Range branch: RFC 9110 §13.2.2 gives the
2014
+ // validators precedence over Range (steps 3/4 before step 5), so a conditional
2015
+ // request that matches returns 304 (Not Modified), not 206 (Partial Content).
2016
+ // Comparison uses fullEtag (the encoding-specific representation a full GET would
2017
+ // return); a 206 below re-tags itself with baseEtag (the identity partial it serves).
2018
+ if (options.browserCacheEnabled) {
2019
+ ctx.set('ETag', fullEtag);
2020
+ ctx.set('Last-Modified', fileStat.mtime.toUTCString());
2021
+
2022
+ // If-None-Match: "*" | comma-list, weak comparison (RFC 9110 §13.1.2).
2023
+ if (ifNoneMatchSatisfied(ctx.get('If-None-Match'), fullEtag)) {
2024
+ ctx.status = 304;
2025
+ return;
2026
+ }
2027
+
2028
+ // If-Modified-Since (date validation). The mtime is truncated to whole seconds
2029
+ // before comparing: Last-Modified is emitted via toUTCString() (second precision
2030
+ // — HTTP dates have no milliseconds), so a client echoing that header back would
2031
+ // otherwise never match a sub-second mtime (e.g. 22:13:20.500 <= 22:13:20.000).
2032
+ const clientModifiedSince = ctx.get('If-Modified-Since');
2033
+ if (clientModifiedSince) {
2034
+ const clientDate = new Date(clientModifiedSince);
2035
+ const mtimeSeconds = Math.floor(fileStat.mtime.getTime() / 1000) * 1000;
2036
+ if (mtimeSeconds <= clientDate.getTime()) {
2037
+ ctx.status = 304;
2038
+ return;
2039
+ }
2040
+ }
2041
+ }
2042
+
1635
2043
  // Range request handling (HTTP 206 Partial Content — compression skipped for ranges)
1636
2044
  const rangeHeader = ctx.get('Range');
1637
2045
  if (rangeHeader) {
@@ -1651,10 +2059,11 @@ module.exports = function koaClassicServer(
1651
2059
  if (!ifRange || ifRange === baseEtag) {
1652
2060
  const { start, end } = parsed;
1653
2061
  const rangeLength = end - start + 1;
1654
- const mimeType = mime.lookup(toOpen) || 'application/octet-stream';
1655
- const filename = path.basename(toOpen);
1656
2062
 
1657
2063
  ctx.status = 206;
2064
+ // 206 returns identity bytes → tag with baseEtag, not the encoding-specific
2065
+ // fullEtag set above. Last-Modified is already set (browserCacheEnabled).
2066
+ if (options.browserCacheEnabled) ctx.set('ETag', baseEtag);
1658
2067
  ctx.set('Content-Range', `bytes ${start}-${end}/${fileSize}`);
1659
2068
  ctx.set('Content-Type', mimeType);
1660
2069
  ctx.set('Content-Length', String(rangeLength));
@@ -1662,8 +2071,9 @@ module.exports = function koaClassicServer(
1662
2071
 
1663
2072
  if (ctx.method !== 'HEAD') {
1664
2073
  if (rawBuffer) {
1665
- // Serve range slice from in-memory buffer — zero disk I/O
1666
- ctx.body = rawBuffer.slice(start, end + 1);
2074
+ // Serve range slice from in-memory buffer — zero disk I/O.
2075
+ // subarray(): zero-copy view; Buffer.slice is deprecated (DEP0158).
2076
+ ctx.body = rawBuffer.subarray(start, end + 1);
1667
2077
  } else {
1668
2078
  const src = fs.createReadStream(toOpen, { start, end });
1669
2079
  src.on('error', (err) => {
@@ -1688,59 +2098,23 @@ module.exports = function koaClassicServer(
1688
2098
  // Invalid Range → fall through to full 200 response
1689
2099
  }
1690
2100
 
1691
- // Determine MIME type and compression encoding for the full-file response
1692
- const mimeType = mime.lookup(toOpen) || 'application/octet-stream';
1693
- const filename = path.basename(toOpen);
1694
-
1695
- // Resolve compression: enabled + compressible MIME + meets minFileSize + client supports it
1696
- let encoding = null; // 'br' | 'gzip' | null
1697
- if (compressionConfig.enabled && compressionConfig.encodings.length > 0) {
1698
- const isCompressibleMime = compressionConfig.mimeTypes.has(mimeType);
1699
- const meetsMinSize = compressionConfig.minFileSize === false
1700
- || fileStat.size >= compressionConfig.minFileSize;
1701
- if (isCompressibleMime && meetsMinSize) {
1702
- encoding = getClientEncoding(ctx.get('Accept-Encoding'));
1703
- }
1704
- }
1705
-
1706
- // fullEtag is encoding-specific to avoid false 304 hits across representations.
1707
- // Proxies use Vary: Accept-Encoding to cache separate versions per encoding.
1708
- const etagSuffix = encoding === 'br' ? '-br' : encoding === 'gzip' ? '-gz' : '';
1709
- const fullEtag = `"${fileStat.mtime.getTime()}-${fileStat.size}${etagSuffix}"`;
1710
-
1711
- // ETag, Last-Modified, and 304 check — deferred until encoding is known
1712
- if (options.browserCacheEnabled) {
1713
- ctx.set('ETag', fullEtag);
1714
- ctx.set('Last-Modified', fileStat.mtime.toUTCString());
1715
-
1716
- // Check If-None-Match (ETag validation)
1717
- const clientEtag = ctx.get('If-None-Match');
1718
- if (clientEtag && clientEtag === fullEtag) {
1719
- ctx.status = 304;
1720
- return;
1721
- }
1722
-
1723
- // Check If-Modified-Since (date validation)
1724
- const clientModifiedSince = ctx.get('If-Modified-Since');
1725
- if (clientModifiedSince) {
1726
- const clientDate = new Date(clientModifiedSince);
1727
- if (fileStat.mtime.getTime() <= clientDate.getTime()) {
1728
- ctx.status = 304;
1729
- return;
1730
- }
1731
- }
1732
- }
1733
-
1734
2101
  // Common response headers
1735
2102
  ctx.set('Content-Type', mimeType);
1736
2103
  ctx.set('Content-Disposition', buildContentDisposition(filename));
1737
2104
 
1738
2105
  if (encoding) {
1739
2106
  // ── Compressed response ───────────────────────────────────────────────
2107
+ // Vary: Accept-Encoding is already set above (potentiallyCompressible).
1740
2108
  ctx.set('Content-Encoding', encoding);
1741
- ctx.set('Vary', 'Accept-Encoding'); // Required so proxies cache per-encoding
1742
2109
 
1743
- if (serverCacheConfig.compressedFile.enabled) {
2110
+ // Safety net (#4): the buffered path reads the WHOLE file into RAM and
2111
+ // compresses at max quality — fine for web assets, catastrophic for a
2112
+ // multi-GB log/CSV. Above compression.maxFileSize the bounded-RAM
2113
+ // streaming mode below is used instead (still compressed, not cached).
2114
+ const withinCompressCap = compressionConfig.maxFileSize === false
2115
+ || fileStat.size <= compressionConfig.maxFileSize;
2116
+
2117
+ if (serverCacheConfig.compressedFile.enabled && withinCompressCap) {
1744
2118
  // compressedFile cache mode: compress once → buffer in RAM → Content-Length known
1745
2119
  const cacheKey = `${toOpen}:${encoding}`;
1746
2120
  const cached = _compressedFileCache.peek(cacheKey);
@@ -1757,21 +2131,34 @@ module.exports = function koaClassicServer(
1757
2131
  buf = cached.buffer; // Serve from cache
1758
2132
  } else {
1759
2133
  try {
1760
- // Use rawFile buffer if available avoids redundant disk read
1761
- const rawData = rawBuffer || await fs.promises.readFile(toOpen);
1762
- buf = await compressBuffer(rawData, encoding);
1763
-
1764
- refreshOrInsert(_compressedFileCache, cacheKey, {
1765
- buffer: buf,
1766
- mtime: fileStat.mtime.getTime(),
1767
- size: fileStat.size,
1768
- insertedAt: Date.now(),
1769
- }, cached, staleByAge);
2134
+ // Single-flight: concurrent misses on the same path+encoding
2135
+ // share one read+compress+insert instead of N parallel brotli
2136
+ // jobs for identical content. A rejection is shared too: all
2137
+ // waiters land in this catch and use the uncompressed fallback.
2138
+ // The key includes the stat'd mtime+size so a request that
2139
+ // observed a DIFFERENT version of the file starts its own job
2140
+ // (its ETag/Last-Modified must describe the bytes it serves).
2141
+ const inflightKey = `${cacheKey}:${fileStat.mtime.getTime()}:${fileStat.size}`;
2142
+ buf = await singleFlight(_inflightCompressions, inflightKey, async () => {
2143
+ // Use rawFile buffer if available — avoids redundant disk read
2144
+ const rawData = rawBuffer || await fs.promises.readFile(toOpen);
2145
+ const compressed = await compressBuffer(rawData, encoding);
2146
+ refreshOrInsert(_compressedFileCache, cacheKey, {
2147
+ buffer: compressed,
2148
+ mtime: fileStat.mtime.getTime(),
2149
+ size: fileStat.size,
2150
+ insertedAt: Date.now(),
2151
+ }, cached, staleByAge);
2152
+ return compressed;
2153
+ });
1770
2154
  } catch (err) {
1771
2155
  _logger.error('Compression error:', err);
1772
- // Fall back to uncompressed on any compression failure
2156
+ // Fall back to uncompressed on any compression failure. Vary STAYS
2157
+ // (the resource is still compressible), but the ETag must be reset to
2158
+ // the un-suffixed form: this identity body must not be cached by a
2159
+ // shared proxy under the -br/-gz validator (#7).
1773
2160
  ctx.remove('Content-Encoding');
1774
- ctx.remove('Vary');
2161
+ if (options.browserCacheEnabled) ctx.set('ETag', baseEtag);
1775
2162
  if (rawBuffer) {
1776
2163
  ctx.set('Content-Length', String(rawBuffer.length));
1777
2164
  if (ctx.method !== 'HEAD') {
@@ -1813,20 +2200,28 @@ module.exports = function koaClassicServer(
1813
2200
  const compress = encoding === 'br'
1814
2201
  ? zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } })
1815
2202
  : zlib.createGzip({ level: 6 });
1816
- if (rawBuffer) {
1817
- // Compress from in-memory buffer — no disk I/O
1818
- const src = Readable.from(rawBuffer);
1819
- ctx.body = src.pipe(compress);
1820
- } else {
1821
- const src = fs.createReadStream(toOpen);
1822
- src.on('error', (err) => {
1823
- _logger.error('Stream error:', err);
1824
- if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
1825
- });
1826
- ctx.body = src.pipe(compress);
1827
- }
2203
+ const src = rawBuffer
2204
+ ? Readable.from(rawBuffer) // compress from in-memory buffer — no disk I/O
2205
+ : fs.createReadStream(toOpen);
2206
+ // pipeline (NOT pipe): teardown propagates in BOTH directions.
2207
+ // When the client disconnects mid-transfer, Koa destroys the body
2208
+ // (`compress`) and pipeline destroys `src` too, closing its file
2209
+ // descriptor. A bare src.pipe(compress) leaves the ReadStream
2210
+ // paused with the fd open forever — fd leak under aborted
2211
+ // downloads. Client disconnects are a normal event and are not
2212
+ // logged (avoids client-driven log spam).
2213
+ ctx.body = pipeline(src, compress, (err) => {
2214
+ if (!err || err.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
2215
+ _logger.error('Stream error:', err);
2216
+ if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
2217
+ });
2218
+ } else {
2219
+ // HEAD: mirror the GET status and headers (RFC 9110 §9.3.2) — no
2220
+ // Content-Length, since the compressed size is unknown without
2221
+ // running the compression. The explicit status is required: with
2222
+ // no body ever assigned, Koa would otherwise respond its default 404.
2223
+ ctx.status = 200;
1828
2224
  }
1829
- // HEAD + streaming: no Content-Length available; Koa sends headers only via res.end()
1830
2225
  }
1831
2226
 
1832
2227
  } else {
@@ -1945,9 +2340,12 @@ module.exports = function koaClassicServer(
1945
2340
  parts.push("</thead>");
1946
2341
  parts.push("<tbody>");
1947
2342
 
1948
- // Parent directory link
2343
+ // Parent directory link — omitted at the middleware's LOGICAL root
2344
+ // (pageHrefOutPrefix.pathname === '/'), not only at the absolute root: with
2345
+ // urlPrefix '/static', the listing of /static/ must not link to '/', which is
2346
+ // outside the served tree (#13).
1949
2347
  const currentPath = pageHref.origin + pageHref.pathname;
1950
- if (currentPath !== pageHrefOutPrefix.origin + "/") {
2348
+ if (pageHrefOutPrefix.pathname !== "/") {
1951
2349
  // Build parent directory URL without query parameters
1952
2350
  const a_pD = currentPath.split("/");
1953
2351
  a_pD.pop();
@@ -1956,8 +2354,9 @@ module.exports = function koaClassicServer(
1956
2354
  parts.push(`<tr><td><a href="${escapeHtml(parentDirectory)}"><b>.. Parent Directory</b></a></td><td>DIR</td><td>-</td></tr>`);
1957
2355
  }
1958
2356
 
2357
+ const emptyFolderRow = `<tr><td>empty folder</td><td></td><td></td></tr>`;
1959
2358
  if (dir.length === 0) {
1960
- parts.push(`<tr><td>empty folder</td><td></td><td></td></tr>`);
2359
+ parts.push(emptyFolderRow);
1961
2360
  } else {
1962
2361
  const _listingBaseUrl = pageHref.origin + pageHref.pathname;
1963
2362
  const _listingOriginPrefix = pageHref.origin + options.urlPrefix;
@@ -2051,6 +2450,13 @@ module.exports = function koaClassicServer(
2051
2450
  }
2052
2451
  const items = rawItems.filter(Boolean);
2053
2452
 
2453
+ // Every entry was filtered out as hidden (dotfiles / alwaysHide / blacklist):
2454
+ // show the same "empty folder" row as a physically empty directory (#16), not a
2455
+ // header-only empty table (which would also hint that hidden files exist).
2456
+ if (items.length === 0) {
2457
+ parts.push(emptyFolderRow);
2458
+ }
2459
+
2054
2460
  // Places directories before non-directories; falls back to `tieBreaker`
2055
2461
  // when both items are in the same bucket. `effectiveType === 2` covers plain
2056
2462
  // dirs and dir-resolved symlinks, matching the rest of the listing logic.