fulmine.js 5.5.2 → 5.7.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 and a fraction of the bytes goes out: on a 4KB script with a brotli twin, 12 times fewer. It costs no more than serving the file itself, one `stat` per request, because the twin is looked for before the file and its own `stat` is the only one the request needs. A type that is already compressed, a woff2 or a webp, is not looked up at all, and which twins a path has is remembered for a second: `{ cache: false }` asks the disk every time, `{ cache: "5s" }` sets the window. Only their presence is remembered, never their size or mtime, so nothing is ever described by a stale number. `Vary: Accept-Encoding` is sent whether or not a twin 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.7.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,445 @@
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, memoizeByString } = 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
+ /**
45
+ * Says the answer depends on Accept-Encoding. res.vary() parses what is there and merges, which on
46
+ * the usual response is parsing an absent header: only a response that already varies pays for it.
47
+ *
48
+ * @param {any} res
49
+ */
50
+ function addVary(res) {
51
+ if (res.getHeader("Vary") === undefined) {
52
+ res.setHeader("Vary", "Accept-Encoding");
53
+ return;
54
+ }
55
+ res.vary("Accept-Encoding");
56
+ }
57
+
58
+ /**
59
+ * res.flush for a response that is not being compressed. The compression module puts a function
60
+ * there on every response it sees, and code written against it calls one without asking first.
61
+ */
62
+ function noFlush() {}
63
+
64
+ // what enforceEncoding is allowed to name, the compression module's list
65
+ const ENFORCEABLE = new Set(["gzip", "deflate", "identity", "br"]);
66
+
67
+ // Up to this many bytes a whole body is compressed on this thread, and above it on the libuv pool.
68
+ // One call either way; what changes is who waits. A small body pays more for the hop onto the pool
69
+ // than the compression costs, and a large one is worth handing over, since the pool has four
70
+ // threads and the loop has everyone else to serve: measured with gzip at the default level, sync
71
+ // wins by 43% at 1.4KB and by 22% at 16KB, and loses by 32% at 32KB and by 90% at 78KB.
72
+ const SYNC_LIMIT = 24 * 1024;
73
+
74
+ /**
75
+ * The default filter: whether the content type is worth compressing at all. A response with no
76
+ * type is left alone, since nothing says what its bytes are.
77
+ *
78
+ * @param {any} req
79
+ * @param {any} res
80
+ * @returns {boolean}
81
+ */
82
+ function shouldCompress(req, res) {
83
+ const type = res.getHeader("Content-Type");
84
+ if (type === undefined) {
85
+ return false;
86
+ }
87
+ // memoized, because an application answers with two or three content-types and compressible
88
+ // splits the parameters off and searches the mime database to reach the same answer each time
89
+ return isCompressible(typeof type === "string" ? type : String(type));
90
+ }
91
+
92
+ const isCompressible = memoizeByString((type) => compressible(type) === true);
93
+
94
+ /**
95
+ * How many bytes a chunk is, which is what the threshold is compared against.
96
+ *
97
+ * @param {any} chunk
98
+ * @param {BufferEncoding} [encoding]
99
+ * @returns {number}
100
+ */
101
+ function chunkLength(chunk, encoding) {
102
+ if (chunk === undefined || chunk === null) {
103
+ return 0;
104
+ }
105
+ return Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk, encoding);
106
+ }
107
+
108
+ /**
109
+ * The bytes of a chunk, whatever it arrived as.
110
+ *
111
+ * @param {any} chunk
112
+ * @param {BufferEncoding} [encoding]
113
+ * @returns {Buffer}
114
+ */
115
+ function toBuffer(chunk, encoding) {
116
+ if (Buffer.isBuffer(chunk)) {
117
+ return chunk;
118
+ }
119
+ // end() with nothing to send still has to hand the compressor something, and a threshold of 0
120
+ // lets an empty body reach it: Buffer.from(undefined) throws where this sends the empty answer
121
+ if (chunk === undefined || chunk === null) {
122
+ return Buffer.alloc(0);
123
+ }
124
+ return Buffer.from(chunk, encoding);
125
+ }
126
+
127
+ /**
128
+ * Compresses a response body as the client asked for it.
129
+ *
130
+ * @param {object} [options]
131
+ * @param {number|string} [options.threshold] the smallest body worth compressing, bytes or "1kb".
132
+ * Default 1024. A response whose size is not known in advance is compressed whatever its size.
133
+ * @param {(req: any, res: any) => boolean} [options.filter] whether this response should be
134
+ * compressed at all. The default says yes to any compressible content type.
135
+ * @param {string} [options.enforceEncoding] what to use when the request carries no
136
+ * Accept-Encoding at all. Default "identity", which is to say nothing is compressed.
137
+ * @param {object} [options.brotli] brotli options, `params` included. The default quality is 4.
138
+ * @param {number} [options.level] zlib compression level, for gzip and deflate.
139
+ * @param {number} [options.chunkSize] zlib chunk size.
140
+ * @param {number} [options.memLevel] zlib memory level.
141
+ * @param {number} [options.strategy] zlib strategy.
142
+ * @param {number} [options.windowBits] zlib window size.
143
+ * @returns {(req: any, res: any, next: (err?: any) => void) => void} the middleware
144
+ */
145
+ function compression(options) {
146
+ const opts = options || {};
147
+ // the whole bag goes to zlib, as the compression module does: level, memLevel, strategy,
148
+ // windowBits and chunkSize arrive under their own names and zlib ignores the rest
149
+ const zlibOptions = /** @type {any} */ (opts);
150
+ const brotliOptions = { ...opts.brotli };
151
+ brotliOptions.params = {
152
+ [zlib.constants.BROTLI_PARAM_QUALITY]: 4,
153
+ ...(opts.brotli && /** @type {any} */ (opts.brotli).params)
154
+ };
155
+ const filter = opts.filter || shouldCompress;
156
+ const enforceEncoding = opts.enforceEncoding || "identity";
157
+ // bytes.parse reads "1kb" and hands back null for anything it cannot, an absent option
158
+ // included, which is where the default comes in
159
+ const threshold = bytes.parse(/** @type {any} */ (opts.threshold)) ?? 1024;
160
+
161
+ /**
162
+ * A whole body, compressed on this thread. Blocks the event loop for as long as it takes,
163
+ * which is why only a small one comes here, see SYNC_LIMIT.
164
+ *
165
+ * @param {string} method
166
+ * @param {Buffer} body
167
+ * @returns {Buffer}
168
+ */
169
+ function compressWhole(method, body) {
170
+ if (method === "gzip") {
171
+ return zlib.gzipSync(body, zlibOptions);
172
+ }
173
+ if (method === "br") {
174
+ return zlib.brotliCompressSync(body, brotliOptions);
175
+ }
176
+ return zlib.deflateSync(body, zlibOptions);
177
+ }
178
+
179
+ /**
180
+ * The same, on the libuv thread pool.
181
+ *
182
+ * @param {string} method
183
+ * @param {Buffer} body
184
+ * @param {(err: Error|null, out: Buffer) => void} done
185
+ */
186
+ function compressWholeAsync(method, body, done) {
187
+ if (method === "gzip") {
188
+ zlib.gzip(body, zlibOptions, done);
189
+ } else if (method === "br") {
190
+ zlib.brotliCompress(body, brotliOptions, done);
191
+ } else {
192
+ zlib.deflate(body, zlibOptions, done);
193
+ }
194
+ }
195
+
196
+ /**
197
+ * @param {string} method
198
+ * @returns {any} the transform stream for a body that arrives in pieces
199
+ */
200
+ function compressStream(method) {
201
+ if (method === "gzip") {
202
+ return zlib.createGzip(zlibOptions);
203
+ }
204
+ if (method === "br") {
205
+ return zlib.createBrotliCompress(brotliOptions);
206
+ }
207
+ return zlib.createDeflate(zlibOptions);
208
+ }
209
+
210
+ return function compression(req, res, next) {
211
+ // Negotiated here rather than when the body arrives, because the answer to "could this
212
+ // request take a compressed body at all" decides how much of this middleware the response
213
+ // has to carry. Most requests to most routes cannot: a client that sent no Accept-Encoding,
214
+ // one that refused everything, a HEAD. Those get the Vary and nothing else, since the
215
+ // 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);
218
+ if (accept === undefined && ENFORCEABLE.has(enforceEncoding)) {
219
+ chosen = enforceEncoding;
220
+ }
221
+ if (!chosen || chosen === "identity" || req.method === "HEAD") {
222
+ res.flush = noFlush;
223
+ const _plainEnd = res.end;
224
+ let varied = false;
225
+ res.end = function end(chunk, encoding, callback) {
226
+ if (!varied) {
227
+ varied = true;
228
+ const cacheControl = res.headersSent ? undefined : res.getHeader("Cache-Control");
229
+ if (
230
+ !res.headersSent &&
231
+ filter(req, res) &&
232
+ !(cacheControl && NO_TRANSFORM.test(String(cacheControl)))
233
+ ) {
234
+ addVary(res);
235
+ }
236
+ }
237
+ return _plainEnd.call(this, chunk, encoding, callback);
238
+ };
239
+ return next();
240
+ }
241
+
242
+ const _write = res.write;
243
+ const _end = res.end;
244
+ const _on = res.on;
245
+
246
+ /** drain listeners parked until there is a compressor to hang them on, see res.on below */
247
+ let listeners = /** @type {any[][]|null} */ ([]);
248
+ /** @type {any} */
249
+ let stream = null;
250
+ let decided = false;
251
+ let ended = false;
252
+ /** what end() was given to call back, held until the compressor has finished */
253
+ let endCallback = /** @type {any} */ (undefined);
254
+
255
+ // the compression module adds this, and code written against it calls it: an SSE feed
256
+ // pushes its event out with res.flush(). Nothing to flush before there is a compressor
257
+ res.flush = function flush() {
258
+ if (stream) {
259
+ stream.flush();
260
+ }
261
+ };
262
+
263
+ /**
264
+ * Hands back the parked drain listeners: this response is not being compressed, so the
265
+ * response itself is what a pipe should hear from.
266
+ * @returns {string} the empty method, so the callers can `return noCompress()`
267
+ */
268
+ function noCompress() {
269
+ if (listeners) {
270
+ for (const listener of listeners) {
271
+ _on.call(res, listener[0], listener[1]);
272
+ }
273
+ listeners = null;
274
+ }
275
+ return "";
276
+ }
277
+
278
+ /**
279
+ * Whether this response is compressed, and how. Taken once, when the first byte of the
280
+ * body arrives, which is also when the headers are decided: everything read here is set
281
+ * by then. The order is the compression module's, and so is the Vary, which is added even
282
+ * when the answer goes out uncompressed because the answer still depends on the header.
283
+ *
284
+ * @param {number} [length] the size of the body, when end() already has all of it
285
+ * @returns {string} the encoding chosen, "" to send the body as it is
286
+ */
287
+ function decide(length) {
288
+ decided = true;
289
+ // res.flushHeaders() commits the head here rather than holding it until the body, so a
290
+ // response that used it has no room left for a Content-Encoding
291
+ if (res.headersSent) {
292
+ return noCompress();
293
+ }
294
+ if (!filter(req, res)) {
295
+ return noCompress();
296
+ }
297
+ const cacheControl = res.getHeader("Cache-Control");
298
+ if (cacheControl && NO_TRANSFORM.test(String(cacheControl))) {
299
+ return noCompress();
300
+ }
301
+ addVary(res);
302
+ // NaN when there is no Content-Length, and a comparison against NaN is false: a body
303
+ // whose size is not known yet is compressed whatever the threshold says
304
+ if (Number(res.getHeader("Content-Length")) < threshold || Number(length) < threshold) {
305
+ return noCompress();
306
+ }
307
+ const already = res.getHeader("Content-Encoding");
308
+ if (already && already !== "identity") {
309
+ return noCompress();
310
+ }
311
+ // a range is a window into the bytes on disk, and a client that asked for one cannot
312
+ // decode a compressed answer to it
313
+ if (res.statusCode === 206 || res.getHeader("Content-Range") !== undefined) {
314
+ return noCompress();
315
+ }
316
+ // HEAD never reaches here: it took the Vary-only path above
317
+ res.setHeader("Content-Encoding", chosen);
318
+ // what it says is the size of the body before this middleware saw it. The whole-body
319
+ // path below puts the right one back; the streaming one cannot know it in advance
320
+ res.removeHeader("Content-Length");
321
+ return chosen;
322
+ }
323
+
324
+ /**
325
+ * Starts the compressor for a body that arrives in pieces, and wires it to the response.
326
+ * @param {string} method
327
+ */
328
+ function startStream(method) {
329
+ stream = compressStream(method);
330
+ // The parked listeners, and the list itself stays rather than being emptied: res.on
331
+ // reads it to know that a drain listener belongs on the compressor from here on. That
332
+ // matters because a pipe registers its own the first time write() tells it to slow
333
+ // down, which is after this, and from here on the compressor is what fills up.
334
+ for (const listener of /** @type {any[][]} */ (listeners)) {
335
+ stream.on(listener[0], listener[1]);
336
+ }
337
+ stream.on("data", (chunk) => {
338
+ if (_write.call(res, chunk) === false) {
339
+ stream.pause();
340
+ }
341
+ });
342
+ stream.on("end", () => {
343
+ _end.call(res, endCallback);
344
+ });
345
+ _on.call(res, "drain", () => stream.resume());
346
+ // an aborted response never reaches the end of the stream, and the zlib context behind
347
+ // it is native memory that a garbage collector is in no hurry to reach
348
+ _on.call(res, "close", () => stream.destroy());
349
+ }
350
+
351
+ res.write = function write(chunk, encoding, callback) {
352
+ if (typeof encoding === "function") {
353
+ callback = encoding;
354
+ encoding = undefined;
355
+ }
356
+ if (ended) {
357
+ return false;
358
+ }
359
+ if (!decided) {
360
+ const method = decide();
361
+ if (method) {
362
+ startStream(method);
363
+ }
364
+ }
365
+ if (stream) {
366
+ return stream.write(toBuffer(chunk, encoding), callback);
367
+ }
368
+ return _write.call(this, chunk, encoding, callback);
369
+ };
370
+
371
+ res.end = function end(chunk, encoding, callback) {
372
+ // node's shapes, of which this project's own end() takes (data, cb): the third
373
+ // argument only arrives from code written against node's ServerResponse
374
+ if (typeof chunk === "function") {
375
+ callback = chunk;
376
+ chunk = undefined;
377
+ encoding = undefined;
378
+ } else if (typeof encoding === "function") {
379
+ callback = encoding;
380
+ encoding = undefined;
381
+ }
382
+ if (ended) {
383
+ return this;
384
+ }
385
+ if (stream) {
386
+ ended = true;
387
+ endCallback = callback;
388
+ if (chunk === undefined || chunk === null || chunk === "") {
389
+ stream.end();
390
+ } else {
391
+ stream.end(toBuffer(chunk, encoding));
392
+ }
393
+ return this;
394
+ }
395
+ if (!decided) {
396
+ const method = decide(chunkLength(chunk, encoding));
397
+ if (method) {
398
+ // the whole answer is here, so it is compressed in one call rather than
399
+ // through a stream, and goes out with the length it ended up being
400
+ ended = true;
401
+ const input = toBuffer(chunk, encoding);
402
+ if (input.length <= SYNC_LIMIT) {
403
+ const body = compressWhole(method, input);
404
+ res.setHeader("Content-Length", String(body.length));
405
+ return _end.call(this, body, callback);
406
+ }
407
+ compressWholeAsync(method, input, (err, body) => {
408
+ // the client can leave while the pool is working, and writing to a
409
+ // response that is already gone is not something uWS survives
410
+ if (res.aborted || res.finished) {
411
+ return;
412
+ }
413
+ if (err) {
414
+ return res.destroy(err);
415
+ }
416
+ res.setHeader("Content-Length", String(body.length));
417
+ _end.call(res, body, callback);
418
+ });
419
+ return this;
420
+ }
421
+ }
422
+ ended = true;
423
+ return _end.call(this, chunk, callback);
424
+ };
425
+
426
+ res.on = function on(type, listener) {
427
+ if (!listeners || type !== "drain") {
428
+ return _on.call(this, type, listener);
429
+ }
430
+ if (stream) {
431
+ return stream.on(type, listener);
432
+ }
433
+ // there is nothing to listen to yet: a compressor that does not exist has not filled up
434
+ listeners.push([type, listener]);
435
+ return this;
436
+ };
437
+
438
+ next();
439
+ };
440
+ }
441
+
442
+ module.exports = compression;
443
+ // the compression module exports its default filter, and a front that wants to compress one more
444
+ // type than the default calls it and adds to what it says
445
+ 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,24 @@ 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");
26
+ const compressible = require("compressible");
27
+ const ms = require("ms");
25
28
  const qs = require("qs");
