fulmine.js 5.7.0 → 5.8.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 +66 -1
- package/package.json +2 -2
- package/src/application.js +5 -0
- package/src/cli.js +159 -41
- package/src/index.js +8 -0
- 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 +26 -0
- package/src/verify.js +309 -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, not an `http.Server
|
|
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`.
|
|
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.
|
|
@@ -519,8 +572,15 @@ In general, basically all features and options are supported. Use the [Express 5
|
|
|
519
572
|
- ✅ express.json()
|
|
520
573
|
- ✅ express.urlencoded()
|
|
521
574
|
- ✅ express.static()
|
|
575
|
+
- - ✅ options.index, options.redirect, options.fallthrough, options.extensions
|
|
576
|
+
- - ✅ 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
|
|
577
|
+
- - ✅ options.setHeaders, options.headers
|
|
578
|
+
- - ✅ options.etag, options.lastModified, options.maxAge, options.immutable, options.cacheControl, options.acceptRanges
|
|
579
|
+
- - ✅ options.preCompressed, Fulmine's own: serve the `.br` or `.gz` twin on disk, described under [Performance tips](#performance-tips)
|
|
522
580
|
- ✅ express.text()
|
|
523
581
|
- ✅ express.raw()
|
|
582
|
+
- ✅ express.serverTiming(). Fulmine's own: Server-Timing carrying how the request was routed, described under [Performance tips](#performance-tips).
|
|
583
|
+
- ✅ express.testing. Fulmine's own: `expectNative`, `expectDeclarative` and `routeReport`, described under [Performance tips](#performance-tips).
|
|
524
584
|
- ✅ 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
585
|
- 🚧 express.request (this is not a constructor but a prototype for replacing methods)
|
|
526
586
|
- 🚧 express.response (this is not a constructor but a prototype for replacing methods)
|
|
@@ -554,6 +614,11 @@ In general, basically all features and options are supported. Use the [Express 5
|
|
|
554
614
|
- ✅ OPTIONS method
|
|
555
615
|
- ✅ QUERY method
|
|
556
616
|
|
|
617
|
+
What `listen()` hands back is the app, and it answers as an `http.Server` so the shutdown wrappers
|
|
618
|
+
recognise it: `app.close()`, `app.address()`, `app.listening`, `app.getConnections()`, `app.ref()`,
|
|
619
|
+
`app.unref()`, `app.setTimeout()` and the `keepAliveTimeout` family. See
|
|
620
|
+
[Differences from Express](#differences-from-express) for what is behind them and what is not.
|
|
621
|
+
|
|
557
622
|
### Application settings
|
|
558
623
|
|
|
559
624
|
- ✅ case sensitive routing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fulmine.js",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.8.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,7 @@ 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");
|
|
39
40
|
|
|
40
41
|
const cpuCount = os.cpus().length;
|
|
41
42
|
|
|
@@ -871,3 +872,7 @@ module.exports = function (options) {
|
|
|
871
872
|
// the class itself, so index.js can expose its prototype as express.application does. Adding a
|
|
872
873
|
// method to that prototype adds it to every app, which is what the property is for.
|
|
873
874
|
module.exports.Application = Application;
|
|
875
|
+
|
|
876
|
+
// and what makes an application answer as an http.Server, since that is what a library handed the
|
|
877
|
+
// result of listen() looks for. See server-shape.js for what is answered and what is not.
|
|
878
|
+
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:
|
package/src/index.js
CHANGED
|
@@ -49,7 +49,9 @@ try {
|
|
|
49
49
|
* response: object,
|
|
50
50
|
* application: object,
|
|
51
51
|
* static: Function,
|
|
52
|
+
* testing: object,
|
|
52
53
|
* compression: Function,
|
|
54
|
+
* serverTiming: Function,
|
|
53
55
|
* json: Function,
|
|
54
56
|
* urlencoded: Function,
|
|
55
57
|
* text: Function,
|
|
@@ -73,9 +75,15 @@ module.exports.response = Response.prototype;
|
|
|
73
75
|
module.exports.application = Application.Application.prototype;
|
|
74
76
|
|
|
75
77
|
module.exports.static = middlewares.static;
|
|
78
|
+
// what listen() decided about each route, as something a test can assert on rather than something
|
|
79
|
+
// to read in a terminal. See src/testing.js
|
|
80
|
+
module.exports.testing = require("./testing.js");
|
|
76
81
|
// not one of express's, since express has none: the compression module is what everyone installs
|
|
77
82
|
// instead, and this is that middleware's options and behaviour without the install
|
|
78
83
|
module.exports.compression = require("./compression.js");
|
|
84
|
+
// Server-Timing with the routing verdict in it, which no other framework can report because no
|
|
85
|
+
// other framework has two routes to tell apart. See src/server-timing.js
|
|
86
|
+
module.exports.serverTiming = require("./server-timing.js");
|
|
79
87
|
module.exports.json = middlewares.json;
|
|
80
88
|
module.exports.urlencoded = middlewares.urlencoded;
|
|
81
89
|
module.exports.text = middlewares.text;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2026 Nigro Simone
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// What makes an application answer the questions a library asks about an http.Server.
|
|
18
|
+
//
|
|
19
|
+
// `app.listen()` returns the app, and there is no node server under it: the socket belongs to µWS.
|
|
20
|
+
// That is the one place where a drop-in stops being a drop-in, because the graceful shutdown
|
|
21
|
+
// libraries, the connection trackers and the health check wrappers do not use a server, they
|
|
22
|
+
// *recognise* one: `server instanceof http.Server`, then close(), address(), getConnections() and
|
|
23
|
+
// the events around them. Everything they need can be answered honestly here.
|
|
24
|
+
//
|
|
25
|
+
// Two halves, and the second is the delicate one:
|
|
26
|
+
//
|
|
27
|
+
// - the members. close(), address(), listening and the events already exist on the application,
|
|
28
|
+
// because Express hands back an http.Server and code written for Express uses them. What was
|
|
29
|
+
// missing is the rest of the net.Server surface, added below.
|
|
30
|
+
// - the recognition. An application cannot inherit from http.Server: its prototype chain already
|
|
31
|
+
// runs through Router and this project's own EventEmitter, and http.Server.prototype has a
|
|
32
|
+
// chain of its own that cannot be spliced into it without re-parenting node's classes for the
|
|
33
|
+
// whole process. So instanceof is taught about applications instead, through the hook the
|
|
34
|
+
// language provides for exactly this: Symbol.hasInstance. The patch is additive. Everything
|
|
35
|
+
// that was an http.Server before still is, and the only new answer is for an application.
|
|
36
|
+
//
|
|
37
|
+
// What this deliberately does not do is pretend the plumbing is there. Nothing emits 'request',
|
|
38
|
+
// 'connection' or 'upgrade', because those carry node sockets and there are none: a library that
|
|
39
|
+
// counts connections through them counts zero, and socket.io still wants app.uwsApp. The shape is
|
|
40
|
+
// honest about what is behind it, which is why getConnections answers with the requests in flight
|
|
41
|
+
// rather than with a number nobody could stand behind.
|
|
42
|
+
|
|
43
|
+
const http = require("http");
|
|
44
|
+
const net = require("net");
|
|
45
|
+
|
|
46
|
+
// what marks an application, read by the instanceof hook below. A symbol rather than a property
|
|
47
|
+
// name, so nothing can be mistaken for an application by carrying the wrong field
|
|
48
|
+
const kIsApplication = Symbol.for("fulmine.application");
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Teaches `instanceof` that an application is a server, once per class. The original answer is
|
|
52
|
+
* asked first and is never overruled: this only adds an answer for objects carrying the mark.
|
|
53
|
+
*
|
|
54
|
+
* @param {Function} klass http.Server or net.Server
|
|
55
|
+
*/
|
|
56
|
+
function acceptApplications(klass) {
|
|
57
|
+
const previous = /** @type {any} */ (klass)[Symbol.hasInstance];
|
|
58
|
+
// already taught, which happens when two copies of this package share one process
|
|
59
|
+
if (/** @type {any} */ (klass)[kIsApplication] === true) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
Object.defineProperty(klass, Symbol.hasInstance, {
|
|
63
|
+
/** @param {any} value @returns {boolean} */
|
|
64
|
+
value: function (value) {
|
|
65
|
+
if (previous.call(this, value)) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
// an application is a function, and a property read works on one; the guard is for the
|
|
69
|
+
// primitives and the nulls that reach any instanceof
|
|
70
|
+
return value != null && /** @type {any} */ (value)[kIsApplication] === true;
|
|
71
|
+
},
|
|
72
|
+
configurable: true,
|
|
73
|
+
writable: true
|
|
74
|
+
});
|
|
75
|
+
Object.defineProperty(klass, kIsApplication, { value: true, configurable: true });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
acceptApplications(http.Server);
|
|
79
|
+
acceptApplications(net.Server);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The net.Server members an application does not get from Express's side of the API, defined on
|
|
83
|
+
* the application prototype. Each one answers for µWS rather than for a socket node does not have.
|
|
84
|
+
*
|
|
85
|
+
* @param {any} prototype Application.prototype
|
|
86
|
+
*/
|
|
87
|
+
function addServerMembers(prototype) {
|
|
88
|
+
Object.defineProperty(prototype, kIsApplication, { value: true, configurable: true });
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* How many requests this application is serving right now.
|
|
92
|
+
*
|
|
93
|
+
* node counts sockets; there are none to count here, and the number a graceful shutdown is
|
|
94
|
+
* waiting for is this one anyway: it reaches zero when the last answer has gone out. An idle
|
|
95
|
+
* keep-alive connection is not counted, and closing does not wait for one either.
|
|
96
|
+
*
|
|
97
|
+
* @param {(err: Error|null, count: number) => void} callback
|
|
98
|
+
*/
|
|
99
|
+
prototype.getConnections = function getConnections(callback) {
|
|
100
|
+
let count = 0;
|
|
101
|
+
for (let response = this._pending.head; response !== null; response = response._pendingNext) {
|
|
102
|
+
count++;
|
|
103
|
+
}
|
|
104
|
+
// node answers this one asynchronously, and a caller written against it may rely on that
|
|
105
|
+
process.nextTick(callback, null, count);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* node's, for a handle this does not own: µWS's loop is what keeps the process alive, and it
|
|
110
|
+
* is not something a caller may unref. Both are no-ops that hand the server back, so a chain
|
|
111
|
+
* written against node's API keeps working.
|
|
112
|
+
*
|
|
113
|
+
* @returns {any}
|
|
114
|
+
*/
|
|
115
|
+
prototype.ref = function ref() {
|
|
116
|
+
return this;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** @returns {any} */
|
|
120
|
+
prototype.unref = function unref() {
|
|
121
|
+
return this;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Registers the callback the way node's does and remembers the value, which is all a caller
|
|
126
|
+
* can observe. The timeout itself belongs to µWS and is set through uwsOptions.idleTimeout.
|
|
127
|
+
*
|
|
128
|
+
* @this {any}
|
|
129
|
+
* @param {number} [msecs]
|
|
130
|
+
* @param {() => void} [callback]
|
|
131
|
+
* @returns {any}
|
|
132
|
+
*/
|
|
133
|
+
prototype.setTimeout = function setTimeout(msecs, callback) {
|
|
134
|
+
this.timeout = msecs;
|
|
135
|
+
if (callback) {
|
|
136
|
+
this.on("timeout", callback);
|
|
137
|
+
}
|
|
138
|
+
return this;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// The numbers node's http.Server carries and a caller may read or write. They are inert here,
|
|
142
|
+
// and they are declared rather than left undefined because reading one is how a library works
|
|
143
|
+
// out what it is talking to: `server.keepAliveTimeout` undefined has been read as "not a
|
|
144
|
+
// server" before now.
|
|
145
|
+
for (const [name, value] of /** @type {[string, any][]} */ ([
|
|
146
|
+
["timeout", 0],
|
|
147
|
+
["keepAliveTimeout", 5000],
|
|
148
|
+
["headersTimeout", 60000],
|
|
149
|
+
["requestTimeout", 300000],
|
|
150
|
+
["maxHeadersCount", null],
|
|
151
|
+
["maxRequestsPerSocket", 0]
|
|
152
|
+
])) {
|
|
153
|
+
Object.defineProperty(prototype, name, { value, writable: true, configurable: true, enumerable: false });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = { addServerMembers, kIsApplication };
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2026 Nigro Simone
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// express.serverTiming(): Server-Timing, with the two things only this framework can put in it.
|
|
18
|
+
//
|
|
19
|
+
// A stopwatch middleware is nothing new, and there are several on npm. What none of them can add
|
|
20
|
+
// is how the request was routed, because in every other framework there is only one way:
|
|
21
|
+
//
|
|
22
|
+
// Server-Timing: route;desc="native", hdr;desc="not copied", total;dur=0.42
|
|
23
|
+
//
|
|
24
|
+
// `route;desc="native"` means µWS matched the path in C++ and handed over a chain worked out at
|
|
25
|
+
// startup. `route;desc="router"` means this request was matched here, in javascript, layer by
|
|
26
|
+
// layer. That is the difference between the two halves of this project, per request, in the
|
|
27
|
+
// browser's network panel, for someone who would never run a CLI.
|
|
28
|
+
//
|
|
29
|
+
// What it cannot show is the route that is faster still: a handler compiled into a response never
|
|
30
|
+
// enters javascript, so no middleware runs on it and there is nothing to time. `npx fulmine
|
|
31
|
+
// profile` is where those are counted.
|
|
32
|
+
//
|
|
33
|
+
// The duration ends where the header does. Server-Timing goes out with the head, so `total` covers
|
|
34
|
+
// everything up to the moment the answer starts leaving, and not the body after it. Every stopwatch
|
|
35
|
+
// middleware has that boundary; this one says so.
|
|
36
|
+
|
|
37
|
+
"use strict";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A duration in milliseconds, as Server-Timing writes them: two decimals, which is a hundredth of
|
|
41
|
+
* a millisecond and finer than anything above it is worth.
|
|
42
|
+
*
|
|
43
|
+
* @param {bigint} nanoseconds
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
function millis(nanoseconds) {
|
|
47
|
+
return (Number(nanoseconds) / 1e6).toFixed(2);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Escapes a description for the quoted-string it goes in.
|
|
52
|
+
* @param {string} text
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
function describe(text) {
|
|
56
|
+
return `"${String(text).replace(/["\\]/g, "")}"`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Measures the request and answers with Server-Timing.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} [options]
|
|
63
|
+
* @param {boolean} [options.routing] whether to report how the request was routed. Default true.
|
|
64
|
+
* @param {boolean} [options.total] whether to report the time up to the head. Default true.
|
|
65
|
+
* @param {string} [options.name] what the total is called. Default "total".
|
|
66
|
+
* @returns {(req: any, res: any, next: (err?: any) => void) => void}
|
|
67
|
+
*/
|
|
68
|
+
function serverTiming(options) {
|
|
69
|
+
const opts = options || {};
|
|
70
|
+
const routing = opts.routing !== false;
|
|
71
|
+
const wantsTotal = opts.total !== false;
|
|
72
|
+
const totalName = opts.name || "total";
|
|
73
|
+
|
|
74
|
+
return function serverTiming(req, res, next) {
|
|
75
|
+
const started = process.hrtime.bigint();
|
|
76
|
+
/** @type {string[]} */
|
|
77
|
+
const marks = [];
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Adds a mark of the caller's own, which is what the rest of Server-Timing is for: the
|
|
81
|
+
* query, the upstream call, the render. A duration is optional, since a mark with only a
|
|
82
|
+
* description is a legal entry and is how a cache hit is usually reported.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} name a token: letters, digits, dash and underscore
|
|
85
|
+
* @param {number} [duration] milliseconds
|
|
86
|
+
* @param {string} [description]
|
|
87
|
+
* @returns {any} the response, so calls chain
|
|
88
|
+
*/
|
|
89
|
+
res.timing = function timing(name, duration, description) {
|
|
90
|
+
let mark = String(name).replace(/[^\w-]/g, "");
|
|
91
|
+
if (typeof duration === "number") {
|
|
92
|
+
mark += `;dur=${duration.toFixed(2)}`;
|
|
93
|
+
}
|
|
94
|
+
if (description) {
|
|
95
|
+
mark += `;desc=${describe(description)}`;
|
|
96
|
+
}
|
|
97
|
+
marks.push(mark);
|
|
98
|
+
return this;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Times a piece of work under a name, whatever it is: the value comes back, and a promise
|
|
103
|
+
* is timed to where it settles.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} name
|
|
106
|
+
* @param {() => any} work
|
|
107
|
+
* @returns {any} whatever the work returned
|
|
108
|
+
*/
|
|
109
|
+
res.time = function time(name, work) {
|
|
110
|
+
const from = process.hrtime.bigint();
|
|
111
|
+
const done = () => res.timing(name, Number(process.hrtime.bigint() - from) / 1e6);
|
|
112
|
+
let value;
|
|
113
|
+
try {
|
|
114
|
+
value = work();
|
|
115
|
+
} catch (err) {
|
|
116
|
+
done();
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
if (value && typeof value.then === "function") {
|
|
120
|
+
return value.then(
|
|
121
|
+
/** @param {any} resolved */ (resolved) => {
|
|
122
|
+
done();
|
|
123
|
+
return resolved;
|
|
124
|
+
},
|
|
125
|
+
/** @param {any} err */ (err) => {
|
|
126
|
+
done();
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
done();
|
|
132
|
+
return value;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const _write = res.write;
|
|
136
|
+
const _end = res.end;
|
|
137
|
+
let written = false;
|
|
138
|
+
|
|
139
|
+
/** Writes the header, once, just before the head goes out with the first byte of body. */
|
|
140
|
+
const stamp = () => {
|
|
141
|
+
if (written || res.headersSent) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
written = true;
|
|
145
|
+
const entries = [];
|
|
146
|
+
if (routing) {
|
|
147
|
+
// what the router decided about the route this request ran, which is the same
|
|
148
|
+
// verdict npx fulmine profile prints for it
|
|
149
|
+
const native = req.route?._native;
|
|
150
|
+
entries.push(`route;desc=${describe(native ? "native" : "router")}`);
|
|
151
|
+
if (native) {
|
|
152
|
+
entries.push(`hdr;desc=${describe(native.skipHeaders ? "not copied" : "copied")}`);
|
|
153
|
+
if (native.skipQuery) {
|
|
154
|
+
entries.push(`query;desc=${describe("not parsed")}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
entries.push(...marks);
|
|
159
|
+
if (wantsTotal) {
|
|
160
|
+
entries.push(`${totalName};dur=${millis(process.hrtime.bigint() - started)}`);
|
|
161
|
+
}
|
|
162
|
+
if (entries.length !== 0) {
|
|
163
|
+
res.append("Server-Timing", entries.join(", "));
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
res.write = function write(chunk, encoding, callback) {
|
|
168
|
+
stamp();
|
|
169
|
+
return _write.call(this, chunk, encoding, callback);
|
|
170
|
+
};
|
|
171
|
+
res.end = function end(chunk, encoding, callback) {
|
|
172
|
+
stamp();
|
|
173
|
+
return _end.call(this, chunk, encoding, callback);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
next();
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = serverTiming;
|
package/src/testing.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
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.testing: what listen() decided about each route, as something a test can assert on.
|
|
18
|
+
//
|
|
19
|
+
// A route is answered by µWS itself only while it stays eligible, and eligibility is not a property
|
|
20
|
+
// of the route alone: a `const` in the wrong place, a middleware that reads a header, a new route
|
|
21
|
+
// written above an old one, and it quietly falls back to the ordinary router. The answer is still
|
|
22
|
+
// correct, which is why nothing complains. What changes is the throughput, and by the time anyone
|
|
23
|
+
// notices, the commit that did it is three weeks back.
|
|
24
|
+
//
|
|
25
|
+
// `npx fulmine profile` prints the same verdicts for a human to read. This is the half a test can
|
|
26
|
+
// hold on to, so a pull request that loses the fast path fails in CI with the reason written out
|
|
27
|
+
// instead of being found in production.
|
|
28
|
+
|
|
29
|
+
"use strict";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Every route of an application and of the routers mounted under it, each with the path it answers
|
|
33
|
+
* from the outside.
|
|
34
|
+
*
|
|
35
|
+
* @param {any} router
|
|
36
|
+
* @param {string} prefix
|
|
37
|
+
* @param {any[]} [into]
|
|
38
|
+
* @returns {{route: any, full: string}[]}
|
|
39
|
+
*/
|
|
40
|
+
function collectRoutes(router, prefix, into = []) {
|
|
41
|
+
for (const route of router._routes ?? []) {
|
|
42
|
+
const full = prefix + (typeof route.path === "string" ? route.path : String(route.pattern)) || "/";
|
|
43
|
+
into.push({ route, full });
|
|
44
|
+
const mounted = route.callbacks?.[0];
|
|
45
|
+
if (mounted && Array.isArray(mounted._routes)) {
|
|
46
|
+
collectRoutes(mounted, typeof route.path === "string" ? prefix + route.path : prefix, into);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return into;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compiles the routes, which is what listen() does before it binds, without binding anything. Once
|
|
54
|
+
* per application: a second compilation would register everything with µWS twice.
|
|
55
|
+
*
|
|
56
|
+
* @param {any} app
|
|
57
|
+
*/
|
|
58
|
+
function compileOnce(app) {
|
|
59
|
+
if (app.listenCalled || app._testingCompiled) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
app._testingCompiled = true;
|
|
63
|
+
app._compileOptimizedRoutes();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What compiling the routes decided, one entry per route, in the order they were registered.
|
|
68
|
+
*
|
|
69
|
+
* This is the primitive the two assertions below are written on, and it is exported because an
|
|
70
|
+
* application with rules of its own is better served asserting them itself: how many routes may
|
|
71
|
+
* fall back, which ones may read headers, that the one route carrying the traffic is declarative.
|
|
72
|
+
*
|
|
73
|
+
* @param {any} app an application, listening or not
|
|
74
|
+
* @returns {{method: string, path: string, native: boolean, declarative: boolean, skipHeaders: boolean,
|
|
75
|
+
* skipQuery: boolean, reason: string|undefined}[]}
|
|
76
|
+
*/
|
|
77
|
+
function routeReport(app) {
|
|
78
|
+
compileOnce(app);
|
|
79
|
+
return collectRoutes(app, "")
|
|
80
|
+
.filter(({ route }) => !route.use)
|
|
81
|
+
.map(({ route, full }) => ({
|
|
82
|
+
method: String(route.method),
|
|
83
|
+
path: full,
|
|
84
|
+
native: Boolean(route._native),
|
|
85
|
+
declarative: Boolean(route._native?.declarative),
|
|
86
|
+
skipHeaders: Boolean(route._native?.skipHeaders),
|
|
87
|
+
skipQuery: Boolean(route._native?.skipQuery),
|
|
88
|
+
reason: route._native ? undefined : (route._whyGeneric ?? "it was not eligible")
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether one of the patterns given names this route.
|
|
94
|
+
*
|
|
95
|
+
* A pattern is a path as it was registered, not a URL: "/api/items/:id" and not "/api/items/7". It
|
|
96
|
+
* may carry the method, "GET /health", and it may end in "*" to name everything under a prefix.
|
|
97
|
+
*
|
|
98
|
+
* @param {{method: string, path: string}} entry
|
|
99
|
+
* @param {string} pattern
|
|
100
|
+
* @returns {boolean}
|
|
101
|
+
*/
|
|
102
|
+
function names(entry, pattern) {
|
|
103
|
+
let path = pattern;
|
|
104
|
+
const space = pattern.indexOf(" ");
|
|
105
|
+
if (space !== -1) {
|
|
106
|
+
const method = pattern.slice(0, space).toUpperCase();
|
|
107
|
+
if (method !== entry.method.toUpperCase()) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
path = pattern.slice(space + 1);
|
|
111
|
+
}
|
|
112
|
+
if (path.endsWith("*")) {
|
|
113
|
+
return entry.path.startsWith(path.slice(0, -1));
|
|
114
|
+
}
|
|
115
|
+
return entry.path === path;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The routes the patterns name, refusing a pattern that names none: a test that asserts about a
|
|
120
|
+
* route it misspelled has to fail rather than pass on an empty list.
|
|
121
|
+
*
|
|
122
|
+
* @param {any} app
|
|
123
|
+
* @param {string|string[]} patterns
|
|
124
|
+
* @param {string} caller the name in the message
|
|
125
|
+
* @returns {ReturnType<typeof routeReport>}
|
|
126
|
+
*/
|
|
127
|
+
function select(app, patterns, caller) {
|
|
128
|
+
const wanted = typeof patterns === "string" ? [patterns] : patterns;
|
|
129
|
+
if (!Array.isArray(wanted) || wanted.length === 0) {
|
|
130
|
+
throw new TypeError(`${caller} needs a path, or a list of them, to check`);
|
|
131
|
+
}
|
|
132
|
+
const report = routeReport(app);
|
|
133
|
+
const selected = [];
|
|
134
|
+
for (const pattern of wanted) {
|
|
135
|
+
const matched = report.filter((entry) => names(entry, pattern));
|
|
136
|
+
if (matched.length === 0) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`${caller}: no route is registered as "${pattern}".\n` +
|
|
139
|
+
`The paths this application has are:\n` +
|
|
140
|
+
report.map((entry) => ` ${entry.method} ${entry.path}`).join("\n")
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
for (const entry of matched) {
|
|
144
|
+
if (!selected.includes(entry)) {
|
|
145
|
+
selected.push(entry);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return selected;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Throws unless every route named is answered by µWS itself.
|
|
154
|
+
*
|
|
155
|
+
* The message is the point: it names each route that fell back and why, in the same words
|
|
156
|
+
* `npx fulmine profile` uses, so the failure says what to change.
|
|
157
|
+
*
|
|
158
|
+
* @param {any} app
|
|
159
|
+
* @param {string|string[]} patterns paths as they were registered, "GET /path" to pin the method,
|
|
160
|
+
* a trailing "*" for everything under a prefix
|
|
161
|
+
*/
|
|
162
|
+
function expectNative(app, patterns) {
|
|
163
|
+
const lost = select(app, patterns, "expectNative").filter((entry) => !entry.native);
|
|
164
|
+
if (lost.length === 0) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
throw new Error(
|
|
168
|
+
`${lost.length} route(s) are no longer answered by µWS itself:\n\n` +
|
|
169
|
+
lost.map((entry) => ` ${entry.method} ${entry.path}\n ${entry.reason}`).join("\n") +
|
|
170
|
+
`\n\nRun \`npx fulmine profile\` to see the whole picture.`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Throws unless every route named is answered from a response written at startup, which is the
|
|
176
|
+
* step past native: µWS answers it without entering javascript at all.
|
|
177
|
+
*
|
|
178
|
+
* @param {any} app
|
|
179
|
+
* @param {string|string[]} patterns as in expectNative
|
|
180
|
+
*/
|
|
181
|
+
function expectDeclarative(app, patterns) {
|
|
182
|
+
const lost = select(app, patterns, "expectDeclarative").filter((entry) => !entry.declarative);
|
|
183
|
+
if (lost.length === 0) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
throw new Error(
|
|
187
|
+
`${lost.length} route(s) are no longer compiled into a response:\n\n` +
|
|
188
|
+
lost
|
|
189
|
+
.map(
|
|
190
|
+
(entry) =>
|
|
191
|
+
` ${entry.method} ${entry.path}\n ` +
|
|
192
|
+
(entry.native
|
|
193
|
+
? "answered by µWS, but the handler is no longer simple enough to compile"
|
|
194
|
+
: entry.reason)
|
|
195
|
+
)
|
|
196
|
+
.join("\n") +
|
|
197
|
+
`\n\nRun \`npx fulmine profile\` to see the whole picture.`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = { routeReport, expectNative, expectDeclarative, collectRoutes };
|
package/src/types.d.ts
CHANGED
|
@@ -50,6 +50,32 @@ declare module "fulmine.js" {
|
|
|
50
50
|
/** Brotli options. The default quality is 4. */
|
|
51
51
|
brotli?: BrotliOptions;
|
|
52
52
|
}
|
|
53
|
+
// what listen() decided about each route, for a test to hold on to
|
|
54
|
+
interface RouteVerdict {
|
|
55
|
+
/** The method as it was registered, "GET". */
|
|
56
|
+
method: string;
|
|
57
|
+
/** The path as it was registered, "/users/:id". */
|
|
58
|
+
path: string;
|
|
59
|
+
/** Whether µWS matches this route itself. */
|
|
60
|
+
native: boolean;
|
|
61
|
+
/** Whether it was compiled into a response written at startup. */
|
|
62
|
+
declarative: boolean;
|
|
63
|
+
/** Whether the chain provably reads no request header. */
|
|
64
|
+
skipHeaders: boolean;
|
|
65
|
+
/** Whether the chain provably reads no query. */
|
|
66
|
+
skipQuery: boolean;
|
|
67
|
+
/** Why it fell back to the ordinary router, when it did. */
|
|
68
|
+
reason?: string;
|
|
69
|
+
}
|
|
70
|
+
export namespace testing {
|
|
71
|
+
/** Every route, with what compiling it decided. */
|
|
72
|
+
function routeReport(app: Fulmine): RouteVerdict[];
|
|
73
|
+
/** Throws unless µWS answers every route named. A trailing "*" names a prefix. */
|
|
74
|
+
function expectNative(app: Fulmine, patterns: string | string[]): void;
|
|
75
|
+
/** Throws unless every route named was compiled into a response. */
|
|
76
|
+
function expectDeclarative(app: Fulmine, patterns: string | string[]): void;
|
|
77
|
+
}
|
|
78
|
+
|
|
53
79
|
export function compression(options?: CompressionOptions): e.RequestHandler;
|
|
54
80
|
export namespace compression {
|
|
55
81
|
/** The default filter: any compressible content type. */
|
package/src/verify.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
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
|
+
// npx fulmine verify
|
|
18
|
+
//
|
|
19
|
+
// Whether this machine, and the image it will be deployed in, can run the thing at all. Not
|
|
20
|
+
// whether the application behaves the same, which is what the test suite and `differences` are
|
|
21
|
+
// for: this is the question that comes before it, and it is the one that costs an hour when the
|
|
22
|
+
// answer is no and nobody asked.
|
|
23
|
+
//
|
|
24
|
+
// There is a µWebSockets.js binary underneath, and a binary has requirements a package does not:
|
|
25
|
+
// it is built per platform, per architecture and per node ABI, and it is linked against glibc. An
|
|
26
|
+
// Alpine image, a node version the pinned build has no binary for, a musl base chosen by a
|
|
27
|
+
// Dockerfile written before any of this: each one fails at require time, in a container, in CI,
|
|
28
|
+
// with a message about a missing module that says nothing about what to do.
|
|
29
|
+
//
|
|
30
|
+
// Thirty seconds here instead.
|
|
31
|
+
|
|
32
|
+
"use strict";
|
|
33
|
+
|
|
34
|
+
const fs = require("fs");
|
|
35
|
+
const path = require("path");
|
|
36
|
+
|
|
37
|
+
// The oldest glibc the pinned µWS binaries are built against. A runtime older than this loads the
|
|
38
|
+
// file and then fails on a symbol, which is a worse error than not finding it at all.
|
|
39
|
+
const MIN_GLIBC = "2.38";
|
|
40
|
+
|
|
41
|
+
// What a project may carry that needs a different API here rather than none. Everything that just
|
|
42
|
+
// works, and everything that only wants a faster built-in, is `npx fulmine migrate`'s business.
|
|
43
|
+
const NEEDS_A_LOOK = {
|
|
44
|
+
"socket.io": "attach it with io.attachApp(app.uwsApp), not io.attach(server): there is no node socket to take over",
|
|
45
|
+
ws: "the websocket server is µWS's own, through app.ws(path, behavior)",
|
|
46
|
+
"express-ws": "the same: app.ws(path, behavior) is built in",
|
|
47
|
+
spdy: "no spdy here; TLS is configured through express({ uwsOptions: { key_file_name, cert_file_name } })",
|
|
48
|
+
"http2-express-bridge": "no HTTP/2 server to bridge to"
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One line of the report. Three levels, and only one of them is a failure: an image that cannot
|
|
53
|
+
* load the binary stops the deployment, while a dependency that wants a different call is
|
|
54
|
+
* something to read, not something to fail a pipeline over.
|
|
55
|
+
*
|
|
56
|
+
* @param {"ok"|"note"|"no"} level
|
|
57
|
+
* @param {string} what
|
|
58
|
+
* @param {string} [detail] what to do about it
|
|
59
|
+
* @returns {{level: "ok"|"note"|"no", what: string, detail: string|undefined}}
|
|
60
|
+
*/
|
|
61
|
+
function result(level, what, detail) {
|
|
62
|
+
return { level, what, detail };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether a version string is at least the other, compared piece by piece so "2.38" and "2.9"
|
|
67
|
+
* order the way versions do rather than the way strings do.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} version
|
|
70
|
+
* @param {string} minimum
|
|
71
|
+
* @returns {boolean}
|
|
72
|
+
*/
|
|
73
|
+
function atLeast(version, minimum) {
|
|
74
|
+
const left = version.split(".").map(Number);
|
|
75
|
+
const right = minimum.split(".").map(Number);
|
|
76
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
77
|
+
const a = left[i] ?? 0;
|
|
78
|
+
const b = right[i] ?? 0;
|
|
79
|
+
if (a !== b) {
|
|
80
|
+
return a > b;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The node this is running on, against what the package asks for.
|
|
88
|
+
*
|
|
89
|
+
* The version arrives as an argument rather than being read here, so the answer for a node this
|
|
90
|
+
* machine is not running is testable from the machine it is not running on. Every check below
|
|
91
|
+
* takes what it judges for the same reason.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} [running] defaults to the node running this
|
|
94
|
+
* @param {string} [required] defaults to what package.json asks for
|
|
95
|
+
* @returns {ReturnType<typeof result>}
|
|
96
|
+
*/
|
|
97
|
+
function checkNode(running = process.versions.node, required = require("../package.json").engines.node) {
|
|
98
|
+
const minimum = required.replace(/[^0-9.]/g, "");
|
|
99
|
+
if (atLeast(running, minimum)) {
|
|
100
|
+
return result("ok", `Node ${running}`);
|
|
101
|
+
}
|
|
102
|
+
return result("no", `Node ${running}`, `this package needs ${required}. Upgrade node, or pin an older fulmine.`);
|
|
103
|
+
}
|
|
104
|
+
|
|
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.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} [platform]
|
|
111
|
+
* @param {string|undefined} [glibc] the runtime glibc, absent on musl
|
|
112
|
+
* @returns {ReturnType<typeof result>|undefined} undefined where the question does not arise
|
|
113
|
+
*/
|
|
114
|
+
function checkLibc(
|
|
115
|
+
platform = process.platform,
|
|
116
|
+
glibc = /** @type {any} */ (process.report.getReport()).header.glibcVersionRuntime
|
|
117
|
+
) {
|
|
118
|
+
if (platform !== "linux") {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
if (!glibc) {
|
|
122
|
+
return result(
|
|
123
|
+
"no",
|
|
124
|
+
"musl libc, which the µWebSockets.js binaries are not built for",
|
|
125
|
+
"this is Alpine, or another musl distribution. Use a glibc image: node:22-trixie-slim, " +
|
|
126
|
+
"node:24-bookworm-slim\n or the plain node:22. There is no musl build to install."
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (!atLeast(glibc, MIN_GLIBC)) {
|
|
130
|
+
return result(
|
|
131
|
+
"no",
|
|
132
|
+
`glibc ${glibc}`,
|
|
133
|
+
`the binaries need ${MIN_GLIBC} or newer. A newer base image is the fix: node:22-trixie-slim.`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return result("ok", `glibc ${glibc}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether there is a µWebSockets.js binary for this platform, architecture and node ABI, which is
|
|
141
|
+
* the failure that greets everyone who tries an unusual combination. The file is named rather than
|
|
142
|
+
* loaded first, so the answer says which of the three does not line up.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} [platform]
|
|
145
|
+
* @param {string} [arch]
|
|
146
|
+
* @param {string} [abi]
|
|
147
|
+
* @param {string} [from] the directory holding the binaries, for a test that has no real one
|
|
148
|
+
* @returns {ReturnType<typeof result>}
|
|
149
|
+
*/
|
|
150
|
+
function checkBinary(platform = process.platform, arch = process.arch, abi = process.versions.modules, from) {
|
|
151
|
+
const name = `uws_${platform}_${arch}_${abi}.node`;
|
|
152
|
+
let dir = from;
|
|
153
|
+
if (dir === undefined) {
|
|
154
|
+
try {
|
|
155
|
+
dir = path.dirname(require.resolve("uWebSockets.js"));
|
|
156
|
+
} catch {
|
|
157
|
+
return result("no", "uWebSockets.js is not installed", "run npm install.");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (fs.existsSync(path.join(dir, name))) {
|
|
161
|
+
// named and present, so the only thing left is whether it loads
|
|
162
|
+
try {
|
|
163
|
+
require("uWebSockets.js");
|
|
164
|
+
return result("ok", `µWebSockets.js binary for ${platform} ${arch}, node ABI ${abi}`);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
return result(
|
|
167
|
+
"no",
|
|
168
|
+
`${name} is there and will not load`,
|
|
169
|
+
`${/** @type {Error} */ (err).message}\n On linux this is nearly always the C library, see the line above.`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const prefix = `uws_${platform}_${arch}_`;
|
|
174
|
+
const shipped = fs
|
|
175
|
+
.readdirSync(dir)
|
|
176
|
+
.filter((file) => file.startsWith(prefix) && file.endsWith(".node"))
|
|
177
|
+
.map((file) => file.slice(prefix.length, -".node".length));
|
|
178
|
+
if (shipped.length === 0) {
|
|
179
|
+
return result(
|
|
180
|
+
"no",
|
|
181
|
+
`no µWebSockets.js binary for ${platform} ${arch}`,
|
|
182
|
+
"this platform is not one the pinned build ships. Linux, macOS and Windows on x64 or arm64 are."
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return result(
|
|
186
|
+
"no",
|
|
187
|
+
`no µWebSockets.js binary for node ABI ${abi}`,
|
|
188
|
+
`this build ships ABI ${shipped.join(", ")}, which is node ${shipped.map(abiToNode).join(", ")}.\n` +
|
|
189
|
+
` Run one of those, or wait for a fulmine that pins a newer µWebSockets.js.`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The node release line an ABI number belongs to, for the versions this package can meet. An
|
|
195
|
+
* unknown one is reported as itself rather than guessed at.
|
|
196
|
+
*
|
|
197
|
+
* @param {string} abi
|
|
198
|
+
* @returns {string}
|
|
199
|
+
*/
|
|
200
|
+
function abiToNode(abi) {
|
|
201
|
+
const known = { 108: "18", 115: "20", 127: "22", 131: "23", 137: "24", 147: "26" };
|
|
202
|
+
return /** @type {any} */ (known)[abi] ?? `ABI ${abi}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The base images a Dockerfile names, which is where the musl question is usually answered without
|
|
207
|
+
* anybody meaning to.
|
|
208
|
+
*
|
|
209
|
+
* @param {string} dir the project being verified
|
|
210
|
+
* @returns {ReturnType<typeof result>[]}
|
|
211
|
+
*/
|
|
212
|
+
function checkDockerfiles(dir) {
|
|
213
|
+
/** @type {ReturnType<typeof result>[]} */
|
|
214
|
+
const results = [];
|
|
215
|
+
let names;
|
|
216
|
+
try {
|
|
217
|
+
names = fs.readdirSync(dir).filter((file) => file === "Dockerfile" || file.startsWith("Dockerfile."));
|
|
218
|
+
} catch {
|
|
219
|
+
return results;
|
|
220
|
+
}
|
|
221
|
+
for (const name of names) {
|
|
222
|
+
const source = fs.readFileSync(path.join(dir, name), "utf8");
|
|
223
|
+
for (const line of source.split("\n")) {
|
|
224
|
+
const match = /^\s*FROM\s+(\S+)/i.exec(line);
|
|
225
|
+
if (!match) {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const image = match[1];
|
|
229
|
+
const where = `${name}: ${image}`;
|
|
230
|
+
if (/alpine|musl/i.test(image)) {
|
|
231
|
+
results.push(
|
|
232
|
+
result("no", where, "musl, and there is no musl build: node:22-trixie-slim is the closest swap.")
|
|
233
|
+
);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const node = /^node:(\d+)/.exec(image);
|
|
237
|
+
if (node && Number(node[1]) < 22) {
|
|
238
|
+
results.push(result("no", where, `this package needs node 22 or newer: node:22-trixie-slim.`));
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
results.push(result("ok", where));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return results;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The dependencies that need a different API here. Read from package.json rather than from
|
|
249
|
+
* node_modules, so a project is answered before it installs anything.
|
|
250
|
+
*
|
|
251
|
+
* @param {string} dir
|
|
252
|
+
* @returns {ReturnType<typeof result>[]}
|
|
253
|
+
*/
|
|
254
|
+
function checkDependencies(dir) {
|
|
255
|
+
/** @type {ReturnType<typeof result>[]} */
|
|
256
|
+
const results = [];
|
|
257
|
+
let pkg;
|
|
258
|
+
try {
|
|
259
|
+
pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
260
|
+
} catch {
|
|
261
|
+
return results;
|
|
262
|
+
}
|
|
263
|
+
const installed = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
264
|
+
for (const name of Object.keys(NEEDS_A_LOOK)) {
|
|
265
|
+
if (installed[name]) {
|
|
266
|
+
results.push(result("note", `${name} needs a different API here`, /** @type {any} */ (NEEDS_A_LOOK)[name]));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return results;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Runs every check and prints the report. Anything that would stop the application from starting
|
|
274
|
+
* is a failure and the command exits non-zero, so it can be a step in a pipeline.
|
|
275
|
+
*
|
|
276
|
+
* @param {string[]} argv
|
|
277
|
+
* @returns {number} exit code
|
|
278
|
+
*/
|
|
279
|
+
function verify(argv) {
|
|
280
|
+
const dir = path.resolve(argv.find((arg) => !arg.startsWith("--")) ?? ".");
|
|
281
|
+
/** @type {ReturnType<typeof result>[]} */
|
|
282
|
+
const results = [checkNode()];
|
|
283
|
+
const libc = checkLibc();
|
|
284
|
+
if (libc) {
|
|
285
|
+
results.push(libc);
|
|
286
|
+
}
|
|
287
|
+
results.push(checkBinary(), ...checkDockerfiles(dir), ...checkDependencies(dir));
|
|
288
|
+
|
|
289
|
+
console.log(`\nWhether this machine and this project can run fulmine.js\n`);
|
|
290
|
+
const label = { ok: "ok ", note: "note", no: "NO " };
|
|
291
|
+
for (const { level, what, detail } of results) {
|
|
292
|
+
console.log(` ${label[level]} ${what}`);
|
|
293
|
+
if (level !== "ok" && detail) {
|
|
294
|
+
console.log(` ${detail}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// only a blocked start is a failure. A dependency that wants a different call is worth
|
|
298
|
+
// reading and is not worth failing a pipeline over
|
|
299
|
+
const blocking = results.filter((entry) => entry.level === "no").length;
|
|
300
|
+
const notes = results.filter((entry) => entry.level === "note").length;
|
|
301
|
+
console.log(
|
|
302
|
+
blocking === 0
|
|
303
|
+
? `\nNothing in the way${notes ? `, ${notes} thing(s) worth reading` : ""}.\n`
|
|
304
|
+
: `\n${blocking} thing(s) stop this from running.\n`
|
|
305
|
+
);
|
|
306
|
+
return blocking === 0 ? 0 : 1;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
module.exports = { verify, checkNode, checkLibc, checkBinary, checkDockerfiles, checkDependencies };
|