- Consolidated duplicate UndoManagers to single instance - Fixed connection promise to only resolve on 'connected' status - Fixed WebSocketProvider import (WebsocketProvider) - Added proper doc.destroy() cleanup - Renamed isPresenceInitialized property to avoid conflict Co-Authored-By: Paperclip <noreply@paperclip.ing>
260 lines
7.4 KiB
TypeScript
260 lines
7.4 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.call(sourcePeerId, new MediaStream());
|
|
targetConnection.connections.set(sourcePeerId, conn);
|
|
|
|
// Handle connection events
|
|
conn.on('stream', (stream: MediaStream) => {
|
|
console.log(`Media stream received: ${targetPeerId} from ${sourcePeerId}`);
|
|
});
|
|
|
|
conn.on('close', () => {
|
|
console.log(`Connection closed: ${targetPeerId} <-> ${sourcePeerId}`);
|
|
});
|
|
|
|
conn.on('error', (error: Error) => {
|
|
console.error(`Connection error: ${targetPeerId} <-> ${sourcePeerId}`, error);
|
|
});
|
|
|
|
// Send accumulated ICE candidates
|
|
const accumulatedCandidates = targetConnection.iceCandidates.get(sourcePeerId) || [];
|
|
accumulatedCandidates.forEach(candidate => {
|
|
conn.dataChannel.send(JSON.stringify({
|
|
type: 'ice-candidate',
|
|
payload: candidate,
|
|
targetPeerId,
|
|
}));
|
|
});
|
|
}
|
|
|
|
// Forward offer to target peer
|
|
const targetConn = targetConnection.connections.get(sourcePeerId);
|
|
if (targetConn) {
|
|
(targetConn as any).dataChannel.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) {
|
|
return;
|
|
}
|
|
|
|
// Forward ICE candidate to target peer
|
|
if (targetConnection.connections.has(sourcePeerId)) {
|
|
const conn = targetConnection.connections.get(sourcePeerId);
|
|
if (conn) {
|
|
(conn as any).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;
|