Skip to content
Open
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
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,42 @@ interceptor.on('connection', (connection) => {

> You can also use this package with [Mock Service Worker](https://github.com/mswjs/msw) directly.


### Namespaces

Every message delivered through the wrapper carries the namespace that it was sent on. Access it via `event.socketio.namespace` to make assertions or branch per namespace during tests:

```js
io.client.on('power_on', (event, deviceId) => {
if (event.socketio.namespace === '/bedroom/ceiling_fan') {
console.log('fan turned on:', deviceId)
}
})
```

When mocking outgoing traffic with `emit()`, you can pass an object in the first argument to specify the namespace in addition to the event name:

```js
io.client.emit({ event: 'power_on', namespace: '/bedroom/ceiling_fan' })
Copy link
Member

Choose a reason for hiding this comment

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

Is this how namespace-bound events are also dispatched in SocketIO normally? We should try to mimic that API to benefit from developer familiarity.

Copy link
Author

@rgov rgov Oct 1, 2025

Choose a reason for hiding this comment

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

In the Socket.IO Client API, you simply connect to scheme://host[:port][/namespace] and then emit to the returned socket object like any other. I don't think we can mimic that.

In Server API, there is server.of(). You would write io.client.of('/bedroom/ceiling_fan').emit('power_on').

If we did add an SocketIoConnection.of() method that returned some Namespace instance with on() and emit() methods, it could lead to messy code because this object is now divorced from the client or server label: nsp.emit('foo') no longer tells you which direction the event is being emitted.

Personally I would think it's not important to mimic the Socket.IO API. The binding is already inconsistent with Socket.IO in how .on() and .emit() behave—e.g., the .on() callback receives different arguments, and .emit() works in the reverse direction. Using the same names with different semantics actually increases developer friction. Socket.IO's Server and Client APIs also aren't always identical so for each feature there would be an arbitrary decision of which API to mimic.

.of() is just a terrible method name anyway. It doesn't seem to relate to the concept of namespaces at all, and the sequence .of().emit() is meaningless to the reader as a phrase.

```

By default, connections on all namespaces are accepted when a mocked server. This can be customized by setting a custom authorizer:

```js
interceptor.on('connection', (connection) => {
const io = toSocketIo(connection)
io.setAuthorizer((namespace) => {
Copy link
Member

Choose a reason for hiding this comment

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

Is .setAuthorizer a thing in SocketIO normally? If not, I'd probably vote against such a convenience wrapper and recommend people to implement their own. Will also take one line of code but give us fewer APIs to maintain.

Copy link
Author

@rgov rgov Oct 1, 2025

Choose a reason for hiding this comment

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

On a Socket.IO server you have use() which defines middleware that can act as an authorizer. This is a more focused feature.

The mock needs to respond to the client's CONNECT request. It does so before my changes as well, but without supporting namespaces. If the mock is going to respond to CONNECT, the developer should be able to override whether the connection succeeds or fails. That's all the idea is.

If you removed the overridable authorizer behavior, you could accept connections on any namespace, but then you cannot fully mock the server in some situations—such as confirming the isolation of per-user namespaces, or requiring a JWT token before the backend starts streaming sensitive data, etc.

Copy link
Author

@rgov rgov Oct 1, 2025

Choose a reason for hiding this comment

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

There's some complexity mimicking Socket.IO's API. How they intend it is:

// deny all connections on all namespaces
io.on("new_namespace", (namespace) => {
  namespace.use((socket, next) => {
    next(new Error("Not authorized"))
  })
})

The middleware callback does not get passed the namespace name at invocation time, and it's not an attribute of the socket instance. Recreating this machinery seems like it's not worth it.

Copy link
Author

Choose a reason for hiding this comment

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

Another option I considered is io.client.onConnect((namespace: string) => bool) — this mirrors the semantics of io.client.on() being a way to capture what the client is doing. But unlike .on() you can only have a single connection handler that allows or rejects the connection. And the Socket.IO Client API style would be io.client.on("connect", () => void) (with no way to reject the connection at this point).

return (namespace !== '/forbidden')
})
})
```

Note that multiple Socket.IO namespaces will use a single underlying WebSocket, so this connection handler will only run once even if multiple Socket.IO sockets are created.


## Limitations

This wrapper is not meant to provide full feature parity with the Socket.IO client API. Some features may be missing (like rooms, namespaces, broadcasting). If you rely of any of the missing features, open a pull request and implement it. Thank you.
This wrapper is not meant to provide full feature parity with the Socket.IO client API. Some features may be missing (like rooms or broadcasting). If you rely of any of the missing features, open a pull request and implement it. Thank you.

> Note that feature parity only concerns the _connection wrapper_. You can still use the entire of the Socket.IO feature set in the actual application code.

Expand Down
211 changes: 174 additions & 37 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Encoder,
Decoder,
PacketType as SocketIoPacketType,
type Packet as SocketIoPacket,
} from 'socket.io-parser'
import type { WebSocketHandlerConnection } from 'msw'
import type {
Expand All @@ -18,7 +19,48 @@ import type {
const encoder = new Encoder()
const decoder = new Decoder()

type BoundMessageListener = (event: MessageEvent, ...data: Array<any>) => void
interface SocketIoMessageDetails {
namespace: string
}

interface SocketIoMessageEvent<T = any> extends MessageEvent<T> {
socketio: SocketIoMessageDetails
}

type BoundMessageListener = (
event: SocketIoMessageEvent,
...data: Array<any>
) => void
type ConnectionAuthorizer = (
namespace: string,
auth: Record<string, unknown>,
) => boolean | Promise<boolean>
type EventEnvelope = {
event: string
namespace?: string
}

function createSocketIoMessageEvent(
event: MessageEvent,
details: SocketIoMessageDetails,
): SocketIoMessageEvent {
return new Proxy(event, {
Copy link
Member

Choose a reason for hiding this comment

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

What do you think if we instead created a subclass of MessageEvent, providing the same socket-specific details on it without relying on a proxy?

class SocketIoMessageEvent extends Message Event {
  public namespace: string 

  constructor(name, init: { namespace }) {
    this.namespace = namespace
  }
}
  • Can ditch event.socketio[xyz] nesting as we are in the SocketIO context already;
  • Don't rely on a Proxy.
  • Have a more common custom event implementation.

Copy link
Author

@rgov rgov Oct 1, 2025

Choose a reason for hiding this comment

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

This part of the implementation was suggested by a coding agent 😅 Since the MessageEvent is an immutable object that's defined in some browser spec I didn't know how sound it is to try to copy and mutate it. Wouldn't we have to enumerate all of the properties and copy them from the object we're wrapping? How does that affect bubbling behavior? Etc...

I definitely defer to someone more experienced in how best to do this.

Copy link
Author

Choose a reason for hiding this comment

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

The event instance should also carry socketio.event.

get(target, property, receiver) {
if (property === 'socketio') {
return details
}

return Reflect.get(target, property, receiver)
},
has(target, property) {
if (property === 'socketio') {
return true
}

return Reflect.has(target, property)
},
}) as SocketIoMessageEvent
}

class SocketIoConnection {
constructor(
Expand All @@ -27,7 +69,9 @@ class SocketIoConnection {
| WebSocketServerConnectionProtocol,
) {}

public on(event: string, listener: BoundMessageListener): void {
public _onSocketIoPacket(
Copy link
Member

@kettanaito kettanaito Oct 1, 2025

Choose a reason for hiding this comment

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

If this is meant as an internal method, I recommend using # for the method name instead. That would truly make it private:

Suggested change
public _onSocketIoPacket(
#onSocketIoPacket(

The method could then be used as this.#onSocketIoPacket() within this class.

Copy link
Author

@rgov rgov Oct 1, 2025

Choose a reason for hiding this comment

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

I struggled with this because SocketIoDuplexConnection.setAuthorizer() has to use SocketIoConnection._onSocketIoPacket().

We could move the connection acceptance logic to SocketIoConnection as in io.client.setAuthorizer() (client? 😒) and then make _onSocketIoPacket() private. Then sendMockEngineIoOpen etc. could probably also move over to SocketIoConnection which seems conceptually better.

callback: (messageEvent: MessageEvent, packet: SocketIoPacket) => void,
): void {
const addEventListener = this.connection.addEventListener.bind(
this.connection,
) as WebSocketClientConnectionProtocol['addEventListener']
Expand Down Expand Up @@ -62,33 +106,51 @@ class SocketIoConnection {

for (const packet of engineIoPackets) {
decoder.once('decoded', (decodedSocketIoPacket) => {
/**
* @note Ignore any non-event messages.
* To forward all Socket.IO messages one must listen
* to the raw outgoing client events:
* client.on('message', (event) => server.send(event.data))
*/
if (decodedSocketIoPacket.type !== SocketIoPacketType.EVENT) {
return
}

const [sentEvent, ...data] = decodedSocketIoPacket.data

if (sentEvent === event) {
listener.call(undefined, messageEvent, ...data)
}
callback(messageEvent, decodedSocketIoPacket)
})

decoder.add(packet.data)
}
})
}

public on(event: string, listener: BoundMessageListener): void {
this._onSocketIoPacket((messageEvent, decodedSocketIoPacket) => {
/**
* @note Ignore any non-event messages.
* To forward all Socket.IO messages one must listen
* to the raw outgoing client events:
* client.on('message', (event) => server.send(event.data))
*/
if (decodedSocketIoPacket.type !== SocketIoPacketType.EVENT) {
return
}

const [sentEvent, ...data] = decodedSocketIoPacket.data

if (sentEvent === event) {
// Create a proxy wrapper around the original MessageEvent object,
// adding a `socketio` property with our namespace details.
const extendedEvent = createSocketIoMessageEvent(messageEvent, {
namespace: decodedSocketIoPacket.nsp,
})

listener.call(undefined, extendedEvent, ...data)
}
})
}

public send(...data: Array<any>): void {
this.emit('message', ...data)
}

public emit(event: string, ...data: Array<any>): void {
public emit(event: string, ...data: Array<any>): void
public emit(envelope: EventEnvelope, ...data: Array<any>): void
public emit(envelope: string | EventEnvelope, ...data: Array<any>): void {
Copy link
Member

Choose a reason for hiding this comment

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

When designing this API, we really have to make sure we're mimicking how namespace-bound events are emitted in SocketIO normally. The closer we are, the better DX we would provide to developers mocking such connections.

const event = typeof envelope === 'string' ? envelope : envelope.event
const namespace =
typeof envelope === 'string' ? '/' : envelope.namespace ?? '/'

/**
* @todo Check if this correctly encodes Blob
* and ArrayBuffer data.
Expand All @@ -98,7 +160,7 @@ class SocketIoConnection {
/**
* @todo Support custom namespaces.
*/
nsp: '/',
nsp: namespace,
data: [event].concat(data),
})

Expand All @@ -124,35 +186,110 @@ class SocketIoDuplexConnection {
public client: SocketIoConnection
public server: SocketIoConnection

private hasAuthorizer = false

constructor(
readonly rawClient: WebSocketClientConnectionProtocol,
readonly rawServer: WebSocketServerConnectionProtocol,
) {
queueMicrotask(() => {
try {
// Accessing the "socket" property on the server
// throws if the actual server connection hasn't been established.
// If it doesn't throw, don't mock the namespace approval message.
// That becomes the responsibility of the server.
Reflect.get(this.rawServer, 'socket').readyState
return
} catch {
this.rawClient.send(
'0' +
JSON.stringify({
sid: 'test',
upgrades: [],
pingInterval: 25000,
pingTimeout: 5000,
}),
)
this.rawClient.send('40' + JSON.stringify({ sid: 'test' }))
// If the actual server connection hasn't been established yet, send
// a mock Engine.IO handshake.
if (!this.hasUpstreamServer()) {
// Set a default authorizer that always allows connections.
if (!this.hasAuthorizer) {
this.setAuthorizer(() => true)
}

this.sendMockEngineIoOpen()
}
})

this.client = new SocketIoConnection(this.rawClient)
this.server = new SocketIoConnection(this.rawServer)
}

private hasUpstreamServer(): boolean {
Copy link
Member

Choose a reason for hiding this comment

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

A nitpick: we're only using this method once. I'd probably vote for keeping the previous inline implementation unless we heavily rely on the upstream server checks.

Copy link
Author

@rgov rgov Oct 1, 2025

Choose a reason for hiding this comment

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

I factored it out while attempting to implement a feature where the mock authorizer function could allow the connection through, which would be forwarded to the actual server, and then the actual server would respond to the CONNECT message. This would allow mocking rejection behaviors even in the presence of a server that would normally accept. It would require using this logic in two places.

It’s clearer to read as a simple, expressive predicate if (hasUpstreamServer()) than to explain in a multi-line comment the esoteric behavior that accessing .readyState before connecting will throw. The implementation also relies on the obscure Reflect.get(), and catch is being used for branching control flow, not error handling as many developers would expect.

So this particular check is especially well-suited to being pulled into a dedicated predicate function, which isolates all these elements of complexity and makes the logic of the call site more straightforward to understand.

try {
// Accessing the "socket" property on the server throws if the actual
// server connection hasn't been established.
Reflect.get(this.rawServer, 'socket').readyState
return true
} catch {
return false
}
}

public setAuthorizer(authorizer: ConnectionAuthorizer): void {
this.client._onSocketIoPacket((_event, packet) => {
if (packet.type !== SocketIoPacketType.CONNECT) {
return
}

Promise.resolve(authorizer(packet.nsp, packet.data)).then((allowed) => {
// Allow the authorizer to bounce connections before the actual server
// even sees it.
if (!allowed) {
this.sendMockSocketIoConnectError(packet.nsp, 'Not authorized')
return
}

this.sendMockSocketIoConnect(packet.nsp)
})
})

this.hasAuthorizer = true
}

private sendMockEngineIoOpen(): void {
const openPacket: EngineIoPacket = {
type: 'open',
data: JSON.stringify({
sid: 'test',
upgrades: [],
pingInterval: 25000,
pingTimeout: 5000,
}),
}

encodePayload([openPacket], (encodedPayload) => {
this.rawClient.send(encodedPayload)
})
}

private sendMockSocketIoConnect(namespace: string): void {
Copy link
Member

Choose a reason for hiding this comment

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

I don't believe these should be methods. Instead, we can make them into simple functions that produce respective messages and then pass them to .send().

this.rawClient.send(messages.connect())

Copy link
Author

@rgov rgov Oct 1, 2025

Choose a reason for hiding this comment

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

How do you envision the messages object? A challenge is that the encoded packet is produced asynchronously:

encodePayload([openPacket], (encodedPayload) => {
  this.rawClient.send(encodedPayload)
})

Are you thinking we would write:

MessageBuilder.connect(namespace, (encodedPayload) => {
  this.rawClient.send(encodedPayload)
})

this.sendSocketIoPacket({
type: SocketIoPacketType.CONNECT,
nsp: namespace,
data: { sid: 'test' },
})
}

private sendMockSocketIoConnectError(
namespace: string,
message: string,
): void {
this.sendSocketIoPacket({
type: SocketIoPacketType.CONNECT_ERROR,
nsp: namespace,
data: { message },
})
}

private sendSocketIoPacket(packet: SocketIoPacket): void {
const socketIoPackets = encoder.encode(packet)

const engineIoPackets = socketIoPackets.map<EngineIoPacket>((encoded) => {
return {
type: 'message',
data: encoded,
}
})

encodePayload(engineIoPackets, (encodedPayload) => {
this.rawClient.send(encodedPayload)
})
}
}

/**
Expand Down
2 changes: 1 addition & 1 deletion tests/socket.io-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ import { Socket } from 'socket.io-client'
import { io } from 'socket.io-client/dist/socket.io.js'

export function createSocketClient(uri: string): Socket {
return io(uri, { transports: ['websocket'] })
return io(uri, { transports: ['websocket'], forceNew: true })
Copy link
Member

Choose a reason for hiding this comment

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

What does forceNew do? 👀

Copy link
Author

Choose a reason for hiding this comment

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

Because our tests now create sockets on multiple namespaces, Engine.IO will multiplex them across a single WebSocket. This means we're leaking state between test cases, and only one test's interceptor connection handler will fire. Resolve this by disabling shared WebSockets.

I could add a comment to this effect if desired.

}
Loading