diff --git a/README.md b/README.md index b806b93..54344fb 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,52 @@ -# Rondevu +# Rondevu Demo -🎯 **Simple WebRTC peer signaling and discovery** +🎯 **Interactive WebRTC peer discovery and connection demo** -Meet peers by topic, by peer ID, or by connection ID. +Experience topic-based peer discovery and WebRTC connections using the Rondevu signaling platform. **Related repositories:** -- [rondevu-server](https://github.com/xtr-dev/rondevu-server) - HTTP signaling server +- [rondevu-server](https://github.com/xtr-dev/rondevu) - HTTP signaling server - [rondevu-client](https://github.com/xtr-dev/rondevu-client) - TypeScript client library --- -## Rondevu Demo +## Overview -**Interactive demo showcasing three ways to connect WebRTC peers.** +This demo showcases the complete Rondevu workflow: -Experience how easy WebRTC peer discovery can be with Rondevu's three connection methods: +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 +5. **Chat** - Send messages over WebRTC data channels -🎯 **Connect by Topic** - Auto-discover and join any available peer -👤 **Connect by Peer ID** - Filter and connect to specific peers -🔗 **Connect by Connection ID** - Share a code and connect directly +### Key Features -### 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) +- **Persistent Credentials** - Saves authentication to localStorage +- **Topics Browser** - Browse all active topics and peer counts +- **Multiple Connections** - Support multiple simultaneous peer connections +- **Real-time Chat** - Direct peer-to-peer messaging -- **Three Connection Methods** - Experience topic discovery, peer filtering, and direct connection -- **Real WebRTC** - Actual P2P connections using RTCPeerConnection (not simulated!) -- **P2P Data Channel** - Direct peer-to-peer chat without server relay -- **Peer Discovery** - Browse topics and discover available peers -- **Real-time Chat** - Send and receive messages over WebRTC data channel -- **Activity Log** - Monitor all API and WebRTC events +## Quick Start -### Quick Start - -#### Installation +### Installation ```bash npm install ``` -#### Development +### Development ```bash npm run dev ``` -This will start the Vite dev server at `http://localhost:5173` +This starts the Vite dev server at `http://localhost:5173` -#### Build for Production +### Build for Production ```bash npm run build @@ -53,153 +54,145 @@ npm run build The built files will be in the `dist/` directory. -#### Preview Production Build +### Preview Production Build ```bash npm run preview ``` -### Three Ways to Connect +## How to Use -This demo demonstrates all three Rondevu connection methods: +### Step 1: Register (One-time) -#### 1️⃣ Join Topic (Auto-Discovery) +The demo automatically registers you when you first visit. Your credentials are saved in localStorage for future visits. -**Easiest method** - Just enter a topic and auto-connect to first available peer: +### Step 2: Create an Offer -1. Enter a topic name in the "Join Topic" section (e.g., "demo-room") -2. Click "Join Topic" -3. Rondevu finds the first available peer and connects automatically -4. Start chatting! +1. Go to the "Create Offer" tab +2. Enter one or more topics (comma-separated), e.g., `demo-room, testing` +3. Click "Create Offer" +4. Your offer is now advertised on those topics -**Best for:** Quick matching, joining any available game/chat +**Share the topic name with peers you want to connect with!** ---- +### Step 3: Discover and Connect (Other Peer) -#### 2️⃣ Discover Peers (Filter by Peer ID) +1. Go to the "Discover Offers" tab +2. Enter the same topic (e.g., `demo-room`) +3. Click "Discover Offers" +4. See available peers and their offers +5. Click "Answer Offer" to connect -**Connect to specific peers** - Browse and select which peer to connect to: +### Step 4: Chat -1. Enter a topic name (e.g., "demo-room") -2. Click "Discover in [topic]" to list all available peers -3. See each peer's ID in the list -4. Click "Connect" on the specific peer you want to talk to -5. Start chatting! +1. Once connected, go to the "Chat" tab +2. Select a connection from the dropdown +3. Type messages and hit Enter or click Send +4. Messages are sent **directly peer-to-peer** via WebRTC -**Best for:** Connecting to friends, teammates, or specific users +### Browse Topics ---- +Click the "Topics" tab to: +- See all active topics +- View peer counts for each topic +- Quick-discover by clicking a topic -#### 3️⃣ Create/Connect by ID (Direct Connection) - -**Share a connection code** - Like sharing a meeting link: - -**To create:** -1. Enter a topic name (e.g., "meetings") -2. Enter a custom Connection ID (e.g., "my-meeting-123") or leave blank for auto-generation -3. Click "Create Connection" -4. **Share the Connection ID** with the person you want to connect with - -**To join:** -1. Get the Connection ID from your friend (e.g., "my-meeting-123") -2. Enter it in the "Connect by ID" section -3. Click "Connect to ID" -4. Start chatting! - -**Best for:** Meeting rooms, QR code connections, invitation-based sessions - -#### Testing Locally +## Testing Locally The easiest way to test: -1. Open the demo in **two different browser windows** (or tabs) -2. In window 1: Create an offer with topic "test-room" -3. In window 2: Discover peers in "test-room" and click Connect -4. Watch the connection establish and start chatting! -#### Browse Topics +1. Open the demo in **two browser windows** (or tabs) +2. **Window 1**: Create an offer with topic `test-room` +3. **Window 2**: Discover offers in `test-room` and answer +4. Switch to Chat tab in both windows +5. Start chatting peer-to-peer! -- Click "Refresh Topics" to see all active topics -- Click on any topic to auto-fill the discovery form +## Technical Implementation -### Server Configuration +### Connection Manager -This demo connects to: `https://api.ronde.vu` - -To use a different server, modify the `baseUrl` in `src/main.js`: +This demo uses the high-level `RondevuConnection` class which abstracts all WebRTC complexity: ```javascript -const rdv = new Rondevu({ - baseUrl: 'https://your-server.com' +// Create connection +const conn = client.createConnection(); + +// Set up event listeners +conn.on('connected', () => { + console.log('P2P connection established!'); }); -// Access the API for low-level operations -rdv.api.listTopics(); +conn.on('datachannel', (channel) => { + channel.onmessage = (event) => { + console.log('Message:', event.data); + }; +}); + +// Create offer +await conn.createOffer({ + topics: ['demo-room'], + ttl: 300000 +}); + +// Or answer an offer +await conn.answer(offerId, offerSdp); ``` -### Technologies +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 -- **Vite** - Fast development and build tool -- **@xtr-dev/rondevu-client** - TypeScript client for Rondevu API -- **Vanilla JavaScript** - No framework dependencies +### What Happens Under the Hood -### API Examples +1. **Offerer** calls `conn.createOffer()`: + - Creates RTCPeerConnection + - Generates SDP offer + - Creates data channel + - Posts offer to Rondevu server + - Polls for answers every 2 seconds -The demo showcases all major Rondevu API endpoints: +2. **Answerer** calls `conn.answer()`: + - Creates RTCPeerConnection + - Sets remote description (offer SDP) + - Generates SDP answer + - Posts answer to server + - Polls for ICE candidates every 1 second -- `GET /` - List all topics -- `GET /:topic/sessions` - Discover peers in a topic -- `POST /:topic/offer` - Create a new offer -- `POST /answer` - Send answer to a peer -- `POST /poll` - Poll for peer data -- `GET /health` - Check server health +3. **ICE Exchange**: + - Both peers generate ICE candidates + - Candidates are automatically sent to server + - Peers poll and receive remote candidates + - ICE establishes the direct P2P path -### WebRTC Implementation Details +4. **Connection Established**: + - Data channel opens + - Chat messages flow directly between peers + - No server relay (true P2P!) -This demo implements a **complete WebRTC peer-to-peer connection** with: +### Architecture -#### Connection Flow +- **Frontend**: React + Vite +- **Signaling**: Rondevu server (Cloudflare Workers + D1) +- **Client**: @xtr-dev/rondevu-client (TypeScript library) +- **WebRTC**: RTCPeerConnection with Google STUN servers -1. **Offerer** creates an `RTCPeerConnection` and generates an SDP offer -2. Offer is sent to the Rondevu signaling server via `POST /:topic/offer` -3. **Answerer** discovers the offer via `GET /:topic/sessions` -4. Answerer creates an `RTCPeerConnection`, sets the remote offer, and generates an SDP answer -5. Answer is sent via `POST /answer` -6. Both peers generate ICE candidates and send them via `POST /answer` with `candidate` field -7. Both peers poll via `POST /poll` to receive remote ICE candidates -8. Once candidates are exchanged, the **direct P2P connection** is established -9. Data channel opens and chat messages flow **directly between peers** +## Server Configuration -#### Key Features +This demo connects to: `https://rondevu.xtrdev.workers.dev` -- **Real RTCPeerConnection** - Not simulated, actual WebRTC -- **STUN servers** - Google's public STUN servers for NAT traversal -- **Data Channel** - Named "chat" channel for text messaging -- **ICE Trickle** - Candidates are sent as they're generated -- **Automatic Polling** - Polls every 1 second for remote data -- **Connection States** - Visual indicators for connecting/connected/failed states -- **Graceful Cleanup** - Properly closes connections and stops polling +To use a different server, modify `API_URL` in `src/App.jsx`: -#### Technologies +```javascript +const API_URL = 'https://your-server.com'; +``` -- **RTCPeerConnection API** - Core WebRTC connection -- **RTCDataChannel API** - Unreliable but fast text messaging -- **Rondevu Signaling** - SDP and ICE candidate exchange -- **STUN Protocol** - NAT traversal (stun.l.google.com) +## Deployment -### Development Notes - -- Peer IDs are auto-generated on page load -- WebRTC connections use **real** RTCPeerConnection (not simulated!) -- Sessions expire after the server's configured timeout (5 minutes default) -- The demo is completely client-side (no backend required) -- Messages are sent P2P - the server only facilitates discovery -- Works across different browsers and networks (with STUN support) - -### Deployment - -#### Deploy to Cloudflare Pages - -The demo can be easily deployed to Cloudflare Pages (free tier): +### Deploy to Cloudflare Pages **Quick Deploy via Wrangler:** @@ -216,7 +209,23 @@ npx wrangler pages deploy dist --project-name=rondevu-demo 4. Set output directory: `dist` 5. Deploy automatically on every push! -### License +## Development Notes + +- 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) +- Multiple simultaneous connections are supported +- WebRTC uses Google's public STUN servers for NAT traversal +- Data channel messages are unreliable but fast (perfect for chat) + +## Technologies + +- **React** - UI framework +- **Vite** - Build tool and dev server +- **@xtr-dev/rondevu-client** - Rondevu client library +- **RTCPeerConnection** - WebRTC connections +- **RTCDataChannel** - P2P messaging + +## License MIT - diff --git a/package-lock.json b/package-lock.json index 1a62852..3da7478 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,19 @@ { "name": "rondevu-demo", - "version": "0.3.2", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rondevu-demo", - "version": "0.3.2", + "version": "0.4.0", "dependencies": { - "@xtr-dev/rondevu-client": "^0.3.2", + "@xtr-dev/rondevu-client": "^0.3.5", "@zxing/library": "^0.21.3", "qrcode": "^1.5.4", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-hot-toast": "^2.6.0" }, "devDependencies": { "@types/react": "^18.2.0", @@ -1170,9 +1171,9 @@ } }, "node_modules/@xtr-dev/rondevu-client": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.3.2.tgz", - "integrity": "sha512-qWQDP6L675bLksKrk8HYc1ZNoAe0X/1Fj92Lffh9HPHcoeME7ateXb0mD7KlPNNOem6u210q35FNTiJWuHEyuw==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.3.5.tgz", + "integrity": "sha512-5dnG4P0FJgaUGPDnNoT4wM4hgdZc+rnBxxVUx+xAxSkDTRSM7UsJFFYlJtflvnVOcVZEhS/x525AOV3LMAXBgw==", "license": "MIT" }, "node_modules/@zxing/library": { @@ -1335,7 +1336,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -1480,6 +1480,15 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1720,6 +1729,23 @@ "react": "^18.3.1" } }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", diff --git a/package.json b/package.json index 0296914..2d35314 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "rondevu-demo", - "version": "0.3.2", - "description": "Demo application for Rondevu peer signaling and discovery", + "version": "0.4.0", + "description": "Demo application for Rondevu topic-based peer discovery and signaling", "type": "module", "scripts": { "dev": "vite", @@ -10,11 +10,12 @@ "deploy": "npm run build && npx wrangler pages deploy dist --project-name=rondevu-demo" }, "dependencies": { - "@xtr-dev/rondevu-client": "^0.3.2", + "@xtr-dev/rondevu-client": "^0.3.5", "@zxing/library": "^0.21.3", "qrcode": "^1.5.4", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-hot-toast": "^2.6.0" }, "devDependencies": { "@types/react": "^18.2.0", diff --git a/src/App.jsx b/src/App.jsx index 03fd7ce..9824523 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,480 +1,680 @@ -import { useState, useEffect, useRef } from 'react'; -import { Rondevu } from '@xtr-dev/rondevu-client'; -import QRCode from 'qrcode'; -import Header from './components/Header'; -import ActionSelector from './components/ActionSelector'; -import ConnectionForm from './components/ConnectionForm'; -import ChatView from './components/ChatView'; +import React, {useState, useEffect} from 'react'; +import {Rondevu} from '@xtr-dev/rondevu-client'; +import toast, {Toaster} from 'react-hot-toast'; -const rdv = new Rondevu({ - baseUrl: 'https://api.ronde.vu', - rtcConfig: { - iceServers: [ - { urls: 'stun:stun.l.google.com:19302' }, - { urls: 'stun:stun1.l.google.com:19302' }, - { - urls: 'turn:relay1.expressturn.com:3480', - username: 'ef13B1E5PH265HK1N2', - credential: 'TTcTPEy3ndxsS0Gp' - } - ] - } -}); +const API_URL = 'https://rondevu.xtrdev.workers.dev'; -// Generate a random 6-digit string -function generateConnectionId() { - const chars = '23456789abcdefghjkmnopqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ'; - let id = ''; - for (let i = 0; i < 6; i++) { - id += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return id; -} +const RTC_CONFIG = { + iceServers: [ + { + urls: "stun:stun.relay.metered.ca:80", + }, + { + urls: "turn:standard.relay.metered.ca:80", + username: "e03a51621b4f11ffbed3addd", + credential: "QPjJzPau1Ng5S0dq", + }, + { + urls: "turn:standard.relay.metered.ca:80?transport=tcp", + username: "e03a51621b4f11ffbed3addd", + credential: "QPjJzPau1Ng5S0dq", + }, + { + urls: "turn:standard.relay.metered.ca:443", + username: "e03a51621b4f11ffbed3addd", + credential: "QPjJzPau1Ng5S0dq", + }, + { + urls: "turns:standard.relay.metered.ca:443?transport=tcp", + username: "e03a51621b4f11ffbed3addd", + credential: "QPjJzPau1Ng5S0dq", + }, + ] +}; -function App() { - // Step-based state - const [step, setStep] = useState(1); // 1: action, 2: details, 3: connected - const [action, setAction] = useState(null); // 'create' or 'connect' - const [qrCodeUrl, setQrCodeUrl] = useState(''); +export default function App() { + const [client, setClient] = useState(null); + const [credentials, setCredentials] = useState(null); + const [activeTab, setActiveTab] = useState('setup'); + const [status, setStatus] = useState('Not registered'); - // Connection state - const [connectionId, setConnectionId] = useState(''); - const [connectionStatus, setConnectionStatus] = useState('disconnected'); - const [connectedPeer, setConnectedPeer] = useState(null); - const [currentConnectionId, setCurrentConnectionId] = useState(null); + // Offer state + const [offerTopics, setOfferTopics] = useState('demo-chat'); + const [myConnections, setMyConnections] = useState([]); - // Chat state + // Discovery state + const [searchTopic, setSearchTopic] = useState('demo-chat'); + const [discoveredOffers, setDiscoveredOffers] = useState([]); + + // Messages const [messages, setMessages] = useState([]); const [messageInput, setMessageInput] = useState(''); - const [logs, setLogs] = useState([]); - const [channelReady, setChannelReady] = useState(false); - const [fileUploadProgress, setFileUploadProgress] = useState(null); - - // Version state - const [demoVersion, setDemoVersion] = useState('unknown'); - const [serverVersion, setServerVersion] = useState('unknown'); - - const connectionRef = useRef(null); - const dataChannelRef = useRef(null); - const fileTransfersRef = useRef(new Map()); // Track ongoing file transfers - const uploadCancelRef = useRef(false); + // Load credentials useEffect(() => { - log('Demo initialized', 'info'); - loadVersions(); + const saved = localStorage.getItem('rondevu-credentials'); + if (saved) { + const creds = JSON.parse(saved); + setCredentials(creds); + setClient(new Rondevu({baseUrl: API_URL, credentials: creds})); + setStatus('Registered (from storage)'); + } else { + setClient(new Rondevu({baseUrl: API_URL})); + } }, []); - const log = (message, type = 'info') => { - const timestamp = new Date().toLocaleTimeString(); - setLogs(prev => [...prev, { message, type, timestamp }]); - }; - - const loadVersions = async () => { - // Get demo version from build environment - setDemoVersion(import.meta.env.VITE_VERSION || 'unknown'); - - // Get server version from API + // Register + const handleRegister = async () => { + if (!client) return; try { - const { version } = await rdv.api.getVersion(); - setServerVersion(version); - } catch (error) { - log(`Error loading server version: ${error.message}`, 'error'); + setStatus('Registering...'); + const creds = await client.register(); + setCredentials(creds); + localStorage.setItem('rondevu-credentials', JSON.stringify(creds)); + setClient(new Rondevu({baseUrl: API_URL, credentials: creds})); + setStatus('Registered!'); + setActiveTab('offer'); + } catch (err) { + setStatus(`Error: ${err.message}`); } }; - const setupConnection = (connection) => { - connectionRef.current = connection; - - connection.on('connect', () => { - log('✅ Connected!', 'success'); - setConnectionStatus('connected'); - setStep(3); - - const channel = connection.dataChannel('chat'); - setupDataChannel(channel); - }); - - connection.on('disconnect', () => { - log('Disconnected', 'info'); - reset(); - }); - - connection.on('error', (error) => { - log(`Error: ${error.message}`, 'error'); - if (error.message.includes('timeout')) { - reset(); - } - }); - - connection.on('datachannel', (channel) => { - if (channel.label === 'chat') { - setupDataChannel(channel); - } - }); - }; - - const setupDataChannel = (channel) => { - dataChannelRef.current = channel; - - channel.onopen = () => { - log('Data channel ready', 'success'); - setChannelReady(true); - }; - - channel.onclose = () => { - log('Data channel closed', 'info'); - setChannelReady(false); - }; - - channel.onmessage = (event) => { - handleReceivedMessage(event.data); - }; - - // If channel is already open (for channels we create) - if (channel.readyState === 'open') { - log('Data channel ready', 'success'); - setChannelReady(true); - } - }; - - const handleConnect = async () => { - try { - setConnectionStatus('connecting'); - log('Connecting...', 'info'); - - let connection; - let connId; - - if (action === 'create') { - connId = connectionId || generateConnectionId(); - connection = await rdv.create(connId); - setCurrentConnectionId(connId); - log(`Created connection: ${connId}`, 'success'); - - // Generate QR code if creating a connection - try { - const qrUrl = await QRCode.toDataURL(connId, { - width: 256, - margin: 2, - color: { - dark: '#667eea', - light: '#ffffff' - } - }); - setQrCodeUrl(qrUrl); - log('QR code generated', 'success'); - } catch (err) { - log(`QR code generation error: ${err.message}`, 'error'); - } - } else { - connection = await rdv.connect(connectionId); - setCurrentConnectionId(connectionId); - } - - setConnectedPeer(connection.remotePeerId || 'Waiting...'); - setupConnection(connection); - } catch (error) { - log(`Error: ${error.message}`, 'error'); - setConnectionStatus('disconnected'); - } - }; - - const sendMessage = () => { - if (!messageInput || !channelReady || !dataChannelRef.current) { + // Create offer with connection manager + const handleCreateOffer = async () => { + if (!client || !credentials) { + toast.error('Please register first!'); return; } - const message = { type: 'text', content: messageInput }; - dataChannelRef.current.send(JSON.stringify(message)); + try { + const topics = offerTopics.split(',').map(t => t.trim()).filter(Boolean); + + // Create connection using the manager + const conn = client.createConnection(RTC_CONFIG); + + // Setup event listeners + conn.on('connecting', () => { + updateConnectionStatus(conn.id, 'connecting'); + }); + + conn.on('connected', () => { + updateConnectionStatus(conn.id, 'connected'); + }); + + conn.on('disconnected', () => { + updateConnectionStatus(conn.id, 'disconnected'); + }); + + conn.on('datachannel', (channel) => { + // Handle data channel + channel.onmessage = (event) => { + setMessages(prev => [...prev, { + from: 'peer', + text: event.data, + timestamp: Date.now(), + connId: conn.id + }]); + }; + + updateConnectionChannel(conn.id, channel); + }); + + // Create offer + const offerId = await conn.createOffer({ + topics, + ttl: 300000 + }); + + // Add to connections list + setMyConnections(prev => [...prev, { + id: offerId, + topics, + status: 'waiting', + role: 'offerer', + conn, + channel: conn.channel + }]); + + setOfferTopics(''); + toast.success(`Created offer! Share topic "${topics[0]}" with peers.`); + } catch (err) { + toast.error(`Error: ${err.message}`); + } + }; + + // Discover peers + const handleDiscoverPeers = async () => { + if (!client) return; + + try { + const offers = await client.offers.findByTopic(searchTopic.trim(), {limit: 50}); + setDiscoveredOffers(offers); + + if (offers.length === 0) { + toast.error('No peers found!'); + } else { + toast.success(`Found ${offers.length} peer(s)`); + } + } catch (err) { + toast.error(`Error: ${err.message}`); + } + }; + + // Answer an offer + const handleAnswerOffer = async (offer) => { + if (!client || !credentials) { + toast.error('Please register first!'); + return; + } + + try { + // Create connection using the manager + const conn = client.createConnection(RTC_CONFIG); + + // Setup event listeners + conn.on('connecting', () => { + updateConnectionStatus(conn.id, 'connecting'); + }); + + conn.on('connected', () => { + updateConnectionStatus(conn.id, 'connected'); + }); + + conn.on('disconnected', () => { + updateConnectionStatus(conn.id, 'disconnected'); + }); + + conn.on('datachannel', (channel) => { + // Handle data channel + channel.onmessage = (event) => { + setMessages(prev => [...prev, { + from: 'peer', + text: event.data, + timestamp: Date.now(), + connId: conn.id + }]); + }; + + updateConnectionChannel(conn.id, channel); + }); + + // Answer the offer + await conn.answer(offer.id, offer.sdp); + + // Add to connections list + setMyConnections(prev => [...prev, { + id: offer.id, + topics: offer.topics, + status: 'connecting', + role: 'answerer', + conn, + channel: null + }]); + + setActiveTab('connections'); + toast.success('Connecting...'); + } catch (err) { + toast.error(`Error: ${err.message}`); + } + }; + + // Helper functions + const updateConnectionStatus = (connId, status) => { + setMyConnections(prev => prev.map(c => + c.id === connId ? {...c, status} : c + )); + }; + + const updateConnectionChannel = (connId, channel) => { + setMyConnections(prev => prev.map(c => + c.id === connId ? {...c, channel} : c + )); + }; + + // Send message + const handleSendMessage = (connection) => { + if (!messageInput.trim() || !connection.channel) return; + + if (connection.channel.readyState !== 'open') { + toast.error('Channel not open yet!'); + return; + } + + connection.channel.send(messageInput); setMessages(prev => [...prev, { + from: 'me', text: messageInput, - messageType: 'text', - type: 'sent', - timestamp: new Date() + timestamp: Date.now(), + connId: connection.id }]); setMessageInput(''); }; - const handleFileSelect = async (event) => { - const file = event.target.files[0]; - if (!file || !channelReady || !dataChannelRef.current) { - return; - } - - const CHUNK_SIZE = 16384; // 16KB chunks - const fileId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const totalChunks = Math.ceil(file.size / CHUNK_SIZE); - - uploadCancelRef.current = false; - setFileUploadProgress({ fileName: file.name, progress: 0 }); - - log(`Sending file: ${file.name} (${(file.size / 1024).toFixed(2)} KB)`, 'info'); - - try { - // Send file metadata - const metadata = { - type: 'file-start', - fileId, - name: file.name, - size: file.size, - mimeType: file.type, - chunks: totalChunks - }; - dataChannelRef.current.send(JSON.stringify(metadata)); - - // Read and send file in chunks - const reader = new FileReader(); - let offset = 0; - let chunkIndex = 0; - - const readChunk = () => { - if (uploadCancelRef.current) { - setFileUploadProgress(null); - log('File upload cancelled', 'info'); - return; - } - - const slice = file.slice(offset, offset + CHUNK_SIZE); - reader.readAsArrayBuffer(slice); - }; - - reader.onload = (e) => { - if (uploadCancelRef.current) { - setFileUploadProgress(null); - return; - } - - const chunk = { - type: 'file-chunk', - fileId, - index: chunkIndex, - data: Array.from(new Uint8Array(e.target.result)) - }; - dataChannelRef.current.send(JSON.stringify(chunk)); - - offset += CHUNK_SIZE; - chunkIndex++; - - // Update progress - const progress = Math.round((chunkIndex / totalChunks) * 100); - setFileUploadProgress({ fileName: file.name, progress }); - - if (offset < file.size) { - readChunk(); - } else { - // Send completion message - const complete = { type: 'file-complete', fileId }; - dataChannelRef.current.send(JSON.stringify(complete)); - - // Add to local messages - setMessages(prev => [...prev, { - messageType: 'file', - file: { - name: file.name, - size: file.size, - mimeType: file.type, - data: file - }, - type: 'sent', - timestamp: new Date() - }]); - - setFileUploadProgress(null); - log(`File sent: ${file.name}`, 'success'); - } - }; - - reader.onerror = () => { - setFileUploadProgress(null); - log(`Error reading file: ${file.name}`, 'error'); - }; - - readChunk(); - } catch (error) { - setFileUploadProgress(null); - log(`Error sending file: ${error.message}`, 'error'); - } - - // Reset file input - event.target.value = ''; - }; - - const cancelFileUpload = () => { - uploadCancelRef.current = true; - setFileUploadProgress(null); - }; - - const handleReceivedMessage = (data) => { - try { - const message = JSON.parse(data); - - if (message.type === 'text') { - setMessages(prev => [...prev, { - text: message.content, - messageType: 'text', - type: 'received', - timestamp: new Date() - }]); - } else if (message.type === 'file-start') { - fileTransfersRef.current.set(message.fileId, { - name: message.name, - size: message.size, - mimeType: message.mimeType, - chunks: new Array(message.chunks), - receivedChunks: 0 - }); - log(`Receiving file: ${message.name}`, 'info'); - } else if (message.type === 'file-chunk') { - const transfer = fileTransfersRef.current.get(message.fileId); - if (transfer) { - transfer.chunks[message.index] = new Uint8Array(message.data); - transfer.receivedChunks++; - } - } else if (message.type === 'file-complete') { - const transfer = fileTransfersRef.current.get(message.fileId); - if (transfer) { - // Combine all chunks - const totalSize = transfer.chunks.reduce((sum, chunk) => sum + chunk.length, 0); - const combined = new Uint8Array(totalSize); - let offset = 0; - for (const chunk of transfer.chunks) { - combined.set(chunk, offset); - offset += chunk.length; - } - - const blob = new Blob([combined], { type: transfer.mimeType }); - - setMessages(prev => [...prev, { - messageType: 'file', - file: { - name: transfer.name, - size: transfer.size, - mimeType: transfer.mimeType, - data: blob - }, - type: 'received', - timestamp: new Date() - }]); - - log(`File received: ${transfer.name}`, 'success'); - fileTransfersRef.current.delete(message.fileId); - } - } - } catch (error) { - // Assume it's a plain text message (backward compatibility) - setMessages(prev => [...prev, { - text: data, - messageType: 'text', - type: 'received', - timestamp: new Date() - }]); - } - }; - - const downloadFile = (file) => { - const url = URL.createObjectURL(file.data); - const a = document.createElement('a'); - a.href = url; - a.download = file.name; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - }; - - const reset = () => { - if (connectionRef.current) { - connectionRef.current.close(); - } - setStep(1); - setAction(null); - setConnectionId(''); - setConnectionStatus('disconnected'); - setConnectedPeer(null); - setCurrentConnectionId(null); + // Clear credentials + const handleClearCredentials = () => { + localStorage.removeItem('rondevu-credentials'); + setCredentials(null); + setStatus('Not registered'); + myConnections.forEach(c => c.conn?.close()); + setMyConnections([]); setMessages([]); - setChannelReady(false); - setQrCodeUrl(''); - connectionRef.current = null; - dataChannelRef.current = null; - }; - - const handleScanComplete = (scannedId) => { - setConnectionId(scannedId); - setAction('connect'); - setStep(2); - }; - - const handleScanCancel = () => { - setAction(null); + setClient(new Rondevu({baseUrl: API_URL})); }; return ( -
-
- -
- {step === 1 && ( - { - setAction(selectedAction); - if (selectedAction !== 'scan') { - setStep(2); - } - }} - onScanComplete={handleScanComplete} - onScanCancel={handleScanCancel} - log={log} - /> - )} - - {step === 2 && ( - setStep(1)} - /> - )} - - {step === 3 && ( - - )} - -
Your Peer ID: {rdv.peerId}
-
- -
-
- - ronde.vu - -
- Demo: {demoVersion} | Server: {serverVersion} -
+
+ +
+ {/* Header */} +
+

