koa-classic-server 4.2.1 → 5.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 +77 -32
  2. package/index.cjs +564 -150
  3. package/package.json +3 -3
package/index.cjs CHANGED
@@ -14,6 +14,26 @@ const _gzipAsync = util.promisify(zlib.gzip);
14
14
  // Pre-computed module-level constants
15
15
  const _LOG_1024 = Math.log(1024);
16
16
 
17
+ // Redirect statuses accepted for hideExtension.redirect (V5 — anything else
18
+ // throws at factory time; see the validation block for the rationale). The
19
+ // set is exactly what Koa's ctx.redirect() emits AS-IS (statuses.redirect):
20
+ // 300 and 305 are exotic/deprecated but valid and honored; 304 is NOT here
21
+ // because Koa does not treat it as a redirect (it would be rewritten to 302).
22
+ const _VALID_REDIRECT_CODES = new Set([300, 301, 302, 303, 305, 307, 308]);
23
+
24
+ // Shared extension-suffix normalizer (V5 — v4.3 register #10), used by BOTH
25
+ // hideExtension.ext and template.ext entries: the leading dot is optional
26
+ // decoration ('.ejs' and 'ejs' are equivalent; '.ejs' is the preferred,
27
+ // documented form) and compound suffixes ('.tar.gz', '.html.ejs') are
28
+ // first-class — both options match by SUFFIX. Returns the canonical dotted
29
+ // form, or null when the value cannot name a suffix (non-string, empty, '.').
30
+ function normalizeExtSuffix(value) {
31
+ if (typeof value !== 'string') return null;
32
+ const stripped = value.startsWith('.') ? value.slice(1) : value;
33
+ if (stripped === '') return null;
34
+ return '.' + stripped;
35
+ }
36
+
17
37
  // Emitted at most once per process lifetime when the caller passes the v2-era
18
38
  // `showDirContents` option instead of the v3 `dirListing.enabled`. The old
19
39
  // name is accepted as a backward-compatibility alias and may be removed in a
