From 69c72ba9b0603fa5ce3b69c3bb766808929e9f65 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 21 Sep 2026 15:48:11 +0200 Subject: [PATCH 1/5] feat!: implement as an extension --- README.md | 67 +++++++-- package.json | 2 +- src/index.ts | 275 +++++++++++++++++++++++++++--------- tests/socket-io.test.ts | 200 +++++++++++++++++++++----- tests/typings/msw.test-d.ts | 27 +++- 5 files changed, 454 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index 9445ab8..b3d1892 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,20 @@ ## `@mswjs/socket.io-binding` -The Socket.IO protocol as a WebSocket protocol for [`@mswjs/interceptors`](https://github.com/mswjs/interceptors) and [Mock Service Worker](https://github.com/mswjs/msw). Apply it to intercepted WebSocket connections to work with Socket.IO events instead of the raw Engine.IO/Socket.IO frames. +The Socket.IO protocol as a WebSocket extension for [`@mswjs/interceptors`](https://github.com/mswjs/interceptors) and [Mock Service Worker](https://github.com/mswjs/msw). Apply it to intercepted WebSocket connections to work with Socket.IO events instead of the raw Engine.IO/Socket.IO frames. ## Motivation -Socket.IO implements its own protocol on top of WebSocket: a session handshake, a heartbeat, and a packet framing. Without the protocol, an intercepted connection exposes the raw frames (e.g. `40`, `42["hello","John"]`), expects you to send them back the same way, and never completes the handshake a mocked Socket.IO client waits for. With the protocol, the connection speaks Socket.IO events, and the session is established for you. +Socket.IO implements its own protocol on top of WebSocket: a session handshake, a heartbeat, namespaces, and a packet framing. Without the extension, an intercepted connection exposes the raw frames (e.g. `40`, `42["hello","John"]`), expects you to send them back the same way, and never completes the handshake a Socket.IO client waits for. With the extension, the connection speaks Socket.IO events, and the session is established for you. -An event is represented as the JSON text of its `[event, ...args]` tuple. +An event is represented as an object: + +```ts +interface SocketIoMessage { + namespace?: string // The default namespace ("/") when omitted. + event: string + args: Array +} +``` ## Install @@ -22,41 +30,80 @@ npm install @mswjs/socket.io-binding import { ws } from 'msw' import { SocketIo } from '@mswjs/socket.io-binding' -const chat = ws.link('wss://example.com/chat', { protocol: new SocketIo() }) +const chat = ws.link('wss://example.com/chat', { extensions: [new SocketIo()] }) export const handlers = [ chat.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { - const [name, firstName] = JSON.parse(event.data) + const { event: name, args } = event.data if (name === 'hello') { - client.send(JSON.stringify(['greeting', `Hello, ${firstName}!`])) + const [firstName] = args + client.send({ event: 'greeting', args: [`Hello, ${firstName}!`] }) } }) }), ] ``` +`client.send()`, `server.send()`, `chat.broadcast()`, and the `message` events all operate on Socket.IO events. The extension encodes and decodes the frames behind the scenes. + ### With Interceptors -The protocol recognizes Socket.IO connections by their URL, so it applies to them automatically. +The extension recognizes Socket.IO connections by their URL, so it applies to them automatically. ```js import { WebSocketInterceptor } from '@mswjs/interceptors/WebSocket' import { SocketIo } from '@mswjs/socket.io-binding' const interceptor = new WebSocketInterceptor({ - protocols: [new SocketIo()], + extensions: [new SocketIo()], }) interceptor.on('connection', ({ server }) => { server.connect() server.addEventListener('message', (event) => { - console.log(event.data) // '["greeting","Hello, John!"]' + console.log(event.data) // { namespace: '/', event: 'greeting', args: ['Hello, John!'] } + }) +}) +``` + +### Namespaces + +The extension approves every namespace a client connects to. The namespace of an event is a part of the event, both when receiving and when sending. + +```js +chat.addEventListener('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data.namespace === '/admin') { + client.send({ + namespace: '/admin', + event: 'greeting', + args: ['Hello, admin!'], + }) + } + }) +}) +``` + +### Rooms + +The extension exposes the rooms of a connection on the connection event. A connection leaves all of its rooms once closed. + +```js +chat.addEventListener('connection', ({ client, rooms }) => { + client.addEventListener('message', (event) => { + const { event: name, args } = event.data + + if (name === 'join') { + const [room] = args + rooms.join(room) + rooms.to(room).send({ event: 'joined', args: [room] }) + } }) }) ``` ## Limitations -The protocol supports the default namespace and text events only. Custom namespaces, acknowledgements, and binary attachments are not supported. If you rely on any of these, open a pull request and implement them. Thank you. +Acknowledgements are not supported. Rooms are kept per extension instance and do not span multiple runtimes (e.g. multiple browser tabs). If you rely on any of these, open a pull request and implement them. Thank you. diff --git a/package.json b/package.json index a85046d..705e9fb 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "type": "module", "name": "@mswjs/socket.io-binding", "version": "0.2.0", - "description": "WebSocket codec to mock Socket.IO connections with Mock Service Worker", + "description": "WebSocket extension to mock Socket.IO connections with Interceptors and Mock Service Worker", "exports": { ".": { "types": "./build/index.d.ts", diff --git a/src/index.ts b/src/index.ts index c6b70c1..07c58dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,12 +10,24 @@ import { type Packet as SocketIoPacket, } from 'socket.io-parser' import { - WebSocketProtocol, + WebSocketExtension, type WebSocketData, - type WebSocketProtocolContext, - type WebSocketProtocolMessageContext, + type WebSocketClientHandle, + type WebSocketExtensionContext, + type WebSocketExtensionMessageContext, } from '@mswjs/interceptors/WebSocket' +/** + * A Socket.IO event as the handler sees it: its namespace + * (the default one when omitted), its name, and its arguments. + */ +export interface SocketIoMessage { + namespace?: string + event: string + args: Array +} + +const DEFAULT_NAMESPACE = '/' const SESSION_ID = 'test' /** @@ -29,123 +41,248 @@ const PING_TIMEOUT = 100_000_000 const encoder = new Encoder() -function encodeEngineIoPacket(packet: EngineIoPacket): string { - let encodedPacket = '' +function encodeEngineIoPacket(packet: EngineIoPacket): WebSocketData { + let encodedPacket: WebSocketData = '' - // The callback is invoked synchronously for text packets. - encodePacket(packet, false, (result) => { - if (typeof result === 'string') { - encodedPacket = result - } + // The callback is invoked synchronously. + encodePacket(packet, true, (result) => { + encodedPacket = result }) return encodedPacket } -function encodeSocketIoPacket(packet: SocketIoPacket): string { - const [encodedPacket] = encoder.encode(packet) +/** + * Encode the given Socket.IO packet into its Engine.IO frames: + * the text packet, followed by its binary attachments, if any. + */ +function* encodeSocketIoPacket( + packet: SocketIoPacket, +): Generator { + const [encodedPacket, ...attachments] = encoder.encode(packet) + + if (typeof encodedPacket === 'string') { + yield encodeEngineIoPacket({ type: 'message', data: encodedPacket }) + } + + for (const attachment of attachments) { + yield encodeEngineIoPacket({ type: 'message', data: attachment }) + } +} + +/** + * Decode the given frame into the Socket.IO packets it completes. + * Binary attachments complete the packet that announced them, + * which is why the decoder is stateful and must be kept per stream. + */ +function decodeSocketIoPackets( + frame: WebSocketData, + decoder: Decoder, +): Array { + const engineIoPacket = decodePacket(frame, 'arraybuffer') + + // Engine.IO control packets (open, ping, pong, etc) carry no Socket.IO packet. + if (engineIoPacket.type !== 'message') { + return [] + } + + const packets: Array = [] + const collectPacket = (packet: SocketIoPacket) => { + packets.push(packet) + } + + decoder.on('decoded', collectPacket) + decoder.add(engineIoPacket.data) + decoder.off('decoded', collectPacket) + + return packets +} + +function toSocketIoMessage(packet: SocketIoPacket): SocketIoMessage { + const [event, ...args] = packet.data + + return { + namespace: packet.nsp, + event, + args, + } +} + +type SocketIoRoomMembers = Map< + string, + Set> +> + +/** + * The rooms of a Socket.IO connection: join and leave them, + * and send events to every connection in a room. + */ +export class SocketIoRooms { + constructor( + private readonly client: WebSocketClientHandle, + private readonly members: SocketIoRoomMembers, + ) {} + + /** + * Add this connection to the given room. + * A closed connection leaves all of its rooms. + */ + public join(room: string): void { + let members = this.members.get(room) + + if (!members) { + members = new Set() + this.members.set(room, members) + } + + if (!members.has(this.client)) { + this.client.addEventListener('close', () => this.leave(room), { + once: true, + }) + } + + members.add(this.client) + } - if (typeof encodedPacket !== 'string') { - throw new Error('Binary Socket.IO packets are not supported') + /** + * Remove this connection from the given room. + */ + public leave(room: string): void { + this.members.get(room)?.delete(this.client) } - return encodeEngineIoPacket({ type: 'message', data: encodedPacket }) + /** + * Send events to every connection in the given room. + */ + public to(room: string): { send(message: SocketIoMessage): void } { + const members = this.members.get(room) ?? new Set() + + return { + send(message) { + for (const member of members) { + member.send(message) + } + }, + } + } } /** - * The Socket.IO protocol over WebSocket. + * The Socket.IO protocol as a WebSocket extension. * - * Messages are Socket.IO events as JSON text: `'["event", ...args]'`. - * The Engine.IO session and the protocol control packets are handled - * by the protocol and never surface. Binary attachments are not supported. + * Handlers see Socket.IO events (`{ namespace, event, args }`) instead of + * Engine.IO/Socket.IO frames. The session (Engine.IO handshake, namespace + * connects, heartbeat) is spoken by the extension on behalf of the mocked + * server and never surfaces. Rooms are exposed on the connection event. * * @example * // With Interceptors: applied to every Socket.IO connection. - * new WebSocketInterceptor({ protocols: [new SocketIo()] }) + * new WebSocketInterceptor({ extensions: [new SocketIo()] }) * * @example * // With Mock Service Worker: applied to the connections of this link. - * const chat = ws.link('wss://example.com/chat', { protocol: new SocketIo() }) + * const chat = ws.link('wss://example.com/chat', { extensions: [new SocketIo()] }) * - * chat.addEventListener('connection', ({ client }) => { + * chat.addEventListener('connection', ({ client, rooms }) => { * client.addEventListener('message', (event) => { - * const [name, ...args] = JSON.parse(event.data) + * if (event.data.event === 'join') { + * rooms.join(String(event.data.args[0])) + * } * }) - * client.send(JSON.stringify(['greeting', 'Hello, John!'])) + * rooms.to('lobby').send({ event: 'greeting', args: ['Hello, everyone!'] }) * }) */ -export class SocketIo extends WebSocketProtocol { +export class SocketIo extends WebSocketExtension< + SocketIoMessage, + { rooms: SocketIoRooms } +> { /** * The Socket.IO decoder is stateful (binary attachments span * multiple frames), so keep one per connection. */ private readonly decoders = new WeakMap() + private readonly members: SocketIoRoomMembers = new Map() - public match({ client }: WebSocketProtocolContext): boolean { + public match({ + client, + }: WebSocketExtensionContext): boolean { + // Socket.IO connections carry the Engine.IO protocol version. return client.url.searchParams.has('EIO') } - public encode(message: string): string { + public connect(): WebSocketData { + // Establish the Engine.IO session. + return encodeEngineIoPacket({ + type: 'open', + data: JSON.stringify({ + sid: SESSION_ID, + upgrades: [], + pingInterval: PING_INTERVAL, + pingTimeout: PING_TIMEOUT, + }), + }) + } + + public encode(message: SocketIoMessage): Generator { return encodeSocketIoPacket({ type: PacketType.EVENT, - /** - * @todo Support custom namespaces. - */ - nsp: '/', - data: JSON.parse(message), + nsp: message.namespace ?? DEFAULT_NAMESPACE, + data: [message.event, ...message.args], }) } - public decode( + public *decode( frame: WebSocketData, - { connection }: WebSocketProtocolMessageContext, - ): Iterator | undefined { - // Messages are always decoded as strings. + { connection }: WebSocketExtensionMessageContext, + ): Generator { + for (const packet of decodeSocketIoPackets( + frame, + this.#getDecoder(connection), + )) { + if ( + packet.type === PacketType.EVENT || + packet.type === PacketType.BINARY_EVENT + ) { + yield toSocketIoMessage(packet) + } + } + } + + public *receive(frame: WebSocketData): Generator { if (typeof frame !== 'string') { return } - const packet = decodePacket(frame, 'arraybuffer') + const engineIoPacket = decodePacket(frame, 'arraybuffer') - // Ignore the Engine.IO control packets (open, ping, pong, etc). - if (packet.type !== 'message') { + // Engine.IO v3 clients ping the server and expect a pong. + if (engineIoPacket.type === 'ping') { + yield encodeEngineIoPacket({ type: 'pong' }) return } - const decoder = this.#getDecoder(connection) - const events: Array = [] - const collectEvent = (socketIoPacket: SocketIoPacket) => { - // Ignore the Socket.IO control packets (connect, ack, etc). - if (socketIoPacket.type === PacketType.EVENT) { - events.push(JSON.stringify(socketIoPacket.data)) - } + if (engineIoPacket.type !== 'message') { + return } - decoder.on('decoded', collectEvent) - decoder.add(packet.data) - decoder.off('decoded', collectEvent) - - return events.values() + // Approve every namespace the client connects to. + for (const packet of decodeSocketIoPackets(frame, new Decoder())) { + if (packet.type === PacketType.CONNECT) { + yield* encodeSocketIoPacket({ + type: PacketType.CONNECT, + nsp: packet.nsp, + data: { sid: SESSION_ID }, + }) + } + } } - public *handshake(): Generator { - // Establish the Engine.IO session. - yield encodeEngineIoPacket({ - type: 'open', - data: JSON.stringify({ - sid: SESSION_ID, - upgrades: [], - pingInterval: PING_INTERVAL, - pingTimeout: PING_TIMEOUT, - }), - }) - - // Approve the connection to the default namespace. - yield encodeSocketIoPacket({ - type: PacketType.CONNECT, - nsp: '/', - data: { sid: SESSION_ID }, - }) + public extend({ client }: WebSocketExtensionContext): { + rooms: SocketIoRooms + } { + return { + rooms: new SocketIoRooms(client, this.members), + } } #getDecoder(connection: object): Decoder { diff --git a/tests/socket-io.test.ts b/tests/socket-io.test.ts index c31962b..207bf79 100644 --- a/tests/socket-io.test.ts +++ b/tests/socket-io.test.ts @@ -1,18 +1,15 @@ import http from 'node:http' -import { - WebSocketInterceptor, - type WebSocketData, -} from '@mswjs/interceptors/WebSocket' +import { WebSocketInterceptor } from '@mswjs/interceptors/WebSocket' import { Server } from 'socket.io' import { createTestHttpServer, kServer, type TestHttpServer, } from '@epic-web/test-server/http' -import { SocketIo } from '../src/index.js' +import { SocketIo, type SocketIoMessage } from '../src/index.js' const interceptor = new WebSocketInterceptor({ - protocols: [new SocketIo()], + extensions: [new SocketIo()], }) function createSocketIoServer(httpServer: TestHttpServer): Server { @@ -44,8 +41,8 @@ afterAll(() => { it('decodes outgoing client events', async () => { const { createSocketClient } = await import('./socket.io-client.js') - const eventLog: Array = [] - const outgoingData = Promise.withResolvers() + const eventLog: Array = [] + const outgoingData = Promise.withResolvers() interceptor.on('connection', ({ client }) => { client.addEventListener('message', (event) => { @@ -55,34 +52,39 @@ it('decodes outgoing client events', async () => { }) const ws = createSocketClient('wss://example.com') + onTestFinished(() => { + ws.close() + }) ws.emit('hello', 'John') - await expect(outgoingData.promise).resolves.toBe('["hello","John"]') + await expect(outgoingData.promise).resolves.toEqual({ + namespace: '/', + event: 'hello', + args: ['John'], + }) expect(eventLog, 'exposes no protocol packets').toEqual([ - '["hello","John"]', + { namespace: '/', event: 'hello', args: ['John'] }, ]) }) it('encodes mocked incoming server events', async () => { const { createSocketClient } = await import('./socket.io-client.js') - const incomingData = Promise.withResolvers() + const incomingData = Promise.withResolvers() interceptor.on('connection', ({ client }) => { client.addEventListener('message', (event) => { - if (typeof event.data !== 'string') { - return - } - - const [name, firstName]: [string, string] = JSON.parse(event.data) - - if (name === 'hello') { - client.send(JSON.stringify(['greetings', `Hello, ${firstName}!`])) + if (event.data.event === 'hello') { + const [firstName] = event.data.args + client.send({ event: 'greetings', args: [`Hello, ${firstName}!`] }) } }) }) const ws = createSocketClient('wss://example.com') + onTestFinished(() => { + ws.close() + }) ws.emit('hello', 'John') ws.on('greetings', (message) => incomingData.resolve(message)) @@ -93,10 +95,12 @@ it('decodes incoming server events', async () => { const { createSocketClient } = await import('./socket.io-client.js') await using httpServer = await createTestHttpServer() const wsServer = createSocketIoServer(httpServer) - onTestFinished(() => wsServer.close()) + onTestFinished(() => { + wsServer.close() + }) - const incomingServerData = Promise.withResolvers() - const incomingClientData = Promise.withResolvers() + const incomingServerData = Promise.withResolvers() + const incomingClientData = Promise.withResolvers() wsServer.on('connection', (client) => { client.on('hello', (name) => { @@ -113,6 +117,9 @@ it('decodes incoming server events', async () => { }) const ws = createSocketClient(getWsUrl(httpServer)) + onTestFinished(() => { + ws.close() + }) ws.emit('hello', 'John') ws.on('greeting', (message) => { incomingClientData.resolve(message) @@ -120,11 +127,15 @@ it('decodes incoming server events', async () => { await expect( incomingServerData.promise, - 'the interceptor gets the decoded event' - ).resolves.toBe('["greeting",{"id":1,"text":"Hello, John!"}]') + 'the interceptor gets the decoded event', + ).resolves.toEqual({ + namespace: '/', + event: 'greeting', + args: [{ id: 1, text: 'Hello, John!' }], + }) await expect( incomingClientData.promise, - 'the Socket.IO client gets the original event' + 'the Socket.IO client gets the original event', ).resolves.toEqual({ id: 1, text: 'Hello, John!', @@ -135,10 +146,12 @@ it('modifies incoming server events', async () => { const { createSocketClient } = await import('./socket.io-client.js') await using httpServer = await createTestHttpServer() const wsServer = createSocketIoServer(httpServer) - onTestFinished(() => wsServer.close()) + onTestFinished(() => { + wsServer.close() + }) - const incomingServerData = Promise.withResolvers() - const incomingClientData = Promise.withResolvers() + const incomingServerData = Promise.withResolvers() + const incomingClientData = Promise.withResolvers() wsServer.on('connection', (client) => { client.on('hello', (name) => { @@ -153,11 +166,17 @@ it('modifies incoming server events', async () => { incomingServerData.resolve(event.data) event.preventDefault() - client.send(JSON.stringify(['greeting', { id: 2, text: 'Hello, Sarah!' }])) + client.send({ + event: 'greeting', + args: [{ id: 2, text: 'Hello, Sarah!' }], + }) }) }) const ws = createSocketClient(getWsUrl(httpServer)) + onTestFinished(() => { + ws.close() + }) ws.emit('hello', 'John') ws.on('greeting', (message) => { incomingClientData.resolve(message) @@ -165,13 +184,130 @@ it('modifies incoming server events', async () => { await expect( incomingServerData.promise, - 'the interceptor gets the original event' - ).resolves.toBe('["greeting",{"id":1,"text":"Hello, John!"}]') + 'the interceptor gets the original event', + ).resolves.toEqual({ + namespace: '/', + event: 'greeting', + args: [{ id: 1, text: 'Hello, John!' }], + }) await expect( incomingClientData.promise, - 'the Socket.IO client gets the modified event' + 'the Socket.IO client gets the modified event', ).resolves.toEqual({ id: 2, text: 'Hello, Sarah!', }) }) + +it('exchanges events on a custom namespace', async () => { + const { createSocketClient } = await import('./socket.io-client.js') + + const outgoingData = Promise.withResolvers() + const incomingData = Promise.withResolvers() + + interceptor.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + outgoingData.resolve(event.data) + + if (event.data.event === 'hello') { + client.send({ + namespace: event.data.namespace, + event: 'greetings', + args: ['Hello from /admin!'], + }) + } + }) + }) + + const ws = createSocketClient('wss://example.com/admin') + onTestFinished(() => { + ws.close() + }) + ws.emit('hello', 'John') + ws.on('greetings', (message) => incomingData.resolve(message)) + + await expect(outgoingData.promise).resolves.toEqual({ + namespace: '/admin', + event: 'hello', + args: ['John'], + }) + await expect(incomingData.promise).resolves.toBe('Hello from /admin!') +}) + +it('sends events to every connection in a room', async () => { + const { createSocketClient } = await import('./socket.io-client.js') + + const firstIncomingData = Promise.withResolvers() + const secondIncomingData = Promise.withResolvers() + + interceptor.on('connection', ({ client, rooms }) => { + client.addEventListener('message', (event) => { + const [room] = event.data.args + + if (event.data.event === 'join' && typeof room === 'string') { + rooms.join(room) + } + + if (event.data.event === 'announce' && typeof room === 'string') { + rooms.to(room).send({ event: 'news', args: ['hello lobby'] }) + } + }) + }) + + const first = createSocketClient('wss://example.com') + onTestFinished(() => { + first.close() + }) + const second = createSocketClient('wss://example.com') + onTestFinished(() => { + second.close() + }) + first.on('news', (message) => firstIncomingData.resolve(message)) + second.on('news', (message) => secondIncomingData.resolve(message)) + + first.emit('join', 'lobby') + second.emit('join', 'lobby') + second.emit('announce', 'lobby') + + await expect(firstIncomingData.promise).resolves.toBe('hello lobby') + await expect(secondIncomingData.promise).resolves.toBe('hello lobby') +}) + +it('does not send room events to connections outside the room', async () => { + const { createSocketClient } = await import('./socket.io-client.js') + + const memberIncomingData = Promise.withResolvers() + const onOutsiderData = vi.fn<(message: unknown) => void>() + + interceptor.on('connection', ({ client, rooms }) => { + client.addEventListener('message', (event) => { + const [room] = event.data.args + + if (event.data.event === 'join' && typeof room === 'string') { + rooms.join(room) + } + + if (event.data.event === 'announce' && typeof room === 'string') { + rooms.to(room).send({ event: 'news', args: ['hello lobby'] }) + } + }) + }) + + const member = createSocketClient('wss://example.com') + onTestFinished(() => { + member.close() + }) + const outsider = createSocketClient('wss://example.com') + onTestFinished(() => { + outsider.close() + }) + member.on('news', (message) => memberIncomingData.resolve(message)) + outsider.on('news', onOutsiderData) + + member.emit('join', 'lobby') + outsider.emit('join', 'elsewhere') + member.emit('announce', 'lobby') + + await expect(memberIncomingData.promise).resolves.toBe('hello lobby') + expect(onOutsiderData).not.toHaveBeenCalled() +}) diff --git a/tests/typings/msw.test-d.ts b/tests/typings/msw.test-d.ts index 8c6acec..7f0e0a0 100644 --- a/tests/typings/msw.test-d.ts +++ b/tests/typings/msw.test-d.ts @@ -1,14 +1,31 @@ import { ws } from 'msw' import { setupWorker } from 'msw/browser' -import { SocketIo } from '../../src/index.js' +import { + SocketIo, + SocketIoRooms, + type SocketIoMessage, +} from '../../src/index.js' it('is compatible with the msw WebSocket link', () => { - const api = ws.link('wss://example.com/', { protocol: new SocketIo() }) + const api = ws.link('wss://example.com/', { extensions: [new SocketIo()] }) setupWorker( - api.addEventListener('connection', ({ client, server }) => { - client.send('["hello","John"]') - server.send('["hello","John"]') + api.addEventListener('connection', ({ client, server, rooms }) => { + expectTypeOf(rooms).toEqualTypeOf() + + client.send({ event: 'hello', args: ['John'] }) + server.send({ namespace: '/admin', event: 'hello', args: ['John'] }) + api.broadcast({ event: 'hello', args: [] }) + + client.addEventListener('message', (event) => { + expectTypeOf(event.data).toEqualTypeOf() + }) + server.addEventListener('message', (event) => { + expectTypeOf(event.data).toEqualTypeOf() + }) + + // @ts-expect-error Raw frames are never sent by the handler. + client.send('42["hello","John"]') }), ) }) From 410f094f84da10b859526b3880a032e48ffed956 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 21 Sep 2026 17:29:13 +0200 Subject: [PATCH 2/5] fix: improve the api --- README.md | 99 ++++--- src/index.ts | 565 +++++++++++++++++++++++++++++++----- tests/socket-io.test.ts | 456 +++++++++++++++++++++++++++-- tests/socket.io-client.ts | 9 +- tests/typings/msw.test-d.ts | 13 +- 5 files changed, 1000 insertions(+), 142 deletions(-) diff --git a/README.md b/README.md index b3d1892..cb296d8 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,12 @@ ## `@mswjs/socket.io-binding` -The Socket.IO protocol as a WebSocket extension for [`@mswjs/interceptors`](https://github.com/mswjs/interceptors) and [Mock Service Worker](https://github.com/mswjs/msw). Apply it to intercepted WebSocket connections to work with Socket.IO events instead of the raw Engine.IO/Socket.IO frames. +The Socket.IO protocol as a WebSocket extension for [`@mswjs/interceptors`](https://github.com/mswjs/interceptors) and [Mock Service Worker](https://github.com/mswjs/msw). Apply it to intercepted WebSocket connections to work with Socket.IO messages instead of the raw Engine.IO/Socket.IO frames. ## Motivation -Socket.IO implements its own protocol on top of WebSocket: a session handshake, a heartbeat, namespaces, and a packet framing. Without the extension, an intercepted connection exposes the raw frames (e.g. `40`, `42["hello","John"]`), expects you to send them back the same way, and never completes the handshake a Socket.IO client waits for. With the extension, the connection speaks Socket.IO events, and the session is established for you. +Socket.IO implements its own protocol on top of WebSocket: a session handshake, a heartbeat, namespaces, acknowledgements, and a packet framing. Without the extension, an intercepted connection exposes the raw frames (e.g. `40`, `42["hello","John"]`), expects you to send them back the same way, and never completes the handshake a Socket.IO client waits for. With the extension, the connection speaks Socket.IO messages, and the session is established for you. -An event is represented as an object: - -```ts -interface SocketIoMessage { - namespace?: string // The default namespace ("/") when omitted. - event: string - args: Array -} -``` +> **Only WebSocket transports reach a WebSocket extension.** Connect the Socket.IO client with `transports: ['websocket']`. A client that starts with HTTP long-polling never gets here. ## Install @@ -22,6 +14,25 @@ interface SocketIoMessage { npm install @mswjs/socket.io-binding ``` +## Messages + +A message is one of the following objects. The `namespace` is the default one (`/`) when omitted. + +```ts +// An event. Carries an `id` when the sender expects an acknowledgement. +{ type?: 'event', namespace?: string, event: string, args: Array, id?: number } +// An acknowledgement of the event with the given `id`. +{ type: 'ack', namespace?: string, id: number, args: Array } +// A connection to a namespace, with the `auth` payload the client sent. +{ type: 'connect', namespace?: string, auth?: unknown } +// A rejected connection to a namespace. +{ type: 'connect_error', namespace?: string, error: unknown } +// A disconnection from a namespace. +{ type: 'disconnect', namespace?: string } +``` + +Events are the default: `client.send({ event: 'greeting', args: ['Hello!'] })`. + ## Usage ### With Mock Service Worker @@ -35,10 +46,8 @@ const chat = ws.link('wss://example.com/chat', { extensions: [new SocketIo()] }) export const handlers = [ chat.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { - const { event: name, args } = event.data - - if (name === 'hello') { - const [firstName] = args + if (event.data.type === 'event' && event.data.event === 'hello') { + const [firstName] = event.data.args client.send({ event: 'greeting', args: [`Hello, ${firstName}!`] }) } }) @@ -46,7 +55,7 @@ export const handlers = [ ] ``` -`client.send()`, `server.send()`, `chat.broadcast()`, and the `message` events all operate on Socket.IO events. The extension encodes and decodes the frames behind the scenes. +`client.send()`, `server.send()`, `chat.broadcast()`, and the `message` events all operate on Socket.IO messages. The extension encodes and decodes the frames behind the scenes. ### With Interceptors @@ -63,47 +72,71 @@ const interceptor = new WebSocketInterceptor({ interceptor.on('connection', ({ server }) => { server.connect() server.addEventListener('message', (event) => { - console.log(event.data) // { namespace: '/', event: 'greeting', args: ['Hello, John!'] } + console.log(event.data) // { type: 'event', namespace: '/', event: 'greeting', args: ['Hello, John!'] } }) }) ``` -### Namespaces +### Acknowledgements -The extension approves every namespace a client connects to. The namespace of an event is a part of the event, both when receiving and when sending. +An event the client sent with a callback (`emit('hello', 'John', callback)` or `emitWithAck()`) carries an `id`. Acknowledge it with an `ack` message of the same `id`. Events you send with an `id` get acknowledged the same way. ```js chat.addEventListener('connection', ({ client }) => { client.addEventListener('message', (event) => { - if (event.data.namespace === '/admin') { - client.send({ - namespace: '/admin', - event: 'greeting', - args: ['Hello, admin!'], - }) + if (event.data.type === 'event' && event.data.id !== undefined) { + client.send({ type: 'ack', id: event.data.id, args: ['received'] }) + } + }) +}) +``` + +### Namespaces + +Every namespace a client connects to is accepted by default. The connection surfaces as a `connect` message with the `auth` payload the client sent. To accept or reject connections, register a policy on the client's `socket`. The rejection reaches the client as `connect_error`. + +```js +chat.addEventListener('connection', ({ socket }) => { + socket.use((namespace, auth) => { + if (auth?.token !== 'valid') { + return Object.assign(new Error('unauthorized'), { data: { namespace } }) } + return true }) }) ``` +The namespace of an event is a part of the event, both when receiving and when sending. A client leaving a namespace surfaces as a `disconnect` message, and `socket.of(namespace).disconnect()` disconnects the client from a namespace. + ### Rooms -The extension exposes the rooms of a connection on the connection event. A connection leaves all of its rooms once closed. +The connection event carries the client's `socket` and the mocked server `io`, mirroring Socket.IO. Rooms are scoped to their namespace. `socket.to()` and `socket.broadcast` exclude the sending socket, `io.to()` and `io.send()` include it. ```js -chat.addEventListener('connection', ({ client, rooms }) => { +chat.addEventListener('connection', ({ client, socket, io }) => { client.addEventListener('message', (event) => { - const { event: name, args } = event.data + if (event.data.type !== 'event') { + return + } + + const [room] = event.data.args - if (name === 'join') { - const [room] = args - rooms.join(room) - rooms.to(room).send({ event: 'joined', args: [room] }) + if (event.data.event === 'join') { + socket.join(room) // The default namespace. Use `socket.of('/chat').join(room)` for others. + socket.to(room).send({ event: 'joined', args: [socket.id] }) + } + + if (event.data.event === 'announce') { + io.of('/chat') + .to(room) + .send({ event: 'news', args: ['Hello, room!'] }) } }) }) ``` +Every socket leaves its rooms once the client disconnects. + ## Limitations -Acknowledgements are not supported. Rooms are kept per extension instance and do not span multiple runtimes (e.g. multiple browser tabs). If you rely on any of these, open a pull request and implement them. Thank you. +Rooms and sockets are kept per extension instance and do not span multiple runtimes (e.g. multiple browser tabs). Namespace policies are synchronous. If you rely on any of these, open a pull request and implement them. Thank you. diff --git a/src/index.ts b/src/index.ts index 07c58dd..e0ef431 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,17 +18,71 @@ import { } from '@mswjs/interceptors/WebSocket' /** - * A Socket.IO event as the handler sees it: its namespace - * (the default one when omitted), its name, and its arguments. + * A Socket.IO event. The default namespace ("/") when omitted. + * Carries an `id` when the sender expects an acknowledgement. */ -export interface SocketIoMessage { +export interface SocketIoEventMessage { + type?: 'event' namespace?: string event: string args: Array + id?: number } +/** + * An acknowledgement of the event with the given `id`. + */ +export interface SocketIoAckMessage { + type: 'ack' + namespace?: string + id: number + args: Array +} + +/** + * A connection to a namespace, with the `auth` payload the client sent. + */ +export interface SocketIoConnectMessage { + type: 'connect' + namespace?: string + auth?: unknown +} + +/** + * A rejected connection to a namespace. + */ +export interface SocketIoConnectErrorMessage { + type: 'connect_error' + namespace?: string + error: unknown +} + +/** + * A disconnection from a namespace (the WebSocket stays open). + */ +export interface SocketIoDisconnectMessage { + type: 'disconnect' + namespace?: string +} + +export type SocketIoMessage = + | SocketIoEventMessage + | SocketIoAckMessage + | SocketIoConnectMessage + | SocketIoConnectErrorMessage + | SocketIoDisconnectMessage + +/** + * Decides whether a client may connect to a namespace. + * Return `true` to accept, or an error to reject with + * (its `message` and `data` reach the client's `connect_error`). + */ +export type SocketIoNamespacePolicy = ( + namespace: string, + auth: unknown, +) => true | Error + const DEFAULT_NAMESPACE = '/' -const SESSION_ID = 'test' /** * @note Advertise a heartbeat the client will never expect within @@ -98,82 +152,404 @@ function decodeSocketIoPackets( return packets } -function toSocketIoMessage(packet: SocketIoPacket): SocketIoMessage { - const [event, ...args] = packet.data +function toSocketIoMessage( + packet: SocketIoPacket, +): SocketIoMessage | undefined { + switch (packet.type) { + case PacketType.EVENT: + case PacketType.BINARY_EVENT: { + const [event, ...args] = packet.data + const message: SocketIoEventMessage = { + type: 'event', + namespace: packet.nsp, + event, + args, + } + + if (packet.id !== undefined) { + message.id = packet.id + } + + return message + } + + case PacketType.ACK: + case PacketType.BINARY_ACK: { + if (packet.id === undefined) { + return undefined + } + + return { + type: 'ack', + namespace: packet.nsp, + id: packet.id, + args: packet.data, + } + } + + case PacketType.CONNECT: { + return { type: 'connect', namespace: packet.nsp, auth: packet.data } + } + + case PacketType.CONNECT_ERROR: { + return { + type: 'connect_error', + namespace: packet.nsp, + error: packet.data, + } + } + + case PacketType.DISCONNECT: { + return { type: 'disconnect', namespace: packet.nsp } + } + } +} + +function toSocketIoPacket(message: SocketIoMessage): SocketIoPacket { + const nsp = message.namespace ?? DEFAULT_NAMESPACE + + switch (message.type) { + case 'ack': { + return { type: PacketType.ACK, nsp, id: message.id, data: message.args } + } + + case 'connect': { + return { type: PacketType.CONNECT, nsp, data: message.auth } + } + + case 'connect_error': { + return { type: PacketType.CONNECT_ERROR, nsp, data: message.error } + } + + case 'disconnect': { + return { type: PacketType.DISCONNECT, nsp } + } + + default: { + const packet: SocketIoPacket = { + type: PacketType.EVENT, + nsp, + data: [message.event, ...message.args], + } + + if (message.id !== undefined) { + packet.id = message.id + } + return packet + } + } +} + +function toConnectError(error: Error): SocketIoConnectErrorMessage['error'] { return { - namespace: packet.nsp, - event, - args, + message: error.message, + data: 'data' in error ? error.data : undefined, } } -type SocketIoRoomMembers = Map< - string, - Set> -> +/** + * Where events can be sent to: every socket of a room or a namespace. + */ +export interface SocketIoTarget { + send(message: SocketIoEventMessage): void +} + +type SocketIoRoomMembers = Map> /** - * The rooms of a Socket.IO connection: join and leave them, - * and send events to every connection in a room. + * The mocked Socket.IO server: every socket connected through + * this extension, across all connections. Sending to a room or + * a namespace here includes the sending socket, like `io.to()`. */ -export class SocketIoRooms { - constructor( - private readonly client: WebSocketClientHandle, - private readonly members: SocketIoRoomMembers, - ) {} +export class SocketIoServer { + readonly #sockets = new Map>() + readonly #rooms = new Map() /** - * Add this connection to the given room. - * A closed connection leaves all of its rooms. + * The given namespace of the server. */ - public join(room: string): void { - let members = this.members.get(room) + public of(namespace: string): SocketIoNamespace { + return new SocketIoNamespace(this, namespace) + } + + /** + * Send an event to every socket in the given room of the default namespace. + */ + public to(room: string): SocketIoTarget { + return this.of(DEFAULT_NAMESPACE).to(room) + } + + /** + * Send an event to every socket of the default namespace. + */ + public send(message: SocketIoEventMessage): void { + this.of(DEFAULT_NAMESPACE).send(message) + } + + /** @internal */ + public socketsOf(namespace: string): Set { + let sockets = this.#sockets.get(namespace) + + if (!sockets) { + sockets = new Set() + this.#sockets.set(namespace, sockets) + } + + return sockets + } + + /** @internal */ + public membersOf( + namespace: string, + room: string, + ): Set { + let rooms = this.#rooms.get(namespace) + + if (!rooms) { + rooms = new Map() + this.#rooms.set(namespace, rooms) + } + + let members = rooms.get(room) if (!members) { members = new Set() - this.members.set(room, members) + rooms.set(room, members) } - if (!members.has(this.client)) { - this.client.addEventListener('close', () => this.leave(room), { - once: true, - }) + return members + } +} + +/** + * A namespace of the mocked server. + */ +export class SocketIoNamespace implements SocketIoTarget { + constructor( + private readonly server: SocketIoServer, + public readonly name: string, + ) {} + + /** + * Send an event to every socket in the given room. + */ + public to(room: string): SocketIoTarget { + const sockets = this.server.membersOf(this.name, room) + + return { + send(message) { + for (const socket of sockets) { + socket.send(message) + } + }, } + } - members.add(this.client) + /** + * Send an event to every socket of this namespace. + */ + public send(message: SocketIoEventMessage): void { + for (const socket of this.server.socketsOf(this.name)) { + socket.send(message) + } } +} + +/** + * The socket a client has in a namespace: its rooms, and the + * events to the other sockets. Sending to a room or broadcasting + * here excludes this socket, like `socket.to()`. + */ +export class SocketIoNamespaceSocket { + public readonly rooms = new Set() + + constructor( + private readonly server: SocketIoServer, + private readonly client: WebSocketClientHandle, + public readonly namespace: string, + ) {} /** - * Remove this connection from the given room. + * Send an event to this socket. + */ + public send(message: SocketIoEventMessage): void { + this.client.send({ ...message, namespace: this.namespace }) + } + + /** + * Add this socket to the given room. + */ + public join(room: string): void { + this.rooms.add(room) + this.server.membersOf(this.namespace, room).add(this) + } + + /** + * Remove this socket from the given room. */ public leave(room: string): void { - this.members.get(room)?.delete(this.client) + this.rooms.delete(room) + this.server.membersOf(this.namespace, room).delete(this) } /** - * Send events to every connection in the given room. + * Send an event to every other socket in the given room. */ - public to(room: string): { send(message: SocketIoMessage): void } { - const members = this.members.get(room) ?? new Set() + public to(room: string): SocketIoTarget { + const members = this.server.membersOf(this.namespace, room) return { - send(message) { + send: (message) => { for (const member of members) { - member.send(message) + if (member !== this) { + member.send(message) + } } }, } } + + /** + * Send an event to every other socket of this namespace. + */ + public get broadcast(): SocketIoTarget { + const sockets = this.server.socketsOf(this.namespace) + + return { + send: (message) => { + for (const socket of sockets) { + if (socket !== this) { + socket.send(message) + } + } + }, + } + } + + /** + * Disconnect this socket from its namespace. + */ + public disconnect(): void { + this.client.send({ type: 'disconnect', namespace: this.namespace }) + this.detach() + } + + /** @internal */ + public attach(): void { + this.server.socketsOf(this.namespace).add(this) + } + + /** @internal */ + public detach(): void { + for (const room of this.rooms) { + this.leave(room) + } + + this.server.socketsOf(this.namespace).delete(this) + } +} + +/** + * The client side of a connection as the mocked server sees it: + * its session id, its socket in every namespace, and the policy + * deciding which namespaces it may connect to. + */ +export class SocketIoSocket { + public readonly id = globalThis.crypto.randomUUID() + readonly #namespaces = new Map() + readonly #policies: Array = [] + + constructor( + private readonly server: SocketIoServer, + private readonly client: WebSocketClientHandle, + ) { + client.addEventListener('close', () => this.detach(), { once: true }) + } + + /** + * The socket of this client in the given namespace. + */ + public of(namespace: string): SocketIoNamespaceSocket { + let socket = this.#namespaces.get(namespace) + + if (!socket) { + socket = new SocketIoNamespaceSocket(this.server, this.client, namespace) + this.#namespaces.set(namespace, socket) + } + + return socket + } + + /** + * Decide whether this client may connect to a namespace. + * Every namespace is accepted without a policy. + */ + public use(policy: SocketIoNamespacePolicy): void { + this.#policies.push(policy) + } + + /** + * The rooms of this client in the default namespace. + */ + public get rooms(): Set { + return this.of(DEFAULT_NAMESPACE).rooms + } + + public join(room: string): void { + this.of(DEFAULT_NAMESPACE).join(room) + } + + public leave(room: string): void { + this.of(DEFAULT_NAMESPACE).leave(room) + } + + public to(room: string): SocketIoTarget { + return this.of(DEFAULT_NAMESPACE).to(room) + } + + public get broadcast(): SocketIoTarget { + return this.of(DEFAULT_NAMESPACE).broadcast + } + + /** @internal */ + public authorize(namespace: string, auth: unknown): true | Error { + for (const policy of this.#policies) { + const verdict = policy(namespace, auth) + + if (verdict !== true) { + return verdict + } + } + + return true + } + + /** @internal */ + public detach(namespace?: string): void { + if (namespace !== undefined) { + this.#namespaces.get(namespace)?.detach() + return + } + + for (const socket of this.#namespaces.values()) { + socket.detach() + } + } } /** * The Socket.IO protocol as a WebSocket extension. * - * Handlers see Socket.IO events (`{ namespace, event, args }`) instead of - * Engine.IO/Socket.IO frames. The session (Engine.IO handshake, namespace - * connects, heartbeat) is spoken by the extension on behalf of the mocked - * server and never surfaces. Rooms are exposed on the connection event. + * Handlers see Socket.IO messages (events, acknowledgements, namespace + * connections and disconnections) instead of Engine.IO/Socket.IO frames. + * The session (Engine.IO handshake, namespace connects, heartbeat) is + * spoken by the extension on behalf of the mocked server and never + * surfaces. The connection event carries the client's `socket` and the + * mocked server `io`. + * + * @note Only WebSocket transports reach a WebSocket extension. + * Clients must connect with `transports: ['websocket']`. * * @example * // With Interceptors: applied to every Socket.IO connection. @@ -183,25 +559,27 @@ export class SocketIoRooms { * // With Mock Service Worker: applied to the connections of this link. * const chat = ws.link('wss://example.com/chat', { extensions: [new SocketIo()] }) * - * chat.addEventListener('connection', ({ client, rooms }) => { + * chat.addEventListener('connection', ({ client, socket, io }) => { * client.addEventListener('message', (event) => { - * if (event.data.event === 'join') { - * rooms.join(String(event.data.args[0])) + * if (event.data.type === 'event' && event.data.event === 'join') { + * socket.join(String(event.data.args[0])) * } * }) - * rooms.to('lobby').send({ event: 'greeting', args: ['Hello, everyone!'] }) + * io.to('lobby').send({ event: 'greeting', args: ['Hello, everyone!'] }) * }) */ export class SocketIo extends WebSocketExtension< SocketIoMessage, - { rooms: SocketIoRooms } + { socket: SocketIoSocket; io: SocketIoServer } > { + public readonly io = new SocketIoServer() + /** * The Socket.IO decoder is stateful (binary attachments span * multiple frames), so keep one per connection. */ - private readonly decoders = new WeakMap() - private readonly members: SocketIoRoomMembers = new Map() + readonly #decoders = new WeakMap() + readonly #sockets = new WeakMap() public match({ client, @@ -210,25 +588,8 @@ export class SocketIo extends WebSocketExtension< return client.url.searchParams.has('EIO') } - public connect(): WebSocketData { - // Establish the Engine.IO session. - return encodeEngineIoPacket({ - type: 'open', - data: JSON.stringify({ - sid: SESSION_ID, - upgrades: [], - pingInterval: PING_INTERVAL, - pingTimeout: PING_TIMEOUT, - }), - }) - } - public encode(message: SocketIoMessage): Generator { - return encodeSocketIoPacket({ - type: PacketType.EVENT, - nsp: message.namespace ?? DEFAULT_NAMESPACE, - data: [message.event, ...message.args], - }) + return encodeSocketIoPacket(toSocketIoPacket(message)) } public *decode( @@ -239,16 +600,33 @@ export class SocketIo extends WebSocketExtension< frame, this.#getDecoder(connection), )) { - if ( - packet.type === PacketType.EVENT || - packet.type === PacketType.BINARY_EVENT - ) { - yield toSocketIoMessage(packet) + const message = toSocketIoMessage(packet) + + if (message) { + yield message } } } - public *receive(frame: WebSocketData): Generator { + public connect({ + client, + }: WebSocketExtensionContext): WebSocketData { + // Establish the Engine.IO session. + return encodeEngineIoPacket({ + type: 'open', + data: JSON.stringify({ + sid: this.#getSocket(client).id, + upgrades: [], + pingInterval: PING_INTERVAL, + pingTimeout: PING_TIMEOUT, + }), + }) + } + + public *receive( + frame: WebSocketData, + { client }: WebSocketExtensionContext, + ): Generator { if (typeof frame !== 'string') { return } @@ -265,34 +643,61 @@ export class SocketIo extends WebSocketExtension< return } - // Approve every namespace the client connects to. + const socket = this.#getSocket(client) + for (const packet of decodeSocketIoPackets(frame, new Decoder())) { if (packet.type === PacketType.CONNECT) { + const verdict = socket.authorize(packet.nsp, packet.data) + + if (verdict instanceof Error) { + yield* encodeSocketIoPacket({ + type: PacketType.CONNECT_ERROR, + nsp: packet.nsp, + data: toConnectError(verdict), + }) + continue + } + + socket.of(packet.nsp).attach() yield* encodeSocketIoPacket({ type: PacketType.CONNECT, nsp: packet.nsp, - data: { sid: SESSION_ID }, + data: { sid: socket.id }, }) } + + if (packet.type === PacketType.DISCONNECT) { + socket.detach(packet.nsp) + } } } public extend({ client }: WebSocketExtensionContext): { - rooms: SocketIoRooms + socket: SocketIoSocket + io: SocketIoServer } { - return { - rooms: new SocketIoRooms(client, this.members), - } + return { socket: this.#getSocket(client), io: this.io } } #getDecoder(connection: object): Decoder { - let decoder = this.decoders.get(connection) + let decoder = this.#decoders.get(connection) if (!decoder) { decoder = new Decoder() - this.decoders.set(connection, decoder) + this.#decoders.set(connection, decoder) } return decoder } + + #getSocket(client: WebSocketClientHandle): SocketIoSocket { + let socket = this.#sockets.get(client) + + if (!socket) { + socket = new SocketIoSocket(this.io, client) + this.#sockets.set(client, socket) + } + + return socket + } } diff --git a/tests/socket-io.test.ts b/tests/socket-io.test.ts index 207bf79..dc80961 100644 --- a/tests/socket-io.test.ts +++ b/tests/socket-io.test.ts @@ -41,13 +41,16 @@ afterAll(() => { it('decodes outgoing client events', async () => { const { createSocketClient } = await import('./socket.io-client.js') - const eventLog: Array = [] - const outgoingData = Promise.withResolvers() + const messages: Array = [] + const outgoingEvent = Promise.withResolvers() interceptor.on('connection', ({ client }) => { client.addEventListener('message', (event) => { - eventLog.push(event.data) - outgoingData.resolve(event.data) + messages.push(event.data) + + if (event.data.type === 'event') { + outgoingEvent.resolve(event.data) + } }) }) @@ -57,13 +60,15 @@ it('decodes outgoing client events', async () => { }) ws.emit('hello', 'John') - await expect(outgoingData.promise).resolves.toEqual({ + await expect(outgoingEvent.promise).resolves.toEqual({ + type: 'event', namespace: '/', event: 'hello', args: ['John'], }) - expect(eventLog, 'exposes no protocol packets').toEqual([ - { namespace: '/', event: 'hello', args: ['John'] }, + expect(messages, 'exposes messages only, never frames').toEqual([ + { type: 'connect', namespace: '/', auth: undefined }, + { type: 'event', namespace: '/', event: 'hello', args: ['John'] }, ]) }) @@ -74,7 +79,7 @@ it('encodes mocked incoming server events', async () => { interceptor.on('connection', ({ client }) => { client.addEventListener('message', (event) => { - if (event.data.event === 'hello') { + if (event.data.type === 'event' && event.data.event === 'hello') { const [firstName] = event.data.args client.send({ event: 'greetings', args: [`Hello, ${firstName}!`] }) } @@ -95,9 +100,7 @@ it('decodes incoming server events', async () => { const { createSocketClient } = await import('./socket.io-client.js') await using httpServer = await createTestHttpServer() const wsServer = createSocketIoServer(httpServer) - onTestFinished(() => { - wsServer.close() - }) + onTestFinished(() => wsServer.close()) const incomingServerData = Promise.withResolvers() const incomingClientData = Promise.withResolvers() @@ -112,7 +115,9 @@ it('decodes incoming server events', async () => { server.connect() server.addEventListener('message', (event) => { - incomingServerData.resolve(event.data) + if (event.data.type === 'event') { + incomingServerData.resolve(event.data) + } }) }) @@ -129,6 +134,7 @@ it('decodes incoming server events', async () => { incomingServerData.promise, 'the interceptor gets the decoded event', ).resolves.toEqual({ + type: 'event', namespace: '/', event: 'greeting', args: [{ id: 1, text: 'Hello, John!' }], @@ -146,9 +152,7 @@ it('modifies incoming server events', async () => { const { createSocketClient } = await import('./socket.io-client.js') await using httpServer = await createTestHttpServer() const wsServer = createSocketIoServer(httpServer) - onTestFinished(() => { - wsServer.close() - }) + onTestFinished(() => wsServer.close()) const incomingServerData = Promise.withResolvers() const incomingClientData = Promise.withResolvers() @@ -163,6 +167,10 @@ it('modifies incoming server events', async () => { server.connect() server.addEventListener('message', (event) => { + if (event.data.type !== 'event') { + return + } + incomingServerData.resolve(event.data) event.preventDefault() @@ -186,6 +194,7 @@ it('modifies incoming server events', async () => { incomingServerData.promise, 'the interceptor gets the original event', ).resolves.toEqual({ + type: 'event', namespace: '/', event: 'greeting', args: [{ id: 1, text: 'Hello, John!' }], @@ -199,6 +208,239 @@ it('modifies incoming server events', async () => { }) }) +it('acknowledges client events', async () => { + const { createSocketClient } = await import('./socket.io-client.js') + + const outgoingEvent = Promise.withResolvers() + + interceptor.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data.type === 'event' && event.data.id !== undefined) { + outgoingEvent.resolve(event.data) + client.send({ + type: 'ack', + id: event.data.id, + args: [`Hello, ${event.data.args[0]}!`], + }) + } + }) + }) + + const ws = createSocketClient('wss://example.com') + onTestFinished(() => { + ws.close() + }) + + await expect(ws.emitWithAck('hello', 'John')).resolves.toBe('Hello, John!') + await expect( + outgoingEvent.promise, + 'the event carries its acknowledgement id', + ).resolves.toEqual({ + type: 'event', + namespace: '/', + event: 'hello', + args: ['John'], + id: 0, + }) +}) + +it('receives acknowledgements of server events', async () => { + const { createSocketClient } = await import('./socket.io-client.js') + + const acknowledgement = Promise.withResolvers() + + interceptor.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data.type === 'connect') { + client.send({ event: 'ping', args: [], id: 42 }) + } + + if (event.data.type === 'ack') { + acknowledgement.resolve(event.data) + } + }) + }) + + const ws = createSocketClient('wss://example.com') + onTestFinished(() => { + ws.close() + }) + ws.on('ping', (callback) => callback('pong')) + + await expect(acknowledgement.promise).resolves.toEqual({ + type: 'ack', + namespace: '/', + id: 42, + args: ['pong'], + }) +}) + +it('exposes the authentication payload of a namespace connection', async () => { + const { createSocketClient } = await import('./socket.io-client.js') + + const connection = Promise.withResolvers() + + interceptor.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data.type === 'connect') { + connection.resolve(event.data) + } + }) + }) + + const ws = createSocketClient('wss://example.com/admin', { + auth: { token: 'abc-123' }, + }) + onTestFinished(() => { + ws.close() + }) + + await expect(connection.promise).resolves.toEqual({ + type: 'connect', + namespace: '/admin', + auth: { token: 'abc-123' }, + }) +}) + +it('rejects a namespace connection by policy', async () => { + const { createSocketClient } = await import('./socket.io-client.js') + + const connectError = Promise.withResolvers() + const onConnect = vi.fn() + + interceptor.on('connection', ({ socket }) => { + socket.use((namespace, auth) => { + const token = + typeof auth === 'object' && auth !== null && 'token' in auth + ? auth.token + : undefined + + if (token !== 'valid') { + const error = new Error('unauthorized') + return Object.assign(error, { data: { namespace } }) + } + + return true + }) + }) + + const ws = createSocketClient('wss://example.com/admin', { + auth: { token: 'invalid' }, + }) + onTestFinished(() => { + ws.close() + }) + ws.on('connect', onConnect) + ws.on('connect_error', (error) => connectError.resolve(error)) + + const error = await connectError.promise + expect.soft(error.message).toBe('unauthorized') + expect.soft(Reflect.get(error, 'data')).toEqual({ namespace: '/admin' }) + expect(onConnect, 'the client never connects').not.toHaveBeenCalled() +}) + +it('accepts a namespace connection by policy', async () => { + const { createSocketClient } = await import('./socket.io-client.js') + + const connected = Promise.withResolvers() + + interceptor.on('connection', ({ socket }) => { + socket.use((_, auth) => { + return typeof auth === 'object' && + auth !== null && + 'token' in auth && + auth.token === 'valid' + ? true + : new Error('unauthorized') + }) + }) + + const ws = createSocketClient('wss://example.com/admin', { + auth: { token: 'valid' }, + }) + onTestFinished(() => { + ws.close() + }) + ws.on('connect', () => connected.resolve()) + + await expect(connected.promise).resolves.toBeUndefined() +}) + +it('exposes a namespace disconnection', async () => { + const { createSocketClient } = await import('./socket.io-client.js') + + const disconnection = Promise.withResolvers() + + interceptor.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data.type === 'disconnect') { + disconnection.resolve(event.data) + } + }) + }) + + const ws = createSocketClient('wss://example.com/chat') + onTestFinished(() => { + ws.close() + }) + ws.on('connect', () => ws.disconnect()) + + await expect(disconnection.promise).resolves.toEqual({ + type: 'disconnect', + namespace: '/chat', + }) +}) + +it('disconnects a client from a namespace', async () => { + const { createSocketClient } = await import('./socket.io-client.js') + + const reason = Promise.withResolvers() + + interceptor.on('connection', ({ client, socket }) => { + client.addEventListener('message', (event) => { + if (event.data.type === 'event' && event.data.event === 'leave') { + socket.of('/chat').disconnect() + } + }) + }) + + const ws = createSocketClient('wss://example.com/chat') + onTestFinished(() => { + ws.close() + }) + ws.on('disconnect', (disconnectReason) => reason.resolve(disconnectReason)) + ws.emit('leave') + + await expect(reason.promise).resolves.toBe('io server disconnect') +}) + +it('assigns a distinct session id to every connection', async () => { + const { createSocketClient } = await import('./socket.io-client.js') + + const socketIds: Array = [] + + interceptor.on('connection', ({ socket }) => { + socketIds.push(socket.id) + }) + + const first = createSocketClient('wss://example.com') + onTestFinished(() => { + first.close() + }) + const second = createSocketClient('wss://example.com') + onTestFinished(() => { + second.close() + }) + + await expect.poll(() => first.id).toBeTypeOf('string') + await expect.poll(() => second.id).toBeTypeOf('string') + expect.soft(first.id, 'the clients get distinct ids').not.toBe(second.id) + expect(socketIds, 'the handler sees the ids the clients got').toEqual([ + first.id, + second.id, + ]) +}) + it('exchanges events on a custom namespace', async () => { const { createSocketClient } = await import('./socket.io-client.js') @@ -207,6 +449,10 @@ it('exchanges events on a custom namespace', async () => { interceptor.on('connection', ({ client }) => { client.addEventListener('message', (event) => { + if (event.data.type !== 'event') { + return + } + outgoingData.resolve(event.data) if (event.data.event === 'hello') { @@ -227,6 +473,7 @@ it('exchanges events on a custom namespace', async () => { ws.on('greetings', (message) => incomingData.resolve(message)) await expect(outgoingData.promise).resolves.toEqual({ + type: 'event', namespace: '/admin', event: 'hello', args: ['John'], @@ -234,22 +481,26 @@ it('exchanges events on a custom namespace', async () => { await expect(incomingData.promise).resolves.toBe('Hello from /admin!') }) -it('sends events to every connection in a room', async () => { +it('sends events to every socket in a room, including the sender', async () => { const { createSocketClient } = await import('./socket.io-client.js') const firstIncomingData = Promise.withResolvers() const secondIncomingData = Promise.withResolvers() - interceptor.on('connection', ({ client, rooms }) => { + interceptor.on('connection', ({ client, socket, io }) => { client.addEventListener('message', (event) => { + if (event.data.type !== 'event') { + return + } + const [room] = event.data.args if (event.data.event === 'join' && typeof room === 'string') { - rooms.join(room) + socket.join(room) } if (event.data.event === 'announce' && typeof room === 'string') { - rooms.to(room).send({ event: 'news', args: ['hello lobby'] }) + io.to(room).send({ event: 'news', args: ['hello lobby'] }) } }) }) @@ -273,22 +524,69 @@ it('sends events to every connection in a room', async () => { await expect(secondIncomingData.promise).resolves.toBe('hello lobby') }) -it('does not send room events to connections outside the room', async () => { +it('sends events to the other sockets in a room, excluding the sender', async () => { + const { createSocketClient } = await import('./socket.io-client.js') + + const memberIncomingData = Promise.withResolvers() + const onSenderData = vi.fn<(message: unknown) => void>() + + interceptor.on('connection', ({ client, socket }) => { + client.addEventListener('message', (event) => { + if (event.data.type !== 'event') { + return + } + + const [room] = event.data.args + + if (event.data.event === 'join' && typeof room === 'string') { + socket.join(room) + } + + if (event.data.event === 'announce' && typeof room === 'string') { + socket.to(room).send({ event: 'news', args: ['hello lobby'] }) + } + }) + }) + + const member = createSocketClient('wss://example.com') + onTestFinished(() => { + member.close() + }) + const sender = createSocketClient('wss://example.com') + onTestFinished(() => { + sender.close() + }) + member.on('news', (message) => memberIncomingData.resolve(message)) + sender.on('news', onSenderData) + + member.emit('join', 'lobby') + sender.emit('join', 'lobby') + sender.emit('announce', 'lobby') + + await expect(memberIncomingData.promise).resolves.toBe('hello lobby') + expect(onSenderData).not.toHaveBeenCalled() +}) + +it('does not send room events to sockets outside the room', async () => { const { createSocketClient } = await import('./socket.io-client.js') const memberIncomingData = Promise.withResolvers() const onOutsiderData = vi.fn<(message: unknown) => void>() - interceptor.on('connection', ({ client, rooms }) => { + interceptor.on('connection', ({ client, socket, io }) => { client.addEventListener('message', (event) => { + if (event.data.type !== 'event') { + return + } + const [room] = event.data.args if (event.data.event === 'join' && typeof room === 'string') { - rooms.join(room) + socket.join(room) } if (event.data.event === 'announce' && typeof room === 'string') { - rooms.to(room).send({ event: 'news', args: ['hello lobby'] }) + io.to(room).send({ event: 'news', args: ['hello lobby'] }) } }) }) @@ -311,3 +609,115 @@ it('does not send room events to connections outside the room', async () => { await expect(memberIncomingData.promise).resolves.toBe('hello lobby') expect(onOutsiderData).not.toHaveBeenCalled() }) + +it('scopes rooms to their namespace', async () => { + const { createSocketClient } = await import('./socket.io-client.js') + + const chatIncomingData = Promise.withResolvers() + const onDefaultData = vi.fn<(message: unknown) => void>() + + interceptor.on('connection', ({ client, socket, io }) => { + client.addEventListener('message', (event) => { + if (event.data.type !== 'event') { + return + } + + const [room] = event.data.args + + if (event.data.event === 'join' && typeof room === 'string') { + socket.of(event.data.namespace ?? '/').join(room) + } + + if (event.data.event === 'announce' && typeof room === 'string') { + io.of('/chat') + .to(room) + .send({ event: 'news', args: ['hello chat'] }) + } + }) + }) + + const chat = createSocketClient('wss://example.com/chat') + onTestFinished(() => { + chat.close() + }) + const other = createSocketClient('wss://example.com') + onTestFinished(() => { + other.close() + }) + chat.on('news', (message) => chatIncomingData.resolve(message)) + other.on('news', onDefaultData) + + chat.emit('join', 'lobby') + other.emit('join', 'lobby') + chat.emit('announce', 'lobby') + + await expect(chatIncomingData.promise).resolves.toBe('hello chat') + expect( + onDefaultData, + 'the same room in another namespace is a different room', + ).not.toHaveBeenCalled() +}) + +it('sends events to every socket of a namespace', async () => { + const { createSocketClient } = await import('./socket.io-client.js') + + const firstIncomingData = Promise.withResolvers() + const secondIncomingData = Promise.withResolvers() + + interceptor.on('connection', ({ client, io }) => { + client.addEventListener('message', (event) => { + if (event.data.type === 'event' && event.data.event === 'announce') { + io.send({ event: 'news', args: ['hello everyone'] }) + } + }) + }) + + const first = createSocketClient('wss://example.com') + onTestFinished(() => { + first.close() + }) + const second = createSocketClient('wss://example.com') + onTestFinished(() => { + second.close() + }) + first.on('news', (message) => firstIncomingData.resolve(message)) + second.on('news', (message) => secondIncomingData.resolve(message)) + + await expect.poll(() => first.connected && second.connected).toBe(true) + first.emit('announce') + + await expect(firstIncomingData.promise).resolves.toBe('hello everyone') + await expect(secondIncomingData.promise).resolves.toBe('hello everyone') +}) + +it('broadcasts events to the other sockets of a namespace', async () => { + const { createSocketClient } = await import('./socket.io-client.js') + + const otherIncomingData = Promise.withResolvers() + const onSenderData = vi.fn<(message: unknown) => void>() + + interceptor.on('connection', ({ client, socket }) => { + client.addEventListener('message', (event) => { + if (event.data.type === 'event' && event.data.event === 'announce') { + socket.broadcast.send({ event: 'news', args: ['hello others'] }) + } + }) + }) + + const sender = createSocketClient('wss://example.com') + onTestFinished(() => { + sender.close() + }) + const other = createSocketClient('wss://example.com') + onTestFinished(() => { + other.close() + }) + sender.on('news', onSenderData) + other.on('news', (message) => otherIncomingData.resolve(message)) + + await expect.poll(() => sender.connected && other.connected).toBe(true) + sender.emit('announce') + + await expect(otherIncomingData.promise).resolves.toBe('hello others') + expect(onSenderData).not.toHaveBeenCalled() +}) diff --git a/tests/socket.io-client.ts b/tests/socket.io-client.ts index 71ee21b..5d501e8 100644 --- a/tests/socket.io-client.ts +++ b/tests/socket.io-client.ts @@ -5,10 +5,13 @@ * goes to "engine.io-client", and uses "ws" for WebSocket class * (ignores the global class because assumes itself in Node.js). */ -import { Socket } from 'socket.io-client' +import { Socket, type SocketOptions } from 'socket.io-client' // @ts-expect-error Socket.IO shenanigans. import { io } from 'socket.io-client/dist/socket.io.js' -export function createSocketClient(uri: string): Socket { - return io(uri, { transports: ['websocket'] }) +export function createSocketClient( + uri: string, + options?: Pick, +): Socket { + return io(uri, { transports: ['websocket'], ...options }) } diff --git a/tests/typings/msw.test-d.ts b/tests/typings/msw.test-d.ts index 7f0e0a0..8c9fef9 100644 --- a/tests/typings/msw.test-d.ts +++ b/tests/typings/msw.test-d.ts @@ -2,7 +2,8 @@ import { ws } from 'msw' import { setupWorker } from 'msw/browser' import { SocketIo, - SocketIoRooms, + SocketIoSocket, + SocketIoServer, type SocketIoMessage, } from '../../src/index.js' @@ -10,15 +11,21 @@ it('is compatible with the msw WebSocket link', () => { const api = ws.link('wss://example.com/', { extensions: [new SocketIo()] }) setupWorker( - api.addEventListener('connection', ({ client, server, rooms }) => { - expectTypeOf(rooms).toEqualTypeOf() + api.addEventListener('connection', ({ client, server, socket, io }) => { + expectTypeOf(socket).toEqualTypeOf() + expectTypeOf(io).toEqualTypeOf() client.send({ event: 'hello', args: ['John'] }) + client.send({ type: 'ack', id: 1, args: ['John'] }) server.send({ namespace: '/admin', event: 'hello', args: ['John'] }) api.broadcast({ event: 'hello', args: [] }) client.addEventListener('message', (event) => { expectTypeOf(event.data).toEqualTypeOf() + + if (event.data.type === 'event') { + expectTypeOf(event.data.event).toEqualTypeOf() + } }) server.addEventListener('message', (event) => { expectTypeOf(event.data).toEqualTypeOf() From 906c95e0c4f67fcf17a1d224b502b1c1b1ae23b8 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 21 Sep 2026 17:45:22 +0200 Subject: [PATCH 3/5] chore: update interceptors to 0.45.0 --- package.json | 2 +- pnpm-lock.yaml | 21 +++++++++------------ 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index 705e9fb..753fe86 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ }, "devDependencies": { "@epic-web/test-server": "^0.1.6", - "@mswjs/interceptors": "^0.44.0", + "@mswjs/interceptors": "^0.45.0", "@ossjs/release": "^0.11.2", "@types/node": "^26.6.2", "msw": "^2.15.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ebad1f..d5bfd79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,8 +19,8 @@ importers: specifier: ^0.1.6 version: 0.1.6 '@mswjs/interceptors': - specifier: ^0.44.0 - version: 0.44.0 + specifier: ^0.45.0 + version: 0.45.0 '@ossjs/release': specifier: ^0.11.2 version: 0.11.2 @@ -53,7 +53,7 @@ importers: version: 8.3.0(@types/node@26.6.2) vitest: specifier: ^5.0.1 - version: 5.0.1(@types/node@26.6.2)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.0(@types/node@26.6.2)) + version: 5.0.1(@types/node@26.6.2)(vite@8.3.0(@types/node@26.6.2)) packages: @@ -126,8 +126,8 @@ packages: resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} engines: {node: '>=18'} - '@mswjs/interceptors@0.44.0': - resolution: {integrity: sha512-IYHevRStF4ijM20GI9c9Ndhw483NSAjCydQhUr4/zIB0BNLsTVw3vFAxTo6TlvX4QnV60wEHttUa9539TTAq+A==} + '@mswjs/interceptors@0.45.0': + resolution: {integrity: sha512-9lorWiv40Dwj2bwy4MKrKXm9KickEPlWoXAdkE/sQIOJ4fj+/v8PtBR5lATxYIqN/e4Ep5ydtVWFAMb1cY6UuQ==} engines: {node: '>=22'} '@open-draft/deferred-promise@2.2.0': @@ -1788,7 +1788,7 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 - '@mswjs/interceptors@0.44.0': + '@mswjs/interceptors@0.45.0': dependencies: '@open-draft/until': 3.0.1 '@types/debug': 4.1.13 @@ -2077,14 +2077,13 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitest/mocker@5.0.1(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.0(@types/node@26.6.2))': + '@vitest/mocker@5.0.1(vite@8.3.0(@types/node@26.6.2))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.1 estree-walker: 3.0.3 magic-string: 1.4.1 optionalDependencies: - msw: 2.15.0(@types/node@26.6.2)(typescript@7.0.2) vite: 8.3.0(@types/node@26.6.2) '@vitest/spy@5.0.1': {} @@ -2976,10 +2975,10 @@ snapshots: '@types/node': 26.6.2 fsevents: 2.3.3 - vitest@5.0.1(@types/node@26.6.2)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.0(@types/node@26.6.2)): + vitest@5.0.1(@types/node@26.6.2)(vite@8.3.0(@types/node@26.6.2)): dependencies: '@types/chai': 5.2.2 - '@vitest/mocker': 5.0.1(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.0(@types/node@26.6.2)) + '@vitest/mocker': 5.0.1(vite@8.3.0(@types/node@26.6.2)) chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -2994,8 +2993,6 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.6.2 - transitivePeerDependencies: - - msw why-is-node-running@2.3.0: dependencies: From 99d0a28fae0545820cd492c5adda055688eaa522 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 24 Sep 2026 16:10:01 +0200 Subject: [PATCH 4/5] chore: update `@mswjs/interceptors` to 0.45.1 --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 753fe86..6f35708 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ }, "devDependencies": { "@epic-web/test-server": "^0.1.6", - "@mswjs/interceptors": "^0.45.0", + "@mswjs/interceptors": "^0.45.1", "@ossjs/release": "^0.11.2", "@types/node": "^26.6.2", "msw": "^2.15.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5bfd79..86c8004 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,8 +19,8 @@ importers: specifier: ^0.1.6 version: 0.1.6 '@mswjs/interceptors': - specifier: ^0.45.0 - version: 0.45.0 + specifier: ^0.45.1 + version: 0.45.1 '@ossjs/release': specifier: ^0.11.2 version: 0.11.2 @@ -126,8 +126,8 @@ packages: resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} engines: {node: '>=18'} - '@mswjs/interceptors@0.45.0': - resolution: {integrity: sha512-9lorWiv40Dwj2bwy4MKrKXm9KickEPlWoXAdkE/sQIOJ4fj+/v8PtBR5lATxYIqN/e4Ep5ydtVWFAMb1cY6UuQ==} + '@mswjs/interceptors@0.45.1': + resolution: {integrity: sha512-BK/eSU3e9bYb7sbcEB+dl390LmKWM6Q9n3d9HEu9ypyd6vVkzdfx4a0C+cUP4p6Bz4wAxTz7ddFt3NXazMCWxA==} engines: {node: '>=22'} '@open-draft/deferred-promise@2.2.0': @@ -1788,7 +1788,7 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 - '@mswjs/interceptors@0.45.0': + '@mswjs/interceptors@0.45.1': dependencies: '@open-draft/until': 3.0.1 '@types/debug': 4.1.13 From 0f705f55711f5a70173dc7bab0d4f418c4985f46 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 24 Sep 2026 16:31:48 +0200 Subject: [PATCH 5/5] docs: mention being WebSocket-based only --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cb296d8..166a4df 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The Socket.IO protocol as a WebSocket extension for [`@mswjs/interceptors`](http Socket.IO implements its own protocol on top of WebSocket: a session handshake, a heartbeat, namespaces, acknowledgements, and a packet framing. Without the extension, an intercepted connection exposes the raw frames (e.g. `40`, `42["hello","John"]`), expects you to send them back the same way, and never completes the handshake a Socket.IO client waits for. With the extension, the connection speaks Socket.IO messages, and the session is established for you. -> **Only WebSocket transports reach a WebSocket extension.** Connect the Socket.IO client with `transports: ['websocket']`. A client that starts with HTTP long-polling never gets here. +> **Only Socket.IO clients that use the global `WebSocket` class as the transport are supported.** Socket.IO does not support injecting a transport for testing, so the client must be created with `transports: ['websocket']` (in the browser) or `transports: [WebSocket]` from `engine.io-client` (in Node.js, where the default `'websocket'` transport uses the `ws` package instead). ## Install