fulmine.js 5.21.4 → 5.21.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.21.4",
3
+ "version": "5.21.5",
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": {
@@ -31,13 +31,43 @@ const parseQuery = require("./parse-query.js");
31
31
  const { kGetSafe } = require("./usage.js");
32
32
  const { AsyncResource } = require("async_hooks");
33
33
 
34
+ // node's unexported Symbol("context_frame"), where an AsyncResource keeps the store it captured,
35
+ // read off the first resource. null when node has none, then every binding keeps its scope
36
+ /** @type {symbol|null|undefined} */
37
+ let kContextFrame;
38
+ // set once an init hook was seen, never cleared: from then on every binding has its scope as before
39
+ let initHooksSeen = false;
40
+
34
41
  /**
35
- * AsyncResource.bind without node's generic wrapper: ~1.9us per call there, ~0.08 here.
42
+ * raw-body's wrap: AsyncResource.bind without node's generic wrapper (~1.9us per call there), and no
43
+ * scope without an AsyncLocalStorage store or an init hook: 2.3 to 3.5us per request in a uWS
44
+ * callback, +9 to 16% on the arena's POST. new AsyncResource("") throws only while a hook listens.
36
45
  *
37
46
  * @param {(...args: any[]) => any} fn called with at most one argument by every caller here
38
47
  * @returns {(err?: any) => any}
39
48
  */
40
49
  function bindContext(fn) {
50
+ if (!initHooksSeen) {
51
+ /** @type {AsyncResource|undefined} */
52
+ let probe;
53
+ try {
54
+ probe = new AsyncResource("");
55
+ } catch {
56
+ initHooksSeen = true;
57
+ }
58
+ if (probe !== undefined) {
59
+ if (kContextFrame === undefined) {
60
+ kContextFrame =
61
+ Object.getOwnPropertySymbols(probe).find((s) => s.description === "context_frame") ?? null;
62
+ }
63
+ if (kContextFrame !== null && /** @type {any} */ (probe)[kContextFrame] === undefined) {
64
+ return fn;
65
+ }
66
+ // a store to carry, and no init hook to have seen the empty type: the probe is the resource
67
+ const resource = probe;
68
+ return (err) => resource.runInAsyncScope(fn, undefined, err);
69
+ }
70
+ }
41
71
  const resource = new AsyncResource(fn.name || "bound-anonymous-fn");
42
72
  return (err) => resource.runInAsyncScope(fn, undefined, err);
43
73
  }
@@ -586,8 +616,14 @@ function serveStatic(root, options) {
586
616
  }
587
617
  const ext = path.extname(fullpath);
588
618
  let i = 0;
