fulmine.js 5.17.0 → 5.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -3
- package/package.json +1 -1
- package/src/compression.js +147 -8
- package/src/request.js +40 -3
- package/src/response.js +9 -0
- package/src/router.js +19 -3
- package/src/server-timing.js +23 -2
- package/src/testing.js +57 -1
- package/src/types.d.ts +43 -1
- package/src/utils.js +19 -3
- package/src/work.js +102 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Fulmine.js
|
|
4
4
|
|
|
5
|
-
Fulmine - means lightning ⚡ in Italian - is 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.
|
|
5
|
+
Fulmine - means lightning ⚡ in Italian - is a blazing-fast 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
|
|
|
7
7
|
```js
|
|
8
8
|
const express = require("fulmine.js"); // instead of require("express")
|
|
@@ -50,6 +50,7 @@ See [Migrating](#migrating) for what it handles and what it deliberately does no
|
|
|
50
50
|
- [NestJS](#nestjs)
|
|
51
51
|
- [When Express is somebody else's dependency](#when-express-is-somebody-elses-dependency)
|
|
52
52
|
- [Docker](#docker)
|
|
53
|
+
- [Behind a private registry](#behind-a-private-registry)
|
|
53
54
|
- [Differences from Express](#differences-from-express)
|
|
54
55
|
- [Performance tips](#performance-tips)
|
|
55
56
|
- [WebSockets](#websockets)
|
|
@@ -294,6 +295,79 @@ CMD ["node", "server.js"]
|
|
|
294
295
|
|
|
295
296
|
A single-stage `node:26-trixie-slim` image works too if you `apt-get install -y git ca-certificates` before `npm ci`. Prebuilt binaries exist for x64 and arm64 on Linux, macOS and Windows, so nothing is compiled at install time either way.
|
|
296
297
|
|
|
298
|
+
## Behind a private registry
|
|
299
|
+
|
|
300
|
+
µWebSockets.js is not on npm, it is installed from GitHub, and npm allows that by default, so an
|
|
301
|
+
ordinary `npm install fulmine.js` needs nothing from this section. It is here for the builds that
|
|
302
|
+
have turned git dependencies off, or that cannot reach github.com at all:
|
|
303
|
+
|
|
304
|
+
```
|
|
305
|
+
allow-git=none npm error code EALLOWGIT
|
|
306
|
+
npm error Fetching packages of type "git" have been disabled
|
|
307
|
+
npm error Refusing to fetch "uWebSockets.js@github:uNetworking/uWebSockets.js#v20.69.0"
|
|
308
|
+
|
|
309
|
+
allow-git=root npm error code EALLOWGIT
|
|
310
|
+
npm error Fetching non-root packages of type "git" have been disabled
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
`root` is the one that surprises people: it allows a git dependency your own `package.json` asks
|
|
314
|
+
for, and still refuses this one, because it is asked for by fulmine rather than by you.
|
|
315
|
+
|
|
316
|
+
The answer is to put µWebSockets.js in your own registry and point at it from there. Three steps,
|
|
317
|
+
and nothing is compiled on the way: the tarball is what uNetworking already publishes on the tag,
|
|
318
|
+
prebuilt binaries included.
|
|
319
|
+
|
|
320
|
+
**1. Pack the tag.** No clone needed, npm takes the git spec directly:
|
|
321
|
+
|
|
322
|
+
```sh
|
|
323
|
+
npm pack "github:uNetworking/uWebSockets.js#v20.69.0"
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Read the version out of [`package.json`](./package.json) rather than copying the one above, since
|
|
327
|
+
it moves with each release of this package.
|
|
328
|
+
|
|
329
|
+
**2. Publish it to your registry.**
|
|
330
|
+
|
|
331
|
+
```sh
|
|
332
|
+
npm publish uWebSockets.js-20.69.0.tgz --registry https://registry.internal/
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
The tarball is around 33 MB, because it carries a binary for every Node ABI and platform, and that
|
|
336
|
+
is larger than several defaults along the way. Verdaccio refuses it at `max_body_size: 10mb`,
|
|
337
|
+
and an nginx in front of any registry refuses it at `client_max_body_size 1m`. Both answer
|
|
338
|
+
`413 Payload Too Large` without ever naming µWebSockets.js, so raise them before deciding the
|
|
339
|
+
tarball is broken:
|
|
340
|
+
|
|
341
|
+
```yaml
|
|
342
|
+
# verdaccio config.yaml
|
|
343
|
+
max_body_size: 200mb
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
**3. Override the git spec in your application.** The version is the same one you packed:
|
|
347
|
+
|
|
348
|
+
```json
|
|
349
|
+
{
|
|
350
|
+
"dependencies": { "fulmine.js": "5.17.0" },
|
|
351
|
+
"overrides": { "uWebSockets.js": "20.69.0" }
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
`npm install` and `npm ci` both work from here with git off, and the lockfile resolves to your
|
|
356
|
+
registry with an integrity hash, so nothing reaches for git at install time:
|
|
357
|
+
|
|
358
|
+
```json
|
|
359
|
+
"node_modules/uWebSockets.js": {
|
|
360
|
+
"version": "20.69.0",
|
|
361
|
+
"resolved": "https://registry.internal/uWebSockets.js/-/uWebSockets.js-20.69.0.tgz",
|
|
362
|
+
"integrity": "sha512-kO7bcc/Hy3K6YnAVKBCu9ffYC1tl/ccDzDJqVK/GAO81XRRL+R1VmJP8mReg..."
|
|
363
|
+
}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
One thing to tell your security team before their scanner does: the lockfile still contains the
|
|
367
|
+
line `"uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.69.0"`. That is fulmine's declared
|
|
368
|
+
range, not a resolution, and a scanner that reads what is declared rather than what was installed
|
|
369
|
+
will report a git dependency that the install never used.
|
|
370
|
+
|
|
297
371
|
## Differences from Express
|
|
298
372
|
|
|
299
373
|
What the two servers answer on the wire, probed from outside, malformed input and smuggling
|
|
@@ -469,6 +543,20 @@ routeReport(app); // the whole list, to assert on however you like
|
|
|
469
543
|
|
|
470
544
|
A path is written as it was registered, `"/users/:id"` and not `"/users/7"`, and a trailing `*` names everything under a prefix. A pattern that matches no route throws too, so a misspelled path fails instead of passing quietly. The application does not need to be listening. Runnable: [`examples/fast-routes.js`](./examples/fast-routes.js).
|
|
471
545
|
|
|
546
|
+
A route can stay native and still slow down request by request, because most of what makes this fast is work that does not happen: the request is not turned into a `Readable`, the response is not turned into a `Writable`, `req.headers` is not folded into an object, the query is not parsed, no socket stand-in is allocated. A middleware that reads `req.headers.host` puts one of those back on every request, and nothing fails. `expectLazy` is the assertion for that half, asked from inside a handler:
|
|
547
|
+
|
|
548
|
+
```js
|
|
549
|
+
const { workReport, expectLazy } = require("fulmine.js").testing;
|
|
550
|
+
|
|
551
|
+
app.post("/items", (req, res) => {
|
|
552
|
+
expectLazy(req, res, { allow: ["body"] }); // throws naming what else was built
|
|
553
|
+
workReport(req, res); // { native, declarative, headers, query, body, requestStream, ... }
|
|
554
|
+
res.json(req.body);
|
|
555
|
+
});
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
Asking costs a property read: every field is state the framework already keeps, nothing is counted or wrapped to make it readable.
|
|
559
|
+
|
|
472
560
|
`npx fulmine.js explain /api/items/:id` answers the other question, the one about a single endpoint rather than about the table: how it is matched, what is copied out of the request, what runs and what each layer costs the route.
|
|
473
561
|
|
|
474
562
|
```text
|
|
@@ -489,7 +577,7 @@ The same verdict reaches the browser, per request, with `express.serverTiming()`
|
|
|
489
577
|
Server-Timing: route;desc="native", hdr;desc="not copied", db;dur=3.62, total;dur=4.66
|
|
490
578
|
```
|
|
491
579
|
|
|
492
|
-
`route;desc="native"` means µWS matched the path in C++ and the chain was worked out at startup; `route;desc="router"` means this one was matched here, layer by layer. `res.timing(name, ms, desc)` and `res.time(name, fn)` add marks of your own, and `fn` may return a promise. The duration ends where the header does, since Server-Timing goes out with the head. A route compiled into a response never enters JavaScript, so nothing times it: `npx fulmine.js profile` is where those are counted. Runnable: [`examples/server-timing.js`](./examples/server-timing.js).
|
|
580
|
+
`route;desc="native"` means µWS matched the path in C++ and the chain was worked out at startup; `route;desc="router"` means this one was matched here, layer by layer. `res.timing(name, ms, desc)` and `res.time(name, fn)` add marks of your own, and `fn` may return a promise. The duration ends where the header does, since Server-Timing goes out with the head. A route compiled into a response never enters JavaScript, so nothing times it: `npx fulmine.js profile` is where those are counted. The same middleware writes `work;desc="headers, query"` when the request built something a fast one does not, the fields `expectLazy` checks, and writes nothing when it built none of them. `serverTiming({ work: false })` turns that off. Runnable: [`examples/server-timing.js`](./examples/server-timing.js).
|
|
493
581
|
|
|
494
582
|
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. Runnable: [`examples/static-precompressed.js`](./examples/static-precompressed.js).
|
|
495
583
|
|
|
@@ -677,7 +765,7 @@ In general, basically all features and options are supported. Use the [Express 5
|
|
|
677
765
|
- ✅ express.text()
|
|
678
766
|
- ✅ express.raw()
|
|
679
767
|
- ✅ express.serverTiming(). Fulmine's own: Server-Timing carrying how the request was routed, described under [Performance tips](#performance-tips).
|
|
680
|
-
- ✅ express.testing. Fulmine's own: `expectNative`, `expectDeclarative` and `
|
|
768
|
+
- ✅ express.testing. Fulmine's own: `expectNative`, `expectDeclarative`, `routeReport`, `expectLazy` and `workReport`, described under [Performance tips](#performance-tips).
|
|
681
769
|
- ✅ 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).
|
|
682
770
|
- 🚧 express.request (this is not a constructor but a prototype for replacing methods)
|
|
683
771
|
- 🚧 express.response (this is not a constructor but a prototype for replacing methods)
|
package/package.json
CHANGED
package/src/compression.js
CHANGED
|
@@ -26,6 +26,9 @@ limitations under the License.
|
|
|
26
26
|
// write produce the same deflate output.
|
|
27
27
|
// - partial content is left alone. The compression module compresses a 206 as well, and the
|
|
28
28
|
// result is a byte range of the file described as gzip, which no client can decode.
|
|
29
|
+
// - zstd is on offer, which the compression module cannot do at all. It is ranked below brotli,
|
|
30
|
+
// so a client that takes both is answered exactly as it was before, and above gzip, which it
|
|
31
|
+
// beats on ratio and on time. A Node whose zlib has no zstd never offers it.
|
|
29
32
|
//
|
|
30
33
|
// The streaming half is the module's own design, because it is the right one: a transform stream,
|
|
31
34
|
// its output written as it comes, and the drain listeners moved onto it so a pipe that fills up
|
|
@@ -42,18 +45,27 @@ const {
|
|
|
42
45
|
ENCODING_BR,
|
|
43
46
|
ENCODING_GZIP,
|
|
44
47
|
ENCODING_DEFLATE,
|
|
48
|
+
ENCODING_ZSTD,
|
|
45
49
|
memoizeByString
|
|
46
50
|
} = require("./utils.js");
|
|
47
51
|
|
|
52
|
+
// zstd arrived in node's zlib during the range of versions this supports, so whether it can be
|
|
53
|
+
// answered at all is a question about the runtime rather than about the options
|
|
54
|
+
const HAS_ZSTD = typeof zlib.zstdCompressSync === "function";
|
|
55
|
+
|
|
48
56
|
// what the `encodings` option may name, and the mask each name contributes. identity is 0: an
|
|
49
57
|
// uncompressed answer is always on offer, naming it only makes the list read complete
|
|
50
58
|
const ENCODING_MASKS = new Map([
|
|
51
59
|
["br", ENCODING_BR],
|
|
60
|
+
["zstd", ENCODING_ZSTD],
|
|
52
61
|
["gzip", ENCODING_GZIP],
|
|
53
62
|
["deflate", ENCODING_DEFLATE],
|
|
54
63
|
["identity", 0]
|
|
55
64
|
]);
|
|
56
65
|
|
|
66
|
+
// everything on offer, which is everything the runtime can produce
|
|
67
|
+
const ENCODING_DEFAULT = HAS_ZSTD ? ENCODING_ANY : ENCODING_ANY & ~ENCODING_ZSTD;
|
|
68
|
+
|
|
57
69
|
// Cache-Control: no-transform forbids recoding the body, which is what this does
|
|
58
70
|
const NO_TRANSFORM = /(?:^|,)\s*?no-transform\s*?(?:,|$)/;
|
|
59
71
|
|
|
@@ -77,8 +89,11 @@ function addVary(res) {
|
|
|
77
89
|
*/
|
|
78
90
|
function noFlush() {}
|
|
79
91
|
|
|
80
|
-
// what enforceEncoding is allowed to name, the compression module's list
|
|
92
|
+
// what enforceEncoding is allowed to name, the compression module's list and ours
|
|
81
93
|
const ENFORCEABLE = new Set(["gzip", "deflate", "identity", "br"]);
|
|
94
|
+
if (HAS_ZSTD) {
|
|
95
|
+
ENFORCEABLE.add("zstd");
|
|
96
|
+
}
|
|
82
97
|
|
|
83
98
|
// Up to this many bytes a whole body is compressed on this thread, and above it on the libuv pool.
|
|
84
99
|
// One call either way; what changes is who waits. A small body pays more for the hop onto the pool
|
|
@@ -87,6 +102,100 @@ const ENFORCEABLE = new Set(["gzip", "deflate", "identity", "br"]);
|
|
|
87
102
|
// wins by 43% at 1.4KB and by 22% at 16KB, and loses by 32% at 32KB and by 90% at 78KB.
|
|
88
103
|
const SYNC_LIMIT = 24 * 1024;
|
|
89
104
|
|
|
105
|
+
const noop = () => {};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A whole-body compressor that keeps one stream instead of letting zlib build and throw one away
|
|
109
|
+
* per call, which on a body under the sync limit costs more than the compression does. Same bytes,
|
|
110
|
+
* a third of the time.
|
|
111
|
+
*
|
|
112
|
+
* It is private node, and two things have to be held in place for it: close, because the FINISH
|
|
113
|
+
* that ends the member would otherwise take the binding with it, and the handle, which that same
|
|
114
|
+
* FINISH drops off the stream before returning. The probe then compresses each body twice and
|
|
115
|
+
* gives the whole thing up unless every answer matches `oneShot` exactly, so a node that does any
|
|
116
|
+
* of this differently gets the public API back and loses nothing but the speed.
|
|
117
|
+
*
|
|
118
|
+
* Only for the deflate formats. A brotli stream carries context across a reset and answers the
|
|
119
|
+
* second body with bytes that depend on the first.
|
|
120
|
+
*
|
|
121
|
+
* @param {() => any} create
|
|
122
|
+
* @param {number} finishFlag
|
|
123
|
+
* @param {(body: Buffer) => Buffer} oneShot
|
|
124
|
+
* @returns {(body: Buffer) => Buffer}
|
|
125
|
+
*/
|
|
126
|
+
function reusableCompressor(create, finishFlag, oneShot) {
|
|
127
|
+
let stream;
|
|
128
|
+
try {
|
|
129
|
+
stream = create();
|
|
130
|
+
} catch {
|
|
131
|
+
return oneShot;
|
|
132
|
+
}
|
|
133
|
+
const handle = stream._handle;
|
|
134
|
+
if (
|
|
135
|
+
typeof stream._processChunk !== "function" ||
|
|
136
|
+
typeof stream.reset !== "function" ||
|
|
137
|
+
!handle ||
|
|
138
|
+
typeof handle.close !== "function"
|
|
139
|
+
) {
|
|
140
|
+
return oneShot;
|
|
141
|
+
}
|
|
142
|
+
const realClose = stream.close;
|
|
143
|
+
const realHandleClose = handle.close;
|
|
144
|
+
let broken = false;
|
|
145
|
+
// a compression is sync from its first byte to its last, so a second body can only arrive
|
|
146
|
+
// here from inside the first one; that one is answered the ordinary way
|
|
147
|
+
let busy = false;
|
|
148
|
+
|
|
149
|
+
// a stream given up on is left half finished, and an abandoned zlib handle emits later on its
|
|
150
|
+
// own: an uncaught "buffer error" and a dead process
|
|
151
|
+
const giveUp = () => {
|
|
152
|
+
broken = true;
|
|
153
|
+
try {
|
|
154
|
+
stream.on("error", noop);
|
|
155
|
+
stream.destroy();
|
|
156
|
+
} catch {
|
|
157
|
+
// it is on its way out either way
|
|
158
|
+
}
|
|
159
|
+
return oneShot;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const compress = (body) => {
|
|
163
|
+
if (broken || busy) {
|
|
164
|
+
return oneShot(body);
|
|
165
|
+
}
|
|
166
|
+
busy = true;
|
|
167
|
+
stream.close = noop;
|
|
168
|
+
handle.close = noop;
|
|
169
|
+
try {
|
|
170
|
+
// FINISH hands the member back and drops the handle off the stream on its way out,
|
|
171
|
+
// so the stream is only whole again once it is put back, and only reusable once reset
|
|
172
|
+
const out = Buffer.from(stream._processChunk(body, finishFlag));
|
|
173
|
+
stream._handle = handle;
|
|
174
|
+
stream.reset();
|
|
175
|
+
return out;
|
|
176
|
+
} catch {
|
|
177
|
+
stream._handle = handle;
|
|
178
|
+
giveUp();
|
|
179
|
+
return oneShot(body);
|
|
180
|
+
} finally {
|
|
181
|
+
stream.close = realClose;
|
|
182
|
+
handle.close = realHandleClose;
|
|
183
|
+
stream.removeAllListeners("error");
|
|
184
|
+
busy = false;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// twice per probe, because a stream that keeps state answers the first body correctly and the
|
|
189
|
+
// second one differently
|
|
190
|
+
for (const probe of [Buffer.alloc(64, 0x61), Buffer.from("{}".repeat(600))]) {
|
|
191
|
+
const expected = oneShot(probe);
|
|
192
|
+
if (!compress(probe).equals(expected) || !compress(probe).equals(expected)) {
|
|
193
|
+
return giveUp();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return compress;
|
|
197
|
+
}
|
|
198
|
+
|
|
90
199
|
/**
|
|
91
200
|
* The default filter: whether the content type is worth compressing at all. A response with no
|
|
92
201
|
* type is left alone, since nothing says what its bytes are.
|
|
@@ -151,11 +260,13 @@ function toBuffer(chunk, encoding) {
|
|
|
151
260
|
* @param {string} [options.enforceEncoding] what to use when the request carries no
|
|
152
261
|
* Accept-Encoding at all. Default "identity", which is to say nothing is compressed.
|
|
153
262
|
* @param {object} [options.brotli] brotli options, `params` included. The default quality is 4.
|
|
263
|
+
* @param {object} [options.zstd] zstd options, `params` included, node's own defaults otherwise.
|
|
154
264
|
* @param {string[]} [options.encodings] the encodings this middleware may answer with, out of
|
|
155
|
-
* "br", "gzip" and "deflate". What is not named is never used, however the client ranks
|
|
156
|
-
* server that prefers cheap gzip over brotli passes ["gzip", "deflate"]
|
|
157
|
-
*
|
|
158
|
-
*
|
|
265
|
+
* "br", "zstd", "gzip" and "deflate". What is not named is never used, however the client ranks
|
|
266
|
+
* it: a server that prefers cheap gzip over brotli passes ["gzip", "deflate"], one that would
|
|
267
|
+
* rather answer zstd than brotli passes ["zstd", "gzip"]. An uncompressed answer is always on
|
|
268
|
+
* offer, and enforceEncoding stays its own explicit choice, outside this list. This option is
|
|
269
|
+
* fulmine's own, the compression module has no equivalent.
|
|
159
270
|
* @param {number} [options.level] zlib compression level, for gzip and deflate.
|
|
160
271
|
* @param {number} [options.chunkSize] zlib chunk size.
|
|
161
272
|
* @param {number} [options.memLevel] zlib memory level.
|
|
@@ -173,6 +284,9 @@ function compression(options) {
|
|
|
173
284
|
[zlib.constants.BROTLI_PARAM_QUALITY]: 4,
|
|
174
285
|
...(opts.brotli && /** @type {any} */ (opts.brotli).params)
|
|
175
286
|
};
|
|
287
|
+
// node's default level, unlike brotli above: zstd at its default is already in the band where
|
|
288
|
+
// this middleware wants to be, and dropping it further buys nothing worth the ratio
|
|
289
|
+
const zstdOptions = { ...opts.zstd };
|
|
176
290
|
const filter = opts.filter || shouldCompress;
|
|
177
291
|
const enforceEncoding = opts.enforceEncoding || "identity";
|
|
178
292
|
// bytes.parse reads "1kb" and hands back null for anything it cannot, an absent option
|
|
@@ -180,7 +294,7 @@ function compression(options) {
|
|
|
180
294
|
const threshold = bytes.parse(/** @type {any} */ (opts.threshold)) ?? 1024;
|
|
181
295
|
// the mask handed to the negotiation, built once here: a name nobody knows is a config
|
|
182
296
|
// mistake and throws now rather than serving the wrong bytes later
|
|
183
|
-
let allowed =
|
|
297
|
+
let allowed = ENCODING_DEFAULT;
|
|
184
298
|
if (opts.encodings !== undefined) {
|
|
185
299
|
if (!Array.isArray(opts.encodings)) {
|
|
186
300
|
throw new TypeError("encodings must be an array of encoding names");
|
|
@@ -191,10 +305,17 @@ function compression(options) {
|
|
|
191
305
|
if (mask === undefined) {
|
|
192
306
|
throw new TypeError(`unknown encoding "${name}" in encodings`);
|
|
193
307
|
}
|
|
308
|
+
if (mask === ENCODING_ZSTD && !HAS_ZSTD) {
|
|
309
|
+
throw new TypeError(`"zstd" needs a node whose zlib has zstd, and this one does not`);
|
|
310
|
+
}
|
|
194
311
|
allowed |= mask;
|
|
195
312
|
}
|
|
196
313
|
}
|
|
197
314
|
|
|
315
|
+
// built on first use, so an application that never answers gzip keeps no stream alive
|
|
316
|
+
let gzipWhole;
|
|
317
|
+
let deflateWhole;
|
|
318
|
+
|
|
198
319
|
/**
|
|
199
320
|
* A whole body, compressed on this thread. Blocks the event loop for as long as it takes,
|
|
200
321
|
* which is why only a small one comes here, see SYNC_LIMIT.
|
|
@@ -205,12 +326,25 @@ function compression(options) {
|
|
|
205
326
|
*/
|
|
206
327
|
function compressWhole(method, body) {
|
|
207
328
|
if (method === "gzip") {
|
|
208
|
-
|
|
329
|
+
gzipWhole ??= reusableCompressor(
|
|
330
|
+
() => zlib.createGzip(zlibOptions),
|
|
331
|
+
zlib.constants.Z_FINISH,
|
|
332
|
+
(b) => zlib.gzipSync(b, zlibOptions)
|
|
333
|
+
);
|
|
334
|
+
return gzipWhole(body);
|
|
209
335
|
}
|
|
210
336
|
if (method === "br") {
|
|
211
337
|
return zlib.brotliCompressSync(body, brotliOptions);
|
|
212
338
|
}
|
|
213
|
-
|
|
339
|
+
if (method === "zstd") {
|
|
340
|
+
return zlib.zstdCompressSync(body, zstdOptions);
|
|
341
|
+
}
|
|
342
|
+
deflateWhole ??= reusableCompressor(
|
|
343
|
+
() => zlib.createDeflate(zlibOptions),
|
|
344
|
+
zlib.constants.Z_FINISH,
|
|
345
|
+
(b) => zlib.deflateSync(b, zlibOptions)
|
|
346
|
+
);
|
|
347
|
+
return deflateWhole(body);
|
|
214
348
|
}
|
|
215
349
|
|
|
216
350
|
/**
|
|
@@ -225,6 +359,8 @@ function compression(options) {
|
|
|
225
359
|
zlib.gzip(body, zlibOptions, done);
|
|
226
360
|
} else if (method === "br") {
|
|
227
361
|
zlib.brotliCompress(body, brotliOptions, done);
|
|
362
|
+
} else if (method === "zstd") {
|
|
363
|
+
zlib.zstdCompress(body, zstdOptions, done);
|
|
228
364
|
} else {
|
|
229
365
|
zlib.deflate(body, zlibOptions, done);
|
|
230
366
|
}
|
|
@@ -241,6 +377,9 @@ function compression(options) {
|
|
|
241
377
|
if (method === "br") {
|
|
242
378
|
return zlib.createBrotliCompress(brotliOptions);
|
|
243
379
|
}
|
|
380
|
+
if (method === "zstd") {
|
|
381
|
+
return zlib.createZstdCompress(zstdOptions);
|
|
382
|
+
}
|
|
244
383
|
return zlib.createDeflate(zlibOptions);
|
|
245
384
|
}
|
|
246
385
|
|
package/src/request.js
CHANGED
|
@@ -914,9 +914,11 @@ module.exports = class Request extends LazyReadable {
|
|
|
914
914
|
if (this.#responseEnded) {
|
|
915
915
|
return;
|
|
916
916
|
}
|
|
917
|
-
//
|
|
918
|
-
//
|
|
919
|
-
|
|
917
|
+
// The bytes are copied because uWS neuters `ab` when this callback returns, and a
|
|
918
|
+
// view of it would corrupt whatever is still queued. Buffer.from over a view rather
|
|
919
|
+
// than ab.slice(0): the slice allocates an ArrayBuffer of its own for every chunk,
|
|
920
|
+
// which on a small body costs several times the copying it is there to do.
|
|
921
|
+
const chunk = Buffer.from(new Uint8Array(ab));
|
|
920
922
|
const accepted = this.push(chunk);
|
|
921
923
|
// push() may synchronously end the response via a flowing-mode listener.
|
|
922
924
|
if (!accepted && !isLast && !this.#responseEnded) {
|
|
@@ -1371,6 +1373,11 @@ module.exports = class Request extends LazyReadable {
|
|
|
1371
1373
|
this._querySnap = capture.invalid === true ? false : capture;
|
|
1372
1374
|
return out;
|
|
1373
1375
|
}
|
|
1376
|
+
// The other two parsers keep no snapshot, so they only leave the mark that says a parse
|
|
1377
|
+
// happened: false is "there is nothing to replay", which is what the branch above already
|
|
1378
|
+
// does for a query it cannot replay. Two stores next to two allocations.
|
|
1379
|
+
this._querySnapRaw = this._rawQuery;
|
|
1380
|
+
this._querySnap = false;
|
|
1374
1381
|
if (qp === fastQueryParse) {
|
|
1375
1382
|
return Object.assign(Object.create(null), fastQueryParse(this._rawQuery));
|
|
1376
1383
|
}
|
|
@@ -1841,6 +1848,36 @@ module.exports = class Request extends LazyReadable {
|
|
|
1841
1848
|
// would let a caller rewrite what routing reads
|
|
1842
1849
|
return this.#rawHeadersEntries.slice();
|
|
1843
1850
|
}
|
|
1851
|
+
|
|
1852
|
+
// The three below report work this request was made to do, for src/work.js: they exist because
|
|
1853
|
+
// the state that answers them is private, and they compute nothing that was not already there.
|
|
1854
|
+
// Reading one costs a load; not reading one costs nothing at all, which is the point.
|
|
1855
|
+
|
|
1856
|
+
/**
|
|
1857
|
+
* Whether the folded `req.headers` object has been built. Most requests never ask for it, and
|
|
1858
|
+
* a middleware that does puts it back on all of them.
|
|
1859
|
+
* @returns {boolean}
|
|
1860
|
+
*/
|
|
1861
|
+
get _headersBuilt() {
|
|
1862
|
+
return this.#cachedHeaders !== null || this.#cachedDistinctHeaders !== null;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
/**
|
|
1866
|
+
* Whether the query string has been parsed. `req.query` caches nothing, so this says a parse
|
|
1867
|
+
* happened and not how many.
|
|
1868
|
+
* @returns {boolean}
|
|
1869
|
+
*/
|
|
1870
|
+
get _queryParsed() {
|
|
1871
|
+
return this._querySnapRaw !== undefined;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
/**
|
|
1875
|
+
* Whether the socket stand-in `req.connection` was allocated.
|
|
1876
|
+
* @returns {boolean}
|
|
1877
|
+
*/
|
|
1878
|
+
get _socketBuilt() {
|
|
1879
|
+
return Boolean(this.#cachedConnection);
|
|
1880
|
+
}
|
|
1844
1881
|
};
|
|
1845
1882
|
|
|
1846
1883
|
// req.header is req.get under Express's other name. On the prototype rather than an instance
|
package/src/response.js
CHANGED
|
@@ -470,6 +470,15 @@ module.exports = class Response extends LazyWritable {
|
|
|
470
470
|
return this.#socket;
|
|
471
471
|
}
|
|
472
472
|
|
|
473
|
+
/**
|
|
474
|
+
* Whether that socket was ever built, for src/work.js. `socket` itself answers null once the
|
|
475
|
+
* response is over, so it cannot be asked after the fact; this reads the field.
|
|
476
|
+
* @returns {boolean}
|
|
477
|
+
*/
|
|
478
|
+
get _socketBuilt() {
|
|
479
|
+
return this.#socket !== null;
|
|
480
|
+
}
|
|
481
|
+
|
|
473
482
|
/**
|
|
474
483
|
* Hands everything queued to uWS as one write, which is where the saving is, and keeps the
|
|
475
484
|
* backpressure the single write used to do.
|
package/src/router.js
CHANGED
|
@@ -708,7 +708,7 @@ function nativeDone(matched) {
|
|
|
708
708
|
this.router._endUnmatched(this.req, response);
|
|
709
709
|
} catch (err) {
|
|
710
710
|
if (response.aborted || response.finished) {
|
|
711
|
-
|
|
711
|
+
logError(this.router, err);
|
|
712
712
|
} else {
|
|
713
713
|
this.router._handleError(err, null, this.req, response);
|
|
714
714
|
}
|
|
@@ -731,7 +731,7 @@ function nativeFail(err) {
|
|
|
731
731
|
queueMicrotask(() => {
|
|
732
732
|
const response = this.res;
|
|
733
733
|
if (response.aborted || response.finished) {
|
|
734
|
-
|
|
734
|
+
logError(this.router, err);
|
|
735
735
|
} else {
|
|
736
736
|
this.router._handleError(err, null, this.req, response);
|
|
737
737
|
}
|
|
@@ -1025,6 +1025,22 @@ function adoptPlainRequest(req, router) {
|
|
|
1025
1025
|
req.app = req.app ?? router;
|
|
1026
1026
|
}
|
|
1027
1027
|
|
|
1028
|
+
/**
|
|
1029
|
+
* What express's logerror does. Its final handler prints the error it is about to answer with,
|
|
1030
|
+
* unless the application runs under `env: "test"`, which is how its own suite stays quiet, and it
|
|
1031
|
+
* prints the stack rather than the object. A falsy throw is not printed at all, since finalhandler
|
|
1032
|
+
* only calls onerror when there is an error to call it with.
|
|
1033
|
+
*
|
|
1034
|
+
* @param {any} router the router whose settings decide it
|
|
1035
|
+
* @param {any} err
|
|
1036
|
+
* @returns {void}
|
|
1037
|
+
*/
|
|
1038
|
+
function logError(router, err) {
|
|
1039
|
+
if (err && router.get("env") !== "test") {
|
|
1040
|
+
console.error(err.stack || err.toString());
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1028
1044
|
/**
|
|
1029
1045
|
* The uWS onAborted handler, bound to the response: a closure here captured two locals and cost
|
|
1030
1046
|
* a context plus a function per request, for a path that only ever runs on a client abort.
|
|
@@ -2832,7 +2848,7 @@ module.exports = class Router extends EventEmitter {
|
|
|
2832
2848
|
return request.next(thrown);
|
|
2833
2849
|
}
|
|
2834
2850
|
}
|
|
2835
|
-
|
|
2851
|
+
logError(this, err);
|
|
2836
2852
|
if (response.statusCode === 200) {
|
|
2837
2853
|
// the status the error carries, as express's own final handler reads it: a body that
|
|
2838
2854
|
// was too large or a request cut short is the client's 4xx, not a 500 from here
|
package/src/server-timing.js
CHANGED
|
@@ -30,12 +30,20 @@ limitations under the License.
|
|
|
30
30
|
// enters javascript, so no middleware runs on it and there is nothing to time. `npx fulmine
|
|
31
31
|
// profile` is where those are counted.
|
|
32
32
|
//
|
|
33
|
+
// The other field only this framework can write is `work`, which names what the request was made to
|
|
34
|
+
// build: the folded headers object, the parsed query, the body, the Readable, the Writable, the
|
|
35
|
+
// socket stand-in. A fast request builds none of them and the field is absent, so it appears
|
|
36
|
+
// exactly when something is worth looking at. See src/work.js.
|
|
37
|
+
//
|
|
33
38
|
// The duration ends where the header does. Server-Timing goes out with the head, so `total` covers
|
|
34
|
-
// everything up to the moment the answer starts leaving, and not the body after it
|
|
35
|
-
//
|
|
39
|
+
// everything up to the moment the answer starts leaving, and not the body after it, and `work` has
|
|
40
|
+
// the same boundary: a stream built by the write that carries the head is built after this is
|
|
41
|
+
// written. Every stopwatch middleware has that boundary; this one says so.
|
|
36
42
|
|
|
37
43
|
"use strict";
|
|
38
44
|
|
|
45
|
+
const { work, names } = require("./work.js");
|
|
46
|
+
|
|
39
47
|
/**
|
|
40
48
|
* A duration in milliseconds, as Server-Timing writes them: two decimals, which is a hundredth of
|
|
41
49
|
* a millisecond and finer than anything above it is worth.
|
|
@@ -61,6 +69,8 @@ function describe(text) {
|
|
|
61
69
|
*
|
|
62
70
|
* @param {object} [options]
|
|
63
71
|
* @param {boolean} [options.routing] whether to report how the request was routed. Default true.
|
|
72
|
+
* @param {boolean} [options.work] whether to report what the request was made to build. Default
|
|
73
|
+
* true. Nothing is written for a request that built none of it, which is the usual one.
|
|
64
74
|
* @param {boolean} [options.total] whether to report the time up to the head. Default true.
|
|
65
75
|
* @param {string} [options.name] what the total is called. Default "total".
|
|
66
76
|
* @returns {(req: any, res: any, next: (err?: any) => void) => void}
|
|
@@ -68,6 +78,7 @@ function describe(text) {
|
|
|
68
78
|
function serverTiming(options) {
|
|
69
79
|
const opts = options || {};
|
|
70
80
|
const routing = opts.routing !== false;
|
|
81
|
+
const wantsWork = opts.work !== false;
|
|
71
82
|
const wantsTotal = opts.total !== false;
|
|
72
83
|
const totalName = opts.name || "total";
|
|
73
84
|
|
|
@@ -155,6 +166,16 @@ function serverTiming(options) {
|
|
|
155
166
|
}
|
|
156
167
|
}
|
|
157
168
|
}
|
|
169
|
+
if (wantsWork) {
|
|
170
|
+
// what this one request made the framework build, which the route verdict above
|
|
171
|
+
// cannot say: a native route still folds the headers if a middleware reads them,
|
|
172
|
+
// and that is per request, not per route. Read at the head, so it covers the
|
|
173
|
+
// chain and not the body written after it, the same boundary as the total.
|
|
174
|
+
const listed = names(work(req, res));
|
|
175
|
+
if (listed.length !== 0) {
|
|
176
|
+
entries.push(`work;desc=${describe(listed.join(", "))}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
158
179
|
entries.push(...marks);
|
|
159
180
|
if (wantsTotal) {
|
|
160
181
|
entries.push(`${totalName};dur=${millis(process.hrtime.bigint() - started)}`);
|
package/src/testing.js
CHANGED
|
@@ -28,6 +28,8 @@ limitations under the License.
|
|
|
28
28
|
|
|
29
29
|
"use strict";
|
|
30
30
|
|
|
31
|
+
const { work, names: workNames } = require("./work.js");
|
|
32
|
+
|
|
31
33
|
/**
|
|
32
34
|
* Every route of an application and of the routers mounted under it, each with the path it answers
|
|
33
35
|
* from the outside.
|
|
@@ -222,4 +224,58 @@ function expectDeclarative(app, patterns) {
|
|
|
222
224
|
);
|
|
223
225
|
}
|
|
224
226
|
|
|
225
|
-
|
|
227
|
+
/**
|
|
228
|
+
* What this one request made the framework do, asked from inside a handler or from a `finish`
|
|
229
|
+
* listener. See src/work.js for what each field means and why asking is free.
|
|
230
|
+
*
|
|
231
|
+
* @param {any} req
|
|
232
|
+
* @param {any} res
|
|
233
|
+
* @returns {import("./work.js").Work}
|
|
234
|
+
*/
|
|
235
|
+
function workReport(req, res) {
|
|
236
|
+
return work(req, res);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// The work a fast request does none of, which is what expectLazy is about. The route verdict is
|
|
240
|
+
// not in here: expectNative and expectDeclarative are what assert on that.
|
|
241
|
+
const LAZY = ["headers", "query", "body", "requestStream", "responseStream", "socket"];
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Throws if this request built anything it did not have to.
|
|
245
|
+
*
|
|
246
|
+
* The route verdict is a property of the application and holds for every request; this is the
|
|
247
|
+
* other half, which holds for one. A route can stay native and still slow down request by request,
|
|
248
|
+
* because a middleware read `req.headers.host` or piped instead of sending: the answer stays
|
|
249
|
+
* correct, the route report stays green, and the throughput does not.
|
|
250
|
+
*
|
|
251
|
+
* `allow` names what is fine here, which is most of the point: a route that parses a body is
|
|
252
|
+
* asserted as one that parses a body and nothing else.
|
|
253
|
+
*
|
|
254
|
+
* @param {any} req
|
|
255
|
+
* @param {any} res
|
|
256
|
+
* @param {object} [options]
|
|
257
|
+
* @param {string[]} [options.allow] fields of the report this route is expected to do anyway
|
|
258
|
+
*/
|
|
259
|
+
function expectLazy(req, res, options) {
|
|
260
|
+
const allowed = options?.allow ?? [];
|
|
261
|
+
for (const field of allowed) {
|
|
262
|
+
if (!LAZY.includes(field)) {
|
|
263
|
+
throw new TypeError(`expectLazy: "${field}" is not one of ${LAZY.join(", ")}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const done = work(req, res);
|
|
267
|
+
const unwanted = { ...done };
|
|
268
|
+
for (const field of allowed) {
|
|
269
|
+
/** @type {any} */ (unwanted)[field] = false;
|
|
270
|
+
}
|
|
271
|
+
const listed = workNames(unwanted);
|
|
272
|
+
if (listed.length === 0) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
throw new Error(
|
|
276
|
+
`${req.method} ${req.originalUrl} did work a fast request does not: ${listed.join(", ")}.\n` +
|
|
277
|
+
`Run \`npx fulmine explain ${req.route?.path ?? req.path}\` to see what the chain asks for.`
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
module.exports = { routeReport, expectNative, expectDeclarative, collectRoutes, workReport, expectLazy };
|
package/src/types.d.ts
CHANGED
|
@@ -41,6 +41,17 @@ declare module "fulmine.js" {
|
|
|
41
41
|
|
|
42
42
|
// express has no compression middleware, so there is nothing to re-export: these are the
|
|
43
43
|
// compression module's options, which this one takes as they are
|
|
44
|
+
/**
|
|
45
|
+
* What zlib takes for zstd, written out here rather than imported from "zlib": the type
|
|
46
|
+
* arrived in @types/node only when node grew zstd, and the version of that is the
|
|
47
|
+
* consumer's, so importing it would stop an older one from compiling at all.
|
|
48
|
+
*/
|
|
49
|
+
interface ZstdCompressOptions {
|
|
50
|
+
chunkSize?: number;
|
|
51
|
+
maxOutputLength?: number;
|
|
52
|
+
/** zlib.constants.ZSTD_c_* to their values, ZSTD_c_compressionLevel included. */
|
|
53
|
+
params?: Record<number, number>;
|
|
54
|
+
}
|
|
44
55
|
interface CompressionOptions extends ZlibOptions {
|
|
45
56
|
/** The smallest body worth compressing, in bytes or as "1kb". Default 1024. */
|
|
46
57
|
threshold?: number | string;
|
|
@@ -50,12 +61,14 @@ declare module "fulmine.js" {
|
|
|
50
61
|
enforceEncoding?: string;
|
|
51
62
|
/** Brotli options. The default quality is 4. */
|
|
52
63
|
brotli?: BrotliOptions;
|
|
64
|
+
/** Zstd options. Node's own defaults. Offered only where zlib has zstd. */
|
|
65
|
+
zstd?: ZstdCompressOptions;
|
|
53
66
|
/**
|
|
54
67
|
* The encodings this middleware may answer with; what is not named is never used,
|
|
55
68
|
* however the client ranks it. Fulmine's own option, the compression module has no
|
|
56
69
|
* equivalent. An uncompressed answer is always on offer.
|
|
57
70
|
*/
|
|
58
|
-
encodings?: ("br" | "gzip" | "deflate" | "identity")[];
|
|
71
|
+
encodings?: ("br" | "zstd" | "gzip" | "deflate" | "identity")[];
|
|
59
72
|
}
|
|
60
73
|
// what listen() decided about each route, for a test to hold on to
|
|
61
74
|
interface RouteVerdict {
|
|
@@ -74,6 +87,26 @@ declare module "fulmine.js" {
|
|
|
74
87
|
/** Why it fell back to the ordinary router, when it did. */
|
|
75
88
|
reason?: string;
|
|
76
89
|
}
|
|
90
|
+
// what one request was made to build, none of which a fast request builds
|
|
91
|
+
interface WorkReport {
|
|
92
|
+
/** Whether µWS matched this route itself. */
|
|
93
|
+
native: boolean;
|
|
94
|
+
/** Whether it was compiled into a response written at startup. */
|
|
95
|
+
declarative: boolean;
|
|
96
|
+
/** Whether the folded `req.headers` object was built. */
|
|
97
|
+
headers: boolean;
|
|
98
|
+
/** Whether the query string was parsed. */
|
|
99
|
+
query: boolean;
|
|
100
|
+
/** Whether a body parser put something on `req.body`. */
|
|
101
|
+
body: boolean;
|
|
102
|
+
/** Whether the request became a real Readable. */
|
|
103
|
+
requestStream: boolean;
|
|
104
|
+
/** Whether the response became a real Writable. */
|
|
105
|
+
responseStream: boolean;
|
|
106
|
+
/** Whether a socket stand-in was allocated. */
|
|
107
|
+
socket: boolean;
|
|
108
|
+
}
|
|
109
|
+
type WorkField = "headers" | "query" | "body" | "requestStream" | "responseStream" | "socket";
|
|
77
110
|
export namespace testing {
|
|
78
111
|
/** Every route, with what compiling it decided. */
|
|
79
112
|
function routeReport(app: Fulmine): RouteVerdict[];
|
|
@@ -81,6 +114,10 @@ declare module "fulmine.js" {
|
|
|
81
114
|
function expectNative(app: Fulmine, patterns: string | string[]): void;
|
|
82
115
|
/** Throws unless every route named was compiled into a response. */
|
|
83
116
|
function expectDeclarative(app: Fulmine, patterns: string | string[]): void;
|
|
117
|
+
/** What this one request was made to build, asked from a handler or a finish listener. */
|
|
118
|
+
function workReport(req: e.Request, res: e.Response): WorkReport;
|
|
119
|
+
/** Throws if this request built anything `allow` does not name. */
|
|
120
|
+
function expectLazy(req: e.Request, res: e.Response, options?: { allow?: WorkField[] }): void;
|
|
84
121
|
}
|
|
85
122
|
|
|
86
123
|
// Server-Timing, carrying how the request was routed. Express has no such middleware, so
|
|
@@ -88,6 +125,11 @@ declare module "fulmine.js" {
|
|
|
88
125
|
interface ServerTimingOptions {
|
|
89
126
|
/** Whether to report how the request was routed. Default true. */
|
|
90
127
|
routing?: boolean;
|
|
128
|
+
/**
|
|
129
|
+
* Whether to report what the request was made to build. Default true. A request that
|
|
130
|
+
* built none of it writes no such field.
|
|
131
|
+
*/
|
|
132
|
+
work?: boolean;
|
|
91
133
|
/** Whether to report the time up to the head. Default true. */
|
|
92
134
|
total?: boolean;
|
|
93
135
|
/** What the total is called. Default "total". */
|
package/src/utils.js
CHANGED
|
@@ -849,7 +849,8 @@ function stringify(value, replacer, spaces, escape) {
|
|
|
849
849
|
const ENCODING_BR = 1;
|
|
850
850
|
const ENCODING_GZIP = 2;
|
|
851
851
|
const ENCODING_DEFLATE = 4;
|
|
852
|
-
const
|
|
852
|
+
const ENCODING_ZSTD = 8;
|
|
853
|
+
const ENCODING_ANY = ENCODING_BR | ENCODING_GZIP | ENCODING_DEFLATE | ENCODING_ZSTD;
|
|
853
854
|
|
|
854
855
|
/**
|
|
855
856
|
* The encoding to answer with, read straight off Accept-Encoding rather than through negotiator:
|
|
@@ -864,12 +865,15 @@ const ENCODING_ANY = ENCODING_BR | ENCODING_GZIP | ENCODING_DEFLATE;
|
|
|
864
865
|
* answer is always on offer, and is what an empty header ends up choosing.
|
|
865
866
|
*
|
|
866
867
|
* @param {string} accept the header, or "" when the request carried none
|
|
867
|
-
* @param {number} allowed ENCODING_BR, ENCODING_GZIP and ENCODING_DEFLATE, or'd
|
|
868
|
-
*
|
|
868
|
+
* @param {number} allowed ENCODING_BR, ENCODING_ZSTD, ENCODING_GZIP and ENCODING_DEFLATE, or'd
|
|
869
|
+
* together
|
|
870
|
+
* @returns {string} "br", "zstd", "gzip", "deflate", "identity", or "" when nothing is
|
|
871
|
+
* acceptable
|
|
869
872
|
*/
|
|
870
873
|
function negotiateEncoding(accept, allowed) {
|
|
871
874
|
// -1 while a name has not appeared: q=0 is a refusal and has to be told apart from silence
|
|
872
875
|
let br = -1;
|
|
876
|
+
let zstd = -1;
|
|
873
877
|
let gzip = -1;
|
|
874
878
|
let deflate = -1;
|
|
875
879
|
let identity = -1;
|
|
@@ -907,6 +911,9 @@ function negotiateEncoding(accept, allowed) {
|
|
|
907
911
|
case "gzip":
|
|
908
912
|
gzip = q;
|
|
909
913
|
break;
|
|
914
|
+
case "zstd":
|
|
915
|
+
zstd = q;
|
|
916
|
+
break;
|
|
910
917
|
case "deflate":
|
|
911
918
|
deflate = q;
|
|
912
919
|
break;
|
|
@@ -920,6 +927,7 @@ function negotiateEncoding(accept, allowed) {
|
|
|
920
927
|
index = end + 1;
|
|
921
928
|
}
|
|
922
929
|
if (br < 0) br = star;
|
|
930
|
+
if (zstd < 0) zstd = star;
|
|
923
931
|
if (gzip < 0) gzip = star;
|
|
924
932
|
if (deflate < 0) deflate = star;
|
|
925
933
|
// An uncompressed answer that the request did not name is worth the lowest q it named
|
|
@@ -929,6 +937,7 @@ function negotiateEncoding(accept, allowed) {
|
|
|
929
937
|
if (identity < 0) identity = star < 0 ? minQuality : star;
|
|
930
938
|
|
|
931
939
|
if (!(allowed & ENCODING_BR)) br = -1;
|
|
940
|
+
if (!(allowed & ENCODING_ZSTD)) zstd = -1;
|
|
932
941
|
if (!(allowed & ENCODING_GZIP)) gzip = -1;
|
|
933
942
|
if (!(allowed & ENCODING_DEFLATE)) deflate = -1;
|
|
934
943
|
|
|
@@ -938,6 +947,12 @@ function negotiateEncoding(accept, allowed) {
|
|
|
938
947
|
best = "br";
|
|
939
948
|
bestQ = br;
|
|
940
949
|
}
|
|
950
|
+
// Below brotli on a tie, on purpose: a client that takes both is answered the way it was
|
|
951
|
+
// answered before zstd was on the list. Above gzip, which it beats on both counts.
|
|
952
|
+
if (zstd > bestQ) {
|
|
953
|
+
best = "zstd";
|
|
954
|
+
bestQ = zstd;
|
|
955
|
+
}
|
|
941
956
|
if (gzip > bestQ) {
|
|
942
957
|
best = "gzip";
|
|
943
958
|
bestQ = gzip;
|
|
@@ -1645,6 +1660,7 @@ module.exports = {
|
|
|
1645
1660
|
ENCODING_BR,
|
|
1646
1661
|
ENCODING_GZIP,
|
|
1647
1662
|
ENCODING_DEFLATE,
|
|
1663
|
+
ENCODING_ZSTD,
|
|
1648
1664
|
ENCODING_ANY,
|
|
1649
1665
|
memoizeByString,
|
|
1650
1666
|
isRangeFresh,
|
package/src/work.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
// What one request actually made this framework do, read from state it already keeps.
|
|
18
|
+
//
|
|
19
|
+
// Most of what makes this faster than Express is work that does not happen: the Readable and the
|
|
20
|
+
// Writable are not built, the headers are not folded into an object, the query is not parsed, the
|
|
21
|
+
// socket stand-in is not allocated. None of that is visible from the outside, and all of it is one
|
|
22
|
+
// careless middleware away from coming back: a `req.headers.host` where `req.get("host")` would do
|
|
23
|
+
// puts the folded object back on every request, and the answer stays correct, so nothing fails.
|
|
24
|
+
//
|
|
25
|
+
// Every field below is a property this framework already had to keep for its own reasons, so
|
|
26
|
+
// asking costs a load and nothing is counted, stamped or wrapped for the sake of being asked. That
|
|
27
|
+
// is the whole design rule here: a probe that charges the requests nobody is probing would be
|
|
28
|
+
// paid for by everyone, forever, to be read once.
|
|
29
|
+
//
|
|
30
|
+
// What is deliberately not here is whether the constructor copied the headers out of µWS. That is
|
|
31
|
+
// a decision about the chain rather than about the request, `routeReport().skipHeaders` reports it
|
|
32
|
+
// already, and the one case where the two differ, a granted route whose request declares a body,
|
|
33
|
+
// would cost a flag written on every request to be read on almost none.
|
|
34
|
+
//
|
|
35
|
+
// The two readers are `express.testing.expectLazy`, which fails a build that lost one of these,
|
|
36
|
+
// and `express.serverTiming()`, which writes them into the header for a browser to show.
|
|
37
|
+
|
|
38
|
+
"use strict";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {object} Work
|
|
42
|
+
* @property {boolean} native whether µWS matched this route itself
|
|
43
|
+
* @property {boolean} declarative whether the route was compiled into a response at startup
|
|
44
|
+
* @property {boolean} headers whether the folded `req.headers` object was built
|
|
45
|
+
* @property {boolean} query whether the query string was parsed
|
|
46
|
+
* @property {boolean} body whether a body parser put something on `req.body`
|
|
47
|
+
* @property {boolean} requestStream whether the request became a real Readable
|
|
48
|
+
* @property {boolean} responseStream whether the response became a real Writable
|
|
49
|
+
* @property {boolean} socket whether a socket stand-in was allocated
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What this request did, as it stands right now: the answer changes while the chain runs, so a
|
|
54
|
+
* reader that wants the whole picture asks at the end of it.
|
|
55
|
+
*
|
|
56
|
+
* @param {any} req
|
|
57
|
+
* @param {any} res the response, since half of this is about the response
|
|
58
|
+
* @returns {Work}
|
|
59
|
+
*/
|
|
60
|
+
function work(req, res) {
|
|
61
|
+
const native = req.route?._native;
|
|
62
|
+
return {
|
|
63
|
+
native: Boolean(native),
|
|
64
|
+
declarative: Boolean(native?.declarative),
|
|
65
|
+
headers: req._headersBuilt,
|
|
66
|
+
query: req._queryParsed,
|
|
67
|
+
body: req.body !== undefined,
|
|
68
|
+
requestStream: req._readableState !== undefined,
|
|
69
|
+
responseStream: res._writableState !== undefined,
|
|
70
|
+
socket: req._socketBuilt || res._socketBuilt
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The order the two readers list them in: what the request was made to do, cheapest first, so a
|
|
75
|
+
// header and a failure message read the same way.
|
|
76
|
+
const NAMES = [
|
|
77
|
+
["headers", "headers"],
|
|
78
|
+
["query", "query"],
|
|
79
|
+
["body", "body"],
|
|
80
|
+
["requestStream", "req stream"],
|
|
81
|
+
["responseStream", "res stream"],
|
|
82
|
+
["socket", "socket"]
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The names of everything that did happen, for a message or a header. Empty for the request that
|
|
87
|
+
* did none of it, which is the one this framework is built to serve.
|
|
88
|
+
*
|
|
89
|
+
* @param {Work} done
|
|
90
|
+
* @returns {string[]}
|
|
91
|
+
*/
|
|
92
|
+
function names(done) {
|
|
93
|
+
const listed = [];
|
|
94
|
+
for (const [key, name] of NAMES) {
|
|
95
|
+
if (/** @type {any} */ (done)[key]) {
|
|
96
|
+
listed.push(name);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return listed;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { work, names };
|