From 50eeec51645cde5f5f061b73d03b3c19a9e8a471 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Sun, 16 Nov 2025 16:37:28 +0100 Subject: [PATCH] refactor: Update demo to use RondevuPeer with state management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace RondevuConnection with RondevuPeer throughout - Add state event listener for better state tracking - Add failed event listener with error details - Configure timeouts for offer/answer operations - Enhanced ICE debugging with candidate pair tracking - Add connection failure detection and logging - Improved error handling and user feedback - Update version to v0.5.0 (State-Based Peer Manager) - Update TURN server configuration to ronde.vu - Add comprehensive logging for troubleshooting 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 66 ++++++++++++++++ src/App.jsx | 214 ++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 224 insertions(+), 56 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43de69c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,66 @@ +# Rondevu Demo Development Guidelines + +## WebRTC Configuration + +### TURN Server Setup + +When configuring TURN servers: + +- ✅ **DO** include the port number in TURN URLs: `turn:server.com:3478` +- ✅ **DO** test TURN connectivity before deploying: `turnutils_uclient -u user -w pass server.com 3478 -y` +- ✅ **DO** provide both TCP and UDP transports for maximum compatibility +- ❌ **DON'T** omit the port number (even if it's the default 3478) +- ❌ **DON'T** assume TURN works without testing + +### ICE Configuration + +**Force Relay Mode for Testing:** +```javascript +const RTC_CONFIG = { + iceServers: [...], + iceTransportPolicy: 'relay' // Forces TURN relay, bypasses NAT issues +}; +``` + +Use `iceTransportPolicy: 'relay'` to: +- Test if TURN server is working correctly +- Bypass NAT hairpinning issues (when both peers are on same network) +- Ensure maximum compatibility + +**Remove or comment out** `iceTransportPolicy: 'relay'` for production to allow direct connections when possible. + +## Debugging + +### Enable Detailed ICE Logging + +The demo includes detailed ICE candidate logging. Check browser console for: +- 🧊 ICE candidate gathering +- 🧊 ICE connection state changes +- 📤 Candidates sent to server +- 📥 Candidates received from server +- ✅ Successful candidate pairs +- ❌ Failed candidate pairs + +### Common Issues + +1. **Connection stuck in "connecting":** + - Enable relay-only mode to test TURN + - Check if both peers are behind same NAT (hairpinning issue) + - Verify TURN credentials are correct + +2. **No candidates gathered:** + - Check STUN/TURN server URLs + - Verify firewall isn't blocking UDP ports + - Check TURN server is running + +3. **Candidates gathered but connection fails:** + - Check if TURN relay is actually working (use `turnutils_uclient`) + - Verify server is filtering candidates by role correctly + - Enable detailed logging to see which candidate pairs are failing + +## UI Guidelines + +- Show clear connection status (waiting, connecting, connected, failed) +- Display peer role (offerer vs answerer) for debugging +- Provide visual feedback for all user actions +- Use toast notifications for errors and success messages diff --git a/src/App.jsx b/src/App.jsx index 0b498c0..8072b6c 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -6,21 +6,20 @@ const API_URL = 'https://api.ronde.vu'; const RTC_CONFIG = { iceServers: [ - // TCP transport to TURN server - VPN blocks UDP connections to TURN { - urls: "turn:standard.relay.metered.ca:80?transport=tcp", - username: "c53a9c971da5e6f3bc959d8d", - credential: "QaccPqtPPaxyokXp", + urls: ["stun:stun.ronde.vu:3478"] }, { - urls: "turns:standard.relay.metered.ca:443?transport=tcp", - username: "c53a9c971da5e6f3bc959d8d", - credential: "QaccPqtPPaxyokXp", - }, + urls: [ + "turn:turn.ronde.vu:3478?transport=tcp", + "turn:turn.ronde.vu:3478?transport=udp", + ], + username: "webrtcuser", + credential: "supersecretpassword" + } ], - // Force relay to avoid direct connection attempts through VPN - iceTransportPolicy: 'relay', - iceCandidatePoolSize: 10 + // Force relay to test TURN server (comment out for normal operation) + // iceTransportPolicy: 'relay' }; export default function App() { @@ -70,13 +69,14 @@ export default function App() { } }, []); - // Cleanup on unmount + // Cleanup on unmount only (empty dependency array) useEffect(() => { return () => { - // Close all connections when component unmounts - myConnections.forEach(c => c.conn?.close()); + // Close all peer connections when component unmounts + myConnections.forEach(c => c.peer?.close()); }; - }, [myConnections]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Empty deps = only run on mount/unmount // Register const handleRegister = async () => { @@ -94,7 +94,7 @@ export default function App() { } }; - // Create offer with connection manager + // Create offer with peer connection manager const handleCreateOffer = async () => { if (!client || !credentials) { toast.error('Please register first!'); @@ -104,59 +104,72 @@ export default function App() { try { const topics = offerTopics.split(',').map(t => t.trim()).filter(Boolean); - // Create connection using the manager - const conn = client.createConnection(RTC_CONFIG); + // Create peer connection using the manager + const peer = client.createPeer(RTC_CONFIG); // Add debugging addApiLogging(client); - addIceLogging(conn); + addIceLogging(peer); // Setup event listeners - conn.on('connecting', () => { - updateConnectionStatus(conn.id, 'connecting'); + peer.on('state', (state) => { + console.log(`🔄 Peer state: ${state}`); + updateConnectionStatus(peer.offerId, state); }); - conn.on('connected', () => { - updateConnectionStatus(conn.id, 'connected'); + peer.on('connected', () => { + updateConnectionStatus(peer.offerId, 'connected'); }); - conn.on('disconnected', () => { - updateConnectionStatus(conn.id, 'disconnected'); + peer.on('disconnected', () => { + updateConnectionStatus(peer.offerId, 'disconnected'); }); - conn.on('datachannel', (channel) => { + peer.on('failed', (error) => { + console.error('❌ Peer connection failed:', error); + toast.error(`Connection failed: ${error.message}`); + updateConnectionStatus(peer.offerId, 'failed'); + }); + + peer.on('datachannel', (channel) => { // Handle data channel channel.onmessage = (event) => { setMessages(prev => [...prev, { from: 'peer', text: event.data, timestamp: Date.now(), - connId: conn.id + connId: peer.offerId }]); }; - updateConnectionChannel(conn.id, channel); + updateConnectionChannel(peer.offerId, channel); }); // Create offer - const offerId = await conn.createOffer({ + const offerId = await peer.createOffer({ topics, - ttl: 300000 + ttl: 300000, + timeouts: { + iceGathering: 15000, + waitingForAnswer: 60000, + iceConnection: 45000 + } }); // Add to connections list setMyConnections(prev => [...prev, { id: offerId, topics, - status: 'waiting', + status: 'waiting-for-answer', role: 'offerer', - conn, - channel: conn.channel + peer, + channel: null }]); setOfferTopics(''); toast.success(`Created offer! Share topic "${topics[0]}" with peers.`); } catch (err) { + console.error('Error creating offer:', err); toast.error(`Error: ${err.message}`); } }; @@ -192,56 +205,71 @@ export default function App() { } try { - // Create connection using the manager - const conn = client.createConnection(RTC_CONFIG); + // Create peer connection using the manager + const peer = client.createPeer(RTC_CONFIG); // Add debugging addApiLogging(client); - addIceLogging(conn); + addIceLogging(peer); // Setup event listeners - conn.on('connecting', () => { - updateConnectionStatus(conn.id, 'connecting'); + peer.on('state', (state) => { + console.log(`🔄 Peer state: ${state}`); + updateConnectionStatus(offer.id, state); }); - conn.on('connected', () => { - updateConnectionStatus(conn.id, 'connected'); + peer.on('connected', () => { + updateConnectionStatus(offer.id, 'connected'); }); - conn.on('disconnected', () => { - updateConnectionStatus(conn.id, 'disconnected'); + peer.on('disconnected', () => { + updateConnectionStatus(offer.id, 'disconnected'); }); - conn.on('datachannel', (channel) => { + peer.on('failed', (error) => { + console.error('❌ Peer connection failed:', error); + toast.error(`Connection failed: ${error.message}`); + updateConnectionStatus(offer.id, 'failed'); + }); + + peer.on('datachannel', (channel) => { // Handle data channel channel.onmessage = (event) => { setMessages(prev => [...prev, { from: 'peer', text: event.data, timestamp: Date.now(), - connId: conn.id + connId: offer.id }]); }; - updateConnectionChannel(conn.id, channel); + updateConnectionChannel(offer.id, channel); }); // Answer the offer - await conn.answer(offer.id, offer.sdp); + await peer.answer(offer.id, offer.sdp, { + topics: offer.topics, + timeouts: { + iceGathering: 15000, + creatingAnswer: 15000, + iceConnection: 45000 + } + }); // Add to connections list setMyConnections(prev => [...prev, { id: offer.id, topics: offer.topics, - status: 'connecting', + status: 'answering', role: 'answerer', - conn, + peer, channel: null }]); setActiveTab('connections'); - toast.success('Connecting...'); + toast.success('Answering offer...'); } catch (err) { + console.error('Error answering offer:', err); toast.error(`Error: ${err.message}`); } }; @@ -266,26 +294,43 @@ export default function App() { client.offers.addIceCandidates = async (offerId, candidates) => { console.log(`📤 Sending ${candidates.length} ICE candidate(s) to server for offer ${offerId}`); - return originalAddIceCandidates(offerId, candidates); + console.log(`📤 Candidates:`, candidates); + const result = await originalAddIceCandidates(offerId, candidates); + console.log(`📤 Send result:`, result); + return result; }; client.offers.getIceCandidates = async (offerId, since) => { const result = await originalGetIceCandidates(offerId, since); console.log(`📥 Received ${result.length} ICE candidate(s) from server for offer ${offerId}, since=${since}`); if (result.length > 0) { - console.log(`📥 First candidate:`, result[0]); + console.log(`📥 All candidates:`, result); + result.forEach((cand, i) => { + console.log(`📥 Candidate ${i}:`, { + role: cand.role, + peerId: cand.peerId, + candidate: cand.candidate, + createdAt: cand.createdAt + }); + }); } return result; }; }; - // Add ICE debugging to a connection - const addIceLogging = (conn) => { - const pc = conn['pc']; // Access underlying peer connection for debugging + // Add ICE debugging to a peer connection + const addIceLogging = (peer) => { + const pc = peer.pc; // Access underlying peer connection for debugging if (pc) { // Add new handlers that don't override existing ones pc.addEventListener('icecandidate', (event) => { if (event.candidate) { + // Skip empty/end-of-candidates markers in logs + if (!event.candidate.candidate || event.candidate.candidate === '') { + console.log('🧊 ICE gathering complete (end-of-candidates marker)'); + return; + } + console.log('🧊 ICE candidate gathered:', { type: event.candidate.type, protocol: event.candidate.protocol, @@ -304,10 +349,67 @@ export default function App() { pc.addEventListener('iceconnectionstatechange', () => { console.log('🧊 ICE connection state:', pc.iceConnectionState); + if (pc.iceConnectionState === 'failed') { + console.error('❌ ICE connection failed! Check firewall/NAT/TURN server.'); + // Log stats when failed + pc.getStats().then(stats => { + console.log('📊 Connection stats at failure:', stats); + }); + } else if (pc.iceConnectionState === 'checking') { + console.log('⏳ ICE checking candidates...'); + // Set a timeout to detect if we're stuck + setTimeout(() => { + if (pc.iceConnectionState === 'checking') { + console.warn('⚠️ Still in checking state after 30s - connection may be stuck'); + pc.getStats().then(stats => { + stats.forEach(report => { + if (report.type === 'candidate-pair') { + console.log('Candidate pair:', report); + } + }); + }); + } + }, 30000); + } }); pc.addEventListener('connectionstatechange', () => { console.log('🔌 Connection state:', pc.connectionState); + if (pc.connectionState === 'failed') { + console.error('❌ Connection failed!'); + // Log the selected candidate pair to see what was attempted + pc.getStats().then(stats => { + stats.forEach(report => { + if (report.type === 'candidate-pair' && report.selected) { + console.log('Selected candidate pair:', report); + } + }); + }); + } + }); + + // Log ICE candidate pair changes + pc.addEventListener('icecandidate', () => { + setTimeout(() => { + pc.getStats().then(stats => { + stats.forEach(report => { + if (report.type === 'candidate-pair' && report.state === 'succeeded') { + console.log('✅ ICE candidate pair succeeded:', { + local: report.localCandidateId, + remote: report.remoteCandidateId, + nominated: report.nominated, + state: report.state + }); + } else if (report.type === 'candidate-pair' && report.state === 'failed') { + console.log('❌ ICE candidate pair failed:', { + local: report.localCandidateId, + remote: report.remoteCandidateId, + state: report.state + }); + } + }); + }); + }, 1000); }); } }; @@ -336,7 +438,7 @@ export default function App() { localStorage.removeItem('rondevu-credentials'); setCredentials(null); setStatus('Not registered'); - myConnections.forEach(c => c.conn?.close()); + myConnections.forEach(c => c.peer?.close()); setMyConnections([]); setMessages([]); setClient(new Rondevu({baseUrl: API_URL})); @@ -350,7 +452,7 @@ export default function App() {

🌐 Rondevu

Topic-Based Peer Discovery & WebRTC

-

v0.4.0 - With Connection Manager

+

v0.5.0 - State-Based Peer Manager

{/* Tabs */}