Bas van den Aakster b2d42fa776 fix: use async ed25519 functions (signAsync, getPublicKeyAsync)
The sync ed25519 functions (sign, getPublicKey) require hashes.sha512,
but WebCrypto only provides async digest. Switch to using the async
ed25519 API which works with hashes.sha512Async.

This fixes the "hashes.sha512 not set" error.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 19:19:05 +01:00

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

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