From de00460caead9bfd317a345a825aea7fd99c5201 Mon Sep 17 00:00:00 2001 From: Ryan Govostes Date: Tue, 30 Sep 2025 23:49:45 -0700 Subject: [PATCH 1/7] Refactor SocketIoConnection with a reusable packet decoder --- src/index.ts | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/src/index.ts b/src/index.ts index c7b1dbb..6fbd872 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { @@ -27,7 +28,9 @@ class SocketIoConnection { | WebSocketServerConnectionProtocol, ) {} - public on(event: string, listener: BoundMessageListener): void { + private _onSocketIoPacket( + callback: (messageEvent: MessageEvent, packet: SocketIoPacket) => void, + ): void { const addEventListener = this.connection.addEventListener.bind( this.connection, ) as WebSocketClientConnectionProtocol['addEventListener'] @@ -62,21 +65,7 @@ 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) @@ -84,6 +73,26 @@ class SocketIoConnection { }) } + 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) { + listener.call(undefined, messageEvent, ...data) + } + }) + } + public send(...data: Array): void { this.emit('message', ...data) } From 6a7c762aae671fc1af5739b74ee9b87415817227 Mon Sep 17 00:00:00 2001 From: Ryan Govostes Date: Wed, 1 Oct 2025 01:40:26 -0700 Subject: [PATCH 2/7] Force each test to establish a new WebSocket connection --- tests/socket.io-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/socket.io-client.ts b/tests/socket.io-client.ts index 71ee21b..cb30f62 100644 --- a/tests/socket.io-client.ts +++ b/tests/socket.io-client.ts @@ -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 }) } From d4c08836219dc53bc935a96e6ffaefa4c520c153 Mon Sep 17 00:00:00 2001 From: Ryan Govostes Date: Wed, 1 Oct 2025 01:41:26 -0700 Subject: [PATCH 3/7] Add support for custom connection authorizer functions --- src/index.ts | 117 +++++++++++++++++++++++++++++++------ tests/to-socket-io.test.ts | 20 +++++++ 2 files changed, 118 insertions(+), 19 deletions(-) diff --git a/src/index.ts b/src/index.ts index 6fbd872..745520e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,10 @@ const encoder = new Encoder() const decoder = new Decoder() type BoundMessageListener = (event: MessageEvent, ...data: Array) => void +type ConnectionAuthorizer = ( + namespace: string, + auth: Record, +) => boolean | Promise class SocketIoConnection { constructor( @@ -28,7 +32,7 @@ class SocketIoConnection { | WebSocketServerConnectionProtocol, ) {} - private _onSocketIoPacket( + public _onSocketIoPacket( callback: (messageEvent: MessageEvent, packet: SocketIoPacket) => void, ): void { const addEventListener = this.connection.addEventListener.bind( @@ -133,35 +137,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 { + 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 { + 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((encoded) => { + return { + type: 'message', + data: encoded, + } + }) + + encodePayload(engineIoPackets, (encodedPayload) => { + this.rawClient.send(encodedPayload) + }) + } } /** diff --git a/tests/to-socket-io.test.ts b/tests/to-socket-io.test.ts index f45c0eb..6ad386b 100644 --- a/tests/to-socket-io.test.ts +++ b/tests/to-socket-io.test.ts @@ -180,3 +180,23 @@ it('modifies incoming server event', async () => { text: 'Hello, Sarah!', }) }) + +it('supports custom connection authorizers', async () => { + const { createSocketClient } = await import('./socket.io-client.js') + + const forbiddenError = new DeferredPromise() + + interceptor.on('connection', (connection) => { + const io = toSocketIo(connection) + io.setAuthorizer(() => false) + }) + + const ws = createSocketClient('wss://example.com') + ws.on('connect_error', (error) => { + forbiddenError.resolve(error) + }) + + const error = await forbiddenError + expect(error).toBeInstanceOf(Error) + expect(error.message).toBe('Not authorized') +}) From e5f469d1746133a459cf4121fea9a79c138063d5 Mon Sep 17 00:00:00 2001 From: Ryan Govostes Date: Wed, 1 Oct 2025 01:49:22 -0700 Subject: [PATCH 4/7] Add test showing namespace connections work --- tests/to-socket-io.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/to-socket-io.test.ts b/tests/to-socket-io.test.ts index 6ad386b..ff893dc 100644 --- a/tests/to-socket-io.test.ts +++ b/tests/to-socket-io.test.ts @@ -200,3 +200,33 @@ it('supports custom connection authorizers', async () => { expect(error).toBeInstanceOf(Error) expect(error.message).toBe('Not authorized') }) + +it('intercepts outgoing client namespace connections', async () => { + const { createSocketClient } = await import('./socket.io-client.js') + + let namespace: string | null = null + const eventLog: Array = [] + const connectedPromise = new DeferredPromise() + + + interceptor.on('connection', (connection) => { + connection.client.addEventListener('message', (event) => { + eventLog.push(event.data) + }) + + const io = toSocketIo(connection) + io.setAuthorizer((ns) => { + namespace = ns + return true + }) + }) + + const ws = createSocketClient('wss://example.com/foo') + ws.on('connect', () => { + connectedPromise.resolve() + }) + + await connectedPromise + expect(namespace).toBe('/foo') + expect(eventLog).toEqual(['40/foo,']) +}) From 4ef54bfbc367bdf3b03067d7b985343a720e78b6 Mon Sep 17 00:00:00 2001 From: Ryan Govostes Date: Wed, 1 Oct 2025 02:11:08 -0700 Subject: [PATCH 5/7] Annotate event object with Socket.IO namespace --- src/index.ts | 43 ++++++++++++++++++++++++++++++++++++-- tests/to-socket-io.test.ts | 31 +++++++++++++++++---------- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/src/index.ts b/src/index.ts index 745520e..e749755 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,12 +19,45 @@ import type { const encoder = new Encoder() const decoder = new Decoder() -type BoundMessageListener = (event: MessageEvent, ...data: Array) => void +interface SocketIoMessageDetails { + namespace: string +} + +interface SocketIoMessageEvent extends MessageEvent { + socketio: SocketIoMessageDetails +} + +type BoundMessageListener = ( + event: SocketIoMessageEvent, + ...data: Array +) => void type ConnectionAuthorizer = ( namespace: string, auth: Record, ) => boolean | Promise +function createSocketIoMessageEvent( + event: MessageEvent, + details: SocketIoMessageDetails, +): SocketIoMessageEvent { + return new Proxy(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( private readonly connection: @@ -92,7 +125,13 @@ class SocketIoConnection { const [sentEvent, ...data] = decodedSocketIoPacket.data if (sentEvent === event) { - listener.call(undefined, messageEvent, ...data) + // 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) } }) } diff --git a/tests/to-socket-io.test.ts b/tests/to-socket-io.test.ts index ff893dc..0b1975d 100644 --- a/tests/to-socket-io.test.ts +++ b/tests/to-socket-io.test.ts @@ -201,13 +201,13 @@ it('supports custom connection authorizers', async () => { expect(error.message).toBe('Not authorized') }) -it('intercepts outgoing client namespace connections', async () => { +it('intercepts custom outgoing client event on namespace', async () => { const { createSocketClient } = await import('./socket.io-client.js') - let namespace: string | null = null + let connectionNamespace: string | null = null + let eventNamespace: string | null = null const eventLog: Array = [] - const connectedPromise = new DeferredPromise() - + const outgoingDataPromise = new DeferredPromise() interceptor.on('connection', (connection) => { connection.client.addEventListener('message', (event) => { @@ -216,17 +216,26 @@ it('intercepts outgoing client namespace connections', async () => { const io = toSocketIo(connection) io.setAuthorizer((ns) => { - namespace = ns + connectionNamespace = ns return true }) + + io.client.on('hello', (event, name) => { + eventNamespace = event.socketio.namespace + outgoingDataPromise.resolve(name) + }) }) const ws = createSocketClient('wss://example.com/foo') - ws.on('connect', () => { - connectedPromise.resolve() - }) + ws.emit('hello', 'John') + + // Must expose the decoded event payload. + expect(await outgoingDataPromise).toBe('John') + + // Connection and event must have the expected namespace. + expect(connectionNamespace).toBe('/foo') + expect(eventNamespace).toBe('/foo') - await connectedPromise - expect(namespace).toBe('/foo') - expect(eventLog).toEqual(['40/foo,']) + // Must emit proper outgoing client messages. + expect(eventLog).toEqual(['40/foo,', '42/foo,["hello","John"]']) }) From 788c6b35f6b2196f5b35c8f84368ca9e45394bf4 Mon Sep 17 00:00:00 2001 From: Ryan Govostes Date: Wed, 1 Oct 2025 02:21:17 -0700 Subject: [PATCH 6/7] Add support for injecting mocked events on namespaces --- src/index.ts | 14 ++++++++++++-- tests/to-socket-io.test.ts | 21 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index e749755..6f80ad4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,10 @@ type ConnectionAuthorizer = ( namespace: string, auth: Record, ) => boolean | Promise +type EventEnvelope = { + event: string + namespace?: string +} function createSocketIoMessageEvent( event: MessageEvent, @@ -140,7 +144,13 @@ class SocketIoConnection { this.emit('message', ...data) } - public emit(event: string, ...data: Array): void { + public emit(event: string, ...data: Array): void + public emit(envelope: EventEnvelope, ...data: Array): void + public emit(envelope: string | EventEnvelope, ...data: Array): void { + 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. @@ -150,7 +160,7 @@ class SocketIoConnection { /** * @todo Support custom namespaces. */ - nsp: '/', + nsp: namespace, data: [event].concat(data), }) diff --git a/tests/to-socket-io.test.ts b/tests/to-socket-io.test.ts index 0b1975d..69250d9 100644 --- a/tests/to-socket-io.test.ts +++ b/tests/to-socket-io.test.ts @@ -239,3 +239,24 @@ it('intercepts custom outgoing client event on namespace', async () => { // Must emit proper outgoing client messages. expect(eventLog).toEqual(['40/foo,', '42/foo,["hello","John"]']) }) + +it('sends a mocked custom incoming server event on a namespace', async () => { + const { createSocketClient } = await import('./socket.io-client.js') + + const incomingDataPromise = new DeferredPromise() + + interceptor.on('connection', (connection) => { + const { client } = toSocketIo(connection) + + client.on('hello', (event, name) => { + client.emit({ event: 'greetings', namespace: '/foo' }, `Hello, ${name}!`) + }) + }) + + const ws = createSocketClient('wss://example.com/foo') + ws.emit('hello', 'John') + ws.on('greetings', (message) => incomingDataPromise.resolve(message)) + + // Must emit proper outgoing server messages. + expect(await incomingDataPromise).toBe('Hello, John!') +}) From 5900917f89b40b39dfb9314d6884263057e253fa Mon Sep 17 00:00:00 2001 From: Ryan Govostes Date: Wed, 1 Oct 2025 02:26:54 -0700 Subject: [PATCH 7/7] Update README with details on namespace support --- README.md | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9bf0645..0e63183 100644 --- a/README.md +++ b/README.md @@ -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' }) +``` + +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) => { + 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.