26
29
  const parseQuery = require("./parse-query.js");
27
30
  const { kGetSafe } = require("./usage.js");
28
31
  const { AsyncResource } = require("async_hooks");
29
- const { fastQueryParse, NullObject, asStatError, httpError, memoizeByString, containsDotFile } = require("./utils.js");
32
+ const {
33
+ fastQueryParse,
34
+ NullObject,
35
+ asStatError,
36
+ httpError,
37
+ memoizeByString,
38
+ containsDotFile,
39
+ negotiateEncoding,
40
+ ENCODING_BR,
41
+ ENCODING_GZIP
42
+ } = require("./utils.js");
30
43
 
31
44
  // largest content-length we will allocate a body buffer for up front. above this the body is
32
45
  // collected chunk by chunk instead, so a declared-but-unsent body cannot pin more memory than a
@@ -36,6 +49,14 @@ const MAX_PREALLOCATED_BODY = 1024 * 1024;
36
49
  // what the finish pass feeds zlib: no bytes, only the flush flag
37
50
  const EMPTY_BUFFER = Buffer.alloc(0);
38
51
 
52
+ // What express.static serves instead of the file itself when preCompressed is on and the client
53
+ // takes it: the suffix nginx, brotli_static and every build tool that writes these agree on.
54
+ // Ordered by what is worth having, and negotiation decides between them.
55
+ const PRECOMPRESSED = [
56
+ { encoding: "br", suffix: ".br", flag: ENCODING_BR },
57
+ { encoding: "gzip", suffix: ".gz", flag: ENCODING_GZIP }
58
+ ];
59
+
39
60
  // The failures express.static answers by moving on to the next handler rather than by reporting
