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/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;
@@ -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
- const fullpath = path.resolve(path.join(root, url));
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(path.resolve(root))) {
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
- if (containsDotFile(path.normalize(url).split(/[\\/]/))) {
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;