Compare commits

14 Commits

Author SHA1 Message Date
d575022412 Update README to include live API link for rondevu-server 2025-11-17 21:44:13 +01:00
84ceae9a05 Update live demo link in README to use ronde.vu domain 2025-11-17 21:43:09 +01:00
c5f640bc62 Expand README with links to related repositories and NPM packages 2025-11-17 21:41:52 +01:00
9163e5166c Replace preset topics with dynamic topic listing from server
Changed discover page to fetch and display real topics from the API:
- Added fetchTopics() to call client.offers.getTopics()
- Display actual topic names and active peer counts
- Added loading, error, and empty states
- Added refresh button to reload topics
- Improved UX with better error handling

Updated to @xtr-dev/rondevu-client@0.7.4

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 20:17:27 +01:00
7d3b19a2b0 Update README to use client.createPeer() method
- Replaced `new RondevuPeer(client.offers)` with `client.createPeer()`
- Updated import to show Rondevu instead of RondevuPeer

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 18:03:46 +01:00
7d19557966 Update README to reflect current RondevuPeer API
- Replaced all references to removed RondevuConnection class
- Updated to use RondevuPeer with state machine lifecycle
- Documented offerer and answerer state flows
- Added detailed "What Happens Under the Hood" section
- Updated all code examples to use addEventListener
- Added trickle ICE implementation details
- Documented timeout configurations
- Updated technical implementation section

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 18:01:20 +01:00
70fd6bd16a Update rondevu-client to v0.7.3
Fixed answerer authorization for ICE candidates:
- Answer is now sent to server BEFORE setLocalDescription
- This ensures answererPeerId is registered before ICE candidates arrive
- Fixes 403 Forbidden errors when answerer sends candidates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 17:52:12 +01:00
6dece31f2d Update rondevu-client to v0.7.2
Critical fix for ICE candidate timing bug:
- ICE handler now set up BEFORE setLocalDescription
- Ensures all ICE candidates are captured and sent to server
- Fixes connection failures where peers received 0 candidates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 17:46:33 +01:00
b741e8f40c Update rondevu-client to v0.7.1
Updated to latest client version with:
- addEventListener/removeEventListener instead of .on* properties
- Extracted duplicate ICE handler code to base PeerState class
- Proper event listener cleanup to prevent memory leaks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 17:37:45 +01:00
2c20af83c9 feat: Update to @xtr-dev/rondevu-client@0.6.0
Includes proper trickle ICE support:
- Offers/answers sent immediately (no waiting for ICE)
- Answering state now takes ~50-200ms instead of 15+ seconds
- ICE candidates trickle in as they're discovered
- Faster connection establishment

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 17:13:18 +01:00
78c16c95f5 fix: Handle data channel onopen event for bidirectional messaging
The offerer creates the data channel immediately, but it's not in the
'open' state until the connection is established. The answerer receives
the channel later when it's typically already open.

Now we:
- Listen for channel.onopen and update the connection state when open
- Check if channel is already open and update immediately if so
- Add logging for channel state changes
- Handle channel errors and close events

This fixes the issue where only the answerer could send messages.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 16:53:03 +01:00
953f62ce81 fix: Update to @xtr-dev/rondevu-client@0.5.1
Fixes timeout issue where answerer would fail when ICE gathering
took close to the timeout duration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 16:49:03 +01:00
c46bfb40a9 chore: Update to @xtr-dev/rondevu-client@0.5.0
- Update client dependency to 0.5.0 (state-based peer manager)
- Bump demo version to 0.5.0

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 16:41:26 +01:00
50eeec5164 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>
2025-11-16 16:37:28 +01:00
6 changed files with 564 additions and 148 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

148
README.md
View File

@@ -5,8 +5,9 @@
Experience topic-based peer discovery and WebRTC connections using the Rondevu signaling platform.
**Related repositories:**
- [rondevu-server](https://github.com/xtr-dev/rondevu) - HTTP signaling server
- [rondevu-client](https://github.com/xtr-dev/rondevu-client) - TypeScript client library
- [@xtr-dev/rondevu-client](https://github.com/xtr-dev/rondevu-client) - TypeScript client library ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-client))
- [@xtr-dev/rondevu-server](https://github.com/xtr-dev/rondevu-server) - HTTP signaling server ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-server), [live](https://api.ronde.vu))
- [@xtr-dev/rondevu-demo](https://github.com/xtr-dev/rondevu-demo) - Interactive demo ([live](https://ronde.vu))
---
@@ -17,14 +18,15 @@ This demo showcases the complete Rondevu workflow:
1. **Register** - Get peer credentials (automatically saved)
2. **Create Offers** - Advertise your WebRTC connection on topics
3. **Discover Peers** - Find other peers by topic
4. **Connect** - Establish direct P2P WebRTC connections
4. **Connect** - Establish direct P2P WebRTC connections via `RondevuPeer`
5. **Chat** - Send messages over WebRTC data channels
### Key Features
- **Topic-Based Discovery** - Find peers by shared topics (like torrent infohashes)
- **Real P2P Connections** - Actual WebRTC data channels (not simulated)
- **Connection Manager** - Uses high-level `RondevuConnection` API (no manual WebRTC plumbing)
- **State-Based Peer Management** - Uses `RondevuPeer` with clean state machine (idle → creating-offer → waiting-for-answer → exchanging-ice → connected)
- **Trickle ICE** - Fast connection establishment by sending ICE candidates as they're discovered
- **Persistent Credentials** - Saves authentication to localStorage
- **Topics Browser** - Browse all active topics and peer counts
- **Multiple Connections** - Support multiple simultaneous peer connections
@@ -109,76 +111,121 @@ The easiest way to test:
## Technical Implementation
### Connection Manager
### RondevuPeer State Machine
This demo uses the high-level `RondevuConnection` class which abstracts all WebRTC complexity:
This demo uses the `RondevuPeer` class which implements a clean state-based connection lifecycle:
```javascript
// Create connection
const conn = client.createConnection();
import { Rondevu } from '@xtr-dev/rondevu-client';
// Create peer
const peer = client.createPeer();
// Set up event listeners
conn.on('connected', () => {
console.log('P2P connection established!');
peer.on('state', (state) => {
console.log('Peer state:', state);
// Offerer: idle → creating-offer → waiting-for-answer → exchanging-ice → connected
// Answerer: idle → answering → exchanging-ice → connected
});
conn.on('datachannel', (channel) => {
channel.onmessage = (event) => {
console.log('Message:', event.data);
};
peer.on('connected', () => {
console.log('✅ P2P connection established!');
});
// Create offer
await conn.createOffer({
peer.on('datachannel', (channel) => {
channel.addEventListener('message', (event) => {
console.log('📥 Message:', event.data);
});
channel.addEventListener('open', () => {
// Channel is ready, can send messages
channel.send('Hello!');
});
});
peer.on('failed', (error) => {
console.error('❌ Connection failed:', error);
});
// Create offer (offerer)
await peer.createOffer({
topics: ['demo-room'],
ttl: 300000
});
// Or answer an offer
await conn.answer(offerId, offerSdp);
// Or answer an offer (answerer)
await peer.answer(offerId, offerSdp, {
topics: ['demo-room']
});
```
The connection manager handles:
- Offer/answer SDP generation
- ICE candidate gathering and exchange
- Automatic polling for answers and candidates
- Data channel lifecycle
- Connection state management
- Event-driven API
### Connection States
**Offerer Flow:**
1. **idle** - Initial state
2. **creating-offer** - Creating WebRTC offer and sending to server
3. **waiting-for-answer** - Polling for answer from peer (every 2 seconds)
4. **exchanging-ice** - Exchanging ICE candidates (polling every 1 second)
5. **connected** - Successfully connected!
6. **failed/closed** - Connection failed or was closed
**Answerer Flow:**
1. **idle** - Initial state
2. **answering** - Creating WebRTC answer and sending to server
3. **exchanging-ice** - Exchanging ICE candidates (polling every 1 second)
4. **connected** - Successfully connected!
5. **failed/closed** - Connection failed or was closed
### What Happens Under the Hood
1. **Offerer** calls `conn.createOffer()`:
- Creates RTCPeerConnection
1. **Offerer** calls `peer.createOffer()`:
- State → `creating-offer`
- Creates RTCPeerConnection and data channel
- Generates SDP offer
- Creates data channel
- Sets up ICE candidate handler (before gathering starts)
- Sets local description → ICE gathering begins
- Posts offer to Rondevu server
- State → `waiting-for-answer`
- Polls for answers every 2 seconds
- When answer received → State → `exchanging-ice`
2. **Answerer** calls `conn.answer()`:
2. **Answerer** calls `peer.answer()`:
- State → `answering`
- Creates RTCPeerConnection
- Sets remote description (offer SDP)
- Generates SDP answer
- Posts answer to server
- Polls for ICE candidates every 1 second
- Sends answer to server (registers as answerer)
- Sets up ICE candidate handler (before gathering starts)
- Sets local description → ICE gathering begins
- State → `exchanging-ice`
3. **ICE Exchange**:
- Both peers generate ICE candidates
- Candidates are automatically sent to server
- Peers poll and receive remote candidates
3. **ICE Exchange** (Trickle ICE):
- Both peers generate ICE candidates as they're discovered
- Candidates are automatically sent to server immediately
- Peers poll and receive remote candidates (every 1 second)
- ICE establishes the direct P2P path
- State → `connected`
4. **Connection Established**:
- Data channel opens
- Chat messages flow directly between peers
- No server relay (true P2P!)
### Key Features of Implementation
- **Trickle ICE**: Candidates sent immediately as discovered (no waiting)
- **Proper Authorization**: Answer sent to server before ICE gathering to authorize candidate posting
- **Event Cleanup**: All event listeners properly removed with `removeEventListener`
- **State Management**: Clean state machine with well-defined transitions
- **Error Handling**: Graceful failure states with error events
### Architecture
- **Frontend**: React + Vite
- **Signaling**: Rondevu server (Cloudflare Workers + D1)
- **Client**: @xtr-dev/rondevu-client (TypeScript library)
- **WebRTC**: RTCPeerConnection with Google STUN servers
- **WebRTC**: RTCPeerConnection with STUN/TURN servers
- **Connection Management**: RondevuPeer class with state machine
## Server Configuration
@@ -213,18 +260,41 @@ npx wrangler pages deploy dist --project-name=rondevu-demo
- Credentials are stored in localStorage and persist across sessions
- Offers expire after 5 minutes by default
- The connection manager polls automatically (no manual polling needed)
- The peer automatically polls for answers and ICE candidates
- Multiple simultaneous connections are supported
- WebRTC uses Google's public STUN servers for NAT traversal
- WebRTC uses Google's public STUN servers + custom TURN server for NAT traversal
- Data channel messages are unreliable but fast (perfect for chat)
- Connection cleanup is automatic when peers disconnect
## Connection Timeouts
The demo uses these default timeouts:
- **ICE Gathering**: 10 seconds (not used with trickle ICE)
- **Waiting for Answer**: 30 seconds
- **Creating Answer**: 10 seconds
- **ICE Connection**: 30 seconds
These can be customized in the `PeerOptions`:
```javascript
await peer.createOffer({
topics: ['my-topic'],
timeouts: {
waitingForAnswer: 60000, // 1 minute
iceConnection: 45000 // 45 seconds
}
});
```
## Technologies
- **React** - UI framework
- **Vite** - Build tool and dev server
- **@xtr-dev/rondevu-client** - Rondevu client library
- **@xtr-dev/rondevu-client** - Rondevu client library with `RondevuPeer`
- **RTCPeerConnection** - WebRTC connections
- **RTCDataChannel** - P2P messaging
- **QRCode** - QR code generation for easy topic sharing
## License

21
package-lock.json generated
View File

@@ -1,14 +1,14 @@
{
"name": "rondevu-demo",
"version": "0.4.0",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rondevu-demo",
"version": "0.4.0",
"version": "0.5.0",
"dependencies": {
"@xtr-dev/rondevu-client": "^0.4.1",
"@xtr-dev/rondevu-client": "^0.7.4",
"@zxing/library": "^0.21.3",
"qrcode": "^1.5.4",
"react": "^18.2.0",
@@ -1171,9 +1171,18 @@
}
},
"node_modules/@xtr-dev/rondevu-client": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.4.1.tgz",
"integrity": "sha512-8giBS48thHKoIiqD6hD2VpMer50cGg4iwVMRCaaTiC7Ci6ICHXyCorNj6lWgw7dwL56oWhzbZU+cWHlQw2dxyQ==",
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.7.4.tgz",
"integrity": "sha512-MPmw9iSc7LxLduu4TtVrcPvBl/Cuul5sqgOAKUWW7XYXYAObFYUtu9RcbWShR+a6Bwwx7oHadz5I2U8eWsebXQ==",
"license": "MIT",
"dependencies": {
"@xtr-dev/rondevu-client": "^0.5.1"
}
},
"node_modules/@xtr-dev/rondevu-client/node_modules/@xtr-dev/rondevu-client": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.5.1.tgz",
"integrity": "sha512-110ejMCizPUPkHwwwNvcdCSZceLaHeFbf1LNkXvbG6pnLBqCf2uoGOOaRkArb7HNNFABFB+HXzm/AVzNdadosw==",
"license": "MIT"
},
"node_modules/@zxing/library": {

View File

@@ -1,6 +1,6 @@
{
"name": "rondevu-demo",
"version": "0.4.0",
"version": "0.5.0",
"description": "Demo application for Rondevu topic-based peer discovery and signaling",
"type": "module",
"scripts": {
@@ -10,7 +10,7 @@
"deploy": "npm run build && npx wrangler pages deploy dist --project-name=rondevu-demo"
},
"dependencies": {
"@xtr-dev/rondevu-client": "^0.4.1",
"@xtr-dev/rondevu-client": "^0.7.4",
"@zxing/library": "^0.21.3",
"qrcode": "^1.5.4",
"react": "^18.2.0",

View File

@@ -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() {
@@ -34,8 +33,11 @@ export default function App() {
const [myConnections, setMyConnections] = useState([]);
// Discovery state
const [searchTopic, setSearchTopic] = useState('demo-chat');
const [selectedTopic, setSelectedTopic] = useState(null);
const [discoveredOffers, setDiscoveredOffers] = useState([]);
const [topics, setTopics] = useState([]);
const [topicsLoading, setTopicsLoading] = useState(false);
const [topicsError, setTopicsError] = useState(null);
// Messages
const [messages, setMessages] = useState([]);
@@ -70,13 +72,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 +97,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,65 +107,120 @@ 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) => {
console.log(`📡 Data channel received, state: ${channel.readyState}`);
// 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);
channel.onopen = () => {
console.log(`✅ Data channel opened for offer ${peer.offerId}`);
updateConnectionChannel(peer.offerId, channel);
};
channel.onerror = (error) => {
console.error('❌ Data channel error:', error);
};
channel.onclose = () => {
console.log('🔒 Data channel closed');
};
// If already open, update immediately
if (channel.readyState === 'open') {
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}`);
}
};
// Discover peers
const handleDiscoverPeers = async () => {
// Fetch available topics from server
const fetchTopics = async () => {
if (!client) return;
try {
setTopicsLoading(true);
setTopicsError(null);
const result = await client.offers.getTopics({ limit: 100 });
setTopics(result.topics);
} catch (err) {
console.error('Error fetching topics:', err);
setTopicsError(err.message);
} finally {
setTopicsLoading(false);
}
};
// Fetch topics when discover tab is opened
useEffect(() => {
if (activeTab === 'discover' && topics.length === 0 && !topicsLoading && client) {
fetchTopics();
}
}, [activeTab, client]);
// Discover peers by topic
const handleDiscoverPeers = async (topicName) => {
if (!client) return;
if (!client.isAuthenticated()) {
@@ -171,13 +229,14 @@ export default function App() {
}
try {
const offers = await client.offers.findByTopic(searchTopic.trim(), {limit: 50});
setSelectedTopic(topicName);
const offers = await client.offers.findByTopic(topicName, {limit: 50});
setDiscoveredOffers(offers);
if (offers.length === 0) {
toast.error('No peers found!');
toast(`No peers found for "${topicName}"`);
} else {
toast.success(`Found ${offers.length} peer(s)`);
toast.success(`Found ${offers.length} peer(s) for "${topicName}"`);
}
} catch (err) {
toast.error(`Error: ${err.message}`);
@@ -192,56 +251,89 @@ 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) => {
console.log(`📡 Data channel received, state: ${channel.readyState}`);
// 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);
channel.onopen = () => {
console.log(`✅ Data channel opened for offer ${offer.id}`);
updateConnectionChannel(offer.id, channel);
};
channel.onerror = (error) => {
console.error('❌ Data channel error:', error);
};
channel.onclose = () => {
console.log('🔒 Data channel closed');
};
// If already open, update immediately
if (channel.readyState === 'open') {
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 +358,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 +413,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 +502,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 +516,7 @@ export default function App() {
<div style={styles.header}>
<h1 style={styles.title}>🌐 Rondevu</h1>
<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>
{/* Tabs */}
@@ -462,52 +628,122 @@ export default function App() {
{activeTab === 'discover' && (
<div>
<h2>Discover Peers</h2>
<p style={styles.desc}>Search for peers by topic</p>
<p style={styles.desc}>Browse topics to find peers</p>
<div style={{marginBottom: '20px'}}>
<label style={styles.label}>Topic:</label>
<div style={{display: 'flex', gap: '10px'}}>
<input
type="text"
value={searchTopic}
onChange={(e) => setSearchTopic(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleDiscoverPeers()}
style={{...styles.input, flex: 1}}
/>
<button onClick={handleDiscoverPeers} style={styles.btnPrimary}>
🔍 Search
</button>
</div>
</div>
{discoveredOffers.length > 0 && (
{!selectedTopic ? (
<div>
<h3>Found {discoveredOffers.length} Peer(s)</h3>
{discoveredOffers.map(offer => {
const isConnected = myConnections.some(c => c.id === offer.id);
const isMine = credentials && offer.peerId === credentials.peerId;
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px'}}>
<h3>Active Topics ({topics.length})</h3>
<button onClick={fetchTopics} style={styles.btnSecondary} disabled={topicsLoading}>
{topicsLoading ? '⟳ Loading...' : '🔄 Refresh'}
</button>
</div>
return (
<div key={offer.id} style={styles.card}>
<div style={{marginBottom: '10px'}}>
<div style={{fontWeight: '600'}}>{offer.topics.join(', ')}</div>
<div style={{fontSize: '0.85em', color: '#666'}}>
Peer: {offer.peerId.substring(0, 16)}...
{topicsLoading ? (
<div style={{textAlign: 'center', padding: '60px', color: '#999'}}>
<div style={{fontSize: '3em', marginBottom: '10px'}}></div>
<div>Loading topics...</div>
</div>
) : topicsError ? (
<div style={{textAlign: 'center', padding: '40px'}}>
<div style={{...styles.card, background: '#ffebee', color: '#c62828', border: '2px solid #ef9a9a'}}>
<div style={{fontSize: '2em', marginBottom: '10px'}}></div>
<div style={{fontWeight: '600', marginBottom: '5px'}}>Failed to load topics</div>
<div style={{fontSize: '0.9em'}}>{topicsError}</div>
<button onClick={fetchTopics} style={{...styles.btnPrimary, marginTop: '15px'}}>
Try Again
</button>
</div>
</div>
) : topics.length === 0 ? (
<div style={{textAlign: 'center', padding: '60px', color: '#999'}}>
<div style={{fontSize: '3em', marginBottom: '10px'}}>📭</div>
<div style={{fontWeight: '600', marginBottom: '5px'}}>No active topics</div>
<div style={{fontSize: '0.9em'}}>Create an offer to start a new topic</div>
</div>
) : (
<div style={styles.topicsGrid}>
{topics.map(topic => (
<div
key={topic.topic}
className="topic-card-hover"
onClick={() => handleDiscoverPeers(topic.topic)}
>
<div style={{fontSize: '2.5em', marginBottom: '10px'}}>💬</div>
<div style={{fontWeight: '600', marginBottom: '5px', wordBreak: 'break-word'}}>{topic.topic}</div>
<div style={{
fontSize: '0.85em',
color: '#667eea',
fontWeight: '600',
background: '#f0f2ff',
padding: '4px 12px',
borderRadius: '12px',
display: 'inline-block',
marginTop: '5px'
}}>
{topic.activePeers} {topic.activePeers === 1 ? 'peer' : 'peers'}
</div>
</div>
))}
</div>
)}
</div>
) : (
<div>
<div style={{marginBottom: '20px'}}>
<button
onClick={() => {
setSelectedTopic(null);
setDiscoveredOffers([]);
fetchTopics(); // Refresh topics when going back
}}
style={{...styles.btnSecondary, marginBottom: '10px'}}
>
Back to Topics
</button>
<h3>Topic: {selectedTopic}</h3>
</div>
{isMine ? (
<div style={{...styles.badge, background: '#2196f3'}}>Your offer</div>
) : isConnected ? (
<div style={{...styles.badge, background: '#4caf50'}}> Connected</div>
) : (
<button onClick={() => handleAnswerOffer(offer)} style={styles.btnSuccess}>
🤝 Connect
</button>
)}
{discoveredOffers.length > 0 ? (
<div>
<p style={{marginBottom: '15px', color: '#666'}}>
Found {discoveredOffers.length} peer(s)
</p>
{discoveredOffers.map(offer => {
const isConnected = myConnections.some(c => c.id === offer.id);
const isMine = credentials && offer.peerId === credentials.peerId;
return (
<div key={offer.id} style={styles.card}>
<div style={{marginBottom: '10px'}}>
<div style={{fontWeight: '600'}}>{offer.topics.join(', ')}</div>
<div style={{fontSize: '0.85em', color: '#666'}}>
Peer: {offer.peerId.substring(0, 16)}...
</div>
</div>
{isMine ? (
<div style={{...styles.badge, background: '#2196f3'}}>Your offer</div>
) : isConnected ? (
<div style={{...styles.badge, background: '#4caf50'}}> Connected</div>
) : (
<button onClick={() => handleAnswerOffer(offer)} style={styles.btnSuccess}>
🤝 Connect
</button>
)}
</div>
);
})}
</div>
) : (
<div style={{textAlign: 'center', padding: '40px', color: '#999'}}>
<div style={{fontSize: '3em'}}>🔍</div>
<div>No peers available for this topic</div>
<div style={{fontSize: '0.9em', marginTop: '10px'}}>
Try creating an offer or check back later
</div>
);
})}
</div>
)}
</div>
)}
</div>
@@ -720,6 +956,16 @@ const styles = {
fontWeight: '600',
width: '100%'
},
btnSecondary: {
padding: '10px 20px',
background: '#f5f5f5',
color: '#333',
border: '2px solid #e0e0e0',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '0.95em',
fontWeight: '600'
},
btnDanger: {
padding: '12px 24px',
background: '#f44336',
@@ -757,5 +1003,11 @@ const styles = {
color: 'white',
opacity: 0.8,
fontSize: '0.9em'
},
topicsGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '15px',
marginTop: '20px'
}
};

View File

@@ -997,3 +997,22 @@ input[type="text"]:disabled {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(85, 104, 211, 0.4);
}
/* Topic cards grid for discovery */
.topic-card-hover {
padding: 25px;
background: white;
border-radius: 12px;
border: 2px solid #e0e0e0;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.topic-card-hover:hover {
border-color: #667eea;
background: #fafbfc;
transform: translateY(-3px);
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.2);
}