fulmine.js 5.6.0 → 5.8.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.
@@ -23,6 +23,8 @@ const bytes = require("bytes");
23
23
  const zlib = require("fast-zlib");
24
24
  const typeis = require("type-is");
25
25
  const mime = require("mime-types");
26
+ const compressible = require("compressible");
27
+ const ms = require("ms");
26
28
  const qs = require("qs");
27
29
  const parseQuery = require("./parse-query.js");
28
30
  const { kGetSafe } = require("./usage.js");
@@ -267,6 +269,52 @@ function bodyError(message, status, type, extra) {
267
269
  return Object.assign(err, extra);
268
270
  }
269
271
 
272
+ /**
273
+ * Whether a file of this extension is one anybody writes a `.br` or a `.gz` next to. A webp or a
274
+ * woff2 is already compressed and never has a twin, and looking for one costs two stats on a
275
+ * request that could not have used it: on a mixed directory that is most of the stat time. An
276
+ * extension nothing knows is looked up anyway, since it might well be text.
277
+ *
278
+ * @param {string} extension including the dot, or "" for a name without one
279
+ * @returns {boolean}
280
+ */
281
+ const hasTwins = memoizeByString((extension) => {
282
+ const type = mime.lookup(extension);
283
+ return type ? compressible(type) === true : true;
284
+ });
285
+
286
+ // Which twins a path has, remembered for a moment. What is cached is only whether they are there,
287
+ // never their size or their mtime: those decide the ETag, the Last-Modified and the length, so they
288
+ // are read fresh on every request and a file that changed is never described by a stale number.
289
+ // The worst a stale entry can do is serve the file where it could have served the twin, or look for
290
+ // a twin that has just been deleted and fall back. nginx's open_file_cache is the same trade.
291
+ const twinCache = new Map();
292
+ const TWIN_CACHE_LIMIT = 4096;
293
+
294
+ /**
295
+ * What is known about a path's twins right now, as a record to fill in.
296
+ *
297
+ * @param {string} filePath
298
+ * @param {number} ttl how long an answer stays good, in milliseconds
299
+ * @returns {{br: boolean|undefined, gz: boolean|undefined, until: number}}
300
+ */
301
+ function twinsOf(filePath, ttl) {
302
+ const now = Date.now();
303
+ const known = twinCache.get(filePath);
304
+ if (known !== undefined && known.until > now) {
305
+ return known;
306
+ }
307
+ const entry = { br: undefined, gz: undefined, until: now + ttl };
308
+ // cleared rather than evicted one by one, as memoizeByString does: this holds one small object
309
+ // per path served, and a directory big enough to reach the limit is being served by something
310
+ // other than an application server anyway
311
+ if (twinCache.size >= TWIN_CACHE_LIMIT) {
312
+ twinCache.clear();
313
+ }
314
+ twinCache.set(filePath, entry);
315
+ return entry;
316
+ }
317
+
270
318
  /**
271
319
  * The compressed twin of a file to serve in its place, or undefined when the client would rather
272
320
  * have the file itself or the twin is not there.
@@ -275,17 +323,19 @@ function bodyError(message, status, type, extra) {
275
323
  * Last-Modified of a variant are its own, which is the whole point. Two bodies sharing one ETag is
276
324
  * how a shared cache ends up handing brotli to a client that cannot read it.
277
325
  *
278
- * At most two stats, and usually one: negotiation picks the best of the two encodings first, and
279
- * only looks at the other when the client takes it too and the first file is missing.
326
+ * One stat when the answer is a twin, and none at all when the last request already found there is
327
+ * no twin to have. See twinCache above for what is remembered and what is not.
280
328
  *
281
329
  * @param {string} filePath absolute path of the file that was asked for
282
330
  * @param {string|undefined} accept the request's Accept-Encoding
331
+ * @param {number} ttl how long the twin cache holds an answer, 0 to ask the disk every time
283
332
  * @returns {{suffix: string, encoding: string, stat: import("fs").Stats}|undefined}
284
333
  */
