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

@@ -328,6 +328,104 @@ function initializeSocket(httpServer) {
}
});
// WebRTC Signaling Events
// Send WebRTC offer
socket.on('webrtc_offer', async ({ matchId, offer }) => {
try {
const roomName = `match_${matchId}`;
// Verify user is part of this match
const match = await prisma.match.findUnique({
where: { id: parseInt(matchId) },
select: { user1Id: true, user2Id: true },
});
if (!match) {
return socket.emit('error', { message: 'Match not found' });
}
if (match.user1Id !== socket.user.id && match.user2Id !== socket.user.id) {
return socket.emit('error', { message: 'Not authorized for this match' });
}
// Forward offer to the other user in the match room
socket.to(roomName).emit('webrtc_offer', {
from: socket.user.id,
offer,
});
console.log(`📡 WebRTC offer sent in match ${matchId} from ${socket.user.username}`);
} catch (error) {
console.error('WebRTC offer error:', error);
socket.emit('error', { message: 'Failed to send WebRTC offer' });
}
});
// Send WebRTC answer
socket.on('webrtc_answer', async ({ matchId, answer }) => {
try {
const roomName = `match_${matchId}`;
// Verify user is part of this match
const match = await prisma.match.findUnique({
where: { id: parseInt(matchId) },
select: { user1Id: true, user2Id: true },
});
if (!match) {
return socket.emit('error', { message: 'Match not found' });
}
if (match.user1Id !== socket.user.id && match.user2Id !== socket.user.id) {
return socket.emit('error', { message: 'Not authorized for this match' });
}
// Forward answer to the other user in the match room
socket.to(roomName).emit('webrtc_answer', {
from: socket.user.id,
answer,
});
console.log(`📡 WebRTC answer sent in match ${matchId} from ${socket.user.username}`);
} catch (error) {
console.error('WebRTC answer error:', error);
socket.emit('error', { message: 'Failed to send WebRTC answer' });
}
});
// Send ICE candidate
socket.on('webrtc_ice_candidate', async ({ matchId, candidate }) => {
try {
const roomName = `match_${matchId}`;
// Verify user is part of this match
const match = await prisma.match.findUnique({
where: { id: parseInt(matchId) },
select: { user1Id: true, user2Id: true },
});
if (!match) {
return socket.emit('error', { message: 'Match not found' });
}
if (match.user1Id !== socket.user.id && match.user2Id !== socket.user.id) {
return socket.emit('error', { message: 'Not authorized for this match' });
}
// Forward ICE candidate to the other user in the match room
socket.to(roomName).emit('webrtc_ice_candidate', {
from: socket.user.id,
candidate,
});
console.log(`🧊 ICE candidate sent in match ${matchId} from ${socket.user.username}`);
} catch (error) {
console.error('ICE candidate error:', error);
socket.emit('error', { message: 'Failed to send ICE candidate' });
}
});
// Handle disconnection
socket.on('disconnect', () => {
console.log(`❌ User disconnected: ${socket.user.username} (${socket.id})`);