Skip to content

Commit

Permalink
feat: add WebSocket class interceptor
Browse files Browse the repository at this point in the history
  • Loading branch information
kettanaito committed Jan 28, 2024
1 parent 3429931 commit 7c542cf
Show file tree
Hide file tree
Showing 10 changed files with 678 additions and 0 deletions.
80 changes: 80 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,86 @@ interceptor.on(

> Note that the `isMockedResponse` property will only be set to `true` if you resolved this request in the "request" event listener using the `request.respondWith()` method and providing a mocked `Response` instance.
## WebSocket interception

```js
import { WebSocketInterceptor } from '@mswjs/interceptors/WebSocket'

const interceptor = new WebSocketInterceptor()
```

The WebSocket interceptor emits a single `connection` event that represents a new client connecting to the server. Within the `connection` event listener, you can use the `client` reference to listen to the outgoing events and send the mock events from the server.

```js
interceptor.on('connection', ({ client }) => {
console.log(client.url)

client.on('message', (event) => {
if (event.data === 'hello from client') {
client.send('hello from server')
}
})
})
```

### Interceptor arguments

| Name | Type | Description |
| -------- | -------- | -------------------------------------- |
| `client` | `object` | A client WebSocket connection. |
| `server` | `object` | An actual WebSocket server connection. |

Remember that you write the interceptor from the _server's perspective_. With that in mind, the `client` represents a client connecting to this "server" (your interceptor). The `server`, on the other hand, represents an actual production server connection, if you ever wish to establish one to intercept the incoming events as well.

### Bypassing events

By default, the WebSocket interceptor prevents all the outgoing events from reaching the production server. To bypass the event, first establish the actual server connection by calling `await server.connect()`, and then use `server.send()` and provide it the `MessageEvent` to bypass.

```js
interceptor.on('connection', async ({ client, server }) => {
// First, connect to the actual server.
await server.connect()

// Forward all outgoing client events to the original server.
client.on('message', (event) => server.send(event))
})
```

### Incoming server events

The WebSocket interceptor also intercepts the incoming events sent from the original server.

```js
interceptor.on('connection', async ({ server }) => {
await server.connect()

server.on('message', (event) => {
console.log('original server sent:', event.data)
})
})
```

> Note: If you establish the original server connection, all the incoming server events will be automatically forwarded to the client.
If you wish to prevent the automatic forwarding of the server events to the client, call `event.preventDefault()` on the incoming event you wish to prevent.

```js
interceptor.on('connection', async ({ client, server }) => {
await server.connect()

server.on('message', (event) => {
if (event.data === 'hello from actual server') {
// Never forward this event to the client.
event.preventDefault()

// Instead, send this mock data.
client.send('hello from mock server')
return
}
})
})
```

## API

### `Interceptor`
Expand Down
65 changes: 65 additions & 0 deletions src/interceptors/WebSocket/WebSocketClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type {
WebSocketSendData,
WebSocketTransport,
} from './WebSocketTransport'

const kEmitter = Symbol('emitter')

/**
* The WebSocket client instance represents an incoming
* client connection. The user can control the connection,
* send and receive events.
*/
export class WebSocketClient {
protected [kEmitter]: EventTarget

constructor(
public readonly url: URL,
protected readonly transport: WebSocketTransport
) {
this[kEmitter] = new EventTarget()

/**
* Emit incoming server events so they can be reacted to.
* @note This does NOT forward the events to the client.
* That must be done explicitly via "server.send()".
*/
transport.onIncoming = (event) => {
this[kEmitter].dispatchEvent(event)
}
}

/**
* Listen for incoming events from the connected client.
*/
public on(
event: string,
listener: (...data: Array<WebSocketSendData>) => void
): void {
this[kEmitter].addEventListener(event, (event) => {
if (event instanceof MessageEvent) {
listener(event.data)
}
})
}

/**
* Send data to the connected client.
*/
public send(data: WebSocketSendData): void {
this.transport.send(data)
}

/**
* Emit the given event to the connected client.
*/
public emit(event: string, data: WebSocketSendData): void {
throw new Error('WebSocketClient#emit is not implemented')
}

public close(error?: Error): void {
// Don't do any guessing behind the close code's semantics
// and fallback to a generic contrived close code of 3000.
this.transport.close(error ? 3000 : 1000, error?.message)
}
}
35 changes: 35 additions & 0 deletions src/interceptors/WebSocket/WebSocketServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { WebSocketSendData } from './WebSocketTransport'
import type { WebSocketMessageListener } from './implementations/WebSocketClass/WebSocketClassInterceptor'

/**
* The WebSocket server instance represents the actual production
* WebSocket server connection. It's idle by default but you can
* establish it by calling `server.connect()`.
*/
export class WebSocketServer {
/**
* Connect to the actual WebSocket server.
*/
public connect(): Promise<void> {
throw new Error('WebSocketServer#connect is not implemented')
}

/**
* Send the data to the original server.
* The connection to the original server will be opened
* as a part of the first `server.send()` call.
*/
public send(data: WebSocketSendData): void {
throw new Error('WebSocketServer#send is not implemented')
}

/**
* Listen to the incoming events from the original
* WebSocket server. All the incoming events are automatically
* forwarded to the client connection unless you prevent them
* via `event.preventDefault()`.
*/
public on(event: string, callback: WebSocketMessageListener): void {
throw new Error('WebSocketServer#on is not implemented')
}
}
31 changes: 31 additions & 0 deletions src/interceptors/WebSocket/WebSocketTransport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export type WebSocketSendData =
| string
| ArrayBufferLike
| Blob
| ArrayBufferView

export type WebSocketTransportOnIncomingCallback = (
event: MessageEvent<WebSocketSendData>
) => void

export abstract class WebSocketTransport {
/**
* Listener for the incoming server events.
* This is called when the client receives the
* event from the original server connection.
*
* This way, we can trigger the "message" event
* on the mocked connection to let the user know.
*/
abstract onIncoming: WebSocketTransportOnIncomingCallback

/**
* Send the data from the server to this client.
*/
abstract send(data: WebSocketSendData): void

/**
* Close the client connection.
*/
abstract close(code: number, reason?: string): void
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { invariant } from 'outvariant'
import { WebSocketClient } from '../../WebSocketClient'
import type { WebSocketSendData } from '../../WebSocketTransport'
import { WebSocketClassTransport } from './WebSocketClassTransport'
import type { WebSocketClassOverride } from './WebSocketClassInterceptor'

export class WebSocketClassClient extends WebSocketClient {
constructor(
readonly ws: WebSocketClassOverride,
readonly transport: WebSocketClassTransport
) {
super(new URL(ws.url), transport)
}

public emit(event: string, data: WebSocketSendData): void {
invariant(
event === 'message',
'Failed to emit unknown WebSocket event "%s": only the "message" event is supported using the standard WebSocket class',
event
)

this.transport.send(data)
}
}
Loading

0 comments on commit 7c542cf

Please sign in to comment.