koa-classic-server 3.1.0 → 4.1.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 +817 -266
  3. package/package.json +4 -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, Transform, 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,20 +542,60 @@ 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.
515
579
  // Otherwise falls back to delete + set (frequency resets to 1).
580
+ // Bounded-RAM streaming compressor: constant-memory transform, lower quality
581
+ // than the buffered path (which can afford brotli Q11 because it runs once and
582
+ // is cached). Used by every streamed-compression pipeline.
583
+ // LGWIN 19 (512 KB window instead of brotli's default 4 MB): the encoder state
584
+ // is the dominant per-request RAM on this path (~10 MB/stream at the default,
585
+ // ~1.3 GB peak measured under 100 concurrent cold requests), and at Q4 the big
586
+ // window buys nothing on typical text (measured: same output size, ~40% faster).
587
+ // The trade-off: content with identical blocks repeated at >512 KB distance
588
+ // (rare, pathological) compresses worse than with the 4 MB window. gzip's
589
+ // window is 32 KB by design — nothing to bound there.
590
+ function createStreamCompressor(encoding) {
591
+ return encoding === 'br'
592
+ ? zlib.createBrotliCompress({ params: {
593
+ [zlib.constants.BROTLI_PARAM_QUALITY]: 4,
594
+ [zlib.constants.BROTLI_PARAM_LGWIN]: 19,
595
+ } })
596
+ : zlib.createGzip({ level: 6 });
597
+ }
598
+
516
599
  function refreshOrInsert(cache, key, newEntry, cached, staleByAge) {
517
600
  const canRefreshInPlace = cached
518
601
  && staleByAge
@@ -560,16 +643,36 @@ module.exports = function koaClassicServer(
560
643
  // when visible entries > entriesPerPage. Page index via
561
644
  // ?page=N (0-based); out-of-range values are clamped silently.
562
645
  // Must be a finite integer >= 0; 0 = disabled (no pagination).
646
+ trailingSlash: true, // Canonical trailing-slash enforcement (V4). Default true:
647
+ // GET /dir (directory, no slash) → 301 redirect to /dir/
648
+ // GET /file/ (file, trailing slash) → 404
649
+ // so relative links in an index page resolve against the
650
+ // directory. Set false for the v3 behavior (serve directories
651
+ // and files regardless of the trailing slash).
563
652
  },
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)
653
+ index: [], // Index file name(s) - must be an ARRAY.
654
+ // Default: [] — no index file is looked up; directories always
655
+ // show the listing (when dirListing.enabled). Configure explicitly
656
+ // for the classic index-file behavior, e.g. ["index.html"].
657
+ // - Array of strings: ["index.html", "index.htm", "default.html"]
658
+ // - Array of RegExp: [/index\.html/i, /default\.(html|htm)/i]
659
+ // - Mixed array: ["index.html", /index\.[eE][jJ][sS]/]
660
+ // Priority is determined by array order (first match wins)
661
+ urlPrefix: "", // URL path prefix. Should start with "/" and NOT end with "/"
662
+ // (e.g. "/static"); "" disables the prefix. A malformed value
663
+ // is tolerated with a deprecation warning for now (behavior
664
+ // unchanged) and WILL throw in the next major version.
665
+ urlsReserved: [], // Reserved first-level paths passed through to next().
666
+ // Each entry should be a single first-level path: a leading
667
+ // "/" plus one segment, no further "/" (e.g. "/admin").
668
+ // Malformed entries are tolerated with a deprecation warning
669
+ // for now and WILL throw in the next major version (a
670
+ // non-string entry is dropped to avoid a per-request 500).
571
671
  template: {
572
672
  render: undefined, // Template rendering function: async (ctx, next, filePath, rawBuffer, signal) => {}
673
+ // rawBuffer (4th arg, may be null) is READ-ONLY: the same Buffer
674
+ // instance is shared with the server cache and with concurrent
675
+ // requests — mutating it corrupts other responses.
573
676
  ext: [], // File extensions to process with template.render
574
677
  renderTimeout: 30000, // Max ms allowed for template.render (number ≥ 0; 0 = disabled).
575
678
  // On timeout responds 504 Gateway Timeout. The render receives an
@@ -628,6 +731,19 @@ module.exports = function koaClassicServer(
628
731
  enabled: true, // master switch (false = disable all compression)
629
732
  encodings: ['br', 'gzip'], // algorithms in priority order; [] = disable
630
733
  minFileSize: 1024, // min file size in bytes to compress; false = no minimum
734
+ maxFileSize: 10485760, // max file size (bytes) for the buffered high-quality
735
+ // compression path (whole file in RAM → brotli Q11 →
736
+ // result cached). Default: 10 MB; false = no cap.
737
+ // Larger files are STILL compressed, but via the
738
+ // bounded-RAM streaming mode (brotli Q4 / gzip 6, no
739
+ // Content-Length on the first response). This is a
740
+ // SAFETY NET against unbounded RAM/CPU on huge
741
+ // compressible files (multi-GB logs/JSON/CSV), not a
742
+ // serving restriction. When serverCache.compressedFile
743
+ // is enabled, the streamed OUTPUT is also cached
744
+ // (when it fits in a quarter of that cache's maxSize),
745
+ // so subsequent requests are served from RAM with
746
+ // Content-Length.
631
747
  mimeTypes: [], // compressible MIME types (replaces default list if provided)
632
748
  },
633
749
  // compression: false // shorthand to disable all compression
@@ -656,8 +772,31 @@ module.exports = function koaClassicServer(
656
772
 
657
773
  const normalizedRootDir = path.resolve(rootDir);
658
774
 
659
- const options = opts || {};
660
- options.template = opts.template || {};
775
+ // Options must be a plain object, or omitted entirely (the `opts = {}`
776
+ // default covers undefined). An explicit null — or any other non-object —
777
+ // is a configuration bug: surface it with a helpful error instead of a raw
778
+ // TypeError further down (or, worse, a silent fall-through to defaults).
779
+ if (opts === null || typeof opts !== 'object' || Array.isArray(opts)) {
780
+ throw new Error(
781
+ '[koa-classic-server] options must be a plain object (or omitted entirely). Got: ' +
782
+ (opts === null ? 'null' : Array.isArray(opts) ? 'an array' : typeof opts)
783
+ );
784
+ }
785
+
786
+ // Work on a copy: the factory normalizes options in place and must never
787
+ // mutate the caller's configuration object (reusing one config for two
788
+ // instances, or inspecting it after startup, would otherwise observe the
789
+ // rewritten values). Only the two nested objects the normalization writes
790
+ // into need their own copy — template (render/ext/renderTimeout) and
791
+ // hideExtension (ext/redirect); every other namespace is only read and
792
+ // normalized into new internal structures.
793
+ const options = { ...opts };
794
+ options.template = (opts.template && typeof opts.template === 'object' && !Array.isArray(opts.template))
795
+ ? { ...opts.template }
796
+ : {};
797
+ if (options.hideExtension && typeof options.hideExtension === 'object' && !Array.isArray(options.hideExtension)) {
798
+ options.hideExtension = { ...options.hideExtension };
799
+ }
661
800
 
662
801
  const _logger = normalizeLogger(options.logger);
663
802
 
@@ -732,6 +871,15 @@ module.exports = function koaClassicServer(
732
871
  'dirListing.entriesPerPage',
733
872
  100
734
873
  ),
874
+ // Canonical trailing-slash enforcement (V4). Default ON:
875
+ // - GET /dir (directory, no slash) → 301 redirect to /dir/
876
+ // - GET /file/ (file, trailing slash) → 404
877
+ // so relative links in an index page resolve against the directory and
878
+ // a file is only reachable at its slash-less URL. Set false to keep the
879
+ // v3 behavior (serve directories and files regardless of trailing slash).
880
+ trailingSlash: userDirListing && userDirListing.trailingSlash !== undefined
881
+ ? !!userDirListing.trailingSlash
882
+ : true,
735
883
  };
736
884
 
737
885
  // Normalize index option to array format
@@ -755,9 +903,68 @@ module.exports = function koaClassicServer(
755
903
  options.index = [];
756
904
  }
757
905
 
758
- options.urlPrefix = typeof options.urlPrefix === 'string' ? options.urlPrefix : "";
906
+ // ── urlPrefix / urlsReserved validation (V3.1, deprecation-warn) ──
907
+ // The request-time matcher depends on an implicit format for both options,
908
+ // and a malformed value fails SILENTLY: a urlPrefix with a stray
909
+ // leading/trailing slash makes the middleware serve nothing (it always falls
910
+ // through to next()), and a urlsReserved entry without a leading slash makes
911
+ // the reservation never match (the path is served instead of passed on).
912
+ // Both are v2-stable options, so instead of throwing (a breaking change on a
913
+ // minor upgrade — a mis-slashed value that "worked" only by falling through
914
+ // to a downstream handler would suddenly change behavior), we WARN and leave
915
+ // the runtime behavior exactly as it is today. The next major turns these
916
+ // warnings into throws. The one exception is a non-string urlsReserved entry:
917
+ // it would 500 on every request (value.substring is not a function), which is
918
+ // not working behavior, so it is dropped defensively (still warned).
919
+ if (options.urlPrefix === undefined) {
920
+ options.urlPrefix = "";
921
+ } else if (typeof options.urlPrefix !== 'string') {
922
+ // Unchanged behavior: pre-existing code already coerced non-string → "".
923
+ warnConfigDeprecation(_logger,
924
+ 'urlPrefix should be a string like "/static" (or "" for no prefix); got ' +
925
+ (options.urlPrefix === null ? 'null' : typeof options.urlPrefix) + ' — treating it as "".');
926
+ options.urlPrefix = "";
927
+ } else if (options.urlPrefix !== "" && (!options.urlPrefix.startsWith('/') || options.urlPrefix.endsWith('/'))) {
928
+ // Left as-is: the matcher behaves exactly as today (falls through to
929
+ // next() under this prefix). Warn only — no behavior change.
930
+ warnConfigDeprecation(_logger,
931
+ 'urlPrefix should start with "/" and not end with "/" (use "" to disable); got ' +
932
+ JSON.stringify(options.urlPrefix) + ' — it will not route correctly until corrected.');
933
+ }
759
934
  const _urlPrefixParts = options.urlPrefix.split("/");
760
- options.urlsReserved = Array.isArray(options.urlsReserved) ? options.urlsReserved : [];
935
+
936
+ if (options.urlsReserved === undefined) {
937
+ options.urlsReserved = [];
938
+ } else if (!Array.isArray(options.urlsReserved)) {
939
+ // Unchanged behavior: pre-existing code already coerced non-array → [].
940
+ warnConfigDeprecation(_logger,
941
+ 'urlsReserved should be an array of first-level paths like ["/admin"]; got ' +
942
+ (options.urlsReserved === null ? 'null' : typeof options.urlsReserved) + ' — treating it as [].');
943
+ options.urlsReserved = [];
944
+ } else {
945
+ const cleaned = [];
946
+ for (const value of options.urlsReserved) {
947
+ if (typeof value !== 'string') {
948
+ // Dropped defensively: a non-string entry would throw at match
949
+ // time (value.substring is not a function) → a 500 on every
950
+ // request. Dropping it can't break working code.
951
+ warnConfigDeprecation(_logger,
952
+ 'urlsReserved entries must be strings like "/admin"; dropping a non-string (' +
953
+ (value === null ? 'null' : typeof value) + ') entry.');
954
+ continue;
955
+ }
956
+ // Malformed but non-crashing (missing leading slash, extra segment,
957
+ // trailing slash, empty): kept as-is so the matcher behaves exactly
958
+ // as today (it simply won't match). Warn only.
959
+ if (value === '' || !value.startsWith('/') || value.indexOf('/', 1) !== -1) {
960
+ warnConfigDeprecation(_logger,
961
+ 'each urlsReserved entry should be a single first-level path — a leading "/" plus one ' +
962
+ 'segment, e.g. "/admin"; got ' + JSON.stringify(value) + ' — it will not match until corrected.');
963
+ }
964
+ cleaned.push(value);
965
+ }
966
+ options.urlsReserved = cleaned;
967
+ }
761
968
  options.template.render = (options.template.render === undefined || typeof options.template.render === 'function') ? options.template.render : undefined;
762
969
  options.template.ext = Array.isArray(options.template.ext) ? options.template.ext : [];
763
970
 
@@ -788,7 +995,21 @@ module.exports = function koaClassicServer(
788
995
  );
789
996
  }