🌐 Rondevu

+

Topic-Based Peer Discovery & WebRTC

+

v0.4.0 - With Connection Manager

-
+ + {/* Tabs */} +
+ {[ + {id: 'setup', label: '1️⃣ Setup', icon: '⚙️'}, + {id: 'offer', label: '2️⃣ Create', icon: '📤'}, + {id: 'discover', label: '3️⃣ Discover', icon: '🔍'}, + {id: 'connections', label: '4️⃣ Chat', icon: '💬'} + ].map(tab => ( + + ))} +
+ + {/* Content */} +
+ {/* Setup Tab */} + {activeTab === 'setup' && ( +
+

Registration

+

Get your credentials to start connecting

+ +
+
Status: {status}
+ {credentials && ( +
+
Peer ID: {credentials.peerId.substring(0, 20)}...
+
+ )} +
+ + {!credentials ? ( + + ) : ( +
+ + +
+ )} +
+ )} + + {/* Create Offer Tab */} + {activeTab === 'offer' && ( +
+

Create Offer

+

Create a WebRTC offer that peers can discover

+ + {!credentials ? ( +
+ ⚠️ Please register first +
+ ) : ( + <> +
+ + setOfferTopics(e.target.value)} + placeholder="demo-chat, file-share" + style={styles.input} + /> +
+ + + + {myConnections.filter(c => c.role === 'offerer').length > 0 && ( +
+

My Offers ({myConnections.filter(c => c.role === 'offerer').length})

+ {myConnections.filter(c => c.role === 'offerer').map(conn => ( +
+
+
+
{conn.topics.join(', ')}
+
+ ID: {conn.id.substring(0, 12)}... +
+
+
+ {conn.status} +
+
+
+ ))} +
+ )} + + )} +
+ )} + + {/* Discover Tab */} + {activeTab === 'discover' && ( +
+

Discover Peers

+

Search for peers by topic

+ +
+ +
+ setSearchTopic(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleDiscoverPeers()} + style={{...styles.input, flex: 1}} + /> + +
+
+ + {discoveredOffers.length > 0 && ( +
+

Found {discoveredOffers.length} Peer(s)

+ {discoveredOffers.map(offer => { + const isConnected = myConnections.some(c => c.id === offer.id); + const isMine = credentials && offer.peerId === credentials.peerId; + + return ( +
+
+
{offer.topics.join(', ')}
+
+ Peer: {offer.peerId.substring(0, 16)}... +
+
+ + {isMine ? ( +
Your offer
+ ) : isConnected ? ( +
✓ Connected
+ ) : ( + + )} +
+ ); + })} +
+ )} +
+ )} + + {/* Connections Tab */} + {activeTab === 'connections' && ( +
+

Active Connections

+

Chat with connected peers

+ + {myConnections.length === 0 ? ( +
+
🔌
+
No connections yet
+
+ ) : ( +
+ {myConnections.map(conn => { + const connMessages = messages.filter(m => m.connId === conn.id); + + return ( +
+
+
+
{conn.topics.join(', ')}
+
Role: {conn.role}
+
+
+ {conn.status === 'connected' ? '🟢 Connected' : + conn.status === 'connecting' ? '🟡 Connecting' : '⚪ Waiting'} +
+
+ + {conn.status === 'connected' && ( + <> +
+ {connMessages.length === 0 ? ( +
+ No messages yet. Say hi! +
+ ) : ( + connMessages.map((msg, i) => ( +
+
+
{msg.text}
+
+ {new Date(msg.timestamp).toLocaleTimeString()} +
+
+
+ )) + )} +
+ +
+ setMessageInput(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleSendMessage(conn)} + placeholder="Type a message..." + style={{...styles.input, flex: 1, margin: 0}} + /> + +
+ + )} +
+ ); + })} +
+ )} +
+ )} +
+ + {/* Footer */} +
+

