fulmine.js 5.1.6 → 5.1.8
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 +6 -6
- package/package.json +1 -1
- package/src/application.js +16 -4
- package/src/middlewares.js +10 -1
- package/src/request.js +59 -7
- package/src/router.js +108 -10
- package/src/usage.js +286 -0
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ to run it yourself.
|
|
|
55
55
|
|
|
56
56
|
Numbers produced by a project about itself deserve suspicion, so Fulmine also stands in public arenas, run by their own rigs under their own rules:
|
|
57
57
|
|
|
58
|
-
- **[HttpArena](https://www.http-arena.com/#sort=rps:-1&q=Js)** (the link lands filtered on the JavaScript entries): the saved run measures
|
|
58
|
+
- **[HttpArena](https://www.http-arena.com/#sort=rps:-1&q=Js)** (the link lands filtered on the JavaScript entries): first among the JavaScript entries and second overall across every language on the board. The saved run measures 7.64 million pipelined requests per second, 1.12 million on the json profile, 457 thousand on compressed json and 222 thousand on the Postgres profile.
|
|
59
59
|
- **[web-frameworks](https://github.com/the-benchmarker/web-frameworks)**: entry merged, numbers arrive with their next published round.
|
|
60
60
|
|
|
61
61
|
More to come as their maintainers take the entries in.
|
|
@@ -94,18 +94,18 @@ command whose name ends in `.js` on Windows, where it exits without a word.
|
|
|
94
94
|
|
|
95
95
|
Two things about µWebSockets.js make a Dockerfile that works for Express fail here, and both have easy answers:
|
|
96
96
|
|
|
97
|
-
- **No Alpine.** µWebSockets.js ships prebuilt binaries linked against glibc. Alpine images use musl, so the binary does not load
|
|
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.
|
|
97
|
+
- **No Alpine, and no Debian bookworm either.** µWebSockets.js ships prebuilt binaries linked against glibc 2.38 or newer. Alpine images use musl, so the binary does not load at all; `node:22` and `node:22-slim` are Debian bookworm, whose glibc 2.36 fails at startup with `GLIBC_2.38' not found`. Use the trixie variants: `node:22-trixie-slim` and up.
|
|
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-trixie` have git; `-slim` ones do not.
|
|
99
99
|
|
|
100
100
|
The clean way to satisfy both is a multi-stage build: install with the full image, run with the slim one.
|
|
101
101
|
|
|
102
102
|
```dockerfile
|
|
103
|
-
FROM node:22 AS build
|
|
103
|
+
FROM node:22-trixie AS build
|
|
104
104
|
WORKDIR /app
|
|
105
105
|
COPY package*.json ./
|
|
106
106
|
RUN npm ci --omit=dev
|
|
107
107
|
|
|
108
|
-
FROM node:22-slim
|
|
108
|
+
FROM node:22-trixie-slim
|
|
109
109
|
WORKDIR /app
|
|
110
110
|
COPY --from=build /app/node_modules ./node_modules
|
|
111
111
|
COPY . .
|
|
@@ -113,7 +113,7 @@ EXPOSE 3000
|
|
|
113
113
|
CMD ["node", "server.js"]
|
|
114
114
|
```
|
|
115
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.
|
|
116
|
+
A single-stage `node:22-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.
|
|
117
117
|
|
|
118
118
|
## Differences from Express
|
|
119
119
|
|
package/package.json
CHANGED
package/src/application.js
CHANGED
|
@@ -128,9 +128,10 @@ class Application extends Router {
|
|
|
128
128
|
* @param {any} res
|
|
129
129
|
* @param {any} app
|
|
130
130
|
* @param {any} [preset]
|
|
131
|
+
* @param {any} [skipHolder]
|
|
131
132
|
*/
|
|
132
|
-
constructor(req, res, app, preset) {
|
|
133
|
-
super(req, res, app, preset);
|
|
133
|
+
constructor(req, res, app, preset, skipHolder) {
|
|
134
|
+
super(req, res, app, preset, skipHolder);
|
|
134
135
|
}
|
|
135
136
|
};
|
|
136
137
|
this._response = class extends Response {
|
|
@@ -359,6 +360,15 @@ class Application extends Router {
|
|
|
359
360
|
this.settings[key] = Array.isArray(value) ? value.map((dir) => path.resolve(dir)) : path.resolve(value);
|
|
360
361
|
return this;
|
|
361
362
|
} else if (key === "etag") {
|
|
363
|
+
// an etag arriving after listen would make send consult freshness headers the
|
|
364
|
+
// header-skip routes never copied, so those skips are taken back
|
|
365
|
+
if (value !== false && this._skipPresets?.size) {
|
|
366
|
+
for (const preset of this._skipPresets) {
|
|
367
|
+
preset.skipHeaders = false;
|
|
368
|
+
preset.skipQuery = false;
|
|
369
|
+
}
|
|
370
|
+
this._skipPresets.clear();
|
|
371
|
+
}
|
|
362
372
|
if (typeof value === "function") {
|
|
363
373
|
this.settings["etag fn"] = value;
|
|
364
374
|
} else {
|
|
@@ -431,10 +441,12 @@ class Application extends Router {
|
|
|
431
441
|
* @param {any} res uWS response
|
|
432
442
|
* @param {any} req uWS request, readable only during this call
|
|
433
443
|
* @param {any} [preset] a literal registration's constants, see nativePreset in the router
|
|
444
|
+
* @param {any} [skipHolder] where a granted header skip lives, forwarded whole: dropping
|
|
445
|
+
* it here silently turned every skip off, since the native closures call this override
|
|
434
446
|
* @returns {any} the request, with the response reachable as request.res
|
|
435
447
|
*/
|
|
436
|
-
handleRequest(res, req, preset) {
|
|
437
|
-
const request = super.handleRequest(res, req, preset);
|
|
448
|
+
handleRequest(res, req, preset, skipHolder) {
|
|
449
|
+
const request = super.handleRequest(res, req, preset, skipHolder);
|
|
438
450
|
// removal rides the close listener the Response constructor already has, since a second
|
|
439
451
|
// once() per request measured a tenth of a microsecond on the hot path.
|
|
440
452
|
// An aborted response only flips its flags without emitting 'close', which is why
|
package/src/middlewares.js
CHANGED
|
@@ -22,6 +22,7 @@ const zlib = require("fast-zlib");
|
|
|
22
22
|
const typeis = require("type-is");
|
|
23
23
|
const qs = require("qs");
|
|
24
24
|
const parseQuery = require("./parse-query.js");
|
|
25
|
+
const { kGetSafe } = require("./usage.js");
|
|
25
26
|
const { AsyncResource } = require("async_hooks");
|
|
26
27
|
const { fastQueryParse, NullObject, asStatError, httpError, memoizeByString } = require("./utils.js");
|
|
27
28
|
|
|
@@ -526,7 +527,7 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
526
527
|
|
|
527
528
|
let additionalMethods;
|
|
528
529
|
|
|
529
|
-
|
|
530
|
+
const parserMiddleware = (req, res, next) => {
|
|
530
531
|
// Not bound yet: every return in this prologue is synchronous, so the caller's async
|
|
531
532
|
// context is still intact and an AsyncResource here would be 1.4 microseconds of
|
|
532
533
|
// nothing. The bind happens below, only once a real read is about to go async.
|
|
@@ -875,6 +876,14 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
|
|
|
875
876
|
req.on("end", onEnd);
|
|
876
877
|
}
|
|
877
878
|
};
|
|
879
|
+
// A GET without a declared body leaves this middleware through the synchronous
|
|
880
|
+
// no-body exit before anything type- or charset-shaped is read, and a GET that does
|
|
881
|
+
// declare one takes the full header copy in the constructor, so the header-skip
|
|
882
|
+
// analysis may trust it. A type function sees the request itself, so it may not.
|
|
883
|
+
if (typeof options.type !== "function") {
|
|
884
|
+
parserMiddleware[kGetSafe] = true;
|
|
885
|
+
}
|
|
886
|
+
return parserMiddleware;
|
|
878
887
|
};
|
|
879
888
|
}
|
|
880
889
|
|
package/src/request.js
CHANGED
|
@@ -200,16 +200,61 @@ module.exports = class Request extends Readable {
|
|
|
200
200
|
* @param {any} [preset] a literal native registration's constants: µWS matched the URL byte
|
|
201
201
|
* for byte against that exact pattern and dispatched by method, so path, method and what
|
|
202
202
|
* derives from them are known without asking
|
|
203
|
+
* @param {any} [skipHolder] where a granted header skip lives: the preset itself for a
|
|
204
|
+
* literal registration, a holder of its own for a parameterised one
|
|
203
205
|
*/
|
|
204
|
-
constructor(req, res, app, preset) {
|
|
206
|
+
constructor(req, res, app, preset, skipHolder) {
|
|
205
207
|
// the same object every time: Readable reads these options and never writes to them
|
|
206
208
|
super(READABLE_OPTIONS);
|
|
207
209
|
this._res = res;
|
|
208
210
|
this._req = req;
|
|
209
211
|
this.readable = true;
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
212
|
+
if (skipHolder !== undefined && skipHolder.skipHeaders) {
|
|
213
|
+
// The chain behind this registration provably never reads a header, so instead of
|
|
214
|
+
// copying them all out of uWS the constructor asks for the four that steer the
|
|
215
|
+
// framework itself: body framing, keep-alive, and accept for the error page a
|
|
216
|
+
// throw could still need. A GET that does declare a body is the rare case, and
|
|
217
|
+
// the parsers and the stream want the whole picture, so it takes the full copy.
|
|
218
|
+
const length = req.getHeader("content-length");
|
|
219
|
+
const transferEncoding = req.getHeader("transfer-encoding");
|
|
220
|
+
if ((length !== "" && length !== "0") || transferEncoding !== "") {
|
|
221
|
+
currentRequest = this;
|
|
222
|
+
this._req.forEach(Request.#collectHeader);
|
|
223
|
+
currentRequest = null;
|
|
224
|
+
} else {
|
|
225
|
+
const entries = this.#rawHeadersEntries;
|
|
226
|
+
const connection = req.getHeader("connection");
|
|
227
|
+
if (connection !== "") {
|
|
228
|
+
entries.push("connection", connection);
|
|
229
|
+
if (connection.length === 5 && connection.toLowerCase() === "close") {
|
|
230
|
+
this._connectionClose = true;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const accept = req.getHeader("accept");
|
|
234
|
+
if (accept !== "") {
|
|
235
|
+
entries.push("accept", accept);
|
|
236
|
+
}
|
|
237
|
+
// send consults freshness whatever the etag setting: if-none-match can be "*"
|
|
238
|
+
// and a handler may set a validator by hand, so the conditional trio has to be
|
|
239
|
+
// really absent rather than merely uncopied
|
|
240
|
+
const ifNoneMatch = req.getHeader("if-none-match");
|
|
241
|
+
if (ifNoneMatch !== "") {
|
|
242
|
+
entries.push("if-none-match", ifNoneMatch);
|
|
243
|
+
}
|
|
244
|
+
const ifModifiedSince = req.getHeader("if-modified-since");
|
|
245
|
+
if (ifModifiedSince !== "") {
|
|
246
|
+
entries.push("if-modified-since", ifModifiedSince);
|
|
247
|
+
}
|
|
248
|
+
const cacheControl = req.getHeader("cache-control");
|
|
249
|
+
if (cacheControl !== "") {
|
|
250
|
+
entries.push("cache-control", cacheControl);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
currentRequest = this;
|
|
255
|
+
this._req.forEach(Request.#collectHeader);
|
|
256
|
+
currentRequest = null;
|
|
257
|
+
}
|
|
213
258
|
this.routeCount = 1;
|
|
214
259
|
this.key = key++;
|
|
215
260
|
if (key > 100000) {
|
|
@@ -218,9 +263,16 @@ module.exports = class Request extends Readable {
|
|
|
218
263
|
this.app = app;
|
|
219
264
|
// both forms are kept, because both are asked for: the query with its "?" goes into
|
|
220
265
|
// req.url, and req.query parses the raw one. Keeping only the first meant slicing the "?"
|
|
221
|
-
// back off for every request that reads req.query.
|
|
222
|
-
|
|
223
|
-
|
|
266
|
+
// back off for every request that reads req.query. When the chain provably reads
|
|
267
|
+
// neither, the native call is not made at all: the framework's own answers, the 404
|
|
268
|
+
// included, are written from the path alone
|
|
269
|
+
if (skipHolder !== undefined && skipHolder.skipQuery) {
|
|
270
|
+
this._rawQuery = "";
|
|
271
|
+
this.urlQuery = "";
|
|
272
|
+
} else {
|
|
273
|
+
this._rawQuery = req.getQuery() ?? "";
|
|
274
|
+
this.urlQuery = this._rawQuery === "" ? "" : "?" + this._rawQuery;
|
|
275
|
+
}
|
|
224
276
|
if (preset) {
|
|
225
277
|
// the registration's constants: two native crossings and their strings not asked for
|
|
226
278
|
this.path = preset.path;
|
package/src/router.js
CHANGED
|
@@ -36,6 +36,7 @@ const compileDeclarative = require("./declarative.js");
|
|
|
36
36
|
const statuses = require("statuses");
|
|
37
37
|
const { METHODS } = require("http");
|
|
38
38
|
const { isNodeRequest, serveNodeRequest } = require("./node-shim.js");
|
|
39
|
+
const { chainUsage } = require("./usage.js");
|
|
39
40
|
|
|
40
41
|
// every method the declarative compiler can emit: a patched one must disable compilation, or the
|
|
41
42
|
// patch would be honoured everywhere but on compiled routes
|
|
@@ -691,10 +692,39 @@ function nativePreset(path, method, strict) {
|
|
|
691
692
|
endsWithSlash,
|
|
692
693
|
opPath: endsWithSlash && path !== "/" && !strict ? path.slice(0, -1) : path,
|
|
693
694
|
isOptions: method === "OPTIONS",
|
|
694
|
-
isHead: method === "HEAD"
|
|
695
|
+
isHead: method === "HEAD",
|
|
696
|
+
// set at registration when the whole chain provably never reads a header, or never
|
|
697
|
+
// reads the query; mutable, because a middleware added after listen takes them back
|
|
698
|
+
skipHeaders: false,
|
|
699
|
+
skipQuery: false
|
|
695
700
|
};
|
|
696
701
|
}
|
|
697
702
|
|
|
703
|
+
/**
|
|
704
|
+
* Whether any error middleware exists anywhere under this router, mounted routers and sub-apps
|
|
705
|
+
* included. The header-skip analysis needs the answer to be no: a throw inside an analyzed
|
|
706
|
+
* handler would hand the request to code nobody analyzed.
|
|
707
|
+
*
|
|
708
|
+
* @param {any} router
|
|
709
|
+
* @returns {boolean}
|
|
710
|
+
*/
|
|
711
|
+
function hasErrorMiddleware(router) {
|
|
712
|
+
for (const route of router._routes) {
|
|
713
|
+
for (const callback of route.callbacks) {
|
|
714
|
+
// a mounted router or a callable sub-app carries routes of its own; the callable
|
|
715
|
+
// app is also a function, so the routes are looked for first
|
|
716
|
+
if (callback && callback._routes) {
|
|
717
|
+
if (hasErrorMiddleware(callback)) {
|
|
718
|
+
return true;
|
|
719
|
+
}
|
|
720
|
+
} else if (typeof callback === "function" && callback.length >= 4) {
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
727
|
+
|
|
698
728
|
/**
|
|
699
729
|
*
|
|
700
730
|
*/
|
|
@@ -822,6 +852,12 @@ module.exports = class Router extends EventEmitter {
|
|
|
822
852
|
this._paramCallbacks = new Map();
|
|
823
853
|
this._mountpathCache = new Map();
|
|
824
854
|
this._routes = [];
|
|
855
|
+
// the native presets allowed to skip the header copy, so a late middleware or an etag
|
|
856
|
+
// arriving after listen can take the permission back; null until one is granted
|
|
857
|
+
/** @type {Set<any>|null} */
|
|
858
|
+
this._skipPresets = null;
|
|
859
|
+
/** @type {boolean|undefined} */
|
|
860
|
+
this._hasErrMwCache = undefined;
|
|
825
861
|
// an array when mounted on several paths at once, as Express allows
|
|
826
862
|
/** @type {string|string[]} */
|
|
827
863
|
this.mountpath = "/";
|
|
@@ -1107,6 +1143,17 @@ module.exports = class Router extends EventEmitter {
|
|
|
1107
1143
|
}
|
|
1108
1144
|
this._routes.push(...routes);
|
|
1109
1145
|
|
|
1146
|
+
// anything registered after listen invalidates what the header-skip analysis proved:
|
|
1147
|
+
// it could catch a throw or read what a chain never did, so every skip is taken back
|
|
1148
|
+
this._hasErrMwCache = undefined;
|
|
1149
|
+
if (this._skipPresets?.size) {
|
|
1150
|
+
for (const preset of this._skipPresets) {
|
|
1151
|
+
preset.skipHeaders = false;
|
|
1152
|
+
preset.skipQuery = false;
|
|
1153
|
+
}
|
|
1154
|
+
this._skipPresets.clear();
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1110
1157
|
return parent;
|
|
1111
1158
|
}
|
|
1112
1159
|
|
|
@@ -1338,10 +1385,12 @@ module.exports = class Router extends EventEmitter {
|
|
|
1338
1385
|
* @param {any} res uWS response
|
|
1339
1386
|
* @param {any} req uWS request, readable only during this call
|
|
1340
1387
|
* @param {any} [preset] a literal registration's constants, see nativePreset
|
|
1388
|
+
* @param {any} [skipHolder] the object a granted header skip lives on: the preset itself
|
|
1389
|
+
* for a literal registration, a holder of its own for a parameterised one
|
|
1341
1390
|
* @returns {any} the request, with the response reachable as request.res
|
|
1342
1391
|
*/
|
|
1343
|
-
handleRequest(res, req, preset) {
|
|
1344
|
-
const request = new this._request(req, res, this, preset);
|
|
1392
|
+
handleRequest(res, req, preset, skipHolder) {
|
|
1393
|
+
const request = new this._request(req, res, this, preset, skipHolder);
|
|
1345
1394
|
const response = new this._response(res, request, this);
|
|
1346
1395
|
request.res = response;
|
|
1347
1396
|
response.req = request;
|
|
@@ -1417,7 +1466,15 @@ module.exports = class Router extends EventEmitter {
|
|
|
1417
1466
|
if (route.path.includes(":")) {
|
|
1418
1467
|
route.optimizedParams = route.path.match(regExParam).map((p) => p.slice(1));
|
|
1419
1468
|
}
|
|
1420
|
-
const makeHandler = (chain, preset) => {
|
|
1469
|
+
const makeHandler = (chain, preset, skips) => {
|
|
1470
|
+
// the mutable object a granted skip lives on, so a middleware arriving after
|
|
1471
|
+
// listen can take it back: a literal registration's preset doubles as it, and a
|
|
1472
|
+
// parameterised one, which has no preset, gets a holder of its own
|
|
1473
|
+
let skipHolder = preset;
|
|
1474
|
+
if (skipHolder === undefined && (skips.skipHeaders || skips.skipQuery)) {
|
|
1475
|
+
skipHolder = { skipHeaders: skips.skipHeaders, skipQuery: skips.skipQuery };
|
|
1476
|
+
(this._skipPresets ??= new Set()).add(skipHolder);
|
|
1477
|
+
}
|
|
1421
1478
|
// all three are registration-time constants: computing them in the handler was a
|
|
1422
1479
|
// closure and a scan of the chain on every native request.
|
|
1423
1480
|
// Falling back resumes after the mount, not after the router's leaf: the leaf can have
|
|
@@ -1430,7 +1487,7 @@ module.exports = class Router extends EventEmitter {
|
|
|
1430
1487
|
// and this one never did. nativeDone and nativeFail defer their epilogues to a
|
|
1431
1488
|
// microtask, which is where the await used to resume, so the visible order holds
|
|
1432
1489
|
return (res, req) => {
|
|
1433
|
-
const request = this.handleRequest(res, req, preset);
|
|
1490
|
+
const request = this.handleRequest(res, req, preset, skipHolder);
|
|
1434
1491
|
const response = request.res;
|
|
1435
1492
|
if (optimizedParams) {
|
|
1436
1493
|
request.optimizedParams = new NullObject();
|
|
@@ -1461,6 +1518,7 @@ module.exports = class Router extends EventEmitter {
|
|
|
1461
1518
|
const getChain =
|
|
1462
1519
|
route.method === "GET" ? optimizedPath.filter((r) => r.all || r.method !== "HEAD") : optimizedPath;
|
|
1463
1520
|
route.optimizedPath = optimizedPath;
|
|
1521
|
+
const headChain = getChain.length === optimizedPath.length ? getChain : optimizedPath;
|
|
1464
1522
|
|
|
1465
1523
|
// A fully literal registration knows path and method here, so each registration site
|
|
1466
1524
|
// hands the request constructor its own constants. An "any" registration serves every
|
|
@@ -1471,11 +1529,47 @@ module.exports = class Router extends EventEmitter {
|
|
|
1471
1529
|
// registering that path here is the only way it could
|
|
1472
1530
|
const strictHere = Boolean((route.owner ?? this).get("strict routing"));
|
|
1473
1531
|
|
|
1474
|
-
|
|
1532
|
+
// Whether requests served by this registration may skip the header copy: GET and its
|
|
1533
|
+
// HEAD twins only, the app must not compute etags (send would consult freshness
|
|
1534
|
+
// headers), no error middleware may exist anywhere (a throw hands the request to code
|
|
1535
|
+
// the analysis never saw), and every callback in the chain has to pass the source
|
|
1536
|
+
// analysis in usage.js, whose default answer is no.
|
|
1537
|
+
const NO_SKIPS = { skipHeaders: false, skipQuery: false };
|
|
1538
|
+
let getSkips = NO_SKIPS;
|
|
1539
|
+
let headSkips = NO_SKIPS;
|
|
1540
|
+
if (route.method === "GET" && this.get("etag") === false) {
|
|
1541
|
+
let hasErr = this._hasErrMwCache;
|
|
1542
|
+
if (hasErr === undefined) {
|
|
1543
|
+
hasErr = this._hasErrMwCache = hasErrorMiddleware(this);
|
|
1544
|
+
}
|
|
1545
|
+
if (!hasErr) {
|
|
1546
|
+
// a terminal next() may only fall into the framework's own 404, so no later
|
|
1547
|
+
// route may be able to catch the same path
|
|
1548
|
+
const owner = route.owner ?? this;
|
|
1549
|
+
const noLaterMatch = !owner._isFollowedByAnOverlap.call(owner, route, owner._routes);
|
|
1550
|
+
getSkips = chainUsage(getChain, noLaterMatch);
|
|
1551
|
+
headSkips = headChain === getChain ? getSkips : chainUsage(headChain, noLaterMatch);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
// remembered so a middleware or setting arriving after listen can take the skips back
|
|
1555
|
+
const makePreset = (path, method, skips) => {
|
|
1556
|
+
const preset = nativePreset(path, method, strictHere);
|
|
1557
|
+
if (skips.skipHeaders || skips.skipQuery) {
|
|
1558
|
+
preset.skipHeaders = skips.skipHeaders;
|
|
1559
|
+
preset.skipQuery = skips.skipQuery;
|
|
1560
|
+
(this._skipPresets ??= new Set()).add(preset);
|
|
1561
|
+
}
|
|
1562
|
+
return preset;
|
|
1563
|
+
};
|
|
1564
|
+
|
|
1565
|
+
let fn = makeHandler(
|
|
1566
|
+
getChain,
|
|
1567
|
+
canPreset ? makePreset(route.path, route.method, getSkips) : undefined,
|
|
1568
|
+
getSkips
|
|
1569
|
+
);
|
|
1475
1570
|
const jsFn = fn;
|
|
1476
1571
|
|
|
1477
1572
|
let replacedPath = route.path;
|
|
1478
|
-
const headChain = getChain.length === optimizedPath.length ? getChain : optimizedPath;
|
|
1479
1573
|
|
|
1480
1574
|
// the response prototype the route will really run under: its own app's, which sees a
|
|
1481
1575
|
// method patched there or inherited from a parent app, falling back to the registering app
|
|
@@ -1505,13 +1599,17 @@ module.exports = class Router extends EventEmitter {
|
|
|
1505
1599
|
fn !== jsFn
|
|
1506
1600
|
? fn
|
|
1507
1601
|
: canPreset
|
|
1508
|
-
? makeHandler(getChain,
|
|
1602
|
+
? makeHandler(getChain, makePreset(route.path + "/", route.method, getSkips), getSkips)
|
|
1509
1603
|
: fn;
|
|
1510
1604
|
this.uwsApp[method](replacedPath + "/", slashFn);
|
|
1511
1605
|
if (method === "get") {
|
|
1512
1606
|
this.uwsApp.head(
|
|
1513
1607
|
replacedPath + "/",
|
|
1514
|
-
makeHandler(
|
|
1608
|
+
makeHandler(
|
|
1609
|
+
headChain,
|
|
1610
|
+
canPreset ? makePreset(route.path + "/", "HEAD", headSkips) : undefined,
|
|
1611
|
+
headSkips
|
|
1612
|
+
)
|
|
1515
1613
|
);
|
|
1516
1614
|
}
|
|
1517
1615
|
}
|
|
@@ -1519,7 +1617,7 @@ module.exports = class Router extends EventEmitter {
|
|
|
1519
1617
|
// its own handler always: the shared one would carry the GET registration's method
|
|
1520
1618
|
this.uwsApp.head(
|
|
1521
1619
|
replacedPath,
|
|
1522
|
-
makeHandler(headChain, canPreset ?
|
|
1620
|
+
makeHandler(headChain, canPreset ? makePreset(route.path, "HEAD", headSkips) : undefined, headSkips)
|
|
1523
1621
|
);
|
|
1524
1622
|
}
|
|
1525
1623
|
}
|
package/src/usage.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const acorn = require("acorn");
|
|
4
|
+
|
|
5
|
+
// Marks a middleware the analysis may trust on a GET request without reading its source: the
|
|
6
|
+
// body parsers set it, whose prologue only reads body-framing headers and leaves a bodyless
|
|
7
|
+
// GET alone (and a GET that declares a body falls back to the full header copy).
|
|
8
|
+
const kGetSafe = Symbol("fulmine.getSafe");
|
|
9
|
+
|
|
10
|
+
// What a handler may do with `req` and still let the header copy be skipped: members whose
|
|
11
|
+
// reads never reach a header. Anything else, computed access included, keeps the copy.
|
|
12
|
+
// req.res and req.app are deliberately absent: the walk judges only the member directly on
|
|
13
|
+
// the parameter, so anything that can reach another object could reach headers through it.
|
|
14
|
+
const REQ_OK = new Set(["query", "params", "body", "method", "path", "url", "baseUrl", "originalUrl", "route"]);
|
|
15
|
+
|
|
16
|
+
// Reading any of these needs the query string fetched: req.url and req.originalUrl carry it
|
|
17
|
+
const REQ_QUERY = new Set(["query", "url", "originalUrl"]);
|
|
18
|
+
|
|
19
|
+
// Writing any of these re-enters routing: dispatch treats a changed req.url as a rewrite and
|
|
20
|
+
// walks routes nobody analyzed, so an assignment is as disqualifying as an unknown call
|
|
21
|
+
const REQ_NO_WRITE = new Set(["url", "originalUrl", "path", "baseUrl", "method"]);
|
|
22
|
+
|
|
23
|
+
// What a handler may do with `res`: writing the response. Anything that negotiates against
|
|
24
|
+
// request headers (format, redirect, sendFile, jsonp) is deliberately absent.
|
|
25
|
+
const RES_OK = new Set([
|
|
26
|
+
"json",
|
|
27
|
+
"send",
|
|
28
|
+
"end",
|
|
29
|
+
"status",
|
|
30
|
+
"sendStatus",
|
|
31
|
+
"set",
|
|
32
|
+
"setHeader",
|
|
33
|
+
"header",
|
|
34
|
+
"get",
|
|
35
|
+
"type",
|
|
36
|
+
"contentType",
|
|
37
|
+
"append",
|
|
38
|
+
"vary",
|
|
39
|
+
"links",
|
|
40
|
+
"locals",
|
|
41
|
+
"statusCode",
|
|
42
|
+
"writeHead",
|
|
43
|
+
"headersSent",
|
|
44
|
+
"finished",
|
|
45
|
+
"cork"
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// what the analysis can say about one callback, as independent facts
|
|
49
|
+
const UNKNOWN = 1; // a shape the walk cannot vouch for: could do anything
|
|
50
|
+
const NEXT_PLAIN = 2; // calls next() bare: advances the chain, may fall off its end
|
|
51
|
+
const NEXT_ERROR = 4; // calls next(err): lands in the framework's own error answer
|
|
52
|
+
const QUERY = 8; // reads req.query, req.url or req.originalUrl
|
|
53
|
+
|
|
54
|
+
const verdicts = new WeakMap();
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What one callback provably does, as a mask of the facts above. The default is UNKNOWN: any
|
|
58
|
+
* shape this walk does not understand and any alias of req, res or next could do anything.
|
|
59
|
+
* That inversion is what makes source analysis sound to act on.
|
|
60
|
+
*
|
|
61
|
+
* @param {Function} fn
|
|
62
|
+
* @returns {number}
|
|
63
|
+
*/
|
|
64
|
+
function callbackUsage(fn) {
|
|
65
|
+
if (fn[kGetSafe]) {
|
|
66
|
+
// the body parsers: they advance the chain and read nothing a skip would miss
|
|
67
|
+
return NEXT_PLAIN;
|
|
68
|
+
}
|
|
69
|
+
let verdict = verdicts.get(fn);
|
|
70
|
+
if (verdict === undefined) {
|
|
71
|
+
verdict = analyze(fn);
|
|
72
|
+
verdicts.set(fn, verdict);
|
|
73
|
+
}
|
|
74
|
+
return verdict;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @param {Function} fn @returns {number} */
|
|
78
|
+
function analyze(fn) {
|
|
79
|
+
let code = fn.toString();
|
|
80
|
+
if (code.startsWith("function") || code.startsWith("async function")) {
|
|
81
|
+
code = code.replace(/function *\(/, "function __cb(");
|
|
82
|
+
}
|
|
83
|
+
let tree;
|
|
84
|
+
try {
|
|
85
|
+
tree = acorn.parse(code, { ecmaVersion: "latest" });
|
|
86
|
+
} catch {
|
|
87
|
+
// class methods and native functions do not parse alone, and unread code is unknown code
|
|
88
|
+
return UNKNOWN;
|
|
89
|
+
}
|
|
90
|
+
let root = /** @type {any} */ (tree.body[0]);
|
|
91
|
+
if (!root) {
|
|
92
|
+
return UNKNOWN;
|
|
93
|
+
}
|
|
94
|
+
if (root.type === "ExpressionStatement") {
|
|
95
|
+
root = root.expression;
|
|
96
|
+
}
|
|
97
|
+
if (
|
|
98
|
+
root.type !== "FunctionDeclaration" &&
|
|
99
|
+
root.type !== "ArrowFunctionExpression" &&
|
|
100
|
+
root.type !== "FunctionExpression"
|
|
101
|
+
) {
|
|
102
|
+
return UNKNOWN;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const params = /** @type {any[]} */ (root.params);
|
|
106
|
+
// rest or destructured parameters alias the objects somewhere the walk cannot follow
|
|
107
|
+
for (const p of params) {
|
|
108
|
+
if (p.type !== "Identifier") {
|
|
109
|
+
return UNKNOWN;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const reqName = params[0] ? params[0].name : null;
|
|
113
|
+
const resName = params[1] ? params[1].name : null;
|
|
114
|
+
const nextName = params[2] ? params[2].name : null;
|
|
115
|
+
|
|
116
|
+
// Every appearance of the three names in the whole body is judged, nested functions
|
|
117
|
+
// included: an inner binding that shadows one of them only makes this stricter, never
|
|
118
|
+
// looser, so scope tracking is not needed for soundness.
|
|
119
|
+
let mask = 0;
|
|
120
|
+
walk(root.body, null, (node, parent) => {
|
|
121
|
+
if (mask & UNKNOWN) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (node.type === "MemberExpression" && !node.computed && node.object.type === "Identifier") {
|
|
125
|
+
const owner = node.object.name;
|
|
126
|
+
if (owner !== reqName) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const member = node.property.name;
|
|
130
|
+
if (REQ_QUERY.has(member)) {
|
|
131
|
+
mask |= QUERY;
|
|
132
|
+
}
|
|
133
|
+
// an assignment, an update or a delete on the routing members re-enters dispatch
|
|
134
|
+
if (
|
|
135
|
+
REQ_NO_WRITE.has(member) &&
|
|
136
|
+
parent &&
|
|
137
|
+
((parent.type === "AssignmentExpression" && parent.left === node) ||
|
|
138
|
+
(parent.type === "UpdateExpression" && parent.argument === node) ||
|
|
139
|
+
(parent.type === "UnaryExpression" && parent.operator === "delete" && parent.argument === node))
|
|
140
|
+
) {
|
|
141
|
+
mask |= UNKNOWN;
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (node.type !== "Identifier") {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const name = node.name;
|
|
149
|
+
if (name === "eval" || name === "arguments") {
|
|
150
|
+
mask |= UNKNOWN;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (name !== reqName && name !== resName && name !== nextName) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// being renamed inside a member expression (req.query's `query`) is not a use
|
|
157
|
+
if (parent && parent.type === "MemberExpression" && parent.property === node && !parent.computed) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// a redeclaration as an inner parameter or variable name is not a use either
|
|
161
|
+
if (
|
|
162
|
+
parent &&
|
|
163
|
+
(((parent.type === "FunctionDeclaration" ||
|
|
164
|
+
parent.type === "FunctionExpression" ||
|
|
165
|
+
parent.type === "ArrowFunctionExpression") &&
|
|
166
|
+
parent.params.includes(node)) ||
|
|
167
|
+
(parent.type === "VariableDeclarator" && parent.id === node) ||
|
|
168
|
+
(parent.type === "Property" && parent.key === node && !parent.computed))
|
|
169
|
+
) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (name === nextName) {
|
|
173
|
+
// calling next is how a chain advances, and past its end or with an error the
|
|
174
|
+
// request lands in the framework's own final answer, which the constructor's
|
|
175
|
+
// accept pre-read covers. Anything but a direct call aliases the continuation,
|
|
176
|
+
// and an argument that could be the string "route" would leave the chain for
|
|
177
|
+
// routes nobody analyzed, so only shapes that cannot be a string pass.
|
|
178
|
+
if (!parent || parent.type !== "CallExpression" || parent.callee !== node) {
|
|
179
|
+
mask |= UNKNOWN;
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const args = parent.arguments;
|
|
183
|
+
if (args.length === 0) {
|
|
184
|
+
mask |= NEXT_PLAIN;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const arg = args[0];
|
|
188
|
+
if (
|
|
189
|
+
args.length > 1 ||
|
|
190
|
+
(arg.type !== "NewExpression" &&
|
|
191
|
+
arg.type !== "ObjectExpression" &&
|
|
192
|
+
!(arg.type === "Literal" && typeof arg.value !== "string"))
|
|
193
|
+
) {
|
|
194
|
+
mask |= UNKNOWN;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
mask |= NEXT_ERROR;
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (!parent || parent.type !== "MemberExpression" || parent.object !== node || parent.computed) {
|
|
201
|
+
mask |= UNKNOWN;
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const member = parent.property.name;
|
|
205
|
+
if (name === reqName ? !REQ_OK.has(member) : !RES_OK.has(member)) {
|
|
206
|
+
mask |= UNKNOWN;
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
return mask;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Walks every node, handing each its parent. Arrays and nested objects are entered, nothing
|
|
214
|
+
* is interpreted: the judging happens in the visitor.
|
|
215
|
+
*
|
|
216
|
+
* @param {any} node
|
|
217
|
+
* @param {any} parent
|
|
218
|
+
* @param {(node: any, parent: any) => void} visit
|
|
219
|
+
*/
|
|
220
|
+
function walk(node, parent, visit) {
|
|
221
|
+
if (!node || typeof node.type !== "string") {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
visit(node, parent);
|
|
225
|
+
for (const key in node) {
|
|
226
|
+
if (key === "type" || key === "start" || key === "end") {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const value = node[key];
|
|
230
|
+
if (Array.isArray(value)) {
|
|
231
|
+
for (const item of value) {
|
|
232
|
+
if (item && typeof item.type === "string") {
|
|
233
|
+
walk(item, node, visit);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
} else if (value && typeof value.type === "string") {
|
|
237
|
+
walk(value, node, visit);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* What a native route's whole chain provably never does, so the request constructor may leave
|
|
244
|
+
* that work undone: skipHeaders spares the header copy, skipQuery the query fetch.
|
|
245
|
+
*
|
|
246
|
+
* A callback that calls next() bare passes anywhere but in the terminal route, where it would
|
|
247
|
+
* fall out of the chain: there it only passes when the caller established that no later route
|
|
248
|
+
* could catch the fall-through. The framework's own 404 answers with the path alone, so the
|
|
249
|
+
* fall-through itself needs neither headers nor query.
|
|
250
|
+
*
|
|
251
|
+
* @param {any[]} chain the routes the native handler runs, in order, this route last
|
|
252
|
+
* @param {boolean} allowTerminalNext whether a fall-through past the chain lands only in the
|
|
253
|
+
* framework's own final answer
|
|
254
|
+
* @returns {{skipHeaders: boolean, skipQuery: boolean}}
|
|
255
|
+
*/
|
|
256
|
+
function chainUsage(chain, allowTerminalNext) {
|
|
257
|
+
const none = { skipHeaders: false, skipQuery: false };
|
|
258
|
+
let query = false;
|
|
259
|
+
for (let i = 0; i < chain.length; i++) {
|
|
260
|
+
const entry = chain[i];
|
|
261
|
+
const callbacks = entry.callbacks;
|
|
262
|
+
if (!Array.isArray(callbacks)) {
|
|
263
|
+
return none;
|
|
264
|
+
}
|
|
265
|
+
const terminal = i === chain.length - 1;
|
|
266
|
+
for (const cb of callbacks) {
|
|
267
|
+
if (typeof cb !== "function") {
|
|
268
|
+
return none;
|
|
269
|
+
}
|
|
270
|
+
const mask = callbackUsage(cb);
|
|
271
|
+
if (mask & UNKNOWN || (mask & NEXT_PLAIN && terminal && !allowTerminalNext)) {
|
|
272
|
+
return none;
|
|
273
|
+
}
|
|
274
|
+
if (mask & QUERY) {
|
|
275
|
+
query = true;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// a param callback runs code this walk never saw
|
|
279
|
+
if (entry.paramCallbacks && entry.paramCallbacks.size > 0) {
|
|
280
|
+
return none;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return { skipHeaders: true, skipQuery: !query };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
module.exports = { chainUsage, callbackUsage, kGetSafe, UNKNOWN, NEXT_PLAIN, NEXT_ERROR, QUERY };
|