fulmine.js 5.7.0 → 5.9.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 +99 -9
- package/package.json +2 -2
- package/src/application.js +46 -2
- package/src/cli.js +159 -41
- package/src/cluster.js +204 -0
- package/src/index.js +8 -0
- package/src/middlewares.js +20 -3
- 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 +27 -0
- package/src/verify.js +318 -0
package/src/cluster.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
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({ cluster: "auto" }): one process per core, all on the same port.
|
|
18
|
+
//
|
|
19
|
+
// A node process runs the application on one core, and the other fifteen sit there. The usual
|
|
20
|
+
// answer is the cluster module, where the primary holds the listening socket and hands each
|
|
21
|
+
// accepted connection to a worker over an IPC channel. µWS does not need that: it can bind with
|
|
22
|
+
// the port marked shared, which is SO_REUSEPORT, and then every worker has its own listening
|
|
23
|
+
// socket on the same port and the kernel picks which one gets each connection. No primary in the
|
|
24
|
+
// path, no handle to pass, nothing serialised between processes.
|
|
25
|
+
//
|
|
26
|
+
// The flag has been passed for a while, see Application#listen: a worker binds shared and a lone
|
|
27
|
+
// process binds exclusive. What was missing is the fork, which every application had to write for
|
|
28
|
+
// itself, and an application that does not write it uses one core.
|
|
29
|
+
|
|
30
|
+
"use strict";
|
|
31
|
+
|
|
32
|
+
const cluster = require("cluster");
|
|
33
|
+
const fs = require("fs");
|
|
34
|
+
const os = require("os");
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} file
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
function readFile(file) {
|
|
41
|
+
return fs.readFileSync(file, "utf8");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How many cores the operating system says are usable.
|
|
46
|
+
*
|
|
47
|
+
* @returns {number}
|
|
48
|
+
*/
|
|
49
|
+
function parallelism() {
|
|
50
|
+
return os.availableParallelism ? os.availableParallelism() : os.cpus().length;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The CPU quota a cgroup puts on this process, in cores, or undefined where there is none.
|
|
55
|
+
*
|
|
56
|
+
* This is the number that matters in a container: os.availableParallelism() reports the machine,
|
|
57
|
+
* not the share of it the orchestrator gave away, so a 2-core pod on a 64-core node would fork 64
|
|
58
|
+
* processes that fight over two cores. Both cgroup layouts are read, v2 first.
|
|
59
|
+
*
|
|
60
|
+
* @param {(file: string) => string} [read] the file reader, for a test that has no cgroup
|
|
61
|
+
* @returns {number|undefined}
|
|
62
|
+
*/
|
|
63
|
+
function cgroupCores(read = readFile) {
|
|
64
|
+
try {
|
|
65
|
+
const [quota, period] = read("/sys/fs/cgroup/cpu.max").trim().split(/\s+/);
|
|
66
|
+
// "max" is the word for no quota at all, and it is not a number
|
|
67
|
+
if (quota !== "max" && Number(period) > 0) {
|
|
68
|
+
return Number(quota) / Number(period);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// no cgroup v2 here, try the older layout
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const quota = Number(read("/sys/fs/cgroup/cpu/cpu.cfs_quota_us"));
|
|
75
|
+
const period = Number(read("/sys/fs/cgroup/cpu/cpu.cfs_period_us"));
|
|
76
|
+
if (quota > 0 && period > 0) {
|
|
77
|
+
return quota / period;
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// nor v1: this is a plain machine
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The cores this process may actually use: what the machine has, capped by what the cgroup allows.
|
|
87
|
+
*
|
|
88
|
+
* @param {(file: string) => string} [read]
|
|
89
|
+
* @param {() => number} [cores]
|
|
90
|
+
* @returns {number}
|
|
91
|
+
*/
|
|
92
|
+
function availableCores(read = readFile, cores = parallelism) {
|
|
93
|
+
const machine = cores();
|
|
94
|
+
const quota = cgroupCores(read);
|
|
95
|
+
if (quota === undefined || !(quota > 0)) {
|
|
96
|
+
return machine;
|
|
97
|
+
}
|
|
98
|
+
// floored, never rounded up: half a core of headroom is not a process
|
|
99
|
+
return Math.max(1, Math.min(machine, Math.floor(quota)));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* How many workers a `cluster` setting asks for. Zero means the setting is off and the process
|
|
104
|
+
* serves by itself, which is the default.
|
|
105
|
+
*
|
|
106
|
+
* A value nobody can read is a throw rather than a quiet zero: `cluster: "atuo"` running on one
|
|
107
|
+
* core in production, with nothing said about it, is the failure this whole thing is against.
|
|
108
|
+
*
|
|
109
|
+
* @param {boolean|number|"auto"|undefined} setting
|
|
110
|
+
* @param {number} [cores] counted only when the setting asks for it: every application calls this,
|
|
111
|
+
* and almost none of them wants the cgroup read
|
|
112
|
+
* @returns {number}
|
|
113
|
+
*/
|
|
114
|
+
function workerCount(setting, cores) {
|
|
115
|
+
if (setting === undefined || setting === false || setting === 0) {
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
if (setting === true || setting === "auto") {
|
|
119
|
+
return Math.max(1, cores ?? availableCores());
|
|
120
|
+
}
|
|
121
|
+
if (typeof setting === "number" && Number.isFinite(setting) && setting > 0) {
|
|
122
|
+
return Math.floor(setting);
|
|
123
|
+
}
|
|
124
|
+
throw new TypeError(`cluster must be "auto", a boolean or a positive number, not ${JSON.stringify(setting)}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Whether this process has forked workers, and so serves nothing itself. An application carries
|
|
128
|
+
// the setting, but the answer is about the process: an entry with a second app on a TLS port would
|
|
129
|
+
// otherwise bind that one here, exclusively, and every worker would fail on it.
|
|
130
|
+
let supervising = false;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether this process forked workers and left the serving to them.
|
|
134
|
+
*
|
|
135
|
+
* @returns {boolean}
|
|
136
|
+
*/
|
|
137
|
+
function isSupervising() {
|
|
138
|
+
return supervising;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Says this process is the primary of a clustered application, before it has forked anything.
|
|
143
|
+
*
|
|
144
|
+
* Written when the application is constructed and not when it listens, because the order is the
|
|
145
|
+
* application's to choose: an entry that listens on its TLS port first would otherwise have taken
|
|
146
|
+
* that port here, exclusively, a line before the fork.
|
|
147
|
+
*
|
|
148
|
+
* @returns {void}
|
|
149
|
+
*/
|
|
150
|
+
function becomeSupervisor() {
|
|
151
|
+
supervising = true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Forks the workers and keeps that many of them alive.
|
|
156
|
+
*
|
|
157
|
+
* A worker that dies is replaced, and there is nothing to rebuild when it comes back: it binds the
|
|
158
|
+
* shared port again and the kernel starts handing it connections. A signal that reaches the
|
|
159
|
+
* primary alone, which is what a container sends, is passed on rather than leaving the workers
|
|
160
|
+
* running with nobody watching them.
|
|
161
|
+
*
|
|
162
|
+
* @param {number} count
|
|
163
|
+
* @returns {{stop: () => void}}
|
|
164
|
+
*/
|
|
165
|
+
function forkWorkers(count) {
|
|
166
|
+
let stopping = false;
|
|
167
|
+
supervising = true;
|
|
168
|
+
/** @param {any} worker @param {number} code @param {string} signal */
|
|
169
|
+
const onExit = (worker, code, signal) => {
|
|
170
|
+
if (!stopping) {
|
|
171
|
+
console.error(`worker ${worker.process.pid} exited (${signal || code}), starting another`);
|
|
172
|
+
cluster.fork();
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
cluster.on("exit", onExit);
|
|
176
|
+
for (let i = 0; i < count; i++) {
|
|
177
|
+
cluster.fork();
|
|
178
|
+
}
|
|
179
|
+
const stop = () => {
|
|
180
|
+
stopping = true;
|
|
181
|
+
supervising = false;
|
|
182
|
+
cluster.off("exit", onExit);
|
|
183
|
+
for (const id of Object.keys(cluster.workers ?? {})) {
|
|
184
|
+
/** @type {any} */ (cluster.workers)[id]?.kill();
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
for (const signal of ["SIGTERM", "SIGINT"]) {
|
|
188
|
+
process.on(signal, () => {
|
|
189
|
+
stop();
|
|
190
|
+
// the primary exits on its own once the last IPC channel closes; this only makes sure
|
|
191
|
+
// it does, and being unref'd it never keeps the process up by itself
|
|
192
|
+
const done = setInterval(() => {
|
|
193
|
+
if (Object.keys(cluster.workers ?? {}).length === 0) {
|
|
194
|
+
clearInterval(done);
|
|
195
|
+
process.exit(0);
|
|
196
|
+
}
|
|
197
|
+
}, 20);
|
|
198
|
+
done.unref();
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
return { stop };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = { availableCores, cgroupCores, workerCount, forkWorkers, isSupervising, becomeSupervisor };
|
package/src/index.js
CHANGED
|
@@ -49,7 +49,9 @@ try {
|
|
|
49
49
|
* response: object,
|
|
50
50
|
* application: object,
|
|
51
51
|
* static: Function,
|
|
52
|
+
* testing: object,
|
|
52
53
|
* compression: Function,
|
|
54
|
+
* serverTiming: Function,
|
|
53
55
|
* json: Function,
|
|
54
56
|
* urlencoded: Function,
|
|
55
57
|
* text: Function,
|
|
@@ -73,9 +75,15 @@ module.exports.response = Response.prototype;
|
|
|
73
75
|
module.exports.application = Application.Application.prototype;
|
|
74
76
|
|
|
75
77
|
module.exports.static = middlewares.static;
|
|
78
|
+
// what listen() decided about each route, as something a test can assert on rather than something
|
|
79
|
+
// to read in a terminal. See src/testing.js
|
|
80
|
+
module.exports.testing = require("./testing.js");
|
|
76
81
|
// not one of express's, since express has none: the compression module is what everyone installs
|
|
77
82
|
// instead, and this is that middleware's options and behaviour without the install
|
|
78
83
|
module.exports.compression = require("./compression.js");
|
|
84
|
+
// Server-Timing with the routing verdict in it, which no other framework can report because no
|
|
85
|
+
// other framework has two routes to tell apart. See src/server-timing.js
|
|
86
|
+
module.exports.serverTiming = require("./server-timing.js");
|
|
79
87
|
module.exports.json = middlewares.json;
|
|
80
88
|
module.exports.urlencoded = middlewares.urlencoded;
|
|
81
89
|
module.exports.text = middlewares.text;
|
package/src/middlewares.js
CHANGED
|
@@ -418,6 +418,8 @@ function serveStatic(root, options) {
|
|
|
418
418
|
}
|
|
419
419
|
}
|
|
420
420
|
options.root = root;
|
|
421
|
+
// resolved once here rather than on every request: the root cannot change under a mount
|
|
422
|
+
const resolvedRoot = path.resolve(root);
|
|
421
423
|
// serve-static decides this for itself and never asks the app, so a static file keeps its
|
|
422
424
|
// ETag under app.set("etag", false) and only { etag: false } here turns it off. res.sendFile
|
|
423
425
|
// takes the app's setting instead, which is why this has to be said out loud.
|
|
@@ -469,7 +471,19 @@ function serveStatic(root, options) {
|
|
|
469
471
|
} else return next();
|
|
470
472
|
}
|
|
471
473
|
let _path = url;
|
|
472
|
-
|
|
474
|
+
// Joined against the root and not normalised on its own first, which is the difference
|
|
475
|
+
// between "/mount/../package.json" being refused and being served: a ".." has to climb
|
|
476
|
+
// relative to the root so the check below can see it leave, and normalizing the url alone
|
|
477
|
+
// clamps it at "/" where nothing has left anywhere. Absolute because resolvedRoot is, so
|
|
478
|
+
// nothing here resolves against the working directory per request either.
|
|
479
|
+
// and without the trailing separator join keeps and resolve does not, because statTarget
|
|
480
|
+
// below puts it back only where it belongs: linux refuses a file asked for as a directory,
|
|
481
|
+
// so a mount whose root is a file answers nothing at all if the separator stays here.
|
|
482
|
+
// Windows stats it either way, which is why only the CI said so.
|
|
483
|
+
let fullpath = path.join(resolvedRoot, url);
|
|
484
|
+
if (fullpath.length > resolvedRoot.length && fullpath.endsWith(path.sep)) {
|
|
485
|
+
fullpath = fullpath.slice(0, -1);
|
|
486
|
+
}
|
|
473
487
|
// the same file as _path, absolute: the two move together through the index and extension
|
|
474
488
|
// rules below, and only the precompressed lookup needs the absolute one
|
|
475
489
|
let filePath = fullpath;
|
|
@@ -483,7 +497,7 @@ function serveStatic(root, options) {
|
|
|
483
497
|
// is what an error handler prints when fallthrough is off.
|
|
484
498
|
const mountRelative = rawPath === "/" && !req.endsWithSlash ? "" : url;
|
|
485
499
|
const statTarget = mountRelative.endsWith("/") && !fullpath.endsWith(path.sep) ? fullpath + path.sep : fullpath;
|
|
486
|
-
if (root && !fullpath.startsWith(
|
|
500
|
+
if (root && !fullpath.startsWith(resolvedRoot)) {
|
|
487
501
|
if (!options.fallthrough) {
|
|
488
502
|
res.status(403);
|
|
489
503
|
return next(httpError(403));
|
|
@@ -496,7 +510,10 @@ function serveStatic(root, options) {
|
|
|
496
510
|
// reaches it only for paths that do exist.
|
|
497
511
|
// normalized first, as send normalizes before it judges: a ".." segment is not a hidden
|
|
498
512
|
// file, and resolving it away is what tells the two apart
|
|
499
|
-
|
|
513
|
+
// and these are the segments path.normalize(url) would have produced, taken off the joined
|
|
514
|
+
// path rather than walked again: the check above has just proved it starts with the root,
|
|
515
|
+
// so what follows the root is the url in normal form
|
|
516
|
+
if (containsDotFile(fullpath.slice(resolvedRoot.length).split(/[\\/]/))) {
|
|
500
517
|
const refusal = options.dotfiles === "deny" ? 403 : options.dotfiles === "allow" ? 0 : 404;
|
|
501
518
|
if (refusal !== 0 && !(options.dotfiles === "ignore_files" && !path.basename(url).startsWith("."))) {
|
|
502
519
|
if (!options.fallthrough) {
|
|
@@ -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;
|