Repository: websockets/ws
Pull request #2219 (2024-04-12 16:22:28)

[feature] Add createConnection option to control client socket setup
This adds support for a new `createConnection` option on client websockets, with the exact same functionality as the same option on `http.request` etc on Node.

The option is just passed straight through to `http(s).request`, so supports exactly the same functionality, including returning _any_ Duplex stream as a connection, not just net sockets.

This is useful in quite a few cases. In my specific case, I need to create an outgoing WebSocket over an existing duplex stream, and this makes that possible with `createConnection: () => stream`. I've tested this change in my codebase and it seems to be able to do that perfectly.

Notably though, this also makes it possible to potentially create WebSockets over things like HTTP/2 streams (see #1458). It probably doesn't fully provide that, and that's not something I'm worrying about much myself right now, but it's a step in the right direction, and this would get rid of the fragile & confusing workarounds linked in that issue (https://github.com/szmarczak/http2-wrapper/blob/master/examples/ws/index.js).

lpinca: See https://github.com/websockets/ws/issues/1944
pimterry: Ah, interesting! I hadn't seen the `agent` workaround, that is probably a usable workaround for me.

Given that 3x people (#1944, this PR and #2088) have now all independently suggested adding this option though (and others like the HTTP/2 example above seem to have done worse things to workaround not being obviously able to do this) maybe it's worth including? Otherwise I'm sure others make the same suggestion in future, and it's a very tiny change to pave the path. Personally I was looking for this because I use the Node HTTP API for this very frequently, while I'm not sure I've ever written a custom agent.

Up to you though! In the meantime the agent version should be manageable for me even if you don't want to merge this.
lpinca: I don't remember why but now allowing a custom `options.createConnection` was intentional.

Patch:
diff --git a/doc/ws.md b/doc/ws.md
index 017087f5..37f4c970 100644
--- a/doc/ws.md
+++ b/doc/ws.md
@@ -304,6 +304,10 @@ This class represents a WebSocket. It extends the `EventEmitter`.
     `'ping'`, and `'pong'` events can be emitted multiple times in the same
     tick. To improve compatibility with the WHATWG standard, the default value
     is `false`. Setting it to `true` improves performance slightly.
+  - `createConnection` {Function} An alternative function to use in place of
+    `tls.createConnection` or `net.createConnection`. This can be used to
+    manually control exactly how the connection to the server is made, or to
+    make a connection over an existing Duplex stream obtained elsewhere.
   - `finishRequest` {Function} A function which can be used to customize the
     headers of each HTTP request before it is sent. See description below.
   - `followRedirects` {Boolean} Whether or not to follow redirects. Defaults to
diff --git a/lib/websocket.js b/lib/websocket.js
index f133d08f..a2c8edbe 100644
--- a/lib/websocket.js
+++ b/lib/websocket.js
@@ -628,6 +628,8 @@ module.exports = WebSocket;
  *     times in the same tick
  * @param {Boolean} [options.autoPong=true] Specifies whether or not to
  *     automatically send a pong in response to a ping
+ * @param {Function} [options.createConnection] An alternative function to use
+ *     in place of `tls.createConnection` or `net.createConnection`.
  * @param {Function} [options.finishRequest] A function which can be used to
  *     customize the headers of each http request before it is sent
  * @param {Boolean} [options.followRedirects=false] Whether or not to follow
@@ -660,8 +662,8 @@ function initAsClient(websocket, address, protocols, options) {
     perMessageDeflate: true,
     followRedirects: false,
     maxRedirects: 10,
-    ...options,
     createConnection: undefined,
+    ...options,
     socketPath: undefined,
     hostname: undefined,
     protocol: undefined,
@@ -732,7 +734,8 @@ function initAsClient(websocket, address, protocols, options) {
   const protocolSet = new Set();
   let perMessageDeflate;
 
-  opts.createConnection = isSecure ? tlsConnect : netConnect;
+  opts.createConnection =
+    opts.createConnection || (isSecure ? tlsConnect : netConnect);
   opts.defaultPort = opts.defaultPort || defaultPort;
   opts.port = parsedUrl.port || defaultPort;
   opts.host = parsedUrl.hostname.startsWith('[')


Test patch:
diff --git a/test/websocket.test.js b/test/websocket.test.js
index e1b3bd23..fc41ae75 100644
--- a/test/websocket.test.js
+++ b/test/websocket.test.js
@@ -1224,6 +1224,34 @@ describe('WebSocket', () => {
       });
     });
 
+    it('honors the `createConnection` option', (done) => {
+      const wss = new WebSocket.Server({ noServer: true, path: '/foo' });
+
+      server.once('upgrade', (req, socket, head) => {
+        assert.strictEqual(req.headers.host, 'google.com:22');
+        wss.handleUpgrade(req, socket, head, NOOP);
+      });
+
+      const ws = new WebSocket('ws://google.com:22/foo', {
+        createConnection: (options) => {
+          assert.strictEqual(options.host, 'google.com');
+          assert.strictEqual(options.port, '22');
+
+          // Ignore the invalid host address, and connect to the server manually:
+          return net.createConnection({
+            host: 'localhost',
+            port: server.address().port
+          });
+        }
+      });
+
+      ws.on('open', () => {
+        assert.strictEqual(ws.url, 'ws://google.com:22/foo');
+        ws.on('close', () => done());
+        ws.close();
+      });
+    });
+
     it('emits an error if the redirect URL is invalid (1/2)', (done) => {
       server.once('upgrade', (req, socket) => {
         socket.end('HTTP/1.1 302 Found\r\nLocation: ws://\r\n\r\n');