mirror of
https://github.com/xtr-dev/rondevu-demo.git
synced 2025-12-10 02:43:23 +00:00
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:
66
CLAUDE.md
Normal file
66
CLAUDE.md
Normal 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
|
||||||
214
src/App.jsx
214
src/App.jsx
@@ -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",
|
||||||
},
|
],
|
||||||
|
username: "webrtcuser",
|
||||||
|
credential: "supersecretpassword"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
// Force relay to avoid direct connection attempts through VPN
|
// Force relay to test TURN server (comment out for normal operation)
|
||||||
iceTransportPolicy: 'relay',
|
// iceTransportPolicy: 'relay'
|
||||||
iceCandidatePoolSize: 10
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user