fulmine.js 5.1.1 → 5.1.2

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
@@ -37,7 +37,7 @@ Compatibility here is not a claim, it is a test suite. Every test runs against r
37
37
 
38
38
  Fulmine is faster than Express where the framework itself is doing the work, and the same speed where it is not. Both halves of that sentence matter, so here is the honest version.
39
39
 
40
- **Where it is clearly faster.** Routing and dispatch, request shapes with params and query strings, connection handling. Plain routing lands between 1.9x and 3.9x: hello-world 1.9x to 2.7x, an API endpoint with params and a query 3x to 3.8x, five route shapes served by one process 2.7x to 3.9x, nested routers 2.2x to 2.8x, a urlencoded body 3.2x to 3.8x, a thousand concurrent connections 2.7x to 3x. Route tables are where the native router shows: a thousand routes 9.8x to 12.7x, with a parameter in every one of them 9.9x to 14.3x, a parameterised route in a mounted router 7.4x to 8.8x. Those routes go to µWS's own router instead of being scanned, so the gap grows with the table instead of shrinking. Even the chain of 100 middlewares, for a long time the one routing row that stayed even because its cost is calling application code a hundred times, sits at 1.35x to 1.55x after the per-request allocation work of August 2026.
40
+ **Where it is clearly faster.** Routing and dispatch, request shapes with params and query strings, connection handling. Plain routing lands between 1.9x and 4.3x: hello-world 1.9x to 2.2x, an API endpoint with params and a query 3.2x to 4.3x, five route shapes served by one process 2.5x to 3.3x, nested routers 2.1x to 3.1x, a urlencoded body 3.4x to 4.1x, a thousand concurrent connections 2.7x to 3.2x. Route tables are where the native router shows: a thousand routes 9.7x to 12.9x, with a parameter in every one of them 10.4x to 14x, a parameterised route in a mounted router 7.1x to 8.3x. Those routes go to µWS's own router instead of being scanned, so the gap grows with the table instead of shrinking. Even the chain of 100 middlewares, for a long time the one routing row that stayed even because its cost is calling application code a hundred times, sits at 1.5x to 1.7x after the per-request work of August 2026.
41
41
 
42
42
  **Where it is a wash.** Any request whose cost is dominated by work both servers hand to the same library. A 512 KiB JSON body is `JSON.parse`, a gzipped response is zlib, a hashed upload is OpenSSL, a 5 MiB stream is memory bandwidth. On those the ratio is capped by arithmetic somewhere around 1.0x to 1.2x, and no amount of work on either server moves it. The benchmark labels those rows rather than quietly publishing them as if the two were equivalent.
43
43
 
@@ -51,6 +51,15 @@ and posts the result where it belongs: as a comment on the commit or the pull re
51
51
  `benchmark-summary` artifact on the run, see [`benchmark/README.md`](./benchmark/README.md)
52
52
  to run it yourself.
53
53
 
