-
Notifications
You must be signed in to change notification settings - Fork 4
Feature: Support for namespaces #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
de00460
6a7c762
d4c0883
e5f469d
4ef54bf
788c6b3
5900917
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,9 +30,42 @@ interceptor.on('connection', (connection) => { | |
|
|
||
| > You can also use this package with [Mock Service Worker](https://github.com/mswjs/msw) directly. | ||
|
|
||
|
|
||
| ### Namespaces | ||
|
|
||
| Every message delivered through the wrapper carries the namespace that it was sent on. Access it via `event.socketio.namespace` to make assertions or branch per namespace during tests: | ||
|
|
||
| ```js | ||
| io.client.on('power_on', (event, deviceId) => { | ||
| if (event.socketio.namespace === '/bedroom/ceiling_fan') { | ||
| console.log('fan turned on:', deviceId) | ||
| } | ||
| }) | ||
| ``` | ||
|
|
||
| When mocking outgoing traffic with `emit()`, you can pass an object in the first argument to specify the namespace in addition to the event name: | ||
|
|
||
| ```js | ||
| io.client.emit({ event: 'power_on', namespace: '/bedroom/ceiling_fan' }) | ||
| ``` | ||
|
|
||
| By default, connections on all namespaces are accepted when a mocked server. This can be customized by setting a custom authorizer: | ||
|
|
||
| ```js | ||
| interceptor.on('connection', (connection) => { | ||
| const io = toSocketIo(connection) | ||
| io.setAuthorizer((namespace) => { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On a Socket.IO server you have The mock needs to respond to the client's If you removed the overridable authorizer behavior, you could accept connections on any namespace, but then you cannot fully mock the server in some situations—such as confirming the isolation of per-user namespaces, or requiring a JWT token before the backend starts streaming sensitive data, etc.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's some complexity mimicking Socket.IO's API. How they intend it is: // deny all connections on all namespaces
io.on("new_namespace", (namespace) => {
namespace.use((socket, next) => {
next(new Error("Not authorized"))
})
})The middleware callback does not get passed the namespace name at invocation time, and it's not an attribute of the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Another option I considered is |
||
| return (namespace !== '/forbidden') | ||
| }) | ||
| }) | ||
| ``` | ||
|
|
||
| Note that multiple Socket.IO namespaces will use a single underlying WebSocket, so this connection handler will only run once even if multiple Socket.IO sockets are created. | ||
|
|
||
|
|
||
| ## Limitations | ||
|
|
||
| This wrapper is not meant to provide full feature parity with the Socket.IO client API. Some features may be missing (like rooms, namespaces, broadcasting). If you rely of any of the missing features, open a pull request and implement it. Thank you. | ||
| This wrapper is not meant to provide full feature parity with the Socket.IO client API. Some features may be missing (like rooms or broadcasting). If you rely of any of the missing features, open a pull request and implement it. Thank you. | ||
|
|
||
| > Note that feature parity only concerns the _connection wrapper_. You can still use the entire of the Socket.IO feature set in the actual application code. | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -8,6 +8,7 @@ import { | |||||
| Encoder, | ||||||
| Decoder, | ||||||
| PacketType as SocketIoPacketType, | ||||||
| type Packet as SocketIoPacket, | ||||||
| } from 'socket.io-parser' | ||||||
| import type { WebSocketHandlerConnection } from 'msw' | ||||||
| import type { | ||||||
|
|
@@ -18,7 +19,48 @@ import type { | |||||
| const encoder = new Encoder() | ||||||
| const decoder = new Decoder() | ||||||
|
|
||||||
| type BoundMessageListener = (event: MessageEvent, ...data: Array<any>) => void | ||||||
| interface SocketIoMessageDetails { | ||||||
| namespace: string | ||||||
| } | ||||||
|
|
||||||
| interface SocketIoMessageEvent<T = any> extends MessageEvent<T> { | ||||||
| socketio: SocketIoMessageDetails | ||||||
| } | ||||||
|
|
||||||
| type BoundMessageListener = ( | ||||||
| event: SocketIoMessageEvent, | ||||||
| ...data: Array<any> | ||||||
| ) => void | ||||||
| type ConnectionAuthorizer = ( | ||||||
| namespace: string, | ||||||
| auth: Record<string, unknown>, | ||||||
| ) => boolean | Promise<boolean> | ||||||
| type EventEnvelope = { | ||||||
| event: string | ||||||
| namespace?: string | ||||||
| } | ||||||
|
|
||||||
| function createSocketIoMessageEvent( | ||||||
| event: MessageEvent, | ||||||
| details: SocketIoMessageDetails, | ||||||
| ): SocketIoMessageEvent { | ||||||
| return new Proxy(event, { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What do you think if we instead created a subclass of class SocketIoMessageEvent extends Message Event {
public namespace: string
constructor(name, init: { namespace }) {
this.namespace = namespace
}
}
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This part of the implementation was suggested by a coding agent 😅 Since the I definitely defer to someone more experienced in how best to do this. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The event instance should also carry |
||||||
| get(target, property, receiver) { | ||||||
| if (property === 'socketio') { | ||||||
| return details | ||||||
| } | ||||||
|
|
||||||
| return Reflect.get(target, property, receiver) | ||||||
| }, | ||||||
| has(target, property) { | ||||||
| if (property === 'socketio') { | ||||||
| return true | ||||||
| } | ||||||
|
|
||||||
| return Reflect.has(target, property) | ||||||
| }, | ||||||
| }) as SocketIoMessageEvent | ||||||
| } | ||||||
|
|
||||||
| class SocketIoConnection { | ||||||
| constructor( | ||||||
|
|
@@ -27,7 +69,9 @@ class SocketIoConnection { | |||||
| | WebSocketServerConnectionProtocol, | ||||||
| ) {} | ||||||
|
|
||||||
| public on(event: string, listener: BoundMessageListener): void { | ||||||
| public _onSocketIoPacket( | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If this is meant as an internal method, I recommend using
Suggested change
The method could then be used as
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I struggled with this because We could move the connection acceptance logic to |
||||||
| callback: (messageEvent: MessageEvent, packet: SocketIoPacket) => void, | ||||||
| ): void { | ||||||
| const addEventListener = this.connection.addEventListener.bind( | ||||||
| this.connection, | ||||||
| ) as WebSocketClientConnectionProtocol['addEventListener'] | ||||||
|
|
@@ -62,33 +106,51 @@ class SocketIoConnection { | |||||
|
|
||||||
| for (const packet of engineIoPackets) { | ||||||
| decoder.once('decoded', (decodedSocketIoPacket) => { | ||||||
| /** | ||||||
| * @note Ignore any non-event messages. | ||||||
| * To forward all Socket.IO messages one must listen | ||||||
| * to the raw outgoing client events: | ||||||
| * client.on('message', (event) => server.send(event.data)) | ||||||
| */ | ||||||
| if (decodedSocketIoPacket.type !== SocketIoPacketType.EVENT) { | ||||||
| return | ||||||
| } | ||||||
|
|
||||||
| const [sentEvent, ...data] = decodedSocketIoPacket.data | ||||||
|
|
||||||
| if (sentEvent === event) { | ||||||
| listener.call(undefined, messageEvent, ...data) | ||||||
| } | ||||||
| callback(messageEvent, decodedSocketIoPacket) | ||||||
| }) | ||||||
|
|
||||||
| decoder.add(packet.data) | ||||||
| } | ||||||
| }) | ||||||
| } | ||||||
|
|
||||||
| public on(event: string, listener: BoundMessageListener): void { | ||||||
| this._onSocketIoPacket((messageEvent, decodedSocketIoPacket) => { | ||||||
| /** | ||||||
| * @note Ignore any non-event messages. | ||||||
| * To forward all Socket.IO messages one must listen | ||||||
| * to the raw outgoing client events: | ||||||
| * client.on('message', (event) => server.send(event.data)) | ||||||
| */ | ||||||
| if (decodedSocketIoPacket.type !== SocketIoPacketType.EVENT) { | ||||||
| return | ||||||
| } | ||||||
|
|
||||||
| const [sentEvent, ...data] = decodedSocketIoPacket.data | ||||||
|
|
||||||
| if (sentEvent === event) { | ||||||
| // Create a proxy wrapper around the original MessageEvent object, | ||||||
| // adding a `socketio` property with our namespace details. | ||||||
| const extendedEvent = createSocketIoMessageEvent(messageEvent, { | ||||||
| namespace: decodedSocketIoPacket.nsp, | ||||||
| }) | ||||||
|
|
||||||
| listener.call(undefined, extendedEvent, ...data) | ||||||
| } | ||||||
| }) | ||||||
| } | ||||||
|
|
||||||
| public send(...data: Array<any>): void { | ||||||
| this.emit('message', ...data) | ||||||
| } | ||||||
|
|
||||||
| public emit(event: string, ...data: Array<any>): void { | ||||||
| public emit(event: string, ...data: Array<any>): void | ||||||
| public emit(envelope: EventEnvelope, ...data: Array<any>): void | ||||||
| public emit(envelope: string | EventEnvelope, ...data: Array<any>): void { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When designing this API, we really have to make sure we're mimicking how namespace-bound events are emitted in SocketIO normally. The closer we are, the better DX we would provide to developers mocking such connections. |
||||||
| const event = typeof envelope === 'string' ? envelope : envelope.event | ||||||
| const namespace = | ||||||
| typeof envelope === 'string' ? '/' : envelope.namespace ?? '/' | ||||||
|
|
||||||
| /** | ||||||
| * @todo Check if this correctly encodes Blob | ||||||
| * and ArrayBuffer data. | ||||||
|
|
@@ -98,7 +160,7 @@ class SocketIoConnection { | |||||
| /** | ||||||
| * @todo Support custom namespaces. | ||||||
| */ | ||||||
| nsp: '/', | ||||||
| nsp: namespace, | ||||||
| data: [event].concat(data), | ||||||
| }) | ||||||
|
|
||||||
|
|
@@ -124,35 +186,110 @@ class SocketIoDuplexConnection { | |||||
| public client: SocketIoConnection | ||||||
| public server: SocketIoConnection | ||||||
|
|
||||||
| private hasAuthorizer = false | ||||||
|
|
||||||
| constructor( | ||||||
| readonly rawClient: WebSocketClientConnectionProtocol, | ||||||
| readonly rawServer: WebSocketServerConnectionProtocol, | ||||||
| ) { | ||||||
| queueMicrotask(() => { | ||||||
| try { | ||||||
| // Accessing the "socket" property on the server | ||||||
| // throws if the actual server connection hasn't been established. | ||||||
| // If it doesn't throw, don't mock the namespace approval message. | ||||||
| // That becomes the responsibility of the server. | ||||||
| Reflect.get(this.rawServer, 'socket').readyState | ||||||
| return | ||||||
| } catch { | ||||||
| this.rawClient.send( | ||||||
| '0' + | ||||||
| JSON.stringify({ | ||||||
| sid: 'test', | ||||||
| upgrades: [], | ||||||
| pingInterval: 25000, | ||||||
| pingTimeout: 5000, | ||||||
| }), | ||||||
| ) | ||||||
| this.rawClient.send('40' + JSON.stringify({ sid: 'test' })) | ||||||
| // If the actual server connection hasn't been established yet, send | ||||||
| // a mock Engine.IO handshake. | ||||||
| if (!this.hasUpstreamServer()) { | ||||||
| // Set a default authorizer that always allows connections. | ||||||
| if (!this.hasAuthorizer) { | ||||||
| this.setAuthorizer(() => true) | ||||||
| } | ||||||
|
|
||||||
| this.sendMockEngineIoOpen() | ||||||
| } | ||||||
| }) | ||||||
|
|
||||||
| this.client = new SocketIoConnection(this.rawClient) | ||||||
| this.server = new SocketIoConnection(this.rawServer) | ||||||
| } | ||||||
|
|
||||||
| private hasUpstreamServer(): boolean { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A nitpick: we're only using this method once. I'd probably vote for keeping the previous inline implementation unless we heavily rely on the upstream server checks.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I factored it out while attempting to implement a feature where the mock authorizer function could allow the connection through, which would be forwarded to the actual server, and then the actual server would respond to the It’s clearer to read as a simple, expressive predicate So this particular check is especially well-suited to being pulled into a dedicated predicate function, which isolates all these elements of complexity and makes the logic of the call site more straightforward to understand. |
||||||
| try { | ||||||
| // Accessing the "socket" property on the server throws if the actual | ||||||
| // server connection hasn't been established. | ||||||
| Reflect.get(this.rawServer, 'socket').readyState | ||||||
| return true | ||||||
| } catch { | ||||||
| return false | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| public setAuthorizer(authorizer: ConnectionAuthorizer): void { | ||||||
| this.client._onSocketIoPacket((_event, packet) => { | ||||||
| if (packet.type !== SocketIoPacketType.CONNECT) { | ||||||
| return | ||||||
| } | ||||||
|
|
||||||
| Promise.resolve(authorizer(packet.nsp, packet.data)).then((allowed) => { | ||||||
| // Allow the authorizer to bounce connections before the actual server | ||||||
| // even sees it. | ||||||
| if (!allowed) { | ||||||
| this.sendMockSocketIoConnectError(packet.nsp, 'Not authorized') | ||||||
| return | ||||||
| } | ||||||
|
|
||||||
| this.sendMockSocketIoConnect(packet.nsp) | ||||||
| }) | ||||||
| }) | ||||||
|
|
||||||
| this.hasAuthorizer = true | ||||||
| } | ||||||
|
|
||||||
| private sendMockEngineIoOpen(): void { | ||||||
| const openPacket: EngineIoPacket = { | ||||||
| type: 'open', | ||||||
| data: JSON.stringify({ | ||||||
| sid: 'test', | ||||||
| upgrades: [], | ||||||
| pingInterval: 25000, | ||||||
| pingTimeout: 5000, | ||||||
| }), | ||||||
| } | ||||||
|
|
||||||
| encodePayload([openPacket], (encodedPayload) => { | ||||||
| this.rawClient.send(encodedPayload) | ||||||
| }) | ||||||
| } | ||||||
|
|
||||||
| private sendMockSocketIoConnect(namespace: string): void { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't believe these should be methods. Instead, we can make them into simple functions that produce respective messages and then pass them to this.rawClient.send(messages.connect())
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How do you envision the encodePayload([openPacket], (encodedPayload) => {
this.rawClient.send(encodedPayload)
})Are you thinking we would write: MessageBuilder.connect(namespace, (encodedPayload) => {
this.rawClient.send(encodedPayload)
}) |
||||||
| this.sendSocketIoPacket({ | ||||||
| type: SocketIoPacketType.CONNECT, | ||||||
| nsp: namespace, | ||||||
| data: { sid: 'test' }, | ||||||
| }) | ||||||
| } | ||||||
|
|
||||||
| private sendMockSocketIoConnectError( | ||||||
| namespace: string, | ||||||
| message: string, | ||||||
| ): void { | ||||||
| this.sendSocketIoPacket({ | ||||||
| type: SocketIoPacketType.CONNECT_ERROR, | ||||||
| nsp: namespace, | ||||||
| data: { message }, | ||||||
| }) | ||||||
| } | ||||||
|
|
||||||
| private sendSocketIoPacket(packet: SocketIoPacket): void { | ||||||
| const socketIoPackets = encoder.encode(packet) | ||||||
|
|
||||||
| const engineIoPackets = socketIoPackets.map<EngineIoPacket>((encoded) => { | ||||||
| return { | ||||||
| type: 'message', | ||||||
| data: encoded, | ||||||
| } | ||||||
| }) | ||||||
|
|
||||||
| encodePayload(engineIoPackets, (encodedPayload) => { | ||||||
| this.rawClient.send(encodedPayload) | ||||||
| }) | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,5 +10,5 @@ import { Socket } from 'socket.io-client' | |
| import { io } from 'socket.io-client/dist/socket.io.js' | ||
|
|
||
| export function createSocketClient(uri: string): Socket { | ||
| return io(uri, { transports: ['websocket'] }) | ||
| return io(uri, { transports: ['websocket'], forceNew: true }) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What does There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I could add a comment to this effect if desired. |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this how namespace-bound events are also dispatched in SocketIO normally? We should try to mimic that API to benefit from developer familiarity.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the Socket.IO Client API, you simply connect to
scheme://host[:port][/namespace]and then emit to the returned socket object like any other. I don't think we can mimic that.In Server API, there is
server.of(). You would writeio.client.of('/bedroom/ceiling_fan').emit('power_on').If we did add an
SocketIoConnection.of()method that returned someNamespaceinstance withon()andemit()methods, it could lead to messy code because this object is now divorced from theclientorserverlabel:nsp.emit('foo')no longer tells you which direction the event is being emitted.Personally I would think it's not important to mimic the Socket.IO API. The binding is already inconsistent with Socket.IO in how
.on()and.emit()behave—e.g., the.on()callback receives different arguments, and.emit()works in the reverse direction. Using the same names with different semantics actually increases developer friction. Socket.IO's Server and Client APIs also aren't always identical so for each feature there would be an arbitrary decision of which API to mimic..of()is just a terrible method name anyway. It doesn't seem to relate to the concept of namespaces at all, and the sequence.of().emit()is meaningless to the reader as a phrase.