feat: implement WebRTC P2P file transfer with DataChannel

Add complete WebRTC peer-to-peer file transfer functionality:

Backend changes:
- Add WebRTC signaling events to Socket.IO (offer, answer, ICE candidates)
- Implement authorization checks for match participants
- Add signaling relay between matched users

Frontend changes:
- Create useWebRTC hook for RTCPeerConnection management
- Implement RTCDataChannel with 16KB chunking for large files
- Add real-time progress monitoring for sender and receiver
- Implement automatic file download on receiver side
- Add connection state tracking and error handling
- Integrate WebRTC with MatchChatPage (replace mockup)

Configuration:
- Add Vite allowed hosts configuration via VITE_ALLOWED_HOSTS env var
- Support comma-separated host list or 'all' for development
- Add .env.example with configuration examples
- Update docker-compose.yml with default allowed hosts

Documentation:
- Add comprehensive WebRTC testing guide with troubleshooting
- Add quick test checklist for manual testing
- Document WebRTC flow, requirements, and success criteria

Features:
- End-to-end encrypted P2P transfer (DTLS)
- 16KB chunk size optimized for DataChannel
- Buffer management to prevent overflow
- Automatic connection establishment with 30s timeout
- Support for files of any size
- Real-time progress tracking
- Clean connection lifecycle management
This commit is contained in:
Radosław Gierwiało
2025-11-15 14:12:51 +01:00
parent 6948efeef9
commit 664a2865b9
8 changed files with 998 additions and 59 deletions

View File

