koa-classic-server 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.cjs +170 -25
  2. package/package.json +2 -1
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);
@@ -577,6 +577,25 @@ function singleFlight(map, key, work) {
577
577
  // stale-by-age (mtime + size unchanged), updates in place so the existing
578
578
  // frequency counter survives — important for popular files refreshed by maxAge.
579
579
  // Otherwise falls back to delete + set (frequency resets to 1).
580
+ // Bounded-RAM streaming compressor: constant-memory transform, lower quality
581
+ // than the buffered path (which can afford brotli Q11 because it runs once and
582
+ // is cached). Used by every streamed-compression pipeline.
583
+ // LGWIN 19 (512 KB window instead of brotli's default 4 MB): the encoder state
584
+ // is the dominant per-request RAM on this path (~10 MB/stream at the default,
585
+ // ~1.3 GB peak measured under 100 concurrent cold requests), and at Q4 the big
586
+ // window buys nothing on typical text (measured: same output size, ~40% faster).
587
+ // The trade-off: content with identical blocks repeated at >512 KB distance
588
+ // (rare, pathological) compresses worse than with the 4 MB window. gzip's
589
+ // window is 32 KB by design — nothing to bound there.
590
+ function createStreamCompressor(encoding) {
591
+ return encoding === 'br'
592
+ ? zlib.createBrotliCompress({ params: {
593
+ [zlib.constants.BROTLI_PARAM_QUALITY]: 4,
594
+ [zlib.constants.BROTLI_PARAM_LGWIN]: 19,
595
+ } })
596
+ : zlib.createGzip({ level: 6 });
597
+ }
598
+
580
599
  function refreshOrInsert(cache, key, newEntry, cached, staleByAge) {
581
600
  const canRefreshInPlace = cached
582
601
  && staleByAge
@@ -717,9 +736,14 @@ module.exports = function koaClassicServer(
717
736
  // result cached). Default: 10 MB; false = no cap.
718
737
  // Larger files are STILL compressed, but via the
719
738
  // 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.
739
+ // Content-Length on the first response). This is a
740
+ // SAFETY NET against unbounded RAM/CPU on huge
741
+ // compressible files (multi-GB logs/JSON/CSV), not a
742
+ // serving restriction. When serverCache.compressedFile
743
+ // is enabled, the streamed OUTPUT is also cached
744
+ // (when it fits in a quarter of that cache's maxSize),
745
+ // so subsequent requests are served from RAM with
746
+ // Content-Length.
723
747
  mimeTypes: [], // compressible MIME types (replaces default list if provided)
724
748
  },
725
749
  // compression: false // shorthand to disable all compression
@@ -1419,6 +1443,37 @@ module.exports = function koaClassicServer(
1419
1443
  // ETag/Last-Modified).
1420
1444
  const _inflightRawReads = new Map(); // `${path}:${mtime}:${size}` → Promise<Buffer>
1421
1445
  const _inflightCompressions = new Map(); // `${path}:${encoding}:${mtime}:${size}` → Promise<Buffer>
1446
+ // Streamed-compression tee leaders in flight (`${path}:${encoding}:${mtime}:${size}`).
1447
+ // Only ONE request per key accumulates the compressed output for the cache,
1448
+ // so tee RAM never scales with the number of concurrent clients.
1449
+ const _inflightStreamTees = new Set();
1450
+ // Aggregate bytes currently accumulated by ALL in-flight tees. Bounds the
1451
+ // transient RAM across DISTINCT large files too: accumulation stops (the
1452
+ // entry is skipped, streaming continues) rather than let the tees hold more
1453
+ // RAM in aggregate than the compressed cache they feed (its maxSize).
1454
+ let _inflightTeeBytes = 0;
1455
+
1456
+ // Streams `toOpen` (or `rawBuffer`) through the bounded-RAM compressor for
1457
+ // `encoding` and sets it as the response body. Shared by the cache-disabled
1458
+ // streaming branch and by tee followers; the tee leader builds its own
1459
+ // pipeline with the extra accumulator stage.
1460
+ // pipeline (NOT pipe): teardown propagates in BOTH directions. When the
1461
+ // client disconnects mid-transfer, Koa destroys the body (the zlib
1462
+ // transform) and pipeline destroys `src` too, closing its file descriptor.
1463
+ // A bare src.pipe(compress) leaves the ReadStream paused with the fd open
1464
+ // forever — fd leak under aborted downloads. Client disconnects are a
1465
+ // normal event and are not logged (avoids client-driven log spam).
1466
+ function streamCompressedBody(ctx, toOpen, rawBuffer, encoding) {
1467
+ const compress = createStreamCompressor(encoding);
1468
+ const src = rawBuffer
1469
+ ? Readable.from(rawBuffer) // compress from in-memory buffer — no disk I/O
1470
+ : fs.createReadStream(toOpen);
1471
+ ctx.body = pipeline(src, compress, (err) => {
1472
+ if (!err || err.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
1473
+ _logger.error('Stream error:', err);
1474
+ if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
1475
+ });
1476
+ }
1422
1477
 
1423
1478
  // Returns the server-preferred enabled encoding the client is willing to accept.
1424
1479
  // Server preference order (compressionConfig.encodings) still wins; the
@@ -2110,11 +2165,15 @@ module.exports = function koaClassicServer(
2110
2165
  // Safety net (#4): the buffered path reads the WHOLE file into RAM and
2111
2166
  // compresses at max quality — fine for web assets, catastrophic for a
2112
2167
  // multi-GB log/CSV. Above compression.maxFileSize the bounded-RAM
2113
- // streaming mode below is used instead (still compressed, not cached).
2168
+ // streaming mode below is used instead. The cap gates only HOW the
2169
+ // compression runs (buffered Q11 vs streamed Q4) — the compressed
2170
+ // cache stays in play on both sides: above the cap the streamed
2171
+ // OUTPUT is teed into the cache when it fits (see the tee branch),
2172
+ // so later requests are RAM hits either way.
2114
2173
  const withinCompressCap = compressionConfig.maxFileSize === false
2115
2174
  || fileStat.size <= compressionConfig.maxFileSize;
2116
2175
 
2117
- if (serverCacheConfig.compressedFile.enabled && withinCompressCap) {
2176
+ if (serverCacheConfig.compressedFile.enabled) {
2118
2177
  // compressedFile cache mode: compress once → buffer in RAM → Content-Length known
2119
2178
  const cacheKey = `${toOpen}:${encoding}`;
2120
2179
  const cached = _compressedFileCache.peek(cacheKey);
@@ -2129,6 +2188,93 @@ module.exports = function koaClassicServer(
2129
2188
  if (!stale) {
2130
2189
  _compressedFileCache.get(cacheKey); // increment frequency
2131
2190
  buf = cached.buffer; // Serve from cache
2191
+ } else if (!withinCompressCap) {
2192
+ // Above the buffered-compression cap: stream at the bounded-RAM
2193
+ // quality (never buffer the input), and tee the compressed OUTPUT
2194
+ // into the cache so later requests are RAM hits with Content-Length.
2195
+ // The cap protects against a large INPUT; the cache admission below
2196
+ // is decided on the actual OUTPUT size, which is only known here.
2197
+ if (ctx.method === 'HEAD') {
2198
+ // Mirror the GET status/headers (RFC 9110 §9.3.2) without running
2199
+ // the compression: no Content-Length (unknown), no cache insert.
2200
+ ctx.status = 200;
2201
+ return;
2202
+ }
2203
+ const mtimeMs = fileStat.mtime.getTime();
2204
+ const teeKey = `${cacheKey}:${mtimeMs}:${fileStat.size}`;
2205
+ if (_inflightStreamTees.has(teeKey)) {
2206
+ // Follower: another request is already accumulating this exact
2207
+ // file version. Stream independently (no added latency, no tee
2208
+ // stage) — the cache will be warm for the NEXT request.
2209
+ streamCompressedBody(ctx, toOpen, rawBuffer, encoding);
2210
+ return;
2211
+ }
2212
+ // Leader: stream AND accumulate a copy for the cache.
2213
+ _inflightStreamTees.add(teeKey);
2214
+ let acc = [];
2215
+ let accBytes = 0;
2216
+ // Two admission bounds, both on the real OUTPUT size:
2217
+ // - per entry: a quarter of the cache, so one huge file cannot
2218
+ // evict most of the working set on insert;
2219
+ // - aggregate (_inflightTeeBytes): all in-flight tees together may
2220
+ // never hold more RAM than the cache's own maxSize.
2221
+ const entryCap = Math.floor(serverCacheConfig.compressedFile.maxSize / 4);
2222
+ // Stops accumulating and releases this tee's share of the aggregate
2223
+ // budget. Safe to call more than once.
2224
+ const abandonAccumulation = () => {
2225
+ if (acc) {
2226
+ _inflightTeeBytes -= accBytes;
2227
+ acc = null;
2228
+ }
2229
+ };
2230
+ try {
2231
+ const compress = createStreamCompressor(encoding);
2232
+ const src = rawBuffer
2233
+ ? Readable.from(rawBuffer) // compress from in-memory buffer — no disk I/O
2234
+ : fs.createReadStream(toOpen);
2235
+ const tee = new Transform({
2236
+ transform(chunk, _enc, done) {
2237
+ if (acc) {
2238
+ accBytes += chunk.length;
2239
+ _inflightTeeBytes += chunk.length;
2240
+ if (accBytes > entryCap
2241
+ || _inflightTeeBytes > serverCacheConfig.compressedFile.maxSize) {
2242
+ abandonAccumulation(); // over budget: keep streaming, skip the cache
2243
+ } else {
2244
+ acc.push(chunk);
2245
+ }
2246
+ }
2247
+ done(null, chunk);
2248
+ },
2249
+ });
2250
+ // pipeline (NOT pipe): teardown propagates in BOTH directions —
2251
+ // same fd-leak rationale as streamCompressedBody.
2252
+ ctx.body = pipeline(src, compress, tee, (err) => {
2253
+ _inflightStreamTees.delete(teeKey);
2254
+ if (!err && acc) {
2255
+ // Clean completion only: an aborted or failed stream never
2256
+ // inserts a (truncated) entry.
2257
+ refreshOrInsert(_compressedFileCache, cacheKey, {
2258
+ buffer: Buffer.concat(acc, accBytes),
2259
+ mtime: mtimeMs,
2260
+ size: fileStat.size,
2261
+ insertedAt: Date.now(),
2262
+ }, cached, staleByAge);
2263
+ }
2264
+ abandonAccumulation();
2265
+ if (!err || err.code === 'ERR_STREAM_PREMATURE_CLOSE') return;
2266
+ _logger.error('Stream error:', err);
2267
+ if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
2268
+ });
2269
+ } catch (err) {
2270
+ // Defensive: nothing between add() and pipeline() is expected to
2271
+ // throw synchronously, but a leaked teeKey would silently disable
2272
+ // the tee for this file version forever.
2273
+ _inflightStreamTees.delete(teeKey);
2274
+ abandonAccumulation();
2275
+ throw err;
2276
+ }
2277
+ return;
2132
2278
  } else {
2133
2279
  try {
2134
2280
  // Single-flight: concurrent misses on the same path+encoding
@@ -2195,26 +2341,10 @@ module.exports = function koaClassicServer(
2195
2341
  }
2196
2342
 
2197
2343
  } else {
2198
- // Streaming mode: pipe through zlib transform — Content-Length not known in advance
2344
+ // Streaming mode (compressed cache disabled): pipe through the zlib
2345
+ // transform — Content-Length not known in advance, nothing cached.
2199
2346
  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
- });
2347
+ streamCompressedBody(ctx, toOpen, rawBuffer, encoding);
2218
2348
  } else {
2219
2349
  // HEAD: mirror the GET status and headers (RFC 9110 §9.3.2) — no
2220
2350
  // Content-Length, since the compressed size is unknown without
@@ -2592,3 +2722,18 @@ module.exports = function koaClassicServer(
2592
2722
 
2593
2723
  };
2594
2724
  };
2725
+
2726
+ // ── Test-only internals ──────────────────────────────────────────────────────
2727
+ // NOT part of the public API: exposed so the unit tests can exercise the pure
2728
+ // helpers and the LFU cache directly (eviction order, frequency preservation,
2729
+ // range parsing, validator matching) without a full HTTP round-trip. No
2730
+ // stability guarantee — do not import from application code.
2731
+ module.exports._internals = {
2732
+ LFUCache,
2733
+ parseRangeHeader,
2734
+ ifNoneMatchSatisfied,
2735
+ formatSize,
2736
+ singleFlight,
2737
+ refreshOrInsert,
2738
+ escapeHtml,
2739
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koa-classic-server",
3
- "version": "4.0.0",
3
+ "version": "4.1.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",