Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add WebSocket class interceptor #501

Merged
merged 40 commits into from
Feb 14, 2024
Merged

feat: add WebSocket class interceptor #501

merged 40 commits into from
Feb 14, 2024

Conversation

kettanaito
Copy link
Member

@kettanaito kettanaito commented Jan 28, 2024

Changes

  • Adds a WebSocket interceptor based on the globalThis.WebSocket class.
  • Introduces the client and the server APIs to represent the incoming client connection and the original server connection respectively in the connection listener of the interceptor.
  • Adds the required infrastructure of the interceptor/client/server/transport classes that describe the generic class and also the WebSocket class-relevant implementations of them, such as WebSocketClassClient, WebSocketClassServer, WebSocketClassTransport.

Todo

  • Tests only: The ws module we use for testing doesn't pass the instanceof check on dispatched events in Node, causing some tests to fail (Use Node.js 15 native EventTarget object websockets/ws#1818).
    • Once this is fixed, unskip the tests at test/modules/WebSocket/WebSocketClass/exchange/websocket.server.connect.test.ts
    • This was caused by JSDOM. It polyfills global Event.
  • Write WebSocket compliance tests.
    • Default state
    • Events
    • Methods (.send() and .close() and its input)
    • Setters (.onopen and etc)
  • Some third-parties also add origin on the MessageEvent from the socket. Check if the interceptor should do that too (inspect the original WebSocket event).
  • Emit the error event correctly. It's emitted in the following scenarios:
  • Close the actual server connection whenever the original (mocked) client is closed.
  • Consider removing WebSocketClient#emit. It's non-standard.
  • Support custom encoder/decoder for (a) JSON transfer; (b) to support custom event format (like SocketIO).
    • This may be out of scope. Consider this carefully.
    • Use bindings instead. I've implemented the socket.io-binding.
  • Consider migrating to EventTarget-like public API: client.addEventListener().

Third-party support

constructor() {
super({
name: 'websocket',
interceptors: [new WebSocketClassInterceptor()],
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WebSocketInterceptor itself has started as a batched interceptor on a premise that in the future it will combine multiple, transport-related interceptors (WebSocket, XHR polling).

I believe that's redundant now. I don't plan to support any non-standard WebSocket implementations, and this pull request implements the interceptor for the standard implementation already.

protected checkEnvironment(): boolean {
// Enable this interceptor in any environment
// that has a global WebSocket API.
return typeof globalThis.WebSocket !== 'undefined'
Copy link
Member Author

@kettanaito kettanaito Jan 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The WebSocket interceptor will be available anywhere where the global WebSocket class is present.

For testing, we are setting that class in a custom Vitest environment, taking it from undici and making it a global class.

// All WebSocket instances are mocked and don't forward
// any events to the original server (no connection established).
// To forward the events, the user must use the "server.send()" API.
const mockWs = new WebSocketClassOverride(url, protocols)
Copy link
Member Author

@kettanaito kettanaito Jan 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All constructed WebSocket instances will point to a "dummy" implementation of the WebSocket class (i.e. mock). This makes all connections mocked by default (will never reach the actual server unless you tell them to). I find this to be a good default when developing against non-existing WebSocket servers.

Reason

new WebSocket(url) connections to non-existing addresses throw a special kind of error in the browser that cannot be caught by any means.

data: WebSocketSendData
) => void

export abstract class WebSocketTransport {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Transport is an abstract class responsible for handling incoming and outgoing events from a WebSocket instance.

At its core, the transport is the barebones implementation of how to apply client.send() events and how to listen to client.on() events.

Later, the transport is wrapped in the Connection instance to acquire a nicer public API.

* client connection. The user can control the connection,
* send and receive events.
*/
export class WebSocketClient {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a WebSocket client connection class. It represents a single WebSocket client connected to the "server" (the interceptor).

This API is implementation-agnostic and provides the public methods to interact with the intercepted WebSocket client.

* WebSocket server connection. It's idle by default but you can
* establish it by calling `server.connect()`.
*/
export class WebSocketServer {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the WebSocket client representation, this is the actual WebSocket server connection representation. It's used to:

  • Establish the original connection to the server;
  • Intercept the incoming server events (the real ones);
  • Forward any (intercepted) outgoing client events to the actual server (if you wish a full passthrough interception).

@kettanaito kettanaito marked this pull request as ready for review January 29, 2024 15:40
@kettanaito
Copy link
Member Author

Socket.IO support

import { io } from '@mswjs/socket.io-parser'

interceptor.on('connection', (args) => {
  const client = io(args.client)
  const server = io(args.server)

  // MessageEvent data is decoded.
  client.on('message', (event) => {
    console.log(event.data) // "hello"
  })

  // Custom events are supported
  // (decoded from the generic MessageEvent).
  client.on('custom-event', (data) => {})

  // Outgoing messages are encoded.
  client.send('hello')
  // Custom events are encoded and transformed
  // into the generic MessageEvent.
  client.emit('custom-event',new Blob(['hello']))

  // Incoming original server events are decoded.
  server.on('server-event', (data) => {})
  // Sent messages to the server are encoded.
  // Also sent in 2 packets as Socket.IO does it.
  server.send(new Blob(['hello']))
})

@kettanaito
Copy link
Member Author

The API itself is complete. One thing remaining is proper test coverage, specifically addressing the bug in the ws module (the Event instance check) or migrating to a different server package to use for testing.

@kettanaito kettanaito merged commit 84a16d4 into main Feb 14, 2024
1 check passed
@kettanaito kettanaito deleted the feat/ws-browser-impl branch February 14, 2024 16:11
@kettanaito
Copy link
Member Author

Released: v0.26.0 🎉

This has been released in v0.26.0!

Make sure to always update to the latest version (npm i @mswjs/interceptors@latest) to get the newest features and bug fixes.


Predictable release automation by @ossjs/release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant