fulmine.js 5.5.2 → 5.6.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.
@@ -0,0 +1,25 @@
1
+ compression License (only code derived from the compression module).
2
+
3
+ (The MIT License)
4
+
5
+ Copyright (c) 2014 Jonathan Ong <me@jongleberry.com>
6
+ Copyright (c) 2014-2015 Douglas Christopher Wilson <doug@somethingdoug.com>
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining
9
+ a copy of this software and associated documentation files (the
10
+ 'Software'), to deal in the Software without restriction, including
11
+ without limitation the rights to use, copy, modify, merge, publish,
12
+ distribute, sublicense, and/or sell copies of the Software, and to
13
+ permit persons to whom the Software is furnished to do so, subject to
14
+ the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be
17
+ included in all copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
20
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
21
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
22
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
23
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
24
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
25
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/NOTICE CHANGED
@@ -43,6 +43,15 @@ every copy:
43
43
  IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
44
44
  DEALINGS IN THE SOFTWARE.
45
45
 
46
+ This product includes test code derived from the compression middleware
47
+ (https://github.com/expressjs/compression), ported in
48
+ tests/unit/compression.test.js and licensed under the MIT License. Its terms
49
+ travel with this repository in COMPRESSION_LICENSE, and the copyright it
50
+ requires to be carried is:
51
+
52
+ Copyright (c) 2014 Jonathan Ong <me@jongleberry.com>
53
+ Copyright (c) 2014-2015 Douglas Christopher Wilson <doug@somethingdoug.com>
54
+
46
55
  As required by section 4(b) of the Apache License, the following are the
47
56
  significant changes made to the original work:
48
57
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <img src="./assets/logo-mark.svg" alt="" width="88" align="right">
2
2
 
3
- # Fulmine
3
+ # Fulmine.js
4
4
 
5
5
  A drop-in replacement for Express 5, running on [µWebSockets.js](https://github.com/uNetworking/uWebSockets.js) instead of `node:http`. Your existing middleware keeps working.
6
6
 
@@ -59,7 +59,6 @@ See [Migrating](#migrating) for what it handles and what it deliberately does no
59
59
  - [Router](#router)
60
60
  - [Tested middlewares](#tested-middlewares)
61
61
  - [Tested view engines](#tested-view-engines)
62
- - [The demo](https://fulmine-demo.fly.dev)
63
62
  - [Working on Fulmine](./CONTRIBUTING.md)
64
63
 
65
64
  ## Why this exists
@@ -90,7 +89,7 @@ to run it yourself.
90
89
 
91
90
  Numbers produced by a project about itself deserve suspicion, so Fulmine also stands in public arenas, run by their own rigs under their own rules:
92
91
 
93
- - **[HttpArena](https://www.http-arena.com/#sort=rps:-1&q=Js)** (the link lands filtered on the JavaScript entries): first among the JavaScript entries, across fifteen subscribed profiles. The saved runs measure 24.3 million WebSocket echoes per second pipelined and 3.77 million one-at-a-time (past Bun's own dedicated WebSocket entry), 7.3 million pipelined HTTP requests per second, 1.12 million on the json profile with 1.04 million of that surviving TLS, 457 thousand on compressed json, and 359 thousand on the Postgres CRUD profile, within ten percent of the leading Rust and C# entries there.
92
+ - **[HttpArena](https://www.http-arena.com/#sort=rps:-1&q=Js)**: thirty profiles on 64-core dedicated hardware, same conditions for every entry, rerun whenever one of them changes. The link lands filtered on the JavaScript entries. No figures are copied here on purpose: the board is the current one and this page would not be.
94
93
  - **[web-frameworks](https://web-frameworks-benchmark.netlify.app/result?l=javascript)**: entry merged, numbers arrive with their next published round.
95
94
 
96
95
  More to come as their maintainers take the entries in.
@@ -125,6 +124,10 @@ npx fulmine differences # print the list below and change nothing
125
124
  The command is installed under both `fulmine` and `fulmine.js`. Use `fulmine`: `npx` cannot run a
126
125
  command whose name ends in `.js` on Windows, where it exits without a word.
127
126
 
127
+ It also names the middlewares it found that have a faster one built in here, `compression`,
128
+ `body-parser` and `serve-static`, and leaves them to you: the replacement is reached through the
129
+ `express` import, and no rewrite can know that it is in scope where they are required.
130
+
128
131
  ### Angular SSR
129
132
 
130
133
  The `server.ts` that `ng add @angular/ssr` generates is an ordinary Express application, so the same
@@ -196,12 +199,17 @@ Bun is not an option: µWebSockets.js is a native Node addon, and Bun does not l
196
199
 
197
200
  ## Docker
198
201
 
199
- Two things about µWebSockets.js make a Dockerfile that works for Express fail here, and both have easy answers:
202
+ Three things about µWebSockets.js make a Dockerfile that works for Express fail here, and all three have easy answers:
200
203
 
201
204
  - **No Alpine, and no Debian bookworm either.** µWebSockets.js ships prebuilt binaries linked against glibc 2.38 or newer. Alpine images use musl, so the binary does not load at all; `node:26` and `node:26-slim` are Debian bookworm, whose glibc 2.36 fails at startup with `GLIBC_2.38' not found`. Use the trixie variants: `node:26-trixie-slim` and up.
202
205
  - **`git` must be there when `npm install` runs.** µWebSockets.js is not on npm; it is installed straight from GitHub (`github:uNetworking/uWebSockets.js`), and npm uses git to fetch it. Full images like `node:26-trixie` have git; `-slim` ones do not.
206
+ - **git must be allowed to speak https.** Where the build environment rewrites GitHub URLs to ssh, which some CI images and company-wide git configs do, the fetch asks for a key the image does not have and the install dies on a permission denied that never names µWebSockets.js. One line before `npm ci` puts it back:
207
+
208
+ ```dockerfile
209
+ RUN git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
210
+ ```
203
211
 
204
- The clean way to satisfy both is a multi-stage build: install with the full image, run with the slim one.
212
+ The clean way to satisfy the first two is a multi-stage build: install with the full image, run with the slim one.
205
213
 
206
214
  ```dockerfile
207
215
  FROM node:26-trixie AS build
@@ -366,17 +374,24 @@ Worth changing, if these are routes that carry traffic
366
374
 
367
375
  It loads the application with `listen()` replaced by the half that compiles the routes, so nothing binds a port and the listen callback does not run: profiling a running service does not start a second copy of it. There is no score, on purpose. A percentage of routes is not a percentage of traffic, and an application with a thousand cold routes and one hot one that fell back would score well and serve badly.
368
376
 
369
- 2. Do not use external `serve-static` module. Instead use built-in `express.static()` middleware, which is optimized for Fulmine.
377
+ 2. Do not use external `serve-static` module. Instead use built-in `express.static()` middleware, which is optimized for Fulmine. If your build already writes `.br` and `.gz` files next to the originals, `express.static(dir, { preCompressed: true })` serves those to the clients that accept them, so nothing is compressed at request time. It costs one more `stat` per request and sends a fraction of the bytes: on a 4KB script with a brotli twin, 12 times fewer. `Vary: Accept-Encoding` is sent whether or not a variant is found, the content type stays the one the requested name implies, and each variant carries its own ETag.
370
378
 
371
379
  3. Do not use `body-parser` module. Instead use built-in `express.text()`, `express.json()` etc.
372
380
 
373
- 4. 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.
381
+ 4. Do not use the `compression` module. `express.compression()` takes the same options and decides the same way, and it served about 50% more requests per second on an 8KB JSON body here, gzip and brotli alike. A response that arrives whole, which is every `res.send()` and `res.json()`, is compressed in one call rather than through a transform stream and goes out with a `Content-Length` instead of chunked; a response written in pieces still streams. The bytes are the same bytes either way.
382
+
383
+ ```js
384
+ // the compression module's options, unchanged: threshold, filter, level, brotli, enforceEncoding
385
+ app.use(express.compression({ threshold: 1024 }));
386
+ ```
387
+
388
+ 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.
374
389
 
375
- 5. Do not set `body methods` to read body of requests with GET method or other methods that don't need a body. Reading body makes endpoint about 15% slower.
390
+ 6. Do not set `body methods` to read body of requests with GET method or other methods that don't need a body. Reading body makes endpoint about 15% slower.
376
391
 
377
- 6. `app.set("etag", false)` is worth about 8% on small responses, measured on both Fulmine and Express, which pay it almost identically. Know what you are trading: without an ETag a client cannot make a conditional request, so there are no `304 Not Modified` replies and every response is downloaded in full. On anything cacheable the bandwidth a 304 saves is usually worth far more than the 8%. It is left on by default for that reason. Turn it off for an API whose responses are never revalidated.
392
+ 7. `app.set("etag", false)` is worth about 8% on small responses, measured on both Fulmine and Express, which pay it almost identically. Know what you are trading: without an ETag a client cannot make a conditional request, so there are no `304 Not Modified` replies and every response is downloaded in full. On anything cacheable the bandwidth a 304 saves is usually worth far more than the 8%. It is left on by default for that reason. Turn it off for an API whose responses are never revalidated.
378
393
 
379
- 7. By default, Fulmine creates 1 (or 0 if your CPU has only 1 core) child thread to improve performance of reading files. You can change this number by setting `threads` to a different number in `express()`, or set to 0 to disable thread pool (`express({ threads: 0 })`). Threads are shared between all express() instances, with largest `threads` number being used. Using more threads will not necessarily improve performance. Sometimes not using threads at all is faster, so measure both.
394
+ 8. By default, Fulmine creates 1 (or 0 if your CPU has only 1 core) child thread to improve performance of reading files. You can change this number by setting `threads` to a different number in `express()`, or set to 0 to disable thread pool (`express({ threads: 0 })`). Threads are shared between all express() instances, with largest `threads` number being used. Using more threads will not necessarily improve performance. Sometimes not using threads at all is faster, so measure both.
380
395
 
381
396
  ## WebSockets
382
397
 
@@ -506,6 +521,7 @@ In general, basically all features and options are supported. Use the [Express 5
506
521
  - ✅ express.static()
507
522
  - ✅ express.text()
508
523
  - ✅ express.raw()
524
+ - ✅ express.compression(). Fulmine's own, since Express has none: it is the [compression](https://npmjs.com/package/compression) module's options and behaviour built in, described under [Performance tips](#performance-tips).
509
525
  - 🚧 express.request (this is not a constructor but a prototype for replacing methods)
510
526
  - 🚧 express.response (this is not a constructor but a prototype for replacing methods)
511
527
  - 🚧 express.application (likewise: a method added here is on every app)
@@ -564,7 +580,7 @@ Two of these keep a compiled form alongside the value, which you can also set di
564
580
  Fulmine adds three of its own:
565
581
 
566
582
  - `declarative responses`, on by default. Lets a simple enough handler be compiled into a native uWS response, described under [Performance tips](#performance-tips).
567
- - `file cache`, on by default. Small files served by `res.sendFile` come from a bounded in-process cache, checked against the file's `stat` on every request, so an edited file is never served stale.
583
+ - `file cache`, on by default. Small files served by `res.sendFile` come from a bounded in-process cache, checked against the file's `stat` on every request, so an edited file is never served stale. Turn it off where every request has to reach the disk, which is what a public benchmark asks of a standard entry: it was worth about 4% on a 4KB file here, so the cost of turning it off is small.
568
584
  - `trust proxy protocol`, off by default. Takes `req.ip` from a PROXY protocol preamble, described under [Behind a proxy](#behind-a-proxy). Read the warning there before turning it on.
569
585
 
570
586
  ### Request
@@ -672,7 +688,7 @@ Almost all middlewares that are compatible with Express are compatible with Fulm
672
688
  - ✅ [body-parser](https://npmjs.com/package/body-parser) (use `express.text()` etc instead for better performance)
673
689
  - ✅ [cookie-parser](https://npmjs.com/package/cookie-parser)
674
690
  - ✅ [cookie-session](https://npmjs.com/package/cookie-session)
675
- - ✅ [compression](https://npmjs.com/package/compression)
691
+ - ✅ [compression](https://npmjs.com/package/compression) (use `express.compression()` instead for better performance)
676
692
  - ✅ [serve-static](https://npmjs.com/package/serve-static) (use `express.static()` instead for better performance)
677
693
  - ✅ [serve-index](https://npmjs.com/package/serve-index)
678
694
  - ✅ [cors](https://npmjs.com/package/cors)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.5.2",
3
+ "version": "5.6.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
  "bin": {
@@ -40,7 +40,8 @@
40
40
  "src",
41
41
  "LICENSE",
42
42
  "NOTICE",
43
- "EXPRESS_LICENSE"
43
+ "EXPRESS_LICENSE",
44
+ "COMPRESSION_LICENSE"
44
45
  ],
45
46
  "repository": {
46
47
  "type": "git",
@@ -72,6 +73,7 @@
72
73
  "accepts": "^2.0.0",
73
74
  "acorn": "^8.16.0",
74
75
  "bytes": "^3.1.2",
76
+ "compressible": "^2.0.18",
75
77
  "content-disposition": "^1.1.0",
76
78
  "cookie": "^1.1.1",
77
79
  "cookie-signature": "^1.2.2",
package/src/cli.js CHANGED
@@ -34,6 +34,16 @@ const acorn = require("acorn");
34
34
  const FROM = "express";
35
35
  const TO = "fulmine.js";
36
36
 
37
+ // Modules this has a faster version of, spotted while the files are being read anyway. They are
38
+ // reported and not rewritten: the replacement is reached through the express import, which this
39
+ // command cannot know is in scope in the file that requires them, and body-parser is four
40
+ // functions rather than one.
41
+ const BUILT_IN_INSTEAD = {
42
+ compression: "express.compression(), which takes the same options",
43
+ "serve-static": "express.static()",
44
+ "body-parser": "express.json(), express.urlencoded(), express.text(), express.raw()"
45
+ };
46
+
37
47
  const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "coverage", ".nyc_output", ".next"]);
38
48
  const EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".ts", ".mts", ".cts", ".tsx"]);
39
49
  const TYPESCRIPT_EXTENSIONS = new Set([".ts", ".mts", ".cts", ".tsx"]);
@@ -139,9 +149,10 @@ function loadTypeScript(target) {
139
149
  * @param {string} source
140
150
  * @param {string} fileName decides whether JSX is allowed, so a .tsx angle bracket is not a cast
141
151
  * @param {any} ts the compiler
152
+ * @param {Set<string>} [seen] as in findSpecifiers
142
153
  * @returns {{start: number, end: number}[]}
143
154
  */
144
- function findSpecifiersTypeScript(source, fileName, ts) {
155
+ function findSpecifiersTypeScript(source, fileName, ts, seen) {
145
156
  const sourceFile = ts.createSourceFile(
146
157
  fileName,
147
158
  source,
@@ -152,7 +163,14 @@ function findSpecifiersTypeScript(source, fileName, ts) {
152
163
 
153
164
  /** @type {{start: number, end: number}[]} */
154
165
  const found = [];
155
- const take = (node) => found.push({ start: node.getStart(sourceFile), end: node.getEnd() });
166
+ /** @param {any} node a string literal naming a module */
167
+ const take = (node) => {
168
+ if (node.text === FROM) {
169
+ found.push({ start: node.getStart(sourceFile), end: node.getEnd() });
170
+ } else if (seen && BUILT_IN_INSTEAD[node.text]) {
171
+ seen.add(node.text);
172
+ }
173
+ };
156
174
 
157
175
  const visit = (node) => {
158
176
  // import express from "express", import type { Request } from "express", export * from it.
@@ -160,23 +178,21 @@ function findSpecifiersTypeScript(source, fileName, ts) {
160
178
  if (
161
179
  (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
162
180
  node.moduleSpecifier &&
163
- ts.isStringLiteral(node.moduleSpecifier) &&
164
- node.moduleSpecifier.text === FROM
181
+ ts.isStringLiteral(node.moduleSpecifier)
165
182
  ) {
166
183
  take(node.moduleSpecifier);
167
184
  } else if (
168
185
  // import express = require("express"), which is TypeScript's own spelling
169
186
  ts.isImportEqualsDeclaration(node) &&
170
187
  ts.isExternalModuleReference(node.moduleReference) &&
171
- ts.isStringLiteral(node.moduleReference.expression) &&
172
- node.moduleReference.expression.text === FROM
188
+ ts.isStringLiteral(node.moduleReference.expression)
173
189
  ) {
174
190
  take(node.moduleReference.expression);
175
191
  } else if (ts.isCallExpression(node)) {
176
192
  const isRequire = ts.isIdentifier(node.expression) && node.expression.text === "require";
177
193
  const isDynamicImport = node.expression.kind === ts.SyntaxKind.ImportKeyword;
178
194
  const arg = node.arguments[0];
179
- if ((isRequire || isDynamicImport) && arg && ts.isStringLiteral(arg) && arg.text === FROM) {
195
+ if ((isRequire || isDynamicImport) && arg && ts.isStringLiteral(arg)) {
180
196
  take(arg);
181
197
  }
182
198
  }
@@ -193,9 +209,11 @@ function findSpecifiersTypeScript(source, fileName, ts) {
193
209
  * imports at all, and none of those may be rewritten.
194
210
  *
195
211
  * @param {string} source
212
+ * @param {Set<string>} [seen] collects the names of the modules with something built in here,
213
+ * which are recognised on the same walk rather than on one of their own
196
214
  * @returns {{start: number, end: number}[]|null} null when the file does not parse
197
215
  */
198
- function findSpecifiers(source) {
216
+ function findSpecifiers(source, seen) {
199
217
  /** @type {any} */
200
218
  let tree;
201
219
  // A file is either a module or a script and the parser has to be told which. Try module first,
@@ -220,15 +238,23 @@ function findSpecifiers(source) {
220
238
 
221
239
  /** @type {{start: number, end: number}[]} */
222
240
  const found = [];
241
+ /** @param {any} node a string literal naming a module */
242
+ const record = (node) => {
243
+ if (node.value === FROM) {
244
+ found.push({ start: node.start, end: node.end });
245
+ } else if (seen && BUILT_IN_INSTEAD[node.value]) {
246
+ seen.add(node.value);
247
+ }
248
+ };
223
249
  walk(tree, (node) => {
224
250
  // import express from "express", export * from "express"
225
251
  if (
226
252
  (node.type === "ImportDeclaration" ||
227
253
  node.type === "ExportNamedDeclaration" ||
228
254
  node.type === "ExportAllDeclaration") &&
229
- node.source?.value === FROM
255
+ typeof node.source?.value === "string"
230
256
  ) {
231
- found.push({ start: node.source.start, end: node.source.end });
257
+ record(node.source);
232
258
  return;
233
259
  }
234
260
  // require("express") and import("express"), the second being a node of its own
@@ -237,8 +263,8 @@ function findSpecifiers(source) {
237
263
  const isDynamicImport = node.type === "ImportExpression";
238
264
  if (isRequire || isDynamicImport) {
239
265
  const arg = isDynamicImport ? node.source : node.arguments?.[0];
240
- if (arg?.type === "Literal" && arg.value === FROM) {
241
- found.push({ start: arg.start, end: arg.end });
266
+ if (arg?.type === "Literal" && typeof arg.value === "string") {
267
+ record(arg);
242
268
  }
243
269
  }
244
270
  });
@@ -582,6 +608,8 @@ Options:
582
608
  const unparsed = [];
583
609
  /** @type {string[]} */
584
610
  const needTypeScript = [];
611
+ /** @type {Set<string>} */
612
+ const builtInInstead = new Set();
585
613
 
586
614
  // resolved once, and only if there is anything to use it on
587
615
  const hasTypeScriptFiles = files.some((file) => TYPESCRIPT_EXTENSIONS.has(path.extname(file)));
@@ -590,8 +618,8 @@ Options:
590
618
  for (const file of files) {
591
619
  const source = fs.readFileSync(file, "utf8");
592
620
  // reading every file's AST to find nothing is the common case, so skip the ones that
593
- // cannot contain the specifier at all
594
- if (!source.includes(FROM)) continue;
621
+ // cannot contain any of the names being looked for
622
+ if (!source.includes(FROM) && !Object.keys(BUILT_IN_INSTEAD).some((name) => source.includes(name))) continue;
595
623
 
596
624
  const isTypeScript = TYPESCRIPT_EXTENSIONS.has(path.extname(file));
597
625
  if (isTypeScript && !ts) {
@@ -599,7 +627,9 @@ Options:
599
627
  continue;
600
628
  }
601
629
 
602
- const specifiers = isTypeScript ? findSpecifiersTypeScript(source, file, ts) : findSpecifiers(source);
630
+ const specifiers = isTypeScript
631
+ ? findSpecifiersTypeScript(source, file, ts, builtInInstead)
632
+ : findSpecifiers(source, builtInInstead);
603
633
  if (specifiers === null) {
604
634
  unparsed.push(path.relative(target, file));
605
635
  continue;
@@ -635,6 +665,18 @@ Options:
635
665
  console.log(
636
666
  `\n${dryRun ? "would rewrite" : "rewrote"} ${changedImports} import(s) in ${changedFiles} file(s) of ${files.length} scanned`
637
667
  );
668
+
669
+ // said whether or not anything was rewritten: an application migrated last month still has
670
+ // these, and they are the difference between running on µWS and running through a middleware
671
+ // that was written for node streams
672
+ if (builtInInstead.size) {
673
+ console.log(`\n${builtInInstead.size} module(s) with a faster one built in here, worth replacing by hand:`);
674
+ for (const name of builtInInstead) {
675
+ console.log(` ${name} -> ${BUILT_IN_INSTEAD[name]}`);
676
+ }
677
+ console.log("");
678
+ }
679
+
638
680
  if (changedFiles) {
639
681
  console.log(`Remember to install it: npm install ${TO}`);
640
682
  printDifferences();
@@ -0,0 +1,400 @@
1
+ /*
2
+ Copyright 2026 Nigro Simone
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ // express.compression(), which answers with a compressed body when the client asked for one.
18
+ //
19
+ // The options, the defaults and the order the decision is taken in are the compression module's,
20
+ // so a front that already uses it can drop the require and change nothing else. Two things are
21
+ // different, and both only ever turn a worse answer into a better one:
22
+ //
23
+ // - a response that arrives whole, which is every res.send() and res.json(), is compressed in
24
+ // one call instead of through a transform stream, and goes out with a Content-Length. The
25
+ // bytes are the same bytes: zlib.gzipSync and a createGzip that receives the same body in one
26
+ // write produce the same deflate output.
27
+ // - partial content is left alone. The compression module compresses a 206 as well, and the
28
+ // result is a byte range of the file described as gzip, which no client can decode.
29
+ //
30
+ // The streaming half is the module's own design, because it is the right one: a transform stream,
31
+ // its output written as it comes, and the drain listeners moved onto it so a pipe that fills up
32
+ // hears from the compressor rather than from a socket that is no longer what it is waiting for.
33
+
34
+ "use strict";
35
+
36
+ const zlib = require("zlib");
37
+ const bytes = require("bytes");
38
+ const compressible = require("compressible");
39
+ const { negotiateEncoding, ENCODING_ANY } = require("./utils.js");
40
+
41
+ // Cache-Control: no-transform forbids recoding the body, which is what this does
42
+ const NO_TRANSFORM = /(?:^|,)\s*?no-transform\s*?(?:,|$)/;
43
+
44
+ // what enforceEncoding is allowed to name, the compression module's list
45
+ const ENFORCEABLE = new Set(["gzip", "deflate", "identity", "br"]);
46
+
47
+ // Up to this many bytes a whole body is compressed on this thread, and above it on the libuv pool.
48
+ // One call either way; what changes is who waits. A small body pays more for the hop onto the pool
49
+ // than the compression costs, and a large one is worth handing over, since the pool has four
50
+ // threads and the loop has everyone else to serve: measured with gzip at the default level, sync
51
+ // wins by 43% at 1.4KB and by 22% at 16KB, and loses by 32% at 32KB and by 90% at 78KB.
52
+ const SYNC_LIMIT = 24 * 1024;
53
+
54
+ /**
55
+ * The default filter: whether the content type is worth compressing at all. A response with no
56
+ * type is left alone, since nothing says what its bytes are.
57
+ *
58
+ * @param {any} req
59
+ * @param {any} res
60
+ * @returns {boolean}
61
+ */
62
+ function shouldCompress(req, res) {
63
+ const type = res.getHeader("Content-Type");
64
+ if (type === undefined || !compressible(String(type))) {
65
+ return false;
66
+ }
67
+ return true;
68
+ }
69
+
70
+ /**
71
+ * How many bytes a chunk is, which is what the threshold is compared against.
72
+ *
73
+ * @param {any} chunk
74
+ * @param {BufferEncoding} [encoding]
75
+ * @returns {number}
76
+ */
77
+ function chunkLength(chunk, encoding) {
78
+ if (chunk === undefined || chunk === null) {
79
+ return 0;
80
+ }
81
+ return Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk, encoding);
82
+ }
83
+
84
+ /**
85
+ * The bytes of a chunk, whatever it arrived as.
86
+ *
87
+ * @param {any} chunk
88
+ * @param {BufferEncoding} [encoding]
89
+ * @returns {Buffer}
90
+ */
91
+ function toBuffer(chunk, encoding) {
92
+ if (Buffer.isBuffer(chunk)) {
93
+ return chunk;
94
+ }
95
+ // end() with nothing to send still has to hand the compressor something, and a threshold of 0
96
+ // lets an empty body reach it: Buffer.from(undefined) throws where this sends the empty answer
97
+ if (chunk === undefined || chunk === null) {
98
+ return Buffer.alloc(0);
99
+ }
100
+ return Buffer.from(chunk, encoding);
101
+ }
102
+
103
+ /**
104
+ * Compresses a response body as the client asked for it.
105
+ *
106
+ * @param {object} [options]
107
+ * @param {number|string} [options.threshold] the smallest body worth compressing, bytes or "1kb".
108
+ * Default 1024. A response whose size is not known in advance is compressed whatever its size.
109
+ * @param {(req: any, res: any) => boolean} [options.filter] whether this response should be
110
+ * compressed at all. The default says yes to any compressible content type.
111
+ * @param {string} [options.enforceEncoding] what to use when the request carries no
112
+ * Accept-Encoding at all. Default "identity", which is to say nothing is compressed.
113
+ * @param {object} [options.brotli] brotli options, `params` included. The default quality is 4.
114
+ * @param {number} [options.level] zlib compression level, for gzip and deflate.
115
+ * @param {number} [options.chunkSize] zlib chunk size.
116
+ * @param {number} [options.memLevel] zlib memory level.
117
+ * @param {number} [options.strategy] zlib strategy.
118
+ * @param {number} [options.windowBits] zlib window size.
119
+ * @returns {(req: any, res: any, next: (err?: any) => void) => void} the middleware
120
+ */
121
+ function compression(options) {
122
+ const opts = options || {};
123
+ // the whole bag goes to zlib, as the compression module does: level, memLevel, strategy,
124
+ // windowBits and chunkSize arrive under their own names and zlib ignores the rest
125
+ const zlibOptions = /** @type {any} */ (opts);
126
+ const brotliOptions = { ...opts.brotli };
127
+ brotliOptions.params = {
128
+ [zlib.constants.BROTLI_PARAM_QUALITY]: 4,
129
+ ...(opts.brotli && /** @type {any} */ (opts.brotli).params)
130
+ };
131
+ const filter = opts.filter || shouldCompress;
132
+ const enforceEncoding = opts.enforceEncoding || "identity";
133
+ // bytes.parse reads "1kb" and hands back null for anything it cannot, an absent option
134
+ // included, which is where the default comes in
135
+ const threshold = bytes.parse(/** @type {any} */ (opts.threshold)) ?? 1024;
136
+
137
+ /**
138
+ * A whole body, compressed on this thread. Blocks the event loop for as long as it takes,
139
+ * which is why only a small one comes here, see SYNC_LIMIT.
140
+ *
141
+ * @param {string} method
142
+ * @param {Buffer} body
143
+ * @returns {Buffer}
144
+ */
145
+ function compressWhole(method, body) {
146
+ if (method === "gzip") {
147
+ return zlib.gzipSync(body, zlibOptions);
148
+ }
149
+ if (method === "br") {
150
+ return zlib.brotliCompressSync(body, brotliOptions);
151
+ }
152
+ return zlib.deflateSync(body, zlibOptions);
153
+ }
154
+
155
+ /**
156
+ * The same, on the libuv thread pool.
157
+ *
158
+ * @param {string} method
159
+ * @param {Buffer} body
160
+ * @param {(err: Error|null, out: Buffer) => void} done
161
+ */
162
+ function compressWholeAsync(method, body, done) {
163
+ if (method === "gzip") {
164
+ zlib.gzip(body, zlibOptions, done);
165
+ } else if (method === "br") {
166
+ zlib.brotliCompress(body, brotliOptions, done);
167
+ } else {
168
+ zlib.deflate(body, zlibOptions, done);
169
+ }
170
+ }
171
+
172
+ /**
173
+ * @param {string} method
174
+ * @returns {any} the transform stream for a body that arrives in pieces
175
+ */
176
+ function compressStream(method) {
177
+ if (method === "gzip") {
178
+ return zlib.createGzip(zlibOptions);
179
+ }
180
+ if (method === "br") {
181
+ return zlib.createBrotliCompress(brotliOptions);
182
+ }
183
+ return zlib.createDeflate(zlibOptions);
184
+ }
185
+
186
+ return function compression(req, res, next) {
187
+ const _write = res.write;
188
+ const _end = res.end;
189
+ const _on = res.on;
190
+
191
+ /** drain listeners parked until there is a compressor to hang them on, see res.on below */
192
+ let listeners = /** @type {any[][]|null} */ ([]);
193
+ /** @type {any} */
194
+ let stream = null;
195
+ let decided = false;
196
+ let ended = false;
197
+ /** what end() was given to call back, held until the compressor has finished */
198
+ let endCallback = /** @type {any} */ (undefined);
199
+
200
+ // the compression module adds this, and code written against it calls it: an SSE feed
201
+ // pushes its event out with res.flush(). Nothing to flush before there is a compressor
202
+ res.flush = function flush() {
203
+ if (stream) {
204
+ stream.flush();
205
+ }
206
+ };
207
+
208
+ /**
209
+ * Hands back the parked drain listeners: this response is not being compressed, so the
210
+ * response itself is what a pipe should hear from.
211
+ * @returns {string} the empty method, so the callers can `return noCompress()`
212
+ */
213
+ function noCompress() {
214
+ if (listeners) {
215
+ for (const listener of listeners) {
216
+ _on.call(res, listener[0], listener[1]);
217
+ }
218
+ listeners = null;
219
+ }
220
+ return "";
221
+ }
222
+
223
+ /**
224
+ * Whether this response is compressed, and how. Taken once, when the first byte of the
225
+ * body arrives, which is also when the headers are decided: everything read here is set
226
+ * by then. The order is the compression module's, and so is the Vary, which is added even
227
+ * when the answer goes out uncompressed because the answer still depends on the header.
228
+ *
229
+ * @param {number} [length] the size of the body, when end() already has all of it
230
+ * @returns {string} the encoding chosen, "" to send the body as it is
231
+ */
232
+ function decide(length) {
233
+ decided = true;
234
+ // res.flushHeaders() commits the head here rather than holding it until the body, so a
235
+ // response that used it has no room left for a Content-Encoding
236
+ if (res.headersSent) {
237
+ return noCompress();
238
+ }
239
+ if (!filter(req, res)) {
240
+ return noCompress();
241
+ }
242
+ const cacheControl = res.getHeader("Cache-Control");
243
+ if (cacheControl && NO_TRANSFORM.test(String(cacheControl))) {
244
+ return noCompress();
245
+ }
246
+ res.vary("Accept-Encoding");
247
+ // NaN when there is no Content-Length, and a comparison against NaN is false: a body
248
+ // whose size is not known yet is compressed whatever the threshold says
249
+ if (Number(res.getHeader("Content-Length")) < threshold || Number(length) < threshold) {
250
+ return noCompress();
251
+ }
252
+ const already = res.getHeader("Content-Encoding");
253
+ if (already && already !== "identity") {
254
+ return noCompress();
255
+ }
256
+ if (req.method === "HEAD") {
257
+ return noCompress();
258
+ }
259
+ // a range is a window into the bytes on disk, and a client that asked for one cannot
260
+ // decode a compressed answer to it
261
+ if (res.statusCode === 206 || res.getHeader("Content-Range") !== undefined) {
262
+ return noCompress();
263
+ }
264
+ const accept = req.headers["accept-encoding"];
265
+ let method = negotiateEncoding(accept === undefined ? "" : accept, ENCODING_ANY);
266
+ if (accept === undefined && ENFORCEABLE.has(enforceEncoding)) {
267
+ method = enforceEncoding;
268
+ }
269
+ if (!method || method === "identity") {
270
+ return noCompress();
271
+ }
272
+ res.setHeader("Content-Encoding", method);
273
+ // what it says is the size of the body before this middleware saw it. The whole-body
274
+ // path below puts the right one back; the streaming one cannot know it in advance
275
+ res.removeHeader("Content-Length");
276
+ return method;
277
+ }
278
+
279
+ /**
280
+ * Starts the compressor for a body that arrives in pieces, and wires it to the response.
281
+ * @param {string} method
282
+ */
283
+ function startStream(method) {
284
+ stream = compressStream(method);
285
+ // The parked listeners, and the list itself stays rather than being emptied: res.on
286
+ // reads it to know that a drain listener belongs on the compressor from here on. That
287
+ // matters because a pipe registers its own the first time write() tells it to slow
288
+ // down, which is after this, and from here on the compressor is what fills up.
289
+ for (const listener of /** @type {any[][]} */ (listeners)) {
290
+ stream.on(listener[0], listener[1]);
291
+ }
292
+ stream.on("data", (chunk) => {
293
+ if (_write.call(res, chunk) === false) {
294
+ stream.pause();
295
+ }
296
+ });
297
+ stream.on("end", () => {
298
+ _end.call(res, endCallback);
299
+ });
300
+ _on.call(res, "drain", () => stream.resume());
301
+ // an aborted response never reaches the end of the stream, and the zlib context behind
302
+ // it is native memory that a garbage collector is in no hurry to reach
303
+ _on.call(res, "close", () => stream.destroy());
304
+ }
305
+
306
+ res.write = function write(chunk, encoding, callback) {
307
+ if (typeof encoding === "function") {
308
+ callback = encoding;
309
+ encoding = undefined;
310
+ }
311
+ if (ended) {
312
+ return false;
313
+ }
314
+ if (!decided) {
315
+ const method = decide();
316
+ if (method) {
317
+ startStream(method);
318
+ }
319
+ }
320
+ if (stream) {
321
+ return stream.write(toBuffer(chunk, encoding), callback);
322
+ }
323
+ return _write.call(this, chunk, encoding, callback);
324
+ };
325
+
326
+ res.end = function end(chunk, encoding, callback) {
327
+ // node's shapes, of which this project's own end() takes (data, cb): the third
328
+ // argument only arrives from code written against node's ServerResponse
329
+ if (typeof chunk === "function") {
330
+ callback = chunk;
331
+ chunk = undefined;
332
+ encoding = undefined;
333
+ } else if (typeof encoding === "function") {
334
+ callback = encoding;
335
+ encoding = undefined;
336
+ }
337
+ if (ended) {
338
+ return this;
339
+ }
340
+ if (stream) {
341
+ ended = true;
342
+ endCallback = callback;
343
+ if (chunk === undefined || chunk === null || chunk === "") {
344
+ stream.end();
345
+ } else {
346
+ stream.end(toBuffer(chunk, encoding));
347
+ }
348
+ return this;
349
+ }
350
+ if (!decided) {
351
+ const method = decide(chunkLength(chunk, encoding));
352
+ if (method) {
353
+ // the whole answer is here, so it is compressed in one call rather than
354
+ // through a stream, and goes out with the length it ended up being
355
+ ended = true;
356
+ const input = toBuffer(chunk, encoding);
357
+ if (input.length <= SYNC_LIMIT) {
358
+ const body = compressWhole(method, input);
359
+ res.setHeader("Content-Length", String(body.length));
360
+ return _end.call(this, body, callback);
361
+ }
362
+ compressWholeAsync(method, input, (err, body) => {
363
+ // the client can leave while the pool is working, and writing to a
364
+ // response that is already gone is not something uWS survives
365
+ if (res.aborted || res.finished) {
366
+ return;
367
+ }
368
+ if (err) {
369
+ return res.destroy(err);
370
+ }
371
+ res.setHeader("Content-Length", String(body.length));
372
+ _end.call(res, body, callback);
373
+ });
374
+ return this;
375
+ }
376
+ }
377
+ ended = true;
378
+ return _end.call(this, chunk, callback);
379
+ };
380
+
381
+ res.on = function on(type, listener) {
382
+ if (!listeners || type !== "drain") {
383
+ return _on.call(this, type, listener);
384
+ }
385
+ if (stream) {
386
+ return stream.on(type, listener);
387
+ }
388
+ // there is nothing to listen to yet: a compressor that does not exist has not filled up
389
+ listeners.push([type, listener]);
390
+ return this;
391
+ };
392
+
393
+ next();
394
+ };
395
+ }
396
+
397
+ module.exports = compression;
398
+ // the compression module exports its default filter, and a front that wants to compress one more
399
+ // type than the default calls it and adds to what it says
400
+ module.exports.filter = shouldCompress;
package/src/index.js CHANGED
@@ -49,6 +49,7 @@ try {
49
49
  * response: object,
50
50
  * application: object,
51
51
  * static: Function,
52
+ * compression: Function,
52
53
  * json: Function,
53
54
  * urlencoded: Function,
54
55
  * text: Function,
@@ -72,6 +73,9 @@ module.exports.response = Response.prototype;
72
73
  module.exports.application = Application.Application.prototype;
73
74
 
74
75
  module.exports.static = middlewares.static;
76
+ // not one of express's, since express has none: the compression module is what everyone installs
77
+ // instead, and this is that middleware's options and behaviour without the install
78
+ module.exports.compression = require("./compression.js");
75
79
  module.exports.json = middlewares.json;
76
80
  module.exports.urlencoded = middlewares.urlencoded;
77
81
  module.exports.text = middlewares.text;
@@ -22,11 +22,22 @@ const path = require("path");
22
22
  const bytes = require("bytes");
23
23
  const zlib = require("fast-zlib");
24
24
  const typeis = require("type-is");
25
+ const mime = require("mime-types");
25
26
  const qs = require("qs");
26
27
  const parseQuery = require("./parse-query.js");
27
28
  const { kGetSafe } = require("./usage.js");
28
29
  const { AsyncResource } = require("async_hooks");
29
- const { fastQueryParse, NullObject, asStatError, httpError, memoizeByString, containsDotFile } = require("./utils.js");
30
+ const {
31
+ fastQueryParse,
32
+ NullObject,
33
+ asStatError,
34
+ httpError,
35
+ memoizeByString,
36
+ containsDotFile,
37
+ negotiateEncoding,
38
+ ENCODING_BR,
39
+ ENCODING_GZIP
40
+ } = require("./utils.js");
30
41
 
31
42
  // largest content-length we will allocate a body buffer for up front. above this the body is
32
43
  // collected chunk by chunk instead, so a declared-but-unsent body cannot pin more memory than a
@@ -36,6 +47,14 @@ const MAX_PREALLOCATED_BODY = 1024 * 1024;
36
47
  // what the finish pass feeds zlib: no bytes, only the flush flag
37
48
  const EMPTY_BUFFER = Buffer.alloc(0);
38
49
 
50
+ // What express.static serves instead of the file itself when preCompressed is on and the client
51
+ // takes it: the suffix nginx, brotli_static and every build tool that writes these agree on.
52
+ // Ordered by what is worth having, and negotiation decides between them.
53
+ const PRECOMPRESSED = [
54
+ { encoding: "br", suffix: ".br", flag: ENCODING_BR },
55
+ { encoding: "gzip", suffix: ".gz", flag: ENCODING_GZIP }
56
+ ];
57
+
39
58
  // The failures express.static answers by moving on to the next handler rather than by reporting
40
59
  // them, when fallthrough is on. They all mean the same thing: the request is not a file here.
41
60
  //
@@ -248,6 +267,46 @@ function bodyError(message, status, type, extra) {
248
267
  return Object.assign(err, extra);
249
268
  }
250
269
 
270
+ /**
271
+ * The compressed twin of a file to serve in its place, or undefined when the client would rather
272
+ * have the file itself or the twin is not there.
273
+ *
274
+ * The stat comes back with it, and is what sendFile then answers from: the ETag and the
275
+ * Last-Modified of a variant are its own, which is the whole point. Two bodies sharing one ETag is
276
+ * how a shared cache ends up handing brotli to a client that cannot read it.
277
+ *
278
+ * At most two stats, and usually one: negotiation picks the best of the two encodings first, and
279
+ * only looks at the other when the client takes it too and the first file is missing.
280
+ *
281
+ * @param {string} filePath absolute path of the file that was asked for
282
+ * @param {string|undefined} accept the request's Accept-Encoding
283
+ * @returns {{suffix: string, encoding: string, stat: import("fs").Stats}|undefined}
284
+ */
285
+ function pickPrecompressed(filePath, accept) {
286
+ if (!accept) {
287
+ return undefined;
288
+ }
289
+ let allowed = ENCODING_BR | ENCODING_GZIP;
290
+ // twice at most: the second pass is the case where brotli won and there is no .br on disk
291
+ for (let attempt = 0; attempt < 2; attempt++) {
292
+ const chosen = negotiateEncoding(accept, allowed);
293
+ const variant = PRECOMPRESSED.find((candidate) => candidate.encoding === chosen);
294
+ if (!variant) {
295
+ return undefined;
296
+ }
297
+ try {
298
+ const stat = fs.statSync(filePath + variant.suffix);
299
+ if (!stat.isDirectory()) {
300
+ return { suffix: variant.suffix, encoding: variant.encoding, stat };
301
+ }
302
+ } catch {
303
+ // not on disk, which is the ordinary case for a file nobody precompressed
304
+ }
305
+ allowed &= ~variant.flag;
306
+ }
307
+ return undefined;
308
+ }
309
+
251
310
  /**
252
311
  * express.static, which is a thin front for res.sendFile: it resolves the path, refuses anything
253
312
  * that climbs out of the root, applies the dotfiles and index rules, and hands the rest over.
@@ -337,6 +396,9 @@ function serveStatic(root, options) {
337
396
  }
338
397
  let _path = url;
339
398
  const fullpath = path.resolve(path.join(root, url));
399
+ // the same file as _path, absolute: the two move together through the index and extension
400
+ // rules below, and only the precompressed lookup needs the absolute one
401
+ let filePath = fullpath;
340
402
  // What serve-static hands send is this path, except that a bare "/" under a mount the
341
403
  // request did not write with one becomes "": without that rule a mount whose root is a file
342
404
  // would ask the disk for a directory and could never answer at all.
@@ -400,6 +462,7 @@ function serveStatic(root, options) {
400
462
  try {
401
463
  stat = fs.statSync(fullpath + "." + options.extensions[i]);
402
464
  _path = url + "." + options.extensions[i];
465
+ filePath = fullpath + "." + options.extensions[i];
403
466
  break;
404
467
  } catch (extensionError) {
405
468
  statError = extensionError;
@@ -453,6 +516,7 @@ function serveStatic(root, options) {
453
516
  try {
454
517
  stat = fs.statSync(path.join(fullpath, options.index));
455
518
  _path = path.join(url, options.index);
519
+ filePath = path.join(fullpath, options.index);
456
520
  } catch (err) {
457
521
  if (!options.fallthrough) {
458
522
  res.status(404);
@@ -473,6 +537,23 @@ function serveStatic(root, options) {
473
537
  }
474
538
  }
475
539
 
540
+ if (options.preCompressed) {
541
+ // whatever is served, the answer depended on the header, so a shared cache has to be
542
+ // told. Said before the lookup, because it is true even when there is no variant
543
+ res.vary("Accept-Encoding");
544
+ const variant = pickPrecompressed(filePath, req.headers["accept-encoding"]);
545
+ if (variant) {
546
+ _path += variant.suffix;
547
+ stat = variant.stat;
548
+ res.setHeader("Content-Encoding", variant.encoding);
549
+ // from the name of the file that was asked for, since the one being sent ends in
550
+ // .br and nothing would call that javascript. sendFile leaves a content-type that
551
+ // is already there alone, which is what makes this the deciding one
552
+ const type = mime.lookup(filePath);
553
+ res.type(type || "application/octet-stream");
554
+ }
555
+ }
556
+
476
557
  options._stat = stat;
477
558
 
478
559
  return res.sendFile(
package/src/options.d.ts CHANGED
@@ -68,6 +68,12 @@ export interface StaticOptions extends SendFileOptions {
68
68
  fallthrough?: boolean;
69
69
  /** Extensions tried when the path names no file, or false to try none. */
70
70
  extensions?: string[] | false;
71
+ /**
72
+ * Serve `file.br` or `file.gz` in place of `file` when one is on disk and the client takes it.
73
+ * Off by default. Vary: Accept-Encoding is sent whether or not a variant is found, and the
74
+ * content type stays the one the requested name implies.
75
+ */
76
+ preCompressed?: boolean;
71
77
  }
72
78
 
73
79
  /** A body parser's options once its factory has filled in every default it needs. */
package/src/response.js CHANGED
@@ -790,7 +790,10 @@ module.exports = class Response extends LazyWritable {
790
790
  // remembered rather than measured: only a caller that asks for content-length pays
791
791
  // for it, and uWS is measuring the same bytes for the wire anyway
792
792
  this._sentBody = data ?? "";
793
- this._res.end(data);
793
+ // and null is sent as the empty body it means. uWS answers end(null) with a
794
+ // response the client never sees the end of, where node and express send an empty
795
+ // 200: res.end(null) is what the compression module's own test suite does
796
+ this._res.end(data ?? "");
794
797
  }
795
798
  }
796
799
 
package/src/types.d.ts CHANGED
@@ -17,6 +17,7 @@ limitations under the License.
17
17
  declare module "fulmine.js" {
18
18
  import e from "express";
19
19
  import uWS from "uWebSockets.js";
20
+ import { ZlibOptions, BrotliOptions } from "zlib";
20
21
 
21
22
  type Settings = {
22
23
  uwsOptions?: uWS.AppOptions;
@@ -37,6 +38,24 @@ declare module "fulmine.js" {
37
38
  export import static = e.static;
38
39
  // export import query = e.query;
39
40
 
41
+ // express has no compression middleware, so there is nothing to re-export: these are the
42
+ // compression module's options, which this one takes as they are
43
+ interface CompressionOptions extends ZlibOptions {
44
+ /** The smallest body worth compressing, in bytes or as "1kb". Default 1024. */
45
+ threshold?: number | string;
46
+ /** Whether this response should be compressed at all. */
47
+ filter?: (req: e.Request, res: e.Response) => boolean;
48
+ /** What to use when the request carries no Accept-Encoding. Default "identity". */
49
+ enforceEncoding?: string;
50
+ /** Brotli options. The default quality is 4. */
51
+ brotli?: BrotliOptions;
52
+ }
53
+ export function compression(options?: CompressionOptions): e.RequestHandler;
54
+ export namespace compression {
55
+ /** The default filter: any compressible content type. */
56
+ function filter(req: e.Request, res: e.Response): boolean;
57
+ }
58
+
40
59
  export import urlencoded = e.urlencoded;
41
60
 
42
61
  export import RouterOptions = e.RouterOptions;
package/src/utils.js CHANGED
@@ -788,6 +788,113 @@ function stringify(value, replacer, spaces, escape) {
788
788
  return json;
789
789
  }
790
790
 
791
+ // What negotiateEncoding may answer with, since a caller can only offer what it can produce
792
+ const ENCODING_BR = 1;
793
+ const ENCODING_GZIP = 2;
794
+ const ENCODING_DEFLATE = 4;
795
+ const ENCODING_ANY = ENCODING_BR | ENCODING_GZIP | ENCODING_DEFLATE;
796
+
797
+ /**
798
+ * The encoding to answer with, read straight off Accept-Encoding rather than through negotiator:
799
+ * the header is a short list of names with an optional q, and building a Negotiator per response
800
+ * to read it costs more than the scan does.
801
+ *
802
+ * The tie-break is negotiator's, for the list the compression module hands it: brotli first, then
803
+ * gzip, then deflate, and identity last.
804
+ *
805
+ * Only the encodings named in `allowed` are on offer, since the caller may not be able to
806
+ * produce all three: express.static offers the two it can have lying on disk. An uncompressed
807
+ * answer is always on offer, and is what an empty header ends up choosing.
808
+ *
809
+ * @param {string} accept the header, or "" when the request carried none
810
+ * @param {number} allowed ENCODING_BR, ENCODING_GZIP and ENCODING_DEFLATE, or'd together
811
+ * @returns {string} "br", "gzip", "deflate", "identity", or "" when nothing is acceptable
812
+ */
813
+ function negotiateEncoding(accept, allowed) {
814
+ // -1 while a name has not appeared: q=0 is a refusal and has to be told apart from silence
815
+ let br = -1;
816
+ let gzip = -1;
817
+ let deflate = -1;
818
+ let identity = -1;
819
+ let star = -1;
820
+ // the lowest q anything was named with, which is what an unnamed identity is worth, see below
821
+ let minQuality = 1;
822
+ let index = 0;
823
+ while (index < accept.length) {
824
+ let end = accept.indexOf(",", index);
825
+ if (end === -1) {
826
+ end = accept.length;
827
+ }
828
+ let semi = accept.indexOf(";", index);
829
+ if (semi === -1 || semi > end) {
830
+ semi = end;
831
+ }
832
+ const name = accept.slice(index, semi).trim().toLowerCase();
833
+ let q = 1;
834
+ if (semi < end) {
835
+ const params = accept.slice(semi + 1, end);
836
+ const at = params.indexOf("q=");
837
+ if (at !== -1) {
838
+ const parsed = parseFloat(params.slice(at + 2));
839
+ // a q nobody can read is a refusal, which is how negotiator reads it too
840
+ q = parsed === parsed ? parsed : 0;
841
+ }
842
+ }
843
+ if (q < minQuality) {
844
+ minQuality = q;
845
+ }
846
+ switch (name) {
847
+ case "br":
848
+ br = q;
849
+ break;
850
+ case "gzip":
851
+ gzip = q;
852
+ break;
853
+ case "deflate":
854
+ deflate = q;
855
+ break;
856
+ case "identity":
857
+ identity = q;
858
+ break;
859
+ case "*":
860
+ star = q;
861
+ break;
862
+ }
863
+ index = end + 1;
864
+ }
865
+ if (br < 0) br = star;
866
+ if (gzip < 0) gzip = star;
867
+ if (deflate < 0) deflate = star;
868
+ // An uncompressed answer that the request did not name is worth the lowest q it named
869
+ // anything with, which is negotiator's rule and not the obvious one: "br;q=0.5, gzip;q=0.9"
870
+ // means gzip, because identity comes in at 0.5 rather than at 1 and does not win the list.
871
+ // A "*" names identity as much as it names anything else, so its q is identity's.
872
+ if (identity < 0) identity = star < 0 ? minQuality : star;
873
+
874
+ if (!(allowed & ENCODING_BR)) br = -1;
875
+ if (!(allowed & ENCODING_GZIP)) gzip = -1;
876
+ if (!(allowed & ENCODING_DEFLATE)) deflate = -1;
877
+
878
+ let best = "";
879
+ let bestQ = 0;
880
+ if (br > bestQ) {
881
+ best = "br";
882
+ bestQ = br;
883
+ }
884
+ if (gzip > bestQ) {
885
+ best = "gzip";
886
+ bestQ = gzip;
887
+ }
888
+ if (deflate > bestQ) {
889
+ best = "deflate";
890
+ bestQ = deflate;
891
+ }
892
+ if (identity > bestQ) {
893
+ best = "identity";
894
+ }
895
+ return best;
896
+ }
897
+
791
898
  const defaultSettings = {
792
899
  "jsonp callback name": "callback",
793
900
  env: () => process.env.NODE_ENV ?? "development",
@@ -1305,6 +1412,10 @@ module.exports = {
1305
1412
  entityTag,
1306
1413
  statTag,
1307
1414
  contentTypeFor,
1415
+ negotiateEncoding,
1416
+ ENCODING_BR,
1417
+ ENCODING_GZIP,
1418
+ ENCODING_ANY,
1308
1419
  memoizeByString,
1309
1420
  isRangeFresh,
1310
1421
  findIndexStartingFrom,