How to intercept WebSocket traffic?
JS Socket Inspector that can read and even block all the messages moving in a socket
There are a lot of libraries available to choose from when you're trying to create a web socket. We are currently going to be using the reconnecting-websocket for our implementation. This library is great, but it doesn’t have the interception logic that we need.
Our Requirements:
It should read through all the messages and events that come through a particular socket.
It should have the ability to modify the response as well.
It should accommodate and support interceptors to allow any number of sockets to have their own interceptors
It should have control to allow or block a response from the server to be handled by the other socket listeners.
It should be event-based and not for loops for running all the events listening to the message event from the socket.
Basically, something like this,
The last requirement is pretty tricky to implement. Usually, this is what we do when we want to listen to a message from a socket
const socket = useSocket();
socket.addEventListener(“message”, onMessage)
It shouldn’t be the case that we loop through all the listeners like so,
const callbacks : Record<string, ()=>{}> = {}
const addEventListener = (type,cb) => {
callbacks[type].push(cb)
}
The bad way of doing it,
You can see the problem here while dispatching events you’ll have to loop through the callbacks thereby, have changes to loosing context of the closure for the original call.
const dispatchEvent = (type, event) => {
callbacks[type].forEach(cb => cb(event)) // problem
}
There could be so many misses in terms of closure and overall order of events being run.
Instead, here’s how we do it.
We use the EventTarget class
This is one of the extended classes of dom elements. Essentially, this class will help you add listeners to dispatch events just like you’d add listeners to with any other DOM element.
So now, we extend it.
class SocketInterceptor extends EventTarget
EventTarget can attach an event listener and dispatch events just like any other DOM element.
Our final Generic Socket Interceptor looks like this,
Standard Imports
import ReconnectingWebSocket, { CloseEvent as ReCloseEvent, ErrorEvent as ReErrorEvent } from "reconnecting-websocket"
import { Event as ReEvent } from "reconnecting-websocket/dist/events"
Basic Message Interceptor
type SocketInterceptorListener = (event: Event) => boolean
class SocketInterceptor extends EventTarget {
private socket: ReconnectingWebSocket | undefined = undefined
private interceptors: Record<keyof WebSocketEventMap, Set<SocketInterceptorListener>> = {
message: new Set(),
} as Record<keyof WebSocketEventMap, Set<SocketInterceptorListener>>
constructor(socket: ReconnectingWebSocket) {
super()
this.socket = socket
this.createInterceptors()
}
private createInterceptors = () => {
this.socket?.addEventListener("message", this.socketEmitedMessage)
}
private socketEmitedMessage = (event: MessageEvent) => {
if (!this.continueWithInterceptors("message", event)) return
this.dispatchEvent(new MessageEvent("message", event as Event))
}
on(
type: keyof WebSocketEventMap,
callback: EventListenerOrEventListenerObject | null,
options?: AddEventListenerOptions | boolean,
): void {
this.addEventListener(type, callback, options)
}
addInterceptor = (type: keyof WebSocketEventMap, callback: SocketInterceptorListener): void => {
this.interceptors[type].add(callback)
}
removeInterceptor = (type: keyof WebSocketEventMap, callback: SocketInterceptorListener): void => {
this.interceptors[type].delete(callback)
}
private continueWithInterceptors = (type: keyof WebSocketEventMap, event: Event): boolean => {
if (!this.interceptors[type].size) return true
for (let callback of this.interceptors[type]) {
if (!callback(event)) {
return false
}
}
return true
}
}
export default SocketInterceptor
Finally, here is the code dump with implementations for open, error and close as well.
import ReconnectingWebSocket, { CloseEvent as ReCloseEvent, ErrorEvent as ReErrorEvent } from "reconnecting-websocket"
import { Event as ReEvent } from "reconnecting-websocket/dist/events"
type SocketInterceptorListener = (event: Event) => boolean
class SocketInterceptor extends EventTarget {
private socket: ReconnectingWebSocket | undefined = undefined
private interceptors: Record<keyof WebSocketEventMap, Set<SocketInterceptorListener>> = {
error: new Set(),
open: new Set(),
message: new Set(),
close: new Set(),
} as Record<keyof WebSocketEventMap, Set<SocketInterceptorListener>>
constructor(socket: ReconnectingWebSocket) {
super()
this.socket = socket
this.createInterceptors()
}
private createInterceptors = () => {
this.socket?.addEventListener("error", this.socketEmitedError)
this.socket?.addEventListener("open", this.socketEmitedOpen)
this.socket?.addEventListener("message", this.socketEmitedMessage)
this.socket?.addEventListener("close", this.socketEmitedClose)
}
private socketEmitedError = (event: ReErrorEvent) => {
if (!this.continueWithInterceptors("error", event as unknown as Event)) return
this.dispatchEvent(new ErrorEvent("error", event))
}
private socketEmitedOpen = (event: ReEvent) => {
if (!this.continueWithInterceptors("open", event as unknown as Event)) return
this.dispatchEvent(new Event("open", event as Event))
}
private socketEmitedMessage = (event: MessageEvent) => {
if (!this.continueWithInterceptors("message", event)) return
this.dispatchEvent(new MessageEvent("message", event as Event))
}
private socketEmitedClose = (event: ReCloseEvent) => {
if (!this.continueWithInterceptors("close", event as unknown as Event)) return
this.dispatchEvent(new CloseEvent("close", event))
}
on(
type: keyof WebSocketEventMap,
callback: EventListenerOrEventListenerObject | null,
options?: AddEventListenerOptions | boolean,
): void {
this.addEventListener(type, callback, options)
}
addInterceptor = (type: keyof WebSocketEventMap, callback: SocketInterceptorListener): void => {
this.interceptors[type].add(callback)
}
removeInterceptor = (type: keyof WebSocketEventMap, callback: SocketInterceptorListener): void => {
this.interceptors[type].delete(callback)
}
private continueWithInterceptors = (type: keyof WebSocketEventMap, event: Event): boolean => {
if (!this.interceptors[type].size) return true
for (let callback of this.interceptors[type]) {
if (!callback(event)) {
return false
}
}
return true
}
}
export default SocketInterceptor
Observe there is no method for,
addEventListener
Since it extends EventTarget, we get those events by default and hence can call this directly,
dispatchEvent
To use this, if you have a socket class set up already in the connect call you just need to add this interceptor,
Usage of the socket interceptor
class Socket {
private socketInterceptor: SocketInterceptor | undefined
connect = () => {
this.socketInterceptor = new SocketInterceptor(this.socket)
this.socketInterceptor.addInterceptor("message", this.interceptorForPingPong)
}
addEventListener = (type, cb) => {
this.socketInterceptor.addEventListener(type, cb)
}
}
Notes:
We are using the reconnecting-websocket package here just as an example usage for socket
Given these, I’m assuming that you are capable of building the rest of the logic of removeEventListener and so on
interceptorForPingPong
is an example of the usage of this interceptor. To increase the reliability of the socket, we implemented this feature. Do let us know in the comments if you are interested to know how that is built for the web.