From cbb0cc3f8314c1b688950ece9f2f87bafb2f4ef6 Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Sun, 7 Dec 2025 21:58:20 +0100 Subject: [PATCH] docs: Update README with semver and privacy features --- README.md | 837 +++++++------------ demo/README.md | 141 ---- demo/demo.js | 304 ------- demo/index.html | 280 ------- src/{connection.ts => durable-connection.ts} | 6 +- src/noop-signaler.ts | 35 - src/rondevu-context.ts | 0 src/rondevu-signaler.ts | 0 src/service-client.ts | 247 ------ src/service-host.ts | 239 ------ vite.config.js | 10 - 11 files changed, 319 insertions(+), 1780 deletions(-) delete mode 100644 demo/README.md delete mode 100644 demo/demo.js delete mode 100644 demo/index.html rename src/{connection.ts => durable-connection.ts} (97%) delete mode 100644 src/noop-signaler.ts create mode 100644 src/rondevu-context.ts create mode 100644 src/rondevu-signaler.ts delete mode 100644 src/service-client.ts delete mode 100644 src/service-host.ts delete mode 100644 vite.config.js diff --git a/README.md b/README.md index 3739f0a..8bbbc66 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ [![npm version](https://img.shields.io/npm/v/@xtr-dev/rondevu-client)](https://www.npmjs.com/package/@xtr-dev/rondevu-client) -🌐 **WebRTC with durable connections and automatic reconnection** +🌐 **Simple, high-level WebRTC peer-to-peer connections** -TypeScript/JavaScript client for Rondevu, providing durable WebRTC connections that survive network interruptions with automatic reconnection and message queuing. +TypeScript/JavaScript client for Rondevu, providing easy-to-use WebRTC connections with automatic signaling, username-based discovery, and built-in reconnection support. **Related repositories:** - [@xtr-dev/rondevu-client](https://github.com/xtr-dev/rondevu-client) - TypeScript client library ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-client)) @@ -15,14 +15,16 @@ TypeScript/JavaScript client for Rondevu, providing durable WebRTC connections t ## 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) +- **High-Level Wrappers**: ServiceHost and ServiceClient eliminate WebRTC boilerplate +- **Username-Based Discovery**: Connect to peers by username, not complex offer/answer exchange +- **Semver-Compatible Matching**: Requesting chat@1.0.0 matches any compatible 1.x.x version +- **Privacy-First Design**: Services are hidden by default - no enumeration possible +- **Automatic Reconnection**: Built-in retry logic with exponential backoff +- **Message Queuing**: Messages sent while disconnected are queued and flushed on reconnect +- **Cryptographic Username Claiming**: Secure ownership with Ed25519 signatures +- **Service Publishing**: Package-style naming (chat.app@1.0.0) with multiple simultaneous offers - **TypeScript**: Full type safety and autocomplete -- **Configurable**: All timeouts, retry limits, and queue sizes are configurable +- **Configurable Polling**: Exponential backoff with jitter to reduce server load ## Install @@ -32,588 +34,400 @@ npm install @xtr-dev/rondevu-client ## Quick Start -### Publishing a Service (Alice) +### Hosting a Service (Alice) ```typescript -import { Rondevu } from '@xtr-dev/rondevu-client'; +import { RondevuService, ServiceHost } from '@xtr-dev/rondevu-client' -// Initialize client and register -const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' }); -await client.register(); +// Step 1: Create and initialize service +const service = new RondevuService({ + apiUrl: 'https://api.ronde.vu', + username: 'alice' +}) -// Step 1: Claim username (one-time) -const claim = await client.usernames.claimUsername('alice'); -client.usernames.saveKeypairToStorage('alice', claim.publicKey, claim.privateKey); +await service.initialize() // Generates keypair +await service.claimUsername() // Claims username with signature -// Step 2: Expose service with handler -const keypair = client.usernames.loadKeypairFromStorage('alice'); +// Step 2: Create ServiceHost +const host = new ServiceHost({ + service: 'chat.app@1.0.0', + rondevuService: service, + maxPeers: 5, // Accept up to 5 connections + ttl: 300000 // 5 minutes +}) -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}`); +// Step 3: Listen for incoming connections +host.events.on('connection', (connection) => { + console.log('✅ New connection!') - channel.on('message', (data) => { - console.log('📥 Received:', data); - channel.send(`Echo: ${data}`); - }); + connection.events.on('message', (msg) => { + console.log('📨 Received:', msg) + connection.sendMessage('Hello from Alice!') + }) - channel.on('close', () => { - console.log(`👋 Connection ${connectionId} closed`); - }); - } -}); + connection.events.on('state-change', (state) => { + console.log('Connection state:', state) + }) +}) -// Start the service -const info = await service.start(); -console.log(`Service published with UUID: ${info.uuid}`); -console.log('Waiting for connections...'); +host.events.on('error', (error) => { + console.error('Host error:', error) +}) -// Later: stop the service -await service.stop(); +// Step 4: Start hosting +await host.start() +console.log('Service is now live! Others can connect to @alice') + +// Later: stop hosting +host.dispose() ``` ### Connecting to a Service (Bob) ```typescript -import { Rondevu } from '@xtr-dev/rondevu-client'; +import { RondevuService, ServiceClient } from '@xtr-dev/rondevu-client' -// Initialize client and register -const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' }); -await client.register(); +// Step 1: Create and initialize service +const service = new RondevuService({ + apiUrl: 'https://api.ronde.vu', + username: 'bob' +}) -// Connect to Alice's service -const connection = await client.connect('alice', 'chat@1.0.0', { - maxReconnectAttempts: 5 -}); +await service.initialize() +await service.claimUsername() -// Create a durable channel -const channel = connection.createChannel('main'); +// Step 2: Create ServiceClient +const client = new ServiceClient({ + username: 'alice', // Connect to Alice + serviceFqn: 'chat.app@1.0.0', + rondevuService: service, + autoReconnect: true, + maxReconnectAttempts: 5 +}) -channel.on('message', (data) => { - console.log('📥 Received:', data); -}); +// Step 3: Listen for connection events +client.events.on('connected', (connection) => { + console.log('✅ Connected to Alice!') -channel.on('open', () => { - console.log('✅ Channel open'); - channel.send('Hello Alice!'); -}); + connection.events.on('message', (msg) => { + console.log('📨 Received:', msg) + }) -// Listen for connection events -connection.on('connected', () => { - console.log('🎉 Connected to Alice'); -}); + // Send a message + connection.sendMessage('Hello from Bob!') +}) -connection.on('reconnecting', (attempt, max, delay) => { - console.log(`🔄 Reconnecting... (${attempt}/${max}, retry in ${delay}ms)`); -}); +client.events.on('disconnected', () => { + console.log('🔌 Disconnected') +}) -connection.on('disconnected', () => { - console.log('🔌 Disconnected'); -}); +client.events.on('reconnecting', ({ attempt, maxAttempts }) => { + console.log(`🔄 Reconnecting (${attempt}/${maxAttempts})...`) +}) -connection.on('failed', (error) => { - console.error('❌ Connection failed permanently:', error); -}); +client.events.on('error', (error) => { + console.error('❌ Error:', error) +}) -// Establish the connection -await connection.connect(); +// Step 4: Connect +await client.connect() -// Messages sent during disconnection are automatically queued -channel.send('This will be queued if disconnected'); - -// Later: close the connection -await connection.close(); +// Later: disconnect +client.dispose() ``` ## Core Concepts -### DurableConnection +### RondevuService -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 +Handles authentication and username management: +- Generates Ed25519 keypair for signing +- Claims usernames with cryptographic proof +- Provides API client for signaling server -### DurableChannel +### ServiceHost -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 +High-level wrapper for hosting a WebRTC service: +- Automatically creates and publishes offers +- Handles incoming connections +- Manages ICE candidate exchange +- Supports multiple simultaneous peers -### DurableService +### ServiceClient -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 +High-level wrapper for connecting to services: +- Discovers services by username +- Handles offer/answer exchange automatically +- Built-in auto-reconnection with exponential backoff +- Event-driven API + +### RTCDurableConnection + +Low-level connection wrapper (used internally): +- Manages WebRTC PeerConnection lifecycle +- Handles ICE candidate polling +- Provides message queue for reliability +- State management and events ## API Reference -### Main Client +### RondevuService ```typescript -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 -}); +const service = new RondevuService({ + apiUrl: string, // Signaling server URL + username: string, // Your username + keypair?: Keypair // Optional: reuse existing keypair +}) -// Register and get credentials -const creds = await client.register(); -// { peerId: '...', secret: '...' } +// Initialize service (generates keypair if not provided) +await service.initialize(): Promise -// Check if authenticated -client.isAuthenticated(); // boolean +// Claim username with cryptographic signature +await service.claimUsername(): Promise -// Get current credentials -client.getCredentials(); // { peerId, secret } | undefined +// Check if username is claimed +service.isUsernameClaimed(): boolean + +// Get current username +service.getUsername(): string + +// Get keypair +service.getKeypair(): Keypair + +// Get API client +service.getAPI(): RondevuAPI ``` -### Username API +### ServiceHost ```typescript -// Check username availability -const check = await client.usernames.checkUsername('alice'); -// { available: true } or { available: false, expiresAt: number, publicKey: string } +const host = new ServiceHost({ + service: string, // Service FQN (e.g., 'chat.app@1.0.0') + rondevuService: RondevuService, + maxPeers?: number, // Default: 5 + ttl?: number, // Default: 300000 (5 minutes) + isPublic?: boolean, // Default: true + rtcConfiguration?: RTCConfiguration +}) -// Claim username with new keypair -const claim = await client.usernames.claimUsername('alice'); -// { username, publicKey, privateKey, claimedAt, expiresAt } +// Start hosting +await host.start(): Promise -// Save keypair to localStorage -client.usernames.saveKeypairToStorage('alice', claim.publicKey, claim.privateKey); +// Stop hosting and cleanup +host.dispose(): void -// Load keypair from localStorage -const keypair = client.usernames.loadKeypairFromStorage('alice'); -// { publicKey, privateKey } | null +// Get all active connections +host.getConnections(): RTCDurableConnection[] + +// Events +host.events.on('connection', (conn: RTCDurableConnection) => {}) +host.events.on('error', (error: Error) => {}) ``` -**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 +### ServiceClient ```typescript -// Expose a durable service -const service = await client.exposeService({ - username: 'alice', - privateKey: keypair.privateKey, - serviceFqn: 'chat@1.0.0', +const client = new ServiceClient({ + username: string, // Host username to connect to + serviceFqn: string, // Service FQN (e.g., 'chat.app@1.0.0') + rondevuService: RondevuService, + autoReconnect?: boolean, // Default: true + maxReconnectAttempts?: number, // Default: 5 + rtcConfiguration?: RTCConfiguration +}) - // 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 +// Connect to service +await client.connect(): Promise - // Connection pooling - poolSize: 10, // optional, default: 1 - pollingInterval: 2000, // optional, default: 2000ms +// Disconnect and cleanup +client.dispose(): void - // 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 +// Get current connection +client.getConnection(): RTCDurableConnection | null - // 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(); +// Events +client.events.on('connected', (conn: RTCDurableConnection) => {}) +client.events.on('disconnected', () => {}) +client.events.on('reconnecting', (info: { attempt: number, maxAttempts: number }) => {}) +client.events.on('error', (error: Error) => {}) ``` -**Service Events:** +### RTCDurableConnection + ```typescript -service.on('published', (serviceId, uuid) => { - console.log(`Service published: ${uuid}`); -}); +// Connection state +connection.state: 'connected' | 'connecting' | 'disconnected' -service.on('connection', (connectionId) => { - console.log(`New connection: ${connectionId}`); -}); +// Send message (returns true if sent, false if queued) +await connection.sendMessage(message: string): Promise -service.on('disconnection', (connectionId) => { - console.log(`Connection closed: ${connectionId}`); -}); +// Queue message for sending when connected +await connection.queueMessage(message: string, options?: QueueMessageOptions): Promise -service.on('ttl-refreshed', (expiresAt) => { - console.log(`TTL refreshed, expires at: ${new Date(expiresAt)}`); -}); +// Disconnect +connection.disconnect(): void -service.on('error', (error, context) => { - console.error(`Service error (${context}):`, error); -}); - -service.on('closed', () => { - console.log('Service stopped'); -}); +// Events +connection.events.on('message', (msg: string) => {}) +connection.events.on('state-change', (state: ConnectionStates) => {}) ``` -### Durable Connection API +## Configuration + +### Polling Configuration + +The signaling uses configurable polling with exponential backoff: ```typescript -// 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:** -```typescript -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 - -```typescript -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:** -```typescript -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 - -```typescript -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; +// Default polling config +{ + initialInterval: 500, // Start at 500ms + maxInterval: 5000, // Max 5 seconds + backoffMultiplier: 1.5, // Increase by 1.5x each time + maxRetries: 50, // Max 50 attempts + jitter: true // Add random 0-100ms to prevent thundering herd } ``` -### Service Configuration +This is handled automatically - no configuration needed. + +### WebRTC Configuration + +Provide custom STUN/TURN servers: ```typescript -interface DurableServiceConfig extends DurableConnectionConfig { - username: string; - privateKey: string; - serviceFqn: string; - isPublic?: boolean; // default: false - metadata?: Record; - ttl?: number; // default: 300000 (5 minutes) - ttlRefreshMargin?: number; // default: 0.2 (refresh at 80%) - poolSize?: number; // default: 1 - pollingInterval?: number; // default: 2000 (2 seconds) -} +const host = new ServiceHost({ + service: 'chat.app@1.0.0', + rondevuService: service, + rtcConfiguration: { + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { + urls: 'turn:turn.example.com:3478', + username: 'user', + credential: 'pass' + } + ] + } +}) ``` +## 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 signature + ## Examples ### Chat Application -```typescript -// Server -const client = new Rondevu(); -await client.register(); +See [demo/demo.js](./demo/demo.js) for a complete working example. -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 +### Persistent Keypair ```typescript -// 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); +// Save keypair to localStorage +const service = new RondevuService({ + apiUrl: 'https://api.ronde.vu', + username: 'alice' +}) - if (request.action === 'download') { - const file = await fs.readFile(request.path); - const chunkSize = 16 * 1024; // 16KB chunks +await service.initialize() +await service.claimUsername() - for (let i = 0; i < file.byteLength; i += chunkSize) { - const chunk = file.slice(i, i + chunkSize); - channel.send(chunk); +// Save for later +localStorage.setItem('rondevu-keypair', JSON.stringify(service.getKeypair())) +localStorage.setItem('rondevu-username', service.getUsername()) - // Wait for buffer to drain if needed - while (channel.bufferedAmount > 16 * 1024 * 1024) { // 16MB - await new Promise(resolve => setTimeout(resolve, 100)); - } - } +// Load on next session +const savedKeypair = JSON.parse(localStorage.getItem('rondevu-keypair')) +const savedUsername = localStorage.getItem('rondevu-username') - channel.send(JSON.stringify({ done: true })); - } - }); - } -}); +const service2 = new RondevuService({ + apiUrl: 'https://api.ronde.vu', + username: savedUsername, + keypair: savedKeypair +}) -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' })); +await service2.initialize() // Reuses keypair ``` -## Platform-Specific Setup +### Message Queue Example + +```typescript +// Messages are automatically queued if not connected yet +client.events.on('connected', (connection) => { + // Send immediately + connection.sendMessage('Hello!') +}) + +// Or queue for later +await client.connect() +const conn = client.getConnection() +await conn.queueMessage('This will be sent when connected', { + expiresAt: Date.now() + 60000 // Expire after 1 minute +}) +``` + +## Migration from v0.9.x + +v0.11.0+ introduces high-level wrappers, RESTful API changes, and semver-compatible discovery: + +**API Changes:** +- Server endpoints restructured (`/usernames/*` → `/users/*`) +- Added `ServiceHost` and `ServiceClient` wrappers +- Message queue fully implemented +- Configurable polling with exponential backoff +- Removed deprecated `cleanup()` methods (use `dispose()`) +- **v0.11.0+**: Services use `offers` array instead of single `sdp` +- **v0.11.0+**: Semver-compatible service discovery (chat@1.0.0 matches 1.x.x) +- **v0.11.0+**: All services are hidden - no listing endpoint +- **v0.11.0+**: Services support multiple simultaneous offers for connection pooling + +**Migration Guide:** + +```typescript +// Before (v0.9.x) - Manual WebRTC setup +const signaler = new RondevuSignaler(service, 'chat@1.0.0') +const context = new WebRTCContext() +const pc = context.createPeerConnection() +// ... 50+ lines of boilerplate + +// After (v0.11.0) - ServiceHost wrapper +const host = new ServiceHost({ + service: 'chat@1.0.0', + rondevuService: service +}) +await host.start() +// Done! +``` + +## Platform Support ### Modern Browsers Works out of the box - no additional setup needed. ### Node.js 18+ -Native fetch is available, but you need WebRTC polyfills: +Native fetch is available, but WebRTC requires polyfills: ```bash npm install wrtc ``` ```typescript -import { Rondevu } from '@xtr-dev/rondevu-client'; -import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'wrtc'; +import { WebRTCContext } 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: - -```bash -npm install node-fetch wrtc -``` - -```typescript -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 -}); +// Configure WebRTC context +const context = new WebRTCContext({ + RTCPeerConnection, + RTCSessionDescription, + RTCIceCandidate +} as any) ``` ## TypeScript @@ -622,38 +436,23 @@ All types are exported: ```typescript 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'; + RondevuServiceOptions, + ServiceHostOptions, + ServiceHostEvents, + ServiceClientOptions, + ServiceClientEvents, + ConnectionInterface, + ConnectionEvents, + ConnectionStates, + Message, + QueueMessageOptions, + Signaler, + PollingConfig, + Credentials, + Keypair +} 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](./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 diff --git a/demo/README.md b/demo/README.md deleted file mode 100644 index 82d485e..0000000 --- a/demo/README.md +++ /dev/null @@ -1,141 +0,0 @@ -# Rondevu WebRTC Local Test - -Simple side-by-side demo for testing `WebRTCRondevuConnection` with **local signaling** (no server required). - -## Quick Start - -```bash -npm run dev -``` - -Opens browser at `http://localhost:3000` - -## How It Works - -This demo uses **local in-memory signaling** to test WebRTC connections between two peers on the same page. The `LocalSignaler` class simulates a signaling server by directly exchanging ICE candidates and SDP between peers. - -### Architecture - -- **LocalSignaler**: Implements the `Signaler` interface with local peer-to-peer communication -- **Host (Peer A)**: Creates the offer (offerer role) -- **Client (Peer B)**: Receives the offer and creates answer (answerer role) -- **ICE Exchange**: Candidates are automatically exchanged between peers through the linked signalers - -## Usage Steps - -1. **Create Host** (Peer A) - - Click "1️⃣ Create Host Connection" on the left side - - The host will create an offer and display it - - Status changes to "Connecting" - -2. **Create Client** (Peer B) - - Click "2️⃣ Create Client Connection" on the right side - - The client receives the host's offer automatically - - Creates an answer and sends it back to the host - - Both peers exchange ICE candidates - -3. **Connection Established** - - Watch the status indicators turn green ("Connected") - - Activity logs show the connection progress - -4. **Send Messages** - - Type a message in either peer's input field - - Click "📤 Send" or press Enter - - Messages appear in the other peer's activity log - -## Features - -- ✅ **No signaling server required** - Everything runs locally -- ✅ **Automatic ICE candidate exchange** - Signalers handle candidate exchange -- ✅ **Real-time activity logs** - See exactly what's happening -- ✅ **Connection state indicators** - Visual feedback for connection status -- ✅ **Bidirectional messaging** - Send messages in both directions - -## Code Structure - -### demo.js - -```javascript -// LocalSignaler - Implements local signaling -class LocalSignaler { - addIceCandidate(candidate) // Called when local peer has a candidate - addListener(callback) // Listen for remote candidates - linkTo(remoteSignaler) // Connect two signalers together -} - -// Create and link signalers -const hostSignaler = new LocalSignaler('HOST', 'CLIENT') -const clientSignaler = new LocalSignaler('CLIENT', 'HOST') -hostSignaler.linkTo(clientSignaler) -clientSignaler.linkTo(hostSignaler) - -// Create connections -const hostConnection = new WebRTCRondevuConnection({ - id: 'test-connection', - host: 'client-peer', - service: 'test.demo@1.0.0', - offer: null, // No offer = offerer role - context: new WebRTCContext(hostSignaler) -}) - -const clientConnection = new WebRTCRondevuConnection({ - id: 'test-connection', - host: 'host-peer', - service: 'test.demo@1.0.0', - offer: hostConnection.connection.localDescription, // With offer = answerer role - context: new WebRTCContext(clientSignaler) -}) -``` - -### index.html - -- Side-by-side layout for Host and Client -- Status indicators (disconnected/connecting/connected) -- SDP display areas (offer/answer) -- Message input and send buttons -- Activity logs for each peer - -## Debugging - -Open the browser console to see detailed logs: - -- `[HOST]` - Logs from the host connection -- `[CLIENT]` - Logs from the client connection -- ICE candidate exchange -- Connection state changes -- Message send/receive events - -## Comparison: Local vs Remote Signaling - -### Local Signaling (This Demo) -```javascript -const signaler = new LocalSignaler('HOST', 'CLIENT') -signaler.linkTo(remoteSignaler) // Direct link -``` - -**Pros**: No server, instant testing, no network latency -**Cons**: Only works for same-page testing - -### Remote Signaling (Production) -```javascript -const api = new RondevuAPI('https://api.ronde.vu', credentials) -const signaler = new RondevuSignaler(api, offerId) -``` - -**Pros**: Real peer discovery, works across networks -**Cons**: Requires signaling server, network latency - -## Next Steps - -After testing locally, you can: - -1. Switch to `RondevuSignaler` for real signaling server testing -2. Test across different browsers/devices -3. Test with STUN/TURN servers for NAT traversal -4. Implement production signaling with Rondevu API - -## Files - -- `index.html` - UI layout and styling -- `demo.js` - Local signaling implementation and WebRTC logic -- `README.md` - This file diff --git a/demo/demo.js b/demo/demo.js deleted file mode 100644 index 20afbd5..0000000 --- a/demo/demo.js +++ /dev/null @@ -1,304 +0,0 @@ -import { WebRTCRondevuConnection } from '../src/index.js' -import { WebRTCContext } from '../src/webrtc-context.js' - -// Local signaling implementation for testing -class LocalSignaler { - constructor(name, remoteName) { - this.name = name - this.remoteName = remoteName - this.iceCandidates = [] - this.iceListeners = [] - this.remote = null - this.remoteIceCandidates = [] - this.offerCallbacks = [] - this.answerCallbacks = [] - } - - // Link two signalers together - linkTo(remoteSignaler) { - this.remote = remoteSignaler - this.remoteIceCandidates = remoteSignaler.iceCandidates - } - - // Set local offer (called when offer is created) - setOffer(offer) { - console.log(`[${this.name}] Setting offer`) - // Notify remote peer about the offer - if (this.remote) { - this.remote.offerCallbacks.forEach(callback => callback(offer)) - } - } - - // Set local answer (called when answer is created) - setAnswer(answer) { - console.log(`[${this.name}] Setting answer`) - // Notify remote peer about the answer - if (this.remote) { - this.remote.answerCallbacks.forEach(callback => callback(answer)) - } - } - - // Listen for offers from remote peer - addOfferListener(callback) { - this.offerCallbacks.push(callback) - return () => { - const index = this.offerCallbacks.indexOf(callback) - if (index > -1) { - this.offerCallbacks.splice(index, 1) - } - } - } - - // Listen for answers from remote peer - addAnswerListener(callback) { - this.answerCallbacks.push(callback) - return () => { - const index = this.answerCallbacks.indexOf(callback) - if (index > -1) { - this.answerCallbacks.splice(index, 1) - } - } - } - - // Add local ICE candidate (called by local connection) - addIceCandidate(candidate) { - console.log(`[${this.name}] Adding ICE candidate:`, candidate.candidate) - this.iceCandidates.push(candidate) - - // Immediately send to remote peer if linked - if (this.remote) { - setTimeout(() => { - this.remote.iceListeners.forEach(listener => { - console.log(`[${this.name}] Sending ICE to ${this.remoteName}`) - listener(candidate) - }) - }, 10) - } - } - - // Listen for remote ICE candidates - addListener(callback) { - console.log(`[${this.name}] Adding ICE listener`) - this.iceListeners.push(callback) - - // Send any existing remote candidates - this.remoteIceCandidates.forEach(candidate => { - setTimeout(() => callback(candidate), 10) - }) - - return () => { - const index = this.iceListeners.indexOf(callback) - if (index > -1) { - this.iceListeners.splice(index, 1) - } - } - } -} - -// Create signalers for host and client -const hostSignaler = new LocalSignaler('HOST', 'CLIENT') -const clientSignaler = new LocalSignaler('CLIENT', 'HOST') - -// Link them together for bidirectional communication -hostSignaler.linkTo(clientSignaler) -clientSignaler.linkTo(hostSignaler) - -// Store connections -let hostConnection = null -let clientConnection = null - -// UI Update functions -function updateStatus(peer, state) { - const statusEl = document.getElementById(`status-${peer}`) - if (statusEl) { - statusEl.className = `status ${state}` - statusEl.textContent = state.charAt(0).toUpperCase() + state.slice(1) - } -} - -function addLog(peer, message) { - const logEl = document.getElementById(`log-${peer}`) - if (logEl) { - const time = new Date().toLocaleTimeString() - logEl.innerHTML += `
[${time}] ${message}
` - logEl.scrollTop = logEl.scrollHeight - } -} - -// Create Host (Offerer) -async function createHost() { - try { - addLog('a', 'Creating host connection (offerer)...') - - const hostContext = new WebRTCContext(hostSignaler) - - hostConnection = new WebRTCRondevuConnection({ - id: 'test-connection', - host: 'client-peer', - service: 'test.demo@1.0.0', - offer: null, - context: hostContext, - }) - - // Listen for state changes - hostConnection.events.on('state-change', state => { - console.log('[HOST] State changed:', state) - updateStatus('a', state) - addLog('a', `State changed to: ${state}`) - }) - - // Listen for messages - hostConnection.events.on('message', message => { - console.log('[HOST] Received message:', message) - addLog('a', `📨 Received: ${message}`) - }) - - addLog('a', '✅ Host connection created') - updateStatus('a', 'connecting') - - // Wait for host to be ready (offer created and set) - await hostConnection.ready - addLog('a', '✅ Host offer created') - - // Get the offer - const offer = hostConnection.connection.localDescription - document.getElementById('offer-a').value = JSON.stringify(offer, null, 2) - - addLog('a', 'Offer ready to send to client') - } catch (error) { - console.error('[HOST] Error:', error) - addLog('a', `❌ Error: ${error.message}`) - updateStatus('a', 'disconnected') - } -} - -// Create Client (Answerer) -async function createClient() { - try { - addLog('b', 'Creating client connection (answerer)...') - - // Get offer from host - if (!hostConnection) { - alert('Please create host first!') - return - } - - const offer = hostConnection.connection.localDescription - if (!offer) { - alert('Host offer not ready yet!') - return - } - - addLog('b', 'Got offer from host') - - const clientContext = new WebRTCContext(clientSignaler) - - clientConnection = new WebRTCRondevuConnection({ - id: 'test-connection', - host: 'host-peer', - service: 'test.demo@1.0.0', - offer: offer, - context: clientContext, - }) - - // Listen for state changes - clientConnection.events.on('state-change', state => { - console.log('[CLIENT] State changed:', state) - updateStatus('b', state) - addLog('b', `State changed to: ${state}`) - }) - - // Listen for messages - clientConnection.events.on('message', message => { - console.log('[CLIENT] Received message:', message) - addLog('b', `📨 Received: ${message}`) - }) - - addLog('b', '✅ Client connection created') - updateStatus('b', 'connecting') - - // Wait for client to be ready - await clientConnection.ready - addLog('b', '✅ Client answer created') - - // Get the answer - const answer = clientConnection.connection.localDescription - document.getElementById('answer-b').value = JSON.stringify(answer, null, 2) - - // Set answer on host - addLog('b', 'Setting answer on host...') - await hostConnection.connection.setRemoteDescription(answer) - addLog('b', '✅ Answer set on host') - addLog('a', '✅ Answer received from client') - } catch (error) { - console.error('[CLIENT] Error:', error) - addLog('b', `❌ Error: ${error.message}`) - updateStatus('b', 'disconnected') - } -} - -// Send test message from host to client -function sendFromHost() { - if (!hostConnection) { - alert('Please create host first!') - return - } - - const message = document.getElementById('message-a').value || 'Hello from Host!' - addLog('a', `📤 Sending: ${message}`) - hostConnection - .sendMessage(message) - .then(success => { - if (success) { - addLog('a', '✅ Message sent successfully') - } else { - addLog('a', '⚠️ Message queued (not connected)') - } - }) - .catch(error => { - addLog('a', `❌ Error sending: ${error.message}`) - }) -} - -// Send test message from client to host -function sendFromClient() { - if (!clientConnection) { - alert('Please create client first!') - return - } - - const message = document.getElementById('message-b').value || 'Hello from Client!' - addLog('b', `📤 Sending: ${message}`) - clientConnection - .sendMessage(message) - .then(success => { - if (success) { - addLog('b', '✅ Message sent successfully') - } else { - addLog('b', '⚠️ Message queued (not connected)') - } - }) - .catch(error => { - addLog('b', `❌ Error sending: ${error.message}`) - }) -} - -// Attach event listeners when DOM is ready -document.addEventListener('DOMContentLoaded', () => { - // Clear all textareas on load - document.getElementById('offer-a').value = '' - document.getElementById('answer-b').value = '' - - // Make functions globally available (for console testing) - window.createHost = createHost - window.createClient = createClient - window.sendFromHost = sendFromHost - window.sendFromClient = sendFromClient - - console.log('🚀 Local signaling test loaded') - console.log('Steps:') - console.log('1. Click "Create Host" (Peer A)') - console.log('2. Click "Create Client" (Peer B)') - console.log('3. Wait for connection to establish') - console.log('4. Send messages between peers') -}) diff --git a/demo/index.html b/demo/index.html deleted file mode 100644 index 9bfdc52..0000000 --- a/demo/index.html +++ /dev/null @@ -1,280 +0,0 @@ - - - - - - Rondevu WebRTC Local Test - - - -
-

🔗 Rondevu WebRTC Local Test

- -
- -
-

Peer A (Host/Offerer)

- -
Disconnected
- -
- -
- -
-

Local Offer (SDP)

- -
- -
-

Send Message

-
- - -
-
- -
-

Activity Log

-
-
-
- - -
-

Peer B (Client/Answerer)

- -
Disconnected
- -
- -
- -
-

Local Answer (SDP)

- -
- -
-

Send Message

-
- - -
-
- -
-

Activity Log

-
-
-
-
-
- - - - diff --git a/src/connection.ts b/src/durable-connection.ts similarity index 97% rename from src/connection.ts rename to src/durable-connection.ts index 5f69dcd..04eb4ba 100644 --- a/src/connection.ts +++ b/src/durable-connection.ts @@ -71,15 +71,11 @@ export class WebRTCRondevuConnection implements ConnectionInterface { public readonly ready: Promise private iceBin = createBin() private ctx: WebRTCContext - public id: string - public service: string private _conn: RTCPeerConnection | null = null private _state: ConnectionInterface['state'] = 'disconnected' - constructor({ context: ctx, offer, id, service }: WebRTCRondevuConnectionOptions) { + constructor({ context: ctx, offer }: WebRTCRondevuConnectionOptions) { this.ctx = ctx - this.id = id - this.service = service this._conn = ctx.createPeerConnection() this.side = offer ? 'answer' : 'offer' diff --git a/src/noop-signaler.ts b/src/noop-signaler.ts deleted file mode 100644 index b6441fd..0000000 --- a/src/noop-signaler.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Signaler } from './types.js' -import { Binnable } from './bin.js' - -/** - * NoOpSignaler - A signaler that does nothing - * Used as a placeholder during connection setup before the real signaler is available - */ -export class NoOpSignaler implements Signaler { - addIceCandidate(_candidate: RTCIceCandidate): void { - // No-op - } - - addListener(_callback: (candidate: RTCIceCandidate) => void): Binnable { - // Return no-op cleanup function - return () => {} - } - - addOfferListener(_callback: (offer: RTCSessionDescriptionInit) => void): Binnable { - // Return no-op cleanup function - return () => {} - } - - addAnswerListener(_callback: (answer: RTCSessionDescriptionInit) => void): Binnable { - // Return no-op cleanup function - return () => {} - } - - async setOffer(_offer: RTCSessionDescriptionInit): Promise { - // No-op - } - - async setAnswer(_answer: RTCSessionDescriptionInit): Promise { - // No-op - } -} diff --git a/src/rondevu-context.ts b/src/rondevu-context.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/rondevu-signaler.ts b/src/rondevu-signaler.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/service-client.ts b/src/service-client.ts deleted file mode 100644 index b5ac6f0..0000000 --- a/src/service-client.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { WebRTCRondevuConnection } from './connection.js' -import { WebRTCContext } from './webrtc-context.js' -import { RondevuService } from './rondevu-service.js' -import { RondevuSignaler } from './signaler.js' -import { EventBus } from './event-bus.js' -import { createBin } from './bin.js' -import { ConnectionInterface } from './types.js' - -export interface ServiceClientOptions { - username: string - serviceFqn: string - rondevuService: RondevuService - autoReconnect?: boolean - reconnectDelay?: number - maxReconnectAttempts?: number - rtcConfiguration?: RTCConfiguration -} - -export interface ServiceClientEvents { - connected: ConnectionInterface - disconnected: { reason: string } - reconnecting: { attempt: number; maxAttempts: number } - error: Error -} - -/** - * ServiceClient - Connects to a hosted service - * - * Searches for available service offers and establishes a WebRTC connection. - * Optionally supports automatic reconnection on failure. - * - * @example - * ```typescript - * const rondevuService = new RondevuService({ - * apiUrl: 'https://signal.example.com', - * username: 'client-user', - * }) - * - * await rondevuService.initialize() - * - * const client = new ServiceClient({ - * username: 'host-user', - * serviceFqn: 'chat.app@1.0.0', - * rondevuService, - * autoReconnect: true, - * }) - * - * await client.connect() - * - * client.events.on('connected', (conn) => { - * console.log('Connected to service') - * conn.sendMessage('Hello!') - * }) - * ``` - */ -export class ServiceClient { - private readonly username: string - private readonly serviceFqn: string - private readonly rondevuService: RondevuService - private readonly autoReconnect: boolean - private readonly reconnectDelay: number - private readonly maxReconnectAttempts: number - private readonly rtcConfiguration?: RTCConfiguration - private connection: WebRTCRondevuConnection | null = null - private reconnectAttempts = 0 - private reconnectTimeout: ReturnType | null = null - private readonly bin = createBin() - private isConnecting = false - - public readonly events = new EventBus() - - constructor(options: ServiceClientOptions) { - this.username = options.username - this.serviceFqn = options.serviceFqn - this.rondevuService = options.rondevuService - this.autoReconnect = options.autoReconnect !== false - this.reconnectDelay = options.reconnectDelay || 2000 - this.maxReconnectAttempts = options.maxReconnectAttempts || 5 - this.rtcConfiguration = options.rtcConfiguration - } - - /** - * Connect to the service - */ - async connect(): Promise { - if (this.isConnecting) { - throw new Error('Already connecting') - } - - if (this.connection && this.connection.state === 'connected') { - return this.connection - } - - this.isConnecting = true - - try { - // Search for available services - const services = await this.rondevuService - .getAPI() - .searchServices(this.username, this.serviceFqn) - - if (services.length === 0) { - throw new Error(`No services found for ${this.username}/${this.serviceFqn}`) - } - - // Get the first available service - const service = services[0] - - // Get service details including SDP - const serviceDetails = await this.rondevuService.getAPI().getService(service.uuid) - - // Create WebRTC context with signaler for this offer - const signaler = new RondevuSignaler( - this.rondevuService.getAPI(), - serviceDetails.offerId - ) - const context = new WebRTCContext(signaler, this.rtcConfiguration) - - // Create connection (answerer role) - const conn = new WebRTCRondevuConnection({ - id: `client-${this.serviceFqn}-${Date.now()}`, - service: this.serviceFqn, - offer: { - type: 'offer', - sdp: serviceDetails.sdp, - }, - context, - }) - - // Wait for answer to be created - await conn.ready - - // Get answer SDP - if (!conn.connection?.localDescription?.sdp) { - throw new Error('Failed to create answer SDP') - } - - const answerSdp = conn.connection.localDescription.sdp - - // Send answer to server - await this.rondevuService.getAPI().answerOffer(serviceDetails.offerId, answerSdp) - - // Track connection - this.connection = conn - this.reconnectAttempts = 0 - - // Listen for state changes - const cleanup = conn.events.on('state-change', state => { - this.handleConnectionStateChange(state) - }) - this.bin(cleanup) - - this.isConnecting = false - - // Emit connected event when actually connected - if (conn.state === 'connected') { - this.events.emit('connected', conn) - } - - return conn - } catch (error) { - this.isConnecting = false - this.events.emit('error', error as Error) - throw error - } - } - - /** - * Disconnect from the service - */ - disconnect(): void { - if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout) - this.reconnectTimeout = null - } - - if (this.connection) { - this.connection.disconnect() - this.connection = null - } - - this.bin.clean() - this.reconnectAttempts = 0 - } - - /** - * Get the current connection - */ - getConnection(): WebRTCRondevuConnection | null { - return this.connection - } - - /** - * Check if currently connected - */ - isConnected(): boolean { - return this.connection?.state === 'connected' - } - - /** - * Handle connection state changes - */ - private handleConnectionStateChange(state: ConnectionInterface['state']): void { - if (state === 'connected') { - this.events.emit('connected', this.connection!) - this.reconnectAttempts = 0 - } else if (state === 'disconnected') { - this.events.emit('disconnected', { reason: 'Connection closed' }) - - // Attempt reconnection if enabled - if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) { - this.scheduleReconnect() - } - } - } - - /** - * Schedule a reconnection attempt - */ - private scheduleReconnect(): void { - if (this.reconnectTimeout) { - return - } - - this.reconnectAttempts++ - - this.events.emit('reconnecting', { - attempt: this.reconnectAttempts, - maxAttempts: this.maxReconnectAttempts, - }) - - // Exponential backoff - const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1) - - this.reconnectTimeout = setTimeout(() => { - this.reconnectTimeout = null - this.connect().catch(error => { - this.events.emit('error', error as Error) - - // Schedule next attempt if we haven't exceeded max attempts - if (this.reconnectAttempts < this.maxReconnectAttempts) { - this.scheduleReconnect() - } - }) - }, delay) - } -} diff --git a/src/service-host.ts b/src/service-host.ts deleted file mode 100644 index e25f820..0000000 --- a/src/service-host.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { WebRTCRondevuConnection } from './connection.js' -import { WebRTCContext } from './webrtc-context.js' -import { RondevuService } from './rondevu-service.js' -import { RondevuSignaler } from './signaler.js' -import { NoOpSignaler } from './noop-signaler.js' -import { EventBus } from './event-bus.js' -import { createBin } from './bin.js' -import { ConnectionInterface } from './types.js' - -export interface ServiceHostOptions { - service: string - rondevuService: RondevuService - maxPeers?: number - ttl?: number - isPublic?: boolean - metadata?: Record - rtcConfiguration?: RTCConfiguration -} - -export interface ServiceHostEvents { - connection: ConnectionInterface - 'connection-closed': { connectionId: string; reason: string } - error: Error -} - -/** - * ServiceHost - Manages a pool of WebRTC offers for a service - * - * Maintains up to maxPeers concurrent offers, automatically replacing - * them when connections are established or expire. - * - * @example - * ```typescript - * const rondevuService = new RondevuService({ - * apiUrl: 'https://signal.example.com', - * username: 'myusername', - * }) - * - * await rondevuService.initialize() - * await rondevuService.claimUsername() - * - * const host = new ServiceHost({ - * service: 'chat.app@1.0.0', - * rondevuService, - * maxPeers: 5, - * }) - * - * await host.start() - * - * host.events.on('connection', (conn) => { - * console.log('New connection:', conn.id) - * conn.events.on('message', (msg) => { - * console.log('Message:', msg) - * }) - * }) - * ``` - */ -export class ServiceHost { - private connections = new Map() - private readonly service: string - private readonly rondevuService: RondevuService - private readonly maxPeers: number - private readonly ttl: number - private readonly isPublic: boolean - private readonly metadata?: Record - private readonly rtcConfiguration?: RTCConfiguration - private readonly bin = createBin() - private isStarted = false - - public readonly events = new EventBus() - - constructor(options: ServiceHostOptions) { - this.service = options.service - this.rondevuService = options.rondevuService - this.maxPeers = options.maxPeers || 20 - this.ttl = options.ttl || 300000 - this.isPublic = options.isPublic !== false - this.metadata = options.metadata - this.rtcConfiguration = options.rtcConfiguration - } - - /** - * Start hosting the service - creates initial pool of offers - */ - async start(): Promise { - if (this.isStarted) { - throw new Error('ServiceHost already started') - } - - this.isStarted = true - await this.fillOfferPool() - } - - /** - * Stop hosting - closes all connections and cleans up - */ - stop(): void { - this.isStarted = false - this.connections.forEach(conn => conn.disconnect()) - this.connections.clear() - this.bin.clean() - } - - /** - * Get current number of active connections - */ - getConnectionCount(): number { - return Array.from(this.connections.values()).filter(conn => conn.state === 'connected') - .length - } - - /** - * Get current number of pending offers - */ - getPendingOfferCount(): number { - return Array.from(this.connections.values()).filter(conn => conn.state === 'connecting') - .length - } - - /** - * Fill the offer pool up to maxPeers - */ - private async fillOfferPool(): Promise { - const currentOffers = this.connections.size - const needed = this.maxPeers - currentOffers - - if (needed <= 0) { - return - } - - // Create multiple offers in parallel - const offerPromises: Promise[] = [] - for (let i = 0; i < needed; i++) { - offerPromises.push(this.createOffer()) - } - - await Promise.allSettled(offerPromises) - } - - /** - * Create a single offer and publish it - */ - private async createOffer(): Promise { - try { - // Create temporary context with NoOp signaler - const tempContext = new WebRTCContext(new NoOpSignaler(), this.rtcConfiguration) - - // Create connection (offerer role) - const conn = new WebRTCRondevuConnection({ - id: `${this.service}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, - service: this.service, - offer: null, - context: tempContext, - }) - - // Wait for offer to be created - await conn.ready - - // Get offer SDP - if (!conn.connection?.localDescription?.sdp) { - throw new Error('Failed to create offer SDP') - } - - const sdp = conn.connection.localDescription.sdp - - // Publish service offer - const service = await this.rondevuService.publishService({ - serviceFqn: this.service, - sdp, - ttl: this.ttl, - isPublic: this.isPublic, - metadata: this.metadata, - }) - - // Replace with real signaler now that we have offerId - const realSignaler = new RondevuSignaler(this.rondevuService.getAPI(), service.offerId) - ;(tempContext as any).signaler = realSignaler - - // Track connection - this.connections.set(conn.id, conn) - - // Listen for state changes - const cleanup = conn.events.on('state-change', state => { - this.handleConnectionStateChange(conn, state) - }) - - this.bin(cleanup) - } catch (error) { - this.events.emit('error', error as Error) - } - } - - /** - * Handle connection state changes - */ - private handleConnectionStateChange( - conn: WebRTCRondevuConnection, - state: ConnectionInterface['state'] - ): void { - if (state === 'connected') { - // Connection established - emit event - this.events.emit('connection', conn) - - // Create new offer to replace this one - if (this.isStarted) { - this.fillOfferPool().catch(error => { - this.events.emit('error', error as Error) - }) - } - } else if (state === 'disconnected') { - // Connection closed - remove and create new offer - this.connections.delete(conn.id) - this.events.emit('connection-closed', { - connectionId: conn.id, - reason: state, - }) - - if (this.isStarted) { - this.fillOfferPool().catch(error => { - this.events.emit('error', error as Error) - }) - } - } - } - - /** - * Get all active connections - */ - getConnections(): WebRTCRondevuConnection[] { - return Array.from(this.connections.values()) - } - - /** - * Get a specific connection by ID - */ - getConnection(connectionId: string): WebRTCRondevuConnection | undefined { - return this.connections.get(connectionId) - } -} diff --git a/vite.config.js b/vite.config.js deleted file mode 100644 index 62c9f36..0000000 --- a/vite.config.js +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from 'vite'; - -export default defineConfig({ - root: 'demo', - server: { - port: 3000, - open: true, - allowedHosts: ['241284034b20.ngrok-free.app'] - } -});