Skip to content

Commit c82818f

Browse files
committed
feat(collaboration): add peer metadata and WebSocket authentication support
- Add peer metadata system to PeerMessagingActor (localMetadata, peerMetadata) - Add setLocalMetadata and updatePeerMetadata actions - Add metadataChanged event for tracking peer metadata updates - Add WebSocketAuthConfig supporting credentials, query params, and auth payload - Add connectionError event for handling auth failures with error data - Clean up metadata automatically when peers disconnect
1 parent 7e3dd80 commit c82818f

File tree

6 files changed

+388
-6
lines changed

6 files changed

+388
-6
lines changed

packages/collaboration/src/PeerMessagingActor.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Actor, action, effect, type IActorClient } from '@d-buckner/ensemble-co
22
import type {
33
PeerMessagingState,
44
PeerMessagingEvents,
5+
PeerMetadata,
56
MessagePayload,
67
SignalingPayload,
78
RoomJoinedPayload,
@@ -20,6 +21,8 @@ export interface PeerMessagingDeps {
2021
export interface PeerMessagingActions {
2122
sendTo(peerId: string, message: Uint8Array): void;
2223
broadcast(message: Uint8Array): void;
24+
setLocalMetadata(metadata: PeerMetadata): void;
25+
updatePeerMetadata(peerId: string, metadata: PeerMetadata): void;
2326
}
2427

2528
/**
@@ -41,6 +44,8 @@ export class PeerMessagingActor extends Actor<PeerMessagingState, PeerMessagingA
4144
static readonly initialState: PeerMessagingState = {
4245
connectedPeers: [],
4346
peerTransports: {},
47+
peerMetadata: {},
48+
localMetadata: null,
4449
};
4550

4651
protected declare deps: PeerMessagingDeps;
@@ -102,6 +107,39 @@ export class PeerMessagingActor extends Actor<PeerMessagingState, PeerMessagingA
102107
}
103108
}
104109

110+
// ========================================
111+
// Actions: Metadata management
112+
// ========================================
113+
114+
/**
115+
* Set metadata for the local user.
116+
* This metadata can be shared with peers when they connect.
117+
*
118+
* @param metadata - Metadata object (displayName, instrument, color, etc.)
119+
*/
120+
@action
121+
setLocalMetadata(metadata: PeerMetadata): void {
122+
this.setState(draft => {
123+
draft.localMetadata = metadata;
124+
});
125+
}
126+
127+
/**
128+
* Update metadata for a specific peer.
129+
* Typically called when receiving metadata updates from the network.
130+
*
131+
* @param peerId - ID of the peer
132+
* @param metadata - Metadata object for the peer
133+
*/
134+
@action
135+
updatePeerMetadata(peerId: string, metadata: PeerMetadata): void {
136+
this.setState(draft => {
137+
draft.peerMetadata[peerId] = metadata;
138+
});
139+
140+
this.emit('metadataChanged', { peerId, metadata });
141+
}
142+
105143
// ========================================
106144
// Effects: WebSocket events
107145
// ========================================
@@ -161,7 +199,7 @@ export class PeerMessagingActor extends Actor<PeerMessagingState, PeerMessagingA
161199
/**
162200
* Handle peer left via WebSocket.
163201
* Removes peer from state and emits peerDisconnected.
164-
* Also disconnects WebRTC connection.
202+
* Also disconnects WebRTC connection and cleans up metadata.
165203
*/
166204
@effect('websocket.peerLeft')
167205
private handleWebSocketPeerLeft(peerId: string): void {
@@ -172,6 +210,7 @@ export class PeerMessagingActor extends Actor<PeerMessagingState, PeerMessagingA
172210
this.setState(draft => {
173211
draft.connectedPeers = draft.connectedPeers.filter(id => id !== peerId);
174212
delete draft.peerTransports[peerId];
213+
delete draft.peerMetadata[peerId];
175214
});
176215

177216
// Disconnect WebRTC

packages/collaboration/src/WebSocketActor.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
WebSocketState,
55
WebSocketEvents,
66
WebSocketConfig,
7+
WebSocketAuthConfig,
78
} from './types';
89
import type { SignalData } from '@d-buckner/peer-pressure';
910

@@ -36,6 +37,7 @@ export class WebSocketActor extends Actor<WebSocketState, WebSocketActions, WebS
3637
roomId: '',
3738
peerId: null,
3839
connectionState: 'disconnected',
40+
authConfig: null,
3941
};
4042

4143
private socket: Socket | null = null;
@@ -51,13 +53,14 @@ export class WebSocketActor extends Actor<WebSocketState, WebSocketActions, WebS
5153
* Initialize the WebSocket connection configuration.
5254
* Must be called before connect().
5355
*
54-
* @param config - Configuration with Socket.IO URL and room ID
56+
* @param config - Configuration with Socket.IO URL, room ID, and optional auth
5557
*/
5658
@action
5759
initialize(config: WebSocketConfig): void {
5860
this.setState(draft => {
5961
draft.url = config.url;
6062
draft.roomId = config.roomId;
63+
draft.authConfig = config.authConfig ?? null;
6164
});
6265
}
6366

@@ -68,6 +71,11 @@ export class WebSocketActor extends Actor<WebSocketState, WebSocketActions, WebS
6871
/**
6972
* Connect to Socket.IO server and join room.
7073
* Server will assign a unique peer ID and return list of existing peers.
74+
*
75+
* Uses auth configuration if provided during initialize():
76+
* - withCredentials: Include cookies for session-based auth
77+
* - query: Custom query parameters (displayName, etc.)
78+
* - auth: Socket.IO auth payload (token-based auth)
7179
*/
7280
@action
7381
connect(): void {
@@ -84,14 +92,33 @@ export class WebSocketActor extends Actor<WebSocketState, WebSocketActions, WebS
8492
});
8593
this.emit('connectionStateChanged', 'connecting');
8694

