fulmine.js 5.6.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 +67 -2
- package/package.json +2 -2
- package/src/application.js +5 -0
- package/src/cli.js +159 -41
- package/src/compression.js +62 -17
- package/src/index.js +8 -0
- package/src/middlewares.js +101 -12
- package/src/options.d.ts +5 -1
- 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/utils.js +33 -5
- package/src/verify.js +309 -0
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/utils.js
CHANGED
|
@@ -220,9 +220,13 @@ function patternToRegex(pattern, isPrefix = false, caseSensitive = true, strict
|
|
|
220
220
|
groupOutputName.set(group, name);
|
|
221
221
|
return group;
|
|
222
222
|
};
|
|
223
|
-
// whether the token just emitted was a :parameter, which decides how greedy the
|
|
224
|
-
// optional group is allowed to be. see the comment where it is read
|
|
223
|
+
// whether the token just emitted was a :parameter or a wildcard, which decides how greedy the
|
|
224
|
+
// next optional group is allowed to be. see the comment where it is read
|
|
225
225
|
let lastTokenWasParam = false;
|
|
226
|
+
// the wildcard just emitted, and where it ends, so an optional group written right after it
|
|
227
|
+
// can rewrite the two into one alternation. See the { branch
|
|
228
|
+
let lastWildcard = /** @type {{start: number, body: string, name: string}|null} */ (null);
|
|
229
|
+
let lastWildcardEnd = -1;
|
|
226
230
|
// What path-to-regexp calls the wildcard backtrack: the literal text written since the last
|
|
227
231
|
// wildcard. Once a wildcard has eaten slashes, a later one in the same path is held to a single
|
|
228
232
|
// segment, or the two would divide the path between them in more than one way and the regex
|
|
@@ -339,8 +343,16 @@ function patternToRegex(pattern, isPrefix = false, caseSensitive = true, strict
|
|
|
339
343
|
}
|
|
340
344
|
const splatGroup = uniqueGroupName(name);
|
|
341
345
|
wildcardNames.push(splatGroup);
|
|
342
|
-
|
|
343
|
-
|
|
346
|
+
const body = wildcardClass();
|
|
347
|
+
// where this capture starts and what it is made of, so an optional group written right
|
|
348
|
+
// after it can rewrite the pair into the alternation path-to-regexp compiles. See the
|
|
349
|
+
// { branch below
|
|
350
|
+
lastWildcard = { start: regexPattern.length, body, name };
|
|
351
|
+
regexPattern += `(?<${splatGroup}>${body})`;
|
|
352
|
+
lastWildcardEnd = regexPattern.length;
|
|
353
|
+
// the group that follows is held to one segment of its own, the way it is after a
|
|
354
|
+
// parameter: without it ext could take the separator back and swallow the dots
|
|
355
|
+
lastTokenWasParam = true;
|
|
344
356
|
continue;
|
|
345
357
|
}
|
|
346
358
|
|
|
@@ -417,7 +429,23 @@ function patternToRegex(pattern, isPrefix = false, caseSensitive = true, strict
|
|
|
417
429
|
gi++;
|
|
418
430
|
}
|
|
419
431
|
}
|
|
420
|
-
regexPattern
|
|
432
|
+
if (lastWildcard && lastWildcardEnd === regexPattern.length) {
|
|
433
|
+
// A wildcard immediately before the group. `(?<w>[^]+)(?:group)?` can never let
|
|
434
|
+
// the group match, because the wildcard is greedy and the group may be empty, and
|
|
435
|
+
// making the wildcard lazy is not the same thing either: it gives the trailing
|
|
436
|
+
// slash away, and /*path{.:ext} against /a/b/ then loses the empty last segment.
|
|
437
|
+
// path-to-regexp writes the two branches out instead, group first and the
|
|
438
|
+
// wildcard greedy in both, so that is what goes here. The second branch captures
|
|
439
|
+
// the same parameter under a name of its own, which is what uniqueGroupName is for.
|
|
440
|
+
const second = uniqueGroupName(lastWildcard.name);
|
|
441
|
+
wildcardNames.push(second);
|
|
442
|
+
const withWildcard = regexPattern.slice(lastWildcard.start);
|
|
443
|
+
regexPattern =
|
|
444
|
+
regexPattern.slice(0, lastWildcard.start) +
|
|
445
|
+
`(?:${withWildcard}${groupRegex}|(?<${second}>${lastWildcard.body}))`;
|
|
446
|
+
} else {
|
|
447
|
+
regexPattern += `(?:${groupRegex})?`;
|
|
448
|
+
}
|
|
421
449
|
literal(groupContent);
|
|
422
450
|
lastTokenWasParam = false;
|
|
423
451
|
continue;
|
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 };
|