@@ -0,0 +1,428 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { getSocket } from '../services/socket';
// WebRTC configuration with STUN servers
const rtcConfig = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' },
],
};
// File chunk size (16KB recommended for WebRTC DataChannel)
const CHUNK_SIZE = 16384;
/**
* Custom hook for managing WebRTC peer-to-peer connections and file transfers
*
* @param {number} matchId - The match ID for this connection
* @param {number} userId - Current user's ID
* @returns {Object} WebRTC state and control functions
*/
export const useWebRTC = (matchId, userId) => {
const [connectionState, setConnectionState] = useState('disconnected'); // disconnected, connecting, connected, failed
const [transferProgress, setTransferProgress] = useState(0);
const [isTransferring, setIsTransferring] = useState(false);
const [receivingFile, setReceivingFile] = useState(null);
const peerConnectionRef = useRef(null);
const dataChannelRef = useRef(null);
const socketRef = useRef(null);
// File transfer state
const fileTransferRef = useRef({
file: null,
fileName: '',
fileSize: 0,
fileType: '',
chunks: [],
currentChunk: 0,
totalChunks: 0,
});
// Receiving file state
const receivingBufferRef = useRef([]);
const receivingMetadataRef = useRef(null);
/**
* Initialize peer connection
*/
const initializePeerConnection = useCallback(() => {
if (peerConnectionRef.current) {
return peerConnectionRef.current;
}
const pc = new RTCPeerConnection(rtcConfig);
peerConnectionRef.current = pc;
// 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');
}
};
// Connection state change handler
pc.onconnectionstatechange = () => {
console.log('🔄 Connection state:', pc.connectionState);
setConnectionState(pc.connectionState);
if (pc.connectionState === 'failed') {
console.error('❌ WebRTC connection failed');
cleanupConnection();
}
};
// ICE connection state handler
pc.oniceconnectionstatechange = () => {
console.log('🧊 ICE connection state:', pc.iceConnectionState);
};
console.log('✅ RTCPeerConnection initialized');
return pc;
}, [matchId]);
/**
* Create data channel for file transfer
*/
const createDataChannel = useCallback((pc) => {
const dc = pc.createDataChannel('fileTransfer', {
ordered: true,
});
setupDataChannelHandlers(dc);
dataChannelRef.current = dc;
console.log('✅ DataChannel created');
return dc;
}, []);
/**
* Setup data channel event handlers
*/
const setupDataChannelHandlers = useCallback((dc) => {
dc.onopen = () => {
console.log('✅ DataChannel opened');
setConnectionState('connected');
};
dc.onclose = () => {
console.log('❌ DataChannel closed');
setConnectionState('disconnected');
};
dc.onerror = (error) => {
console.error('❌ DataChannel error:', error);
setConnectionState('failed');
};
dc.onmessage = (event) => {
handleDataChannelMessage(event.data);
};
}, []);
/**
* Handle incoming data channel messages
*/
const handleDataChannelMessage = useCallback((data) => {
// Check if it's metadata (JSON string)
if (typeof data === 'string') {
try {
const metadata = JSON.parse(data);
if (metadata.type === 'file_metadata') {
// Receiving file metadata
receivingMetadataRef.current = metadata;
receivingBufferRef.current = [];
setReceivingFile({
name: metadata.fileName,
size: metadata.fileSize,
type: metadata.fileType,
});
setIsTransferring(true);
setTransferProgress(0);
console.log('📥 Receiving file:', metadata.fileName);
} else if (metadata.type === 'file_end') {
// File transfer complete
const blob = new Blob(receivingBufferRef.current, {
type: receivingMetadataRef.current.fileType
});
// Trigger download
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = receivingMetadataRef.current.fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.log('✅ File received and downloaded:', receivingMetadataRef.current.fileName);
// Reset state
setIsTransferring(false);
setTransferProgress(0);
setReceivingFile(null);
receivingBufferRef.current = [];
receivingMetadataRef.current = null;
}
} catch (error) {
console.error('Failed to parse metadata:', error);
}
} else {
// Binary data - file chunk
receivingBufferRef.current.push(data);
const received = receivingBufferRef.current.length;
const total = Math.ceil(receivingMetadataRef.current.fileSize / CHUNK_SIZE);
const progress = Math.round((received / total) * 100);
setTransferProgress(progress);
console.log(`📥 Received chunk ${received}/${total} (${progress}%)`);
}
}, []);
/**
* Create and send offer to peer
*/
const createOffer = useCallback(async () => {
try {
setConnectionState('connecting');
const pc = initializePeerConnection();
createDataChannel(pc);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
socketRef.current.emit('webrtc_offer', {
matchId,
offer: pc.localDescription,
});
console.log('📤 Sent WebRTC offer');
} catch (error) {
console.error('Failed to create offer:', error);
setConnectionState('failed');
}
}, [matchId, initializePeerConnection, createDataChannel]);
/**
* Handle incoming offer and create answer
*/
const handleOffer = useCallback(async (offer) => {
try {
setConnectionState('connecting');
const pc = initializePeerConnection();
// Setup data channel handler for incoming connections
pc.ondatachannel = (event) => {
console.log('✅ DataChannel received');
dataChannelRef.current = event.channel;
setupDataChannelHandlers(event.channel);
};
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
socketRef.current.emit('webrtc_answer', {
matchId,
answer: pc.localDescription,
});
console.log('📤 Sent WebRTC answer');
} catch (error) {
console.error('Failed to handle offer:', error);
setConnectionState('failed');
}
}, [matchId, initializePeerConnection, setupDataChannelHandlers]);
/**
* Handle incoming answer
*/
const handleAnswer = useCallback(async (answer) => {
try {
const pc = peerConnectionRef.current;
if (!pc) {
console.error('No peer connection found');
return;
}
await pc.setRemoteDescription(new RTCSessionDescription(answer));
console.log('✅ Remote description set');
} catch (error) {
console.error('Failed to handle answer:', error);
setConnectionState('failed');
}
}, []);
/**
* Handle incoming ICE candidate
*/
const handleIceCandidate = useCallback(async (candidate) => {
try {
const pc = peerConnectionRef.current;
if (!pc) {
console.error('No peer connection found');
return;
}
await pc.addIceCandidate(new RTCIceCandidate(candidate));
console.log('✅ Added ICE candidate');
} catch (error) {
console.error('Failed to add ICE candidate:', error);
}
}, []);
/**
* Send file via data channel
*/
const sendFile = useCallback(async (file) => {
if (!file) {
console.error('No file provided');
return;
}
const dc = dataChannelRef.current;
if (!dc || dc.readyState !== 'open') {
console.error('DataChannel is not open');
alert('Connection not established. Please wait and try again.');
return;
}
try {
setIsTransferring(true);
setTransferProgress(0);
// Send file metadata
const metadata = {
type: 'file_metadata',
fileName: file.name,
fileSize: file.size,
fileType: file.type,
};
dc.send(JSON.stringify(metadata));
console.log('📤 Sent file metadata:', metadata);
// Read file and send in chunks
const arrayBuffer = await file.arrayBuffer();
const totalChunks = Math.ceil(arrayBuffer.byteLength / CHUNK_SIZE);
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, arrayBuffer.byteLength);
const chunk = arrayBuffer.slice(start, end);
// Wait if buffer is getting full
while (dc.bufferedAmount > CHUNK_SIZE * 10) {
await new Promise(resolve => setTimeout(resolve, 10));
}
dc.send(chunk);
const progress = Math.round(((i + 1) / totalChunks) * 100);
setTransferProgress(progress);
console.log(`📤 Sent chunk ${i + 1}/${totalChunks} (${progress}%)`);
}
// Send end marker
dc.send(JSON.stringify({ type: 'file_end' }));
console.log('✅ File transfer complete:', file.name);
setIsTransferring(false);
setTransferProgress(0);
} catch (error) {
console.error('Failed to send file:', error);
setIsTransferring(false);
setTransferProgress(0);
alert('Failed to send file: ' + error.message);
}
}, []);
/**
* Cleanup connection
*/
const cleanupConnection = useCallback(() => {
if (dataChannelRef.current) {
dataChannelRef.current.close();
dataChannelRef.current = null;
}
if (peerConnectionRef.current) {
peerConnectionRef.current.close();
peerConnectionRef.current = null;
}
setConnectionState('disconnected');
setIsTransferring(false);
setTransferProgress(0);
setReceivingFile(null);
receivingBufferRef.current = [];
receivingMetadataRef.current = null;
console.log('🧹 WebRTC connection cleaned up');
}, []);
/**
* Setup Socket.IO event listeners
*/
useEffect(() => {
const socket = getSocket();
if (!socket) {
console.error('Socket not available');
return;
}
socketRef.current = socket;
// Listen for WebRTC signaling events
socket.on('webrtc_offer', ({ from, offer }) => {
console.log('📥 Received WebRTC offer from:', from);
if (from !== userId) {
handleOffer(offer);
}
});
socket.on('webrtc_answer', ({ from, answer }) => {
console.log('📥 Received WebRTC answer from:', from);
if (from !== userId) {
handleAnswer(answer);
}
});
socket.on('webrtc_ice_candidate', ({ from, candidate }) => {
console.log('📥 Received ICE candidate from:', from);
if (from !== userId) {
handleIceCandidate(candidate);
}
});
// Cleanup on unmount
return () => {
socket.off('webrtc_offer');
socket.off('webrtc_answer');
socket.off('webrtc_ice_candidate');
cleanupConnection();
};
}, [userId, handleOffer, handleAnswer, handleIceCandidate, cleanupConnection]);
return {
connectionState,
isTransferring,
transferProgress,
receivingFile,
createOffer,
sendFile,
cleanupConnection,
};
};

