fulmine.js 5.15.0 → 5.16.0
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 +11 -0
- package/package.json +1 -1
- package/src/compression.js +46 -3
- package/src/middlewares.js +69 -45
- package/src/parse-query.js +29 -1
- package/src/request.js +151 -57
- package/src/response.js +56 -13
- package/src/router.js +304 -78
- package/src/types.d.ts +6 -0
- package/src/utils.js +16 -0
package/README.md
CHANGED
|
@@ -296,6 +296,10 @@ A single-stage `node:26-trixie-slim` image works too if you `apt-get install -y
|
|
|
296
296
|
|
|
297
297
|
## Differences from Express
|
|
298
298
|
|
|
299
|
+
What the two servers answer on the wire, probed from outside, malformed input and smuggling
|
|
300
|
+
attempts included: [fulmine.js on http-probe.com](https://www.http-probe.com/servers/fulmine-js.html)
|
|
301
|
+
against [express](https://www.http-probe.com/servers/express.html).
|
|
302
|
+
|
|
299
303
|
- `app.listen()` returns the app rather than a separate server object, and the app answers as an `http.Server`: `app instanceof http.Server` is true, which is what the graceful shutdown wrappers and the connection trackers look for. There is still no node server underneath, the socket belongs to µWS, so what is answered is the surface and not the plumbing. There: `close()`, `address()`, `listening`, `getConnections()`, `ref()`, `unref()`, `setTimeout()` and the `keepAliveTimeout` family. Not there: nothing emits `connection`, `request` or `upgrade`, `getConnections()` counts the requests in flight rather than sockets, and the timeouts belong to µWS and are set through `uwsOptions.idleTimeout`. Anything that wants to serve its own protocol on the socket, socket.io being the usual case, still wants `app.uwsApp`. Runnable: [`examples/graceful-shutdown.js`](./examples/graceful-shutdown.js).
|
|
300
304
|
- `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.
|
|
301
305
|
- 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.
|
|
@@ -498,6 +502,13 @@ Server-Timing: route;desc="native", hdr;desc="not copied", db;dur=3.62, total;du
|
|
|
498
502
|
app.use(express.compression({ threshold: 1024 }));
|
|
499
503
|
```
|
|
500
504
|
|
|
505
|
+
One option is Fulmine's own, `encodings`: the list of what the middleware may answer with, out of `"br"`, `"gzip"` and `"deflate"`. What is not named is never used, however the client ranks it, and an uncompressed answer is always on offer. It exists because the preferred encoding is a cost decision, not only a size one: brotli compresses smaller but what it costs per response depends on the machine, and on a CPU where it runs expensive `encodings: ["gzip"]` buys the cheaper call for every client that accepts both.
|
|
506
|
+
|
|
507
|
+
```js
|
|
508
|
+
// answer gzip even to a client that also accepts br
|
|
509
|
+
app.use(express.compression({ level: 1, encodings: ["gzip"] }));
|
|
510
|
+
```
|
|
511
|
+
|
|
501
512
|
Runnable: [`examples/compression.js`](./examples/compression.js).
|
|
502
513
|
|
|
503
514
|
5. If a route answers with a JSON shape you know in advance, [express-fast-json-stringify](https://www.npmjs.com/package/express-fast-json-stringify) compiles that shape into a serializer and `res.fastJson()` replaces `res.json()`. `JSON.stringify()` has to walk an object it knows nothing about; a compiled serializer does not. It is worth reaching for, and a CPU profile says why: on a route answering 3.6KB of JSON, serialising it is about 25% of the time that is not spent waiting, ahead of the ETag at 19% and of everything the framework does to route the request and build its request and response objects.
|
package/package.json
CHANGED
package/src/compression.js
CHANGED
|
@@ -36,7 +36,23 @@ limitations under the License.
|
|
|
36
36
|
const zlib = require("zlib");
|
|
37
37
|
const bytes = require("bytes");
|
|
38
38
|
const compressible = require("compressible");
|
|
39
|
-
const {
|
|
39
|
+
const {
|
|
40
|
+
negotiateEncoding,
|
|
41
|
+
ENCODING_ANY,
|
|
42
|
+
ENCODING_BR,
|
|
43
|
+
ENCODING_GZIP,
|
|
44
|
+
ENCODING_DEFLATE,
|
|
45
|
+
memoizeByString
|
|
46
|
+
} = require("./utils.js");
|
|
47
|
+
|
|
48
|
+
// what the `encodings` option may name, and the mask each name contributes. identity is 0: an
|
|
49
|
+
// uncompressed answer is always on offer, naming it only makes the list read complete
|
|
50
|
+
const ENCODING_MASKS = new Map([
|
|
51
|
+
["br", ENCODING_BR],
|
|
52
|
+
["gzip", ENCODING_GZIP],
|
|
53
|
+
["deflate", ENCODING_DEFLATE],
|
|
54
|
+
["identity", 0]
|
|
55
|
+
]);
|
|
40
56
|
|
|
41
57
|
// Cache-Control: no-transform forbids recoding the body, which is what this does
|
|
42
58
|
const NO_TRANSFORM = /(?:^|,)\s*?no-transform\s*?(?:,|$)/;
|
|
@@ -135,6 +151,11 @@ function toBuffer(chunk, encoding) {
|
|
|
135
151
|
* @param {string} [options.enforceEncoding] what to use when the request carries no
|
|
136
152
|
* Accept-Encoding at all. Default "identity", which is to say nothing is compressed.
|
|
137
153
|
* @param {object} [options.brotli] brotli options, `params` included. The default quality is 4.
|
|
154
|
+
* @param {string[]} [options.encodings] the encodings this middleware may answer with, out of
|
|
155
|
+
* "br", "gzip" and "deflate". What is not named is never used, however the client ranks it: a
|
|
156
|
+
* server that prefers cheap gzip over brotli passes ["gzip", "deflate"]. An uncompressed answer
|
|
157
|
+
* is always on offer, and enforceEncoding stays its own explicit choice, outside this list.
|
|
158
|
+
* This option is fulmine's own, the compression module has no equivalent.
|
|
138
159
|
* @param {number} [options.level] zlib compression level, for gzip and deflate.
|
|
139
160
|
* @param {number} [options.chunkSize] zlib chunk size.
|
|
140
161
|
* @param {number} [options.memLevel] zlib memory level.
|
|
@@ -157,6 +178,22 @@ function compression(options) {
|
|
|
157
178
|
// bytes.parse reads "1kb" and hands back null for anything it cannot, an absent option
|
|
158
179
|
// included, which is where the default comes in
|
|
159
180
|
const threshold = bytes.parse(/** @type {any} */ (opts.threshold)) ?? 1024;
|
|
181
|
+
// the mask handed to the negotiation, built once here: a name nobody knows is a config
|
|
182
|
+
// mistake and throws now rather than serving the wrong bytes later
|
|
183
|
+
let allowed = ENCODING_ANY;
|
|
184
|
+
if (opts.encodings !== undefined) {
|
|
185
|
+
if (!Array.isArray(opts.encodings)) {
|
|
186
|
+
throw new TypeError("encodings must be an array of encoding names");
|
|
187
|
+
}
|
|
188
|
+
allowed = 0;
|
|
189
|
+
for (const name of opts.encodings) {
|
|
190
|
+
const mask = ENCODING_MASKS.get(name);
|
|
191
|
+
if (mask === undefined) {
|
|
192
|
+
throw new TypeError(`unknown encoding "${name}" in encodings`);
|
|
193
|
+
}
|
|
194
|
+
allowed |= mask;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
160
197
|
|
|
161
198
|
/**
|
|
162
199
|
* A whole body, compressed on this thread. Blocks the event loop for as long as it takes,
|
|
@@ -213,8 +250,14 @@ function compression(options) {
|
|
|
213
250
|
// has to carry. Most requests to most routes cannot: a client that sent no Accept-Encoding,
|
|
214
251
|
// one that refused everything, a HEAD. Those get the Vary and nothing else, since the
|
|
215
252
|
// answer still depends on the header even when this particular client did not ask.
|
|
216
|
-
|
|
217
|
-
|
|
253
|
+
// straight from the raw entries where this request keeps them: reading req.headers here
|
|
254
|
+
// built the whole object for one name. Folded, so a repeated Accept-Encoding still reads
|
|
255
|
+
// as the joined list the headers object would have shown
|
|
256
|
+
const accept =
|
|
257
|
+
typeof req._foldedHeader === "function"
|
|
258
|
+
? req._foldedHeader("accept-encoding")
|
|
259
|
+
: req.headers["accept-encoding"];
|
|
260
|
+
let chosen = negotiateEncoding(accept === undefined ? "" : accept, allowed);
|
|
218
261
|
if (accept === undefined && ENFORCEABLE.has(enforceEncoding)) {
|
|
219
262
|
chosen = enforceEncoding;
|
|
220
263
|
}
|
package/src/middlewares.js
CHANGED
|
@@ -29,6 +29,20 @@ const qs = require("qs");
|
|
|
29
29
|
const parseQuery = require("./parse-query.js");
|
|
30
30
|
const { kGetSafe } = require("./usage.js");
|
|
31
31
|
const { AsyncResource } = require("async_hooks");
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What AsyncResource.bind answers, without node's generic wrapper: that one builds a rest-args
|
|
35
|
+
* closure and defines properties onto it per call, ~1.9us on this node, where the resource plus
|
|
36
|
+
* an arrow through runInAsyncScope restores the same context for ~0.08. The type keeps the bound
|
|
37
|
+
* function's name, as node's does.
|
|
38
|
+
*
|
|
39
|
+
* @param {(...args: any[]) => any} fn called with at most one argument by every caller here
|
|
40
|
+
* @returns {(err?: any) => any}
|
|
41
|
+
*/
|
|
42
|
+
function bindContext(fn) {
|
|
43
|
+
const resource = new AsyncResource(fn.name || "bound-anonymous-fn");
|
|
44
|
+
return (err) => resource.runInAsyncScope(fn, undefined, err);
|
|
45
|
+
}
|
|
32
46
|
const {
|
|
33
47
|
fastQueryParse,
|
|
34
48
|
NullObject,
|
|
@@ -758,7 +772,7 @@ function serveStatic(root, options) {
|
|
|
758
772
|
return res.sendFile(
|
|
759
773
|
_path,
|
|
760
774
|
options,
|
|
761
|
-
|
|
775
|
+
bindContext((e) => {
|
|
762
776
|
if (e) {
|
|
763
777
|
next(options.fallthrough && FALLTHROUGH_STATUSES.has(e.status) ? undefined : e);
|
|
764
778
|
}
|
|
@@ -911,12 +925,14 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
911
925
|
}
|
|
912
926
|
|
|
913
927
|
const length = req._rawHeader("content-length");
|
|
928
|
+
// converted once: four sites read this number on the fast path
|
|
929
|
+
const lengthNumber = length === undefined ? NaN : +length;
|
|
914
930
|
|
|
915
931
|
// No content-length and no transfer-encoding means the request carries no body at all,
|
|
916
932
|
// and a body parser must leave it alone rather than parse nothing into an empty value.
|
|
917
933
|
// type-is applies this before matching the type, but the simpleType shortcut below
|
|
918
934
|
// compares strings directly and would otherwise skip the check.
|
|
919
|
-
if (req._rawHeader("transfer-encoding") === undefined && isNaN(
|
|
935
|
+
if (req._rawHeader("transfer-encoding") === undefined && Number.isNaN(lengthNumber)) {
|
|
920
936
|
return next();
|
|
921
937
|
}
|
|
922
938
|
|
|
@@ -960,7 +976,7 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
960
976
|
// {} for json and urlencoded, '' for text, an empty Buffer for raw - rather than leaving
|
|
961
977
|
// req.body as the placeholder object. there is nothing to read, so run the tail directly,
|
|
962
978
|
// and the verify hook still runs first: webhook signature checks rely on that
|
|
963
|
-
if (
|
|
979
|
+
if (lengthNumber === 0) {
|
|
964
980
|
req.bodyRead = true;
|
|
965
981
|
const empty = Buffer.alloc(0);
|
|
966
982
|
if (!runVerify(req, res, next, options, empty)) {
|
|
@@ -969,12 +985,12 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
969
985
|
return beforeReturn(req, res, next, options, empty, encoding);
|
|
970
986
|
}
|
|
971
987
|
|
|
972
|
-
// skip reading too large body
|
|
973
|
-
if (
|
|
988
|
+
// skip reading too large body; NaN compares false, so no declared length passes
|
|
989
|
+
if (lengthNumber > limit) {
|
|
974
990
|
return next(
|
|
975
991
|
bodyError("request entity too large", 413, "entity.too.large", {
|
|
976
|
-
expected:
|
|
977
|
-
length:
|
|
992
|
+
expected: lengthNumber,
|
|
993
|
+
length: lengthNumber,
|
|
978
994
|
limit: limit
|
|
979
995
|
})
|
|
980
996
|
);
|
|
@@ -1024,7 +1040,7 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
1024
1040
|
// From here the body really gets read, and uWS delivers it on native callbacks that
|
|
1025
1041
|
// carry no async context, so this is the one continuation that has to be bound: an
|
|
1026
1042
|
// upstream middleware's AsyncLocalStorage must still be there when next runs
|
|
1027
|
-
next =
|
|
1043
|
+
next = bindContext(next);
|
|
1028
1044
|
|
|
1029
1045
|
// with nothing to decompress, uWS can collect the whole body in native code: one
|
|
1030
1046
|
// callback instead of one per chunk, the limit enforced before any byte reaches JS,
|
|
@@ -1032,8 +1048,8 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
1032
1048
|
// returns, so a view over uWS's own memory is enough. A declared length was the
|
|
1033
1049
|
// original case; a chunked body accumulates in the same native vector and only loses
|
|
1034
1050
|
// the length check, since there is no declaration to hold it to
|
|
1035
|
-
const declared =
|
|
1036
|
-
const declaresLength = !isNaN(declared) && declared > 0;
|
|
1051
|
+
const declared = lengthNumber;
|
|
1052
|
+
const declaresLength = !Number.isNaN(declared) && declared > 0;
|
|
1037
1053
|
if (!req.receivedData && !inflate && req._res.collectBody && (declaresLength || isNaN(declared))) {
|
|
1038
1054
|
req.bodyRead = true;
|
|
1039
1055
|
req._res.collectBody(limit, (body) => {
|
|
@@ -1358,50 +1374,58 @@ const urlencoded = createBodyParser(
|
|
|
1358
1374
|
function (req, res, next, options, buf, encoding) {
|
|
1359
1375
|
try {
|
|
1360
1376
|
const body = decodeBody(buf, encoding);
|
|
1361
|
-
const count = parameterCount(body, options.parameterLimit);
|
|
1362
|
-
if (count === undefined) {
|
|
1363
|
-
return next(bodyError("too many parameters", 413, "parameters.too.many"));
|
|
1364
|
-
}
|
|
1365
1377
|
// Express 5 defaults extended to false, so nested keys need opting in
|
|
1366
1378
|
const extended = typeof options.extended !== "undefined" ? options.extended : false;
|
|
1367
1379
|
// qs has to know the charset itself for anything but utf-8, and the sentinel options
|
|
1368
1380
|
// change what a parse means, so those bodies skip the fast parsers
|
|
1369
1381
|
const needsQs = encoding !== "utf-8" || options.charsetSentinel || options.interpretNumericEntities;
|
|
1370
|
-
if (extended) {
|
|
1371
|
-
// the
|
|
1372
|
-
//
|
|
1373
|
-
//
|
|
1374
|
-
const
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
allowPrototypes: true,
|
|
1393
|
-
arrayLimit: count + 1,
|
|
1394
|
-
depth: 0,
|
|
1395
|
-
strictDepth: true,
|
|
1382
|
+
if (!extended && !needsQs) {
|
|
1383
|
+
// the vendored parser, so an urlencoded body inspects like req.query does. The
|
|
1384
|
+
// parameter limit is enforced inside its scan, so the body is not walked twice;
|
|
1385
|
+
// assigned only when it held, so an overflow leaves req.body the placeholder
|
|
1386
|
+
const parsed = parseQuery(body, undefined, options.parameterLimit);
|
|
1387
|
+
if (parseQuery.overflow === true) {
|
|
1388
|
+
return next(bodyError("too many parameters", 413, "parameters.too.many"));
|
|
1389
|
+
}
|
|
1390
|
+
req.body = parsed;
|
|
1391
|
+
} else {
|
|
1392
|
+
const count = parameterCount(body, options.parameterLimit);
|
|
1393
|
+
if (count === undefined) {
|
|
1394
|
+
return next(bodyError("too many parameters", 413, "parameters.too.many"));
|
|
1395
|
+
}
|
|
1396
|
+
if (extended) {
|
|
1397
|
+
// the ceiling body-parser gives qs: the array limit rises to the parameter
|
|
1398
|
+
// count, so a form posting 150 array members still yields an array. count
|
|
1399
|
+
// counts "&" separators where body-parser counts parameters, hence the + 1
|
|
1400
|
+
const qsOptions = {
|
|
1401
|
+
...EXTENDED_QS_OPTIONS,
|
|
1402
|
+
depth: options.depth !== undefined ? options.depth : 32,
|
|
1403
|
+
arrayLimit: Math.max(100, count + 1),
|
|
1396
1404
|
charsetSentinel: options.charsetSentinel,
|
|
1397
1405
|
interpretNumericEntities: options.interpretNumericEntities,
|
|
1398
1406
|
charset: encoding,
|
|
1399
1407
|
parameterLimit: options.parameterLimit
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1408
|
+
};
|
|
1409
|
+
req.body = needsQs
|
|
1410
|
+
? Object.assign(Object.create(null), qs.parse(body, qsOptions))
|
|
1411
|
+
: fastQueryParse(body, qsOptions);
|
|
1412
|
+
} else {
|
|
1413
|
+
// body-parser's extended: false is still qs, with depth 0 and the count as
|
|
1414
|
+
// the array ceiling; only qs decodes latin1 percent escapes as latin1
|
|
1415
|
+
req.body = Object.assign(
|
|
1416
|
+
Object.create(null),
|
|
1417
|
+
qs.parse(body, {
|
|
1418
|
+
allowPrototypes: true,
|
|
1419
|
+
arrayLimit: count + 1,
|
|
1420
|
+
depth: 0,
|
|
1421
|
+
strictDepth: true,
|
|
1422
|
+
charsetSentinel: options.charsetSentinel,
|
|
1423
|
+
interpretNumericEntities: options.interpretNumericEntities,
|
|
1424
|
+
charset: encoding,
|
|
1425
|
+
parameterLimit: options.parameterLimit
|
|
1426
|
+
})
|
|
1427
|
+
);
|
|
1428
|
+
}
|
|
1405
1429
|
}
|
|
1406
1430
|
} catch (e) {
|
|
1407
1431
|
// qs reports a depth overflow as a RangeError with its own wording; body-parser
|
package/src/parse-query.js
CHANGED
|
@@ -34,17 +34,30 @@ const plusRegex = /\+/g;
|
|
|
34
34
|
* node's querystring.parse semantics on a null-prototype result: repeated keys accumulate into
|
|
35
35
|
* arrays, '+' is a space, percent sequences decode when present and stay literal when broken.
|
|
36
36
|
*
|
|
37
|
+
* `capture` collects the decoded pairs flat, key then value, so a caller can replay the stores
|
|
38
|
+
* without scanning again; a repeated key marks it invalid instead. See `get query`.
|
|
39
|
+
*
|
|
40
|
+
* `separatorLimit` refuses a body with that many "&" separators the way body-parser's
|
|
41
|
+
* parameterCount does, but inside this scan instead of a scan of its own: the overflow flag on
|
|
42
|
+
* the function is set, the partial result is to be discarded, and the caller answers 413.
|
|
43
|
+
*
|
|
37
44
|
* @param {string} input
|
|
45
|
+
* @param {string[] & {invalid?: boolean}} [capture]
|
|
46
|
+
* @param {number} [separatorLimit]
|
|
38
47
|
* @returns {Record<string, string | string[]>}
|
|
39
48
|
*/
|
|
40
|
-
function parseQuery(input) {
|
|
49
|
+
function parseQuery(input, capture, separatorLimit) {
|
|
41
50
|
const result = Object.create(null);
|
|
51
|
+
if (separatorLimit !== undefined) {
|
|
52
|
+
parseQuery.overflow = false;
|
|
53
|
+
}
|
|
42
54
|
|
|
43
55
|
if (typeof input !== "string") {
|
|
44
56
|
return result;
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
const inputLength = input.length;
|
|
60
|
+
let separators = 0;
|
|
48
61
|
let key;
|
|
49
62
|
let value = "";
|
|
50
63
|
let startingIndex = -1;
|
|
@@ -62,6 +75,12 @@ function parseQuery(input) {
|
|
|
62
75
|
|
|
63
76
|
// '&' or the end of the input closes the current pair
|
|
64
77
|
if (c === 38) {
|
|
78
|
+
// real separators only, not the synthetic closing one, counted exactly as
|
|
79
|
+
// parameterCount counts them
|
|
80
|
+
if (i !== inputLength && separatorLimit !== undefined && ++separators === separatorLimit) {
|
|
81
|
+
parseQuery.overflow = true;
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
65
84
|
hasBothKeyValuePair = equalityIndex > startingIndex;
|
|
66
85
|
|
|
67
86
|
// the equality index doubles as the end of the key when there was no '='
|
|
@@ -92,7 +111,13 @@ function parseQuery(input) {
|
|
|
92
111
|
const currentValue = result[key];
|
|
93
112
|
if (currentValue === undefined) {
|
|
94
113
|
result[key] = value;
|
|
114
|
+
if (capture !== undefined) {
|
|
115
|
+
capture.push(key, value);
|
|
116
|
+
}
|
|
95
117
|
} else {
|
|
118
|
+
if (capture !== undefined) {
|
|
119
|
+
capture.invalid = true;
|
|
120
|
+
}
|
|
96
121
|
// value.pop is cheaper than Array.isArray here, as upstream measured
|
|
97
122
|
if (currentValue.pop) {
|
|
98
123
|
currentValue.push(value);
|
|
@@ -134,4 +159,7 @@ function parseQuery(input) {
|
|
|
134
159
|
return result;
|
|
135
160
|
}
|
|
136
161
|
|
|
162
|
+
// whether the last limited call hit its separator limit, see the parameter's doc
|
|
163
|
+
parseQuery.overflow = false;
|
|
164
|
+
|
|
137
165
|
module.exports = parseQuery;
|