ICE candidate handler was being set up AFTER setLocalDescription, but ICE gathering starts when setLocalDescription is called. This meant candidates were generated before the handler was attached, so they were never sent to the server, causing connection failures. Fixed by: - Setting up ICE handler BEFORE setLocalDescription in both offer and answer flows - Changed setupIceCandidateHandler() to use this.peer.offerId instead of parameter - Handler now checks this.peer.offerId before sending (waits for it to be set) Order of operations now: 1. Set up ICE candidate handler 2. Call setLocalDescription (ICE gathering starts) 3. Set this.peer.offerId (handler can now send candidates) This ensures all ICE candidates are captured and sent to the server. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
@xtr-dev/rondevu-client
🌐 Topic-based peer discovery and WebRTC signaling client
TypeScript/JavaScript client for Rondevu, providing topic-based peer discovery, stateless authentication, and complete WebRTC signaling.
Related repositories:
- rondevu-server - HTTP signaling server
- rondevu-demo - Interactive demo
Features
- Topic-Based Discovery: Find peers by topics (e.g., torrent infohashes)
- Stateless Authentication: No server-side sessions, portable credentials
- Bloom Filters: Efficient peer exclusion for repeated discoveries
- Multi-Offer Management: Create and manage multiple offers per peer
- Complete WebRTC Signaling: Full offer/answer and ICE candidate exchange
- TypeScript: Full type safety and autocomplete
Install
npm install @xtr-dev/rondevu-client
Quick Start
The easiest way to use Rondevu is with the high-level RondevuConnection class, which handles all WebRTC connection complexity including offer/answer exchange, ICE candidates, and connection lifecycle.
Creating an Offer (Peer A)
import { Rondevu } from '@xtr-dev/rondevu-client';
const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
await client.register();
// Create a connection
const conn = client.createConnection();
// Set up event listeners
conn.on('connected', () => {
console.log('Connected to peer!');
});
conn.on('datachannel', (channel) => {
console.log('Data channel ready');
channel.onmessage = (event) => {
console.log('Received:', event.data);
};
channel.send('Hello from peer A!');
});
// Create offer and advertise on topics
const offerId = await conn.createOffer({
topics: ['my-app', 'room-123'],
ttl: 300000 // 5 minutes
});
console.log('Offer created:', offerId);
console.log('Share these topics with peers:', ['my-app', 'room-123']);
Answering an Offer (Peer B)
import { Rondevu } from '@xtr-dev/rondevu-client';
const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
await client.register();
// Discover offers by topic
const offers = await client.offers.findByTopic('my-app', { limit: 10 });
if (offers.length > 0) {
const offer = offers[0];
// Create connection
const conn = client.createConnection();
// Set up event listeners
conn.on('connecting', () => {
console.log('Connecting...');
});
conn.on('connected', () => {
console.log('Connected!');
});
conn.on('datachannel', (channel) => {
console.log('Data channel ready');
channel.onmessage = (event) => {
console.log('Received:', event.data);
};
channel.send('Hello from peer B!');
});
// Answer the offer
await conn.answer(offer.id, offer.sdp);
}
Connection Events
conn.on('connecting', () => {
// Connection is being established
});
conn.on('connected', () => {
// Connection established successfully
});
conn.on('disconnected', () => {
// Connection lost or closed
});
conn.on('error', (error) => {
// An error occurred
console.error('Connection error:', error);
});
conn.on('datachannel', (channel) => {
// Data channel is ready to use
});
conn.on('track', (event) => {
// Media track received (for audio/video streaming)
const stream = event.streams[0];
videoElement.srcObject = stream;
});
Adding Media Tracks
// Get user's camera/microphone
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
// Add tracks to connection
stream.getTracks().forEach(track => {
conn.addTrack(track, stream);
});
Connection Properties
// Get connection state
console.log(conn.connectionState); // 'connecting', 'connected', 'disconnected', etc.
// Get offer ID
console.log(conn.id);
// Get data channel
console.log(conn.channel);
Closing a Connection
conn.close();
Platform-Specific Setup
Node.js 18+ (with native fetch)
Works out of the box - no additional setup needed.
Node.js < 18 (without native fetch)
Install node-fetch and provide it to the client:
npm install node-fetch
import { Rondevu } from '@xtr-dev/rondevu-client';
import fetch from 'node-fetch';
const client = new Rondevu({
baseUrl: 'https://api.ronde.vu',
fetch: fetch as any
});
Deno
import { Rondevu } from 'npm:@xtr-dev/rondevu-client';
const client = new Rondevu({
baseUrl: 'https://api.ronde.vu'
});
Bun
Works out of the box - no additional setup needed.
Cloudflare Workers
import { Rondevu } from '@xtr-dev/rondevu-client';
export default {
async fetch(request: Request, env: Env) {
const client = new Rondevu({
baseUrl: 'https://api.ronde.vu'
});
const creds = await client.register();
return new Response(JSON.stringify(creds));
}
};
Low-Level API Usage
For advanced use cases where you need direct control over the signaling process, you can use the low-level API:
import { Rondevu, BloomFilter } from '@xtr-dev/rondevu-client';
const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
// Register and get credentials
const creds = await client.register();
console.log('Peer ID:', creds.peerId);
// Save credentials for later use
localStorage.setItem('rondevu-creds', JSON.stringify(creds));
// Create offer with topics
const offers = await client.offers.create([{
sdp: 'v=0...', // Your WebRTC offer SDP
topics: ['movie-xyz', 'hd-content'],
ttl: 300000 // 5 minutes
}]);
// Discover peers by topic
const discovered = await client.offers.findByTopic('movie-xyz', {
limit: 50
});
console.log(`Found ${discovered.length} peers`);
// Use bloom filter to exclude known peers
const knownPeers = new Set(['peer-id-1', 'peer-id-2']);
const bloom = new BloomFilter(1024, 3);
knownPeers.forEach(id => bloom.add(id));
const newPeers = await client.offers.findByTopic('movie-xyz', {
bloomFilter: bloom.toBytes(),
limit: 50
});
API Reference
Authentication
client.register()
Register a new peer and receive credentials.
const creds = await client.register();
// { peerId: '...', secret: '...' }
Topics
client.offers.getTopics(options?)
List all topics with active peer counts (paginated).
const result = await client.offers.getTopics({
limit: 50,
offset: 0
});
// {
// topics: [
// { topic: 'movie-xyz', activePeers: 42 },
// { topic: 'torrent-abc', activePeers: 15 }
// ],
// total: 123,
// limit: 50,
// offset: 0
// }
Offers
client.offers.create(offers)
Create one or more offers with topics.
const offers = await client.offers.create([
{
sdp: 'v=0...',
topics: ['topic-1', 'topic-2'],
ttl: 300000 // optional, default 5 minutes
}
]);
client.offers.findByTopic(topic, options?)
Find offers by topic with optional bloom filter.
const offers = await client.offers.findByTopic('movie-xyz', {
limit: 50,
bloomFilter: bloomBytes // optional
});
client.offers.getMine()
Get all offers owned by the authenticated peer.
const myOffers = await client.offers.getMine();
client.offers.heartbeat(offerId)
Update last_seen timestamp for an offer.
await client.offers.heartbeat(offerId);
client.offers.delete(offerId)
Delete a specific offer.
await client.offers.delete(offerId);
client.offers.answer(offerId, sdp)
Answer an offer (locks it to answerer).
await client.offers.answer(offerId, answerSdp);
client.offers.getAnswers()
Poll for answers to your offers.
const answers = await client.offers.getAnswers();
ICE Candidates
client.offers.addIceCandidates(offerId, candidates)
Post ICE candidates for an offer.
await client.offers.addIceCandidates(offerId, [
'candidate:1 1 UDP...'
]);
client.offers.getIceCandidates(offerId, since?)
Get ICE candidates from the other peer.
const candidates = await client.offers.getIceCandidates(offerId);
Bloom Filter
import { BloomFilter } from '@xtr-dev/rondevu-client';
// Create filter: size=1024 bits, hash=3 functions
const bloom = new BloomFilter(1024, 3);
// Add items
bloom.add('peer-id-1');
bloom.add('peer-id-2');
// Test membership
bloom.test('peer-id-1'); // true (probably)
bloom.test('unknown'); // false (definitely)
// Export for API
const bytes = bloom.toBytes();
TypeScript
All types are exported:
import type {
Credentials,
Offer,
CreateOfferRequest,
TopicInfo,
IceCandidate,
FetchFunction,
RondevuOptions,
ConnectionOptions,
RondevuConnectionEvents
} from '@xtr-dev/rondevu-client';
Environment Compatibility
The client library is designed to work across different JavaScript runtimes:
| Environment | Native Fetch | Custom Fetch Needed |
|---|---|---|
| Modern Browsers | ✅ Yes | ❌ No |
| Node.js 18+ | ✅ Yes | ❌ No |
| Node.js < 18 | ❌ No | ✅ Yes (node-fetch) |
| Deno | ✅ Yes | ❌ No |
| Bun | ✅ Yes | ❌ No |
| Cloudflare Workers | ✅ Yes | ❌ No |
If your environment doesn't have native fetch:
npm install node-fetch
import { Rondevu } from '@xtr-dev/rondevu-client';
import fetch from 'node-fetch';
const client = new Rondevu({
baseUrl: 'https://rondevu.xtrdev.workers.dev',
fetch: fetch as any
});
License
MIT