fulmine.js 5.21.2 → 5.21.3

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
@@ -1,6 +1,6 @@
1
1
  <img src="./assets/logo-mark.svg" alt="" width="88" align="right">
2
2
 
3
- # Fulmine.js: the drop-in Express 5 replacement, up to 20x faster
3
+ # Fulmine.js: the drop-in Express 5 replacement, up to 22x faster
4
4
 
5
5
  **Fulmine** (lightning in Italian ⚡) is an Express 5 compatible web framework for Node.js, built on
6
6
  [µWebSockets.js](https://github.com/uNetworking/uWebSockets.js) instead of `node:http`. Same API, same
@@ -24,8 +24,8 @@ const express = require("fulmine.js"); // instead of require("express")
24
24
 
25
25
  ## Why Fulmine
26
26
 
27
- - **Faster than Express, measured.** 1.3x to 4.9x on plain routing, 2x to 5x on a request with a body,
28
- 7x to 20x on a large route table. Routes are matched in C++ by µWS's own router,
27
+ - **Faster than Express, measured.** 1.2x to 4.5x on plain routing, 1.7x to 5x on a request with a body,
28
+ 7x to 22x on a large route table. Routes are matched in C++ by µWS's own router,
29
29
  and a simple enough handler is answered without running any JavaScript at all.
30
30
  - **Zero rewrite.** `helmet`, `cors`, `passport`, `morgan`, `multer`, `express-session` and the rest of
31
31
  the Express ecosystem keep working. Not "mostly": every test runs against real Express first and the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.21.2",
3
+ "version": "5.21.3",
4
4
  "description": "Drop-in Express 5 replacement on uWebSockets.js, up to 20x faster. Same API, your middleware and framework keep working.",
5
5
  "main": "src/index.js",
6
6
  "exports": {
@@ -236,6 +236,11 @@ function readStatusAndHeaders(callExprs, headers) {
236
236
 
237
237
  for (let [header, value] of pairs) {
238
238
  const name = String(header).toLowerCase();
239
+ // a chunked framing the handler asked for: a compiled response is one end() with a
240
+ // length, the ordinary path frames it through write(), see Response#writeHeaders
241
+ if (name === "transfer-encoding") {
242
+ return null;
243
+ }
239
244
  // res.set resolves a content-type through the mime database, setHeader does not
240
245
  if (call.obj.propertyName !== "setHeader" && name === "content-type") {
241
246
  const resolved = contentTypeSet(String(value));
@@ -266,6 +271,9 @@ function readStatusAndHeaders(callExprs, headers) {
266
271
  if (call.arguments[0].type !== "Literal" || call.arguments[1].type !== "Literal") {
267
272
  return null;
268
273
  }
274
+ if (String(call.arguments[0].value).toLowerCase() === "transfer-encoding") {
275
+ return null;
276
+ }
269
277
  if (!headerIsWritable(String(call.arguments[0].value), String(call.arguments[1].value))) {
270
278
  return null;
271
279
  }
@@ -289,8 +297,8 @@ function readStatusAndHeaders(callExprs, headers) {
289
297
  * @param {[string, string][]} headers written to
290
298
  * @param {any[]} body written to, a literal's value kept as it is
291
299
  * @param {Application|Router} app for the json settings
292
- * @param {string[]} queries names bound by a destructured req.query
293
- * @param {string[]} params names bound by a destructured req.params
300
+ * @param {Binding[]} queries what a destructured req.query bound
301
+ * @param {Binding[]} params what a destructured req.params bound
294
302
  * @returns {{sendUsed: boolean, bodyFromSend: boolean}|null}
295
303
  */
296
304
  function readBody(callExprs, headers, body, app, queries, params) {
@@ -383,10 +391,14 @@ function readBody(callExprs, headers, body, app, queries, params) {
383
391
  }
384
392
  body.push({ type, value: expr.property.name });
385
393
  } else if (expr.type === "Identifier") {
386
- if (queries.includes(expr.name)) {
387
- body.push({ type: "query", value: expr.name });
388
- } else if (params.includes(expr.name)) {
389
- body.push({ type: "params", value: expr.name });
394
+ // the key, not the local name: a minifier renames the local and uWS
395
+ // would be asked for a parameter that does not exist
396
+ const query = queries.find((binding) => binding.local === expr.name);
397
+ const param = params.find((binding) => binding.local === expr.name);
398
+ if (query) {
399
+ body.push({ type: "query", value: query.key });
400
+ } else if (param) {
401
+ body.push({ type: "params", value: param.key });
390
402
  } else {
391
403
  return null;
392
404
  }
@@ -550,9 +562,40 @@ function readHandler(cb) {
550
562
  * @property {string} res
551
563
  * @property {string|undefined} queryName
552
564
  * @property {string|undefined} paramsName
553
- * @property {string[]} queries
554
- * @property {string[]} params
565
+ * @property {Binding[]} queries
566
+ * @property {Binding[]} params
567
+ */
568
+
569
+ /**
570
+ * One name a destructured req.query or req.params bound, with the key it stands for: the same
571
+ * word in `{ id }`, two after a minifier has been through (`{ id: c }`).
572
+ * @typedef {object} Binding
573
+ * @property {string} local
574
+ * @property {string} key
575
+ */
576
+
577
+ /**
578
+ * The bindings of one destructuring pattern, `{ id, name: n }`. False for a shape this cannot
579
+ * read: a computed key, a rest element, a default value or a nested pattern.
580
+ *
581
+ * @param {any} pattern an ObjectPattern node
582
+ * @param {Binding[]} into
583
+ * @returns {boolean}
555
584
  */
585
+ function readBindings(pattern, into) {
586
+ for (const prop of pattern.properties) {
587
+ if (
588
+ prop.type !== "Property" ||
589
+ prop.computed ||
590
+ prop.key.type !== "Identifier" ||
591
+ prop.value.type !== "Identifier"
592
+ ) {
593
+ return false;
594
+ }
595
+ into.push({ local: prop.value.name, key: prop.key.name });
596
+ }
597
+ return true;
598
+ }
556
599
 
557
600
  /**
558
601
  * The names a destructured `req` binds for query and params; null for a pattern this cannot read.
@@ -574,11 +617,8 @@ function readParamNames(fn, args) {
574
617
  if (query?.value?.type === "Identifier") {
575
618
  queryName = query.value.name;
576
619
  } else if (query?.value?.type === "ObjectPattern") {
577
- for (const prop of query.value.properties) {
578
- if (prop.value.type !== "Identifier") {
579
- return null;
580
- }
581
- queries.push(prop.value.name);
620
+ if (!readBindings(query.value, queries)) {
621
+ return null;
582
622
  }
583
623
  } else {
584
624
  return null;
@@ -587,11 +627,8 @@ function readParamNames(fn, args) {
587
627
  if (param?.value?.type === "Identifier") {
588
628
  paramsName = param.value.name;
589
629
  } else if (param?.value?.type === "ObjectPattern") {
590
- for (const prop of param.value.properties) {
591
- if (prop.value.type !== "Identifier") {
592
- return null;
593
- }
594
- params.push(prop.value.name);
630
+ if (!readBindings(param.value, params)) {
631
+ return null;
595
632
  }
596
633
  } else {
597
634
  return null;
@@ -698,8 +735,8 @@ function identifiersAllowed(fn, args, names) {
698
735
  (identifiers[i - 2] === req && identifiers[i - 1] === "query") ||
699
736
  id === queryName ||
700
737
  id === paramsName ||
701
- queries.includes(id) ||
702
- params.includes(id)
738
+ queries.some((binding) => binding.local === id) ||
739
+ params.some((binding) => binding.local === id)
703
740
  );
704
741
  }
705
742
  // A uWS declarative response never enters node, so it is very fast. Only a handler that is simple
@@ -799,11 +799,16 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
799
799
  checkOptions(options);
800
800
  }
801
801
  // bytes.parse only on a string: bytes(1024) formats it to "1KB" and no comparison held
802
- if (typeof options.limit === "undefined") {
803
- options.limit = /** @type {number} */ (bytes.parse("100kb"));
802
+ if (options.limit === undefined || options.limit === null) {
803
+ options.limit = 100 * 1024;
804
804
  } else if (typeof options.limit !== "number") {
805
- // null for a size it cannot read, as body-parser passes it along
806
- options.limit = /** @type {number} */ (bytes.parse(options.limit));
805
+ // a size it cannot read is refused here, as body-parser 2.3 does: passed along as
806
+ // null it disabled the limit (CVE-2026-12590)
807
+ const parsed = bytes.parse(options.limit);
808
+ if (parsed === null) {
809
+ throw new TypeError(`option limit "${String(options.limit)}" is invalid`);
810
+ }
811
+ options.limit = parsed;
807
812
  }
808
813
 
809
814
  const limit = /** @type {number} */ (options.limit);
package/src/response.js CHANGED
@@ -115,6 +115,9 @@ const MAX_MAXAGE = 60 * 60 * 24 * 365 * 1000;
115
115
  // what send takes as a range request, checked on the header's text before parsing
116
116
  const BYTES_RANGE = /^ *bytes=/;
117
117
 
118
+ // node's test for a Transfer-Encoding that means chunked framing
119
+ const CHUNKED_VALUE = /(?:^|\W)chunked(?:$|\W)/i;
120
+
118
121
  module.exports = class Response extends LazyWritable {
119
122
  /** @type {Socket|null} */
120
123
  #socket = null;
@@ -142,6 +145,13 @@ module.exports = class Response extends LazyWritable {
142
145
  /** Whether the status line and the headers have reached uWS, which only a body write does. */
143
146
  #headOut = false;
144
147
 
148
+ /**
149
+ * Whether the application set Transfer-Encoding: chunked itself. The body then goes out
150
+ * through uWS's write(), which frames it and writes the header, and no Content-Length is
151
+ * added beside it, as express 5.3 and node do. See writeHeaders and _finish.
152
+ */
153
+ #userChunked = false;
154
+
145
155
  /**
146
156
  * The status as writeHead settled it, the one the wire gets: a status set later never reaches
147
157
  * the client, as in node.
@@ -619,6 +629,17 @@ module.exports = class Response extends LazyWritable {
619
629
  this.totalSize = parseInt(value);
620
630
  continue;
621
631
  }
632
+ if (
633
+ header === "transfer-encoding" &&
634
+ typeof value === "string" &&
635
+ res._nodeRes === undefined &&
636
+ CHUNKED_VALUE.test(value)
637
+ ) {
638
+ // not written: uWS writes its own on the write() path, two came out before. Node's
639
+ // own response, behind the shim, frames by the header itself
640
+ this.#userChunked = true;
641
+ continue;
642
+ }
622
643
  // the recurring names and values cross as cached Buffers, see HEADER_NAME_BUF
623
644
  const name = HEADER_NAME_BUF[header] || header;
624
645
  if (Array.isArray(value)) {
@@ -759,9 +780,10 @@ module.exports = class Response extends LazyWritable {
759
780
  this._res.endWithoutBody();
760
781
  } else if (!data && contentLength) {
761
782
  this._res.endWithoutBody(contentLength.toString(), closeConnection);
762
- } else if (headWasAlreadyOut && this.chunkedTransfer) {
783
+ } else if ((headWasAlreadyOut && this.chunkedTransfer) || (this.#userChunked && this._hasBody && data)) {
763
784
  // the queue first, then the last piece as a chunk: the head already went out without a
764
- // length, and uWS's end() would append one
785
+ // length, or the application asked for chunked framing, and uWS's end() would append one.
786
+ // An empty body under that header takes end() below: uWS frames nothing without a write()
765
787
  this.#flushQueued(null);
766
788
  if (data) {
767
789
  this._res.write(data);
@@ -770,9 +792,15 @@ module.exports = class Response extends LazyWritable {
770
792
  this._res.endWithoutBody();
771
793
  } else {
772
794
  if (!this._hasBody) {
773
- const length = Buffer.byteLength(data ?? "");
774
- this.headers["content-length"] = String(length);
775
- this._res.endWithoutBody(length, closeConnection);
795
+ if (this.#userChunked) {
796
+ // a HEAD under the application's chunked framing carries no length, as node.
797
+ // No arguments: given a close flag alone, uWS writes a 2^63 length
798
+ this._res.endWithoutBody();
799
+ } else {
800
+ const length = Buffer.byteLength(data ?? "");
801
+ this.headers["content-length"] = String(length);
802
+ this._res.endWithoutBody(length, closeConnection);
803
+ }
776
804
  } else {
777
805
  this._sentBody = data ?? "";
778
806
  // null is the empty body: uWS never ends a response given end(null)
@@ -892,9 +920,10 @@ module.exports = class Response extends LazyWritable {
892
920
  body = "";
893
921
  }
894
922
  // by req.method as express's send does, so a GET a middleware made a HEAD answers its
895
- // length and no body; end() alone decides by the wire, see _hasBody
923
+ // length and no body; end() alone decides by the wire, see _hasBody. No length beside a
924
+ // Transfer-Encoding the application set, as express 5.3
896
925
  if (this.req.method === "HEAD") {
897
- if (this.statusCode !== 204 && this.statusCode !== 304) {
926
+ if (this.statusCode !== 204 && this.statusCode !== 304 && !this.headers["transfer-encoding"]) {
898
927
  this.headers["content-length"] = String(Buffer.byteLength(body));
899
928
  }
900
929
  return this.end();
package/src/usage.js CHANGED
@@ -143,6 +143,20 @@ function analyze(fn) {
143
143
  if (mask & UNKNOWN) {
144
144
  return;
145
145
  }
146
+ // a member read off what a call returned: res.status(200).sendFile() is a sendFile on res,
147
+ // every chainable method returns res itself, so it is judged as one. Reading it off the
148
+ // call had let sendFile and redirect through, and a Range or an Accept then went unread
149
+ if (
150
+ node.type === "MemberExpression" &&
151
+ node.object.type === "CallExpression" &&
152
+ resName !== null &&
153
+ chainRoot(node.object) === resName
154
+ ) {
155
+ if (node.computed || !RES_OK.has(node.property.name)) {
156
+ mask |= UNKNOWN;
157
+ }
158
+ return;
159
+ }
146
160
  if (node.type === "MemberExpression" && !node.computed && node.object.type === "Identifier") {
147
161
  const owner = node.object.name;
148
162
  if (owner !== reqName) {
@@ -227,6 +241,25 @@ function analyze(fn) {
227
241
  return mask;
228
242
  }
229
243
 
244
+ /**
245
+ * The identifier a chain of members and calls starts from: res for res.status(200).sendFile().
246
+ *
247
+ * @param {any} node
248
+ * @returns {string|null} null when the chain does not start from a plain identifier
249
+ */
250
+ function chainRoot(node) {
251
+ while (node) {
252
+ if (node.type === "MemberExpression") {
253
+ node = node.object;
254
+ } else if (node.type === "CallExpression") {
255
+ node = node.callee;
256
+ } else {
257
+ return node.type === "Identifier" ? node.name : null;
258
+ }
259
+ }
260
+ return null;
261
+ }
262
+
230
263
  /**
231
264
  * Walks every node by key, handing each its parent.
232
265
  *
@@ -275,12 +308,16 @@ function chainUsage(chain, allowTerminalNext) {
275
308
  return none;
276
309
  }
277
310
  const terminal = i === chain.length - 1;
278
- for (const cb of callbacks) {
311
+ for (let j = 0; j < callbacks.length; j++) {
312
+ const cb = callbacks[j];
279
313
  if (typeof cb !== "function") {
280
314
  return none;
281
315
  }
282
316
  const mask = callbackUsage(cb);
283
- if (mask & UNKNOWN || (mask & NEXT_PLAIN && terminal && !allowTerminalNext)) {
317
+ // a bare next() leaves the chain only from the route's last callback: from one in
318
+ // front of it, the step lands on the next callback of the same route, judged here too
319
+ const last = terminal && j === callbacks.length - 1;
320
+ if (mask & UNKNOWN || (mask & NEXT_PLAIN && last && !allowTerminalNext)) {
284
321
  return none;
285
322
  }
286
323
  if (mask & QUERY) {