fulmine.js 5.8.0 → 5.10.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 CHANGED
@@ -18,12 +18,12 @@ import type { Request, Response } from "fulmine.js";
18
18
  There is a command that does that replacing for you, across a whole project, and then tells you the handful of things that behave differently:
19
19
 
20
20
  ```sh
21
- npx fulmine verify # can this machine and this image even run it
22
- npx fulmine migrate --dry-run # say what it would change, change nothing
23
- npx fulmine migrate # do it
24
- npx fulmine differences # just the list of what to check by hand
25
- npx fulmine profile # what listen() decided about each route
26
- npx fulmine explain /api/items # what happens when a request for that route arrives
21
+ npx fulmine.js verify # can this machine and this image even run it
22
+ npx fulmine.js migrate --dry-run # say what it would change, change nothing
23
+ npx fulmine.js migrate # do it
24
+ npx fulmine.js differences # just the list of what to check by hand
25
+ npx fulmine.js profile # what listen() decided about each route
26
+ npx fulmine.js explain /api/items # what happens when a request for that route arrives
27
27
  ```
28
28
 
29
29
  See [Migrating](#migrating) for what it handles and what it deliberately does not.
@@ -61,6 +61,7 @@ See [Migrating](#migrating) for what it handles and what it deliberately does no
61
61
  - [Router](#router)
62
62
  - [Tested middlewares](#tested-middlewares)
63
63
  - [Tested view engines](#tested-view-engines)
64
+ - [Examples](./examples/README.md)
64
65
  - [Working on Fulmine](./CONTRIBUTING.md)
65
66
 
66
67
  ## Why this exists
@@ -115,22 +116,19 @@ It is likewise not affiliated with the OpenJS Foundation or the Express.js proje
115
116
 
116
117
  ## Migrating
117
118
 
118
- In a lot of cases, replacing `require("express")` with `require("fulmine.js")` is the whole migration. `npx fulmine migrate` does that across a project:
119
+ In a lot of cases, replacing `require("express")` with `require("fulmine.js")` is the whole migration. `npx fulmine.js migrate` does that across a project:
119
120
 
120
121
  ```sh
121
- npx fulmine migrate [dir] # defaults to the current directory
122
- npx fulmine migrate --dry-run # say what it would rewrite and rewrite nothing
123
- npx fulmine differences # print the list below and change nothing
122
+ npx fulmine.js migrate [dir] # defaults to the current directory
123
+ npx fulmine.js migrate --dry-run # say what it would rewrite and rewrite nothing
124
+ npx fulmine.js differences # print the list below and change nothing
124
125
  ```
125
126
 
126
- The command is installed under both `fulmine` and `fulmine.js`. Use `fulmine`: `npx` cannot run a
127
- command whose name ends in `.js` on Windows, where it exits without a word.
128
-
129
127
  It also names the middlewares it found that have a faster one built in here, `compression`,
130
128
  `body-parser` and `serve-static`, and leaves them to you: the replacement is reached through the
131
129
  `express` import, and no rewrite can know that it is in scope where they are required.
132
130
 
133
- `npx fulmine verify` is the question that comes before all of that: whether this machine, and the
131
+ `npx fulmine.js verify` is the question that comes before all of that: whether this machine, and the
134
132
  image this will be deployed in, can run it at all. There is a µWebSockets.js binary underneath, and
135
133
  a binary is built per platform, per architecture and per node ABI, and linked against glibc. An
136
134
  Alpine base, a node version the pinned build has no binary for, a `FROM node:20-alpine` written
@@ -248,7 +246,7 @@ A single-stage `node:26-trixie-slim` image works too if you `apt-get install -y
248
246
 
249
247
  ## Differences from Express
250
248
 
251
- - `app.listen()` returns the app, not an `http.Server`, because there is no node server underneath: the socket belongs to µWS. The app answers as one anyway, which is what the graceful shutdown wrappers and the connection trackers look for. `app instanceof http.Server` is true, and `close()`, `address()`, `listening`, `getConnections()`, `ref()`, `unref()`, `setTimeout()` and the `keepAliveTimeout` family are all there. What is not there is the plumbing that carries node sockets: 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`.
249
+ - `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`. Runnable: [`examples/graceful-shutdown.js`](./examples/graceful-shutdown.js).
252
250
  - `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
251
  - 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
252
  - **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`.
@@ -291,6 +289,8 @@ app.listen(3000, () => {
291
289
  });
292
290
  ```
293
291
 
292
+ Runnable: [`examples/https.js`](./examples/https.js).
293
+
294
294
  - This also applies to non-SSL HTTP too. Use `app.listen()` rather than creating a server by hand. `http.createServer(app)` does work, because the app is a request listener like Express's and answers node's requests through a shim, which is what lets `supertest`, `vhost` and anything else that calls an app keep working. But it serves those requests through `node:http` rather than through µWS, so the speed is Express's. It is there for compatibility, not for production.
295
295
  - Node.JS max header size is 16384 bytes, while uWebSockets by default is 4096 bytes, so if you need longer headers set the env variable `UWS_HTTP_MAX_HEADERS_SIZE` to max byte count you need.
296
296
  - uWebSockets drops a request whose body arrives slower than 16KB/s, and the timeout is not reachable from JavaScript, while Node.JS waits as long as the client needs. Uploads over very slow connections can therefore fail here and succeed on Express. A body stalled for 5 seconds still completes; one stalled for 12 seconds gets its socket reset at around 11.8 seconds.
@@ -325,7 +325,7 @@ arriving at a handler costs no matching at all:
325
325
  That is the whole difference on a large route table: the scan grows with the table and the match
326
326
  does not, which is why a thousand routes measure 10x and a handful measure 3x.
327
327
 
328
- Two more things happen on the way in, and `npx fulmine profile` will tell you which of them your
328
+ Two more things happen on the way in, and `npx fulmine.js profile` will tell you which of them your
329
329
  routes get:
330
330
 
331
331
  ```text
@@ -362,11 +362,11 @@ On top of that, a handler simple enough to be read at registration time is compi
362
362
 
363
363
  `app.set("declarative responses", false)` turns the whole thing off if you would rather have Express's exact framing than the speed.
364
364
 
365
- None of that is guesswork you have to do from the outside. `listen()` decides it all, and `npx fulmine profile` prints what it decided:
365
+ None of that is guesswork you have to do from the outside. `listen()` decides it all, and `npx fulmine.js profile` prints what it decided:
366
366
 
367
367
  ```sh
368
- npx fulmine profile # the file package.json's "main" points at
369
- npx fulmine profile server.js # or name it
368
+ npx fulmine.js profile # the file "main" or the start script points at
369
+ npx fulmine.js profile server.js # or name it
370
370
  ```
371
371
 
372
372
  ```text
@@ -403,9 +403,9 @@ expectDeclarative(app, "/health"); // the step past native: no javascript at all
403
403
  routeReport(app); // the whole list, to assert on however you like
404
404
  ```
405
405
 
406
- A path is written as it was registered, `"/users/:id"` and not `"/users/7"`, and a trailing `*` names everything under a prefix. A pattern that matches no route throws too, so a misspelled path fails instead of passing quietly. The application does not need to be listening.
406
+ A path is written as it was registered, `"/users/:id"` and not `"/users/7"`, and a trailing `*` names everything under a prefix. A pattern that matches no route throws too, so a misspelled path fails instead of passing quietly. The application does not need to be listening. Runnable: [`examples/fast-routes.js`](./examples/fast-routes.js).
407
407
 
408
- `npx fulmine explain /api/items/:id` answers the other question, the one about a single endpoint rather than about the table: how it is matched, what is copied out of the request, what runs and what each layer costs the route.
408
+ `npx fulmine.js explain /api/items/:id` answers the other question, the one about a single endpoint rather than about the table: how it is matched, what is copied out of the request, what runs and what each layer costs the route.
409
409
 
410
410
  ```text
411
411
  GET /api/items/:id
@@ -425,9 +425,9 @@ The same verdict reaches the browser, per request, with `express.serverTiming()`
425
425
  Server-Timing: route;desc="native", hdr;desc="not copied", db;dur=3.62, total;dur=4.66
426
426
  ```
427
427
 
428
- `route;desc="native"` means µWS matched the path in C++ and the chain was worked out at startup; `route;desc="router"` means this one was matched here, layer by layer. `res.timing(name, ms, desc)` and `res.time(name, fn)` add marks of your own, and `fn` may return a promise. The duration ends where the header does, since Server-Timing goes out with the head. A route compiled into a response never enters JavaScript, so nothing times it: `npx fulmine profile` is where those are counted.
428
+ `route;desc="native"` means µWS matched the path in C++ and the chain was worked out at startup; `route;desc="router"` means this one was matched here, layer by layer. `res.timing(name, ms, desc)` and `res.time(name, fn)` add marks of your own, and `fn` may return a promise. The duration ends where the header does, since Server-Timing goes out with the head. A route compiled into a response never enters JavaScript, so nothing times it: `npx fulmine.js profile` is where those are counted. Runnable: [`examples/server-timing.js`](./examples/server-timing.js).
429
429
 
430
- 2. Do not use external `serve-static` module. Instead use built-in `express.static()` middleware, which is optimized for Fulmine. If your build already writes `.br` and `.gz` files next to the originals, `express.static(dir, { preCompressed: true })` serves those to the clients that accept them, so nothing is compressed at request time and a fraction of the bytes goes out: on a 4KB script with a brotli twin, 12 times fewer. It costs no more than serving the file itself, one `stat` per request, because the twin is looked for before the file and its own `stat` is the only one the request needs. A type that is already compressed, a woff2 or a webp, is not looked up at all, and which twins a path has is remembered for a second: `{ cache: false }` asks the disk every time, `{ cache: "5s" }` sets the window. Only their presence is remembered, never their size or mtime, so nothing is ever described by a stale number. `Vary: Accept-Encoding` is sent whether or not a twin is found, the content type stays the one the requested name implies, and each variant carries its own ETag.
430
+ 2. Do not use external `serve-static` module. Instead use built-in `express.static()` middleware, which is optimized for Fulmine. If your build already writes `.br` and `.gz` files next to the originals, `express.static(dir, { preCompressed: true })` serves those to the clients that accept them, so nothing is compressed at request time and a fraction of the bytes goes out: on a 4KB script with a brotli twin, 12 times fewer. It costs no more than serving the file itself, one `stat` per request, because the twin is looked for before the file and its own `stat` is the only one the request needs. A type that is already compressed, a woff2 or a webp, is not looked up at all, and which twins a path has is remembered for a second: `{ cache: false }` asks the disk every time, `{ cache: "5s" }` sets the window. Only their presence is remembered, never their size or mtime, so nothing is ever described by a stale number. `Vary: Accept-Encoding` is sent whether or not a twin is found, the content type stays the one the requested name implies, and each variant carries its own ETag. Runnable: [`examples/static-precompressed.js`](./examples/static-precompressed.js).
431
431
 
432
432
  3. Do not use `body-parser` module. Instead use built-in `express.text()`, `express.json()` etc.
433
433
 
@@ -438,6 +438,8 @@ Server-Timing: route;desc="native", hdr;desc="not copied", db;dur=3.62, total;du
438
438
  app.use(express.compression({ threshold: 1024 }));
439
439
  ```
440
440
 
441
+ Runnable: [`examples/compression.js`](./examples/compression.js).
442
+
441
443
  5. If a route answers with a JSON shape you know in advance, [express-fast-json-stringify](https://www.npmjs.com/package/express-fast-json-stringify) compiles that shape into a serializer and `res.fastJson()` replaces `res.json()`. `JSON.stringify()` has to walk an object it knows nothing about; a compiled serializer does not.
442
444
 
443
445
  6. Do not set `body methods` to read body of requests with GET method or other methods that don't need a body. Reading body makes endpoint about 15% slower.
@@ -446,9 +448,26 @@ app.use(express.compression({ threshold: 1024 }));
446
448
 
447
449
  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
450
 
451
+ 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.
452
+
453
+ ```js
454
+ // "auto" is one worker per usable core: the cgroup quota is read first, so a 2-core container
455
+ // on a 64-core host forks 2 and not 64. A number instead of "auto" says how many.
456
+ const app = express({ cluster: "auto" });
457
+
458
+ app.get("/", (req, res) => res.send("hello"));
459
+
460
+ // The whole file runs again in every worker, which is how cluster works: the code above this
461
+ // line runs once per process. The primary only forks, so the callback runs once per worker too,
462
+ // and a worker that dies is replaced.
463
+ app.listen(3000, () => console.log(`worker ${process.pid} listening`));
464
+ ```
465
+
466
+ 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. Runnable: [`examples/cluster.js`](./examples/cluster.js).
467
+
449
468
  ## WebSockets
450
469
 
451
- `app.ws()` registers a WebSocket route, served by µWS itself. There is no `http.Server` underneath, so `http.on("upgrade")` and the libraries built on it do not apply; this is the replacement.
470
+ `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
471
 
453
472
  ```js
454
473
  app.ws("/room/:id", {
@@ -476,14 +495,14 @@ app.ws("/room/:id", {
476
495
  - **Paths are the ones µWS matches**: literal, or with parameters that are a whole segment such as `/room/:id`. Anything else throws where it is written rather than failing to match later.
477
496
  - **Broadcasting from outside a socket**: `app.publish(topic, message)` and `app.numSubscribers(topic)`.
478
497
 
479
- A WebSocket route and an ordinary route can share a path: the upgrade goes to the WebSocket route, a plain GET goes through normal routing.
498
+ A WebSocket route and an ordinary route can share a path: the upgrade goes to the WebSocket route, a plain GET goes through normal routing. Runnable, with a page that opens the socket: [`examples/websocket.js`](./examples/websocket.js).
480
499
 
481
500
  If you would rather use the `ws` module's API, [Ultimate WS](https://github.com/dimdenGD/ultimate-ws) is a drop-in replacement for it written against Ultimate Express, and Fulmine still exposes the mechanism it hooks into, but that combination is not covered by this project's tests. `app.uwsApp` also remains available for anything µWS offers that this does not.
482
501
 
483
502
  ### socket.io
484
503
 
485
- socket.io normally takes over the upgrade on a node `http.Server`. There isn't one here, so hand it
486
- the µWS app instead, which socket.io supports natively through `attachApp()`:
504
+ socket.io normally takes over the upgrade on a node `http.Server`. The upgrade here never reaches
505
+ node, so hand it the µWS app instead, which socket.io supports natively through `attachApp()`:
487
506
 
488
507
  ```js
489
508
  const express = require("fulmine.js");
@@ -500,10 +519,14 @@ io.on("connection", (socket) => {
500
519
  });
501
520
  ```
502
521
 
503
- `attachApp()` works before or after `app.listen()`. What does not work is `new Server(server)` on the
504
- value `app.listen()` returns: plain HTTP keeps serving, but the WebSocket upgrade fails, because
505
- that object is not a real `http.Server`. This is covered by `tests/tests/middlewares/socket-io.js`,
506
- which runs the same file against Express and against Fulmine and compares the output.
522
+ `attachApp()` works before or after `app.listen()`. What does not work is `new Server(app)` on the
523
+ app itself, or on what `app.listen()` returns, which is the same object: socket.io refuses it with
524
+ "You are trying to attach socket.io to an express request handler function", because it checks for a
525
+ function before it checks for a server, and an app here is callable. That refusal is the useful
526
+ answer. Even if it accepted the object, there is no node socket behind it to take an upgrade over,
527
+ so it would have failed later and more quietly. Plain HTTP keeps serving either way. This is covered
528
+ by `tests/tests/middlewares/socket-io.js`, which runs the same file against Express and against
529
+ Fulmine and compares the output. Runnable: [`examples/socket-io.js`](./examples/socket-io.js).
507
530
 
508
531
  ## HTTP/3
509
532
 
@@ -544,7 +567,9 @@ app.set("trust proxy protocol", true);
544
567
 
545
568
  `trust proxy` and this can both be on. The preamble decides what the connection's address is, and
546
569
  `trust proxy` then peels `X-Forwarded-For` off that, so a proxy that sends both is read the way it
547
- meant.
570
+ meant. It is the binary v2 preamble that µWS reads, not the v1 text line, so a connection starting
571
+ with `PROXY TCP4 ...` is answered as a malformed request. Runnable, with a client that writes one:
572
+ [`examples/proxy-protocol.js`](./examples/proxy-protocol.js).
548
573
 
549
574
  ## Versioning
550
575
 
@@ -792,7 +817,25 @@ Any Express view engine should work. Here's list of engines we include in our te
792
817
  - ✅ [express-handlebars](https://npmjs.com/package/express-handlebars)
793
818
  - ✅ [swig](https://npmjs.com/package/swig)
794
819
 
820
+ ## Examples
821
+
822
+ [`examples/`](./examples/README.md) has one runnable file per thing this does that Express does not:
823
+ the cluster option, `app.ws()`, socket.io through `attachApp`, the pre-compressed twins,
824
+ `express.compression()`, `express.serverTiming()`, TLS through `uwsOptions`, the PROXY protocol,
825
+ what `listen()` decided about each route, and the app answering as an `http.Server`. What an
826
+ Express application already does is documented by Express and is not repeated there.
827
+
828
+ ```sh
829
+ cd examples
830
+ npm install
831
+ node websocket.js
832
+ ```
833
+
795
834
  ## Working on Fulmine
796
835
 
797
836
  How to run the suites, what each of them is for, and how to write a comparison test:
798
- [`CONTRIBUTING.md`](./CONTRIBUTING.md).
837
+ [`CONTRIBUTING.md`](./CONTRIBUTING.md). What is expected of everyone taking part:
838
+ [`CODE_OF_CONDUCT.md`](./CODE_OF_CONDUCT.md).
839
+
840
+ Found something exploitable? Report it privately rather than in an issue, and see
841
+ [`SECURITY.md`](./SECURITY.md) for what is in scope and what to expect.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.8.0",
3
+ "version": "5.10.0",
4
4
  "description": "Drop-in Express 5 replacement on uWebSockets.js. Your existing middleware keeps working.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -29,9 +29,7 @@
29
29
  "release:local": "node tools/release-local.js",
30
30
  "cover:full": "nyc --silent npm run test && nyc --silent --no-clean npm run test:unit && nyc --silent --no-clean npm run test:express && nyc report",
31
31
  "cover:check": "nyc check-coverage --statements 94.5 --branches 90 --functions 94 --lines 94.5",
32
- "demo:start": "npm --prefix demo install && npm --prefix demo start",
33
- "demo:deploy": "cd demo && fly deploy",
34
- "demo:logs": "fly logs --app fulmine-demo"
32
+ "examples:install": "npm --prefix examples install"
35
33
  },
36
34
  "engines": {
37
35
  "node": ">=22"
@@ -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; uwsApp adopts an existing uWS app instead of making one. Everything else is
107
- * an application setting and lands next to the defaults.
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/cli.js CHANGED
@@ -304,7 +304,40 @@ function walk(node, visit) {
304
304
  const DEFAULT_ENTRIES = ["server.js", "app.js", "index.js", "src/server.js", "src/app.js", "src/index.js"];
305
305
 
306
306
  /**
307
- * The file to load, from the argument, or from package.json's main, or from the usual names.
307
+ * The file a start script runs, when it runs node on one.
308
+ *
309
+ * "main" is about what a package exports, and a service usually exports nothing: the entry of a
310
+ * deployed application is far more often the one written here, which is also the only place that
311
+ * knows about a src/ or a bin/ the usual names do not cover.
312
+ *
313
+ * @param {unknown} script the "start" script, as package.json wrote it
314
+ * @returns {string|null}
315
+ */
316
+ function entryFromScript(script) {
317
+ if (typeof script !== "string") {
318
+ return null;
319
+ }
320
+ const words = script.split(/\s+/).filter(Boolean);
321
+ if (!/^(node|nodejs)$/.test(path.basename(words[0] ?? "", ".exe"))) {
322
+ // ts-node, nodemon, a shell pipeline: what that runs is not a file this can load
323
+ return null;
324
+ }
325
+ for (const word of words.slice(1)) {
326
+ if (word.startsWith("-")) {
327
+ continue; // --env-file=.env, --watch, and the rest of node's own flags
328
+ }
329
+ const candidate = path.resolve(word);
330
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
331
+ return candidate;
332
+ }
333
+ break; // the first thing that is not a flag is the file, and it is not there
334
+ }
335
+ return null;
336
+ }
337
+
338
+ /**
339
+ * The file to load, from the argument, from package.json's main or start script, or from the
340
+ * usual names.
308
341
  *
309
342
  * @param {string|undefined} given
310
343
  * @returns {string|null}
@@ -319,6 +352,12 @@ function findEntry(given) {
319
352
  if (pkg.main && fs.existsSync(path.resolve(pkg.main))) {
320
353
  return path.resolve(pkg.main);
321
354
  }
355
+ // a main that names a file nobody built, dist/server.js in a TypeScript project, is worth
356
+ // no more than no main at all
357
+ const started = entryFromScript(pkg.scripts?.start);
358
+ if (started) {
359
+ return started;
360
+ }
322
361
  } catch {
323
362
  // no package.json, or one that will not parse: the usual names are still worth trying
324
363
  }
@@ -331,6 +370,57 @@ function findEntry(given) {
331
370
  return null;
332
371
  }
333
372
 
373
+ /**
374
+ * Every build of this library the application could load, as the prototype that owns listen().
375
+ *
376
+ * The command runs from its own copy, and the application loads whichever one resolves from its
377
+ * own directory. That is usually the same file and sometimes is not: a global install, an
378
+ * `npx fulmine.js@version`, a workspace that hoisted a second copy, or the `express` name pointing
379
+ * here through an override. Patching only this command's copy leaves the application's own listen()
380
+ * to bind the port, and the command then reports that the file built nothing.
381
+ *
382
+ * An app is a callable, so its own prototype is not the one that carries the methods: walk up to
383
+ * whichever link owns listen.
384
+ *
385
+ * @param {string} entry
386
+ * @returns {any[]} the prototypes to stub, this command's copy first
387
+ */
388
+ function listenOwners(entry) {
389
+ const builds = new Set([require("./index.js")]);
390
+ for (const specifier of [TO, FROM]) {
391
+ try {
392
+ builds.add(require(require.resolve(specifier, { paths: [path.dirname(entry), process.cwd()] })));
393
+ } catch {
394
+ // not installed next to the application, or not resolvable from there
395
+ }
396
+ }
397
+
398
+ const owners = [];
399
+ for (const build of builds) {
400
+ if (typeof build !== "function") {
401
+ continue;
402
+ }
403
+ let app;
404
+ try {
405
+ app = build();
406
+ } catch {
407
+ continue; // not an application factory, or one that will not build without arguments
408
+ }
409
+ // real express resolves under the same two names, and has none of this to stub
410
+ if (typeof app._compileOptimizedRoutes !== "function") {
411
+ continue;
412
+ }
413
+ let proto = Object.getPrototypeOf(app);
414
+ while (proto && !Object.prototype.hasOwnProperty.call(proto, "listen")) {
415
+ proto = Object.getPrototypeOf(proto);
416
+ }
417
+ if (proto && !owners.includes(proto)) {
418
+ owners.push(proto);
419
+ }
420
+ }
421
+ return owners;
422
+ }
423
+
334
424
  /**
335
425
  * The applications a file builds, compiled but not listening.
336
426
  *
@@ -348,42 +438,38 @@ function loadApps(argv, command) {
348
438
  if (!entry) {
349
439
  console.error(
350
440
  `Nothing to ${command}: name the file that builds the application, or run this from a
351
- ` + "directory whose package.json main points at it."
441
+ ` + "directory whose package.json main or start script points at it."
352
442
  );
353
443
  return null;
354
444
  }
355
445
 
356
- // the same module instance the application will load, so patching this prototype patches the
357
- // application it builds. An app is a callable, so its own prototype is not the one that carries
358
- // the methods: walk up to whichever link owns listen
359
- const express = require("./index.js");
360
- let proto = Object.getPrototypeOf(express());
361
- while (proto && !Object.prototype.hasOwnProperty.call(proto, "listen")) {
362
- proto = Object.getPrototypeOf(proto);
363
- }
364
- if (!proto) {
446
+ const owners = listenOwners(entry);
447
+ if (owners.length === 0) {
365
448
  console.error("This build of fulmine has no listen() to stand in for, which should not happen.");
366
449
  return null;
367
450
  }
368
451
 
369
452
  const listened = [];
370
- const realListen = proto.listen;
371
- proto.listen = function stubbedListen() {
372
- this._compileOptimizedRoutes();
373
- listened.push(this);
374
- return this;
375
- };
453
+ const real = owners.map((proto) => proto.listen);
454
+ for (const proto of owners) {
455
+ proto.listen = function stubbedListen() {
456
+ this._compileOptimizedRoutes();
457
+ listened.push(this);
458
+ return this;
459
+ };
460
+ }
461
+ const restore = () => owners.forEach((proto, i) => (proto.listen = real[i]));
376
462
 
377
463
  try {
378
464
  require(entry);
379
465
  } catch (e) {
380
466
  const error = /** @type {any} */ (e);
381
- proto.listen = realListen;
467
+ restore();
382
468
  console.error(`${path.relative(process.cwd(), entry)} could not be loaded:
383
469
  ${error.stack ?? error}`);
384
470
  return null;
385
471
  }
386
- proto.listen = realListen;
472
+ restore();
387
473
 
388
474
  let apps = listened;
389
475
  if (apps.length === 0) {
@@ -400,7 +486,10 @@ ${error.stack ?? error}`);
400
486
  if (apps.length === 0) {
401
487
  console.error(
402
488
  `${path.relative(process.cwd(), entry)} built no application: it neither called listen() nor
403
- ` + "exported one. Point this at the file that does."
489
+ ` +
490
+ `exported one. Point this at the file that does. A listen() that runs after an await is
491
+ ` +
492
+ "not seen either, since this loads the file rather than waiting on what it started."
404
493
  );
405
494
  return null;
406
495
  }
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 };
@@ -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) {
package/src/types.d.ts CHANGED
@@ -22,6 +22,7 @@ declare module "fulmine.js" {
22
22
  type Settings = {
23
23
  uwsOptions?: uWS.AppOptions;
24
24
  threads?: number;
25
+ cluster?: boolean | number | "auto";
25
26
  http3?: boolean;
26
27
  uwsApp?: uWS.TemplatedApp;
27
28
  };
@@ -76,6 +77,18 @@ declare module "fulmine.js" {
76
77
  function expectDeclarative(app: Fulmine, patterns: string | string[]): void;
77
78
  }
78
79
 
80
+ // Server-Timing, carrying how the request was routed. Express has no such middleware, so
81
+ // like compression() there is nothing to re-export
82
+ interface ServerTimingOptions {
83
+ /** Whether to report how the request was routed. Default true. */
84
+ routing?: boolean;
85
+ /** Whether to report the time up to the head. Default true. */
86
+ total?: boolean;
87
+ /** What the total is called. Default "total". */
88
+ name?: string;
89
+ }
90
+ export function serverTiming(options?: ServerTimingOptions): e.RequestHandler;
91
+
79
92
  export function compression(options?: CompressionOptions): e.RequestHandler;
80
93
  export namespace compression {
81
94
  /** The default filter: any compressible content type. */
@@ -183,3 +196,15 @@ declare module "fulmine.js" {
183
196
 
184
197
  export = express;
185
198
  }
199
+
200
+ // What express.serverTiming() hangs on the response. Express's own Response extends this global
201
+ // interface, which is how a middleware adds to it. They are written per request rather than on the
202
+ // prototype, so a route only has them where that middleware ran, which the optional marks say.
203
+ declare namespace Express {
204
+ interface Response {
205
+ /** Adds a mark of your own. A mark with only a description is a legal entry. */
206
+ timing?(name: string, duration?: number, description?: string): this;
207
+ /** Times a piece of work under a name. A promise is timed to where it settles. */
208
+ time?<T>(name: string, work: () => T): T;
209
+ }
210
+ }
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
- * Whether the C library is the one the binaries are linked against. Only linux has two of them,
107
- * and node reports the glibc it is running against; a musl build reports none, which is what
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
- * @param {string} [platform]
111
- * @param {string|undefined} [glibc] the runtime glibc, absent on musl
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 };