koa-classic-server 3.0.0-alpha.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.cjs CHANGED
@@ -4,15 +4,21 @@ const fs = require("fs");
4
4
  const path = require("path");
5
5
  const crypto = require("crypto");
6
6
  const zlib = require("zlib");
7
+ const util = require("util");
7
8
  const mime = require("mime-types");
8
9
  const { Readable } = require('stream');
9
10
 
11
+ const _brotliCompressAsync = util.promisify(zlib.brotliCompress);
12
+ const _gzipAsync = util.promisify(zlib.gzip);
13
+
10
14
  // Pre-computed module-level constants
11
15
  const _LOG_1024 = Math.log(1024);
12
16
 
13
- // Emitted at most once per process lifetime when hidden.dotFiles.default or
14
- // hidden.dotDirs.default are not explicitly set by the caller.
15
- let _hiddenDefaultWarnEmitted = false;
17
+ // Emitted at most once per process lifetime when the caller passes the v2-era
18
+ // `showDirContents` option instead of the v3 `dirListing.enabled`. The old
19
+ // name is accepted as a backward-compatibility alias and may be removed in a
20
+ // future major version.
21
+ let _showDirContentsDeprecationWarned = false;
16
22
 
17
23
  // Default list of MIME types that benefit from compression.
18
24
  // User-provided compression.mimeTypes replaces this list entirely.
@@ -73,6 +79,36 @@ const LISTING_CSS = `
73
79
  th:nth-child(1), td:nth-child(1) { width: 50%; }
74
80
  th:nth-child(2), td:nth-child(2) { width: 30%; }
75
81
  th:nth-child(3), td:nth-child(3) { width: 20%; text-align: right; }
82
+ .kcs-banner {
83
+ max-width: 800px;
84
+ margin: 10px 0;
85
+ padding: 10px 14px;
86
+ background-color: #fff7e0;
87
+ border-left: 4px solid #e0a800;
88
+ font-size: 14px;
89
+ color: #5a4a00;
90
+ }
91
+ .kcs-pagination {
92
+ max-width: 800px;
93
+ margin: 16px 0;
94
+ font-size: 14px;
95
+ }
96
+ .kcs-pagination a, .kcs-pagination span {
97
+ display: inline-block;
98
+ padding: 4px 8px;
99
+ margin-right: 4px;
100
+ }
101
+ .kcs-pagination .kcs-page-current {
102
+ font-weight: bold;
103
+ background-color: #f0f0f0;
104
+ border-radius: 3px;
105
+ }
106
+ .kcs-pagination .kcs-page-ellipsis {
107
+ color: #888;
108
+ }
109
+ .kcs-pagination .kcs-page-disabled {
110
+ color: #bbb;
111
+ }
76
112
  `;
77
113
 
78
114
  // SHA-256 hash of the listing CSS, computed once at startup (zero per-request overhead).
@@ -94,20 +130,27 @@ function setGeneratedPageHeaders(ctx, csp) {
94
130
  ctx.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
95
131
  }
96
132
 
97
- // Pre-computed 404 HTML body identical on every call, no need to regenerate.
98
- const _NOT_FOUND_HTML = `<!DOCTYPE html>
133
+ // Builds a minimal error page used by the middleware (404 / 500 / 504).
134
+ // Each page is pre-computed once at module load and reused on every request.
135
+ function buildErrorHtml(title, heading, message) {
136
+ return `<!DOCTYPE html>
99
137
  <html>
100
138
  <head>
101
139
  <meta charset="UTF-8">
102
140
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
103
141
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
104
- <title>URL not found</title>
142
+ <title>${title}</title>
105
143
  </head>
106
144
  <body>
107
- <h1>Not Found</h1>
108
- <h3>The requested URL was not found on this server.</h3>
145
+ <h1>${heading}</h1>
146
+ <h3>${message}</h3>
109
147
  </body>
