fulmine.js 5.13.2 → 5.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/NOTICE CHANGED
@@ -79,6 +79,37 @@ significant changes made to the original work:
79
79
  - The app is callable as a request listener, so http.createServer(app),
80
80
  supertest and anything else that invokes an app directly keeps working
81
81
  through a node:http shim.
82
+ - Routing rebuilt on Express 5 semantics: its path matcher, case-insensitive
83
+ matching by default, req.route, and assigning req.url inside a middleware
84
+ re-routes the rest of the stack.
85
+ - The routes listen() can read are compiled into one handler, with an overlap
86
+ analysis deciding which of them may skip the middleware chain, and a
87
+ self-check mode that serves the same application twice to show the compiled
88
+ path answers as the chain it stands in for.
89
+ - ETags are generated in this package rather than by the etag module, and an
90
+ "etag methods" setting limits them to the methods it names.
91
+ - express.compression() added, the compression module's middleware built in: a
92
+ body that arrives whole is compressed in one call, and partial content is
93
+ left alone.
94
+ - express.serverTiming() added, which reports on the response how the request
95
+ was routed.
96
+ - express.static serves the .br and .gz twins of a file with preCompressed.
97
+ - app.ws() serves websockets on the same port, with an upgrade hook and the
98
+ request on the socket.
99
+ - express({ cluster: "auto" }) forks one worker per core on the same port.
100
+ - express.Route and express.testing added, the second one asserting what
101
+ listen() decided about a route.
102
+ - X-Powered-By is not sent unless the application asks for it, and the header
103
+ methods, res.flushHeaders and the node members that were missing are filled
104
+ in.
105
+ - An adapter for NestJS ships as fulmine.js/nest.
106
+ - A fulmine command added: it profiles and explains what listen() worked out
107
+ about each route, verifies the machine, migrates a project, and prints the
108
+ differences from Express.
109
+ - Random applications are generated and compared against Express round by
110
+ round, with the request bytes, the methods that compute a header value and
111
+ keep-alive sequences each having their own fuzzer, and applications built on
112
+ four frameworks run as an integration suite.
82
113
  - Releases are built and published to npm from CI.
83
114
  - Benchmark harness reworked: wrk replaced by autocannon so the suite runs
84
115
  anywhere Node does, NODE_ENV is set, load errors and response validation are
