@morojs/moro 1.8.15 → 1.8.17
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/dist/core/http/moro-engine-server.d.ts +20 -0
- package/dist/core/http/moro-engine-server.js +140 -47
- package/dist/core/http/moro-engine-server.js.map +1 -1
- package/dist/core/http/utils/compression.d.ts +34 -4
- package/dist/core/http/utils/compression.js +146 -27
- package/dist/core/http/utils/compression.js.map +1 -1
- package/dist/core/http/uws-http-server.js +2 -2
- package/dist/core/http/uws-http-server.js.map +1 -1
- package/dist/core/middleware/built-in/compression/core.d.ts +4 -2
- package/dist/core/middleware/built-in/compression/core.js +13 -3
- package/dist/core/middleware/built-in/compression/core.js.map +1 -1
- package/dist/core/middleware/built-in/static/core.d.ts +88 -0
- package/dist/core/middleware/built-in/static/core.js +321 -15
- package/dist/core/middleware/built-in/static/core.js.map +1 -1
- package/dist/core/networking/adapters/engine-adapter.js +91 -11
- package/dist/core/networking/adapters/engine-adapter.js.map +1 -1
- package/dist/core/utilities/package-utils.d.ts +5 -0
- package/dist/core/utilities/package-utils.js +1 -0
- package/dist/core/utilities/package-utils.js.map +1 -1
- package/dist/types/config.d.ts +6 -0
- package/dist/types/config.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { HttpRequest, HttpResponse } from '../../../../types/http.js';
|
|
2
|
-
import { type Encoding } from '../../../http/utils/compression.js';
|
|
3
|
-
export interface CompressionOptions {
|
|
2
|
+
import { type Encoding, type EncoderOptions } from '../../../http/utils/compression.js';
|
|
3
|
+
export interface CompressionOptions extends EncoderOptions {
|
|
4
4
|
threshold?: number;
|
|
5
|
+
/** 1-9: the gzip level, and the brotli quality it maps to (see `gzip` / `brotli` to pin either). */
|
|
5
6
|
level?: number;
|
|
6
7
|
encodings?: Encoding[];
|
|
7
8
|
/**
|
|
@@ -17,6 +18,7 @@ export declare class CompressionCore {
|
|
|
17
18
|
private level;
|
|
18
19
|
private encodings;
|
|
19
20
|
private maxInlineBytes;
|
|
21
|
+
private encoders;
|
|
20
22
|
private filter?;
|
|
21
23
|
constructor(options?: CompressionOptions);
|
|
22
24
|
shouldCompress(req: HttpRequest, res: HttpResponse): boolean;
|
|
@@ -4,12 +4,18 @@ export class CompressionCore {
|
|
|
4
4
|
level;
|
|
5
5
|
encodings;
|
|
6
6
|
maxInlineBytes;
|
|
7
|
+
encoders;
|
|
7
8
|
filter;
|
|
8
9
|
constructor(options = {}) {
|
|
9
10
|
this.threshold = options.threshold || 1024; // 1KB default
|
|
10
11
|
this.level = options.level || 6; // Default compression level
|
|
11
12
|
this.encodings = options.encodings || DEFAULT_ENCODINGS;
|
|
12
13
|
this.maxInlineBytes = options.maxInlineBytes ?? DEFAULT_MAX_INLINE_BYTES;
|
|
14
|
+
this.encoders = {
|
|
15
|
+
...(options.gzip !== undefined ? { gzip: options.gzip } : {}),
|
|
16
|
+
...(options.brotli !== undefined ? { brotli: options.brotli } : {}),
|
|
17
|
+
...(options.reuseEncoders !== undefined ? { reuseEncoders: options.reuseEncoders } : {}),
|
|
18
|
+
};
|
|
13
19
|
this.filter = options.filter;
|
|
14
20
|
}
|
|
15
21
|
shouldCompress(req, res) {
|
|
@@ -28,6 +34,7 @@ export class CompressionCore {
|
|
|
28
34
|
const threshold = this.threshold;
|
|
29
35
|
const encodings = this.encodings;
|
|
30
36
|
const maxInline = this.maxInlineBytes;
|
|
37
|
+
const encoders = this.encoders;
|
|
31
38
|
const filterOk = this.filter ? this.filter(req, res) : true;
|
|
32
39
|
const compressResponse = (data, isJson, contentType) => {
|
|
33
40
|
const content = isJson ? JSON.stringify(data) : data;
|
|
@@ -59,7 +66,7 @@ export class CompressionCore {
|
|
|
59
66
|
if (maxInline > 0 && byteLength <= maxInline && !res.headersSent) {
|
|
60
67
|
let compressed;
|
|
61
68
|
try {
|
|
62
|
-
compressed = compressBufferSync(buffer, encoding, level);
|
|
69
|
+
compressed = compressBufferSync(buffer, encoding, level, encoders);
|
|
63
70
|
}
|
|
64
71
|
catch {
|
|
65
72
|
if (isJson) {
|
|
@@ -91,14 +98,17 @@ export class CompressionCore {
|
|
|
91
98
|
res.setHeader('Vary', vary ? `${vary}, Accept-Encoding` : 'Accept-Encoding');
|
|
92
99
|
res.writeHead(res.statusCode || 200);
|
|
93
100
|
}
|
|
94
|
-
compressBuffer(buffer, encoding, level)
|
|
101
|
+
compressBuffer(buffer, encoding, level, encoders)
|
|
95
102
|
.then(compressed => res.end(compressed))
|
|
96
103
|
.catch(() => res.end()); // headers already committed - can't fall back to raw
|
|
97
104
|
return res;
|
|
98
105
|
};
|
|
99
106
|
res.json = function (data) {
|
|
107
|
+
// The same type res.json sends without the middleware: JSON is UTF-8 by
|
|
108
|
+
// definition (RFC 8259 §8.1), so a charset parameter only adds bytes to
|
|
109
|
+
// every reply.
|
|
100
110
|
if (!this.getHeader('Content-Type')) {
|
|
101
|
-
this.setHeader('Content-Type', 'application/json
|
|
111
|
+
this.setHeader('Content-Type', 'application/json');
|
|
102
112
|
}
|
|
103
113
|
compressResponse(data, true, 'application/json');
|
|
104
114
|
return this;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"core.js","sourceRoot":"","sources":["../../../../../src/core/middleware/built-in/compression/core.ts"],"names":[],"mappings":"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","sourcesContent":["// Compression Core Logic\n//\n// The global compression middleware wraps res.json/res.send uniformly across\n// EVERY runtime (Node, uWS, HTTP/2, engine), so it is the single source of\n// response compression parity. It delegates encoding choice and content-type\n// filtering to the shared utility (utils/compression) so brotli, q-value\n// negotiation, and the compressible-type allowlist behave identically here and\n// in the per-server fallback paths.\nimport { HttpRequest, HttpResponse } from '../../../../types/http.js';\nimport {\n compressBuffer,\n compressBufferSync,\n negotiateEncoding,\n isCompressible,\n DEFAULT_ENCODINGS,\n DEFAULT_MAX_INLINE_BYTES,\n type Encoding,\n} from '../../../http/utils/compression.js';\n\nexport interface CompressionOptions {\n threshold?: number;\n level?: number;\n encodings?: Encoding[];\n /**\n * Bodies at or below this size are compressed on the calling thread and\n * sent with a Content-Length; larger ones go to the threadpool and stream\n * chunked. 0 sends every body through the pool. Default 16384.\n */\n maxInlineBytes?: number;\n filter?: (req: HttpRequest, res: HttpResponse) => boolean;\n}\n\nexport class CompressionCore {\n private threshold: number;\n private level: number;\n private encodings: Encoding[];\n private maxInlineBytes: number;\n private filter?: ((req: HttpRequest, res: HttpResponse) => boolean) | undefined;\n\n constructor(options: CompressionOptions = {}) {\n this.threshold = options.threshold || 1024; // 1KB default\n this.level = options.level || 6; // Default compression level\n this.encodings = options.encodings || DEFAULT_ENCODINGS;\n this.maxInlineBytes = options.maxInlineBytes ?? DEFAULT_MAX_INLINE_BYTES;\n this.filter = options.filter;\n }\n\n shouldCompress(req: HttpRequest, res: HttpResponse): boolean {\n // Check custom filter\n if (this.filter && !this.filter(req, res)) {\n return false;\n }\n\n // Check if client accepts any encoding we offer\n const acceptEncoding = (req.headers['accept-encoding'] as string) || '';\n return negotiateEncoding(acceptEncoding, this.encodings) !== null;\n }\n\n wrapResponse(req: HttpRequest, res: HttpResponse): void {\n const originalSend = res.send;\n const acceptEncoding = (req.headers['accept-encoding'] as string) || '';\n const level = this.level;\n const threshold = this.threshold;\n const encodings = this.encodings;\n const maxInline = this.maxInlineBytes;\n const filterOk = this.filter ? this.filter(req, res) : true;\n\n const compressResponse = (data: any, isJson: boolean, contentType?: string) => {\n const content = isJson ? JSON.stringify(data) : data;\n const byteLength = Buffer.isBuffer(content)\n ? content.length\n : Buffer.byteLength(content ?? '');\n const ctHeader =\n contentType ??\n (res.getHeader ? (res.getHeader('content-type') as string | undefined) : undefined);\n const encoding = filterOk ? negotiateEncoding(acceptEncoding, encodings) : null;\n\n // Skip compression when: below threshold, client accepts nothing we\n // offer, or the content type isn't worth compressing (already-compressed\n // binary - images, video, octet-stream). For JSON we already have the\n // serialized string; ending with it here avoids re-entering res.json,\n // which would JSON.stringify the same data a second time.\n if (byteLength < threshold || !encoding || !isCompressible(ctHeader)) {\n if (isJson) {\n if (res.headersSent) return;\n res.setHeader('Content-Length', byteLength);\n res.end(content);\n return;\n }\n return originalSend.call(res, data);\n }\n\n const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content ?? '');\n\n // A small body is compressed here and now: cheaper than the round trip\n // through the threadpool, and the response carries a Content-Length\n // instead of streaming chunked. A failure falls back to the plain body.\n if (maxInline > 0 && byteLength <= maxInline && !res.headersSent) {\n let compressed: Buffer;\n try {\n compressed = compressBufferSync(buffer, encoding, level);\n } catch {\n if (isJson) {\n res.setHeader('Content-Length', byteLength);\n res.end(content);\n return;\n }\n return originalSend.call(res, data);\n }\n res.setHeader('Content-Encoding', encoding);\n const vary = res.getHeader ? (res.getHeader('vary') as string | undefined) : undefined;\n res.setHeader('Vary', vary ? `${vary}, Accept-Encoding` : 'Accept-Encoding');\n res.setHeader('Content-Length', compressed.length);\n res.end(compressed);\n return res;\n }\n\n // Commit the response headers synchronously, BEFORE the asynchronous\n // compression. This marks headersSent the moment send/json is called, so\n // the \"no route matched\" fallback can't 404 the response mid-compress.\n // The compressed length isn't known yet, so the body streams (chunked).\n if (!res.headersSent) {\n // A Content-Length set for the uncompressed body would truncate (or\n // hang) the compressed one; the compressed body streams chunked.\n if (typeof (res as any).removeHeader === 'function') {\n (res as any).removeHeader('Content-Length');\n }\n res.setHeader('Content-Encoding', encoding);\n const vary = res.getHeader ? (res.getHeader('vary') as string | undefined) : undefined;\n res.setHeader('Vary', vary ? `${vary}, Accept-Encoding` : 'Accept-Encoding');\n (res as any).writeHead((res as any).statusCode || 200);\n }\n\n compressBuffer(buffer, encoding, level)\n .then(compressed => res.end(compressed))\n .catch(() => res.end()); // headers already committed - can't fall back to raw\n\n return res;\n };\n\n res.json = function (data: any) {\n if (!this.getHeader('Content-Type')) {\n this.setHeader('Content-Type', 'application/json; charset=utf-8');\n }\n compressResponse(data, true, 'application/json');\n return this;\n };\n\n res.send = function (data: any) {\n compressResponse(data, false);\n return this;\n };\n }\n\n getThreshold(): number {\n return this.threshold;\n }\n\n getLevel(): number {\n return this.level;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"core.js","sourceRoot":"","sources":["../../../../../src/core/middleware/built-in/compression/core.ts"],"names":[],"mappings":"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","sourcesContent":["// Compression Core Logic\n//\n// The global compression middleware wraps res.json/res.send uniformly across\n// EVERY runtime (Node, uWS, HTTP/2, engine), so it is the single source of\n// response compression parity. It delegates encoding choice and content-type\n// filtering to the shared utility (utils/compression) so brotli, q-value\n// negotiation, and the compressible-type allowlist behave identically here and\n// in the per-server fallback paths.\nimport { HttpRequest, HttpResponse } from '../../../../types/http.js';\nimport {\n compressBuffer,\n compressBufferSync,\n negotiateEncoding,\n isCompressible,\n DEFAULT_ENCODINGS,\n DEFAULT_MAX_INLINE_BYTES,\n type Encoding,\n type EncoderOptions,\n} from '../../../http/utils/compression.js';\n\nexport interface CompressionOptions extends EncoderOptions {\n threshold?: number;\n /** 1-9: the gzip level, and the brotli quality it maps to (see `gzip` / `brotli` to pin either). */\n level?: number;\n encodings?: Encoding[];\n /**\n * Bodies at or below this size are compressed on the calling thread and\n * sent with a Content-Length; larger ones go to the threadpool and stream\n * chunked. 0 sends every body through the pool. Default 16384.\n */\n maxInlineBytes?: number;\n filter?: (req: HttpRequest, res: HttpResponse) => boolean;\n}\n\nexport class CompressionCore {\n private threshold: number;\n private level: number;\n private encodings: Encoding[];\n private maxInlineBytes: number;\n private encoders: EncoderOptions;\n private filter?: ((req: HttpRequest, res: HttpResponse) => boolean) | undefined;\n\n constructor(options: CompressionOptions = {}) {\n this.threshold = options.threshold || 1024; // 1KB default\n this.level = options.level || 6; // Default compression level\n this.encodings = options.encodings || DEFAULT_ENCODINGS;\n this.maxInlineBytes = options.maxInlineBytes ?? DEFAULT_MAX_INLINE_BYTES;\n this.encoders = {\n ...(options.gzip !== undefined ? { gzip: options.gzip } : {}),\n ...(options.brotli !== undefined ? { brotli: options.brotli } : {}),\n ...(options.reuseEncoders !== undefined ? { reuseEncoders: options.reuseEncoders } : {}),\n };\n this.filter = options.filter;\n }\n\n shouldCompress(req: HttpRequest, res: HttpResponse): boolean {\n // Check custom filter\n if (this.filter && !this.filter(req, res)) {\n return false;\n }\n\n // Check if client accepts any encoding we offer\n const acceptEncoding = (req.headers['accept-encoding'] as string) || '';\n return negotiateEncoding(acceptEncoding, this.encodings) !== null;\n }\n\n wrapResponse(req: HttpRequest, res: HttpResponse): void {\n const originalSend = res.send;\n const acceptEncoding = (req.headers['accept-encoding'] as string) || '';\n const level = this.level;\n const threshold = this.threshold;\n const encodings = this.encodings;\n const maxInline = this.maxInlineBytes;\n const encoders = this.encoders;\n const filterOk = this.filter ? this.filter(req, res) : true;\n\n const compressResponse = (data: any, isJson: boolean, contentType?: string) => {\n const content = isJson ? JSON.stringify(data) : data;\n const byteLength = Buffer.isBuffer(content)\n ? content.length\n : Buffer.byteLength(content ?? '');\n const ctHeader =\n contentType ??\n (res.getHeader ? (res.getHeader('content-type') as string | undefined) : undefined);\n const encoding = filterOk ? negotiateEncoding(acceptEncoding, encodings) : null;\n\n // Skip compression when: below threshold, client accepts nothing we\n // offer, or the content type isn't worth compressing (already-compressed\n // binary - images, video, octet-stream). For JSON we already have the\n // serialized string; ending with it here avoids re-entering res.json,\n // which would JSON.stringify the same data a second time.\n if (byteLength < threshold || !encoding || !isCompressible(ctHeader)) {\n if (isJson) {\n if (res.headersSent) return;\n res.setHeader('Content-Length', byteLength);\n res.end(content);\n return;\n }\n return originalSend.call(res, data);\n }\n\n const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content ?? '');\n\n // A small body is compressed here and now: cheaper than the round trip\n // through the threadpool, and the response carries a Content-Length\n // instead of streaming chunked. A failure falls back to the plain body.\n if (maxInline > 0 && byteLength <= maxInline && !res.headersSent) {\n let compressed: Buffer;\n try {\n compressed = compressBufferSync(buffer, encoding, level, encoders);\n } catch {\n if (isJson) {\n res.setHeader('Content-Length', byteLength);\n res.end(content);\n return;\n }\n return originalSend.call(res, data);\n }\n res.setHeader('Content-Encoding', encoding);\n const vary = res.getHeader ? (res.getHeader('vary') as string | undefined) : undefined;\n res.setHeader('Vary', vary ? `${vary}, Accept-Encoding` : 'Accept-Encoding');\n res.setHeader('Content-Length', compressed.length);\n res.end(compressed);\n return res;\n }\n\n // Commit the response headers synchronously, BEFORE the asynchronous\n // compression. This marks headersSent the moment send/json is called, so\n // the \"no route matched\" fallback can't 404 the response mid-compress.\n // The compressed length isn't known yet, so the body streams (chunked).\n if (!res.headersSent) {\n // A Content-Length set for the uncompressed body would truncate (or\n // hang) the compressed one; the compressed body streams chunked.\n if (typeof (res as any).removeHeader === 'function') {\n (res as any).removeHeader('Content-Length');\n }\n res.setHeader('Content-Encoding', encoding);\n const vary = res.getHeader ? (res.getHeader('vary') as string | undefined) : undefined;\n res.setHeader('Vary', vary ? `${vary}, Accept-Encoding` : 'Accept-Encoding');\n (res as any).writeHead((res as any).statusCode || 200);\n }\n\n compressBuffer(buffer, encoding, level, encoders)\n .then(compressed => res.end(compressed))\n .catch(() => res.end()); // headers already committed - can't fall back to raw\n\n return res;\n };\n\n res.json = function (data: any) {\n // The same type res.json sends without the middleware: JSON is UTF-8 by\n // definition (RFC 8259 §8.1), so a charset parameter only adds bytes to\n // every reply.\n if (!this.getHeader('Content-Type')) {\n this.setHeader('Content-Type', 'application/json');\n }\n compressResponse(data, true, 'application/json');\n return this;\n };\n\n res.send = function (data: any) {\n compressResponse(data, false);\n return this;\n };\n }\n\n getThreshold(): number {\n return this.threshold;\n }\n\n getLevel(): number {\n return this.level;\n }\n}\n"]}
|
|
@@ -1,4 +1,31 @@
|
|
|
1
1
|
import { HttpRequest, HttpResponse } from '../../../../types/http.js';
|
|
2
|
+
export interface StaticCacheOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Largest file kept in memory, in bytes. Bigger files take the uncached
|
|
5
|
+
* path (read or streamed from disk per request). Default 1 MiB.
|
|
6
|
+
*/
|
|
7
|
+
maxFileBytes?: number;
|
|
8
|
+
/**
|
|
9
|
+
* Budget for everything cached, in bytes, originals and precompressed
|
|
10
|
+
* sidecars together; the oldest entries leave when it is exceeded.
|
|
11
|
+
* Default 64 MiB.
|
|
12
|
+
*/
|
|
13
|
+
maxBytes?: number;
|
|
14
|
+
/**
|
|
15
|
+
* How long a cached file is served without looking at the disk again, in
|
|
16
|
+
* milliseconds. Past it, the next request stats the file (and the
|
|
17
|
+
* sidecars it may have) and reloads what changed, so a replaced file is
|
|
18
|
+
* served within this window at the latest. Default 1000. `0` stats on
|
|
19
|
+
* every request.
|
|
20
|
+
*/
|
|
21
|
+
revalidateMs?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Watch the root directory and revalidate everything the moment a change
|
|
24
|
+
* is reported, ahead of `revalidateMs`. Default true where the platform
|
|
25
|
+
* supports it; a watcher that cannot be started is simply not used.
|
|
26
|
+
*/
|
|
27
|
+
watch?: boolean;
|
|
28
|
+
}
|
|
2
29
|
export interface StaticOptions {
|
|
3
30
|
root: string;
|
|
4
31
|
/**
|
|
@@ -32,6 +59,17 @@ export interface StaticOptions {
|
|
|
32
59
|
* Range requests always get the identity file. Default false.
|
|
33
60
|
*/
|
|
34
61
|
precompressed?: boolean;
|
|
62
|
+
/**
|
|
63
|
+
* Keep the files in memory. Without it every request resolves, stats and
|
|
64
|
+
* reads the file (and its sidecars) from disk - five to eight filesystem
|
|
65
|
+
* round trips through the threadpool per response, which is what bounds a
|
|
66
|
+
* busy static route. With it, a file is read once and served from memory
|
|
67
|
+
* with its validators and sidecars; the disk is consulted again at most
|
|
68
|
+
* once per `revalidateMs` (default every second) or as soon as the root
|
|
69
|
+
* directory reports a change, so a replaced file is still served promptly.
|
|
70
|
+
* `true` takes the defaults; an object tunes them. Default false.
|
|
71
|
+
*/
|
|
72
|
+
cache?: boolean | StaticCacheOptions;
|
|
35
73
|
}
|
|
36
74
|
export declare class StaticCore {
|
|
37
75
|
private root;
|
|
@@ -45,9 +83,59 @@ export declare class StaticCore {
|
|
|
45
83
|
private precompressed;
|
|
46
84
|
/** Files at or below this size are read in one go instead of streamed. */
|
|
47
85
|
private static readonly STREAM_THRESHOLD;
|
|
86
|
+
private cache;
|
|
87
|
+
private cacheMaxFileBytes;
|
|
88
|
+
private cacheMaxBytes;
|
|
89
|
+
private cacheRevalidateMs;
|
|
90
|
+
private cacheBytes;
|
|
91
|
+
private watcher;
|
|
92
|
+
private watchWanted;
|
|
48
93
|
private mimeTypes;
|
|
49
94
|
constructor(options: StaticOptions);
|
|
50
95
|
handleRequest(req: HttpRequest, res: HttpResponse): Promise<boolean>;
|
|
96
|
+
/**
|
|
97
|
+
* Read the file and whichever current sidecars exist beside it into one
|
|
98
|
+
* entry. Null when the file could not be read (gone between stat and
|
|
99
|
+
* read), in which case the request takes the uncached path's answer.
|
|
100
|
+
*/
|
|
101
|
+
private buildEntry;
|
|
102
|
+
/** The sidecar at `candidate` when it is a regular file inside the root. */
|
|
103
|
+
private statSidecar;
|
|
104
|
+
/**
|
|
105
|
+
* Whether the disk still matches the entry: the original and every sidecar
|
|
106
|
+
* position (present or absent when the entry was built) are stat'ed and
|
|
107
|
+
* compared on mtime, size and inode, so a same-length replacement counts
|
|
108
|
+
* as a change and a sidecar that appeared or vanished does too.
|
|
109
|
+
*/
|
|
110
|
+
private stillCurrent;
|
|
111
|
+
private insert;
|
|
112
|
+
private evict;
|
|
113
|
+
/**
|
|
114
|
+
* One watcher on the root directory: any change reported there marks every
|
|
115
|
+
* entry for revalidation on its next request, ahead of `revalidateMs`.
|
|
116
|
+
* Non-persistent, so it never keeps the process alive, and best effort: a
|
|
117
|
+
* platform or filesystem without change events simply falls back to the
|
|
118
|
+
* timed revalidation.
|
|
119
|
+
*/
|
|
120
|
+
private ensureWatcher;
|
|
121
|
+
/** Stop the directory watcher and drop every cached file. */
|
|
122
|
+
close(): void;
|
|
123
|
+
/** Answer a request from a cache entry: the same headers and bytes the uncached path sends. */
|
|
124
|
+
private serveCached;
|
|
125
|
+
/**
|
|
126
|
+
* The identity body of a small file. A compressible one goes through
|
|
127
|
+
* send() so it joins the server's response compression (the compression
|
|
128
|
+
* middleware or performance.compression) exactly like a handler's body
|
|
129
|
+
* would; send() sets Content-Length itself. Binary bodies are ended as they
|
|
130
|
+
* are, with their length.
|
|
131
|
+
*/
|
|
132
|
+
private sendBody;
|
|
133
|
+
/**
|
|
134
|
+
* The strong ETag of one representation: the file's mtime and size, plus
|
|
135
|
+
* the encoding for a sidecar, since a strong ETag must differ between
|
|
136
|
+
* representations (RFC 9110 8.8.1).
|
|
137
|
+
*/
|
|
138
|
+
private static etagFor;
|
|
51
139
|
/**
|
|
52
140
|
* The best precompressed sidecar for `filePath` that the client accepts, or
|
|
53
141
|
* null. A sidecar must be a regular file INSIDE the root (its real path is
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as fs from 'fs/promises';
|
|
2
|
-
import { createReadStream } from 'fs';
|
|
2
|
+
import { createReadStream, watch as watchDir } from 'fs';
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import * as crypto from 'crypto';
|
|
5
5
|
import { isCompressible, negotiateEncoding } from '../../../http/utils/compression.js';
|
|
@@ -8,6 +8,13 @@ const SIDECARS = [
|
|
|
8
8
|
{ encoding: 'br', suffix: '.br' },
|
|
9
9
|
{ encoding: 'gzip', suffix: '.gz' },
|
|
10
10
|
];
|
|
11
|
+
const stampOf = (st) => ({
|
|
12
|
+
mtimeMs: st.mtimeMs,
|
|
13
|
+
size: st.size,
|
|
14
|
+
ino: st.ino,
|
|
15
|
+
});
|
|
16
|
+
const sameStamp = (a, b) => a === b ||
|
|
17
|
+
(a !== null && b !== null && a.mtimeMs === b.mtimeMs && a.size === b.size && a.ino === b.ino);
|
|
11
18
|
export class StaticCore {
|
|
12
19
|
root;
|
|
13
20
|
prefix;
|
|
@@ -20,6 +27,15 @@ export class StaticCore {
|
|
|
20
27
|
precompressed;
|
|
21
28
|
/** Files at or below this size are read in one go instead of streamed. */
|
|
22
29
|
static STREAM_THRESHOLD = 512 * 1024;
|
|
30
|
+
// The in-memory cache (see StaticOptions.cache), keyed by the request path
|
|
31
|
+
// under the prefix. Null when caching is off.
|
|
32
|
+
cache = null;
|
|
33
|
+
cacheMaxFileBytes = 1024 * 1024;
|
|
34
|
+
cacheMaxBytes = 64 * 1024 * 1024;
|
|
35
|
+
cacheRevalidateMs = 1000;
|
|
36
|
+
cacheBytes = 0;
|
|
37
|
+
watcher = null;
|
|
38
|
+
watchWanted = true;
|
|
23
39
|
mimeTypes = {
|
|
24
40
|
// Documents / text
|
|
25
41
|
'.html': 'text/html',
|
|
@@ -90,6 +106,17 @@ export class StaticCore {
|
|
|
90
106
|
this.lastModified = options.lastModified !== false;
|
|
91
107
|
this.acceptRanges = options.acceptRanges !== false;
|
|
92
108
|
this.precompressed = options.precompressed === true;
|
|
109
|
+
if (options.cache) {
|
|
110
|
+
const c = typeof options.cache === 'object' ? options.cache : {};
|
|
111
|
+
this.cache = new Map();
|
|
112
|
+
if (c.maxFileBytes !== undefined)
|
|
113
|
+
this.cacheMaxFileBytes = Math.max(0, c.maxFileBytes);
|
|
114
|
+
if (c.maxBytes !== undefined)
|
|
115
|
+
this.cacheMaxBytes = Math.max(0, c.maxBytes);
|
|
116
|
+
if (c.revalidateMs !== undefined)
|
|
117
|
+
this.cacheRevalidateMs = Math.max(0, c.revalidateMs);
|
|
118
|
+
this.watchWanted = c.watch !== false;
|
|
119
|
+
}
|
|
93
120
|
}
|
|
94
121
|
async handleRequest(req, res) {
|
|
95
122
|
// Only handle GET and HEAD requests
|
|
@@ -101,6 +128,26 @@ export class StaticCore {
|
|
|
101
128
|
if (urlPath === null) {
|
|
102
129
|
return false;
|
|
103
130
|
}
|
|
131
|
+
// Cached: the common case is a hit that needs no filesystem call at all.
|
|
132
|
+
// A Range request keeps the uncached path (byte offsets refer to the
|
|
133
|
+
// identity file on disk).
|
|
134
|
+
const cache = this.cache;
|
|
135
|
+
if (cache !== null) {
|
|
136
|
+
const entry = cache.get(urlPath);
|
|
137
|
+
if (entry !== undefined && req.headers.range === undefined) {
|
|
138
|
+
if (Date.now() - entry.checkedAt < this.cacheRevalidateMs) {
|
|
139
|
+
return this.serveCached(req, res, entry);
|
|
140
|
+
}
|
|
141
|
+
// Past the revalidation window: one look at the disk. Unchanged, the
|
|
142
|
+
// entry serves on; changed or gone, it is dropped and the request
|
|
143
|
+
// takes the resolving path below, which rebuilds it.
|
|
144
|
+
if (await this.stillCurrent(entry)) {
|
|
145
|
+
entry.checkedAt = Date.now();
|
|
146
|
+
return this.serveCached(req, res, entry);
|
|
147
|
+
}
|
|
148
|
+
this.evict(urlPath, entry);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
104
151
|
try {
|
|
105
152
|
let filePath = path.join(this.root, urlPath);
|
|
106
153
|
// Security: prevent directory traversal. The trailing separator is required —
|
|
@@ -167,6 +214,18 @@ export class StaticCore {
|
|
|
167
214
|
const ext = path.extname(filePath);
|
|
168
215
|
const baseMimeType = this.mimeTypes[ext.toLowerCase()] || 'application/octet-stream';
|
|
169
216
|
const contentType = this.addCharsetIfNeeded(baseMimeType);
|
|
217
|
+
// A file that fits the cache is read once, with its sidecars, and the
|
|
218
|
+
// response comes from the entry - this request's and every later one's.
|
|
219
|
+
if (cache !== null &&
|
|
220
|
+
stats.isFile() &&
|
|
221
|
+
stats.size <= this.cacheMaxFileBytes &&
|
|
222
|
+
req.headers.range === undefined) {
|
|
223
|
+
const entry = await this.buildEntry(filePath, stats, baseMimeType, contentType);
|
|
224
|
+
if (entry !== null) {
|
|
225
|
+
this.insert(urlPath, entry);
|
|
226
|
+
return this.serveCached(req, res, entry);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
170
229
|
res.setHeader('Content-Type', contentType);
|
|
171
230
|
// Precompressed sidecar (app.js.br / app.js.gz next to app.js). Chosen
|
|
172
231
|
// before the validators so the ETag names the encoded representation;
|
|
@@ -194,10 +253,7 @@ export class StaticCore {
|
|
|
194
253
|
if (this.etag) {
|
|
195
254
|
// A strong ETag must differ between representations, so the encoded
|
|
196
255
|
// sidecar gets its own (RFC 9110 8.8.1)
|
|
197
|
-
etag =
|
|
198
|
-
.createHash('md5')
|
|
199
|
-
.update(`${stats.mtime.getTime()}-${stats.size}`)
|
|
200
|
-
.digest('hex')}${sidecar ? `-${sidecar.encoding}` : ''}"`;
|
|
256
|
+
etag = StaticCore.etagFor(stats, sidecar ? sidecar.encoding : '');
|
|
201
257
|
res.setHeader('ETag', etag);
|
|
202
258
|
}
|
|
203
259
|
// Conditional requests. Content-Length is deliberately not set for a 304 —
|
|
@@ -256,16 +312,7 @@ export class StaticCore {
|
|
|
256
312
|
// doesn't cost its full size in resident memory per request.
|
|
257
313
|
if (stats.size <= StaticCore.STREAM_THRESHOLD || !StaticCore.canStream(res)) {
|
|
258
314
|
const data = await fs.readFile(filePath);
|
|
259
|
-
|
|
260
|
-
// Through send() the body joins the server's response compression
|
|
261
|
-
// (performance.compression / the compression middleware) exactly
|
|
262
|
-
// like a handler's body would; send() sets Content-Length itself.
|
|
263
|
-
res.send(data);
|
|
264
|
-
}
|
|
265
|
-
else {
|
|
266
|
-
res.setHeader('Content-Length', stats.size);
|
|
267
|
-
res.end(data);
|
|
268
|
-
}
|
|
315
|
+
this.sendBody(res, data, baseMimeType);
|
|
269
316
|
}
|
|
270
317
|
else {
|
|
271
318
|
res.setHeader('Content-Length', stats.size);
|
|
@@ -278,6 +325,265 @@ export class StaticCore {
|
|
|
278
325
|
return true;
|
|
279
326
|
}
|
|
280
327
|
}
|
|
328
|
+
// ---- the in-memory cache ----
|
|
329
|
+
/**
|
|
330
|
+
* Read the file and whichever current sidecars exist beside it into one
|
|
331
|
+
* entry. Null when the file could not be read (gone between stat and
|
|
332
|
+
* read), in which case the request takes the uncached path's answer.
|
|
333
|
+
*/
|
|
334
|
+
async buildEntry(filePath, stats, baseMimeType, contentType) {
|
|
335
|
+
let body;
|
|
336
|
+
try {
|
|
337
|
+
body = await fs.readFile(filePath);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
const stamp = stampOf(stats);
|
|
343
|
+
const identity = {
|
|
344
|
+
encoding: '',
|
|
345
|
+
path: filePath,
|
|
346
|
+
stamp,
|
|
347
|
+
body,
|
|
348
|
+
etag: StaticCore.etagFor(stats, ''),
|
|
349
|
+
};
|
|
350
|
+
const sidecars = [];
|
|
351
|
+
const sidecarStamps = [];
|
|
352
|
+
let bytes = body.length;
|
|
353
|
+
if (this.precompressed) {
|
|
354
|
+
const rootWithSep = this.root.endsWith(path.sep) ? this.root : this.root + path.sep;
|
|
355
|
+
for (const { encoding, suffix } of SIDECARS) {
|
|
356
|
+
const found = await this.statSidecar(filePath + suffix, rootWithSep);
|
|
357
|
+
sidecarStamps.push(found ? stampOf(found.stats) : null);
|
|
358
|
+
// A sidecar older than its source is a stale body for a replaced
|
|
359
|
+
// asset: not served, and its stamp is still remembered so a later
|
|
360
|
+
// touch of it is noticed.
|
|
361
|
+
if (!found || found.stats.mtimeMs < stats.mtimeMs)
|
|
362
|
+
continue;
|
|
363
|
+
let sideBody;
|
|
364
|
+
try {
|
|
365
|
+
sideBody = await fs.readFile(found.real);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
sidecars.push({
|
|
371
|
+
encoding,
|
|
372
|
+
path: found.real,
|
|
373
|
+
stamp: stampOf(found.stats),
|
|
374
|
+
body: sideBody,
|
|
375
|
+
etag: StaticCore.etagFor(stats, encoding),
|
|
376
|
+
});
|
|
377
|
+
bytes += sideBody.length;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
filePath,
|
|
382
|
+
baseMimeType,
|
|
383
|
+
contentType,
|
|
384
|
+
mtime: stats.mtime,
|
|
385
|
+
lastModified: stats.mtime.toUTCString(),
|
|
386
|
+
identity,
|
|
387
|
+
sidecars,
|
|
388
|
+
sidecarStamps,
|
|
389
|
+
bytes,
|
|
390
|
+
checkedAt: Date.now(),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
/** The sidecar at `candidate` when it is a regular file inside the root. */
|
|
394
|
+
async statSidecar(candidate, rootWithSep) {
|
|
395
|
+
try {
|
|
396
|
+
const real = await fs.realpath(candidate);
|
|
397
|
+
if (!real.startsWith(rootWithSep))
|
|
398
|
+
return null;
|
|
399
|
+
const stats = await fs.stat(real);
|
|
400
|
+
if (!stats.isFile())
|
|
401
|
+
return null;
|
|
402
|
+
return { real, stats };
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Whether the disk still matches the entry: the original and every sidecar
|
|
410
|
+
* position (present or absent when the entry was built) are stat'ed and
|
|
411
|
+
* compared on mtime, size and inode, so a same-length replacement counts
|
|
412
|
+
* as a change and a sidecar that appeared or vanished does too.
|
|
413
|
+
*/
|
|
414
|
+
async stillCurrent(entry) {
|
|
415
|
+
let stats;
|
|
416
|
+
try {
|
|
417
|
+
stats = await fs.stat(entry.filePath);
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
if (!stats.isFile() || !sameStamp(stampOf(stats), entry.identity.stamp))
|
|
423
|
+
return false;
|
|
424
|
+
if (this.precompressed) {
|
|
425
|
+
for (let i = 0; i < SIDECARS.length; i++) {
|
|
426
|
+
let now = null;
|
|
427
|
+
try {
|
|
428
|
+
const st = await fs.stat(entry.filePath + SIDECARS[i].suffix);
|
|
429
|
+
if (st.isFile())
|
|
430
|
+
now = stampOf(st);
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
// absent
|
|
434
|
+
}
|
|
435
|
+
if (!sameStamp(now, entry.sidecarStamps[i] ?? null))
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
insert(key, entry) {
|
|
442
|
+
const cache = this.cache;
|
|
443
|
+
if (cache === null)
|
|
444
|
+
return;
|
|
445
|
+
const old = cache.get(key);
|
|
446
|
+
if (old !== undefined)
|
|
447
|
+
this.evict(key, old);
|
|
448
|
+
cache.set(key, entry);
|
|
449
|
+
this.cacheBytes += entry.bytes;
|
|
450
|
+
// Over budget: the oldest entries go first (insertion order).
|
|
451
|
+
for (const [k, e] of cache) {
|
|
452
|
+
if (this.cacheBytes <= this.cacheMaxBytes || k === key)
|
|
453
|
+
break;
|
|
454
|
+
this.evict(k, e);
|
|
455
|
+
}
|
|
456
|
+
this.ensureWatcher();
|
|
457
|
+
}
|
|
458
|
+
evict(key, entry) {
|
|
459
|
+
if (this.cache !== null && this.cache.delete(key))
|
|
460
|
+
this.cacheBytes -= entry.bytes;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* One watcher on the root directory: any change reported there marks every
|
|
464
|
+
* entry for revalidation on its next request, ahead of `revalidateMs`.
|
|
465
|
+
* Non-persistent, so it never keeps the process alive, and best effort: a
|
|
466
|
+
* platform or filesystem without change events simply falls back to the
|
|
467
|
+
* timed revalidation.
|
|
468
|
+
*/
|
|
469
|
+
ensureWatcher() {
|
|
470
|
+
if (!this.watchWanted || this.watcher !== null)
|
|
471
|
+
return;
|
|
472
|
+
try {
|
|
473
|
+
const watcher = watchDir(this.root, { persistent: false }, () => {
|
|
474
|
+
if (this.cache === null)
|
|
475
|
+
return;
|
|
476
|
+
for (const entry of this.cache.values())
|
|
477
|
+
entry.checkedAt = 0;
|
|
478
|
+
});
|
|
479
|
+
watcher.on('error', () => {
|
|
480
|
+
this.watcher = null;
|
|
481
|
+
this.watchWanted = false; // a dead watcher is not retried
|
|
482
|
+
});
|
|
483
|
+
this.watcher = watcher;
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
this.watchWanted = false;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/** Stop the directory watcher and drop every cached file. */
|
|
490
|
+
close() {
|
|
491
|
+
if (this.watcher !== null) {
|
|
492
|
+
try {
|
|
493
|
+
this.watcher.close();
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
// already closed
|
|
497
|
+
}
|
|
498
|
+
this.watcher = null;
|
|
499
|
+
}
|
|
500
|
+
if (this.cache !== null) {
|
|
501
|
+
this.cache.clear();
|
|
502
|
+
this.cacheBytes = 0;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
/** Answer a request from a cache entry: the same headers and bytes the uncached path sends. */
|
|
506
|
+
serveCached(req, res, entry) {
|
|
507
|
+
res.setHeader('Content-Type', entry.contentType);
|
|
508
|
+
let variant = entry.identity;
|
|
509
|
+
if (this.precompressed) {
|
|
510
|
+
res.setHeader('Vary', 'Accept-Encoding');
|
|
511
|
+
const sidecars = entry.sidecars;
|
|
512
|
+
if (sidecars.length > 0) {
|
|
513
|
+
const acceptEncoding = req.headers['accept-encoding'];
|
|
514
|
+
if (acceptEncoding) {
|
|
515
|
+
for (let i = 0; i < sidecars.length; i++) {
|
|
516
|
+
const candidate = sidecars[i];
|
|
517
|
+
if (negotiateEncoding(acceptEncoding, [candidate.encoding]) !== null) {
|
|
518
|
+
variant = candidate;
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (this.maxAge > 0) {
|
|
526
|
+
res.setHeader('Cache-Control', `public, max-age=${this.maxAge}`);
|
|
527
|
+
}
|
|
528
|
+
if (this.lastModified) {
|
|
529
|
+
res.setHeader('Last-Modified', entry.lastModified);
|
|
530
|
+
}
|
|
531
|
+
if (this.acceptRanges) {
|
|
532
|
+
res.setHeader('Accept-Ranges', 'bytes');
|
|
533
|
+
}
|
|
534
|
+
const etag = this.etag ? variant.etag : undefined;
|
|
535
|
+
if (etag !== undefined)
|
|
536
|
+
res.setHeader('ETag', etag);
|
|
537
|
+
if (this.isNotModified(req, entry.mtime, etag)) {
|
|
538
|
+
res.statusCode = 304;
|
|
539
|
+
res.end();
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
if (variant.encoding !== '') {
|
|
543
|
+
res.setHeader('Content-Encoding', variant.encoding);
|
|
544
|
+
res.setHeader('Content-Length', variant.body.length);
|
|
545
|
+
if (req.method === 'HEAD') {
|
|
546
|
+
res.end();
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
res.end(variant.body);
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
if (req.method === 'HEAD') {
|
|
553
|
+
res.setHeader('Content-Length', variant.body.length);
|
|
554
|
+
res.end();
|
|
555
|
+
return true;
|
|
556
|
+
}
|
|
557
|
+
this.sendBody(res, variant.body, entry.baseMimeType);
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* The identity body of a small file. A compressible one goes through
|
|
562
|
+
* send() so it joins the server's response compression (the compression
|
|
563
|
+
* middleware or performance.compression) exactly like a handler's body
|
|
564
|
+
* would; send() sets Content-Length itself. Binary bodies are ended as they
|
|
565
|
+
* are, with their length.
|
|
566
|
+
*/
|
|
567
|
+
sendBody(res, data, baseMimeType) {
|
|
568
|
+
if (isCompressible(baseMimeType) && typeof res.send === 'function') {
|
|
569
|
+
res.send(data);
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
res.setHeader('Content-Length', data.length);
|
|
573
|
+
res.end(data);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* The strong ETag of one representation: the file's mtime and size, plus
|
|
578
|
+
* the encoding for a sidecar, since a strong ETag must differ between
|
|
579
|
+
* representations (RFC 9110 8.8.1).
|
|
580
|
+
*/
|
|
581
|
+
static etagFor(stats, encoding) {
|
|
582
|
+
return `"${crypto
|
|
583
|
+
.createHash('md5')
|
|
584
|
+
.update(`${Math.trunc(stats.mtimeMs)}-${stats.size}`)
|
|
585
|
+
.digest('hex')}${encoding ? `-${encoding}` : ''}"`;
|
|
586
|
+
}
|
|
281
587
|
/**
|
|
282
588
|
* The best precompressed sidecar for `filePath` that the client accepts, or
|
|
283
589
|
* null. A sidecar must be a regular file INSIDE the root (its real path is
|