Skip to content

Instantly share code, notes, and snippets.

@kettanaito
Last active November 12, 2023 10:43
Show Gist options
  • Save kettanaito/0f942a25c154548a07bb8c9f5c0a8115 to your computer and use it in GitHub Desktop.
Save kettanaito/0f942a25c154548a07bb8c9f5c0a8115 to your computer and use it in GitHub Desktop.
On Mocking Websockets

On Mocking WebSockets

Unlike HTTP, WebSocket communication is duplex (both the client and the server can emit and listen to events) and persistent. This difference is reflected in how one would expect to mock a WebSocket communication.

Connection

I believe mocking WebSockets should be connection-based. This way each individual connected client is scoped to a particular interceptor handler.

interceptor.on('connection', (connection) => {
  console.log('New WebSocket connection at', connection.url)
})

Mocked vs Bypassed vs Combined

Similar to mocking HTTP communication, mocking WebSocket communication encompases three intentions:

  1. I want to replace the communication (i.e. mock);
  2. I want to augment the communication (i.e. get the original server events and transform/ignore them);
  3. I want to bypass the communication (i.e. observe).

Unlike HTTP, with WebSockets, one has to decide early on the intention since the very first client-server interaction is the issued connection (handshake). The interceptor must know about the intention early in order to capture and replay the connection errors accordingly.

I was thinking about an explicit connection method that allows the consumer to decide the mocking approach they want to take.

interceptor.on('connection', (connection) => {
  connection.open('mocked')
  // connection.open('bypass')
})

This choice must be made. The connection will be in the CONNECTING state until it is.

Alternatively, I'd like to start the interception by mocking (or bypassing) the handshake request.

connection.handshake() // connect as-is
connection.handshake(new Response(null, { status: 101 }))

I tried using XHR interceptor to mock the handshake response but it doesn't seem to trigger. I may try using the fetch interceptor but in my quick experimentation, the handshake request bypassed it as well. One last thing to try it the Service Worker but I'd rather not spread the logic across different workers (MSW's default worker + WebSocket interception handshake worker). Although, the latter part can technically be achieved by adding a new http.* handler as a part of MSW's higher-level interception API.

Approaching this choice on the handshake basis is more semantically correct but is more verbose as well.

Events

Once the consumer decides on the nature of the connection (mocked/bypassed/combined), they can listen and react to outgoing WebSocket events.

Listening to events

connection.on('message', (data) => {
  console.log('from client:', data)
})

For normalization reasons, the data received in the connection is always plain data, unlike the MessageEvent that you receive in the message listener on the standard WebSocket class. This normalization will make it easier to adjust the interceptor to different WebSocket implementations, such as Socket.io that doesn't utilize the MessageEvent protocol.

Sending data

connection.send('Hello from server!')

Emittig events

Emitting custom events is not supported in the browser WebSocket implementation. The consumer can still, however, emit custom events if using other WebSocket implementations, such as Socket.io.

connection.emit('chat', 'Welcome!')

Since the underlying WebSocket implementation is an implementation detail, perhaps it's best the interceptor simply ignores custom emitted events instead of throwing upon them.

Closing the connection

The consumer can close the WebSocket connection at any time.

connection.close()

Similar to the WebSocket.prototype.close method, the connection.close() method accepts two arguments: code (ref) and reason. It is up to the underlying transport implementation to respect those arguments.

For example, if the consumer wishes to mock a connection error on a new WebSocket connection, they can do this:

interceptor.on('connection', (connection) => {
  connection.close(3021, 'Custom server-side error')
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment