fulmine.js 5.1.3 → 5.1.6
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 +23 -2
- package/README.md +25 -0
- package/package.json +3 -2
- package/src/application.js +5 -2
- package/src/middlewares.js +91 -21
- package/src/node-shim.js +2 -0
- package/src/parse-query.js +120 -0
- package/src/request.js +31 -4
- package/src/router.js +17 -1
- package/src/utils.js +4 -3
package/NOTICE
CHANGED
|
@@ -12,6 +12,10 @@ itself and can be inspected with `git log` and `git shortlog -sn`.
|
|
|
12
12
|
Fulmine is not affiliated with, endorsed by, or maintained by the authors of
|
|
13
13
|
Ultimate Express.
|
|
14
14
|
|
|
15
|
+
This product includes code derived from fast-querystring
|
|
16
|
+
(https://github.com/anonrig/fast-querystring), Copyright (c) Yagiz Nizipli,
|
|
17
|
+
licensed under the MIT License, vendored in src/parse-query.js.
|
|
18
|
+
|
|
15
19
|
As required by section 4(b) of the Apache License, the following are the
|
|
16
20
|
significant changes made to the original work:
|
|
17
21
|
|
|
@@ -19,10 +23,27 @@ significant changes made to the original work:
|
|
|
19
23
|
- Public API documented, and type checked from those annotations.
|
|
20
24
|
- Router dispatch reworked so a chain of N middlewares allocates one promise
|
|
21
25
|
instead of N, and the mount stack is no longer walked on every hop.
|
|
22
|
-
- Request bodies are collected
|
|
23
|
-
|
|
26
|
+
- Request bodies are collected in native code by uWebSockets.js when the
|
|
27
|
+
length is known, with the size limit enforced before any byte reaches
|
|
28
|
+
JavaScript, instead of being copied once per chunk and again on
|
|
29
|
+
concatenation; the body parsers read the few headers they need from the
|
|
30
|
+
raw header entries and bind the caller's async context only when a read
|
|
31
|
+
actually goes asynchronous.
|
|
24
32
|
- Empty request bodies now produce the same value each Express body parser
|
|
25
33
|
produces, rather than being skipped.
|
|
34
|
+
- Routes whose path is a literal are registered as native uWebSockets.js
|
|
35
|
+
routes, with their path and method handed to the request as
|
|
36
|
+
registration-time constants.
|
|
37
|
+
- sendFile answers small unchanged files from a stat-validated cache, and
|
|
38
|
+
concurrent reads of the same file are coalesced.
|
|
39
|
+
- The query string parser is vendored from fast-querystring and answers on a
|
|
40
|
+
bare null prototype, matching how Express displays parsed queries.
|
|
41
|
+
- Express 5's own test suite runs against Fulmine as a CI gate, from a
|
|
42
|
+
pinned checkout of Express.
|
|
43
|
+
- The app is callable as a request listener, so http.createServer(app),
|
|
44
|
+
supertest and anything else that invokes an app directly keeps working
|
|
45
|
+
through a node:http shim.
|
|
46
|
+
- Releases are built and published to npm from CI.
|
|
26
47
|
- Benchmark harness reworked: wrk replaced by autocannon so the suite runs
|
|
27
48
|
anywhere Node does, NODE_ENV is set, load errors and response validation are
|
|
28
49
|
no longer discarded, rows bounded by shared work are labelled, and scenarios
|
package/README.md
CHANGED
|
@@ -90,6 +90,31 @@ npx fulmine differences # print the list below and change nothing
|
|
|
90
90
|
The command is installed under both `fulmine` and `fulmine.js`. Use `fulmine`: `npx` cannot run a
|
|
91
91
|
command whose name ends in `.js` on Windows, where it exits without a word.
|
|
92
92
|
|
|
93
|
+
## Docker
|
|
94
|
+
|
|
95
|
+
Two things about µWebSockets.js make a Dockerfile that works for Express fail here, and both have easy answers:
|
|
96
|
+
|
|
97
|
+
- **No Alpine.** µWebSockets.js ships prebuilt binaries linked against glibc. Alpine images use musl, so the binary does not load. Use a Debian-based image such as `node:22-slim` instead of `node:22-alpine`.
|
|
98
|
+
- **`git` must be there when `npm install` runs.** µWebSockets.js is not on npm; it is installed straight from GitHub (`github:uNetworking/uWebSockets.js`), and npm uses git to fetch it. Full images like `node:22` have git; `-slim` ones do not.
|
|
99
|
+
|
|
100
|
+
The clean way to satisfy both is a multi-stage build: install with the full image, run with the slim one.
|
|
101
|
+
|
|
102
|
+
```dockerfile
|
|
103
|
+
FROM node:22 AS build
|
|
104
|
+
WORKDIR /app
|
|
105
|
+
COPY package*.json ./
|
|
106
|
+
RUN npm ci --omit=dev
|
|
107
|
+
|
|
108
|
+
FROM node:22-slim
|
|
109
|
+
WORKDIR /app
|
|
110
|
+
COPY --from=build /app/node_modules ./node_modules
|
|
111
|
+
COPY . .
|
|
112
|
+
EXPOSE 3000
|
|
113
|
+
CMD ["node", "server.js"]
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
A single-stage `node:22-slim` image works too if you `apt-get install -y git` before `npm ci`. Prebuilt binaries exist for x64 and arm64 on Linux, macOS and Windows, so nothing is compiled at install time either way.
|
|
117
|
+
|
|
93
118
|
## Differences from Express
|
|
94
119
|
|
|
95
120
|
- `app.listen()` returns the app, not an `http.Server`. There is no node server underneath, so `server.close()`, `server.address()` and anything that attaches itself to a real `http.Server` need a look. `app.close()`, `app.address()` and `app.listening` are there and do what you would expect.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fulmine.js",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.6",
|
|
4
4
|
"description": "Drop-in Express 5 replacement on uWebSockets.js. Your existing middleware keeps working.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"cookie": "^1.1.1",
|
|
71
71
|
"cookie-signature": "^1.2.2",
|
|
72
72
|
"encodeurl": "^2.0.0",
|
|
73
|
-
"fast-
|
|
73
|
+
"fast-decode-uri-component": "^1.0.1",
|
|
74
74
|
"fast-zlib": "^2.0.1",
|
|
75
75
|
"fresh": "^2.0.0",
|
|
76
76
|
"iconv-lite": "^0.7.3",
|
|
@@ -131,6 +131,7 @@
|
|
|
131
131
|
"express-rate-limit": "^8.5.2",
|
|
132
132
|
"express-session": "^1.19.0",
|
|
133
133
|
"express-subdomain": "^1.0.6",
|
|
134
|
+
"fast-querystring": "^1.1.2",
|
|
134
135
|
"globals": "^17.8.0",
|
|
135
136
|
"graphql-http": "^1.22.4",
|
|
136
137
|
"helmet": "^8.2.0",
|
package/src/application.js
CHANGED
|
@@ -28,7 +28,7 @@ const {
|
|
|
28
28
|
fastQueryParse,
|
|
29
29
|
NullObject
|
|
30
30
|
} = require("./utils.js");
|
|
31
|
-
const
|
|
31
|
+
const parseQuery = require("./parse-query.js");
|
|
32
32
|
const Request = require("./request.js");
|
|
33
33
|
const Response = require("./response.js");
|
|
34
34
|
const ViewClass = require("./view.js");
|
|
@@ -345,7 +345,7 @@ class Application extends Router {
|
|
|
345
345
|
if (value === "extended") {
|
|
346
346
|
this.settings["query parser fn"] = fastQueryParse;
|
|
347
347
|
} else if (value === "simple" || value === true) {
|
|
348
|
-
this.settings["query parser fn"] =
|
|
348
|
+
this.settings["query parser fn"] = parseQuery;
|
|
349
349
|
} else if (typeof value === "function") {
|
|
350
350
|
this.settings["query parser fn"] = value;
|
|
351
351
|
} else if (value === false) {
|
|
@@ -460,6 +460,9 @@ class Application extends Router {
|
|
|
460
460
|
this.uwsApp.any("/*", async (res, req) => {
|
|
461
461
|
const request = this.handleRequest(res, req);
|
|
462
462
|
const response = request.res;
|
|
463
|
+
// armed up front here: this handler awaits, so the response outlives the callback
|
|
464
|
+
// on every path through it
|
|
465
|
+
this._armAbort(res, response);
|
|
463
466
|
|
|
464
467
|
try {
|
|
465
468
|
const routed = this._routeRequest(request, response);
|
package/src/middlewares.js
CHANGED
|
@@ -21,7 +21,7 @@ const bytes = require("bytes");
|
|
|
21
21
|
const zlib = require("fast-zlib");
|
|
22
22
|
const typeis = require("type-is");
|
|
23
23
|
const qs = require("qs");
|
|
24
|
-
const
|
|
24
|
+
const parseQuery = require("./parse-query.js");
|
|
25
25
|
const { AsyncResource } = require("async_hooks");
|
|
26
26
|
const { fastQueryParse, NullObject, asStatError, httpError, memoizeByString } = require("./utils.js");
|
|
27
27
|
|
|
@@ -289,7 +289,10 @@ function serveStatic(root, options) {
|
|
|
289
289
|
options._ownEtag = true;
|
|
290
290
|
|
|
291
291
|
return (req, res, next) => {
|
|
292
|
-
|
|
292
|
+
// Not bound here: every path down to sendFile is synchronous, statSync included, so the
|
|
293
|
+
// caller's async context is intact at each of these next() calls. Only sendFile's
|
|
294
|
+
// completion can arrive on a uWS callback that carries no context, and that one
|
|
295
|
+
// continuation is bound where it is handed over.
|
|
293
296
|
|
|
294
297
|
// a file is read, not written: anything but GET and HEAD belongs to whoever comes next, or
|
|
295
298
|
// is refused outright when this middleware is the last word
|
|
@@ -410,11 +413,15 @@ function serveStatic(root, options) {
|
|
|
410
413
|
|
|
411
414
|
options._stat = stat;
|
|
412
415
|
|
|
413
|
-
return res.sendFile(
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
416
|
+
return res.sendFile(
|
|
417
|
+
_path,
|
|
418
|
+
options,
|
|
419
|
+
AsyncResource.bind((e) => {
|
|
420
|
+
if (e) {
|
|
421
|
+
next(options.fallthrough && FALLTHROUGH_STATUSES.has(e.status) ? undefined : e);
|
|
422
|
+
}
|
|
423
|
+
})
|
|
424
|
+
);
|
|
418
425
|
};
|
|
419
426
|
}
|
|
420
427
|
|
|
@@ -462,9 +469,11 @@ function createInflate(contentEncoding) {
|
|
|
462
469
|
* @param {string} [charsetPolicy] which charsets this parser accepts, as body-parser draws the
|
|
463
470
|
* lines: "utf" (json, utf-* only), "urlencoded" (utf-8 and iso-8859-1), "any" (anything iconv
|
|
464
471
|
* knows), or undefined for a parser that never decodes (raw)
|
|
472
|
+
* @param {boolean} [keepsBuffer] whether the collected buffer itself escapes to the application,
|
|
473
|
+
* which rules out handing it a view over uWS memory
|
|
465
474
|
* @returns {(options?: object) => Function} the middleware factory
|
|
466
475
|
*/
|
|
467
|
-
function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy) {
|
|
476
|
+
function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy, keepsBuffer) {
|
|
468
477
|
return function (options) {
|
|
469
478
|
// a copy, because everything below writes the parsed values back: with the caller's own
|
|
470
479
|
// object, altering it after the parser was built would alter the parser
|
|
@@ -500,6 +509,10 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
500
509
|
}
|
|
501
510
|
if (typeof options.defaultCharset === "undefined") options.defaultCharset = "utf-8";
|
|
502
511
|
|
|
512
|
+
// whether the collected bytes escape the collection callback: the raw parser hands the
|
|
513
|
+
// buffer itself to the application, and a verify hook may keep what it is shown
|
|
514
|
+
const copyBody = keepsBuffer || typeof options.verify === "function";
|
|
515
|
+
|
|
503
516
|
// Whether a content-type is one this parser claims, remembered per parser.
|
|
504
517
|
//
|
|
505
518
|
// Only reached when the caller asked for a wildcard or a list, since a plain type takes the
|
|
@@ -514,14 +527,17 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
514
527
|
let additionalMethods;
|
|
515
528
|
|
|
516
529
|
return (req, res, next) => {
|
|
517
|
-
|
|
530
|
+
// Not bound yet: every return in this prologue is synchronous, so the caller's async
|
|
531
|
+
// context is still intact and an AsyncResource here would be 1.4 microseconds of
|
|
532
|
+
// nothing. The bind happens below, only once a real read is about to go async.
|
|
518
533
|
|
|
519
534
|
// skip reading body twice
|
|
520
535
|
if (req.bodyRead) {
|
|
521
536
|
return next();
|
|
522
537
|
}
|
|
523
538
|
|
|
524
|
-
|
|
539
|
+
// straight from the raw entries: three headers do not justify building the object
|
|
540
|
+
const type = req._rawHeader("content-type");
|
|
525
541
|
|
|
526
542
|
// req.body is deliberately left undefined until a parser claims the request. That is
|
|
527
543
|
// what lets a handler tell "nothing parsed this" apart from "the body was empty",
|
|
@@ -534,13 +550,13 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
534
550
|
return next();
|
|
535
551
|
}
|
|
536
552
|
|
|
537
|
-
const length = req.
|
|
553
|
+
const length = req._rawHeader("content-length");
|
|
538
554
|
|
|
539
555
|
// No content-length and no transfer-encoding means the request carries no body at all,
|
|
540
556
|
// and a body parser must leave it alone rather than parse nothing into an empty value.
|
|
541
557
|
// type-is applies this before matching the type, but the simpleType shortcut below
|
|
542
558
|
// compares strings directly and would otherwise skip the check.
|
|
543
|
-
if (req.
|
|
559
|
+
if (req._rawHeader("transfer-encoding") === undefined && isNaN(length)) {
|
|
544
560
|
return next();
|
|
545
561
|
}
|
|
546
562
|
|
|
@@ -620,7 +636,8 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
620
636
|
const abs = [];
|
|
621
637
|
let inflate;
|
|
622
638
|
let totalSize = 0;
|
|
623
|
-
const
|
|
639
|
+
const rawContentEncoding = req._rawHeader("content-encoding");
|
|
640
|
+
const contentEncoding = (rawContentEncoding || "identity").toLowerCase();
|
|
624
641
|
if (!options.inflate && contentEncoding !== "identity") {
|
|
625
642
|
return next(
|
|
626
643
|
bodyError("content encoding unsupported", 415, "encoding.unsupported", {
|
|
@@ -629,19 +646,64 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
629
646
|
);
|
|
630
647
|
}
|
|
631
648
|
if (options.inflate) {
|
|
632
|
-
inflate = createInflate(
|
|
649
|
+
inflate = createInflate(rawContentEncoding);
|
|
633
650
|
if (inflate === false) {
|
|
634
651
|
return next(
|
|
635
652
|
bodyError(
|
|
636
|
-
'unsupported content encoding "' +
|
|
653
|
+
'unsupported content encoding "' + rawContentEncoding + '"',
|
|
637
654
|
415,
|
|
638
655
|
"encoding.unsupported",
|
|
639
|
-
{
|
|
656
|
+
{
|
|
657
|
+
encoding: rawContentEncoding
|
|
658
|
+
}
|
|
640
659
|
)
|
|
641
660
|
);
|
|
642
661
|
}
|
|
643
662
|
}
|
|
644
663
|
|
|
664
|
+
// From here the body really gets read, and uWS delivers it on native callbacks that
|
|
665
|
+
// carry no async context, so this is the one continuation that has to be bound: an
|
|
666
|
+
// upstream middleware's AsyncLocalStorage must still be there when next runs
|
|
667
|
+
next = AsyncResource.bind(next);
|
|
668
|
+
|
|
669
|
+
// with a known content-length and nothing to decompress, uWS can collect the whole
|
|
670
|
+
// body in native code: one callback instead of one per chunk, the limit enforced
|
|
671
|
+
// before any byte reaches JS, and no copy at all - the parsers turn the bytes into
|
|
672
|
+
// req.body before the callback returns, so a view over uWS's own memory is enough
|
|
673
|
+
if (!req.receivedData && !inflate && !isNaN(length) && Number(length) > 0 && req._res.collectBody) {
|
|
674
|
+
req.bodyRead = true;
|
|
675
|
+
const declared = Number(length);
|
|
676
|
+
req._res.collectBody(options.limit, (body) => {
|
|
677
|
+
if (body === null) {
|
|
678
|
+
// over maxSize: uWS refused it natively
|
|
679
|
+
return next(
|
|
680
|
+
bodyError("request entity too large", 413, "entity.too.large", {
|
|
681
|
+
limit: options.limit,
|
|
682
|
+
received: options.limit
|
|
683
|
+
})
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
if (body.byteLength !== declared) {
|
|
687
|
+
return next(
|
|
688
|
+
bodyError("request size did not match content length", 400, "request.size.invalid", {
|
|
689
|
+
expected: declared,
|
|
690
|
+
length: declared,
|
|
691
|
+
received: body.byteLength
|
|
692
|
+
})
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
let buf = Buffer.from(body);
|
|
696
|
+
if (copyBody) {
|
|
697
|
+
buf = Buffer.from(buf);
|
|
698
|
+
}
|
|
699
|
+
if (!runVerify(req, res, next, options, buf)) {
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
beforeReturn(req, res, next, options, buf, encoding);
|
|
703
|
+
});
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
|
|
645
707
|
// uWS neuters its ArrayBuffer after the callback, so every chunk has to be copied out of
|
|
646
708
|
// it - and then Buffer.concat copied the whole body a second time. when content-length is
|
|
647
709
|
// known and we aren't inflating, the final size is known up front, so chunks can go
|
|
@@ -860,10 +922,17 @@ const json = createBodyParser(
|
|
|
860
922
|
"utf"
|
|
861
923
|
);
|
|
862
924
|
|
|
863
|
-
const raw = createBodyParser(
|
|
864
|
-
|
|
865
|
-
next
|
|
866
|
-
|
|
925
|
+
const raw = createBodyParser(
|
|
926
|
+
"application/octet-stream",
|
|
927
|
+
function (req, res, next, options, buf) {
|
|
928
|
+
req.body = buf;
|
|
929
|
+
next();
|
|
930
|
+
},
|
|
931
|
+
undefined,
|
|
932
|
+
undefined,
|
|
933
|
+
// req.body is the collected buffer itself, so it must not be a view over uWS memory
|
|
934
|
+
true
|
|
935
|
+
);
|
|
867
936
|
|
|
868
937
|
const text = createBodyParser(
|
|
869
938
|
"text/plain",
|
|
@@ -954,7 +1023,8 @@ const urlencoded = createBodyParser(
|
|
|
954
1023
|
})
|
|
955
1024
|
);
|
|
956
1025
|
} else {
|
|
957
|
-
|
|
1026
|
+
// the vendored parser, so an urlencoded body inspects like req.query does
|
|
1027
|
+
req.body = parseQuery(body);
|
|
958
1028
|
}
|
|
959
1029
|
} catch (e) {
|
|
960
1030
|
// qs reports a depth overflow as a RangeError with its own wording; body-parser
|
package/src/node-shim.js
CHANGED
|
@@ -395,6 +395,8 @@ function serveNodeRequest(router, nodeReq, nodeRes, next) {
|
|
|
395
395
|
const shimReq = new NodeHttpRequest(nodeReq);
|
|
396
396
|
const request = router.handleRequest(shimRes, shimReq);
|
|
397
397
|
const response = request.res;
|
|
398
|
+
// the shim's onAborted rides node's own close event, needed on every request here
|
|
399
|
+
router._armAbort(shimRes, response);
|
|
398
400
|
|
|
399
401
|
return router._routeRequest(request, response).then((matched) => {
|
|
400
402
|
if (matched || response.headersSent || response.aborted) {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
The parser from fast-querystring 1.1.x (MIT, Copyright (c) Yagiz Nizipli,
|
|
5
|
+
https://github.com/anonrig/fast-querystring), vendored for one change: the result is a bare
|
|
6
|
+
Object.create(null) instead of the library's Empty-constructor trick. The trick is faster to
|
|
7
|
+
construct but node inspects it as "Empty <[Object: null prototype] {}>", where Express shows
|
|
8
|
+
"[Object: null prototype]", and matching that used to cost an Object.assign copy of every parse
|
|
9
|
+
on every query-carrying request. The copy was worth more than the trick.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fastDecode = require("fast-decode-uri-component");
|
|
13
|
+
|
|
14
|
+
const plusRegex = /\+/g;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* node's querystring.parse semantics on a null-prototype result: repeated keys accumulate into
|
|
18
|
+
* arrays, '+' is a space, percent sequences decode when present and stay literal when broken.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} input
|
|
21
|
+
* @returns {Record<string, string | string[]>}
|
|
22
|
+
*/
|
|
23
|
+
function parseQuery(input) {
|
|
24
|
+
const result = Object.create(null);
|
|
25
|
+
|
|
26
|
+
if (typeof input !== "string") {
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const inputLength = input.length;
|
|
31
|
+
let key;
|
|
32
|
+
let value = "";
|
|
33
|
+
let startingIndex = -1;
|
|
34
|
+
let equalityIndex = -1;
|
|
35
|
+
let shouldDecodeKey = false;
|
|
36
|
+
let shouldDecodeValue = false;
|
|
37
|
+
let keyHasPlus = false;
|
|
38
|
+
let valueHasPlus = false;
|
|
39
|
+
let hasBothKeyValuePair;
|
|
40
|
+
let c;
|
|
41
|
+
|
|
42
|
+
// a boundary of input.length + 1, so the last pair is handled inside the loop
|
|
43
|
+
for (let i = 0; i < inputLength + 1; i++) {
|
|
44
|
+
c = i !== inputLength ? input.charCodeAt(i) : 38;
|
|
45
|
+
|
|
46
|
+
// '&' or the end of the input closes the current pair
|
|
47
|
+
if (c === 38) {
|
|
48
|
+
hasBothKeyValuePair = equalityIndex > startingIndex;
|
|
49
|
+
|
|
50
|
+
// the equality index doubles as the end of the key when there was no '='
|
|
51
|
+
if (!hasBothKeyValuePair) {
|
|
52
|
+
equalityIndex = i;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
key = input.slice(startingIndex + 1, equalityIndex);
|
|
56
|
+
|
|
57
|
+
// only a pair with at least an '=' or a non-empty key lands in the result
|
|
58
|
+
if (hasBothKeyValuePair || key.length > 0) {
|
|
59
|
+
if (keyHasPlus) {
|
|
60
|
+
key = key.replace(plusRegex, " ");
|
|
61
|
+
}
|
|
62
|
+
if (shouldDecodeKey) {
|
|
63
|
+
key = fastDecode(key) || key;
|
|
64
|
+
}
|
|
65
|
+
if (hasBothKeyValuePair) {
|
|
66
|
+
value = input.slice(equalityIndex + 1, i);
|
|
67
|
+
if (valueHasPlus) {
|
|
68
|
+
value = value.replace(plusRegex, " ");
|
|
69
|
+
}
|
|
70
|
+
if (shouldDecodeValue) {
|
|
71
|
+
value = fastDecode(value) || value;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const currentValue = result[key];
|
|
76
|
+
if (currentValue === undefined) {
|
|
77
|
+
result[key] = value;
|
|
78
|
+
} else {
|
|
79
|
+
// value.pop is cheaper than Array.isArray here, as upstream measured
|
|
80
|
+
if (currentValue.pop) {
|
|
81
|
+
currentValue.push(value);
|
|
82
|
+
} else {
|
|
83
|
+
result[key] = [currentValue, value];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
value = "";
|
|
89
|
+
startingIndex = i;
|
|
90
|
+
equalityIndex = i;
|
|
91
|
+
shouldDecodeKey = false;
|
|
92
|
+
shouldDecodeValue = false;
|
|
93
|
+
keyHasPlus = false;
|
|
94
|
+
valueHasPlus = false;
|
|
95
|
+
} else if (c === 61) {
|
|
96
|
+
if (equalityIndex <= startingIndex) {
|
|
97
|
+
equalityIndex = i;
|
|
98
|
+
} else {
|
|
99
|
+
// a second '=' belongs to the value and needs decoding
|
|
100
|
+
shouldDecodeValue = true;
|
|
101
|
+
}
|
|
102
|
+
} else if (c === 43) {
|
|
103
|
+
if (equalityIndex > startingIndex) {
|
|
104
|
+
valueHasPlus = true;
|
|
105
|
+
} else {
|
|
106
|
+
keyHasPlus = true;
|
|
107
|
+
}
|
|
108
|
+
} else if (c === 37) {
|
|
109
|
+
if (equalityIndex > startingIndex) {
|
|
110
|
+
shouldDecodeValue = true;
|
|
111
|
+
} else {
|
|
112
|
+
shouldDecodeKey = true;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = parseQuery;
|
package/src/request.js
CHANGED
|
@@ -22,6 +22,7 @@ const parseRange = require("range-parser");
|
|
|
22
22
|
const proxyaddr = require("proxy-addr");
|
|
23
23
|
const { isIP } = require("node:net");
|
|
24
24
|
const fresh = require("fresh");
|
|
25
|
+
const parseQuery = require("./parse-query.js");
|
|
25
26
|
const { Readable } = require("stream");
|
|
26
27
|
|
|
27
28
|
// accepts, type-is, proxy-addr and fresh all declare a node IncomingMessage and read nothing off
|
|
@@ -323,6 +324,28 @@ module.exports = class Request extends Readable {
|
|
|
323
324
|
});
|
|
324
325
|
}
|
|
325
326
|
|
|
327
|
+
/**
|
|
328
|
+
* One header by its lowercase wire name, straight from the raw entries. The body parsers ask
|
|
329
|
+
* for three of these per request, and materializing the whole headers object for that costs
|
|
330
|
+
* more than all three scans together. Reads the built object instead when it already exists,
|
|
331
|
+
* so joined duplicates come out the same either way.
|
|
332
|
+
*
|
|
333
|
+
* @param {string} name lowercase
|
|
334
|
+
* @returns {string|undefined}
|
|
335
|
+
*/
|
|
336
|
+
_rawHeader(name) {
|
|
337
|
+
if (this.#cachedHeaders !== null) {
|
|
338
|
+
return this.#cachedHeaders[name];
|
|
339
|
+
}
|
|
340
|
+
const entries = this.#rawHeadersEntries;
|
|
341
|
+
for (let i = 0, len = entries.length; i < len; i += 2) {
|
|
342
|
+
if (entries[i] === name) {
|
|
343
|
+
return entries[i + 1];
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
|
|
326
349
|
/**
|
|
327
350
|
* Whether there is any point still reading the body: once the response is finished or the
|
|
328
351
|
* connection is gone, uWS has nothing left to hand over.
|
|
@@ -540,10 +563,14 @@ module.exports = class Request extends Readable {
|
|
|
540
563
|
return this.#cachedQuery;
|
|
541
564
|
}
|
|
542
565
|
const qp = this.app.get("query parser fn");
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
const parsed = qp
|
|
566
|
+
// the vendored default already answers on a bare null prototype, so it goes out as is;
|
|
567
|
+
// any other parser is copied onto one, which is what kept fast-querystring's result from
|
|
568
|
+
// inspecting as "Empty <[Object: null prototype] {}>" where Express shows the bare form
|
|
569
|
+
const parsed = qp
|
|
570
|
+
? qp === parseQuery
|
|
571
|
+
? parseQuery(this._rawQuery)
|
|
572
|
+
: Object.assign(Object.create(null), qp(this._rawQuery))
|
|
573
|
+
: Object.create(null);
|
|
547
574
|
this.#cachedQuery = parsed;
|
|
548
575
|
return parsed;
|
|
549
576
|
}
|
package/src/router.js
CHANGED
|
@@ -1345,11 +1345,22 @@ module.exports = class Router extends EventEmitter {
|
|
|
1345
1345
|
const response = new this._response(res, request, this);
|
|
1346
1346
|
request.res = response;
|
|
1347
1347
|
response.req = request;
|
|
1348
|
-
res.onAborted(onNativeAborted.bind(response));
|
|
1349
1348
|
|
|
1350
1349
|
return request;
|
|
1351
1350
|
}
|
|
1352
1351
|
|
|
1352
|
+
/**
|
|
1353
|
+
* Tells uWS whom to call on a client abort. Out of handleRequest, because uWS only needs it
|
|
1354
|
+
* for a response that outlives its handler callback: the native handler arms it in its
|
|
1355
|
+
* finally when the answer is still pending, which on a synchronous route it never is.
|
|
1356
|
+
*
|
|
1357
|
+
* @param {any} res uWS response
|
|
1358
|
+
* @param {any} response
|
|
1359
|
+
*/
|
|
1360
|
+
_armAbort(res, response) {
|
|
1361
|
+
res.onAborted(onNativeAborted.bind(response));
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1353
1364
|
/**
|
|
1354
1365
|
* Whether a route registered later in the same router could match a path this one matches.
|
|
1355
1366
|
*
|
|
@@ -1437,6 +1448,11 @@ module.exports = class Router extends EventEmitter {
|
|
|
1437
1448
|
// whatever runs after this line is outside the cork uWS held for this
|
|
1438
1449
|
// callback, so later writes have to open their own
|
|
1439
1450
|
response._corkNeeded = true;
|
|
1451
|
+
// an abort can only arrive after this callback returns, so a response that
|
|
1452
|
+
// already finished inside it never needs uWS told at all
|
|
1453
|
+
if (!response.finished) {
|
|
1454
|
+
this._armAbort(res, response);
|
|
1455
|
+
}
|
|
1440
1456
|
}
|
|
1441
1457
|
};
|
|
1442
1458
|
};
|
package/src/utils.js
CHANGED
|
@@ -19,7 +19,7 @@ const mime = require("mime-types");
|
|
|
19
19
|
const path = require("path");
|
|
20
20
|
const proxyaddr = require("proxy-addr");
|
|
21
21
|
const qs = require("qs");
|
|
22
|
-
const
|
|
22
|
+
const parseQuery = require("./parse-query.js");
|
|
23
23
|
const crypto = require("crypto");
|
|
24
24
|
const statuses = require("statuses");
|
|
25
25
|
const { Stats } = require("fs");
|
|
@@ -46,7 +46,8 @@ function fastQueryParse(query, options) {
|
|
|
46
46
|
}
|
|
47
47
|
if (len <= 128) {
|
|
48
48
|
if (!query.includes("[") && !query.includes("%5B") && !query.includes(".") && !query.includes("%2E")) {
|
|
49
|
-
|
|
49
|
+
// already on a bare null prototype, no copy needed, see parse-query.js
|
|
50
|
+
return parseQuery(query);
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
53
|
return Object.assign(Object.create(null), qs.parse(query, options));
|
|
@@ -564,7 +565,7 @@ const defaultSettings = {
|
|
|
564
565
|
etag: "weak",
|
|
565
566
|
"etag fn": () => createETagGenerator({ weak: true }),
|
|
566
567
|
"query parser": "simple",
|
|
567
|
-
"query parser fn": () =>
|
|
568
|
+
"query parser fn": () => parseQuery,
|
|
568
569
|
"subdomain offset": 2,
|
|
569
570
|
"trust proxy": false,
|
|
570
571
|
views: () => path.join(process.cwd(), "views"),
|