40
61
  // them, when fallthrough is on. They all mean the same thing: the request is not a file here.
41
62
  //
@@ -248,6 +269,98 @@ function bodyError(message, status, type, extra) {
248
269
  return Object.assign(err, extra);
249
270
  }
250
271
 
272
+ /**
273
+ * Whether a file of this extension is one anybody writes a `.br` or a `.gz` next to. A webp or a
274
+ * woff2 is already compressed and never has a twin, and looking for one costs two stats on a
275
+ * request that could not have used it: on a mixed directory that is most of the stat time. An
276
+ * extension nothing knows is looked up anyway, since it might well be text.
277
+ *
278
+ * @param {string} extension including the dot, or "" for a name without one
279
+ * @returns {boolean}
280
+ */
281
+ const hasTwins = memoizeByString((extension) => {
282
+ const type = mime.lookup(extension);
283
+ return type ? compressible(type) === true : true;
284
+ });
285
+
286
+ // Which twins a path has, remembered for a moment. What is cached is only whether they are there,
287
+ // never their size or their mtime: those decide the ETag, the Last-Modified and the length, so they
288
+ // are read fresh on every request and a file that changed is never described by a stale number.
289
+ // The worst a stale entry can do is serve the file where it could have served the twin, or look for
290
+ // a twin that has just been deleted and fall back. nginx's open_file_cache is the same trade.
291
+ const twinCache = new Map();
292
+ const TWIN_CACHE_LIMIT = 4096;
293
+
294
+ /**
295
+ * What is known about a path's twins right now, as a record to fill in.
296
+ *
297
+ * @param {string} filePath
298
+ * @param {number} ttl how long an answer stays good, in milliseconds
299
+ * @returns {{br: boolean|undefined, gz: boolean|undefined, until: number}}
300
+ */
301
+ function twinsOf(filePath, ttl) {
302
+ const now = Date.now();
303
+ const known = twinCache.get(filePath);
304
+ if (known !== undefined && known.until > now) {
305
+ return known;
306
+ }
307
+ const entry = { br: undefined, gz: undefined, until: now + ttl };
308
+ // cleared rather than evicted one by one, as memoizeByString does: this holds one small object
309
+ // per path served, and a directory big enough to reach the limit is being served by something
310
+ // other than an application server anyway
311
+ if (twinCache.size >= TWIN_CACHE_LIMIT) {
312
+ twinCache.clear();
313
+ }
314
+ twinCache.set(filePath, entry);
315
+ return entry;
316
+ }
317
+
318
+ /**
319
+ * The compressed twin of a file to serve in its place, or undefined when the client would rather
320
+ * have the file itself or the twin is not there.
321
+ *
322
+ * The stat comes back with it, and is what sendFile then answers from: the ETag and the
323
+ * Last-Modified of a variant are its own, which is the whole point. Two bodies sharing one ETag is
324
+ * how a shared cache ends up handing brotli to a client that cannot read it.
325
+ *
326
+ * One stat when the answer is a twin, and none at all when the last request already found there is
327
+ * no twin to have. See twinCache above for what is remembered and what is not.
328
+ *
329
+ * @param {string} filePath absolute path of the file that was asked for
330
+ * @param {string|undefined} accept the request's Accept-Encoding
331
+ * @param {number} ttl how long the twin cache holds an answer, 0 to ask the disk every time
332
+ * @returns {{suffix: string, encoding: string, stat: import("fs").Stats}|undefined}
333
+ */
334
+ function pickPrecompressed(filePath, accept, ttl) {
335
+ if (!accept || !hasTwins(filePath.slice(filePath.lastIndexOf(".")))) {
336
+ return undefined;
337
+ }
338
+ const known = ttl > 0 ? twinsOf(filePath, ttl) : undefined;
339
+ let allowed = ENCODING_BR | ENCODING_GZIP;
340
+ // twice at most: the second pass is the case where brotli won and there is no .br on disk
341
+ for (let attempt = 0; attempt < 2; attempt++) {
342
+ const chosen = negotiateEncoding(accept, allowed);
343
+ const variant = PRECOMPRESSED.find((candidate) => candidate.encoding === chosen);
344
+ if (!variant) {
345
+ return undefined;
346
+ }
347
+ if (known === undefined || known[variant.encoding === "br" ? "br" : "gz"] !== false) {
348
+ try {
349
+ const stat = fs.statSync(filePath + variant.suffix);
350
+ if (!stat.isDirectory()) {
351
+ if (known !== undefined) known[variant.encoding === "br" ? "br" : "gz"] = true;
352
+ return { suffix: variant.suffix, encoding: variant.encoding, stat };
353
+ }
354
+ } catch {
355
+ // not on disk, which is the ordinary case for a file nobody precompressed
356
+ }
357
+ if (known !== undefined) known[variant.encoding === "br" ? "br" : "gz"] = false;
358
+ }
359
+ allowed &= ~variant.flag;
360
+ }
361
+ return undefined;
362
+ }
363
+
251
364
  /**
252
365
  * express.static, which is a thin front for res.sendFile: it resolves the path, refuses anything
253
366
  * that climbs out of the root, applies the dotfiles and index rules, and hands the rest over.
@@ -284,6 +397,26 @@ function serveStatic(root, options) {
284
397
  if (options.setHeaders !== undefined && typeof options.setHeaders !== "function") {
285
398
  throw new TypeError("option setHeaders must be function");
286
399
  }
400
+ // How long express.static remembers which twins a path has. A second is short enough that a
401
+ // deploy is picked up while it is still going out, and long enough that the lookup costs
402
+ // nothing under any traffic at all. { cache: false } asks the disk on every request.
403
+ let twinTtl = 0;
404
+ if (options.preCompressed) {
405
+ const cache = /** @type {any} */ (
406
+ typeof options.preCompressed === "object" ? options.preCompressed.cache : undefined
407
+ );
408
+ twinTtl =
409
+ cache === undefined
410
+ ? 1000
411
+ : cache === false
412
+ ? 0
413
+ : typeof cache === "string"
414
+ ? ms(/** @type {any} */ (cache))
415
+ : cache;
416
+ if (typeof twinTtl !== "number" || !(twinTtl >= 0)) {
417
+ throw new TypeError("option preCompressed.cache must be a duration");
418
+ }
419
+ }
287
420
  options.root = root;
