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.
- package/COMPRESSION_LICENSE +25 -0
- package/NOTICE +9 -0
- package/README.md +28 -12
- package/package.json +4 -2
- package/src/cli.js +57 -15
- package/src/compression.js +400 -0
- package/src/index.js +4 -0
- package/src/middlewares.js +82 -1
- package/src/options.d.ts +6 -0
- package/src/response.js +4 -1
- package/src/types.d.ts +19 -0
- package/src/utils.js +111 -0
|
@@ -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)
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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 ===
|
|
255
|
+
typeof node.source?.value === "string"
|
|
230
256
|
) {
|
|
231
|
-
|
|
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 ===
|
|
241
|
-
|
|
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
|
|
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
|
|
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;
|
package/src/middlewares.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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,
|