285
- function pickPrecompressed(filePath, accept) {
286
- if (!accept) {
334
+ function pickPrecompressed(filePath, accept, ttl) {
335
+ if (!accept || !hasTwins(filePath.slice(filePath.lastIndexOf(".")))) {
287
336
  return undefined;
288
337
  }
338
+ const known = ttl > 0 ? twinsOf(filePath, ttl) : undefined;
289
339
  let allowed = ENCODING_BR | ENCODING_GZIP;
290
340
  // twice at most: the second pass is the case where brotli won and there is no .br on disk
291
341
  for (let attempt = 0; attempt < 2; attempt++) {
@@ -294,13 +344,17 @@ function pickPrecompressed(filePath, accept) {
294
344
  if (!variant) {
295
345
  return undefined;
296
346
  }
297
- try {
298
- const stat = fs.statSync(filePath + variant.suffix);
299
- if (!stat.isDirectory()) {
300
- return { suffix: variant.suffix, encoding: variant.encoding, stat };
347
+ if (known === undefined || known[variant.encoding === "br" ? "br" : "gz"] !== false) {
348
+ try {
349
+ const stat = fs.statSync(filePath + variant.suffix);
350
+ if (!stat.isDirectory()) {
351
+ if (known !== undefined) known[variant.encoding === "br" ? "br" : "gz"] = true;
352
+ return { suffix: variant.suffix, encoding: variant.encoding, stat };
353
+ }
354
+ } catch {
355
+ // not on disk, which is the ordinary case for a file nobody precompressed
301
356
  }
302
- } catch {
303
- // not on disk, which is the ordinary case for a file nobody precompressed
357
+ if (known !== undefined) known[variant.encoding === "br" ? "br" : "gz"] = false;
304
358
  }
305
359
  allowed &= ~variant.flag;
306
360
  }
@@ -343,6 +397,26 @@ function serveStatic(root, options) {
343
397
  if (options.setHeaders !== undefined && typeof options.setHeaders !== "function") {
344
398
  throw new TypeError("option setHeaders must be function");
345
399
  }
400
+ // How long express.static remembers which twins a path has. A second is short enough that a
401
+ // deploy is picked up while it is still going out, and long enough that the lookup costs
402
+ // nothing under any traffic at all. { cache: false } asks the disk on every request.
403
+ let twinTtl = 0;
404
+ if (options.preCompressed) {
405
+ const cache = /** @type {any} */ (
406
+ typeof options.preCompressed === "object" ? options.preCompressed.cache : undefined
407
+ );
408
+ twinTtl =
409
+ cache === undefined
410
+ ? 1000
411
+ : cache === false
412
+ ? 0
413
+ : typeof cache === "string"
414
+ ? ms(/** @type {any} */ (cache))
415
+ : cache;
416
+ if (typeof twinTtl !== "number" || !(twinTtl >= 0)) {
417
+ throw new TypeError("option preCompressed.cache must be a duration");
418
+ }
419
+ }
346
420
  options.root = root;
347
421
  // serve-static decides this for itself and never asks the app, so a static file keeps its
348
422
  // ETag under app.set("etag", false) and only { etag: false } here turns it off. res.sendFile
@@ -434,8 +508,22 @@ function serveStatic(root, options) {
434
508
  }
435
509
 
436
510
  let stat;
511
+ // The twin, looked for before the file itself rather than after it. When there is one, it
512
+ // is the file being served and its stat is the only one this request needs: the request
513
+ // that asks for /app.js and gets /app.js.br has no use for /app.js's size or mtime. A
514
+ // directory, or a path written with a trailing slash, keeps the ordinary order, since what
515
+ // decides those is the stat of the thing that was asked for.
516
+ let twin;
517
+ if (options.preCompressed && !rawPath.endsWith("/") && !req.endsWithSlash) {
518
+ twin = pickPrecompressed(filePath, req.headers["accept-encoding"], twinTtl);
519
+ if (twin) {
520
+ stat = twin.stat;
521
+ }
522
+ }
437
523
  try {
438
- stat = fs.statSync(statTarget);
524
+ if (stat === undefined) {
525
+ stat = fs.statSync(statTarget);
526
+ }
439
527
  } catch (err) {
440
528
  // the one to report when nothing is found: send hands each failed attempt to the next
441
529
  // one and reports whichever came last, so an extensions option that also missed names
@@ -541,7 +629,8 @@ function serveStatic(root, options) {
541
629
  // whatever is served, the answer depended on the header, so a shared cache has to be
542
630
  // told. Said before the lookup, because it is true even when there is no variant
543
631
  res.vary("Accept-Encoding");
544
- const variant = pickPrecompressed(filePath, req.headers["accept-encoding"]);
632
+ // already found before the stat below, on the ordinary path
633
+ const variant = twin ?? pickPrecompressed(filePath, req.headers["accept-encoding"], twinTtl);
545
634
  if (variant) {
546
635
  _path += variant.suffix;
547
636
  stat = variant.stat;
package/src/options.d.ts CHANGED
@@ -72,8 +72,12 @@ export interface StaticOptions extends SendFileOptions {
72
72
  * Serve `file.br` or `file.gz` in place of `file` when one is on disk and the client takes it.
73
73
  * Off by default. Vary: Accept-Encoding is sent whether or not a variant is found, and the
74
74
  * content type stays the one the requested name implies.
75
+ *
76
+ * Which twins a path has is remembered for a second, since asking the disk costs a stat per
77
+ * request; `{ cache: false }` asks every time, and a duration sets how long. Only their
78
+ * presence is cached, never their size or mtime.
75
79
  */
76
- preCompressed?: boolean;
80
+ preCompressed?: boolean | { cache?: number | string | false };
77
81
  }
78
82
 
79
83
  /** A body parser's options once its factory has filled in every default it needs. */
@@ -0,0 +1,157 @@
1
+ /*
2
+ Copyright 2026 Nigro Simone
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ // What makes an application answer the questions a library asks about an http.Server.
18
+ //
19
+ // `app.listen()` returns the app, and there is no node server under it: the socket belongs to µWS.
20
+ // That is the one place where a drop-in stops being a drop-in, because the graceful shutdown
21
+ // libraries, the connection trackers and the health check wrappers do not use a server, they
22
+ // *recognise* one: `server instanceof http.Server`, then close(), address(), getConnections() and
23
+ // the events around them. Everything they need can be answered honestly here.
24
+ //
25
+ // Two halves, and the second is the delicate one:
26
+ //
27
+ // - the members. close(), address(), listening and the events already exist on the application,
28
+ // because Express hands back an http.Server and code written for Express uses them. What was
29
+ // missing is the rest of the net.Server surface, added below.
30
+ // - the recognition. An application cannot inherit from http.Server: its prototype chain already
31
+ // runs through Router and this project's own EventEmitter, and http.Server.prototype has a
32
+ // chain of its own that cannot be spliced into it without re-parenting node's classes for the
33
+ // whole process. So instanceof is taught about applications instead, through the hook the
34
+ // language provides for exactly this: Symbol.hasInstance. The patch is additive. Everything
35
+ // that was an http.Server before still is, and the only new answer is for an application.
36
+ //
37
+ // What this deliberately does not do is pretend the plumbing is there. Nothing emits 'request',
38
+ // 'connection' or 'upgrade', because those carry node sockets and there are none: a library that
39
+ // counts connections through them counts zero, and socket.io still wants app.uwsApp. The shape is
40
+ // honest about what is behind it, which is why getConnections answers with the requests in flight
41
+ // rather than with a number nobody could stand behind.
42
+
43
+ const http = require("http");
44
+ const net = require("net");
45
+
46
+ // what marks an application, read by the instanceof hook below. A symbol rather than a property
47
+ // name, so nothing can be mistaken for an application by carrying the wrong field
48
+ const kIsApplication = Symbol.for("fulmine.application");
49
+
50
+ /**
51
+ * Teaches `instanceof` that an application is a server, once per class. The original answer is
52
+ * asked first and is never overruled: this only adds an answer for objects carrying the mark.
53
+ *
54
+ * @param {Function} klass http.Server or net.Server
55
+ */
56
+ function acceptApplications(klass) {
57
+ const previous = /** @type {any} */ (klass)[Symbol.hasInstance];
58
+ // already taught, which happens when two copies of this package share one process
59
+ if (/** @type {any} */ (klass)[kIsApplication] === true) {
60
+ return;
61
+ }
62
+ Object.defineProperty(klass, Symbol.hasInstance, {
63
+ /** @param {any} value @returns {boolean} */
64
+ value: function (value) {
65
+ if (previous.call(this, value)) {
66
+ return true;
67
+ }
68
+ // an application is a function, and a property read works on one; the guard is for the
69
+ // primitives and the nulls that reach any instanceof
70
+ return value != null && /** @type {any} */ (value)[kIsApplication] === true;
71
+ },
72
+ configurable: true,
73
+ writable: true
74
+ });
75
+ Object.defineProperty(klass, kIsApplication, { value: true, configurable: true });
76
+ }
77
+
78
+ acceptApplications(http.Server);
79
+ acceptApplications(net.Server);
80
+
81
+ /**
82
+ * The net.Server members an application does not get from Express's side of the API, defined on
83
+ * the application prototype. Each one answers for µWS rather than for a socket node does not have.
84
+ *
85
+ * @param {any} prototype Application.prototype
86
+ */
87
+ function addServerMembers(prototype) {
88
+ Object.defineProperty(prototype, kIsApplication, { value: true, configurable: true });
89
+
90
+ /**
91
+ * How many requests this application is serving right now.
92
+ *
93
+ * node counts sockets; there are none to count here, and the number a graceful shutdown is
94
+ * waiting for is this one anyway: it reaches zero when the last answer has gone out. An idle
95
+ * keep-alive connection is not counted, and closing does not wait for one either.
96
+ *
97
+ * @param {(err: Error|null, count: number) => void} callback
98
+ */
99
+ prototype.getConnections = function getConnections(callback) {
100
+ let count = 0;
101
+ for (let response = this._pending.head; response !== null; response = response._pendingNext) {
102
+ count++;
103
+ }
104
+ // node answers this one asynchronously, and a caller written against it may rely on that
105
+ process.nextTick(callback, null, count);
106
+ };
107
+
108
+ /**
109
+ * node's, for a handle this does not own: µWS's loop is what keeps the process alive, and it
110
+ * is not something a caller may unref. Both are no-ops that hand the server back, so a chain
111
+ * written against node's API keeps working.
112
+ *
113
+ * @returns {any}
114
+ */
115
+ prototype.ref = function ref() {
116
+ return this;
117
+ };
118
+
119
+ /** @returns {any} */
120
+ prototype.unref = function unref() {
121
+ return this;
122
+ };
123
+
124
+ /**
125
+ * Registers the callback the way node's does and remembers the value, which is all a caller
126
+ * can observe. The timeout itself belongs to µWS and is set through uwsOptions.idleTimeout.
127
+ *
128
+ * @this {any}
129
+ * @param {number} [msecs]
130
+ * @param {() => void} [callback]
131
+ * @returns {any}
132
+ */
133
+ prototype.setTimeout = function setTimeout(msecs, callback) {
134
+ this.timeout = msecs;
135
+ if (callback) {
136
+ this.on("timeout", callback);
137
+ }
138
+ return this;
139
+ };
140
+
141
+ // The numbers node's http.Server carries and a caller may read or write. They are inert here,
142
+ // and they are declared rather than left undefined because reading one is how a library works
143
+ // out what it is talking to: `server.keepAliveTimeout` undefined has been read as "not a
144
+ // server" before now.
145
+ for (const [name, value] of /** @type {[string, any][]} */ ([
146
+ ["timeout", 0],
147
+ ["keepAliveTimeout", 5000],
148
+ ["headersTimeout", 60000],
149
+ ["requestTimeout", 300000],
150
+ ["maxHeadersCount", null],
151
+ ["maxRequestsPerSocket", 0]
152
+ ])) {
153
+ Object.defineProperty(prototype, name, { value, writable: true, configurable: true, enumerable: false });
154
+ }
155
+ }
156
+
157
+ module.exports = { addServerMembers, kIsApplication };
@@ -0,0 +1,180 @@
1
+ /*
2
+ Copyright 2026 Nigro Simone
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ // express.serverTiming(): Server-Timing, with the two things only this framework can put in it.
18
+ //
19
+ // A stopwatch middleware is nothing new, and there are several on npm. What none of them can add
20
+ // is how the request was routed, because in every other framework there is only one way:
21
+ //
22
+ // Server-Timing: route;desc="native", hdr;desc="not copied", total;dur=0.42
23
+ //
24
+ // `route;desc="native"` means µWS matched the path in C++ and handed over a chain worked out at
25
+ // startup. `route;desc="router"` means this request was matched here, in javascript, layer by
26
+ // layer. That is the difference between the two halves of this project, per request, in the
27
+ // browser's network panel, for someone who would never run a CLI.
28
+ //
29
+ // What it cannot show is the route that is faster still: a handler compiled into a response never
30
+ // enters javascript, so no middleware runs on it and there is nothing to time. `npx fulmine
31
+ // profile` is where those are counted.
32
+ //
33
+ // The duration ends where the header does. Server-Timing goes out with the head, so `total` covers
34
+ // everything up to the moment the answer starts leaving, and not the body after it. Every stopwatch
35
+ // middleware has that boundary; this one says so.
36
+
37
+ "use strict";
38
+
39
+ /**
40
+ * A duration in milliseconds, as Server-Timing writes them: two decimals, which is a hundredth of
41
+ * a millisecond and finer than anything above it is worth.
42
+ *
43
+ * @param {bigint} nanoseconds
44
+ * @returns {string}
45
+ */
46
+ function millis(nanoseconds) {
47
+ return (Number(nanoseconds) / 1e6).toFixed(2);
48
+ }
49
+
50
+ /**
51
+ * Escapes a description for the quoted-string it goes in.
52
+ * @param {string} text
53
+ * @returns {string}
54
+ */
55
+ function describe(text) {
56
+ return `"${String(text).replace(/["\\]/g, "")}"`;
57
+ }
58
+
59
+ /**
60
+ * Measures the request and answers with Server-Timing.
61
+ *
62
+ * @param {object} [options]
63
+ * @param {boolean} [options.routing] whether to report how the request was routed. Default true.
64
+ * @param {boolean} [options.total] whether to report the time up to the head. Default true.
65
+ * @param {string} [options.name] what the total is called. Default "total".
66
+ * @returns {(req: any, res: any, next: (err?: any) => void) => void}
67
+ */
68
+ function serverTiming(options) {
69
+ const opts = options || {};
70
+ const routing = opts.routing !== false;
71
+ const wantsTotal = opts.total !== false;
72
+ const totalName = opts.name || "total";
73
+
74
+ return function serverTiming(req, res, next) {
75
+ const started = process.hrtime.bigint();
76
+ /** @type {string[]} */
77
+ const marks = [];
78
+
79
+ /**
80
+ * Adds a mark of the caller's own, which is what the rest of Server-Timing is for: the
81
+ * query, the upstream call, the render. A duration is optional, since a mark with only a
82
+ * description is a legal entry and is how a cache hit is usually reported.
83
+ *
84
+ * @param {string} name a token: letters, digits, dash and underscore
85
+ * @param {number} [duration] milliseconds
86
+ * @param {string} [description]
87
+ * @returns {any} the response, so calls chain
88
+ */
89
+ res.timing = function timing(name, duration, description) {
90
+ let mark = String(name).replace(/[^\w-]/g, "");
91
+ if (typeof duration === "number") {
92
+ mark += `;dur=${duration.toFixed(2)}`;
93
+ }
94
+ if (description) {
95
+ mark += `;desc=${describe(description)}`;
96
+ }
97
+ marks.push(mark);
98
+ return this;
99
+ };
100
+
101
+ /**
102
+ * Times a piece of work under a name, whatever it is: the value comes back, and a promise
103
+ * is timed to where it settles.
104
+ *
105
+ * @param {string} name
106
+ * @param {() => any} work
107
+ * @returns {any} whatever the work returned
108
+ */
109
+ res.time = function time(name, work) {
110
+ const from = process.hrtime.bigint();
111
+ const done = () => res.timing(name, Number(process.hrtime.bigint() - from) / 1e6);
112
+ let value;
113
+ try {
114
+ value = work();
115
+ } catch (err) {
116
+ done();
117
+ throw err;
118
+ }
119
+ if (value && typeof value.then === "function") {
120
+ return value.then(
121
+ /** @param {any} resolved */ (resolved) => {
122
+ done();
123
+ return resolved;
124
+ },
125
+ /** @param {any} err */ (err) => {
126
+ done();
127
+ throw err;
128
+ }
129
+ );
130
+ }
131
+ done();
132
+ return value;
133
+ };
134
+
135
+ const _write = res.write;
136
+ const _end = res.end;
137
+ let written = false;
138
+
139
+ /** Writes the header, once, just before the head goes out with the first byte of body. */
140
+ const stamp = () => {
141
+ if (written || res.headersSent) {
142
+ return;
143
+ }
144
+ written = true;
145
+ const entries = [];
146
+ if (routing) {
147
+ // what the router decided about the route this request ran, which is the same
148
+ // verdict npx fulmine profile prints for it
149
+ const native = req.route?._native;
150
+ entries.push(`route;desc=${describe(native ? "native" : "router")}`);
151
+ if (native) {
152
+ entries.push(`hdr;desc=${describe(native.skipHeaders ? "not copied" : "copied")}`);
153
+ if (native.skipQuery) {
154
+ entries.push(`query;desc=${describe("not parsed")}`);
155
+ }
156
+ }
157
+ }
158
+ entries.push(...marks);
159
+ if (wantsTotal) {
160
+ entries.push(`${totalName};dur=${millis(process.hrtime.bigint() - started)}`);
161
+ }
162
+ if (entries.length !== 0) {
163
+ res.append("Server-Timing", entries.join(", "));
164
+ }
165
+ };
166
+
167
+ res.write = function write(chunk, encoding, callback) {
168
+ stamp();
169
+ return _write.call(this, chunk, encoding, callback);
170
+ };
171
+ res.end = function end(chunk, encoding, callback) {
172
+ stamp();
173
+ return _end.call(this, chunk, encoding, callback);
174
+ };
175
+
176
+ next();
177
+ };
178
+ }
179
+
180
+ module.exports = serverTiming;