- Add OfferPool class for managing multiple offers with auto-refill polling - Add ServicePool class for orchestrating pooled connections and connection registry - Modify exposeService() to support poolSize parameter (backward compatible) - Add discovery API with service resolution and online status checking - Add username claiming with Ed25519 signatures and TTL-based expiry - Fix TypeScript import errors (RondevuPeer default export) - Fix RondevuPeer instantiation to use RondevuOffers instance - Fix peer.answer() calls to include required PeerOptions parameter - Fix Ed25519 API call (randomSecretKey vs randomPrivateKey) - Remove bloom filter (V1 legacy code) - Update version to 0.8.0 - Document pooling feature and new APIs in README 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
18 KiB
Rondevu Client
🌐 DNS-like WebRTC client with username claiming and service discovery
TypeScript/JavaScript client for Rondevu, providing cryptographic username claiming, service publishing, and privacy-preserving discovery.
Related repositories:
- @xtr-dev/rondevu-client - TypeScript client library (npm)
- @xtr-dev/rondevu-server - HTTP signaling server (npm, live)
- @xtr-dev/rondevu-demo - Interactive demo (live)
Features
- Username Claiming: Cryptographic ownership with Ed25519 signatures
- Service Publishing: Package-style naming (com.example.chat@1.0.0)
- Privacy-Preserving Discovery: UUID-based service index
- Public/Private Services: Control service visibility
- Complete WebRTC Signaling: Full offer/answer and ICE candidate exchange
- Trickle ICE: Send ICE candidates as they're discovered
- TypeScript: Full type safety and autocomplete
Install
npm install @xtr-dev/rondevu-client
Quick Start
Publishing a Service (Alice)
import { Rondevu } from '@xtr-dev/rondevu-client';
// Initialize client and register
const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
await client.register();
// Step 1: Claim username (one-time)
const claim = await client.usernames.claimUsername('alice');
client.usernames.saveKeypairToStorage('alice', claim.publicKey, claim.privateKey);
console.log(`Username claimed: ${claim.username}`);
console.log(`Expires: ${new Date(claim.expiresAt)}`);
// Step 2: Expose service with handler
const keypair = client.usernames.loadKeypairFromStorage('alice');
const handle = await client.services.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'com.example.chat@1.0.0',
isPublic: true,
handler: (channel, peer) => {
console.log('📡 New connection established');
channel.onmessage = (e) => {
console.log('📥 Received:', e.data);
channel.send(`Echo: ${e.data}`);
};
channel.onopen = () => {
console.log('✅ Data channel open');
};
}
});
console.log(`Service published with UUID: ${handle.uuid}`);
console.log('Waiting for connections...');
// Later: unpublish
await handle.unpublish();
Connecting to a Service (Bob)
import { Rondevu } from '@xtr-dev/rondevu-client';
// Initialize client and register
const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' });
await client.register();
// Option 1: Connect by username + FQN
const { peer, channel } = await client.discovery.connect(
'alice',
'com.example.chat@1.0.0'
);
channel.onmessage = (e) => {
console.log('📥 Received:', e.data);
};
channel.onopen = () => {
console.log('✅ Connected!');
channel.send('Hello Alice!');
};
peer.on('connected', () => {
console.log('🎉 WebRTC connection established');
});
peer.on('failed', (error) => {
console.error('❌ Connection failed:', error);
});
// Option 2: List services first, then connect
const services = await client.discovery.listServices('alice');
console.log(`Found ${services.services.length} services`);
for (const service of services.services) {
console.log(`- UUID: ${service.uuid}`);
if (service.isPublic) {
console.log(` FQN: ${service.serviceFqn}`);
}
}
// Connect by UUID
const { peer: peer2, channel: channel2 } = await client.discovery.connectByUuid(
services.services[0].uuid
);
API Reference
Main Client
const client = new Rondevu({
baseUrl: 'https://api.ronde.vu', // optional, default shown
credentials?: { peerId, secret }, // optional, skip registration
fetch?: customFetch, // optional, for Node.js < 18
RTCPeerConnection?: RTCPeerConnection, // optional, for Node.js
RTCSessionDescription?: RTCSessionDescription,
RTCIceCandidate?: RTCIceCandidate
});
// Register and get credentials
const creds = await client.register();
// { peerId: '...', secret: '...' }
// Check if authenticated
client.isAuthenticated(); // boolean
// Get current credentials
client.getCredentials(); // { peerId, secret } | undefined
Username API
// Check username availability
const check = await client.usernames.checkUsername('alice');
// { available: true } or { available: false, expiresAt: number, publicKey: string }
// Claim username with new keypair
const claim = await client.usernames.claimUsername('alice');
// { username, publicKey, privateKey, claimedAt, expiresAt }
// Claim with existing keypair
const keypair = await client.usernames.generateKeypair();
const claim2 = await client.usernames.claimUsername('bob', keypair);
// Save keypair to localStorage
client.usernames.saveKeypairToStorage('alice', publicKey, privateKey);
// Load keypair from localStorage
const stored = client.usernames.loadKeypairFromStorage('alice');
// { publicKey, privateKey } | null
// Export keypair for backup
const exported = client.usernames.exportKeypair('alice');
// { username, publicKey, privateKey }
// Import keypair from backup
client.usernames.importKeypair({ username: 'alice', publicKey, privateKey });
// Low-level: Generate keypair
const { publicKey, privateKey } = await client.usernames.generateKeypair();
// Low-level: Sign message
const signature = await client.usernames.signMessage(
'claim:alice:1234567890',
privateKey
);
// Low-level: Verify signature
const valid = await client.usernames.verifySignature(
'claim:alice:1234567890',
signature,
publicKey
);
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
Services API
// Publish service (returns UUID)
const service = await client.services.publishService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'com.example.chat@1.0.0',
isPublic: false, // optional, default false
metadata: { description: '...' }, // optional
ttl: 5 * 60 * 1000, // optional, default 5 minutes
rtcConfig: { ... } // optional RTCConfiguration
});
// { serviceId, uuid, offerId, expiresAt }
console.log(`Service UUID: ${service.uuid}`);
console.log('Share this UUID to allow connections');
// Expose service with automatic connection handling
const handle = await client.services.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'com.example.echo@1.0.0',
isPublic: true,
handler: (channel, peer) => {
channel.onmessage = (e) => {
console.log('Received:', e.data);
channel.send(`Echo: ${e.data}`);
};
}
});
// Later: unpublish
await handle.unpublish();
// Unpublish service manually
await client.services.unpublishService(serviceId, username);
Multi-Connection Service Hosting (Offer Pooling)
By default, exposeService() creates a single offer and can only accept one connection. To handle multiple concurrent connections, use the poolSize option to enable offer pooling:
// Expose service with offer pooling for multiple concurrent connections
const handle = await client.services.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'com.example.chat@1.0.0',
isPublic: true,
poolSize: 5, // Maintain 5 simultaneous open offers
pollingInterval: 2000, // Optional: polling interval in ms (default: 2000)
handler: (channel, peer, connectionId) => {
console.log(`📡 New connection: ${connectionId}`);
channel.onmessage = (e) => {
console.log(`📥 [${connectionId}] Received:`, e.data);
channel.send(`Echo: ${e.data}`);
};
channel.onclose = () => {
console.log(`👋 [${connectionId}] Connection closed`);
};
},
onPoolStatus: (status) => {
console.log('Pool status:', {
activeOffers: status.activeOffers,
activeConnections: status.activeConnections,
totalHandled: status.totalConnectionsHandled
});
},
onError: (error, context) => {
console.error(`Pool error (${context}):`, error);
}
});
// Get current pool status
const status = handle.getStatus();
console.log(`Active offers: ${status.activeOffers}`);
console.log(`Active connections: ${status.activeConnections}`);
// Manually add more offers if needed
await handle.addOffers(3);
How Offer Pooling Works:
- The pool maintains
poolSizesimultaneous open offers at all times - When an offer is answered (connection established), a new offer is automatically created
- Polling checks for answers every
pollingIntervalmilliseconds (default: 2000ms) - Each connection gets a unique
connectionIdpassed to the handler - No limit on total concurrent connections - only pool size (open offers) is controlled
Use Cases:
- Chat servers handling multiple clients
- File sharing services with concurrent downloads
- Multiplayer game lobbies
- Collaborative editing sessions
- Any service that needs to accept multiple simultaneous connections
Pool Status Interface:
interface PoolStatus {
activeOffers: number; // Current number of open offers
activeConnections: number; // Current number of connected peers
totalConnectionsHandled: number; // Total connections since start
failedOfferCreations: number; // Failed offer creation attempts
}
Pooled Service Handle:
interface PooledServiceHandle extends ServiceHandle {
getStatus: () => PoolStatus; // Get current pool status
addOffers: (count: number) => Promise<void>; // Manually add offers
}
Service FQN Format:
- Service name: Reverse domain notation (e.g.,
com.example.chat) - Version: Semantic versioning (e.g.,
1.0.0,2.1.3-beta) - Complete FQN:
service-name@version - Examples:
com.example.chat@1.0.0,io.github.alice.notes@0.1.0-beta
Validation Rules:
- Service name pattern:
^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$ - Length: 3-128 characters
- Minimum 2 components (at least one dot)
- Version pattern:
^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.-]+)?$
Discovery API
// List all services for a username
const services = await client.discovery.listServices('alice');
// {
// username: 'alice',
// services: [
// { uuid: 'abc123', isPublic: false },
// { uuid: 'def456', isPublic: true, serviceFqn: '...', metadata: {...} }
// ]
// }
// Query service by FQN
const query = await client.discovery.queryService('alice', 'com.example.chat@1.0.0');
// { uuid: 'abc123', allowed: true }
// Get service details by UUID
const details = await client.discovery.getServiceDetails('abc123');
// { serviceId, username, serviceFqn, offerId, sdp, isPublic, metadata, ... }
// Connect to service by UUID
const peer = await client.discovery.connectToService('abc123', {
rtcConfig: { ... }, // optional
onConnected: () => { ... }, // optional
onData: (data) => { ... } // optional
});
// Connect by username + FQN (convenience method)
const { peer, channel } = await client.discovery.connect(
'alice',
'com.example.chat@1.0.0',
{ rtcConfig: { ... } } // optional
);
// Connect by UUID with channel
const { peer, channel } = await client.discovery.connectByUuid('abc123');
Low-Level Peer Connection
// Create peer connection
const peer = client.createPeer({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:turn.example.com:3478',
username: 'user',
credential: 'pass'
}
],
iceTransportPolicy: 'relay' // optional: force TURN relay
});
// Event listeners
peer.on('state', (state) => {
console.log('Peer state:', state);
});
peer.on('connected', () => {
console.log('✅ Connected');
});
peer.on('disconnected', () => {
console.log('🔌 Disconnected');
});
peer.on('failed', (error) => {
console.error('❌ Failed:', error);
});
peer.on('datachannel', (channel) => {
console.log('📡 Data channel ready');
});
peer.on('track', (event) => {
// Media track received
const stream = event.streams[0];
videoElement.srcObject = stream;
});
// Create offer
const offerId = await peer.createOffer({
ttl: 300000, // optional
timeouts: { // optional
iceGathering: 10000,
waitingForAnswer: 30000,
creatingAnswer: 10000,
iceConnection: 30000
}
});
// Answer offer
await peer.answer(offerId, sdp);
// Add media tracks
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
stream.getTracks().forEach(track => {
peer.addTrack(track, stream);
});
// Close connection
await peer.close();
// Properties
peer.stateName; // 'idle', 'creating-offer', 'connected', etc.
peer.connectionState; // RTCPeerConnectionState
peer.offerId; // string | undefined
peer.role; // 'offerer' | 'answerer' | undefined
Connection Lifecycle
Service Publisher (Offerer)
- idle - Initial state
- creating-offer - Creating WebRTC offer
- waiting-for-answer - Polling for answer from peer
- exchanging-ice - Exchanging ICE candidates
- connected - Successfully connected
- failed - Connection failed
- closed - Connection closed
Service Consumer (Answerer)
- idle - Initial state
- answering - Creating WebRTC answer
- exchanging-ice - Exchanging ICE candidates
- connected - Successfully connected
- failed - Connection failed
- closed - Connection closed
Platform-Specific Setup
Modern Browsers
Works out of the box - no additional setup needed.
Node.js 18+
Native fetch is available, but you need WebRTC polyfills:
npm install wrtc
import { Rondevu } from '@xtr-dev/rondevu-client';
import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'wrtc';
const client = new Rondevu({
baseUrl: 'https://api.ronde.vu',
RTCPeerConnection,
RTCSessionDescription,
RTCIceCandidate
});
Node.js < 18
Install both fetch and WebRTC polyfills:
npm install node-fetch wrtc
import { Rondevu } from '@xtr-dev/rondevu-client';
import fetch from 'node-fetch';
import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'wrtc';
const client = new Rondevu({
baseUrl: 'https://api.ronde.vu',
fetch: fetch as any,
RTCPeerConnection,
RTCSessionDescription,
RTCIceCandidate
});
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));
}
};
Examples
Echo Service
// Publisher
const client1 = new Rondevu();
await client1.register();
const claim = await client1.usernames.claimUsername('alice');
client1.usernames.saveKeypairToStorage('alice', claim.publicKey, claim.privateKey);
const keypair = client1.usernames.loadKeypairFromStorage('alice');
await client1.services.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'com.example.echo@1.0.0',
isPublic: true,
handler: (channel, peer) => {
channel.onmessage = (e) => {
console.log('Received:', e.data);
channel.send(`Echo: ${e.data}`);
};
}
});
// Consumer
const client2 = new Rondevu();
await client2.register();
const { peer, channel } = await client2.discovery.connect(
'alice',
'com.example.echo@1.0.0'
);
channel.onmessage = (e) => console.log('Received:', e.data);
channel.send('Hello!');
File Transfer Service
// Publisher
await client.services.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'com.example.files@1.0.0',
isPublic: false,
handler: (channel, peer) => {
channel.binaryType = 'arraybuffer';
channel.onmessage = (e) => {
if (typeof e.data === 'string') {
console.log('Request:', JSON.parse(e.data));
} else {
console.log('Received file chunk:', e.data.byteLength, 'bytes');
}
};
}
});
// Consumer
const { peer, channel } = await client.discovery.connect(
'alice',
'com.example.files@1.0.0'
);
channel.binaryType = 'arraybuffer';
// Request file
channel.send(JSON.stringify({ action: 'get', path: '/readme.txt' }));
channel.onmessage = (e) => {
if (e.data instanceof ArrayBuffer) {
console.log('Received file:', e.data.byteLength, 'bytes');
}
};
Video Chat Service
// Publisher
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
const peer = client.createPeer();
stream.getTracks().forEach(track => peer.addTrack(track, stream));
const offerId = await peer.createOffer({ ttl: 300000 });
await client.services.publishService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'com.example.videochat@1.0.0',
isPublic: true
});
// Consumer
const { peer, channel } = await client.discovery.connect(
'alice',
'com.example.videochat@1.0.0'
);
peer.on('track', (event) => {
const remoteStream = event.streams[0];
videoElement.srcObject = remoteStream;
});
TypeScript
All types are exported:
import type {
Credentials,
RondevuOptions,
// Username types
UsernameCheckResult,
UsernameClaimResult,
Keypair,
// Service types
ServicePublishResult,
PublishServiceOptions,
ServiceHandle,
// Discovery types
ServiceInfo,
ServiceListResult,
ServiceQueryResult,
ServiceDetails,
ConnectResult,
// Peer types
PeerOptions,
PeerEvents,
PeerTimeouts
} from '@xtr-dev/rondevu-client';
Migration from V1
V2 is a breaking change that replaces topic-based discovery with username claiming and service publishing. See the main MIGRATION.md for detailed migration guide.
Key Changes:
- ❌ Removed:
offers.findByTopic(),offers.getTopics(), bloom filters - ✅ Added:
usernames.*,services.*,discovery.*APIs - ✅ Changed: Focus on service-based discovery instead of topics
License
MIT