View File

@@ -5,6 +5,7 @@ import { useAuth } from '../contexts/AuthContext';
import { matchesAPI } from '../services/api';
import { Send, Video, Upload, X, Check, Link as LinkIcon, Loader2 } from 'lucide-react';
import { connectSocket, getSocket } from '../services/socket';
import { useWebRTC } from '../hooks/useWebRTC';
const MatchChatPage = () => {
const { slug } = useParams();
@@ -15,15 +16,23 @@ const MatchChatPage = () => {
const [messages, setMessages] = useState([]);
const [newMessage, setNewMessage] = useState('');
const [selectedFile, setSelectedFile] = useState(null);
const [isTransferring, setIsTransferring] = useState(false);
const [transferProgress, setTransferProgress] = useState(0);
const [webrtcStatus, setWebrtcStatus] = useState('disconnected'); // disconnected, connecting, connected, failed
const [showLinkInput, setShowLinkInput] = useState(false);
const [videoLink, setVideoLink] = useState('');
const [isConnected, setIsConnected] = useState(false);
const messagesEndRef = useRef(null);
const fileInputRef = useRef(null);
// WebRTC hook
const {
connectionState,
isTransferring,
transferProgress,
receivingFile,
createOffer,
sendFile,
cleanupConnection,
} = useWebRTC(match?.id, user?.id);
// Fetch match data
useEffect(() => {
const loadMatch = async () => {
@@ -128,61 +137,60 @@ const MatchChatPage = () => {
if (file && file.type.startsWith('video/')) {
setSelectedFile(file);
} else {
alert('Proszę wybrać plik wideo');
alert('Please select a video file');
}
};
const simulateWebRTCConnection = () => {
setWebrtcStatus('connecting');
setTimeout(() => {
setWebrtcStatus('connected');
}, 1500);
};
const handleStartTransfer = () => {
const handleStartTransfer = async () => {
if (!selectedFile) return;
// Simulate WebRTC connection
simulateWebRTCConnection();
// If not connected, initiate connection first
if (connectionState !== 'connected') {
console.log('Creating WebRTC offer...');
await createOffer();
setTimeout(() => {
setIsTransferring(true);
setTransferProgress(0);
// Simulate transfer progress
const interval = setInterval(() => {
setTransferProgress((prev) => {
if (prev >= 100) {
clearInterval(interval);
setIsTransferring(false);
setSelectedFile(null);
setWebrtcStatus('disconnected');
// Add message about completed transfer
const message = {
id: messages.length + 1,
room_id: 10,
user_id: user.id,
username: user.username,
content: `📹 Video sent: ${selectedFile.name} (${(selectedFile.size / 1024 / 1024).toFixed(2)} MB)`,
type: 'video',
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, message]);
return 0;
// Wait for connection
const waitForConnection = new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Connection timeout')), 30000);
const checkConnection = setInterval(() => {
if (connectionState === 'connected') {
clearInterval(checkConnection);
clearTimeout(timeout);
resolve();
} else if (connectionState === 'failed') {
clearInterval(checkConnection);
clearTimeout(timeout);
reject(new Error('Connection failed'));
}
return prev + 5;
});
}, 200);
}, 2000);
}, 100);
});
try {
await waitForConnection;
} catch (error) {
alert('Failed to establish connection: ' + error.message);
return;
}
}
// Send file
await sendFile(selectedFile);
// Add message about completed transfer
const socket = getSocket();
if (socket && socket.connected) {
socket.emit('send_match_message', {
matchId: match.id,
content: `📹 Video sent: ${selectedFile.name} (${(selectedFile.size / 1024 / 1024).toFixed(2)} MB)`,
});
}
setSelectedFile(null);
};
const handleCancelTransfer = () => {
setIsTransferring(false);
setTransferProgress(0);
setSelectedFile(null);
setWebrtcStatus('disconnected');
cleanupConnection();
};
const handleSendLink = (e) => {
@@ -209,7 +217,7 @@ const MatchChatPage = () => {
};
const getWebRTCStatusColor = () => {
switch (webrtcStatus) {
switch (connectionState) {
case 'connected':
return 'text-green-600';
case 'connecting':
@@ -222,7 +230,7 @@ const MatchChatPage = () => {
};
const getWebRTCStatusText = () => {
switch (webrtcStatus) {
switch (connectionState) {
case 'connected':
return 'Connected (P2P)';
case 'connecting':
@@ -230,7 +238,7 @@ const MatchChatPage = () => {
case 'failed':
return 'Connection failed';
default:
return 'Disconnected';
return 'Ready to connect';
}
};
@@ -289,14 +297,21 @@ const MatchChatPage = () => {
{/* WebRTC Status Bar */}
<div className="bg-gray-50 border-b px-4 py-2 flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${webrtcStatus === 'connected' ? 'bg-green-500' : 'bg-gray-300'}`} />
<div className={`w-2 h-2 rounded-full ${connectionState === 'connected' ? 'bg-green-500' : 'bg-gray-300'}`} />
<span className={`text-sm font-medium ${getWebRTCStatusColor()}`}>
{getWebRTCStatusText()}
</span>
</div>
<span className="text-xs text-gray-500">
{webrtcStatus === 'connected' ? '🔒 E2E Encrypted (DTLS/SRTP)' : 'WebRTC ready to connect'}
</span>
<div className="flex items-center space-x-4">
{receivingFile && (
<span className="text-xs text-blue-600 font-medium">
📥 Receiving: {receivingFile.name}
</span>
)}
<span className="text-xs text-gray-500">
{connectionState === 'connected' ? '🔒 E2E Encrypted (DTLS)' : 'WebRTC P2P Ready'}
</span>
</div>
</div>
<div className="flex flex-col h-[calc(100vh-320px)]">
@@ -495,11 +510,11 @@ const MatchChatPage = () => {
</div>
{/* Info Box */}
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-sm text-blue-800">
<strong>🚀 WebRTC P2P Functionality Mockup:</strong> In the full version, videos will be transferred directly
between users via RTCDataChannel, with chunking and progress monitoring.
The server is only used for SDP/ICE exchange (signaling).
<div className="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg">
<p className="text-sm text-green-800">
<strong>🚀 WebRTC P2P File Transfer Active!</strong> Videos are transferred directly between users via
RTCDataChannel with 16KB chunking and real-time progress monitoring. The server is only used for
SDP/ICE exchange (signaling). Connection is end-to-end encrypted (DTLS).
</p>
</div>
</div>