diff --git a/README.md b/README.md index 9445ab8..166a4df 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ ## `@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 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, 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, 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 the JSON text of its `[event, ...args]` tuple. +> **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 @@ -14,6 +14,25 @@ An event is represented as the JSON text of its `[event, ...args]` tuple. 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 @@ -22,41 +41,102 @@ 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) - - if (name === 'hello') { - client.send(JSON.stringify(['greeting', `Hello, ${firstName}!`])) + if (event.data.type === 'event' && event.data.event === 'hello') { + const [firstName] = event.data.args + client.send({ event: 'greeting', args: [`Hello, ${firstName}!`] }) } }) }), ] ``` +`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 -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) // { type: 'event', namespace: '/', event: 'greeting', args: ['Hello, John!'] } + }) +}) +``` + +### Acknowledgements + +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.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 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, socket, io }) => { + client.addEventListener('message', (event) => { + if (event.data.type !== 'event') { + return + } + + const [room] = event.data.args + + 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 -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. +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/package.json b/package.json index a85046d..6f35708 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", @@ -48,7 +48,7 @@ }, "devDependencies": { "@epic-web/test-server": "^0.1.6", - "@mswjs/interceptors": "^0.44.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 9ebad1f..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.44.0 - version: 0.44.0 + specifier: ^0.45.1 + version: 0.45.1 '@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.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.44.0': + '@mswjs/interceptors@0.45.1': 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: diff --git a/src/index.ts b/src/index.ts index c6b70c1..e0ef431 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,13 +10,79 @@ 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' -const SESSION_ID = 'test' +/** + * A Socket.IO event. The default namespace ("/") when omitted. + * Carries an `id` when the sender expects an acknowledgement. + */ +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 = '/' /** * @note Advertise a heartbeat the client will never expect within @@ -29,133 +95,609 @@ 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 | 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 { + message: error.message, + data: 'data' in error ? error.data : undefined, + } +} + +/** + * 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 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 SocketIoServer { + readonly #sockets = new Map>() + readonly #rooms = new Map() + + /** + * The given namespace of the server. + */ + 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) + } - if (typeof encodedPacket !== 'string') { - throw new Error('Binary Socket.IO packets are not supported') + return sockets } - return encodeEngineIoPacket({ type: 'message', data: encodedPacket }) + /** @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() + rooms.set(room, members) + } + + return members + } } /** - * The Socket.IO protocol over WebSocket. + * 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) + } + }, + } + } + + /** + * 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, + ) {} + + /** + * 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.rooms.delete(room) + this.server.membersOf(this.namespace, room).delete(this) + } + + /** + * Send an event to every other socket in the given room. + */ + public to(room: string): SocketIoTarget { + const members = this.server.membersOf(this.namespace, room) + + return { + send: (message) => { + for (const member of members) { + 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 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`. * - * 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. + * @note Only WebSocket transports reach a WebSocket extension. + * Clients must connect with `transports: ['websocket']`. * * @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, socket, io }) => { * client.addEventListener('message', (event) => { - * const [name, ...args] = JSON.parse(event.data) + * if (event.data.type === 'event' && event.data.event === 'join') { + * socket.join(String(event.data.args[0])) + * } * }) - * client.send(JSON.stringify(['greeting', 'Hello, John!'])) + * io.to('lobby').send({ event: 'greeting', args: ['Hello, everyone!'] }) * }) */ -export class SocketIo extends WebSocketProtocol { +export class SocketIo extends WebSocketExtension< + SocketIoMessage, + { 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() + readonly #decoders = new WeakMap() + readonly #sockets = new WeakMap() - 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 { - return encodeSocketIoPacket({ - type: PacketType.EVENT, - /** - * @todo Support custom namespaces. - */ - nsp: '/', - data: JSON.parse(message), - }) + public encode(message: SocketIoMessage): Generator { + return encodeSocketIoPacket(toSocketIoPacket(message)) } - public decode( + public *decode( frame: WebSocketData, - { connection }: WebSocketProtocolMessageContext, - ): Iterator | undefined { - // Messages are always decoded as strings. - if (typeof frame !== 'string') { - return - } - - const packet = decodePacket(frame, 'arraybuffer') - - // Ignore the Engine.IO control packets (open, ping, pong, etc). - if (packet.type !== 'message') { - return - } + { connection }: WebSocketExtensionMessageContext, + ): Generator { + for (const packet of decodeSocketIoPackets( + frame, + this.#getDecoder(connection), + )) { + const message = toSocketIoMessage(packet) - 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 (message) { + yield message } } - - decoder.on('decoded', collectEvent) - decoder.add(packet.data) - decoder.off('decoded', collectEvent) - - return events.values() } - public *handshake(): Generator { + public connect({ + client, + }: WebSocketExtensionContext): WebSocketData { // Establish the Engine.IO session. - yield encodeEngineIoPacket({ + return encodeEngineIoPacket({ type: 'open', data: JSON.stringify({ - sid: SESSION_ID, + sid: this.#getSocket(client).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 *receive( + frame: WebSocketData, + { client }: WebSocketExtensionContext, + ): Generator { + if (typeof frame !== 'string') { + return + } + + const engineIoPacket = decodePacket(frame, 'arraybuffer') + + // Engine.IO v3 clients ping the server and expect a pong. + if (engineIoPacket.type === 'ping') { + yield encodeEngineIoPacket({ type: 'pong' }) + return + } + + if (engineIoPacket.type !== 'message') { + return + } + + 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: socket.id }, + }) + } + + if (packet.type === PacketType.DISCONNECT) { + socket.detach(packet.nsp) + } + } + } + + public extend({ client }: WebSocketExtensionContext): { + socket: SocketIoSocket + io: SocketIoServer + } { + 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 c31962b..dc80961 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,45 +41,55 @@ 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) + } }) }) const ws = createSocketClient('wss://example.com') + onTestFinished(() => { + ws.close() + }) ws.emit('hello', 'John') - await expect(outgoingData.promise).resolves.toBe('["hello","John"]') - expect(eventLog, 'exposes no protocol packets').toEqual([ - '["hello","John"]', + await expect(outgoingEvent.promise).resolves.toEqual({ + type: 'event', + 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'] }, ]) }) 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.type === 'event' && 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)) @@ -95,8 +102,8 @@ it('decodes incoming server events', async () => { const wsServer = createSocketIoServer(httpServer) 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) => { @@ -108,11 +115,16 @@ 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) + } }) }) const ws = createSocketClient(getWsUrl(httpServer)) + onTestFinished(() => { + ws.close() + }) ws.emit('hello', 'John') ws.on('greeting', (message) => { incomingClientData.resolve(message) @@ -120,11 +132,16 @@ 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({ + type: 'event', + 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!', @@ -137,8 +154,8 @@ it('modifies incoming server events', async () => { const wsServer = createSocketIoServer(httpServer) 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) => { @@ -150,14 +167,24 @@ it('modifies incoming server events', async () => { server.connect() server.addEventListener('message', (event) => { + if (event.data.type !== 'event') { + return + } + 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 +192,532 @@ 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({ + type: 'event', + 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('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') + + const outgoingData = Promise.withResolvers() + const incomingData = Promise.withResolvers() + + interceptor.on('connection', ({ client }) => { + client.addEventListener('message', (event) => { + if (event.data.type !== 'event') { + return + } + + 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({ + type: 'event', + namespace: '/admin', + event: 'hello', + args: ['John'], + }) + await expect(incomingData.promise).resolves.toBe('Hello from /admin!') +}) + +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, 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.join(room) + } + + if (event.data.event === 'announce' && typeof room === 'string') { + io.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('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, 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.join(room) + } + + if (event.data.event === 'announce' && typeof room === 'string') { + io.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() +}) + +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 8c6acec..8c9fef9 100644 --- a/tests/typings/msw.test-d.ts +++ b/tests/typings/msw.test-d.ts @@ -1,14 +1,38 @@ import { ws } from 'msw' import { setupWorker } from 'msw/browser' -import { SocketIo } from '../../src/index.js' +import { + SocketIo, + SocketIoSocket, + SocketIoServer, + 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, 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() + }) + + // @ts-expect-error Raw frames are never sent by the handler. + client.send('42["hello","John"]') }), ) })