fulmine.js 5.5.1 → 5.5.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
@@ -26,12 +26,6 @@ npx fulmine profile # what listen() decided about each route
26
26
 
27
27
  See [Migrating](#migrating) for what it handles and what it deliberately does not.
28
28
 
29
- There is a **[live demo](https://fulmine-demo.fly.dev)**, which is an ordinary Express application:
30
- real routes, `helmet`, `cors`, `compression`, `express-session` and `morgan` unmodified, and a
31
- WebSocket chat served by `app.ws()`. It links to [its own source](https://fulmine-demo.fly.dev/source),
32
- which is [in this repository](./demo). It shows no throughput figure on purpose: it runs on a small
33
- shared machine, so the number would describe the machine rather than the framework.
34
-
35
29
  [![npm version](https://img.shields.io/npm/v/fulmine.js)](https://www.npmjs.com/package/fulmine.js)
36
30
  [![Node.js >= 22.0.0](https://img.shields.io/badge/Node.js-%3E=22.0.0-green)](https://nodejs.org)
37
31
  [![Coverage Status](https://coveralls.io/repos/github/nigrosimone/fulmine.js/badge.svg?branch=main)](https://coveralls.io/github/nigrosimone/fulmine.js?branch=main)
@@ -230,6 +224,7 @@ A single-stage `node:26-trixie-slim` image works too if you `apt-get install -y
230
224
  - `app.listen()` returns the app, not an `http.Server`. There is no node server underneath, so `server.close()`, `server.address()` and anything that attaches itself to a real `http.Server` need a look. `app.close()`, `app.address()` and `app.listening` are there and do what you would expect.
231
225
  - `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.
232
226
  - 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.
227
+ - **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`.
233
228
  - For HTTPS, instead of doing this:
234
229
 
235
230
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.5.1",
3
+ "version": "5.5.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": {
@@ -500,31 +500,16 @@ class Application extends Router {
500
500
  * @param {any} res the uWS response
501
501
  * @param {any} req the uWS request
502
502
  */
503
- async _serveGeneric(res, req) {
503
+ _serveGeneric(res, req) {
504
504
  const request = this.handleRequest(res, req);
505
505
  const response = request.res;
506
- // armed up front here: this handler awaits, so the response outlives the callback
507
- // on every path through it
506
+ // armed up front here: this handler can outlive the callback on every path through it
508
507
  this._armAbort(res, response);
509
508
 
510
- try {
511
- const routed = this._routeRequest(request, response);
512
- // dispatch has run its synchronous stretch inside _routeRequest by now, still
513
- // under the cork uWS holds for this callback; the await below leaves it
514
- response._corkNeeded = true;
515
- const matchedRoute = await routed;
516
- if (!matchedRoute && !response.headersSent && !response.aborted) {
517
- this._endUnmatched(request, response);
518
- }
519
- } catch (err) {
520
- // an internal throw answers 500 as express's final handler would, instead of
521
- // dying as an unhandled rejection
522
- if (response.aborted || response.finished) {
523
- console.error(err);
524
- } else {
525
- this._handleError(err, null, request, response);
526
- }
527
- }
509
+ this._routeRequestDirect(request, response);
510
+ // the synchronous stretch has run under the cork uWS holds for this callback, and
511
+ // whatever comes after it is outside
512
+ response._corkNeeded = true;
528
513
  }
529
514
 
530
515
  /**
package/src/request.js CHANGED
@@ -756,10 +756,6 @@ module.exports = class Request extends LazyReadable {
756
756
  * @param {() => void} [callback]
757
757
  * @returns {this}
758
758
  */
759
-
760
- /**
761
- *
762
- */
763
759
  setTimeout(msecs, callback) {
764
760
  if (typeof callback === "function") {
765
761
  this.once("timeout", callback);
package/src/response.js CHANGED
@@ -228,9 +228,10 @@ module.exports = class Response extends LazyWritable {
228
228
  * measured on uWS alone, 500 writes of 66 bytes take 13ms against 0.06ms for 100 of them.
229
229
  * Handing it the same bytes in blocks costs 0.4ms. Nothing here changes what goes on the wire,
230
230
  * only how many calls it takes to put it there.
231
- * @type {Buffer[]}
231
+ * Null until the first chunked write, since a res.send never queues anything.
232
+ * @type {Buffer[]|null}
232
233
  */
233
- #queued = [];
234
+ #queued = null;
234
235
 
235
236
  /** How many bytes {@link #queued} holds, kept alongside so the flush does not add them up. */
236
237
  #queuedBytes = 0;
@@ -437,12 +438,12 @@ module.exports = class Response extends LazyWritable {
437
438
  * @param {((err?: Error|null) => void)|null} callback the stream's, when there is one waiting
438
439
  */
439
440
  #flushQueued(callback) {
440
- if (this.#queuedBytes === 0) {
441
+ if (this.#queued === null || this.#queuedBytes === 0) {
441
442
  if (callback) callback(null);
442
443
  return;
443
444
  }
444
445
  const body = this.#queued.length === 1 ? this.#queued[0] : Buffer.concat(this.#queued, this.#queuedBytes);
445
- this.#queued = [];
446
+ this.#queued = null;
446
447
  this.#queuedBytes = 0;
447
448
 
448
449
  const ok = this._res.write(body);
@@ -467,13 +468,16 @@ module.exports = class Response extends LazyWritable {
467
468
  }
468
469
 
469
470
  /**
470
- * The booked flush. An arrow so it can be handed to nextTick without binding it every write.
471
+ * The booked flush. Static, so a response that never writes in pieces allocates nothing for it:
472
+ * nextTick forwards the receiver as an argument.
473
+ *
474
+ * @param {any} res
471
475
  */
472
- #flushOnTick = () => {
473
- this.#flushBooked = false;
474
- if (this.aborted || this.finished || this.#queuedBytes === 0) return;
475
- this._res.cork(() => this.#flushQueued(null));
476
- };
476
+ static #flushOnTick(res) {
477
+ res.#flushBooked = false;
478
+ if (res.aborted || res.finished || res.#queuedBytes === 0) return;
479
+ res._res.cork(() => res.#flushQueued(null));
480
+ }
477
481
 
478
482
  /**
479
483
  * Writable's sink. Sends the headers if they have not gone yet, then hands the chunk to uWS,
@@ -520,7 +524,7 @@ module.exports = class Response extends LazyWritable {
520
524
  // turn or once it is big enough to be worth a call. A stream that writes once per
521
525
  // turn, an SSE feed for instance, still leaves on its own turn: the queue only ever
522
526
  // gathers what was written without yielding in between.
523
- this.#queued.push(/** @type {Buffer} */ (chunk));
527
+ (this.#queued ??= []).push(/** @type {Buffer} */ (chunk));
524
528
  this.#queuedBytes += /** @type {Buffer} */ (chunk).byteLength;
525
529
  // a chunk that is already big enough to be worth its own call leaves with whatever
526
530
  // was waiting in front of it, rather than paying for a copy it does not need
@@ -529,7 +533,7 @@ module.exports = class Response extends LazyWritable {
529
533
  } else {
530
534
  if (!this.#flushBooked) {
531
535
  this.#flushBooked = true;
532
- process.nextTick(this.#flushOnTick);
536
+ process.nextTick(Response.#flushOnTick, this);
533
537
  }
534
538
  this.writingChunk = false;
535
539
  callback(null);
@@ -1329,11 +1333,16 @@ module.exports = class Response extends LazyWritable {
1329
1333
  }
1330
1334
 
1331
1335
  /**
1332
- * Sends the status line and the headers now, without waiting for a body, which is node's
1336
+ * Hands the status line and the headers over now, without waiting for a body, which is node's
1333
1337
  * flushHeaders(). Callers use it to let the client start on the head while the body is still
1334
1338
  * being produced, and one of them is `@angular/ssr`'s writeResponseToNodeResponse, which calls
1335
1339
  * it before streaming a rendered page.
1336
1340
  *
1341
+ * **The head does not reach the wire here.** uWS holds it until the first body chunk, so a
1342
+ * client sees nothing until then, where express answers at once. `beginWrite` is uWS's API for
1343
+ * this and is unusable: it emits a stray CRLF before the first chunk size, which node's parser
1344
+ * rejects as HPE_INVALID_CHUNK_SIZE. Checked against v20.69.0, the latest release.
1345
+ *
1337
1346
  * A second call does nothing, as node's does. Nothing is written for a response already
1338
1347
  * finished or aborted: uWS has let go of it by then.
1339
1348
  *
@@ -1425,10 +1434,6 @@ module.exports = class Response extends LazyWritable {
1425
1434
  * @param {Record<string, string>|[string, string][]} [headers]
1426
1435
  * @returns {void}
1427
1436
  */
1428
-
1429
- /**
1430
- *
1431
- */
1432
1437
  addTrailers(headers) {}
1433
1438
 
1434
1439
  /**
@@ -1440,10 +1445,6 @@ module.exports = class Response extends LazyWritable {
1440
1445
  * @param {() => void} [callback]
1441
1446
  * @returns {this}
1442
1447
  */
1443
-
1444
- /**
1445
- *
1446
- */
1447
1448
  setTimeout(msecs, callback) {
1448
1449
  if (typeof callback === "function") {
1449
1450
  this.once("timeout", callback);
@@ -1458,20 +1459,12 @@ module.exports = class Response extends LazyWritable {
1458
1459
  * @param {any} [socket]
1459
1460
  * @returns {void}
1460
1461
  */
1461
-
1462
- /**
1463
- *
1464
- */
1465
1462
  assignSocket(socket) {}
1466
1463
 
1467
1464
  /**
1468
1465
  * @param {any} [socket]
1469
1466
  * @returns {void}
1470
1467
  */
1471
-
1472
- /**
1473
- *
1474
- */
1475
1468
  detachSocket(socket) {}
1476
1469
 
1477
1470
  /**
@@ -2005,12 +1998,6 @@ module.exports = class Response extends LazyWritable {
2005
1998
  return this.set("content-type", ct);
2006
1999
  }
2007
2000
 
2008
- /**
2009
- * express carries both names for the same method, and middleware reaches for either.
2010
- * @type {(type: string) => any}
2011
- */
2012
- contentType = this.type;
2013
-
2014
2001
  /**
2015
2002
  * Adds a field to Vary, without repeating one already there.
2016
2003
  * @param {string|string[]} field
@@ -2040,3 +2027,7 @@ module.exports = class Response extends LazyWritable {
2040
2027
  return this.finished;
2041
2028
  }
2042
2029
  };
2030
+
2031
+ // res.contentType is res.type under express's other name. On the prototype rather than an instance
2032
+ // field, which wrote one own property per response in the constructor.
2033
+ /** @type {any} */ (module.exports.prototype).contentType = module.exports.prototype.type;
package/src/router.js CHANGED
@@ -446,7 +446,7 @@ class Walk {
446
446
  return this.step(undefined);
447
447
  }
448
448
  if (kind === CALLBACK_ROUTER) {
449
- if (callback.constructor.name === "Application") {
449
+ if (callback._isApplication) {
450
450
  rememberApp(this, route, req);
451
451
  useApp(req, callback);
452
452
  }
@@ -616,10 +616,15 @@ function nativeFail(err) {
616
616
  * @returns {number}
617
617
  */
618
618
  function mountPrefixLength(route, req) {
619
- const path = req._opPath;
619
+ // a use with no path is EMPTY_REGEX, which matches "" at 0 whatever the path is. Answered
620
+ // without the exec, since this runs per hop and most middleware is pathless
621
+ if (route.pattern === EMPTY_REGEX) {
622
+ return 0;
623
+ }
620
624
  if (typeof route.pattern === "string") {
621
625
  return route.pattern.length;
622
626
  }
627
+ const path = req._opPath;
623
628
  const matched = route.pattern.exec(path === "" ? "/" : path);
624
629
  return matched ? Math.min(matched[0].length, path.length) : 0;
625
630
  }
@@ -1057,7 +1062,7 @@ function guardsInside(router, mount, pathPrefix, chain, inherited) {
1057
1062
  * @param {any} req
1058
1063
  */
1059
1064
  function rememberApp(walk, route, req) {
1060
- if (walk.router._isApplication && route.callbacks[0]?.constructor.name === "Application") {
1065
+ if (walk.router._isApplication && route.callbacks[0]?._isApplication) {
1061
1066
  (req._appStack ??= []).push(route, req.app);
1062
1067
  }
1063
1068
  }
@@ -2536,6 +2541,24 @@ module.exports = class Router extends EventEmitter {
2536
2541
  });
2537
2542
  }
2538
2543
 
2544
+ /**
2545
+ * The same walk without the promise pair, for a uWS handler that never awaited it. nativeDone
2546
+ * and nativeFail defer their epilogues to the microtask the await used to resume on, so the
2547
+ * visible order holds.
2548
+ *
2549
+ * @param {any} req
2550
+ * @param {any} res
2551
+ */
2552
+ _routeRequestDirect(req, res) {
2553
+ const walk = new Walk(this, req, res, this._routes, false, undefined, nativeDone, nativeFail);
2554
+ try {
2555
+ walk.dispatch(0);
2556
+ } catch (err) {
2557
+ // what a throw inside a promise executor did: reject, once
2558
+ nativeFail.call(walk, err);
2559
+ }
2560
+ }
2561
+
2539
2562
  /**
2540
2563
  * Mounts middleware, or a whole router, at a path. The path is optional, and a mount matches
2541
2564
  * everything under it, which is what separates it from all(). Mounting a Router sets its
package/src/utils.js CHANGED
@@ -1102,6 +1102,11 @@ function createETagGenerator(options) {
1102
1102
  if (body instanceof Stats) {
1103
1103
  return statTag(body, options.weak);
1104
1104
  }
1105
+ // crypto.hash reads a string as the utf8 bytes Buffer.from would have produced, so the tag
1106
+ // is the same one without copying the whole body first
1107
+ if (typeof body === "string" && (encoding === undefined || encoding === "utf8" || encoding === "utf-8")) {
1108
+ return entityTag(body, options.weak);
1109
+ }
1105
1110
  const buf = !Buffer.isBuffer(body) ? Buffer.from(body, encoding) : body;
1106
1111
  return entityTag(buf, options.weak);
1107
1112
  };