790
997
 
791
- options.browserCacheMaxAge = typeof options.browserCacheMaxAge === 'number' && options.browserCacheMaxAge >= 0 ? options.browserCacheMaxAge : 3600;
998
+ // browserCacheMaxAge: non-negative integer seconds. An invalid value (negative, NaN,
999
+ // non-integer, Infinity, or a string) previously fell back to 3600 SILENTLY (#12).
1000
+ // Consistent with #11: warn now and keep the fallback; a future major will throw
1001
+ // (validateNonNegativeInt semantics) — so what warns here is exactly what will throw then.
1002
+ if (options.browserCacheMaxAge === undefined) {
1003
+ options.browserCacheMaxAge = 3600;
1004
+ } else if (!(typeof options.browserCacheMaxAge === 'number'
1005
+ && Number.isInteger(options.browserCacheMaxAge)
1006
+ && options.browserCacheMaxAge >= 0)) {
1007
+ warnConfigDeprecation(_logger,
1008
+ 'browserCacheMaxAge must be a non-negative integer (seconds). Got: ' +
1009
+ String(options.browserCacheMaxAge) + '. Falling back to the default 3600 for now.');
1010
+ options.browserCacheMaxAge = 3600;
1011
+ }
1012
+ // else: valid — keep as-is
792
1013
  options.browserCacheEnabled = typeof options.browserCacheEnabled === 'boolean' ? options.browserCacheEnabled : false;
793
1014
  options.useOriginalUrl = typeof options.useOriginalUrl === 'boolean' ? options.useOriginalUrl : true;
794
1015
 
@@ -1012,6 +1233,7 @@ module.exports = function koaClassicServer(
1012
1233
  enabled: true,
1013
1234
  encodings: ['br', 'gzip'], // priority order: brotli first, gzip as fallback
1014
1235
  minFileSize: 1024, // bytes; skip compression for files smaller than this
1236
+ maxFileSize: 10485760, // bytes; above this the buffered+cached path is skipped (streaming instead)
1015
1237
  mimeTypes: new Set(DEFAULT_COMPRESSIBLE_MIME_TYPES),
1016
1238
  };