package/README.md CHANGED
@@ -44,7 +44,6 @@ See [Migrating](#migrating) for what it handles and what it deliberately does no
44
44
  - [Why this exists](#why-this-exists)
45
45
  - [Performance](#performance)
46
46
  - [Public benchmarks](#public-benchmarks)
47
- - [Attribution](#attribution)
48
47
  - [Difference from similar projects](#difference-from-similar-projects)
49
48
  - [Migrating](#migrating)
50
49
  - [Angular SSR](#angular-ssr)
@@ -69,6 +68,7 @@ See [Migrating](#migrating) for what it handles and what it deliberately does no
69
68
  - [Tested frameworks](#tested-frameworks)
70
69
  - [Tested view engines](#tested-view-engines)
71
70
  - [Examples](./examples/README.md)
71
+ - [Attribution](#attribution)
72
72
  - [Working on Fulmine](./CONTRIBUTING.md)
73
73
 
74
74
  ## Why this exists
@@ -77,6 +77,8 @@ There are several fast HTTP servers for Node built on [µWebSockets.js](https://
77
77
 
78
78
  Compatibility here is not a claim, it is a test suite. Every test runs against real Express first and then against Fulmine, and the outputs have to match byte for byte. That is what makes `helmet`, `cors`, `passport`, `morgan`, `multer`, `express-session` and the rest of the ecosystem work rather than "mostly work". Express 5's own test suite runs against Fulmine too, and passes whole: 1130 passing, 0 failing at the pinned Express version.
79
79
 
80
+ It started as a fork of [Ultimate Express](https://github.com/dimdenGD/ultimate-express), which is where the hard part was already done. See [Attribution](#attribution).
81
+
80
82
  ## Performance
81
83
 
82
84
  Fulmine is faster than Express where the framework itself is doing the work, and the same speed where it is not. Both halves of that sentence matter, so here is the honest version.
@@ -104,16 +106,6 @@ Numbers produced by a project about itself deserve suspicion, so Fulmine also st
104
106
 
105
107
  More to come as their maintainers take the entries in.
106
108
 
107
- ## Attribution
108
-
109
- Fulmine is a derivative work of [Ultimate Express](https://github.com/dimdenGD/ultimate-express) by [@dimdenGD](https://github.com/dimdenGD), used under the Apache License 2.0. The full commit history is preserved, so the original authorship is visible in the repository itself.
110
-
111
- **Special thanks to [@dimdenGD](https://github.com/dimdenGD).** Ultimate Express is the hard part of this project, and it was already done before Fulmine existed. Everything here stands on that work.
112
-
113
- Fulmine is not affiliated with, endorsed by, or maintained by the authors of Ultimate Express. See [`NOTICE`](./NOTICE) for the list of significant changes.
114
-
115
- It is likewise not affiliated with the OpenJS Foundation or the Express.js project. Express is a trademark of the OpenJS Foundation.
116
-
117
109
  ## Difference from similar projects
118
110
 
119
111
  - **`ultimate-express`** is what Fulmine is derived from, and is the closest relative by far. It targets Express 4, keeps the v4 API surface and its deprecations. Fulmine targets Express 5 only, which removes the compatibility layer for everything v5 dropped, and is typed. If you are on Express 4, use `ultimate-express`.
@@ -559,6 +551,7 @@ app.ws("/room/:id", {
559
551
  - **The behavior object is µWS's**, settings included: `maxPayloadLength`, `idleTimeout`, `compression`, `maxBackpressure`, `sendPingsAutomatically` and the rest are passed through untouched, as are the `open`, `message`, `drain`, `close`, `ping`, `pong`, `dropped` and `subscription` handlers. The socket is µWS's too, so `send`, `subscribe`, `publish`, `cork` and `getBufferedAmount` behave exactly as its documentation describes.
560
552
  - **`upgrade(req, res)` is this project's addition.** It runs before the handshake with the same `Request` and `Response` your routes get, so a session, a token or a header decides whether the socket opens. Answering the response, with `res.sendStatus(401)` or any other write, declines the upgrade. Returning a promise holds the handshake until it settles, which is what an authentication lookup needs.
561
553
  - **`ws.req` is that request**, and it outlives the response: the client's address, headers, query and params are readable from any handler for as long as the socket is open. Hanging your own values on it in `upgrade` is how per-connection state gets to `message`.
554
+ - **A hook that awaits can be left holding a dead request.** The client may go while a token is being checked, and µWS frees the response when it does, so `res.aborted` says whether there is still anybody to answer. Writing to a response that was aborted does nothing rather than throwing.
562
555
  - **Routers work.** `router.ws("/lobby", ...)` mounted with `app.use("/chat", router)` serves `/chat/lobby`.
563
556
  - **Paths are the ones µWS matches**: literal, or with parameters that are a whole segment such as `/room/:id`. Anything else throws where it is written rather than failing to match later.
564
557
  - **Broadcasting from outside a socket**: `app.publish(topic, message)` and `app.numSubscribers(topic)`.
@@ -927,6 +920,16 @@ npm install
927
920
  node websocket.js
928
921
  ```
929
922
 
923
+ ## Attribution
924
+
925
+ Fulmine is a derivative work of [Ultimate Express](https://github.com/dimdenGD/ultimate-express) by [@dimdenGD](https://github.com/dimdenGD), used under the Apache License 2.0. The full commit history is preserved, so the original authorship is visible in the repository itself.
926
+
927
+ **Special thanks to [@dimdenGD](https://github.com/dimdenGD).** Ultimate Express is the hard part of this project, and it was already done before Fulmine existed. Everything here stands on that work.
928
+
929
+ Fulmine is not affiliated with, endorsed by, or maintained by the authors of Ultimate Express. See [`NOTICE`](./NOTICE) for the list of significant changes.
930
+
931
+ It is likewise not affiliated with the OpenJS Foundation or the Express.js project. Express is a trademark of the OpenJS Foundation.
932
+
930
933
  ## Working on Fulmine
931
934
 
932
935
  How to run the suites, what each of them is for, and how to write a comparison test:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.13.2",
3
+ "version": "5.14.0",
4
4
  "description": "Drop-in Express 5 replacement on uWebSockets.js. Your existing middleware keeps working.",
5
5
  "main": "src/index.js",
6
6
  "exports": {
@@ -210,9 +210,8 @@ function runVerify(req, res, next, options, buf) {
210
210
  } catch (e) {
211
211
  const err = /** @type {any} */ (e);
212
212
  next(
213
- bodyError(err.message, err.status ?? err.statusCode ?? 403, err.type ?? "entity.verify.failed", {
214
- body: buf,
215
- stack: err.stack
213
+ asBodyError(err, err.status ?? err.statusCode ?? 403, err.type ?? "entity.verify.failed", {
214
+ body: buf
216
215
  })
217
216
  );
218
217
  return false;
@@ -249,6 +248,15 @@ function strictSyntaxMessage(text, char) {
249
248
  return "strict violation";
250
249
  }
251
250
 
251
+ // The name http-errors gives each status body-parser answers with. An application reading
252
+ // err.name, or a logger printing it, sees "PayloadTooLargeError" from Express and would have
253
+ // seen a bare "Error" here.
254
+ const BODY_ERROR_NAMES = {
255
+ 400: "BadRequestError",
256
+ 413: "PayloadTooLargeError",
257
+ 415: "UnsupportedMediaTypeError"
258
+ };
259
+
252
260
  /**
253
261
  * The error a body parser hands to next(), shaped as body-parser shapes it: with a status, since
254
262
  * `res.status(err.status || 500)` would otherwise answer 500 to a request that was merely too
@@ -262,6 +270,25 @@ function strictSyntaxMessage(text, char) {
262
270
  */
263
271
  function bodyError(message, status, type, extra) {
264
272
  const err = /** @type {any} */ (new Error(message));
273
+ if (BODY_ERROR_NAMES[status]) {
274
+ err.name = BODY_ERROR_NAMES[status];
275
+ }
276
+ return asBodyError(err, status, type, extra);
277
+ }
278
+
279
+ /**
280
+ * The same, for an error somebody else made: the SyntaxError JSON.parse threw, or whatever a
281
+ * verify hook threw. http-errors decorates such an error rather than replacing it, so its name,
282
+ * its stack and any property the thrower put on it are all still there when the application
283
+ * reads it.
284
+ *
285
+ * @param {any} err
286
+ * @param {number} status
287
+ * @param {string} type body-parser's own name for the kind of failure
288
+ * @param {object} [extra] anything else body-parser puts on that particular error
289
+ * @returns {Error}
290
+ */
291
+ function asBodyError(err, status, type, extra) {
265
292
  // 4xx is the client's to see; a 5xx here would be the server's own problem and stays hidden
266
293
  err.expose = status < 500;
267
294
  err.statusCode = status;
@@ -1246,17 +1273,23 @@ const json = createBodyParser(
1246
1273
  // eslint-disable-next-line no-control-regex
1247
1274
  const first = text.match(/^[\x20\x09\x0a\x0d]*([^\x20\x09\x0a\x0d])/)?.[1];
1248
1275
  if (first !== "{" && first !== "[") {
1249
- return next(bodyError(strictSyntaxMessage(text, first), 400, "entity.parse.failed", { body: text }));
1276
+ // a SyntaxError rather than an Error, since that is what body-parser builds here and
1277
+ // what an application testing `err instanceof SyntaxError` looks for
1278
+ return next(
1279
+ asBodyError(new SyntaxError(strictSyntaxMessage(text, first)), 400, "entity.parse.failed", {
1280
+ body: text
1281
+ })
1282
+ );
1250
1283
  }
1251
1284
  }
1252
1285
 
1253
1286
  try {
1254
1287
  req.body = JSON.parse(text, options.reviver);
1255
1288
  } catch (e) {
1256
- // the JSON error's own message, which is what body-parser keeps, so an application
1257
- // showing err.message still says where the parse gave up
1289
+ // V8's own error, which is what body-parser hands on: its message says where the parse
1290
+ // gave up, and it is still the SyntaxError an application may be testing for
1258
1291
  const err = /** @type {any} */ (e);
1259
- return next(bodyError(err.message, 400, "entity.parse.failed", { body: text }));
1292
+ return next(asBodyError(err, 400, "entity.parse.failed", { body: text }));
1260
1293
  }
1261
1294
 
1262
1295
  next();
package/src/request.js CHANGED
@@ -17,7 +17,7 @@ See the License for the specific language governing permissions and
17
17
  limitations under the License.
18
18
  */
19
19
 
20
- const { deprecated } = require("./utils.js");
20
+ const { deprecated, fastQueryParse } = require("./utils.js");
21
21
  const accepts = require("accepts");
22
22
  const typeis = require("type-is");
23
23
  const parseRange = require("range-parser");
@@ -157,6 +157,29 @@ const READABLE_OPTIONS = { highWaterMark: 128 * 1024 };
157
157
  // `{"a":1}GET /path HTTP/1.1` is a request to it, with `{"A":1}GET` as the method. See _mustRefuse.
158
158
  const KNOWN_METHODS = new Set(require("http").METHODS);
159
159
 
160
+ /**
161
+ * Whether a request target is bytes node's parser would have accepted, which is printable ASCII
162
+ * and nothing else.
163
+ *
164
+ * µWS takes the target as it finds it and decodes it as UTF-8, so `GET /café` arrives here
165
+ * as a path with an é in it and the overlong encoding of a slash arrives as replacement
166
+ * characters. Node refuses both with a 400 before any application sees them, and it has to: what
167
+ * reaches req.url otherwise is not what is on the wire, and a proxy in front reading the same
168
+ * bytes can disagree with this server about which path was asked for. Control characters are µWS's
169
+ * own to refuse and it does, so the test is one comparison per character rather than two.
170
+ *
171
+ * @param {string} target the path or the query string, as µWS decoded it
172
+ * @returns {boolean}
173
+ */
174
+ function isAsciiTarget(target) {
175
+ for (let i = 0; i < target.length; i++) {
176
+ if (target.charCodeAt(i) > 0x7e) {
177
+ return false;
178
+ }
179
+ }
180
+ return true;
181
+ }
182
+
160
183
  /**
161
184
  * Whether a transfer-encoding leaves the body's length knowable, which is RFC 9112's rule that
162
185
  * `chunked` comes last. `gzip, chunked` is fine and `chunked, gzip` is not: with a coding applied
@@ -174,7 +197,65 @@ function endsWithChunked(value) {
174
197
  const last = value.slice(value.lastIndexOf(",") + 1).trim();
175
198
  // a coding may carry parameters, which are not part of its name
176
199
  const semicolon = last.indexOf(";");
177
- return (semicolon === -1 ? last : last.slice(0, semicolon)).trim().toLowerCase() === "chunked";
200
+ if ((semicolon === -1 ? last : last.slice(0, semicolon)).trim().toLowerCase() !== "chunked") {
201
+ return false;
202
+ }
203
+ // and only once. "chunked, chunked" ends with it and is still nonsense: a sender may not frame
204
+ // a body twice, and where node refuses the request outright µWS frames it as one chunked body
205
+ // and reads whatever follows as the next request on the connection
206
+ const codings = value.split(",");
207
+ let chunkedCount = 0;
208
+ for (const coding of codings) {
209
+ const parameter = coding.indexOf(";");
210
+ if ((parameter === -1 ? coding : coding.slice(0, parameter)).trim().toLowerCase() === "chunked") {
211
+ chunkedCount++;
212
+ }
213
+ }
214
+ return chunkedCount === 1;
215
+ }
216
+
217
+ /**
218
+ * Whether a Connection header says the connection ends with this response.
219
+ *
220
+ * It is a list, and "keep-alive, close" closes as much as "close" alone does. Compared against an
221
+ * exact "close", this server kept a connection the client had said it was done with, and then read
222
+ * the bytes after it as another request: node closes there, so the two disagreed on how many
223
+ * requests the same bytes carried, which is what a desync is.
224
+ *
225
+ * Written as a scan rather than a split and a lowercase, because almost every request that carries
226
+ * this header carries "keep-alive", and both of those allocate per request.
227
+ *
228
+ * @param {string} value as µWS hands it over
229
+ * @returns {boolean}
230
+ */
231
+ function saysClose(value) {
232
+ const length = value.length;
233
+ let at = 0;
234
+ while (at < length) {
235
+ while (at < length && (value.charCodeAt(at) === 0x20 || value.charCodeAt(at) === 0x09)) {
236
+ at++;
237
+ }
238
+ const start = at;
239
+ while (at < length && value.charCodeAt(at) !== 0x2c) {
240
+ at++;
241
+ }
242
+ let end = at;
243
+ while (end > start && (value.charCodeAt(end - 1) === 0x20 || value.charCodeAt(end - 1) === 0x09)) {
244
+ end--;
245
+ }
246
+ if (
247
+ end - start === 5 &&
248
+ (value.charCodeAt(start) | 0x20) === 0x63 &&
249
+ (value.charCodeAt(start + 1) | 0x20) === 0x6c &&
250
+ (value.charCodeAt(start + 2) | 0x20) === 0x6f &&
251
+ (value.charCodeAt(start + 3) | 0x20) === 0x73 &&
252
+ (value.charCodeAt(start + 4) | 0x20) === 0x65
253
+ ) {
254
+ return true;
255
+ }
256
+ at++;
257
+ }
258
+ return false;
178
259
  }
179
260
 
180
261
  /**
@@ -401,12 +482,7 @@ module.exports = class Request extends LazyReadable {
401
482
  // spotted in the loop that is running anyway: a client asking for the connection to be
402
483
  // closed must not be answered that it is being kept alive. The response is built right
403
484
  // after this and reads the flag.
404
- if (
405
- headerKey.length === 10 &&
406
- headerKey === "connection" &&
407
- value.length === 5 &&
408
- value.toLowerCase() === "close"
409
- ) {
485
+ if (headerKey.length === 10 && headerKey === "connection" && saysClose(value)) {
410
486
  r._connectionClose = true;
411
487
  } else if (
412
488
  (headerKey.length === 14 && headerKey === "content-length") ||
@@ -644,7 +720,7 @@ module.exports = class Request extends LazyReadable {
644
720
  const connection = req.getHeader("connection");
645
721
  if (connection !== "") {
646
722
  entries.push("connection", connection);
647
- if (connection.length === 5 && connection.toLowerCase() === "close") {
723
+ if (saysClose(connection)) {
648
724
  this._connectionClose = true;
649
725
  }
650
726
  }
@@ -689,6 +765,9 @@ module.exports = class Request extends LazyReadable {
689
765
  const rawQuery = req.getQuery();
690
766
  this._rawQuery = rawQuery ?? "";
691
767
  this.urlQuery = rawQuery === undefined ? "" : "?" + rawQuery;
768
+ if (rawQuery !== undefined && rawQuery.length !== 0 && !isAsciiTarget(rawQuery)) {
769
+ this._mustRefuse = true;
770
+ }
692
771
  }
693
772
  if (preset) {
694
773
  // the registration's constants: two native crossings and their strings not asked for
@@ -707,6 +786,12 @@ module.exports = class Request extends LazyReadable {
707
786
  // again. Building originalUrl and picking the path back out of it with indexOf and
708
787
  // substring was a search and a second string for something uWS had just handed over.
709
788
  this._path = req.getUrl();
789
+ // the target as it arrived, which node would have refused before this ran. A preset
790
+ // needs no check: it is a literal registration, and µWS only matched it because the
791
+ // bytes were that literal
792
+ if (!isAsciiTarget(this._path)) {
793
+ this._mustRefuse = true;
794
+ }
710
795
  this.originalUrl = this._path + this.urlQuery;
711
796
  this.url = this.originalUrl;
712
797
  // what the router last wrote to req.url. A middleware assigning something else is a
@@ -1183,11 +1268,21 @@ module.exports = class Request extends LazyReadable {
1183
1268
  // the vendored default already answers on a bare null prototype, so it goes out as is;
1184
1269
  // any other parser is copied onto one, which is what kept fast-querystring's result from
1185
1270
  // inspecting as "Empty <[Object: null prototype] {}>" where Express shows the bare form
1186
- return qp
1187
- ? qp === parseQuery
1188
- ? parseQuery(this._rawQuery)
1189
- : Object.assign(Object.create(null), qp(this._rawQuery))
1190
- : Object.create(null);
1271
+ // A parser of the application's own is handed what express hands it, which is
1272
+ // parseurl's `query`: null when the url carries no "?" at all, and the text after it
1273
+ // otherwise, the empty string included. Passing "" for both meant a parser written for
1274
+ // express, which may check for null before it reads the string, saw a request that had no
1275
+ // query as one with an empty query. The two built in parsers take the raw string.
1276
+ if (!qp) {
1277
+ return Object.create(null);
1278
+ }
1279
+ if (qp === parseQuery) {
1280
+ return parseQuery(this._rawQuery);
1281
+ }
1282
+ if (qp === fastQueryParse) {
1283
+ return Object.assign(Object.create(null), fastQueryParse(this._rawQuery));
1284
+ }
1285
+ return Object.assign(Object.create(null), qp(this.urlQuery === "" ? null : this._rawQuery));
1191
1286
  }
1192
1287
 
1193
1288
  /**
package/src/response.js CHANGED
@@ -790,13 +790,24 @@ module.exports = class Response extends LazyWritable {
790
790
  this.writeHeaders(true);
791
791
  }
792
792
  const contentLength = this.headers["content-length"];
793
+ // The client said this connection ends here, and it is this end() that has to make it so.
794
+ // µWS closes by itself for a bare "close" and not for a list, so "keep-alive, close" left
795
+ // the socket open and the bytes after that request were read as another one: node closes
796
+ // there, and a server that does not is a server the client and it disagree with about how
797
+ // many requests were sent. See saysClose.
798
+ //
799
+ // Only where a length goes out with it. endWithoutBody takes the flag as its second
800
+ // argument and reads the first as the length whatever it holds, so asking it to close
801
+ // without one writes "Content-Length: 9223372036854775808" onto a 204. Those two paths keep
802
+ // µWS's own rule, which closes for a bare "close" and not for a list.
803
+ const closeConnection = this.req._connectionClose === true;
793
804
  if (STATUSES_WITHOUT_BODY.has(this.statusCode) || this.statusCode < 200) {
794
805
  // no body and no length describing one, whatever the caller passed. node decides
795
806
  // this the same way, from the status alone, so res.status(304).end("x") sends the
796
807
  // status and nothing else on either.
797
808
  this._res.endWithoutBody();
798
809
  } else if (!data && contentLength) {
799
- this._res.endWithoutBody(contentLength.toString());
810
+ this._res.endWithoutBody(contentLength.toString(), closeConnection);
800
811
  } else if (headWasAlreadyOut && this.chunkedTransfer) {
801
812
  // whatever is still queued goes first: end() must not overtake the body written before it
802
813
  this.#flushQueued(null);
@@ -817,7 +828,7 @@ module.exports = class Response extends LazyWritable {
817
828
  if (this.req.method === "HEAD") {
818
829
  const length = Buffer.byteLength(data ?? "");
819
830
  this.headers["content-length"] = String(length);
820
- this._res.endWithoutBody(length.toString());
831
+ this._res.endWithoutBody(length.toString(), closeConnection);
821
832
  } else {
822
833
  // remembered rather than measured: only a caller that asks for content-length pays
823
834
  // for it, and uWS is measuring the same bytes for the wire anyway
@@ -825,7 +836,7 @@ module.exports = class Response extends LazyWritable {
825
836
  // and null is sent as the empty body it means. uWS answers end(null) with a
826
837
  // response the client never sees the end of, where node and express send an empty
827
838
  // 200: res.end(null) is what the compression module's own test suite does
828
- this._res.end(data ?? "");
839
+ this._res.end(data ?? "", closeConnection);
829
840
  }
830
841
  }
831
842
 
package/src/router.js CHANGED
@@ -1790,6 +1790,55 @@ module.exports = class Router extends EventEmitter {
1790
1790
  method = method.toUpperCase();
1791
1791
  callbacks = callbacks.flat(Infinity);
1792
1792
  checkHandlers(callbacks);
1793
+ // What express hangs off req.route as its methods, and the three registrations do not
1794
+ // agree on it: app.all() registers every verb one at a time, so the map names all of
1795
+ // them; router.all() and app.route().all() mark the route _all instead; and everything
1796
+ // hung off one app.route() shares one map, since express builds one Route for the lot.
1797
+ // Built in node's own order, which is the order the methods package hands express, so
1798
+ // the map reads back key for key as express's does.
1799
+ let methodMap;
1800
+ let stack;
1801
+ if (method !== "USE") {
1802
+ methodMap = this._pendingGroupMethods ?? new NullObject();
1803
+ // and the layers behind them, which is express's Route#stack: one per handler per verb
1804
+ // the route was registered for, in the order express pushes them. app.all() therefore
1805
+ // has one for every verb, since that is how many times express registers the handler
1806
+ stack = this._pendingGroupStack ?? [];
1807
+ let verbs;
1808
+ if (method === "ALL") {
1809
+ if (this._isApplication && this._pendingGroup === undefined) {
1810
+ verbs = [];
1811
+ for (const known of METHODS) {
1812
+ const lowered = known.toLowerCase();
1813
+ methodMap[lowered] = true;
1814
+ verbs.push(lowered);
1815
+ }
1816
+ } else {
1817
+ methodMap._all = true;
1818
+ // Route#all leaves the layer without one, and express reads that as any verb
1819
+ verbs = [undefined];
1820
+ }
1821
+ } else {
1822
+ methodMap[method.toLowerCase()] = true;
1823
+ verbs = [method.toLowerCase()];
1824
+ }
1825
+ for (const verb of verbs) {
1826
+ for (const handle of callbacks) {
1827
+ stack.push({
1828
+ handle,
1829
+ name: handle.name || "<anonymous>",
1830
+ params: undefined,
1831
+ path: undefined,
1832
+ keys: [],
1833
+ method: verb
1834
+ });
1835
+ }
1836
+ }
1837
+ }
1838
+ // Several paths at once are one route to express, whose path is the array it was given,
1839
+ // and several here, one per path, so they share the map and the stack and read back with
1840
+ // the array as their path.
1841
+ const writtenPath = path;
1793
1842
  const paths = Array.isArray(path) ? path : [path];
1794
1843
  const routes = [];
1795
1844
  for (let path of paths) {
@@ -1843,6 +1892,12 @@ module.exports = class Router extends EventEmitter {
1843
1892
  regexMount: method === "USE" && path instanceof RegExp,
1844
1893
  // written by the application, so express matches it as it stands
1845
1894
  userRegexp: path instanceof RegExp,
1895
+ // express reads these off req.route, and a middleware has none: see _preprocessRequest
1896
+ methods: methodMap,
1897
+ stack,
1898
+ // the route as a request sees it, which is the route itself unless the path was
1899
+ // normalised. Written into the literal so every route keeps one shape
1900
+ exposed: /** @type {any} */ (undefined),
1846
1901
  routeKey: routeKey++,
1847
1902
  // which app.route() this came from, when it came from one, so the routes it built
1848
1903
  // count as one route where an error is concerned. undefined for every other route
@@ -1859,6 +1914,17 @@ module.exports = class Router extends EventEmitter {
1859
1914
  all: method === "ALL" || method === "USE",
1860
1915
  gettable: method === "GET" || method === "HEAD"
1861
1916
  };
1917
+ // Everything here matches on the normalised path, and express hands out the written
1918
+ // one: a route registered as "/users/" is matched as "/users" with strict routing off
1919
+ // and still reads back with its slash. Rather than carry two paths through the
1920
+ // optimizer, a route whose path was normalised gets a view of itself with the written
1921
+ // path on top, and that is the one the request is given.
1922
+ route.exposed = route;
1923
+ if (writtenPath !== path) {
1924
+ const view = Object.create(route);
1925
+ view.path = writtenPath;
1926
+ route.exposed = view;
1927
+ }
1862
1928
  if (
1863
1929
  route.pattern instanceof RegExp &&
1864
1930
  // a RegExp the application wrote: its capture groups are params too
@@ -2643,7 +2709,13 @@ module.exports = class Router extends EventEmitter {
2643
2709
  * @returns {any} a promise only when a param callback is involved
2644
2710
  */
2645
2711
  _preprocessRequest(req, res, route) {
2646
- req.route = route;
2712
+ // express sets this inside Route#dispatch, so only a route ever writes one: a middleware
2713
+ // reads undefined there, and so does a request nothing routed. Code that tells a route
2714
+ // from a middleware by asking for req.route, which is how a metric gets its name, read
2715
+ // the mount here and named itself after it
2716
+ if (route.use !== true) {
2717
+ req.route = route.exposed;
2718
+ }
2647
2719
  // both, not the route flag alone: the flag says the route was registered natively, the
2648
2720
  // values say this request came in that way
2649
2721
  if (route.optimizedParams && req.optimizedParams) {
@@ -2966,12 +3038,24 @@ module.exports = class Router extends EventEmitter {
2966
3038
  // far as an error is concerned, see errorHop
2967
3039
  const group = ++routeGroups;
2968
3040
  const fns = new NullObject();
3041
+ // one map for the whole chain, because express builds one Route for it: a request answered
3042
+ // by the get() of an app.route() reads post() in its req.route.methods too
3043
+ const groupMethods = new NullObject();
3044
+ const groupStack = [];
3045
+ // express hands back a Route, which carries these three beside the verb methods
3046
+ fns.path = path;
3047
+ fns.methods = groupMethods;
3048
+ fns.stack = groupStack;
2969
3049
  const inGroup = (method, callbacks) => {
2970
3050
  this._pendingGroup = group;
3051
+ this._pendingGroupMethods = groupMethods;
3052
+ this._pendingGroupStack = groupStack;
2971
3053
  try {
2972
3054
  return this.createRoute(method, path, /** @type {any} */ (fns), ...callbacks);
2973
3055
  } finally {
2974
3056
  this._pendingGroup = undefined;
3057
+ this._pendingGroupMethods = undefined;
3058
+ this._pendingGroupStack = undefined;
2975
3059
  }
2976
3060
  };
2977
3061
  for (const method of methods) {
package/src/types.d.ts CHANGED
@@ -115,6 +115,13 @@ declare module "fulmine.js" {
115
115
  export import NextFunction = e.NextFunction;
116
116
  export import Locals = e.Locals;
117
117
  export import Request = e.Request;
118
+
119
+ // The application, the socket and the behaviour, nameable from outside. `export =` leaves
120
+ // everything declared beside it out of reach of an import, so anything wrapping ws() had
121
+ // to write ReturnType<typeof express> and index into it to say what it takes.
122
+ export type FulmineApplication = Fulmine;
123
+ export type FulmineSocket = FulmineWebSocket;
124
+ export type FulmineWebSocketBehavior = WebSocketBehavior;
118
125
  export import RequestHandler = e.RequestHandler;
119
126
  export import RequestParamHandler = e.RequestParamHandler;
120
127
  export import Response = e.Response;
@@ -136,15 +143,21 @@ declare module "fulmine.js" {
136
143
  uWS.WebSocketBehavior<SocketData>,
137
144
  "upgrade" | "open" | "message" | "dropped" | "drain" | "close" | "ping" | "pong" | "subscription"
138
145
  > & {
139
- upgrade?: (req: e.Request, res: e.Response) => void | Promise<void>;
140
- open?: (ws: FulmineWebSocket) => void;
141
- message?: (ws: FulmineWebSocket, message: ArrayBuffer, isBinary: boolean) => void;
142
- dropped?: (ws: FulmineWebSocket, message: ArrayBuffer, isBinary: boolean) => void;
143
- drain?: (ws: FulmineWebSocket) => void;
144
- close?: (ws: FulmineWebSocket, code: number, message: ArrayBuffer) => void;
145
- ping?: (ws: FulmineWebSocket, message: ArrayBuffer) => void;
146
- pong?: (ws: FulmineWebSocket, message: ArrayBuffer) => void;
147
- subscription?: (ws: FulmineWebSocket, topic: ArrayBuffer, newCount: number, oldCount: number) => void;
146
+ // The return types are µWS's own: it awaits nothing, but open, message and dropped are
147
+ // declared there as returning void or a promise, and narrowing that here made an async
148
+ // handler a type error against the library this wraps.
149
+ // Methods rather than function-typed properties, so a handler may narrow the request or
150
+ // the socket to one carrying what the upgrade hook hung on it, which is how this project
151
+ // says per-connection state is kept
152
+ upgrade?(req: e.Request, res: e.Response): void | Promise<void>;
153
+ open?(ws: FulmineWebSocket): void | Promise<void>;
154
+ message?(ws: FulmineWebSocket, message: ArrayBuffer, isBinary: boolean): void | Promise<void>;
155
+ dropped?(ws: FulmineWebSocket, message: ArrayBuffer, isBinary: boolean): void | Promise<void>;
156
+ drain?(ws: FulmineWebSocket): void;
157
+ close?(ws: FulmineWebSocket, code: number, message: ArrayBuffer): void;
158
+ ping?(ws: FulmineWebSocket, message: ArrayBuffer): void;
159
+ pong?(ws: FulmineWebSocket, message: ArrayBuffer): void;
160
+ subscription?(ws: FulmineWebSocket, topic: ArrayBuffer, newCount: number, oldCount: number): void;
148
161
  };
149
162
 
150
163
  // interfaces rather than aliases: `this` is how ws() answers the router or the app it was
@@ -202,6 +215,12 @@ declare module "fulmine.js" {
202
215
  // prototype, so a route only has them where that middleware ran, which the optional marks say.
203
216
  declare namespace Express {
204
217
  interface Response {
218
+ /**
219
+ * Whether the client went away before this response was answered. µWS frees the response
220
+ * then and writing to it does nothing, so a handler that awaited something checks this
221
+ * first. Express has no counterpart, which is why it is optional here.
222
+ */
223
+ aborted?: boolean;
205
224
  /** Adds a mark of your own. A mark with only a description is a legal entry. */
206
225
  timing?(name: string, duration?: number, description?: string): this;
207
226
  /** Times a piece of work under a name. A promise is timed to where it settles. */
package/src/websocket.js CHANGED
@@ -174,6 +174,9 @@ function makeUpgradeHandler(app, path, behavior) {
174
174
  // handler, which is the only place µWS accepts it
175
175
  res.onAborted(() => {
176
176
  aborted = true;
177
+ // and on the response too, so a hook that is still awaiting can see the client left
178
+ // rather than working on towards a handshake nobody is waiting for
179
+ response.aborted = true;
177
180
  });
178
181
  // and whatever the hook writes now lands outside the cork µWS holds for this callback,
179
182
  // so the response opens its own, exactly as a route handler answering late does