fulmine.js 5.12.1 → 5.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -285,6 +285,7 @@ A single-stage `node:26-trixie-slim` image works too if you `apt-get install -y
285
285
  - `app.listen()` returns the app rather than a separate server object, and the app answers as an `http.Server`: `app instanceof http.Server` is true, which is what the graceful shutdown wrappers and the connection trackers look for. There is still no node server underneath, the socket belongs to µWS, so what is answered is the surface and not the plumbing. There: `close()`, `address()`, `listening`, `getConnections()`, `ref()`, `unref()`, `setTimeout()` and the `keepAliveTimeout` family. Not there: nothing emits `connection`, `request` or `upgrade`, `getConnections()` counts the requests in flight rather than sockets, and the timeouts belong to µWS and are set through `uwsOptions.idleTimeout`. Anything that wants to serve its own protocol on the socket, socket.io being the usual case, still wants `app.uwsApp`. Runnable: [`examples/graceful-shutdown.js`](./examples/graceful-shutdown.js).
286
286
  - `x-powered-by` is disabled by default. Express sends `X-Powered-By: Express` unless you turn it off; Fulmine does not send it unless you turn it on with `app.set("x-powered-by", true)`. The header only tells anyone asking which framework is running.
287
287
  - request body is only read for POST, PUT, PATCH and QUERY requests by default. You can add additional methods by setting `body methods` to array with uppercased methods.
288
+ - **A request whose framing cannot be trusted is refused by hanging up, with no answer at all.** Node's parser refuses each of these with a `400` and Fulmine refuses the same ones: a repeated `Content-Length`; one that is not a plain count of bytes, an empty value or a count past `Number.MAX_SAFE_INTEGER` included; a `Transfer-Encoding` whose last coding is not `chunked`; and a method nobody defines, which includes a lowercase one, since methods are case sensitive. µWS accepts all of them. It frames the request on the first length, or on no body at all, and it takes any token as a method, so `{"a":1}GET /path HTTP/1.1` is a request line to it. What the client sent as a body is then read as the next request on the connection: that is request smuggling, and a proxy in front disagreeing about the framing is all it takes. The answer differs from Express because it cannot be helped. µWS only skips the request it has already queued when the response is closed rather than completed, and writing the `400` completes it, so the choice is between telling the client and stopping the smuggled request. Nothing well behaved sends any of these.
288
289
  - **A compiled route answers `connection: keep-alive` to a client that sent `Connection: close`.** A handler simple enough to be read at registration time is answered by µWS from a response written once at `listen()`, and that response cannot read the request. The socket still closes, so what is wrong is the header and not the transport. A response that would carry a validator is never compiled, so conditional requests behave as on Express; `app.set("declarative responses", false)` turns compiling off.
289
290
  - **Informational responses go nowhere.** `res.writeEarlyHints()`, `res.writeContinue()` and `res.writeProcessing()` are all there, take what node's take and throw what node's throw once the head has gone out, but nothing reaches the wire: µWebSockets.js has no API for a `1xx`. They exist so that code written for Express keeps running rather than dying on "is not a function", which is the only thing a drop-in can honestly promise here. `res.addTrailers()` is the same story, and `res.setTimeout()` and `req.setTimeout()` register the listener without changing anything, since µWS runs its own idle timeout through `uwsOptions.idleTimeout`.
290
291
  - For HTTPS, instead of doing this:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.12.1",
3
+ "version": "5.12.3",
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": {
@@ -28,7 +28,10 @@
28
28
  "release:local": "node tools/release-local.js",
29
29
  "cover:full": "nyc --silent npm run test && nyc --silent --no-clean npm run test:unit && nyc --silent --no-clean npm run test:express && nyc report",
30
30
  "cover:check": "nyc check-coverage --statements 94.5 --branches 90 --functions 94 --lines 94.5",
31
- "examples:install": "npm --prefix examples install"
31
+ "examples:install": "npm --prefix examples install",
32
+ "fuzz:wire": "node tools/wire-fuzz.js",
33
+ "fuzz:headers": "node tools/header-fuzz.js",
34
+ "fuzz:session": "node tools/session-fuzz.js"
32
35
  },
33
36
  "engines": {
34
37
  "node": ">=22"
@@ -72,7 +75,7 @@
72
75
  "bytes": "^3.1.2",
73
76
  "compressible": "^2.0.18",
74
77
  "content-disposition": "^1.1.0",
75
- "cookie": "^1.1.1",
78
+ "cookie": "^0.7.2",
76
79
  "cookie-signature": "^1.2.2",
77
80
  "encodeurl": "^2.0.0",
78
81
  "fast-decode-uri-component": "^1.0.1",
@@ -529,8 +529,8 @@ class Application extends Router {
529
529
  _serveGeneric(res, req) {
530
530
  const request = this.handleRequest(res, req);
531
531
  const response = request.res;
532
- if (request._badFraming === true) {
533
- return this._refuseFraming(response);
532
+ if (request._mustRefuse === true) {
533
+ return this._refuseRequest(response);
534
534
  }
535
535
  try {
536
536
  this._routeRequestDirect(request, response);
@@ -54,6 +54,11 @@ const MAX_INSTRUCTION_LENGTH = 65535;
54
54
  // so it cannot honour one, and a handler that sets one has to stay on the ordinary path.
55
55
  const VALIDATOR_HEADERS = new Set(["etag", "last-modified"]);
56
56
 
57
+ // The statuses whose message carries no content. 205 is here for node's reason rather than
58
+ // express's: express strips the body for 204 and 304, and node answers a 205 with a lone
59
+ // Content-Length of zero, so all three come out of the ordinary path with no body at all.
60
+ const BODILESS_STATUSES = new Set([204, 205, 304]);
61
+
57
62
  const bodyMethods = new Set(["send", "json", "end"]);
58
63
  // and the four that finish the response, after which nothing a handler does is observable
59
64
  const terminalMethods = new Set(["send", "json", "end", "sendStatus"]);
@@ -710,6 +715,17 @@ module.exports = function compileDeclarative(cb, app) {
710
715
  return false;
711
716
  }
712
717
 
718
+ // A status that carries no content, which is a rule about the message and not about the
719
+ // application: express drops the body and the framing headers for 204 and 304, and node
720
+ // writes a lone Content-Length: 0 for 205. Compiled, the body went out anyway, so
721
+ // res.sendStatus(204) answered "No Content" with a Content-Length of ten. A client frames
722
+ // a 204 as bodiless whatever the headers say, so those ten bytes were read as the start of
723
+ // the next answer on the connection, which is a desync on any keep-alive client. The
724
+ // ordinary path already writes all three the way express does: this hands them back to it.
725
+ if (BODILESS_STATUSES.has(statusCode) || statusCode < 200) {
726
+ return false;
727
+ }
728
+
713
729
  let decRes = new uWSAny.DeclarativeResponse();
714
730
 
715
731
  if (statusCode !== 200) {
package/src/request.js CHANGED
@@ -152,6 +152,52 @@ const discardedDuplicates = new Set([
152
152
  // 128 KB of body buffered before uWS is asked to pause
153
153
  const READABLE_OPTIONS = { highWaterMark: 128 * 1024 };
154
154
 
155
+ // The methods node's parser accepts, which is the set a request can arrive with behind Express and
156
+ // the set a route can be registered for here. µWS accepts any token, so without this a line like
157
+ // `{"a":1}GET /path HTTP/1.1` is a request to it, with `{"A":1}GET` as the method. See _mustRefuse.
158
+ const KNOWN_METHODS = new Set(require("http").METHODS);
159
+
160
+ /**
161
+ * Whether a transfer-encoding leaves the body's length knowable, which is RFC 9112's rule that
162
+ * `chunked` comes last. `gzip, chunked` is fine and `chunked, gzip` is not: with a coding applied
163
+ * after the framing one, nothing can say where the body ends, and node answers 400 rather than
164
+ * guess. µWS guesses, and what it guesses wrong becomes the next request on the connection.
165
+ *
166
+ * Read per header rather than over the joined value, so a request splitting the list across two
167
+ * transfer-encoding headers is refused even when the codings would be legal joined up. That is
168
+ * stricter than node by a hair, on a shape nothing sends, and stricter is the safe direction here.
169
+ *
170
+ * @param {string} value one transfer-encoding header, as uWS hands it over
171
+ * @returns {boolean}
172
+ */
173
+ function endsWithChunked(value) {
174
+ const last = value.slice(value.lastIndexOf(",") + 1).trim();
175
+ // a coding may carry parameters, which are not part of its name
176
+ const semicolon = last.indexOf(";");
177
+ return (semicolon === -1 ? last : last.slice(0, semicolon)).trim().toLowerCase() === "chunked";
178
+ }
179
+
180
+ /**
181
+ * The path of the url a request carries right now, without the query.
182
+ *
183
+ * Express reads it off req.url on every access, so a middleware that assigns req.url is seen by
184
+ * whatever runs next, the callback after it in the same route included: the router takes a rewrite
185
+ * over at its next hop, and until then the field is a hop behind. `req.url = req.url.replace(/^\/+/,
186
+ * "/")` reported "//" for the rest of its own route where express reports "/". The field answers
187
+ * while the two agree, which is every read of a request nobody rewrote.
188
+ *
189
+ * @param {any} req
190
+ * @returns {string}
191
+ */
192
+ function currentPath(req) {
193
+ const url = req.url;
194
+ if (url === req._lastUrl) {
195
+ return req._path;
196
+ }
197
+ const query = url.indexOf("?");
198
+ return query === -1 ? url : url.slice(0, query);
199
+ }
200
+
155
201
  /**
156
202
  * Whether a content-length is a plain count of bytes, which is the only thing RFC 9112 allows.
157
203
  *
@@ -173,6 +219,12 @@ function isByteCount(value) {
173
219
  return false;
174
220
  }
175
221
  }
222
+ // A count nothing can represent is not a count. Node refuses one that overflows, and µWS framed
223
+ // the request as if it had said something else, which put the bytes after it in a request of
224
+ // their own. The length test first, so an ordinary value never parses.
225
+ if (value.length > 15 && Number(value) > Number.MAX_SAFE_INTEGER) {
226
+ return false;
227
+ }
176
228
  return true;
177
229
  }
178
230
 
@@ -363,11 +415,15 @@ module.exports = class Request extends LazyReadable {
363
415
  if (headerKey.length === 14) {
364
416
  // a second content-length whatever it says, and one that is not a count of bytes:
365
417
  // both make uWS frame the request differently from what is on the wire, see
366
- // _badFraming and isByteCount
418
+ // _mustRefuse and isByteCount
367
419
  if (r._sawContentLength || !isByteCount(value)) {
368
- r._badFraming = true;
420
+ r._mustRefuse = true;
369
421
  }
370
422
  r._sawContentLength = true;
423
+ } else if (!endsWithChunked(value)) {
424
+ // chunked has to be the last coding: anything after it and the length of the body
425
+ // is not knowable, which node answers 400 to and µWS served. See endsWithChunked
426
+ r._mustRefuse = true;
371
427
  }
372
428
  // saying anything about framing at all, "0" included. A parser that can see a
373
429
  // content-length answers about the body it describes, even an empty one: a zero length
@@ -480,9 +536,9 @@ module.exports = class Request extends LazyReadable {
480
536
  _sawContentLength;
481
537
 
482
538
  /**
483
- * Whether the request said two different things about how long its body is, so uWS may have
484
- * framed it differently from the client that sent it and the proxy that forwarded it. Two
485
- * shapes reach this, and node's parser refuses both outright:
539
+ * Whether this request must not be routed at all. Node's parser refuses each of these outright
540
+ * and answers 400; every one of them is a way for bytes the client did not send as a request to
541
+ * be served as one, which is request smuggling.
486
542
  *
487
543
  * a repeated content-length uWS frames on the first and drops the rest, so a proxy in
488
544
  * front reading the last one instead forwards bytes uWS then
@@ -491,13 +547,17 @@ module.exports = class Request extends LazyReadable {
491
547
  * included, and frames the request as carrying no body at all,
492
548
  * which turns the body the client sent into that same second
493
549
  * request. See isByteCount
550
+ * a method nobody defines uWS takes any token as the method, so anything at all
551
+ * followed by a space and a path is a request line to it. A
552
+ * request with no content-length and no transfer-encoding has
553
+ * no body, so the bytes after it are the next request: node
554
+ * reads them and answers 400, uWS served them. See KNOWN_METHODS
494
555
  *
495
- * Either way it is request smuggling, and the request is refused rather than routed. Declared
496
- * for the same reason as rawIp.
556
+ * Declared for the same reason as rawIp.
497
557
  *
498
558
  * @type {boolean|undefined}
499
559
  */
500
- _badFraming;
560
+ _mustRefuse;
501
561
 
502
562
  /**
503
563
  * Whether the client asked for the connection to be closed. Declared for the same reason.
@@ -567,7 +627,7 @@ module.exports = class Request extends LazyReadable {
567
627
  // A content-length of "0" declares no body and used to stay on the cheap side, but
568
628
  // getHeader only ever returns the first of a repeated header, so a duplicate cannot be
569
629
  // seen from here, and a duplicate has to be refused rather than routed: see
570
- // _badFraming. Anything that says a word about framing takes the full copy instead.
630
+ // _mustRefuse. Anything that says a word about framing takes the full copy instead.
571
631
  //
572
632
  // One shape stays invisible here, a content-length present with an empty value: uWS
573
633
  // answers "" for that and for a header that was never sent, and nothing in its API
@@ -632,7 +692,7 @@ module.exports = class Request extends LazyReadable {
632
692
  }
633
693
  if (preset) {
634
694
  // the registration's constants: two native crossings and their strings not asked for
635
- this.path = preset.path;
695
+ this._path = preset.path;
636
696
  this.originalUrl = preset.path + this.urlQuery;
637
697
  this.url = this.originalUrl;
638
698
  this._lastUrl = this.originalUrl;
@@ -646,17 +706,27 @@ module.exports = class Request extends LazyReadable {
646
706
  // getUrl() is the path already, so the query is joined on and then not split off
647
707
  // again. Building originalUrl and picking the path back out of it with indexOf and
648
708
  // substring was a search and a second string for something uWS had just handed over.
649
- this.path = req.getUrl();
650
- this.originalUrl = this.path + this.urlQuery;
709
+ this._path = req.getUrl();
710
+ this.originalUrl = this._path + this.urlQuery;
651
711
  this.url = this.originalUrl;
652
712
  // what the router last wrote to req.url. A middleware assigning something else is a
653
713
  // rewrite, which express honours, and dispatch compares against this to notice it
654
714
  this._lastUrl = this.originalUrl;
655
715
  // charCodeAt rather than indexing: s[i] builds a one character string to throw away
656
- this.endsWithSlash = this.path.charCodeAt(this.path.length - 1) === 0x2f;
657
- this._opPath = this.path;
658
- this._originalPath = this.path;
659
- this.method = req.getCaseSensitiveMethod().toUpperCase();
716
+ this.endsWithSlash = this._path.charCodeAt(this._path.length - 1) === 0x2f;
717
+ this._opPath = this._path;
718
+ this._originalPath = this._path;
719
+ const rawMethod = req.getCaseSensitiveMethod();
720
+ this.method = rawMethod.toUpperCase();
721
+ // node's parser knows a fixed set and refuses everything else; µWS takes the token as
722
+ // it finds it, so a request line is anything with a space in it. Compared before the
723
+ // uppercasing on purpose: a method is case sensitive, node refuses "post", and µWS
724
+ // folds it to POST and serves it. Only asked of a method the framework cannot route
725
+ // anyway, since a route can only be registered for one of these, see the loop that
726
+ // builds the verb methods at the end of router.js
727
+ if (!KNOWN_METHODS.has(rawMethod)) {
728
+ this._mustRefuse = true;
729
+ }
660
730
  this._isOptions = this.method === "OPTIONS";
661
731
  this._isHead = this.method === "HEAD";
662
732
  }
@@ -1004,6 +1074,17 @@ module.exports = class Request extends LazyReadable {
1004
1074
  return index !== -1 ? header.slice(0, index).trim() : header.trim();
1005
1075
  }
1006
1076
 
1077
+ /**
1078
+ * The path of the current url, without the query and relative to the mount the request is in.
1079
+ * A getter rather than a field, because express recomputes it from req.url on every read, see
1080
+ * currentPath.
1081
+ *
1082
+ * @returns {string}
1083
+ */
1084
+ get path() {
1085
+ return currentPath(this);
1086
+ }
1087
+
1007
1088
  /**
1008
1089
  * Takes over what a middleware assigned to req.url: the remaining routing matches the new
1009
1090
  * path, and req.query reflects the new query string. The assigned url is relative to the
@@ -1025,7 +1106,7 @@ module.exports = class Request extends LazyReadable {
1025
1106
  // a rewrite to "/a?" keeps its "?", as one arriving that way does
1026
1107
  this.urlQuery = queryIndex === -1 ? "" : "?" + this._rawQuery;
1027
1108
  this._originalPath = prefix + newPath;
1028
- this.path = newPath;
1109
+ this._path = newPath;
1029
1110
  this.endsWithSlash = newPath.charCodeAt(newPath.length - 1) === 0x2f;
1030
1111
  this._opPath = newPath;
1031
1112
  this._opPathLower = null;
package/src/router.js CHANGED
@@ -241,7 +241,18 @@ class Walk {
241
241
  // was one closure per hop of every request not on a compiled chain
242
242
  for (; routeIndex < routes.length; routeIndex++) {
243
243
  const r = routes[routeIndex];
244
- if (!(r.all || r.method === req.method || req._isOptions || (r.gettable && req._isHead))) {
244
+ // A HEAD request enters a route whose path matched even when its verb cannot serve
245
+ // one: express exempts HEAD from the method check ("if (!hasMethod && method !==
246
+ // 'HEAD')" in router/index.js), so the layer's parameters are captured and its
247
+ // param() callbacks run before the route is dropped. Only asked when the router has
248
+ // callbacks to run, since entering a route to step straight back out of it is
249
+ // otherwise pure cost with nothing to show for it. runRoute steps over it.
250
+ if (!(
251
+ r.all ||
252
+ r.method === req.method ||
253
+ req._isOptions ||
254
+ (req._isHead && (r.gettable || r.paramCallbacks.size > 0))
255
+ )) {
245
256
  // taken only to fail: _preprocessRequest decodes again and turns it into the
246
257
  // error, so the handlers of a route this request cannot run never see it
247
258
  if (mayFailDecode && router._pathMatches(r, req) && router._paramsFailToDecode(r, req)) {
@@ -343,6 +354,13 @@ class Walk {
343
354
  runRoute(continueRoute) {
344
355
  const req = this.req;
345
356
  const route = this.route;
357
+ // A compiled chain walks into a mount rather than entering it, so the rule above needs
358
+ // saying here as well: everything after this marker is inside the mount, and a mount is
359
+ // stepped over while an error is in flight. Leaving the chain is what running out of it
360
+ // already means, and ordinary routing takes over after the mount.
361
+ if (route.keepMount === true && req._error) {
362
+ return this.dispatch(this.routes.length);
363
+ }
346
364
  if (route.use) {
347
365
  if (route.mountApp) {
348
366
  // optimized chain: normal dispatch swaps req.app when it enters a mounted
@@ -473,7 +491,12 @@ class Walk {
473
491
  if (!this.skipCheck && this.skipUntil && this.skipUntil.routeKey >= route.routeKey) {
474
492
  return this.step(undefined);
475
493
  }
476
- if (kind === CALLBACK_ROUTER) {
494
+ // A mounted router or application is stepped over while an error is in flight. Its handle
495
+ // takes three arguments, so express's Layer#handleError hands the error straight on without
496
+ // entering it: what a mount catches is what it raised itself. Entering it ran the error
497
+ // handlers written inside the mount, and left req.app pointing at a mounted application,
498
+ // whose settings then answered. A 500 carried an ETag under app.set("etag", false).
499
+ if (kind === CALLBACK_ROUTER && !req._error) {
477
500
  if (callback._isApplication) {
478
501
  rememberApp(this, route, req);
479
502
  useApp(req, callback);
@@ -514,20 +537,20 @@ class Walk {
514
537
  if (parentMethods !== null) {
515
538
  req._matchedMethods = parentMethods;
516
539
  }
517
- if (req._isOptions && childMethods.size) {
540
+ if (req._isOptions && childMethods.size && !req._error) {
518
541
  // OPTIONS routing is different, it stops in the router if matched.
519
542
  // Express answers as the router hands back, so a throw while answering,
520
543
  // a head already written being the way, walks on to later error handlers
521
- if (!req._error) {
522
- try {
523
- router._sendOptionsReply(req, res, childMethods);
524
- return this.resolve(true);
525
- } catch (err) {
526
- return this.step(err);
527
- }
544
+ try {
545
+ router._sendOptionsReply(req, res, childMethods);
546
+ return this.resolve(true);
547
+ } catch (err) {
548
+ return this.step(err);
528
549
  }
529
- return this.resolve(false);
530
550
  }
551
+ // An error carried out of the mount is not answered by the automatic reply, and
552
+ // stopping here handed it to the default page: express walks on to the error
553
+ // handlers written after the mount, for OPTIONS as for any other method.
531
554
  this.step(undefined);
532
555
  })
533
556
  // a rejection out of the nested walk, or a throw above, must reject this one
@@ -550,6 +573,11 @@ class Walk {
550
573
  }
551
574
  return this.step(undefined);
552
575
  }
576
+ // entered only so its param callbacks could run, see the scan in dispatch: the verb
577
+ // cannot serve a HEAD, so nothing here answers it
578
+ if (req._isHead && !route.all && !route.gettable && route.method !== "HEAD") {
579
+ return this.step(undefined);
580
+ }
553
581
 
554
582
  const out = callback(req, res, this.next);
555
583
  if (out instanceof Promise) {
@@ -667,10 +695,54 @@ function setMountedPath(req) {
667
695
  req._opPath = req._consumed === 0 ? req._originalPath : req._originalPath.slice(req._consumed);
668
696
  req._opPathLower = null;
669
697
  req.url = req._opPath === "" ? "/" + req.urlQuery : req._opPath + req.urlQuery;
670
- req.path = req._opPath === "" ? "/" : req._opPath;
698
+ req._path = req._opPath === "" ? "/" : req._opPath;
671
699
  req._lastUrl = req.url;
672
700
  }
673
701
 
702
+ // req.path as the request class declares it, taken off the prototype rather than written out a
703
+ // second time. A request the router adopts is a plain object and gets it defined on itself, see
704
+ // adoptPlainRequest. Enumerable, as express's own is.
705
+ const PATH_PROPERTY = {
706
+ .../** @type {PropertyDescriptor} */ (Object.getOwnPropertyDescriptor(Request.prototype, "path")),
707
+ enumerable: true
708
+ };
709
+
710
+ const NO_PARAM_NAMES = [];
711
+
712
+ /**
713
+ * The parameter names a route captures with its own pattern.
714
+ *
715
+ * This is the set express runs param callbacks for. A name that reached req.params from a mount
716
+ * above, through mergeParams, belongs to that mount's router and not to this one, and express does
717
+ * not call this router's param() for it: it walks the keys the layer itself matched. Reading
718
+ * req.params instead ran a callback for every inherited name too, which is visible whenever such a
719
+ * callback does anything, and turned a 200 into a 500 when one of them refused the value.
720
+ *
721
+ * Worked out once per route and kept, since it follows from the pattern and never changes.
722
+ *
723
+ * @param {any} route
724
+ * @returns {string[]}
725
+ */
726
+ function ownParamNames(route) {
727
+ let names = route._ownParamNames;
728
+ if (names !== undefined) {
729
+ return names;
730
+ }
731
+ if (route.optimizedParams) {
732
+ // µWS matched the pattern and hands the values back by position, under these names
733
+ names = route.optimizedParams;
734
+ } else if (route.pattern instanceof RegExp) {
735
+ const meta = getPatternMeta(route.pattern);
736
+ // outputNames is what _extractParams writes into params; a RegExp the application wrote
737
+ // itself was never compiled here, so its capture groups are the names
738
+ names = meta ? meta.outputNames : regexpGroupKeys(route.pattern);
739
+ } else {
740
+ names = NO_PARAM_NAMES;
741
+ }
742
+ route._ownParamNames = names;
743
+ return names;
744
+ }
745
+
674
746
  /**
675
747
  * Whether this route reads the parameters of the mounts above it, which is its own router asking
676
748
  * for them. The stack holds what a mergeParams router captured on the way in, and a plain router
@@ -778,7 +850,10 @@ function adoptPlainRequest(req, router) {
778
850
  const path = queryIndex === -1 ? raw : raw.slice(0, queryIndex);
779
851
  req.urlQuery = queryIndex === -1 ? "" : raw.slice(queryIndex);
780
852
  req._rawQuery = req.urlQuery.slice(1);
781
- req.path = path;
853
+ req._path = path;
854
+ // an adopted request is a plain object, so it carries no prototype of ours and reads its path
855
+ // off a property of its own. The class's getter itself, so there is one of it
856
+ Object.defineProperty(req, "path", PATH_PROPERTY);
782
857
  req.originalUrl = req.originalUrl ?? arrived;
783
858
  req._originalPath = path;
784
859
  req.endsWithSlash = path.charCodeAt(path.length - 1) === 0x2f;
@@ -1796,6 +1871,16 @@ module.exports = class Router extends EventEmitter {
1796
1871
  }
1797
1872
  }
1798
1873
 
1874
+ // The same rule as the one just above, which a route of another method reaches by
1875
+ // another road. A mount's chain is inherited by every path under it, and a route that
1876
+ // is not itself a mount answers the mount point rather than the subtree: in the chain
1877
+ // it ran for the whole of it, so router.all("/:p1") answered the /posts/a-b that
1878
+ // belongs to the router mounted at /posts. guardsInside is written for this: only
1879
+ // layers with more segments than the mount path are asked about a leaf.
1880
+ if (route.use && !r.use && typeof route.path === "string" && couldAnswer(r, route.path)) {
1881
+ return false;
1882
+ }
1883
+
1799
1884
  // a RegExp mount runs only where its match starts the path and breaks on a separator,
1800
1885
  // which is decidable here against a literal path and not against one with a parameter
1801
1886
  if (r.regexMount) {
@@ -1822,13 +1907,9 @@ module.exports = class Router extends EventEmitter {
1822
1907
 
1823
1908
  // check if the paths match. A route with parameters is excluded from the text test:
1824
1909
  // its literal ":name" text would let an earlier regex in on requests it never matches.
1825
- // Both spellings of this route's path are tried, because without strict routing it
1826
- // answers "/x/" as well as "/x", and an earlier pattern that matches only the first is
1827
- // still an earlier pattern that answers a request this registration would take.
1910
+ const regexCanMatch = r.pattern instanceof RegExp && (!withParams || r.use);
1828
1911
  if (
1829
- (r.pattern instanceof RegExp &&
1830
- (!withParams || r.use) &&
1831
- (r.pattern.test(route.path) || (!strictHere && r.pattern.test(route.path + "/")))) ||
1912
+ (regexCanMatch && r.pattern.test(route.path)) ||
1832
1913
  (typeof r.pattern === "string" &&
1833
1914
  (r.pattern === route.path ||
1834
1915
  (!caseSensitive && r.pattern.toLowerCase() === routePathFolded) ||
@@ -1840,6 +1921,15 @@ module.exports = class Router extends EventEmitter {
1840
1921
  optimizedPath.push(r);
1841
1922
  continue;
1842
1923
  }
1924
+ // Without strict routing this registration answers "/x/" as well as "/x". An earlier
1925
+ // pattern matching only the second answers part of what the registration takes and not
1926
+ // the rest, which the chain has no way to say: it runs what is in it without matching
1927
+ // again. Both spellings used to put the route in whole, so app.all("/:p0/{:o1}/{:o2}")
1928
+ // answered a GET /list/Mixed that belonged to the route written after it. An ordinary
1929
+ // pattern answers both spellings, so only an optional group or a wildcard reaches here.
1930
+ if (regexCanMatch && !strictHere && r.pattern.test(route.path + "/")) {
1931
+ return false;
1932
+ }
1843
1933
  if (!withParams) {
1844
1934
  continue;
1845
1935
  }
@@ -1899,6 +1989,13 @@ module.exports = class Router extends EventEmitter {
1899
1989
  if (!this.uwsApp) {
1900
1990
  return;
1901
1991
  }
1992
+ // Everything below is what makes this framework fast, and every one of its decisions is a
1993
+ // claim that µWS answering by itself is the same answer the chain would have given. Turned
1994
+ // off, the claim is not made and the chain answers everything. Serving one application both
1995
+ // ways and comparing the answers is what tests those claims: `npm run fuzz -- --self`.
1996
+ if (this.get("native routes") === false) {
1997
+ return;
1998
+ }
1902
1999
 
1903
2000
  // pathPrefix/chainPrefix accumulate across nested sole-callback mounts, and outerGuards
1904
2001
  // carries what was written before them and answers only part of what is under them
@@ -2083,7 +2180,7 @@ module.exports = class Router extends EventEmitter {
2083
2180
  *
2084
2181
  * @param {any} response
2085
2182
  */
2086
- _refuseFraming(response) {
2183
+ _refuseRequest(response) {
2087
2184
  response.finished = true;
2088
2185
  response._res.close();
2089
2186
  response.emit("close");
@@ -2195,8 +2292,8 @@ module.exports = class Router extends EventEmitter {
2195
2292
  }
2196
2293
  const request = this.handleRequest(res, req, preset, skipHolder);
2197
2294
  const response = request.res;
2198
- if (request._badFraming === true) {
2199
- return this._refuseFraming(response);
2295
+ if (request._mustRefuse === true) {
2296
+ return this._refuseRequest(response);
2200
2297
  }
2201
2298
  if (optimizedParams) {
2202
2299
  request.optimizedParams = new NullObject();
@@ -2295,6 +2392,10 @@ module.exports = class Router extends EventEmitter {
2295
2392
  route.callbacks.length === 1 && // must not have multiple callbacks
2296
2393
  typeof route.callbacks[0] === "function" && // must be a function
2297
2394
  route.paramCallbacks.size === 0 && // a param callback has to run, and this answers without running anything
2395
+ // a captured value is decoded when the route runs, and one that cannot be decoded is a
2396
+ // 400 in express and on the ordinary path here. Nothing runs to raise it on a
2397
+ // declarative response, so GET /a-b%5Ec@d%e came back 200 from app.get("/:p12")
2398
+ route.optimizedParams === undefined &&
2298
2399
  // a declarative response is answered by µWS itself, so no javascript runs and the case
2299
2400
  // guard could not: a route that needs one has to stay an ordinary handler
2300
2401
  caseGuards === null &&
@@ -2514,8 +2615,14 @@ module.exports = class Router extends EventEmitter {
2514
2615
 
2515
2616
  // the route's own router's callbacks: an optimized chain is walked by the app even when it
2516
2617
  // ends in a mounted router's route
2618
+ //
2619
+ // A route an OPTIONS request reaches only to have its verb counted for the automatic reply
2620
+ // is not a route this request runs, and express does not run its app.param() callbacks for
2621
+ // it. The same condition runRoute counts the verb under, see the OPTIONS branch there. The
2622
+ // decoding above still happens either way, because express decodes a layer whose path
2623
+ // matched whatever its method is, which is what answers 400 for a malformed escape.
2517
2624
  const paramCallbacks = route.paramCallbacks;
2518
- if (paramCallbacks.size > 0) {
2625
+ if (paramCallbacks.size > 0 && !(req._isOptions && !route.all && route.method !== "OPTIONS")) {
2519
2626
  return this._runParamCallbacks(req, res, route, paramCallbacks);
2520
2627
  }
2521
2628
  return true;
@@ -2558,9 +2665,14 @@ module.exports = class Router extends EventEmitter {
2558
2665
  * @returns {Promise<true|"route">|true}
2559
2666
  */
2560
2667
  _runParamCallbacks(req, res, route, paramCallbacks) {
2668
+ // the names this route captured itself, not everything in req.params: a merged-in name
2669
+ // belongs to the mount that captured it, see ownParamNames
2561
2670
  let names;
2562
- for (const name in req.params) {
2563
- if (paramCallbacks.has(name)) {
2671
+ const own = ownParamNames(route);
2672
+ for (let i = 0; i < own.length; i++) {
2673
+ const name = own[i];
2674
+ // an optional group that did not match leaves no parameter to call anything for
2675
+ if (paramCallbacks.has(name) && req.params[name] !== undefined) {
2564
2676
  (names ??= []).push(name);
2565
2677
  }
2566
2678
  }
package/src/utils.js CHANGED
@@ -409,8 +409,21 @@ function patternToRegex(pattern, isPrefix = false, caseSensitive = true, strict
409
409
  // splits /a.b.c against /:file{.:ext} as file=a.b, ext=c, which only works if ext
410
410
  // cannot contain a dot. After static text there is nothing to give ground, so the
411
411
  // parameter takes everything: /file{.:ext} against /file.tar.gz gives ext=tar.gz.
412
- const separator = lastTokenWasParam && groupContent[0] && groupContent[0] !== ":" ? groupContent[0] : "";
413
- const groupParamClass = `[^/${separator.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}]+`;
412
+ // The whole of it, not its first character: /:foo{abc:bar} against /123abcabc splits as
413
+ // foo=123 and bar=abc on express, and reading the separator as "a" left bar unable to
414
+ // match its own text, so the group never matched and foo took the segment whole. More
415
+ // than one character cannot go in a class, so it is written as a lookahead, and either
416
+ // way the parameter is still allowed to be exactly the separator, as path-to-regexp
417
+ // writes it.
418
+ const colon = groupContent.indexOf(":");
419
+ const separator = lastTokenWasParam && colon > 0 ? groupContent.slice(0, colon) : "";
420
+ const escapedSeparator = separator.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
421
+ const groupParamClass =
422
+ separator === ""
423
+ ? "[^/]+"
424
+ : separator.length === 1
425
+ ? `[^/${escapedSeparator}]+|${escapedSeparator}`
426
+ : `(?:(?!${escapedSeparator})[^/])+|${escapedSeparator}`;
414
427
 
415
428
  let groupRegex = "";
416
429
  let gi = 0;
@@ -596,13 +609,23 @@ function canBeOptimizedWithParams(pattern) {
596
609
  return true;
597
610
  }
598
611
 
612
+ // What makes a segment something other than the text it is written as: a parameter, a wildcard, an
613
+ // optional group, or an escape. Only two plain literals can prove that two paths never meet, so
614
+ // anything carrying one of these has to be read as "could be anything".
615
+ const NOT_A_LITERAL = /[:*{}\\]/;
616
+
599
617
  /**
600
618
  * Whether two paths could both match the same request.
601
619
  *
602
- * Only asked about paths µWS could match itself, so the answer is structural: the same number of
603
- * segments, and no position where two different literals meet. `/orders/:id` and `/invoices/:id`
604
- * cannot both match, `/users/:id` and `/users/me` can. Anything else is not asked, and the caller
605
- * reads "do not know" as yes.
620
+ * The answer is structural: no position where two different literals meet, and, when neither path
621
+ * can change length, the same number of segments. `/orders/:id` and `/invoices/:id` cannot both
622
+ * match, `/users/:id` and `/users/me` can. The caller reads "do not know" as yes, so every doubt
623
+ * answers true: saying two paths overlap only costs a native registration, while missing one lets
624
+ * µWS answer a request that belonged to an earlier route.
625
+ *
626
+ * A parameter is not the only shape that matches more than itself. A wildcard and an optional group
627
+ * do too, and reading `{:opt}` or `*splat` as the literal text it is written as reported "cannot
628
+ * overlap" for a route that plainly could, which took the earlier route's turn away.
606
629
  *
607
630
  * @param {string} a
608
631
  * @param {string} b
@@ -612,15 +635,19 @@ function canBeOptimizedWithParams(pattern) {
612
635
  function pathsCanOverlap(a, b, aIsPrefix = false) {
613
636
  const left = a.split("/");
614
637
  const right = b.split("/");
615
- if (aIsPrefix ? left.length > right.length : left.length !== right.length) {
638
+ // An optional group matches its segment or nothing at all and a wildcard matches several, so a
639
+ // path carrying either one matches more than one length and the count settles nothing.
640
+ const fixedLength =
641
+ a.indexOf("{") === -1 && b.indexOf("{") === -1 && a.indexOf("*") === -1 && b.indexOf("*") === -1;
642
+ if (fixedLength && (aIsPrefix ? left.length > right.length : left.length !== right.length)) {
616
643
  return false;
617
644
  }
618
- for (let i = 0; i < left.length; i++) {
645
+ const shared = left.length < right.length ? left.length : right.length;
646
+ for (let i = 0; i < shared; i++) {
619
647
  if (left[i] === right[i]) {
620
648
  continue;
621
649
  }
622
- // a parameter matches whatever is in that segment, so only two different literals settle it
623
- if (left[i].charCodeAt(0) === 0x3a || right[i].charCodeAt(0) === 0x3a) {
650
+ if (NOT_A_LITERAL.test(left[i]) || NOT_A_LITERAL.test(right[i])) {
624
651
  continue;
625
652
  }
626
653
  return false;
@@ -947,6 +974,13 @@ const defaultSettings = {
947
974
  // The native µWS router matches bytes, so the compiler in _compileOptimizedRoutes only hands
948
975
  // it routes whose earlier siblings it can prove agree under either case rule.
949
976
  "declarative responses": true,
977
+ // on. Off hands every request to the ordinary chain instead of letting µWS match what it can,
978
+ // which is slower and answers the same. Not a tuning knob: it exists so one application can be
979
+ // served both ways and the two sets of answers compared, which tests the optimizer against the
980
+ // rest of the framework without a second framework to compare with. See
981
+ // `npm run fuzz -- --self`. A compiled response needs a native registration to hang on, so this
982
+ // takes "declarative responses" with it.
983
+ "native routes": true,
950
984
  // off: with a window set, the size and mtime of a file served by sendFile are remembered for
951
985
  // it, which is one syscall less per request and a file that can be served as it was a moment
952
986
  // ago. "stat cache ms" is the window in milliseconds, compiled from it by set()
@@ -1436,6 +1470,32 @@ function withUtf8Charset(value) {
1436
1470
  const HEADER_TOKEN = /^[\^_`a-zA-Z\-0-9!#$%&'*+.|~]+$/;
