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/src/request.js CHANGED
@@ -229,6 +229,13 @@ function endsWithChunked(value) {
229
229
  * @returns {boolean}
230
230
  */
231
231
  function saysClose(value) {
232
+ // what clients actually send, almost always: two interned compares answer before the scan
233
+ if (value === "keep-alive") {
234
+ return false;
235
+ }
236
+ if (value === "close") {
237
+ return true;
238
+ }
232
239
  const length = value.length;
233
240
  let at = 0;
234
241
  while (at < length) {
@@ -662,6 +669,19 @@ module.exports = class Request extends LazyReadable {
662
669
  */
663
670
  noEtag;
664
671
 
672
+ /**
673
+ * The decoded pairs of the first default-parser parse of req.query, flat key,value; false
674
+ * when it saw a repeated key, which a flat replay cannot reproduce. See `get query`.
675
+ * @type {string[]|false|undefined}
676
+ */
677
+ _querySnap;
678
+
679
+ /**
680
+ * The raw string _querySnap came from: a url rewrite replaces _rawQuery.
681
+ * @type {string|undefined}
682
+ */
683
+ _querySnapRaw;
684
+
665
685
  /**
666
686
  * Built for every request, which is why so little happens here. The headers are copied out
667
687
  * because uWS only lends them for this call, everything derived from them waits until something
@@ -681,23 +701,24 @@ module.exports = class Request extends LazyReadable {
681
701
  super();
682
702
  this._res = res;
683
703
  this._req = req;
684
- // the plain field behind the `readable` accessor, written rather than set so a request that
685
- // never streams never builds a stream
686
- this._readableFlag = true;
687
704
  if (skipHolder !== undefined && skipHolder.skipHeaders) {
688
705
  // The chain behind this registration provably never reads a header, so instead of
689
- // copying them all out of uWS the constructor asks for the four that steer the
690
- // framework itself: body framing, keep-alive, and accept for the error page a
691
- // throw could still need. A GET that does declare a body is the rare case, and
692
- // the parsers and the stream want the whole picture, so it takes the full copy.
706
+ // copying them all out of uWS the constructor asks for the ones that steer the
707
+ // framework itself: body framing, keep-alive, and the conditional pair. A GET that
708
+ // does declare a body is the rare case, and the parsers and the stream want the
709
+ // whole picture, so it takes the full copy.
693
710
  //
694
- // Seven named reads against one forEach looks like it should lose, and does not: the
695
- // seven are flat at 0.75us however many headers are on the wire, since each one is a
696
- // napi crossing and the scan behind it is nothing, while the copy pays a hop back into
697
- // JS per header and grows, 1.16us at four headers, 1.61 at eight, 2.90 at sixteen. They
698
- // do not cross, and the gap widens exactly where real traffic lives, since a browser
699
- // sends a dozen or more. The body case pays two of the seven and then copies anyway,
700
- // which is 0.2us on a request that is about to read a body.
711
+ // A handful of named reads against one forEach looks like it should lose, and does
712
+ // not: measured at seven reads they were flat at 0.75us however many headers are on
713
+ // the wire, since each one is a napi crossing and the scan behind it is nothing,
714
+ // while the copy pays a hop back into JS per header and grows, 1.16us at four
715
+ // headers, 1.61 at eight, 2.90 at sixteen. They do not cross, and the gap widens
716
+ // exactly where real traffic lives, since a browser sends a dozen or more. The body
717
+ // case pays two reads and then copies anyway, which is 0.2us on a request that is
718
+ // about to read a body.
719
+ //
720
+ // accept is not read: nothing on a granted chain consumes it, the error and 404
721
+ // pages are fixed HTML that never negotiate.
701
722
  const length = req.getHeader("content-length");
702
723
  const transferEncoding = req.getHeader("transfer-encoding");
703
724
  // A content-length of "0" declares no body and used to stay on the cheap side, but
@@ -724,12 +745,8 @@ module.exports = class Request extends LazyReadable {
724
745
  this._connectionClose = true;
725
746
  }
726
747
  }
727
- const accept = req.getHeader("accept");
728
- if (accept !== "") {
729
- entries.push("accept", accept);
730
- }
731
748
  // send consults freshness whatever the etag setting: if-none-match can be "*"
732
- // and a handler may set a validator by hand, so the conditional trio has to be
749
+ // and a handler may set a validator by hand, so the conditional pair has to be
733
750
  // really absent rather than merely uncopied
734
751
  const ifNoneMatch = req.getHeader("if-none-match");
735
752
  if (ifNoneMatch !== "") {
@@ -739,9 +756,13 @@ module.exports = class Request extends LazyReadable {
739
756
  if (ifModifiedSince !== "") {
740
757
  entries.push("if-modified-since", ifModifiedSince);
741
758
  }
742
- const cacheControl = req.getHeader("cache-control");
743
- if (cacheControl !== "") {
744
- entries.push("cache-control", cacheControl);
759
+ // fresh() answers false before it reads cache-control unless a conditional
760
+ // arrived, so the read only pays on the requests that can use it
761
+ if (ifNoneMatch !== "" || ifModifiedSince !== "") {
762
+ const cacheControl = req.getHeader("cache-control");
763
+ if (cacheControl !== "") {
764
+ entries.push("cache-control", cacheControl);
765
+ }
745
766
  }
746
767
  }
747
768
  } else {
@@ -802,18 +823,26 @@ module.exports = class Request extends LazyReadable {
802
823
  this._opPath = this._path;
803
824
  this._originalPath = this._path;
804
825
  const rawMethod = req.getCaseSensitiveMethod();
805
- this.method = rawMethod.toUpperCase();
806
- // node's parser knows a fixed set and refuses everything else; µWS takes the token as
807
- // it finds it, so a request line is anything with a space in it. Compared before the
808
- // uppercasing on purpose: a method is case sensitive, node refuses "post", and µWS
809
- // folds it to POST and serves it. Only asked of a method the framework cannot route
810
- // anyway, since a route can only be registered for one of these, see the loop that
811
- // builds the verb methods at the end of router.js
812
- if (!KNOWN_METHODS.has(rawMethod)) {
813
- this._mustRefuse = true;
826
+ if (skipHolder !== undefined && rawMethod === skipHolder.method) {
827
+ // the registration's constant, byte for byte; any other spelling, "get" that µWS
828
+ // folded here included, takes the full check below
829
+ this.method = rawMethod;
830
+ this._isOptions = skipHolder.isOptions;
831
+ this._isHead = skipHolder.isHead;
832
+ } else {
833
+ this.method = rawMethod.toUpperCase();
834
+ // node's parser knows a fixed set and refuses everything else; µWS takes the token
835
+ // as it finds it, so a request line is anything with a space in it. Compared before
836
+ // the uppercasing on purpose: a method is case sensitive, node refuses "post", and
837
+ // µWS folds it to POST and serves it. Only asked of a method the framework cannot
838
+ // route anyway, since a route can only be registered for one of these, see the loop
839
+ // that builds the verb methods at the end of router.js
840
+ if (!KNOWN_METHODS.has(rawMethod)) {
841
+ this._mustRefuse = true;
842
+ }
843
+ this._isOptions = this.method === "OPTIONS";
844
+ this._isHead = this.method === "HEAD";
814
845
  }
815
- this._isOptions = this.method === "OPTIONS";
816
- this._isHead = this.method === "HEAD";
817
846
  }
818
847
  // what the router last saw as the method. A middleware assigning another one is a rewrite,
819
848
  // which express honours because it reads req.method at every layer, and dispatch compares
@@ -839,9 +868,6 @@ module.exports = class Request extends LazyReadable {
839
868
  // null for the same reason as the two above: a request that never enters a mount never
840
869
  // needs either array, and the push sites materialize them
841
870
  this._stack = null;
842
- // how many characters of _originalPath the mounts entered so far have taken, which is
843
- // where baseUrl ends and the path below them begins
844
- this._consumed = 0;
845
871
  // whether one of them took a trailing slash, which only a RegExp mount can: see baseUrl
846
872
  this._mountSlash = false;
847
873
  this._paramStack = null;
@@ -925,6 +951,35 @@ module.exports = class Request extends LazyReadable {
925
951
  return undefined;
926
952
  }
927
953
 
954
+ /**
955
+ * The same, with repeats folded exactly as the headers object folds them, so a reader of one
956
+ * name per request does not build the whole object to stay correct on a repeated header.
957
+ * Not for set-cookie, whose folded form is an array.
958
+ *
959
+ * @param {string} name lowercase
960
+ * @returns {string|undefined}
961
+ */
962
+ _foldedHeader(name) {
963
+ if (this.#cachedHeaders !== null) {
964
+ return this.#cachedHeaders[name];
965
+ }
966
+ const entries = this.#rawHeadersEntries;
967
+ let value;
968
+ for (let i = 0, len = entries.length; i < len; i += 2) {
969
+ if (entries[i] === name) {
970
+ if (value === undefined) {
971
+ value = entries[i + 1];
972
+ } else {
973
+ if (discardedDuplicates.has(name)) {
974
+ continue;
975
+ }
976
+ value += (name === "cookie" ? "; " : ", ") + entries[i + 1];
977
+ }
978
+ }
979
+ }
980
+ return value;
981
+ }
982
+
928
983
  /**
929
984
  * Whether there is any point still reading the body: once the response is finished or the
930
985
  * connection is gone, uWS has nothing left to hand over.
@@ -1039,7 +1094,11 @@ module.exports = class Request extends LazyReadable {
1039
1094
  // slash, a registered path having had it removed, so almost every request answers above.
1040
1095
  let out = "";
1041
1096
  let at = 0;
1042
- for (const taken of this._stack) {
1097
+ for (let taken of this._stack) {
1098
+ // negative marks a mount that consumed the whole path, see the push in runRoute
1099
+ if (taken < 0) {
1100
+ taken = -taken;
1101
+ }
1043
1102
  const piece = this._originalPath.slice(at, at + taken);
1044
1103
  at += taken;
1045
1104
  out += piece.charCodeAt(taken - 1) === 0x2f ? piece.slice(0, -1) : piece;
@@ -1199,18 +1258,30 @@ module.exports = class Request extends LazyReadable {
1199
1258
  * path, and req.query reflects the new query string. The assigned url is relative to the
1200
1259
  * mount the request is currently in, as it is in express, so the absolute path is rebuilt
1201
1260
  * from the piece the mounts had consumed.
1261
+ *
1262
+ * @param {boolean} [leavingMount] the caller is popping the mount the rewrite happened in
1202
1263
  */
1203
- _absorbUrlRewrite() {
1204
- const newUrl = String(this.url);
1205
- const queryIndex = newUrl.indexOf("?");
1206
- const newPath = queryIndex === -1 ? newUrl : newUrl.slice(0, queryIndex);
1264
+ _absorbUrlRewrite(leavingMount) {
1265
+ const assignedUrl = String(this.url);
1266
+ let newUrl = assignedUrl;
1207
1267
  // the prefix the mounts consumed: everything of the absolute path the relative one was not
1208
1268
  const lastQueryIndex = this._lastUrl.indexOf("?");
1209
1269
  const oldPath = lastQueryIndex === -1 ? this._lastUrl : this._lastUrl.slice(0, lastQueryIndex);
1210
- const prefix =
1211
- oldPath === "/" && !this._originalPath.endsWith("/")
1212
- ? this._originalPath
1213
- : this._originalPath.slice(0, this._originalPath.length - oldPath.length);
1270
+ let prefix;
1271
+ if (oldPath === "/" && !this._originalPath.endsWith("/")) {
1272
+ prefix = this._originalPath;
1273
+ // express's slashAdded restore, applied where express applies it, on the way out of a
1274
+ // mount that consumed the whole path: the "/" the middleware saw was invented, and the
1275
+ // rejoin strips the first character of whatever was assigned ("/found" + "target" is
1276
+ // "/foundtarget"). Inside the mount the assigned url routes as it is, as express does
1277
+ if (leavingMount === true) {
1278
+ newUrl = newUrl.slice(1);
1279
+ }
1280
+ } else {
1281
+ prefix = this._originalPath.slice(0, this._originalPath.length - oldPath.length);
1282
+ }
1283
+ const queryIndex = newUrl.indexOf("?");
1284
+ const newPath = queryIndex === -1 ? newUrl : newUrl.slice(0, queryIndex);
1214
1285
  this._rawQuery = queryIndex === -1 ? "" : newUrl.slice(queryIndex + 1);
1215
1286
  // a rewrite to "/a?" keeps its "?", as one arriving that way does
1216
1287
  this.urlQuery = queryIndex === -1 ? "" : "?" + this._rawQuery;
@@ -1220,7 +1291,9 @@ module.exports = class Request extends LazyReadable {
1220
1291
  this._opPath = newPath;
1221
1292
  this._opPathLower = null;
1222
1293
  this._mayFailDecode = null;
1223
- this._lastUrl = newUrl;
1294
+ // the assigned string, not the mangled one: the compare against req.url must go quiet or
1295
+ // the next hop absorbs the same rewrite again with a different prefix
1296
+ this._lastUrl = assignedUrl;
1224
1297
  }
1225
1298
 
1226
1299
  /**
@@ -1251,15 +1324,16 @@ module.exports = class Request extends LazyReadable {
1251
1324
  * the parse cached and handed out as itself, the sanitised value leaked into req.query here and
1252
1325
  * a handler written against express read a trimmed value where express gives it the raw one.
1253
1326
  *
1254
- * And that is why there is no cache: the fresh object comes from parsing the raw string again,
1255
- * not from copying a kept parse. As first shipped this was parse-once-copy-per-read, and the
1256
- * copy was the expensive half: Object.assign between null-prototype objects, which live in
1257
- * V8's dictionary mode, measured 638ns for a two-parameter query where parsing the same string
1258
- * measures 119ns, and on a benchmark whose every request carries such a query it cost +1.5us
1259
- * of CPU per request, which a public arena saw as -8% on its query-carrying rows. A handler
1260
- * that reads req.query once per request now pays exactly what it paid when the parse was
1261
- * cached, one parse, and a handler that reads it N times pays N parses, which is express's
1262
- * own cost shape.
1327
+ * And that is why there is no cache of the object: the fresh object comes from the raw
1328
+ * string, not from copying a kept parse. As first shipped this was parse-once-copy-per-read,
1329
+ * and the copy was the expensive half: Object.assign between null-prototype objects, which
1330
+ * live in V8's dictionary mode, measured 638ns for a two-parameter query where parsing the
1331
+ * same string measures 119ns, and on a benchmark whose every request carries such a query it
1332
+ * cost +1.5us of CPU per request, which a public arena saw as -8% on its query-carrying rows.
1333
+ *
1334
+ * The default parser does keep the decoded pairs of its first parse, and a later read of the
1335
+ * same raw string replays the stores into a fresh null-prototype object: identical output,
1336
+ * still nothing shared between reads. A repeated key cannot be replayed and re-parses.
1263
1337
  *
1264
1338
  * @returns {Record<string, any>}
1265
1339
  */
@@ -1277,7 +1351,25 @@ module.exports = class Request extends LazyReadable {
1277
1351
  return Object.create(null);
1278
1352
  }
1279
1353
  if (qp === parseQuery) {
1280
- return parseQuery(this._rawQuery);
1354
+ const raw = this._rawQuery;
1355
+ if (this._querySnapRaw === raw) {
1356
+ const snap = this._querySnap;
1357
+ if (snap === false) {
1358
+ return parseQuery(raw);
1359
+ }
1360
+ const out = Object.create(null);
1361
+ const pairs = /** @type {string[]} */ (snap);
1362
+ for (let i = 0, len = pairs.length; i < len; i += 2) {
1363
+ out[pairs[i]] = pairs[i + 1];
1364
+ }
1365
+ return out;
1366
+ }
1367
+ /** @type {string[] & {invalid?: boolean}} */
1368
+ const capture = [];
1369
+ const out = parseQuery(raw, capture);
1370
+ this._querySnapRaw = raw;
1371
+ this._querySnap = capture.invalid === true ? false : capture;
1372
+ return out;
1281
1373
  }
1282
1374
  if (qp === fastQueryParse) {
1283
1375
  return Object.assign(Object.create(null), fastQueryParse(this._rawQuery));
@@ -1686,7 +1778,9 @@ module.exports = class Request extends LazyReadable {
1686
1778
  const value = entries[index + 1];
1687
1779
  // lowercase by the entries' contract, see the field declaration
1688
1780
  const key = entries[index];
1689
- if (Object.hasOwn(headers, key)) {
1781
+ // own values are never undefined, so the read answers "absent" without the hasOwn
1782
+ // call; a prototype-named header reads truthy and still takes the hasOwn check
1783
+ if (headers[key] !== undefined && Object.hasOwn(headers, key)) {
1690
1784
  if (discardedDuplicates.has(key)) {
1691
1785
  continue;
1692
1786
  }
package/src/response.js CHANGED
@@ -72,15 +72,37 @@ const symbols = Object.getOwnPropertySymbols(outgoingMessage);
72
72
  // if a future node renames it, fall back to a private symbol rather than writing a property
73
73
  // literally named "undefined", which is what indexing with undefined would do
74
74
  const kOutHeaders = symbols.find((s) => s.toString() === "Symbol(kOutHeaders)") ?? Symbol("kOutHeaders");
75
+ // node's emitters tombstone a removed listener's slot instead of deleting it when this flag is
76
+ // set, which is what keeps _events in a stable shape. EventEmitter.init sets it, and never runs
77
+ // for the lazily-materialized response, so it is set by hand; a future rename degrades to the
78
+ // delete, not to an error
79
+ const kShapeMode =
80
+ Object.getOwnPropertySymbols(new EventEmitter()).find((s) => s.toString() === "Symbol(shapeMode)") ??
81
+ Symbol("shapeMode");
82
+ // names setHeader has validated and lowercased, so the constant names middleware writes per
83
+ // request are one Map hit. Insert-only after validation, bounded; only setHeader may insert,
84
+ // the never-throwing readers keep their plain toLowerCase
85
+ const VALIDATED_HEADER_NAMES = new Map();
75
86
  const HIGH_WATERMARK = 128 * 1024;
76
- // Statuses whose message carries no body, so no Content-Length may describe one either. 1xx is
77
- // the third case and is checked by range rather than listed.
78
- const STATUSES_WITHOUT_BODY = new Set([204, 304]);
87
+ // the exact string json() writes, so send() can skip recomputing the charset on it
88
+ const JSON_UTF8 = "application/json; charset=utf-8";
79
89
  // send's ceiling for maxAge, one year in milliseconds. Anything larger is clamped to it rather
80
90
  // than written out, since a year is already longer than any cache will honour.
81
91
  const MAX_MAXAGE = 60 * 60 * 24 * 365 * 1000;
82
92
 
83
93
  class Socket extends EventEmitter {
94
+ /**
95
+ * The Socket's own error listener, shared across sockets: an error on the stand-in closes it,
96
+ * which is the close the connection trackers wait for. EventEmitter calls it with this = the
97
+ * emitter.
98
+ *
99
+ * @this {any}
100
+ * @param {any} err
101
+ */
102
+ static _onError(err) {
103
+ this.emit("close");
104
+ }
105
+
84
106
  /**
85
107
  * Enough of a node socket for the middleware that reaches for one. There is no socket object
86
108
  * in uWS to hand over, so this stands in and forwards what it can to the response.
@@ -90,10 +112,10 @@ class Socket extends EventEmitter {
90
112
  constructor(response) {
91
113
  super();
92
114
  this.response = response;
115
+ this[kShapeMode] = true;
93
116
 
94
- this.on("error", (err) => {
95
- this.emit("close");
96
- });
117
+ // shared, not an arrow: one per process instead of one per materialized socket
118
+ this.on("error", Socket._onError);
97
119
  }
98
120
 
99
121
  /** Whether anything more can be written, which stops being true once the response is done. */
@@ -276,6 +298,11 @@ module.exports = class Response extends LazyWritable {
276
298
  drain: undefined
277
299
  };
278
300
  this._eventsCount = 0;
301
+ // tombstone removed listeners instead of deleting the key, as node's own streams do: the
302
+ // delete flipped this literal to dictionary mode on every on-finished cancel
303
+ this[kShapeMode] = true;
304
+ // on-finished stores its state here; declared so that store is not a shape change
305
+ this.__onFinished = null;
279
306
  this._req = req;
280
307
  // linked here rather than by the caller: the pair is built together, and a field the
281
308
  // constructor leaves unset is a shape change on whoever assigns it first
@@ -801,7 +828,9 @@ module.exports = class Response extends LazyWritable {
801
828
  // without one writes "Content-Length: 9223372036854775808" onto a 204. Those two paths keep
802
829
  // µWS's own rule, which closes for a bare "close" and not for a list.
803
830
  const closeConnection = this.req._connectionClose === true;
804
- if (STATUSES_WITHOUT_BODY.has(this.statusCode) || this.statusCode < 200) {
831
+ // 204 and 304 carry no body, so no Content-Length may describe one either; 1xx is the
832
+ // third case, by range
833
+ if (this.statusCode === 204 || this.statusCode === 304 || this.statusCode < 200) {
805
834
  // no body and no length describing one, whatever the caller passed. node decides
806
835
  // this the same way, from the status alone, so res.status(304).end("x") sends the
807
836
  // status and nothing else on either.
@@ -905,9 +934,10 @@ module.exports = class Response extends LazyWritable {
905
934
  if (!skipContentType) {
906
935
  this.headers["content-type"] = "text/html; charset=utf-8";
907
936
  }
908
- } else if (typeof contentType === "string") {
937
+ } else if (typeof contentType === "string" && contentType !== JSON_UTF8) {
909
938
  // replaced, not only added: the body goes out as utf-8, so a content-type saying
910
- // iso-8859-1 would be describing bytes that are not there.
939
+ // iso-8859-1 would be describing bytes that are not there. The json() literal is
940
+ // already exactly that, so the common res.json answer skips the recomputation
911
941
  this.headers["content-type"] = withUtf8Charset(contentType);
912
942
  }
913
943
  } else {
@@ -1374,7 +1404,16 @@ module.exports = class Response extends LazyWritable {
1374
1404
  if (this.headersSent) {
1375
1405
  throw new Error("Cannot set headers after they are sent to the client");
1376
1406
  }
1377
- validateHeaderName(field);
1407
+ // one Map hit for a name already validated and lowercased: middleware writes the same
1408
+ // constant names on every request. Insert-only after validation, so no bad name can enter
1409
+ let key = VALIDATED_HEADER_NAMES.get(field);
1410
+ if (key === undefined) {
1411
+ validateHeaderName(field);
1412
+ key = field.toLowerCase();
1413
+ if (VALIDATED_HEADER_NAMES.size < 512) {
1414
+ VALIDATED_HEADER_NAMES.set(field, key);
1415
+ }
1416
+ }
1378
1417
  if (value === undefined) {
1379
1418
  /** @type {NodeJS.ErrnoException} */
1380
1419
  const err = new TypeError(`Invalid value "undefined" for header "${field}"`);
@@ -1388,7 +1427,7 @@ module.exports = class Response extends LazyWritable {
1388
1427
  // catch it
1389
1428
  const out = Array.isArray(value) ? value.map(String) : String(value);
1390
1429
  validateHeaderValue(field, out);
1391
- this.headers[field.toLowerCase()] = out;
1430
+ this.headers[key] = out;
1392
1431
  return this;
1393
1432
  }
1394
1433
 
@@ -1737,7 +1776,11 @@ module.exports = class Response extends LazyWritable {
1737
1776
  * @param {string} field
1738
1777
  */
1739
1778
  removeHeader(field) {
1740
- delete this.headers[field.toLowerCase()];
1779
+ const key = field.toLowerCase();
1780
+ // the delete is a runtime call, and helmet removes a header most responses never carry
1781
+ if (key in this.headers) {
1782
+ delete this.headers[key];
1783
+ }
1741
1784
  }
1742
1785
 
1743
1786
  /**
@@ -1912,7 +1955,7 @@ module.exports = class Response extends LazyWritable {
1912
1955
  */
1913
1956
  json(body) {
1914
1957
  if (!this.headers["content-type"]) {
1915
- this.headers["content-type"] = "application/json; charset=utf-8";
1958
+ this.headers["content-type"] = JSON_UTF8;
1916
1959
  }
1917
1960
  const hot = this.app._hot();
1918
1961
  return this.send(stringify(body, hot.jsonReplacer, hot.jsonSpaces, hot.jsonEscape));