refactor: Update demo to use RondevuPeer with state management

- 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 <noreply@anthropic.com>
This commit is contained in:
2025-11-16 16:37:28 +01:00
parent 217b84701f
commit 50eeec5164
2 changed files with 224 additions and 56 deletions

66
CLAUDE.md Normal file
View File

@@ -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

View File

@@ -6,21 +6,20 @@ const API_URL = 'https://api.ronde.vu';
const RTC_CONFIG = { const RTC_CONFIG = {
iceServers: [ iceServers: [
// TCP transport to TURN server - VPN blocks UDP connections to TURN
{ {
urls: "turn:standard.relay.metered.ca:80?transport=tcp", urls: ["stun:stun.ronde.vu:3478"]
username: "c53a9c971da5e6f3bc959d8d",
credential: "QaccPqtPPaxyokXp",
}, },
{ {
urls: "turns:standard.relay.metered.ca:443?transport=tcp", urls: [
username: "c53a9c971da5e6f3bc959d8d", "turn:turn.ronde.vu:3478?transport=tcp",
credential: "QaccPqtPPaxyokXp", "turn:turn.ronde.vu:3478?transport=udp",
},
], ],
// Force relay to avoid direct connection attempts through VPN username: "webrtcuser",
iceTransportPolicy: 'relay', credential: "supersecretpassword"
iceCandidatePoolSize: 10 }
],
// Force relay to test TURN server (comment out for normal operation)
// iceTransportPolicy: 'relay'
}; };
export default function App() { export default function App() {
@@ -70,13 +69,14 @@ export default function App() {
} }
}, []); }, []);
// Cleanup on unmount // Cleanup on unmount only (empty dependency array)
useEffect(() => { useEffect(() => {
return () => { return () => {
// Close all connections when component unmounts // Close all peer connections when component unmounts
myConnections.forEach(c => c.conn?.close()); myConnections.forEach(c => c.peer?.close());
}; };
}, [myConnections]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Empty deps = only run on mount/unmount
// Register // Register
const handleRegister = async () => { 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 () => { const handleCreateOffer = async () => {
if (!client || !credentials) { if (!client || !credentials) {
toast.error('Please register first!'); toast.error('Please register first!');
@@ -104,59 +104,72 @@ export default function App() {
try { try {
const topics = offerTopics.split(',').map(t => t.trim()).filter(Boolean); const topics = offerTopics.split(',').map(t => t.trim()).filter(Boolean);
// Create connection using the manager // Create peer connection using the manager
const conn = client.createConnection(RTC_CONFIG); const peer = client.createPeer(RTC_CONFIG);
// Add debugging // Add debugging
addApiLogging(client); addApiLogging(client);
addIceLogging(conn); addIceLogging(peer);
// Setup event listeners // Setup event listeners
conn.on('connecting', () => { peer.on('state', (state) => {
updateConnectionStatus(conn.id, 'connecting'); console.log(`🔄 Peer state: ${state}`);
updateConnectionStatus(peer.offerId, state);
}); });
conn.on('connected', () => { peer.on('connected', () => {
updateConnectionStatus(conn.id, 'connected'); updateConnectionStatus(peer.offerId, 'connected');
}); });
conn.on('disconnected', () => { peer.on('disconnected', () => {
updateConnectionStatus(conn.id, '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 // Handle data channel
channel.onmessage = (event) => { channel.onmessage = (event) => {
setMessages(prev => [...prev, { setMessages(prev => [...prev, {
from: 'peer', from: 'peer',
text: event.data, text: event.data,
timestamp: Date.now(), timestamp: Date.now(),
connId: conn.id connId: peer.offerId
}]); }]);
}; };
updateConnectionChannel(conn.id, channel); updateConnectionChannel(peer.offerId, channel);
}); });
// Create offer // Create offer
const offerId = await conn.createOffer({ const offerId = await peer.createOffer({
topics, topics,
ttl: 300000 ttl: 300000,
timeouts: {
iceGathering: 15000,
waitingForAnswer: 60000,
iceConnection: 45000
}
}); });
// Add to connections list // Add to connections list
setMyConnections(prev => [...prev, { setMyConnections(prev => [...prev, {
id: offerId, id: offerId,
topics, topics,
status: 'waiting', status: 'waiting-for-answer',
role: 'offerer', role: 'offerer',
conn, peer,
channel: conn.channel channel: null
}]); }]);
setOfferTopics(''); setOfferTopics('');
toast.success(`Created offer! Share topic "${topics[0]}" with peers.`); toast.success(`Created offer! Share topic "${topics[0]}" with peers.`);
} catch (err) { } catch (err) {
console.error('Error creating offer:', err);
toast.error(`Error: ${err.message}`); toast.error(`Error: ${err.message}`);
} }
}; };
@@ -192,56 +205,71 @@ export default function App() {
} }
try { try {
// Create connection using the manager // Create peer connection using the manager
const conn = client.createConnection(RTC_CONFIG); const peer = client.createPeer(RTC_CONFIG);
// Add debugging // Add debugging
addApiLogging(client); addApiLogging(client);
addIceLogging(conn); addIceLogging(peer);
// Setup event listeners // Setup event listeners
conn.on('connecting', () => { peer.on('state', (state) => {
updateConnectionStatus(conn.id, 'connecting'); console.log(`🔄 Peer state: ${state}`);
updateConnectionStatus(offer.id, state);
}); });
conn.on('connected', () => { peer.on('connected', () => {
updateConnectionStatus(conn.id, 'connected'); updateConnectionStatus(offer.id, 'connected');
}); });
conn.on('disconnected', () => { peer.on('disconnected', () => {
updateConnectionStatus(conn.id, '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 // Handle data channel
channel.onmessage = (event) => { channel.onmessage = (event) => {
setMessages(prev => [...prev, { setMessages(prev => [...prev, {
from: 'peer', from: 'peer',
text: event.data, text: event.data,
timestamp: Date.now(), timestamp: Date.now(),
connId: conn.id connId: offer.id
}]); }]);
}; };
updateConnectionChannel(conn.id, channel); updateConnectionChannel(offer.id, channel);
}); });
// Answer the offer // 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 // Add to connections list
setMyConnections(prev => [...prev, { setMyConnections(prev => [...prev, {
id: offer.id, id: offer.id,
topics: offer.topics, topics: offer.topics,
status: 'connecting', status: 'answering',
role: 'answerer', role: 'answerer',
conn, peer,
channel: null channel: null
}]); }]);
setActiveTab('connections'); setActiveTab('connections');
toast.success('Connecting...'); toast.success('Answering offer...');
} catch (err) { } catch (err) {
console.error('Error answering offer:', err);
toast.error(`Error: ${err.message}`); toast.error(`Error: ${err.message}`);
} }
}; };
@@ -266,26 +294,43 @@ export default function App() {
client.offers.addIceCandidates = async (offerId, candidates) => { client.offers.addIceCandidates = async (offerId, candidates) => {
console.log(`📤 Sending ${candidates.length} ICE candidate(s) to server for offer ${offerId}`); 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) => { client.offers.getIceCandidates = async (offerId, since) => {
const result = await originalGetIceCandidates(offerId, since); const result = await originalGetIceCandidates(offerId, since);
console.log(`📥 Received ${result.length} ICE candidate(s) from server for offer ${offerId}, since=${since}`); console.log(`📥 Received ${result.length} ICE candidate(s) from server for offer ${offerId}, since=${since}`);
if (result.length > 0) { 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; return result;
}; };
}; };
// Add ICE debugging to a connection // Add ICE debugging to a peer connection
const addIceLogging = (conn) => { const addIceLogging = (peer) => {
const pc = conn['pc']; // Access underlying peer connection for debugging const pc = peer.pc; // Access underlying peer connection for debugging
if (pc) { if (pc) {
// Add new handlers that don't override existing ones // Add new handlers that don't override existing ones
pc.addEventListener('icecandidate', (event) => { pc.addEventListener('icecandidate', (event) => {
if (event.candidate) { 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:', { console.log('🧊 ICE candidate gathered:', {
type: event.candidate.type, type: event.candidate.type,
protocol: event.candidate.protocol, protocol: event.candidate.protocol,
@@ -304,10 +349,67 @@ export default function App() {
pc.addEventListener('iceconnectionstatechange', () => { pc.addEventListener('iceconnectionstatechange', () => {
console.log('🧊 ICE connection state:', pc.iceConnectionState); 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', () => { pc.addEventListener('connectionstatechange', () => {
console.log('🔌 Connection state:', pc.connectionState); 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'); localStorage.removeItem('rondevu-credentials');
setCredentials(null); setCredentials(null);
setStatus('Not registered'); setStatus('Not registered');
myConnections.forEach(c => c.conn?.close()); myConnections.forEach(c => c.peer?.close());
setMyConnections([]); setMyConnections([]);
setMessages([]); setMessages([]);
setClient(new Rondevu({baseUrl: API_URL})); setClient(new Rondevu({baseUrl: API_URL}));
@@ -350,7 +452,7 @@ export default function App() {
<div style={styles.header}> <div style={styles.header}>
<h1 style={styles.title}>🌐 Rondevu</h1> <h1 style={styles.title}>🌐 Rondevu</h1>
<p style={styles.subtitle}>Topic-Based Peer Discovery & WebRTC</p> <p style={styles.subtitle}>Topic-Based Peer Discovery & WebRTC</p>
<p style={styles.version}>v0.4.0 - With Connection Manager</p> <p style={styles.version}>v0.5.0 - State-Based Peer Manager</p>
</div> </div>
{/* Tabs */} {/* Tabs */}