110
148
  </html>`;
149
+ }
150
+
151
+ const _NOT_FOUND_HTML = buildErrorHtml('URL not found', 'Not Found', 'The requested URL was not found on this server.');
152
+ const _GATEWAY_TIMEOUT_HTML = buildErrorHtml('Gateway Timeout', 'Gateway Timeout', 'The template took too long to render.');
153
+ const _TEMPLATE_ERROR_HTML = buildErrorHtml('Internal Server Error', 'Internal Server Error', 'Template rendering failed for the requested resource.');
111
154
 
112
155
  function sendNotFound(ctx) {
113
156
  setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
@@ -115,6 +158,144 @@ function sendNotFound(ctx) {
115
158
  ctx.body = _NOT_FOUND_HTML;
116
159
  }
117
160
 
161
+ // Validates and returns a logger compatible with our contract. The minimum
162
+ // surface is `{ error: Function, warn: Function }` — any object exposing both
163
+ // (console, pino, winston, bunyan, ...) is accepted as-is.
164
+ function normalizeLogger(logger) {
165
+ if (logger === undefined) return console;
166
+ if (!logger || typeof logger !== 'object' || Array.isArray(logger)) {
167
+ throw new Error(
168
+ '[koa-classic-server] options.logger must be an object exposing error() and warn() methods.'
169
+ );
170
+ }
171
+ if (typeof logger.error !== 'function' || typeof logger.warn !== 'function') {
172
+ throw new Error(
173
+ '[koa-classic-server] options.logger must implement both error() and warn() methods.'
174
+ );
175
+ }
176
+ return logger;
177
+ }
178
+
179
+ // Yellow ANSI wrap, but only when writing to the actual console TTY. Structured
180
+ // loggers (pino/winston/...) would otherwise receive escape bytes as noise.
181
+ function warnPayload(logger, message) {
182
+ return logger === console
183
+ ? ['\x1b[33m%s\x1b[0m', message]
184
+ : [message];
185
+ }
186
+
187
+ // Sends an error response for a failed template render. If headers were already
188
+ // flushed by the render itself, destroys the underlying socket instead (the
189
+ // status/body can no longer be changed at that point).
190
+ function sendTemplateError(ctx, status, html, logMsg, err, logger) {
191
+ logger.error(logMsg, err);
192
+ if (ctx.headerSent || ctx.res.writableEnded) {
193
+ ctx.res.destroy();
194
+ return;
195
+ }
196
+ setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
197
+ ctx.status = status;
198
+ ctx.body = html;
199
+ }
200
+
201
+ // Rewrites an already-rendered response into an RFC 9110 §9.3.2 compliant HEAD
202
+ // response: the status and headers produced by the render are preserved, the
203
+ // body is replaced with an empty buffer (so no content is sent), and
204
+ // Content-Length is restored to the byte length the GET body would have had.
205
+ // Reassigning ctx.body to a non-stream value also makes Koa auto-destroy a
206
+ // previous stream body, so no file descriptor leaks. Stream / non-buffer bodies
207
+ // (uncommon for template renders) carry no Content-Length, matching the static
208
+ // streaming-HEAD branch.
209
+ function stripBodyForHead(ctx) {
210
+ if (ctx.headerSent) return; // render already flushed — status/headers are locked
211
+ const body = ctx.body;
212
+ if (body == null) return; // render produced no body (redirect, pass-through, ...) — leave status as-is
213
+ const hasKnownLength = typeof body === 'string' || Buffer.isBuffer(body);
214
+ const length = hasKnownLength ? Buffer.byteLength(body) : null;
215
+ ctx.body = Buffer.alloc(0);
216
+ if (length !== null) {
217
+ ctx.set('Content-Length', String(length)); // body setter zeroed it — restore the real length
218
+ } else {
219
+ ctx.remove('Content-Length'); // unknown length — omit, like static streaming HEAD
220
+ }
221
+ }
222
+
223
+ // Attempts to render the requested file through the user's template engine.
224
+ // Returns true if the request was handled (success, timeout, or error response
225
+ // already written), false if no template applies (caller should continue with
226
+ // normal file serving).
227
+ //
228
+ // The render function is invoked with (ctx, next, filePath, rawBuffer, signal).
229
+ // The signal aborts on timeout (when templateOpts.renderTimeout > 0) and on
230
+ // client disconnect. Cooperative renders that propagate the signal to fetch/db
231
+ // release backend resources promptly; non-cooperative renders still get a 504
232
+ // response, but their work continues in the background.
233
+ async function tryRenderTemplate(ctx, next, filePath, rawBuffer, templateOpts, logger) {
234
+ if (templateOpts.ext.length === 0 || !templateOpts.render) return false;
235
+
236
+ const fileExt = path.extname(filePath).slice(1);
237
+ if (!fileExt || !templateOpts.ext.includes(fileExt)) return false;
238
+
239
+ // RFC 9110 §9.3.2: HEAD must mirror GET (same status + headers, no body). The
240
+ // user's render is run exactly as for GET — by presenting ctx.method as GET for
241
+ // the duration of the render — so it resolves, validates, and sets Content-Type
242
+ // / status identically; stripBodyForHead() then discards the body and restores
243
+ // Content-Length. Without this, a render that early-returns on non-GET never
244
+ // sets ctx.body, leaving ctx.status at Koa's default 404 for HEAD even though
245
+ // GET returns 200.
246
+ const isHeadRequest = ctx.method === 'HEAD';
247
+ if (isHeadRequest) ctx.method = 'GET';
248
+
249
+ const controller = new AbortController();
250
+ const onClientClose = () => controller.abort();
251
+ ctx.req.on('close', onClientClose);
252
+
253
+ const timeoutMs = templateOpts.renderTimeout;
254
+ let timer = null;
255
+ let timedOut = false;
256
+
257
+ const renderPromise = Promise.resolve().then(() =>
258
+ templateOpts.render(ctx, next, filePath, rawBuffer, controller.signal)
259
+ );
260
+ renderPromise.catch(() => {}); // swallow rejections that arrive after we've already responded
261
+
262
+ try {
263
+ if (timeoutMs > 0) {
264
+ await Promise.race([
265
+ renderPromise,
266
+ new Promise((_, reject) => {
267
+ timer = setTimeout(() => {
268
+ timedOut = true;
269
+ controller.abort();
270
+ const err = new Error('Template render timeout');
271
+ err.code = 'ETEMPLATETIMEOUT';
272
+ reject(err);
273
+ }, timeoutMs);
274
+ })
275
+ ]);
276
+ } else {
277
+ await renderPromise;
278
+ }
279
+ } catch (error) {
280
+ if (timedOut || error.code === 'ETEMPLATETIMEOUT') {
281
+ sendTemplateError(ctx, 504, _GATEWAY_TIMEOUT_HTML,
282
+ 'Template render timeout after ' + timeoutMs + 'ms:', filePath, logger);
283
+ } else {
284
+ sendTemplateError(ctx, 500, _TEMPLATE_ERROR_HTML,
285
+ 'Template rendering error:', error, logger);
286
+ }
287
+ } finally {
288
+ if (timer) clearTimeout(timer);
289
+ ctx.req.removeListener('close', onClientClose);
290
+ if (isHeadRequest) {
291
+ ctx.method = 'HEAD';
292
+ stripBodyForHead(ctx);
293
+ }
294
+ }
295
+
296
+ return true;
297
+ }
298
+
118
299
  // Single-pass HTML escaping — one regex scan, one allocation, lookup table compiled once.
119
300
  const _HTML_ESCAPE_MAP = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' };
120
301
  const _HTML_ESCAPE_RE = /[&<>"']/g;
@@ -204,12 +385,13 @@ function parseRangeHeader(rangeHeader, fileSize) {
204
385
  // set(key, entry) — insert, evicting LFU entries if needed
205
386
  // delete(key) — remove explicitly (e.g. stale entry before re-insert)
206
387
  class LFUCache {
207
- constructor(maxSize, warnInterval, cacheLabel) {
388
+ constructor(maxSize, warnInterval, cacheLabel, logger) {
208
389
  this.maxSize = maxSize;
209
390
  this.warnInterval = warnInterval;
210
391
  this.cacheLabel = cacheLabel;
392
+ this.logger = logger || console;
211
393
  this.currentSize = 0;
212
- this._keyMap = new Map(); // key → { buffer, mtime, size, freq }
394
+ this._keyMap = new Map(); // key → { buffer, mtime, size, insertedAt, freq }
213
395
  this._freqMap = new Map(); // freq → Set<key>
214
396
  this._minFreq = 0;
215
397
  this._lastWarnAt = 0;
@@ -241,6 +423,26 @@ class LFUCache {
241
423
  this._minFreq = 1;
242
424
  }
243
425
 
426
+ // In-place update of an existing entry that preserves its current frequency.
427
+ // Used when refreshing a stale-by-maxAge entry so popular files don't fall to
428
+ // the bottom of the LFU bucket just because they got re-read from disk.
429
+ // Returns true on success, false if the new buffer doesn't fit in maxSize
430
+ // (caller can fall back to delete + set in that case).
431
+ refresh(key, fields) {
432
+ const entry = this._keyMap.get(key);
433
+ if (!entry) return false;
434
+
435
+ const sizeDelta = fields.buffer.length - entry.buffer.length;
436
+ if (this.currentSize + sizeDelta > this.maxSize) return false;
437
+
438
+ entry.buffer = fields.buffer;
439
+ if (fields.mtime !== undefined) entry.mtime = fields.mtime;
440
+ if (fields.size !== undefined) entry.size = fields.size;
441
+ if (fields.insertedAt !== undefined) entry.insertedAt = fields.insertedAt;
442
+ this.currentSize += sizeDelta;
443
+ return true;
444
+ }
445
+
244
446
  delete(key) {
245
447
  if (!this._keyMap.has(key)) return;
246
448
  const { freq, buffer } = this._keyMap.get(key);
@@ -272,7 +474,6 @@ class LFUCache {
272
474
  if (!this._freqMap.has(freq)) this._freqMap.set(freq, new Set());
273
475
  this._freqMap.get(freq).add(key);
274
476
  }
275
-
276
477
  _evictOne() {
277
478
  // Recover from stale _minFreq (can happen after consecutive evictions)
278
479
  while (this._freqMap.size > 0 && (!this._freqMap.has(this._minFreq) || this._freqMap.get(this._minFreq).size === 0)) {
@@ -293,13 +494,28 @@ class LFUCache {
293
494
  if (this.warnInterval !== false) {
294
495
  const now = Date.now();
295
496
  if (now - this._lastWarnAt >= this.warnInterval) {
296
- console.warn(`[koa-classic-server] serverCache.${this.cacheLabel}: maxSize reached, evicting LFU entries. Consider increasing maxSize.`);
497
+ this.logger.warn(`[koa-classic-server] serverCache.${this.cacheLabel}: maxSize reached, evicting LFU entries. Consider increasing maxSize.`);
297
498
  this._lastWarnAt = now;
298
499
  }
299
500
  }
300
501
  }
301
502
  }
302
503
 
504
+ // Upserts a fresh entry into an LFUCache. When the previous entry was only
505
+ // stale-by-age (mtime + size unchanged), updates in place so the existing
506
+ // frequency counter survives — important for popular files refreshed by maxAge.
507
+ // Otherwise falls back to delete + set (frequency resets to 1).
508
+ function refreshOrInsert(cache, key, newEntry, cached, staleByAge) {
509
+ const canRefreshInPlace = cached
510
+ && staleByAge
511
+ && cached.mtime === newEntry.mtime
512
+ && cached.size === newEntry.size;
513
+ if (!canRefreshInPlace || !cache.refresh(key, newEntry)) {
514
+ if (cached) cache.delete(key);
515
+ cache.set(key, newEntry);
516
+ }
517
+ }
518
+
303
519
  module.exports = function koaClassicServer(
304
520
  rootDir,
305
521
  opts = {}
@@ -307,7 +523,26 @@ module.exports = function koaClassicServer(
307
523
  opts STRUCTURE
308
524
  opts = {
309
525
  method: ['GET'], // Supported methods, otherwise next() will be called
310
- showDirContents: true, // Show or hide directory contents
526
+ dirListing: { // Directory listing configuration (V3+).
527
+ enabled: true, // Render the directory listing HTML when no index file matches.
528
+ // Set to false to return 404 instead of a listing.
529
+ maxEntries: 100000, // Soft cap on entries shown / sorted / stat'd per listing.
530
+ // Implementation: fs.promises.readdir() then slice(0, maxEntries).
531
+ // This is a SAFETY NET against catastrophic operational accidents
532
+ // (broken log rotation, mistakenly mounted huge FS) — not a policy
533
+ // restriction on what the operator can serve. 99% of legitimate
534
+ // deployments never hit this cap. Excess entries are not shown:
535
+ // a banner + the X-Dir-Truncated response header advertise the
536
+ // truncation. Bounds the rendering / CPU cost, NOT the size of the
537
+ // initial readdir() allocation.
538
+ // Must be a finite integer >= 0; 0 = disabled (no cap).
539
+ // For directories writable by untrusted parties, see the v3.1
540
+ // TODO [F-1] in docs/security_improvement_for_V3.md (`readMode`).
541
+ entriesPerPage: 100, // Entries per page in the listing UI. Pagination kicks in only
542
+ // when visible entries > entriesPerPage. Page index via
543
+ // ?page=N (0-based); out-of-range values are clamped silently.
544
+ // Must be a finite integer >= 0; 0 = disabled (no pagination).
545
+ },
311
546
  index: ["index.html"], // Index file name(s) - must be an ARRAY:
312
547
  // - Array of strings: ["index.html", "index.htm", "default.html"]
313
548
  // - Array of RegExp: [/index\.html/i, /default\.(html|htm)/i]
@@ -316,8 +551,13 @@ module.exports = function koaClassicServer(
316
551
  urlPrefix: "", // URL path prefix
317
552
  urlsReserved: [], // Reserved paths (first level only)
318
553
  template: {
319
- render: undefined, // Template rendering function: async (ctx, next, filePath) => {}
554
+ render: undefined, // Template rendering function: async (ctx, next, filePath, rawBuffer, signal) => {}
320
555
  ext: [], // File extensions to process with template.render
556
+ renderTimeout: 30000, // Max ms allowed for template.render (number ≥ 0; 0 = disabled).
557
+ // On timeout responds 504 Gateway Timeout. The render receives an
558
+ // AbortSignal as 5th argument; propagate it to fetch/db/fs to free
559
+ // backend resources. The signal also aborts on client disconnect,
560
+ // even when renderTimeout is 0.
321
561
  },
322
562
  browserCacheMaxAge: 3600, // Browser Cache-Control max-age in seconds (default: 1 hour)
323
563
  browserCacheEnabled: false, // Enable browser HTTP caching headers (ETag, Last-Modified)
@@ -331,8 +571,10 @@ module.exports = function koaClassicServer(
331
571
  redirect: 301 // HTTP redirect code for URLs with extension (optional, default: 301)
332
572
  },
333
573
  hidden: { // Block files/dirs from listing and serving (HTTP 404)
334
- dotFiles: { // Dot-files (names starting with '.'): hidden by default
335
- default: 'hidden', // 'hidden' | 'visible' — system default: 'hidden'
574
+ dotFiles: { // Dot-files (names starting with '.'): visible by default — design philosophy
575
+ default: 'visible', // 'hidden' | 'visible' — system default: 'visible'
576
+ // To protect .env / .git / etc., set 'hidden' explicitly OR add to
577
+ // `blacklist` / `alwaysHide`. See README "Security Checklist".
336
578
  whitelist: [], // Always visible (string exact/glob or RegExp). Overrides default and alwaysHide.
337
579
  blacklist: [], // Always hidden (string or RegExp). Overrides whitelist.
338
580
  },
@@ -350,21 +592,31 @@ module.exports = function koaClassicServer(
350
592
  enabled: false, // enable in-memory cache of raw file buffers
351
593
  maxSize: 52428800, // max total RAM used by this cache (bytes; default: 50 MB)
352
594
  maxFileSize: 1048576, // files larger than this are never cached (bytes; default: 1 MB)
595
+ maxAge: 0, // ms after insertion to consider an entry stale; 0 = disabled.
596
+ // Useful on NFS/SMB/overlay FS where mtime+size may not reflect
597
+ // remote changes within the OS attribute-cache window. Limits but
598
+ // does not eliminate staleness — combine with low actimeo on the
599
+ // mount for stricter freshness.
353
600
  warnInterval: 60000, // ms between "maxSize reached" warnings; 0 = always; false = never
354
601
  },
355
602
  compressedFile: { // cache for HTTP br/gzip responses — not for .zip/.tar files on disk
356
603
  enabled: true, // enable in-memory cache of compressed response buffers
357
604
  maxSize: 104857600, // max total RAM used by this cache (bytes; default: 100 MB)
605
+ maxAge: 0, // ms after insertion to consider an entry stale; 0 = disabled. See rawFile.maxAge.
358
606
  warnInterval: 60000, // ms between "maxSize reached" warnings; 0 = always; false = never
359
607
  },
360
608
  },
361
609
  compression: { // Response compression (gzip / brotli) — to enable/disable caching → serverCache.compressedFile
362
610
  enabled: true, // master switch (false = disable all compression)
363
611
  encodings: ['br', 'gzip'], // algorithms in priority order; [] = disable
364
- minSize: 1024, // min file size in bytes to compress; false = no minimum
612
+ minFileSize: 1024, // min file size in bytes to compress; false = no minimum
365
613
  mimeTypes: [], // compressible MIME types (replaces default list if provided)
366
614
  },
367
615
  // compression: false // shorthand to disable all compression
616
+ logger: console, // Logger used for internal errors and warnings.
617
+ // Must expose error(...) and warn(...). Pass pino/winston/bunyan
618
+ // or any compatible object to integrate with aggregated logging.
619
+ // Default: the global console.
368
620
 
369
621
  }
370
622
  */
@@ -381,8 +633,80 @@ module.exports = function koaClassicServer(
381
633
  const options = opts || {};
382
634
  options.template = opts.template || {};
383
635
 
636
+ const _logger = normalizeLogger(options.logger);
637
+
384
638
  options.method = Array.isArray(options.method) ? options.method : ['GET'];
385
- options.showDirContents = typeof options.showDirContents === 'boolean' ? options.showDirContents : true;
639
+
640
+ // ── V3 breaking-change guards: helpful errors for V3-alpha-only renamed options ──
641
+ // These were introduced in v3.0.0-alpha.0 only; no v2 user can have them in production.
642
+ if (opts.maxDirEntries !== undefined) {
643
+ throw new Error(
644
+ '[koa-classic-server] options.maxDirEntries was relocated in v3.0.0.\n' +
645
+ ` Replace with: dirListing: { maxEntries: ${opts.maxDirEntries} }`
646
+ );
647
+ }
648
+ if (opts.pageSize !== undefined) {
649
+ throw new Error(
650
+ '[koa-classic-server] options.pageSize was relocated and renamed in v3.0.0.\n' +
651
+ ` Replace with: dirListing: { entriesPerPage: ${opts.pageSize} }`
652
+ );
653
+ }
654
+
655
+ function validateNonNegativeInt(value, optionName, defaultValue) {
656
+ if (value === undefined) return defaultValue;
657
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || !Number.isInteger(value)) {
658
+ throw new Error(
659
+ `[koa-classic-server] options.${optionName} must be a non-negative integer. ` +
660
+ 'Use 0 to disable. Got: ' + String(value)
661
+ );
662
+ }
663
+ return value;
664
+ }
665
+
666
+ // ── dirListing namespace (V3+) — single source of truth for listing config ──
667
+ const userDirListing = opts.dirListing;
668
+ if (userDirListing !== undefined && (typeof userDirListing !== 'object' || userDirListing === null || Array.isArray(userDirListing))) {
669
+ throw new Error('[koa-classic-server] options.dirListing must be an object.');
670
+ }
671
+
672
+ // V3 backward-compat alias: showDirContents (v2-stable) maps to dirListing.enabled.
673
+ // The alias may be removed in a future major version. Emits a one-time deprecation
674
+ // warning per process. Throws if both names are passed (the user picked one of them
675
+ // by mistake — surface the conflict rather than silently choosing one).
676
+ let aliasEnabled; // undefined unless showDirContents was passed
677
+ if (opts.showDirContents !== undefined) {
678
+ if (userDirListing && userDirListing.enabled !== undefined) {
679
+ throw new Error(
680
+ '[koa-classic-server] options.showDirContents and options.dirListing.enabled are both set.\n' +
681
+ ' These configure the same thing — pick one.'
682
+ );
683
+ }
684
+ if (!_showDirContentsDeprecationWarned) {
685
+ _showDirContentsDeprecationWarned = true;
686
+ _logger.warn(...warnPayload(_logger,
687
+ '[koa-classic-server] DEPRECATION: options.showDirContents was renamed to dirListing.enabled in v3.0.0.\n' +
688
+ ' The old name is currently accepted as an alias and may be removed in a future major version.\n' +
689
+ ` Replace with: dirListing: { enabled: ${opts.showDirContents} }`
690
+ ));
691
+ }
692
+ aliasEnabled = !!opts.showDirContents;
693
+ }
694
+
695
+ options.dirListing = {
696
+ enabled: userDirListing && userDirListing.enabled !== undefined
697
+ ? !!userDirListing.enabled
698
+ : (aliasEnabled !== undefined ? aliasEnabled : true),
699
+ maxEntries: validateNonNegativeInt(
700
+ userDirListing && userDirListing.maxEntries,
701
+ 'dirListing.maxEntries',
702
+ 10000
703
+ ),
704
+ entriesPerPage: validateNonNegativeInt(
705
+ userDirListing && userDirListing.entriesPerPage,
706
+ 'dirListing.entriesPerPage',
707
+ 100
708
+ ),
709
+ };
386
710
 
387
711
  // Normalize index option to array format
388
712
  if (typeof options.index === 'string') {
@@ -411,6 +735,19 @@ module.exports = function koaClassicServer(
411
735
  options.template.render = (options.template.render === undefined || typeof options.template.render === 'function') ? options.template.render : undefined;
412
736
  options.template.ext = Array.isArray(options.template.ext) ? options.template.ext : [];
413
737
 
738
+ if (options.template.renderTimeout === undefined) {
739
+ options.template.renderTimeout = 30000;
740
+ } else if (
741
+ typeof options.template.renderTimeout !== 'number' ||
742
+ !Number.isFinite(options.template.renderTimeout) ||
743
+ options.template.renderTimeout < 0
744
+ ) {
745
+ throw new Error(
746
+ '[koa-classic-server] template.renderTimeout must be a finite number >= 0 (ms). ' +
747
+ 'Use 0 to disable. Got: ' + String(options.template.renderTimeout)
748
+ );
749
+ }
750
+
414
751
  // v3.0.0: removed legacy option names — throw to surface the breaking change clearly
415
752
  if ('cacheMaxAge' in opts) {
416
753
  throw new Error(
@@ -439,13 +776,12 @@ module.exports = function koaClassicServer(
439
776
  }
440
777
  // Normalize ext: add leading dot if missing
441
778
  if (!options.hideExtension.ext.startsWith('.')) {
442
- console.warn(
443
- '\x1b[33m%s\x1b[0m',
779
+ _logger.warn(...warnPayload(_logger,
444
780
  '[koa-classic-server] WARNING: hideExtension.ext should start with a dot.\n' +
445
781
  ` Current usage: ext: "${options.hideExtension.ext}"\n` +
446
782
  ` Corrected to: ext: ".${options.hideExtension.ext}"\n` +
447
783
  ' Please update your configuration.'
448
- );
784
+ ));
449
785
  options.hideExtension.ext = '.' + options.hideExtension.ext;
450
786
  }
451
787
  // Validate redirect code
@@ -462,7 +798,7 @@ module.exports = function koaClassicServer(
462
798
  function normalizeHiddenConfig(hidden) {
463
799
  if (!hidden || typeof hidden !== 'object' || Array.isArray(hidden)) {
464
800
  return {
465
- dotFiles: { default: 'hidden', whitelist: [], blacklist: [] },
801
+ dotFiles: { default: 'visible', whitelist: [], blacklist: [] },
466
802
  dotDirs: { default: 'visible', whitelist: [], blacklist: [] },
467
803
  alwaysHide: []
468
804
  };
@@ -490,7 +826,7 @@ module.exports = function koaClassicServer(
490
826
  }
491
827
 
492
828
  return {
493
- dotFiles: normalizeCategory(hidden.dotFiles, 'hidden', 'dotFiles'),
829
+ dotFiles: normalizeCategory(hidden.dotFiles, 'visible', 'dotFiles'),
494
830
  dotDirs: normalizeCategory(hidden.dotDirs, 'visible', 'dotDirs'),
495
831
  alwaysHide: filterPatternList(hidden.alwaysHide),
496
832
  };
@@ -498,66 +834,53 @@ module.exports = function koaClassicServer(
498
834
 
499
835
  const hiddenConfig = normalizeHiddenConfig(options.hidden);
500
836
 
501
- // One-time per-process warning when the caller relies on implicit defaults for
502
- // hidden.dotFiles.default or hidden.dotDirs.default. Since v3.0.0 dotFiles are
503
- // hidden by default ('hidden') while dotDirs are visible by default ('visible').
504
- // Explicitly declaring the values makes intent clear and silences the warning.
505
- if (!_hiddenDefaultWarnEmitted) {
506
- const dotFilesImplicit = opts.hidden?.dotFiles?.default === undefined;
507
- const dotDirsImplicit = opts.hidden?.dotDirs?.default === undefined;
508
- if (dotFilesImplicit || dotDirsImplicit) {
509
- _hiddenDefaultWarnEmitted = true;
510
- console.warn(
511
- '\x1b[33m%s\x1b[0m',
512
- '[koa-classic-server] WARNING: hidden.dotFiles.default and/or hidden.dotDirs.default are not explicitly set.\n' +
513
- ' Since v3.0.0 the defaults are: dotFiles → "hidden", dotDirs → "visible".\n' +
514
- ' To suppress this warning, add to your configuration:\n' +
515
- ' hidden: { dotFiles: { default: \'hidden\' }, dotDirs: { default: \'visible\' } }\n' +
516
- ' (adjust values to match your desired behaviour)'
517
- );
518
- }
519
- }
520
-
521
- // Returns true if `name` matches any pattern in the list.
522
- // Patterns are matched against the bare filename (case-sensitive).
523
- // Each entry can be a string (exact match or simple glob with * and ?) or a RegExp.
524
- function matchesNameList(name, patterns) {
837
+ // Returns true if `value` matches any pattern in the list.
838
+ // RegExp patterns are tested directly; string patterns go through `globMatch`.
839
+ // Non-string non-RegExp entries are ignored (defensive config validation should reject them).
840
+ function matchesPatternList(value, patterns, globMatch) {
525
841
  for (const pattern of patterns) {
526
842
  if (pattern instanceof RegExp) {
527
- if (pattern.test(name)) return true;
843
+ if (pattern.test(value)) return true;
528
844
  } else if (typeof pattern === 'string') {
529
- if (nameGlobMatch(name, pattern)) return true;
845
+ if (globMatch(value, pattern)) return true;
530
846
  }
531
847
  }
532
848
  return false;
533
849
  }
534
850
 
851
+ // Match against a list using filename-glob semantics (case-sensitive, no path component).
852
+ function matchesNameList(name, patterns) {
853
+ return matchesPatternList(name, patterns, nameGlobMatch);
854
+ }
855
+
856
+ // Compiled-RegExp caches for glob patterns. Patterns come from `hidden.*` config and are
857
+ // immutable after factory init, so memoization is bounded by the operator's config size
858
+ // and avoids recompiling the same regex on every directory entry during a listing.
859
+ const _nameGlobRegexCache = new Map();
860
+ const _pathGlobRegexCache = new Map();
861
+
535
862
  // Matches a bare filename against a simple glob pattern (* = any chars except /, ? = one char).
536
863
  function nameGlobMatch(name, pattern) {
537
864
  if (!pattern.includes('*') && !pattern.includes('?')) {
538
865
  return name === pattern;
539
866
  }
540
- const regexStr = '^' +
541
- pattern
542
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
543
- .replace(/\*/g, '[^/]*')
544
- .replace(/\?/g, '[^/]')
545
- + '$';
546
- return new RegExp(regexStr).test(name);
867
+ let re = _nameGlobRegexCache.get(pattern);
868
+ if (re === undefined) {
869
+ const regexStr = '^' +
870
+ pattern
871
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
872
+ .replace(/\*/g, '[^/]*')
873
+ .replace(/\?/g, '[^/]')
874
+ + '$';
875
+ re = new RegExp(regexStr);
876
+ _nameGlobRegexCache.set(pattern, re);
877
+ }
878
+ return re.test(name);
547
879
  }
548
880
 
549
- // Returns true if `relPath` matches any pattern in the list.
550
- // Patterns are matched against the full relative path from rootDir (case-sensitive).
551
- // Each entry can be a string glob or a RegExp.
881
+ // Match against a list using path-aware glob semantics (anchored to rootDir, supports **).
552
882
  function matchesPathList(relPath, patterns) {
553
- for (const pattern of patterns) {
554
- if (pattern instanceof RegExp) {
555
- if (pattern.test(relPath)) return true;
556
- } else if (typeof pattern === 'string') {
557
- if (pathGlobMatch(relPath, pattern)) return true;
558
- }
559
- }
560
- return false;
883
+ return matchesPatternList(relPath, patterns, pathGlobMatch);
561
884
  }
562
885
 
563
886
  /**
@@ -569,19 +892,24 @@ module.exports = function koaClassicServer(
569
892
  * - '?' matches any single character except '/'
570
893
  */
571
894
  function pathGlobMatch(relPath, pattern) {
572
- const hasSlash = pattern.includes('/');
573
- const escaped = pattern
574
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
575
- .replace(/\*\*/g, '\x00')
576
- .replace(/\*/g, '[^/]*')
577
- .replace(/\?/g, '[^/]')
578
- .replace(/\x00/g, '.*');
579
-
580
- const regexStr = hasSlash
581
- ? '^' + escaped + '($|/)' // path-anchored from root
582
- : '(^|/)' + escaped + '$'; // basename match at any depth
583
-
584
- return new RegExp(regexStr).test(relPath);
895
+ let re = _pathGlobRegexCache.get(pattern);
896
+ if (re === undefined) {
897
+ const hasSlash = pattern.includes('/');
898
+ const escaped = pattern
899
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
900
+ .replace(/\*\*/g, '\x00')
901
+ .replace(/\*/g, '[^/]*')
902
+ .replace(/\?/g, '[^/]')
903
+ .replace(/\x00/g, '.*');
904
+
905
+ const regexStr = hasSlash
906
+ ? '^' + escaped + '($|/)' // path-anchored from root
907
+ : '(^|/)' + escaped + '$'; // basename match at any depth
908
+
909
+ re = new RegExp(regexStr);
910
+ _pathGlobRegexCache.set(pattern, re);
911
+ }
912
+ return re.test(relPath);
585
913
  }
586
914
 
587
915
  /**
@@ -657,11 +985,19 @@ module.exports = function koaClassicServer(
657
985
  return {
658
986
  enabled: true,
659
987
  encodings: ['br', 'gzip'], // priority order: brotli first, gzip as fallback
660
- minSize: 1024, // bytes; skip compression for files smaller than this
988
+ minFileSize: 1024, // bytes; skip compression for files smaller than this
661
989
  mimeTypes: new Set(DEFAULT_COMPRESSIBLE_MIME_TYPES),
662
990
  };
663
991
  }
664
992
 
993
+ // V3 breaking-change guard: catch the v2-alpha name minSize with a helpful migration hint.
994
+ if (compression.minSize !== undefined) {
995
+ throw new Error(
996
+ '[koa-classic-server] options.compression.minSize was renamed in v3.0.0.\n' +
997
+ ` Replace with: compression: { minFileSize: ${compression.minSize} }`
998
+ );
999
+ }
1000
+
665
1001
  const enabled = typeof compression.enabled === 'boolean' ? compression.enabled : true;
666
1002
  if (!enabled) return { enabled: false };
667
1003
 
@@ -669,14 +1005,14 @@ module.exports = function koaClassicServer(
669
1005
  ? compression.encodings.filter(e => e === 'br' || e === 'gzip')
670
1006
  : ['br', 'gzip'];
671
1007
 
672
- const minSize = compression.minSize === false ? false
673
- : (typeof compression.minSize === 'number' && compression.minSize >= 0 ? compression.minSize : 1024);
1008
+ const minFileSize = compression.minFileSize === false ? false
1009
+ : (typeof compression.minFileSize === 'number' && compression.minFileSize >= 0 ? compression.minFileSize : 1024);
674
1010
 
675
1011
  const mimeTypes = Array.isArray(compression.mimeTypes) && compression.mimeTypes.length > 0
676
1012
  ? compression.mimeTypes
677
1013
  : DEFAULT_COMPRESSIBLE_MIME_TYPES;
678
1014
 
679
- return { enabled, encodings, minSize, mimeTypes: new Set(mimeTypes) };
1015
+ return { enabled, encodings, minFileSize, mimeTypes: new Set(mimeTypes) };
680
1016
  }
681
1017
 
682
1018
  // Normalize and validate the serverCache option into a clean internal structure.
@@ -685,14 +1021,27 @@ module.exports = function koaClassicServer(
685
1021
  enabled: false,
686
1022
  maxSize: 52428800, // 50 MB
687
1023
  maxFileSize: 1048576, // 1 MB
1024
+ maxAge: 0,
688
1025
  warnInterval: 60000,
689
1026
  };
690
1027
  const defaultCompressedFile = {
691
1028
  enabled: true,
692
1029
  maxSize: 104857600, // 100 MB
1030
+ maxAge: 0,
693
1031
  warnInterval: 60000,
694
1032
  };
695
1033
 
1034
+ function validateMaxAge(value, cacheName) {
1035
+ if (value === undefined) return 0;
1036
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
1037
+ throw new Error(
1038
+ `[koa-classic-server] serverCache.${cacheName}.maxAge must be a finite number >= 0 (ms). ` +
1039
+ 'Use 0 to disable. Got: ' + String(value)
1040
+ );
1041
+ }
1042
+ return value;
1043
+ }
1044
+
696
1045
  if (!serverCache || typeof serverCache !== 'object' || Array.isArray(serverCache)) {
697
1046
  return { rawFile: defaultRawFile, compressedFile: defaultCompressedFile };
698
1047
  }
@@ -702,6 +1051,7 @@ module.exports = function koaClassicServer(
702
1051
  enabled: typeof rf.enabled === 'boolean' ? rf.enabled : false,
703
1052
  maxSize: typeof rf.maxSize === 'number' && rf.maxSize > 0 ? rf.maxSize : 52428800,
704
1053
  maxFileSize: typeof rf.maxFileSize === 'number' && rf.maxFileSize > 0 ? rf.maxFileSize : 1048576,
1054
+ maxAge: validateMaxAge(rf.maxAge, 'rawFile'),
705
1055
  warnInterval: rf.warnInterval === false ? false : (typeof rf.warnInterval === 'number' ? rf.warnInterval : 60000),
706
1056
  };
707
1057
 
@@ -709,6 +1059,7 @@ module.exports = function koaClassicServer(
709
1059
  const compressedFile = (!cf || typeof cf !== 'object' || Array.isArray(cf)) ? defaultCompressedFile : {
710
1060
  enabled: typeof cf.enabled === 'boolean' ? cf.enabled : true,
711
1061
  maxSize: typeof cf.maxSize === 'number' && cf.maxSize > 0 ? cf.maxSize : 104857600,
1062
+ maxAge: validateMaxAge(cf.maxAge, 'compressedFile'),
712
1063
  warnInterval: cf.warnInterval === false ? false : (typeof cf.warnInterval === 'number' ? cf.warnInterval : 60000),
713
1064
  };
714
1065
 
@@ -723,7 +1074,8 @@ module.exports = function koaClassicServer(
723
1074
  const _rawFileCache = new LFUCache(
724
1075
  serverCacheConfig.rawFile.maxSize,
725
1076
  serverCacheConfig.rawFile.warnInterval,
726
- 'rawFile'
1077
+ 'rawFile',
1078
+ _logger
727
1079
  );
728
1080
 
729
1081
  // In-memory LFU cache for compressed file buffers (serverCache.compressedFile).
@@ -731,7 +1083,8 @@ module.exports = function koaClassicServer(
731
1083
  const _compressedFileCache = new LFUCache(
732
1084
  serverCacheConfig.compressedFile.maxSize,
733
1085
  serverCacheConfig.compressedFile.warnInterval,
734
- 'compressedFile'
1086
+ 'compressedFile',
1087
+ _logger
735
1088
  );
736
1089
 
737
1090
  // Returns the client's preferred encoding based on Accept-Encoding header,
@@ -745,23 +1098,14 @@ module.exports = function koaClassicServer(
745
1098
  }
746
1099
 
747
1100
  // Compress a Buffer using the given encoding ('br' or 'gzip').
748
- // Uses maximum quality appropriate for serverCache mode (cost paid once).
1101
+ // Quality is maxed out: serverCache pays this cost once per file, not per request.
749
1102
  function compressBuffer(data, encoding) {
750
- return new Promise((resolve, reject) => {
751
- if (encoding === 'br') {
752
- zlib.brotliCompress(
753
- data,
754
- { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } },
755
- (err, result) => { if (err) reject(err); else resolve(result); }
756
- );
757
- } else {
758
- zlib.gzip(
759
- data,
760
- { level: zlib.constants.Z_BEST_COMPRESSION },
761
- (err, result) => { if (err) reject(err); else resolve(result); }
762
- );
763
- }
764
- });
1103
+ if (encoding === 'br') {
1104
+ return _brotliCompressAsync(data, {
1105
+ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 }
1106
+ });
1107
+ }
1108
+ return _gzipAsync(data, { level: zlib.constants.Z_BEST_COMPRESSION });
765
1109
  }
766
1110
 
767
1111
  /**
@@ -816,7 +1160,7 @@ module.exports = function koaClassicServer(
816
1160
  );
817
1161
  fileNames = checkResults.filter(e => e.isFile).map(e => e.name);
818
1162
  } catch (error) {
819
- console.error('Error finding index file:', error);
1163
+ _logger.error('Error finding index file:', error);
820
1164
  return null;
821
1165
  }
822
1166
  }
@@ -909,7 +1253,9 @@ module.exports = function koaClassicServer(
909
1253
  return;
910
1254
  }
911
1255
 
912
- // Hidden check: block requests that traverse a hidden directory
1256
+ // Hidden check: block requests that traverse a hidden directory.
1257
+ // Stops at length-1 because the leaf (the file or dir being served) is
1258
+ // checked separately by the file/listing path with its real stat.isDirectory().
913
1259
  if (requestedPath !== '') {
914
1260
  const segments = normalizedPath.split(path.sep).filter(Boolean);
915
1261
  for (let i = 0; i < segments.length - 1; i++) {
@@ -933,14 +1279,23 @@ module.exports = function koaClassicServer(
933
1279
  const rawPath = urlToUse.split('?')[0];
934
1280
  const hadTrailingSlash = rawPath.length > 1 && rawPath.endsWith('/');
935
1281
 
936
- // Check if URL ends with the configured extension redirect to clean URL
937
- // Use the original path (before trailing slash stripping) for accurate matching
1282
+ // Strip a trailing slash before the extension check so URLs like /foo.html/
1283
+ // still match (the slash is URL formality, not part of the filename) without
1284
+ // this, /foo.html/ would skip the redirect and 404 trying to open it as a dir.
938
1285
  const pathForExtCheck = hadTrailingSlash ? rawPath.slice(0, -1) : requestedPath;
939
1286
  if (pathForExtCheck.endsWith(hideExt)) {
940
1287
  // Build redirect target using ctx.originalUrl (always, regardless of useOriginalUrl)
941
1288
  const originalUrlObj = new URL(_origin + ctx.originalUrl);
942
1289
  let redirectPath = originalUrlObj.pathname;
943
1290
 
1291
+ // Collapse leading slashes: a Location header starting with "//" (or "/\")
1292
+ // is a protocol-relative URL and would let "GET //evil.com/foo.ejs" redirect
1293
+ // off-origin. path.normalize() upstream already collapses these for the
1294
+ // filesystem check, so the source-of-truth URL has a single leading slash.
1295
+ if (redirectPath.length > 1 && (redirectPath.charCodeAt(1) === 0x2F || redirectPath.charCodeAt(1) === 0x5C)) {
1296
+ redirectPath = '/' + redirectPath.replace(/^[/\\]+/, '');
1297
+ }
1298
+
944
1299
  redirectPath = redirectPath.slice(0, redirectPath.length - hideExt.length);
945
1300
 
946
1301
  // Special case: /index.ejs → /, /sezione/index.ejs → /sezione/
@@ -1007,7 +1362,7 @@ module.exports = function koaClassicServer(
1007
1362
 
1008
1363
  if (stat.isDirectory()) {
1009
1364
  // Handle directory
1010
- if (options.showDirContents) {
1365
+ if (options.dirListing.enabled) {
1011
1366
  // Search for index file matching configured patterns
1012
1367
  if (options.index && options.index.length > 0) {
1013
1368
  const indexFile = await findIndexFile(toOpen, options.index);
@@ -1042,7 +1397,7 @@ module.exports = function koaClassicServer(
1042
1397
  try {
1043
1398
  fileStat = await fs.promises.stat(toOpen);
1044
1399
  } catch (error) {
1045
- console.error('File stat error:', error);
1400
+ _logger.error('File stat error:', error);
1046
1401
  sendNotFound(ctx);
1047
1402
  return;
1048
1403
  }
@@ -1053,54 +1408,32 @@ module.exports = function koaClassicServer(
1053
1408
  let rawBuffer = null;
1054
1409
  if (serverCacheConfig.rawFile.enabled && fileStat.size <= serverCacheConfig.rawFile.maxFileSize) {
1055
1410
  const cached = _rawFileCache.peek(toOpen);
1056
- if (cached && cached.mtime === fileStat.mtime.getTime() && cached.size === fileStat.size) {
1411
+ const maxAge = serverCacheConfig.rawFile.maxAge;
1412
+ const staleByAge = maxAge > 0 && cached && (Date.now() - cached.insertedAt) >= maxAge;
1413
+ const fresh = cached
1414
+ && cached.mtime === fileStat.mtime.getTime()
1415
+ && cached.size === fileStat.size
1416
+ && !staleByAge;
1417
+ if (fresh) {
1057
1418
  _rawFileCache.get(toOpen); // increment frequency
1058
1419
  rawBuffer = cached.buffer;
1059
1420
  } else {
1060
1421
  try {
1061
1422
  rawBuffer = await fs.promises.readFile(toOpen);
1062
- if (cached) _rawFileCache.delete(toOpen); // remove stale entry before re-inserting
1063
- _rawFileCache.set(toOpen, {
1423
+ refreshOrInsert(_rawFileCache, toOpen, {
1064
1424
  buffer: rawBuffer,
1065
1425
  mtime: fileStat.mtime.getTime(),
1066
1426
  size: fileStat.size,
1067
- });
1427
+ insertedAt: Date.now(),
1428
+ }, cached, staleByAge);
1068
1429
  } catch {
1069
1430
  rawBuffer = null; // Fall through to disk reads later
1070
1431
  }
1071
1432
  }
1072
1433
  }
1073
1434
 
1074
- // Template rendering rawBuffer passed as optional 4th param so render functions
1075
- // can skip their own fs.readFile() call when the file is already in memory.
1076
- if (options.template.ext.length > 0 && options.template.render) {
1077
- const fileExt = path.extname(toOpen).slice(1); // Remove leading dot
1078
-
1079
- if (fileExt && options.template.ext.includes(fileExt)) {
1080
- try {
1081
- await options.template.render(ctx, next, toOpen, rawBuffer);
1082
- return;
1083
- } catch (error) {
1084
- console.error('Template rendering error:', error);
1085
- setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
1086
- ctx.status = 500;
1087
- ctx.body = `
1088
- <!DOCTYPE html>
1089
- <html>
1090
- <head>
1091
- <meta charset="UTF-8">
1092
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
1093
- <title>Internal Server Error</title>
1094
- </head>
1095
- <body>
1096
- <h1>Internal Server Error</h1>
1097
- <h3>Template rendering failed for the requested resource.</h3>
1098
- </body>
1099
- </html>
1100
- `;
1101
- return;
1102
- }
1103
- }
1435
+ if (await tryRenderTemplate(ctx, next, toOpen, rawBuffer, options.template, _logger)) {
1436
+ return;
1104
1437
  }
1105
1438
 
1106
1439
  // baseEtag — encoding-independent; used only for If-Range (Range requests skip compression)
@@ -1125,7 +1458,7 @@ module.exports = function koaClassicServer(
1125
1458
  try {
1126
1459
  await fs.promises.access(toOpen, fs.constants.R_OK);
1127
1460
  } catch (error) {
1128
- console.error('File access error:', error);
1461
+ _logger.error('File access error:', error);
1129
1462
  sendNotFound(ctx);
1130
1463
  return;
1131
1464
  }
@@ -1166,7 +1499,7 @@ module.exports = function koaClassicServer(
1166
1499
  } else {
1167
1500
  const src = fs.createReadStream(toOpen, { start, end });
1168
1501
  src.on('error', (err) => {
1169
- console.error('Stream error:', err);
1502
+ _logger.error('Stream error:', err);
1170
1503
  if (!ctx.headerSent) {
1171
1504
  ctx.status = 500;
1172
1505
  ctx.body = 'Error reading file';
@@ -1191,12 +1524,12 @@ module.exports = function koaClassicServer(
1191
1524
  const mimeType = mime.lookup(toOpen) || 'application/octet-stream';
1192
1525
  const filename = path.basename(toOpen);
1193
1526
 
1194
- // Resolve compression: enabled + compressible MIME + meets minSize + client supports it
1527
+ // Resolve compression: enabled + compressible MIME + meets minFileSize + client supports it
1195
1528
  let encoding = null; // 'br' | 'gzip' | null
1196
1529
  if (compressionConfig.enabled && compressionConfig.encodings.length > 0) {
1197
1530
  const isCompressibleMime = compressionConfig.mimeTypes.has(mimeType);
1198
- const meetsMinSize = compressionConfig.minSize === false
1199
- || fileStat.size >= compressionConfig.minSize;
1531
+ const meetsMinSize = compressionConfig.minFileSize === false
1532
+ || fileStat.size >= compressionConfig.minFileSize;
1200
1533
  if (isCompressibleMime && meetsMinSize) {
1201
1534
  encoding = getClientEncoding(ctx.get('Accept-Encoding'));
1202
1535
  }
@@ -1243,9 +1576,12 @@ module.exports = function koaClassicServer(
1243
1576
  // compressedFile cache mode: compress once → buffer in RAM → Content-Length known
1244
1577
  const cacheKey = `${toOpen}:${encoding}`;
1245
1578
  const cached = _compressedFileCache.peek(cacheKey);
1579
+ const maxAge = serverCacheConfig.compressedFile.maxAge;
1580
+ const staleByAge = maxAge > 0 && cached && (Date.now() - cached.insertedAt) >= maxAge;
1246
1581
  const stale = !cached
1247
1582
  || cached.mtime !== fileStat.mtime.getTime()
1248
- || cached.size !== fileStat.size;
1583
+ || cached.size !== fileStat.size
1584
+ || staleByAge;
1249
1585
 
1250
1586
  let buf;
1251
1587
  if (!stale) {
@@ -1257,14 +1593,14 @@ module.exports = function koaClassicServer(
1257
1593
  const rawData = rawBuffer || await fs.promises.readFile(toOpen);
1258
1594
  buf = await compressBuffer(rawData, encoding);
1259
1595
 
1260
- if (cached) _compressedFileCache.delete(cacheKey); // remove stale entry before re-inserting
1261
- _compressedFileCache.set(cacheKey, {
1596
+ refreshOrInsert(_compressedFileCache, cacheKey, {
1262
1597
  buffer: buf,
1263
1598
  mtime: fileStat.mtime.getTime(),
1264
1599
  size: fileStat.size,
1265
- });
1600
+ insertedAt: Date.now(),
1601
+ }, cached, staleByAge);
1266
1602
  } catch (err) {
1267
- console.error('Compression error:', err);
1603
+ _logger.error('Compression error:', err);
1268
1604
  // Fall back to uncompressed on any compression failure
1269
1605
  ctx.remove('Content-Encoding');
1270
1606
  ctx.remove('Vary');
@@ -1281,7 +1617,7 @@ module.exports = function koaClassicServer(
1281
1617
  if (ctx.method !== 'HEAD') {
1282
1618
  const src = fs.createReadStream(toOpen);
1283
1619
  src.on('error', (streamErr) => {
1284
- console.error('Stream error:', streamErr);
1620
+ _logger.error('Stream error:', streamErr);
1285
1621
  if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
1286
1622
  });
1287
1623
  ctx.body = src;
@@ -1316,7 +1652,7 @@ module.exports = function koaClassicServer(
1316
1652
  } else {
1317
1653
  const src = fs.createReadStream(toOpen);
1318
1654
  src.on('error', (err) => {
1319
- console.error('Stream error:', err);
1655
+ _logger.error('Stream error:', err);
1320
1656
  if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
1321
1657
  });
1322
1658
  ctx.body = src.pipe(compress);
@@ -1341,7 +1677,7 @@ module.exports = function koaClassicServer(
1341
1677
  if (ctx.method !== 'HEAD') {
1342
1678
  const src = fs.createReadStream(toOpen);
1343
1679
  src.on('error', (err) => {
1344
- console.error('Stream error:', err);
1680
+ _logger.error('Stream error:', err);
1345
1681
  if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
1346
1682
  });
1347
1683
  ctx.body = src;
@@ -1356,11 +1692,23 @@ module.exports = function koaClassicServer(
1356
1692
 
1357
1693
 
1358
1694
  async function show_dir(toOpen, ctx) {
1695
+ // Read the full directory in one syscall, then cap the result.
1696
+ // `dirListing.maxEntries` bounds the visible / sorted / stat'd entries, but
1697
+ // does NOT bound the size of the initial readdir() allocation — see the
1698
+ // adversarial-directory caveat tracked for v3.1 [F-1].
1699
+ const maxDirEntries = options.dirListing.maxEntries; // 0 = disabled (no cap)
1359
1700
  let dir;
1701
+ let truncated = false;
1360
1702
  try {
1361
- dir = await fs.promises.readdir(toOpen, { withFileTypes: true });
1703
+ const all = await fs.promises.readdir(toOpen, { withFileTypes: true });
1704
+ if (maxDirEntries > 0 && all.length > maxDirEntries) {
1705
+ truncated = true;
1706
+ dir = all.slice(0, maxDirEntries);
1707
+ } else {
1708
+ dir = all;
1709
+ }
1362
1710
  } catch (error) {
1363
- console.error('Directory read error:', error);
1711
+ _logger.error('Directory read error:', error);
1364
1712
  ctx.status = 500;
1365
1713
  setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
1366
1714
  return `
