koa-classic-server 4.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +25 -0
  2. package/index.cjs +346 -69
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -150,6 +150,25 @@ app.use(koaClassicServer(path.join(__dirname, 'www'), {
150
150
  // .env, .git/config, *.key → 404
151
151
  ```
152
152
 
153
+ ### Custom error pages
154
+
155
+ Branded 404/500/504 pages from self-contained `.html` files (v4.2+). Keep them **outside**
156
+ `rootDir` so they are not themselves reachable via URL; edit them anytime — changes are
157
+ picked up without a restart, and an unreadable file falls back to the built-in page:
158
+
159
+ ```javascript
160
+ app.use(koaClassicServer(path.join(__dirname, 'www'), {
161
+ errorPages: {
162
+ 404: path.join(__dirname, 'errors', '404.html'),
163
+ 500: path.join(__dirname, 'errors', '500.html'),
164
+ },
165
+ }));
166
+ ```
167
+
168
+ One rule: each page must be a single self-contained file (inline CSS, no external
169
+ css/js/img references). Custom pages are served without the built-in pages'
170
+ `Content-Security-Policy`, so an external reference would really load.
171
+
153
172
  ### Mount under a prefix, pass through some routes
154
173
 
155
174
  ```javascript
@@ -299,6 +318,12 @@ koaClassicServer(rootDir, {
299
318
  symlinks: 'follow', // 'follow' | 'follow-within-root' | 'deny'
300
319
  staticSecurityHeaders: { nosniff: false }, // set nosniff on static responses
301
320
 
321
+ errorPages: { // custom error pages (v4.2+); omitted → built-ins
322
+ // 404: './errors/404.html', // supported statuses: 404, 500, 504
323
+ // 500: './errors/500.html', // self-contained .html (inline CSS, no external refs)
324
+ // 504: './errors/504.html', // editable without a restart; unreadable → built-in
325
+ },
326
+
302
327
  template: {
303
328
  ext: [], // e.g. ['ejs']
304
329
  renderTimeout: 30000, // ms; on timeout the request fails closed (0 = off)
package/index.cjs CHANGED
@@ -6,7 +6,7 @@ const crypto = require("crypto");
6
6
  const zlib = require("zlib");
7
7
  const util = require("util");
8
8
  const mime = require("mime-types");
9
- const { Readable, pipeline } = require('stream');
9
+ const { Readable, Transform, pipeline } = require('stream');
10
10
 
11
11
  const _brotliCompressAsync = util.promisify(zlib.brotliCompress);
12
12
  const _gzipAsync = util.promisify(zlib.gzip);
@@ -121,9 +121,12 @@ const LISTING_CSP = `default-src 'none'; style-src '${_listingCssHash}'; frame-a
121
121
  const NOT_FOUND_CSP = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'";
122
122
 
123
123
  // Sets security headers on all middleware-generated HTML pages (listing + error).
124
- // Must NOT be called for user files served from disk.
124
+ // Must NOT be called for user files served from disk. `csp` may be null for
125
+ // operator-authored custom error pages (options.errorPages): the built-in pages'
126
+ // `default-src 'none'` would block their inline styles, so they get the non-CSP
127
+ // headers only — the self-contained requirement is documented, not enforced.
125
128
  function setGeneratedPageHeaders(ctx, csp) {
126
- ctx.set('Content-Security-Policy', csp);
129
+ if (csp) ctx.set('Content-Security-Policy', csp);
127
130
  ctx.set('X-Content-Type-Options', 'nosniff');
128
131
  ctx.set('X-Frame-Options', 'DENY');
129
132
  ctx.set('Referrer-Policy', 'no-referrer');
@@ -153,10 +156,38 @@ const _GATEWAY_TIMEOUT_HTML = buildErrorHtml('Gateway Timeout', 'Gateway T
153
156
  const _TEMPLATE_ERROR_HTML = buildErrorHtml('Internal Server Error', 'Internal Server Error', 'Template rendering failed for the requested resource.');
154
157
  const _INTERNAL_ERROR_HTML = buildErrorHtml('Internal Server Error', 'Internal Server Error', 'The server encountered an unexpected condition.');
155
158
 
156
- function sendNotFound(ctx) {
157
- setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
158
- ctx.status = 404;
159
- ctx.body = _NOT_FOUND_HTML;
159
+ // Statuses an operator can override via options.errorPages, each with its
160
+ // built-in fallback page. 400 is deliberately absent: it answers malformed /
161
+ // hostile requests and stays minimal by design. _TEMPLATE_ERROR_HTML is a
162
+ // call-site-specific 500 body (template failures), not a separate status.
163
+ const _BUILTIN_ERROR_HTML = {
164
+ 404: _NOT_FOUND_HTML,
165
+ 500: _INTERNAL_ERROR_HTML,
166
+ 504: _GATEWAY_TIMEOUT_HTML,
167
+ };
168
+
169
+ // Representation / caching headers a partially-built response may have left
170
+ // behind by the time an error is detected: a stale Content-Encoding would
171
+ // corrupt the error page (its body is never compressed), a public
172
+ // Cache-Control could get the 404/500 cached by proxies under the resource's
173
+ // URL. Scrubbed on every error page write.
174
+ const ERROR_PAGE_SCRUB_HEADERS = [
175
+ 'Content-Encoding', 'Content-Disposition', 'Content-Range', 'Vary',
176
+ 'ETag', 'Last-Modified', 'Accept-Ranges',
177
+ 'Cache-Control', 'Pragma', 'Expires',
178
+ ];
179
+
180
+ // Single writer for every middleware-generated error response (404 / 500 / 504).
181
+ // `customBuffer` is the operator's page from options.errorPages (or null →
182
+ // `builtinHtml` is served). Assumes the response is still writable — callers
183
+ // keep their own headerSent / writableEnded guards.
184
+ function writeErrorPage(ctx, status, customBuffer, builtinHtml) {
185
+ for (const h of ERROR_PAGE_SCRUB_HEADERS) ctx.remove(h);
186
+ if (status >= 500) ctx.set('Cache-Control', 'no-store');
187
+ setGeneratedPageHeaders(ctx, customBuffer ? null : NOT_FOUND_CSP);
188
+ ctx.set('Content-Type', 'text/html; charset=utf-8');
189
+ ctx.status = status;
190
+ ctx.body = customBuffer || builtinHtml;
160
191
  }
161
192
 
162
193
  // Plain-text 400 for malformed requests (bad percent-encoding, invalid Host,
@@ -212,16 +243,16 @@ function warnConfigDeprecation(logger, message) {
212
243
 
213
244
  // Sends an error response for a failed template render. If headers were already
214
245
  // flushed by the render itself, destroys the underlying socket instead (the
215
- // status/body can no longer be changed at that point).
216
- function sendTemplateError(ctx, status, html, logMsg, err, logger) {
246
+ // status/body can no longer be changed at that point). Page selection (custom
247
+ // vs built-in) goes through the instance's sendErrorPage; `builtinHtml` keeps
248
+ // the call-site-specific fallback body (timeout vs render failure).
249
+ async function sendTemplateError(ctx, status, builtinHtml, logMsg, err, logger, sendErrorPage) {
217
250
  logger.error(logMsg, err);
218
251
  if (ctx.headerSent || ctx.res.writableEnded) {
219
252
  ctx.res.destroy();
220
253
  return;
221
254
  }
222
- setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
223
- ctx.status = status;
224
- ctx.body = html;
255
+ await sendErrorPage(ctx, status, builtinHtml);
225
256
  }
226
257
 
227
258
  // Rewrites an already-rendered response into an RFC 9110 §9.3.2 compliant HEAD
@@ -256,7 +287,7 @@ function stripBodyForHead(ctx) {
256
287
  // client disconnect. Cooperative renders that propagate the signal to fetch/db
257
288
  // release backend resources promptly; non-cooperative renders still get a 504
258
289
  // response, but their work continues in the background.
259
- async function tryRenderTemplate(ctx, next, filePath, rawBuffer, templateOpts, logger) {
290
+ async function tryRenderTemplate(ctx, next, filePath, rawBuffer, templateOpts, logger, sendErrorPage) {
260
291
  if (templateOpts.ext.length === 0 || !templateOpts.render) return false;
261
292
 
262
293
  const fileExt = path.extname(filePath).slice(1);
@@ -304,11 +335,11 @@ async function tryRenderTemplate(ctx, next, filePath, rawBuffer, templateOpts, l
304
335
  }
305
336
  } catch (error) {
306
337
  if (timedOut || error.code === 'ETEMPLATETIMEOUT') {
307
- sendTemplateError(ctx, 504, _GATEWAY_TIMEOUT_HTML,
308
- 'Template render timeout after ' + timeoutMs + 'ms:', filePath, logger);
338
+ await sendTemplateError(ctx, 504, _GATEWAY_TIMEOUT_HTML,
339
+ 'Template render timeout after ' + timeoutMs + 'ms:', filePath, logger, sendErrorPage);
309
340
  } else {
310
- sendTemplateError(ctx, 500, _TEMPLATE_ERROR_HTML,
311
- 'Template rendering error:', error, logger);
341
+ await sendTemplateError(ctx, 500, _TEMPLATE_ERROR_HTML,
342
+ 'Template rendering error:', error, logger, sendErrorPage);
312
343
  }
313
344
  } finally {
314
345
  if (timer) clearTimeout(timer);
@@ -577,6 +608,25 @@ function singleFlight(map, key, work) {
577
608
  // stale-by-age (mtime + size unchanged), updates in place so the existing
578
609
  // frequency counter survives — important for popular files refreshed by maxAge.
579
610
  // Otherwise falls back to delete + set (frequency resets to 1).
611
+ // Bounded-RAM streaming compressor: constant-memory transform, lower quality
612
+ // than the buffered path (which can afford brotli Q11 because it runs once and
613
+ // is cached). Used by every streamed-compression pipeline.
614
+ // LGWIN 19 (512 KB window instead of brotli's default 4 MB): the encoder state
615
+ // is the dominant per-request RAM on this path (~10 MB/stream at the default,
616
+ // ~1.3 GB peak measured under 100 concurrent cold requests), and at Q4 the big
617
+ // window buys nothing on typical text (measured: same output size, ~40% faster).
618
+ // The trade-off: content with identical blocks repeated at >512 KB distance
619
+ // (rare, pathological) compresses worse than with the 4 MB window. gzip's
620
+ // window is 32 KB by design — nothing to bound there.
621
+ function createStreamCompressor(encoding) {
622
+ return encoding === 'br'
623
+ ? zlib.createBrotliCompress({ params: {
624
+ [zlib.constants.BROTLI_PARAM_QUALITY]: 4,
625
+ [zlib.constants.BROTLI_PARAM_LGWIN]: 19,
626
+ } })
627
+ : zlib.createGzip({ level: 6 });
628
+ }
629
+
580
630
  function refreshOrInsert(cache, key, newEntry, cached, staleByAge) {
581
631
  const canRefreshInPlace = cached
582
632
  && staleByAge
@@ -672,6 +722,25 @@ module.exports = function koaClassicServer(
672
722
  ext: '.ejs', // Extension to hide (required, string, case-sensitive, must start with '.')
673
723
  redirect: 301 // HTTP redirect code for URLs with extension (optional, default: 301)
674
724
  },
725
+ errorPages: { // Custom error pages (V4.2+). Opt-in — omitted statuses keep the built-in pages.
726
+ // Keys: the statuses the middleware generates error pages for: 404, 500, 504.
727
+ // Any other key throws at factory time. (400 replies to malformed/hostile
728
+ // requests are deliberately NOT customizable — they stay minimal.)
729
+ // Values: filesystem path (absolute, or relative to process.cwd()) to a
730
+ // SELF-CONTAINED .html file: one single file, inline CSS only, no external
731
+ // css/js/img references. Documented requirement, not enforced — custom
732
+ // pages are served WITHOUT the built-in pages' Content-Security-Policy
733
+ // (which would block their inline styles); the other generated-page
734
+ // security headers (nosniff, X-Frame-Options, ...) are still sent.
735
+ // The file may live outside rootDir (recommended: keeps it unreachable via URL).
736
+ // Read and validated at factory time (missing/unreadable → throw), cached in
737
+ // RAM, and re-read automatically when its mtime/size changes — editable
738
+ // without a restart. If it becomes unreadable at request time, the built-in
739
+ // page is served instead (fallback; throttled warning via logger).
740
+ // 404: './errors/404.html',
741
+ // 500: './errors/500.html',
742
+ // 504: './errors/504.html',
743
+ },
675
744
  hidden: { // Block files/dirs from listing and serving (HTTP 404)
676
745
  dotFiles: { // Dot-files (names starting with '.'): visible by default — design philosophy
677
746
  default: 'visible', // 'hidden' | 'visible' — system default: 'visible'
@@ -717,9 +786,14 @@ module.exports = function koaClassicServer(
717
786
  // result cached). Default: 10 MB; false = no cap.
718
787
  // Larger files are STILL compressed, but via the
719
788
  // bounded-RAM streaming mode (brotli Q4 / gzip 6, no
720
- // Content-Length, not cached). This is a SAFETY NET
721
- // against unbounded RAM/CPU on huge compressible files
722
- // (multi-GB logs/JSON/CSV), not a serving restriction.
789
+ // Content-Length on the first response). This is a
790
+ // SAFETY NET against unbounded RAM/CPU on huge
791
+ // compressible files (multi-GB logs/JSON/CSV), not a
792
+ // serving restriction. When serverCache.compressedFile
793
+ // is enabled, the streamed OUTPUT is also cached
794
+ // (when it fits in a quarter of that cache's maxSize),
795
+ // so subsequent requests are served from RAM with
796
+ // Content-Length.
723
797
  mimeTypes: [], // compressible MIME types (replaces default list if provided)
724
798
  },
725
799
  // compression: false // shorthand to disable all compression
@@ -1390,6 +1464,98 @@ module.exports = function koaClassicServer(
1390
1464
  nosniff: !!(_ssh && _ssh.nosniff),
1391
1465
  };
1392
1466
 
1467
+ // ── errorPages (V4.2+) — operator-supplied custom error pages ──
1468
+ // Validated and read once at factory time (a typo must fail at startup, not
1469
+ // on the first 404), then kept in RAM and refreshed when mtime/size changes.
1470
+ const _errorPages = new Map(); // status → { path, buffer, mtimeMs, size }
1471
+ const userErrorPages = opts.errorPages;
1472
+ if (userErrorPages !== undefined) {
1473
+ if (typeof userErrorPages !== 'object' || userErrorPages === null || Array.isArray(userErrorPages)) {
1474
+ throw new Error(
1475
+ '[koa-classic-server] options.errorPages must be an object mapping a supported HTTP status ' +
1476
+ "to an .html file path. Example: errorPages: { 404: './errors/404.html' }"
1477
+ );
1478
+ }
1479
+ for (const key of Object.keys(userErrorPages)) {
1480
+ const status = Number(key);
1481
+ if (_BUILTIN_ERROR_HTML[status] === undefined) {
1482
+ throw new Error(
1483
+ `[koa-classic-server] errorPages: unsupported status "${key}". ` +
1484
+ 'Supported statuses: ' + Object.keys(_BUILTIN_ERROR_HTML).join(', ') + '. ' +
1485
+ '(400 is deliberately not customizable: replies to malformed requests stay minimal.)'
1486
+ );
1487
+ }
1488
+ const value = userErrorPages[key];
1489
+ if (typeof value !== 'string' || value === '') {
1490
+ throw new Error(
1491
+ `[koa-classic-server] errorPages[${key}] must be a non-empty string path to an .html file. ` +
1492
+ 'Got: ' + (value === '' ? 'an empty string' : value === null ? 'null' : typeof value)
1493
+ );
1494
+ }
1495
+ const resolved = path.resolve(value);
1496
+ let pageStat, pageBuffer;
1497
+ try {
1498
+ pageStat = fs.statSync(resolved);
1499
+ if (!pageStat.isFile()) throw new Error('not a regular file');
1500
+ pageBuffer = fs.readFileSync(resolved);
1501
+ } catch (err) {
1502
+ throw new Error(
1503
+ `[koa-classic-server] errorPages[${key}]: cannot read "${resolved}" (${err.message}). ` +
1504
+ 'The file must exist and be readable at startup.'
1505
+ );
1506
+ }
1507
+ _errorPages.set(status, { path: resolved, buffer: pageBuffer, mtimeMs: pageStat.mtimeMs, size: pageStat.size });
1508
+ }
1509
+ }
1510
+
1511
+ // Runtime read-failure warnings, throttled per status: a 404 flood against a
1512
+ // deleted custom page must not flood the log.
1513
+ const _errorPageWarnAt = new Map(); // status → last warn timestamp (ms)
1514
+ function warnErrorPageUnreadable(status, entry, err) {
1515
+ const now = Date.now();
1516
+ if (now - (_errorPageWarnAt.get(status) || 0) < 60000) return;
1517
+ _errorPageWarnAt.set(status, now);
1518
+ _logger.warn(...warnPayload(_logger,
1519
+ `[koa-classic-server] errorPages[${status}]: cannot read "${entry.path}" — ` +
1520
+ `serving the built-in page instead. (${err.message})`));
1521
+ }
1522
+
1523
+ // Returns the custom page buffer for `status`, re-read from disk when the
1524
+ // file changed (mtime+size — pages are editable without a restart). Returns
1525
+ // null when no custom page is configured for the status, or when the file is
1526
+ // no longer readable (the caller falls back to the built-in page).
1527
+ async function getCustomErrorPage(status) {
1528
+ const entry = _errorPages.get(status);
1529
+ if (!entry) return null;
1530
+ try {
1531
+ const st = await fs.promises.stat(entry.path);
1532
+ if (st.mtimeMs !== entry.mtimeMs || st.size !== entry.size) {
1533
+ entry.buffer = await fs.promises.readFile(entry.path);
1534
+ entry.mtimeMs = st.mtimeMs;
1535
+ entry.size = st.size;
1536
+ }
1537
+ return entry.buffer;
1538
+ } catch (err) {
1539
+ warnErrorPageUnreadable(status, entry, err);
1540
+ return null;
1541
+ }
1542
+ }
1543
+
1544
+ // Single sender for every middleware-generated error response. `builtinHtml`
1545
+ // lets a call site keep its specific fallback body (e.g. the template-failure
1546
+ // 500) while still honoring the operator's custom page when configured.
1547
+ async function sendErrorPage(ctx, status, builtinHtml = _BUILTIN_ERROR_HTML[status]) {
1548
+ writeErrorPage(ctx, status, await getCustomErrorPage(status), builtinHtml);
1549
+ }
1550
+
1551
+ // Sync variant for stream-error callbacks, where no await is possible: serves
1552
+ // the last-loaded custom buffer without the freshness stat — an acceptable
1553
+ // trade for a branch that only fires when the disk fails mid-response.
1554
+ function sendErrorPageSync(ctx, status) {
1555
+ const entry = _errorPages.get(status);
1556
+ writeErrorPage(ctx, status, entry ? entry.buffer : null, _BUILTIN_ERROR_HTML[status]);
1557
+ }
1558
+
1393
1559
  const compressionConfig = normalizeCompressionConfig(options.compression);
1394
1560
  const serverCacheConfig = normalizeServerCacheConfig(options.serverCache);
1395
1561
 
@@ -1419,6 +1585,37 @@ module.exports = function koaClassicServer(
1419
1585
  // ETag/Last-Modified).
1420
1586
  const _inflightRawReads = new Map(); // `${path}:${mtime}:${size}` → Promise<Buffer>
1421
1587
  const _inflightCompressions = new Map(); // `${path}:${encoding}:${mtime}:${size}` → Promise<Buffer>
1588
+ // Streamed-compression tee leaders in flight (`${path}:${encoding}:${mtime}:${size}`).
1589
+ // Only ONE request per key accumulates the compressed output for the cache,
1590
+ // so tee RAM never scales with the number of concurrent clients.
1591
+ const _inflightStreamTees = new Set();
1592
+ // Aggregate bytes currently accumulated by ALL in-flight tees. Bounds the
1593
+ // transient RAM across DISTINCT large files too: accumulation stops (the
1594
+ // entry is skipped, streaming continues) rather than let the tees hold more
1595
+ // RAM in aggregate than the compressed cache they feed (its maxSize).
1596
+ let _inflightTeeBytes = 0;
1597
+
1598
+ // Streams `toOpen` (or `rawBuffer`) through the bounded-RAM compressor for
1599
+ // `encoding` and sets it as the response body. Shared by the cache-disabled
1600
+ // streaming branch and by tee followers; the tee leader builds its own
1601
+ // pipeline with the extra accumulator stage.
1602
+ // pipeline (NOT pipe): teardown propagates in BOTH directions. When the
1603
+ // client disconnects mid-transfer, Koa destroys the body (the zlib
1604
+ // transform) and pipeline destroys `src` too, closing its file descriptor.
1605
+ // A bare src.pipe(compress) leaves the ReadStream paused with the fd open
1606
+ // forever — fd leak under aborted downloads. Client disconnects are a
1607
+ // normal event and are not logged (avoids client-driven log spam).
1608
+ function streamCompressedBody(ctx, toOpen, rawBuffer, encoding) {
1609
+ const compress = createStreamCompressor(encoding);
1610
+ const src = rawBuffer
1611
+ ? Readable.from(rawBuffer) // compress from in-memory buffer — no disk I/O
1612
+ : fs.createReadStream(toOpen);
1613
+ ctx.body = pipeline(src, compress, (err) => {
1614
+ if (!err || err.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
1615
+ _logger.error('Stream error:', err);
1616
+ if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
1617
+ });
1618
+ }
1422
1619
 
1423
1620
  // Returns the server-preferred enabled encoding the client is willing to accept.
1424
1621
  // Server preference order (compressionConfig.encodings) still wins; the
@@ -1640,7 +1837,7 @@ module.exports = function koaClassicServer(
1640
1837
  // Returns 404 (not 403) so "outside root" is indistinguishable from "not found",
1641
1838
  // matching the symlink-escape and hidden-entry outcomes.
1642
1839
  if (!_isWithinRoot(fullPath, normalizedRootDir)) {
1643
- sendNotFound(ctx);
1840
+ await sendErrorPage(ctx, 404);
1644
1841
  return;
1645
1842
  }
1646
1843
 
@@ -1653,7 +1850,7 @@ module.exports = function koaClassicServer(
1653
1850
  const segName = segments[i];
1654
1851
  const segRelPath = segments.slice(0, i + 1).join('/');
1655
1852
  if (isHiddenEntry(segName, segRelPath, true)) {
1656
- sendNotFound(ctx);
1853
+ await sendErrorPage(ctx, 404);
1657
1854
  return;
1658
1855
  }
1659
1856
  }
@@ -1768,7 +1965,7 @@ module.exports = function koaClassicServer(
1768
1965
  stat = await fs.promises.stat(toOpen);
1769
1966
  } catch {
1770
1967
  // File/directory doesn't exist or can't be accessed
1771
- sendNotFound(ctx);
1968
+ await sendErrorPage(ctx, 404);
1772
1969
  return;
1773
1970
  }
1774
1971
 
@@ -1777,7 +1974,7 @@ module.exports = function koaClassicServer(
1777
1974
  const entryName = path.basename(toOpen);
1778
1975
  const entryRelPath = path.relative(normalizedRootDir, toOpen).split(path.sep).join('/');
1779
1976
  if (isHiddenEntry(entryName, entryRelPath, stat.isDirectory())) {
1780
- sendNotFound(ctx);
1977
+ await sendErrorPage(ctx, 404);
1781
1978
  return;
1782
1979
  }
1783
1980
  }
@@ -1787,7 +1984,7 @@ module.exports = function koaClassicServer(
1787
1984
  // resolved below rootDir). The `_symlinkMode !== 'follow'` guard short-circuits
1788
1985
  // before any await so the default 'follow' mode stays truly zero-overhead.
1789
1986
  if (_symlinkMode !== 'follow' && !(await symlinkAllowed(toOpen))) {
1790
- sendNotFound(ctx);
1987
+ await sendErrorPage(ctx, 404);
1791
1988
  return;
1792
1989
  }
1793
1990
 
@@ -1836,7 +2033,7 @@ module.exports = function koaClassicServer(
1836
2033
  // symlink escaping rootDir — validate before serving it.
1837
2034
  // Guarded so the default 'follow' mode skips the await entirely.
1838
2035
  if (_symlinkMode !== 'follow' && !(await symlinkAllowed(indexPath))) {
1839
- sendNotFound(ctx);
2036
+ await sendErrorPage(ctx, 404);
1840
2037
  return;
1841
2038
  }
1842
2039
  await loadFile(indexPath, indexFile.stat);
@@ -1849,7 +2046,7 @@ module.exports = function koaClassicServer(
1849
2046
  ctx.body = await show_dir(toOpen, ctx);
1850
2047
  } else {
1851
2048
  // Directory listing disabled
1852
- sendNotFound(ctx);
2049
+ await sendErrorPage(ctx, 404);
1853
2050
  }
1854
2051
  return;
1855
2052
  } else {
@@ -1859,7 +2056,7 @@ module.exports = function koaClassicServer(
1859
2056
  // from not-found) rather than serving the file at a non-canonical
1860
2057
  // URL. Disabled by dirListing.trailingSlash: false (v3 behavior).
1861
2058
  if (options.dirListing.trailingSlash && _pathEndsWithSlash) {
1862
- sendNotFound(ctx);
2059
+ await sendErrorPage(ctx, 404);
1863
2060
  return;
1864
2061
  }
1865
2062
  await loadFile(toOpen, stat);
@@ -1871,17 +2068,10 @@ module.exports = function koaClassicServer(
1871
2068
  ctx.res.destroy(); // response already in flight — nothing sane left to send
1872
2069
  return;
1873
2070
  }
1874
- // Scrub representation/caching headers a partially-built response may
1875
- // have left behind: a stale Content-Encoding would corrupt the error
1876
- // page, a public Cache-Control could get the 500 cached by proxies.
1877
- for (const h of ['Content-Encoding', 'Content-Disposition', 'Content-Range', 'Vary', 'ETag', 'Last-Modified', 'Accept-Ranges']) {
1878
- ctx.remove(h);
1879
- }
1880
- ctx.set('Cache-Control', 'no-store');
1881
- ctx.set('Content-Type', 'text/html; charset=utf-8');
1882
- setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
1883
- ctx.status = 500;
1884
- ctx.body = _INTERNAL_ERROR_HTML;
2071
+ // writeErrorPage scrubs the representation/caching headers a
2072
+ // partially-built response may have left behind (stale
2073
+ // Content-Encoding, public Cache-Control, ...) and sets no-store.
2074
+ await sendErrorPage(ctx, 500);
1885
2075
  }
1886
2076
 
1887
2077
  // Internal functions
@@ -1894,7 +2084,7 @@ module.exports = function koaClassicServer(
1894
2084
  fileStat = await fs.promises.stat(toOpen);
1895
2085
  } catch (error) {
1896
2086
  _logger.error('File stat error:', error);
1897
- sendNotFound(ctx);
2087
+ await sendErrorPage(ctx, 404);
1898
2088
  return;
1899
2089
  }
1900
2090
  }
@@ -1938,7 +2128,7 @@ module.exports = function koaClassicServer(
1938
2128
  }
1939
2129
  }
1940
2130
 
1941
- if (await tryRenderTemplate(ctx, next, toOpen, rawBuffer, options.template, _logger)) {
2131
+ if (await tryRenderTemplate(ctx, next, toOpen, rawBuffer, options.template, _logger, sendErrorPage)) {
1942
2132
  return;
1943
2133
  }
1944
2134
 
@@ -1974,7 +2164,7 @@ module.exports = function koaClassicServer(
1974
2164
  await fs.promises.access(toOpen, fs.constants.R_OK);
1975
2165
  } catch (error) {
1976
2166
  _logger.error('File access error:', error);
1977
- sendNotFound(ctx);
2167
+ await sendErrorPage(ctx, 404);
1978
2168
  return;
1979
2169
  }
1980
2170
  }
@@ -2078,10 +2268,7 @@ module.exports = function koaClassicServer(
2078
2268
  const src = fs.createReadStream(toOpen, { start, end });
2079
2269
  src.on('error', (err) => {
2080
2270
  _logger.error('Stream error:', err);
2081
- if (!ctx.headerSent) {
2082
- ctx.status = 500;
2083
- ctx.body = 'Error reading file';
2084
- }
2271
+ if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
2085
2272
  });
2086
2273
  ctx.body = src;
2087
2274
  }
@@ -2110,11 +2297,15 @@ module.exports = function koaClassicServer(
2110
2297
  // Safety net (#4): the buffered path reads the WHOLE file into RAM and
2111
2298
  // compresses at max quality — fine for web assets, catastrophic for a
2112
2299
  // multi-GB log/CSV. Above compression.maxFileSize the bounded-RAM
2113
- // streaming mode below is used instead (still compressed, not cached).
2300
+ // streaming mode below is used instead. The cap gates only HOW the
2301
+ // compression runs (buffered Q11 vs streamed Q4) — the compressed
2302
+ // cache stays in play on both sides: above the cap the streamed
2303
+ // OUTPUT is teed into the cache when it fits (see the tee branch),
2304
+ // so later requests are RAM hits either way.
2114
2305
  const withinCompressCap = compressionConfig.maxFileSize === false
2115
2306
  || fileStat.size <= compressionConfig.maxFileSize;
2116
2307
 
2117
- if (serverCacheConfig.compressedFile.enabled && withinCompressCap) {
2308
+ if (serverCacheConfig.compressedFile.enabled) {
2118
2309
  // compressedFile cache mode: compress once → buffer in RAM → Content-Length known
2119
2310
  const cacheKey = `${toOpen}:${encoding}`;
2120
2311
  const cached = _compressedFileCache.peek(cacheKey);
@@ -2129,6 +2320,93 @@ module.exports = function koaClassicServer(
2129
2320
  if (!stale) {
2130
2321
  _compressedFileCache.get(cacheKey); // increment frequency
2131
2322
  buf = cached.buffer; // Serve from cache
2323
+ } else if (!withinCompressCap) {
2324
+ // Above the buffered-compression cap: stream at the bounded-RAM
2325
+ // quality (never buffer the input), and tee the compressed OUTPUT
2326
+ // into the cache so later requests are RAM hits with Content-Length.
2327
+ // The cap protects against a large INPUT; the cache admission below
2328
+ // is decided on the actual OUTPUT size, which is only known here.
2329
+ if (ctx.method === 'HEAD') {
2330
+ // Mirror the GET status/headers (RFC 9110 §9.3.2) without running
2331
+ // the compression: no Content-Length (unknown), no cache insert.
2332
+ ctx.status = 200;
2333
+ return;
2334
+ }
2335
+ const mtimeMs = fileStat.mtime.getTime();
2336
+ const teeKey = `${cacheKey}:${mtimeMs}:${fileStat.size}`;
2337
+ if (_inflightStreamTees.has(teeKey)) {
2338
+ // Follower: another request is already accumulating this exact
2339
+ // file version. Stream independently (no added latency, no tee
2340
+ // stage) — the cache will be warm for the NEXT request.
2341
+ streamCompressedBody(ctx, toOpen, rawBuffer, encoding);
2342
+ return;
2343
+ }
2344
+ // Leader: stream AND accumulate a copy for the cache.
2345
+ _inflightStreamTees.add(teeKey);
2346
+ let acc = [];
2347
+ let accBytes = 0;
2348
+ // Two admission bounds, both on the real OUTPUT size:
2349
+ // - per entry: a quarter of the cache, so one huge file cannot
2350
+ // evict most of the working set on insert;
2351
+ // - aggregate (_inflightTeeBytes): all in-flight tees together may
2352
+ // never hold more RAM than the cache's own maxSize.
2353
+ const entryCap = Math.floor(serverCacheConfig.compressedFile.maxSize / 4);
2354
+ // Stops accumulating and releases this tee's share of the aggregate
2355
+ // budget. Safe to call more than once.
2356
+ const abandonAccumulation = () => {
2357
+ if (acc) {
2358
+ _inflightTeeBytes -= accBytes;
2359
+ acc = null;
2360
+ }
2361
+ };
2362
+ try {
2363
+ const compress = createStreamCompressor(encoding);
2364
+ const src = rawBuffer
2365
+ ? Readable.from(rawBuffer) // compress from in-memory buffer — no disk I/O
2366
+ : fs.createReadStream(toOpen);
2367
+ const tee = new Transform({
2368
+ transform(chunk, _enc, done) {
2369
+ if (acc) {
2370
+ accBytes += chunk.length;
2371
+ _inflightTeeBytes += chunk.length;
2372
+ if (accBytes > entryCap
2373
+ || _inflightTeeBytes > serverCacheConfig.compressedFile.maxSize) {
2374
+ abandonAccumulation(); // over budget: keep streaming, skip the cache
2375
+ } else {
2376
+ acc.push(chunk);
2377
+ }
2378
+ }
2379
+ done(null, chunk);
2380
+ },
2381
+ });
2382
+ // pipeline (NOT pipe): teardown propagates in BOTH directions —
2383
+ // same fd-leak rationale as streamCompressedBody.
2384
+ ctx.body = pipeline(src, compress, tee, (err) => {
2385
+ _inflightStreamTees.delete(teeKey);
2386
+ if (!err && acc) {
2387
+ // Clean completion only: an aborted or failed stream never
2388
+ // inserts a (truncated) entry.
2389
+ refreshOrInsert(_compressedFileCache, cacheKey, {
2390
+ buffer: Buffer.concat(acc, accBytes),
2391
+ mtime: mtimeMs,
2392
+ size: fileStat.size,
2393
+ insertedAt: Date.now(),
2394
+ }, cached, staleByAge);
2395
+ }
2396
+ abandonAccumulation();
2397
+ if (!err || err.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
2398
+ _logger.error('Stream error:', err);
2399
+ if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
2400
+ });
2401
+ } catch (err) {
2402
+ // Defensive: nothing between add() and pipeline() is expected to
2403
+ // throw synchronously, but a leaked teeKey would silently disable
2404
+ // the tee for this file version forever.
2405
+ _inflightStreamTees.delete(teeKey);
2406
+ abandonAccumulation();
2407
+ throw err;
2408
+ }
2409
+ return;
2132
2410
  } else {
2133
2411
  try {
2134
2412
  // Single-flight: concurrent misses on the same path+encoding
@@ -2173,7 +2451,7 @@ module.exports = function koaClassicServer(
2173
2451
  const src = fs.createReadStream(toOpen);
2174
2452
  src.on('error', (streamErr) => {
2175
2453
  _logger.error('Stream error:', streamErr);
2176
- if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
2454
+ if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
2177
2455
  });
2178
2456
  ctx.body = src;
2179
2457
  } else {
@@ -2195,26 +2473,10 @@ module.exports = function koaClassicServer(
2195
2473
  }
2196
2474
 
2197
2475
  } else {
2198
- // Streaming mode: pipe through zlib transform — Content-Length not known in advance
2476
+ // Streaming mode (compressed cache disabled): pipe through the zlib
2477
+ // transform — Content-Length not known in advance, nothing cached.
2199
2478
  if (ctx.method !== 'HEAD') {
2200
- const compress = encoding === 'br'
2201
- ? zlib.createBrotliCompress({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } })
2202
- : zlib.createGzip({ level: 6 });
2203
- const src = rawBuffer
2204
- ? Readable.from(rawBuffer) // compress from in-memory buffer — no disk I/O
2205
- : fs.createReadStream(toOpen);
2206
- // pipeline (NOT pipe): teardown propagates in BOTH directions.
2207
- // When the client disconnects mid-transfer, Koa destroys the body
2208
- // (`compress`) and pipeline destroys `src` too, closing its file
2209
- // descriptor. A bare src.pipe(compress) leaves the ReadStream
2210
- // paused with the fd open forever — fd leak under aborted
2211
- // downloads. Client disconnects are a normal event and are not
2212
- // logged (avoids client-driven log spam).
2213
- ctx.body = pipeline(src, compress, (err) => {
2214
- if (!err || err.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
2215
- _logger.error('Stream error:', err);
2216
- if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
2217
- });
2479
+ streamCompressedBody(ctx, toOpen, rawBuffer, encoding);
2218
2480
  } else {
2219
2481
  // HEAD: mirror the GET status and headers (RFC 9110 §9.3.2) — no
2220
2482
  // Content-Length, since the compressed size is unknown without
@@ -2241,7 +2503,7 @@ module.exports = function koaClassicServer(
2241
2503
  const src = fs.createReadStream(toOpen);
2242
2504
  src.on('error', (err) => {
2243
2505
  _logger.error('Stream error:', err);
2244
- if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
2506
+ if (!ctx.headerSent) sendErrorPageSync(ctx, 500);
2245
2507
  });
2246
2508
  ctx.body = src;
2247
2509
  } else {
@@ -2592,3 +2854,18 @@ module.exports = function koaClassicServer(
2592
2854
 
2593
2855
  };
2594
2856
  };
2857
+
2858
+ // ── Test-only internals ──────────────────────────────────────────────────────
2859
+ // NOT part of the public API: exposed so the unit tests can exercise the pure
2860
+ // helpers and the LFU cache directly (eviction order, frequency preservation,
2861
+ // range parsing, validator matching) without a full HTTP round-trip. No
2862
+ // stability guarantee — do not import from application code.
2863
+ module.exports._internals = {
2864
+ LFUCache,
2865
+ parseRangeHeader,
2866
+ ifNoneMatchSatisfied,
2867
+ formatSize,
2868
+ singleFlight,
2869
+ refreshOrInsert,
2870
+ escapeHtml,
2871
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koa-classic-server",
3
- "version": "4.0.0",
3
+ "version": "4.2.0",
4
4
  "description": "High-performance Koa middleware for serving static files with classic directory listing (similar, but not identical, to Apache 2), HTTP caching, template engine support, and comprehensive security fixes",
5
5
  "main": "index.cjs",
6
6
  "exports": {
@@ -17,6 +17,7 @@
17
17
  "pretest": "npm run lint",
18
18
  "test": "jest",
19
19
  "test:ci": "jest --testPathIgnorePatterns=performance",
20
+ "test:coverage": "jest --coverage --testPathIgnorePatterns=performance",
20
21
  "test:security": "jest __tests__/security.test.js",
21
22
  "test:performance": "jest __tests__/performance.test.js --runInBand",
22
23
  "benchmark": "node __tests__/benchmark.js",