Spaces:
Running
Running
feat(sync): replace polling-based WebRTC with high-performance unified SignalR-bridge UDP WebRTC sync
e44f847 | import { useEffect, useRef, useState } from 'react'; | |
| import * as signalR from '@microsoft/signalr'; | |
| export const useSignalR = (projectId, user, onRemoteMessage) => { | |
| const [peers, setPeers] = useState({}); | |
| const [activeUsers, setActiveUsers] = useState([]); | |
| const connectionRef = useRef(null); | |
| const peerConnectionsRef = useRef({}); // userId -> RTCPeerConnection | |
| const dataChannelsRef = useRef({}); // userId -> RTCDataChannel | |
| const pendingCandidatesRef = useRef({}); // userId -> RTCIceCandidate[] | |
| const activeUsersRef = useRef([]); | |
| const onRemoteMessageRef = useRef(onRemoteMessage); | |
| useEffect(() => { | |
| onRemoteMessageRef.current = onRemoteMessage; | |
| }, [onRemoteMessage]); | |
| const API_URL = import.meta.env.VITE_API_URL; | |
| const token = localStorage.getItem('token') || ''; | |
| // Standard RTC Configuration with public Google STUN and openrelay TURN servers for robust NAT traversing | |
| const rtcConfig = { | |
| iceServers: [ | |
| { urls: 'stun:stun.l.google.com:19302' }, | |
| { urls: 'stun:stun1.l.google.com:19302' }, | |
| { urls: 'stun:stun2.l.google.com:19302' }, | |
| { urls: 'stun:stun3.l.google.com:19302' }, | |
| { urls: 'stun:stun4.l.google.com:19302' }, | |
| { urls: 'stun:openrelay.metered.ca:80' }, | |
| { | |
| urls: 'turn:openrelay.metered.ca:80', | |
| username: 'openrelayproject', | |
| credential: 'openrelayproject' | |
| }, | |
| { | |
| urls: 'turn:openrelay.metered.ca:443', | |
| username: 'openrelayproject', | |
| credential: 'openrelayproject' | |
| }, | |
| { | |
| urls: 'turn:openrelay.metered.ca:443?transport=tcp', | |
| username: 'openrelayproject', | |
| credential: 'openrelayproject' | |
| } | |
| ], | |
| iceCandidatePoolSize: 10 | |
| }; | |
| // Clean Up Specific Peer Connection | |
| const cleanupPeer = (peerId) => { | |
| if (dataChannelsRef.current[peerId]) { | |
| try { dataChannelsRef.current[peerId].close(); } catch (e) {} | |
| delete dataChannelsRef.current[peerId]; | |
| } | |
| if (peerConnectionsRef.current[peerId]) { | |
| try { peerConnectionsRef.current[peerId].close(); } catch (e) {} | |
| delete peerConnectionsRef.current[peerId]; | |
| } | |
| setPeers(prev => { | |
| const updated = { ...prev }; | |
| delete updated[peerId]; | |
| return updated; | |
| }); | |
| }; | |
| // Send WebRTC Signaling Packet to Peer via SignalR BoardHub | |
| const sendSignal = async (connection, receiverId, type, data) => { | |
| if (!connection || connection.state !== signalR.HubConnectionState.Connected) return; | |
| try { | |
| await connection.invoke('SendSignal', projectId, receiverId, type, JSON.stringify(data)); | |
| } catch (err) { | |
| console.error(`[WebRTC] Failed to send signal ${type} to ${receiverId}`, err); | |
| } | |
| }; | |
| // Setup Local RTCPeerConnection Event Handlers | |
| const createPeerConnection = (connection, peerId, info) => { | |
| if (peerConnectionsRef.current[peerId]) { | |
| return peerConnectionsRef.current[peerId]; | |
| } | |
| console.log(`[WebRTC] Creating RTCPeerConnection for peer ${peerId}`); | |
| const pc = new RTCPeerConnection(rtcConfig); | |
| peerConnectionsRef.current[peerId] = pc; | |
| // Handle ICE Candidates | |
| pc.onicecandidate = (event) => { | |
| if (event.candidate) { | |
| sendSignal(connection, peerId, 'candidate', event.candidate); | |
| } | |
| }; | |
| // Monitor Connection State | |
| pc.onconnectionstatechange = () => { | |
| console.log(`[WebRTC] Connection state with peer ${peerId}: ${pc.connectionState}`); | |
| if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') { | |
| cleanupPeer(peerId); | |
| } else { | |
| setPeers(prev => ({ | |
| ...prev, | |
| [peerId]: { | |
| ...prev[peerId], | |
| connectionState: pc.connectionState | |
| } | |
| })); | |
| } | |
| }; | |
| // Update state with new peer | |
| setPeers(prev => ({ | |
| ...prev, | |
| [peerId]: { | |
| userId: peerId, | |
| name: info?.name || 'Collaborator', | |
| email: info?.email || '', | |
| connectionState: pc.connectionState, | |
| channelOpen: false | |
| } | |
| })); | |
| return pc; | |
| }; | |
| // Configure Data Channel Events | |
| const setupDataChannel = (peerId, channel) => { | |
| dataChannelsRef.current[peerId] = channel; | |
| channel.onopen = () => { | |
| console.log(`[WebRTC] RTC Data Channel opened with peer ${peerId}`); | |
| setPeers(prev => ({ | |
| ...prev, | |
| [peerId]: { | |
| ...prev[peerId], | |
| channelOpen: true | |
| } | |
| })); | |
| }; | |
| channel.onclose = () => { | |
| console.log(`[WebRTC] RTC Data Channel closed with peer ${peerId}`); | |
| setPeers(prev => ({ | |
| ...prev, | |
| [peerId]: { | |
| ...prev[peerId], | |
| channelOpen: false | |
| } | |
| })); | |
| }; | |
| channel.onerror = (err) => { | |
| console.error(`[WebRTC] RTC Data Channel error with peer ${peerId}`, err); | |
| }; | |
| channel.onmessage = (event) => { | |
| try { | |
| const message = JSON.parse(event.data); | |
| if (onRemoteMessageRef.current) { | |
| onRemoteMessageRef.current(peerId, message); | |
| } | |
| } catch (e) { | |
| console.error('[WebRTC] Failed to parse incoming message', e); | |
| } | |
| }; | |
| }; | |
| useEffect(() => { | |
| if (!projectId || !user || !user.id || !token) return; | |
| const hubUrl = API_URL.replace(/\/api$/, '') + '/hubs/board'; | |
| console.log('[SignalR] Connecting to hub at:', hubUrl); | |
| const connection = new signalR.HubConnectionBuilder() | |
| .withUrl(hubUrl, { | |
| accessTokenFactory: () => token, | |
| skipNegotiation: true, | |
| transport: signalR.HttpTransportType.WebSockets | |
| }) | |
| .withAutomaticReconnect() | |
| .build(); | |
| connectionRef.current = connection; | |
| // Presence updates | |
| connection.on('PresenceUpdated', (uniquePresences) => { | |
| console.log('[SignalR] Presence updated:', uniquePresences); | |
| const onlineUsers = uniquePresences.filter(u => u.userId !== user.id); | |
| setActiveUsers(onlineUsers); | |
| activeUsersRef.current = onlineUsers; | |
| // 1. Clean up offline peers | |
| const onlineUserIds = new Set(onlineUsers.map(u => u.userId)); | |
| Object.keys(peerConnectionsRef.current).forEach(peerId => { | |
| if (!onlineUserIds.has(peerId)) { | |
| console.log(`[WebRTC] Peer ${peerId} went offline. Cleaning up.`); | |
| cleanupPeer(peerId); | |
| } | |
| }); | |
| // 2. Initiate connection with peers where we are the initiator (tie-breaker: our user ID is lexicographically greater) | |
| onlineUsers.forEach(async (peer) => { | |
| const peerId = peer.userId; | |
| if (user.id > peerId && !peerConnectionsRef.current[peerId]) { | |
| console.log(`[WebRTC] Initiating connection to peer ${peerId}`); | |
| const pc = createPeerConnection(connection, peerId, peer); | |
| // Create outgoing data channel | |
| const channel = pc.createDataChannel('taskflow-sync-channel', { | |
| ordered: true | |
| }); | |
| setupDataChannel(peerId, channel); | |
| try { | |
| const offer = await pc.createOffer(); | |
| await pc.setLocalDescription(offer); | |
| await sendSignal(connection, peerId, 'offer', offer); | |
| } catch (err) { | |
| console.error(`[WebRTC] Failed to create offer for ${peerId}`, err); | |
| } | |
| } | |
| }); | |
| }); | |
| // WebRTC Signaling Bridge receiver | |
| connection.on('ReceiveSignal', async (senderUserId, type, dataJson) => { | |
| try { | |
| const signalData = JSON.parse(dataJson); | |
| const peerInfo = activeUsersRef.current.find(u => u.userId === senderUserId); | |
| const processPendingCandidates = async (peerId, pc) => { | |
| const candidates = pendingCandidatesRef.current[peerId]; | |
| if (candidates && candidates.length > 0) { | |
| console.log(`[WebRTC] Applying ${candidates.length} queued ICE candidates for peer ${peerId}`); | |
| while (candidates.length > 0) { | |
| const cand = candidates.shift(); | |
| try { | |
| await pc.addIceCandidate(new RTCIceCandidate(cand)); | |
| } catch (e) { | |
| console.error('[WebRTC] Error adding queued ICE candidate', e); | |
| } | |
| } | |
| } | |
| }; | |
| if (type === 'offer') { | |
| console.log(`[WebRTC] Received SDP Offer from peer ${senderUserId}`); | |
| const pc = createPeerConnection(connection, senderUserId, peerInfo); | |
| // Capture incoming data channel | |
| pc.ondatachannel = (event) => { | |
| setupDataChannel(senderUserId, event.channel); | |
| }; | |
| await pc.setRemoteDescription(new RTCSessionDescription(signalData)); | |
| await processPendingCandidates(senderUserId, pc); | |
| const answer = await pc.createAnswer(); | |
| await pc.setLocalDescription(answer); | |
| await sendSignal(connection, senderUserId, 'answer', answer); | |
| } | |
| else if (type === 'answer') { | |
| console.log(`[WebRTC] Received SDP Answer from peer ${senderUserId}`); | |
| const pc = peerConnectionsRef.current[senderUserId]; | |
| if (pc) { | |
| await pc.setRemoteDescription(new RTCSessionDescription(signalData)); | |
| await processPendingCandidates(senderUserId, pc); | |
| } | |
| } | |
| else if (type === 'candidate') { | |
| console.log(`[WebRTC] Received ICE Candidate from peer ${senderUserId}`); | |
| const pc = peerConnectionsRef.current[senderUserId]; | |
| if (pc && pc.remoteDescription) { | |
| try { | |
| await pc.addIceCandidate(new RTCIceCandidate(signalData)); | |
| } catch (e) { | |
| console.error('[WebRTC] Error adding received ICE candidate', e); | |
| } | |
| } else { | |
| if (!pendingCandidatesRef.current[senderUserId]) { | |
| pendingCandidatesRef.current[senderUserId] = []; | |
| } | |
| pendingCandidatesRef.current[senderUserId].push(signalData); | |
| console.log(`[WebRTC] Queued ICE candidate for peer ${senderUserId} (remoteDescription is null)`); | |
| } | |
| } | |
| } catch (err) { | |
| console.error('[WebRTC] Error handling received signal', err); | |
| } | |
| }); | |
| // Cursors | |
| connection.on('CursorMoved', (connectionId, senderId, x, y) => { | |
| if (senderId === user.id) return; | |
| onRemoteMessageRef.current(senderId, { | |
| type: 'CURSOR_MOVE', | |
| payload: { x, y } | |
| }); | |
| }); | |
| // Live nodes/edges updates | |
| connection.on('NodeMoved', (nodeId, x, y) => { | |
| onRemoteMessageRef.current('', { | |
| type: 'NODE_MOVE', | |
| payload: { id: nodeId, position: { x, y } } | |
| }); | |
| }); | |
| connection.on('NodeDataUpdated', (nodeId, newDataJson) => { | |
| try { | |
| const newData = JSON.parse(newDataJson); | |
| onRemoteMessageRef.current('', { | |
| type: 'NODE_DATA', | |
| payload: { id: nodeId, newData } | |
| }); | |
| } catch (e) { | |
| console.error(e); | |
| } | |
| }); | |
| connection.on('NodeAdded', (nodeJson) => { | |
| try { | |
| const node = JSON.parse(nodeJson); | |
| onRemoteMessageRef.current('', { | |
| type: 'NODE_ADD', | |
| payload: node | |
| }); | |
| } catch (e) { | |
| console.error(e); | |
| } | |
| }); | |
| connection.on('NodeDeleted', (nodeId) => { | |
| onRemoteMessageRef.current('', { | |
| type: 'NODE_DELETE', | |
| payload: { id: nodeId } | |
| }); | |
| }); | |
| connection.on('EdgeAdded', (edgeJson) => { | |
| try { | |
| const edge = JSON.parse(edgeJson); | |
| onRemoteMessageRef.current('', { | |
| type: 'EDGE_ADD', | |
| payload: edge | |
| }); | |
| } catch (e) { | |
| console.error(e); | |
| } | |
| }); | |
| connection.on('EdgeDeleted', (edgeId) => { | |
| onRemoteMessageRef.current('', { | |
| type: 'EDGE_DELETE', | |
| payload: { id: edgeId } | |
| }); | |
| }); | |
| // Access authorization check failed | |
| connection.on('AccessDenied', () => { | |
| alert("У вас немає прав для доступу до цієї дошки."); | |
| window.location.href = '/dashboard'; | |
| }); | |
| const startConnection = async () => { | |
| try { | |
| await connection.start(); | |
| console.log('[SignalR] Connected to BoardHub successfully.'); | |
| await connection.invoke('JoinBoard', projectId); | |
| } catch (err) { | |
| console.error('[SignalR] Connection failed: ', err); | |
| } | |
| }; | |
| startConnection(); | |
| return () => { | |
| // Clean up all peer connections on unmount | |
| Object.keys(peerConnectionsRef.current).forEach(cleanupPeer); | |
| connection.stop() | |
| .then(() => console.log('[SignalR] Connection stopped.')) | |
| .catch(err => console.error('[SignalR] Error stopping connection:', err)); | |
| }; | |
| }, [projectId, user, token, API_URL]); | |
| const broadcastMessage = async (msg) => { | |
| const connection = connectionRef.current; | |
| // Determine if we should send high-frequency updates via WebRTC P2P Data Channel | |
| const isHighFrequency = msg.type === 'CURSOR_MOVE' || msg.type === 'NODE_MOVE'; | |
| let sentP2P = false; | |
| if (isHighFrequency) { | |
| const payload = JSON.stringify(msg); | |
| // Attempt to send to all peers with open WebRTC data channels | |
| Object.keys(dataChannelsRef.current).forEach(peerId => { | |
| const channel = dataChannelsRef.current[peerId]; | |
| if (channel && channel.readyState === 'open') { | |
| try { | |
| channel.send(payload); | |
| sentP2P = true; | |
| } catch (e) { | |
| console.error(`[WebRTC] Failed to send message to peer ${peerId}`, e); | |
| } | |
| } | |
| }); | |
| } | |
| // Fallback/Standard logic via SignalR Hub if not sent via P2P | |
| if (!sentP2P) { | |
| if (!connection || connection.state !== signalR.HubConnectionState.Connected) { | |
| return; | |
| } | |
| try { | |
| if (msg.type === 'CURSOR_MOVE') { | |
| const { x, y } = msg.payload; | |
| await connection.invoke('SendCursor', projectId, x, y); | |
| } else if (msg.type === 'NODE_MOVE') { | |
| const { id, position } = msg.payload; | |
| await connection.invoke('MoveNode', projectId, id, position.x, position.y); | |
| } else if (msg.type === 'NODE_DATA') { | |
| const { id, newData } = msg.payload; | |
| await connection.invoke('UpdateNodeData', projectId, id, JSON.stringify(newData)); | |
| } else if (msg.type === 'NODE_ADD') { | |
| await connection.invoke('AddNode', projectId, JSON.stringify(msg.payload)); | |
| } else if (msg.type === 'NODE_DELETE') { | |
| await connection.invoke('DeleteNode', projectId, msg.payload.id); | |
| } else if (msg.type === 'EDGE_ADD') { | |
| await connection.invoke('AddEdge', projectId, JSON.stringify(msg.payload)); | |
| } else if (msg.type === 'EDGE_DELETE') { | |
| await connection.invoke('DeleteEdge', projectId, msg.payload.id); | |
| } | |
| } catch (err) { | |
| console.error('[SignalR] Failed to invoke hub method:', err); | |
| } | |
| } | |
| }; | |
| return { | |
| peers, | |
| activeUsers, | |
| broadcastMessage | |
| }; | |
| }; | |