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.
- package/README.md +67 -2
- package/package.json +2 -2
- package/src/application.js +5 -0
- package/src/cli.js +159 -41
- package/src/compression.js +62 -17
- package/src/index.js +8 -0
- package/src/middlewares.js +101 -12
- package/src/options.d.ts +5 -1
- package/src/server-shape.js +157 -0
- package/src/server-timing.js +180 -0
- package/src/testing.js +201 -0
- package/src/types.d.ts +26 -0
- package/src/utils.js +33 -5
- package/src/verify.js +309 -0
package/src/middlewares.js
CHANGED
|
@@ -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
|
-
*
|
|
279
|
-
*
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|