feat: implement WebRTC P2P file transfer with detection and fallback
Implemented complete WebRTC peer-to-peer file transfer system for match chat: **Core WebRTC Implementation:** - Created useWebRTC hook with RTCPeerConnection and RTCDataChannel - P2P file transfer with 16KB chunking for large files (tested up to 700MB) - Real-time progress monitoring for sender and receiver - Automatic file download on receiver side - End-to-end encryption via DTLS (native WebRTC) - ICE candidate exchange via Socket.IO signaling - Support for host candidates (localhost testing) **WebRTC Detection & User Experience:** - Automatic WebRTC capability detection on page load - Detects if ICE candidates can be generated (fails in Opera, privacy-focused browsers, VPNs) - User-friendly warning component with fix suggestions - Graceful degradation: disables WebRTC button when blocked - Suggests alternative methods (video links via Google Drive/Dropbox) **Socket.IO Improvements:** - Fixed multiple socket instance creation issue - Implemented socket instance reuse pattern - Disabled React.StrictMode to prevent reconnection loops in development **Technical Details:** - RTCPeerConnection with configurable STUN servers (currently using localhost config) - RTCDataChannel with ordered delivery - Comprehensive logging for debugging (ICE gathering, connection states, signaling) - Match room-based signaling relay via Socket.IO - Authorization checks for all WebRTC signaling events **Files Changed:** - frontend/src/hooks/useWebRTC.js - Complete WebRTC implementation - frontend/src/utils/webrtcDetection.js - WebRTC capability detection - frontend/src/components/WebRTCWarning.jsx - User warning component - frontend/src/pages/MatchChatPage.jsx - WebRTC integration - frontend/src/services/socket.js - Socket instance reuse - frontend/src/main.jsx - Disabled StrictMode for Socket.IO stability **Testing:** - ✅ Verified working in Chrome (ICE candidates generated) - ✅ Tested with 700MB file transfer - ✅ Detection working in Opera (shows warning when WebRTC blocked) - ✅ P2P connection establishment and DataChannel opening - ✅ File chunking and progress monitoring **TODO:** - Add STUN server configuration for production (NAT traversal) - Consider server-based upload fallback for blocked users
This commit is contained in:
@@ -8,6 +8,13 @@ const rtcConfig = {
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
{ urls: 'stun:stun2.l.google.com:19302' },
|
||||
],
|
||||
iceTransportPolicy: 'all', // Use all candidates (host, srflx, relay)
|
||||
iceCandidatePoolSize: 10, // Pre-gather candidates
|
||||
};
|
||||
|
||||
// Alternative config for localhost testing (no STUN)
|
||||
const rtcConfigLocalhost = {
|
||||
iceServers: [], // No STUN - use only host candidates for localhost
|
||||
};
|
||||
|
||||
// File chunk size (16KB recommended for WebRTC DataChannel)
|
||||
@@ -29,6 +36,14 @@ export const useWebRTC = (matchId, userId) => {
|
||||
const peerConnectionRef = useRef(null);
|
||||
const dataChannelRef = useRef(null);
|
||||
const socketRef = useRef(null);
|
||||
const matchIdRef = useRef(matchId);
|
||||
const userIdRef = useRef(userId);
|
||||
|
||||
// Update refs when props change
|
||||
useEffect(() => {
|
||||
matchIdRef.current = matchId;
|
||||
userIdRef.current = userId;
|
||||
}, [matchId, userId]);
|
||||
|
||||
// File transfer state
|
||||
const fileTransferRef = useRef({
|
||||
@@ -53,20 +68,39 @@ export const useWebRTC = (matchId, userId) => {
|
||||
return peerConnectionRef.current;
|
||||
}
|
||||
|
||||
const pc = new RTCPeerConnection(rtcConfig);
|
||||
// Use localhost config for testing (no STUN servers)
|
||||
const pc = new RTCPeerConnection(rtcConfigLocalhost);
|
||||
peerConnectionRef.current = pc;
|
||||
console.log('🔧 Using rtcConfigLocalhost (no STUN servers)');
|
||||
|
||||
// ICE candidate handler
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate && socketRef.current) {
|
||||
socketRef.current.emit('webrtc_ice_candidate', {
|
||||
matchId,
|
||||
candidate: event.candidate,
|
||||
});
|
||||
console.log('📤 Sent ICE candidate');
|
||||
if (event.candidate) {
|
||||
console.log('🧊 ICE candidate generated:', event.candidate.type);
|
||||
if (socketRef.current) {
|
||||
socketRef.current.emit('webrtc_ice_candidate', {
|
||||
matchId: matchIdRef.current,
|
||||
candidate: event.candidate,
|
||||
});
|
||||
console.log('📤 Sent ICE candidate');
|
||||
} else {
|
||||
console.error('❌ Socket not available to send ICE candidate');
|
||||
}
|
||||
} else {
|
||||
console.log('🧊 ICE gathering complete (candidate is null)');
|
||||
}
|
||||
};
|
||||
|
||||
// ICE gathering state handler
|
||||
pc.onicegatheringstatechange = () => {
|
||||
console.log('🧊 ICE gathering state:', pc.iceGatheringState);
|
||||
};
|
||||
|
||||
// Signaling state handler
|
||||
pc.onsignalingstatechange = () => {
|
||||
console.log('📡 Signaling state:', pc.signalingState);
|
||||
};
|
||||
|
||||
// Connection state change handler
|
||||
pc.onconnectionstatechange = () => {
|
||||
console.log('🔄 Connection state:', pc.connectionState);
|
||||
@@ -84,8 +118,9 @@ export const useWebRTC = (matchId, userId) => {
|
||||
};
|
||||
|
||||
console.log('✅ RTCPeerConnection initialized');
|
||||
console.log('🔍 Initial states - Connection:', pc.connectionState, 'ICE:', pc.iceConnectionState, 'Signaling:', pc.signalingState);
|
||||
return pc;
|
||||
}, [matchId]);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Create data channel for file transfer
|
||||
@@ -200,9 +235,11 @@ export const useWebRTC = (matchId, userId) => {
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
console.log('✅ Local description set (offer). ICE gathering should start now...');
|
||||
console.log('🔍 SDP has candidates:', pc.localDescription.sdp.includes('candidate:'));
|
||||
|
||||
socketRef.current.emit('webrtc_offer', {
|
||||
matchId,
|
||||
matchId: matchIdRef.current,
|
||||
offer: pc.localDescription,
|
||||
});
|
||||
|
||||
@@ -211,7 +248,7 @@ export const useWebRTC = (matchId, userId) => {
|
||||
console.error('Failed to create offer:', error);
|
||||
setConnectionState('failed');
|
||||
}
|
||||
}, [matchId, initializePeerConnection, createDataChannel]);
|
||||
}, [initializePeerConnection, createDataChannel]);
|
||||
|
||||
/**
|
||||
* Handle incoming offer and create answer
|
||||
@@ -230,12 +267,15 @@ export const useWebRTC = (matchId, userId) => {
|
||||
};
|
||||
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
||||
console.log('✅ Remote description set (offer)');
|
||||
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
console.log('✅ Local description set (answer). ICE gathering should start now...');
|
||||
console.log('🔍 SDP has candidates:', pc.localDescription.sdp.includes('candidate:'));
|
||||
|
||||
socketRef.current.emit('webrtc_answer', {
|
||||
matchId,
|
||||
matchId: matchIdRef.current,
|
||||
answer: pc.localDescription,
|
||||
});
|
||||
|
||||
@@ -244,7 +284,7 @@ export const useWebRTC = (matchId, userId) => {
|
||||
console.error('Failed to handle offer:', error);
|
||||
setConnectionState('failed');
|
||||
}
|
||||
}, [matchId, initializePeerConnection, setupDataChannelHandlers]);
|
||||
}, [initializePeerConnection, setupDataChannelHandlers]);
|
||||
|
||||
/**
|
||||
* Handle incoming answer
|
||||
@@ -258,7 +298,7 @@ export const useWebRTC = (matchId, userId) => {
|
||||
}
|
||||
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(answer));
|
||||
console.log('✅ Remote description set');
|
||||
console.log('✅ Remote description set (answer). ICE should connect now...');
|
||||
} catch (error) {
|
||||
console.error('Failed to handle answer:', error);
|
||||
setConnectionState('failed');
|
||||
@@ -385,36 +425,67 @@ export const useWebRTC = (matchId, userId) => {
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
// Listen for WebRTC signaling events
|
||||
socket.on('webrtc_offer', ({ from, offer }) => {
|
||||
console.log('📥 Received WebRTC offer from:', from);
|
||||
if (from !== userId) {
|
||||
// Create stable handlers using current values from refs
|
||||
const onOffer = ({ from, offer }) => {
|
||||
console.log('📥 Received WebRTC offer from:', from, 'My userId:', userIdRef.current);
|
||||
if (from !== userIdRef.current) {
|
||||
handleOffer(offer);
|
||||
} else {
|
||||
console.log('⏭️ Ignoring offer from self');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
socket.on('webrtc_answer', ({ from, answer }) => {
|
||||
console.log('📥 Received WebRTC answer from:', from);
|
||||
if (from !== userId) {
|
||||
const onAnswer = ({ from, answer }) => {
|
||||
console.log('📥 Received WebRTC answer from:', from, 'My userId:', userIdRef.current);
|
||||
if (from !== userIdRef.current) {
|
||||
handleAnswer(answer);
|
||||
} else {
|
||||
console.log('⏭️ Ignoring answer from self');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
socket.on('webrtc_ice_candidate', ({ from, candidate }) => {
|
||||
const onIceCandidate = ({ from, candidate }) => {
|
||||
console.log('📥 Received ICE candidate from:', from);
|
||||
if (from !== userId) {
|
||||
if (from !== userIdRef.current) {
|
||||
handleIceCandidate(candidate);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Cleanup on unmount
|
||||
// Attach listeners
|
||||
const attachListeners = () => {
|
||||
socket.off('webrtc_offer', onOffer);
|
||||
socket.off('webrtc_answer', onAnswer);
|
||||
socket.off('webrtc_ice_candidate', onIceCandidate);
|
||||
|
||||
socket.on('webrtc_offer', onOffer);
|
||||
socket.on('webrtc_answer', onAnswer);
|
||||
socket.on('webrtc_ice_candidate', onIceCandidate);
|
||||
|
||||
console.log('✅ WebRTC Socket.IO listeners attached (socketId:', socket.id, ')');
|
||||
};
|
||||
|
||||
// Attach initially
|
||||
attachListeners();
|
||||
|
||||
// Reattach on reconnect
|
||||
const onReconnect = () => {
|
||||
console.log('🔄 Socket reconnected - reattaching WebRTC listeners');
|
||||
attachListeners();
|
||||
};
|
||||
|
||||
socket.on('connect', onReconnect);
|
||||
|
||||
// Cleanup on unmount only
|
||||
return () => {
|
||||
socket.off('webrtc_offer');
|
||||
socket.off('webrtc_answer');
|
||||
socket.off('webrtc_ice_candidate');
|
||||
console.log('🧹 Removing WebRTC Socket.IO listeners');
|
||||
socket.off('connect', onReconnect);
|
||||
socket.off('webrtc_offer', onOffer);
|
||||
socket.off('webrtc_answer', onAnswer);
|
||||
socket.off('webrtc_ice_candidate', onIceCandidate);
|
||||
cleanupConnection();
|
||||
};
|
||||
}, [userId, handleOffer, handleAnswer, handleIceCandidate, cleanupConnection]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId]); // Only re-run if userId changes
|
||||
|
||||
return {
|
||||
connectionState,
|
||||
|
||||
Reference in New Issue
Block a user