1017
1239
  }
@@ -1034,11 +1256,17 @@ module.exports = function koaClassicServer(
1034
1256
  const minFileSize = compression.minFileSize === false ? false
1035
1257
  : (typeof compression.minFileSize === 'number' && compression.minFileSize >= 0 ? compression.minFileSize : 1024);
1036
1258
 
1259
+ // Cap for the buffered (whole-file-in-RAM, max-quality, cached) compression
1260
+ // path. false = no cap. Files above the cap still get compressed via the
1261
+ // bounded-RAM streaming mode — safety net, not a serving restriction.
1262
+ const maxFileSize = compression.maxFileSize === false ? false
1263
+ : (typeof compression.maxFileSize === 'number' && compression.maxFileSize > 0 ? compression.maxFileSize : 10485760);
1264
+
1037
1265
  const mimeTypes = Array.isArray(compression.mimeTypes) && compression.mimeTypes.length > 0
1038
1266
  ? compression.mimeTypes
1039
1267
  : DEFAULT_COMPRESSIBLE_MIME_TYPES;
1040
1268
 
1041
- return { enabled, encodings, minFileSize, mimeTypes: new Set(mimeTypes) };
1269
+ return { enabled, encodings, minFileSize, maxFileSize, mimeTypes: new Set(mimeTypes) };
1042
1270
  }
1043
1271
 
1044
1272
  // Normalize and validate the serverCache option into a clean internal structure.
@@ -1207,12 +1435,71 @@ module.exports = function koaClassicServer(
1207
1435
  _logger
1208
1436
  );
1209
1437
 
