koa-classic-server 3.1.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/README.md +191 -747
- package/index.cjs +817 -266
- package/package.json +4 -2
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 } = 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);
|
|
@@ -151,6 +151,7 @@ function buildErrorHtml(title, heading, message) {
|
|
|
151
151
|
const _NOT_FOUND_HTML = buildErrorHtml('URL not found', 'Not Found', 'The requested URL was not found on this server.');
|
|
152
152
|
const _GATEWAY_TIMEOUT_HTML = buildErrorHtml('Gateway Timeout', 'Gateway Timeout', 'The template took too long to render.');
|
|
153
153
|
const _TEMPLATE_ERROR_HTML = buildErrorHtml('Internal Server Error', 'Internal Server Error', 'Template rendering failed for the requested resource.');
|
|
154
|
+
const _INTERNAL_ERROR_HTML = buildErrorHtml('Internal Server Error', 'Internal Server Error', 'The server encountered an unexpected condition.');
|
|
154
155
|
|
|
155
156
|
function sendNotFound(ctx) {
|
|
156
157
|
setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
|
|
@@ -192,6 +193,23 @@ function warnPayload(logger, message) {
|
|
|
192
193
|
: [message];
|
|
193
194
|
}
|
|
194
195
|
|
|
196
|
+
// Config-deprecation warnings, deduplicated once-per-process (per distinct
|
|
197
|
+
// message) so creating many middleware instances doesn't repeat the same nag —
|
|
198
|
+
// same intent as the _showDirContentsDeprecationWarned flag, generalized to
|
|
199
|
+
// multiple messages. These options (urlPrefix, urlsReserved, ...) are v2-stable,
|
|
200
|
+
// so a malformed value is TOLERATED with a warning for now rather than thrown:
|
|
201
|
+
// throwing on a stable option would be a breaking change on a minor upgrade.
|
|
202
|
+
// The next major will flip `warnConfigDeprecation` into a hard throw (the call
|
|
203
|
+
// sites already carry the final message) — see docs/revisione_codice_v3.1.md #11.
|
|
204
|
+
const _configDeprecationsWarned = new Set();
|
|
205
|
+
function warnConfigDeprecation(logger, message) {
|
|
206
|
+
if (_configDeprecationsWarned.has(message)) return;
|
|
207
|
+
_configDeprecationsWarned.add(message);
|
|
208
|
+
logger.warn(...warnPayload(logger,
|
|
209
|
+
'[koa-classic-server] DEPRECATION: ' + message +
|
|
210
|
+
'\n This is tolerated for now and WILL throw in a future major version.'));
|
|
211
|
+
}
|
|
212
|
+
|
|
195
213
|
// Sends an error response for a failed template render. If headers were already
|
|
196
214
|
// flushed by the render itself, destroys the underlying socket instead (the
|
|
197
215
|
// status/body can no longer be changed at that point).
|
|
@@ -387,6 +405,24 @@ function parseRangeHeader(rangeHeader, fileSize) {
|
|
|
387
405
|
return { start, end };
|
|
388
406
|
}
|
|
389
407
|
|
|
408
|
+
// Evaluate an If-None-Match header against our (strong) ETag, per RFC 9110 §13.1.2.
|
|
409
|
+
// The header is either "*" (matches any existing representation) or a comma-separated
|
|
410
|
+
// list of entity-tags. The comparison is the WEAK function: a leading "W/" is ignored
|
|
411
|
+
// on both sides (a proxy/CDN may have weakened the validator). Returns true when the
|
|
412
|
+
// precondition is satisfied, i.e. the client's cached representation is still current
|
|
413
|
+
// and the server should answer 304 (Not Modified) for a GET/HEAD.
|
|
414
|
+
function ifNoneMatchSatisfied(headerValue, etag) {
|
|
415
|
+
if (!headerValue) return false;
|
|
416
|
+
const trimmed = headerValue.trim();
|
|
417
|
+
if (trimmed === '*') return true; // "*" matches any existing representation
|
|
418
|
+
const target = etag.replace(/^W\//, ''); // our ETag is strong, but strip defensively
|
|
419
|
+
for (const part of trimmed.split(',')) {
|
|
420
|
+
const tag = part.trim().replace(/^W\//, '');
|
|
421
|
+
if (tag && tag === target) return true;
|
|
422
|
+
}
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
|
|
390
426
|
// LFU cache with O(1) eviction using frequency buckets.
|
|
391
427
|
// peek(key) — read without touching frequency (for staleness checks)
|
|
392
428
|
// get(key) — read and increment frequency
|
|
@@ -420,10 +456,17 @@ class LFUCache {
|
|
|
420
456
|
}
|
|
421
457
|
|
|
422
458
|
set(key, entry) {
|
|
459
|
+
// An entry larger than the whole cache can never fit: bail out BEFORE the
|
|
460
|
+
// eviction loop, otherwise it would flush every other entry for nothing.
|
|
461
|
+
// Warn (throttled) so the operator learns the cache is undersized for
|
|
462
|
+
// this file instead of it silently never being cached.
|
|
463
|
+
if (entry.buffer.length > this.maxSize) {
|
|
464
|
+
this._warnThrottled(`[koa-classic-server] serverCache.${this.cacheLabel}: entry of ${entry.buffer.length} bytes exceeds maxSize (${this.maxSize} bytes) and will never be cached. Consider increasing maxSize.`);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
423
467
|
while (this.currentSize + entry.buffer.length > this.maxSize && this._keyMap.size > 0) {
|
|
424
468
|
this._evictOne();
|
|
425
469
|
}
|
|
426
|
-
if (this.currentSize + entry.buffer.length > this.maxSize) return; // entry too large for cache
|
|
427
470
|
|
|
428
471
|
this._keyMap.set(key, { ...entry, freq: 1 });
|
|
429
472
|
this._addToFreqBucket(key, 1);
|
|
@@ -499,20 +542,60 @@ class LFUCache {
|
|
|
499
542
|
bucket.delete(evictKey);
|
|
500
543
|
if (bucket.size === 0) this._freqMap.delete(this._minFreq);
|
|
501
544
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
545
|
+
this._warnThrottled(`[koa-classic-server] serverCache.${this.cacheLabel}: maxSize reached, evicting LFU entries. Consider increasing maxSize.`);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Emits a warning at most once per warnInterval ms (0 = always, false = never).
|
|
549
|
+
_warnThrottled(message) {
|
|
550
|
+
if (this.warnInterval === false) return;
|
|
551
|
+
const now = Date.now();
|
|
552
|
+
if (now - this._lastWarnAt >= this.warnInterval) {
|
|
553
|
+
this.logger.warn(message);
|
|
554
|
+
this._lastWarnAt = now;
|
|
508
555
|
}
|
|
509
556
|
}
|
|
510
557
|
}
|
|
511
558
|
|
|
559
|
+
// Single-flight job map helper: joins the in-flight job for `key`, or starts
|
|
560
|
+
// `work()` as the leader. Concurrent callers share the same Promise — including
|
|
561
|
+
// a rejection, so on failure every waiter falls back together instead of
|
|
562
|
+
// re-running the work. The entry is removed as soon as the job settles
|
|
563
|
+
// (success or failure), so the next request after a failure retries from
|
|
564
|
+
// scratch and the map only ever holds jobs actually in progress.
|
|
565
|
+
function singleFlight(map, key, work) {
|
|
566
|
+
let job = map.get(key);
|
|
567
|
+
if (!job) {
|
|
568
|
+
job = work();
|
|
569
|
+
map.set(key, job);
|
|
570
|
+
const clean = () => map.delete(key);
|
|
571
|
+
job.then(clean, clean);
|
|
572
|
+
}
|
|
573
|
+
return job;
|
|
574
|
+
}
|
|
575
|
+
|
|
512
576
|
// Upserts a fresh entry into an LFUCache. When the previous entry was only
|
|
513
577
|
// stale-by-age (mtime + size unchanged), updates in place so the existing
|
|
514
578
|
// frequency counter survives — important for popular files refreshed by maxAge.
|
|
515
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
|
+
|
|
516
599
|
function refreshOrInsert(cache, key, newEntry, cached, staleByAge) {
|
|
517
600
|
const canRefreshInPlace = cached
|
|
518
601
|
&& staleByAge
|
|
@@ -560,16 +643,36 @@ module.exports = function koaClassicServer(
|
|
|
560
643
|
// when visible entries > entriesPerPage. Page index via
|
|
561
644
|
// ?page=N (0-based); out-of-range values are clamped silently.
|
|
562
645
|
// Must be a finite integer >= 0; 0 = disabled (no pagination).
|
|
646
|
+
trailingSlash: true, // Canonical trailing-slash enforcement (V4). Default true:
|
|
647
|
+
// GET /dir (directory, no slash) → 301 redirect to /dir/
|
|
648
|
+
// GET /file/ (file, trailing slash) → 404
|
|
649
|
+
// so relative links in an index page resolve against the
|
|
650
|
+
// directory. Set false for the v3 behavior (serve directories
|
|
651
|
+
// and files regardless of the trailing slash).
|
|
563
652
|
},
|
|
564
|
-
index: [
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
653
|
+
index: [], // Index file name(s) - must be an ARRAY.
|
|
654
|
+
// Default: [] — no index file is looked up; directories always
|
|
655
|
+
// show the listing (when dirListing.enabled). Configure explicitly
|
|
656
|
+
// for the classic index-file behavior, e.g. ["index.html"].
|
|
657
|
+
// - Array of strings: ["index.html", "index.htm", "default.html"]
|
|
658
|
+
// - Array of RegExp: [/index\.html/i, /default\.(html|htm)/i]
|
|
659
|
+
// - Mixed array: ["index.html", /index\.[eE][jJ][sS]/]
|
|
660
|
+
// Priority is determined by array order (first match wins)
|
|
661
|
+
urlPrefix: "", // URL path prefix. Should start with "/" and NOT end with "/"
|
|
662
|
+
// (e.g. "/static"); "" disables the prefix. A malformed value
|
|
663
|
+
// is tolerated with a deprecation warning for now (behavior
|
|
664
|
+
// unchanged) and WILL throw in the next major version.
|
|
665
|
+
urlsReserved: [], // Reserved first-level paths passed through to next().
|
|
666
|
+
// Each entry should be a single first-level path: a leading
|
|
667
|
+
// "/" plus one segment, no further "/" (e.g. "/admin").
|
|
668
|
+
// Malformed entries are tolerated with a deprecation warning
|
|
669
|
+
// for now and WILL throw in the next major version (a
|
|
670
|
+
// non-string entry is dropped to avoid a per-request 500).
|
|
571
671
|
template: {
|
|
572
672
|
render: undefined, // Template rendering function: async (ctx, next, filePath, rawBuffer, signal) => {}
|
|
673
|
+
// rawBuffer (4th arg, may be null) is READ-ONLY: the same Buffer
|
|
674
|
+
// instance is shared with the server cache and with concurrent
|
|
675
|
+
// requests — mutating it corrupts other responses.
|
|
573
676
|
ext: [], // File extensions to process with template.render
|
|
574
677
|
renderTimeout: 30000, // Max ms allowed for template.render (number ≥ 0; 0 = disabled).
|
|
575
678
|
// On timeout responds 504 Gateway Timeout. The render receives an
|
|
@@ -628,6 +731,19 @@ module.exports = function koaClassicServer(
|
|
|
628
731
|
enabled: true, // master switch (false = disable all compression)
|
|
629
732
|
encodings: ['br', 'gzip'], // algorithms in priority order; [] = disable
|
|
630
733
|
minFileSize: 1024, // min file size in bytes to compress; false = no minimum
|
|
734
|
+
maxFileSize: 10485760, // max file size (bytes) for the buffered high-quality
|
|
735
|
+
// compression path (whole file in RAM → brotli Q11 →
|
|
736
|
+
// result cached). Default: 10 MB; false = no cap.
|
|
737
|
+
// Larger files are STILL compressed, but via the
|
|
738
|
+
// bounded-RAM streaming mode (brotli Q4 / gzip 6, no
|
|
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.
|
|
631
747
|
mimeTypes: [], // compressible MIME types (replaces default list if provided)
|
|
632
748
|
},
|
|
633
749
|
// compression: false // shorthand to disable all compression
|
|
@@ -656,8 +772,31 @@ module.exports = function koaClassicServer(
|
|
|
656
772
|
|
|
657
773
|
const normalizedRootDir = path.resolve(rootDir);
|
|
658
774
|
|
|
659
|
-
|
|
660
|
-
|
|
775
|
+
// Options must be a plain object, or omitted entirely (the `opts = {}`
|
|
776
|
+
// default covers undefined). An explicit null — or any other non-object —
|
|
777
|
+
// is a configuration bug: surface it with a helpful error instead of a raw
|
|
778
|
+
// TypeError further down (or, worse, a silent fall-through to defaults).
|
|
779
|
+
if (opts === null || typeof opts !== 'object' || Array.isArray(opts)) {
|
|
780
|
+
throw new Error(
|
|
781
|
+
'[koa-classic-server] options must be a plain object (or omitted entirely). Got: ' +
|
|
782
|
+
(opts === null ? 'null' : Array.isArray(opts) ? 'an array' : typeof opts)
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Work on a copy: the factory normalizes options in place and must never
|
|
787
|
+
// mutate the caller's configuration object (reusing one config for two
|
|
788
|
+
// instances, or inspecting it after startup, would otherwise observe the
|
|
789
|
+
// rewritten values). Only the two nested objects the normalization writes
|
|
790
|
+
// into need their own copy — template (render/ext/renderTimeout) and
|
|
791
|
+
// hideExtension (ext/redirect); every other namespace is only read and
|
|
792
|
+
// normalized into new internal structures.
|
|
793
|
+
const options = { ...opts };
|
|
794
|
+
options.template = (opts.template && typeof opts.template === 'object' && !Array.isArray(opts.template))
|
|
795
|
+
? { ...opts.template }
|
|
796
|
+
: {};
|
|
797
|
+
if (options.hideExtension && typeof options.hideExtension === 'object' && !Array.isArray(options.hideExtension)) {
|
|
798
|
+
options.hideExtension = { ...options.hideExtension };
|
|
799
|
+
}
|
|
661
800
|
|
|
662
801
|
const _logger = normalizeLogger(options.logger);
|
|
663
802
|
|
|
@@ -732,6 +871,15 @@ module.exports = function koaClassicServer(
|
|
|
732
871
|
'dirListing.entriesPerPage',
|
|
733
872
|
100
|
|
734
873
|
),
|
|
874
|
+
// Canonical trailing-slash enforcement (V4). Default ON:
|
|
875
|
+
// - GET /dir (directory, no slash) → 301 redirect to /dir/
|
|
876
|
+
// - GET /file/ (file, trailing slash) → 404
|
|
877
|
+
// so relative links in an index page resolve against the directory and
|
|
878
|
+
// a file is only reachable at its slash-less URL. Set false to keep the
|
|
879
|
+
// v3 behavior (serve directories and files regardless of trailing slash).
|
|
880
|
+
trailingSlash: userDirListing && userDirListing.trailingSlash !== undefined
|
|
881
|
+
? !!userDirListing.trailingSlash
|
|
882
|
+
: true,
|
|
735
883
|
};
|
|
736
884
|
|
|
737
885
|
// Normalize index option to array format
|
|
@@ -755,9 +903,68 @@ module.exports = function koaClassicServer(
|
|
|
755
903
|
options.index = [];
|
|
756
904
|
}
|
|
757
905
|
|
|
758
|
-
|
|
906
|
+
// ── urlPrefix / urlsReserved validation (V3.1, deprecation-warn) ──
|
|
907
|
+
// The request-time matcher depends on an implicit format for both options,
|
|
908
|
+
// and a malformed value fails SILENTLY: a urlPrefix with a stray
|
|
909
|
+
// leading/trailing slash makes the middleware serve nothing (it always falls
|
|
910
|
+
// through to next()), and a urlsReserved entry without a leading slash makes
|
|
911
|
+
// the reservation never match (the path is served instead of passed on).
|
|
912
|
+
// Both are v2-stable options, so instead of throwing (a breaking change on a
|
|
913
|
+
// minor upgrade — a mis-slashed value that "worked" only by falling through
|
|
914
|
+
// to a downstream handler would suddenly change behavior), we WARN and leave
|
|
915
|
+
// the runtime behavior exactly as it is today. The next major turns these
|
|
916
|
+
// warnings into throws. The one exception is a non-string urlsReserved entry:
|
|
917
|
+
// it would 500 on every request (value.substring is not a function), which is
|
|
918
|
+
// not working behavior, so it is dropped defensively (still warned).
|
|
919
|
+
if (options.urlPrefix === undefined) {
|
|
920
|
+
options.urlPrefix = "";
|
|
921
|
+
} else if (typeof options.urlPrefix !== 'string') {
|
|
922
|
+
// Unchanged behavior: pre-existing code already coerced non-string → "".
|
|
923
|
+
warnConfigDeprecation(_logger,
|
|
924
|
+
'urlPrefix should be a string like "/static" (or "" for no prefix); got ' +
|
|
925
|
+
(options.urlPrefix === null ? 'null' : typeof options.urlPrefix) + ' — treating it as "".');
|
|
926
|
+
options.urlPrefix = "";
|
|
927
|
+
} else if (options.urlPrefix !== "" && (!options.urlPrefix.startsWith('/') || options.urlPrefix.endsWith('/'))) {
|
|
928
|
+
// Left as-is: the matcher behaves exactly as today (falls through to
|
|
929
|
+
// next() under this prefix). Warn only — no behavior change.
|
|
930
|
+
warnConfigDeprecation(_logger,
|
|
931
|
+
'urlPrefix should start with "/" and not end with "/" (use "" to disable); got ' +
|
|
932
|
+
JSON.stringify(options.urlPrefix) + ' — it will not route correctly until corrected.');
|
|
933
|
+
}
|
|
759
934
|
const _urlPrefixParts = options.urlPrefix.split("/");
|
|
760
|
-
|
|
935
|
+
|
|
936
|
+
if (options.urlsReserved === undefined) {
|
|
937
|
+
options.urlsReserved = [];
|
|
938
|
+
} else if (!Array.isArray(options.urlsReserved)) {
|
|
939
|
+
// Unchanged behavior: pre-existing code already coerced non-array → [].
|
|
940
|
+
warnConfigDeprecation(_logger,
|
|
941
|
+
'urlsReserved should be an array of first-level paths like ["/admin"]; got ' +
|
|
942
|
+
(options.urlsReserved === null ? 'null' : typeof options.urlsReserved) + ' — treating it as [].');
|
|
943
|
+
options.urlsReserved = [];
|
|
944
|
+
} else {
|
|
945
|
+
const cleaned = [];
|
|
946
|
+
for (const value of options.urlsReserved) {
|
|
947
|
+
if (typeof value !== 'string') {
|
|
948
|
+
// Dropped defensively: a non-string entry would throw at match
|
|
949
|
+
// time (value.substring is not a function) → a 500 on every
|
|
950
|
+
// request. Dropping it can't break working code.
|
|
951
|
+
warnConfigDeprecation(_logger,
|
|
952
|
+
'urlsReserved entries must be strings like "/admin"; dropping a non-string (' +
|
|
953
|
+
(value === null ? 'null' : typeof value) + ') entry.');
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
// Malformed but non-crashing (missing leading slash, extra segment,
|
|
957
|
+
// trailing slash, empty): kept as-is so the matcher behaves exactly
|
|
958
|
+
// as today (it simply won't match). Warn only.
|
|
959
|
+
if (value === '' || !value.startsWith('/') || value.indexOf('/', 1) !== -1) {
|
|
960
|
+
warnConfigDeprecation(_logger,
|
|
961
|
+
'each urlsReserved entry should be a single first-level path — a leading "/" plus one ' +
|
|
962
|
+
'segment, e.g. "/admin"; got ' + JSON.stringify(value) + ' — it will not match until corrected.');
|
|
963
|
+
}
|
|
964
|
+
cleaned.push(value);
|
|
965
|
+
}
|
|
966
|
+
options.urlsReserved = cleaned;
|
|
967
|
+
}
|
|
761
968
|
options.template.render = (options.template.render === undefined || typeof options.template.render === 'function') ? options.template.render : undefined;
|
|
762
969
|
options.template.ext = Array.isArray(options.template.ext) ? options.template.ext : [];
|
|
763
970
|
|
|
@@ -788,7 +995,21 @@ module.exports = function koaClassicServer(
|
|
|
788
995
|
);
|
|
789
996
|
}
|
|
790
997
|
|
|
791
|
-
|
|
998
|
+
// browserCacheMaxAge: non-negative integer seconds. An invalid value (negative, NaN,
|
|
999
|
+
// non-integer, Infinity, or a string) previously fell back to 3600 SILENTLY (#12).
|
|
1000
|
+
// Consistent with #11: warn now and keep the fallback; a future major will throw
|
|
1001
|
+
// (validateNonNegativeInt semantics) — so what warns here is exactly what will throw then.
|
|
1002
|
+
if (options.browserCacheMaxAge === undefined) {
|
|
1003
|
+
options.browserCacheMaxAge = 3600;
|
|
1004
|
+
} else if (!(typeof options.browserCacheMaxAge === 'number'
|
|
1005
|
+
&& Number.isInteger(options.browserCacheMaxAge)
|
|
1006
|
+
&& options.browserCacheMaxAge >= 0)) {
|
|
1007
|
+
warnConfigDeprecation(_logger,
|
|
1008
|
+
'browserCacheMaxAge must be a non-negative integer (seconds). Got: ' +
|
|
1009
|
+
String(options.browserCacheMaxAge) + '. Falling back to the default 3600 for now.');
|
|
1010
|
+
options.browserCacheMaxAge = 3600;
|
|
1011
|
+
}
|
|
1012
|
+
// else: valid — keep as-is
|
|
792
1013
|
options.browserCacheEnabled = typeof options.browserCacheEnabled === 'boolean' ? options.browserCacheEnabled : false;
|
|
793
1014
|
options.useOriginalUrl = typeof options.useOriginalUrl === 'boolean' ? options.useOriginalUrl : true;
|
|
794
1015
|
|
|
@@ -1012,6 +1233,7 @@ module.exports = function koaClassicServer(
|
|
|
1012
1233
|
enabled: true,
|
|
1013
1234
|
encodings: ['br', 'gzip'], // priority order: brotli first, gzip as fallback
|
|
1014
1235
|
minFileSize: 1024, // bytes; skip compression for files smaller than this
|
|
1236
|
+
maxFileSize: 10485760, // bytes; above this the buffered+cached path is skipped (streaming instead)
|
|
1015
1237
|
mimeTypes: new Set(DEFAULT_COMPRESSIBLE_MIME_TYPES),
|
|
1016
1238
|
};
|
|
1017
1239
|
}
|
|
@@ -1034,11 +1256,17 @@ module.exports = function koaClassicServer(
|
|
|
1034
1256
|
const minFileSize = compression.minFileSize === false ? false
|
|
1035
1257
|
: (typeof compression.minFileSize === 'number' && compression.minFileSize >= 0 ? compression.minFileSize : 1024);
|
|
1036
1258
|
|
|
1259
|
+
// Cap for the buffered (whole-file-in-RAM, max-quality, cached) compression
|
|
1260
|
+
// path. false = no cap. Files above the cap still get compressed via the
|
|
1261
|
+
// bounded-RAM streaming mode — safety net, not a serving restriction.
|
|
1262
|
+
const maxFileSize = compression.maxFileSize === false ? false
|
|
1263
|
+
: (typeof compression.maxFileSize === 'number' && compression.maxFileSize > 0 ? compression.maxFileSize : 10485760);
|
|
1264
|
+
|
|
1037
1265
|
const mimeTypes = Array.isArray(compression.mimeTypes) && compression.mimeTypes.length > 0
|
|
1038
1266
|
? compression.mimeTypes
|
|
1039
1267
|
: DEFAULT_COMPRESSIBLE_MIME_TYPES;
|
|
1040
1268
|
|
|
1041
|
-
return { enabled, encodings, minFileSize, mimeTypes: new Set(mimeTypes) };
|
|
1269
|
+
return { enabled, encodings, minFileSize, maxFileSize, mimeTypes: new Set(mimeTypes) };
|
|
1042
1270
|
}
|
|
1043
1271
|
|
|
1044
1272
|
// Normalize and validate the serverCache option into a clean internal structure.
|
|
@@ -1207,12 +1435,71 @@ module.exports = function koaClassicServer(
|
|
|
1207
1435
|
_logger
|
|
1208
1436
|
);
|
|
1209
1437
|
|
|
1210
|
-
//
|
|
1211
|
-
//
|
|
1438
|
+
// Single-flight maps for cache population (thundering-herd protection):
|
|
1439
|
+
// N concurrent misses on the same key share ONE read (+ compression) instead
|
|
1440
|
+
// of N duplicated jobs. Entries live only while a job is in flight. Keys
|
|
1441
|
+
// include the stat'd mtime+size so requests that observed different versions
|
|
1442
|
+
// of the file never share a job (each response stays coherent with its own
|
|
1443
|
+
// ETag/Last-Modified).
|
|
1444
|
+
const _inflightRawReads = new Map(); // `${path}:${mtime}:${size}` → Promise<Buffer>
|
|
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
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// Returns the server-preferred enabled encoding the client is willing to accept.
|
|
1479
|
+
// Server preference order (compressionConfig.encodings) still wins; the
|
|
1480
|
+
// Accept-Encoding q-values are used only to EXCLUDE encodings the client refuses
|
|
1481
|
+
// (q=0), per RFC 9110 §12.5.3. Token match is exact, not substring ("x-gzip" is
|
|
1482
|
+
// NOT "gzip"). A "*" supplies the q-value for any encoding not explicitly listed.
|
|
1212
1483
|
function getClientEncoding(acceptEncoding) {
|
|
1213
1484
|
if (!acceptEncoding) return null;
|
|
1485
|
+
const qValues = new Map(); // token → q-value
|
|
1486
|
+
for (const part of acceptEncoding.split(',')) {
|
|
1487
|
+
const [tokenRaw, ...params] = part.split(';');
|
|
1488
|
+
const token = tokenRaw.trim().toLowerCase();
|
|
1489
|
+
if (!token) continue;
|
|
1490
|
+
let q = 1;
|
|
1491
|
+
for (const p of params) {
|
|
1492
|
+
const m = /^\s*q=(\d+(?:\.\d+)?)\s*$/i.exec(p);
|
|
1493
|
+
if (m) q = parseFloat(m[1]);
|
|
1494
|
+
}
|
|
1495
|
+
qValues.set(token, q);
|
|
1496
|
+
}
|
|
1497
|
+
const star = qValues.get('*');
|
|
1214
1498
|
for (const enc of compressionConfig.encodings) {
|
|
1215
|
-
|
|
1499
|
+
let q = qValues.get(enc);
|
|
1500
|
+
if (q === undefined) q = star; // not listed → fall back to "*" if present
|
|
1501
|
+
if (q === undefined) continue; // not listed and no "*" → not offered
|
|
1502
|
+
if (q > 0) return enc; // acceptable → pick it (server preference order)
|
|
1216
1503
|
}
|
|
1217
1504
|
return null;
|
|
1218
1505
|
}
|
|
@@ -1308,6 +1595,14 @@ module.exports = function koaClassicServer(
|
|
|
1308
1595
|
const urlToUse = options.useOriginalUrl ? ctx.originalUrl : ctx.url;
|
|
1309
1596
|
const _origin = ctx.protocol + '://' + ctx.host;
|
|
1310
1597
|
const fullUrl = _origin + urlToUse;
|
|
1598
|
+
|
|
1599
|
+
// Whether the client's request path ends with "/" — captured from the
|
|
1600
|
+
// raw originalUrl BEFORE the trailing slash is stripped for URL parsing
|
|
1601
|
+
// below. Drives the canonical trailing-slash redirect / 404 in the
|
|
1602
|
+
// directory / file branch. "/" (root) counts as ending with a slash, so
|
|
1603
|
+
// it is already canonical and never redirects.
|
|
1604
|
+
const _rawOriginalPath = ctx.originalUrl.split('?')[0];
|
|
1605
|
+
const _pathEndsWithSlash = _rawOriginalPath.endsWith('/');
|
|
1311
1606
|
// Parse the request URL. `new URL()` throws on an invalid Host header
|
|
1312
1607
|
// (e.g. "Host: bad host") — reject as 400 rather than letting it surface as 500.
|
|
1313
1608
|
let pageHref = '';
|
|
@@ -1357,194 +1652,291 @@ module.exports = function koaClassicServer(
|
|
|
1357
1652
|
}
|
|
1358
1653
|
}
|
|
1359
1654
|
|
|
1360
|
-
//
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1655
|
+
// From this point on the middleware OWNS the request: every early
|
|
1656
|
+
// pass-through above has already returned, and no next() is called below
|
|
1657
|
+
// (the template render's next goes through tryRenderTemplate's own catch).
|
|
1658
|
+
// Last-resort net: an unexpected failure on any unguarded path must not
|
|
1659
|
+
// leak to Koa's default handler (a plain-text 500 without the middleware's
|
|
1660
|
+
// security headers, logged outside the operator's `logger`). Errors from
|
|
1661
|
+
// downstream middleware are NOT masked — they never reach this try.
|
|
1662
|
+
try {
|
|
1663
|
+
// Path traversal protection: build and validate safe file path
|
|
1664
|
+
let requestedPath = "";
|
|
1665
|
+
if (pageHrefOutPrefix.pathname === "/") {
|
|
1666
|
+
requestedPath = "";
|
|
1667
|
+
} else {
|
|
1668
|
+
// decodeURIComponent() throws URIError on malformed percent-encoding
|
|
1669
|
+
// (e.g. "/%", "/%zz", a truncated UTF-8 sequence) — reject as 400
|
|
1670
|
+
// rather than letting it surface as an unhandled 500.
|
|
1671
|
+
try {
|
|
1672
|
+
requestedPath = decodeURIComponent(pageHrefOutPrefix.pathname);
|
|
1673
|
+
} catch {
|
|
1674
|
+
sendBadRequest(ctx);
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
// Null byte guard: path.normalize() throws ERR_INVALID_ARG_VALUE for paths
|
|
1680
|
+
// containing \0. Reject early with 400 Bad Request before it reaches fs calls.
|
|
1681
|
+
if (requestedPath.includes('\0')) {
|
|
1371
1682
|
sendBadRequest(ctx);
|
|
1372
1683
|
return;
|
|
1373
1684
|
}
|
|
1374
|
-
}
|
|
1375
1685
|
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
// backslash sequences (path.normalize converts \ to / before the check).
|
|
1392
|
-
// Returns 404 (not 403) so "outside root" is indistinguishable from "not found",
|
|
1393
|
-
// matching the symlink-escape and hidden-entry outcomes.
|
|
1394
|
-
if (!_isWithinRoot(fullPath, normalizedRootDir)) {
|
|
1395
|
-
sendNotFound(ctx);
|
|
1396
|
-
return;
|
|
1397
|
-
}
|
|
1686
|
+
const normalizedPath = path.normalize(requestedPath);
|
|
1687
|
+
const fullPath = path.join(normalizedRootDir, normalizedPath);
|
|
1688
|
+
|
|
1689
|
+
// Security check: ensure resolved path is within rootDir. Uses the shared
|
|
1690
|
+
// _isWithinRoot() helper, which is boundary-aware: it matches rootDir exactly
|
|
1691
|
+
// or rootDir + path.sep, never a sibling (e.g. /srv/wwwsecret for root /srv/www) —
|
|
1692
|
+
// hardened defense in depth against a future change to how fullPath is built.
|
|
1693
|
+
// Covers: ../ traversal, URL-encoded variants (%2e%2e%2f), and on Windows
|
|
1694
|
+
// backslash sequences (path.normalize converts \ to / before the check).
|
|
1695
|
+
// Returns 404 (not 403) so "outside root" is indistinguishable from "not found",
|
|
1696
|
+
// matching the symlink-escape and hidden-entry outcomes.
|
|
1697
|
+
if (!_isWithinRoot(fullPath, normalizedRootDir)) {
|
|
1698
|
+
sendNotFound(ctx);
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1398
1701
|
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1702
|
+
// Hidden check: block requests that traverse a hidden directory.
|
|
1703
|
+
// Stops at length-1 because the leaf (the file or dir being served) is
|
|
1704
|
+
// checked separately by the file/listing path with its real stat.isDirectory().
|
|
1705
|
+
if (requestedPath !== '') {
|
|
1706
|
+
const segments = normalizedPath.split(path.sep).filter(Boolean);
|
|
1707
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
1708
|
+
const segName = segments[i];
|
|
1709
|
+
const segRelPath = segments.slice(0, i + 1).join('/');
|
|
1710
|
+
if (isHiddenEntry(segName, segRelPath, true)) {
|
|
1711
|
+
sendNotFound(ctx);
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1410
1714
|
}
|
|
1411
1715
|
}
|
|
1412
|
-
}
|
|
1413
1716
|
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
//
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1717
|
+
let toOpen = fullPath;
|
|
1718
|
+
|
|
1719
|
+
// hideExtension logic: redirect URLs with extension and resolve clean URLs
|
|
1720
|
+
if (options.hideExtension) {
|
|
1721
|
+
const hideExt = options.hideExtension.ext;
|
|
1722
|
+
const hideRedirect = options.hideExtension.redirect;
|
|
1723
|
+
|
|
1724
|
+
// Trailing slash check via string — avoids a full new URL() construction
|
|
1725
|
+
const rawPath = urlToUse.split('?')[0];
|
|
1726
|
+
const hadTrailingSlash = rawPath.length > 1 && rawPath.endsWith('/');
|
|
1727
|
+
|
|
1728
|
+
// Model B (V4): an extension URL is canonicalized to its clean form only when it
|
|
1729
|
+
// has NO trailing slash. A trailing slash means directory intent, so /foo.ejs/
|
|
1730
|
+
// falls through to the file/dir dispatch, where a file requested with a trailing
|
|
1731
|
+
// slash is a 404 (finding #3). We gate on _pathEndsWithSlash — the very flag that
|
|
1732
|
+
// #3's file-branch 404 uses — so "skip the redirect" and "404 downstream" are the
|
|
1733
|
+
// same condition. requestedPath is decoded (and already slash-stripped by the URL
|
|
1734
|
+
// parse), so a percent-encoded dot (/foo%2Eejs → /foo.ejs) matches consistently (#14),
|
|
1735
|
+
// while /foo%2Eejs/ is excluded here by _pathEndsWithSlash and 404s downstream.
|
|
1736
|
+
if (!_pathEndsWithSlash && requestedPath.endsWith(hideExt)) {
|
|
1737
|
+
// Build redirect target using ctx.originalUrl (always, regardless of
|
|
1738
|
+
// useOriginalUrl). With useOriginalUrl: false the URL-parsing prologue
|
|
1739
|
+
// validated ctx.url (the rewritten one), NOT originalUrl — a malformed
|
|
1740
|
+
// originalUrl (e.g. an absolute-form request target, legal in HTTP/1.1)
|
|
1741
|
+
// makes this constructor throw. Same treatment as the other malformed
|
|
1742
|
+
// client input: 400 Bad Request, not an unhandled error.
|
|
1743
|
+
let originalUrlObj;
|
|
1744
|
+
try {
|
|
1745
|
+
originalUrlObj = new URL(_origin + ctx.originalUrl);
|
|
1746
|
+
} catch {
|
|
1747
|
+
sendBadRequest(ctx);
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1441
1750
|
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1751
|
+
// Build the clean target in DECODED space: the extension may be percent-encoded
|
|
1752
|
+
// in the raw URL (e.g. "%2Eejs"), so decoding first makes it literal and
|
|
1753
|
+
// sliceable, then we re-encode per segment (#14, #20). The URL constructor
|
|
1754
|
+
// already validated the encoding, so decodeURIComponent cannot throw — but
|
|
1755
|
+
// guard for symmetry with the other malformed-client-input guards.
|
|
1756
|
+
let decodedPath;
|
|
1757
|
+
try {
|
|
1758
|
+
decodedPath = decodeURIComponent(originalUrlObj.pathname);
|
|
1759
|
+
} catch {
|
|
1760
|
+
sendBadRequest(ctx);
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
let cleanPath = decodedPath.slice(0, decodedPath.length - hideExt.length);
|
|
1765
|
+
|
|
1766
|
+
// Special case: /index.ejs → /, /sezione/index.ejs → /sezione/
|
|
1767
|
+
const baseName = path.basename(cleanPath);
|
|
1768
|
+
if (options.index && options.index.length > 0) {
|
|
1769
|
+
for (const pattern of options.index) {
|
|
1770
|
+
if (typeof pattern === 'string' && (baseName + hideExt) === pattern) {
|
|
1771
|
+
// Redirect to the directory (with trailing slash)
|
|
1772
|
+
cleanPath = cleanPath.slice(0, cleanPath.length - baseName.length);
|
|
1773
|
+
break;
|
|
1774
|
+
}
|
|
1453
1775
|
}
|
|
1454
1776
|
}
|
|
1455
|
-
}
|
|
1456
1777
|
|
|
1457
|
-
|
|
1458
|
-
|
|
1778
|
+
// Re-encode per path segment: round-trips spaces, "%", etc. back into a valid
|
|
1779
|
+
// URL. encodeURIComponent may over-encode sub-delims in exotic filenames, but
|
|
1780
|
+
// the result always resolves to the same path.
|
|
1781
|
+
let redirectPath = cleanPath.split('/').map(encodeURIComponent).join('/');
|
|
1782
|
+
|
|
1783
|
+
// Open-redirect guard LAST: a Location starting with "//" (or "/\") is a
|
|
1784
|
+
// protocol-relative URL that would navigate off-origin ("GET //evil.com/foo.ejs").
|
|
1785
|
+
// Placed after re-encoding because a decoded "%2F" can reintroduce a leading
|
|
1786
|
+
// "//"; collapse any run of leading slashes/backslashes to a single slash.
|
|
1787
|
+
if (redirectPath.length > 1 && (redirectPath.charCodeAt(1) === 0x2F || redirectPath.charCodeAt(1) === 0x5C)) {
|
|
1788
|
+
redirectPath = '/' + redirectPath.replace(/^[/\\]+/, '');
|
|
1789
|
+
}
|
|
1459
1790
|
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
return;
|
|
1463
|
-
}
|
|
1791
|
+
// Preserve query string
|
|
1792
|
+
const redirectUrl = redirectPath + (originalUrlObj.search || '');
|
|
1464
1793
|
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
const pathWithExt = fullPath + hideExt;
|
|
1794
|
+
ctx.status = hideRedirect;
|
|
1795
|
+
ctx.redirect(redirectUrl);
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1470
1798
|
|
|
1471
|
-
//
|
|
1472
|
-
if (
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1799
|
+
// Check if URL has no extension → try adding the configured extension
|
|
1800
|
+
// Skip if original URL had trailing slash (trailing slash = directory intent)
|
|
1801
|
+
const extOfRequested = path.extname(requestedPath);
|
|
1802
|
+
if (!extOfRequested && requestedPath !== '' && !requestedPath.endsWith('/') && !hadTrailingSlash) {
|
|
1803
|
+
const pathWithExt = fullPath + hideExt;
|
|
1804
|
+
|
|
1805
|
+
// Security check: ensure resolved path is still within rootDir (boundary-aware)
|
|
1806
|
+
if (_isWithinRoot(pathWithExt, normalizedRootDir)) {
|
|
1807
|
+
try {
|
|
1808
|
+
const statWithExt = await fs.promises.stat(pathWithExt);
|
|
1809
|
+
if (statWithExt.isFile()) {
|
|
1810
|
+
// File with extension exists, serve it
|
|
1811
|
+
toOpen = pathWithExt;
|
|
1812
|
+
}
|
|
1813
|
+
} catch {
|
|
1814
|
+
// File with extension doesn't exist, continue normal flow
|
|
1478
1815
|
}
|
|
1479
|
-
} catch {
|
|
1480
|
-
// File with extension doesn't exist, continue normal flow
|
|
1481
1816
|
}
|
|
1482
1817
|
}
|
|
1483
1818
|
}
|
|
1484
|
-
}
|
|
1485
1819
|
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1820
|
+
// Check if path exists
|
|
1821
|
+
let stat;
|
|
1822
|
+
try {
|
|
1823
|
+
stat = await fs.promises.stat(toOpen);
|
|
1824
|
+
} catch {
|
|
1825
|
+
// File/directory doesn't exist or can't be accessed
|
|
1826
|
+
sendNotFound(ctx);
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// Hidden check: block access to the requested file or directory itself
|
|
1831
|
+
if (requestedPath !== '') {
|
|
1832
|
+
const entryName = path.basename(toOpen);
|
|
1833
|
+
const entryRelPath = path.relative(normalizedRootDir, toOpen).split(path.sep).join('/');
|
|
1834
|
+
if (isHiddenEntry(entryName, entryRelPath, stat.isDirectory())) {
|
|
1835
|
+
sendNotFound(ctx);
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1495
1839
|
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
if (
|
|
1840
|
+
// Symlink boundary check (V-1): in protected modes reject any requested file
|
|
1841
|
+
// or directory whose realpath escapes rootDir (or, in 'deny' mode, any symlink
|
|
1842
|
+
// resolved below rootDir). The `_symlinkMode !== 'follow'` guard short-circuits
|
|
1843
|
+
// before any await so the default 'follow' mode stays truly zero-overhead.
|
|
1844
|
+
if (_symlinkMode !== 'follow' && !(await symlinkAllowed(toOpen))) {
|
|
1501
1845
|
sendNotFound(ctx);
|
|
1502
1846
|
return;
|
|
1503
1847
|
}
|
|
1504
|
-
}
|
|
1505
1848
|
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1849
|
+
if (stat.isDirectory()) {
|
|
1850
|
+
// Handle directory
|
|
1851
|
+
if (options.dirListing.enabled) {
|
|
1852
|
+
// Canonical trailing-slash redirect (V4): a directory URL
|
|
1853
|
+
// without a trailing slash serves an index/listing whose
|
|
1854
|
+
// relative links would resolve against the parent. Redirect
|
|
1855
|
+
// /dir → /dir/ (301) BEFORE serving so the browser's base is
|
|
1856
|
+
// the directory itself.
|
|
1857
|
+
if (options.dirListing.trailingSlash && !_pathEndsWithSlash) {
|
|
1858
|
+
// Build the Location from the parsed originalUrl (same
|
|
1859
|
+
// defense as the hideExtension redirect): re-parsing forces
|
|
1860
|
+
// the target to be origin-relative — an absolute-form
|
|
1861
|
+
// request target (`GET http://evil/x`, legal in HTTP/1.1
|
|
1862
|
+
// and reachable under useOriginalUrl:false + rewriting)
|
|
1863
|
+
// would otherwise become an off-origin `Location` (open
|
|
1864
|
+
// redirect). .pathname keeps urlPrefix and percent-encoding.
|
|
1865
|
+
let originalUrlObj;
|
|
1866
|
+
try {
|
|
1867
|
+
originalUrlObj = new URL(_origin + ctx.originalUrl);
|
|
1868
|
+
} catch {
|
|
1869
|
+
sendBadRequest(ctx);
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
// Collapse a leading "//" / "/\" (protocol-relative) to a
|
|
1873
|
+
// single slash before appending the canonical trailing one.
|
|
1874
|
+
let redirectPath = originalUrlObj.pathname;
|
|
1875
|
+
if (redirectPath.length > 1 && (redirectPath.charCodeAt(1) === 0x2F || redirectPath.charCodeAt(1) === 0x5C)) {
|
|
1876
|
+
redirectPath = '/' + redirectPath.replace(/^[/\\]+/, '');
|
|
1877
|
+
}
|
|
1878
|
+
ctx.status = 301;
|
|
1879
|
+
ctx.redirect(redirectPath + '/' + originalUrlObj.search);
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1514
1882
|
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1883
|
+
// Search for index file matching configured patterns
|
|
1884
|
+
if (options.index && options.index.length > 0) {
|
|
1885
|
+
const indexFile = await findIndexFile(toOpen, options.index);
|
|
1886
|
+
if (indexFile) {
|
|
1887
|
+
const indexRelPath = path.relative(normalizedRootDir, path.join(toOpen, indexFile.name)).split(path.sep).join('/');
|
|
1888
|
+
if (!isHiddenEntry(indexFile.name, indexRelPath, false)) {
|
|
1889
|
+
const indexPath = path.join(toOpen, indexFile.name);
|
|
1890
|
+
// Symlink boundary check (V-1): an index file may itself be a
|
|
1891
|
+
// symlink escaping rootDir — validate before serving it.
|
|
1892
|
+
// Guarded so the default 'follow' mode skips the await entirely.
|
|
1893
|
+
if (_symlinkMode !== 'follow' && !(await symlinkAllowed(indexPath))) {
|
|
1894
|
+
sendNotFound(ctx);
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
await loadFile(indexPath, indexFile.stat);
|
|
1530
1898
|
return;
|
|
1531
1899
|
}
|
|
1532
|
-
await loadFile(indexPath, indexFile.stat);
|
|
1533
|
-
return;
|
|
1534
1900
|
}
|
|
1535
1901
|
}
|
|
1536
|
-
}
|
|
1537
1902
|
|
|
1538
|
-
|
|
1539
|
-
|
|
1903
|
+
// No index file found, show directory listing
|
|
1904
|
+
ctx.body = await show_dir(toOpen, ctx);
|
|
1905
|
+
} else {
|
|
1906
|
+
// Directory listing disabled
|
|
1907
|
+
sendNotFound(ctx);
|
|
1908
|
+
}
|
|
1909
|
+
return;
|
|
1540
1910
|
} else {
|
|
1541
|
-
//
|
|
1542
|
-
|
|
1911
|
+
// Canonical trailing-slash 404 (V4): a trailing slash means
|
|
1912
|
+
// "directory", but this path resolved to a FILE — a file is only
|
|
1913
|
+
// reachable at its slash-less URL. Return 404 (indistinguishable
|
|
1914
|
+
// from not-found) rather than serving the file at a non-canonical
|
|
1915
|
+
// URL. Disabled by dirListing.trailingSlash: false (v3 behavior).
|
|
1916
|
+
if (options.dirListing.trailingSlash && _pathEndsWithSlash) {
|
|
1917
|
+
sendNotFound(ctx);
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
await loadFile(toOpen, stat);
|
|
1921
|
+
return;
|
|
1543
1922
|
}
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1923
|
+
} catch (err) {
|
|
1924
|
+
_logger.error('[koa-classic-server] Unexpected error while serving the request:', err);
|
|
1925
|
+
if (ctx.headerSent || ctx.res.writableEnded) {
|
|
1926
|
+
ctx.res.destroy(); // response already in flight — nothing sane left to send
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
// Scrub representation/caching headers a partially-built response may
|
|
1930
|
+
// have left behind: a stale Content-Encoding would corrupt the error
|
|
1931
|
+
// page, a public Cache-Control could get the 500 cached by proxies.
|
|
1932
|
+
for (const h of ['Content-Encoding', 'Content-Disposition', 'Content-Range', 'Vary', 'ETag', 'Last-Modified', 'Accept-Ranges']) {
|
|
1933
|
+
ctx.remove(h);
|
|
1934
|
+
}
|
|
1935
|
+
ctx.set('Cache-Control', 'no-store');
|
|
1936
|
+
ctx.set('Content-Type', 'text/html; charset=utf-8');
|
|
1937
|
+
setGeneratedPageHeaders(ctx, NOT_FOUND_CSP);
|
|
1938
|
+
ctx.status = 500;
|
|
1939
|
+
ctx.body = _INTERNAL_ERROR_HTML;
|
|
1548
1940
|
}
|
|
1549
1941
|
|
|
1550
1942
|
// Internal functions
|
|
@@ -1578,13 +1970,23 @@ module.exports = function koaClassicServer(
|
|
|
1578
1970
|
rawBuffer = cached.buffer;
|
|
1579
1971
|
} else {
|
|
1580
1972
|
try {
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1973
|
+
// Single-flight: concurrent misses on the same path share one
|
|
1974
|
+
// readFile + cache insert; only the leader (the closure below)
|
|
1975
|
+
// runs, waiters await the same Promise. The key includes the
|
|
1976
|
+
// stat'd mtime+size so a request that observed a DIFFERENT
|
|
1977
|
+
// version of the file starts its own job instead of adopting
|
|
1978
|
+
// bytes that don't match the validators it will emit.
|
|
1979
|
+
const inflightKey = `${toOpen}:${fileStat.mtime.getTime()}:${fileStat.size}`;
|
|
1980
|
+
rawBuffer = await singleFlight(_inflightRawReads, inflightKey, async () => {
|
|
1981
|
+
const buf = await fs.promises.readFile(toOpen);
|
|
1982
|
+
refreshOrInsert(_rawFileCache, toOpen, {
|
|
1983
|
+
buffer: buf,
|
|
1984
|
+
mtime: fileStat.mtime.getTime(),
|
|
1985
|
+
size: fileStat.size,
|
|
1986
|
+
insertedAt: Date.now(),
|
|
1987
|
+
}, cached, staleByAge);
|
|
1988
|
+
return buf;
|
|
1989
|
+
});
|
|
1588
1990
|
} catch {
|
|
1589
1991
|
rawBuffer = null; // Fall through to disk reads later
|
|
1590
1992
|
}
|
|
@@ -1632,6 +2034,67 @@ module.exports = function koaClassicServer(
|
|
|
1632
2034
|
}
|
|
1633
2035
|
}
|
|
1634
2036
|
|
|
2037
|
+
// Determine MIME type and compression encoding for the full-file response
|
|
2038
|
+
const mimeType = mime.lookup(toOpen) || 'application/octet-stream';
|
|
2039
|
+
const filename = path.basename(toOpen);
|
|
2040
|
+
|
|
2041
|
+
// Resolve compression: enabled + compressible MIME + meets minFileSize + client supports it
|
|
2042
|
+
let encoding = null; // 'br' | 'gzip' | null
|
|
2043
|
+
let potentiallyCompressible = false; // response content-negotiates on Accept-Encoding
|
|
2044
|
+
if (compressionConfig.enabled && compressionConfig.encodings.length > 0) {
|
|
2045
|
+
const isCompressibleMime = compressionConfig.mimeTypes.has(mimeType);
|
|
2046
|
+
const meetsMinSize = compressionConfig.minFileSize === false
|
|
2047
|
+
|| fileStat.size >= compressionConfig.minFileSize;
|
|
2048
|
+
if (isCompressibleMime && meetsMinSize) {
|
|
2049
|
+
potentiallyCompressible = true; // even if this client gets identity
|
|
2050
|
+
encoding = getClientEncoding(ctx.get('Accept-Encoding'));
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
// fullEtag is encoding-specific to avoid false 304 hits across representations.
|
|
2055
|
+
// Proxies use Vary: Accept-Encoding to cache separate versions per encoding.
|
|
2056
|
+
const etagSuffix = encoding === 'br' ? '-br' : encoding === 'gzip' ? '-gz' : '';
|
|
2057
|
+
const fullEtag = `"${fileStat.mtime.getTime()}-${fileStat.size}${etagSuffix}"`;
|
|
2058
|
+
|
|
2059
|
+
// Vary: Accept-Encoding as soon as the resource is *potentially* compressible —
|
|
2060
|
+
// regardless of whether THIS client gets a compressed variant, and regardless
|
|
2061
|
+
// of browserCacheEnabled. RFC 9110 §15.4.5: the 304 below must carry the same
|
|
2062
|
+
// Vary the 200 would; and a shared proxy must not serve the identity variant to
|
|
2063
|
+
// a client that would have received the compressed one (#7).
|
|
2064
|
+
if (potentiallyCompressible) {
|
|
2065
|
+
ctx.set('Vary', 'Accept-Encoding');
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
// Preconditions are evaluated BEFORE the Range branch: RFC 9110 §13.2.2 gives the
|
|
2069
|
+
// validators precedence over Range (steps 3/4 before step 5), so a conditional
|
|
2070
|
+
// request that matches returns 304 (Not Modified), not 206 (Partial Content).
|
|
2071
|
+
// Comparison uses fullEtag (the encoding-specific representation a full GET would
|
|
2072
|
+
// return); a 206 below re-tags itself with baseEtag (the identity partial it serves).
|
|
2073
|
+
if (options.browserCacheEnabled) {
|
|
2074
|
+
ctx.set('ETag', fullEtag);
|
|
2075
|
+
ctx.set('Last-Modified', fileStat.mtime.toUTCString());
|
|
2076
|
+
|
|
2077
|
+
// If-None-Match: "*" | comma-list, weak comparison (RFC 9110 §13.1.2).
|
|
2078
|
+
if (ifNoneMatchSatisfied(ctx.get('If-None-Match'), fullEtag)) {
|
|
2079
|
+
ctx.status = 304;
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
// If-Modified-Since (date validation). The mtime is truncated to whole seconds
|
|
2084
|
+
// before comparing: Last-Modified is emitted via toUTCString() (second precision
|
|
2085
|
+
// — HTTP dates have no milliseconds), so a client echoing that header back would
|
|
2086
|
+
// otherwise never match a sub-second mtime (e.g. 22:13:20.500 <= 22:13:20.000).
|
|
2087
|
+
const clientModifiedSince = ctx.get('If-Modified-Since');
|
|
2088
|
+
if (clientModifiedSince) {
|
|
2089
|
+
const clientDate = new Date(clientModifiedSince);
|
|
2090
|
+
const mtimeSeconds = Math.floor(fileStat.mtime.getTime() / 1000) * 1000;
|
|
2091
|
+
if (mtimeSeconds <= clientDate.getTime()) {
|
|
2092
|
+
ctx.status = 304;
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
|
|
1635
2098
|
// Range request handling (HTTP 206 Partial Content — compression skipped for ranges)
|
|
1636
2099
|
const rangeHeader = ctx.get('Range');
|
|
1637
2100
|
if (rangeHeader) {
|
|
@@ -1651,10 +2114,11 @@ module.exports = function koaClassicServer(
|
|
|
1651
2114
|
if (!ifRange || ifRange === baseEtag) {
|
|
1652
2115
|
const { start, end } = parsed;
|
|
1653
2116
|
const rangeLength = end - start + 1;
|
|
1654
|
-
const mimeType = mime.lookup(toOpen) || 'application/octet-stream';
|
|
1655
|
-
const filename = path.basename(toOpen);
|
|
1656
2117
|
|
|
1657
2118
|
ctx.status = 206;
|
|
2119
|
+
// 206 returns identity bytes → tag with baseEtag, not the encoding-specific
|
|
2120
|
+
// fullEtag set above. Last-Modified is already set (browserCacheEnabled).
|
|
2121
|
+
if (options.browserCacheEnabled) ctx.set('ETag', baseEtag);
|
|
1658
2122
|
ctx.set('Content-Range', `bytes ${start}-${end}/${fileSize}`);
|
|
1659
2123
|
ctx.set('Content-Type', mimeType);
|
|
1660
2124
|
ctx.set('Content-Length', String(rangeLength));
|
|
@@ -1662,8 +2126,9 @@ module.exports = function koaClassicServer(
|
|
|
1662
2126
|
|
|
1663
2127
|
if (ctx.method !== 'HEAD') {
|
|
1664
2128
|
if (rawBuffer) {
|
|
1665
|
-
// Serve range slice from in-memory buffer — zero disk I/O
|
|
1666
|
-
|
|
2129
|
+
// Serve range slice from in-memory buffer — zero disk I/O.
|
|
2130
|
+
// subarray(): zero-copy view; Buffer.slice is deprecated (DEP0158).
|
|
2131
|
+
ctx.body = rawBuffer.subarray(start, end + 1);
|
|
1667
2132
|
} else {
|
|
1668
2133
|
const src = fs.createReadStream(toOpen, { start, end });
|
|
1669
2134
|
src.on('error', (err) => {
|
|
@@ -1688,57 +2153,25 @@ module.exports = function koaClassicServer(
|
|
|
1688
2153
|
// Invalid Range → fall through to full 200 response
|
|
1689
2154
|
}
|
|
1690
2155
|
|
|
1691
|
-
// Determine MIME type and compression encoding for the full-file response
|
|
1692
|
-
const mimeType = mime.lookup(toOpen) || 'application/octet-stream';
|
|
1693
|
-
const filename = path.basename(toOpen);
|
|
1694
|
-
|
|
1695
|
-
// Resolve compression: enabled + compressible MIME + meets minFileSize + client supports it
|
|
1696
|
-
let encoding = null; // 'br' | 'gzip' | null
|
|
1697
|
-
if (compressionConfig.enabled && compressionConfig.encodings.length > 0) {
|
|
1698
|
-
const isCompressibleMime = compressionConfig.mimeTypes.has(mimeType);
|
|
1699
|
-
const meetsMinSize = compressionConfig.minFileSize === false
|
|
1700
|
-
|| fileStat.size >= compressionConfig.minFileSize;
|
|
1701
|
-
if (isCompressibleMime && meetsMinSize) {
|
|
1702
|
-
encoding = getClientEncoding(ctx.get('Accept-Encoding'));
|
|
1703
|
-
}
|
|
1704
|
-
}
|
|
1705
|
-
|
|
1706
|
-
// fullEtag is encoding-specific to avoid false 304 hits across representations.
|
|
1707
|
-
// Proxies use Vary: Accept-Encoding to cache separate versions per encoding.
|
|
1708
|
-
const etagSuffix = encoding === 'br' ? '-br' : encoding === 'gzip' ? '-gz' : '';
|
|
1709
|
-
const fullEtag = `"${fileStat.mtime.getTime()}-${fileStat.size}${etagSuffix}"`;
|
|
1710
|
-
|
|
1711
|
-
// ETag, Last-Modified, and 304 check — deferred until encoding is known
|
|
1712
|
-
if (options.browserCacheEnabled) {
|
|
1713
|
-
ctx.set('ETag', fullEtag);
|
|
1714
|
-
ctx.set('Last-Modified', fileStat.mtime.toUTCString());
|
|
1715
|
-
|
|
1716
|
-
// Check If-None-Match (ETag validation)
|
|
1717
|
-
const clientEtag = ctx.get('If-None-Match');
|
|
1718
|
-
if (clientEtag && clientEtag === fullEtag) {
|
|
1719
|
-
ctx.status = 304;
|
|
1720
|
-
return;
|
|
1721
|
-
}
|
|
1722
|
-
|
|
1723
|
-
// Check If-Modified-Since (date validation)
|
|
1724
|
-
const clientModifiedSince = ctx.get('If-Modified-Since');
|
|
1725
|
-
if (clientModifiedSince) {
|
|
1726
|
-
const clientDate = new Date(clientModifiedSince);
|
|
1727
|
-
if (fileStat.mtime.getTime() <= clientDate.getTime()) {
|
|
1728
|
-
ctx.status = 304;
|
|
1729
|
-
return;
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
|
-
}
|
|
1733
|
-
|
|
1734
2156
|
// Common response headers
|
|
1735
2157
|
ctx.set('Content-Type', mimeType);
|
|
1736
2158
|
ctx.set('Content-Disposition', buildContentDisposition(filename));
|
|
1737
2159
|
|
|
1738
2160
|
if (encoding) {
|
|
1739
2161
|
// ── Compressed response ───────────────────────────────────────────────
|
|
2162
|
+
// Vary: Accept-Encoding is already set above (potentiallyCompressible).
|
|
1740
2163
|
ctx.set('Content-Encoding', encoding);
|
|
1741
|
-
|
|
2164
|
+
|
|
2165
|
+
// Safety net (#4): the buffered path reads the WHOLE file into RAM and
|
|
2166
|
+
// compresses at max quality — fine for web assets, catastrophic for a
|
|
2167
|
+
// multi-GB log/CSV. Above compression.maxFileSize the bounded-RAM
|
|
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.
|
|
2173
|
+
const withinCompressCap = compressionConfig.maxFileSize === false
|
|
2174
|
+
|| fileStat.size <= compressionConfig.maxFileSize;
|
|
1742
2175
|
|
|
1743
2176
|
if (serverCacheConfig.compressedFile.enabled) {
|
|
1744
2177
|
// compressedFile cache mode: compress once → buffer in RAM → Content-Length known
|
|
@@ -1755,23 +2188,123 @@ module.exports = function koaClassicServer(
|
|
|
1755
2188
|
if (!stale) {
|
|
1756
2189
|
_compressedFileCache.get(cacheKey); // increment frequency
|
|
1757
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;
|
|
1758
2278
|
} else {
|
|
1759
2279
|
try {
|
|
1760
|
-
//
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
2280
|
+
// Single-flight: concurrent misses on the same path+encoding
|
|
2281
|
+
// share one read+compress+insert instead of N parallel brotli
|
|
2282
|
+
// jobs for identical content. A rejection is shared too: all
|
|
2283
|
+
// waiters land in this catch and use the uncompressed fallback.
|
|
2284
|
+
// The key includes the stat'd mtime+size so a request that
|
|
2285
|
+
// observed a DIFFERENT version of the file starts its own job
|
|
2286
|
+
// (its ETag/Last-Modified must describe the bytes it serves).
|
|
2287
|
+
const inflightKey = `${cacheKey}:${fileStat.mtime.getTime()}:${fileStat.size}`;
|
|
2288
|
+
buf = await singleFlight(_inflightCompressions, inflightKey, async () => {
|
|
2289
|
+
// Use rawFile buffer if available — avoids redundant disk read
|
|
2290
|
+
const rawData = rawBuffer || await fs.promises.readFile(toOpen);
|
|
2291
|
+
const compressed = await compressBuffer(rawData, encoding);
|
|
2292
|
+
refreshOrInsert(_compressedFileCache, cacheKey, {
|
|
2293
|
+
buffer: compressed,
|
|
2294
|
+
mtime: fileStat.mtime.getTime(),
|
|
2295
|
+
size: fileStat.size,
|
|
2296
|
+
insertedAt: Date.now(),
|
|
2297
|
+
}, cached, staleByAge);
|
|
2298
|
+
return compressed;
|
|
2299
|
+
});
|
|
1770
2300
|
} catch (err) {
|
|
1771
2301
|
_logger.error('Compression error:', err);
|
|
1772
|
-
// Fall back to uncompressed on any compression failure
|
|
2302
|
+
// Fall back to uncompressed on any compression failure. Vary STAYS
|
|
2303
|
+
// (the resource is still compressible), but the ETag must be reset to
|
|
2304
|
+
// the un-suffixed form: this identity body must not be cached by a
|
|
2305
|
+
// shared proxy under the -br/-gz validator (#7).
|
|
1773
2306
|
ctx.remove('Content-Encoding');
|
|
1774
|
-
ctx.
|
|
2307
|
+
if (options.browserCacheEnabled) ctx.set('ETag', baseEtag);
|
|
1775
2308
|
if (rawBuffer) {
|
|
1776
2309
|
ctx.set('Content-Length', String(rawBuffer.length));
|
|
1777
2310
|
if (ctx.method !== 'HEAD') {
|
|
@@ -1808,25 +2341,17 @@ module.exports = function koaClassicServer(
|
|
|
1808
2341
|
}
|
|
1809
2342
|
|
|
1810
2343
|
} else {
|
|
1811
|
-
// 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.
|
|
1812
2346
|
if (ctx.method !== 'HEAD') {
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
} else {
|
|
1821
|
-
const src = fs.createReadStream(toOpen);
|
|
1822
|
-
src.on('error', (err) => {
|
|
1823
|
-
_logger.error('Stream error:', err);
|
|
1824
|
-
if (!ctx.headerSent) { ctx.status = 500; ctx.body = 'Error reading file'; }
|
|
1825
|
-
});
|
|
1826
|
-
ctx.body = src.pipe(compress);
|
|
1827
|
-
}
|
|
2347
|
+
streamCompressedBody(ctx, toOpen, rawBuffer, encoding);
|
|
2348
|
+
} else {
|
|
2349
|
+
// HEAD: mirror the GET status and headers (RFC 9110 §9.3.2) — no
|
|
2350
|
+
// Content-Length, since the compressed size is unknown without
|
|
2351
|
+
// running the compression. The explicit status is required: with
|
|
2352
|
+
// no body ever assigned, Koa would otherwise respond its default 404.
|
|
2353
|
+
ctx.status = 200;
|
|
1828
2354
|
}
|
|
1829
|
-
// HEAD + streaming: no Content-Length available; Koa sends headers only via res.end()
|
|
1830
2355
|
}
|
|
1831
2356
|
|
|
1832
2357
|
} else {
|
|
@@ -1945,9 +2470,12 @@ module.exports = function koaClassicServer(
|
|
|
1945
2470
|
parts.push("</thead>");
|
|
1946
2471
|
parts.push("<tbody>");
|
|
1947
2472
|
|
|
1948
|
-
// Parent directory link
|
|
2473
|
+
// Parent directory link — omitted at the middleware's LOGICAL root
|
|
2474
|
+
// (pageHrefOutPrefix.pathname === '/'), not only at the absolute root: with
|
|
2475
|
+
// urlPrefix '/static', the listing of /static/ must not link to '/', which is
|
|
2476
|
+
// outside the served tree (#13).
|
|
1949
2477
|
const currentPath = pageHref.origin + pageHref.pathname;
|
|
1950
|
-
if (
|
|
2478
|
+
if (pageHrefOutPrefix.pathname !== "/") {
|
|
1951
2479
|
// Build parent directory URL without query parameters
|
|
1952
2480
|
const a_pD = currentPath.split("/");
|
|
1953
2481
|
a_pD.pop();
|
|
@@ -1956,8 +2484,9 @@ module.exports = function koaClassicServer(
|
|
|
1956
2484
|
parts.push(`<tr><td><a href="${escapeHtml(parentDirectory)}"><b>.. Parent Directory</b></a></td><td>DIR</td><td>-</td></tr>`);
|
|
1957
2485
|
}
|
|
1958
2486
|
|
|
2487
|
+
const emptyFolderRow = `<tr><td>empty folder</td><td></td><td></td></tr>`;
|
|
1959
2488
|
if (dir.length === 0) {
|
|
1960
|
-
parts.push(
|
|
2489
|
+
parts.push(emptyFolderRow);
|
|
1961
2490
|
} else {
|
|
1962
2491
|
const _listingBaseUrl = pageHref.origin + pageHref.pathname;
|
|
1963
2492
|
const _listingOriginPrefix = pageHref.origin + options.urlPrefix;
|
|
@@ -2051,6 +2580,13 @@ module.exports = function koaClassicServer(
|
|
|
2051
2580
|
}
|
|
2052
2581
|
const items = rawItems.filter(Boolean);
|
|
2053
2582
|
|
|
2583
|
+
// Every entry was filtered out as hidden (dotfiles / alwaysHide / blacklist):
|
|
2584
|
+
// show the same "empty folder" row as a physically empty directory (#16), not a
|
|
2585
|
+
// header-only empty table (which would also hint that hidden files exist).
|
|
2586
|
+
if (items.length === 0) {
|
|
2587
|
+
parts.push(emptyFolderRow);
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2054
2590
|
// Places directories before non-directories; falls back to `tieBreaker`
|
|
2055
2591
|
// when both items are in the same bucket. `effectiveType === 2` covers plain
|
|
2056
2592
|
// dirs and dir-resolved symlinks, matching the rest of the listing logic.
|
|
@@ -2186,3 +2722,18 @@ module.exports = function koaClassicServer(
|
|
|
2186
2722
|
|
|
2187
2723
|
};
|
|
2188
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
|
+
};
|