Skip to content

WebSocket interceptor support #479

Description

@diego-aquino

Prerequisites

  • I confirm that none of the open issues match my proposal
  • I confirm that my proposal is not yet available in the latest version of Zimic (see the documentation and the releases)

Scope

Adds a new behavior

Compatibility

  • This is a breaking change

Context

msw@2.6.0 added support to intercept and mock WebSocket connections. This opens up a path for a Zimic WebSocket interceptor.

Feature description

Add support to intercept and mock WebSocket connections in Zimic. To do this, I believe we need to create a new entity, WebSocketInterceptor, which is independent from the current HttpInterceptor. We also need a different set of schema primitives, as WebSocket is based on messages, not requests and responses as HTTP.

Requirements

  • It should be possible to intercept messages sent from the server to one or more clients.
  • It should be possible to intercept messages sent from a client to the server.
  • It should be possible to list all WebSocket clients connected to a server
  • It should be possible to list all messages received by a socket (server or client), similarly to handler.requests.
  • It should be possible to send a message from a client to the server.
  • It should be possible to send a message from a server to a subset of clients.
  • It should be possible to send a message from a server to all clients (broadcast).
  • It should be possible to run specific code when a client is first connected (connection event).
  • It should be possible to send a message with text or binary data.
  • It should be possible to validate that a number of messages was sent with expected data (times).
  • The solution should use native WebSocket entities and not be coupled to library-specific implementations. socket.io support may be considered and included if not too distant from the native Web Socket entities.
  • @zimic/ws should not automatically parse the event data. If the data is a JSON-compatible type, type the string in such a way that calling JSON.parse() over it yields the correct type (use type branding).

Questions

  • How to check/handle handshake checks?
  • How to handle third-party libraries such as Socket IO?
    • Recommend transports: ['websocket']?
    • Provide an adapter?

Development

  • [@zimic/interceptor/ws] Create @zimic/interceptor/ws
  • [@zimic/interceptor/ws] Create LocalWebSocketInterceptor
  • [@zimic/interceptor/ws] Create RemoteWebSocketInterceptor
  • [@zimic/interceptor/ws] Create createWebSocketInterceptor
  • [@zimic/interceptor/ws] WebSocketInterceptor#start()
  • [@zimic/interceptor/ws] WebSocketInterceptor#stop()
  • [@zimic/interceptor/ws] WebSocketInterceptor#isRunning

  • [@zimic/interceptor/ws] WebSocketInterceptor#server
  • [@zimic/interceptor/ws] WebSocketInterceptor#clients

  • [@zimic/interceptor/ws] WebSocketInterceptor#server#messages
  • [@zimic/interceptor/ws] WebSocketInterceptor#clients#messages

  • [@zimic/interceptor/ws] WebSocketInterceptor#message()

  • [@zimic/interceptor/ws] WebSocketMessageHandler#with()

  • [@zimic/interceptor/ws] WebSocketMessageHandler#times()

  • [@zimic/interceptor/ws] WebSocketMessageHandler#send()
  • [@zimic/interceptor/ws] WebSocketMessageHandler#send()#to()

  • [@zimic/interceptor/ws] WebSocketInterceptor#on('connection')

  • [@zimic/ws] Create package
  • [@zimic/ws] Create WebSocketSchema
  • [@zimic/ws] Configure deployments

  • [@zimic/ws] Create WebSocketClient
    • [@zimic/ws] Create WebSocketClient#start()
    • [@zimic/ws] Create WebSocketClient#stop()

  • [@zimic/ws] Create WebSocketClient#addEventListener()
  • [@zimic/ws] Create WebSocketClient#removeEventListener()

  • [@zimic/ws] Create WebSocketClient#send()

  • [@zimic/ws] Create WebSocketServer
  • [@zimic/ws] Create WebSocketServer#start()
  • [@zimic/ws] Create WebSocketServer#stop()

  • [@zimic/ws] Create WebSocketServer#addEventListener()
  • [@zimic/ws] Create WebSocketServer#removeEventListener()

  • [@zimic/ws] Create WebSocketServer#send()

  • [@zimic/interceptor/ws] Refactor InterceptorServer to use @zimic/ws

Code snippets (draft)

