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.
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)
})
Similar to mocking HTTP communication, mocking WebSocket communication encompases three intentions:
- I want to replace the communication (i.e. mock);
- I want to augment the communication (i.e. get the original server events and transform/ignore them);
- 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.
Once the consumer decides on the nature of the connection (mocked/bypassed/combined), they can listen and react to outgoing WebSocket events.
connection.on('message', (data) => {
console.log('from client:', data)
})
For normalization reasons, the
data
received in the connection is always plain data, unlike theMessageEvent
that you receive in themessage
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 theMessageEvent
protocol.
connection.send('Hello from server!')
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.
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')
})