Bas van den Aakster d6f2a10e3e Fix critical bug: track and use data channels from offers
This fixes the root cause of all connection failures. The service pool
was creating data channels but discarding the references, then trying
to wait for a 'datachannel' event that would never fire.

Changes:
- Add dataChannel tracking to OfferPool and ServicePool
- Save data channel references when creating offers
- Pass channels through the answer flow
- Use the existing channel instead of waiting for an event
- Wait for channel.onopen instead of ondatachannel

The offerer (service pool) creates the data channel and must keep that
reference. The 'ondatachannel' event only fires on the answerer side.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-06 14:24:31 +01:00

Rondevu Client

npm version

🌐 WebRTC with durable connections and automatic reconnection

TypeScript/JavaScript client for Rondevu, providing durable WebRTC connections that survive network interruptions with automatic reconnection and message queuing.

Related repositories:


Features

  • Durable Connections: Automatic reconnection on network drops
  • Message Queuing: Messages sent during disconnections are queued and flushed on reconnect
  • Durable Channels: RTCDataChannel wrappers that survive connection drops
  • TTL Auto-Refresh: Services automatically republish before expiration
  • Username Claiming: Cryptographic ownership with Ed25519 signatures
  • Service Publishing: Package-style naming (com.example.chat@1.0.0)
  • TypeScript: Full type safety and autocomplete
  • Configurable: All timeouts, retry limits, and queue sizes are configurable

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);

// Step 2: Expose service with handler
const keypair = client.usernames.loadKeypairFromStorage('alice');

const service = await client.exposeService({
  username: 'alice',
  privateKey: keypair.privateKey,
  serviceFqn: 'chat@1.0.0',
  isPublic: true,
  poolSize: 10,  // Handle 10 concurrent connections
  handler: (channel, connectionId) => {
    console.log(`📡 New connection: ${connectionId}`);

    channel.on('message', (data) => {
      console.log('📥 Received:', data);
      channel.send(`Echo: ${data}`);
    });

    channel.on('close', () => {
      console.log(`👋 Connection ${connectionId} closed`);
    });
  }
});

// Start the service
const info = await service.start();
console.log(`Service published with UUID: ${info.uuid}`);
console.log('Waiting for connections...');

// Later: stop the service
await service.stop();

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();

// Connect to Alice's service
const connection = await client.connect('alice', 'chat@1.0.0', {
  maxReconnectAttempts: 5
});

// Create a durable channel
const channel = connection.createChannel('main');

channel.on('message', (data) => {
  console.log('📥 Received:', data);
});

channel.on('open', () => {
  console.log('✅ Channel open');
  channel.send('Hello Alice!');
});

// Listen for connection events
connection.on('connected', () => {
  console.log('🎉 Connected to Alice');
});

connection.on('reconnecting', (attempt, max, delay) => {
  console.log(`🔄 Reconnecting... (${attempt}/${max}, retry in ${delay}ms)`);
});

connection.on('disconnected', () => {
  console.log('🔌 Disconnected');
});

connection.on('failed', (error) => {
  console.error('❌ Connection failed permanently:', error);
});

// Establish the connection
await connection.connect();

// Messages sent during disconnection are automatically queued
channel.send('This will be queued if disconnected');

// Later: close the connection
await connection.close();

Core Concepts

DurableConnection

Manages WebRTC peer lifecycle with automatic reconnection:

  • Automatically reconnects when connection drops
  • Exponential backoff with jitter (1s → 2s → 4s → 8s → ... max 30s)
  • Configurable max retry attempts (default: 10)
  • Manages multiple DurableChannel instances

DurableChannel

Wraps RTCDataChannel with message queuing:

  • Queues messages during disconnection
  • Flushes queue on reconnection
  • Configurable queue size and message age limits
  • RTCDataChannel-compatible API with event emitters

DurableService

Server-side service with TTL auto-refresh:

  • Automatically republishes service before TTL expires
  • Creates DurableConnection for each incoming peer
  • Manages connection pool for multiple simultaneous connections

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 }

// Save keypair to localStorage
client.usernames.saveKeypairToStorage('alice', claim.publicKey, claim.privateKey);

// Load keypair from localStorage
const keypair = client.usernames.loadKeypairFromStorage('alice');
// { publicKey, privateKey } | null

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

Durable Service API

