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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.15.0",
3
+ "version": "5.16.0",
4
4
  "description": "Drop-in Express 5 replacement on uWebSockets.js. Your existing middleware keeps working.",
5
5
  "main": "src/index.js",
6
6
  "exports": {
@@ -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 { negotiateEncoding, ENCODING_ANY, memoizeByString } = require("./utils.js");
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
- const accept = req.headers["accept-encoding"];
217
- let chosen = negotiateEncoding(accept === undefined ? "" : accept, ENCODING_ANY);
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
  }
@@ -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
- AsyncResource.bind((e) => {
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(length)) {
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 (Number(length) === 0) {
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 (length && +length > limit) {
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: +length,
977
- length: +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 = AsyncResource.bind(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 = Number(length);
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 ceiling body-parser gives qs: the array limit rises to the parameter count,
1372
- // so a form posting 150 array members still yields an array. count counts "&"
1373
- // separators where body-parser counts parameters, hence the + 1
1374
- const qsOptions = {
1375
- ...EXTENDED_QS_OPTIONS,
1376
- depth: options.depth !== undefined ? options.depth : 32,
1377
- arrayLimit: Math.max(100, count + 1),
1378
- charsetSentinel: options.charsetSentinel,
1379
- interpretNumericEntities: options.interpretNumericEntities,
1380
- charset: encoding,
1381
- parameterLimit: options.parameterLimit
1382
- };
1383
- req.body = needsQs
1384
- ? Object.assign(Object.create(null), qs.parse(body, qsOptions))
1385
- : fastQueryParse(body, qsOptions);
1386
- } else if (needsQs) {
1387
- // body-parser's extended: false is still qs, with depth 0 and the count as the
1388
- // array ceiling; only qs decodes latin1 percent escapes as latin1
1389
- req.body = Object.assign(
1390
- Object.create(null),
1391
- qs.parse(body, {
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
- } else {
1403
- // the vendored parser, so an urlencoded body inspects like req.query does
1404
- req.body = parseQuery(body);
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
@@ -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;