@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.
@@ -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; charset=utf-8');
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 = `"${crypto
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
- if (isCompressible(baseMimeType) && typeof res.send === 'function') {
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