@@ -183,7 +203,15 @@ const ERROR_PAGE_SCRUB_HEADERS = [
183
203
  // keep their own headerSent / writableEnded guards.
184
204
  function writeErrorPage(ctx, status, customBuffer, builtinHtml) {
185
205
  for (const h of ERROR_PAGE_SCRUB_HEADERS) ctx.remove(h);
186
- if (status >= 500) ctx.set('Cache-Control', 'no-store');
206
+ // Every generated error page is non-cacheable: no-store on ALL handled
207
+ // statuses (404 included), not just >= 500 (v5.0 register #1). A 404 is
208
+ // heuristically cacheable by default (RFC 9110 §15.1 / RFC 7231 §6.1), so
209
+ // without an explicit directive a shared cache could keep serving a stale
210
+ // "not found" after the file is created — the same heuristic-caching hazard
211
+ // the file branch (browserCacheEnabled:false) and the listing (v4.3 #5)
212
+ // already defeat explicitly. no-store rather than the listing's no-cache
213
+ // trio: an error page has nothing worth storing to revalidate later.
214
+ ctx.set('Cache-Control', 'no-store');
187
215
  setGeneratedPageHeaders(ctx, customBuffer ? null : NOT_FOUND_CSP);
188
216
  ctx.set('Content-Type', 'text/html; charset=utf-8');
189
217
  ctx.status = status;
@@ -230,8 +258,9 @@ function warnPayload(logger, message) {
230
258
  // multiple messages. These options (urlPrefix, urlsReserved, ...) are v2-stable,
231
259
  // so a malformed value is TOLERATED with a warning for now rather than thrown:
232
260
  // throwing on a stable option would be a breaking change on a minor upgrade.
233
- // The next major will flip `warnConfigDeprecation` into a hard throw (the call
234
- // sites already carry the final message) see docs/revisione_codice_v3.1.md #11.
261
+ // A FUTURE major (maintainer decision 2026-07-18: target 6.0.0, not 5.0.0)
262
+ // will flip `warnConfigDeprecation` into a hard throw (the call sites already
263
+ // carry the final message) — see docs/revisione_codice_v3.1.md #11.
235
264
  const _configDeprecationsWarned = new Set();
236
265
  function warnConfigDeprecation(logger, message) {
237
266
  if (_configDeprecationsWarned.has(message)) return;
@@ -290,8 +319,12 @@ function stripBodyForHead(ctx) {
290
319
  async function tryRenderTemplate(ctx, next, filePath, rawBuffer, templateOpts, logger, sendErrorPage) {
291
320
  if (templateOpts.ext.length === 0 || !templateOpts.render) return false;
292
321
 
293
- const fileExt = path.extname(filePath).slice(1);
294
- if (!fileExt || !templateOpts.ext.includes(fileExt)) return false;
322
+ // Suffix match (V5 — v4.3 register #10): ext entries are canonical dotted
323
+ // suffixes ('.ejs'; compound forms like '.html.ejs' match too). The length
324
+ // guard preserves the historical dotfile behavior: a file named exactly
325
+ // '.ejs' has no extension and never matches.
326
+ const baseName = path.basename(filePath);
327
+ if (!templateOpts.ext.some((ext) => baseName.length > ext.length && baseName.endsWith(ext))) return false;
295
328
 
296
329
  // RFC 9110 §9.3.2: HEAD must mirror GET (same status + headers, no body). The
297
330
  // user's render is run exactly as for GET — by presenting ctx.method as GET for
@@ -362,14 +395,74 @@ function escapeHtml(unsafe) {
362
395
  return unsafe.replace(_HTML_ESCAPE_RE, c => _HTML_ESCAPE_MAP[c]);
363
396
  }
364
397
 
398
+ // Lone (unpaired) surrogates cannot be percent-encoded: encodeURIComponent
399
+ // throws URIError on them. They cannot arrive from URL-decoded client input
400
+ // (the decode guards already reject invalid encodings with a 400), but Windows
401
+ // filenames are WTF-16 and readdir() can return them — one such name must not
402
+ // turn the whole directory listing (or the file's Content-Disposition) into a
403
+ // 500. String.prototype.toWellFormed() (Node >= 20, the engines minimum since
404
+ // v5.0.0) replaces every lone surrogate with U+FFFD. Well-formed strings —
405
+ // including astral pairs like emoji — pass through unchanged.
406
+ function toWellFormedName(name) {
407
+ return name.toWellFormed();
408
+ }
409
+
410
+ // Explicit bidi control characters (embedding/override/isolate,
411
+ // U+202A-U+202E and U+2066-U+2069) can make a listing entry DISPLAY as a
412
+ // different name — "evil‮txt.exe" renders roughly as "evilexe.txt"
413
+ // (extension spoofing). In the DISPLAYED name only they are replaced with a
414
+ // visible U+FFFD (the href and the served file are untouched); the caller
415
+ // additionally wraps the name in <bdi> so the directional run of one name
416
+ // (legit RTL included) cannot bleed into the rest of its row. Direction MARKS
417
+ // (U+200E/U+200F) are legitimate in RTL text and are left alone.
418
+ const _BIDI_CONTROLS_RE = /[\u202A-\u202E\u2066-\u2069]/g;
419
+ function listingDisplayName(name) {
420
+ return escapeHtml(name.replace(_BIDI_CONTROLS_RE, '\uFFFD'));
421
+ }
422
+
423
+ /**
424
+ * Build a Content-Disposition header value for inline serving.
425
+ *
426
+ * Uses both the legacy quoted-string form (ASCII fallback) and the RFC 5987
427
+ * extended form (UTF-8 percent-encoded) for maximum browser compatibility:
428
+ * inline; filename="ascii-safe"; filename*=UTF-8''percent-encoded
429
+ *
430
+ * The quoted-string form must stay within what Node accepts in a header
431
+ * value (latin1 minus control chars): anything outside — CJK, emoji, \n —
432
+ * would make ctx.set() throw ERR_INVALID_CHAR and turn the response into a
433
+ * 500. Those characters are replaced with '?' (same policy as express's
434
+ * content-disposition package); the real name still round-trips via the
435
+ * RFC 5987 form, which browsers prefer over filename (RFC 6266 §4.1).
436
+ * The name is normalized to well-formed UTF-16 first: a lone surrogate (WTF-16
437
+ * filename on Windows) would otherwise make encodeURIComponent throw.
438
+ */
439
+ function buildContentDisposition(filename) {
440
+ filename = toWellFormedName(filename);
441
+
442
+ // quoted-string fallback: printable latin1 only (controls and >0xFF → '?'), then escape \ and "
443
+ const asciiSafe = filename
444
+ .replace(/[^\x20-\x7e\xa0-\xff]/g, '?')
445
+ .replace(/\\/g, '\\\\')
446
+ .replace(/"/g, '\\"');
447
+
448
+ // RFC 5987 extended value: UTF-8 percent-encode everything except
449
+ // unreserved chars (ALPHA / DIGIT / "-" / "." / "_" / "~")
450
+ const rfc5987 = encodeURIComponent(filename)
451
+ .replace(/['()]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase());
452
+
453
+ return `inline; filename="${asciiSafe}"; filename*=UTF-8''${rfc5987}`;
454
+ }
455
+
365
456
  // Pure helper — depends only on _LOG_1024 (module scope), safe to hoist.
366
457
  function formatSize(bytes) {
367
458
  if (bytes === 0) return '0 B';
368
459
  if (bytes === undefined || bytes === null) return '-';
369
460
 
370
461
  const k = 1024;
371
- const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
372
- const i = Math.floor(Math.log(bytes) / _LOG_1024);
462
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
463
+ // Clamped to the last unit: an off-scale size must degrade to "huge number
464
+ // of EB", never to "N undefined" (#8).
465
+ const i = Math.min(Math.floor(Math.log(bytes) / _LOG_1024), sizes.length - 1);
373
466
 
374
467
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
375
468
  }
@@ -383,6 +476,10 @@ function getDirentType(dirent) {
383
476
  return 0;
384
477
  }
385
478
 
479
+ // Range start/end/suffix must be pure digit runs: parseInt alone would accept
480
+ // "1x" as 1 (see the strict-validation note inside parseRangeHeader).
481
+ const _RANGE_DIGITS_RE = /^\d+$/;
482
+
386
483
  /**
387
484
  * Parse a "Range: bytes=..." header against a known file size.
388
485
  * Only single ranges are supported; multi-range requests are treated as invalid.
@@ -408,25 +505,29 @@ function parseRangeHeader(rangeHeader, fileSize) {
408
505
 
409
506
  let start, end;
410
507
 
508
+ // Strict digit validation (#11): parseInt() would accept garbage prefixes
509
+ // ("bytes=1x-5y" as 1-5) and serve a 206 for a spec that RFC 9110 §14.2
510
+ // says must be ignored (→ full 200 via 'invalid'). Bounds were never at
511
+ // risk — this is pure conformance on malformed input.
411
512
  if (startStr === '') {
412
513
  // Suffix range: bytes=-N (last N bytes)
413
- if (endStr === '') return 'invalid';
514
+ if (!_RANGE_DIGITS_RE.test(endStr)) return 'invalid'; // also rejects ''
414
515
  const suffix = parseInt(endStr, 10);
415
- if (isNaN(suffix) || suffix <= 0) return 'invalid';
516
+ if (suffix <= 0) return 'invalid';
416
517
  if (fileSize === 0) return 'unsatisfiable';
417
518
  start = suffix >= fileSize ? 0 : fileSize - suffix;
418
519
  end = fileSize - 1;
419
520
  } else {
521
+ if (!_RANGE_DIGITS_RE.test(startStr)) return 'invalid';
420
522
  start = parseInt(startStr, 10);
421
- if (isNaN(start) || start < 0) return 'invalid';
422
523
  if (fileSize === 0 || start >= fileSize) return 'unsatisfiable';
423
524
 
424
525
  if (endStr === '') {
425
526
  // Open range: bytes=N-
426
527
  end = fileSize - 1;
427
528
  } else {
529
+ if (!_RANGE_DIGITS_RE.test(endStr)) return 'invalid';
428
530
  end = parseInt(endStr, 10);
429
- if (isNaN(end) || end < 0) return 'invalid';
430
531
  if (start > end) return 'invalid';
431
532
  // Clamp end to file size - 1
432
533
  if (end >= fileSize) end = fileSize - 1;
@@ -460,8 +561,12 @@ function ifNoneMatchSatisfied(headerValue, etag) {
460
561
  // set(key, entry) — insert, evicting LFU entries if needed
461
562
  // delete(key) — remove explicitly (e.g. stale entry before re-insert)
462
563
  class LFUCache {
463
- constructor(maxSize, warnInterval, cacheLabel, logger) {
564
+ constructor(maxSize, warnInterval, cacheLabel, logger, maxEntrySize) {
464
565
  this.maxSize = maxSize;
566
+ // Per-entry admission cap (bytes). Entries larger than this are never
567
+ // cached (they are still served). Infinity = no per-entry cap: only the
568
+ // physical maxSize bound applies.
569
+ this.maxEntrySize = typeof maxEntrySize === 'number' ? maxEntrySize : Infinity;
465
570
  this.warnInterval = warnInterval;
466
571
  this.cacheLabel = cacheLabel;
467
572
  this.logger = logger || console;
@@ -487,6 +592,15 @@ class LFUCache {
487
592
  }
488
593
 
489
594
  set(key, entry) {
595
+ // Invariant: set() must never run against a LIVE key — it would add the
596
+ // new buffer to currentSize without subtracting the overwritten one
597
+ // (accounting inflated forever → chronic premature evictions) and leave
598
+ // the key in two frequency buckets (a ghost that later makes
599
+ // _evictOne() destructure undefined — a throw inside a stream callback,
600
+ // outside any request try). Callers are expected to delete first
601
+ // (refreshOrInsert does, unconditionally); this guard makes the
602
+ // invariant hold by construction for any future caller too.
603
+ if (this._keyMap.has(key)) this.delete(key);
490
604
  // An entry larger than the whole cache can never fit: bail out BEFORE the
491
605
  // eviction loop, otherwise it would flush every other entry for nothing.
492
606
  // Warn (throttled) so the operator learns the cache is undersized for
@@ -495,6 +609,12 @@ class LFUCache {
495
609
  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.`);
496
610
  return;
497
611
  }
612
+ // Per-entry admission cap: refuse (and keep serving uncached) instead of
613
+ // letting one oversized entry evict most of the working set on insert.
614
+ if (entry.buffer.length > this.maxEntrySize) {
615
+ this._warnThrottled(`[koa-classic-server] serverCache.${this.cacheLabel}: entry of ${entry.buffer.length} bytes exceeds maxEntrySize (${this.maxEntrySize} bytes) and will not be cached. Increase serverCache.${this.cacheLabel}.maxEntrySize or set it to false.`);
616
+ return;
617
+ }
498
618
  while (this.currentSize + entry.buffer.length > this.maxSize && this._keyMap.size > 0) {
499
619
  this._evictOne();
500
620
  }
@@ -514,6 +634,7 @@ class LFUCache {
514
634
  const entry = this._keyMap.get(key);
515
635
  if (!entry) return false;
516
636
 
637
+ if (fields.buffer.length > this.maxEntrySize) return false;
517
638
  const sizeDelta = fields.buffer.length - entry.buffer.length;
518
639
  if (this.currentSize + sizeDelta > this.maxSize) return false;
519
640
 
@@ -609,22 +730,25 @@ function singleFlight(map, key, work) {
609
730
  // frequency counter survives — important for popular files refreshed by maxAge.
610
731
  // Otherwise falls back to delete + set (frequency resets to 1).
611
732
  // Bounded-RAM streaming compressor: constant-memory transform, lower quality
612
- // than the buffered path (which can afford brotli Q11 because it runs once and
613
- // is cached). Used by every streamed-compression pipeline.
733
+ // than the buffered path (which can afford max quality because it runs once and
734
+ // is cached). Used by every streamed-compression pipeline. `quality` carries the
735
+ // normalized compression.streaming settings (brotliQuality default 4, gzipLevel
736
+ // default 6).
614
737
  // LGWIN 19 (512 KB window instead of brotli's default 4 MB): the encoder state
615
738
  // is the dominant per-request RAM on this path (~10 MB/stream at the default,
616
739
  // ~1.3 GB peak measured under 100 concurrent cold requests), and at Q4 the big
617
740
  // window buys nothing on typical text (measured: same output size, ~40% faster).
618
741
  // The trade-off: content with identical blocks repeated at >512 KB distance
619
742
  // (rare, pathological) compresses worse than with the 4 MB window. gzip's
620
- // window is 32 KB by design — nothing to bound there.
621
- function createStreamCompressor(encoding) {
743
+ // window is 32 KB by design — nothing to bound there. LGWIN is deliberately
744
+ // NOT configurable: it is what bounds the per-stream RAM.
745
+ function createStreamCompressor(encoding, quality) {
622
746
  return encoding === 'br'
623
747
  ? zlib.createBrotliCompress({ params: {
624
- [zlib.constants.BROTLI_PARAM_QUALITY]: 4,
748
+ [zlib.constants.BROTLI_PARAM_QUALITY]: quality.brotliQuality,
625
749
  [zlib.constants.BROTLI_PARAM_LGWIN]: 19,
626
750
  } })
627
- : zlib.createGzip({ level: 6 });
751
+ : zlib.createGzip({ level: quality.gzipLevel });
628
752
  }
629
753
 
630
754
  function refreshOrInsert(cache, key, newEntry, cached, staleByAge) {
@@ -633,7 +757,13 @@ function refreshOrInsert(cache, key, newEntry, cached, staleByAge) {
633
757
  && cached.mtime === newEntry.mtime
634
758
  && cached.size === newEntry.size;
635
759
  if (!canRefreshInPlace || !cache.refresh(key, newEntry)) {
636
- if (cached) cache.delete(key);
760
+ // Unconditional delete: `cached` is the caller's snapshot from request
761
+ // start and may be STALE — on the streamed-tee path by minutes. Another
762
+ // request can have inserted this key meanwhile (a new file version via
763
+ // a second tee leader, or the buffered path after the file shrank), so
764
+ // gating the delete on the snapshot would set() over a live key and
765
+ // corrupt the LFU accounting. delete() is a safe no-op when absent.
766
+ cache.delete(key);
637
767
  cache.set(key, newEntry);
638
768
  }
639
769
  }
@@ -692,19 +822,23 @@ module.exports = function koaClassicServer(
692
822
  urlPrefix: "", // URL path prefix. Should start with "/" and NOT end with "/"
693
823
  // (e.g. "/static"); "" disables the prefix. A malformed value
694
824
  // is tolerated with a deprecation warning for now (behavior
695
- // unchanged) and WILL throw in the next major version.
825
+ // unchanged) and WILL throw in a future major (target: 6.0.0).
696
826
  urlsReserved: [], // Reserved first-level paths passed through to next().
697
827
  // Each entry should be a single first-level path: a leading
698
828
  // "/" plus one segment, no further "/" (e.g. "/admin").
699
829
  // Malformed entries are tolerated with a deprecation warning
700
- // for now and WILL throw in the next major version (a
830
+ // for now and WILL throw in a future major, target 6.0.0 (a
701
831
  // non-string entry is dropped to avoid a per-request 500).
702
832
  template: {
703
833
  render: undefined, // Template rendering function: async (ctx, next, filePath, rawBuffer, signal) => {}
704
834
  // rawBuffer (4th arg, may be null) is READ-ONLY: the same Buffer
705
835
  // instance is shared with the server cache and with concurrent
706
836
  // requests — mutating it corrupts other responses.
707
- ext: [], // File extensions to process with template.render
837
+ ext: [], // Extension suffixes handled by template.render, e.g. ['.ejs'].
838
+ // Preferred form WITH the leading dot; 'ejs' is equivalent (V5).
839
+ // Matched as a SUFFIX of the file name (case-sensitive), so
840
+ // compound extensions work too: ['.html.ejs'] targets only
841
+ // page.html.ejs-style files.
708
842
  renderTimeout: 30000, // Max ms allowed for template.render (number ≥ 0; 0 = disabled).
709
843
  // On timeout responds 504 Gateway Timeout. The render receives an
710
844
  // AbortSignal as 5th argument; propagate it to fetch/db/fs to free
@@ -719,8 +853,15 @@ module.exports = function koaClassicServer(
719
853
  useOriginalUrl: true, // Use ctx.originalUrl (default) or ctx.url
720
854
  // Set false for URL rewriting middleware (i18n, routing)
721
855
  hideExtension: { // Hide file extension from URLs (clean URLs like mod_rewrite)
722
- ext: '.ejs', // Extension to hide (required, string, case-sensitive, must start with '.')
723
- redirect: 301 // HTTP redirect code for URLs with extension (optional, default: 301)
856
+ ext: '.ejs', // Suffix to hide (required, case-sensitive). Preferred form
857
+ // WITH the leading dot; 'ejs' is equivalent (V5). Compound
858
+ // suffixes ('.tar.gz') are supported.
859
+ redirect: 301 // HTTP redirect code for URLs with extension (optional, default: 301).
860
+ // Must be one of 300 | 301 | 302 | 303 | 305 | 307 | 308 (the codes
861
+ // Koa emits as-is; 300/305 are exotic/deprecated but valid) — anything
862
+ // else throws at factory time (V5; previously other numbers were
863
+ // tolerated and either became 302 silently or failed every redirect
864
+ // with a 500).
724
865
  },
725
866
  errorPages: { // Custom error pages (V4.2+). Opt-in — omitted statuses keep the built-in pages.
726
867
  // Keys: the statuses the middleware generates error pages for: 404, 500, 504.
@@ -773,6 +914,14 @@ module.exports = function koaClassicServer(
773
914
  compressedFile: { // cache for HTTP br/gzip responses — not for .zip/.tar files on disk
774
915
  enabled: true, // enable in-memory cache of compressed response buffers
775
916
  maxSize: 104857600, // max total RAM used by this cache (bytes; default: 100 MB)
917
+ maxEntrySize: undefined, // max bytes of a SINGLE cached entry, measured on the compressed
918
+ // OUTPUT (V4.3+). Applies to every insertion: buffered path and
919
+ // streamed-tee path alike. Oversized entries are still served,
920
+ // just not cached (throttled warning via logger).
921
+ // undefined (default) → maxSize / 4, so one huge file cannot
922
+ // evict most of the working set on insert; false → no per-entry
923
+ // cap (maxSize still bounds); explicit bytes must be <= maxSize
924
+ // (larger throws at factory time — use false instead).
776
925
  maxAge: 0, // ms after insertion to consider an entry stale; 0 = disabled. See rawFile.maxAge.
777
926
  warnInterval: 60000, // ms between "maxSize reached" warnings; 0 = always; false = never
778
927
  },
@@ -795,6 +944,15 @@ module.exports = function koaClassicServer(
795
944
  // so subsequent requests are served from RAM with
796
945
  // Content-Length.
797
946
  mimeTypes: [], // compressible MIME types (replaces default list if provided)
947
+ buffered: { // quality for the BUFFERED path (V4.3+): file <= maxFileSize,
948
+ brotliQuality: 11, // compressed once, output cached — the cost is paid once per
949
+ gzipLevel: 9, // file, so max quality by default. brotliQuality: integer 0-11;
950
+ }, // gzipLevel: integer 0-9. Out-of-range throws at factory time.
951
+ streaming: { // quality for the STREAMING path (V4.3+): file > maxFileSize, or
952
+ brotliQuality: 4, // compressed cache disabled — the cost is paid on EVERY
953
+ gzipLevel: 6, // non-cached request, so deliberately light by default.
954
+ }, // The brotli window stays fixed at 512 KB (bounds per-stream
955
+ // RAM; not configurable). Same ranges as `buffered`.
798
956
  },
799
957
  // compression: false // shorthand to disable all compression
800
958
  staticSecurityHeaders: { // Opt-in security headers on STATIC file responses (V3.1+).
@@ -962,7 +1120,7 @@ module.exports = function koaClassicServer(
962
1120
  // Both are v2-stable options, so instead of throwing (a breaking change on a
963
1121
  // minor upgrade — a mis-slashed value that "worked" only by falling through
964
1122
  // to a downstream handler would suddenly change behavior), we WARN and leave
965
- // the runtime behavior exactly as it is today. The next major turns these
1123
+ // the runtime behavior exactly as it is today. A future major (6.0.0) turns these
966
1124
  // warnings into throws. The one exception is a non-string urlsReserved entry:
967
1125
  // it would 500 on every request (value.substring is not a function), which is
968
1126
  // not working behavior, so it is dropped defensively (still warned).
@@ -1015,8 +1173,36 @@ module.exports = function koaClassicServer(
1015
1173
  }
1016
1174
  options.urlsReserved = cleaned;
1017
1175
  }
1018
- options.template.render = (options.template.render === undefined || typeof options.template.render === 'function') ? options.template.render : undefined;
1019
- options.template.ext = Array.isArray(options.template.ext) ? options.template.ext : [];
1176
+ if (options.template.render !== undefined && typeof options.template.render !== 'function') {
1177
+ // Dropped SILENTLY before V5 (#10): a non-function render means templates
1178
+ // never run and template SOURCES are served as plain static files.
1179
+ warnConfigDeprecation(_logger,
1180
+ 'template.render must be a function; got ' +
1181
+ (options.template.render === null ? 'null' : typeof options.template.render) +
1182
+ ' — template rendering is DISABLED (template sources are served as static files).');
1183
+ options.template.render = undefined;
1184
+ }
1185
+ // template.ext entries go through the shared suffix normalizer: leading
1186
+ // dot optional ('.ejs' preferred), compound suffixes supported. Entries
1187
+ // that cannot name a suffix are dropped with a warning — they could never
1188
+ // match a file, so dropping them cannot break working behavior.
1189
+ if (Array.isArray(options.template.ext)) {
1190
+ const normalizedExts = [];
1191
+ for (const value of options.template.ext) {
1192
+ const suffix = normalizeExtSuffix(value);
1193
+ if (suffix === null) {
1194
+ warnConfigDeprecation(_logger,
1195
+ 'template.ext entries must be non-empty extension suffixes like ".ejs" (leading dot optional); dropping ' +
1196
+ (typeof value === 'string' ? JSON.stringify(value) : 'a non-string (' + (value === null ? 'null' : typeof value) + ')') +
1197
+ ' — it can never match a file.');
1198
+ continue;
1199
+ }
1200
+ normalizedExts.push(suffix);
1201
+ }
1202
+ options.template.ext = normalizedExts;
1203
+ } else {
1204
+ options.template.ext = [];
1205
+ }
1020
1206
 
1021
1207
  if (options.template.renderTimeout === undefined) {
1022
1208
  options.template.renderTimeout = 30000;
@@ -1068,23 +1254,34 @@ module.exports = function koaClassicServer(
1068
1254
  if (typeof options.hideExtension !== 'object' || Array.isArray(options.hideExtension)) {
1069
1255
  throw new Error('[koa-classic-server] hideExtension must be an object with an "ext" property. Example: { ext: ".ejs" }');
1070
1256
  }
1071
- if (!options.hideExtension.ext || typeof options.hideExtension.ext !== 'string') {
1072
- throw new Error('[koa-classic-server] hideExtension.ext is required and must be a non-empty string. Example: { ext: ".ejs" }');
1073
- }
1074
- // Normalize ext: add leading dot if missing
1075
- if (!options.hideExtension.ext.startsWith('.')) {
1076
- _logger.warn(...warnPayload(_logger,
1077
- '[koa-classic-server] WARNING: hideExtension.ext should start with a dot.\n' +
1078
- ` Current usage: ext: "${options.hideExtension.ext}"\n` +
1079
- ` Corrected to: ext: ".${options.hideExtension.ext}"\n` +
1080
- ' Please update your configuration.'
1081
- ));
1082
- options.hideExtension.ext = '.' + options.hideExtension.ext;
1257
+ // Shared suffix normalizer (V5, #10): '.ejs' and 'ejs' are EQUIVALENT
1258
+ // (the old missing-dot warning is gone both forms are legal; '.ejs'
1259
+ // is the preferred, documented one). Compound suffixes ('.tar.gz')
1260
+ // keep working as before. Values that cannot name a suffix ('', '.',
1261
+ // non-string) throw, as the empty/non-string forms always did.
1262
+ const normalizedHideExt = normalizeExtSuffix(options.hideExtension.ext);
1263
+ if (normalizedHideExt === null) {
1264
+ throw new Error(
1265
+ '[koa-classic-server] hideExtension.ext is required and must be a non-empty extension suffix. ' +
1266
+ 'Example: { ext: ".ejs" } (the leading dot is optional: "ejs" is equivalent).'
1267
+ );
1083
1268
  }
1084
- // Validate redirect code
1269
+ options.hideExtension.ext = normalizedHideExt;
1270
+ // Validate redirect code (V5 breaking change — v4.3 register #9).
1271
+ // Only real redirect statuses are accepted. Anything else was a
1272
+ // configuration bug with two silent failure modes: a non-integer (or
1273
+ // out-of-range) value made Koa's status setter throw at request time —
1274
+ // a 500 on EVERY hideExtension redirect — while an integer that is not
1275
+ // a redirect status (200, 404, 999, ...) was silently replaced with
1276
+ // 302 by ctx.redirect(). Both now surface at startup instead.
1085
1277
  if (options.hideExtension.redirect !== undefined) {
1086
- if (typeof options.hideExtension.redirect !== 'number') {
1087
- throw new Error('[koa-classic-server] hideExtension.redirect must be a number (e.g. 301, 302). Got: ' + typeof options.hideExtension.redirect);
1278
+ if (!_VALID_REDIRECT_CODES.has(options.hideExtension.redirect)) {
1279
+ throw new Error(
1280
+ '[koa-classic-server] hideExtension.redirect must be one of: 300, 301, 302, 303, 305, 307, 308. ' +
1281
+ 'Got: ' + String(options.hideExtension.redirect) + '\n' +
1282
+ ' Before v5.0.0 other values were tolerated: non-redirect codes were silently sent\n' +
1283
+ ' as 302, and non-integer values failed every redirect with a 500.'
1284
+ );
1088
1285
  }
1089
1286
  } else {
1090
1287
  options.hideExtension.redirect = 301;
@@ -1276,6 +1473,54 @@ module.exports = function koaClassicServer(
1276
1473
  // Normalize and validate the compression option into a clean internal structure.
1277
1474
  // compression: false is a valid shorthand for { enabled: false }.
1278
1475
  function normalizeCompressionConfig(compression) {
1476
+ // Per-path quality defaults (V4.3+). Two groups because the two execution
1477
+ // modes have opposite cost models: the buffered path compresses once and
1478
+ // caches the result (can afford max quality), the streaming path pays the
1479
+ // CPU on every non-cached request (must stay light).
1480
+ const defaultBuffered = { brotliQuality: 11, gzipLevel: 9 };
1481
+ const defaultStreaming = { brotliQuality: 4, gzipLevel: 6 };
1482
+
1483
+ // Strict validation: these namespaces are new in 4.3.0, so unknown keys
1484
+ // are caught as typos instead of being silently ignored (an operator who
1485
+ // misspells brotliQuality must not believe they lowered the quality).
1486
+ function validateQualityGroup(group, groupName, defaults) {
1487
+ if (group === undefined) return { ...defaults };
1488
+ if (!group || typeof group !== 'object' || Array.isArray(group)) {
1489
+ throw new Error(
1490
+ `[koa-classic-server] compression.${groupName} must be an object, e.g. ` +
1491
+ `{ brotliQuality: ${defaults.brotliQuality}, gzipLevel: ${defaults.gzipLevel} }. Got: ` + String(group)
1492
+ );
1493
+ }
1494
+ for (const key of Object.keys(group)) {
1495
+ if (key !== 'brotliQuality' && key !== 'gzipLevel') {
1496
+ throw new Error(
1497
+ `[koa-classic-server] compression.${groupName}.${key} is not a valid option. ` +
1498
+ 'Valid keys: brotliQuality, gzipLevel.'
1499
+ );
1500
+ }
1501
+ }
1502
+ const out = { ...defaults };
1503
+ if (group.brotliQuality !== undefined) {
1504
+ if (!Number.isInteger(group.brotliQuality) || group.brotliQuality < 0 || group.brotliQuality > 11) {
1505
+ throw new Error(
1506
+ `[koa-classic-server] compression.${groupName}.brotliQuality must be an integer between 0 and 11. ` +
1507
+ 'Got: ' + String(group.brotliQuality)
1508
+ );
1509
+ }
1510
+ out.brotliQuality = group.brotliQuality;
1511
+ }
1512
+ if (group.gzipLevel !== undefined) {
1513
+ if (!Number.isInteger(group.gzipLevel) || group.gzipLevel < 0 || group.gzipLevel > 9) {
1514
+ throw new Error(
1515
+ `[koa-classic-server] compression.${groupName}.gzipLevel must be an integer between 0 and 9. ` +
1516
+ 'Got: ' + String(group.gzipLevel)
1517
+ );
1518
+ }
1519
+ out.gzipLevel = group.gzipLevel;
1520
+ }
1521
+ return out;
1522
+ }
1523
+
1279
1524
  if (compression === false) return { enabled: false };
1280
1525
 
1281
1526
  if (!compression || typeof compression !== 'object' || Array.isArray(compression)) {
@@ -1285,6 +1530,8 @@ module.exports = function koaClassicServer(
1285
1530
  minFileSize: 1024, // bytes; skip compression for files smaller than this
1286
1531
  maxFileSize: 10485760, // bytes; above this the buffered+cached path is skipped (streaming instead)
1287
1532
  mimeTypes: new Set(DEFAULT_COMPRESSIBLE_MIME_TYPES),
1533
+ buffered: { ...defaultBuffered },
1534
+ streaming: { ...defaultStreaming },
1288
1535
  };
1289
1536
  }
1290
1537
 
@@ -1303,20 +1550,47 @@ module.exports = function koaClassicServer(
1303
1550
  ? compression.encodings.filter(e => e === 'br' || e === 'gzip')
1304
1551
  : ['br', 'gzip'];
1305
1552
 
1306
- const minFileSize = compression.minFileSize === false ? false
1307
- : (typeof compression.minFileSize === 'number' && compression.minFileSize >= 0 ? compression.minFileSize : 1024);
1553
+ // minFileSize / maxFileSize: an invalid value used to fall back to the
1554
+ // default SILENTLY (#13) inconsistent with maxAge / maxEntrySize /
1555
+ // quality, which throw. These are v2-stable options, so the fallback
1556
+ // stays (behavior unchanged) but now warns; a future major (6.0.0) will throw.
1557
+ let minFileSize;
1558
+ if (compression.minFileSize === undefined) {
1559
+ minFileSize = 1024;
1560
+ } else if (compression.minFileSize === false
1561
+ || (typeof compression.minFileSize === 'number' && compression.minFileSize >= 0)) {
1562
+ minFileSize = compression.minFileSize;
1563
+ } else {
1564
+ warnConfigDeprecation(_logger,
1565
+ 'compression.minFileSize must be a number >= 0 (bytes) or false; got ' +
1566
+ String(compression.minFileSize) + ' — falling back to the default 1024 for now.');
1567
+ minFileSize = 1024;
1568
+ }
1308
1569
 
1309
1570
  // Cap for the buffered (whole-file-in-RAM, max-quality, cached) compression
1310
1571
  // path. false = no cap. Files above the cap still get compressed via the
1311
1572
  // bounded-RAM streaming mode — safety net, not a serving restriction.
1312
- const maxFileSize = compression.maxFileSize === false ? false
1313
- : (typeof compression.maxFileSize === 'number' && compression.maxFileSize > 0 ? compression.maxFileSize : 10485760);
1573
+ let maxFileSize;
1574
+ if (compression.maxFileSize === undefined) {
1575
+ maxFileSize = 10485760;
1576
+ } else if (compression.maxFileSize === false
1577
+ || (typeof compression.maxFileSize === 'number' && compression.maxFileSize > 0)) {
1578
+ maxFileSize = compression.maxFileSize;
1579
+ } else {
1580
+ warnConfigDeprecation(_logger,
1581
+ 'compression.maxFileSize must be a positive number (bytes) or false; got ' +
1582
+ String(compression.maxFileSize) + ' — falling back to the default 10485760 for now.');
1583
+ maxFileSize = 10485760;
1584
+ }
1314
1585
 
1315
1586
  const mimeTypes = Array.isArray(compression.mimeTypes) && compression.mimeTypes.length > 0
1316
1587
  ? compression.mimeTypes
1317
1588
  : DEFAULT_COMPRESSIBLE_MIME_TYPES;
1318
1589
 
1319
- return { enabled, encodings, minFileSize, maxFileSize, mimeTypes: new Set(mimeTypes) };
1590
+ const buffered = validateQualityGroup(compression.buffered, 'buffered', defaultBuffered);
1591
+ const streaming = validateQualityGroup(compression.streaming, 'streaming', defaultStreaming);
1592
+
1593
+ return { enabled, encodings, minFileSize, maxFileSize, mimeTypes: new Set(mimeTypes), buffered, streaming };
1320
1594
  }
1321
1595
 
1322
1596
  // Normalize and validate the serverCache option into a clean internal structure.
@@ -1331,6 +1605,7 @@ module.exports = function koaClassicServer(
1331
1605
  const defaultCompressedFile = {
1332
1606
  enabled: true,
1333
1607
  maxSize: 104857600, // 100 MB
1608
+ maxEntrySize: 26214400, // maxSize / 4
1334
1609
  maxAge: 0,
1335
1610
  warnInterval: 60000,
1336
1611
  };
@@ -1346,26 +1621,70 @@ module.exports = function koaClassicServer(
1346
1621
  return value;
1347
1622
  }
1348
1623
 
1624
+ // maxEntrySize (V4.3+): per-entry admission cap for the compressed cache,
1625
+ // measured on the compressed OUTPUT. Applies to every insertion (buffered
1626
+ // path and streamed-tee path alike). undefined → a quarter of maxSize
1627
+ // (the historical tee-path bound, and it keeps scaling with maxSize);
1628
+ // false → no per-entry cap (normalized to Infinity; maxSize still bounds).
1629
+ function validateMaxEntrySize(value, maxSize) {
1630
+ if (value === undefined) return Math.floor(maxSize / 4);
1631
+ if (value === false) return Infinity;
1632
+ if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
1633
+ throw new Error(
1634
+ '[koa-classic-server] serverCache.compressedFile.maxEntrySize must be a positive integer (bytes) or false. ' +
1635
+ 'Got: ' + String(value)
1636
+ );
1637
+ }
1638
+ if (value > maxSize) {
1639
+ throw new Error(
1640
+ `[koa-classic-server] serverCache.compressedFile.maxEntrySize (${value}) exceeds maxSize (${maxSize}) — ` +
1641
+ 'a per-entry cap larger than the whole cache is a configuration contradiction. ' +
1642
+ 'Use false to disable the per-entry cap.'
1643
+ );
1644
+ }
1645
+ return value;
1646
+ }
1647
+
1349
1648
  if (!serverCache || typeof serverCache !== 'object' || Array.isArray(serverCache)) {
1350
1649
  return { rawFile: defaultRawFile, compressedFile: defaultCompressedFile };
1351
1650
  }
1352
1651
 
1652
+ // Positive-bytes fields whose invalid values used to fall back to the
1653
+ // default SILENTLY (#13): warn now, behavior unchanged (the fallback is
1654
+ // identical); the next major will throw — same deprecation path as the
1655
+ // other config warnings.
1656
+ function sizeOrWarn(value, fieldName, defaultValue) {
1657
+ if (value === undefined) return defaultValue;
1658
+ if (typeof value === 'number' && value > 0) return value;
1659
+ warnConfigDeprecation(_logger,
1660
+ `serverCache.${fieldName} must be a positive number (bytes); got ` +
1661
+ String(value) + ` — falling back to the default ${defaultValue} for now.`);
1662
+ return defaultValue;
1663
+ }
1664
+
1353
1665
  const rf = serverCache.rawFile;
1354
1666
  const rawFile = (!rf || typeof rf !== 'object' || Array.isArray(rf)) ? defaultRawFile : {
1355
1667
  enabled: typeof rf.enabled === 'boolean' ? rf.enabled : false,
1356
- maxSize: typeof rf.maxSize === 'number' && rf.maxSize > 0 ? rf.maxSize : 52428800,
1357
- maxFileSize: typeof rf.maxFileSize === 'number' && rf.maxFileSize > 0 ? rf.maxFileSize : 1048576,
1668
+ maxSize: sizeOrWarn(rf.maxSize, 'rawFile.maxSize', 52428800),
1669
+ maxFileSize: sizeOrWarn(rf.maxFileSize, 'rawFile.maxFileSize', 1048576),
1358
1670
  maxAge: validateMaxAge(rf.maxAge, 'rawFile'),
1359
1671
  warnInterval: rf.warnInterval === false ? false : (typeof rf.warnInterval === 'number' ? rf.warnInterval : 60000),
1360
1672
  };
1361
1673
 
1362
1674
  const cf = serverCache.compressedFile;
1363
- const compressedFile = (!cf || typeof cf !== 'object' || Array.isArray(cf)) ? defaultCompressedFile : {
1364
- enabled: typeof cf.enabled === 'boolean' ? cf.enabled : true,
1365
- maxSize: typeof cf.maxSize === 'number' && cf.maxSize > 0 ? cf.maxSize : 104857600,
1366
- maxAge: validateMaxAge(cf.maxAge, 'compressedFile'),
1367
- warnInterval: cf.warnInterval === false ? false : (typeof cf.warnInterval === 'number' ? cf.warnInterval : 60000),
1368
- };
1675
+ let compressedFile;
1676
+ if (!cf || typeof cf !== 'object' || Array.isArray(cf)) {
1677
+ compressedFile = defaultCompressedFile;
1678
+ } else {
1679
+ const cfMaxSize = sizeOrWarn(cf.maxSize, 'compressedFile.maxSize', 104857600);
1680
+ compressedFile = {
1681
+ enabled: typeof cf.enabled === 'boolean' ? cf.enabled : true,
1682
+ maxSize: cfMaxSize,
1683
+ maxEntrySize: validateMaxEntrySize(cf.maxEntrySize, cfMaxSize),
1684
+ maxAge: validateMaxAge(cf.maxAge, 'compressedFile'),
1685
+ warnInterval: cf.warnInterval === false ? false : (typeof cf.warnInterval === 'number' ? cf.warnInterval : 60000),
1686
+ };
1687
+ }
1369
1688
 
1370
1689
  return { rawFile, compressedFile };
1371
1690
  }
@@ -1548,12 +1867,35 @@ module.exports = function koaClassicServer(
1548
1867
  writeErrorPage(ctx, status, await getCustomErrorPage(status), builtinHtml);
1549
1868
  }
1550
1869
 
1551
- // Sync variant for stream-error callbacks, where no await is possible: serves
1552
- // the last-loaded custom buffer without the freshness stat an acceptable
1553
- // trade for a branch that only fires when the disk fails mid-response.
1554
- function sendErrorPageSync(ctx, status) {
1555
- const entry = _errorPages.get(status);
1556
- writeErrorPage(ctx, status, entry ? entry.buffer : null, _BUILTIN_ERROR_HTML[status]);
1870
+ // Pre-opens `filePath` and returns a ReadStream reading from the already-open
1871
+ // descriptor, or null after writing the 404 error page when the open fails.
1872
+ // Rationale (v5.0 register #5): assigning a lazily-opening
1873
+ // fs.createReadStream(path) to ctx.body means an open-time failure (file
1874
+ // vanished after the stat, EACCES, EIO at open) fires only AFTER Koa's
1875
+ // respond() has taken ownership of the body — the error page written by a
1876
+ // stream-error handler can never be sent, Stream.pipeline tears the socket
1877
+ // down, and the client sees a bare ECONNRESET. Opening HERE, while the
1878
+ // response is still fully ours, turns that race into a clean 404 (matching
1879
+ // the access-check contract: "unreadable" is indistinguishable from "not
1880
+ // found"). The path is still passed to fs.createReadStream — Node ignores it
1881
+ // when `fd` is set — so path-based instrumentation keeps working.
1882
+ async function openBodyStream(ctx, filePath, streamOpts) {
1883
+ let handle;
1884
+ try {
1885
+ handle = await fs.promises.open(filePath, 'r');
1886
+ } catch (err) {
1887
+ _logger.error('File open error:', err);
1888
+ await sendErrorPage(ctx, 404);
1889
+ return null;
1890
+ }
1891
+ try {
1892
+ // autoClose (default true) closes the FileHandle when the stream ends
1893
+ // or is destroyed — including Koa's teardown on client disconnect.
1894
+ return fs.createReadStream(filePath, { fd: handle, ...streamOpts });
1895
+ } catch (err) {
1896
+ handle.close().catch(() => {}); // nothing consumed the fd — release it
1897
+ throw err; // unexpected: surfaces through the last-resort catch
1898
+ }
1557
1899
  }
1558
1900
 
1559
1901
  const compressionConfig = normalizeCompressionConfig(options.compression);
@@ -1574,7 +1916,8 @@ module.exports = function koaClassicServer(
1574
1916
  serverCacheConfig.compressedFile.maxSize,
1575
1917
  serverCacheConfig.compressedFile.warnInterval,
1576
1918
  'compressedFile',
1577
- _logger
1919
+ _logger,
1920
+ serverCacheConfig.compressedFile.maxEntrySize
1578
1921
  );
1579
1922
 
1580
1923
  // Single-flight maps for cache population (thundering-herd protection):
@@ -1599,21 +1942,26 @@ module.exports = function koaClassicServer(
1599
1942
  // `encoding` and sets it as the response body. Shared by the cache-disabled
1600
1943
  // streaming branch and by tee followers; the tee leader builds its own
1601
1944
  // pipeline with the extra accumulator stage.
1945
+ // The disk source is pre-opened (openBodyStream, register #5): an open-time
1946
+ // failure becomes a clean 404 here, so the pipeline callback below only ever
1947
+ // sees mid-flight errors — where the response is already on the wire and
1948
+ // Koa 3 tears the socket down; logging on the operator's logger is all
1949
+ // that is left to do.
1602
1950
  // pipeline (NOT pipe): teardown propagates in BOTH directions. When the
1603
1951
  // client disconnects mid-transfer, Koa destroys the body (the zlib
1604
1952
  // transform) and pipeline destroys `src` too, closing its file descriptor.
1605
1953
  // A bare src.pipe(compress) leaves the ReadStream paused with the fd open
1606
1954
  // forever — fd leak under aborted downloads. Client disconnects are a
1607
1955
  // normal event and are not logged (avoids client-driven log spam).
1608
- function streamCompressedBody(ctx, toOpen, rawBuffer, encoding) {
1609
- const compress = createStreamCompressor(encoding);
1956
+ async function streamCompressedBody(ctx, toOpen, rawBuffer, encoding) {
1610
1957
  const src = rawBuffer
1611
1958
  ? Readable.from(rawBuffer) // compress from in-memory buffer — no disk I/O
1612
- : fs.createReadStream(toOpen);
1959
+ : await openBodyStream(ctx, toOpen);
1960
+ if (!src) return; // open failed — the 404 page is already written
1961
+ const compress = createStreamCompressor(encoding, compressionConfig.streaming);
1613
1962
  ctx.body = pipeline(src, compress, (err) => {
1614
1963
  if (!err || err.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
1615
1964
  _logger.error('Stream error:', err);
1616
- if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
1617
1965
  });
1618
1966
  }
1619
1967
 
@@ -1646,38 +1994,16 @@ module.exports = function koaClassicServer(
1646
1994
  return null;
1647
1995
  }
1648
1996
 
1649
- // Compress a Buffer using the given encoding ('br' or 'gzip').
1650
- // Quality is maxed out: serverCache pays this cost once per file, not per request.
1997
+ // Compress a Buffer using the given encoding ('br' or 'gzip') at the
1998
+ // buffered-path quality (compression.buffered). Defaults are maxed out:
1999
+ // serverCache pays this cost once per file, not per request.
1651
2000
  function compressBuffer(data, encoding) {
1652
2001
  if (encoding === 'br') {
1653
2002
  return _brotliCompressAsync(data, {
1654
- params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 }
2003
+ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: compressionConfig.buffered.brotliQuality }
1655
2004
  });
1656
2005
  }
1657
- return _gzipAsync(data, { level: zlib.constants.Z_BEST_COMPRESSION });
1658
- }
1659
-
1660
- /**
1661
- * Build a Content-Disposition header value for inline serving.
1662
- *
1663
- * Uses both the legacy quoted-string form (ASCII fallback) and the RFC 5987
1664
- * extended form (UTF-8 percent-encoded) for maximum browser compatibility:
1665
- * inline; filename="ascii-safe"; filename*=UTF-8''percent-encoded
1666
- *
1667
- * The quoted-string form escapes only double-quotes; the RFC 5987 form
1668
- * percent-encodes every byte that is not an unreserved URI character.
1669
- * Browsers that support filename* prefer it over filename (RFC 6266 §4.1).
1670
- */
1671
- function buildContentDisposition(filename) {
1672
- // quoted-string fallback: escape " and \ so the value is always valid ASCII
1673
- const asciiSafe = filename.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1674
-
1675
- // RFC 5987 extended value: UTF-8 percent-encode everything except
1676
- // unreserved chars (ALPHA / DIGIT / "-" / "." / "_" / "~")
1677
- const rfc5987 = encodeURIComponent(filename)
1678
- .replace(/['()]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase());
1679
-
1680
- return `inline; filename="${asciiSafe}"; filename*=UTF-8''${rfc5987}`;
2006
+ return _gzipAsync(data, { level: compressionConfig.buffered.gzipLevel });
1681
2007
  }
1682
2008
 
1683
2009
  // Find the first matching index file in a directory.
@@ -2162,6 +2488,12 @@ module.exports = function koaClassicServer(
2162
2488
 
2163
2489
  // Verify file is still readable (race condition protection).
2164
2490
  // Skip if rawBuffer already loaded — the successful readFile() is equivalent proof.
2491
+ // The disk-streaming branches additionally PRE-OPEN the file
2492
+ // (openBodyStream, register #5), which closes the residual
2493
+ // access→open TOCTOU there; this probe is kept for the outcomes
2494
+ // that never open the file (compressed-cache hits, 304s, HEAD):
2495
+ // dropping it would let a file made unreadable after caching keep
2496
+ // being served from RAM.
2165
2497
  if (!rawBuffer) {
2166
2498
  try {
2167
2499
  await fs.promises.access(toOpen, fs.constants.R_OK);
@@ -2200,7 +2532,10 @@ module.exports = function koaClassicServer(
2200
2532
  // Vary the 200 would; and a shared proxy must not serve the identity variant to
2201
2533
  // a client that would have received the compressed one (#7).
2202
2534
  if (potentiallyCompressible) {
2203
- ctx.set('Vary', 'Accept-Encoding');
2535
+ // ctx.vary() appends (deduplicating) instead of overwriting: a
2536
+ // Vary set by upstream middleware (e.g. "Origin" from a CORS
2537
+ // layer) must survive (#7).
2538
+ ctx.vary('Accept-Encoding');
2204
2539
  }
2205
2540
 
2206
2541
  // Preconditions are evaluated BEFORE the Range branch: RFC 9110 §13.2.2 gives the
@@ -2213,23 +2548,33 @@ module.exports = function koaClassicServer(
2213
2548
  ctx.set('Last-Modified', fileStat.mtime.toUTCString());
2214
2549
 
2215
2550
  // If-None-Match: "*" | comma-list, weak comparison (RFC 9110 §13.1.2).
2216
- if (ifNoneMatchSatisfied(ctx.get('If-None-Match'), fullEtag)) {
2217
- ctx.status = 304;
2218
- return;
2219
- }
2220
-
2221
- // If-Modified-Since (date validation). The mtime is truncated to whole seconds
2222
- // before comparing: Last-Modified is emitted via toUTCString() (second precision
2223
- // — HTTP dates have no milliseconds), so a client echoing that header back would
2224
- // otherwise never match a sub-second mtime (e.g. 22:13:20.500 <= 22:13:20.000).
2225
- const clientModifiedSince = ctx.get('If-Modified-Since');
2226
- if (clientModifiedSince) {
2227
- const clientDate = new Date(clientModifiedSince);
2228
- const mtimeSeconds = Math.floor(fileStat.mtime.getTime() / 1000) * 1000;
2229
- if (mtimeSeconds <= clientDate.getTime()) {
2551
+ // When the header is present it is the ONLY precondition evaluated:
2552
+ // RFC 9110 §13.1.3 "A recipient MUST ignore If-Modified-Since if
2553
+ // the request contains an If-None-Match header field". The ETag is
2554
+ // the strong validator; the date has 1-second resolution and would
2555
+ // otherwise 304 a client whose ETag just said it is stale (e.g. two
2556
+ // same-second edits with a size change, or an encoding-suffix change).
2557
+ const ifNoneMatch = ctx.get('If-None-Match');
2558
+ if (ifNoneMatch) {
2559
+ if (ifNoneMatchSatisfied(ifNoneMatch, fullEtag)) {
2230
2560
  ctx.status = 304;
2231
2561
  return;
2232
2562
  }
2563
+ // present but not satisfied → full response, date NOT consulted
2564
+ } else {
2565
+ // If-Modified-Since (date validation). The mtime is truncated to whole seconds
2566
+ // before comparing: Last-Modified is emitted via toUTCString() (second precision
2567
+ // — HTTP dates have no milliseconds), so a client echoing that header back would
2568
+ // otherwise never match a sub-second mtime (e.g. 22:13:20.500 <= 22:13:20.000).
2569
+ const clientModifiedSince = ctx.get('If-Modified-Since');
2570
+ if (clientModifiedSince) {
2571
+ const clientDate = new Date(clientModifiedSince);
2572
+ const mtimeSeconds = Math.floor(fileStat.mtime.getTime() / 1000) * 1000;
2573
+ if (mtimeSeconds <= clientDate.getTime()) {
2574
+ ctx.status = 304;
2575
+ return;
2576
+ }
2577
+ }
2233
2578
  }
2234
2579
  }
2235
2580
 
@@ -2268,10 +2613,14 @@ module.exports = function koaClassicServer(
2268
2613
  // subarray(): zero-copy view; Buffer.slice is deprecated (DEP0158).
2269
2614
  ctx.body = rawBuffer.subarray(start, end + 1);
2270
2615
  } else {
2271
- const src = fs.createReadStream(toOpen, { start, end });
2616
+ // Pre-open (register #5): an open failure is a clean 404
2617
+ // (writeErrorPage scrubs the 206 headers set above).
2618
+ const src = await openBodyStream(ctx, toOpen, { start, end });
2619
+ if (!src) return;
2620
+ // Mid-flight errors only from here on: the socket is torn
2621
+ // down by Koa 3 — log on the operator's logger.
2272
2622
  src.on('error', (err) => {
2273
2623
  _logger.error('Stream error:', err);
2274
- if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
2275
2624
  });
2276
2625
  ctx.body = src;
2277
2626
  }
@@ -2341,19 +2690,27 @@ module.exports = function koaClassicServer(
2341
2690
  // Follower: another request is already accumulating this exact
2342
2691
  // file version. Stream independently (no added latency, no tee
2343
2692
  // stage) — the cache will be warm for the NEXT request.
2344
- streamCompressedBody(ctx, toOpen, rawBuffer, encoding);
2693
+ await streamCompressedBody(ctx, toOpen, rawBuffer, encoding);
2345
2694
  return;
2346
2695
  }
2347
- // Leader: stream AND accumulate a copy for the cache.
2696
+ // Leader: stream AND accumulate a copy for the cache. The disk
2697
+ // source is pre-opened BEFORE the tee bookkeeping starts, so an
2698
+ // open failure is a clean 404 with nothing to unwind (register #5).
2699
+ const src = rawBuffer
2700
+ ? Readable.from(rawBuffer) // compress from in-memory buffer — no disk I/O
2701
+ : await openBodyStream(ctx, toOpen);
2702
+ if (!src) return;
2348
2703
  _inflightStreamTees.add(teeKey);
2349
2704
  let acc = [];
2350
2705
  let accBytes = 0;
2351
2706
  // Two admission bounds, both on the real OUTPUT size:
2352
- // - per entry: a quarter of the cache, so one huge file cannot
2353
- // evict most of the working set on insert;
2707
+ // - per entry: maxEntrySize (default: a quarter of the cache), so
2708
+ // one huge file cannot evict most of the working set on insert.
2709
+ // Checked here — not just in LFUCache.set() — to stop ACCUMULATING
2710
+ // RAM as soon as the budget is blown, not merely refuse the insert;
2354
2711
  // - aggregate (_inflightTeeBytes): all in-flight tees together may
2355
2712
  // never hold more RAM than the cache's own maxSize.
2356
- const entryCap = Math.floor(serverCacheConfig.compressedFile.maxSize / 4);
2713
+ const entryCap = serverCacheConfig.compressedFile.maxEntrySize;
2357
2714
  // Stops accumulating and releases this tee's share of the aggregate
2358
2715
  // budget. Safe to call more than once.
2359
2716
  const abandonAccumulation = () => {
@@ -2363,10 +2720,7 @@ module.exports = function koaClassicServer(
2363
2720
  }
2364
2721
  };
2365
2722
  try {
2366
- const compress = createStreamCompressor(encoding);
2367
- const src = rawBuffer
2368
- ? Readable.from(rawBuffer) // compress from in-memory buffer — no disk I/O
2369
- : fs.createReadStream(toOpen);
2723
+ const compress = createStreamCompressor(encoding, compressionConfig.streaming);
2370
2724
  const tee = new Transform({
2371
2725
  transform(chunk, _enc, done) {
2372
2726
  if (acc) {
@@ -2398,8 +2752,9 @@ module.exports = function koaClassicServer(
2398
2752
  }
2399
2753
  abandonAccumulation();
2400
2754
  if (!err || err.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
2755
+ // Mid-flight error: the response is already on the wire and
2756
+ // Koa 3 tears the socket down — log and nothing else.
2401
2757
  _logger.error('Stream error:', err);
2402
- if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
2403
2758
  });
2404
2759
  } catch (err) {
2405
2760
  // Defensive: nothing between add() and pipeline() is expected to
@@ -2407,6 +2762,7 @@ module.exports = function koaClassicServer(
2407
2762
  // the tee for this file version forever.
2408
2763
  _inflightStreamTees.delete(teeKey);
2409
2764
  abandonAccumulation();
2765
+ src.destroy(); // release the pre-opened fd — nothing consumed it
2410
2766
  throw err;
2411
2767
  }
2412
2768
  return;
@@ -2451,10 +2807,14 @@ module.exports = function koaClassicServer(
2451
2807
  } else {
2452
2808
  ctx.set('Content-Length', String(fileStat.size));
2453
2809
  if (ctx.method !== 'HEAD') {
2454
- const src = fs.createReadStream(toOpen);
2810
+ // Pre-open (register #5): if the disk is failing hard
2811
+ // enough that the identity fallback cannot even open the
2812
+ // file, the client gets a clean 404 instead of a torn
2813
+ // socket.
2814
+ const src = await openBodyStream(ctx, toOpen);
2815
+ if (!src) return;
2455
2816
  src.on('error', (streamErr) => {
2456
2817
  _logger.error('Stream error:', streamErr);
2457
- if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
2458
2818
  });
2459
2819
  ctx.body = src;
2460
2820
  } else {
@@ -2479,7 +2839,7 @@ module.exports = function koaClassicServer(
2479
2839
  // Streaming mode (compressed cache disabled): pipe through the zlib
2480
2840
  // transform — Content-Length not known in advance, nothing cached.
2481
2841
  if (ctx.method !== 'HEAD') {
2482
- streamCompressedBody(ctx, toOpen, rawBuffer, encoding);
2842
+ await streamCompressedBody(ctx, toOpen, rawBuffer, encoding);
2483
2843
  } else {
2484
2844
  // HEAD: mirror the GET status and headers (RFC 9110 §9.3.2) — no
2485
2845
  // Content-Length, since the compressed size is unknown without
@@ -2503,10 +2863,15 @@ module.exports = function koaClassicServer(
2503
2863
  } else {
2504
2864
  ctx.set('Content-Length', String(fileStat.size));
2505
2865
  if (ctx.method !== 'HEAD') {
2506
- const src = fs.createReadStream(toOpen);
2866
+ // Pre-open (register #5): an open-time failure (file vanished
2867
+ // after the stat, EACCES, EIO) becomes a clean 404 instead of
2868
+ // an ECONNRESET after respond() owns the body.
2869
+ const src = await openBodyStream(ctx, toOpen);
2870
+ if (!src) return;
2871
+ // Mid-flight errors only from here on (Koa 3 tears the socket
2872
+ // down): log on the operator's logger.
2507
2873
  src.on('error', (err) => {
2508
2874
  _logger.error('Stream error:', err);
2509
- if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
2510
2875
  });
2511
2876
  ctx.body = src;
2512
2877
  } else {
@@ -2550,17 +2915,32 @@ module.exports = function koaClassicServer(
2550
2915
  const rawDirRel = path.relative(normalizedRootDir, toOpen);
2551
2916
  const dirRelPath = (rawDirRel === '' || rawDirRel === '.') ? '' : rawDirRel.split(path.sep).join('/');
2552
2917
 
2553
- // Get sorting parameters from query string
2554
- const sortBy = ctx.query.sort || 'name';
2555
- const sortOrder = ctx.query.order || 'asc';
2556
-
2557
- const baseUrl = pageHrefOutPrefix.pathname;
2918
+ // Get sorting parameters from query string. Repeated parameters
2919
+ // (?sort=a&sort=b) arrive as arrays from the query parser: take the
2920
+ // first occurrence so sorting stays deterministic and regenerated
2921
+ // links never embed "a,b" (#12).
2922
+ const firstQueryValue = (v) => Array.isArray(v) ? v[0] : v;
2923
+ const sortParam = firstQueryValue(ctx.query.sort);
2924
+ const orderParam = firstQueryValue(ctx.query.order);
2925
+ const sortBy = sortParam || 'name';
2926
+ const sortOrder = orderParam || 'asc';
2927
+
2928
+ // Base for the listing's self-referencing links (sort headers,
2929
+ // paginator). Built from the WITH-prefix pathname — the out-prefix
2930
+ // one would make these links escape urlPrefix (#2), unlike the
2931
+ // parent/entry links which already use pageHref — and normalized
2932
+ // to exactly one trailing slash: the pathname was slash-stripped
2933
+ // for URL parsing, and linking the canonical /dir/ form spares a
2934
+ // 301 redirect hop on every sort/pagination click.
2935
+ const baseUrl = pageHref.pathname.endsWith('/')
2936
+ ? pageHref.pathname
2937
+ : pageHref.pathname + '/';
2558
2938
 
2559
2939
  // Preserves sort/order while overriding `page`; omits page when 0.
2560
2940
  function buildQueryUrl(targetPage) {
2561
2941
  const params = [];
2562
- if (ctx.query.sort) params.push(`sort=${encodeURIComponent(ctx.query.sort)}`);
2563
- if (ctx.query.order) params.push(`order=${encodeURIComponent(ctx.query.order)}`);
2942
+ if (sortParam) params.push(`sort=${encodeURIComponent(sortParam)}`);
2943
+ if (orderParam) params.push(`order=${encodeURIComponent(orderParam)}`);
2564
2944
  if (targetPage > 0) params.push(`page=${targetPage}`);
2565
2945
  return params.length ? `${baseUrl}?${params.join('&')}` : baseUrl;
2566
2946
  }
@@ -2601,12 +2981,18 @@ module.exports = function koaClassicServer(
2601
2981
  // (pageHrefOutPrefix.pathname === '/'), not only at the absolute root: with
2602
2982
  // urlPrefix '/static', the listing of /static/ must not link to '/', which is
2603
2983
  // outside the served tree (#13).
2604
- const currentPath = pageHref.origin + pageHref.pathname;
2984
+ // PATH-ABSOLUTE (#6): no origin an absolute URL would embed the
2985
+ // client-controlled Host header (cache-poisoning surface) and pin
2986
+ // http:// behind a TLS-terminating proxy without app.proxy. And
2987
+ // CANONICAL SLASH (#16): the parent is a directory, so link the
2988
+ // /dir/ form directly instead of paying the 301 hop per click
2989
+ // (previously the parent of /sub/ was even the bare origin).
2605
2990
  if (pageHrefOutPrefix.pathname !== "/") {
2606
2991
  // Build parent directory URL without query parameters
2607
- const a_pD = currentPath.split("/");
2992
+ const a_pD = pageHref.pathname.split("/");
2608
2993
  a_pD.pop();
2609
- const parentDirectory = a_pD.join("/");
2994
+ const joined = a_pD.join("/");
2995
+ const parentDirectory = joined === "" ? "/" : joined + "/";
2610
2996
  // Escape HTML to prevent XSS
2611
2997
  parts.push(`<tr><td><a href="${escapeHtml(parentDirectory)}"><b>.. Parent Directory</b></a></td><td>DIR</td><td>-</td></tr>`);
2612
2998
  }
@@ -2615,8 +3001,10 @@ module.exports = function koaClassicServer(
2615
3001
  if (dir.length === 0) {
2616
3002
  parts.push(emptyFolderRow);
2617
3003
  } else {
2618
- const _listingBaseUrl = pageHref.origin + pageHref.pathname;
2619
- const _listingOriginPrefix = pageHref.origin + options.urlPrefix;
3004
+ // PATH-ABSOLUTE (#6): entry hrefs carry no origin either — the
3005
+ // browser resolves them against the page's real origin/scheme.
3006
+ const _listingBasePath = pageHref.pathname;
3007
+ const _listingPrefixPath = options.urlPrefix; // "" without a prefix
2620
3008
 
2621
3009
  // Collect item data with stat I/O in parallel (batched to avoid
2622
3010
  // overwhelming the filesystem on very large directories).
@@ -2629,12 +3017,12 @@ module.exports = function koaClassicServer(
2629
3017
  const type = getDirentType(item);
2630
3018
  const itemPath = path.join(toOpen, s_name);
2631
3019
 
2632
- // Build item URI without query parameters
3020
+ // Build item URI (path-absolute, no query parameters)
2633
3021
  let itemUri;
2634
- if (_listingBaseUrl === _listingOriginPrefix + "/" || _listingBaseUrl === _listingOriginPrefix) {
2635
- itemUri = `${_listingOriginPrefix}/${encodeURIComponent(s_name)}`;
3022
+ if (_listingBasePath === _listingPrefixPath + "/" || _listingBasePath === _listingPrefixPath) {
3023
+ itemUri = `${_listingPrefixPath}/${encodeURIComponent(toWellFormedName(s_name))}`;
2636
3024
  } else {
2637
- itemUri = `${_listingBaseUrl}/${encodeURIComponent(s_name)}`;
3025
+ itemUri = `${_listingBasePath}/${encodeURIComponent(toWellFormedName(s_name))}`;
2638
3026
  }
2639
3027
 
2640
3028
  // Resolve symlinks and DT_UNKNOWN entries to their effective type.
@@ -2657,6 +3045,14 @@ module.exports = function koaClassicServer(
2657
3045
  }
2658
3046
  }
2659
3047
 
3048
+ // Canonical trailing slash for directories (#16): link
3049
+ // the /dir/ form the trailing-slash redirect would
3050
+ // enforce anyway, so navigating the listing costs no
3051
+ // 301 hop per click. effectiveType 2 covers plain
3052
+ // directories and dir-resolved symlinks, consistent
3053
+ // with the rest of the listing logic.
3054
+ if (effectiveType === 2) itemUri += '/';
3055
+
2660
3056
  // Hidden check: skip entries that should not appear in directory listing
2661
3057
  const itemIsDir = effectiveType === 2;
2662
3058
  const itemRelPath = dirRelPath ? dirRelPath + '/' + s_name : s_name;
@@ -2741,7 +3137,7 @@ module.exports = function koaClassicServer(
2741
3137
  // Pagination — slice the sorted items into the requested page (0-based).
2742
3138
  const pageSize = options.dirListing.entriesPerPage; // 0 disables pagination
2743
3139
  totalPages = pageSize > 0 ? Math.max(1, Math.ceil(items.length / pageSize)) : 1;
2744
- const rawPage = parseInt(ctx.query.page, 10);
3140
+ const rawPage = parseInt(firstQueryValue(ctx.query.page), 10);
2745
3141
  const requestedPage = Number.isFinite(rawPage) && rawPage >= 0 ? rawPage : 0;
2746
3142
  currentPage = Math.min(requestedPage, totalPages - 1); // silent clamp
2747
3143
  const visibleItems = (pageSize > 0 && items.length > pageSize)
@@ -2770,12 +3166,12 @@ module.exports = function koaClassicServer(
2770
3166
  : '';
2771
3167
 
2772
3168
  if (item.isReserved) {
2773
- parts.push(`${rowStart} ${escapeHtml(item.name)}${symlinkLabel}</td> <td> DIR BUT RESERVED</td><td>${item.sizeStr}</td></tr>`);
3169
+ parts.push(`${rowStart} <bdi>${listingDisplayName(item.name)}</bdi>${symlinkLabel}</td> <td> DIR BUT RESERVED</td><td>${item.sizeStr}</td></tr>`);
2774
3170
  } else if (item.isBrokenSymlink || item.isBlockedSymlink) {
2775
3171
  // Broken or policy-blocked symlink: name visible but not clickable
2776
- parts.push(`${rowStart} ${escapeHtml(item.name)}${symlinkLabel}</td> <td> ${escapeHtml(item.mimeType)} </td><td>${item.sizeStr}</td></tr>`);
3172
+ parts.push(`${rowStart} <bdi>${listingDisplayName(item.name)}</bdi>${symlinkLabel}</td> <td> ${escapeHtml(item.mimeType)} </td><td>${item.sizeStr}</td></tr>`);
2777
3173
  } else {
2778
- parts.push(`${rowStart} <a href="${escapeHtml(item.itemUri)}">${escapeHtml(item.name)}</a>${symlinkLabel} </td> <td> ${escapeHtml(item.mimeType)} </td><td>${item.sizeStr}</td></tr>`);
3174
+ parts.push(`${rowStart} <bdi><a href="${escapeHtml(item.itemUri)}">${listingDisplayName(item.name)}</a></bdi>${symlinkLabel} </td> <td> ${escapeHtml(item.mimeType)} </td><td>${item.sizeStr}</td></tr>`);
2779
3175
  }
2780
3176
  }
2781
3177
  }
@@ -2843,6 +3239,14 @@ module.exports = function koaClassicServer(
2843
3239
  </html>
2844
3240
  `;
2845
3241
 
3242
+ // Listings are dynamic pages — they change with directory content,
3243
+ // sort and page — so an EXPLICIT no-cache policy is sent regardless
3244
+ // of browserCacheEnabled: without it, browsers and shared proxies
3245
+ // may heuristically cache a stale listing (#5). Same header trio the
3246
+ // file path uses when browser caching is disabled.
3247
+ ctx.set('Cache-Control', 'no-cache, no-store, must-revalidate');
3248
+ ctx.set('Pragma', 'no-cache');
3249
+ ctx.set('Expires', '0');
2846
3250
  setGeneratedPageHeaders(ctx, LISTING_CSP);
2847
3251
  return html;
2848
3252
  }
@@ -2863,11 +3267,21 @@ module.exports._internals = {
2863
3267
  singleFlight,
2864
3268
  refreshOrInsert,
2865
3269
  escapeHtml,
3270
+ // toWellFormedName / buildContentDisposition / listingDisplayName: the
3271
+ // lone-surrogate class (#14) cannot be exercised through fixtures on
3272
+ // POSIX (invalid UTF-8 names become U+FFFD at write time, and only
3273
+ // Windows readdir can return WTF-16 names), so their totality is
3274
+ // asserted at unit level here.
3275
+ toWellFormedName,
3276
+ buildContentDisposition,
3277
+ listingDisplayName,
2866
3278
  // writeErrorPage is the shared output path for every middleware-generated error
2867
- // response (sendErrorPage / sendErrorPageSync delegate to it). Exposed so its
2868
- // contract — header scrub, no-store on >=500, Content-Type, custom-vs-built-in
2869
- // body, CSP only for the built-in page — can be asserted deterministically: the
2870
- // stream-failure branches that also use it can't be, because Koa 3 tears the
2871
- // socket down on a mid-stream body error before the client sees the response.
3279
+ // response (sendErrorPage delegates to it). Exposed so its contract — header
3280
+ // scrub, no-store, Content-Type, custom-vs-built-in body, CSP only for the
3281
+ // built-in page — can be asserted deterministically. Mid-flight stream
3282
+ // failures no longer route here at all (v5.0 register #5): the response is
3283
+ // already on the wire and Koa 3 tears the socket down, so those branches
3284
+ // only log; open-time failures are caught BEFORE the body is assigned
3285
+ // (openBodyStream) and go through the regular async sendErrorPage.
2872
3286
  writeErrorPage,
2873
3287
  };