288
421
  // serve-static decides this for itself and never asks the app, so a static file keeps its
289
422
  // ETag under app.set("etag", false) and only { etag: false } here turns it off. res.sendFile
@@ -337,6 +470,9 @@ function serveStatic(root, options) {
337
470
  }
338
471
  let _path = url;
339
472
  const fullpath = path.resolve(path.join(root, url));
473
+ // the same file as _path, absolute: the two move together through the index and extension
474
+ // rules below, and only the precompressed lookup needs the absolute one
475
+ let filePath = fullpath;
340
476
  // What serve-static hands send is this path, except that a bare "/" under a mount the
341
477
  // request did not write with one becomes "": without that rule a mount whose root is a file
342
478
  // would ask the disk for a directory and could never answer at all.
@@ -372,8 +508,22 @@ function serveStatic(root, options) {
372
508
  }
373
509
 
374
510
  let stat;
511
+ // The twin, looked for before the file itself rather than after it. When there is one, it
512
+ // is the file being served and its stat is the only one this request needs: the request
513
+ // that asks for /app.js and gets /app.js.br has no use for /app.js's size or mtime. A
514
+ // directory, or a path written with a trailing slash, keeps the ordinary order, since what
515
+ // decides those is the stat of the thing that was asked for.
516
+ let twin;
517
+ if (options.preCompressed && !rawPath.endsWith("/") && !req.endsWithSlash) {
518
+ twin = pickPrecompressed(filePath, req.headers["accept-encoding"], twinTtl);
519
+ if (twin) {
520
+ stat = twin.stat;
521
+ }
522
+ }
375
523
  try {
376
- stat = fs.statSync(statTarget);
524
+ if (stat === undefined) {
525
+ stat = fs.statSync(statTarget);
526
+ }
377
527
  } catch (err) {
378
528
  // the one to report when nothing is found: send hands each failed attempt to the next
379
529
  // one and reports whichever came last, so an extensions option that also missed names
@@ -400,6 +550,7 @@ function serveStatic(root, options) {
400
550
  try {
401
551
  stat = fs.statSync(fullpath + "." + options.extensions[i]);
402
552
  _path = url + "." + options.extensions[i];
553
+ filePath = fullpath + "." + options.extensions[i];
403
554
  break;
404
555
  } catch (extensionError) {
405
556
  statError = extensionError;
@@ -453,6 +604,7 @@ function serveStatic(root, options) {
453
604
  try {
454
605
  stat = fs.statSync(path.join(fullpath, options.index));
455
606
  _path = path.join(url, options.index);
607
+ filePath = path.join(fullpath, options.index);
456
608
  } catch (err) {
457
609
  if (!options.fallthrough) {
458
610
  res.status(404);
@@ -473,6 +625,24 @@ function serveStatic(root, options) {
473
625
  }
474
626
  }
475
627
 
628
+ if (options.preCompressed) {
629
+ // whatever is served, the answer depended on the header, so a shared cache has to be
630
+ // told. Said before the lookup, because it is true even when there is no variant
631
+ res.vary("Accept-Encoding");
632
+ // already found before the stat below, on the ordinary path
633
+ const variant = twin ?? pickPrecompressed(filePath, req.headers["accept-encoding"], twinTtl);
634
+ if (variant) {
635
+ _path += variant.suffix;
636
+ stat = variant.stat;
637
+ res.setHeader("Content-Encoding", variant.encoding);
638
+ // from the name of the file that was asked for, since the one being sent ends in
639
+ // .br and nothing would call that javascript. sendFile leaves a content-type that
640
+ // is already there alone, which is what makes this the deciding one
641
+ const type = mime.lookup(filePath);
642
+ res.type(type || "application/octet-stream");
643
+ }
644
+ }
645
+
476
646
  options._stat = stat;
477
647
 
478
648
  return res.sendFile(
package/src/options.d.ts CHANGED
@@ -68,6 +68,16 @@ 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
+ * Which twins a path has is remembered for a second, since asking the disk costs a stat per
77
+ * request; `{ cache: false }` asks every time, and a duration sets how long. Only their
78
+ * presence is cached, never their size or mtime.
79
+ */
80
+ preCompressed?: boolean | { cache?: number | string | false };
71
81
  }
72
82
 
73
83
  /** 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
@@ -220,9 +220,13 @@ function patternToRegex(pattern, isPrefix = false, caseSensitive = true, strict
220
220
  groupOutputName.set(group, name);
221
221
  return group;
222
222
  };
223
- // whether the token just emitted was a :parameter, which decides how greedy the next
224
- // optional group is allowed to be. see the comment where it is read
223
+ // whether the token just emitted was a :parameter or a wildcard, which decides how greedy the
224
+ // next optional group is allowed to be. see the comment where it is read
225
225
  let lastTokenWasParam = false;
226
+ // the wildcard just emitted, and where it ends, so an optional group written right after it
227
+ // can rewrite the two into one alternation. See the { branch
228
+ let lastWildcard = /** @type {{start: number, body: string, name: string}|null} */ (null);
229
+ let lastWildcardEnd = -1;
226
230
  // What path-to-regexp calls the wildcard backtrack: the literal text written since the last
227
231
  // wildcard. Once a wildcard has eaten slashes, a later one in the same path is held to a single
228
232
  // segment, or the two would divide the path between them in more than one way and the regex
@@ -339,8 +343,16 @@ function patternToRegex(pattern, isPrefix = false, caseSensitive = true, strict
339
343
  }
340
344
  const splatGroup = uniqueGroupName(name);
341
345
  wildcardNames.push(splatGroup);
342
- regexPattern += `(?<${splatGroup}>${wildcardClass()})`;
343
- lastTokenWasParam = false;
346
+ const body = wildcardClass();
347
+ // where this capture starts and what it is made of, so an optional group written right
348
+ // after it can rewrite the pair into the alternation path-to-regexp compiles. See the
349
+ // { branch below
350
+ lastWildcard = { start: regexPattern.length, body, name };
351
+ regexPattern += `(?<${splatGroup}>${body})`;
352
+ lastWildcardEnd = regexPattern.length;
353
+ // the group that follows is held to one segment of its own, the way it is after a
354
+ // parameter: without it ext could take the separator back and swallow the dots
355
+ lastTokenWasParam = true;
344
356
  continue;
345
357
  }
346
358
 
@@ -417,7 +429,23 @@ function patternToRegex(pattern, isPrefix = false, caseSensitive = true, strict
417
429
  gi++;
418
430
  }
419
431
  }
420
- regexPattern += `(?:${groupRegex})?`;
432
+ if (lastWildcard && lastWildcardEnd === regexPattern.length) {
433
+ // A wildcard immediately before the group. `(?<w>[^]+)(?:group)?` can never let
434
+ // the group match, because the wildcard is greedy and the group may be empty, and
435
+ // making the wildcard lazy is not the same thing either: it gives the trailing
436
+ // slash away, and /*path{.:ext} against /a/b/ then loses the empty last segment.
437
+ // path-to-regexp writes the two branches out instead, group first and the
438
+ // wildcard greedy in both, so that is what goes here. The second branch captures
439
+ // the same parameter under a name of its own, which is what uniqueGroupName is for.
440
+ const second = uniqueGroupName(lastWildcard.name);
441
+ wildcardNames.push(second);
442
+ const withWildcard = regexPattern.slice(lastWildcard.start);
443
+ regexPattern =
444
+ regexPattern.slice(0, lastWildcard.start) +
445
+ `(?:${withWildcard}${groupRegex}|(?<${second}>${lastWildcard.body}))`;
446
+ } else {
447
+ regexPattern += `(?:${groupRegex})?`;
448
+ }
421
449
  literal(groupContent);
422
450
  lastTokenWasParam = false;
423
451
  continue;
@@ -788,6 +816,113 @@ function stringify(value, replacer, spaces, escape) {
788
816
  return json;
789
817
  }
790
818
 
819
+ // What negotiateEncoding may answer with, since a caller can only offer what it can produce
820
+ const ENCODING_BR = 1;
821
+ const ENCODING_GZIP = 2;
822
+ const ENCODING_DEFLATE = 4;
823
+ const ENCODING_ANY = ENCODING_BR | ENCODING_GZIP | ENCODING_DEFLATE;
824
+
825
+ /**
826
+ * The encoding to answer with, read straight off Accept-Encoding rather than through negotiator:
827
+ * the header is a short list of names with an optional q, and building a Negotiator per response
828
+ * to read it costs more than the scan does.
829
+ *
830
+ * The tie-break is negotiator's, for the list the compression module hands it: brotli first, then
831
+ * gzip, then deflate, and identity last.
832
+ *
833
+ * Only the encodings named in `allowed` are on offer, since the caller may not be able to
834
+ * produce all three: express.static offers the two it can have lying on disk. An uncompressed
835
+ * answer is always on offer, and is what an empty header ends up choosing.
836
+ *
837
+ * @param {string} accept the header, or "" when the request carried none
838
+ * @param {number} allowed ENCODING_BR, ENCODING_GZIP and ENCODING_DEFLATE, or'd together
839
+ * @returns {string} "br", "gzip", "deflate", "identity", or "" when nothing is acceptable
840
+ */
841
+ function negotiateEncoding(accept, allowed) {
842
+ // -1 while a name has not appeared: q=0 is a refusal and has to be told apart from silence
843
+ let br = -1;
844
+ let gzip = -1;
845
+ let deflate = -1;
846
+ let identity = -1;
847
+ let star = -1;
848
+ // the lowest q anything was named with, which is what an unnamed identity is worth, see below
849
+ let minQuality = 1;
850
+ let index = 0;
851
+ while (index < accept.length) {
852
+ let end = accept.indexOf(",", index);
853
+ if (end === -1) {
854
+ end = accept.length;
855
+ }
856
+ let semi = accept.indexOf(";", index);
857
+ if (semi === -1 || semi > end) {
858
+ semi = end;
859
+ }
860
+ const name = accept.slice(index, semi).trim().toLowerCase();
861
+ let q = 1;
862
+ if (semi < end) {
863
+ const params = accept.slice(semi + 1, end);
864
+ const at = params.indexOf("q=");
865
+ if (at !== -1) {
866
+ const parsed = parseFloat(params.slice(at + 2));
867
+ // a q nobody can read is a refusal, which is how negotiator reads it too
868
+ q = parsed === parsed ? parsed : 0;
869
+ }
870
+ }
871
+ if (q < minQuality) {
872
+ minQuality = q;
873
+ }
874
+ switch (name) {
875
+ case "br":
876
+ br = q;
877
+ break;
878
+ case "gzip":
879
+ gzip = q;
880
+ break;
881
+ case "deflate":
882
+ deflate = q;
883
+ break;
884
+ case "identity":
885
+ identity = q;
886
+ break;
887
+ case "*":
888
+ star = q;
889
+ break;
890
+ }
891
+ index = end + 1;
892
+ }
893
+ if (br < 0) br = star;
894
+ if (gzip < 0) gzip = star;
895
+ if (deflate < 0) deflate = star;
896
+ // An uncompressed answer that the request did not name is worth the lowest q it named
897
+ // anything with, which is negotiator's rule and not the obvious one: "br;q=0.5, gzip;q=0.9"
898
+ // means gzip, because identity comes in at 0.5 rather than at 1 and does not win the list.
899
+ // A "*" names identity as much as it names anything else, so its q is identity's.
900
+ if (identity < 0) identity = star < 0 ? minQuality : star;
901
+
902
+ if (!(allowed & ENCODING_BR)) br = -1;
903
+ if (!(allowed & ENCODING_GZIP)) gzip = -1;
904
+ if (!(allowed & ENCODING_DEFLATE)) deflate = -1;
905
+
906
+ let best = "";
907
+ let bestQ = 0;
908
+ if (br > bestQ) {
909
+ best = "br";
910
+ bestQ = br;
911
+ }
912
+ if (gzip > bestQ) {
913
+ best = "gzip";
914
+ bestQ = gzip;
915
+ }
916
+ if (deflate > bestQ) {
917
+ best = "deflate";
918
+ bestQ = deflate;
919
+ }
920
+ if (identity > bestQ) {
921
+ best = "identity";
922
+ }
923
+ return best;
924
+ }
925
+
791
926
  const defaultSettings = {
792
927
  "jsonp callback name": "callback",
793
928
  env: () => process.env.NODE_ENV ?? "development",
@@ -1305,6 +1440,10 @@ module.exports = {
1305
1440
  entityTag,
1306
1441
  statTag,
1307
1442
  contentTypeFor,
1443
+ negotiateEncoding,
1444
+ ENCODING_BR,
1445
+ ENCODING_GZIP,
1446
+ ENCODING_ANY,
1308
1447
  memoizeByString,
1309
1448
  isRangeFresh,
1310
1449
  findIndexStartingFrom,