Server: {API_URL}

+

Open in multiple tabs to test peer-to-peer connections

+
+
); } -export default App; +const styles = { + container: { + minHeight: '100vh', + background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', + padding: '20px', + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' + }, + inner: { + maxWidth: '1200px', + margin: '0 auto' + }, + header: { + textAlign: 'center', + marginBottom: '40px', + color: 'white' + }, + title: { + fontSize: '3em', + margin: '0 0 10px 0', + fontWeight: '700' + }, + subtitle: { + fontSize: '1.2em', + opacity: 0.9, + margin: 0 + }, + version: { + fontSize: '0.9em', + opacity: 0.7, + margin: '10px 0 0 0' + }, + tabs: { + display: 'flex', + gap: '10px', + marginBottom: '20px', + flexWrap: 'wrap', + justifyContent: 'center' + }, + tab: { + padding: '12px 24px', + background: 'rgba(255,255,255,0.2)', + color: 'white', + border: 'none', + borderRadius: '8px', + cursor: 'pointer', + fontSize: '1em', + fontWeight: '600', + transition: 'all 0.3s' + }, + tabActive: { + background: 'white', + color: '#667eea', + boxShadow: '0 4px 12px rgba(0,0,0,0.15)' + }, + content: { + background: 'white', + borderRadius: '16px', + padding: '30px', + boxShadow: '0 10px 40px rgba(0,0,0,0.2)', + minHeight: '500px' + }, + desc: { + color: '#666', + marginBottom: '20px' + }, + card: { + padding: '15px', + background: 'white', + borderRadius: '8px', + border: '1px solid #e0e0e0', + boxShadow: '0 2px 4px rgba(0,0,0,0.05)', + marginBottom: '10px' + }, + label: { + display: 'block', + marginBottom: '8px', + fontWeight: '600', + color: '#333' + }, + input: { + width: '100%', + padding: '12px', + fontSize: '1em', + border: '2px solid #e0e0e0', + borderRadius: '8px', + boxSizing: 'border-box', + outline: 'none', + marginBottom: '10px' + }, + btnPrimary: { + padding: '12px 24px', + background: '#667eea', + color: 'white', + border: 'none', + borderRadius: '8px', + cursor: 'pointer', + fontSize: '1em', + fontWeight: '600', + boxShadow: '0 4px 12px rgba(102, 126, 234, 0.3)' + }, + btnSuccess: { + padding: '10px 20px', + background: '#4caf50', + color: 'white', + border: 'none', + borderRadius: '8px', + cursor: 'pointer', + fontSize: '0.95em', + fontWeight: '600', + width: '100%' + }, + btnDanger: { + padding: '12px 24px', + background: '#f44336', + color: 'white', + border: 'none', + borderRadius: '8px', + cursor: 'pointer', + fontSize: '1em', + fontWeight: '600' + }, + badge: { + padding: '6px 14px', + color: 'white', + borderRadius: '12px', + fontSize: '0.85em', + fontWeight: '600' + }, + messages: { + height: '200px', + overflowY: 'auto', + padding: '10px', + background: '#f5f5f5', + borderRadius: '8px', + marginBottom: '10px' + }, + message: { + maxWidth: '70%', + padding: '8px 12px', + borderRadius: '8px', + boxShadow: '0 1px 2px rgba(0,0,0,0.1)' + }, + footer: { + marginTop: '40px', + textAlign: 'center', + color: 'white', + opacity: 0.8, + fontSize: '0.9em' + } +};