1437
1471
  const HEADER_VALUE = /[^\t\x20-\x7e\x80-\xff]/;
1438
1472
 
1473
+ /**
1474
+ * One of node's header errors, built the way node builds it.
1475
+ *
1476
+ * Assigning the code is not the whole of it. Node also puts the code in the first line of the
1477
+ * stack, by naming the error "TypeError [THE_CODE]" while V8 formats that line and then taking
1478
+ * the name back off. Whatever prints a stack therefore says which code it was, and the default
1479
+ * error page prints exactly that: without this, the same refusal reads "TypeError:" here and
1480
+ * "TypeError [ERR_INVALID_CHAR]:" behind Express. Found by fuzzing against express.
1481
+ *
1482
+ * @param {string} message
1483
+ * @param {string} code
1484
+ * @returns {NodeJS.ErrnoException}
1485
+ */
1486
+ function headerError(message, code) {
1487
+ /** @type {NodeJS.ErrnoException} */
1488
+ const err = new TypeError(message);
1489
+ err.name = `TypeError [${code}]`;
1490
+ // reading it is what makes V8 format the line, and it formats it from the name above
1491
+ void err.stack;
1492
+ // back to the prototype's "TypeError", which is what node leaves behind. Cast because Error
1493
+ // declares name as always present, and this deletes the own property to uncover it again
1494
+ delete (/** @type {any} */ (err).name);
1495
+ err.code = code;
1496
+ return err;
1497
+ }
1498
+
1439
1499
  /**
1440
1500
  * Refuses a header name that is not an HTTP token, the way node's setHeader does and with its
1441
1501
  * error, so an application catching ERR_INVALID_HTTP_TOKEN behind Express catches it here.
@@ -1446,10 +1506,7 @@ const HEADER_VALUE = /[^\t\x20-\x7e\x80-\xff]/;
1446
1506
  */
1447
1507
  function validateHeaderName(name) {
1448
1508
  if (typeof name !== "string" || !HEADER_TOKEN.test(name)) {
1449
- /** @type {NodeJS.ErrnoException} */
1450
- const err = new TypeError(`Header name must be a valid HTTP token ["${name}"]`);
1451
- err.code = "ERR_INVALID_HTTP_TOKEN";
1452
- throw err;
1509
+ throw headerError(`Header name must be a valid HTTP token ["${name}"]`, "ERR_INVALID_HTTP_TOKEN");
1453
1510
  }
1454
1511
  }
1455
1512
 
@@ -1471,10 +1528,7 @@ function validateHeaderValue(name, value) {
1471
1528
  return;
1472
1529
  }
1473
1530
  if (HEADER_VALUE.test(value)) {
1474
- /** @type {NodeJS.ErrnoException} */
1475
- const err = new TypeError(`Invalid character in header content ["${name}"]`);
1476
- err.code = "ERR_INVALID_CHAR";
1477
- throw err;
1531
+ throw headerError(`Invalid character in header content ["${name}"]`, "ERR_INVALID_CHAR");
1478
1532
  }
1479
1533
  }
1480
1534