// Expose a durable service
const service = await client.exposeService({
  username: 'alice',
  privateKey: keypair.privateKey,
  serviceFqn: 'chat@1.0.0',

  // Service options
  isPublic: true,               // optional, default: false
  metadata: { version: '1.0' }, // optional
  ttl: 300000,                  // optional, default: 5 minutes
  ttlRefreshMargin: 0.2,        // optional, refresh at 80% of TTL

  // Connection pooling
  poolSize: 10,                 // optional, default: 1
  pollingInterval: 2000,        // optional, default: 2000ms

  // Connection options (applied to incoming connections)
  maxReconnectAttempts: 10,     // optional, default: 10
  reconnectBackoffBase: 1000,   // optional, default: 1000ms
  reconnectBackoffMax: 30000,   // optional, default: 30000ms
  reconnectJitter: 0.2,         // optional, default: 0.2 (±20%)
  connectionTimeout: 30000,     // optional, default: 30000ms

  // Message queuing
  maxQueueSize: 1000,           // optional, default: 1000
  maxMessageAge: 60000,         // optional, default: 60000ms (1 minute)

  // WebRTC configuration
  rtcConfig: {
    iceServers: [
      { urls: 'stun:stun.l.google.com:19302' }
    ]
  },

  // Connection handler
  handler: (channel, connectionId) => {
    // Handle incoming connection
    channel.on('message', (data) => {
      console.log('Received:', data);
      channel.send(`Echo: ${data}`);
    });
  }
});

// Start the service
const info = await service.start();
// { serviceId: '...', uuid: '...', expiresAt: 1234567890 }

// Get active connections
const connections = service.getActiveConnections();
// ['conn-123', 'conn-456']

// Get service info
const serviceInfo = service.getServiceInfo();
// { serviceId: '...', uuid: '...', expiresAt: 1234567890 } | null

// Stop the service
await service.stop();

Service Events:

service.on('published', (serviceId, uuid) => {
  console.log(`Service published: ${uuid}`);
});

service.on('connection', (connectionId) => {
  console.log(`New connection: ${connectionId}`);
});

service.on('disconnection', (connectionId) => {
  console.log(`Connection closed: ${connectionId}`);
});

service.on('ttl-refreshed', (expiresAt) => {
  console.log(`TTL refreshed, expires at: ${new Date(expiresAt)}`);
});

service.on('error', (error, context) => {
  console.error(`Service error (${context}):`, error);
});

service.on('closed', () => {
  console.log('Service stopped');
});

Durable Connection API

// Connect by username and service FQN
const connection = await client.connect('alice', 'chat@1.0.0', {
  // Connection options
  maxReconnectAttempts: 10,     // optional, default: 10
  reconnectBackoffBase: 1000,   // optional, default: 1000ms
  reconnectBackoffMax: 30000,   // optional, default: 30000ms
  reconnectJitter: 0.2,         // optional, default: 0.2 (±20%)
  connectionTimeout: 30000,     // optional, default: 30000ms

  // Message queuing
  maxQueueSize: 1000,           // optional, default: 1000
  maxMessageAge: 60000,         // optional, default: 60000ms

  // WebRTC configuration
  rtcConfig: {
    iceServers: [
      { urls: 'stun:stun.l.google.com:19302' }
    ]
  }
});

// Connect by UUID
const connection2 = await client.connectByUuid('service-uuid-here', {
  maxReconnectAttempts: 5
});

// Create channels before connecting
const channel = connection.createChannel('main');
const fileChannel = connection.createChannel('files', {
  ordered: false,
  maxRetransmits: 3
});

// Get existing channel
const existingChannel = connection.getChannel('main');

// Check connection state
const state = connection.getState();
// 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'failed' | 'closed'

const isConnected = connection.isConnected();

// Connect
await connection.connect();

// Close connection
await connection.close();

Connection Events:

connection.on('state', (newState, previousState) => {
  console.log(`State: ${previousState}${newState}`);
});

connection.on('connected', () => {
  console.log('Connected');
});

connection.on('reconnecting', (attempt, maxAttempts, delay) => {
  console.log(`Reconnecting (${attempt}/${maxAttempts}) in ${delay}ms`);
});

connection.on('disconnected', () => {
  console.log('Disconnected');
});

connection.on('failed', (error, permanent) => {
  console.error('Connection failed:', error, 'Permanent:', permanent);
});

connection.on('closed', () => {
  console.log('Connection closed');
});

Durable Channel API

const channel = connection.createChannel('chat', {
  ordered: true,          // optional, default: true
  maxRetransmits: undefined  // optional, for unordered channels
});

// Send data (queued if disconnected)
channel.send('Hello!');
channel.send(new ArrayBuffer(1024));
channel.send(new Blob(['data']));

// Check state
const state = channel.readyState;
// 'connecting' | 'open' | 'closing' | 'closed'

// Get buffered amount
const buffered = channel.bufferedAmount;

// Set buffered amount low threshold
channel.bufferedAmountLowThreshold = 16 * 1024; // 16KB

// Get queue size (for debugging)
const queueSize = channel.getQueueSize();

// Close channel
channel.close();

Channel Events:

channel.on('open', () => {
  console.log('Channel open');
});

channel.on('message', (data) => {
  console.log('Received:', data);
});

channel.on('error', (error) => {
  console.error('Channel error:', error);
});

channel.on('close', () => {
  console.log('Channel closed');
});

channel.on('bufferedAmountLow', () => {
  console.log('Buffer drained, safe to send more');
});