95+
const authConfig = this.state.authConfig;
8796
this.socket = io(this.state.url, {
8897
autoConnect: false,
98+
withCredentials: authConfig?.withCredentials ?? false,
99+
query: authConfig?.query,
100+
auth: this.buildAuthPayload(authConfig),
89101
});
90102

91103
this.setupSocketListeners();
92104
this.socket.connect();
93105
}
94106

107+
/**
108+
* Build the auth payload for Socket.IO from auth config.
109+
*/
110+
private buildAuthPayload(authConfig: WebSocketAuthConfig | null): Record<string, unknown> | undefined {
111+
if (!authConfig?.auth) {
112+
return undefined;
113+
}
114+
115+
if (typeof authConfig.auth === 'string') {
116+
return { token: authConfig.auth };
117+
}
118+
119+
return authConfig.auth;
120+
}
121+
95122
/**
96123
* Leave the current room and disconnect from server.
97124
*/
@@ -231,12 +258,13 @@ export class WebSocketActor extends Actor<WebSocketState, WebSocketActions, WebS
231258
});
232259

233260
// Error handling
234-
this.socket.on('connect_error', (error: Error) => {
261+
this.socket.on('connect_error', (error: Error & { data?: unknown }) => {
235262
console.error('[WebSocketActor] Connection error:', error.message);
263+
this.emit('connectionError', { message: error.message, data: error.data });
236264
this.throw('Socket.IO connection error', { error: error.message });
237265
});
238266

239-
this.socket.on('error', (error: any) => {
267+
this.socket.on('error', (error: unknown) => {
240268
console.error('[WebSocketActor] Server error:', error);
241269
});
242270
}

packages/collaboration/src/__tests__/PeerMessagingActor.test.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ActorSystem, createActorToken } from '@d-buckner/ensemble-core';
2-
import { describe, it, expect, beforeEach } from 'vitest';
2+
import { describe, it, expect, beforeEach, vi } from 'vitest';
33
import { PeerMessagingActor } from '../PeerMessagingActor';
44
import { WebRTCActor } from '../WebRTCActor';
55
import { WebSocketActor } from '../WebSocketActor';
@@ -173,4 +173,85 @@ describe('PeerMessagingActor', () => {
173173
});
174174
});
175175

176+
describe('Peer Metadata', () => {
177+
it('should start with empty peerMetadata and null localMetadata', () => {
178+
const client = system.getClient(PeerMessagingToken)!;
179+
180+
expect(client.state.peerMetadata).toEqual({});
181+
expect(client.state.localMetadata).toBeNull();
182+
});
183+
184+
it('should set local metadata', async () => {
185+
const client = system.getClient(PeerMessagingToken)!;
186+
187+
client.actions.setLocalMetadata({ displayName: 'TestUser', instrument: 'piano' });
188+
await flushMicrotask();
189+
190+
expect(client.state.localMetadata).toEqual({
191+
displayName: 'TestUser',
192+
instrument: 'piano',
193+
});
194+
});
195+
196+
it('should update local metadata with new values', async () => {
197+
const client = system.getClient(PeerMessagingToken)!;
198+
199+
client.actions.setLocalMetadata({ displayName: 'User1' });
200+
await flushMicrotask();
201+
202+
client.actions.setLocalMetadata({ displayName: 'User2', color: 'blue' });
203+
await flushMicrotask();
204+
205+
expect(client.state.localMetadata).toEqual({
206+
displayName: 'User2',
207+
color: 'blue',
208+
});
209+
});
210+
211+
it('should update peer metadata', async () => {
212+
const client = system.getClient(PeerMessagingToken)!;
213+
214+
client.actions.updatePeerMetadata('peer-123', { displayName: 'RemoteUser', instrument: 'synth' });
215+
await flushMicrotask();
216+
217+
expect(client.state.peerMetadata['peer-123']).toEqual({
218+
displayName: 'RemoteUser',
219+
instrument: 'synth',
220+
});
221+
});
222+
223+
it('should update metadata for multiple peers', async () => {
224+
const client = system.getClient(PeerMessagingToken)!;
225+
226+
client.actions.updatePeerMetadata('peer-1', { displayName: 'Alice' });
227+
client.actions.updatePeerMetadata('peer-2', { displayName: 'Bob' });
228+
await flushMicrotask();
229+
230+
expect(client.state.peerMetadata['peer-1']).toEqual({ displayName: 'Alice' });
231+
expect(client.state.peerMetadata['peer-2']).toEqual({ displayName: 'Bob' });
232+
});
233+
234+
it('should emit metadataChanged event when updating peer metadata', async () => {
235+
const client = system.getClient(PeerMessagingToken)!;
236+
const metadataHandler = vi.fn();
237+
238+
client.on('metadataChanged', metadataHandler);
239+
240+
client.actions.updatePeerMetadata('peer-123', { displayName: 'TestUser' });
241+
await flushMicrotask();
242+
243+
expect(metadataHandler).toHaveBeenCalledWith({
244+
peerId: 'peer-123',
245+
metadata: { displayName: 'TestUser' },
246+
});
247+
});
248+
249+
it('should handle empty metadata object', async () => {
250+
const client = system.getClient(PeerMessagingToken)!;
251+
252+
expect(() => client.actions.setLocalMetadata({})).not.toThrow();
253+
expect(() => client.actions.updatePeerMetadata('peer-1', {})).not.toThrow();
254+
});
255+
});
256+
176257
});

0 commit comments

Comments
 (0)