fulmine.js 5.12.2 → 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,7 +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 `Content-Length` cannot be trusted is refused by hanging up, with no answer at all.** Node's parser refuses two of these with a `400` and Fulmine refuses the same two: a repeated `Content-Length`, whatever the values say, and one that is not a plain count of bytes, an empty value included. µWS accepts both and frames the request on the first value, or on no body at all, so what the client sent as a body is read as the next request on the connection: that is request smuggling, and a proxy in front reading the other value 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 two content-lengths.
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.
289
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.
290
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`.
291
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.2",
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
@@ -354,6 +354,13 @@ class Walk {
354
354
  runRoute(continueRoute) {
355
355
  const req = this.req;
356
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
+ }
357
364
  if (route.use) {
358
365
  if (route.mountApp) {
359
366
  // optimized chain: normal dispatch swaps req.app when it enters a mounted
@@ -484,7 +491,12 @@ class Walk {
484
491
  if (!this.skipCheck && this.skipUntil && this.skipUntil.routeKey >= route.routeKey) {
485
492
  return this.step(undefined);
486
493
  }
487
- 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) {
488
500
  if (callback._isApplication) {
489
501
  rememberApp(this, route, req);
490
502
  useApp(req, callback);
@@ -683,10 +695,18 @@ function setMountedPath(req) {
683
695
  req._opPath = req._consumed === 0 ? req._originalPath : req._originalPath.slice(req._consumed);
684
696
  req._opPathLower = null;
685
697
  req.url = req._opPath === "" ? "/" + req.urlQuery : req._opPath + req.urlQuery;
686
- req.path = req._opPath === "" ? "/" : req._opPath;
698
+ req._path = req._opPath === "" ? "/" : req._opPath;
687
699
  req._lastUrl = req.url;
688
700
  }
689
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
+
690
710
  const NO_PARAM_NAMES = [];
691
711
 
692
712
  /**
@@ -830,7 +850,10 @@ function adoptPlainRequest(req, router) {
830
850
  const path = queryIndex === -1 ? raw : raw.slice(0, queryIndex);
831
851
  req.urlQuery = queryIndex === -1 ? "" : raw.slice(queryIndex);
832
852
  req._rawQuery = req.urlQuery.slice(1);
833
- 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);
834
857
  req.originalUrl = req.originalUrl ?? arrived;
835
858
  req._originalPath = path;
836
859
  req.endsWithSlash = path.charCodeAt(path.length - 1) === 0x2f;
@@ -1848,6 +1871,16 @@ module.exports = class Router extends EventEmitter {
1848
1871
  }
1849
1872
  }
1850
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
+
1851
1884
  // a RegExp mount runs only where its match starts the path and breaks on a separator,
1852
1885
  // which is decidable here against a literal path and not against one with a parameter
1853
1886
  if (r.regexMount) {
@@ -1874,13 +1907,9 @@ module.exports = class Router extends EventEmitter {
1874
1907
 
1875
1908
  // check if the paths match. A route with parameters is excluded from the text test:
1876
1909
  // its literal ":name" text would let an earlier regex in on requests it never matches.
1877
- // Both spellings of this route's path are tried, because without strict routing it
1878
- // answers "/x/" as well as "/x", and an earlier pattern that matches only the first is
1879
- // still an earlier pattern that answers a request this registration would take.
1910
+ const regexCanMatch = r.pattern instanceof RegExp && (!withParams || r.use);
1880
1911
  if (
1881
- (r.pattern instanceof RegExp &&
1882
- (!withParams || r.use) &&
1883
- (r.pattern.test(route.path) || (!strictHere && r.pattern.test(route.path + "/")))) ||
1912
+ (regexCanMatch && r.pattern.test(route.path)) ||
1884
1913
  (typeof r.pattern === "string" &&
1885
1914
  (r.pattern === route.path ||
1886
1915
  (!caseSensitive && r.pattern.toLowerCase() === routePathFolded) ||
@@ -1892,6 +1921,15 @@ module.exports = class Router extends EventEmitter {
1892
1921
  optimizedPath.push(r);
1893
1922
  continue;
1894
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
+ }
1895
1933
  if (!withParams) {
1896
1934
  continue;
1897
1935
  }
@@ -1951,6 +1989,13 @@ module.exports = class Router extends EventEmitter {
1951
1989
  if (!this.uwsApp) {
1952
1990
  return;
1953
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
+ }
1954
1999
 
1955
2000
  // pathPrefix/chainPrefix accumulate across nested sole-callback mounts, and outerGuards
1956
2001
  // carries what was written before them and answers only part of what is under them
@@ -2135,7 +2180,7 @@ module.exports = class Router extends EventEmitter {
2135
2180
  *
2136
2181
  * @param {any} response
2137
2182
  */
2138
- _refuseFraming(response) {
2183
+ _refuseRequest(response) {
2139
2184
  response.finished = true;
2140
2185
  response._res.close();
2141
2186
  response.emit("close");
@@ -2247,8 +2292,8 @@ module.exports = class Router extends EventEmitter {
2247
2292
  }
2248
2293
  const request = this.handleRequest(res, req, preset, skipHolder);
2249
2294
  const response = request.res;
2250
- if (request._badFraming === true) {
2251
- return this._refuseFraming(response);
2295
+ if (request._mustRefuse === true) {
2296
+ return this._refuseRequest(response);
2252
2297
  }
2253
2298
  if (optimizedParams) {
2254
2299
  request.optimizedParams = new NullObject();
@@ -2347,6 +2392,10 @@ module.exports = class Router extends EventEmitter {
2347
2392
  route.callbacks.length === 1 && // must not have multiple callbacks
2348
2393
  typeof route.callbacks[0] === "function" && // must be a function
2349
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 &&
2350
2399
  // a declarative response is answered by µWS itself, so no javascript runs and the case
2351
2400
  // guard could not: a route that needs one has to stay an ordinary handler
2352
2401
  caseGuards === null &&
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;
@@ -961,6 +974,13 @@ const defaultSettings = {
961
974
  // The native µWS router matches bytes, so the compiler in _compileOptimizedRoutes only hands
962
975
  // it routes whose earlier siblings it can prove agree under either case rule.
963
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,
964
984
  // off: with a window set, the size and mtime of a file served by sendFile are remembered for
965
985
  // it, which is one syscall less per request and a file that can be served as it was a moment
966
986
  // ago. "stat cache ms" is the window in milliseconds, compiled from it by set()