fulmine.js 5.7.0 → 5.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -9
- package/package.json +2 -2
- package/src/application.js +46 -2
- package/src/cli.js +159 -41
- package/src/cluster.js +204 -0
- package/src/index.js +8 -0
- package/src/middlewares.js +20 -3
- package/src/server-shape.js +157 -0
- package/src/server-timing.js +180 -0
- package/src/testing.js +201 -0
- package/src/types.d.ts +27 -0
- package/src/verify.js +318 -0
package/README.md
CHANGED
|
@@ -18,10 +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
|
|
21
22
|
npx fulmine migrate --dry-run # say what it would change, change nothing
|
|
22
23
|
npx fulmine migrate # do it
|
|
23
24
|
npx fulmine differences # just the list of what to check by hand
|
|
24
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
|
|
25
27
|
```
|
|
26
28
|
|
|
27
29
|
See [Migrating](#migrating) for what it handles and what it deliberately does not.
|
|
@@ -128,6 +130,23 @@ It also names the middlewares it found that have a faster one built in here, `co
|
|
|
128
130
|
`body-parser` and `serve-static`, and leaves them to you: the replacement is reached through the
|
|
129
131
|
`express` import, and no rewrite can know that it is in scope where they are required.
|
|
130
132
|
|
|
133
|
+
`npx fulmine verify` is the question that comes before all of that: whether this machine, and the
|
|
134
|
+
image this will be deployed in, can run it at all. There is a µWebSockets.js binary underneath, and
|
|
135
|
+
a binary is built per platform, per architecture and per node ABI, and linked against glibc. An
|
|
136
|
+
Alpine base, a node version the pinned build has no binary for, a `FROM node:20-alpine` written
|
|
137
|
+
years ago: each one fails at require time, in a container, with a message about a missing module.
|
|
138
|
+
This says so in thirty seconds, and exits non-zero when something would stop the start.
|
|
139
|
+
|
|
140
|
+
```text
|
|
141
|
+
ok Node 22.15.0
|
|
142
|
+
ok glibc 2.39
|
|
143
|
+
ok µWebSockets.js binary for linux x64, node ABI 127
|
|
144
|
+
NO Dockerfile: node:20-alpine
|
|
145
|
+
musl, and there is no musl build: node:22-trixie-slim is the closest swap.
|
|
146
|
+
note socket.io needs a different API here
|
|
147
|
+
attach it with io.attachApp(app.uwsApp), not io.attach(server)
|
|
148
|
+
```
|
|
149
|
+
|
|
131
150
|
### Angular SSR
|
|
132
151
|
|
|
133
152
|
The `server.ts` that `ng add @angular/ssr` generates is an ordinary Express application, so the same
|
|
@@ -229,7 +248,7 @@ A single-stage `node:26-trixie-slim` image works too if you `apt-get install -y
|
|
|
229
248
|
|
|
230
249
|
## Differences from Express
|
|
231
250
|
|
|
232
|
-
- `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`.
|
|
233
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.
|
|
234
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.
|
|
235
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`.
|
|
@@ -374,6 +393,40 @@ Worth changing, if these are routes that carry traffic
|
|
|
374
393
|
|
|
375
394
|
It loads the application with `listen()` replaced by the half that compiles the routes, so nothing binds a port and the listen callback does not run: profiling a running service does not start a second copy of it. There is no score, on purpose. A percentage of routes is not a percentage of traffic, and an application with a thousand cold routes and one hot one that fell back would score well and serve badly.
|
|
376
395
|
|
|
396
|
+
The same verdicts are readable from a test, which is where they belong for the routes that carry the traffic. A route stays on the fast path only while it stays eligible, and nothing complains when it stops: the answer is still correct, only slower, and the commit that did it is found weeks later.
|
|
397
|
+
|
|
398
|
+
```js
|
|
399
|
+
const { expectNative, expectDeclarative, routeReport } = require("fulmine.js").testing;
|
|
400
|
+
|
|
401
|
+
expectNative(app, ["/api/*", "GET /health"]); // throws, naming the route and the reason
|
|
402
|
+
expectDeclarative(app, "/health"); // the step past native: no javascript at all
|
|
403
|
+
routeReport(app); // the whole list, to assert on however you like
|
|
404
|
+
```
|
|
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.
|
|
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.
|
|
409
|
+
|
|
410
|
+
```text
|
|
411
|
+
GET /api/items/:id
|
|
412
|
+
|
|
413
|
+
route native (µWS matched /api/items/:x and dispatched by method)
|
|
414
|
+
headers copied out of µWS (something in the chain reads them)
|
|
415
|
+
query parsed when something asks for it
|
|
416
|
+
chain 2 layer(s), 1 mounted layer(s) in front of it
|
|
417
|
+
logger readable at registration, reads the query
|
|
418
|
+
(anonymous) readable at registration
|
|
419
|
+
body read for POST, PUT, PATCH and QUERY, when one is declared
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
The same verdict reaches the browser, per request, with `express.serverTiming()`:
|
|
423
|
+
|
|
424
|
+
```text
|
|
425
|
+
Server-Timing: route;desc="native", hdr;desc="not copied", db;dur=3.62, total;dur=4.66
|
|
426
|
+
```
|
|
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.
|
|
429
|
+
|
|
377
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.
|
|
378
431
|
|
|
379
432
|
3. Do not use `body-parser` module. Instead use built-in `express.text()`, `express.json()` etc.
|
|
@@ -393,9 +446,26 @@ app.use(express.compression({ threshold: 1024 }));
|
|
|
393
446
|
|
|
394
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.
|
|
395
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
|
+
|
|
396
466
|
## WebSockets
|
|
397
467
|
|
|
398
|
-
`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.
|
|
399
469
|
|
|
400
470
|
```js
|
|
401
471
|
app.ws("/room/:id", {
|
|
@@ -429,8 +499,8 @@ If you would rather use the `ws` module's API, [Ultimate WS](https://github.com/
|
|
|
429
499
|
|
|
430
500
|
### socket.io
|
|
431
501
|
|
|
432
|
-
socket.io normally takes over the upgrade on a node `http.Server`.
|
|
433
|
-
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()`:
|
|
434
504
|
|
|
435
505
|
```js
|
|
436
506
|
const express = require("fulmine.js");
|
|
@@ -447,10 +517,14 @@ io.on("connection", (socket) => {
|
|
|
447
517
|
});
|
|
448
518
|
```
|
|
449
519
|
|
|
450
|
-
`attachApp()` works before or after `app.listen()`. What does not work is `new Server(
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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.
|
|
454
528
|
|
|
455
529
|
## HTTP/3
|
|
456
530
|
|
|
@@ -519,8 +593,15 @@ In general, basically all features and options are supported. Use the [Express 5
|
|
|
519
593
|
- ✅ express.json()
|
|
520
594
|
- ✅ express.urlencoded()
|
|
521
595
|
- ✅ express.static()
|
|
596
|
+
- - ✅ options.index, options.redirect, options.fallthrough, options.extensions
|
|
597
|
+
- - ✅ options.dotfiles, plus `"ignore_files"`, which is Fulmine's own: it hides a dotfile that is the last segment while letting a dotted directory through
|
|
598
|
+
- - ✅ options.setHeaders, options.headers
|
|
599
|
+
- - ✅ options.etag, options.lastModified, options.maxAge, options.immutable, options.cacheControl, options.acceptRanges
|
|
600
|
+
- - ✅ options.preCompressed, Fulmine's own: serve the `.br` or `.gz` twin on disk, described under [Performance tips](#performance-tips)
|
|
522
601
|
- ✅ express.text()
|
|
523
602
|
- ✅ express.raw()
|
|
603
|
+
- ✅ express.serverTiming(). Fulmine's own: Server-Timing carrying how the request was routed, described under [Performance tips](#performance-tips).
|
|
604
|
+
- ✅ express.testing. Fulmine's own: `expectNative`, `expectDeclarative` and `routeReport`, described under [Performance tips](#performance-tips).
|
|
524
605
|
- ✅ express.compression(). Fulmine's own, since Express has none: it is the [compression](https://npmjs.com/package/compression) module's options and behaviour built in, described under [Performance tips](#performance-tips).
|
|
525
606
|
- 🚧 express.request (this is not a constructor but a prototype for replacing methods)
|
|
526
607
|
- 🚧 express.response (this is not a constructor but a prototype for replacing methods)
|
|
@@ -554,6 +635,11 @@ In general, basically all features and options are supported. Use the [Express 5
|
|
|
554
635
|
- ✅ OPTIONS method
|
|
555
636
|
- ✅ QUERY method
|
|
556
637
|
|
|
638
|
+
What `listen()` hands back is the app, and it answers as an `http.Server` so the shutdown wrappers
|
|
639
|
+
recognise it: `app.close()`, `app.address()`, `app.listening`, `app.getConnections()`, `app.ref()`,
|
|
640
|
+
`app.unref()`, `app.setTimeout()` and the `keepAliveTimeout` family. See
|
|
641
|
+
[Differences from Express](#differences-from-express) for what is behind them and what is not.
|
|
642
|
+
|
|
557
643
|
### Application settings
|
|
558
644
|
|
|
559
645
|
- ✅ case sensitive routing
|
|
@@ -730,4 +816,8 @@ Any Express view engine should work. Here's list of engines we include in our te
|
|
|
730
816
|
## Working on Fulmine
|
|
731
817
|
|
|
732
818
|
How to run the suites, what each of them is for, and how to write a comparison test:
|
|
733
|
-
[`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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fulmine.js",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.9.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": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"benchmark:profile": "node benchmark/profile.js",
|
|
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
|
-
"cover:check": "nyc check-coverage --statements
|
|
31
|
+
"cover:check": "nyc check-coverage --statements 94.5 --branches 90 --functions 94 --lines 94.5",
|
|
32
32
|
"demo:start": "npm --prefix demo install && npm --prefix demo start",
|
|
33
33
|
"demo:deploy": "cd demo && fly deploy",
|
|
34
34
|
"demo:logs": "fly logs --app fulmine-demo"
|
package/src/application.js
CHANGED
|
@@ -36,6 +36,8 @@ const os = require("os");
|
|
|
36
36
|
const { Worker } = require("worker_threads");
|
|
37
37
|
const cluster = require("cluster");
|
|
38
38
|
const { registerWebSocketRoutes } = require("./websocket.js");
|
|
39
|
+
const { addServerMembers } = require("./server-shape.js");
|
|
40
|
+
const { workerCount, forkWorkers, isSupervising, becomeSupervisor } = require("./cluster.js");
|
|
39
41
|
|
|
40
42
|
const cpuCount = os.cpus().length;
|
|
41
43
|
|
|
@@ -102,8 +104,9 @@ class Application extends Router {
|
|
|
102
104
|
/**
|
|
103
105
|
* @param {object} [settings] the options express() takes. uwsOptions goes to uWS and decides
|
|
104
106
|
* between an HTTP, an HTTPS and an HTTP/3 server; threads sizes the file-reading pool, and 0
|
|
105
|
-
* turns it off;
|
|
106
|
-
*
|
|
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.
|
|
107
110
|
*/
|
|
108
111
|
constructor(settings = new NullObject()) {
|
|
109
112
|
super(settings);
|
|
@@ -113,6 +116,13 @@ class Application extends Router {
|
|
|
113
116
|
if (typeof settings.threads !== "number") {
|
|
114
117
|
settings.threads = cpuCount > 1 ? 1 : 0;
|
|
115
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
|
+
}
|
|
116
126
|
if (settings.uwsApp) {
|
|
117
127
|
this.uwsApp = settings.uwsApp;
|
|
118
128
|
} else if (settings.http3) {
|
|
@@ -219,6 +229,9 @@ class Application extends Router {
|
|
|
219
229
|
// the uWS listen socket, and the responses being served right now: close() stops the
|
|
220
230
|
// first and waits for the second, the way node's server.close() does
|
|
221
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;
|
|
222
235
|
// readSmallFile's cache and its in-flight reads, see the method
|
|
223
236
|
this._fileCache = new Map();
|
|
224
237
|
this._fileCacheBytes = 0;
|
|
@@ -528,6 +541,21 @@ class Application extends Router {
|
|
|
528
541
|
* @returns {this} the app, which doubles as the server handle
|
|
529
542
|
*/
|
|
530
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
|
+
}
|
|
531
559
|
this._compileOptimizedRoutes();
|
|
532
560
|
// before the catch-all: µWS sends an upgrade to the websocket route even when a
|
|
533
561
|
// catch-all covers the same path, so the two coexist and the order is only tidiness
|
|
@@ -798,6 +826,18 @@ class Application extends Router {
|
|
|
798
826
|
* @returns {this} the app, for chaining
|
|
799
827
|
*/
|
|
800
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
|
+
}
|
|
801
841
|
const wasListening = this.listening;
|
|
802
842
|
this.listening = false;
|
|
803
843
|
// in Express the close callback is nothing more than the first 'close' listener, and a
|
|
@@ -871,3 +911,7 @@ module.exports = function (options) {
|
|
|
871
911
|
// the class itself, so index.js can expose its prototype as express.application does. Adding a
|
|
872
912
|
// method to that prototype adds it to every app, which is what the property is for.
|
|
873
913
|
module.exports.Application = Application;
|
|
914
|
+
|
|
915
|
+
// and what makes an application answer as an http.Server, since that is what a library handed the
|
|
916
|
+
// result of listen() looks for. See server-shape.js for what is answered and what is not.
|
|
917
|
+
addServerMembers(Application.prototype);
|
package/src/cli.js
CHANGED
|
@@ -26,10 +26,18 @@ limitations under the License.
|
|
|
26
26
|
// Prints what listen() worked out about each route and normally keeps to itself: which ones µWS
|
|
27
27
|
// answers on its own, which ones fell back to the ordinary router and why, and which ones were
|
|
28
28
|
// compiled all the way down to a response written at startup.
|
|
29
|
+
//
|
|
30
|
+
// npx fulmine verify [dir]
|
|
31
|
+
//
|
|
32
|
+
// Whether this machine and this project can run it at all: the node version, the C library, the
|
|
33
|
+
// µWebSockets.js binary, the base image a Dockerfile names. See src/verify.js.
|
|
29
34
|
|
|
30
35
|
const fs = require("fs");
|
|
31
36
|
const path = require("path");
|
|
32
37
|
const acorn = require("acorn");
|
|
38
|
+
// the same walk express.testing asserts on, so the command and the assertions cannot drift
|
|
39
|
+
const { collectRoutes } = require("./testing.js");
|
|
40
|
+
const { verify } = require("./verify.js");
|
|
33
41
|
|
|
34
42
|
const FROM = "express";
|
|
35
43
|
const TO = "fulmine.js";
|
|
@@ -53,9 +61,10 @@ const TYPESCRIPT_EXTENSIONS = new Set([".ts", ".mts", ".cts", ".tsx"]);
|
|
|
53
61
|
const DIFFERENCES = [
|
|
54
62
|
[
|
|
55
63
|
"app.listen() returns the app, not an http.Server",
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
64
|
+
"The app answers as one: instanceof http.Server is true, and close(), address(), listening,\n" +
|
|
65
|
+
"getConnections(), ref(), unref() and setTimeout() are all there. What is missing is the plumbing\n" +
|
|
66
|
+
"that carries node sockets, so nothing emits connection, request or upgrade, and a library that\n" +
|
|
67
|
+
"serves its own protocol on the socket, socket.io being the usual one, wants app.uwsApp instead."
|
|
59
68
|
],
|
|
60
69
|
[
|
|
61
70
|
"an HTTPS server is configured through express(), not https.createServer()",
|
|
@@ -323,45 +332,25 @@ function findEntry(given) {
|
|
|
323
332
|
}
|
|
324
333
|
|
|
325
334
|
/**
|
|
326
|
-
*
|
|
327
|
-
* and the path it answers from the outside.
|
|
328
|
-
*
|
|
329
|
-
* @param {any} router
|
|
330
|
-
* @param {string} prefix
|
|
331
|
-
* @param {any[]} [into]
|
|
332
|
-
* @returns {any[]}
|
|
333
|
-
*/
|
|
334
|
-
function collectRoutes(router, prefix, into = []) {
|
|
335
|
-
for (const route of router._routes ?? []) {
|
|
336
|
-
const full = prefix + (typeof route.path === "string" ? route.path : String(route.pattern)) || "/";
|
|
337
|
-
into.push({ route, full });
|
|
338
|
-
const mounted = route.callbacks?.[0];
|
|
339
|
-
if (mounted && Array.isArray(mounted._routes)) {
|
|
340
|
-
collectRoutes(mounted, typeof route.path === "string" ? prefix + route.path : prefix, into);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
return into;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/**
|
|
347
|
-
* Loads an application without letting it listen, and prints what compiling its routes decided.
|
|
335
|
+
* The applications a file builds, compiled but not listening.
|
|
348
336
|
*
|
|
349
337
|
* listen() is where the routes are compiled and also where the port is bound, and only the first
|
|
350
|
-
* of those is wanted here: an application that answered on its port while being
|
|
351
|
-
*
|
|
352
|
-
*
|
|
338
|
+
* of those is wanted here: an application that answered on its port while being read would be a
|
|
339
|
+
* surprise, and a second copy of a running service is worse than a surprise. So listen is replaced
|
|
340
|
+
* by the half that matters, and the callback it was given is not run for the same reason.
|
|
353
341
|
*
|
|
354
342
|
* @param {string[]} argv
|
|
355
|
-
* @
|
|
343
|
+
* @param {string} command the word for the message when there is nothing to load
|
|
344
|
+
* @returns {{apps: any[], entry: string}|null} null once the reason has been printed
|
|
356
345
|
*/
|
|
357
|
-
function
|
|
346
|
+
function loadApps(argv, command) {
|
|
358
347
|
const entry = findEntry(argv.find((arg) => !arg.startsWith("--")));
|
|
359
348
|
if (!entry) {
|
|
360
349
|
console.error(
|
|
361
|
-
|
|
362
|
-
|
|
350
|
+
`Nothing to ${command}: name the file that builds the application, or run this from a
|
|
351
|
+
` + "directory whose package.json main points at it."
|
|
363
352
|
);
|
|
364
|
-
return
|
|
353
|
+
return null;
|
|
365
354
|
}
|
|
366
355
|
|
|
367
356
|
// the same module instance the application will load, so patching this prototype patches the
|
|
@@ -374,7 +363,7 @@ function profile(argv) {
|
|
|
374
363
|
}
|
|
375
364
|
if (!proto) {
|
|
376
365
|
console.error("This build of fulmine has no listen() to stand in for, which should not happen.");
|
|
377
|
-
return
|
|
366
|
+
return null;
|
|
378
367
|
}
|
|
379
368
|
|
|
380
369
|
const listened = [];
|
|
@@ -390,8 +379,9 @@ function profile(argv) {
|
|
|
390
379
|
} catch (e) {
|
|
391
380
|
const error = /** @type {any} */ (e);
|
|
392
381
|
proto.listen = realListen;
|
|
393
|
-
console.error(`${path.relative(process.cwd(), entry)} could not be loaded
|
|
394
|
-
|
|
382
|
+
console.error(`${path.relative(process.cwd(), entry)} could not be loaded:
|
|
383
|
+
${error.stack ?? error}`);
|
|
384
|
+
return null;
|
|
395
385
|
}
|
|
396
386
|
proto.listen = realListen;
|
|
397
387
|
|
|
@@ -409,14 +399,32 @@ function profile(argv) {
|
|
|
409
399
|
|
|
410
400
|
if (apps.length === 0) {
|
|
411
401
|
console.error(
|
|
412
|
-
`${path.relative(process.cwd(), entry)} built no application: it neither called listen() nor
|
|
413
|
-
|
|
402
|
+
`${path.relative(process.cwd(), entry)} built no application: it neither called listen() nor
|
|
403
|
+
` + "exported one. Point this at the file that does."
|
|
414
404
|
);
|
|
415
|
-
return
|
|
405
|
+
return null;
|
|
416
406
|
}
|
|
407
|
+
return { apps, entry };
|
|
408
|
+
}
|
|
417
409
|
|
|
418
|
-
|
|
419
|
-
|
|
410
|
+
/**
|
|
411
|
+
* Loads an application without letting it listen, and prints what compiling its routes decided.
|
|
412
|
+
*
|
|
413
|
+
* listen() is where the routes are compiled and also where the port is bound, and only the first
|
|
414
|
+
* of those is wanted here: an application that answered on its port while being profiled would be
|
|
415
|
+
* a surprise, and a second copy of a running service is worse than a surprise. So listen is
|
|
416
|
+
* replaced by the half that matters. The callback it was given is not run, for the same reason.
|
|
417
|
+
*
|
|
418
|
+
* @param {string[]} argv
|
|
419
|
+
* @returns {number} exit code
|
|
420
|
+
*/
|
|
421
|
+
function profile(argv) {
|
|
422
|
+
const loaded = loadApps(argv, "profile");
|
|
423
|
+
if (!loaded) {
|
|
424
|
+
return 1;
|
|
425
|
+
}
|
|
426
|
+
for (const app of loaded.apps) {
|
|
427
|
+
printProfile(app, loaded.apps.length > 1);
|
|
420
428
|
}
|
|
421
429
|
return 0;
|
|
422
430
|
}
|
|
@@ -446,6 +454,108 @@ const ADVICE = [
|
|
|
446
454
|
]
|
|
447
455
|
];
|
|
448
456
|
|
|
457
|
+
/**
|
|
458
|
+
* The plan for one route, the way a database explains a query.
|
|
459
|
+
*
|
|
460
|
+
* `profile` answers "how much of this application is on the fast path", which is a question about
|
|
461
|
+
* the whole table. This one answers "what happens when this request arrives", which is the question
|
|
462
|
+
* somebody has when one endpoint is slower than they expected: how it is matched, what is copied
|
|
463
|
+
* out of it, what runs, and what each layer costs the route.
|
|
464
|
+
*
|
|
465
|
+
* @param {string[]} argv the path to explain, then the entry
|
|
466
|
+
* @returns {number} exit code
|
|
467
|
+
*/
|
|
468
|
+
function explain(argv) {
|
|
469
|
+
const args = argv.filter((arg) => !arg.startsWith("--"));
|
|
470
|
+
const wanted = args[0];
|
|
471
|
+
if (!wanted) {
|
|
472
|
+
console.error(`Name the route to explain: npx ${TO} explain /api/items`);
|
|
473
|
+
return 1;
|
|
474
|
+
}
|
|
475
|
+
const loaded = loadApps(args.slice(1), "explain");
|
|
476
|
+
if (!loaded) {
|
|
477
|
+
return 1;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const { callbackUsage, UNKNOWN, QUERY } = require("./usage.js");
|
|
481
|
+
let found = 0;
|
|
482
|
+
for (const app of loaded.apps) {
|
|
483
|
+
const entries = collectRoutes(app, "").filter(({ route }) => !route.use);
|
|
484
|
+
for (const { route, full } of entries) {
|
|
485
|
+
if (!matchesWanted(full, route.method, wanted)) {
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
found++;
|
|
489
|
+
const native = route._native;
|
|
490
|
+
console.log(`
|
|
491
|
+
${String(route.method).toUpperCase()} ${full}
|
|
492
|
+
`);
|
|
493
|
+
console.log(
|
|
494
|
+
` route ${native ? `native (µWS matched ${native.path} and dispatched by method)` : `router (matched here, layer by layer: ${route._whyGeneric ?? "it was not eligible"})`}`
|
|
495
|
+
);
|
|
496
|
+
if (native) {
|
|
497
|
+
console.log(
|
|
498
|
+
` headers ${native.skipHeaders ? "not copied (nothing in the chain reads one)" : "copied out of µWS (something in the chain reads them)"}`
|
|
499
|
+
);
|
|
500
|
+
console.log(
|
|
501
|
+
` query ${native.skipQuery ? "not parsed (nothing in the chain reads it)" : "parsed when something asks for it"}`
|
|
502
|
+
);
|
|
503
|
+
if (native.guards) {
|
|
504
|
+
console.log(` guards ${native.guards} case guard(s) in front of it`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const chain = route.callbacks ?? [];
|
|
509
|
+
const ahead = native?.ahead ? `, ${native.ahead} mounted layer(s) in front of it` : "";
|
|
510
|
+
console.log(
|
|
511
|
+
` chain ${chain.length} layer(s)${ahead}${native?.declarative ? ", compiled into a response written at startup" : ""}`
|
|
512
|
+
);
|
|
513
|
+
for (const fn of chain) {
|
|
514
|
+
const usage = callbackUsage(fn);
|
|
515
|
+
const name = fn.name || "(anonymous)";
|
|
516
|
+
const notes = [];
|
|
517
|
+
if (usage & UNKNOWN) {
|
|
518
|
+
notes.push("not readable at registration: it keeps the route off the compiled path");
|
|
519
|
+
} else {
|
|
520
|
+
notes.push("readable at registration");
|
|
521
|
+
if (usage & QUERY) notes.push("reads the query");
|
|
522
|
+
}
|
|
523
|
+
console.log(` ${name.padEnd(22)}${notes.join(", ")}`);
|
|
524
|
+
}
|
|
525
|
+
console.log(
|
|
526
|
+
` body ${route.bodyMethods ? `read for ${route.bodyMethods.join(", ")}` : "read for POST, PUT, PATCH and QUERY, when one is declared"}`
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (found === 0) {
|
|
532
|
+
console.error(`No route is registered as "${wanted}". Run \`npx ${TO} profile\` to see the ones that are.`);
|
|
533
|
+
return 1;
|
|
534
|
+
}
|
|
535
|
+
return 0;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Whether a route answers to the name given on the command line: the path as registered, with an
|
|
540
|
+
* optional method in front and an optional "*" at the end for a prefix.
|
|
541
|
+
*
|
|
542
|
+
* @param {string} full
|
|
543
|
+
* @param {string} method
|
|
544
|
+
* @param {string} wanted
|
|
545
|
+
* @returns {boolean}
|
|
546
|
+
*/
|
|
547
|
+
function matchesWanted(full, method, wanted) {
|
|
548
|
+
let path = wanted;
|
|
549
|
+
const space = wanted.indexOf(" ");
|
|
550
|
+
if (space !== -1) {
|
|
551
|
+
if (wanted.slice(0, space).toUpperCase() !== String(method).toUpperCase()) {
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
path = wanted.slice(space + 1);
|
|
555
|
+
}
|
|
556
|
+
return path.endsWith("*") ? full.startsWith(path.slice(0, -1)) : full === path;
|
|
557
|
+
}
|
|
558
|
+
|
|
449
559
|
/**
|
|
450
560
|
* A summary that says how much of this application the native router carries, and what could be
|
|
451
561
|
* changed to make it carry more.
|
|
@@ -582,11 +692,19 @@ function main(argv) {
|
|
|
582
692
|
if (command === "profile") {
|
|
583
693
|
return profile(argv.slice(1));
|
|
584
694
|
}
|
|
695
|
+
if (command === "verify") {
|
|
696
|
+
return verify(argv.slice(1));
|
|
697
|
+
}
|
|
698
|
+
if (command === "explain") {
|
|
699
|
+
return explain(argv.slice(1));
|
|
700
|
+
}
|
|
585
701
|
if (command !== "migrate") {
|
|
586
702
|
console.log(`Usage:
|
|
587
703
|
npx ${TO} migrate [dir] rewrite require("${FROM}") and import from "${FROM}" to "${TO}"
|
|
588
704
|
npx ${TO} profile [entry] load an application without listening and print what compiling
|
|
589
705
|
its routes decided, route by route
|
|
706
|
+
npx ${TO} explain <route> what happens when a request for that route arrives
|
|
707
|
+
npx ${TO} verify [dir] check that this machine and this project can run it at all
|
|
590
708
|
npx ${TO} differences print what behaves differently, without changing anything
|
|
591
709
|
|
|
592
710
|
Options:
|