fulmine.js 5.1.8 → 5.2.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/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ <img src="./assets/logo-mark.svg" alt="" width="88" align="right">
2
+
1
3
  # Fulmine
2
4
 
3
5
  A drop-in replacement for Express 5, running on [µWebSockets.js](https://github.com/uNetworking/uWebSockets.js) instead of `node:http`. Your existing middleware keeps working.
@@ -25,8 +27,37 @@ See [Migrating](#migrating) for what it handles and what it deliberately does no
25
27
 
26
28
  [![npm version](https://img.shields.io/npm/v/fulmine.js)](https://www.npmjs.com/package/fulmine.js)
27
29
  [![Node.js >= 22.0.0](https://img.shields.io/badge/Node.js-%3E=22.0.0-green)](https://nodejs.org)
30
+ [![Coverage Status](https://coveralls.io/repos/github/nigrosimone/fulmine.js/badge.svg?branch=main)](https://coveralls.io/github/nigrosimone/fulmine.js?branch=main)
31
+ [![CodeQL](https://github.com/nigrosimone/fulmine.js/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/nigrosimone/fulmine.js/actions/workflows/codeql.yml)
28
32
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)
29
33
 
34
+ ## Table of contents
35
+
36
+ - [Why this exists](#why-this-exists)
37
+ - [Performance](#performance)
38
+ - [Public benchmarks](#public-benchmarks)
39
+ - [Attribution](#attribution)
40
+ - [Difference from similar projects](#difference-from-similar-projects)
41
+ - [Migrating](#migrating)
42
+ - [Docker](#docker)
43
+ - [Differences from Express](#differences-from-express)
44
+ - [Performance tips](#performance-tips)
45
+ - [WebSockets](#websockets)
46
+ - [socket.io](#socketio)
47
+ - [HTTP/3](#http3)
48
+ - [Versioning](#versioning)
49
+ - [Compatibility](#compatibility)
50
+ - [express](#express)
51
+ - [Application](#application)
52
+ - [Application settings](#application-settings)
53
+ - [Request](#request)
54
+ - [Response](#response)
55
+ - [Router](#router)
56
+ - [Tested middlewares](#tested-middlewares)
57
+ - [Tested view engines](#tested-view-engines)
58
+ - [Working on Fulmine](#working-on-fulmine)
59
+ - [Writing a comparison test](#writing-a-comparison-test)
60
+
30
61
  ## Why this exists
31
62
 
32
63
  There are several fast HTTP servers for Node built on [µWebSockets.js](https://github.com/uNetworking/uWebSockets.js). What is scarce is one you can actually drop into an existing Express application without rewriting it.
@@ -55,7 +86,7 @@ to run it yourself.
55
86
 
56
87
  Numbers produced by a project about itself deserve suspicion, so Fulmine also stands in public arenas, run by their own rigs under their own rules:
57
88
 
58
- - **[HttpArena](https://www.http-arena.com/#sort=rps:-1&q=Js)** (the link lands filtered on the JavaScript entries): first among the JavaScript entries and second overall across every language on the board. The saved run measures 7.64 million pipelined requests per second, 1.12 million on the json profile, 457 thousand on compressed json and 222 thousand on the Postgres profile.
89
+ - **[HttpArena](https://www.http-arena.com/#sort=rps:-1&q=Js)** (the link lands filtered on the JavaScript entries): first among the JavaScript entries, across fifteen subscribed profiles. The saved runs measure 24.3 million WebSocket echoes per second pipelined and 3.77 million one-at-a-time (past Bun's own dedicated WebSocket entry), 7.3 million pipelined HTTP requests per second, 1.12 million on the json profile with 1.04 million of that surviving TLS, 457 thousand on compressed json, and 359 thousand on the Postgres CRUD profile, within ten percent of the leading Rust and C# entries there.
59
90
  - **[web-frameworks](https://github.com/the-benchmarker/web-frameworks)**: entry merged, numbers arrive with their next published round.
60
91
 
61
92
  More to come as their maintainers take the entries in.
@@ -194,10 +225,37 @@ On top of that, a handler simple enough to be read at registration time is compi
194
225
 
195
226
  ## WebSockets
196
227
 
197
- Since you don't create http server manually, you can't properly use http.on("upgrade") to handle WebSockets. To solve this, there's currently 2 options:
228
+ `app.ws()` registers a WebSocket route, served by µWS itself. There is no `http.Server` underneath, so `http.on("upgrade")` and the libraries built on it do not apply; this is the replacement.
229
+
230
+ ```js
231
+ app.ws("/room/:id", {
232
+ upgrade(req, res) {
233
+ // runs before the handshake, with a real request and response.
234
+ // Answering the response declines the socket:
235
+ if (!req.query.token) return res.sendStatus(401);
236
+ // and anything left on the request is there for the socket's whole life:
237
+ req.room = req.params.id;
238
+ },
239
+ open(ws) {
240
+ ws.subscribe(ws.req.room);
241
+ },
242
+ message(ws, message, isBinary) {
243
+ ws.publish(ws.req.room, message, isBinary);
244
+ },
245
+ close(ws, code, message) {}
246
+ });
247
+ ```
248
+
249
+ - **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.
250
+ - **`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.
251
+ - **`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`.
252
+ - **Routers work.** `router.ws("/lobby", …)` mounted with `app.use("/chat", router)` serves `/chat/lobby`.
253
+ - **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.
254
+ - **Broadcasting from outside a socket**: `app.publish(topic, message)` and `app.numSubscribers(topic)`.
255
+
256
+ A WebSocket route and an ordinary route can share a path: the upgrade goes to the WebSocket route, a plain GET goes through normal routing.
198
257
 
199
- - [Ultimate WS](https://github.com/dimdenGD/ultimate-ws) implements a `ws` compatible API on the same idea: a drop-in replacement for the `ws` module. It was written against Ultimate Express and hooks into the same upgrade mechanism, which Fulmine still exposes, but that combination is not covered by this project's tests. There's a guide for how to upgrade http requests in the documentation.
200
- - You can simply use `app.uwsApp` to access uWebSockets.js `App` instance and call its `ws()` method directly.
258
+ If you would rather use the `ws` module's API, [Ultimate WS](https://github.com/dimdenGD/ultimate-ws) is a drop-in replacement for it written against Ultimate Express, and Fulmine still exposes the mechanism it hooks into, but that combination is not covered by this project's tests. `app.uwsApp` also remains available for anything µWS offers that this does not.
201
259
 
202
260
  ### socket.io
203
261
 
@@ -226,9 +284,10 @@ which runs the same file against Express and against Fulmine and compares the ou
226
284
 
227
285
  ## HTTP/3
228
286
 
229
- HTTP/3 is supported. To use:
287
+ There is an `http3: true` option, inherited from Ultimate Express, that asks µWebSockets.js for its experimental HTTP/3 app. **It is guarded off with the currently pinned µWS build**: asking for it throws a clear error, because the underlying `H3App` segfaults during construction on Linux, verified with µWS alone before a single request is served. On Windows the listener does come up, but nothing answers over QUIC that we could verify, and shipping an option that works on no deployable platform helps nobody. A skipped canary test probes `H3App` on every CI run and will turn red the day µWS ships working QUIC in its prebuilt binaries, which is when the guard goes and this section changes.
230
288
 
231
289
  ```js
290
+ // what it would look like, once µWS's H3 support actually works
232
291
  const app = express({
233
292
  http3: true,
234
293
  uwsOptions: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fulmine.js",
3
- "version": "5.1.8",
3
+ "version": "5.2.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
  "bin": {
@@ -13,7 +13,7 @@
13
13
  "test:types": "tsd --files tests/types/*.test-d.ts",
14
14
  "test:express": "node tools/express-suite.js",
15
15
  "benchmark:compare": "node benchmark/run.js",
16
- "cover": "npm run cover:unit && npm run cover:report",
16
+ "cover": "npm run cover:full && npm run cover:report",
17
17
  "cover:unit": "nyc --silent npm run test",
18
18
  "cover:report": "nyc report --reporter=html",
19
19
  "lint": "eslint .",
@@ -25,7 +25,9 @@
25
25
  "typecheck": "tsc -p tsconfig.typecheck.json",
26
26
  "benchmark:ab": "node benchmark/ab.js",
27
27
  "benchmark:profile": "node benchmark/profile.js",
28
- "release:local": "node tools/release-local.js"
28
+ "release:local": "node tools/release-local.js",
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
+ "cover:check": "nyc check-coverage --statements 93 --branches 88 --functions 92 --lines 93"
29
31
  },
30
32
  "engines": {
31
33
  "node": ">=22"
@@ -15,10 +15,7 @@ See the License for the specific language governing permissions and
15
15
  limitations under the License.
16
16
  */
17
17
 
18
- // H3App, DeclarativeResponse and _cfg all exist at runtime but are missing from the
19
- // declaration file the package ships, so the module is read through a loose alias
20
18
  const uWS = require("uWebSockets.js");
21
- const uWSAny = /** @type {any} */ (uWS);
22
19
  const Router = require("./router.js");
23
20
  const {
24
21
  removeDuplicateSlashes,
@@ -36,6 +33,7 @@ const path = require("path");
36
33
  const os = require("os");
37
34
  const { Worker } = require("worker_threads");
38
35
  const cluster = require("cluster");
36
+ const { registerWebSocketRoutes } = require("./websocket.js");
39
37
 
40
38
  const cpuCount = os.cpus().length;
41
39
 
@@ -102,10 +100,14 @@ class Application extends Router {
102
100
  if (settings.uwsApp) {
103
101
  this.uwsApp = settings.uwsApp;
104
102
  } else if (settings.http3) {
105
- if (!settings.uwsOptions.key_file_name || !settings.uwsOptions.cert_file_name) {
106
- throw new Error("uwsOptions.key_file_name and uwsOptions.cert_file_name are required for HTTP/3");
107
- }
108
- this.uwsApp = uWSAny.H3App(settings.uwsOptions);
103
+ // uWS.H3App exists in the pinned build but its QUIC stack does not: the constructor
104
+ // segfaults on Linux and hangs forever on Windows before serving a single request,
105
+ // verified 2026-08-05 with uWS alone. A clear throw beats a native crash; this
106
+ // branch goes back to H3App once uNetworking ships working QUIC in the prebuilts.
107
+ throw new Error(
108
+ "http3 is not usable with the pinned uWebSockets.js build: its H3App crashes " +
109
+ "during construction. Track uNetworking/uWebSockets.js for working QUIC support."
110
+ );
109
111
  } else if (settings.uwsOptions.key_file_name && settings.uwsOptions.cert_file_name) {
110
112
  this.uwsApp = uWS.SSLApp(settings.uwsOptions);
111
113
  } else {
@@ -514,6 +516,9 @@ class Application extends Router {
514
516
  */
515
517
  listen(port, host, backlog, callback) {
516
518
  this._compileOptimizedRoutes();
519
+ // before the catch-all: µWS sends an upgrade to the websocket route even when a
520
+ // catch-all covers the same path, so the two coexist and the order is only tidiness
521
+ registerWebSocketRoutes(this);
517
522
  this._createRequestHandler();
518
523
  // node's shapes: (cb), (port, cb), (port, host, cb) and (port, host, backlog, cb)
519
524
  if (typeof port === "function") {
@@ -594,6 +599,32 @@ class Application extends Router {
594
599
  return this;
595
600
  }
596
601
 
602
+ /**
603
+ * Publishes a message to every socket subscribed to a topic, from outside any of them.
604
+ *
605
+ * The socket's own `publish` reaches the same topics; this one is for the sender that is
606
+ * not a socket, a timer or a route handler broadcasting to a room.
607
+ *
608
+ * @param {string} topic
609
+ * @param {string|ArrayBuffer|Buffer} message
610
+ * @param {boolean} [isBinary]
611
+ * @param {boolean} [compress]
612
+ * @returns {boolean} whether the topic had anyone listening
613
+ */
614
+ publish(topic, message, isBinary, compress) {
615
+ return this.uwsApp.publish(topic, message, isBinary, compress);
616
+ }
617
+
618
+ /**
619
+ * How many sockets are subscribed to a topic.
620
+ *
621
+ * @param {string} topic
622
+ * @returns {number}
623
+ */
624
+ numSubscribers(topic) {
625
+ return this.uwsApp.numSubscribers(topic);
626
+ }
627
+
597
628
  /**
598
629
  * The bound address, or null when not listening.
599
630
  * @returns {{address: string, family: string, port: number}|null}
@@ -606,6 +606,13 @@ module.exports = function compileDeclarative(cb, app) {
606
606
  }
607
607
  }
608
608
 
609
+ // a handler that never sends is not a response: Express leaves the request waiting, so
610
+ // compiling the empty shape would answer a bare 200 where the ordinary path answers
611
+ // nothing at all. It has to fall back instead.
612
+ if (!sendUsed && !sendStatusUsed) {
613
+ return false;
614
+ }
615
+
609
616
  let decRes = new uWSAny.DeclarativeResponse();
610
617
 
611
618
  if (statusCode !== 200) {
@@ -251,7 +251,7 @@ function bodyError(message, status, type, extra) {
251
251
  * that climbs out of the root, applies the dotfiles and index rules, and hands the rest over.
252
252
  *
253
253
  * @param {string} root directory to serve from
254
- * @param {object} [options] index, redirect, fallthrough, dotfiles, extensions, setHeaders, etag
254
+ * @param {import("./options").StaticOptions} [options]
255
255
  * @returns {(req: any, res: any, next: (err?: any) => void) => any}
256
256
  */
257
257
  function serveStatic(root, options) {
@@ -321,8 +321,8 @@ function serveStatic(root, options) {
321
321
  } else return next();
322
322
  }
323
323
  let _path = url;
324
- const fullpath = path.resolve(path.join(options.root, url));
325
- if (options.root && !fullpath.startsWith(path.resolve(options.root))) {
324
+ const fullpath = path.resolve(path.join(root, url));
325
+ if (root && !fullpath.startsWith(path.resolve(root))) {
326
326
  if (!options.fallthrough) {
327
327
  res.status(403);
328
328
  return next(httpError(403));
@@ -472,13 +472,16 @@ function createInflate(contentEncoding) {
472
472
  * knows), or undefined for a parser that never decodes (raw)
473
473
  * @param {boolean} [keepsBuffer] whether the collected buffer itself escapes to the application,
474
474
  * which rules out handing it a view over uWS memory
475
- * @returns {(options?: object) => Function} the middleware factory
475
+ * @returns {(options?: import("./options").BodyParserOptions) => Function} the middleware factory
476
476
  */
477
477
  function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy, keepsBuffer) {
478
- return function (options) {
478
+ return function (userOptions) {
479
479
  // a copy, because everything below writes the parsed values back: with the caller's own
480
- // object, altering it after the parser was built would alter the parser
481
- options = options && typeof options === "object" ? { ...options } : new NullObject();
480
+ // object, altering it after the parser was built would alter the parser. The type says
481
+ // settled because the block below fills in every default, which is what the middleware
482
+ // and its closures then rely on
483
+ /** @type {import("./options").BodyParserOptions} */
484
+ const options = userOptions && typeof userOptions === "object" ? { ...userOptions } : new NullObject();
482
485
  // refused where it is written, not where it is used: an option nobody can honour is a
483
486
  // mistake in the application, and body-parser throws for it at the same point
484
487
  if (options.verify !== undefined && options.verify !== false && typeof options.verify !== "function") {
@@ -491,11 +494,17 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
491
494
  // and every comparison against it is false. express.json({ limit: 5 * 1024 * 1024 }) had no
492
495
  // limit at all. parse, and only what needs parsing
493
496
  if (typeof options.limit === "undefined") {
494
- options.limit = bytes.parse("100kb");
497
+ options.limit = /** @type {number} */ (bytes.parse("100kb"));
495
498
  } else if (typeof options.limit !== "number") {
496
- options.limit = bytes.parse(options.limit);
499
+ // bytes.parse answers null for a size it cannot read, and body-parser passes that
500
+ // along untouched too: matching it matters more than improving on it here
501
+ options.limit = /** @type {number} */ (bytes.parse(options.limit));
497
502
  }
498
503
 
504
+ // settled above, and read once: every check below wants the value, not the bag
505
+ const limit = /** @type {number} */ (options.limit);
506
+ const defaultCharset = /** @type {string} */ (options.defaultCharset ?? "utf-8");
507
+
499
508
  if (typeof options.inflate === "undefined") options.inflate = true;
500
509
  if (typeof options.type === "undefined") options.type = defaultType;
501
510
  if (typeof options.type === "string") {
@@ -523,7 +532,9 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
523
532
  //
524
533
  // typeis.is and not typeis(req, ...): the request form first checks that there is a body,
525
534
  // and the caller below has established that already.
526
- const claimsType = memoizeByString((contentType) => !!typeis.is(contentType, options.type));
535
+ const claimsType = memoizeByString(
536
+ (contentType) => !!typeis.is(contentType, /** @type {string[]} */ (options.type))
537
+ );
527
538
 
528
539
  let additionalMethods;
529
540
 
@@ -585,7 +596,7 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
585
596
  // answers 415 even for an empty body, and before the verify hook can run
586
597
  let encoding;
587
598
  if (charsetPolicy) {
588
- encoding = charsetOf(type) ?? options.defaultCharset;
599
+ encoding = charsetOf(type) ?? defaultCharset;
589
600
  if (
590
601
  (charsetPolicy === "utf" && encoding.slice(0, 4) !== "utf-") ||
591
602
  (charsetPolicy === "urlencoded" && encoding !== "utf-8" && encoding !== "iso-8859-1")
@@ -611,12 +622,12 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
611
622
  }
612
623
 
613
624
  // skip reading too large body
614
- if (length && +length > options.limit) {
625
+ if (length && +length > limit) {
615
626
  return next(
616
627
  bodyError("request entity too large", 413, "entity.too.large", {
617
628
  expected: +length,
618
629
  length: +length,
619
- limit: options.limit
630
+ limit: limit
620
631
  })
621
632
  );
622
633
  }
@@ -674,13 +685,13 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
674
685
  if (!req.receivedData && !inflate && !isNaN(length) && Number(length) > 0 && req._res.collectBody) {
675
686
  req.bodyRead = true;
676
687
  const declared = Number(length);
677
- req._res.collectBody(options.limit, (body) => {
688
+ req._res.collectBody(limit, (body) => {
678
689
  if (body === null) {
679
690
  // over maxSize: uWS refused it natively
680
691
  return next(
681
692
  bodyError("request entity too large", 413, "entity.too.large", {
682
- limit: options.limit,
683
- received: options.limit
693
+ limit: limit,
694
+ received: limit
684
695
  })
685
696
  );
686
697
  }
@@ -710,7 +721,7 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
710
721
  // known and we aren't inflating, the final size is known up front, so chunks can go
711
722
  // straight into one buffer and the body is copied once.
712
723
  // the cap means a client that declares a body and never sends it costs no more than one
713
- // that actually sends a body that size, and content-length above options.limit was
724
+ // that actually sends a body that size, and content-length above limit was
714
725
  // already rejected above
715
726
  const declaredLength = inflate ? -1 : Number(length);
716
727
  let target =
@@ -757,13 +768,13 @@ function createBodyParser(defaultType, beforeReturn, checkOptions, charsetPolicy
757
768
  */
758
769
  function keepChunk(buf) {
759
770
  totalSize += buf.length;
760
- if (totalSize > options.limit) {
771
+ if (totalSize > limit) {
761
772
  finished = true;
762
773
  abs.length = 0;
763
774
  target = null;
764
775
  next(
765
776
  bodyError("request entity too large", 413, "entity.too.large", {
766
- limit: options.limit,
777
+ limit: limit,
767
778
  received: totalSize
768
779
  })
769
780
  );
@@ -0,0 +1,94 @@
1
+ // The option bags the file-serving and body-parsing paths take, written once and referenced from
2
+ // the JSDoc of the functions that read them. They live in a declaration file rather than as
3
+ // @typedef blocks in the sources because those sources export classes, and a typedef hanging off
4
+ // a `module.exports = class` module makes TypeScript see two unrelated copies of the class.
5
+
6
+ /** What res.sendFile, express.static and res.download all read. The names and defaults are send's. */
7
+ export interface SendFileOptions {
8
+ /** The directory a relative path resolves against, and the boundary nothing may climb out of. */
9
+ root?: string;
10
+ /** Cache-Control's max-age, in milliseconds or as a duration such as "1d". */
11
+ maxAge?: number | string;
12
+ /** Adds Cache-Control: immutable, which is only meaningful next to a long maxAge. */
13
+ immutable?: boolean;
14
+ /** Whether Last-Modified is sent, from the file's mtime. */
15
+ lastModified?: boolean;
16
+ /** Whether an ETag is sent. */
17
+ etag?: boolean;
18
+ /** Whether a Range request is honoured. */
19
+ acceptRanges?: boolean;
20
+ /** Whether Cache-Control is sent at all. */
21
+ cacheControl?: boolean;
22
+ /**
23
+ * What to do with a path holding a dotfile. "ignore_files" is one more than send offers:
24
+ * it hides a dotfile that is the last segment while letting a dotted directory through.
25
+ */
26
+ dotfiles?: "allow" | "deny" | "ignore" | "ignore_files";
27
+ /** Extra headers for the response. */
28
+ headers?: Record<string, string>;
29
+ /** Called before the file goes out, to set headers from the path or its stat. */
30
+ setHeaders?: (res: any, path: string, stat: any) => void;
31
+ /** First byte of the window to send. */
32
+ start?: number;
33
+ /** Last byte of the window to send. */
34
+ end?: number;
35
+ /** The path is already encoded, so leave it alone. */
36
+ skipEncodePath?: boolean;
37
+ /** Internal: locals carried through to a view render. */
38
+ _locals?: Record<string, any>;
39
+ /** Internal: the stat the caller already took, so it is not taken twice. */
40
+ _stat?: any;
41
+ /** Internal: the caller computed the ETag itself. */
42
+ _ownEtag?: boolean;
43
+ }
44
+
45
+ /** What express.static reads on top of everything res.sendFile takes. */
46
+ export interface StaticOptions extends SendFileOptions {
47
+ /** The file served for a directory, or false to serve none. */
48
+ index?: string | false;
49
+ /** Whether a directory without a trailing slash is redirected to one. */
50
+ redirect?: boolean;
51
+ /** Whether a request this middleware cannot serve moves on instead of being answered. */
52
+ fallthrough?: boolean;
53
+ /** Extensions tried when the path names no file, or false to try none. */
54
+ extensions?: string[] | false;
55
+ }
56
+
57
+ /** A body parser's options once its factory has filled in every default it needs. */
58
+ export interface SettledBodyParserOptions extends BodyParserOptions {
59
+ limit: number;
60
+ inflate: boolean;
61
+ /** the string form is turned into a one-element list by the factory */
62
+ type: string[] | ((req: any) => boolean);
63
+ defaultCharset: string;
64
+ }
65
+
66
+ /** What the body parsers take. The four share these; each names below the ones it alone reads. */
67
+ export interface BodyParserOptions {
68
+ /** The largest body to accept, as bytes or as "100kb". */
69
+ limit?: number | string;
70
+ /** Which content types this parser claims. */
71
+ type?: string | string[] | ((req: any) => boolean);
72
+ /** Runs on the raw bytes before parsing, which is where a signature check belongs. */
73
+ verify?: false | ((req: any, res: any, buf: Buffer, encoding: string) => void);
74
+ /** Whether a compressed body is decompressed rather than refused. */
75
+ inflate?: boolean;
76
+ /** The charset assumed when the request names none. */
77
+ defaultCharset?: string;
78
+ /** json only: refuse a body that is not an object or an array. */
79
+ strict?: boolean;
80
+ /** json only, passed to JSON.parse. */
81
+ reviver?: (key: string, value: any) => any;
82
+ /** urlencoded only: parse with qs rather than the plain parser. */
83
+ extended?: boolean;
84
+ /** urlencoded only: how many parameters to accept. */
85
+ parameterLimit?: number;
86
+ /** urlencoded only, extended only: how deep a nested object may go. */
87
+ depth?: number;
88
+ /** urlencoded only, passed to qs. */
89
+ charsetSentinel?: boolean;
90
+ /** urlencoded only, passed to qs. */
91
+ interpretNumericEntities?: boolean;
92
+ /** Internal: the single type this parser claims, which lets the prologue compare strings. */
93
+ simpleType?: string;
94
+ }
package/src/request.js CHANGED
@@ -139,26 +139,53 @@ module.exports = class Request extends Readable {
139
139
  /** @type {Record<string, string[]>|null} */
140
140
  #cachedDistinctHeaders = null;
141
141
 
142
- // Flat, name then value: an array of pairs meant one array allocated per header on every
143
- // request, and a request carries eight or ten of them. Everything that reads this walks it two
144
- // at a time. The names are lowercase by contract: uWS lowers them on the wire and the node
145
- // shim lowers them in its forEach, so readers compare without lowering again.
142
+ /**
143
+ * Every header, flat: name then value, name then value.
144
+ *
145
+ * An array of pairs meant one array allocated per header on every request, and a request
146
+ * carries eight or ten of them, so everything that reads this walks it two at a time. The
147
+ * names are lowercase by contract: uWS lowers them on the wire and the node shim lowers
148
+ * them in its forEach, so readers compare without lowering again.
149
+ *
150
+ * @type {string[]}
151
+ */
146
152
  #rawHeadersEntries = [];
147
153
 
148
154
  /** @type {string|undefined|null} */
149
155
  #cachedParsedIp = null;
150
156
 
157
+ /** Whether backpressure has asked uWS to stop delivering the body for now. */
151
158
  #paused = false;
152
159
 
153
- // a bodyless request whose empty end has not been delivered yet, see the constructor
160
+ /** A bodyless request whose empty end has not been delivered yet, see the constructor. */
154
161
  #emptyBody = false;
155
162
 
163
+ /**
164
+ * What a body parser left behind, and undefined until one claims the request.
165
+ * @type {any}
166
+ */
156
167
  body;
157
168
 
169
+ /**
170
+ * The response this request arrived with, linked so either reaches the other.
171
+ *
172
+ * Typed loosely on purpose: it is linked right after construction rather than in the
173
+ * constructor, and the honest `Response|undefined` would put a check in front of every
174
+ * use of a field that is never observed unset.
175
+ *
176
+ * @type {any}
177
+ */
158
178
  res;
159
179
 
160
- // one function for every request, fed through currentRequest: an arrow in the constructor
161
- // captured `this`, which cost a context and a function allocation per request
180
+ /**
181
+ * Copies one header out of uWS and notices the two things the constructor decides by.
182
+ *
183
+ * One function for every request, fed through currentRequest: an arrow in the constructor
184
+ * captured `this`, which cost a context and a function allocation per request.
185
+ *
186
+ * @param {string} headerKey lowercase, as uWS hands it over
187
+ * @param {string} value
188
+ */
162
189
  static #collectHeader = (headerKey, value) => {
163
190
  const r = currentRequest;
164
191
  r.#rawHeadersEntries.push(headerKey, value);
@@ -183,10 +210,31 @@ module.exports = class Request extends Readable {
183
210
  }
184
211
  };
185
212
 
213
+ /**
214
+ * The parameters a native uWS route matched, by name, or undefined off that path.
215
+ * @type {Record<string, string>|undefined}
216
+ */
186
217
  optimizedParams;
187
218
 
219
+ /**
220
+ * The continuation of the chain currently running, which express also hands to a handler
221
+ * through the request. Declared rather than left to appear on assignment: runRoute sets it
222
+ * on every request, and an undeclared property is a shape change on each one.
223
+ *
224
+ * @type {any}
225
+ */
226
+ next;
227
+
228
+ /**
229
+ * What the chain threw or passed to next(err), waiting for an error handler.
230
+ * @type {any}
231
+ */
188
232
  _error;
189
233
 
234
+ /**
235
+ * Set by the paths that must not earn an ETag, res.sendFile's stream among them.
236
+ * @type {boolean|undefined}
237
+ */
190
238
  noEtag;
191
239
 
192
240
  /**
@@ -749,6 +797,33 @@ module.exports = class Request extends Readable {
749
797
  return this.connection;
750
798
  }
751
799
 
800
+ /**
801
+ * Cuts this request loose from the µWS response it arrived on, keeping the two things only
802
+ * that response could answer.
803
+ *
804
+ * A websocket upgrade hands the request to the socket, which outlives the response by the
805
+ * whole life of the connection. Reading the peer address through the freed response is not
806
+ * an error but a use after free, so the values are taken while it is still alive and an
807
+ * inert stand-in answers anything that asks later.
808
+ */
809
+ _detachFromResponse() {
810
+ const uwsRes = this._res;
811
+ if (!this.rawIp) {
812
+ this.rawIp = uwsRes.getRemoteAddress();
813
+ }
814
+ const remotePort = uwsRes.getRemotePort();
815
+ const rawIp = this.rawIp;
816
+ this._res = {
817
+ getRemoteAddress: () => rawIp,
818
+ getRemotePort: () => remotePort,
819
+ // a body cannot arrive on an upgraded socket, and a stray reader must not reach µWS
820
+ onData() {},
821
+ pause() {},
822
+ resume() {},
823
+ close() {}
824
+ };
825
+ }
826
+
752
827
  /**
753
828
  * Whether the client's cached copy is still good, from If-None-Match and If-Modified-Since
754
829
  * against the response headers set so far. Only GET and HEAD can be fresh.
package/src/response.js CHANGED
@@ -123,6 +123,7 @@ module.exports = class Response extends Writable {
123
123
  /** @type {Socket|null} */
124
124
  #socket = null;
125
125
 
126
+ /** Whether end() has run, which is what makes a second one a no-op rather than a throw. */
126
127
  #ended = false;
127
128
 
128
129
  /** @type {((err?: Error|null) => void)|null} */
@@ -131,6 +132,10 @@ module.exports = class Response extends Writable {
131
132
  /** @type {any} */
132
133
  #outHeaders = null;
133
134
 
135
+ /**
136
+ * The request this response answers, linked so either reaches the other.
137
+ * @type {InstanceType<typeof import("./request.js")>}
138
+ */
134
139
  req;
135
140
 
136
141
  /**
@@ -145,6 +150,9 @@ module.exports = class Response extends Writable {
145
150
  constructor(res, req, app) {
146
151
  super();
147
152
  this._req = req;
153
+ // linked here rather than by the caller: the pair is built together, and a field the
154
+ // constructor leaves unset is a shape change on whoever assigns it first
155
+ this.req = req;
148
156
  this._res = res;
149
157
  this.headersSent = false;
150
158
  this.app = app;
@@ -673,7 +681,7 @@ module.exports = class Response extends Writable {
673
681
  * "ignore"), `acceptRanges`, `cacheControl`, `immutable`, `etag` and `setHeaders`.
674
682
  *
675
683
  * @param {string} path
676
- * @param {Record<string, any>} [options]
684
+ * @param {import("./options").SendFileOptions} [options]
677
685
  * @param {(err?: Error) => void} [callback] called once sent, or with the error
678
686
  */
679
687
  sendFile(path, options = new NullObject(), callback) {
@@ -893,9 +901,11 @@ module.exports = class Response extends Writable {
893
901
  // range requests
894
902
  if (options.acceptRanges) {
895
903
  if (this.req.headers.range) {
896
- let ranges = this.req.range(len, {
897
- combine: true
898
- });
904
+ // the branch above established the header is there, so range() cannot answer
905
+ // the undefined it uses to mean "no Range header"
906
+ let ranges = /** @type {ReturnType<typeof import("range-parser")>} */ (
907
+ this.req.range(len, { combine: true })
908
+ );
899
909
 
900
910
  // if-range
901
911
  if (!isRangeFresh(this.req, this)) {
@@ -1003,7 +1013,7 @@ module.exports = class Response extends Writable {
1003
1013
  *
1004
1014
  * @param {string} path
1005
1015
  * @param {string} [filename] name offered to the user, defaults to the basename of the path
1006
- * @param {Record<string, any>} [options] passed through to sendFile
1016
+ * @param {import("./options").SendFileOptions} [options] passed through to sendFile
1007
1017
  * @param {(err?: Error) => void} [callback]
1008
1018
  */
1009
1019
  download(path, filename, options, callback) {
@@ -1236,7 +1246,10 @@ module.exports = class Response extends Writable {
1236
1246
  */
1237
1247
  cookie(name, value, options) {
1238
1248
  const opt = { ...(options ?? {}) }; // create a new ref because we change original object (https://github.com/dimdenGD/ultimate-express/issues/68)
1239
- if (opt.signed && !this.req.secret) {
1249
+ // cookie-parser hangs the secret on the request, so it is read off it rather than
1250
+ // declared here: without that middleware there is none, which is what this checks
1251
+ const req = /** @type {any} */ (this.req);
1252
+ if (opt.signed && !req.secret) {
1240
1253
  // the message has to read like this: it is the one Express throws, and it names the
1241
1254
  // thing that is actually missing rather than the library that noticed
1242
1255
  throw new Error('cookieParser("secret") required for signed cookies');
@@ -1254,7 +1267,7 @@ module.exports = class Response extends Writable {
1254
1267
  delete opt.maxAge;
1255
1268
  }
1256
1269
  if (opt.signed) {
1257
- val = "s:" + sign(val, this.req.secret);
1270
+ val = "s:" + sign(val, req.secret);
1258
1271
  }
1259
1272
 
1260
1273
  if (opt.path == null) {
@@ -1304,7 +1317,9 @@ module.exports = class Response extends Writable {
1304
1317
  */
1305
1318
  format(object) {
1306
1319
  const keys = Object.keys(object).filter((v) => v !== "default");
1307
- const key = keys.length > 0 ? this.req.accepts(keys) : false;
1320
+ // accepts answers the whole list only when asked with no arguments; given types it
1321
+ // answers the best of them, or false
1322
+ const key = keys.length > 0 ? /** @type {string|false} */ (this.req.accepts(keys)) : false;
1308
1323
 
1309
1324
  this.vary("Accept");
1310
1325
 
@@ -1501,6 +1516,10 @@ module.exports = class Response extends Writable {
1501
1516
  return this.set("content-type", ct);
1502
1517
  }
1503
1518
 
1519
+ /**
1520
+ * express carries both names for the same method, and middleware reaches for either.
1521
+ * @type {(type: string) => any}
1522
+ */
1504
1523
  contentType = this.type;
1505
1524
 
1506
1525
  /**
package/src/router.js CHANGED
@@ -37,6 +37,7 @@ const statuses = require("statuses");
37
37
  const { METHODS } = require("http");
38
38
  const { isNodeRequest, serveNodeRequest } = require("./node-shim.js");
39
39
  const { chainUsage } = require("./usage.js");
40
+ const { checkBehavior } = require("./websocket.js");
40
41
 
41
42
  // every method the declarative compiler can emit: a patched one must disable compilation, or the
42
43
  // patch would be honoured everywhere but on compiled routes
@@ -836,10 +837,27 @@ function generateErrorPageHtml(err) {
836
837
  }
837
838
 
838
839
  module.exports = class Router extends EventEmitter {
840
+ /**
841
+ * The router or application this one is mounted on, undefined until it is.
842
+ * @type {any}
843
+ */
839
844
  parent;
840
845
 
846
+ /**
847
+ * Whether listen() has run, after which a new route can no longer reach uWS.
848
+ * @type {boolean|undefined}
849
+ */
841
850
  listenCalled;
842
851
 
852
+ /**
853
+ * The uWS app routes are registered on.
854
+ *
855
+ * Typed loosely on purpose: only an Application owns one, and the callers that reach for
856
+ * it have already established that, so the honest `TemplatedApp|undefined` would only add
857
+ * casts where the guard already is.
858
+ *
859
+ * @type {any}
860
+ */
843
861
  uwsApp;
844
862
 
845
863
  /**
@@ -852,6 +870,10 @@ module.exports = class Router extends EventEmitter {
852
870
  this._paramCallbacks = new Map();
853
871
  this._mountpathCache = new Map();
854
872
  this._routes = [];
873
+ // websocket routes, kept apart from the HTTP ones: µWS serves them itself and listen()
874
+ // hands them over whole, mount paths and all
875
+ /** @type {any[]|null} */
876
+ this._wsRoutes = null;
855
877
  // the native presets allowed to skip the header copy, so a late middleware or an etag
856
878
  // arriving after listen can take the permission back; null until one is granted
857
879
  /** @type {Set<any>|null} */
@@ -1393,7 +1415,6 @@ module.exports = class Router extends EventEmitter {
1393
1415
  const request = new this._request(req, res, this, preset, skipHolder);
1394
1416
  const response = new this._response(res, request, this);
1395
1417
  request.res = response;
1396
- response.req = request;
1397
1418
 
1398
1419
  return request;
1399
1420
  }
@@ -1974,6 +1995,38 @@ module.exports = class Router extends EventEmitter {
1974
1995
  return this;
1975
1996
  }
1976
1997
 
1998
+ /**
1999
+ * Registers a websocket route, which µWS serves itself.
2000
+ *
2001
+ * The behavior is µWS's, settings and socket handlers alike, plus one addition: an
2002
+ * `upgrade(req, res)` of this project's own shape, which runs before the handshake with a
2003
+ * real request and response. Answering with the response declines the socket, which is how
2004
+ * a check refuses one; returning a promise holds the handshake until it settles.
2005
+ *
2006
+ * The request lives as long as the socket and reaches every handler as `ws.req`, so what
2007
+ * the upgrade learned about the client, and anything it hangs on the request, is there when
2008
+ * a message arrives.
2009
+ *
2010
+ * @example
2011
+ * app.ws("/room/:id", {
2012
+ * upgrade(req, res) {
2013
+ * if (!req.query.token) return res.sendStatus(401);
2014
+ * req.room = req.params.id;
2015
+ * },
2016
+ * open(ws) { ws.subscribe(ws.req.room); },
2017
+ * message(ws, message, isBinary) { ws.publish(ws.req.room, message, isBinary); }
2018
+ * });
2019
+ *
2020
+ * @param {string} path a literal path, or one whose parameters are whole segments
2021
+ * @param {object} behavior µWS's WebSocketBehavior, plus the optional `upgrade` above
2022
+ * @returns {this}
2023
+ */
2024
+ ws(path, behavior) {
2025
+ checkBehavior(path, behavior);
2026
+ (this._wsRoutes ??= []).push({ path, behavior, owner: this });
2027
+ return this;
2028
+ }
2029
+
1977
2030
  /**
1978
2031
  * A builder for one path, so the path is written once and the verbs chain off it.
1979
2032
  *
package/src/types.d.ts CHANGED
@@ -24,6 +24,9 @@ declare module "fulmine.js" {
24
24
  export import urlencoded = e.urlencoded;
25
25
 
26
26
  export import RouterOptions = e.RouterOptions;
27
+ // Router is declared rather than re-exported, because this project's routers carry ws()
28
+ export function Router(options?: e.RouterOptions): FulmineRouter;
29
+ export type Router = FulmineRouter;
27
30
  export import Application = e.Application;
28
31
  export import CookieOptions = e.CookieOptions;
29
32
  export import Errback = e.Errback;
@@ -41,7 +44,6 @@ declare module "fulmine.js" {
41
44
  export import RequestHandler = e.RequestHandler;
42
45
  export import RequestParamHandler = e.RequestParamHandler;
43
46
  export import Response = e.Response;
44
- export import Router = e.Router;
45
47
  export import Send = e.Send;
46
48
  }
47
49
 
@@ -49,12 +51,43 @@ declare module "fulmine.js" {
49
51
  uwsApp: uWS.TemplatedApp;
50
52
  };
51
53
 
52
- type Fulmine = Omit<e.Express, "listen"> & {
54
+ // what app.ws() hands the socket, and what µWS merges onto it: the request is reachable as
55
+ // ws.req for as long as the socket is open
56
+ type SocketData = { req: e.Request };
57
+ type FulmineWebSocket = uWS.WebSocket<SocketData> & SocketData;
58
+
59
+ // µWS's behavior, with its socket handlers retyped around that request and its own upgrade
60
+ // replaced by this project's, which takes a request and a response
61
+ type WebSocketBehavior = Omit<
62
+ uWS.WebSocketBehavior<SocketData>,
63
+ "upgrade" | "open" | "message" | "dropped" | "drain" | "close" | "ping" | "pong" | "subscription"
64
+ > & {
65
+ upgrade?: (req: e.Request, res: e.Response) => void | Promise<void>;
66
+ open?: (ws: FulmineWebSocket) => void;
67
+ message?: (ws: FulmineWebSocket, message: ArrayBuffer, isBinary: boolean) => void;
68
+ dropped?: (ws: FulmineWebSocket, message: ArrayBuffer, isBinary: boolean) => void;
69
+ drain?: (ws: FulmineWebSocket) => void;
70
+ close?: (ws: FulmineWebSocket, code: number, message: ArrayBuffer) => void;
71
+ ping?: (ws: FulmineWebSocket, message: ArrayBuffer) => void;
72
+ pong?: (ws: FulmineWebSocket, message: ArrayBuffer) => void;
73
+ subscription?: (ws: FulmineWebSocket, topic: ArrayBuffer, newCount: number, oldCount: number) => void;
74
+ };
75
+
76
+ // interfaces rather than aliases: `this` is how ws() answers the router or the app it was
77
+ // called on, and an alias naming itself in an intersection is circular
78
+ interface FulmineRouter extends e.Router {
79
+ ws(path: string, behavior: WebSocketBehavior): this;
80
+ }
81
+
82
+ interface Fulmine extends Omit<e.Express, "listen"> {
53
83
  readonly uwsApp: uWS.TemplatedApp;
54
84
  listen(port: number, callback?: (token: any) => void): FulmineServer;
55
85
  listen(port: number, host: string, callback?: (token: any) => void): FulmineServer;
56
86
  listen(callback: (token: any) => void): FulmineServer;
57
- };
87
+ ws(path: string, behavior: WebSocketBehavior): this;
88
+ publish(topic: string, message: string | ArrayBuffer | Buffer, isBinary?: boolean, compress?: boolean): boolean;
89
+ numSubscribers(topic: string): number;
90
+ }
58
91
 
59
92
  function express(settings?: Settings): Fulmine;
60
93
 
package/src/usage.js CHANGED
@@ -76,7 +76,13 @@ function callbackUsage(fn) {
76
76
 
77
77
  /** @param {Function} fn @returns {number} */
78
78
  function analyze(fn) {
79
- let code = fn.toString();
79
+ // toString is application-controlled and may throw or answer anything: unreadable is unknown
80
+ let code;
81
+ try {
82
+ code = String(fn.toString());
83
+ } catch {
84
+ return UNKNOWN;
85
+ }
80
86
  if (code.startsWith("function") || code.startsWith("async function")) {
81
87
  code = code.replace(/function *\(/, "function __cb(");
82
88
  }
@@ -0,0 +1,223 @@
1
+ "use strict";
2
+
3
+ const { canBeOptimizedWithParams, decodeParam, NullObject } = require("./utils.js");
4
+
5
+ // the parameter names in a path, in the order µWS numbers them
6
+ const PARAM = /:(\w+)/g;
7
+
8
+ // Handlers µWS calls with the socket. Everything else in a behavior object is a µWS setting
9
+ // (maxPayloadLength, idleTimeout, compression, ...) and rides through untouched.
10
+ const SOCKET_HANDLERS = ["open", "message", "dropped", "drain", "close", "ping", "pong", "subscription"];
11
+
12
+ /**
13
+ * Joins a mount path and a route path the way the router does, without the empty-string edges
14
+ * that would leave a double slash.
15
+ *
16
+ * @param {string} prefix
17
+ * @param {string} path
18
+ * @returns {string}
19
+ */
20
+ function joinPaths(prefix, path) {
21
+ if (!prefix || prefix === "/") {
22
+ return path;
23
+ }
24
+ if (!path || path === "/") {
25
+ return prefix;
26
+ }
27
+ return prefix + path;
28
+ }
29
+
30
+ /**
31
+ * Every websocket route reachable from this router, with the mount paths already applied.
32
+ *
33
+ * Walked separately from the HTTP routes: those fall back to ordinary routing when µWS cannot
34
+ * match them, and a websocket has no fallback to fall back to, so an unmountable one has to be
35
+ * refused out loud instead.
36
+ *
37
+ * @param {any} router
38
+ * @param {string|null} prefix the mount path accumulated so far, or null once a mount was a
39
+ * shape µWS cannot match, which makes everything below it unreachable
40
+ * @param {any[]} out
41
+ * @param {Set<any>} seen routers already walked, since a router may be mounted twice
42
+ */
43
+ function collectRoutes(router, prefix, out, seen) {
44
+ if (seen.has(router)) {
45
+ return;
46
+ }
47
+ seen.add(router);
48
+
49
+ for (const entry of router._wsRoutes ?? []) {
50
+ if (prefix === null) {
51
+ throw new Error(
52
+ `websocket route "${entry.path}" sits under a mount µWS cannot match. ` +
53
+ "Mount the router on a literal path, or on one whose parameters are whole segments."
54
+ );
55
+ }
56
+ const path = joinPaths(prefix, entry.path);
57
+ if (!canBeOptimizedWithParams(path)) {
58
+ throw new Error(
59
+ `websocket path "${path}" is not one µWS can match. Use a literal path, or ` +
60
+ "parameters that are a whole segment, as in /room/:id."
61
+ );
62
+ }
63
+ out.push({ path, behavior: entry.behavior, owner: entry.owner });
64
+ }
65
+
66
+ for (const route of router._routes) {
67
+ if (!route.use) {
68
+ continue;
69
+ }
70
+ for (const callback of route.callbacks) {
71
+ // a mounted Router, or a callable sub-app, which is a function carrying routes
72
+ if (callback && callback._routes) {
73
+ const mount =
74
+ prefix === null || typeof route.path !== "string" || !canBeOptimizedWithParams(route.path)
75
+ ? null
76
+ : joinPaths(prefix, route.path);
77
+ collectRoutes(callback, mount, out, seen);
78
+ }
79
+ }
80
+ }
81
+ }
82
+
83
+ /**
84
+ * The µWS upgrade handler for one route: it builds this project's request and response, offers
85
+ * them to the application's own `upgrade` hook, and completes the handshake unless that hook
86
+ * answered the request itself.
87
+ *
88
+ * @param {any} app the application whose request and response classes serve this route
89
+ * @param {string} path the composed path, whose parameters are read back by index
90
+ * @param {any} behavior what the caller registered
91
+ * @returns {(res: any, req: any, context: any) => void}
92
+ */
93
+ function makeUpgradeHandler(app, path, behavior) {
94
+ const paramNames = [...path.matchAll(PARAM)].map((match) => match[1]);
95
+ const userUpgrade = behavior.upgrade;
96
+
97
+ return (res, req, context) => {
98
+ // read off the µWS request before anything can await: it is neutered on return, and the
99
+ // handshake needs these three even when the upgrade is decided asynchronously
100
+ const key = req.getHeader("sec-websocket-key");
101
+ const protocol = req.getHeader("sec-websocket-protocol");
102
+ const extensions = req.getHeader("sec-websocket-extensions");
103
+
104
+ const request = new app._request(req, res, app);
105
+ if (paramNames.length) {
106
+ const params = new NullObject();
107
+ for (let i = 0; i < paramNames.length; i++) {
108
+ params[paramNames[i]] = decodeParam(req.getParameter(i));
109
+ }
110
+ request.params = params;
111
+ }
112
+
113
+ let aborted = false;
114
+
115
+ /** Completes the handshake, unless the hook answered or the client already left. */
116
+ const accept = () => {
117
+ if (aborted || request.res?.finished) {
118
+ return;
119
+ }
120
+ // the socket outlives the response, so what only the response can answer is read
121
+ // while it is still alive: reading it later would be a use after free
122
+ request._detachFromResponse();
123
+ res.cork(() => {
124
+ res.upgrade({ req: request }, key, protocol, extensions, context);
125
+ });
126
+ };
127
+
128
+ if (!userUpgrade) {
129
+ accept();
130
+ return;
131
+ }
132
+
133
+ const response = new app._response(res, request, app);
134
+ request.res = response;
135
+
136
+ let decision;
137
+ try {
138
+ decision = userUpgrade(request, response);
139
+ } catch (err) {
140
+ // an upgrade that throws refuses the socket, and says so the way an unhandled route
141
+ // would rather than leaving the client hanging on a half-open handshake
142
+ if (!response.finished) {
143
+ res.cork(() => {
144
+ response.status(500).end();
145
+ });
146
+ }
147
+ app.emit("error", err);
148
+ return;
149
+ }
150
+
151
+ if (!decision || typeof decision.then !== "function") {
152
+ accept();
153
+ return;
154
+ }
155
+
156
+ // an async hook (a session lookup, a token check) outlives this callback, so µWS has to
157
+ // be told who to call if the client leaves first. Registered now, still inside the
158
+ // handler, which is the only place µWS accepts it
159
+ res.onAborted(() => {
160
+ aborted = true;
161
+ });
162
+ // and whatever the hook writes now lands outside the cork µWS holds for this callback,
163
+ // so the response opens its own, exactly as a route handler answering late does
164
+ response._corkNeeded = true;
165
+ decision.then(accept, (err) => {
166
+ if (!aborted && !response.finished) {
167
+ res.cork(() => {
168
+ response.status(500).end();
169
+ });
170
+ }
171
+ app.emit("error", err);
172
+ });
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Hands every websocket route this application can reach to µWS. Called from listen(), before
178
+ * the catch-all goes on: µWS routes an upgrade to the websocket route even when a catch-all
179
+ * covers the same path, so the two live side by side.
180
+ *
181
+ * @param {any} app
182
+ */
183
+ function registerWebSocketRoutes(app) {
184
+ const routes = [];
185
+ collectRoutes(app, "", routes, new Set());
186
+ for (const route of routes) {
187
+ const uwsBehavior = { ...route.behavior };
188
+ delete uwsBehavior.upgrade;
189
+ // bound to the owner's classes, so a mounted sub-app's request layer is the one its own
190
+ // handlers expect
191
+ uwsBehavior.upgrade = makeUpgradeHandler(route.owner ?? app, route.path, route.behavior);
192
+ app.uwsApp.ws(route.path, uwsBehavior);
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Whatever a caller passed as a behavior, checked where it is written rather than where it is
198
+ * used: a handler under a misspelled name would otherwise never run and never say why.
199
+ *
200
+ * @param {string} path
201
+ * @param {any} behavior
202
+ */
203
+ function checkBehavior(path, behavior) {
204
+ if (typeof path !== "string") {
205
+ throw new TypeError("app.ws() requires a path string");
206
+ }
207
+ if (!behavior || typeof behavior !== "object") {
208
+ throw new TypeError("app.ws() requires a behavior object, as µWS takes");
209
+ }
210
+ if (!canBeOptimizedWithParams(path)) {
211
+ throw new Error(
212
+ `websocket path "${path}" is not one µWS can match. Use a literal path, or ` +
213
+ "parameters that are a whole segment, as in /room/:id."
214
+ );
215
+ }
216
+ for (const name of [...SOCKET_HANDLERS, "upgrade"]) {
217
+ if (behavior[name] !== undefined && typeof behavior[name] !== "function") {
218
+ throw new TypeError(`app.ws() behavior.${name} must be a function`);
219
+ }
220
+ }
221
+ }
222
+
223
+ module.exports = { registerWebSocketRoutes, checkBehavior };