fulmine.js 5.1.7 → 5.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,6 +25,7 @@ See [Migrating](#migrating) for what it handles and what it deliberately does no
25
25
 
26
26
  [![npm version](https://img.shields.io/npm/v/fulmine.js)](https://www.npmjs.com/package/fulmine.js)
27
27
  [![Node.js >= 22.0.0](https://img.shields.io/badge/Node.js-%3E=22.0.0-green)](https://nodejs.org)
28
+ [![Coverage Status](https://coveralls.io/repos/github/nigrosimone/fulmine.js/badge.svg?branch=main)](https://coveralls.io/github/nigrosimone/fulmine.js?branch=main)
28
29
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)
29
30
 
30
31
  ## Why this exists
@@ -55,7 +56,7 @@ to run it yourself.
55
56
 
56
57
  Numbers produced by a project about itself deserve suspicion, so Fulmine also stands in public arenas, run by their own rigs under their own rules:
57
58
 
58
- - **[HttpArena](https://www.http-arena.com/#sort=rps:-1&q=Js)** (the link lands filtered on the JavaScript entries): first among the JavaScript entries and second overall across every language on the board. The saved run measures 7.64 million pipelined requests per second, 1.12 million on the json profile, 457 thousand on compressed json and 222 thousand on the Postgres profile.
59
+ - **[HttpArena](https://www.http-arena.com/#sort=rps:-1&q=Js)** (the link lands filtered on the JavaScript entries): first among the JavaScript entries, across fifteen subscribed profiles. The saved runs measure 24.3 million WebSocket echoes per second pipelined and 3.77 million one-at-a-time (past Bun's own dedicated WebSocket entry), 7.3 million pipelined HTTP requests per second, 1.12 million on the json profile with 1.04 million of that surviving TLS, 457 thousand on compressed json, and 359 thousand on the Postgres CRUD profile, within ten percent of the leading Rust and C# entries there.
59
60
  - **[web-frameworks](https://github.com/the-benchmarker/web-frameworks)**: entry merged, numbers arrive with their next published round.
60
61
 
61
62
  More to come as their maintainers take the entries in.
@@ -226,9 +227,10 @@ which runs the same file against Express and against Fulmine and compares the ou
226
227
 
227
228
  ## HTTP/3
228
229
 
229
- HTTP/3 is supported. To use:
230
+ There is an `http3: true` option, inherited from Ultimate Express, that asks µWebSockets.js for its experimental HTTP/3 app. **It is guarded off with the currently pinned µWS build**: asking for it throws a clear error, because the underlying `H3App` segfaults during construction on Linux, verified with µWS alone before a single request is served. On Windows the listener does come up, but nothing answers over QUIC that we could verify, and shipping an option that works on no deployable platform helps nobody. A skipped canary test probes `H3App` on every CI run and will turn red the day µWS ships working QUIC in its prebuilt binaries, which is when the guard goes and this section changes.
230
231
 
231
232
  ```js
233
+ // what it would look like, once µWS's H3 support actually works
232
234
  const app = express({
233
235
  http3: true,
234
236
  uwsOptions: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.1.7",
3
+ "version": "5.1.9",
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": {
@@ -13,7 +13,7 @@
13
13
  "test:types": "tsd --files tests/types/*.test-d.ts",
14
14
  "test:express": "node tools/express-suite.js",
15
15
  "benchmark:compare": "node benchmark/run.js",
16
- "cover": "npm run cover:unit && npm run cover:report",
16
+ "cover": "npm run cover:full && npm run cover:report",
17
17
  "cover:unit": "nyc --silent npm run test",
18
18
  "cover:report": "nyc report --reporter=html",
19
19
  "lint": "eslint .",
@@ -25,7 +25,9 @@
25
25
  "typecheck": "tsc -p tsconfig.typecheck.json",
26
26
  "benchmark:ab": "node benchmark/ab.js",
27
27
  "benchmark:profile": "node benchmark/profile.js",
28
- "release:local": "node tools/release-local.js"
28
+ "release:local": "node tools/release-local.js",
29
+ "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",
30
+ "cover:check": "nyc check-coverage --statements 93 --branches 88 --functions 92 --lines 93"
29
31
  },
30
32
  "engines": {
31
33
  "node": ">=22"
@@ -15,10 +15,7 @@ See the License for the specific language governing permissions and
15
15
  limitations under the License.
16
16
  */
17
17
 
18
- // H3App, DeclarativeResponse and _cfg all exist at runtime but are missing from the
19
- // declaration file the package ships, so the module is read through a loose alias
20
18
  const uWS = require("uWebSockets.js");
21
- const uWSAny = /** @type {any} */ (uWS);
22
19
  const Router = require("./router.js");
23
20
  const {
24
21
  removeDuplicateSlashes,
@@ -102,10 +99,14 @@ class Application extends Router {
102
99
  if (settings.uwsApp) {
103
100
  this.uwsApp = settings.uwsApp;
104
101
  } else if (settings.http3) {
105
- if (!settings.uwsOptions.key_file_name || !settings.uwsOptions.cert_file_name) {
106
- throw new Error("uwsOptions.key_file_name and uwsOptions.cert_file_name are required for HTTP/3");
107
- }
108
- this.uwsApp = uWSAny.H3App(settings.uwsOptions);
102
+ // uWS.H3App exists in the pinned build but its QUIC stack does not: the constructor
103
+ // segfaults on Linux and hangs forever on Windows before serving a single request,
104
+ // verified 2026-08-05 with uWS alone. A clear throw beats a native crash; this
105
+ // branch goes back to H3App once uNetworking ships working QUIC in the prebuilts.
106
+ throw new Error(
107
+ "http3 is not usable with the pinned uWebSockets.js build: its H3App crashes " +
108
+ "during construction. Track uNetworking/uWebSockets.js for working QUIC support."
109
+ );
109
110
  } else if (settings.uwsOptions.key_file_name && settings.uwsOptions.cert_file_name) {
110
111
  this.uwsApp = uWS.SSLApp(settings.uwsOptions);
111
112
  } else {
@@ -365,6 +366,7 @@ class Application extends Router {
365
366
  if (value !== false && this._skipPresets?.size) {
366
367
  for (const preset of this._skipPresets) {
367
368
  preset.skipHeaders = false;
369
+ preset.skipQuery = false;
368
370
  }
369
371
  this._skipPresets.clear();
370
372
  }
@@ -606,6 +606,13 @@ module.exports = function compileDeclarative(cb, app) {
606
606
  }
607
607
  }
608
608
 
609
+ // a handler that never sends is not a response: Express leaves the request waiting, so
610
+ // compiling the empty shape would answer a bare 200 where the ordinary path answers
611
+ // nothing at all. It has to fall back instead.
612
+ if (!sendUsed && !sendStatusUsed) {
613
+ return false;
614
+ }
615
+
609
616
  let decRes = new uWSAny.DeclarativeResponse();
610
617
 
611
618
  if (statusCode !== 200) {
package/src/request.js CHANGED
@@ -263,9 +263,16 @@ module.exports = class Request extends Readable {
263
263
  this.app = app;
264
264
  // both forms are kept, because both are asked for: the query with its "?" goes into
265
265
  // req.url, and req.query parses the raw one. Keeping only the first meant slicing the "?"
266
- // back off for every request that reads req.query.
267
- this._rawQuery = req.getQuery() ?? "";
268
- this.urlQuery = this._rawQuery === "" ? "" : "?" + this._rawQuery;
266
+ // back off for every request that reads req.query. When the chain provably reads
267
+ // neither, the native call is not made at all: the framework's own answers, the 404
268
+ // included, are written from the path alone
269
+ if (skipHolder !== undefined && skipHolder.skipQuery) {
270
+ this._rawQuery = "";
271
+ this.urlQuery = "";
272
+ } else {
273
+ this._rawQuery = req.getQuery() ?? "";
274
+ this.urlQuery = this._rawQuery === "" ? "" : "?" + this._rawQuery;
275
+ }
269
276
  if (preset) {
270
277
  // the registration's constants: two native crossings and their strings not asked for
271
278
  this.path = preset.path;
package/src/router.js CHANGED
@@ -36,7 +36,7 @@ const compileDeclarative = require("./declarative.js");
36
36
  const statuses = require("statuses");
37
37
  const { METHODS } = require("http");
38
38
  const { isNodeRequest, serveNodeRequest } = require("./node-shim.js");
39
- const { chainSkipsHeaders } = require("./usage.js");
39
+ const { chainUsage } = require("./usage.js");
40
40
 
41
41
  // every method the declarative compiler can emit: a patched one must disable compilation, or the
42
42
  // patch would be honoured everywhere but on compiled routes
@@ -693,9 +693,10 @@ function nativePreset(path, method, strict) {
693
693
  opPath: endsWithSlash && path !== "/" && !strict ? path.slice(0, -1) : path,
694
694
  isOptions: method === "OPTIONS",
695
695
  isHead: method === "HEAD",
696
- // set at registration when the whole chain provably never reads a header; mutable,
697
- // because a middleware added after listen has to be able to take it back
698
- skipHeaders: false
696
+ // set at registration when the whole chain provably never reads a header, or never
697
+ // reads the query; mutable, because a middleware added after listen takes them back
698
+ skipHeaders: false,
699
+ skipQuery: false
699
700
  };
700
701
  }
701
702
 
@@ -1148,6 +1149,7 @@ module.exports = class Router extends EventEmitter {
1148
1149
  if (this._skipPresets?.size) {
1149
1150
  for (const preset of this._skipPresets) {
1150
1151
  preset.skipHeaders = false;
1152
+ preset.skipQuery = false;
1151
1153
  }
1152
1154
  this._skipPresets.clear();
1153
1155
  }
@@ -1469,8 +1471,8 @@ module.exports = class Router extends EventEmitter {
1469
1471
  // listen can take it back: a literal registration's preset doubles as it, and a
1470
1472
  // parameterised one, which has no preset, gets a holder of its own
1471
1473
  let skipHolder = preset;
1472
- if (skipHolder === undefined && skips) {
1473
- skipHolder = { skipHeaders: true };
1474
+ if (skipHolder === undefined && (skips.skipHeaders || skips.skipQuery)) {
1475
+ skipHolder = { skipHeaders: skips.skipHeaders, skipQuery: skips.skipQuery };
1474
1476
  (this._skipPresets ??= new Set()).add(skipHolder);
1475
1477
  }
1476
1478
  // all three are registration-time constants: computing them in the handler was a
@@ -1532,8 +1534,9 @@ module.exports = class Router extends EventEmitter {
1532
1534
  // headers), no error middleware may exist anywhere (a throw hands the request to code
1533
1535
  // the analysis never saw), and every callback in the chain has to pass the source
1534
1536
  // analysis in usage.js, whose default answer is no.
1535
- let getSkips = false;
1536
- let headSkips = false;
1537
+ const NO_SKIPS = { skipHeaders: false, skipQuery: false };
1538
+ let getSkips = NO_SKIPS;
1539
+ let headSkips = NO_SKIPS;
1537
1540
  if (route.method === "GET" && this.get("etag") === false) {
1538
1541
  let hasErr = this._hasErrMwCache;
1539
1542
  if (hasErr === undefined) {
@@ -1544,15 +1547,16 @@ module.exports = class Router extends EventEmitter {
1544
1547
  // route may be able to catch the same path
1545
1548
  const owner = route.owner ?? this;
1546
1549
  const noLaterMatch = !owner._isFollowedByAnOverlap.call(owner, route, owner._routes);
1547
- getSkips = chainSkipsHeaders(getChain, noLaterMatch);
1548
- headSkips = headChain === getChain ? getSkips : chainSkipsHeaders(headChain, noLaterMatch);
1550
+ getSkips = chainUsage(getChain, noLaterMatch);
1551
+ headSkips = headChain === getChain ? getSkips : chainUsage(headChain, noLaterMatch);
1549
1552
  }
1550
1553
  }
1551
1554
  // remembered so a middleware or setting arriving after listen can take the skips back
1552
1555
  const makePreset = (path, method, skips) => {
1553
1556
  const preset = nativePreset(path, method, strictHere);
1554
- if (skips) {
1555
- preset.skipHeaders = true;
1557
+ if (skips.skipHeaders || skips.skipQuery) {
1558
+ preset.skipHeaders = skips.skipHeaders;
1559
+ preset.skipQuery = skips.skipQuery;
1556
1560
  (this._skipPresets ??= new Set()).add(preset);
1557
1561
  }
1558
1562
  return preset;
package/src/usage.js CHANGED
@@ -13,6 +13,13 @@ const kGetSafe = Symbol("fulmine.getSafe");
13
13
  // the parameter, so anything that can reach another object could reach headers through it.
14
14
  const REQ_OK = new Set(["query", "params", "body", "method", "path", "url", "baseUrl", "originalUrl", "route"]);
15
15
 
16
+ // Reading any of these needs the query string fetched: req.url and req.originalUrl carry it
17
+ const REQ_QUERY = new Set(["query", "url", "originalUrl"]);
18
+
19
+ // Writing any of these re-enters routing: dispatch treats a changed req.url as a rewrite and
20
+ // walks routes nobody analyzed, so an assignment is as disqualifying as an unknown call
21
+ const REQ_NO_WRITE = new Set(["url", "originalUrl", "path", "baseUrl", "method"]);
22
+
16
23
  // What a handler may do with `res`: writing the response. Anything that negotiates against
17
24
  // request headers (format, redirect, sendFile, jsonp) is deliberately absent.
18
25
  const RES_OK = new Set([
@@ -38,25 +45,26 @@ const RES_OK = new Set([
38
45
  "cork"
39
46
  ]);
40
47
 
41
- // what the analysis can say about one callback
42
- const NO = 0; // could read headers, or could not be read at all
43
- const SAFE = 1; // never reads a header, never touches next
44
- const SAFE_NEXT = 2; // never reads a header, calls next: fine mid-chain, and at the end of
45
- // the chain only when no later route could catch the fall-through
48
+ // what the analysis can say about one callback, as independent facts
49
+ const UNKNOWN = 1; // a shape the walk cannot vouch for: could do anything
50
+ const NEXT_PLAIN = 2; // calls next() bare: advances the chain, may fall off its end
51
+ const NEXT_ERROR = 4; // calls next(err): lands in the framework's own error answer
52
+ const QUERY = 8; // reads req.query, req.url or req.originalUrl
46
53
 
47
54
  const verdicts = new WeakMap();
48
55
 
49
56
  /**
50
- * What one callback provably does. The default is NO: any shape this walk does not understand
51
- * and any alias of req, res or next keeps the header copy. That inversion is what makes
52
- * source analysis sound to act on.
57
+ * What one callback provably does, as a mask of the facts above. The default is UNKNOWN: any
58
+ * shape this walk does not understand and any alias of req, res or next could do anything.
59
+ * That inversion is what makes source analysis sound to act on.
53
60
  *
54
61
  * @param {Function} fn
55
- * @returns {number} NO, SAFE or SAFE_NEXT
62
+ * @returns {number}
56
63
  */
57
- function callbackSkipsHeaders(fn) {
64
+ function callbackUsage(fn) {
58
65
  if (fn[kGetSafe]) {
59
- return SAFE_NEXT;
66
+ // the body parsers: they advance the chain and read nothing a skip would miss
67
+ return NEXT_PLAIN;
60
68
  }
61
69
  let verdict = verdicts.get(fn);
62
70
  if (verdict === undefined) {
@@ -68,7 +76,13 @@ function callbackSkipsHeaders(fn) {
68
76
 
69
77
  /** @param {Function} fn @returns {number} */
70
78
  function analyze(fn) {
71
- let code = fn.toString();
79
+ // toString is application-controlled and may throw or answer anything: unreadable is unknown
80
+ let code;
81
+ try {
82
+ code = String(fn.toString());
83
+ } catch {
84
+ return UNKNOWN;
85
+ }
72
86
  if (code.startsWith("function") || code.startsWith("async function")) {
73
87
  code = code.replace(/function *\(/, "function __cb(");
74
88
  }
@@ -77,11 +91,11 @@ function analyze(fn) {
77
91
  tree = acorn.parse(code, { ecmaVersion: "latest" });
78
92
  } catch {
79
93
  // class methods and native functions do not parse alone, and unread code is unknown code
80
- return NO;
94
+ return UNKNOWN;
81
95
  }
82
96
  let root = /** @type {any} */ (tree.body[0]);
83
97
  if (!root) {
84
- return NO;
98
+ return UNKNOWN;
85
99
  }
86
100
  if (root.type === "ExpressionStatement") {
87
101
  root = root.expression;
@@ -91,14 +105,14 @@ function analyze(fn) {
91
105
  root.type !== "ArrowFunctionExpression" &&
92
106
  root.type !== "FunctionExpression"
93
107
  ) {
94
- return NO;
108
+ return UNKNOWN;
95
109
  }
96
110
 
97
111
  const params = /** @type {any[]} */ (root.params);
98
112
  // rest or destructured parameters alias the objects somewhere the walk cannot follow
99
113
  for (const p of params) {
100
114
  if (p.type !== "Identifier") {
101
- return NO;
115
+ return UNKNOWN;
102
116
  }
103
117
  }
104
118
  const reqName = params[0] ? params[0].name : null;
@@ -108,15 +122,38 @@ function analyze(fn) {
108
122
  // Every appearance of the three names in the whole body is judged, nested functions
109
123
  // included: an inner binding that shadows one of them only makes this stricter, never
110
124
  // looser, so scope tracking is not needed for soundness.
111
- let ok = true;
112
- let usesNext = false;
125
+ let mask = 0;
113
126
  walk(root.body, null, (node, parent) => {
114
- if (!ok || node.type !== "Identifier") {
127
+ if (mask & UNKNOWN) {
128
+ return;
129
+ }
130
+ if (node.type === "MemberExpression" && !node.computed && node.object.type === "Identifier") {
131
+ const owner = node.object.name;
132
+ if (owner !== reqName) {
133
+ return;
134
+ }
135
+ const member = node.property.name;
136
+ if (REQ_QUERY.has(member)) {
137
+ mask |= QUERY;
138
+ }
139
+ // an assignment, an update or a delete on the routing members re-enters dispatch
140
+ if (
141
+ REQ_NO_WRITE.has(member) &&
142
+ parent &&
143
+ ((parent.type === "AssignmentExpression" && parent.left === node) ||
144
+ (parent.type === "UpdateExpression" && parent.argument === node) ||
145
+ (parent.type === "UnaryExpression" && parent.operator === "delete" && parent.argument === node))
146
+ ) {
147
+ mask |= UNKNOWN;
148
+ }
149
+ return;
150
+ }
151
+ if (node.type !== "Identifier") {
115
152
  return;
116
153
  }
117
154
  const name = node.name;
118
155
  if (name === "eval" || name === "arguments") {
119
- ok = false;
156
+ mask |= UNKNOWN;
120
157
  return;
121
158
  }
122
159
  if (name !== reqName && name !== resName && name !== nextName) {
@@ -140,17 +177,17 @@ function analyze(fn) {
140
177
  }
141
178
  if (name === nextName) {
142
179
  // calling next is how a chain advances, and past its end or with an error the
143
- // request lands in the framework's own final handler, which the constructor's
180
+ // request lands in the framework's own final answer, which the constructor's
144
181
  // accept pre-read covers. Anything but a direct call aliases the continuation,
145
182
  // and an argument that could be the string "route" would leave the chain for
146
183
  // routes nobody analyzed, so only shapes that cannot be a string pass.
147
184
  if (!parent || parent.type !== "CallExpression" || parent.callee !== node) {
148
- ok = false;
185
+ mask |= UNKNOWN;
149
186
  return;
150
187
  }
151
- usesNext = true;
152
188
  const args = parent.arguments;
153
189
  if (args.length === 0) {
190
+ mask |= NEXT_PLAIN;
154
191
  return;
155
192
  }
156
193
  const arg = args[0];
@@ -160,20 +197,22 @@ function analyze(fn) {
160
197
  arg.type !== "ObjectExpression" &&
161
198
  !(arg.type === "Literal" && typeof arg.value !== "string"))
162
199
  ) {
163
- ok = false;
200
+ mask |= UNKNOWN;
201
+ return;
164
202
  }
203
+ mask |= NEXT_ERROR;
165
204
  return;
166
205
  }
167
206
  if (!parent || parent.type !== "MemberExpression" || parent.object !== node || parent.computed) {
168
- ok = false;
207
+ mask |= UNKNOWN;
169
208
  return;
170
209
  }
171
210
  const member = parent.property.name;
172
211
  if (name === reqName ? !REQ_OK.has(member) : !RES_OK.has(member)) {
173
- ok = false;
212
+ mask |= UNKNOWN;
174
213
  }
175
214
  });
176
- return ok ? (usesNext ? SAFE_NEXT : SAFE) : NO;
215
+ return mask;
177
216
  }
178
217
 
179
218
  /**
@@ -207,41 +246,47 @@ function walk(node, parent, visit) {
207
246
  }
208
247
 
209
248
  /**
210
- * Whether a native literal route's whole chain provably never reads a request header, so the
211
- * request constructor may skip copying them and read the few framing headers directly.
249
+ * What a native route's whole chain provably never does, so the request constructor may leave
250
+ * that work undone: skipHeaders spares the header copy, skipQuery the query fetch.
212
251
  *
213
- * A callback that calls next passes anywhere but in the terminal route, where next would fall
214
- * out of the chain: there it only passes when the caller established that no later route
215
- * could catch the fall-through.
252
+ * A callback that calls next() bare passes anywhere but in the terminal route, where it would
253
+ * fall out of the chain: there it only passes when the caller established that no later route
254
+ * could catch the fall-through. The framework's own 404 answers with the path alone, so the
255
+ * fall-through itself needs neither headers nor query.
216
256
  *
217
257
  * @param {any[]} chain the routes the native handler runs, in order, this route last
218
258
  * @param {boolean} allowTerminalNext whether a fall-through past the chain lands only in the
219
- * framework's own final handler
220
- * @returns {boolean}
259
+ * framework's own final answer
260
+ * @returns {{skipHeaders: boolean, skipQuery: boolean}}
221
261
  */
222
- function chainSkipsHeaders(chain, allowTerminalNext) {
262
+ function chainUsage(chain, allowTerminalNext) {
263
+ const none = { skipHeaders: false, skipQuery: false };
264
+ let query = false;
223
265
  for (let i = 0; i < chain.length; i++) {
224
266
  const entry = chain[i];
225
267
  const callbacks = entry.callbacks;
226
268
  if (!Array.isArray(callbacks)) {
227
- return false;
269
+ return none;
228
270
  }
229
271
  const terminal = i === chain.length - 1;
230
272
  for (const cb of callbacks) {
231
273
  if (typeof cb !== "function") {
232
- return false;
274
+ return none;
275
+ }
276
+ const mask = callbackUsage(cb);
277
+ if (mask & UNKNOWN || (mask & NEXT_PLAIN && terminal && !allowTerminalNext)) {
278
+ return none;
233
279
  }
234
- const verdict = callbackSkipsHeaders(cb);
235
- if (verdict === NO || (verdict === SAFE_NEXT && terminal && !allowTerminalNext)) {
236
- return false;
280
+ if (mask & QUERY) {
281
+ query = true;
237
282
  }
238
283
  }
239
284
  // a param callback runs code this walk never saw
240
285
  if (entry.paramCallbacks && entry.paramCallbacks.size > 0) {
241
- return false;
286
+ return none;
242
287
  }
243
288
  }
244
- return true;
289
+ return { skipHeaders: true, skipQuery: !query };
245
290
  }
246
291
 
247
- module.exports = { chainSkipsHeaders, callbackSkipsHeaders, kGetSafe };
292
+ module.exports = { chainUsage, callbackUsage, kGetSafe, UNKNOWN, NEXT_PLAIN, NEXT_ERROR, QUERY };