fulmine.js 5.15.1 → 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
@@ -502,6 +502,13 @@ Server-Timing: route;desc="native", hdr;desc="not copied", db;dur=3.62, total;du
502
502
  app.use(express.compression({ threshold: 1024 }));
503
503
  ```
504
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
+
505
512
  Runnable: [`examples/compression.js`](./examples/compression.js).
506
513
 
507
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.1",
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
  }
package/src/request.js CHANGED
@@ -951,6 +951,35 @@ module.exports = class Request extends LazyReadable {
951
951
  return undefined;
952
952
  }
953
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
+
954
983
  /**
955
984
  * Whether there is any point still reading the body: once the response is finished or the
956
985
  * connection is gone, uWS has nothing left to hand over.
package/src/types.d.ts CHANGED
@@ -50,6 +50,12 @@ declare module "fulmine.js" {
50
50
  enforceEncoding?: string;
51
51
  /** Brotli options. The default quality is 4. */
52
52
  brotli?: BrotliOptions;
53
+ /**
54
+ * The encodings this middleware may answer with; what is not named is never used,
55
+ * however the client ranks it. Fulmine's own option, the compression module has no
56
+ * equivalent. An uncompressed answer is always on offer.
57
+ */
58
+ encodings?: ("br" | "gzip" | "deflate" | "identity")[];
53
59
  }
54
60
  // what listen() decided about each route, for a test to hold on to
55
61
  interface RouteVerdict {
package/src/utils.js CHANGED
@@ -1644,6 +1644,7 @@ module.exports = {
1644
1644
  negotiateEncoding,
1645
1645
  ENCODING_BR,
1646
1646
  ENCODING_GZIP,
1647
+ ENCODING_DEFLATE,
1647
1648
  ENCODING_ANY,
1648
1649
  memoizeByString,
1649
1650
  isRangeFresh,