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.
- package/index.cjs +170 -25
- 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
|
|
721
|
-
// against unbounded RAM/CPU on huge
|
|
722
|
-
// (multi-GB logs/JSON/CSV), not a
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|