@@ -1386,10 +1734,17 @@ module.exports = function koaClassicServer(
1386
1734
  const sortBy = ctx.query.sort || 'name';
1387
1735
  const sortOrder = ctx.query.order || 'asc';
1388
1736
 
1389
- // Build base URL for sorting links (without query params)
1390
1737
  const baseUrl = pageHrefOutPrefix.pathname;
1391
1738
 
1392
- // Helper to create sorting URL
1739
+ // Preserves sort/order while overriding `page`; omits page when 0.
1740
+ function buildQueryUrl(targetPage) {
1741
+ const params = [];
1742
+ if (ctx.query.sort) params.push(`sort=${encodeURIComponent(ctx.query.sort)}`);
1743
+ if (ctx.query.order) params.push(`order=${encodeURIComponent(ctx.query.order)}`);
1744
+ if (targetPage > 0) params.push(`page=${targetPage}`);
1745
+ return params.length ? `${baseUrl}?${params.join('&')}` : baseUrl;
1746
+ }
1747
+
1393
1748
  function getSortUrl(column) {
1394
1749
  let newOrder = 'asc';
1395
1750
  if (sortBy === column && sortOrder === 'asc') {
@@ -1398,7 +1753,6 @@ module.exports = function koaClassicServer(
1398
1753
  return `${baseUrl}?sort=${column}&order=${newOrder}`;
1399
1754
  }
1400
1755
 
1401
- // Helper to get sort indicator
1402
1756
  function getSortIndicator(column) {
1403
1757
  if (sortBy === column) {
1404
1758
  return sortOrder === 'asc' ? ' ↑' : ' ↓';
@@ -1407,6 +1761,12 @@ module.exports = function koaClassicServer(
1407
1761
  }
1408
1762
 
1409
1763
  const parts = [];
1764
+ let totalPages = 1; // populated in the non-empty branch below
1765
+ let currentPage = 0;
1766
+ if (truncated) {
1767
+ parts.push(`<div class="kcs-banner">⚠ Showing first ${maxDirEntries} entries (cap reached). More files exist but are not listed. Adjust <code>dirListing.maxEntries</code> to see more.</div>`);
1768
+ ctx.set('X-Dir-Truncated', `${maxDirEntries}`);
1769
+ }
1410
1770
  parts.push("<table>");
1411
1771
  parts.push("<thead>");
1412
1772
  parts.push("<tr>");
@@ -1514,37 +1874,45 @@ module.exports = function koaClassicServer(
1514
1874
  }
1515
1875
  const items = rawItems.filter(Boolean);
1516
1876
 
1517
- // Sort items based on query parameters
1877
+ // Places directories before non-directories; falls back to `tieBreaker`
1878
+ // when both items are in the same bucket. `effectiveType === 2` covers plain
1879
+ // dirs and dir-resolved symlinks, matching the rest of the listing logic.
1880
+ const compareDirsFirst = (a, b, tieBreaker) => {
1881
+ const aIsDir = a.effectiveType === 2;
1882
+ const bIsDir = b.effectiveType === 2;
1883
+ if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
1884
+ return tieBreaker(a, b);
1885
+ };
1886
+
1518
1887
  items.sort((a, b) => {
1519
1888
  let comparison = 0;
1520
1889
 
1521
1890
  if (sortBy === 'name') {
1522
1891
  comparison = a.name.localeCompare(b.name);
1523
1892
  } else if (sortBy === 'type') {
1524
- // Sort directories first, then by mime type (using effectiveType for symlinks)
1525
- if (a.effectiveType === 2 && b.effectiveType !== 2) {
1526
- comparison = -1;
1527
- } else if (a.effectiveType !== 2 && b.effectiveType === 2) {
1528
- comparison = 1;
1529
- } else {
1530
- comparison = a.mimeType.localeCompare(b.mimeType);
1531
- }
1893
+ comparison = compareDirsFirst(a, b, (x, y) => x.mimeType.localeCompare(y.mimeType));
1532
1894
  } else if (sortBy === 'size') {
1533
- // Directories always at top when sorting by size (using effectiveType for symlinks)
1534
- if (a.effectiveType === 2 && b.effectiveType !== 2) {
1535
- comparison = -1;
1536
- } else if (a.effectiveType !== 2 && b.effectiveType === 2) {
1537
- comparison = 1;
1538
- } else {
1539
- comparison = a.sizeBytes - b.sizeBytes;
1540
- }
1895
+ comparison = compareDirsFirst(a, b, (x, y) => x.sizeBytes - y.sizeBytes);
1541
1896
  }
1542
1897
 
1543
1898
  return sortOrder === 'desc' ? -comparison : comparison;
1544
1899
  });
1545
1900
 
1901
+ // Pagination — slice the sorted items into the requested page (0-based).
1902
+ const pageSize = options.dirListing.entriesPerPage; // 0 disables pagination
1903
+ totalPages = pageSize > 0 ? Math.max(1, Math.ceil(items.length / pageSize)) : 1;
1904
+ const rawPage = parseInt(ctx.query.page, 10);
1905
+ const requestedPage = Number.isFinite(rawPage) && rawPage >= 0 ? rawPage : 0;
1906
+ currentPage = Math.min(requestedPage, totalPages - 1); // silent clamp
1907
+ const visibleItems = (pageSize > 0 && items.length > pageSize)
1908
+ ? items.slice(currentPage * pageSize, (currentPage + 1) * pageSize)
1909
+ : items;
1910
+ if (totalPages > 1) {
1911
+ ctx.set('X-Dir-Pagination', `${currentPage}/${totalPages - 1}`);
1912
+ }
1913
+
1546
1914
  // Generate HTML for sorted items
1547
- for (const item of items) {
1915
+ for (const item of visibleItems) {
1548
1916
  let rowStart = '';
1549
1917
  if (item.effectiveType === 1) {
1550
1918
  rowStart = `<tr><td> FILE `;
@@ -1573,6 +1941,47 @@ module.exports = function koaClassicServer(
1573
1941
  parts.push("</tbody>");
1574
1942
  parts.push("</table>");
1575
1943
 
1944
+ // Numbered paginator with First/Prev/Next/Last and ellipsis around the current page.
1945
+ // Only emitted when pagination is meaningful (computed above; reuses currentPage/totalPages).
1946
+ if (totalPages > 1) {
1947
+ const pageWindow = 2;
1948
+ const pagesToShow = new Set([0, totalPages - 1]);
1949
+ for (let i = Math.max(0, currentPage - pageWindow); i <= Math.min(totalPages - 1, currentPage + pageWindow); i++) {
1950
+ pagesToShow.add(i);
1951
+ }
1952
+ const sortedPages = [...pagesToShow].sort((a, b) => a - b);
1953
+
1954
+ const pager = ['<nav class="kcs-pagination" aria-label="Pagination">'];
1955
+ if (currentPage > 0) {
1956
+ pager.push(`<a href="${escapeHtml(buildQueryUrl(0))}">« First</a>`);
1957
+ pager.push(`<a href="${escapeHtml(buildQueryUrl(currentPage - 1))}">‹ Prev</a>`);
1958
+ } else {
1959
+ pager.push(`<span class="kcs-page-disabled">« First</span>`);
1960
+ pager.push(`<span class="kcs-page-disabled">‹ Prev</span>`);
1961
+ }
1962
+ let prev = -1;
1963
+ for (const p of sortedPages) {
1964
+ if (prev !== -1 && p - prev > 1) {
1965
+ pager.push(`<span class="kcs-page-ellipsis">…</span>`);
1966
+ }
1967
+ if (p === currentPage) {
1968
+ pager.push(`<span class="kcs-page-current">${p}</span>`);
1969
+ } else {
1970
+ pager.push(`<a href="${escapeHtml(buildQueryUrl(p))}">${p}</a>`);
1971
+ }
1972
+ prev = p;
1973
+ }
1974
+ if (currentPage < totalPages - 1) {
1975
+ pager.push(`<a href="${escapeHtml(buildQueryUrl(currentPage + 1))}">Next ›</a>`);
1976
+ pager.push(`<a href="${escapeHtml(buildQueryUrl(totalPages - 1))}">Last »</a>`);
1977
+ } else {
1978
+ pager.push(`<span class="kcs-page-disabled">Next ›</span>`);
1979
+ pager.push(`<span class="kcs-page-disabled">Last »</span>`);
1980
+ }
1981
+ pager.push('</nav>');
1982
+ parts.push(pager.join(''));
1983
+ }
1984
+
1576
1985
  const tableHtml = parts.join('');
1577
1986
 
1578
1987
  const html = `