fulmine.js 5.8.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 +34 -9
- package/package.json +1 -1
- package/src/application.js +41 -2
- package/src/cluster.js +204 -0
- package/src/middlewares.js +20 -3
- package/src/types.d.ts +1 -0
- package/src/verify.js +20 -11
package/README.md
CHANGED
|
@@ -248,7 +248,7 @@ A single-stage `node:26-trixie-slim` image works too if you `apt-get install -y
|
|
|
248
248
|
|
|
249
249
|
## Differences from Express
|
|
250
250
|
|
|
251
|
-
- `app.listen()` returns the app
|
|
251
|
+
- `app.listen()` returns the app rather than a separate server object, and the app answers as an `http.Server`: `app instanceof http.Server` is true, which is what the graceful shutdown wrappers and the connection trackers look for. There is still no node server underneath, the socket belongs to µWS, so what is answered is the surface and not the plumbing. There: `close()`, `address()`, `listening`, `getConnections()`, `ref()`, `unref()`, `setTimeout()` and the `keepAliveTimeout` family. Not there: nothing emits `connection`, `request` or `upgrade`, `getConnections()` counts the requests in flight rather than sockets, and the timeouts belong to µWS and are set through `uwsOptions.idleTimeout`. Anything that wants to serve its own protocol on the socket, socket.io being the usual case, still wants `app.uwsApp`.
|
|
252
252
|
- `x-powered-by` is disabled by default. Express sends `X-Powered-By: Express` unless you turn it off; Fulmine does not send it unless you turn it on with `app.set("x-powered-by", true)`. The header only tells anyone asking which framework is running.
|
|
253
253
|
- request body is only read for POST, PUT, PATCH and QUERY requests by default. You can add additional methods by setting `body methods` to array with uppercased methods.
|
|
254
254
|
- **Informational responses go nowhere.** `res.writeEarlyHints()`, `res.writeContinue()` and `res.writeProcessing()` are all there, take what node's take and throw what node's throw once the head has gone out, but nothing reaches the wire: µWebSockets.js has no API for a `1xx`. They exist so that code written for Express keeps running rather than dying on "is not a function", which is the only thing a drop-in can honestly promise here. `res.addTrailers()` is the same story, and `res.setTimeout()` and `req.setTimeout()` register the listener without changing anything, since µWS runs its own idle timeout through `uwsOptions.idleTimeout`.
|
|
@@ -446,9 +446,26 @@ app.use(express.compression({ threshold: 1024 }));
|
|
|
446
446
|
|
|
447
447
|
8. By default, Fulmine creates 1 (or 0 if your CPU has only 1 core) child thread to improve performance of reading files. You can change this number by setting `threads` to a different number in `express()`, or set to 0 to disable thread pool (`express({ threads: 0 })`). Threads are shared between all express() instances, with largest `threads` number being used. Using more threads will not necessarily improve performance. Sometimes not using threads at all is faster, so measure both.
|
|
448
448
|
|
|
449
|
+
9. One node process uses one core, and this is the setting that changes it. `express({ cluster: "auto" })` forks one process per core and each of them binds the same port with µWS's shared flag, which is `SO_REUSEPORT`: every process has its own listening socket and the kernel decides which one gets each connection. Node's own `cluster` cannot do that with an `http.Server`, so the primary holds the socket and passes each accepted connection to a worker over IPC; here the primary is not in the path at all. On a 16-core machine that is close to 16 times the throughput, and no other setting comes near it.
|
|
450
|
+
|
|
451
|
+
```js
|
|
452
|
+
// "auto" is one worker per usable core: the cgroup quota is read first, so a 2-core container
|
|
453
|
+
// on a 64-core host forks 2 and not 64. A number instead of "auto" says how many.
|
|
454
|
+
const app = express({ cluster: "auto" });
|
|
455
|
+
|
|
456
|
+
app.get("/", (req, res) => res.send("hello"));
|
|
457
|
+
|
|
458
|
+
// The whole file runs again in every worker, which is how cluster works: the code above this
|
|
459
|
+
// line runs once per process. The primary only forks, so the callback runs once per worker too,
|
|
460
|
+
// and a worker that dies is replaced.
|
|
461
|
+
app.listen(3000, () => console.log(`worker ${process.pid} listening`));
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Anything held per process is now held per worker: an in-memory cache, a rate-limit counter, a session store or a `Map` of connected sockets is not shared, and needs Redis or something like it to be. `app.close()` in the primary stops the workers, and a `SIGTERM` or `SIGINT` that reaches only the primary, which is what a container sends, is passed on to them.
|
|
465
|
+
|
|
449
466
|
## WebSockets
|
|
450
467
|
|
|
451
|
-
`app.ws()` registers a WebSocket route, served by µWS itself.
|
|
468
|
+
`app.ws()` registers a WebSocket route, served by µWS itself. The upgrade never reaches node, so `server.on("upgrade")` and the libraries built on it have nothing to hear; this is the replacement.
|
|
452
469
|
|
|
453
470
|
```js
|
|
454
471
|
app.ws("/room/:id", {
|
|
@@ -482,8 +499,8 @@ If you would rather use the `ws` module's API, [Ultimate WS](https://github.com/
|
|
|
482
499
|
|
|
483
500
|
### socket.io
|
|
484
501
|
|
|
485
|
-
socket.io normally takes over the upgrade on a node `http.Server`.
|
|
486
|
-
the µWS app instead, which socket.io supports natively through `attachApp()`:
|
|
502
|
+
socket.io normally takes over the upgrade on a node `http.Server`. The upgrade here never reaches
|
|
503
|
+
node, so hand it the µWS app instead, which socket.io supports natively through `attachApp()`:
|
|
487
504
|
|
|
488
505
|
```js
|
|
489
506
|
const express = require("fulmine.js");
|
|
@@ -500,10 +517,14 @@ io.on("connection", (socket) => {
|
|
|
500
517
|
});
|
|
501
518
|
```
|
|
502
519
|
|
|
503
|
-
`attachApp()` works before or after `app.listen()`. What does not work is `new Server(
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
520
|
+
`attachApp()` works before or after `app.listen()`. What does not work is `new Server(app)` on the
|
|
521
|
+
app itself, or on what `app.listen()` returns, which is the same object: socket.io refuses it with
|
|
522
|
+
"You are trying to attach socket.io to an express request handler function", because it checks for a
|
|
523
|
+
function before it checks for a server, and an app here is callable. That refusal is the useful
|
|
524
|
+
answer. Even if it accepted the object, there is no node socket behind it to take an upgrade over,
|
|
525
|
+
so it would have failed later and more quietly. Plain HTTP keeps serving either way. This is covered
|
|
526
|
+
by `tests/tests/middlewares/socket-io.js`, which runs the same file against Express and against
|
|
527
|
+
Fulmine and compares the output.
|
|
507
528
|
|
|
508
529
|
## HTTP/3
|
|
509
530
|
|
|
@@ -795,4 +816,8 @@ Any Express view engine should work. Here's list of engines we include in our te
|
|
|
795
816
|
## Working on Fulmine
|
|
796
817
|
|
|
797
818
|
How to run the suites, what each of them is for, and how to write a comparison test:
|
|
798
|
-
[`CONTRIBUTING.md`](./CONTRIBUTING.md).
|
|
819
|
+
[`CONTRIBUTING.md`](./CONTRIBUTING.md). What is expected of everyone taking part:
|
|
820
|
+
[`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md).
|
|
821
|
+
|
|
822
|
+
Found something exploitable? Report it privately rather than in an issue, and see
|
|
823
|
+
[`SECURITY.md`](./SECURITY.md) for what is in scope and what to expect.
|
package/package.json
CHANGED
package/src/application.js
CHANGED
|
@@ -37,6 +37,7 @@ const { Worker } = require("worker_threads");
|
|
|
37
37
|
const cluster = require("cluster");
|
|
38
38
|
const { registerWebSocketRoutes } = require("./websocket.js");
|
|
39
39
|
const { addServerMembers } = require("./server-shape.js");
|
|
40
|
+
const { workerCount, forkWorkers, isSupervising, becomeSupervisor } = require("./cluster.js");
|
|
40
41
|
|
|
41
42
|
const cpuCount = os.cpus().length;
|
|
42
43
|
|
|
@@ -103,8 +104,9 @@ class Application extends Router {
|
|
|
103
104
|
/**
|
|
104
105
|
* @param {object} [settings] the options express() takes. uwsOptions goes to uWS and decides
|
|
105
106
|
* between an HTTP, an HTTPS and an HTTP/3 server; threads sizes the file-reading pool, and 0
|
|
106
|
-
* turns it off;
|
|
107
|
-
*
|
|
107
|
+
* turns it off; cluster forks one process per core over the same port; uwsApp adopts an
|
|
108
|
+
* existing uWS app instead of making one. Everything else is an application setting and
|
|
109
|
+
* lands next to the defaults.
|
|
108
110
|
*/
|
|
109
111
|
constructor(settings = new NullObject()) {
|
|
110
112
|
super(settings);
|
|
@@ -114,6 +116,13 @@ class Application extends Router {
|
|
|
114
116
|
if (typeof settings.threads !== "number") {
|
|
115
117
|
settings.threads = cpuCount > 1 ? 1 : 0;
|
|
116
118
|
}
|
|
119
|
+
// how many processes listen() should fork, counted here so a setting nobody can read is a
|
|
120
|
+
// throw where the application is written and not where it is started. Saying it here also
|
|
121
|
+
// settles it for the whole process before any app has listened, see becomeSupervisor
|
|
122
|
+
this._clusterWorkers = workerCount(settings.cluster);
|
|
123
|
+
if (this._clusterWorkers > 0 && cluster.isPrimary) {
|
|
124
|
+
becomeSupervisor();
|
|
125
|
+
}
|
|
117
126
|
if (settings.uwsApp) {
|
|
118
127
|
this.uwsApp = settings.uwsApp;
|
|
119
128
|
} else if (settings.http3) {
|
|
@@ -220,6 +229,9 @@ class Application extends Router {
|
|
|
220
229
|
// the uWS listen socket, and the responses being served right now: close() stops the
|
|
221
230
|
// first and waits for the second, the way node's server.close() does
|
|
222
231
|
this._listenSocket = undefined;
|
|
232
|
+
// the fork supervisor, in the primary of a clustered app and nowhere else
|
|
233
|
+
/** @type {{stop: () => void}|undefined} */
|
|
234
|
+
this._clusterHandle = undefined;
|
|
223
235
|
// readSmallFile's cache and its in-flight reads, see the method
|
|
224
236
|
this._fileCache = new Map();
|
|
225
237
|
this._fileCacheBytes = 0;
|
|
@@ -529,6 +541,21 @@ class Application extends Router {
|
|
|
529
541
|
* @returns {this} the app, which doubles as the server handle
|
|
530
542
|
*/
|
|
531
543
|
listen(port, host, backlog, callback) {
|
|
544
|
+
// With { cluster } the primary has nothing to bind. Each worker binds this same port with
|
|
545
|
+
// µWS's shared flag, which is SO_REUSEPORT, so the kernel hands each connection to one of
|
|
546
|
+
// them and the primary is not in the path at all: it forks, replaces a worker that dies,
|
|
547
|
+
// and nothing else. Everything below this runs in the workers, listen callback included,
|
|
548
|
+
// so it runs once per worker rather than once.
|
|
549
|
+
//
|
|
550
|
+
// The test is the process and not this app: a second app on a TLS port, one without a
|
|
551
|
+
// cluster setting of its own, would otherwise take that port here, exclusively, and every
|
|
552
|
+
// worker would fail on it.
|
|
553
|
+
if (cluster.isPrimary && isSupervising()) {
|
|
554
|
+
if (this._clusterWorkers > 0 && !this._clusterHandle) {
|
|
555
|
+
this._clusterHandle = forkWorkers(this._clusterWorkers);
|
|
556
|
+
}
|
|
557
|
+
return this;
|
|
558
|
+
}
|
|
532
559
|
this._compileOptimizedRoutes();
|
|
533
560
|
// before the catch-all: µWS sends an upgrade to the websocket route even when a
|
|
534
561
|
// catch-all covers the same path, so the two coexist and the order is only tidiness
|
|
@@ -799,6 +826,18 @@ class Application extends Router {
|
|
|
799
826
|
* @returns {this} the app, for chaining
|
|
800
827
|
*/
|
|
801
828
|
close(callback) {
|
|
829
|
+
// the primary of a clustered app never bound anything, so closing it means stopping the
|
|
830
|
+
// workers. They are killed rather than drained: each one holds its own listening socket
|
|
831
|
+
// and drains itself when the signal reaches it
|
|
832
|
+
if (this._clusterHandle) {
|
|
833
|
+
this._clusterHandle.stop();
|
|
834
|
+
this._clusterHandle = undefined;
|
|
835
|
+
if (callback) {
|
|
836
|
+
this.once("close", () => callback());
|
|
837
|
+
}
|
|
838
|
+
process.nextTick(() => this.emit("close"));
|
|
839
|
+
return this;
|
|
840
|
+
}
|
|
802
841
|
const wasListening = this.listening;
|
|
803
842
|
this.listening = false;
|
|
804
843
|
// in Express the close callback is nothing more than the first 'close' listener, and a
|
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/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) {
|
package/src/types.d.ts
CHANGED
package/src/verify.js
CHANGED
|
@@ -103,18 +103,27 @@ function checkNode(running = process.versions.node, required = require("../packa
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
/**
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
* Alpine looks like from in here.
|
|
106
|
+
* The glibc this process is running against, or undefined when there is none to report, which is
|
|
107
|
+
* what a musl build looks like from in here.
|
|
109
108
|
*
|
|
110
|
-
* @
|
|
111
|
-
|
|
109
|
+
* @returns {string|undefined}
|
|
110
|
+
*/
|
|
111
|
+
function currentGlibc() {
|
|
112
|
+
return /** @type {any} */ (process.report.getReport()).header.glibcVersionRuntime;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Whether the C library is the one the binaries are linked against. Only linux has two of them.
|
|
117
|
+
*
|
|
118
|
+
* Both arguments are required, and deliberately: undefined is the answer that means musl, and a
|
|
119
|
+
* default parameter fires on an explicit undefined, so a default here would quietly turn the musl
|
|
120
|
+
* case into whatever this machine happens to run. Reading the machine is the caller's job.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} platform
|
|
123
|
+
* @param {string|undefined} glibc the runtime glibc, absent on musl
|
|
112
124
|
* @returns {ReturnType<typeof result>|undefined} undefined where the question does not arise
|
|
113
125
|
*/
|
|
114
|
-
function checkLibc(
|
|
115
|
-
platform = process.platform,
|
|
116
|
-
glibc = /** @type {any} */ (process.report.getReport()).header.glibcVersionRuntime
|
|
117
|
-
) {
|
|
126
|
+
function checkLibc(platform, glibc) {
|
|
118
127
|
if (platform !== "linux") {
|
|
119
128
|
return undefined;
|
|
120
129
|
}
|
|
@@ -280,7 +289,7 @@ function verify(argv) {
|
|
|
280
289
|
const dir = path.resolve(argv.find((arg) => !arg.startsWith("--")) ?? ".");
|
|
281
290
|
/** @type {ReturnType<typeof result>[]} */
|
|
282
291
|
const results = [checkNode()];
|
|
283
|
-
const libc = checkLibc();
|
|
292
|
+
const libc = checkLibc(process.platform, currentGlibc());
|
|
284
293
|
if (libc) {
|
|
285
294
|
results.push(libc);
|
|
286
295
|
}
|
|
@@ -306,4 +315,4 @@ function verify(argv) {
|
|
|
306
315
|
return blocking === 0 ? 0 : 1;
|
|
307
316
|
}
|
|
308
317
|
|
|
309
|
-
module.exports = { verify, checkNode, checkLibc, checkBinary, checkDockerfiles, checkDependencies };
|
|
318
|
+
module.exports = { verify, checkNode, checkLibc, currentGlibc, checkBinary, checkDockerfiles, checkDependencies };
|