# Rondevu Client [![npm version](https://img.shields.io/npm/v/@xtr-dev/rondevu-client)](https://www.npmjs.com/package/@xtr-dev/rondevu-client) 🌐 **Simple WebRTC signaling client with username-based discovery** TypeScript/JavaScript client for Rondevu, providing WebRTC signaling with username claiming, service publishing/discovery, and efficient batch polling. **Related repositories:** - [@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)) --- ## Features - **Username Claiming**: Secure ownership with Ed25519 signatures - **Anonymous Users**: Auto-generated anonymous usernames for quick testing - **Service Publishing**: Publish services with multiple offers for connection pooling - **Service Discovery**: Direct lookup, random discovery, or paginated search - **Efficient Batch Polling**: Single endpoint for answers and ICE candidates (50% fewer requests) - **Semantic Version Matching**: Compatible version resolution (chat:1.0.0 matches any 1.x.x) - **TypeScript**: Full type safety and autocomplete - **Keypair Management**: Generate or reuse Ed25519 keypairs - **Automatic Signatures**: All authenticated requests signed automatically ## Installation ```bash npm install @xtr-dev/rondevu-client ``` ## Quick Start ### Publishing a Service (Offerer) ```typescript import { Rondevu } from '@xtr-dev/rondevu-client' // 1. Connect to Rondevu with ICE server preset (generates keypair, username auto-claimed on first request) const rondevu = await Rondevu.connect({ apiUrl: 'https://api.ronde.vu', username: 'alice', // Or omit for anonymous username iceServers: 'ipv4-turn' // Use preset: 'ipv4-turn', 'hostname-turns', 'google-stun', or 'relay-only' }) // 2. Publish service with custom offer factory for event handling await rondevu.publishService({ service: 'chat:1.0.0', maxOffers: 5, // Maintain up to 5 concurrent offers offerFactory: async (rtcConfig) => { const pc = new RTCPeerConnection(rtcConfig) const dc = pc.createDataChannel('chat') // Set up event listeners during creation dc.addEventListener('open', () => { console.log('Connection opened!') dc.send('Hello from Alice!') }) dc.addEventListener('message', (e) => { console.log('Received message:', e.data) }) const offer = await pc.createOffer() await pc.setLocalDescription(offer) return { pc, dc, offer } }, ttl: 300000 }) // 3. Start accepting connections (auto-fills offers and polls) await rondevu.startFilling() // 4. Stop when done // rondevu.stopFilling() ``` ### Connecting to a Service (Answerer) **Automatic mode (recommended):** ```typescript import { Rondevu } from '@xtr-dev/rondevu-client' // 1. Connect to Rondevu with ICE server preset const rondevu = await Rondevu.connect({ apiUrl: 'https://api.ronde.vu', username: 'bob', iceServers: 'ipv4-turn' }) // 2. Connect to service (automatic setup) const connection = await rondevu.connectToService({ serviceFqn: 'chat:1.0.0@alice', onConnection: ({ dc, peerUsername }) => { console.log('Connected to', peerUsername) dc.addEventListener('message', (e) => { console.log('Received:', e.data) }) dc.addEventListener('open', () => { dc.send('Hello from Bob!') }) } }) // Access connection connection.dc.send('Another message') connection.pc.close() // Close when done ``` **Manual mode (legacy):** ```typescript import { Rondevu } from '@xtr-dev/rondevu-client' // 1. Connect to Rondevu const rondevu = await Rondevu.connect({ apiUrl: 'https://api.ronde.vu', username: 'bob', iceServers: 'ipv4-turn' }) // 2. Get service offer const serviceData = await rondevu.getService('chat:1.0.0@alice') // 3. Create peer connection const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }) // 4. Set remote offer and create answer await pc.setRemoteDescription({ type: 'offer', sdp: serviceData.sdp }) const answer = await pc.createAnswer() await pc.setLocalDescription(answer) // 5. Send answer await rondevu.postOfferAnswer( serviceData.serviceFqn, serviceData.offerId, answer.sdp ) // 6. Send ICE candidates pc.onicecandidate = (event) => { if (event.candidate) { rondevu.addOfferIceCandidates( serviceData.serviceFqn, serviceData.offerId, [event.candidate.toJSON()] ) } } // 7. Poll for ICE candidates let lastIceTimestamp = 0 const iceInterval = setInterval(async () => { const result = await rondevu.getOfferIceCandidates( serviceData.serviceFqn, serviceData.offerId, lastIceTimestamp ) for (const item of result.candidates) { if (item.candidate) { await pc.addIceCandidate(new RTCIceCandidate(item.candidate)) lastIceTimestamp = item.createdAt } } }, 1000) // 8. Handle data channel pc.ondatachannel = (event) => { const dc = event.channel dc.onmessage = (event) => { console.log('Received:', event.data) } dc.onopen = () => { dc.send('Hello from Bob!') } } ``` ## API Reference ### Rondevu Class Main class for all Rondevu operations. ```typescript import { Rondevu } from '@xtr-dev/rondevu-client' // Create and connect to Rondevu const rondevu = await Rondevu.connect({ apiUrl: string, // Signaling server URL username?: string, // Optional: your username (auto-generates anonymous if omitted) keypair?: Keypair, // Optional: reuse existing keypair cryptoAdapter?: CryptoAdapter // Optional: platform-specific crypto (defaults to WebCryptoAdapter) batching?: BatcherOptions | false // Optional: RPC batching configuration }) ``` #### Platform Support (Browser & Node.js) The client supports both browser and Node.js environments using crypto adapters: **Browser (default):** ```typescript import { Rondevu } from '@xtr-dev/rondevu-client' // WebCryptoAdapter is used by default - no configuration needed const rondevu = await Rondevu.connect({ apiUrl: 'https://api.ronde.vu', username: 'alice' }) ``` **Node.js (19+ or 18 with --experimental-global-webcrypto):** ```typescript import { Rondevu, NodeCryptoAdapter } from '@xtr-dev/rondevu-client' const rondevu = await Rondevu.connect({ apiUrl: 'https://api.ronde.vu', username: 'alice', cryptoAdapter: new NodeCryptoAdapter() }) ``` **Note:** Node.js support requires: - Node.js 19+ (crypto.subtle available globally), OR - Node.js 18 with `--experimental-global-webcrypto` flag - WebRTC implementation like `wrtc` or `node-webrtc` for RTCPeerConnection **Custom Crypto Adapter:** ```typescript import { CryptoAdapter, Keypair } from '@xtr-dev/rondevu-client' class CustomCryptoAdapter implements CryptoAdapter { async generateKeypair(): Promise { /* ... */ } async signMessage(message: string, privateKey: string): Promise { /* ... */ } async verifySignature(message: string, signature: string, publicKey: string): Promise { /* ... */ } bytesToBase64(bytes: Uint8Array): string { /* ... */ } base64ToBytes(base64: string): Uint8Array { /* ... */ } randomBytes(length: number): Uint8Array { /* ... */ } } const rondevu = await Rondevu.connect({ apiUrl: 'https://api.ronde.vu', cryptoAdapter: new CustomCryptoAdapter() }) ``` #### Username Management Usernames are **automatically claimed** on the first authenticated request (like `publishService()`). ```typescript // Check if username is claimed (checks server) await rondevu.isUsernameClaimed(): Promise // Get username rondevu.getUsername(): string // Get public key rondevu.getPublicKey(): string // Get keypair (for backup/storage) rondevu.getKeypair(): Keypair ``` #### Service Publishing ```typescript // Publish service with offers await rondevu.publishService({ service: string, // e.g., 'chat:1.0.0' (username auto-appended) offers: Array<{ sdp: string }>, ttl?: number // Optional: milliseconds (default: 300000) }): Promise ``` #### Service Discovery ```typescript // Direct lookup by FQN (with username) await rondevu.getService('chat:1.0.0@alice'): Promise // Random discovery (without username) await rondevu.discoverService('chat:1.0.0'): Promise // Paginated discovery (returns multiple offers) await rondevu.discoverServices( 'chat:1.0.0', // serviceVersion 10, // limit 0 // offset ): Promise<{ services: ServiceOffer[], count: number, limit: number, offset: number }> ``` #### WebRTC Signaling ```typescript // Post answer SDP await rondevu.postOfferAnswer( serviceFqn: string, offerId: string, sdp: string ): Promise<{ success: boolean, offerId: string }> // Get answer SDP (offerer polls this - deprecated, use pollOffers instead) await rondevu.getOfferAnswer( serviceFqn: string, offerId: string ): Promise<{ sdp: string, offerId: string, answererId: string, answeredAt: number } | null> // Combined polling for answers and ICE candidates (RECOMMENDED for offerers) await rondevu.pollOffers(since?: number): Promise<{ answers: Array<{ offerId: string serviceId?: string answererId: string sdp: string answeredAt: number }> iceCandidates: Record> }> // Add ICE candidates await rondevu.addOfferIceCandidates( serviceFqn: string, offerId: string, candidates: RTCIceCandidateInit[] ): Promise<{ count: number, offerId: string }> // Get ICE candidates (with polling support) await rondevu.getOfferIceCandidates( serviceFqn: string, offerId: string, since: number = 0 ): Promise<{ candidates: IceCandidate[], offerId: string }> ``` ### RondevuSignaler Class Higher-level signaling abstraction with automatic polling and event listeners. ```typescript import { RondevuSignaler } from '@xtr-dev/rondevu-client' const signaler = new RondevuSignaler( rondevu: Rondevu, service: string, // Service FQN without username (e.g., 'chat:1.0.0') host?: string, // Optional: target username for answerer pollingConfig?: { initialInterval?: number // Default: 500ms maxInterval?: number // Default: 5000ms backoffMultiplier?: number // Default: 1.5 maxRetries?: number // Default: 50 jitter?: boolean // Default: true } ) ``` #### Offerer Side ```typescript // Set offer (automatically starts polling for answer and ICE) await signaler.setOffer(offer: RTCSessionDescriptionInit): Promise // Listen for answer const unbind = signaler.addAnswerListener((answer) => { console.log('Received answer:', answer) }) // Listen for ICE candidates signaler.addListener((candidate) => { console.log('Received ICE candidate:', candidate) }) // Send ICE candidate await signaler.addIceCandidate(candidate: RTCIceCandidate): Promise ``` #### Answerer Side ```typescript // Listen for offer (automatically searches for service) const unbind = signaler.addOfferListener((offer) => { console.log('Received offer:', offer) }) // Set answer (automatically starts polling for ICE) await signaler.setAnswer(answer: RTCSessionDescriptionInit): Promise // Send ICE candidate await signaler.addIceCandidate(candidate: RTCIceCandidate): Promise // Listen for ICE candidates signaler.addListener((candidate) => { console.log('Received ICE candidate:', candidate) }) ``` #### Cleanup ```typescript // Stop all polling and cleanup signaler.dispose(): void ``` ### RondevuAPI Class Low-level HTTP API client (used internally by Rondevu class). ```typescript import { RondevuAPI } from '@xtr-dev/rondevu-client' const api = new RondevuAPI( baseUrl: string, username: string, keypair: Keypair ) // Check username await api.checkUsername(username: string): Promise<{ available: boolean publicKey?: string claimedAt?: number expiresAt?: number }> // Note: Username claiming is now implicit - usernames are auto-claimed // on first authenticated request to the server // ... (all other HTTP endpoints) ``` #### Cryptographic Helpers ```typescript // Generate Ed25519 keypair const keypair = await RondevuAPI.generateKeypair(): Promise // Sign message const signature = await RondevuAPI.signMessage( message: string, privateKey: string ): Promise // Verify signature const valid = await RondevuAPI.verifySignature( message: string, signature: string, publicKey: string ): Promise ``` ## Types ```typescript interface Keypair { publicKey: string // Base64-encoded Ed25519 public key privateKey: string // Base64-encoded Ed25519 private key } interface Service { serviceId: string offers: ServiceOffer[] username: string serviceFqn: string createdAt: number expiresAt: number } interface ServiceOffer { offerId: string sdp: string createdAt: number expiresAt: number } interface IceCandidate { candidate: RTCIceCandidateInit createdAt: number } interface PollingConfig { initialInterval?: number // Default: 500ms maxInterval?: number // Default: 5000ms backoffMultiplier?: number // Default: 1.5 maxRetries?: number // Default: 50 jitter?: boolean // Default: true } ``` ## Advanced Usage ### Anonymous Username ```typescript // Auto-generate anonymous username (format: anon-{timestamp}-{random}) const rondevu = await Rondevu.connect({ apiUrl: 'https://api.ronde.vu' // No username provided - will generate anonymous username }) console.log(rondevu.getUsername()) // e.g., "anon-lx2w34-a3f501" // Anonymous users behave exactly like regular users await rondevu.publishService({ service: 'chat:1.0.0', offers: [{ sdp: offerSdp }] }) ``` ### Persistent Keypair ```typescript // Save keypair and username to localStorage const rondevu = await Rondevu.connect({ apiUrl: 'https://api.ronde.vu', username: 'alice' }) // Save for later (username will be auto-claimed on first authenticated request) localStorage.setItem('rondevu-username', rondevu.getUsername()) localStorage.setItem('rondevu-keypair', JSON.stringify(rondevu.getKeypair())) // Load on next session const savedUsername = localStorage.getItem('rondevu-username') const savedKeypair = JSON.parse(localStorage.getItem('rondevu-keypair')) const rondevu2 = await Rondevu.connect({ apiUrl: 'https://api.ronde.vu', username: savedUsername, keypair: savedKeypair }) ``` ### Service Discovery ```typescript // Get a random available service const service = await rondevu.discoverService('chat:1.0.0') console.log('Discovered:', service.username) // Get multiple services (paginated) const result = await rondevu.discoverServices('chat:1.0.0', 10, 0) console.log(`Found ${result.count} services:`) result.services.forEach(s => console.log(` - ${s.username}`)) ``` ### Multiple Concurrent Offers ```typescript // Publish service with multiple offers for connection pooling const offers = [] const connections = [] for (let i = 0; i < 5; i++) { const pc = new RTCPeerConnection(rtcConfig) const dc = pc.createDataChannel('chat') const offer = await pc.createOffer() await pc.setLocalDescription(offer) offers.push({ sdp: offer.sdp }) connections.push({ pc, dc }) } const service = await rondevu.publishService({ service: 'chat:1.0.0', offers, ttl: 300000 }) // Each offer can be answered independently console.log(`Published ${service.offers.length} offers`) ``` ### Custom Polling Configuration ```typescript const signaler = new RondevuSignaler( rondevu, 'chat:1.0.0', 'alice', { initialInterval: 1000, // Start at 1 second maxInterval: 10000, // Max 10 seconds backoffMultiplier: 2, // Double each time maxRetries: 30, // Stop after 30 retries jitter: true // Add randomness } ) ``` ## Platform Support ### Modern Browsers Works out of the box - no additional setup needed. ### Node.js 18+ Native fetch is available, but WebRTC requires polyfills: ```bash npm install wrtc ``` ```typescript import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'wrtc' // Use wrtc implementations const pc = new RTCPeerConnection() ``` ## Username Rules - **Format**: Lowercase alphanumeric + dash (`a-z`, `0-9`, `-`) - **Length**: 3-32 characters - **Pattern**: `^[a-z0-9][a-z0-9-]*[a-z0-9]$` - **Validity**: 365 days from claim/last use - **Ownership**: Secured by Ed25519 public key signature ## Service FQN Format - **Format**: `service:version@username` - **Service**: Lowercase alphanumeric + dash (e.g., `chat`, `video-call`) - **Version**: Semantic versioning (e.g., `1.0.0`, `2.1.3`) - **Username**: Claimed username - **Example**: `chat:1.0.0@alice` ## Examples ### Node.js Service Host Example You can host WebRTC services in Node.js that browser clients can connect to. See the [Node.js Host Guide](../demo/NODE_HOST_GUIDE.md) for a complete guide. **Quick example:** ```typescript import { Rondevu, NodeCryptoAdapter } from '@xtr-dev/rondevu-client' import wrtc from 'wrtc' const { RTCPeerConnection } = wrtc // Initialize with Node crypto adapter const rondevu = await Rondevu.connect({ apiUrl: 'https://api.ronde.vu', username: 'mybot', cryptoAdapter: new NodeCryptoAdapter() }) // Create peer connection (offerer creates data channel) const pc = new RTCPeerConnection(rtcConfig) const dc = pc.createDataChannel('chat') // Publish service (username auto-claimed on first publish) const offer = await pc.createOffer() await pc.setLocalDescription(offer) await rondevu.publishService({ service: 'chat:1.0.0', offers: [{ sdp: offer.sdp }] }) // Browser clients can now discover and connect to chat:1.0.0@mybot ``` See complete examples: - [Node.js Host Guide](../demo/NODE_HOST_GUIDE.md) - Full guide with complete examples - [test-connect.js](../demo/test-connect.js) - Working Node.js client example - [React Demo](https://github.com/xtr-dev/rondevu-demo) - Complete browser UI ([live](https://ronde.vu)) ## Migration from v0.3.x v0.4.0 removes high-level abstractions and uses manual WebRTC setup: **Removed:** - `ServiceHost` class (use manual WebRTC + `publishService()`) - `ServiceClient` class (use manual WebRTC + `getService()`) - `RTCDurableConnection` class (use native WebRTC APIs) - `RondevuService` class (merged into `Rondevu`) **Added:** - `pollOffers()` - Combined polling for answers and ICE candidates - `RondevuSignaler` - Simplified signaling with automatic polling **Migration Example:** ```typescript // Before (v0.3.x) - ServiceHost const host = new ServiceHost({ service: 'chat@1.0.0', rondevuService: service }) await host.start() // After (v0.4.0) - Manual setup const pc = new RTCPeerConnection() const dc = pc.createDataChannel('chat') const offer = await pc.createOffer() await pc.setLocalDescription(offer) await rondevu.publishService({ serviceFqn: 'chat:1.0.0@alice', offers: [{ sdp: offer.sdp }] }) ``` ## License MIT