1210
- // Returns the client's preferred encoding based on Accept-Encoding header,
1211
- // filtered against the enabled encodings list. Returns null if no match.
1438
+ // Single-flight maps for cache population (thundering-herd protection):
1439
+ // N concurrent misses on the same key share ONE read (+ compression) instead
1440
+ // of N duplicated jobs. Entries live only while a job is in flight. Keys
1441
+ // include the stat'd mtime+size so requests that observed different versions
1442
+ // of the file never share a job (each response stays coherent with its own
1443
+ // ETag/Last-Modified).
1444
+ const _inflightRawReads = new Map(); // `${path}:${mtime}:${size}` → Promise<Buffer>
1445
+ const _inflightCompressions = new Map(); // `${path}:${encoding}:${mtime}:${size}` → Promise<Buffer>
1446
+ // Streamed-compression tee leaders in flight (`${path}:${encoding}:${mtime}:${size}`).
1447
+ // Only ONE request per key accumulates the compressed output for the cache,
1448
+ // so tee RAM never scales with the number of concurrent clients.
1449
+ const _inflightStreamTees = new Set();
1450
+ // Aggregate bytes currently accumulated by ALL in-flight tees. Bounds the
1451
+ // transient RAM across DISTINCT large files too: accumulation stops (the
1452
+ // entry is skipped, streaming continues) rather than let the tees hold more
1453
+ // RAM in aggregate than the compressed cache they feed (its maxSize).
1454
+ let _inflightTeeBytes = 0;
1455
+
1456
+ // Streams `toOpen` (or `rawBuffer`) through the bounded-RAM compressor for
1457
+ // `encoding` and sets it as the response body. Shared by the cache-disabled
1458
+ // streaming branch and by tee followers; the tee leader builds its own
1459
+ // pipeline with the extra accumulator stage.
1460
+ // pipeline (NOT pipe): teardown propagates in BOTH directions. When the
1461
+ // client disconnects mid-transfer, Koa destroys the body (the zlib
1462
+ // transform) and pipeline destroys `src` too, closing its file descriptor.
1463
+ // A bare src.pipe(compress) leaves the ReadStream paused with the fd open
1464
+ // forever — fd leak under aborted downloads. Client disconnects are a
1465
+ // normal event and are not logged (avoids client-driven log spam).
1466
+ function streamCompressedBody(ctx, toOpen, rawBuffer, encoding) {
1467
+ const compress = createStreamCompressor(encoding);
1468
+ const src = rawBuffer
1469
+ ? Readable.from(rawBuffer) // compress from in-memory buffer — no disk I/O
1470
+ : fs.createReadStream(toOpen);
1471
+ ctx.body = pipeline(src, compress, (err) => {
1472
+ if (!err || err.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
1473
+ _logger.error('Stream error:', err);
1474
+ if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
1475
+ });
1476
+ }
1477
+
1478
+ // Returns the server-preferred enabled encoding the client is willing to accept.
1479
+ // Server preference order (compressionConfig.encodings) still wins; the
1480
+ // Accept-Encoding q-values are used only to EXCLUDE encodings the client refuses
1481
+ // (q=0), per RFC 9110 §12.5.3. Token match is exact, not substring ("x-gzip" is
1482
+ // NOT "gzip"). A "*" supplies the q-value for any encoding not explicitly listed.
1212
1483
  function getClientEncoding(acceptEncoding) {
1213
1484
  if (!acceptEncoding) return null;
1485
+ const qValues = new Map(); // token → q-value
1486
+ for (const part of acceptEncoding.split(',')) {
1487
+ const [tokenRaw, ...params] = part.split(';');
1488
+ const token = tokenRaw.trim().toLowerCase();
1489
+ if (!token) continue;
1490
+ let q = 1;
1491
+ for (const p of params) {
1492
+ const m = /^\s*q=(\d+(?:\.\d+)?)\s*$/i.exec(p);
1493
+ if (m) q = parseFloat(m[1]);
1494
+ }
1495
+ qValues.set(token, q);
1496
+ }
1497
+ const star = qValues.get('*');
1214
1498
  for (const enc of compressionConfig.encodings) {
1215
- if (acceptEncoding.includes(enc)) return enc;
1499
+ let q = qValues.get(enc);
1500
+ if (q === undefined) q = star; // not listed → fall back to "*" if present
1501
+ if (q === undefined) continue; // not listed and no "*" → not offered
1502
+ if (q > 0) return enc; // acceptable → pick it (server preference order)
1216
1503
  }
1217
1504
  return null;
1218
1505
  }
@@ -1308,6 +1595,14 @@ module.exports = function koaClassicServer(
1308
1595
  const urlToUse = options.useOriginalUrl ? ctx.originalUrl : ctx.url;
1309
1596
  const _origin = ctx.protocol + '://' + ctx.host;
1310
1597
  const fullUrl = _origin + urlToUse;
1598
+
1599
+ // Whether the client's request path ends with "/" — captured from the
1600
+ // raw originalUrl BEFORE the trailing slash is stripped for URL parsing
1601
+ // below. Drives the canonical trailing-slash redirect / 404 in the
1602
+ // directory / file branch. "/" (root) counts as ending with a slash, so
1603
+ // it is already canonical and never redirects.
1604
+ const _rawOriginalPath = ctx.originalUrl.split('?')[0];
1605
+ const _pathEndsWithSlash = _rawOriginalPath.endsWith('/');
1311
1606
  // Parse the request URL. `new URL()` throws on an invalid Host header
1312
1607
  // (e.g. "Host: bad host") — reject as 400 rather than letting it surface as 500.
1313
1608
  let pageHref = '';
@@ -1357,194 +1652,291 @@ module.exports = function koaClassicServer(
1357
1652
  }
1358
1653
  }
1359
1654
 
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 {
1655
+ // From this point on the middleware OWNS the request: every early
1656
+ // pass-through above has already returned, and no next() is called below
1657
+ // (the template render's next goes through tryRenderTemplate's own catch).
1658
+ // Last-resort net: an unexpected failure on any unguarded path must not
1659
+ // leak to Koa's default handler (a plain-text 500 without the middleware's
1660
+ // security headers, logged outside the operator's `logger`). Errors from
1661
+ // downstream middleware are NOT masked they never reach this try.
1662
+ try {
1663
+ // Path traversal protection: build and validate safe file path
1664
+ let requestedPath = "";
1665
+ if (pageHrefOutPrefix.pathname === "/") {
1666
+ requestedPath = "";
1667
+ } else {
1668
+ // decodeURIComponent() throws URIError on malformed percent-encoding
1669
+ // (e.g. "/%", "/%zz", a truncated UTF-8 sequence) — reject as 400
1670
+ // rather than letting it surface as an unhandled 500.
1671
+ try {
1672
+ requestedPath = decodeURIComponent(pageHrefOutPrefix.pathname);
1673
+ } catch {
1674
+ sendBadRequest(ctx);
1675
+ return;
1676
+ }
1677
+ }
1678
+
1679
+ // Null byte guard: path.normalize() throws ERR_INVALID_ARG_VALUE for paths
1680
+ // containing \0. Reject early with 400 Bad Request before it reaches fs calls.
1681
+ if (requestedPath.includes('\0')) {
1371
1682
  sendBadRequest(ctx);
1372
1683
  return;
1373
1684
  }
1374
- }
1375
1685
 
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
- }
1686
+ const normalizedPath = path.normalize(requestedPath);
1687
+ const fullPath = path.join(normalizedRootDir, normalizedPath);
1688
+
1689
+ // Security check: ensure resolved path is within rootDir. Uses the shared
1690
+ // _isWithinRoot() helper, which is boundary-aware: it matches rootDir exactly
1691
+ // or rootDir + path.sep, never a sibling (e.g. /srv/wwwsecret for root /srv/www) —
1692
+ // hardened defense in depth against a future change to how fullPath is built.
1693
+ // Covers: ../ traversal, URL-encoded variants (%2e%2e%2f), and on Windows
1694
+ // backslash sequences (path.normalize converts \ to / before the check).
1695
+ // Returns 404 (not 403) so "outside root" is indistinguishable from "not found",
1696
+ // matching the symlink-escape and hidden-entry outcomes.
1697
+ if (!_isWithinRoot(fullPath, normalizedRootDir)) {
1698
+ sendNotFound(ctx);
1699
+ return;
1700
+ }
1398
1701
 
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;
1702
+ // Hidden check: block requests that traverse a hidden directory.
1703
+ // Stops at length-1 because the leaf (the file or dir being served) is
1704
+ // checked separately by the file/listing path with its real stat.isDirectory().
1705
+ if (requestedPath !== '') {
1706
+ const segments = normalizedPath.split(path.sep).filter(Boolean);
1707
+ for (let i = 0; i < segments.length - 1; i++) {
1708
+ const segName = segments[i];
1709
+ const segRelPath = segments.slice(0, i + 1).join('/');
1710
+ if (isHiddenEntry(segName, segRelPath, true)) {
1711
+ sendNotFound(ctx);
1712
+ return;
1713
+ }
1410
1714
  }
1411
1715
  }
1412
- }
1413
1716
 
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
- }
1717
+ let toOpen = fullPath;
1718
+
1719
+ // hideExtension logic: redirect URLs with extension and resolve clean URLs
1720
+ if (options.hideExtension) {
1721
+ const hideExt = options.hideExtension.ext;
1722
+ const hideRedirect = options.hideExtension.redirect;
1723
+
1724
+ // Trailing slash check via string — avoids a full new URL() construction
1725
+ const rawPath = urlToUse.split('?')[0];
1726
+ const hadTrailingSlash = rawPath.length > 1 && rawPath.endsWith('/');
1727
+
1728
+ // Model B (V4): an extension URL is canonicalized to its clean form only when it
1729
+ // has NO trailing slash. A trailing slash means directory intent, so /foo.ejs/
1730
+ // falls through to the file/dir dispatch, where a file requested with a trailing
1731
+ // slash is a 404 (finding #3). We gate on _pathEndsWithSlash — the very flag that
1732
+ // #3's file-branch 404 uses — so "skip the redirect" and "404 downstream" are the
1733
+ // same condition. requestedPath is decoded (and already slash-stripped by the URL
1734
+ // parse), so a percent-encoded dot (/foo%2Eejs /foo.ejs) matches consistently (#14),
1735
+ // while /foo%2Eejs/ is excluded here by _pathEndsWithSlash and 404s downstream.
1736
+ if (!_pathEndsWithSlash && requestedPath.endsWith(hideExt)) {
1737
+ // Build redirect target using ctx.originalUrl (always, regardless of
1738
+ // useOriginalUrl). With useOriginalUrl: false the URL-parsing prologue
1739
+ // validated ctx.url (the rewritten one), NOT originalUrl a malformed
1740
+ // originalUrl (e.g. an absolute-form request target, legal in HTTP/1.1)
1741
+ // makes this constructor throw. Same treatment as the other malformed
1742
+ // client input: 400 Bad Request, not an unhandled error.
1743
+ let originalUrlObj;
1744
+ try {
1745
+ originalUrlObj = new URL(_origin + ctx.originalUrl);
1746
+ } catch {
1747
+ sendBadRequest(ctx);
1748
+ return;
1749
+ }
1441
1750
 
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;
1751
+ // Build the clean target in DECODED space: the extension may be percent-encoded
1752
+ // in the raw URL (e.g. "%2Eejs"), so decoding first makes it literal and
1753
+ // sliceable, then we re-encode per segment (#14, #20). The URL constructor
1754
+ // already validated the encoding, so decodeURIComponent cannot throw — but
1755
+ // guard for symmetry with the other malformed-client-input guards.
1756
+ let decodedPath;
1757
+ try {
1758
+ decodedPath = decodeURIComponent(originalUrlObj.pathname);
1759
+ } catch {
1760
+ sendBadRequest(ctx);
1761
+ return;
1762
+ }
1763
+
1764
+ let cleanPath = decodedPath.slice(0, decodedPath.length - hideExt.length);
1765
+
1766
+ // Special case: /index.ejs → /, /sezione/index.ejs → /sezione/
1767
+ const baseName = path.basename(cleanPath);
1768
+ if (options.index && options.index.length > 0) {
1769
+ for (const pattern of options.index) {
1770
+ if (typeof pattern === 'string' && (baseName + hideExt) === pattern) {
1771
+ // Redirect to the directory (with trailing slash)
1772
+ cleanPath = cleanPath.slice(0, cleanPath.length - baseName.length);
1773
+ break;
1774
+ }
1453
1775
  }
1454
1776
  }
1455
- }
1456
1777
 
1457
- // Preserve query string
1458
- const redirectUrl = redirectPath + (originalUrlObj.search || '');
1778
+ // Re-encode per path segment: round-trips spaces, "%", etc. back into a valid
1779
+ // URL. encodeURIComponent may over-encode sub-delims in exotic filenames, but
1780
+ // the result always resolves to the same path.
1781
+ let redirectPath = cleanPath.split('/').map(encodeURIComponent).join('/');
1782
+
1783
+ // Open-redirect guard LAST: a Location starting with "//" (or "/\") is a
1784
+ // protocol-relative URL that would navigate off-origin ("GET //evil.com/foo.ejs").
1785
+ // Placed after re-encoding because a decoded "%2F" can reintroduce a leading
1786
+ // "//"; collapse any run of leading slashes/backslashes to a single slash.
1787
+ if (redirectPath.length > 1 && (redirectPath.charCodeAt(1) === 0x2F || redirectPath.charCodeAt(1) === 0x5C)) {
1788
+ redirectPath = '/' + redirectPath.replace(/^[/\\]+/, '');
1789
+ }
1459
1790
 
1460
- ctx.status = hideRedirect;
1461
- ctx.redirect(redirectUrl);
1462
- return;
1463
- }
1791
+ // Preserve query string
1792
+ const redirectUrl = redirectPath + (originalUrlObj.search || '');
1464
1793
 
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;
1794
+ ctx.status = hideRedirect;
1795
+ ctx.redirect(redirectUrl);
1796
+ return;
1797
+ }
1470
1798
 
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;
1799
+ // Check if URL has no extension try adding the configured extension
1800
+ // Skip if original URL had trailing slash (trailing slash = directory intent)
1801
+ const extOfRequested = path.extname(requestedPath);
1802
+ if (!extOfRequested && requestedPath !== '' && !requestedPath.endsWith('/') && !hadTrailingSlash) {
1803
+ const pathWithExt = fullPath + hideExt;
1804
+
1805
+ // Security check: ensure resolved path is still within rootDir (boundary-aware)
1806
+ if (_isWithinRoot(pathWithExt, normalizedRootDir)) {
1807
+ try {
1808
+ const statWithExt = await fs.promises.stat(pathWithExt);
1809
+ if (statWithExt.isFile()) {
1810
+ // File with extension exists, serve it
1811
+ toOpen = pathWithExt;
1812
+ }
1813
+ } catch {
1814
+ // File with extension doesn't exist, continue normal flow
1478
1815
  }
1479
- } catch {
1480
- // File with extension doesn't exist, continue normal flow
1481
1816
  }
1482
1817
  }