channel.on('queueOverflow', (droppedCount) => {
  console.warn(`Queue overflow: ${droppedCount} messages dropped`);
});

Configuration Options

Connection Configuration

interface DurableConnectionConfig {
  maxReconnectAttempts?: number;      // default: 10
  reconnectBackoffBase?: number;      // default: 1000 (1 second)
  reconnectBackoffMax?: number;       // default: 30000 (30 seconds)
  reconnectJitter?: number;           // default: 0.2 (±20%)
  connectionTimeout?: number;         // default: 30000 (30 seconds)
  maxQueueSize?: number;              // default: 1000 messages
  maxMessageAge?: number;             // default: 60000 (1 minute)
  rtcConfig?: RTCConfiguration;
}

Service Configuration

interface DurableServiceConfig extends DurableConnectionConfig {
  username: string;
  privateKey: string;
  serviceFqn: string;
  isPublic?: boolean;                 // default: false
  metadata?: Record<string, any>;
  ttl?: number;                       // default: 300000 (5 minutes)
  ttlRefreshMargin?: number;          // default: 0.2 (refresh at 80%)
  poolSize?: number;                  // default: 1
  pollingInterval?: number;           // default: 2000 (2 seconds)
}

Examples

Chat Application

// Server
const client = new Rondevu();
await client.register();

const claim = await client.usernames.claimUsername('alice');
client.usernames.saveKeypairToStorage('alice', claim.publicKey, claim.privateKey);
const keypair = client.usernames.loadKeypairFromStorage('alice');

const service = await client.exposeService({
  username: 'alice',
  privateKey: keypair.privateKey,
  serviceFqn: 'chat@1.0.0',
  isPublic: true,
  poolSize: 50,  // Handle 50 concurrent users
  handler: (channel, connectionId) => {
    console.log(`User ${connectionId} joined`);

    channel.on('message', (data) => {
      console.log(`[${connectionId}]: ${data}`);
      // Broadcast to all users (implement your broadcast logic)
    });

    channel.on('close', () => {
      console.log(`User ${connectionId} left`);
    });
  }
});

await service.start();

// Client
const client2 = new Rondevu();
await client2.register();

const connection = await client2.connect('alice', 'chat@1.0.0');
const channel = connection.createChannel('chat');

channel.on('message', (data) => {
  console.log('Message:', data);
});

await connection.connect();
channel.send('Hello everyone!');

File Transfer with Progress

// Server
const service = await client.exposeService({
  username: 'alice',
  privateKey: keypair.privateKey,
  serviceFqn: 'files@1.0.0',
  handler: (channel, connectionId) => {
    channel.on('message', async (data) => {
      const request = JSON.parse(data);

      if (request.action === 'download') {
        const file = await fs.readFile(request.path);
        const chunkSize = 16 * 1024; // 16KB chunks

        for (let i = 0; i < file.byteLength; i += chunkSize) {
          const chunk = file.slice(i, i + chunkSize);
          channel.send(chunk);

          // Wait for buffer to drain if needed
          while (channel.bufferedAmount > 16 * 1024 * 1024) { // 16MB
            await new Promise(resolve => setTimeout(resolve, 100));
          }
        }

        channel.send(JSON.stringify({ done: true }));
      }
    });
  }
});

await service.start();

// Client
const connection = await client.connect('alice', 'files@1.0.0');
const channel = connection.createChannel('files');

const chunks = [];
channel.on('message', (data) => {
  if (typeof data === 'string') {
    const msg = JSON.parse(data);
    if (msg.done) {
      const blob = new Blob(chunks);
      console.log('Download complete:', blob.size, 'bytes');
    }
  } else {
    chunks.push(data);
    console.log('Progress:', chunks.length * 16 * 1024, 'bytes');
  }
});

await connection.connect();
channel.send(JSON.stringify({ action: 'download', path: '/file.zip' }));

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
});

TypeScript

All types are exported:

import type {
  // Client types
  Credentials,
  RondevuOptions,

  // Username types
  UsernameCheckResult,
  UsernameClaimResult,

  // Durable connection types
  DurableConnectionState,
  DurableChannelState,
  DurableConnectionConfig,
  DurableChannelConfig,
  DurableServiceConfig,
  QueuedMessage,
  DurableConnectionEvents,
  DurableChannelEvents,
  DurableServiceEvents,
  ConnectionInfo,
  ServiceInfo
} from '@xtr-dev/rondevu-client';

Migration from v0.8.x

v0.9.0 is a breaking change that replaces the low-level APIs with high-level durable connections. See MIGRATION.md for detailed migration guide.

Key Changes:

  • Removed: client.services.*, client.discovery.*, client.createPeer() (low-level APIs)
  • Added: client.exposeService(), client.connect(), client.connectByUuid() (durable APIs)
  • Changed: Focus on durable connections with automatic reconnection and message queuing

License

MIT

Description
No description provided
Readme 461 KiB
Languages
TypeScript 96.1%
JavaScript 3.9%