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.
- package/README.md +25 -0
- package/index.cjs +346 -69
- 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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
721
|
-
// against unbounded RAM/CPU on huge
|
|
722
|
-
// (multi-GB logs/JSON/CSV), not a
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1875
|
-
// have left behind
|
|
1876
|
-
//
|
|
1877
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
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)
|
|
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.
|
|
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",
|