import { expect } from 'vitest';
import { WebSocketSchema, WebSocketClient } from '@zimic/ws';
import { WebSocketServer } from '@zimic/ws/server';
import { createWebSocketInterceptor } from '@zimic/interceptor/ws';

interface Post {
  id: string;
  content: string;
}

type Schema = WebSocketSchema<
  | { type: 'ping' }
  | { type: 'pong' }
  | { type: 'post:create'; data: { post: Post } }
  | { type: 'post:update'; data: { post: Post; before: Post } }
  | { type: 'post:delete'; data: { post: Post } }
>;

const interceptor = createWebSocketInterceptor<Schema>({
  type: 'local',
  baseURL: 'ws://localhost:3000',
  messageSaving: {
    enabled: true,
    safeLimit: 1000
  },
});

async function main() {
  expect(interceptor.isRunning).toBe(false);

  expect(interceptor.server).instanceOf(WebSocketServer)
  expect(interceptor.server.readyState).toBe(WebSocket.CLOSED););
  expect(interceptor.server.messages).toHaveLength(0);
  expect(interceptor.clients).toHaveLength(0);

  await interceptor.start();

  expect(interceptor.isRunning).toBe(true);

  expect(interceptor.server).instanceOf(WebSocketServer);
  expect(interceptor.server.messages).toHaveLength(0);
  expect(interceptor.clients).toHaveLength(0);

  // Simulating connections from an application...
  // await application.connect({ ws: interceptor.baseURL });
  // await application.connect({ ws: interceptor.baseURL });

  expect(interceptor.server.messages).toHaveLength(0);
  expect(interceptor.clients).toHaveLength(2);

  for (const client of interceptor.clients) {
    expect(client).instanceOf(WebSocketClient);
    expect(client.readyState).toBe(WebSocket.OPEN);
    expect(client.messages).toHaveLength(0);

    // `socket.messages` is only available in WebSocketInterceptorClient, not WebSocketClient
  }

  // `server.send(data)` broadcasts to all clients
  // `server.send(data, { to: clients })` sends a message to specific clients
  // `client.send(data)` sends a message to the server

  interceptor.server
    .message()
    .with({ type: 'ping' })
    .run((message) => message.receiver.send({ type: 'pong' }, { to: message.sender }))
    .times(1);

  interceptor.server
    .message()
    .with({ type: 'ping' })
    .send({ type: 'pong' })
    .times(1);

  interceptor.clients[0]
    .message()
    .with({ type: 'pong' })
    .run((message) => message.sender.send({ type: 'ping' }, { to: message.receiver }));

  interceptor.clients[0]
    .message()
    .with({ type: 'pong' })
    .send({ type: 'ping' })
    .delay(500)
    .times(1);

  await interceptor.server
    .send(
      { type: 'ping' },
      { to: interceptor.clients[0] },
    );

  await interceptor.clients[0]
    .send({
      type: 'post:create',
      data: { post: { id: crypto.randomUUID(), content: '...' } },
    });

  interceptor.server
    .message()
    .from(interceptor.clients[0])
    .with({ type: 'post:create' })
    .send({
      type: 'post:update',
      data: {
        post: { id: crypto.randomUUID(), content: 'Updated content' },
        before: { id: crypto.randomUUID(), content: '...' },
      },
    })
    .delay(200)
    .times(1);

  interceptor.server.on('connection', (client, request) => {
    console.log(client.readyState, request.headers['sec-websocket-protocol']);
  });

  await waitFor(() => {
    expect(interceptor.clients[0].messages).toHaveLength(2);
    expect(interceptor.clients[1].messages).toHaveLength(1);
  });

  // Check the received message data.
  expect(interceptor.clients[0].messages[0]).toEqual({
    sender: interceptor.clients[0],
    receiver: interceptor.server,
    data: { type: 'ping' },
  });

  await interceptor.stop();

  expect(interceptor.isRunning).toBe(false);
  expect(interceptor.server.messages).toHaveLength(0);
  expect(interceptor.clients).toHaveLength(0);
}

void main();

Metadata

Metadata

Assignees

Labels

@zimic/interceptorRelated to @zimic/interceptorfeatureNew feature or request

Type

No type

Fields

No fields configured for issues without a type.

Projects

Status
In progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions