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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,108 @@ 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
+ // Attempts to render the requested file through the user's template engine.
202
+ // Returns true if the request was handled (success, timeout, or error response
203
+ // already written), false if no template applies (caller should continue with
204
+ // normal file serving).
205
+ //
206
+ // The render function is invoked with (ctx, next, filePath, rawBuffer, signal).
207
+ // The signal aborts on timeout (when templateOpts.renderTimeout > 0) and on
208
+ // client disconnect. Cooperative renders that propagate the signal to fetch/db
209
+ // release backend resources promptly; non-cooperative renders still get a 504
210
+ // response, but their work continues in the background.
211
+ async function tryRenderTemplate(ctx, next, filePath, rawBuffer, templateOpts, logger) {
212
+ if (templateOpts.ext.length === 0 || !templateOpts.render) return false;
213
+
214
+ const fileExt = path.extname(filePath).slice(1);
215
+ if (!fileExt || !templateOpts.ext.includes(fileExt)) return false;
216
+
217
+ const controller = new AbortController();
218
+ const onClientClose = () => controller.abort();
219
+ ctx.req.on('close', onClientClose);
220
+
221
+ const timeoutMs = templateOpts.renderTimeout;
222
+ let timer = null;
223
+ let timedOut = false;
224
+
225
+ const renderPromise = Promise.resolve().then(() =>
226
+ templateOpts.render(ctx, next, filePath, rawBuffer, controller.signal)
227
+ );
228
+ renderPromise.catch(() => {}); // swallow rejections that arrive after we've already responded
229
+
230
+ try {
231
+ if (timeoutMs > 0) {
232
+ await Promise.race([
233
+ renderPromise,
234
+ new Promise((_, reject) => {
235
+ timer = setTimeout(() => {
236
+ timedOut = true;
237
+ controller.abort();
238
+ const err = new Error('Template render timeout');
239
+ err.code = 'ETEMPLATETIMEOUT';
240
+ reject(err);
241
+ }, timeoutMs);
242
+ })
243
+ ]);
244
+ } else {
245
+ await renderPromise;
246
+ }
247
+ } catch (error) {
248
+ if (timedOut || error.code === 'ETEMPLATETIMEOUT') {
249
+ sendTemplateError(ctx, 504, _GATEWAY_TIMEOUT_HTML,
250
+ 'Template render timeout after ' + timeoutMs + 'ms:', filePath, logger);
251
+ } else {
252
+ sendTemplateError(ctx, 500, _TEMPLATE_ERROR_HTML,
253
+ 'Template rendering error:', error, logger);
254
+ }
255
+ } finally {
256
+ if (timer) clearTimeout(timer);
257
+ ctx.req.removeListener('close', onClientClose);
258
+ }
259
+
260
+ return true;
261
+ }
262
+
118
263
  // Single-pass HTML escaping — one regex scan, one allocation, lookup table compiled once.
119
264
  const _HTML_ESCAPE_MAP = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' };
120
265
  const _HTML_ESCAPE_RE = /[&<>"']/g;
@@ -204,12 +349,13 @@ function parseRangeHeader(rangeHeader, fileSize) {
204
349
  // set(key, entry) — insert, evicting LFU entries if needed
205
350
  // delete(key) — remove explicitly (e.g. stale entry before re-insert)
206
351
  class LFUCache {
207
- constructor(maxSize, warnInterval, cacheLabel) {
352
+ constructor(maxSize, warnInterval, cacheLabel, logger) {
208
353
  this.maxSize = maxSize;
209
354
  this.warnInterval = warnInterval;
210
355
  this.cacheLabel = cacheLabel;
356
+ this.logger = logger || console;
211
357
  this.currentSize = 0;
212
- this._keyMap = new Map(); // key → { buffer, mtime, size, freq }
358
+ this._keyMap = new Map(); // key → { buffer, mtime, size, insertedAt, freq }
213
359
  this._freqMap = new Map(); // freq → Set<key>
214
360
  this._minFreq = 0;
215
361
  this._lastWarnAt = 0;
@@ -241,6 +387,26 @@ class LFUCache {
241
387
  this._minFreq = 1;
242
388
  }
243
389
 
390
+ // In-place update of an existing entry that preserves its current frequency.
391
+ // Used when refreshing a stale-by-maxAge entry so popular files don't fall to
392
+ // the bottom of the LFU bucket just because they got re-read from disk.
393
+ // Returns true on success, false if the new buffer doesn't fit in maxSize
394
+ // (caller can fall back to delete + set in that case).
395
+ refresh(key, fields) {
396
+ const entry = this._keyMap.get(key);
397
+ if (!entry) return false;
398
+
399
+ const sizeDelta = fields.buffer.length - entry.buffer.length;
400
+ if (this.currentSize + sizeDelta > this.maxSize) return false;
401
+
402
+ entry.buffer = fields.buffer;
403
+ if (fields.mtime !== undefined) entry.mtime = fields.mtime;
404
+ if (fields.size !== undefined) entry.size = fields.size;
405
+ if (fields.insertedAt !== undefined) entry.insertedAt = fields.insertedAt;
406
+ this.currentSize += sizeDelta;
407
+ return true;
408
+ }
409
+
244
410
  delete(key) {
245
411
  if (!this._keyMap.has(key)) return;
246
412
  const { freq, buffer } = this._keyMap.get(key);
@@ -272,7 +438,6 @@ class LFUCache {
272
438
  if (!this._freqMap.has(freq)) this._freqMap.set(freq, new Set());
273
439
  this._freqMap.get(freq).add(key);
274
440
  }
275
-
276
441
  _evictOne() {
277
442
  // Recover from stale _minFreq (can happen after consecutive evictions)
278
443
  while (this._freqMap.size > 0 && (!this._freqMap.has(this._minFreq) || this._freqMap.get(this._minFreq).size === 0)) {
@@ -293,13 +458,28 @@ class LFUCache {
293
458
  if (this.warnInterval !== false) {
294
459
  const now = Date.now();
295
460
  if (now - this._lastWarnAt >= this.warnInterval) {
296
- console.warn(`[koa-classic-server] serverCache.${this.cacheLabel}: maxSize reached, evicting LFU entries. Consider increasing maxSize.`);
461
+ this.logger.warn(`[koa-classic-server] serverCache.${this.cacheLabel}: maxSize reached, evicting LFU entries. Consider increasing maxSize.`);
297
462
  this._lastWarnAt = now;
298
463
  }
299
464
  }
300
465
  }
301
466
  }
302
467
 
468
+ // Upserts a fresh entry into an LFUCache. When the previous entry was only
469
+ // stale-by-age (mtime + size unchanged), updates in place so the existing
470
+ // frequency counter survives — important for popular files refreshed by maxAge.
471
+ // Otherwise falls back to delete + set (frequency resets to 1).
472
+ function refreshOrInsert(cache, key, newEntry, cached, staleByAge) {
473
+ const canRefreshInPlace = cached
474
+ && staleByAge
475
+ && cached.mtime === newEntry.mtime
476
+ && cached.size === newEntry.size;
477
+ if (!canRefreshInPlace || !cache.refresh(key, newEntry)) {
478
+ if (cached) cache.delete(key);
479
+ cache.set(key, newEntry);
480
+ }
481
+ }
482
+
303
483
  module.exports = function koaClassicServer(
304
484
  rootDir,
305
485
  opts = {}
@@ -307,7 +487,26 @@ module.exports = function koaClassicServer(
307
487
  opts STRUCTURE
308
488
  opts = {
309
489
  method: ['GET'], // Supported methods, otherwise next() will be called
310
- showDirContents: true, // Show or hide directory contents
490
+ dirListing: { // Directory listing configuration (V3+).
491
+ enabled: true, // Render the directory listing HTML when no index file matches.
492
+ // Set to false to return 404 instead of a listing.
493
+ maxEntries: 100000, // Soft cap on entries shown / sorted / stat'd per listing.
494
+ // Implementation: fs.promises.readdir() then slice(0, maxEntries).
495
+ // This is a SAFETY NET against catastrophic operational accidents
496
+ // (broken log rotation, mistakenly mounted huge FS) — not a policy
497
+ // restriction on what the operator can serve. 99% of legitimate
498
+ // deployments never hit this cap. Excess entries are not shown:
499
+ // a banner + the X-Dir-Truncated response header advertise the
500
+ // truncation. Bounds the rendering / CPU cost, NOT the size of the
501
+ // initial readdir() allocation.
502
+ // Must be a finite integer >= 0; 0 = disabled (no cap).
503
+ // For directories writable by untrusted parties, see the v3.1
504
+ // TODO [F-1] in docs/security_improvement_for_V3.md (`readMode`).
505
+ entriesPerPage: 100, // Entries per page in the listing UI. Pagination kicks in only
506
+ // when visible entries > entriesPerPage. Page index via
507
+ // ?page=N (0-based); out-of-range values are clamped silently.
508
+ // Must be a finite integer >= 0; 0 = disabled (no pagination).
509
+ },
311
510
  index: ["index.html"], // Index file name(s) - must be an ARRAY:
312
511
  // - Array of strings: ["index.html", "index.htm", "default.html"]
313
512
  // - Array of RegExp: [/index\.html/i, /default\.(html|htm)/i]
@@ -316,8 +515,13 @@ module.exports = function koaClassicServer(
316
515
  urlPrefix: "", // URL path prefix
317
516
  urlsReserved: [], // Reserved paths (first level only)
318
517
  template: {
319
- render: undefined, // Template rendering function: async (ctx, next, filePath) => {}
518
+ render: undefined, // Template rendering function: async (ctx, next, filePath, rawBuffer, signal) => {}
320
519
  ext: [], // File extensions to process with template.render
520
+ renderTimeout: 30000, // Max ms allowed for template.render (number ≥ 0; 0 = disabled).
521
+ // On timeout responds 504 Gateway Timeout. The render receives an
522
+ // AbortSignal as 5th argument; propagate it to fetch/db/fs to free
523
+ // backend resources. The signal also aborts on client disconnect,
524
+ // even when renderTimeout is 0.
321
525
  },
322
526
  browserCacheMaxAge: 3600, // Browser Cache-Control max-age in seconds (default: 1 hour)
323
527
  browserCacheEnabled: false, // Enable browser HTTP caching headers (ETag, Last-Modified)
@@ -331,8 +535,10 @@ module.exports = function koaClassicServer(
331
535
  redirect: 301 // HTTP redirect code for URLs with extension (optional, default: 301)
332
536
  },
333
537
  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'
538
+ dotFiles: { // Dot-files (names starting with '.'): visible by default — design philosophy
539
+ default: 'visible', // 'hidden' | 'visible' — system default: 'visible'
540
+ // To protect .env / .git / etc., set 'hidden' explicitly OR add to
541
+ // `blacklist` / `alwaysHide`. See README "Security Checklist".
336
542
  whitelist: [], // Always visible (string exact/glob or RegExp). Overrides default and alwaysHide.
337
543
  blacklist: [], // Always hidden (string or RegExp). Overrides whitelist.
338
544
  },
@@ -350,21 +556,31 @@ module.exports = function koaClassicServer(
350
556
  enabled: false, // enable in-memory cache of raw file buffers
351
557
  maxSize: 52428800, // max total RAM used by this cache (bytes; default: 50 MB)
352
558
  maxFileSize: 1048576, // files larger than this are never cached (bytes; default: 1 MB)
559
+ maxAge: 0, // ms after insertion to consider an entry stale; 0 = disabled.
560
+ // Useful on NFS/SMB/overlay FS where mtime+size may not reflect
561
+ // remote changes within the OS attribute-cache window. Limits but
562
+ // does not eliminate staleness — combine with low actimeo on the
563
+ // mount for stricter freshness.
353
564
  warnInterval: 60000, // ms between "maxSize reached" warnings; 0 = always; false = never
354
565
  },
355
566
  compressedFile: { // cache for HTTP br/gzip responses — not for .zip/.tar files on disk
356
567
  enabled: true, // enable in-memory cache of compressed response buffers
357
568
  maxSize: 104857600, // max total RAM used by this cache (bytes; default: 100 MB)
569
+ maxAge: 0, // ms after insertion to consider an entry stale; 0 = disabled. See rawFile.maxAge.
358
570
  warnInterval: 60000, // ms between "maxSize reached" warnings; 0 = always; false = never
359
571
  },
360
572
  },
361
573
  compression: { // Response compression (gzip / brotli) — to enable/disable caching → serverCache.compressedFile
362
574
  enabled: true, // master switch (false = disable all compression)
363
575
  encodings: ['br', 'gzip'], // algorithms in priority order; [] = disable
364
- minSize: 1024, // min file size in bytes to compress; false = no minimum
576
+ minFileSize: 1024, // min file size in bytes to compress; false = no minimum
365
577
  mimeTypes: [], // compressible MIME types (replaces default list if provided)
366
578
  },
367
579
  // compression: false // shorthand to disable all compression
580
+ logger: console, // Logger used for internal errors and warnings.
581
+ // Must expose error(...) and warn(...). Pass pino/winston/bunyan
582
+ // or any compatible object to integrate with aggregated logging.
583
+ // Default: the global console.
368
584
 
369
585
  }
370
586
  */
@@ -381,8 +597,80 @@ module.exports = function koaClassicServer(
381
597
  const options = opts || {};
382
598
  options.template = opts.template || {};
383
599
 
600
+ const _logger = normalizeLogger(options.logger);
601
+
384
602
  options.method = Array.isArray(options.method) ? options.method : ['GET'];
385
- options.showDirContents = typeof options.showDirContents === 'boolean' ? options.showDirContents : true;
603
+
604
+ // ── V3 breaking-change guards: helpful errors for V3-alpha-only renamed options ──
605
+ // These were introduced in v3.0.0-alpha.0 only; no v2 user can have them in production.
606
+ if (opts.maxDirEntries !== undefined) {
607
+ throw new Error(
608
+ '[koa-classic-server] options.maxDirEntries was relocated in v3.0.0.\n' +
609
+ ` Replace with: dirListing: { maxEntries: ${opts.maxDirEntries} }`
610
+ );
611
+ }
612
+ if (opts.pageSize !== undefined) {
613
+ throw new Error(
614
+ '[koa-classic-server] options.pageSize was relocated and renamed in v3.0.0.\n' +
615
+ ` Replace with: dirListing: { entriesPerPage: ${opts.pageSize} }`
616
+ );
617
+ }
618
+
619
+ function validateNonNegativeInt(value, optionName, defaultValue) {
620
+ if (value === undefined) return defaultValue;
621
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || !Number.isInteger(value)) {
622
+ throw new Error(
623
+ `[koa-classic-server] options.${optionName} must be a non-negative integer. ` +
624
+ 'Use 0 to disable. Got: ' + String(value)
625
+ );
626
+ }
627
+ return value;
628
+ }
629
+
630
+ // ── dirListing namespace (V3+) — single source of truth for listing config ──
631
+ const userDirListing = opts.dirListing;
632
+ if (userDirListing !== undefined && (typeof userDirListing !== 'object' || userDirListing === null || Array.isArray(userDirListing))) {
633
+ throw new Error('[koa-classic-server] options.dirListing must be an object.');
634
+ }
635
+
636
+ // V3 backward-compat alias: showDirContents (v2-stable) maps to dirListing.enabled.
637
+ // The alias may be removed in a future major version. Emits a one-time deprecation
638
+ // warning per process. Throws if both names are passed (the user picked one of them
639
+ // by mistake — surface the conflict rather than silently choosing one).
640
+ let aliasEnabled; // undefined unless showDirContents was passed
641
+ if (opts.showDirContents !== undefined) {
642
+ if (userDirListing && userDirListing.enabled !== undefined) {
643
+ throw new Error(
644
+ '[koa-classic-server] options.showDirContents and options.dirListing.enabled are both set.\n' +
645
+ ' These configure the same thing — pick one.'
646
+ );
647
+ }
648
+ if (!_showDirContentsDeprecationWarned) {
649
+ _showDirContentsDeprecationWarned = true;
650
+ _logger.warn(...warnPayload(_logger,
651
+ '[koa-classic-server] DEPRECATION: options.showDirContents was renamed to dirListing.enabled in v3.0.0.\n' +
652
+ ' The old name is currently accepted as an alias and may be removed in a future major version.\n' +
653
+ ` Replace with: dirListing: { enabled: ${opts.showDirContents} }`
654
+ ));
655
+ }
656
+ aliasEnabled = !!opts.showDirContents;
657
+ }
658
+
659
+ options.dirListing = {
660
+ enabled: userDirListing && userDirListing.enabled !== undefined
661
+ ? !!userDirListing.enabled
662
+ : (aliasEnabled !== undefined ? aliasEnabled : true),
663
+ maxEntries: validateNonNegativeInt(
664
+ userDirListing && userDirListing.maxEntries,
665
+ 'dirListing.maxEntries',
666
+ 10000
667
+ ),
668
+ entriesPerPage: validateNonNegativeInt(
669
+ userDirListing && userDirListing.entriesPerPage,
670
+ 'dirListing.entriesPerPage',
671
+ 100
672
+ ),
673
+ };
386
674
 
387
675
  // Normalize index option to array format
388
676
  if (typeof options.index === 'string') {
@@ -411,6 +699,19 @@ module.exports = function koaClassicServer(
411
699
  options.template.render = (options.template.render === undefined || typeof options.template.render === 'function') ? options.template.render : undefined;
412
700
  options.template.ext = Array.isArray(options.template.ext) ? options.template.ext : [];
413
701
 
702
+ if (options.template.renderTimeout === undefined) {
703
+ options.template.renderTimeout = 30000;
704
+ } else if (
705
+ typeof options.template.renderTimeout !== 'number' ||
706
+ !Number.isFinite(options.template.renderTimeout) ||
707
+ options.template.renderTimeout < 0
708
+ ) {
709
+ throw new Error(
710
+ '[koa-classic-server] template.renderTimeout must be a finite number >= 0 (ms). ' +
711
+ 'Use 0 to disable. Got: ' + String(options.template.renderTimeout)
712
+ );
713
+ }
714
+
414
715
  // v3.0.0: removed legacy option names — throw to surface the breaking change clearly
415
716
  if ('cacheMaxAge' in opts) {
416
717
  throw new Error(
@@ -439,13 +740,12 @@ module.exports = function koaClassicServer(
439
740
  }
440
741
  // Normalize ext: add leading dot if missing
441
742
  if (!options.hideExtension.ext.startsWith('.')) {
442
- console.warn(
443
- '\x1b[33m%s\x1b[0m',
743
+ _logger.warn(...warnPayload(_logger,
444
744
  '[koa-classic-server] WARNING: hideExtension.ext should start with a dot.\n' +
445
745
  ` Current usage: ext: "${options.hideExtension.ext}"\n` +
446
746
  ` Corrected to: ext: ".${options.hideExtension.ext}"\n` +
447
747
  ' Please update your configuration.'
448
- );
748
+ ));
449
749
  options.hideExtension.ext = '.' + options.hideExtension.ext;
450
750
  }
451
751
  // Validate redirect code
@@ -462,7 +762,7 @@ module.exports = function koaClassicServer(
462
762
  function normalizeHiddenConfig(hidden) {
463
763
  if (!hidden || typeof hidden !== 'object' || Array.isArray(hidden)) {
464
764
  return {
465
- dotFiles: { default: 'hidden', whitelist: [], blacklist: [] },
765
+ dotFiles: { default: 'visible', whitelist: [], blacklist: [] },
466
766
  dotDirs: { default: 'visible', whitelist: [], blacklist: [] },
467
767
  alwaysHide: []
468
768
  };
@@ -490,7 +790,7 @@ module.exports = function koaClassicServer(
490
790
  }
491
791
 
492
792
  return {
493
- dotFiles: normalizeCategory(hidden.dotFiles, 'hidden', 'dotFiles'),
793
+ dotFiles: normalizeCategory(hidden.dotFiles, 'visible', 'dotFiles'),
494
794
  dotDirs: normalizeCategory(hidden.dotDirs, 'visible', 'dotDirs'),
495
795
  alwaysHide: filterPatternList(hidden.alwaysHide),
496
796
  };
@@ -498,66 +798,53 @@ module.exports = function koaClassicServer(
498
798
 
499
799
  const hiddenConfig = normalizeHiddenConfig(options.hidden);
500
800
 
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) {
801
+ // Returns true if `value` matches any pattern in the list.
802
+ // RegExp patterns are tested directly; string patterns go through `globMatch`.
803
+ // Non-string non-RegExp entries are ignored (defensive config validation should reject them).
804
+ function matchesPatternList(value, patterns, globMatch) {
525
805
  for (const pattern of patterns) {
526
806
  if (pattern instanceof RegExp) {
527
- if (pattern.test(name)) return true;
807
+ if (pattern.test(value)) return true;
528
808
  } else if (typeof pattern === 'string') {
529
- if (nameGlobMatch(name, pattern)) return true;
809
+ if (globMatch(value, pattern)) return true;
530
810
  }
531
811
  }
532
812
  return false;
533
813
  }
534
814
 
815
+ // Match against a list using filename-glob semantics (case-sensitive, no path component).
816
+ function matchesNameList(name, patterns) {
817
+ return matchesPatternList(name, patterns, nameGlobMatch);
818
+ }
819
+
820
+ // Compiled-RegExp caches for glob patterns. Patterns come from `hidden.*` config and are
821
+ // immutable after factory init, so memoization is bounded by the operator's config size
822
+ // and avoids recompiling the same regex on every directory entry during a listing.
823
+ const _nameGlobRegexCache = new Map();
824
+ const _pathGlobRegexCache = new Map();
825
+
535
826
  // Matches a bare filename against a simple glob pattern (* = any chars except /, ? = one char).
536
827
  function nameGlobMatch(name, pattern) {
537
828
  if (!pattern.includes('*') && !pattern.includes('?')) {
538
829
  return name === pattern;
539
830
  }
540
- const regexStr = '^' +
541
- pattern
542
- .replace(/[.+^${}()|[\]\\]/g, '\\$&')
543
- .replace(/\*/g, '[^/]*')
544
- .replace(/\?/g, '[^/]')
545
- + '$';
546
- return new RegExp(regexStr).test(name);
831
+ let re = _nameGlobRegexCache.get(pattern);
832
+ if (re === undefined) {
833
+ const regexStr = '^' +
834
+ pattern
835
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
836
+ .replace(/\*/g, '[^/]*')
837
+ .replace(/\?/g, '[^/]')
838
+ + '$';
839
+ re = new RegExp(regexStr);
840
+ _nameGlobRegexCache.set(pattern, re);
841
+ }
842
+ return re.test(name);
547
843
  }
548
844
 
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.
845
+ // Match against a list using path-aware glob semantics (anchored to rootDir, supports **).
552
846
  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;
847
+ return matchesPatternList(relPath, patterns, pathGlobMatch);
561
848
  }
562
849
 
563
850
  /**
@@ -569,19 +856,24 @@ module.exports = function koaClassicServer(
569
856
  * - '?' matches any single character except '/'
570
857
  */
571
858
  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);
859
+ let re = _pathGlobRegexCache.get(pattern);
860
+ if (re === undefined) {
861
+ const hasSlash = pattern.includes('/');
862
+ const escaped = pattern
863
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
864
+ .replace(/\*\*/g, '\x00')
865
+ .replace(/\*/g, '[^/]*')
866
+ .replace(/\?/g, '[^/]')
867
+ .replace(/\x00/g, '.*');
868
+
869
+ const regexStr = hasSlash
870
+ ? '^' + escaped + '($|/)' // path-anchored from root
871
+ : '(^|/)' + escaped + '$'; // basename match at any depth
872
+
873
+ re = new RegExp(regexStr);
874
+ _pathGlobRegexCache.set(pattern, re);
875
+ }
876
+ return re.test(relPath);
585
877
  }
586
878
 
587
879
  /**
@@ -657,11 +949,19 @@ module.exports = function koaClassicServer(
657
949
  return {
658
950
  enabled: true,
659
951
  encodings: ['br', 'gzip'], // priority order: brotli first, gzip as fallback
660
- minSize: 1024, // bytes; skip compression for files smaller than this
952
+ minFileSize: 1024, // bytes; skip compression for files smaller than this
661
953
  mimeTypes: new Set(DEFAULT_COMPRESSIBLE_MIME_TYPES),
662
954
  };
663
955
  }
664
956
 
957
+ // V3 breaking-change guard: catch the v2-alpha name minSize with a helpful migration hint.
958
+ if (compression.minSize !== undefined) {
959
+ throw new Error(
960
+ '[koa-classic-server] options.compression.minSize was renamed in v3.0.0.\n' +
961
+ ` Replace with: compression: { minFileSize: ${compression.minSize} }`
962
+ );
963
+ }
964
+
665
965
  const enabled = typeof compression.enabled === 'boolean' ? compression.enabled : true;
666
966
  if (!enabled) return { enabled: false };
667
967
 
@@ -669,14 +969,14 @@ module.exports = function koaClassicServer(
669
969
  ? compression.encodings.filter(e => e === 'br' || e === 'gzip')
670
970
  : ['br', 'gzip'];
671
971
 
672
- const minSize = compression.minSize === false ? false
673
- : (typeof compression.minSize === 'number' && compression.minSize >= 0 ? compression.minSize : 1024);
972
+ const minFileSize = compression.minFileSize === false ? false
973
+ : (typeof compression.minFileSize === 'number' && compression.minFileSize >= 0 ? compression.minFileSize : 1024);
674
974
 
675
975
  const mimeTypes = Array.isArray(compression.mimeTypes) && compression.mimeTypes.length > 0
676
976
  ? compression.mimeTypes
677
977
  : DEFAULT_COMPRESSIBLE_MIME_TYPES;
678
978
 
679
- return { enabled, encodings, minSize, mimeTypes: new Set(mimeTypes) };
979
+ return { enabled, encodings, minFileSize, mimeTypes: new Set(mimeTypes) };
680
980
  }
681
981
 
682
982
  // Normalize and validate the serverCache option into a clean internal structure.
@@ -685,14 +985,27 @@ module.exports = function koaClassicServer(
685
985
  enabled: false,
686
986
  maxSize: 52428800, // 50 MB
687
987
  maxFileSize: 1048576, // 1 MB
988
+ maxAge: 0,
688
989
  warnInterval: 60000,
689
990
  };
690
991
  const defaultCompressedFile = {
691
992
  enabled: true,
692
993
  maxSize: 104857600, // 100 MB
994
+ maxAge: 0,
693
995
  warnInterval: 60000,
694
996
  };
695
997
 
998
+ function validateMaxAge(value, cacheName) {
999
+ if (value === undefined) return 0;
1000
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
1001
+ throw new Error(
1002
+ `[koa-classic-server] serverCache.${cacheName}.maxAge must be a finite number >= 0 (ms). ` +
1003
+ 'Use 0 to disable. Got: ' + String(value)
1004
+ );
1005
+ }
1006
+ return value;
1007
+ }
1008
+
696
1009
  if (!serverCache || typeof serverCache !== 'object' || Array.isArray(serverCache)) {
697
1010
  return { rawFile: defaultRawFile, compressedFile: defaultCompressedFile };
698
1011
  }
@@ -702,6 +1015,7 @@ module.exports = function koaClassicServer(
702
1015
  enabled: typeof rf.enabled === 'boolean' ? rf.enabled : false,
703
1016
  maxSize: typeof rf.maxSize === 'number' && rf.maxSize > 0 ? rf.maxSize : 52428800,
704
1017
  maxFileSize: typeof rf.maxFileSize === 'number' && rf.maxFileSize > 0 ? rf.maxFileSize : 1048576,
1018
+ maxAge: validateMaxAge(rf.maxAge, 'rawFile'),
705
1019
  warnInterval: rf.warnInterval === false ? false : (typeof rf.warnInterval === 'number' ? rf.warnInterval : 60000),
706
1020
  };
707
1021
 
@@ -709,6 +1023,7 @@ module.exports = function koaClassicServer(
709
1023
  const compressedFile = (!cf || typeof cf !== 'object' || Array.isArray(cf)) ? defaultCompressedFile : {
710
1024
  enabled: typeof cf.enabled === 'boolean' ? cf.enabled : true,
711
1025
  maxSize: typeof cf.maxSize === 'number' && cf.maxSize > 0 ? cf.maxSize : 104857600,
1026
+ maxAge: validateMaxAge(cf.maxAge, 'compressedFile'),
712
1027
  warnInterval: cf.warnInterval === false ? false : (typeof cf.warnInterval === 'number' ? cf.warnInterval : 60000),
713
1028
  };
714
1029
 
@@ -723,7 +1038,8 @@ module.exports = function koaClassicServer(
723
1038
  const _rawFileCache = new LFUCache(
724
1039
  serverCacheConfig.rawFile.maxSize,
725
1040
  serverCacheConfig.rawFile.warnInterval,
726
- 'rawFile'
1041
+ 'rawFile',
1042
+ _logger
727
1043
  );
728
1044
 
729
1045
  // In-memory LFU cache for compressed file buffers (serverCache.compressedFile).
@@ -731,7 +1047,8 @@ module.exports = function koaClassicServer(
731
1047
  const _compressedFileCache = new LFUCache(
732
1048
  serverCacheConfig.compressedFile.maxSize,
733
1049
  serverCacheConfig.compressedFile.warnInterval,
734
- 'compressedFile'
1050
+ 'compressedFile',
1051
+ _logger
735
1052
  );
736
1053
 
737
1054
  // Returns the client's preferred encoding based on Accept-Encoding header,
@@ -745,23 +1062,14 @@ module.exports = function koaClassicServer(
745
1062
  }
746
1063
 
747
1064
  // Compress a Buffer using the given encoding ('br' or 'gzip').
748
- // Uses maximum quality appropriate for serverCache mode (cost paid once).
1065
+ // Quality is maxed out: serverCache pays this cost once per file, not per request.
749
1066
  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
- });
1067
+ if (encoding === 'br') {
1068
+ return _brotliCompressAsync(data, {
1069
+ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 }
1070
+ });
1071
+ }
1072
+ return _gzipAsync(data, { level: zlib.constants.Z_BEST_COMPRESSION });
765
1073
  }
766
1074
 
767
1075
  /**
@@ -816,7 +1124,7 @@ module.exports = function koaClassicServer(
816
1124
  );
817
1125
  fileNames = checkResults.filter(e => e.isFile).map(e => e.name);
818
1126
  } catch (error) {
819
- console.error('Error finding index file:', error);
1127
+ _logger.error('Error finding index file:', error);
820
1128
  return null;
821
1129
  }
822
1130
  }
@@ -909,7 +1217,9 @@ module.exports = function koaClassicServer(
909
1217
  return;
910
1218
  }
911
1219
 
912
- // Hidden check: block requests that traverse a hidden directory
1220
+ // Hidden check: block requests that traverse a hidden directory.
1221
+ // Stops at length-1 because the leaf (the file or dir being served) is
1222
+ // checked separately by the file/listing path with its real stat.isDirectory().
913
1223
  if (requestedPath !== '') {
914
1224
  const segments = normalizedPath.split(path.sep).filter(Boolean);
915
1225
  for (let i = 0; i < segments.length - 1; i++) {
@@ -933,14 +1243,23 @@ module.exports = function koaClassicServer(
933
1243
  const rawPath = urlToUse.split('?')[0];
934
1244
  const hadTrailingSlash = rawPath.length > 1 && rawPath.endsWith('/');
935
1245
 
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
1246
+ // Strip a trailing slash before the extension check so URLs like /foo.html/
1247
+ // still match (the slash is URL formality, not part of the filename) without
1248
+ // this, /foo.html/ would skip the redirect and 404 trying to open it as a dir.
938
1249
  const pathForExtCheck = hadTrailingSlash ? rawPath.slice(0, -1) : requestedPath;
939
1250
  if (pathForExtCheck.endsWith(hideExt)) {
940
1251
  // Build redirect target using ctx.originalUrl (always, regardless of useOriginalUrl)
941
1252
  const originalUrlObj = new URL(_origin + ctx.originalUrl);
942
1253
  let redirectPath = originalUrlObj.pathname;
943
1254
 
1255
+ // Collapse leading slashes: a Location header starting with "//" (or "/\")
1256
+ // is a protocol-relative URL and would let "GET //evil.com/foo.ejs" redirect
1257
+ // off-origin. path.normalize() upstream already collapses these for the
1258
+ // filesystem check, so the source-of-truth URL has a single leading slash.
1259
+ if (redirectPath.length > 1 && (redirectPath.charCodeAt(1) === 0x2F || redirectPath.charCodeAt(1) === 0x5C)) {
1260
+ redirectPath = '/' + redirectPath.replace(/^[/\\]+/, '');
1261
+ }
1262
+
944
1263
  redirectPath = redirectPath.slice(0, redirectPath.length - hideExt.length);
945
1264
 
946
1265
  // Special case: /index.ejs → /, /sezione/index.ejs → /sezione/
@@ -1007,7 +1326,7 @@ module.exports = function koaClassicServer(
1007
1326
 
1008
1327
  if (stat.isDirectory()) {
1009
1328
  // Handle directory
1010
- if (options.showDirContents) {
1329
+ if (options.dirListing.enabled) {
1011
1330
  // Search for index file matching configured patterns
1012
1331
  if (options.index && options.index.length > 0) {
1013
1332
  const indexFile = await findIndexFile(toOpen, options.index);
@@ -1042,7 +1361,7 @@ module.exports = function koaClassicServer(
1042
1361
  try {
1043
1362
  fileStat = await fs.promises.stat(toOpen);
1044
1363
  } catch (error) {
1045
- console.error('File stat error:', error);
1364
+ _logger.error('File stat error:', error);
1046
1365
  sendNotFound(ctx);
1047
1366
  return;
1048
1367
  }
@@ -1053,54 +1372,32 @@ module.exports = function koaClassicServer(
1053
1372
  let rawBuffer = null;
1054
1373
  if (serverCacheConfig.rawFile.enabled && fileStat.size <= serverCacheConfig.rawFile.maxFileSize) {
1055
1374
  const cached = _rawFileCache.peek(toOpen);
1056
- if (cached && cached.mtime === fileStat.mtime.getTime() && cached.size === fileStat.size) {
1375
+ const maxAge = serverCacheConfig.rawFile.maxAge;
1376
+ const staleByAge = maxAge > 0 && cached && (Date.now() - cached.insertedAt) >= maxAge;
1377
+ const fresh = cached
1378
+ && cached.mtime === fileStat.mtime.getTime()
1379
+ && cached.size === fileStat.size
1380
+ && !staleByAge;
1381
+ if (fresh) {
1057
1382
  _rawFileCache.get(toOpen); // increment frequency
1058
1383
  rawBuffer = cached.buffer;
1059
1384
  } else {
1060
1385
  try {
1061
1386
  rawBuffer = await fs.promises.readFile(toOpen);
1062
- if (cached) _rawFileCache.delete(toOpen); // remove stale entry before re-inserting
1063
- _rawFileCache.set(toOpen, {
1387
+ refreshOrInsert(_rawFileCache, toOpen, {
1064
1388
  buffer: rawBuffer,
1065
1389
  mtime: fileStat.mtime.getTime(),
1066
1390
  size: fileStat.size,
1067
- });
1391
+ insertedAt: Date.now(),
1392
+ }, cached, staleByAge);
1068
1393
  } catch {
1069
1394
  rawBuffer = null; // Fall through to disk reads later
1070
1395
  }
1071
1396
  }
1072
1397
  }
1073
1398
 
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
- }
1399
+ if (await tryRenderTemplate(ctx, next, toOpen, rawBuffer, options.template, _logger)) {
1400
+ return;
1104
1401
  }
1105
1402
 
1106
1403
  // baseEtag — encoding-independent; used only for If-Range (Range requests skip compression)
@@ -1125,7 +1422,7 @@ module.exports = function koaClassicServer(
1125
1422
  try {
1126
1423
  await fs.promises.access(toOpen, fs.constants.R_OK);
1127
1424
  } catch (error) {
1128
- console.error('File access error:', error);
1425
+ _logger.error('File access error:', error);
1129
1426
  sendNotFound(ctx);
1130
1427
  return;
1131
1428
  }
@@ -1166,7 +1463,7 @@ module.exports = function koaClassicServer(
1166
1463
  } else {
1167
1464
  const src = fs.createReadStream(toOpen, { start, end });
1168
1465
  src.on('error', (err) => {
1169
- console.error('Stream error:', err);
1466
+ _logger.error('Stream error:', err);
1170
1467
  if (!ctx.headerSent) {
1171
1468
  ctx.status = 500;
1172
1469
  ctx.body = 'Error reading file';
@@ -1191,12 +1488,12 @@ module.exports = function koaClassicServer(
1191
1488
  const mimeType = mime.lookup(toOpen) || 'application/octet-stream';
1192
1489
  const filename = path.basename(toOpen);
1193
1490
 
1194
- // Resolve compression: enabled + compressible MIME + meets minSize + client supports it
1491
+ // Resolve compression: enabled + compressible MIME + meets minFileSize + client supports it
1195
1492
  let encoding = null; // 'br' | 'gzip' | null
1196
1493
  if (compressionConfig.enabled && compressionConfig.encodings.length > 0) {
1197
1494
  const isCompressibleMime = compressionConfig.mimeTypes.has(mimeType);
1198
- const meetsMinSize = compressionConfig.minSize === false
1199
- || fileStat.size >= compressionConfig.minSize;
1495
+ const meetsMinSize = compressionConfig.minFileSize === false
1496
+ || fileStat.size >= compressionConfig.minFileSize;
1200
1497
  if (isCompressibleMime && meetsMinSize) {
1201
1498
  encoding = getClientEncoding(ctx.get('Accept-Encoding'));
1202
1499
  }
@@ -1243,9 +1540,12 @@ module.exports = function koaClassicServer(
1243
1540
  // compressedFile cache mode: compress once → buffer in RAM → Content-Length known
1244
1541
  const cacheKey = `${toOpen}:${encoding}`;
1245
1542
  const cached = _compressedFileCache.peek(cacheKey);
1543
+ const maxAge = serverCacheConfig.compressedFile.maxAge;
1544
+ const staleByAge = maxAge > 0 && cached && (Date.now() - cached.insertedAt) >= maxAge;
1246
1545
  const stale = !cached
1247
1546
  || cached.mtime !== fileStat.mtime.getTime()
1248
- || cached.size !== fileStat.size;
1547
+ || cached.size !== fileStat.size
1548
+ || staleByAge;
1249
1549
 
1250
1550
  let buf;
1251
1551
  if (!stale) {
@@ -1257,14 +1557,14 @@ module.exports = function koaClassicServer(
1257
1557
  const rawData = rawBuffer || await fs.promises.readFile(toOpen);
1258
1558
  buf = await compressBuffer(rawData, encoding);
1259
1559
 
1260
- if (cached) _compressedFileCache.delete(cacheKey); // remove stale entry before re-inserting
1261
- _compressedFileCache.set(cacheKey, {
1560
+ refreshOrInsert(_compressedFileCache, cacheKey, {
1262
1561
  buffer: buf,
1263
1562
  mtime: fileStat.mtime.getTime(),
1264
1563
  size: fileStat.size,
1265
- });
1564
+ insertedAt: Date.now(),
1565
+ }, cached, staleByAge);
1266
1566
  } catch (err) {
1267
- console.error('Compression error:', err);
1567
+ _logger.error('Compression error:', err);
1268
1568
  // Fall back to uncompressed on any compression failure
1269
1569
  ctx.remove('Content-Encoding');
1270
1570
  ctx.remove('Vary');
@@ -1281,7 +1581,7 @@ module.exports = function koaClassicServer(
1281
1581
  if (ctx.method !== 'HEAD') {
1282
1582
  const src = fs.createReadStream(toOpen);
1283
1583
  src.on('error', (streamErr) => {
1284
- console.error('Stream error:', streamErr);
1584
+ _logger.error('Stream error:', streamErr);
1285
1585
  if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
1286
1586
  });
1287
1587
  ctx.body = src;
@@ -1316,7 +1616,7 @@ module.exports = function koaClassicServer(
1316
1616
  } else {
1317
1617
  const src = fs.createReadStream(toOpen);
1318
1618
  src.on('error', (err) => {
1319
- console.error('Stream error:', err);
1619
+ _logger.error('Stream error:', err);
1320
1620
  if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
1321
1621
  });
1322
1622
  ctx.body = src.pipe(compress);
@@ -1341,7 +1641,7 @@ module.exports = function koaClassicServer(
1341
1641
  if (ctx.method !== 'HEAD') {
1342
1642
  const src = fs.createReadStream(toOpen);
1343
1643
  src.on('error', (err) => {
1344
- console.error('Stream error:', err);
1644
+ _logger.error('Stream error:', err);
1345
1645
  if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
1346
1646
  });
1347
1647
  ctx.body = src;
@@ -1356,11 +1656,23 @@ module.exports = function koaClassicServer(
1356
1656
 
1357
1657
 
1358
1658
  async function show_dir(toOpen, ctx) {
1659
+ // Read the full directory in one syscall, then cap the result.
1660
+ // `dirListing.maxEntries` bounds the visible / sorted / stat'd entries, but
1661
+ // does NOT bound the size of the initial readdir() allocation — see the
1662
+ // adversarial-directory caveat tracked for v3.1 [F-1].
1663
+ const maxDirEntries = options.dirListing.maxEntries; // 0 = disabled (no cap)
1359
1664
  let dir;
1665
+ let truncated = false;
1360
1666
  try {
1361
- dir = await fs.promises.readdir(toOpen, { withFileTypes: true });
1667
+ const all = await fs.promises.readdir(toOpen, { withFileTypes: true });
1668
+ if (maxDirEntries > 0 && all.length > maxDirEntries) {
1669
+ truncated = true;
1670
+ dir = all.slice(0, maxDirEntries);
1671
+ } else {
1672
+ dir = all;
1673
+ }
1362
1674
  } catch (error) {
1363
- console.error('Directory read error:', error);
1675
+ _logger.error('Directory read error:', error);
1364
1676
  ctx.status = 500;
1365
1677
  setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
1366
1678
  return `
@@ -1386,10 +1698,17 @@ module.exports = function koaClassicServer(
1386
1698
  const sortBy = ctx.query.sort || 'name';
1387
1699
  const sortOrder = ctx.query.order || 'asc';
1388
1700
 
1389
- // Build base URL for sorting links (without query params)
1390
1701
  const baseUrl = pageHrefOutPrefix.pathname;
1391
1702
 
1392
- // Helper to create sorting URL
1703
+ // Preserves sort/order while overriding `page`; omits page when 0.
1704
+ function buildQueryUrl(targetPage) {
1705
+ const params = [];
1706
+ if (ctx.query.sort) params.push(`sort=${encodeURIComponent(ctx.query.sort)}`);
1707
+ if (ctx.query.order) params.push(`order=${encodeURIComponent(ctx.query.order)}`);
1708
+ if (targetPage > 0) params.push(`page=${targetPage}`);
1709
+ return params.length ? `${baseUrl}?${params.join('&')}` : baseUrl;
1710
+ }
1711
+
1393
1712
  function getSortUrl(column) {
1394
1713
  let newOrder = 'asc';
1395
1714
  if (sortBy === column && sortOrder === 'asc') {
@@ -1398,7 +1717,6 @@ module.exports = function koaClassicServer(
1398
1717
  return `${baseUrl}?sort=${column}&order=${newOrder}`;
1399
1718
  }
1400
1719
 
1401
- // Helper to get sort indicator
1402
1720
  function getSortIndicator(column) {
1403
1721
  if (sortBy === column) {
1404
1722
  return sortOrder === 'asc' ? ' ↑' : ' ↓';
@@ -1407,6 +1725,12 @@ module.exports = function koaClassicServer(
1407
1725
  }
1408
1726
 
1409
1727
  const parts = [];
1728
+ let totalPages = 1; // populated in the non-empty branch below
1729
+ let currentPage = 0;
1730
+ if (truncated) {
1731
+ 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>`);
1732
+ ctx.set('X-Dir-Truncated', `${maxDirEntries}`);
1733
+ }
1410
1734
  parts.push("<table>");
1411
1735
  parts.push("<thead>");
1412
1736
  parts.push("<tr>");
@@ -1514,37 +1838,45 @@ module.exports = function koaClassicServer(
1514
1838
  }
1515
1839
  const items = rawItems.filter(Boolean);
1516
1840
 
1517
- // Sort items based on query parameters
1841
+ // Places directories before non-directories; falls back to `tieBreaker`
1842
+ // when both items are in the same bucket. `effectiveType === 2` covers plain
1843
+ // dirs and dir-resolved symlinks, matching the rest of the listing logic.
1844
+ const compareDirsFirst = (a, b, tieBreaker) => {
1845
+ const aIsDir = a.effectiveType === 2;
1846
+ const bIsDir = b.effectiveType === 2;
1847
+ if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
1848
+ return tieBreaker(a, b);
1849
+ };
1850
+
1518
1851
  items.sort((a, b) => {
1519
1852
  let comparison = 0;
1520
1853
 
1521
1854
  if (sortBy === 'name') {
1522
1855
  comparison = a.name.localeCompare(b.name);
1523
1856
  } 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
- }
1857
+ comparison = compareDirsFirst(a, b, (x, y) => x.mimeType.localeCompare(y.mimeType));
1532
1858
  } 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
- }
1859
+ comparison = compareDirsFirst(a, b, (x, y) => x.sizeBytes - y.sizeBytes);
1541
1860
  }
1542
1861
 
1543
1862
  return sortOrder === 'desc' ? -comparison : comparison;
1544
1863
  });
1545
1864
 
1865
+ // Pagination — slice the sorted items into the requested page (0-based).
1866
+ const pageSize = options.dirListing.entriesPerPage; // 0 disables pagination
1867
+ totalPages = pageSize > 0 ? Math.max(1, Math.ceil(items.length / pageSize)) : 1;
1868
+ const rawPage = parseInt(ctx.query.page, 10);
1869
+ const requestedPage = Number.isFinite(rawPage) && rawPage >= 0 ? rawPage : 0;
1870
+ currentPage = Math.min(requestedPage, totalPages - 1); // silent clamp
1871
+ const visibleItems = (pageSize > 0 && items.length > pageSize)
1872
+ ? items.slice(currentPage * pageSize, (currentPage + 1) * pageSize)
1873
+ : items;
1874
+ if (totalPages > 1) {
1875
+ ctx.set('X-Dir-Pagination', `${currentPage}/${totalPages - 1}`);
1876
+ }
1877
+
1546
1878
  // Generate HTML for sorted items
1547
- for (const item of items) {
1879
+ for (const item of visibleItems) {
1548
1880
  let rowStart = '';
1549
1881
  if (item.effectiveType === 1) {
1550
1882
  rowStart = `<tr><td> FILE `;
@@ -1573,6 +1905,47 @@ module.exports = function koaClassicServer(
1573
1905
  parts.push("</tbody>");
1574
1906
  parts.push("</table>");
1575
1907
 
1908
+ // Numbered paginator with First/Prev/Next/Last and ellipsis around the current page.
1909
+ // Only emitted when pagination is meaningful (computed above; reuses currentPage/totalPages).
1910
+ if (totalPages > 1) {
1911
+ const pageWindow = 2;
1912
+ const pagesToShow = new Set([0, totalPages - 1]);
1913
+ for (let i = Math.max(0, currentPage - pageWindow); i <= Math.min(totalPages - 1, currentPage + pageWindow); i++) {
1914
+ pagesToShow.add(i);
1915
+ }
1916
+ const sortedPages = [...pagesToShow].sort((a, b) => a - b);
1917
+
1918
+ const pager = ['<nav class="kcs-pagination" aria-label="Pagination">'];
1919
+ if (currentPage > 0) {
1920
+ pager.push(`<a href="${escapeHtml(buildQueryUrl(0))}">« First</a>`);
1921
+ pager.push(`<a href="${escapeHtml(buildQueryUrl(currentPage - 1))}">‹ Prev</a>`);
1922
+ } else {
1923
+ pager.push(`<span class="kcs-page-disabled">« First</span>`);
1924
+ pager.push(`<span class="kcs-page-disabled">‹ Prev</span>`);
1925
+ }
1926
+ let prev = -1;
1927
+ for (const p of sortedPages) {
1928
+ if (prev !== -1 && p - prev > 1) {
1929
+ pager.push(`<span class="kcs-page-ellipsis">…</span>`);
1930
+ }
1931
+ if (p === currentPage) {
1932
+ pager.push(`<span class="kcs-page-current">${p}</span>`);
1933
+ } else {
1934
+ pager.push(`<a href="${escapeHtml(buildQueryUrl(p))}">${p}</a>`);
1935
+ }
1936
+ prev = p;
1937
+ }
1938
+ if (currentPage < totalPages - 1) {
1939
+ pager.push(`<a href="${escapeHtml(buildQueryUrl(currentPage + 1))}">Next ›</a>`);
1940
+ pager.push(`<a href="${escapeHtml(buildQueryUrl(totalPages - 1))}">Last »</a>`);
1941
+ } else {
1942
+ pager.push(`<span class="kcs-page-disabled">Next ›</span>`);
1943
+ pager.push(`<span class="kcs-page-disabled">Last »</span>`);
1944
+ }
1945
+ pager.push('</nav>');
1946
+ parts.push(pager.join(''));
1947
+ }
1948
+
1576
1949
  const tableHtml = parts.join('');
1577
1950
 
1578
1951
  const html = `