1483
1818
  }
1484
- }
1485
1819
 
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
- }
1820
+ // Check if path exists
1821
+ let stat;
1822
+ try {
1823
+ stat = await fs.promises.stat(toOpen);
1824
+ } catch {
1825
+ // File/directory doesn't exist or can't be accessed
1826
+ sendNotFound(ctx);
1827
+ return;
1828
+ }
1829
+
1830
+ // Hidden check: block access to the requested file or directory itself
1831
+ if (requestedPath !== '') {
1832
+ const entryName = path.basename(toOpen);
1833
+ const entryRelPath = path.relative(normalizedRootDir, toOpen).split(path.sep).join('/');
1834
+ if (isHiddenEntry(entryName, entryRelPath, stat.isDirectory())) {
1835
+ sendNotFound(ctx);
1836
+ return;
1837
+ }
1838
+ }
1495
1839
 
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())) {
1840
+ // Symlink boundary check (V-1): in protected modes reject any requested file
1841
+ // or directory whose realpath escapes rootDir (or, in 'deny' mode, any symlink
1842
+ // resolved below rootDir). The `_symlinkMode !== 'follow'` guard short-circuits
1843
+ // before any await so the default 'follow' mode stays truly zero-overhead.
1844
+ if (_symlinkMode !== 'follow' && !(await symlinkAllowed(toOpen))) {
1501
1845
  sendNotFound(ctx);
1502
1846
  return;
1503
1847
  }
1504
- }
1505
1848
 
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
- }
1849
+ if (stat.isDirectory()) {
1850
+ // Handle directory
1851
+ if (options.dirListing.enabled) {
1852
+ // Canonical trailing-slash redirect (V4): a directory URL
1853
+ // without a trailing slash serves an index/listing whose
1854
+ // relative links would resolve against the parent. Redirect
1855
+ // /dir → /dir/ (301) BEFORE serving so the browser's base is
1856
+ // the directory itself.
1857
+ if (options.dirListing.trailingSlash && !_pathEndsWithSlash) {
1858
+ // Build the Location from the parsed originalUrl (same
1859
+ // defense as the hideExtension redirect): re-parsing forces
1860
+ // the target to be origin-relative — an absolute-form
1861
+ // request target (`GET http://evil/x`, legal in HTTP/1.1
1862
+ // and reachable under useOriginalUrl:false + rewriting)
1863
+ // would otherwise become an off-origin `Location` (open
1864
+ // redirect). .pathname keeps urlPrefix and percent-encoding.
1865
+ let originalUrlObj;
1866
+ try {
1867
+ originalUrlObj = new URL(_origin + ctx.originalUrl);
1868
+ } catch {
1869
+ sendBadRequest(ctx);
1870
+ return;
1871
+ }
1872
+ // Collapse a leading "//" / "/\" (protocol-relative) to a
1873
+ // single slash before appending the canonical trailing one.
1874
+ let redirectPath = originalUrlObj.pathname;
1875
+ if (redirectPath.length > 1 && (redirectPath.charCodeAt(1) === 0x2F || redirectPath.charCodeAt(1) === 0x5C)) {
1876
+ redirectPath = '/' + redirectPath.replace(/^[/\\]+/, '');
1877
+ }
1878
+ ctx.status = 301;
1879
+ ctx.redirect(redirectPath + '/' + originalUrlObj.search);
1880
+ return;
1881
+ }
1514
1882
 
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);
1883
+ // Search for index file matching configured patterns
1884
+ if (options.index && options.index.length > 0) {
1885
+ const indexFile = await findIndexFile(toOpen, options.index);
1886
+ if (indexFile) {
1887
+ const indexRelPath = path.relative(normalizedRootDir, path.join(toOpen, indexFile.name)).split(path.sep).join('/');
1888
+ if (!isHiddenEntry(indexFile.name, indexRelPath, false)) {
1889
+ const indexPath = path.join(toOpen, indexFile.name);
1890
+ // Symlink boundary check (V-1): an index file may itself be a
1891
+ // symlink escaping rootDir — validate before serving it.
1892
+ // Guarded so the default 'follow' mode skips the await entirely.
1893
+ if (_symlinkMode !== 'follow' && !(await symlinkAllowed(indexPath))) {
1894
+ sendNotFound(ctx);
1895
+ return;
1896
+ }
1897
+ await loadFile(indexPath, indexFile.stat);
1530
1898
  return;
1531
1899
  }
1532
- await loadFile(indexPath, indexFile.stat);
1533
- return;
1534
1900
  }
1535
1901
  }
1536
- }
1537
1902
 