54
+ ## Public benchmarks
55
+
56
+ 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
+ - **[HttpArena](https://www.http-arena.com/#sort=rps:-1&q=Js)** (the link lands filtered on the JavaScript entries): the saved run measures 5.89 million pipelined requests per second, 1.12 million on baseline and 1.04 million on the json profile, ahead of every JavaScript entry on the board.
59
+ - **[web-frameworks](https://github.com/the-benchmarker/web-frameworks)**: entry merged, numbers arrive with their next published round.
60
+
61
+ More to come as their maintainers take the entries in.
62
+
54
63
  ## Attribution
55
64
 
56
65
  Fulmine is a derivative work of [Ultimate Express](https://github.com/dimdenGD/ultimate-express) by [@dimdenGD](https://github.com/dimdenGD), used under the Apache License 2.0. The full commit history is preserved, so the original authorship is visible in the repository itself.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.1.1",
3
+ "version": "5.1.2",
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": {
@@ -120,9 +120,10 @@ class Application extends Router {
120
120
  * @param {any} req
121
121
  * @param {any} res
122
122
  * @param {any} app
123
+ * @param {any} [preset]
123
124
  */
124
- constructor(req, res, app) {
125
- super(req, res, app);
125
+ constructor(req, res, app, preset) {
126
+ super(req, res, app, preset);
126
127
  }
127
128
  };
128
129
  this._response = class extends Response {
@@ -365,10 +366,11 @@ class Application extends Router {
365
366
  *
366
367
  * @param {any} res uWS response
367
368
  * @param {any} req uWS request, readable only during this call
369
+ * @param {any} [preset] a literal registration's constants, see nativePreset in the router
368
370
  * @returns {any} the request, with the response reachable as request.res
369
371
  */
370
- handleRequest(res, req) {
371
- const request = super.handleRequest(res, req);
372
+ handleRequest(res, req, preset) {
373
+ const request = super.handleRequest(res, req, preset);
372
374
  // removal rides the close listener the Response constructor already has, since a second
373
375
  // once() per request measured a tenth of a microsecond on the hot path.
374
376
  // An aborted response only flips its flags without emitting 'close', which is why
package/src/request.js CHANGED
@@ -149,6 +149,9 @@ module.exports = class Request extends Readable {
149
149
 
150
150
  #paused = false;
151
151
 
152
+ // a bodyless request whose empty end has not been delivered yet, see the constructor
153
+ #emptyBody = false;
154
+
152
155
  body;
153
156
 
154
157
  res;
@@ -169,7 +172,9 @@ module.exports = class Request extends Readable {
169
172
  ) {
170
173
  r._connectionClose = true;
171
174
  } else if (
172
- (headerKey.length === 14 && headerKey === "content-length") ||
175
+ // content-length: 0 declares that there is nothing, which is the same as declaring
176
+ // nothing: the stream ends empty either way, without the onData subscription
177
+ (headerKey.length === 14 && headerKey === "content-length" && value !== "0") ||
173
178
  (headerKey.length === 17 && headerKey === "transfer-encoding")
174
179
  ) {
175
180
  // noticed here so the body decision in the constructor does not build the headers object
@@ -191,8 +196,11 @@ module.exports = class Request extends Readable {
191
196
  * @param {any} req the uWS request, readable only during this call
192
197
  * @param {any} res the uWS response
193
198
  * @param {any} app the application or router this request arrived at
199
+ * @param {any} [preset] a literal native registration's constants: µWS matched the URL byte
200
+ * for byte against that exact pattern and dispatched by method, so path, method and what
201
+ * derives from them are known without asking
194
202
  */
195
- constructor(req, res, app) {
203
+ constructor(req, res, app, preset) {
196
204
  // the same object every time: Readable reads these options and never writes to them
197
205
  super(READABLE_OPTIONS);
198
206
  this._res = res;
@@ -212,25 +220,39 @@ module.exports = class Request extends Readable {
212
220
  // back off for every request that reads req.query.
213
221
  this._rawQuery = req.getQuery() ?? "";
214
222
  this.urlQuery = this._rawQuery === "" ? "" : "?" + this._rawQuery;
215
- // getUrl() is the path already, so the query is joined on and then not split off again.
216
- // Building originalUrl and picking the path back out of it with indexOf and substring was
217
- // a search and a second string for something uWS had just handed over.
218
- this.path = req.getUrl();
219
- this.originalUrl = this.path + this.urlQuery;
220
- this.url = this.originalUrl;
221
- // what the router last wrote to req.url. A middleware assigning something else is a
222
- // rewrite, which express honours, and dispatch compares against this to notice it
223
- this._lastUrl = this.originalUrl;
224
- // charCodeAt rather than indexing: s[i] builds a one character string to throw away
225
- this.endsWithSlash = this.path.charCodeAt(this.path.length - 1) === 0x2f;
226
- this._opPath = this.path;
227
- this._originalPath = this.path;
228
- if (this.endsWithSlash && this.path !== "/" && !this.app.get("strict routing")) {
229
- this._opPath = this._opPath.slice(0, -1);
230
- }
231
- this.method = req.getCaseSensitiveMethod().toUpperCase();
232
- this._isOptions = this.method === "OPTIONS";
233
- this._isHead = this.method === "HEAD";
223
+ if (preset) {
224
+ // the registration's constants: two native crossings and their strings not asked for
225
+ this.path = preset.path;
226
+ this.originalUrl = preset.path + this.urlQuery;
227
+ this.url = this.originalUrl;
228
+ this._lastUrl = this.originalUrl;
229
+ this.endsWithSlash = preset.endsWithSlash;
230
+ this._opPath = preset.opPath;
231
+ this._originalPath = preset.path;
232
+ this.method = preset.method;
233
+ this._isOptions = preset.isOptions;
234
+ this._isHead = preset.isHead;
235
+ } else {
236
+ // getUrl() is the path already, so the query is joined on and then not split off
237
+ // again. Building originalUrl and picking the path back out of it with indexOf and
238
+ // substring was a search and a second string for something uWS had just handed over.
239
+ this.path = req.getUrl();
240
+ this.originalUrl = this.path + this.urlQuery;
241
+ this.url = this.originalUrl;
242
+ // what the router last wrote to req.url. A middleware assigning something else is a
243
+ // rewrite, which express honours, and dispatch compares against this to notice it
244
+ this._lastUrl = this.originalUrl;
245
+ // charCodeAt rather than indexing: s[i] builds a one character string to throw away
246
+ this.endsWithSlash = this.path.charCodeAt(this.path.length - 1) === 0x2f;
247
+ this._opPath = this.path;
248
+ this._originalPath = this.path;
249
+ if (this.endsWithSlash && this.path !== "/" && !this.app.get("strict routing")) {
250
+ this._opPath = this._opPath.slice(0, -1);
251
+ }
252
+ this.method = req.getCaseSensitiveMethod().toUpperCase();
253
+ this._isOptions = this.method === "OPTIONS";
254
+ this._isHead = this.method === "HEAD";
255
+ }
234
256
  this.params = {};
235
257
 
236
258
  // Two Sets per request, for two things almost no request needs.
@@ -259,25 +281,19 @@ module.exports = class Request extends Readable {
259
281
  this.rawIp = this._res.getRemoteAddress();
260
282
  }
261
283
 
262
- const additionalMethods = this.app.get("body methods");
263
- // skip reading body for non-POST requests
264
- // this makes it +10k req/sec faster
265
- if (
266
- this.method === "POST" ||
267
- this.method === "PUT" ||
268
- this.method === "PATCH" ||
269
- this.method === "QUERY" ||
270
- (additionalMethods && additionalMethods.includes(this.method)) ||
271
- // any request that declares a body carries one, whatever the verb: a GET with
272
- // content-length left unread would end this stream empty and poison the keep-alive
273
- // connection with its unconsumed bytes. uWS itself discards GET bodies, so this is
274
- // the node shim's path
275
- /** @type {any} */ (this)._declaresBody
276
- ) {
284
+ // A body exists on the wire only when the request declares one, content-length or
285
+ // transfer-encoding, whatever the verb, and that evidence was spotted during the header
286
+ // copy. The verb list and the "body methods" settings read this branch used to pay per
287
+ // request said nothing the headers had not already said; the setting still gates the
288
+ // body parsers, which is where it matters
289
+ if (/** @type {any} */ (this)._declaresBody) {
277
290
  this._subscribeBody();
278
291
  } else {
279
292
  this.receivedData = true;
280
- this.push(null);
293
+ // not pushed here: ending a Readable costs a scheduled tick and its bookkeeping,
294
+ // and on a bodyless request nobody may ever look. The null goes out from _read(),
295
+ // which is where every consumer arrives
296
+ this.#emptyBody = true;
281
297
  }
282
298
  }
283
299
 
@@ -320,6 +336,13 @@ module.exports = class Request extends Readable {
320
336
  * lift the backpressure that a full queue put on it.
321
337
  */
322
338
  _read() {
339
+ // first, so a bodyless stream still ends for a consumer that arrives after the
340
+ // response finished, which express allows
341
+ if (this.#emptyBody) {
342
+ this.#emptyBody = false;
343
+ this.push(null);
344
+ return;
345
+ }
323
346
  if (this.#paused && !this.#responseEnded) {
324
347
  this.#paused = false;
325
348
  this._res.resume();
package/src/response.js CHANGED
@@ -284,8 +284,8 @@ module.exports = class Response extends Writable {
284
284
  }
285
285
 
286
286
  if (!Buffer.isBuffer(chunk) && !(chunk instanceof ArrayBuffer)) {
287
+ // the Buffer view is enough, uWS reads its offset and length itself
287
288
  chunk = Buffer.from(chunk);
288
- chunk = chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength);
289
289
  }
290
290
 
291
291
  if (this.chunkedTransfer) {
@@ -522,9 +522,8 @@ module.exports = class Response extends Writable {
522
522
  } else if (!data && contentLength) {
523
523
  this._res.endWithoutBody(contentLength.toString());
524
524
  } else {
525
- if (data instanceof Buffer) {
526
- data = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
527
- }
525
+ // a Buffer goes to uWS as the view it is: copying it into a fresh ArrayBuffer was
526
+ // an allocation per body, and uWS reads the view's own offset and length
528
527
  if (this.req.method === "HEAD") {
529
528
  const length = Buffer.byteLength(data ?? "");
530
529
  this._res.endWithoutBody(length.toString());
package/src/router.js CHANGED
@@ -671,6 +671,30 @@ function onNativeAborted() {
671
671
  response.socket?.emit("error", err);
672
672
  }
673
673
 
674
+ /**
675
+ *
676
+ */
677
+ /**
678
+ * The per-request constants of a fully literal native registration. µWS matched the URL byte for
679
+ * byte against this exact pattern and dispatches by method, so the request constructor can take
680
+ * these as given instead of asking uWS and recomputing them on every request.
681
+ *
682
+ * @param {string} path the registered pattern, which is what getUrl() would have answered
683
+ * @param {string} method uppercase, fixed by which uWS verb the registration used
684
+ * @param {boolean} strict the owner's strict routing, frozen here like the twin registration is
685
+ */
686
+ function nativePreset(path, method, strict) {
687
+ const endsWithSlash = path.charCodeAt(path.length - 1) === 0x2f;
688
+ return {
689
+ path,
690
+ method,
691
+ endsWithSlash,
692
+ opPath: endsWithSlash && path !== "/" && !strict ? path.slice(0, -1) : path,
693
+ isOptions: method === "OPTIONS",
694
+ isHead: method === "HEAD"
695
+ };
696
+ }
697
+
674
698
  /**
675
699
  *
676
700
  */
@@ -1313,10 +1337,11 @@ module.exports = class Router extends EventEmitter {
1313
1337
  *
1314
1338
  * @param {any} res uWS response
1315
1339
  * @param {any} req uWS request, readable only during this call
1340
+ * @param {any} [preset] a literal registration's constants, see nativePreset
1316
1341
  * @returns {any} the request, with the response reachable as request.res
1317
1342
  */
1318
- handleRequest(res, req) {
1319
- const request = new this._request(req, res, this);
1343
+ handleRequest(res, req, preset) {
1344
+ const request = new this._request(req, res, this, preset);
1320
1345
  const response = new this._response(res, request, this);
1321
1346
  request.res = response;
1322
1347
  response.req = request;
@@ -1381,7 +1406,7 @@ module.exports = class Router extends EventEmitter {
1381
1406
  if (route.path.includes(":")) {
1382
1407
  route.optimizedParams = route.path.match(regExParam).map((p) => p.slice(1));
1383
1408
  }
1384
- const makeHandler = (chain) => {
1409
+ const makeHandler = (chain, preset) => {
1385
1410
  // all three are registration-time constants: computing them in the handler was a
1386
1411
  // closure and a scan of the chain on every native request.
1387
1412
  // Falling back resumes after the mount, not after the router's leaf: the leaf can have
@@ -1394,7 +1419,7 @@ module.exports = class Router extends EventEmitter {
1394
1419
  // and this one never did. nativeDone and nativeFail defer their epilogues to a
1395
1420
  // microtask, which is where the await used to resume, so the visible order holds
1396
1421
  return (res, req) => {
1397
- const request = this.handleRequest(res, req);
1422
+ const request = this.handleRequest(res, req, preset);
1398
1423
  const response = request.res;
1399
1424
  if (optimizedParams) {
1400
1425
  request.optimizedParams = new NullObject();
@@ -1419,12 +1444,22 @@ module.exports = class Router extends EventEmitter {
1419
1444
  // chain runs without re-matching the method, so the get registration must not see it
1420
1445
  const getChain =
1421
1446
  route.method === "GET" ? optimizedPath.filter((r) => r.all || r.method !== "HEAD") : optimizedPath;
1422
- let fn = makeHandler(getChain);
1423
1447
  route.optimizedPath = optimizedPath;
1424
1448
 
1449
+ // A fully literal registration knows path and method here, so each registration site
1450
+ // hands the request constructor its own constants. An "any" registration serves every
1451
+ // verb and a parameterised one matches paths it cannot spell, so both stay dynamic
1452
+ const canPreset = !route.optimizedParams && method !== "any";
1453
+ // the route's own router decides, not the app running the registration: a router created
1454
+ // with { strict: true } and mounted on an app without it does not answer /things/, and
1455
+ // registering that path here is the only way it could
1456
+ const strictHere = Boolean((route.owner ?? this).get("strict routing"));
1457
+
1458
+ let fn = makeHandler(getChain, canPreset ? nativePreset(route.path, route.method, strictHere) : undefined);
1459
+ const jsFn = fn;
1460
+
1425
1461
  let replacedPath = route.path;
1426
- const realFn = fn;
1427
- const headFn = getChain.length === optimizedPath.length ? realFn : makeHandler(optimizedPath);
1462
+ const headChain = getChain.length === optimizedPath.length ? getChain : optimizedPath;
1428
1463
 
1429
1464
  // the response prototype the route will really run under: its own app's, which sees a
1430
1465
  // method patched there or inherited from a parent app, falling back to the registering app
@@ -1447,17 +1482,29 @@ module.exports = class Router extends EventEmitter {
1447
1482
  }
1448
1483
 
1449
1484
  this.uwsApp[method](replacedPath, fn);
1450
- // the route's own router decides, not the app running the registration: a router created
1451
- // with { strict: true } and mounted on an app without it does not answer /things/, and
1452
- // registering that path here is the only way it could
1453
- if (!(route.owner ?? this).get("strict routing") && route.path[route.path.length - 1] !== "/") {
1454
- this.uwsApp[method](replacedPath + "/", fn);
1485
+ if (!strictHere && route.path[route.path.length - 1] !== "/") {
1486
+ // a declarative response answers the twin as itself; a preset handler cannot be
1487
+ // shared, since the twin's path is its own constant
1488
+ const slashFn =
1489
+ fn !== jsFn
1490
+ ? fn
1491
+ : canPreset
1492
+ ? makeHandler(getChain, nativePreset(route.path + "/", route.method, strictHere))
1493
+ : fn;
1494
+ this.uwsApp[method](replacedPath + "/", slashFn);
1455
1495
  if (method === "get") {
1456
- this.uwsApp.head(replacedPath + "/", headFn);
1496
+ this.uwsApp.head(
1497
+ replacedPath + "/",
1498
+ makeHandler(headChain, canPreset ? nativePreset(route.path + "/", "HEAD", strictHere) : undefined)
1499
+ );
1457
1500
  }
1458
1501
  }
1459
1502
  if (method === "get") {
1460
- this.uwsApp.head(replacedPath, headFn);
1503
+ // its own handler always: the shared one would carry the GET registration's method
1504
+ this.uwsApp.head(
1505
+ replacedPath,
1506
+ makeHandler(headChain, canPreset ? nativePreset(route.path, "HEAD", strictHere) : undefined)
1507
+ );
1461
1508
  }
1462
1509
  }
1463
1510