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.
- 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 +445 -0
- package/src/index.js +4 -0
- package/src/middlewares.js +172 -2
- package/src/options.d.ts +10 -0
- package/src/response.js +4 -1
- package/src/types.d.ts +19 -0
- package/src/utils.js +144 -5
|
@@ -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 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.
|
|
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.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
|
-
|
|
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,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;
|
package/src/middlewares.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
343
|
-
|
|
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
|
|
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,
|