589
- // no extension on a directory; on the decoded url, as send tries it
590
- if (ext === "" && !url.endsWith("/") && options.extensions) {
619
+ // no extension on a directory; on the decoded url and only after ENOENT, as send does:
620
+ // ENOTDIR or ENAMETOOLONG report the path asked, not the last extension (fuzz seed 3561875562)
621
+ if (
622
+ /** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT" &&
623
+ ext === "" &&
624
+ !url.endsWith("/") &&
625
+ options.extensions
626
+ ) {
591
627
  while (i < options.extensions.length) {
592
628
  try {
593
629
  stat = fs.statSync(fullpath + "." + options.extensions[i]);
@@ -1174,9 +1210,14 @@ const json = createBodyParser(
1174
1210
 
1175
1211
  // strict: only an object or an array is a body
1176
1212
  if (options.strict !== false) {
1177
- // exactly the four characters body-parser skips
1178
- // eslint-disable-next-line no-control-regex
1179
- const first = text.match(/^[\x20\x09\x0a\x0d]*([^\x20\x09\x0a\x0d])/)?.[1];
1213
+ // the four characters body-parser skips, space, tab, LF and CR, with a loop: the regex
1214
+ // allocated a match array per body, 56ns to 14
1215
+ let at = 0;
1216
+ let code = text.charCodeAt(0);
1217
+ while (code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d) {
1218
+ code = text.charCodeAt(++at);
1219
+ }
1220
+ const first = at < text.length ? text[at] : undefined;
1180
1221
  if (first !== "{" && first !== "[") {
1181
1222
  // a SyntaxError, as body-parser builds it
1182
1223
  return next(
package/src/request.js CHANGED
@@ -999,8 +999,9 @@ module.exports = class Request extends LazyReadable {
999
999
  /** @type {string|undefined} */
1000
1000
  let ip;
1001
1001
  if (rawIp.byteLength === 4) {
1002
- // plain, as node writes an IPv4 peer on an IPv4 socket
1003
- ip = new Uint8Array(rawIp).join(".");
1002
+ // plain, as node writes an IPv4 peer; by hand, Uint8Array join() was 420us per 1000 requests
1003
+ const bytes = new Uint8Array(rawIp);
1004
+ ip = bytes[0] + "." + bytes[1] + "." + bytes[2] + "." + bytes[3];
1004
1005
  } else if (rawIp.byteLength === 16) {
1005
1006
  const bytes = new Uint8Array(rawIp);
1006
1007
  if (isMappedIPv4(bytes)) {
@@ -34,8 +34,30 @@ const kShapeMode =
34
34
  Symbol("shapeMode");
35
35
  // names setHeader has validated and lowercased, so the constant names middleware writes per
36
36
  // request are one Map hit. Insert-only after validation, bounded; only setHeader may insert,
37
- // the never-throwing readers keep their plain toLowerCase
37
+ // the never-throwing readers have HEADER_KEYS below
38
38
  const VALIDATED_HEADER_NAMES = new Map();
39
+ // lowercase names for the header readers, apart from the map above as nothing here is validated.
40
+ // A fresh lowercase key goes through the V8 runtime on res.headers: 76ns against 6 cached, and
41
+ // helmet's removeHeader("X-Powered-By") alone was 190us per thousand requests. Bounded too
42
+ /** @type {Map<string, string>} */
43
+ const HEADER_KEYS = new Map();
44
+
45
+ /**
46
+ * The key a header lives under in res.headers, for the readers.
47
+ *
48
+ * @param {string} field the name as the caller wrote it
49
+ * @returns {string}
50
+ */
51
+ function headerKey(field) {
52
+ let key = HEADER_KEYS.get(field);
53
+ if (key === undefined) {
54
+ key = field.toLowerCase();
55
+ if (HEADER_KEYS.size < 512 && typeof field === "string") {
56
+ HEADER_KEYS.set(field, key);
57
+ }
58
+ }
59
+ return key;
60
+ }
39
61
  // The names and values that recur on every response, kept as Buffers for the uWS crossing: a Buffer
40
62
  // is memcpy'd as it is, a string pays a UTF-8 scan and copy per call. A header that is not here
41
63
  // crosses as the string it was. Names must stay lowercase, as writeHeaders receives them. The
@@ -121,6 +143,7 @@ module.exports = {
121
143
  kOutHeaders,
122
144
  kShapeMode,
123
145
  VALIDATED_HEADER_NAMES,
146
+ headerKey,
124
147
  HEADER_NAME_BUF,
125
148
  HEADER_VALUE_BUF,
126
149
  statusLine,
package/src/response.js CHANGED
@@ -57,6 +57,7 @@ const {
57
57
  kOutHeaders,
58
58
  kShapeMode,
59
59
  VALIDATED_HEADER_NAMES,
60
+ headerKey,
60
61
  HEADER_NAME_BUF,
61
62
  HEADER_VALUE_BUF,
62
63
  statusLine,
@@ -77,6 +78,10 @@ const JSON_UTF8 = "application/json; charset=utf-8";
77
78
 
78
79
  // the seeded Keep-Alive, a constant so setHeader can tell it from one the application set
79
80
  const SEEDED_KEEP_ALIVE = "timeout=10";
81
+ // the seeded connection and keep-alive as one value, every response writes them in one call:
82
+ // 572ns to 438 per head with a content-type, on node 26
83
+ const SEEDED_PAIR = "keep-alive\r\nkeep-alive: " + SEEDED_KEEP_ALIVE;
84
+ const SEEDED_PAIR_BUF = Buffer.from(SEEDED_PAIR);
80
85
  // send's ceiling for maxAge, one year
81
86
  const MAX_MAXAGE = 60 * 60 * 24 * 365 * 1000;
82
87
 
@@ -600,8 +605,11 @@ module.exports = class Response extends LazyWritable {
600
605
 
601
606
  /**
602
607
  * Writes every header set so far to uWS, the point of no return. Content-Length is kept on
603
- * totalSize instead: uWS takes the length through tryEnd or endWithoutBody. One writeHeader
604
- * per header: packing the head into one writeStatus measured slower, see issue #11.
608
+ * totalSize instead: uWS takes the length through tryEnd or endWithoutBody.
609
+ *
610
+ * From the third line on the head goes in one writeHeader, joined in the first value, as uWS
611
+ * writes `name: value\r\n` without reading it. Per head on node 26: helmet's set 3953ns to 1327,
612
+ * five lines 1013 to 531. One or two lines keep a call each, the join costs what it saves (issue #11).
605
613
  *
606
614
  * @param {boolean} utf8 unused, kept because node's equivalent takes it and the two callers
607
615
  * differ on what they know about the body
@@ -614,6 +622,16 @@ module.exports = class Response extends LazyWritable {
614
622
  // length first: the value is nearly always "keep-alive", which paid a lowercase per response
615
623
  const closing =
616
624
  typeof connection === "string" && connection.length === 5 && connection.toLowerCase() === "close";
625
+ // the node shim writes a line with node's appendHeader, that refuses the CRLF of a join
626
+ const joins = res._nodeRes === undefined;
627
+ // lines not written yet: the first two apart, from the third on in joined
628
+ let lines = 0;
629
+ let pair = false;
630
+ let firstName = "";
631
+ let firstValue = "";
632
+ let secondName = "";
633
+ let secondValue = "";
634
+ let joined = "";
617
635
  // for..in over an object a few responses delete from (204, 304, 205, a 304 file,
618
636
  // removeHeader), rare enough to keep the shape, unlike the request side
619
637
  for (const header in headers) {
@@ -637,20 +655,107 @@ module.exports = class Response extends LazyWritable {
637
655
  this.#userChunked = true;
638
656
  continue;
639
657
  }
640
- // the recurring names and values cross as cached Buffers, see HEADER_NAME_BUF
641
- const name = HEADER_NAME_BUF[header] || header;
642
- if (Array.isArray(value)) {
643
- for (const val of value) {
644
- res.writeHeader(name, HEADER_VALUE_BUF[val] || val);
658
+ // a string first, nearly every value is one
659
+ const isText = typeof value === "string";
660
+ const list = isText || !Array.isArray(value) ? null : value;
661
+ // only text is joined, the rest crosses alone as before. false is joined as "false", the
662
+ // content-type res.set stores for an unknown type
663
+ let joinable = joins && (isText || list !== null || value === false);
664
+ if (joinable && list !== null) {
665
+ for (let i = 0; i < list.length; i++) {
666
+ if (typeof list[i] !== "string") {
667
+ joinable = false;
668
+ break;
669
+ }
645
670
  }
646
- } else {
647
- res.writeHeader(name, HEADER_VALUE_BUF[value] || value);
648
671
  }
672
+ if (!joinable) {
673
+ // the lines before this one go first, so the order on the wire holds
674
+ if (lines !== 0) {
675
+ Response.#writeLines(res, lines, pair, firstName, firstValue, secondName, secondValue, joined);
676
+ lines = 0;
677
+ pair = false;
678
+ }
679
+ // the recurring names and values cross as cached Buffers, see HEADER_NAME_BUF
680
+ const name = HEADER_NAME_BUF[header] || header;
681
+ if (list !== null) {
682
+ for (const val of list) {
683
+ res.writeHeader(name, HEADER_VALUE_BUF[val] || val);
684
+ }
685
+ } else {
686
+ res.writeHeader(name, HEADER_VALUE_BUF[value] || value);
687
+ }
688
+ continue;
689
+ }
690
+ const count = list === null ? 1 : list.length;
691
+ for (let i = 0; i < count; i++) {
692
+ const text = list === null ? (value === false ? "false" : value) : list[i];
693
+ if (
694
+ lines === 1 &&
695
+ !pair &&
696
+ header === "keep-alive" &&
697
+ text === SEEDED_KEEP_ALIVE &&
698
+ firstName === "connection" &&
699
+ firstValue === "keep-alive"
700
+ ) {
701
+ // the seeded pair, one line from here on
702
+ pair = true;
703
+ continue;
704
+ }
705
+ if (lines === 0) {
706
+ firstName = header;
707
+ firstValue = text;
708
+ } else if (lines === 1) {
709
+ secondName = header;
710
+ secondValue = text;
711
+ } else if (lines === 2) {
712
+ joined =
713
+ (pair ? SEEDED_PAIR : firstValue) +
714
+ "\r\n" +
715
+ secondName +
716
+ ": " +
717
+ secondValue +
718
+ "\r\n" +
719
+ header +
720
+ ": " +
721
+ text;
722
+ } else {
723
+ joined += "\r\n" + header + ": " + text;
724
+ }
725
+ lines++;
726
+ }
727
+ }
728
+ if (lines !== 0) {
729
+ Response.#writeLines(res, lines, pair, firstName, firstValue, secondName, secondValue, joined);
649
730
  }
650
731
  this.headersSent = true;
651
732
  this.#headOut = true;
652
733
  }
653
734
 
735
+ /**
736
+ * Writes the lines of writeHeaders: one or two a call each, three or more in one call.
737
+ *
738
+ * @param {import("uWebSockets.js").HttpResponse} res
739
+ * @param {number} lines how many, the seeded pair counted as one
740
+ * @param {boolean} pair whether the first line is the seeded pair
741
+ * @param {string} firstName
742
+ * @param {string} firstValue
743
+ * @param {string} secondName
744
+ * @param {string} secondValue
745
+ * @param {string} joined the first value with the other lines after it
746
+ */
747
+ static #writeLines(res, lines, pair, firstName, firstValue, secondName, secondValue, joined) {
748
+ const name = HEADER_NAME_BUF[firstName] || firstName;
749
+ if (lines > 2) {
750
+ res.writeHeader(name, joined);
751
+ return;
752
+ }
753
+ res.writeHeader(name, pair ? SEEDED_PAIR_BUF : HEADER_VALUE_BUF[firstValue] || firstValue);
754
+ if (lines === 2) {
755
+ res.writeHeader(HEADER_NAME_BUF[secondName] || secondName, HEADER_VALUE_BUF[secondValue] || secondValue);
756
+ }
757
+ }
758
+
654
759
  /**
655
760
  * node's, called before a body when writeHead never was. Guarded here: the compression module
656
761
  * calls it on the strength of node's _header, which this response does not keep.
@@ -1473,7 +1578,7 @@ module.exports = class Response extends LazyWritable {
1473
1578
  * @returns {boolean}
1474
1579
  */
1475
1580
  hasHeader(name) {
1476
- return this.headers[name.toLowerCase()] !== undefined;
1581
+ return this.headers[headerKey(name)] !== undefined;
1477
1582
  }
1478
1583
 
1479
1584
  /**
@@ -1502,7 +1607,7 @@ module.exports = class Response extends LazyWritable {
1502
1607
  * @returns {this}
1503
1608
  */
1504
1609
  appendHeader(name, value) {
1505
- const key = name.toLowerCase();
1610
+ const key = headerKey(name);
1506
1611
  const current = this.headers[key];
1507
1612
  if (current === undefined) {
1508
1613
  return this.setHeader(name, value);
@@ -1563,7 +1668,7 @@ module.exports = class Response extends LazyWritable {
1563
1668
  this.set(header, fields[header]);
1564
1669
  }
1565
1670
  } else {
1566
- const name = field.toLowerCase();
1671
+ const name = headerKey(field);
1567
1672
  // coerced here as Express does, so res.get answers what was sent
1568
1673
  let out = Array.isArray(value) ? value.map(String) : String(value);
1569
1674
  if (name === "content-type") {
@@ -1592,7 +1697,7 @@ module.exports = class Response extends LazyWritable {
1592
1697
  * @returns {string|string[]|undefined}
1593
1698
  */
1594
1699
  get(field) {
1595
- const name = field.toLowerCase();
1700
+ const name = headerKey(field);
1596
1701
  const value = this.headers[name];
1597
1702
  // Content-Length is uWS's, measured from the body it was handed: worked out here only for
1598
1703
  // whoever asks, morgan's common and combined formats do, and kept
@@ -1631,7 +1736,7 @@ module.exports = class Response extends LazyWritable {
1631
1736
  if (this.headersSent) {
1632
1737
  throw headersSentError("remove");
1633
1738
  }
1634
- const key = field.toLowerCase();
1739
+ const key = headerKey(field);
1635
1740
  // helmet removes a header most responses never carry, and a delete is a runtime call
1636
1741
  if (key in this.headers) {
1637
1742
  delete this.headers[key];