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 +3 -3
- package/package.json +1 -1
- package/src/declarative.js +57 -20
- package/src/middlewares.js +9 -4
- package/src/response.js +36 -7
- package/src/usage.js +39 -2
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
|
|
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.
|
|
28
|
-
7x to
|
|
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
package/src/declarative.js
CHANGED
|
@@ -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 {
|
|
293
|
-
* @param {
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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 {
|
|
554
|
-
* @property {
|
|
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
|
-
|
|
578
|
-
|
|
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
|
-
|
|
591
|
-
|
|
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.
|
|
702
|
-
params.
|
|
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
|
package/src/middlewares.js
CHANGED
|
@@ -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 (
|
|
803
|
-
options.limit =
|
|
802
|
+
if (options.limit === undefined || options.limit === null) {
|
|
803
|
+
options.limit = 100 * 1024;
|
|
804
804
|
} else if (typeof options.limit !== "number") {
|
|
805
|
-
//
|
|
806
|
-
|
|
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
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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 (
|
|
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
|
-
|
|
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) {
|