1538
- // No index file found, show directory listing
1539
- ctx.body = await show_dir(toOpen, ctx);
1903
+ // No index file found, show directory listing
1904
+ ctx.body = await show_dir(toOpen, ctx);
1905
+ } else {
1906
+ // Directory listing disabled
1907
+ sendNotFound(ctx);
1908
+ }
1909
+ return;
1540
1910
  } else {
1541
- // Directory listing disabled
1542
- sendNotFound(ctx);
1911
+ // Canonical trailing-slash 404 (V4): a trailing slash means
1912
+ // "directory", but this path resolved to a FILE — a file is only
1913
+ // reachable at its slash-less URL. Return 404 (indistinguishable
1914
+ // from not-found) rather than serving the file at a non-canonical
1915
+ // URL. Disabled by dirListing.trailingSlash: false (v3 behavior).
1916
+ if (options.dirListing.trailingSlash && _pathEndsWithSlash) {
1917
+ sendNotFound(ctx);
1918
+ return;
1919
+ }
1920
+ await loadFile(toOpen, stat);
1921
+ return;
1543
1922
  }
1544
- return;
1545
- } else {
1546
- await loadFile(toOpen, stat);
1547
- return;
1923
+ } catch (err) {
1924
+ _logger.error('[koa-classic-server] Unexpected error while serving the request:', err);
1925
+ if (ctx.headerSent || ctx.res.writableEnded) {
1926
+ ctx.res.destroy(); // response already in flight — nothing sane left to send
1927
+ return;
1928
+ }
1929
+ // Scrub representation/caching headers a partially-built response may
1930
+ // have left behind: a stale Content-Encoding would corrupt the error
1931
+ // page, a public Cache-Control could get the 500 cached by proxies.
1932
+ for (const h of ['Content-Encoding', 'Content-Disposition', 'Content-Range', 'Vary', 'ETag', 'Last-Modified', 'Accept-Ranges']) {
1933
+ ctx.remove(h);
1934
+ }
1935
+ ctx.set('Cache-Control', 'no-store');
1936
+ ctx.set('Content-Type', 'text/html; charset=utf-8');
1937
+ setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
1938
+ ctx.status = 500;
1939
+ ctx.body = _INTERNAL_ERROR_HTML;
1548
1940
  }
1549
1941
 
1550
1942
  // Internal functions
@@ -1578,13 +1970,23 @@ module.exports = function koaClassicServer(
1578
1970
  rawBuffer = cached.buffer;
1579
1971
  } else {
1580
1972
  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);
1973
+ // Single-flight: concurrent misses on the same path share one
1974
+ // readFile + cache insert; only the leader (the closure below)
1975
+ // runs, waiters await the same Promise. The key includes the
1976
+ // stat'd mtime+size so a request that observed a DIFFERENT
1977
+ // version of the file starts its own job instead of adopting
1978
+ // bytes that don't match the validators it will emit.
1979
+ const inflightKey = `${toOpen}:${fileStat.mtime.getTime()}:${fileStat.size}`;
1980
+ rawBuffer = await singleFlight(_inflightRawReads, inflightKey, async () => {
1981
+ const buf = await fs.promises.readFile(toOpen);
1982
+ refreshOrInsert(_rawFileCache, toOpen, {
1983
+ buffer: buf,
1984
+ mtime: fileStat.mtime.getTime(),
1985
+ size: fileStat.size,
1986
+ insertedAt: Date.now(),
1987
+ }, cached, staleByAge);
1988
+ return buf;
1989
+ });
1588
1990
  } catch {
1589
1991
  rawBuffer = null; // Fall through to disk reads later
1590
1992
  }
@@ -1632,6 +2034,67 @@ module.exports = function koaClassicServer(
1632
2034
  }
1633
2035
  }
1634
2036
 
2037
+ // Determine MIME type and compression encoding for the full-file response
2038
+ const mimeType = mime.lookup(toOpen) || 'application/octet-stream';
2039
+ const filename = path.basename(toOpen);
2040
+
2041
+ // Resolve compression: enabled + compressible MIME + meets minFileSize + client supports it
2042
+ let encoding = null; // 'br' | 'gzip' | null
2043
+ let potentiallyCompressible = false; // response content-negotiates on Accept-Encoding
2044
+ if (compressionConfig.enabled && compressionConfig.encodings.length > 0) {
2045
+ const isCompressibleMime = compressionConfig.mimeTypes.has(mimeType);
2046
+ const meetsMinSize = compressionConfig.minFileSize === false
2047
+ || fileStat.size >= compressionConfig.minFileSize;
2048
+ if (isCompressibleMime && meetsMinSize) {
2049
+ potentiallyCompressible = true; // even if this client gets identity
2050
+ encoding = getClientEncoding(ctx.get('Accept-Encoding'));
2051
+ }
2052
+ }
2053
+
2054
+ // fullEtag is encoding-specific to avoid false 304 hits across representations.
2055
+ // Proxies use Vary: Accept-Encoding to cache separate versions per encoding.
2056
+ const etagSuffix = encoding === 'br' ? '-br' : encoding === 'gzip' ? '-gz' : '';
2057
+ const fullEtag = `"${fileStat.mtime.getTime()}-${fileStat.size}${etagSuffix}"`;
2058
+
2059
+ // Vary: Accept-Encoding as soon as the resource is *potentially* compressible —
2060
+ // regardless of whether THIS client gets a compressed variant, and regardless
2061
+ // of browserCacheEnabled. RFC 9110 §15.4.5: the 304 below must carry the same
2062
+ // Vary the 200 would; and a shared proxy must not serve the identity variant to
2063
+ // a client that would have received the compressed one (#7).
2064
+ if (potentiallyCompressible) {
2065
+ ctx.set('Vary', 'Accept-Encoding');
2066
+ }
2067
+
2068
+ // Preconditions are evaluated BEFORE the Range branch: RFC 9110 §13.2.2 gives the
2069
+ // validators precedence over Range (steps 3/4 before step 5), so a conditional
2070
+ // request that matches returns 304 (Not Modified), not 206 (Partial Content).
2071
+ // Comparison uses fullEtag (the encoding-specific representation a full GET would
2072
+ // return); a 206 below re-tags itself with baseEtag (the identity partial it serves).
2073
+ if (options.browserCacheEnabled) {
2074
+ ctx.set('ETag', fullEtag);
2075
+ ctx.set('Last-Modified', fileStat.mtime.toUTCString());
2076
+
2077
+ // If-None-Match: "*" | comma-list, weak comparison (RFC 9110 §13.1.2).
2078
+ if (ifNoneMatchSatisfied(ctx.get('If-None-Match'), fullEtag)) {
2079
+ ctx.status = 304;
2080
+ return;
2081
+ }
2082
+
2083
+ // If-Modified-Since (date validation). The mtime is truncated to whole seconds
2084
+ // before comparing: Last-Modified is emitted via toUTCString() (second precision
2085
+ // — HTTP dates have no milliseconds), so a client echoing that header back would
2086
+ // otherwise never match a sub-second mtime (e.g. 22:13:20.500 <= 22:13:20.000).
2087
+ const clientModifiedSince = ctx.get('If-Modified-Since');
2088
+ if (clientModifiedSince) {
2089
+ const clientDate = new Date(clientModifiedSince);
2090
+ const mtimeSeconds = Math.floor(fileStat.mtime.getTime() / 1000) * 1000;
2091
+ if (mtimeSeconds <= clientDate.getTime()) {
2092
+ ctx.status = 304;
2093
+ return;
2094
+ }
2095
+ }
2096
+ }
2097
+
1635
2098
  // Range request handling (HTTP 206 Partial Content — compression skipped for ranges)
