Files
rondevu-client/README.md
Bas van den Aakster 15f821f08a feat: implement offer pooling for multi-connection services
- 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>
2025-12-05 18:26:23 +01:00

18 KiB

Rondevu Client

npm version

🌐 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:


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:

  1. The pool maintains poolSize simultaneous open offers at all times
  2. When an offer is answered (connection established), a new offer is automatically created
  3. Polling checks for answers every pollingInterval milliseconds (default: 2000ms)
  4. Each connection gets a unique connectionId passed to the handler
  5. 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)

  1. idle - Initial state
  2. creating-offer - Creating WebRTC offer
  3. waiting-for-answer - Polling for answer from peer
  4. exchanging-ice - Exchanging ICE candidates
  5. connected - Successfully connected
  6. failed - Connection failed
  7. closed - Connection closed

Service Consumer (Answerer)

  1. idle - Initial state
  2. answering - Creating WebRTC answer
  3. exchanging-ice - Exchanging ICE candidates
  4. connected - Successfully connected
  5. failed - Connection failed
  6. 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