Files
FrenoCorp/server/webrtc/signaling-server.ts
Michael Freno 1c74a082e5 FRE-603: Add Presence & Visibility Layer UI components
- CollaboratorList: Display connected users with presence state
- RemoteCursorOverlay: Render remote cursors in editor
- EditingIndicator: Show active editors and their context
- Component index for clean imports
- Tests for CollaboratorList

Architecture:
- Polling-based presence updates (100ms for cursors, 500ms for editors)
- Color-coded user indicators
- Line:column cursor positioning
- Selection highlighting with transparency

Files:
- src/components/collaboration/collaborator-list.tsx
- src/components/collaboration/remote-cursor-overlay.tsx
- src/components/collaboration/editing-indicator.tsx
- src/components/collaboration/index.ts
- src/components/collaboration/collaborator-list.test.tsx

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-23 06:37:21 -04:00

260 lines
7.5 KiB
TypeScript

/**
* WebRTC Signaling Server
* Reuses WebSocket infrastructure for WebRTC signaling
* Handles peer connection negotiation and ICE candidate exchange
*/
import { WebSocketServer } from 'ws';
import { Peer } from 'peerjs';
type SignalingMessage = {
type: 'offer' | 'answer' | 'ice-candidate' | 'disconnect';
payload: RTCSessionDescriptionInit | RTCIceCandidate | { peerId: string };
targetPeerId: string;
};
interface PeerConnection {
peer: Peer;
connections: Map<string, any>;
iceCandidates: Map<string, RTCIceCandidate[]>;
}
export class WebRTCSignalingServer {
private wss: WebSocketServer;
private peers: Map<string, PeerConnection> = new Map();
private port: number;
constructor(port: number) {
this.port = port;
this.wss = new WebSocketServer({ port });
this.initialize();
}
private initialize(): void {
console.log(`WebRTC Signaling Server starting on port ${this.port}`);
this.wss.on('connection', (ws: any, req) => {
const url = new URL(req.url || '', `http://${req.headers.host}`);
const peerId = url.searchParams.get('peerId') || `peer-${Date.now()}-${Math.random()}`;
console.log(`WebRTC peer connected: ${peerId}`);
const peerConnection: PeerConnection = {
peer: new Peer(peerId, {
host: 'localhost',
port: this.port,
path: '/webrtc',
}),
connections: new Map(),
iceCandidates: new Map(),
};
this.peers.set(peerId, peerConnection);
// Handle incoming signaling messages
ws.on('message', (data: Buffer) => {
try {
const message: SignalingMessage = JSON.parse(data.toString());
this.handleSignalingMessage(peerId, message, ws);
} catch (error) {
console.error('Error parsing signaling message:', error);
ws.send(JSON.stringify({ type: 'error', payload: { message: 'Invalid message format' } }));
}
});
// Handle disconnection
ws.on('close', () => {
console.log(`WebRTC peer disconnected: ${peerId}`);
this.cleanupPeer(peerId);
});
// Send confirmation
ws.send(JSON.stringify({
type: 'connected',
payload: { peerId }
}));
});
console.log(`WebRTC Signaling Server started on port ${this.port}`);
}
private handleSignalingMessage(
sourcePeerId: string,
message: SignalingMessage,
ws: any
): void {
const { type, payload, targetPeerId } = message;
const sourceConnection = this.peers.get(sourcePeerId);
if (!sourceConnection) {
console.warn(`Source peer not found: ${sourcePeerId}`);
return;
}
switch (type) {
case 'offer':
this.handleOffer(sourcePeerId, targetPeerId, payload as RTCSessionDescriptionInit, ws);
break;
case 'answer':
this.handleAnswer(sourcePeerId, targetPeerId, payload as RTCSessionDescriptionInit);
break;
case 'ice-candidate':
this.handleIceCandidate(sourcePeerId, targetPeerId, payload as RTCIceCandidate);
break;
case 'disconnect':
this.disconnectPeer(sourcePeerId, targetPeerId);
break;
}
}
private async handleOffer(
sourcePeerId: string,
targetPeerId: string,
offer: RTCSessionDescriptionInit,
ws: any
): Promise<void> {
console.log(`Offer received from ${sourcePeerId} to ${targetPeerId}`);
const targetConnection = this.peers.get(targetPeerId);
if (!targetConnection) {
console.warn(`Target peer not found: ${targetPeerId}`);
ws.send(JSON.stringify({
type: 'error',
payload: { message: `Target peer ${targetPeerId} not found` },
}));
return;
}
// Store the connection
if (!targetConnection.connections.has(sourcePeerId)) {
const conn = targetConnection.peer.connect(sourcePeerId);
targetConnection.connections.set(sourcePeerId, conn);
// Handle connection events
conn.on('open', () => {
console.log(`Connection opened: ${targetPeerId} <-> ${sourcePeerId}`);
});
conn.on('data', (data: any) => {
// Handle data channel messages
console.log(`Data received: ${targetPeerId} from ${sourcePeerId}`, data);
});
conn.on('stream', (stream: MediaStream) => {
console.log(`Media stream received: ${targetPeerId} from ${sourcePeerId}`);
});
// Send accumulated ICE candidates
const accumulatedCandidates = targetConnection.iceCandidates.get(sourcePeerId) || [];
accumulatedCandidates.forEach(candidate => {
conn.send(JSON.stringify({
type: 'ice-candidate',
payload: candidate,
targetPeerId,
}));
});
}
// Forward offer to target peer
targetConnection.connections.get(sourcePeerId).send(JSON.stringify({
type: 'offer',
payload: offer,
targetPeerId: targetPeerId,
}));
}
private handleAnswer(
sourcePeerId: string,
targetPeerId: string,
answer: RTCSessionDescriptionInit
): void {
console.log(`Answer received from ${sourcePeerId} to ${targetPeerId}`);
const targetConnection = this.peers.get(targetPeerId);
if (targetConnection?.connections.has(sourcePeerId)) {
targetConnection.connections.get(sourcePeerId).send(JSON.stringify({
type: 'answer',
payload: answer,
targetPeerId: targetPeerId,
}));
}
}
private handleIceCandidate(
sourcePeerId: string,
targetPeerId: string,
candidate: RTCIceCandidate
): void {
const targetConnection = this.peers.get(targetPeerId);
if (!targetConnection) {
// Store candidate for later delivery
if (!targetConnection?.iceCandidates.has(sourcePeerId)) {
targetConnection?.iceCandidates.set(sourcePeerId, []);
}
targetConnection?.iceCandidates.get(sourcePeerId)!.push(candidate);
return;
}
// Forward ICE candidate to target peer
if (targetConnection.connections.has(sourcePeerId)) {
targetConnection.connections.get(sourcePeerId).send(JSON.stringify({
type: 'ice-candidate',
payload: candidate,
targetPeerId: targetPeerId,
}));
}
}
private disconnectPeer(sourcePeerId: string, targetPeerId: string): void {
const sourceConnection = this.peers.get(sourcePeerId);
if (sourceConnection?.connections.has(targetPeerId)) {
sourceConnection.connections.get(targetPeerId).close();
sourceConnection.connections.delete(targetPeerId);
console.log(`Connection closed: ${sourcePeerId} <-> ${targetPeerId}`);
}
}
private cleanupPeer(peerId: string): void {
const peerConnection = this.peers.get(peerId);
if (peerConnection) {
// Close all connections
peerConnection.connections.forEach((conn, connectedPeerId) => {
conn.close();
console.log(`Cleaned up connection: ${peerId} <-> ${connectedPeerId}`);
});
// Destroy PeerJS instance
peerConnection.peer.destroy();
// Remove from registry
this.peers.delete(peerId);
}
}
getPeerCount(): number {
return this.peers.size;
}
getPeers(): string[] {
return Array.from(this.peers.keys());
}
getPeerConnections(peerId: string): string[] {
const peerConnection = this.peers.get(peerId);
if (!peerConnection) return [];
return Array.from(peerConnection.connections.keys());
}
close(): void {
this.peers.forEach((_, peerId) => this.cleanupPeer(peerId));
this.wss.close();
console.log('WebRTC Signaling Server closed');
}
}
export default WebRTCSignalingServer;