1636
2099
  const rangeHeader = ctx.get('Range');
1637
2100
  if (rangeHeader) {
@@ -1651,10 +2114,11 @@ module.exports = function koaClassicServer(
1651
2114
  if (!ifRange || ifRange === baseEtag) {
1652
2115
  const { start, end } = parsed;
1653
2116
  const rangeLength = end - start + 1;
1654
- const mimeType = mime.lookup(toOpen) || 'application/octet-stream';
1655
- const filename = path.basename(toOpen);
1656
2117
 
1657
2118
  ctx.status = 206;
2119
+ // 206 returns identity bytes → tag with baseEtag, not the encoding-specific
2120
+ // fullEtag set above. Last-Modified is already set (browserCacheEnabled).
2121
+ if (options.browserCacheEnabled) ctx.set('ETag', baseEtag);
1658
2122
  ctx.set('Content-Range', `bytes ${start}-${end}/${fileSize}`);
1659
2123
  ctx.set('Content-Type', mimeType);
1660
2124
  ctx.set('Content-Length', String(rangeLength));
@@ -1662,8 +2126,9 @@ module.exports = function koaClassicServer(
1662
2126
 
1663
2127
  if (ctx.method !== 'HEAD') {
1664
2128
  if (rawBuffer) {
1665
- // Serve range slice from in-memory buffer — zero disk I/O
1666
- ctx.body = rawBuffer.slice(start, end + 1);
2129
+ // Serve range slice from in-memory buffer — zero disk I/O.
2130
+ // subarray(): zero-copy view; Buffer.slice is deprecated (DEP0158).
2131
+ ctx.body = rawBuffer.subarray(start, end + 1);
1667
2132
  } else {
1668
2133
  const src = fs.createReadStream(toOpen, { start, end });
1669
2134
  src.on('error', (err) => {
@@ -1688,57 +2153,25 @@ module.exports = function koaClassicServer(
1688
2153
  // Invalid Range → fall through to full 200 response
1689
2154
  }
1690
2155
 
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
2156
  // Common response headers
1735
2157
  ctx.set('Content-Type', mimeType);
1736
2158
  ctx.set('Content-Disposition', buildContentDisposition(filename));
1737
2159
 
1738
2160
  if (encoding) {
1739
2161
  // ── Compressed response ───────────────────────────────────────────────
2162
+ // Vary: Accept-Encoding is already set above (potentiallyCompressible).
1740
2163
  ctx.set('Content-Encoding', encoding);
1741
- ctx.set('Vary', 'Accept-Encoding'); // Required so proxies cache per-encoding
2164
+
2165
+ // Safety net (#4): the buffered path reads the WHOLE file into RAM and
2166
+ // compresses at max quality — fine for web assets, catastrophic for a
2167
+ // multi-GB log/CSV. Above compression.maxFileSize the bounded-RAM
2168
+ // streaming mode below is used instead. The cap gates only HOW the
2169
+ // compression runs (buffered Q11 vs streamed Q4) — the compressed
2170
+ // cache stays in play on both sides: above the cap the streamed
2171
+ // OUTPUT is teed into the cache when it fits (see the tee branch),
2172
+ // so later requests are RAM hits either way.
2173
+ const withinCompressCap = compressionConfig.maxFileSize === false
2174
+ || fileStat.size <= compressionConfig.maxFileSize;
1742
2175
 
1743
2176
  if (serverCacheConfig.compressedFile.enabled) {
1744
2177
  // compressedFile cache mode: compress once → buffer in RAM → Content-Length known
@@ -1755,23 +2188,123 @@ module.exports = function koaClassicServer(
1755
2188
  if (!stale) {
1756
2189
  _compressedFileCache.get(cacheKey); // increment frequency
1757
2190
  buf = cached.buffer; // Serve from cache
2191
+ } else if (!withinCompressCap) {
2192
+ // Above the buffered-compression cap: stream at the bounded-RAM
2193
+ // quality (never buffer the input), and tee the compressed OUTPUT
2194
+ // into the cache so later requests are RAM hits with Content-Length.
2195
+ // The cap protects against a large INPUT; the cache admission below
2196
+ // is decided on the actual OUTPUT size, which is only known here.
2197
+ if (ctx.method === 'HEAD') {
2198
+ // Mirror the GET status/headers (RFC 9110 §9.3.2) without running
2199
+ // the compression: no Content-Length (unknown), no cache insert.
2200
+ ctx.status = 200;
2201
+ return;
2202
+ }
2203
+ const mtimeMs = fileStat.mtime.getTime();
2204
+ const teeKey = `${cacheKey}:${mtimeMs}:${fileStat.size}`;
2205
+ if (_inflightStreamTees.has(teeKey)) {
2206
+ // Follower: another request is already accumulating this exact
2207
+ // file version. Stream independently (no added latency, no tee
2208
+ // stage) — the cache will be warm for the NEXT request.
2209
+ streamCompressedBody(ctx, toOpen, rawBuffer, encoding);
2210
+ return;
2211
+ }
2212
+ // Leader: stream AND accumulate a copy for the cache.
2213
+ _inflightStreamTees.add(teeKey);
2214
+ let acc = [];
2215
+ let accBytes = 0;
2216
+ // Two admission bounds, both on the real OUTPUT size:
2217
+ // - per entry: a quarter of the cache, so one huge file cannot
2218
+ // evict most of the working set on insert;
2219
+ // - aggregate (_inflightTeeBytes): all in-flight tees together may
2220
+ // never hold more RAM than the cache's own maxSize.
2221
+ const entryCap = Math.floor(serverCacheConfig.compressedFile.maxSize / 4);
2222
+ // Stops accumulating and releases this tee's share of the aggregate
2223
+ // budget. Safe to call more than once.
2224
+ const abandonAccumulation = () => {
2225
+ if (acc) {
2226
+ _inflightTeeBytes -= accBytes;
2227
+ acc = null;
2228
+ }
2229
+ };
2230
+ try {
2231
+ const compress = createStreamCompressor(encoding);
2232
+ const src = rawBuffer
2233
+ ? Readable.from(rawBuffer) // compress from in-memory buffer — no disk I/O
2234
+ : fs.createReadStream(toOpen);
2235
+ const tee = new Transform({
2236
+ transform(chunk, _enc, done) {
2237
+ if (acc) {
2238
+ accBytes += chunk.length;
2239
+ _inflightTeeBytes += chunk.length;
2240
+ if (accBytes > entryCap
2241
+ || _inflightTeeBytes > serverCacheConfig.compressedFile.maxSize) {
2242
+ abandonAccumulation(); // over budget: keep streaming, skip the cache
2243
+ } else {
2244
+ acc.push(chunk);
2245
+ }
2246
+ }
2247
+ done(null, chunk);
2248
+ },
2249
+ });
2250
+ // pipeline (NOT pipe): teardown propagates in BOTH directions —
2251
+ // same fd-leak rationale as streamCompressedBody.
2252
+ ctx.body = pipeline(src, compress, tee, (err) => {
2253
+ _inflightStreamTees.delete(teeKey);
2254
+ if (!err && acc) {
2255
+ // Clean completion only: an aborted or failed stream never
2256
+ // inserts a (truncated) entry.
2257
+ refreshOrInsert(_compressedFileCache, cacheKey, {
2258
+ buffer: Buffer.concat(acc, accBytes),
2259
+ mtime: mtimeMs,
2260
+ size: fileStat.size,
2261
+ insertedAt: Date.now(),
2262
+ }, cached, staleByAge);
2263
+ }
2264
+ abandonAccumulation();
2265
+ if (!err || err.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
2266
+ _logger.error('Stream error:', err);
2267
+ if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
2268
+ });
2269
+ } catch (err) {
2270
+ // Defensive: nothing between add() and pipeline() is expected to
2271
+ // throw synchronously, but a leaked teeKey would silently disable
2272
+ // the tee for this file version forever.
2273
+ _inflightStreamTees.delete(teeKey);
2274
+ abandonAccumulation();
2275
+ throw err;
2276
+ }
2277
+ return;
1758
2278
  } else {
1759
2279
  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);
2280
+ // Single-flight: concurrent misses on the same path+encoding
2281
+ // share one read+compress+insert instead of N parallel brotli
2282
+ // jobs for identical content. A rejection is shared too: all
2283
+ // waiters land in this catch and use the uncompressed fallback.
2284
+ // The key includes the stat'd mtime+size so a request that
2285
+ // observed a DIFFERENT version of the file starts its own job
2286
+ // (its ETag/Last-Modified must describe the bytes it serves).
2287
+ const inflightKey = `${cacheKey}:${fileStat.mtime.getTime()}:${fileStat.size}`;
2288
+ buf = await singleFlight(_inflightCompressions, inflightKey, async () => {
2289
+ // Use rawFile buffer if available — avoids redundant disk read
2290
+ const rawData = rawBuffer || await fs.promises.readFile(toOpen);
2291
+ const compressed = await compressBuffer(rawData, encoding);
2292
+ refreshOrInsert(_compressedFileCache, cacheKey, {
2293
+ buffer: compressed,
2294
+ mtime: fileStat.mtime.getTime(),
2295
+ size: fileStat.size,
2296
+ insertedAt: Date.now(),
2297
+ }, cached, staleByAge);
2298
+ return compressed;
2299
+ });
1770
2300
  } catch (err) {
1771
2301
  _logger.error('Compression error:', err);
1772
- // Fall back to uncompressed on any compression failure
2302
+ // Fall back to uncompressed on any compression failure. Vary STAYS
2303
+ // (the resource is still compressible), but the ETag must be reset to
2304
+ // the un-suffixed form: this identity body must not be cached by a
2305
+ // shared proxy under the -br/-gz validator (#7).
1773
2306
  ctx.remove('Content-Encoding');
1774
- ctx.remove('Vary');
2307
+ if (options.browserCacheEnabled) ctx.set('ETag', baseEtag);
1775
2308
  if (rawBuffer) {
1776
2309
  ctx.set('Content-Length', String(rawBuffer.length));
1777
2310
  if (ctx.method !== 'HEAD') {
@@ -1808,25 +2341,17 @@ module.exports = function koaClassicServer(
1808
2341
  }
1809
2342
 
1810
2343
  } else {
1811
- // Streaming mode: pipe through zlib transform — Content-Length not known in advance
2344
+ // Streaming mode (compressed cache disabled): pipe through the zlib
2345
+ // transform — Content-Length not known in advance, nothing cached.
1812
2346
  if (ctx.method !== 'HEAD') {
1813
- const compress = encoding === 'br'
1814
- ? zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } })
1815
- : 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
- }
2347
+ streamCompressedBody(ctx, toOpen, rawBuffer, encoding);
2348
+ } else {
2349
+ // HEAD: mirror the GET status and headers (RFC 9110 §9.3.2) — no
2350
+ // Content-Length, since the compressed size is unknown without
2351
+ // running the compression. The explicit status is required: with
2352
+ // no body ever assigned, Koa would otherwise respond its default 404.
2353
+ ctx.status = 200;
1828
2354
  }
1829
- // HEAD + streaming: no Content-Length available; Koa sends headers only via res.end()
1830
2355
  }
1831
2356
 
1832
2357
  } else {
@@ -1945,9 +2470,12 @@ module.exports = function koaClassicServer(
1945
2470
  parts.push("</thead>");
1946
2471
  parts.push("<tbody>");
1947
2472
 
1948
- // Parent directory link
2473
+ // Parent directory link — omitted at the middleware's LOGICAL root
2474
+ // (pageHrefOutPrefix.pathname === '/'), not only at the absolute root: with
2475
+ // urlPrefix '/static', the listing of /static/ must not link to '/', which is
2476
+ // outside the served tree (#13).
1949
2477
  const currentPath = pageHref.origin + pageHref.pathname;
1950
- if (currentPath !== pageHrefOutPrefix.origin + "/") {
2478
+ if (pageHrefOutPrefix.pathname !== "/") {
1951
2479
  // Build parent directory URL without query parameters
1952
2480
  const a_pD = currentPath.split("/");
1953
2481
  a_pD.pop();
@@ -1956,8 +2484,9 @@ module.exports = function koaClassicServer(
1956
2484
  parts.push(`<tr><td><a href="${escapeHtml(parentDirectory)}"><b>.. Parent Directory</b></a></td><td>DIR</td><td>-</td></tr>`);
1957
2485
  }
1958
2486
 
2487
+ const emptyFolderRow = `<tr><td>empty folder</td><td></td><td></td></tr>`;
1959
2488
  if (dir.length === 0) {
1960
- parts.push(`<tr><td>empty folder</td><td></td><td></td></tr>`);
2489
+ parts.push(emptyFolderRow);
1961
2490
  } else {
1962
2491
  const _listingBaseUrl = pageHref.origin + pageHref.pathname;
1963
2492
  const _listingOriginPrefix = pageHref.origin + options.urlPrefix;
@@ -2051,6 +2580,13 @@ module.exports = function koaClassicServer(
2051
2580
  }
2052
2581
  const items = rawItems.filter(Boolean);
2053
2582
 
2583
+ // Every entry was filtered out as hidden (dotfiles / alwaysHide / blacklist):
2584
+ // show the same "empty folder" row as a physically empty directory (#16), not a
2585
+ // header-only empty table (which would also hint that hidden files exist).
2586
+ if (items.length === 0) {
2587
+ parts.push(emptyFolderRow);
2588
+ }
2589
+
2054
2590
  // Places directories before non-directories; falls back to `tieBreaker`
2055
2591
  // when both items are in the same bucket. `effectiveType === 2` covers plain
2056
2592
  // dirs and dir-resolved symlinks, matching the rest of the listing logic.
@@ -2186,3 +2722,18 @@ module.exports = function koaClassicServer(
2186
2722
 
2187
2723
  };
2188
2724
  };
2725
+
2726
+ // ── Test-only internals ──────────────────────────────────────────────────────
2727
+ // NOT part of the public API: exposed so the unit tests can exercise the pure
2728
+ // helpers and the LFU cache directly (eviction order, frequency preservation,
2729
+ // range parsing, validator matching) without a full HTTP round-trip. No
2730
+ // stability guarantee — do not import from application code.
2731
+ module.exports._internals = {
2732
+ LFUCache,
2733
+ parseRangeHeader,
2734
+ ifNoneMatchSatisfied,
2735
+ formatSize,
2736
+ singleFlight,
2737
+ refreshOrInsert,
2738
+ escapeHtml,
2739
+ };