fulmine.js 5.1.6 → 5.1.7

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 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 5.89 million pipelined requests per second, 1.12 million on baseline and 1.04 million on the json profile, ahead of every JavaScript entry on the board.
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. 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.1.6",
3
+ "version": "5.1.7",
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": {
@@ -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,14 @@ 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
+ }
369
+ this._skipPresets.clear();
370
+ }
362
371
  if (typeof value === "function") {
363
372
  this.settings["etag fn"] = value;
364
373
  } else {
@@ -431,10 +440,12 @@ class Application extends Router {
431
440
  * @param {any} res uWS response
432
441
  * @param {any} req uWS request, readable only during this call
433
442
  * @param {any} [preset] a literal registration's constants, see nativePreset in the router
443
+ * @param {any} [skipHolder] where a granted header skip lives, forwarded whole: dropping
444
+ * it here silently turned every skip off, since the native closures call this override
434
445
  * @returns {any} the request, with the response reachable as request.res
435
446
  */
436
- handleRequest(res, req, preset) {
437
- const request = super.handleRequest(res, req, preset);
447
+ handleRequest(res, req, preset, skipHolder) {
448
+ const request = super.handleRequest(res, req, preset, skipHolder);
438
449
  // removal rides the close listener the Response constructor already has, since a second
439
450
  // once() per request measured a tenth of a microsecond on the hot path.
440
451
  // An aborted response only flips its flags without emitting 'close', which is why
@@ -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
- return (req, res, next) => {
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
- currentRequest = this;
211
- this._req.forEach(Request.#collectHeader);
212
- currentRequest = null;
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) {
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 { chainSkipsHeaders } = 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,38 @@ 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; mutable,
697
+ // because a middleware added after listen has to be able to take it back
698
+ skipHeaders: false
695
699
  };
696
700
  }
697
701
 
702
+ /**
703
+ * Whether any error middleware exists anywhere under this router, mounted routers and sub-apps
704
+ * included. The header-skip analysis needs the answer to be no: a throw inside an analyzed
705
+ * handler would hand the request to code nobody analyzed.
706
+ *
707
+ * @param {any} router
708
+ * @returns {boolean}
709
+ */
710
+ function hasErrorMiddleware(router) {
711
+ for (const route of router._routes) {
712
+ for (const callback of route.callbacks) {
713
+ // a mounted router or a callable sub-app carries routes of its own; the callable
714
+ // app is also a function, so the routes are looked for first
715
+ if (callback && callback._routes) {
716
+ if (hasErrorMiddleware(callback)) {
717
+ return true;
718
+ }
719
+ } else if (typeof callback === "function" && callback.length >= 4) {
720
+ return true;
721
+ }
722
+ }
723
+ }
724
+ return false;
725
+ }
726
+
698
727
  /**
699
728
  *
700
729
  */
@@ -822,6 +851,12 @@ module.exports = class Router extends EventEmitter {
822
851
  this._paramCallbacks = new Map();
823
852
  this._mountpathCache = new Map();
824
853
  this._routes = [];
854
+ // the native presets allowed to skip the header copy, so a late middleware or an etag
855
+ // arriving after listen can take the permission back; null until one is granted
856
+ /** @type {Set<any>|null} */
857
+ this._skipPresets = null;
858
+ /** @type {boolean|undefined} */
859
+ this._hasErrMwCache = undefined;
825
860
  // an array when mounted on several paths at once, as Express allows
826
861
  /** @type {string|string[]} */
827
862
  this.mountpath = "/";
@@ -1107,6 +1142,16 @@ module.exports = class Router extends EventEmitter {
1107
1142
  }
1108
1143
  this._routes.push(...routes);
1109
1144
 
1145
+ // anything registered after listen invalidates what the header-skip analysis proved:
1146
+ // it could catch a throw or read what a chain never did, so every skip is taken back
1147
+ this._hasErrMwCache = undefined;
1148
+ if (this._skipPresets?.size) {
1149
+ for (const preset of this._skipPresets) {
1150
+ preset.skipHeaders = false;
1151
+ }
1152
+ this._skipPresets.clear();
1153
+ }
1154
+
1110
1155
  return parent;
1111
1156
  }
1112
1157
 
@@ -1338,10 +1383,12 @@ module.exports = class Router extends EventEmitter {
1338
1383
  * @param {any} res uWS response
1339
1384
  * @param {any} req uWS request, readable only during this call
1340
1385
  * @param {any} [preset] a literal registration's constants, see nativePreset
1386
+ * @param {any} [skipHolder] the object a granted header skip lives on: the preset itself
1387
+ * for a literal registration, a holder of its own for a parameterised one
1341
1388
  * @returns {any} the request, with the response reachable as request.res
1342
1389
  */
1343
- handleRequest(res, req, preset) {
1344
- const request = new this._request(req, res, this, preset);
1390
+ handleRequest(res, req, preset, skipHolder) {
1391
+ const request = new this._request(req, res, this, preset, skipHolder);
1345
1392
  const response = new this._response(res, request, this);
1346
1393
  request.res = response;
1347
1394
  response.req = request;
@@ -1417,7 +1464,15 @@ module.exports = class Router extends EventEmitter {
1417
1464
  if (route.path.includes(":")) {
1418
1465
  route.optimizedParams = route.path.match(regExParam).map((p) => p.slice(1));
1419
1466
  }
1420
- const makeHandler = (chain, preset) => {
1467
+ const makeHandler = (chain, preset, skips) => {
1468
+ // the mutable object a granted skip lives on, so a middleware arriving after
1469
+ // listen can take it back: a literal registration's preset doubles as it, and a
1470
+ // parameterised one, which has no preset, gets a holder of its own
1471
+ let skipHolder = preset;
1472
+ if (skipHolder === undefined && skips) {
1473
+ skipHolder = { skipHeaders: true };
1474
+ (this._skipPresets ??= new Set()).add(skipHolder);
1475
+ }
1421
1476
  // all three are registration-time constants: computing them in the handler was a
1422
1477
  // closure and a scan of the chain on every native request.
1423
1478
  // Falling back resumes after the mount, not after the router's leaf: the leaf can have
@@ -1430,7 +1485,7 @@ module.exports = class Router extends EventEmitter {
1430
1485
  // and this one never did. nativeDone and nativeFail defer their epilogues to a
1431
1486
  // microtask, which is where the await used to resume, so the visible order holds
1432
1487
  return (res, req) => {
1433
- const request = this.handleRequest(res, req, preset);
1488
+ const request = this.handleRequest(res, req, preset, skipHolder);
1434
1489
  const response = request.res;
1435
1490
  if (optimizedParams) {
1436
1491
  request.optimizedParams = new NullObject();
@@ -1461,6 +1516,7 @@ module.exports = class Router extends EventEmitter {
1461
1516
  const getChain =
1462
1517
  route.method === "GET" ? optimizedPath.filter((r) => r.all || r.method !== "HEAD") : optimizedPath;
1463
1518
  route.optimizedPath = optimizedPath;
1519
+ const headChain = getChain.length === optimizedPath.length ? getChain : optimizedPath;
1464
1520
 
1465
1521
  // A fully literal registration knows path and method here, so each registration site
1466
1522
  // hands the request constructor its own constants. An "any" registration serves every
@@ -1471,11 +1527,45 @@ module.exports = class Router extends EventEmitter {
1471
1527
  // registering that path here is the only way it could
1472
1528
  const strictHere = Boolean((route.owner ?? this).get("strict routing"));
1473
1529
 
1474
- let fn = makeHandler(getChain, canPreset ? nativePreset(route.path, route.method, strictHere) : undefined);
1530
+ // Whether requests served by this registration may skip the header copy: GET and its
1531
+ // HEAD twins only, the app must not compute etags (send would consult freshness
1532
+ // headers), no error middleware may exist anywhere (a throw hands the request to code
1533
+ // the analysis never saw), and every callback in the chain has to pass the source
1534
+ // analysis in usage.js, whose default answer is no.
1535
+ let getSkips = false;
1536
+ let headSkips = false;
1537
+ if (route.method === "GET" && this.get("etag") === false) {
1538
+ let hasErr = this._hasErrMwCache;
1539
+ if (hasErr === undefined) {
1540
+ hasErr = this._hasErrMwCache = hasErrorMiddleware(this);
1541
+ }
1542
+ if (!hasErr) {
1543
+ // a terminal next() may only fall into the framework's own 404, so no later
1544
+ // route may be able to catch the same path
1545
+ const owner = route.owner ?? this;
1546
+ const noLaterMatch = !owner._isFollowedByAnOverlap.call(owner, route, owner._routes);
1547
+ getSkips = chainSkipsHeaders(getChain, noLaterMatch);
1548
+ headSkips = headChain === getChain ? getSkips : chainSkipsHeaders(headChain, noLaterMatch);
1549
+ }
1550
+ }
1551
+ // remembered so a middleware or setting arriving after listen can take the skips back
1552
+ const makePreset = (path, method, skips) => {
1553
+ const preset = nativePreset(path, method, strictHere);
1554
+ if (skips) {
1555
+ preset.skipHeaders = true;
1556
+ (this._skipPresets ??= new Set()).add(preset);
1557
+ }
1558
+ return preset;
1559
+ };
1560
+
1561
+ let fn = makeHandler(
1562
+ getChain,
1563
+ canPreset ? makePreset(route.path, route.method, getSkips) : undefined,
1564
+ getSkips
1565
+ );
1475
1566
  const jsFn = fn;
1476
1567
 
1477
1568
  let replacedPath = route.path;
1478
- const headChain = getChain.length === optimizedPath.length ? getChain : optimizedPath;
1479
1569
 
1480
1570
  // the response prototype the route will really run under: its own app's, which sees a
1481
1571
  // method patched there or inherited from a parent app, falling back to the registering app
@@ -1505,13 +1595,17 @@ module.exports = class Router extends EventEmitter {
1505
1595
  fn !== jsFn
1506
1596
  ? fn
1507
1597
  : canPreset
1508
- ? makeHandler(getChain, nativePreset(route.path + "/", route.method, strictHere))
1598
+ ? makeHandler(getChain, makePreset(route.path + "/", route.method, getSkips), getSkips)
1509
1599
  : fn;
1510
1600
  this.uwsApp[method](replacedPath + "/", slashFn);
1511
1601
  if (method === "get") {
1512
1602
  this.uwsApp.head(
1513
1603
  replacedPath + "/",
1514
- makeHandler(headChain, canPreset ? nativePreset(route.path + "/", "HEAD", strictHere) : undefined)
1604
+ makeHandler(
1605
+ headChain,
1606
+ canPreset ? makePreset(route.path + "/", "HEAD", headSkips) : undefined,
1607
+ headSkips
1608
+ )
1515
1609
  );
1516
1610
  }
1517
1611
  }
@@ -1519,7 +1613,7 @@ module.exports = class Router extends EventEmitter {
1519
1613
  // its own handler always: the shared one would carry the GET registration's method
1520
1614
  this.uwsApp.head(
1521
1615
  replacedPath,
1522
- makeHandler(headChain, canPreset ? nativePreset(route.path, "HEAD", strictHere) : undefined)
1616
+ makeHandler(headChain, canPreset ? makePreset(route.path, "HEAD", headSkips) : undefined, headSkips)
1523
1617
  );
1524
1618
  }
1525
1619
  }
package/src/usage.js ADDED
@@ -0,0 +1,247 @@
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
+ // What a handler may do with `res`: writing the response. Anything that negotiates against
17
+ // request headers (format, redirect, sendFile, jsonp) is deliberately absent.
18
+ const RES_OK = new Set([
19
+ "json",
20
+ "send",
21
+ "end",
22
+ "status",
23
+ "sendStatus",
24
+ "set",
25
+ "setHeader",
26
+ "header",
27
+ "get",
28
+ "type",
29
+ "contentType",
30
+ "append",
31
+ "vary",
32
+ "links",
33
+ "locals",
34
+ "statusCode",
35
+ "writeHead",
36
+ "headersSent",
37
+ "finished",
38
+ "cork"
39
+ ]);
40
+
41
+ // what the analysis can say about one callback
42
+ const NO = 0; // could read headers, or could not be read at all
43
+ const SAFE = 1; // never reads a header, never touches next
44
+ const SAFE_NEXT = 2; // never reads a header, calls next: fine mid-chain, and at the end of
45
+ // the chain only when no later route could catch the fall-through
46
+
47
+ const verdicts = new WeakMap();
48
+
49
+ /**
50
+ * What one callback provably does. The default is NO: any shape this walk does not understand
51
+ * and any alias of req, res or next keeps the header copy. That inversion is what makes
52
+ * source analysis sound to act on.
53
+ *
54
+ * @param {Function} fn
55
+ * @returns {number} NO, SAFE or SAFE_NEXT
56
+ */
57
+ function callbackSkipsHeaders(fn) {
58
+ if (fn[kGetSafe]) {
59
+ return SAFE_NEXT;
60
+ }
61
+ let verdict = verdicts.get(fn);
62
+ if (verdict === undefined) {
63
+ verdict = analyze(fn);
64
+ verdicts.set(fn, verdict);
65
+ }
66
+ return verdict;
67
+ }
68
+
69
+ /** @param {Function} fn @returns {number} */
70
+ function analyze(fn) {
71
+ let code = fn.toString();
72
+ if (code.startsWith("function") || code.startsWith("async function")) {
73
+ code = code.replace(/function *\(/, "function __cb(");
74
+ }
75
+ let tree;
76
+ try {
77
+ tree = acorn.parse(code, { ecmaVersion: "latest" });
78
+ } catch {
79
+ // class methods and native functions do not parse alone, and unread code is unknown code
80
+ return NO;
81
+ }
82
+ let root = /** @type {any} */ (tree.body[0]);
83
+ if (!root) {
84
+ return NO;
85
+ }
86
+ if (root.type === "ExpressionStatement") {
87
+ root = root.expression;
88
+ }
89
+ if (
90
+ root.type !== "FunctionDeclaration" &&
91
+ root.type !== "ArrowFunctionExpression" &&
92
+ root.type !== "FunctionExpression"
93
+ ) {
94
+ return NO;
95
+ }
96
+
97
+ const params = /** @type {any[]} */ (root.params);
98
+ // rest or destructured parameters alias the objects somewhere the walk cannot follow
99
+ for (const p of params) {
100
+ if (p.type !== "Identifier") {
101
+ return NO;
102
+ }
103
+ }
104
+ const reqName = params[0] ? params[0].name : null;
105
+ const resName = params[1] ? params[1].name : null;
106
+ const nextName = params[2] ? params[2].name : null;
107
+
108
+ // Every appearance of the three names in the whole body is judged, nested functions
109
+ // included: an inner binding that shadows one of them only makes this stricter, never
110
+ // looser, so scope tracking is not needed for soundness.
111
+ let ok = true;
112
+ let usesNext = false;
113
+ walk(root.body, null, (node, parent) => {
114
+ if (!ok || node.type !== "Identifier") {
115
+ return;
116
+ }
117
+ const name = node.name;
118
+ if (name === "eval" || name === "arguments") {
119
+ ok = false;
120
+ return;
121
+ }
122
+ if (name !== reqName && name !== resName && name !== nextName) {
123
+ return;
124
+ }
125
+ // being renamed inside a member expression (req.query's `query`) is not a use
126
+ if (parent && parent.type === "MemberExpression" && parent.property === node && !parent.computed) {
127
+ return;
128
+ }
129
+ // a redeclaration as an inner parameter or variable name is not a use either
130
+ if (
131
+ parent &&
132
+ (((parent.type === "FunctionDeclaration" ||
133
+ parent.type === "FunctionExpression" ||
134
+ parent.type === "ArrowFunctionExpression") &&
135
+ parent.params.includes(node)) ||
136
+ (parent.type === "VariableDeclarator" && parent.id === node) ||
137
+ (parent.type === "Property" && parent.key === node && !parent.computed))
138
+ ) {
139
+ return;
140
+ }
141
+ if (name === nextName) {
142
+ // calling next is how a chain advances, and past its end or with an error the
143
+ // request lands in the framework's own final handler, which the constructor's
144
+ // accept pre-read covers. Anything but a direct call aliases the continuation,
145
+ // and an argument that could be the string "route" would leave the chain for
146
+ // routes nobody analyzed, so only shapes that cannot be a string pass.
147
+ if (!parent || parent.type !== "CallExpression" || parent.callee !== node) {
148
+ ok = false;
149
+ return;
150
+ }
151
+ usesNext = true;
152
+ const args = parent.arguments;
153
+ if (args.length === 0) {
154
+ return;
155
+ }
156
+ const arg = args[0];
157
+ if (
158
+ args.length > 1 ||
159
+ (arg.type !== "NewExpression" &&
160
+ arg.type !== "ObjectExpression" &&
161
+ !(arg.type === "Literal" && typeof arg.value !== "string"))
162
+ ) {
163
+ ok = false;
164
+ }
165
+ return;
166
+ }
167
+ if (!parent || parent.type !== "MemberExpression" || parent.object !== node || parent.computed) {
168
+ ok = false;
169
+ return;
170
+ }
171
+ const member = parent.property.name;
172
+ if (name === reqName ? !REQ_OK.has(member) : !RES_OK.has(member)) {
173
+ ok = false;
174
+ }
175
+ });
176
+ return ok ? (usesNext ? SAFE_NEXT : SAFE) : NO;
177
+ }
178
+
179
+ /**
180
+ * Walks every node, handing each its parent. Arrays and nested objects are entered, nothing
181
+ * is interpreted: the judging happens in the visitor.
182
+ *
183
+ * @param {any} node
184
+ * @param {any} parent
185
+ * @param {(node: any, parent: any) => void} visit
186
+ */
187
+ function walk(node, parent, visit) {
188
+ if (!node || typeof node.type !== "string") {
189
+ return;
190
+ }
191
+ visit(node, parent);
192
+ for (const key in node) {
193
+ if (key === "type" || key === "start" || key === "end") {
194
+ continue;
195
+ }
196
+ const value = node[key];
197
+ if (Array.isArray(value)) {
198
+ for (const item of value) {
199
+ if (item && typeof item.type === "string") {
200
+ walk(item, node, visit);
201
+ }
202
+ }
203
+ } else if (value && typeof value.type === "string") {
204
+ walk(value, node, visit);
205
+ }
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Whether a native literal route's whole chain provably never reads a request header, so the
211
+ * request constructor may skip copying them and read the few framing headers directly.
212
+ *
213
+ * A callback that calls next passes anywhere but in the terminal route, where next would fall
214
+ * out of the chain: there it only passes when the caller established that no later route
215
+ * could catch the fall-through.
216
+ *
217
+ * @param {any[]} chain the routes the native handler runs, in order, this route last
218
+ * @param {boolean} allowTerminalNext whether a fall-through past the chain lands only in the
219
+ * framework's own final handler
220
+ * @returns {boolean}
221
+ */
222
+ function chainSkipsHeaders(chain, allowTerminalNext) {
223
+ for (let i = 0; i < chain.length; i++) {
224
+ const entry = chain[i];
225
+ const callbacks = entry.callbacks;
226
+ if (!Array.isArray(callbacks)) {
227
+ return false;
228
+ }
229
+ const terminal = i === chain.length - 1;
230
+ for (const cb of callbacks) {
231
+ if (typeof cb !== "function") {
232
+ return false;
233
+ }
234
+ const verdict = callbackSkipsHeaders(cb);
235
+ if (verdict === NO || (verdict === SAFE_NEXT && terminal && !allowTerminalNext)) {
236
+ return false;
237
+ }
238
+ }
239
+ // a param callback runs code this walk never saw
240
+ if (entry.paramCallbacks && entry.paramCallbacks.size > 0) {
241
+ return false;
242
+ }
243
+ }
244
+ return true;
245
+ }
246
+
247
+ module.exports = { chainSkipsHeaders, callbackSkipsHeaders, kGetSafe };