FlowWeb / src /features /canvas /hooks /useSignalR.js
danylokhodus's picture
feat(sync): replace polling-based WebRTC with high-performance unified SignalR-bridge UDP WebRTC sync
e44f847
Raw
History Blame Contribute Delete
15 kB
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
};
};