fulmine.js 5.13.1 → 5.13.3
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/NOTICE +31 -0
- package/README.md +13 -11
- package/package.json +1 -1
- package/src/middlewares.js +61 -9
- package/src/request.js +149 -15
- package/src/response.js +14 -3
- package/src/router.js +133 -1
package/NOTICE
CHANGED
|
@@ -79,6 +79,37 @@ significant changes made to the original work:
|
|
|
79
79
|
- The app is callable as a request listener, so http.createServer(app),
|
|
80
80
|
supertest and anything else that invokes an app directly keeps working
|
|
81
81
|
through a node:http shim.
|
|
82
|
+
- Routing rebuilt on Express 5 semantics: its path matcher, case-insensitive
|
|
83
|
+
matching by default, req.route, and assigning req.url inside a middleware
|
|
84
|
+
re-routes the rest of the stack.
|
|
85
|
+
- The routes listen() can read are compiled into one handler, with an overlap
|
|
86
|
+
analysis deciding which of them may skip the middleware chain, and a
|
|
87
|
+
self-check mode that serves the same application twice to show the compiled
|
|
88
|
+
path answers as the chain it stands in for.
|
|
89
|
+
- ETags are generated in this package rather than by the etag module, and an
|
|
90
|
+
"etag methods" setting limits them to the methods it names.
|
|
91
|
+
- express.compression() added, the compression module's middleware built in: a
|
|
92
|
+
body that arrives whole is compressed in one call, and partial content is
|
|
93
|
+
left alone.
|
|
94
|
+
- express.serverTiming() added, which reports on the response how the request
|
|
95
|
+
was routed.
|
|
96
|
+
- express.static serves the .br and .gz twins of a file with preCompressed.
|
|
97
|
+
- app.ws() serves websockets on the same port, with an upgrade hook and the
|
|
98
|
+
request on the socket.
|
|
99
|
+
- express({ cluster: "auto" }) forks one worker per core on the same port.
|
|
100
|
+
- express.Route and express.testing added, the second one asserting what
|
|
101
|
+
listen() decided about a route.
|
|
102
|
+
- X-Powered-By is not sent unless the application asks for it, and the header
|
|
103
|
+
methods, res.flushHeaders and the node members that were missing are filled
|
|
104
|
+
in.
|
|
105
|
+
- An adapter for NestJS ships as fulmine.js/nest.
|
|
106
|
+
- A fulmine command added: it profiles and explains what listen() worked out
|
|
107
|
+
about each route, verifies the machine, migrates a project, and prints the
|
|
108
|
+
differences from Express.
|
|
109
|
+
- Random applications are generated and compared against Express round by
|
|
110
|
+
round, with the request bytes, the methods that compute a header value and
|
|
111
|
+
keep-alive sequences each having their own fuzzer, and applications built on
|
|
112
|
+
four frameworks run as an integration suite.
|
|
82
113
|
- Releases are built and published to npm from CI.
|
|
83
114
|
- Benchmark harness reworked: wrk replaced by autocannon so the suite runs
|
|
84
115
|
anywhere Node does, NODE_ENV is set, load errors and response validation are
|
package/README.md
CHANGED
|
@@ -44,7 +44,6 @@ See [Migrating](#migrating) for what it handles and what it deliberately does no
|
|
|
44
44
|
- [Why this exists](#why-this-exists)
|
|
45
45
|
- [Performance](#performance)
|
|
46
46
|
- [Public benchmarks](#public-benchmarks)
|
|
47
|
-
- [Attribution](#attribution)
|
|
48
47
|
- [Difference from similar projects](#difference-from-similar-projects)
|
|
49
48
|
- [Migrating](#migrating)
|
|
50
49
|
- [Angular SSR](#angular-ssr)
|
|
@@ -69,6 +68,7 @@ See [Migrating](#migrating) for what it handles and what it deliberately does no
|
|
|
69
68
|
- [Tested frameworks](#tested-frameworks)
|
|
70
69
|
- [Tested view engines](#tested-view-engines)
|
|
71
70
|
- [Examples](./examples/README.md)
|
|
71
|
+
- [Attribution](#attribution)
|
|
72
72
|
- [Working on Fulmine](./CONTRIBUTING.md)
|
|
73
73
|
|
|
74
74
|
## Why this exists
|
|
@@ -77,6 +77,8 @@ There are several fast HTTP servers for Node built on [µWebSockets.js](https://
|
|
|
77
77
|
|
|
78
78
|
Compatibility here is not a claim, it is a test suite. Every test runs against real Express first and then against Fulmine, and the outputs have to match byte for byte. That is what makes `helmet`, `cors`, `passport`, `morgan`, `multer`, `express-session` and the rest of the ecosystem work rather than "mostly work". Express 5's own test suite runs against Fulmine too, and passes whole: 1130 passing, 0 failing at the pinned Express version.
|
|
79
79
|
|
|
80
|
+
It started as a fork of [Ultimate Express](https://github.com/dimdenGD/ultimate-express), which is where the hard part was already done. See [Attribution](#attribution).
|
|
81
|
+
|
|
80
82
|
## Performance
|
|
81
83
|
|
|
82
84
|
Fulmine is faster than Express where the framework itself is doing the work, and the same speed where it is not. Both halves of that sentence matter, so here is the honest version.
|
|
@@ -104,16 +106,6 @@ Numbers produced by a project about itself deserve suspicion, so Fulmine also st
|
|
|
104
106
|
|
|
105
107
|
More to come as their maintainers take the entries in.
|
|
106
108
|
|
|
107
|
-
## Attribution
|
|
108
|
-
|
|
109
|
-
Fulmine is a derivative work of [Ultimate Express](https://github.com/dimdenGD/ultimate-express) by [@dimdenGD](https://github.com/dimdenGD), used under the Apache License 2.0. The full commit history is preserved, so the original authorship is visible in the repository itself.
|
|
110
|
-
|
|
111
|
-
**Special thanks to [@dimdenGD](https://github.com/dimdenGD).** Ultimate Express is the hard part of this project, and it was already done before Fulmine existed. Everything here stands on that work.
|
|
112
|
-
|
|
113
|
-
Fulmine is not affiliated with, endorsed by, or maintained by the authors of Ultimate Express. See [`NOTICE`](./NOTICE) for the list of significant changes.
|
|
114
|
-
|
|
115
|
-
It is likewise not affiliated with the OpenJS Foundation or the Express.js project. Express is a trademark of the OpenJS Foundation.
|
|
116
|
-
|
|
117
109
|
## Difference from similar projects
|
|
118
110
|
|
|
119
111
|
- **`ultimate-express`** is what Fulmine is derived from, and is the closest relative by far. It targets Express 4, keeps the v4 API surface and its deprecations. Fulmine targets Express 5 only, which removes the compatibility layer for everything v5 dropped, and is typed. If you are on Express 4, use `ultimate-express`.
|
|
@@ -927,6 +919,16 @@ npm install
|
|
|
927
919
|
node websocket.js
|
|
928
920
|
```
|
|
929
921
|
|
|
922
|
+
## Attribution
|
|
923
|
+
|
|
924
|
+
Fulmine is a derivative work of [Ultimate Express](https://github.com/dimdenGD/ultimate-express) by [@dimdenGD](https://github.com/dimdenGD), used under the Apache License 2.0. The full commit history is preserved, so the original authorship is visible in the repository itself.
|
|
925
|
+
|
|
926
|
+
**Special thanks to [@dimdenGD](https://github.com/dimdenGD).** Ultimate Express is the hard part of this project, and it was already done before Fulmine existed. Everything here stands on that work.
|
|
927
|
+
|
|
928
|
+
Fulmine is not affiliated with, endorsed by, or maintained by the authors of Ultimate Express. See [`NOTICE`](./NOTICE) for the list of significant changes.
|
|
929
|
+
|
|
930
|
+
It is likewise not affiliated with the OpenJS Foundation or the Express.js project. Express is a trademark of the OpenJS Foundation.
|
|
931
|
+
|
|
930
932
|
## Working on Fulmine
|
|
931
933
|
|
|
932
934
|
How to run the suites, what each of them is for, and how to write a comparison test:
|
package/package.json
CHANGED
package/src/middlewares.js
CHANGED
|
@@ -363,6 +363,41 @@ function pickPrecompressed(filePath, accept, ttl, statTtl) {
|
|
|
363
363
|
return undefined;
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
+
/**
|
|
367
|
+
* The index file to serve from a directory, tried in the order the option lists them, which is
|
|
368
|
+
* send's sendIndex. Throws the last failure when every name failed, and reports nothing when the
|
|
369
|
+
* list ran out without one, since those are the two different answers send gives.
|
|
370
|
+
*
|
|
371
|
+
* @param {string} dir the directory to look in
|
|
372
|
+
* @param {string[]} indexList the index names, in order
|
|
373
|
+
* @returns {{stat: any, name: string, candidate: string}|null} the file to serve, or null
|
|
374
|
+
*/
|
|
375
|
+
function findIndexFile(dir, indexList) {
|
|
376
|
+
let lastError;
|
|
377
|
+
for (const name of indexList) {
|
|
378
|
+
const candidate = path.join(dir, name);
|
|
379
|
+
let stat;
|
|
380
|
+
try {
|
|
381
|
+
stat = fs.statSync(candidate);
|
|
382
|
+
} catch (err) {
|
|
383
|
+
lastError = err;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
// a directory by that name is not an index and is not an error either: send's own loop
|
|
387
|
+
// carries on with nothing to report, so a later name still answers and an exhausted list
|
|
388
|
+
// is the plain 404 rather than whatever the name before it failed with
|
|
389
|
+
if (stat.isDirectory()) {
|
|
390
|
+
lastError = undefined;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
return { stat, name, candidate };
|
|
394
|
+
}
|
|
395
|
+
if (lastError) {
|
|
396
|
+
throw lastError;
|
|
397
|
+
}
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
|
|
366
401
|
/**
|
|
367
402
|
* express.static, which is a thin front for res.sendFile: it resolves the path, refuses anything
|
|
368
403
|
* that climbs out of the root, applies the dotfiles and index rules, and hands the rest over.
|
|
@@ -384,6 +419,9 @@ function serveStatic(root, options) {
|
|
|
384
419
|
// mounts sharing one options object would otherwise also share one root
|
|
385
420
|
options = Object.assign(new NullObject(), options);
|
|
386
421
|
if (typeof options.index === "undefined") options.index = "index.html";
|
|
422
|
+
// send takes a list and tries the names in order, so one name is a list of one and `false` is
|
|
423
|
+
// an empty one. Passing the option along as it came handed an array to path.join, which throws
|
|
424
|
+
const indexList = options.index === false || options.index === "" ? [] : [options.index].flat();
|
|
387
425
|
if (typeof options.redirect === "undefined") options.redirect = true;
|
|
388
426
|
if (typeof options.fallthrough === "undefined") options.fallthrough = true;
|
|
389
427
|
if (typeof options.dotfiles === "undefined") options.dotfiles = "ignore";
|
|
@@ -556,9 +594,9 @@ function serveStatic(root, options) {
|
|
|
556
594
|
// a path written with a trailing slash asks for a directory, and send answers that by
|
|
557
595
|
// looking for the index inside it. With nothing there, the file it names is that
|
|
558
596
|
// index and not the directory that does not exist either
|
|
559
|
-
if (rawPath.endsWith("/") &&
|
|
597
|
+
if (rawPath.endsWith("/") && indexList.length > 0) {
|
|
560
598
|
try {
|
|
561
|
-
|
|
599
|
+
findIndexFile(fullpath, indexList);
|
|
562
600
|
} catch (indexError) {
|
|
563
601
|
statError = indexError;
|
|
564
602
|
}
|
|
@@ -595,8 +633,11 @@ function serveStatic(root, options) {
|
|
|
595
633
|
}
|
|
596
634
|
|
|
597
635
|
// a file asked for with a trailing slash is not that file: send stats the path slash and
|
|
598
|
-
// all and gets ENOTDIR, so a root mounted as a file answers 404 there, not the file
|
|
599
|
-
|
|
636
|
+
// all and gets ENOTDIR, so a root mounted as a file answers 404 there, not the file.
|
|
637
|
+
// With an index configured it never gets that far: a trailing slash sends send looking for
|
|
638
|
+
// the index inside whatever the path turned out to be, so what it reports is that the
|
|
639
|
+
// index under the file is missing, and the directory branch below does it for both
|
|
640
|
+
if (req.endsWithSlash && !stat.isDirectory() && indexList.length === 0) {
|
|
600
641
|
if (!options.fallthrough) {
|
|
601
642
|
res.status(404);
|
|
602
643
|
return next(httpError(404));
|
|
@@ -604,7 +645,7 @@ function serveStatic(root, options) {
|
|
|
604
645
|
return next();
|
|
605
646
|
}
|
|
606
647
|
|
|
607
|
-
if (stat.isDirectory()) {
|
|
648
|
+
if (stat.isDirectory() || req.endsWithSlash) {
|
|
608
649
|
if (!req.endsWithSlash) {
|
|
609
650
|
if (options.redirect) {
|
|
610
651
|
// The query goes along, and the leading slashes are collapsed. Both were
|
|
@@ -624,11 +665,10 @@ function serveStatic(root, options) {
|
|
|
624
665
|
} else return next();
|
|
625
666
|
}
|
|
626
667
|
}
|
|
627
|
-
if (
|
|
668
|
+
if (indexList.length > 0) {
|
|
669
|
+
let found;
|
|
628
670
|
try {
|
|
629
|
-
|
|
630
|
-
_path = path.join(url, options.index);
|
|
631
|
-
filePath = path.join(fullpath, options.index);
|
|
671
|
+
found = findIndexFile(fullpath, indexList);
|
|
632
672
|
} catch (err) {
|
|
633
673
|
if (!options.fallthrough) {
|
|
634
674
|
res.status(404);
|
|
@@ -637,6 +677,18 @@ function serveStatic(root, options) {
|
|
|
637
677
|
return next(asStatError(err));
|
|
638
678
|
} else return next();
|
|
639
679
|
}
|
|
680
|
+
if (found === null) {
|
|
681
|
+
// every name was a directory, which send reports as its plain 404 rather than
|
|
682
|
+
// as a file that could not be read
|
|
683
|
+
if (!options.fallthrough) {
|
|
684
|
+
res.status(404);
|
|
685
|
+
return next(httpError(404));
|
|
686
|
+
}
|
|
687
|
+
return next();
|
|
688
|
+
}
|
|
689
|
+
stat = found.stat;
|
|
690
|
+
_path = path.join(url, found.name);
|
|
691
|
+
filePath = found.candidate;
|
|
640
692
|
} else {
|
|
641
693
|
// a directory with no index to serve is a Not Found, and saying so is the whole
|
|
642
694
|
// point of fallthrough: false. This moved on to the next handler instead, so the
|
package/src/request.js
CHANGED
|
@@ -17,7 +17,7 @@ See the License for the specific language governing permissions and
|
|
|
17
17
|
limitations under the License.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
const { deprecated } = require("./utils.js");
|
|
20
|
+
const { deprecated, fastQueryParse } = require("./utils.js");
|
|
21
21
|
const accepts = require("accepts");
|
|
22
22
|
const typeis = require("type-is");
|
|
23
23
|
const parseRange = require("range-parser");
|
|
@@ -157,6 +157,29 @@ const READABLE_OPTIONS = { highWaterMark: 128 * 1024 };
|
|
|
157
157
|
// `{"a":1}GET /path HTTP/1.1` is a request to it, with `{"A":1}GET` as the method. See _mustRefuse.
|
|
158
158
|
const KNOWN_METHODS = new Set(require("http").METHODS);
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Whether a request target is bytes node's parser would have accepted, which is printable ASCII
|
|
162
|
+
* and nothing else.
|
|
163
|
+
*
|
|
164
|
+
* µWS takes the target as it finds it and decodes it as UTF-8, so `GET /café` arrives here
|
|
165
|
+
* as a path with an é in it and the overlong encoding of a slash arrives as replacement
|
|
166
|
+
* characters. Node refuses both with a 400 before any application sees them, and it has to: what
|
|
167
|
+
* reaches req.url otherwise is not what is on the wire, and a proxy in front reading the same
|
|
168
|
+
* bytes can disagree with this server about which path was asked for. Control characters are µWS's
|
|
169
|
+
* own to refuse and it does, so the test is one comparison per character rather than two.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} target the path or the query string, as µWS decoded it
|
|
172
|
+
* @returns {boolean}
|
|
173
|
+
*/
|
|
174
|
+
function isAsciiTarget(target) {
|
|
175
|
+
for (let i = 0; i < target.length; i++) {
|
|
176
|
+
if (target.charCodeAt(i) > 0x7e) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
160
183
|
/**
|
|
161
184
|
* Whether a transfer-encoding leaves the body's length knowable, which is RFC 9112's rule that
|
|
162
185
|
* `chunked` comes last. `gzip, chunked` is fine and `chunked, gzip` is not: with a coding applied
|
|
@@ -174,7 +197,65 @@ function endsWithChunked(value) {
|
|
|
174
197
|
const last = value.slice(value.lastIndexOf(",") + 1).trim();
|
|
175
198
|
// a coding may carry parameters, which are not part of its name
|
|
176
199
|
const semicolon = last.indexOf(";");
|
|
177
|
-
|
|
200
|
+
if ((semicolon === -1 ? last : last.slice(0, semicolon)).trim().toLowerCase() !== "chunked") {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
// and only once. "chunked, chunked" ends with it and is still nonsense: a sender may not frame
|
|
204
|
+
// a body twice, and where node refuses the request outright µWS frames it as one chunked body
|
|
205
|
+
// and reads whatever follows as the next request on the connection
|
|
206
|
+
const codings = value.split(",");
|
|
207
|
+
let chunkedCount = 0;
|
|
208
|
+
for (const coding of codings) {
|
|
209
|
+
const parameter = coding.indexOf(";");
|
|
210
|
+
if ((parameter === -1 ? coding : coding.slice(0, parameter)).trim().toLowerCase() === "chunked") {
|
|
211
|
+
chunkedCount++;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return chunkedCount === 1;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Whether a Connection header says the connection ends with this response.
|
|
219
|
+
*
|
|
220
|
+
* It is a list, and "keep-alive, close" closes as much as "close" alone does. Compared against an
|
|
221
|
+
* exact "close", this server kept a connection the client had said it was done with, and then read
|
|
222
|
+
* the bytes after it as another request: node closes there, so the two disagreed on how many
|
|
223
|
+
* requests the same bytes carried, which is what a desync is.
|
|
224
|
+
*
|
|
225
|
+
* Written as a scan rather than a split and a lowercase, because almost every request that carries
|
|
226
|
+
* this header carries "keep-alive", and both of those allocate per request.
|
|
227
|
+
*
|
|
228
|
+
* @param {string} value as µWS hands it over
|
|
229
|
+
* @returns {boolean}
|
|
230
|
+
*/
|
|
231
|
+
function saysClose(value) {
|
|
232
|
+
const length = value.length;
|
|
233
|
+
let at = 0;
|
|
234
|
+
while (at < length) {
|
|
235
|
+
while (at < length && (value.charCodeAt(at) === 0x20 || value.charCodeAt(at) === 0x09)) {
|
|
236
|
+
at++;
|
|
237
|
+
}
|
|
238
|
+
const start = at;
|
|
239
|
+
while (at < length && value.charCodeAt(at) !== 0x2c) {
|
|
240
|
+
at++;
|
|
241
|
+
}
|
|
242
|
+
let end = at;
|
|
243
|
+
while (end > start && (value.charCodeAt(end - 1) === 0x20 || value.charCodeAt(end - 1) === 0x09)) {
|
|
244
|
+
end--;
|
|
245
|
+
}
|
|
246
|
+
if (
|
|
247
|
+
end - start === 5 &&
|
|
248
|
+
(value.charCodeAt(start) | 0x20) === 0x63 &&
|
|
249
|
+
(value.charCodeAt(start + 1) | 0x20) === 0x6c &&
|
|
250
|
+
(value.charCodeAt(start + 2) | 0x20) === 0x6f &&
|
|
251
|
+
(value.charCodeAt(start + 3) | 0x20) === 0x73 &&
|
|
252
|
+
(value.charCodeAt(start + 4) | 0x20) === 0x65
|
|
253
|
+
) {
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
at++;
|
|
257
|
+
}
|
|
258
|
+
return false;
|
|
178
259
|
}
|
|
179
260
|
|
|
180
261
|
/**
|
|
@@ -401,12 +482,7 @@ module.exports = class Request extends LazyReadable {
|
|
|
401
482
|
// spotted in the loop that is running anyway: a client asking for the connection to be
|
|
402
483
|
// closed must not be answered that it is being kept alive. The response is built right
|
|
403
484
|
// after this and reads the flag.
|
|
404
|
-
if (
|
|
405
|
-
headerKey.length === 10 &&
|
|
406
|
-
headerKey === "connection" &&
|
|
407
|
-
value.length === 5 &&
|
|
408
|
-
value.toLowerCase() === "close"
|
|
409
|
-
) {
|
|
485
|
+
if (headerKey.length === 10 && headerKey === "connection" && saysClose(value)) {
|
|
410
486
|
r._connectionClose = true;
|
|
411
487
|
} else if (
|
|
412
488
|
(headerKey.length === 14 && headerKey === "content-length") ||
|
|
@@ -644,7 +720,7 @@ module.exports = class Request extends LazyReadable {
|
|
|
644
720
|
const connection = req.getHeader("connection");
|
|
645
721
|
if (connection !== "") {
|
|
646
722
|
entries.push("connection", connection);
|
|
647
|
-
if (connection
|
|
723
|
+
if (saysClose(connection)) {
|
|
648
724
|
this._connectionClose = true;
|
|
649
725
|
}
|
|
650
726
|
}
|
|
@@ -689,6 +765,9 @@ module.exports = class Request extends LazyReadable {
|
|
|
689
765
|
const rawQuery = req.getQuery();
|
|
690
766
|
this._rawQuery = rawQuery ?? "";
|
|
691
767
|
this.urlQuery = rawQuery === undefined ? "" : "?" + rawQuery;
|
|
768
|
+
if (rawQuery !== undefined && rawQuery.length !== 0 && !isAsciiTarget(rawQuery)) {
|
|
769
|
+
this._mustRefuse = true;
|
|
770
|
+
}
|
|
692
771
|
}
|
|
693
772
|
if (preset) {
|
|
694
773
|
// the registration's constants: two native crossings and their strings not asked for
|
|
@@ -707,6 +786,12 @@ module.exports = class Request extends LazyReadable {
|
|
|
707
786
|
// again. Building originalUrl and picking the path back out of it with indexOf and
|
|
708
787
|
// substring was a search and a second string for something uWS had just handed over.
|
|
709
788
|
this._path = req.getUrl();
|
|
789
|
+
// the target as it arrived, which node would have refused before this ran. A preset
|
|
790
|
+
// needs no check: it is a literal registration, and µWS only matched it because the
|
|
791
|
+
// bytes were that literal
|
|
792
|
+
if (!isAsciiTarget(this._path)) {
|
|
793
|
+
this._mustRefuse = true;
|
|
794
|
+
}
|
|
710
795
|
this.originalUrl = this._path + this.urlQuery;
|
|
711
796
|
this.url = this.originalUrl;
|
|
712
797
|
// what the router last wrote to req.url. A middleware assigning something else is a
|
|
@@ -730,6 +815,10 @@ module.exports = class Request extends LazyReadable {
|
|
|
730
815
|
this._isOptions = this.method === "OPTIONS";
|
|
731
816
|
this._isHead = this.method === "HEAD";
|
|
732
817
|
}
|
|
818
|
+
// what the router last saw as the method. A middleware assigning another one is a rewrite,
|
|
819
|
+
// which express honours because it reads req.method at every layer, and dispatch compares
|
|
820
|
+
// against this to notice it
|
|
821
|
+
this._lastMethod = this.method;
|
|
733
822
|
// the folded _opPath and the percent scan of _originalPath, built on the hop that first
|
|
734
823
|
// wants them and dropped by every rewrite, see _pathMatches and Walk#dispatch
|
|
735
824
|
this._opPathLower = null;
|
|
@@ -753,6 +842,8 @@ module.exports = class Request extends LazyReadable {
|
|
|
753
842
|
// how many characters of _originalPath the mounts entered so far have taken, which is
|
|
754
843
|
// where baseUrl ends and the path below them begins
|
|
755
844
|
this._consumed = 0;
|
|
845
|
+
// whether one of them took a trailing slash, which only a RegExp mount can: see baseUrl
|
|
846
|
+
this._mountSlash = false;
|
|
756
847
|
this._paramStack = null;
|
|
757
848
|
// route and application in pairs, one pair per mounted application entered from another
|
|
758
849
|
// application, so handing back puts the one that was current back, see rememberApp
|
|
@@ -934,8 +1025,26 @@ module.exports = class Request extends LazyReadable {
|
|
|
934
1025
|
if (this._baseUrlOverride !== undefined) {
|
|
935
1026
|
return this._baseUrlOverride;
|
|
936
1027
|
}
|
|
1028
|
+
if (this._consumed === 0) {
|
|
1029
|
+
return "";
|
|
1030
|
+
}
|
|
937
1031
|
// what the mounts took, which is where the path they left off begins
|
|
938
|
-
|
|
1032
|
+
if (this._mountSlash !== true) {
|
|
1033
|
+
return this._originalPath.slice(0, this._consumed);
|
|
1034
|
+
}
|
|
1035
|
+
// Express drops one trailing slash off each mount before joining them, so this is a join
|
|
1036
|
+
// of the pieces rather than one slice of the path: a RegExp mount ending in "/" matched
|
|
1037
|
+
// against "/a//b" takes "/a/" and reads back as "/a", and what the mount below it took is
|
|
1038
|
+
// appended to that rather than to the original. Only a RegExp mount can take a trailing
|
|
1039
|
+
// slash, a registered path having had it removed, so almost every request answers above.
|
|
1040
|
+
let out = "";
|
|
1041
|
+
let at = 0;
|
|
1042
|
+
for (const taken of this._stack) {
|
|
1043
|
+
const piece = this._originalPath.slice(at, at + taken);
|
|
1044
|
+
at += taken;
|
|
1045
|
+
out += piece.charCodeAt(taken - 1) === 0x2f ? piece.slice(0, -1) : piece;
|
|
1046
|
+
}
|
|
1047
|
+
return out;
|
|
939
1048
|
}
|
|
940
1049
|
|
|
941
1050
|
/**
|
|
@@ -1114,6 +1223,21 @@ module.exports = class Request extends LazyReadable {
|
|
|
1114
1223
|
this._lastUrl = newUrl;
|
|
1115
1224
|
}
|
|
1116
1225
|
|
|
1226
|
+
/**
|
|
1227
|
+
* Takes over what a middleware assigned to req.method. Both flags are read as bare fields by
|
|
1228
|
+
* the routing scan rather than compared against req.method, so they are what has to follow it,
|
|
1229
|
+
* and an OPTIONS the request has just become still needs the set the verbs are collected in.
|
|
1230
|
+
*/
|
|
1231
|
+
_absorbMethodRewrite() {
|
|
1232
|
+
const method = this.method;
|
|
1233
|
+
this._isOptions = method === "OPTIONS";
|
|
1234
|
+
this._isHead = method === "HEAD";
|
|
1235
|
+
if (this._isOptions && this._matchedMethods === null) {
|
|
1236
|
+
this._matchedMethods = new Set();
|
|
1237
|
+
}
|
|
1238
|
+
this._lastMethod = method;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1117
1241
|
/**
|
|
1118
1242
|
* The query string parsed by whichever parser the "query parser" setting names. A null-prototype
|
|
1119
1243
|
* object, so a key like "__proto__" cannot reach Object.prototype. No setter, so assigning to
|
|
@@ -1144,11 +1268,21 @@ module.exports = class Request extends LazyReadable {
|
|
|
1144
1268
|
// the vendored default already answers on a bare null prototype, so it goes out as is;
|
|
1145
1269
|
// any other parser is copied onto one, which is what kept fast-querystring's result from
|
|
1146
1270
|
// inspecting as "Empty <[Object: null prototype] {}>" where Express shows the bare form
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1271
|
+
// A parser of the application's own is handed what express hands it, which is
|
|
1272
|
+
// parseurl's `query`: null when the url carries no "?" at all, and the text after it
|
|
1273
|
+
// otherwise, the empty string included. Passing "" for both meant a parser written for
|
|
1274
|
+
// express, which may check for null before it reads the string, saw a request that had no
|
|
1275
|
+
// query as one with an empty query. The two built in parsers take the raw string.
|
|
1276
|
+
if (!qp) {
|
|
1277
|
+
return Object.create(null);
|
|
1278
|
+
}
|
|
1279
|
+
if (qp === parseQuery) {
|
|
1280
|
+
return parseQuery(this._rawQuery);
|
|
1281
|
+
}
|
|
1282
|
+
if (qp === fastQueryParse) {
|
|
1283
|
+
return Object.assign(Object.create(null), fastQueryParse(this._rawQuery));
|
|
1284
|
+
}
|
|
1285
|
+
return Object.assign(Object.create(null), qp(this.urlQuery === "" ? null : this._rawQuery));
|
|
1152
1286
|
}
|
|
1153
1287
|
|
|
1154
1288
|
/**
|
package/src/response.js
CHANGED
|
@@ -790,13 +790,24 @@ module.exports = class Response extends LazyWritable {
|
|
|
790
790
|
this.writeHeaders(true);
|
|
791
791
|
}
|
|
792
792
|
const contentLength = this.headers["content-length"];
|
|
793
|
+
// The client said this connection ends here, and it is this end() that has to make it so.
|
|
794
|
+
// µWS closes by itself for a bare "close" and not for a list, so "keep-alive, close" left
|
|
795
|
+
// the socket open and the bytes after that request were read as another one: node closes
|
|
796
|
+
// there, and a server that does not is a server the client and it disagree with about how
|
|
797
|
+
// many requests were sent. See saysClose.
|
|
798
|
+
//
|
|
799
|
+
// Only where a length goes out with it. endWithoutBody takes the flag as its second
|
|
800
|
+
// argument and reads the first as the length whatever it holds, so asking it to close
|
|
801
|
+
// without one writes "Content-Length: 9223372036854775808" onto a 204. Those two paths keep
|
|
802
|
+
// µWS's own rule, which closes for a bare "close" and not for a list.
|
|
803
|
+
const closeConnection = this.req._connectionClose === true;
|
|
793
804
|
if (STATUSES_WITHOUT_BODY.has(this.statusCode) || this.statusCode < 200) {
|
|
794
805
|
// no body and no length describing one, whatever the caller passed. node decides
|
|
795
806
|
// this the same way, from the status alone, so res.status(304).end("x") sends the
|
|
796
807
|
// status and nothing else on either.
|
|
797
808
|
this._res.endWithoutBody();
|
|
798
809
|
} else if (!data && contentLength) {
|
|
799
|
-
this._res.endWithoutBody(contentLength.toString());
|
|
810
|
+
this._res.endWithoutBody(contentLength.toString(), closeConnection);
|
|
800
811
|
} else if (headWasAlreadyOut && this.chunkedTransfer) {
|
|
801
812
|
// whatever is still queued goes first: end() must not overtake the body written before it
|
|
802
813
|
this.#flushQueued(null);
|
|
@@ -817,7 +828,7 @@ module.exports = class Response extends LazyWritable {
|
|
|
817
828
|
if (this.req.method === "HEAD") {
|
|
818
829
|
const length = Buffer.byteLength(data ?? "");
|
|
819
830
|
this.headers["content-length"] = String(length);
|
|
820
|
-
this._res.endWithoutBody(length.toString());
|
|
831
|
+
this._res.endWithoutBody(length.toString(), closeConnection);
|
|
821
832
|
} else {
|
|
822
833
|
// remembered rather than measured: only a caller that asks for content-length pays
|
|
823
834
|
// for it, and uWS is measuring the same bytes for the wire anyway
|
|
@@ -825,7 +836,7 @@ module.exports = class Response extends LazyWritable {
|
|
|
825
836
|
// and null is sent as the empty body it means. uWS answers end(null) with a
|
|
826
837
|
// response the client never sees the end of, where node and express send an empty
|
|
827
838
|
// 200: res.end(null) is what the compression module's own test suite does
|
|
828
|
-
this._res.end(data ?? "");
|
|
839
|
+
this._res.end(data ?? "", closeConnection);
|
|
829
840
|
}
|
|
830
841
|
}
|
|
831
842
|
|
package/src/router.js
CHANGED
|
@@ -216,6 +216,11 @@ class Walk {
|
|
|
216
216
|
if (req.url !== req._lastUrl && this.takeUrlRewrite(startIndex)) {
|
|
217
217
|
return;
|
|
218
218
|
}
|
|
219
|
+
// and the same for req.method, which method-override assigns: the compiled chain was
|
|
220
|
+
// picked by the verb the request arrived with, so it no longer stands for this one
|
|
221
|
+
if (req.method !== req._lastMethod && this.takeMethodRewrite(startIndex)) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
219
224
|
let routeIndex = startIndex;
|
|
220
225
|
// a compiled chain runs what is in it without matching again, so this is where a layer that
|
|
221
226
|
// provably has nothing to do for this request is stepped over rather than entered
|
|
@@ -345,6 +350,34 @@ class Walk {
|
|
|
345
350
|
return true;
|
|
346
351
|
}
|
|
347
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Takes over a req.method a middleware assigned, which method-override is written to do. The
|
|
355
|
+
* ordinary scan reads req.method per route and is right from the next hop on; a compiled chain
|
|
356
|
+
* was chosen by the method µWS dispatched on, so ordinary routing takes over from the top the
|
|
357
|
+
* way a url rewrite does.
|
|
358
|
+
*
|
|
359
|
+
* @param {number} startIndex where dispatch was about to resume
|
|
360
|
+
* @returns {boolean} whether this rerouted the walk itself
|
|
361
|
+
*/
|
|
362
|
+
takeMethodRewrite(startIndex) {
|
|
363
|
+
const req = this.req;
|
|
364
|
+
const router = this.router;
|
|
365
|
+
req._absorbMethodRewrite();
|
|
366
|
+
if (!this.skipCheck) {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
this.skipUntil = startIndex > 0 ? this.routes[startIndex - 1] : undefined;
|
|
370
|
+
if (req._stack !== null && req._stack.length > 0) {
|
|
371
|
+
req._stack.length = 0;
|
|
372
|
+
req._consumed = 0;
|
|
373
|
+
setMountedPath(req);
|
|
374
|
+
}
|
|
375
|
+
this.routes = router._routes;
|
|
376
|
+
this.skipCheck = false;
|
|
377
|
+
this.dispatch(0);
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
|
|
348
381
|
/**
|
|
349
382
|
* Enters the route the walk is on: a mount adjusts req.url, req.path and the mount stack on the
|
|
350
383
|
* way in, and then the route's callbacks run one after another through next().
|
|
@@ -375,6 +408,11 @@ class Walk {
|
|
|
375
408
|
// one cannot bite. An application is mostly pathless middleware, and this is per hop
|
|
376
409
|
if (taken !== 0 || req.endsWithSlash) {
|
|
377
410
|
req._consumed += taken;
|
|
411
|
+
// a mount that took a trailing slash: req.baseUrl then has to join the pieces
|
|
412
|
+
// rather than slice the path, which is the slower half of its getter
|
|
413
|
+
if (taken !== 0 && req._originalPath.charCodeAt(req._consumed - 1) === 0x2f) {
|
|
414
|
+
req._mountSlash = true;
|
|
415
|
+
}
|
|
378
416
|
setMountedPath(req);
|
|
379
417
|
}
|
|
380
418
|
}
|
|
@@ -707,6 +745,12 @@ const PATH_PROPERTY = {
|
|
|
707
745
|
enumerable: true
|
|
708
746
|
};
|
|
709
747
|
|
|
748
|
+
// and the two the walk calls when a middleware rewrote req.url or req.method, for the same reason:
|
|
749
|
+
// an adopted request has no prototype of ours to find them on, and a rewrite through one of those
|
|
750
|
+
// routers threw instead of being taken over
|
|
751
|
+
const ABSORB_URL = Request.prototype._absorbUrlRewrite;
|
|
752
|
+
const ABSORB_METHOD = Request.prototype._absorbMethodRewrite;
|
|
753
|
+
|
|
710
754
|
const NO_PARAM_NAMES = [];
|
|
711
755
|
|
|
712
756
|
/**
|
|
@@ -854,6 +898,8 @@ function adoptPlainRequest(req, router) {
|
|
|
854
898
|
// an adopted request is a plain object, so it carries no prototype of ours and reads its path
|
|
855
899
|
// off a property of its own. The class's getter itself, so there is one of it
|
|
856
900
|
Object.defineProperty(req, "path", PATH_PROPERTY);
|
|
901
|
+
req._absorbUrlRewrite = ABSORB_URL;
|
|
902
|
+
req._absorbMethodRewrite = ABSORB_METHOD;
|
|
857
903
|
req.originalUrl = req.originalUrl ?? arrived;
|
|
858
904
|
req._originalPath = path;
|
|
859
905
|
req.endsWithSlash = path.charCodeAt(path.length - 1) === 0x2f;
|
|
@@ -861,6 +907,7 @@ function adoptPlainRequest(req, router) {
|
|
|
861
907
|
req._opPathLower = null;
|
|
862
908
|
req._mayFailDecode = null;
|
|
863
909
|
req._lastUrl = req.url;
|
|
910
|
+
req._lastMethod = req.method;
|
|
864
911
|
req._isOptions = req.method === "OPTIONS";
|
|
865
912
|
req._isHead = req.method === "HEAD";
|
|
866
913
|
req.params = req.params ?? Object.create(null);
|
|
@@ -868,6 +915,7 @@ function adoptPlainRequest(req, router) {
|
|
|
868
915
|
// requests never see one, same as the Request constructor
|
|
869
916
|
req._stack = null;
|
|
870
917
|
req._consumed = 0;
|
|
918
|
+
req._mountSlash = false;
|
|
871
919
|
req._paramStack = null;
|
|
872
920
|
req._matchedMethods = req._isOptions ? new Set() : null;
|
|
873
921
|
req.routeCount = 1;
|
|
@@ -1742,6 +1790,55 @@ module.exports = class Router extends EventEmitter {
|
|
|
1742
1790
|
method = method.toUpperCase();
|
|
1743
1791
|
callbacks = callbacks.flat(Infinity);
|
|
1744
1792
|
checkHandlers(callbacks);
|
|
1793
|
+
// What express hangs off req.route as its methods, and the three registrations do not
|
|
1794
|
+
// agree on it: app.all() registers every verb one at a time, so the map names all of
|
|
1795
|
+
// them; router.all() and app.route().all() mark the route _all instead; and everything
|
|
1796
|
+
// hung off one app.route() shares one map, since express builds one Route for the lot.
|
|
1797
|
+
// Built in node's own order, which is the order the methods package hands express, so
|
|
1798
|
+
// the map reads back key for key as express's does.
|
|
1799
|
+
let methodMap;
|
|
1800
|
+
let stack;
|
|
1801
|
+
if (method !== "USE") {
|
|
1802
|
+
methodMap = this._pendingGroupMethods ?? new NullObject();
|
|
1803
|
+
// and the layers behind them, which is express's Route#stack: one per handler per verb
|
|
1804
|
+
// the route was registered for, in the order express pushes them. app.all() therefore
|
|
1805
|
+
// has one for every verb, since that is how many times express registers the handler
|
|
1806
|
+
stack = this._pendingGroupStack ?? [];
|
|
1807
|
+
let verbs;
|
|
1808
|
+
if (method === "ALL") {
|
|
1809
|
+
if (this._isApplication && this._pendingGroup === undefined) {
|
|
1810
|
+
verbs = [];
|
|
1811
|
+
for (const known of METHODS) {
|
|
1812
|
+
const lowered = known.toLowerCase();
|
|
1813
|
+
methodMap[lowered] = true;
|
|
1814
|
+
verbs.push(lowered);
|
|
1815
|
+
}
|
|
1816
|
+
} else {
|
|
1817
|
+
methodMap._all = true;
|
|
1818
|
+
// Route#all leaves the layer without one, and express reads that as any verb
|
|
1819
|
+
verbs = [undefined];
|
|
1820
|
+
}
|
|
1821
|
+
} else {
|
|
1822
|
+
methodMap[method.toLowerCase()] = true;
|
|
1823
|
+
verbs = [method.toLowerCase()];
|
|
1824
|
+
}
|
|
1825
|
+
for (const verb of verbs) {
|
|
1826
|
+
for (const handle of callbacks) {
|
|
1827
|
+
stack.push({
|
|
1828
|
+
handle,
|
|
1829
|
+
name: handle.name || "<anonymous>",
|
|
1830
|
+
params: undefined,
|
|
1831
|
+
path: undefined,
|
|
1832
|
+
keys: [],
|
|
1833
|
+
method: verb
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
// Several paths at once are one route to express, whose path is the array it was given,
|
|
1839
|
+
// and several here, one per path, so they share the map and the stack and read back with
|
|
1840
|
+
// the array as their path.
|
|
1841
|
+
const writtenPath = path;
|
|
1745
1842
|
const paths = Array.isArray(path) ? path : [path];
|
|
1746
1843
|
const routes = [];
|
|
1747
1844
|
for (let path of paths) {
|
|
@@ -1795,6 +1892,12 @@ module.exports = class Router extends EventEmitter {
|
|
|
1795
1892
|
regexMount: method === "USE" && path instanceof RegExp,
|
|
1796
1893
|
// written by the application, so express matches it as it stands
|
|
1797
1894
|
userRegexp: path instanceof RegExp,
|
|
1895
|
+
// express reads these off req.route, and a middleware has none: see _preprocessRequest
|
|
1896
|
+
methods: methodMap,
|
|
1897
|
+
stack,
|
|
1898
|
+
// the route as a request sees it, which is the route itself unless the path was
|
|
1899
|
+
// normalised. Written into the literal so every route keeps one shape
|
|
1900
|
+
exposed: /** @type {any} */ (undefined),
|
|
1798
1901
|
routeKey: routeKey++,
|
|
1799
1902
|
// which app.route() this came from, when it came from one, so the routes it built
|
|
1800
1903
|
// count as one route where an error is concerned. undefined for every other route
|
|
@@ -1811,6 +1914,17 @@ module.exports = class Router extends EventEmitter {
|
|
|
1811
1914
|
all: method === "ALL" || method === "USE",
|
|
1812
1915
|
gettable: method === "GET" || method === "HEAD"
|
|
1813
1916
|
};
|
|
1917
|
+
// Everything here matches on the normalised path, and express hands out the written
|
|
1918
|
+
// one: a route registered as "/users/" is matched as "/users" with strict routing off
|
|
1919
|
+
// and still reads back with its slash. Rather than carry two paths through the
|
|
1920
|
+
// optimizer, a route whose path was normalised gets a view of itself with the written
|
|
1921
|
+
// path on top, and that is the one the request is given.
|
|
1922
|
+
route.exposed = route;
|
|
1923
|
+
if (writtenPath !== path) {
|
|
1924
|
+
const view = Object.create(route);
|
|
1925
|
+
view.path = writtenPath;
|
|
1926
|
+
route.exposed = view;
|
|
1927
|
+
}
|
|
1814
1928
|
if (
|
|
1815
1929
|
route.pattern instanceof RegExp &&
|
|
1816
1930
|
// a RegExp the application wrote: its capture groups are params too
|
|
@@ -2595,7 +2709,13 @@ module.exports = class Router extends EventEmitter {
|
|
|
2595
2709
|
* @returns {any} a promise only when a param callback is involved
|
|
2596
2710
|
*/
|
|
2597
2711
|
_preprocessRequest(req, res, route) {
|
|
2598
|
-
|
|
2712
|
+
// express sets this inside Route#dispatch, so only a route ever writes one: a middleware
|
|
2713
|
+
// reads undefined there, and so does a request nothing routed. Code that tells a route
|
|
2714
|
+
// from a middleware by asking for req.route, which is how a metric gets its name, read
|
|
2715
|
+
// the mount here and named itself after it
|
|
2716
|
+
if (route.use !== true) {
|
|
2717
|
+
req.route = route.exposed;
|
|
2718
|
+
}
|
|
2599
2719
|
// both, not the route flag alone: the flag says the route was registered natively, the
|
|
2600
2720
|
// values say this request came in that way
|
|
2601
2721
|
if (route.optimizedParams && req.optimizedParams) {
|
|
@@ -2918,12 +3038,24 @@ module.exports = class Router extends EventEmitter {
|
|
|
2918
3038
|
// far as an error is concerned, see errorHop
|
|
2919
3039
|
const group = ++routeGroups;
|
|
2920
3040
|
const fns = new NullObject();
|
|
3041
|
+
// one map for the whole chain, because express builds one Route for it: a request answered
|
|
3042
|
+
// by the get() of an app.route() reads post() in its req.route.methods too
|
|
3043
|
+
const groupMethods = new NullObject();
|
|
3044
|
+
const groupStack = [];
|
|
3045
|
+
// express hands back a Route, which carries these three beside the verb methods
|
|
3046
|
+
fns.path = path;
|
|
3047
|
+
fns.methods = groupMethods;
|
|
3048
|
+
fns.stack = groupStack;
|
|
2921
3049
|
const inGroup = (method, callbacks) => {
|
|
2922
3050
|
this._pendingGroup = group;
|
|
3051
|
+
this._pendingGroupMethods = groupMethods;
|
|
3052
|
+
this._pendingGroupStack = groupStack;
|
|
2923
3053
|
try {
|
|
2924
3054
|
return this.createRoute(method, path, /** @type {any} */ (fns), ...callbacks);
|
|
2925
3055
|
} finally {
|
|
2926
3056
|
this._pendingGroup = undefined;
|
|
3057
|
+
this._pendingGroupMethods = undefined;
|
|
3058
|
+
this._pendingGroupStack = undefined;
|
|
2927
3059
|
}
|
|
2928
3060
|
};
|
|
2929
3061
|
for (const method of methods) {
|