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

fix(WebSocket): exports client connection protocol, transport, renames types #509

Merged
merged 6 commits into from
Feb 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions src/interceptors/WebSocket/WebSocketClassTransport.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,32 @@
import { bindEvent } from './utils/bindEvent'
import {
WebSocketRawData,
WebSocketData,
WebSocketTransport,
WebSocketTransportOnCloseCallback,
WebSocketTransportOnIncomingCallback,
WebSocketTransportOnOutgoingCallback,
} from './WebSocketTransport'
import { kOnSend, kClose, WebSocketOverride } from './WebSocketOverride'

/**
* Abstraction over the given mock `WebSocket` instance that allows
* for controlling that instance (e.g. sending and receiving messages).
*/
export class WebSocketClassTransport extends WebSocketTransport {
public onOutgoing: WebSocketTransportOnOutgoingCallback = () => {}
public onIncoming: WebSocketTransportOnIncomingCallback = () => {}
public onClose: WebSocketTransportOnCloseCallback = () => {}

constructor(protected readonly ws: WebSocketOverride) {
constructor(protected readonly socket: WebSocketOverride) {
super()

this.ws.addEventListener('close', (event) => this.onClose(event), {
this.socket.addEventListener('close', (event) => this.onClose(event), {
once: true,
})
this.ws[kOnSend] = (...args) => this.onOutgoing(...args)
this.socket[kOnSend] = (...args) => this.onOutgoing(...args)
}

public send(data: WebSocketRawData): void {
public send(data: WebSocketData): void {
queueMicrotask(() => {
const message = bindEvent(
/**
Expand All @@ -33,14 +37,14 @@ export class WebSocketClassTransport extends WebSocketTransport {
* mocked message events like the one below
* (must be dispatched on the client instance).
*/
this.ws,
this.socket,
new MessageEvent('message', {
data,
origin: this.ws.url,
origin: this.socket.url,
})
)

this.ws.dispatchEvent(message)
this.socket.dispatchEvent(message)
})
}

Expand All @@ -50,6 +54,6 @@ export class WebSocketClassTransport extends WebSocketTransport {
* to allow closing the connection with the status codes
* that are non-configurable by the user (> 1000 <= 1015).
*/
this.ws[kClose](code, reason)
this.socket[kClose](code, reason)
}
}
27 changes: 18 additions & 9 deletions src/interceptors/WebSocket/WebSocketClientConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,44 +5,53 @@
* meant to be used over any WebSocket implementation
* (not all of them follow the one from WHATWG).
*/
import type { WebSocketRawData, WebSocketTransport } from './WebSocketTransport'
import type { WebSocketData, WebSocketTransport } from './WebSocketTransport'
import { WebSocketMessageListener } from './WebSocketOverride'
import { bindEvent } from './utils/bindEvent'
import { CloseEvent } from './utils/events'
import { uuidv4 } from '../../utils/uuid'

const kEmitter = Symbol('kEmitter')

export interface WebSocketClientConnectionProtocol {
Copy link
Member Author

Choose a reason for hiding this comment

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

Exporting this protocol means that other consumers, like MSW, can implement custom client connection-like classes. For example, to support .clients in the browser, MSW creates virtual connection objects that use BroadcastChannel to implement methods like send() (signal other clients from other runtimes that they should now receive data).

id: string
url: URL
send(data: WebSocketData): void
close(code?: number, reason?: string): void
}

/**
* The WebSocket client instance represents an incoming
* client connection. The user can control the connection,
* send and receive events.
*/
export class WebSocketClientConnection {
export class WebSocketClientConnection
Copy link
Member Author

Choose a reason for hiding this comment

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

Extending this class itself to implement connection-like objects is inefficient. It requires to provide a bunch of irrelevant properties, like kEmitter, socket, and transport. Those are internal details of this class but extending it means providing those internal details also.

implements WebSocketClientConnectionProtocol
{
public readonly id: string
public readonly url: URL

protected [kEmitter]: EventTarget
private [kEmitter]: EventTarget

constructor(
protected readonly ws: WebSocket,
protected readonly transport: WebSocketTransport
private readonly socket: WebSocket,
private readonly transport: WebSocketTransport
) {
this.id = uuidv4()
this.url = new URL(ws.url)
this.url = new URL(socket.url)
this[kEmitter] = new EventTarget()

// Emit outgoing client data ("ws.send()") as "message"
// events on the client connection.
this.transport.onOutgoing = (data) => {
this[kEmitter].dispatchEvent(
bindEvent(this.ws, new MessageEvent('message', { data }))
bindEvent(this.socket, new MessageEvent('message', { data }))
)
}

this.transport.onClose = (event) => {
this[kEmitter].dispatchEvent(
bindEvent(this.ws, new CloseEvent('close', event))
bindEvent(this.socket, new CloseEvent('close', event))
)
}
}
Expand Down Expand Up @@ -76,7 +85,7 @@ export class WebSocketClientConnection {
/**
* Send data to the connected client.
*/
public send(data: WebSocketRawData): void {
public send(data: WebSocketData): void {
this.transport.send(data)
}

Expand Down
8 changes: 4 additions & 4 deletions src/interceptors/WebSocket/WebSocketOverride.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { invariant } from 'outvariant'
import type { WebSocketRawData } from './WebSocketTransport'
import type { WebSocketData } from './WebSocketTransport'
import { bindEvent } from './utils/bindEvent'
import { CloseEvent } from './utils/events'

Expand Down Expand Up @@ -40,7 +40,7 @@ export class WebSocketOverride extends EventTarget implements WebSocket {
private _onerror: WebSocketEventListener | null = null
private _onclose: WebSocketCloseListener | null = null

private [kOnSend]?: (data: WebSocketRawData) => void
private [kOnSend]?: (data: WebSocketData) => void

constructor(url: string | URL, protocols?: string | Array<string>) {
super()
Expand Down Expand Up @@ -110,7 +110,7 @@ export class WebSocketOverride extends EventTarget implements WebSocket {
/**
* @see https://websockets.spec.whatwg.org/#ref-for-dom-websocket-send%E2%91%A0
*/
public send(data: WebSocketRawData): void {
public send(data: WebSocketData): void {
if (this.readyState === this.CONNECTING) {
this.close()
throw new DOMException('InvalidStateError')
Expand Down Expand Up @@ -217,7 +217,7 @@ export class WebSocketOverride extends EventTarget implements WebSocket {
}
}

function getDataSize(data: WebSocketRawData): number {
function getDataSize(data: WebSocketData): number {
if (typeof data === 'string') {
return data.length
}
Expand Down
22 changes: 10 additions & 12 deletions src/interceptors/WebSocket/WebSocketServerConnection.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { invariant } from 'outvariant'
import type { WebSocketOverride } from './WebSocketOverride'
import type { WebSocketRawData } from './WebSocketTransport'
import type { WebSocketData } from './WebSocketTransport'
import type { WebSocketClassTransport } from './WebSocketClassTransport'
import { bindEvent } from './utils/bindEvent'
import { CancelableMessageEvent } from './utils/events'
Expand All @@ -20,9 +20,9 @@ export class WebSocketServerConnection {
private [kEmitter]: EventTarget

constructor(
private readonly mockWebSocket: WebSocketOverride,
private readonly createConnection: () => WebSocket,
private readonly transport: WebSocketClassTransport
private readonly socket: WebSocketOverride,
private readonly transport: WebSocketClassTransport,
private readonly createConnection: () => WebSocket
) {
this[kEmitter] = new EventTarget()

Expand Down Expand Up @@ -58,14 +58,14 @@ export class WebSocketServerConnection {
* Preventing the default on the message event stops this.
*/
if (!messageEvent.defaultPrevented) {
this.mockWebSocket.dispatchEvent(
this.socket.dispatchEvent(
bindEvent(
/**
* @note Bind the forwarded original server events
* to the mock WebSocket instance so it would
* dispatch them straight away.
*/
this.mockWebSocket,
this.socket,
// Clone the message event again to prevent
// the "already being dispatched" exception.
new MessageEvent('message', {
Expand Down Expand Up @@ -105,7 +105,7 @@ export class WebSocketServerConnection {

// Close the original connection when the (mock)
// client closes, regardless of the reason.
this.mockWebSocket.addEventListener(
this.socket.addEventListener(
'close',
(event) => {
ws.close(event.code, event.reason)
Expand All @@ -120,9 +120,7 @@ export class WebSocketServerConnection {
// Forward server errors to the WebSocket client as-is.
// We may consider exposing them to the interceptor in the future.
ws.addEventListener('error', () => {
this.mockWebSocket.dispatchEvent(
bindEvent(this.mockWebSocket, new Event('error'))
)
this.socket.dispatchEvent(bindEvent(this.socket, new Event('error')))
})

this.realWebSocket = ws
Expand Down Expand Up @@ -165,12 +163,12 @@ export class WebSocketServerConnection {
* server.send(new Blob(['hello']))
* server.send(new TextEncoder().encode('hello'))
*/
public send(data: WebSocketRawData): void {
public send(data: WebSocketData): void {
const { realWebSocket } = this
invariant(
realWebSocket,
'Failed to call "server.send()" for "%s": the connection is not open. Did you forget to call "await server.connect()"?',
this.mockWebSocket.url
this.socket.url
)

// Delegate the send to when the original connection is open.
Expand Down
29 changes: 17 additions & 12 deletions src/interceptors/WebSocket/WebSocketTransport.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,37 @@
export type WebSocketRawData = string | ArrayBufferLike | Blob | ArrayBufferView
export type WebSocketData = string | ArrayBufferLike | Blob | ArrayBufferView

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

export type WebSocketTransportOnOutgoingCallback = (
data: WebSocketRawData
) => void
export type WebSocketTransportOnOutgoingCallback = (data: WebSocketData) => void

export type WebSocketTransportOnCloseCallback = (event: CloseEvent) => 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.
* A callback for the incoming server events.
* This is called when the WebSocket client receives
* a message from the server.
*/
abstract onIncoming: WebSocketTransportOnIncomingCallback

/**
* A callback for outgoing client events.
* This is called when the WebSocket client sends data.
*/
abstract onOutgoing: WebSocketTransportOnOutgoingCallback

/**
* A callback for the close client event.
* This is called when the WebSocket client is closed.
*/
abstract onClose: WebSocketTransportOnCloseCallback

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

/**
* Close the client connection.
Expand Down
29 changes: 18 additions & 11 deletions src/interceptors/WebSocket/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { Interceptor } from '../../Interceptor'
import { WebSocketClientConnection } from './WebSocketClientConnection'
import {
type WebSocketClientConnectionProtocol,
WebSocketClientConnection,
} from './WebSocketClientConnection'
import { WebSocketServerConnection } from './WebSocketServerConnection'
import { WebSocketClassTransport } from './WebSocketClassTransport'
import { WebSocketOverride } from './WebSocketOverride'

export type { WebSocketRawData } from './WebSocketTransport'
export { WebSocketClientConnection, WebSocketServerConnection }
export { type WebSocketData, WebSocketTransport } from './WebSocketTransport'
export {
WebSocketClientConnection,
WebSocketClientConnectionProtocol,
WebSocketServerConnection,
}

export type WebSocketEventMap = {
connection: [
Expand All @@ -19,7 +26,7 @@ export type WebSocketEventMap = {
* The original WebSocket server connection.
*/
server: WebSocketServerConnection
}
},
]
}

Expand Down Expand Up @@ -56,22 +63,22 @@ export class WebSocketInterceptor extends Interceptor<WebSocketEventMap> {
// 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 WebSocketOverride(url, protocols)
const transport = new WebSocketClassTransport(mockWs)
const socket = new WebSocketOverride(url, protocols)
const transport = new WebSocketClassTransport(socket)

// The "globalThis.WebSocket" class stands for
// the client-side connection. Assume it's established
// as soon as the WebSocket instance is constructed.
this.emitter.emit('connection', {
client: new WebSocketClientConnection(mockWs, transport),
client: new WebSocketClientConnection(socket, transport),
server: new WebSocketServerConnection(
mockWs,
createConnection,
transport
socket,
transport,
createConnection
),
})

return mockWs
return socket
},
})

Expand Down
Loading