diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..067803b --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,547 @@ +# Migration Guide: v0.8.x → v0.9.0 + +This guide helps you migrate from Rondevu Client v0.8.x to v0.9.0. + +## Overview + +v0.9.0 is a **breaking change** that completely replaces low-level APIs with high-level durable connections featuring automatic reconnection and message queuing. + +### What's New + +✅ **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 +✅ **Simplified API**: Direct methods on main client instead of nested APIs + +### What's Removed + +❌ **Low-level APIs**: `client.services.*`, `client.discovery.*`, `client.createPeer()` no longer exported +❌ **Manual Connection Management**: No need to handle WebRTC peer lifecycle manually +❌ **Service Handles**: Replaced with DurableService instances + +## Breaking Changes + +### 1. Service Exposure + +#### v0.8.x (Old) +```typescript +import { Rondevu } from '@xtr-dev/rondevu-client'; + +const client = new Rondevu(); +await client.register(); + +const handle = await client.services.exposeService({ + username: 'alice', + privateKey: keypair.privateKey, + serviceFqn: 'chat@1.0.0', + isPublic: true, + handler: (channel, peer) => { + channel.onmessage = (e) => { + console.log('Received:', e.data); + channel.send(`Echo: ${e.data}`); + }; + } +}); + +// Unpublish +await handle.unpublish(); +``` + +#### v0.9.0 (New) +```typescript +import { Rondevu } from '@xtr-dev/rondevu-client'; + +const client = new Rondevu(); +await client.register(); + +const service = await client.exposeService({ + username: 'alice', + privateKey: keypair.privateKey, + serviceFqn: 'chat@1.0.0', + isPublic: true, + poolSize: 10, // NEW: Handle multiple concurrent connections + handler: (channel, connectionId) => { + // NEW: DurableChannel with event emitters + channel.on('message', (data) => { + console.log('Received:', data); + channel.send(`Echo: ${data}`); + }); + } +}); + +// NEW: Start the service +await service.start(); + +// NEW: Stop the service +await service.stop(); +``` + +**Key Differences:** +- `client.services.exposeService()` → `client.exposeService()` +- Returns `DurableService` instead of `ServiceHandle` +- Handler receives `DurableChannel` instead of `RTCDataChannel` +- Handler receives `connectionId` string instead of `RondevuPeer` +- DurableChannel uses `.on('message', ...)` instead of `.onmessage = ...` +- Must call `service.start()` to begin accepting connections +- Use `service.stop()` instead of `handle.unpublish()` + +### 2. Connecting to Services + +#### v0.8.x (Old) +```typescript +// Connect by username + FQN +const { peer, channel } = await client.discovery.connect( + 'alice', + 'chat@1.0.0' +); + +channel.onmessage = (e) => { + console.log('Received:', e.data); +}; + +channel.onopen = () => { + channel.send('Hello!'); +}; + +peer.on('connected', () => { + console.log('Connected'); +}); + +peer.on('failed', (error) => { + console.error('Failed:', error); +}); +``` + +#### v0.9.0 (New) +```typescript +// Connect by username + FQN +const connection = await client.connect('alice', 'chat@1.0.0', { + maxReconnectAttempts: 10 // NEW: Configurable reconnection +}); + +// NEW: Create durable channel +const channel = connection.createChannel('main'); + +channel.on('message', (data) => { + console.log('Received:', data); +}); + +channel.on('open', () => { + channel.send('Hello!'); +}); + +// NEW: Connection lifecycle events +connection.on('connected', () => { + console.log('Connected'); +}); + +connection.on('reconnecting', (attempt, max, delay) => { + console.log(`Reconnecting (${attempt}/${max})...`); +}); + +connection.on('failed', (error) => { + console.error('Failed permanently:', error); +}); + +// NEW: Must explicitly connect +await connection.connect(); +``` + +**Key Differences:** +- `client.discovery.connect()` → `client.connect()` +- Returns `DurableConnection` instead of `{ peer, channel }` +- Must create channels with `connection.createChannel()` +- Must call `connection.connect()` to establish connection +- Automatic reconnection with configurable retry limits +- Messages sent during disconnection are automatically queued + +### 3. Connecting by UUID + +#### v0.8.x (Old) +```typescript +const { peer, channel } = await client.discovery.connectByUuid('service-uuid'); + +channel.onmessage = (e) => { + console.log('Received:', e.data); +}; +``` + +#### v0.9.0 (New) +```typescript +const connection = await client.connectByUuid('service-uuid', { + maxReconnectAttempts: 5 +}); + +const channel = connection.createChannel('main'); + +channel.on('message', (data) => { + console.log('Received:', data); +}); + +await connection.connect(); +``` + +**Key Differences:** +- `client.discovery.connectByUuid()` → `client.connectByUuid()` +- Returns `DurableConnection` instead of `{ peer, channel }` +- Must create channels and connect explicitly + +### 4. Multi-Connection Services (Offer Pooling) + +#### v0.8.x (Old) +```typescript +const handle = await client.services.exposeService({ + username: 'alice', + privateKey: keypair.privateKey, + serviceFqn: 'chat@1.0.0', + poolSize: 5, + pollingInterval: 2000, + handler: (channel, peer, connectionId) => { + console.log(`Connection: ${connectionId}`); + }, + onPoolStatus: (status) => { + console.log('Pool status:', status); + } +}); + +const status = handle.getStatus(); +await handle.addOffers(3); +``` + +#### v0.9.0 (New) +```typescript +const service = await client.exposeService({ + username: 'alice', + privateKey: keypair.privateKey, + serviceFqn: 'chat@1.0.0', + poolSize: 5, // SAME: Pool size + pollingInterval: 2000, // SAME: Polling interval + handler: (channel, connectionId) => { + console.log(`Connection: ${connectionId}`); + } +}); + +await service.start(); + +// Get active connections +const connections = service.getActiveConnections(); + +// Listen for connection events +service.on('connection', (connectionId) => { + console.log('New connection:', connectionId); +}); +``` + +**Key Differences:** +- `onPoolStatus` callback removed (use `service.on('connection')` instead) +- `handle.getStatus()` replaced with `service.getActiveConnections()` +- `handle.addOffers()` removed (pool auto-manages offers) +- Handler receives `DurableChannel` instead of `RTCDataChannel` + +## Feature Comparison + +| Feature | v0.8.x | v0.9.0 | +|---------|--------|--------| +| Service exposure | `client.services.exposeService()` | `client.exposeService()` | +| Connection | `client.discovery.connect()` | `client.connect()` | +| Connection by UUID | `client.discovery.connectByUuid()` | `client.connectByUuid()` | +| Channel type | `RTCDataChannel` | `DurableChannel` | +| Event handling | `.onmessage`, `.onopen`, etc. | `.on('message')`, `.on('open')`, etc. | +| Automatic reconnection | ❌ No | ✅ Yes (configurable) | +| Message queuing | ❌ No | ✅ Yes (during disconnections) | +| TTL auto-refresh | ❌ No | ✅ Yes (configurable) | +| Peer lifecycle | Manual | Automatic | +| Connection pooling | ✅ Yes | ✅ Yes (same API) | + +## API Mapping + +### Removed Exports + +These are no longer exported in v0.9.0: + +```typescript +// ❌ Removed +import { + RondevuServices, + RondevuDiscovery, + RondevuPeer, + ServiceHandle, + PooledServiceHandle, + ConnectResult +} from '@xtr-dev/rondevu-client'; +``` + +### New Exports + +These are new in v0.9.0: + +```typescript +// ✅ New +import { + DurableConnection, + DurableChannel, + DurableService, + DurableConnectionState, + DurableChannelState, + DurableConnectionConfig, + DurableChannelConfig, + DurableServiceConfig, + DurableConnectionEvents, + DurableChannelEvents, + DurableServiceEvents, + ConnectionInfo, + ServiceInfo, + QueuedMessage +} from '@xtr-dev/rondevu-client'; +``` + +### Unchanged Exports + +These work the same in both versions: + +```typescript +// ✅ Unchanged +import { + Rondevu, + RondevuAuth, + RondevuUsername, + Credentials, + UsernameClaimResult, + UsernameCheckResult +} from '@xtr-dev/rondevu-client'; +``` + +## Configuration Options + +### New Connection Options + +v0.9.0 adds extensive configuration for automatic reconnection and message queuing: + +```typescript +const connection = await client.connect('alice', 'chat@1.0.0', { + // Reconnection + maxReconnectAttempts: 10, // default: 10 + reconnectBackoffBase: 1000, // default: 1000ms + reconnectBackoffMax: 30000, // default: 30000ms (30 seconds) + reconnectJitter: 0.2, // default: 0.2 (±20%) + connectionTimeout: 30000, // default: 30000ms + + // Message queuing + maxQueueSize: 1000, // default: 1000 messages + maxMessageAge: 60000, // default: 60000ms (1 minute) + + // WebRTC + rtcConfig: { + iceServers: [...] + } +}); +``` + +### New Service Options + +Services can now auto-refresh TTL: + +```typescript +const service = await client.exposeService({ + username: 'alice', + privateKey: keypair.privateKey, + serviceFqn: 'chat@1.0.0', + + // TTL auto-refresh (NEW) + ttl: 300000, // default: 300000ms (5 minutes) + ttlRefreshMargin: 0.2, // default: 0.2 (refresh at 80% of TTL) + + // All connection options also apply to incoming connections + maxReconnectAttempts: 10, + maxQueueSize: 1000, + // ... +}); +``` + +## Migration Checklist + +- [ ] Replace `client.services.exposeService()` with `client.exposeService()` +- [ ] Add `await service.start()` after creating service +- [ ] Replace `handle.unpublish()` with `service.stop()` +- [ ] Replace `client.discovery.connect()` with `client.connect()` +- [ ] Replace `client.discovery.connectByUuid()` with `client.connectByUuid()` +- [ ] Create channels with `connection.createChannel()` instead of receiving them directly +- [ ] Add `await connection.connect()` to establish connection +- [ ] Update handlers from `(channel, peer, connectionId?)` to `(channel, connectionId)` +- [ ] Replace `.onmessage` with `.on('message', ...)` +- [ ] Replace `.onopen` with `.on('open', ...)` +- [ ] Replace `.onclose` with `.on('close', ...)` +- [ ] Replace `.onerror` with `.on('error', ...)` +- [ ] Add reconnection event handlers (`connection.on('reconnecting', ...)`) +- [ ] Review and configure reconnection options if needed +- [ ] Review and configure message queue limits if needed +- [ ] Update TypeScript imports to use new types +- [ ] Test automatic reconnection behavior +- [ ] Test message queuing during disconnections + +## Common Migration Patterns + +### Pattern 1: Simple Echo Service + +#### Before (v0.8.x) +```typescript +await client.services.exposeService({ + username: 'alice', + privateKey: keypair.privateKey, + serviceFqn: 'echo@1.0.0', + handler: (channel) => { + channel.onmessage = (e) => { + channel.send(`Echo: ${e.data}`); + }; + } +}); +``` + +#### After (v0.9.0) +```typescript +const service = await client.exposeService({ + username: 'alice', + privateKey: keypair.privateKey, + serviceFqn: 'echo@1.0.0', + handler: (channel) => { + channel.on('message', (data) => { + channel.send(`Echo: ${data}`); + }); + } +}); + +await service.start(); +``` + +### Pattern 2: Connection with Error Handling + +#### Before (v0.8.x) +```typescript +try { + const { peer, channel } = await client.discovery.connect('alice', 'chat@1.0.0'); + + channel.onopen = () => { + channel.send('Hello!'); + }; + + peer.on('failed', (error) => { + console.error('Connection failed:', error); + // Manual reconnection logic here + }); +} catch (error) { + console.error('Failed to connect:', error); +} +``` + +#### After (v0.9.0) +```typescript +const connection = await client.connect('alice', 'chat@1.0.0', { + maxReconnectAttempts: 5 +}); + +const channel = connection.createChannel('main'); + +channel.on('open', () => { + channel.send('Hello!'); +}); + +connection.on('reconnecting', (attempt, max, delay) => { + console.log(`Reconnecting (${attempt}/${max}) in ${delay}ms`); +}); + +connection.on('failed', (error) => { + console.error('Connection failed permanently:', error); +}); + +try { + await connection.connect(); +} catch (error) { + console.error('Initial connection failed:', error); +} +``` + +### Pattern 3: Multi-User Chat Server + +#### Before (v0.8.x) +```typescript +const connections = new Map(); + +await client.services.exposeService({ + username: 'alice', + privateKey: keypair.privateKey, + serviceFqn: 'chat@1.0.0', + poolSize: 10, + handler: (channel, peer, connectionId) => { + connections.set(connectionId, channel); + + channel.onmessage = (e) => { + // Broadcast to all + for (const [id, ch] of connections) { + if (id !== connectionId) { + ch.send(e.data); + } + } + }; + + channel.onclose = () => { + connections.delete(connectionId); + }; + } +}); +``` + +#### After (v0.9.0) +```typescript +const channels = new Map(); + +const service = await client.exposeService({ + username: 'alice', + privateKey: keypair.privateKey, + serviceFqn: 'chat@1.0.0', + poolSize: 10, + handler: (channel, connectionId) => { + channels.set(connectionId, channel); + + channel.on('message', (data) => { + // Broadcast to all + for (const [id, ch] of channels) { + if (id !== connectionId) { + ch.send(data); + } + } + }); + + channel.on('close', () => { + channels.delete(connectionId); + }); + } +}); + +await service.start(); + +// Optional: Track connections +service.on('connection', (connectionId) => { + console.log(`User ${connectionId} joined`); +}); + +service.on('disconnection', (connectionId) => { + console.log(`User ${connectionId} left`); +}); +``` + +## Benefits of Migration + +1. **Reliability**: Automatic reconnection handles network hiccups transparently +2. **Simplicity**: No need to manage WebRTC peer lifecycle manually +3. **Durability**: Messages sent during disconnections are queued and delivered when connection restores +4. **Uptime**: Services automatically refresh TTL before expiration +5. **Type Safety**: Better TypeScript types with DurableChannel event emitters +6. **Debugging**: Queue size monitoring, connection state tracking, and detailed events + +## Getting Help + +If you encounter issues during migration: +1. Check the [README](./README.md) for complete API documentation +2. Review the examples for common patterns +3. Open an issue on [GitHub](https://github.com/xtr-dev/rondevu-client/issues) diff --git a/README.md b/README.md index dc7fd9b..3739f0a 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) -🌐 **DNS-like WebRTC client with username claiming and service discovery** +🌐 **WebRTC with durable connections and automatic reconnection** -TypeScript/JavaScript client for Rondevu, providing cryptographic username claiming, service publishing, and privacy-preserving discovery. +TypeScript/JavaScript client for Rondevu, providing durable WebRTC connections that survive network interruptions with automatic reconnection and message queuing. **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,13 +15,14 @@ TypeScript/JavaScript client for Rondevu, providing cryptographic username claim ## 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) -- **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 +- **Configurable**: All timeouts, retry limits, and queue sizes are configurable ## Install @@ -44,36 +45,36 @@ await client.register(); 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({ +const service = await client.exposeService({ username: 'alice', privateKey: keypair.privateKey, - serviceFqn: 'com.example.chat@1.0.0', + serviceFqn: 'chat@1.0.0', isPublic: true, - handler: (channel, peer) => { - console.log('📡 New connection established'); + poolSize: 10, // Handle 10 concurrent connections + handler: (channel, connectionId) => { + console.log(`📡 New connection: ${connectionId}`); - channel.onmessage = (e) => { - console.log('📥 Received:', e.data); - channel.send(`Echo: ${e.data}`); - }; + channel.on('message', (data) => { + console.log('📥 Received:', data); + channel.send(`Echo: ${data}`); + }); - channel.onopen = () => { - console.log('✅ Data channel open'); - }; + channel.on('close', () => { + console.log(`👋 Connection ${connectionId} closed`); + }); } }); -console.log(`Service published with UUID: ${handle.uuid}`); +// Start the service +const info = await service.start(); +console.log(`Service published with UUID: ${info.uuid}`); console.log('Waiting for connections...'); -// Later: unpublish -await handle.unpublish(); +// Later: stop the service +await service.stop(); ``` ### Connecting to a Service (Bob) @@ -85,46 +86,75 @@ import { Rondevu } from '@xtr-dev/rondevu-client'; 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' -); +// Connect to Alice's service +const connection = await client.connect('alice', 'chat@1.0.0', { + maxReconnectAttempts: 5 +}); -channel.onmessage = (e) => { - console.log('📥 Received:', e.data); -}; +// Create a durable channel +const channel = connection.createChannel('main'); -channel.onopen = () => { - console.log('✅ Connected!'); +channel.on('message', (data) => { + console.log('📥 Received:', data); +}); + +channel.on('open', () => { + console.log('✅ Channel open'); channel.send('Hello Alice!'); -}; - -peer.on('connected', () => { - console.log('🎉 WebRTC connection established'); }); -peer.on('failed', (error) => { - console.error('❌ Connection failed:', error); +// Listen for connection events +connection.on('connected', () => { + console.log('🎉 Connected to Alice'); }); -// Option 2: List services first, then connect -const services = await client.discovery.listServices('alice'); -console.log(`Found ${services.services.length} services`); +connection.on('reconnecting', (attempt, max, delay) => { + console.log(`🔄 Reconnecting... (${attempt}/${max}, retry in ${delay}ms)`); +}); -for (const service of services.services) { - console.log(`- UUID: ${service.uuid}`); - if (service.isPublic) { - console.log(` FQN: ${service.serviceFqn}`); - } -} +connection.on('disconnected', () => { + console.log('🔌 Disconnected'); +}); -// Connect by UUID -const { peer: peer2, channel: channel2 } = await client.discovery.connectByUuid( - services.services[0].uuid -); +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 @@ -161,39 +191,12 @@ const check = await client.usernames.checkUsername('alice'); 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); +client.usernames.saveKeypairToStorage('alice', claim.publicKey, claim.privateKey); // Load keypair from localStorage -const stored = client.usernames.loadKeypairFromStorage('alice'); +const keypair = 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:** @@ -203,265 +206,370 @@ const valid = await client.usernames.verifySignature( - Validity: 365 days from claim/last use - Ownership: Secured by Ed25519 public key -### Services API +### Durable Service API ```typescript -// Publish service (returns UUID) -const service = await client.services.publishService({ +// Expose a durable service +const service = await client.exposeService({ 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 } + serviceFqn: 'chat@1.0.0', -console.log(`Service UUID: ${service.uuid}`); -console.log('Share this UUID to allow connections'); + // 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 -// 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}`); - }; - } -}); + // Connection pooling + poolSize: 10, // optional, default: 1 + pollingInterval: 2000, // optional, default: 2000ms -// Later: unpublish -await handle.unpublish(); + // 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 -// Unpublish service manually -await client.services.unpublishService(serviceId, username); -``` + // Message queuing + maxQueueSize: 1000, // optional, default: 1000 + maxMessageAge: 60000, // optional, default: 60000ms (1 minute) -#### 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**: - -```typescript -// 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`); - }; + // WebRTC configuration + rtcConfig: { + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' } + ] }, - onPoolStatus: (status) => { - console.log('Pool status:', { - activeOffers: status.activeOffers, - activeConnections: status.activeConnections, - totalHandled: status.totalConnectionsHandled + + // Connection handler + handler: (channel, connectionId) => { + // Handle incoming connection + channel.on('message', (data) => { + console.log('Received:', data); + channel.send(`Echo: ${data}`); }); - }, - 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}`); +// Start the service +const info = await service.start(); +// { serviceId: '...', uuid: '...', expiresAt: 1234567890 } -// Manually add more offers if needed -await handle.addOffers(3); +// 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(); ``` -**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:** +**Service Events:** ```typescript -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 -} +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'); +}); ``` -**Pooled Service Handle:** -```typescript -interface PooledServiceHandle extends ServiceHandle { - getStatus: () => PoolStatus; // Get current pool status - addOffers: (count: number) => Promise; // 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 +### Durable Connection API ```typescript -// 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: {...} } -// ] -// } +// 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 -// Query service by FQN -const query = await client.discovery.queryService('alice', 'com.example.chat@1.0.0'); -// { uuid: 'abc123', allowed: true } + // Message queuing + maxQueueSize: 1000, // optional, default: 1000 + maxMessageAge: 60000, // optional, default: 60000ms -// 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 - -```typescript -// 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 + // WebRTC configuration + rtcConfig: { + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' } + ] } }); -// 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); +// 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 peer.close(); - -// Properties -peer.stateName; // 'idle', 'creating-offer', 'connected', etc. -peer.connectionState; // RTCPeerConnectionState -peer.offerId; // string | undefined -peer.role; // 'offerer' | 'answerer' | undefined +await connection.close(); ``` -## Connection Lifecycle +**Connection Events:** +```typescript +connection.on('state', (newState, previousState) => { + console.log(`State: ${previousState} → ${newState}`); +}); -### 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 +connection.on('connected', () => { + console.log('Connected'); +}); -### 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 +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; +} +``` + +### Service Configuration + +```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) +} +``` + +## Examples + +### Chat Application + +```typescript +// 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 + +```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); + + 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 @@ -508,185 +616,43 @@ const client = new Rondevu({ }); ``` -### Deno -```typescript -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 -```typescript -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 - -```typescript -// 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 - -```typescript -// 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 - -```typescript -// 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: ```typescript import type { + // Client types Credentials, RondevuOptions, // Username types UsernameCheckResult, UsernameClaimResult, - Keypair, - // Service types - ServicePublishResult, - PublishServiceOptions, - ServiceHandle, - - // Discovery types - ServiceInfo, - ServiceListResult, - ServiceQueryResult, - ServiceDetails, - ConnectResult, - - // Peer types - PeerOptions, - PeerEvents, - PeerTimeouts + // Durable connection types + DurableConnectionState, + DurableChannelState, + DurableConnectionConfig, + DurableChannelConfig, + DurableServiceConfig, + QueuedMessage, + DurableConnectionEvents, + DurableChannelEvents, + DurableServiceEvents, + ConnectionInfo, + ServiceInfo } from '@xtr-dev/rondevu-client'; ``` -## Migration from V1 +## Migration from v0.8.x -V2 is a **breaking change** that replaces topic-based discovery with username claiming and service publishing. See the main [MIGRATION.md](../MIGRATION.md) for detailed migration guide. +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: `offers.findByTopic()`, `offers.getTopics()`, bloom filters -- ✅ Added: `usernames.*`, `services.*`, `discovery.*` APIs -- ✅ Changed: Focus on service-based discovery instead of topics +- ❌ 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 diff --git a/package.json b/package.json index 046de54..5da0d8f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@xtr-dev/rondevu-client", - "version": "0.8.3", - "description": "TypeScript client for Rondevu DNS-like WebRTC with username claiming and service discovery", + "version": "0.9.0", + "description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/discovery.ts b/src/discovery.ts deleted file mode 100644 index c27daab..0000000 --- a/src/discovery.ts +++ /dev/null @@ -1,276 +0,0 @@ -import RondevuPeer from './peer/index.js'; -import { RondevuOffers } from './offers.js'; - -/** - * Service info from discovery - */ -export interface ServiceInfo { - uuid: string; - isPublic: boolean; - serviceFqn?: string; - metadata?: Record; -} - -/** - * Service list result - */ -export interface ServiceListResult { - username: string; - services: ServiceInfo[]; -} - -/** - * Service query result - */ -export interface ServiceQueryResult { - uuid: string; - allowed: boolean; -} - -/** - * Service details - */ -export interface ServiceDetails { - serviceId: string; - username: string; - serviceFqn: string; - offerId: string; - sdp: string; - isPublic: boolean; - metadata?: Record; - createdAt: number; - expiresAt: number; -} - -/** - * Connect result - */ -export interface ConnectResult { - peer: RondevuPeer; - channel: RTCDataChannel; -} - -/** - * Rondevu Discovery API - * Handles service discovery and connections - */ -export class RondevuDiscovery { - private offersApi: RondevuOffers; - - constructor( - private baseUrl: string, - private credentials: { peerId: string; secret: string } - ) { - this.offersApi = new RondevuOffers(baseUrl, credentials); - } - - /** - * Lists all services for a username - * Returns UUIDs only for private services, full details for public - */ - async listServices(username: string): Promise { - const response = await fetch(`${this.baseUrl}/usernames/${username}/services`); - - if (!response.ok) { - throw new Error('Failed to list services'); - } - - const data = await response.json(); - - return { - username: data.username, - services: data.services - }; - } - - /** - * Queries a service by FQN - * Returns UUID if service exists and is allowed - */ - async queryService(username: string, serviceFqn: string): Promise { - const response = await fetch(`${this.baseUrl}/index/${username}/query`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ serviceFqn }) - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Service not found'); - } - - const data = await response.json(); - - return { - uuid: data.uuid, - allowed: data.allowed - }; - } - - /** - * Gets service details by UUID - */ - async getServiceDetails(uuid: string): Promise { - const response = await fetch(`${this.baseUrl}/services/${uuid}`); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Service not found'); - } - - const data = await response.json(); - - return { - serviceId: data.serviceId, - username: data.username, - serviceFqn: data.serviceFqn, - offerId: data.offerId, - sdp: data.sdp, - isPublic: data.isPublic, - metadata: data.metadata, - createdAt: data.createdAt, - expiresAt: data.expiresAt - }; - } - - /** - * Connects to a service by UUID - */ - async connectToService( - uuid: string, - options?: { - rtcConfig?: RTCConfiguration; - onConnected?: () => void; - onData?: (data: any) => void; - } - ): Promise { - // Get service details - const service = await this.getServiceDetails(uuid); - - // Create peer with the offer - const peer = new RondevuPeer( - this.offersApi, - options?.rtcConfig || { - iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] - } - ); - - // Set up event handlers - if (options?.onConnected) { - peer.on('connected', options.onConnected); - } - - if (options?.onData) { - peer.on('datachannel', (channel: RTCDataChannel) => { - channel.onmessage = (e) => options.onData!(e.data); - }); - } - - // Answer the offer - await peer.answer(service.offerId, service.sdp, { - topics: [], // V2 doesn't use topics - rtcConfig: options?.rtcConfig - }); - - return peer; - } - - /** - * Convenience method: Query and connect in one call - * Returns both peer and data channel - */ - async connect( - username: string, - serviceFqn: string, - options?: { - rtcConfig?: RTCConfiguration; - } - ): Promise { - // Query service - const query = await this.queryService(username, serviceFqn); - - if (!query.allowed) { - throw new Error('Service access denied'); - } - - // Get service details - const service = await this.getServiceDetails(query.uuid); - - // Create peer - const peer = new RondevuPeer( - this.offersApi, - options?.rtcConfig || { - iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] - } - ); - - // Answer the offer - await peer.answer(service.offerId, service.sdp, { - topics: [], // V2 doesn't use topics - rtcConfig: options?.rtcConfig - }); - - // Wait for data channel - const channel = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Timeout waiting for data channel')); - }, 30000); - - peer.on('datachannel', (ch: RTCDataChannel) => { - clearTimeout(timeout); - resolve(ch); - }); - - peer.on('failed', (error: Error) => { - clearTimeout(timeout); - reject(error); - }); - }); - - return { peer, channel }; - } - - /** - * Convenience method: Connect to service by UUID with channel - */ - async connectByUuid( - uuid: string, - options?: { rtcConfig?: RTCConfiguration } - ): Promise { - // Get service details - const service = await this.getServiceDetails(uuid); - - // Create peer - const peer = new RondevuPeer( - this.offersApi, - options?.rtcConfig || { - iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] - } - ); - - // Answer the offer - await peer.answer(service.offerId, service.sdp, { - topics: [], // V2 doesn't use topics - rtcConfig: options?.rtcConfig - }); - - // Wait for data channel - const channel = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Timeout waiting for data channel')); - }, 30000); - - peer.on('datachannel', (ch: RTCDataChannel) => { - clearTimeout(timeout); - resolve(ch); - }); - - peer.on('failed', (error: Error) => { - clearTimeout(timeout); - reject(error); - }); - }); - - return { peer, channel }; - } -} diff --git a/src/durable/channel.ts b/src/durable/channel.ts new file mode 100644 index 0000000..89607f7 --- /dev/null +++ b/src/durable/channel.ts @@ -0,0 +1,361 @@ +/** + * DurableChannel - Message queueing wrapper for RTCDataChannel + * + * Provides automatic message queuing during disconnections and transparent + * flushing when the connection is re-established. + */ + +import { EventEmitter } from '../event-emitter.js'; +import { + DurableChannelState +} from './types.js'; +import type { + DurableChannelConfig, + DurableChannelEvents, + QueuedMessage +} from './types.js'; + +/** + * Default configuration for durable channels + */ +const DEFAULT_CONFIG = { + maxQueueSize: 1000, + maxMessageAge: 60000, // 1 minute + ordered: true, + maxRetransmits: undefined +} as const; + +/** + * Durable channel that survives WebRTC peer connection drops + * + * The DurableChannel wraps an RTCDataChannel and provides: + * - Automatic message queuing during disconnections + * - Queue flushing on reconnection + * - Configurable queue size and message age limits + * - RTCDataChannel-compatible API + * + * @example + * ```typescript + * const channel = new DurableChannel('chat', connection, { + * maxQueueSize: 500, + * maxMessageAge: 30000 + * }); + * + * channel.on('message', (data) => { + * console.log('Received:', data); + * }); + * + * channel.on('open', () => { + * channel.send('Hello!'); + * }); + * + * // Messages sent during disconnection are automatically queued + * channel.send('This will be queued if disconnected'); + * ``` + */ +export class DurableChannel extends EventEmitter { + readonly label: string; + readonly config: DurableChannelConfig; + + private _state: DurableChannelState; + private underlyingChannel?: RTCDataChannel; + private messageQueue: QueuedMessage[] = []; + private queueProcessing: boolean = false; + private _bufferedAmountLowThreshold: number = 0; + + // Event handlers that need cleanup + private openHandler?: () => void; + private messageHandler?: (event: MessageEvent) => void; + private errorHandler?: (event: Event) => void; + private closeHandler?: () => void; + private bufferedAmountLowHandler?: () => void; + + constructor( + label: string, + config?: DurableChannelConfig + ) { + super(); + this.label = label; + this.config = { ...DEFAULT_CONFIG, ...config }; + this._state = DurableChannelState.CONNECTING; + } + + /** + * Current channel state + */ + get readyState(): DurableChannelState { + return this._state; + } + + /** + * Buffered amount from underlying channel (0 if no channel) + */ + get bufferedAmount(): number { + return this.underlyingChannel?.bufferedAmount ?? 0; + } + + /** + * Buffered amount low threshold + */ + get bufferedAmountLowThreshold(): number { + return this._bufferedAmountLowThreshold; + } + + set bufferedAmountLowThreshold(value: number) { + this._bufferedAmountLowThreshold = value; + if (this.underlyingChannel) { + this.underlyingChannel.bufferedAmountLowThreshold = value; + } + } + + /** + * Send data through the channel + * + * If the channel is open, sends immediately. Otherwise, queues the message + * for delivery when the channel reconnects. + * + * @param data - Data to send + */ + send(data: string | Blob | ArrayBuffer | ArrayBufferView): void { + if (this._state === DurableChannelState.OPEN && this.underlyingChannel) { + // Channel is open - send immediately + try { + this.underlyingChannel.send(data as any); + } catch (error) { + // Send failed - queue the message + this.enqueueMessage(data); + this.emit('error', error as Error); + } + } else if (this._state !== DurableChannelState.CLOSED) { + // Channel is not open but not closed - queue the message + this.enqueueMessage(data); + } else { + // Channel is closed - throw error + throw new Error('Cannot send on closed channel'); + } + } + + /** + * Close the channel + */ + close(): void { + if (this._state === DurableChannelState.CLOSED || + this._state === DurableChannelState.CLOSING) { + return; + } + + this._state = DurableChannelState.CLOSING; + + if (this.underlyingChannel) { + this.underlyingChannel.close(); + } + + this._state = DurableChannelState.CLOSED; + this.emit('close'); + } + + /** + * Attach to an underlying RTCDataChannel + * + * This is called when a WebRTC connection is established (or re-established). + * The channel will flush any queued messages and forward events. + * + * @param channel - RTCDataChannel to attach to + * @internal + */ + attachToChannel(channel: RTCDataChannel): void { + // Detach from any existing channel first + this.detachFromChannel(); + + this.underlyingChannel = channel; + + // Set buffered amount low threshold + channel.bufferedAmountLowThreshold = this._bufferedAmountLowThreshold; + + // Setup event handlers + this.openHandler = () => { + this._state = DurableChannelState.OPEN; + this.emit('open'); + + // Flush queued messages + this.flushQueue().catch(error => { + this.emit('error', error); + }); + }; + + this.messageHandler = (event: MessageEvent) => { + this.emit('message', event.data); + }; + + this.errorHandler = (event: Event) => { + this.emit('error', new Error(`Channel error: ${event.type}`)); + }; + + this.closeHandler = () => { + if (this._state !== DurableChannelState.CLOSING && + this._state !== DurableChannelState.CLOSED) { + // Unexpected close - transition to connecting (will reconnect) + this._state = DurableChannelState.CONNECTING; + } + }; + + this.bufferedAmountLowHandler = () => { + this.emit('bufferedAmountLow'); + }; + + // Attach handlers + channel.addEventListener('open', this.openHandler); + channel.addEventListener('message', this.messageHandler); + channel.addEventListener('error', this.errorHandler); + channel.addEventListener('close', this.closeHandler); + channel.addEventListener('bufferedamountlow', this.bufferedAmountLowHandler); + + // If channel is already open, trigger open event + if (channel.readyState === 'open') { + this.openHandler(); + } else if (channel.readyState === 'connecting') { + this._state = DurableChannelState.CONNECTING; + } + } + + /** + * Detach from the underlying RTCDataChannel + * + * This is called when a WebRTC connection drops. The channel remains alive + * and continues queuing messages. + * + * @internal + */ + detachFromChannel(): void { + if (!this.underlyingChannel) { + return; + } + + // Remove event listeners + if (this.openHandler) { + this.underlyingChannel.removeEventListener('open', this.openHandler); + } + if (this.messageHandler) { + this.underlyingChannel.removeEventListener('message', this.messageHandler); + } + if (this.errorHandler) { + this.underlyingChannel.removeEventListener('error', this.errorHandler); + } + if (this.closeHandler) { + this.underlyingChannel.removeEventListener('close', this.closeHandler); + } + if (this.bufferedAmountLowHandler) { + this.underlyingChannel.removeEventListener('bufferedamountlow', this.bufferedAmountLowHandler); + } + + this.underlyingChannel = undefined; + this._state = DurableChannelState.CONNECTING; + } + + /** + * Enqueue a message for later delivery + */ + private enqueueMessage(data: string | Blob | ArrayBuffer | ArrayBufferView): void { + // Prune old messages first + this.pruneOldMessages(); + + const message: QueuedMessage = { + data, + enqueuedAt: Date.now(), + id: `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + }; + + this.messageQueue.push(message); + + // Handle overflow + const maxQueueSize = this.config.maxQueueSize ?? 1000; + if (this.messageQueue.length > maxQueueSize) { + const excess = this.messageQueue.length - maxQueueSize; + this.messageQueue.splice(0, excess); + this.emit('queueOverflow', excess); + console.warn( + `DurableChannel[${this.label}]: Dropped ${excess} messages due to queue overflow` + ); + } + } + + /** + * Flush all queued messages through the channel + */ + private async flushQueue(): Promise { + if (this.queueProcessing || !this.underlyingChannel || + this.underlyingChannel.readyState !== 'open') { + return; + } + + this.queueProcessing = true; + + try { + // Prune old messages before flushing + this.pruneOldMessages(); + + // Send all queued messages + while (this.messageQueue.length > 0) { + const message = this.messageQueue.shift(); + if (!message) break; + + try { + this.underlyingChannel.send(message.data as any); + } catch (error) { + // Send failed - re-queue message + this.messageQueue.unshift(message); + throw error; + } + + // If buffer is getting full, wait for it to drain + if (this.underlyingChannel.bufferedAmount > 16 * 1024 * 1024) { // 16MB + await new Promise((resolve) => { + const checkBuffer = () => { + if (!this.underlyingChannel || + this.underlyingChannel.bufferedAmount < 8 * 1024 * 1024) { + resolve(); + } else { + setTimeout(checkBuffer, 100); + } + }; + checkBuffer(); + }); + } + } + } finally { + this.queueProcessing = false; + } + } + + /** + * Remove messages older than maxMessageAge from the queue + */ + private pruneOldMessages(): void { + const maxMessageAge = this.config.maxMessageAge ?? 60000; + if (maxMessageAge === Infinity || maxMessageAge <= 0) { + return; + } + + const now = Date.now(); + const cutoff = now - maxMessageAge; + + const originalLength = this.messageQueue.length; + this.messageQueue = this.messageQueue.filter(msg => msg.enqueuedAt >= cutoff); + + const pruned = originalLength - this.messageQueue.length; + if (pruned > 0) { + console.warn( + `DurableChannel[${this.label}]: Pruned ${pruned} old messages (older than ${maxMessageAge}ms)` + ); + } + } + + /** + * Get the current queue size + * + * @internal + */ + getQueueSize(): number { + return this.messageQueue.length; + } +} diff --git a/src/durable/connection.ts b/src/durable/connection.ts new file mode 100644 index 0000000..37ed220 --- /dev/null +++ b/src/durable/connection.ts @@ -0,0 +1,444 @@ +/** + * DurableConnection - WebRTC connection with automatic reconnection + * + * Manages the WebRTC peer lifecycle and automatically reconnects on + * connection drops with exponential backoff. + */ + +import { EventEmitter } from '../event-emitter.js'; +import RondevuPeer from '../peer/index.js'; +import type { RondevuOffers } from '../offers.js'; +import { DurableChannel } from './channel.js'; +import { createReconnectionScheduler, type ReconnectionScheduler } from './reconnection.js'; +import { + DurableConnectionState +} from './types.js'; +import type { + DurableConnectionConfig, + DurableConnectionEvents, + ConnectionInfo +} from './types.js'; + +/** + * Default configuration for durable connections + */ +const DEFAULT_CONFIG: Required = { + maxReconnectAttempts: 10, + reconnectBackoffBase: 1000, + reconnectBackoffMax: 30000, + reconnectJitter: 0.2, + connectionTimeout: 30000, + maxQueueSize: 1000, + maxMessageAge: 60000, + rtcConfig: { + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' } + ] + } +}; + +/** + * Durable WebRTC connection that automatically reconnects + * + * The DurableConnection manages the lifecycle of a WebRTC peer connection + * and provides: + * - Automatic reconnection with exponential backoff + * - Multiple durable channels that survive reconnections + * - Configurable retry limits and timeouts + * - High-level connection state events + * + * @example + * ```typescript + * const connection = new DurableConnection( + * offersApi, + * { username: 'alice', serviceFqn: 'chat@1.0.0' }, + * { maxReconnectAttempts: 5 } + * ); + * + * connection.on('connected', () => { + * console.log('Connected!'); + * }); + * + * connection.on('reconnecting', (attempt, max, delay) => { + * console.log(`Reconnecting... (${attempt}/${max}, retry in ${delay}ms)`); + * }); + * + * const channel = connection.createChannel('chat'); + * channel.on('message', (data) => { + * console.log('Received:', data); + * }); + * + * await connection.connect(); + * ``` + */ +export class DurableConnection extends EventEmitter { + readonly connectionId: string; + readonly config: Required; + readonly connectionInfo: ConnectionInfo; + + private _state: DurableConnectionState; + private currentPeer?: RondevuPeer; + private channels: Map = new Map(); + private reconnectionScheduler?: ReconnectionScheduler; + + // Track peer event handlers for cleanup + private peerConnectedHandler?: () => void; + private peerDisconnectedHandler?: () => void; + private peerFailedHandler?: (error: Error) => void; + private peerDataChannelHandler?: (channel: RTCDataChannel) => void; + + constructor( + private offersApi: RondevuOffers, + connectionInfo: ConnectionInfo, + config?: DurableConnectionConfig + ) { + super(); + this.connectionId = `conn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + this.config = { ...DEFAULT_CONFIG, ...config }; + this.connectionInfo = connectionInfo; + this._state = DurableConnectionState.CONNECTING; + } + + /** + * Current connection state + */ + getState(): DurableConnectionState { + return this._state; + } + + /** + * Check if connection is currently connected + */ + isConnected(): boolean { + return this._state === DurableConnectionState.CONNECTED; + } + + /** + * Create a durable channel on this connection + * + * The channel will be created on the current peer connection if available, + * otherwise it will be created when the connection is established. + * + * @param label - Channel label + * @param options - RTCDataChannel init options + * @returns DurableChannel instance + */ + createChannel(label: string, options?: RTCDataChannelInit): DurableChannel { + // Check if channel already exists + if (this.channels.has(label)) { + throw new Error(`Channel with label '${label}' already exists`); + } + + // Create durable channel + const durableChannel = new DurableChannel(label, { + maxQueueSize: this.config.maxQueueSize, + maxMessageAge: this.config.maxMessageAge, + ordered: options?.ordered ?? true, + maxRetransmits: options?.maxRetransmits + }); + + this.channels.set(label, durableChannel); + + // If we have a current peer, attach the channel + if (this.currentPeer && this._state === DurableConnectionState.CONNECTED) { + this.createAndAttachChannel(durableChannel, options); + } + + return durableChannel; + } + + /** + * Get an existing channel by label + */ + getChannel(label: string): DurableChannel | undefined { + return this.channels.get(label); + } + + /** + * Establish the initial connection + * + * @returns Promise that resolves when connected + */ + async connect(): Promise { + if (this._state !== DurableConnectionState.CONNECTING) { + throw new Error(`Cannot connect from state: ${this._state}`); + } + + try { + await this.establishConnection(); + } catch (error) { + this._state = DurableConnectionState.DISCONNECTED; + await this.handleDisconnection(); + throw error; + } + } + + /** + * Close the connection gracefully + */ + async close(): Promise { + if (this._state === DurableConnectionState.CLOSED) { + return; + } + + const previousState = this._state; + this._state = DurableConnectionState.CLOSED; + + // Cancel any ongoing reconnection + if (this.reconnectionScheduler) { + this.reconnectionScheduler.cancel(); + } + + // Close all channels + for (const channel of this.channels.values()) { + channel.close(); + } + + // Close peer connection + if (this.currentPeer) { + await this.currentPeer.close(); + this.currentPeer = undefined; + } + + this.emit('state', this._state, previousState); + this.emit('closed'); + } + + /** + * Establish a WebRTC connection + */ + private async establishConnection(): Promise { + // Create new peer + const peer = new RondevuPeer(this.offersApi, this.config.rtcConfig); + this.currentPeer = peer; + + // Setup peer event handlers + this.setupPeerHandlers(peer); + + // Determine connection method based on connection info + if (this.connectionInfo.uuid) { + // Connect by UUID + await this.connectByUuid(peer, this.connectionInfo.uuid); + } else if (this.connectionInfo.username && this.connectionInfo.serviceFqn) { + // Connect by username and service FQN + await this.connectByService(peer, this.connectionInfo.username, this.connectionInfo.serviceFqn); + } else { + throw new Error('Invalid connection info: must provide either uuid or (username + serviceFqn)'); + } + + // Wait for connection with timeout + await this.waitForConnection(peer); + + // Connection established + this.transitionToConnected(); + } + + /** + * Connect to a service by UUID + */ + private async connectByUuid(peer: RondevuPeer, uuid: string): Promise { + // Get service details + const response = await fetch(`${this.offersApi['baseUrl']}/services/${uuid}`); + if (!response.ok) { + throw new Error(`Service not found: ${uuid}`); + } + + const service = await response.json(); + + // Answer the offer + await peer.answer(service.offerId, service.sdp, { + secret: this.offersApi['credentials'].secret, + topics: [] + }); + } + + /** + * Connect to a service by username and service FQN + */ + private async connectByService(peer: RondevuPeer, username: string, serviceFqn: string): Promise { + // Query service to get UUID + const response = await fetch(`${this.offersApi['baseUrl']}/index/${username}/query`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ serviceFqn }) + }); + + if (!response.ok) { + throw new Error(`Service not found: ${username}/${serviceFqn}`); + } + + const { uuid } = await response.json(); + + // Connect by UUID + await this.connectByUuid(peer, uuid); + } + + /** + * Wait for peer connection to establish + */ + private async waitForConnection(peer: RondevuPeer): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Connection timeout')); + }, this.config.connectionTimeout); + + const onConnected = () => { + clearTimeout(timeout); + peer.off('connected', onConnected); + peer.off('failed', onFailed); + resolve(); + }; + + const onFailed = (error: Error) => { + clearTimeout(timeout); + peer.off('connected', onConnected); + peer.off('failed', onFailed); + reject(error); + }; + + peer.on('connected', onConnected); + peer.on('failed', onFailed); + }); + } + + /** + * Setup event handlers for peer + */ + private setupPeerHandlers(peer: RondevuPeer): void { + this.peerConnectedHandler = () => { + // Connection established - will be handled by waitForConnection + }; + + this.peerDisconnectedHandler = () => { + if (this._state !== DurableConnectionState.CLOSED) { + this.handleDisconnection(); + } + }; + + this.peerFailedHandler = (error: Error) => { + if (this._state !== DurableConnectionState.CLOSED) { + console.error('Peer connection failed:', error); + this.handleDisconnection(); + } + }; + + this.peerDataChannelHandler = (channel: RTCDataChannel) => { + // Find matching durable channel and attach + const durableChannel = this.channels.get(channel.label); + if (durableChannel) { + durableChannel.attachToChannel(channel); + } + }; + + peer.on('connected', this.peerConnectedHandler); + peer.on('disconnected', this.peerDisconnectedHandler); + peer.on('failed', this.peerFailedHandler); + peer.on('datachannel', this.peerDataChannelHandler); + } + + /** + * Transition to connected state + */ + private transitionToConnected(): void { + const previousState = this._state; + this._state = DurableConnectionState.CONNECTED; + + // Reset reconnection scheduler if it exists + if (this.reconnectionScheduler) { + this.reconnectionScheduler.reset(); + } + + // Attach all channels to the new peer connection + for (const [label, channel] of this.channels) { + if (this.currentPeer) { + this.createAndAttachChannel(channel); + } + } + + this.emit('state', this._state, previousState); + this.emit('connected'); + } + + /** + * Create underlying RTCDataChannel and attach to durable channel + */ + private createAndAttachChannel( + durableChannel: DurableChannel, + options?: RTCDataChannelInit + ): void { + if (!this.currentPeer) { + return; + } + + // Check if peer already has this channel (received via datachannel event) + // If not, create it + const senders = (this.currentPeer.pc as any).getSenders?.() || []; + const existingChannel = Array.from(senders as RTCRtpSender[]) + .map((sender) => (sender as any).channel as RTCDataChannel) + .find(ch => ch && ch.label === durableChannel.label); + + if (existingChannel) { + durableChannel.attachToChannel(existingChannel); + } else { + // Create new channel on peer + const rtcChannel = this.currentPeer.createDataChannel( + durableChannel.label, + options + ); + durableChannel.attachToChannel(rtcChannel); + } + } + + /** + * Handle connection disconnection + */ + private async handleDisconnection(): Promise { + if (this._state === DurableConnectionState.CLOSED || + this._state === DurableConnectionState.FAILED) { + return; + } + + const previousState = this._state; + this._state = DurableConnectionState.RECONNECTING; + + this.emit('state', this._state, previousState); + this.emit('disconnected'); + + // Detach all channels (but keep them alive) + for (const channel of this.channels.values()) { + channel.detachFromChannel(); + } + + // Close old peer + if (this.currentPeer) { + await this.currentPeer.close(); + this.currentPeer = undefined; + } + + // Create or use existing reconnection scheduler + if (!this.reconnectionScheduler) { + this.reconnectionScheduler = createReconnectionScheduler({ + maxAttempts: this.config.maxReconnectAttempts, + backoffBase: this.config.reconnectBackoffBase, + backoffMax: this.config.reconnectBackoffMax, + jitter: this.config.reconnectJitter, + onReconnect: async () => { + await this.establishConnection(); + }, + onMaxAttemptsExceeded: (error) => { + const prevState = this._state; + this._state = DurableConnectionState.FAILED; + this.emit('state', this._state, prevState); + this.emit('failed', error, true); + }, + onBeforeAttempt: (attempt, max, delay) => { + this.emit('reconnecting', attempt, max, delay); + } + }); + } + + // Schedule reconnection + this.reconnectionScheduler.schedule(); + } +} diff --git a/src/durable/reconnection.ts b/src/durable/reconnection.ts new file mode 100644 index 0000000..7562e90 --- /dev/null +++ b/src/durable/reconnection.ts @@ -0,0 +1,200 @@ +/** + * Reconnection utilities for durable connections + * + * This module provides utilities for managing reconnection logic with + * exponential backoff and jitter. + */ + +/** + * Calculate exponential backoff delay with jitter + * + * @param attempt - Current attempt number (0-indexed) + * @param base - Base delay in milliseconds + * @param max - Maximum delay in milliseconds + * @param jitter - Jitter factor (0-1), e.g., 0.2 for ±20% + * @returns Delay in milliseconds with jitter applied + * + * @example + * ```typescript + * calculateBackoff(0, 1000, 30000, 0.2) // ~1000ms ± 20% + * calculateBackoff(1, 1000, 30000, 0.2) // ~2000ms ± 20% + * calculateBackoff(2, 1000, 30000, 0.2) // ~4000ms ± 20% + * calculateBackoff(5, 1000, 30000, 0.2) // ~30000ms ± 20% (capped at max) + * ``` + */ +export function calculateBackoff( + attempt: number, + base: number, + max: number, + jitter: number +): number { + // Calculate exponential delay: base * 2^attempt + const exponential = base * Math.pow(2, attempt); + + // Cap at maximum + const capped = Math.min(exponential, max); + + // Apply jitter: ± (jitter * capped) + const jitterAmount = capped * jitter; + const randomJitter = (Math.random() * 2 - 1) * jitterAmount; + + // Return delay with jitter, ensuring it's not negative + return Math.max(0, capped + randomJitter); +} + +/** + * Configuration for reconnection scheduler + */ +export interface ReconnectionSchedulerConfig { + /** Maximum number of reconnection attempts */ + maxAttempts: number; + + /** Base delay for exponential backoff */ + backoffBase: number; + + /** Maximum delay between attempts */ + backoffMax: number; + + /** Jitter factor for randomizing delays */ + jitter: number; + + /** Callback invoked for each reconnection attempt */ + onReconnect: () => Promise; + + /** Callback invoked when max attempts exceeded */ + onMaxAttemptsExceeded: (error: Error) => void; + + /** Optional callback invoked before each attempt */ + onBeforeAttempt?: (attempt: number, maxAttempts: number, delay: number) => void; +} + +/** + * Reconnection scheduler state + */ +export interface ReconnectionScheduler { + /** Current attempt number */ + attempt: number; + + /** Whether scheduler is active */ + active: boolean; + + /** Schedule next reconnection attempt */ + schedule: () => void; + + /** Cancel scheduled reconnection */ + cancel: () => void; + + /** Reset attempt counter */ + reset: () => void; +} + +/** + * Create a reconnection scheduler + * + * @param config - Scheduler configuration + * @returns Reconnection scheduler instance + * + * @example + * ```typescript + * const scheduler = createReconnectionScheduler({ + * maxAttempts: 10, + * backoffBase: 1000, + * backoffMax: 30000, + * jitter: 0.2, + * onReconnect: async () => { + * await connect(); + * }, + * onMaxAttemptsExceeded: (error) => { + * console.error('Failed to reconnect:', error); + * }, + * onBeforeAttempt: (attempt, max, delay) => { + * console.log(`Reconnecting in ${delay}ms (${attempt}/${max})...`); + * } + * }); + * + * // Start reconnection + * scheduler.schedule(); + * + * // Cancel reconnection + * scheduler.cancel(); + * ``` + */ +export function createReconnectionScheduler( + config: ReconnectionSchedulerConfig +): ReconnectionScheduler { + let attempt = 0; + let active = false; + let timer: ReturnType | undefined; + + const schedule = () => { + // Cancel any existing timer + if (timer) { + clearTimeout(timer); + timer = undefined; + } + + // Check if max attempts exceeded + if (attempt >= config.maxAttempts) { + active = false; + config.onMaxAttemptsExceeded( + new Error(`Max reconnection attempts exceeded (${config.maxAttempts})`) + ); + return; + } + + // Calculate delay + const delay = calculateBackoff( + attempt, + config.backoffBase, + config.backoffMax, + config.jitter + ); + + // Notify before attempt + if (config.onBeforeAttempt) { + config.onBeforeAttempt(attempt + 1, config.maxAttempts, delay); + } + + // Mark as active + active = true; + + // Schedule reconnection + timer = setTimeout(async () => { + attempt++; + try { + await config.onReconnect(); + // Success - reset scheduler + attempt = 0; + active = false; + } catch (error) { + // Failure - schedule next attempt + schedule(); + } + }, delay); + }; + + const cancel = () => { + if (timer) { + clearTimeout(timer); + timer = undefined; + } + active = false; + }; + + const reset = () => { + cancel(); + attempt = 0; + }; + + return { + get attempt() { + return attempt; + }, + get active() { + return active; + }, + schedule, + cancel, + reset + }; +} diff --git a/src/durable/service.ts b/src/durable/service.ts new file mode 100644 index 0000000..0520a67 --- /dev/null +++ b/src/durable/service.ts @@ -0,0 +1,329 @@ +/** + * DurableService - Service with automatic TTL refresh + * + * Manages service publishing with automatic reconnection for incoming + * connections and TTL auto-refresh to prevent expiration. + */ + +import { EventEmitter } from '../event-emitter.js'; +import { ServicePool, type PoolStatus } from '../service-pool.js'; +import type { RondevuOffers } from '../offers.js'; +import { DurableChannel } from './channel.js'; +import type { + DurableServiceConfig, + DurableServiceEvents, + ServiceInfo +} from './types.js'; + +/** + * Connection handler callback + */ +export type ConnectionHandler = ( + channel: DurableChannel, + connectionId: string +) => void | Promise; + +/** + * Default configuration for durable services + */ +const DEFAULT_CONFIG = { + isPublic: false, + ttlRefreshMargin: 0.2, + poolSize: 1, + pollingInterval: 2000, + maxReconnectAttempts: 10, + reconnectBackoffBase: 1000, + reconnectBackoffMax: 30000, + reconnectJitter: 0.2, + connectionTimeout: 30000, + maxQueueSize: 1000, + maxMessageAge: 60000, + rtcConfig: { + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' } + ] + } +}; + +/** + * Durable service that automatically refreshes TTL and handles reconnections + * + * The DurableService manages service publishing and provides: + * - Automatic TTL refresh before expiration + * - Durable connections for incoming peers + * - Connection pooling for multiple simultaneous connections + * - High-level connection lifecycle events + * + * @example + * ```typescript + * const service = new DurableService( + * offersApi, + * (channel, connectionId) => { + * channel.on('message', (data) => { + * console.log(`Message from ${connectionId}:`, data); + * channel.send(`Echo: ${data}`); + * }); + * }, + * { + * username: 'alice', + * privateKey: keypair.privateKey, + * serviceFqn: 'chat@1.0.0', + * poolSize: 10 + * } + * ); + * + * service.on('published', (serviceId, uuid) => { + * console.log(`Service published: ${uuid}`); + * }); + * + * service.on('connection', (connectionId) => { + * console.log(`New connection: ${connectionId}`); + * }); + * + * await service.start(); + * ``` + */ +export class DurableService extends EventEmitter { + readonly config: Required; + + private serviceId?: string; + private uuid?: string; + private expiresAt?: number; + private ttlRefreshTimer?: ReturnType; + private servicePool?: ServicePool; + private activeChannels: Map = new Map(); + + constructor( + private offersApi: RondevuOffers, + private baseUrl: string, + private credentials: { peerId: string; secret: string }, + private handler: ConnectionHandler, + config: DurableServiceConfig + ) { + super(); + this.config = { ...DEFAULT_CONFIG, ...config } as Required; + } + + /** + * Start the service + * + * Publishes the service and begins accepting connections. + * + * @returns Service information + */ + async start(): Promise { + if (this.servicePool) { + throw new Error('Service already started'); + } + + // Create and start service pool + this.servicePool = new ServicePool( + this.baseUrl, + this.credentials, + { + username: this.config.username, + privateKey: this.config.privateKey, + serviceFqn: this.config.serviceFqn, + rtcConfig: this.config.rtcConfig, + isPublic: this.config.isPublic, + metadata: this.config.metadata, + ttl: this.config.ttl, + poolSize: this.config.poolSize, + pollingInterval: this.config.pollingInterval, + handler: (channel, peer, connectionId) => { + this.handleNewConnection(channel, connectionId); + }, + onPoolStatus: (status) => { + // Could emit pool status event if needed + }, + onError: (error, context) => { + this.emit('error', error, context); + } + } + ); + + const handle = await this.servicePool.start(); + + // Store service info + this.serviceId = handle.serviceId; + this.uuid = handle.uuid; + this.expiresAt = Date.now() + (this.config.ttl || 300000); // Default 5 minutes + + this.emit('published', this.serviceId, this.uuid); + + // Schedule TTL refresh + this.scheduleRefresh(); + + return { + serviceId: this.serviceId, + uuid: this.uuid, + expiresAt: this.expiresAt + }; + } + + /** + * Stop the service + * + * Unpublishes the service and closes all active connections. + */ + async stop(): Promise { + // Cancel TTL refresh + if (this.ttlRefreshTimer) { + clearTimeout(this.ttlRefreshTimer); + this.ttlRefreshTimer = undefined; + } + + // Close all active channels + for (const channel of this.activeChannels.values()) { + channel.close(); + } + this.activeChannels.clear(); + + // Stop service pool + if (this.servicePool) { + await this.servicePool.stop(); + this.servicePool = undefined; + } + + this.emit('closed'); + } + + /** + * Get list of active connection IDs + */ + getActiveConnections(): string[] { + return Array.from(this.activeChannels.keys()); + } + + /** + * Get service information + */ + getServiceInfo(): ServiceInfo | null { + if (!this.serviceId || !this.uuid || !this.expiresAt) { + return null; + } + + return { + serviceId: this.serviceId, + uuid: this.uuid, + expiresAt: this.expiresAt + }; + } + + /** + * Schedule TTL refresh + */ + private scheduleRefresh(): void { + if (!this.expiresAt || !this.config.ttl) { + return; + } + + // Cancel existing timer + if (this.ttlRefreshTimer) { + clearTimeout(this.ttlRefreshTimer); + } + + // Calculate refresh time (default: refresh at 80% of TTL) + const timeUntilExpiry = this.expiresAt - Date.now(); + const refreshMargin = timeUntilExpiry * this.config.ttlRefreshMargin; + const refreshTime = Math.max(0, timeUntilExpiry - refreshMargin); + + // Schedule refresh + this.ttlRefreshTimer = setTimeout(() => { + this.refreshServiceTTL().catch(error => { + this.emit('error', error, 'ttl-refresh'); + // Retry after short delay + setTimeout(() => this.scheduleRefresh(), 5000); + }); + }, refreshTime); + } + + /** + * Refresh service TTL + */ + private async refreshServiceTTL(): Promise { + if (!this.serviceId || !this.uuid) { + return; + } + + // Delete old service + await this.servicePool?.stop(); + + // Recreate service pool (this republishes the service) + this.servicePool = new ServicePool( + this.baseUrl, + this.credentials, + { + username: this.config.username, + privateKey: this.config.privateKey, + serviceFqn: this.config.serviceFqn, + rtcConfig: this.config.rtcConfig, + isPublic: this.config.isPublic, + metadata: this.config.metadata, + ttl: this.config.ttl, + poolSize: this.config.poolSize, + pollingInterval: this.config.pollingInterval, + handler: (channel, peer, connectionId) => { + this.handleNewConnection(channel, connectionId); + }, + onPoolStatus: (status) => { + // Could emit pool status event if needed + }, + onError: (error, context) => { + this.emit('error', error, context); + } + } + ); + + const handle = await this.servicePool.start(); + + // Update service info + this.serviceId = handle.serviceId; + this.uuid = handle.uuid; + this.expiresAt = Date.now() + (this.config.ttl || 300000); + + this.emit('ttl-refreshed', this.expiresAt); + + // Schedule next refresh + this.scheduleRefresh(); + } + + /** + * Handle new incoming connection + */ + private handleNewConnection(channel: RTCDataChannel, connectionId: string): void { + // Create durable channel + const durableChannel = new DurableChannel(channel.label, { + maxQueueSize: this.config.maxQueueSize, + maxMessageAge: this.config.maxMessageAge + }); + + // Attach to underlying channel + durableChannel.attachToChannel(channel); + + // Track channel + this.activeChannels.set(connectionId, durableChannel); + + // Setup cleanup on close + durableChannel.on('close', () => { + this.activeChannels.delete(connectionId); + this.emit('disconnection', connectionId); + }); + + // Emit connection event + this.emit('connection', connectionId); + + // Invoke user handler + try { + const result = this.handler(durableChannel, connectionId); + if (result && typeof result.then === 'function') { + result.catch(error => { + this.emit('error', error, 'handler'); + }); + } + } catch (error) { + this.emit('error', error as Error, 'handler'); + } + } +} diff --git a/src/durable/types.ts b/src/durable/types.ts new file mode 100644 index 0000000..d0170e8 --- /dev/null +++ b/src/durable/types.ts @@ -0,0 +1,184 @@ +/** + * Type definitions for durable WebRTC connections + * + * This module defines all interfaces, enums, and types used by the durable + * connection system for automatic reconnection and message queuing. + */ + +/** + * Connection state enum + */ +export enum DurableConnectionState { + CONNECTING = 'connecting', + CONNECTED = 'connected', + RECONNECTING = 'reconnecting', + DISCONNECTED = 'disconnected', + FAILED = 'failed', + CLOSED = 'closed' +} + +/** + * Channel state enum + */ +export enum DurableChannelState { + CONNECTING = 'connecting', + OPEN = 'open', + CLOSING = 'closing', + CLOSED = 'closed' +} + +/** + * Configuration for durable connections + */ +export interface DurableConnectionConfig { + /** Maximum number of reconnection attempts (default: 10) */ + maxReconnectAttempts?: number; + + /** Base delay for exponential backoff in milliseconds (default: 1000) */ + reconnectBackoffBase?: number; + + /** Maximum delay between reconnection attempts in milliseconds (default: 30000) */ + reconnectBackoffMax?: number; + + /** Jitter factor for randomizing reconnection delays (default: 0.2 = ±20%) */ + reconnectJitter?: number; + + /** Timeout for initial connection attempt in milliseconds (default: 30000) */ + connectionTimeout?: number; + + /** Maximum number of messages to queue during disconnection (default: 1000) */ + maxQueueSize?: number; + + /** Maximum age of queued messages in milliseconds (default: 60000) */ + maxMessageAge?: number; + + /** WebRTC configuration */ + rtcConfig?: RTCConfiguration; +} + +/** + * Configuration for durable channels + */ +export interface DurableChannelConfig { + /** Maximum number of messages to queue (default: 1000) */ + maxQueueSize?: number; + + /** Maximum age of queued messages in milliseconds (default: 60000) */ + maxMessageAge?: number; + + /** Whether messages should be delivered in order (default: true) */ + ordered?: boolean; + + /** Maximum retransmits for unordered channels (default: undefined) */ + maxRetransmits?: number; +} + +/** + * Configuration for durable services + */ +export interface DurableServiceConfig extends DurableConnectionConfig { + /** Username that owns the service */ + username: string; + + /** Private key for signing service operations */ + privateKey: string; + + /** Fully qualified service name (e.g., com.example.chat@1.0.0) */ + serviceFqn: string; + + /** Whether the service is publicly discoverable (default: false) */ + isPublic?: boolean; + + /** Optional metadata for the service */ + metadata?: Record; + + /** Time-to-live for service in milliseconds (default: server default) */ + ttl?: number; + + /** Margin before TTL expiry to trigger refresh (default: 0.2 = refresh at 80%) */ + ttlRefreshMargin?: number; + + /** Number of simultaneous open offers to maintain (default: 1) */ + poolSize?: number; + + /** Polling interval for checking answers in milliseconds (default: 2000) */ + pollingInterval?: number; +} + +/** + * Queued message structure + */ +export interface QueuedMessage { + /** Message data */ + data: string | Blob | ArrayBuffer | ArrayBufferView; + + /** Timestamp when message was enqueued */ + enqueuedAt: number; + + /** Unique message ID */ + id: string; +} + +/** + * Event type map for DurableConnection + */ +export interface DurableConnectionEvents extends Record void> { + 'state': (state: DurableConnectionState, previousState: DurableConnectionState) => void; + 'connected': () => void; + 'reconnecting': (attempt: number, maxAttempts: number, nextRetryIn: number) => void; + 'disconnected': () => void; + 'failed': (error: Error, permanent: boolean) => void; + 'closed': () => void; +} + +/** + * Event type map for DurableChannel + */ +export interface DurableChannelEvents extends Record void> { + 'open': () => void; + 'message': (data: any) => void; + 'error': (error: Error) => void; + 'close': () => void; + 'bufferedAmountLow': () => void; + 'queueOverflow': (droppedCount: number) => void; +} + +/** + * Event type map for DurableService + */ +export interface DurableServiceEvents extends Record void> { + 'published': (serviceId: string, uuid: string) => void; + 'connection': (connectionId: string) => void; + 'disconnection': (connectionId: string) => void; + 'ttl-refreshed': (expiresAt: number) => void; + 'error': (error: Error, context: string) => void; + 'closed': () => void; +} + +/** + * Information about a durable connection + */ +export interface ConnectionInfo { + /** Username (for username-based connections) */ + username?: string; + + /** Service FQN (for service-based connections) */ + serviceFqn?: string; + + /** UUID (for UUID-based connections) */ + uuid?: string; +} + +/** + * Service information returned when service is published + */ +export interface ServiceInfo { + /** Service ID */ + serviceId: string; + + /** Service UUID for discovery */ + uuid: string; + + /** Expiration timestamp */ + expiresAt: number; +} diff --git a/src/index.ts b/src/index.ts index 80edb9b..cd53ec4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ /** * @xtr-dev/rondevu-client - * WebRTC peer signaling and discovery client with topic-based discovery + * WebRTC peer signaling and discovery client with durable connections */ // Export main client class @@ -11,44 +11,26 @@ export type { RondevuOptions } from './rondevu.js'; export { RondevuAuth } from './auth.js'; export type { Credentials, FetchFunction } from './auth.js'; -// Export offers API -export { RondevuOffers } from './offers.js'; -export type { - CreateOfferRequest, - Offer, - IceCandidate, - TopicInfo -} from './offers.js'; - -// Export peer manager -export { default as RondevuPeer } from './peer/index.js'; -export type { - PeerOptions, - PeerEvents, - PeerTimeouts -} from './peer/index.js'; - // Export username API export { RondevuUsername } from './usernames.js'; export type { UsernameClaimResult, UsernameCheckResult } from './usernames.js'; -// Export services API -export { RondevuServices } from './services.js'; -export type { - ServicePublishResult, - PublishServiceOptions, - ServiceHandle -} from './services.js'; +// Export durable connection APIs +export { DurableConnection } from './durable/connection.js'; +export { DurableChannel } from './durable/channel.js'; +export { DurableService } from './durable/service.js'; -// Export discovery API -export { RondevuDiscovery } from './discovery.js'; +// Export durable connection types export type { - ServiceInfo, - ServiceListResult, - ServiceQueryResult, - ServiceDetails, - ConnectResult -} from './discovery.js'; - -// Export pool types -export type { PoolStatus, PooledServiceHandle } from './service-pool.js'; + DurableConnectionState, + DurableChannelState, + DurableConnectionConfig, + DurableChannelConfig, + DurableServiceConfig, + QueuedMessage, + DurableConnectionEvents, + DurableChannelEvents, + DurableServiceEvents, + ConnectionInfo, + ServiceInfo +} from './durable/types.js'; diff --git a/src/rondevu.ts b/src/rondevu.ts index 557ad97..818983d 100644 --- a/src/rondevu.ts +++ b/src/rondevu.ts @@ -1,9 +1,15 @@ import { RondevuAuth, Credentials, FetchFunction } from './auth.js'; import { RondevuOffers } from './offers.js'; import { RondevuUsername } from './usernames.js'; -import { RondevuServices } from './services.js'; -import { RondevuDiscovery } from './discovery.js'; import RondevuPeer from './peer/index.js'; +import { DurableService } from './durable/service.js'; +import { DurableConnection } from './durable/connection.js'; +import { DurableChannel } from './durable/channel.js'; +import type { + DurableServiceConfig, + DurableConnectionConfig, + ConnectionInfo +} from './durable/types.js'; export interface RondevuOptions { /** @@ -71,8 +77,6 @@ export class Rondevu { readonly usernames: RondevuUsername; private _offers?: RondevuOffers; - private _services?: RondevuServices; - private _discovery?: RondevuDiscovery; private credentials?: Credentials; private baseUrl: string; private fetchFn?: FetchFunction; @@ -93,14 +97,12 @@ export class Rondevu { if (options.credentials) { this.credentials = options.credentials; this._offers = new RondevuOffers(this.baseUrl, this.credentials, this.fetchFn); - this._services = new RondevuServices(this.baseUrl, this.credentials); - this._discovery = new RondevuDiscovery(this.baseUrl, this.credentials); } } /** * Get offers API (low-level access, requires authentication) - * For most use cases, use services and discovery APIs instead + * For most use cases, use the durable connection APIs instead */ get offers(): RondevuOffers { if (!this._offers) { @@ -109,26 +111,6 @@ export class Rondevu { return this._offers; } - /** - * Get services API (requires authentication) - */ - get services(): RondevuServices { - if (!this._services) { - throw new Error('Not authenticated. Call register() first or provide credentials.'); - } - return this._services; - } - - /** - * Get discovery API (requires authentication) - */ - get discovery(): RondevuDiscovery { - if (!this._discovery) { - throw new Error('Not authenticated. Call register() first or provide credentials.'); - } - return this._discovery; - } - /** * Register and initialize authenticated client * Generates a cryptographically random peer ID (128-bit) @@ -136,14 +118,12 @@ export class Rondevu { async register(): Promise { this.credentials = await this.auth.register(); - // Create API instances + // Create offers API instance this._offers = new RondevuOffers( this.baseUrl, this.credentials, this.fetchFn ); - this._services = new RondevuServices(this.baseUrl, this.credentials); - this._discovery = new RondevuDiscovery(this.baseUrl, this.credentials); return this.credentials; } @@ -183,4 +163,134 @@ export class Rondevu { this.rtcIceCandidate ); } + + /** + * Expose a durable service with automatic reconnection and TTL refresh + * + * Creates a service that handles incoming connections with automatic + * reconnection and message queuing during network interruptions. + * + * @param config Service configuration + * @returns DurableService instance + * + * @example + * ```typescript + * const service = await client.exposeService({ + * username: 'alice', + * privateKey: keypair.privateKey, + * serviceFqn: 'chat@1.0.0', + * poolSize: 10, + * handler: (channel, connectionId) => { + * channel.on('message', (data) => { + * console.log('Received:', data); + * channel.send(`Echo: ${data}`); + * }); + * } + * }); + * + * await service.start(); + * ``` + */ + async exposeService( + config: DurableServiceConfig & { + handler: (channel: DurableChannel, connectionId: string) => void | Promise; + } + ): Promise { + if (!this._offers || !this.credentials) { + throw new Error('Not authenticated. Call register() first or provide credentials.'); + } + + const service = new DurableService( + this._offers, + this.baseUrl, + this.credentials, + config.handler, + config + ); + + return service; + } + + /** + * Create a durable connection to a service by username and service FQN + * + * Establishes a WebRTC connection with automatic reconnection and + * message queuing during network interruptions. + * + * @param username Username of the service provider + * @param serviceFqn Fully qualified service name + * @param config Optional connection configuration + * @returns DurableConnection instance + * + * @example + * ```typescript + * const connection = await client.connect('alice', 'chat@1.0.0', { + * maxReconnectAttempts: 5 + * }); + * + * const channel = connection.createChannel('main'); + * channel.on('message', (data) => { + * console.log('Received:', data); + * }); + * + * await connection.connect(); + * channel.send('Hello!'); + * ``` + */ + async connect( + username: string, + serviceFqn: string, + config?: DurableConnectionConfig + ): Promise { + if (!this._offers) { + throw new Error('Not authenticated. Call register() first or provide credentials.'); + } + + const connectionInfo: ConnectionInfo = { + username, + serviceFqn + }; + + return new DurableConnection(this._offers, connectionInfo, config); + } + + /** + * Create a durable connection to a service by UUID + * + * Establishes a WebRTC connection with automatic reconnection and + * message queuing during network interruptions. + * + * @param uuid Service UUID + * @param config Optional connection configuration + * @returns DurableConnection instance + * + * @example + * ```typescript + * const connection = await client.connectByUuid('service-uuid-here', { + * maxReconnectAttempts: 5 + * }); + * + * const channel = connection.createChannel('main'); + * channel.on('message', (data) => { + * console.log('Received:', data); + * }); + * + * await connection.connect(); + * channel.send('Hello!'); + * ``` + */ + async connectByUuid( + uuid: string, + config?: DurableConnectionConfig + ): Promise { + if (!this._offers) { + throw new Error('Not authenticated. Call register() first or provide credentials.'); + } + + const connectionInfo: ConnectionInfo = { + uuid + }; + + return new DurableConnection(this._offers, connectionInfo, config); + } } diff --git a/src/service-pool.ts b/src/service-pool.ts index bace761..95d6ea8 100644 --- a/src/service-pool.ts +++ b/src/service-pool.ts @@ -2,7 +2,6 @@ import { RondevuOffers, Offer } from './offers.js'; import { RondevuUsername } from './usernames.js'; import RondevuPeer from './peer/index.js'; import { OfferPool, AnsweredOffer } from './offer-pool.js'; -import { ServiceHandle } from './services.js'; /** * Connection information for tracking active connections @@ -73,9 +72,21 @@ export interface ServicePoolOptions { } /** - * Extended service handle with pool-specific methods + * Service handle with pool-specific methods */ -export interface PooledServiceHandle extends ServiceHandle { +export interface PooledServiceHandle { + /** Service ID */ + serviceId: string; + + /** Service UUID */ + uuid: string; + + /** Offer ID */ + offerId: string; + + /** Unpublish the service */ + unpublish: () => Promise; + /** Get current pool status */ getStatus: () => PoolStatus; diff --git a/src/services.ts b/src/services.ts deleted file mode 100644 index 02c5644..0000000 --- a/src/services.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { RondevuUsername } from './usernames.js'; -import RondevuPeer from './peer/index.js'; -import { RondevuOffers } from './offers.js'; -import { ServicePool, ServicePoolOptions, PooledServiceHandle, PoolStatus } from './service-pool.js'; - -/** - * Service publish result - */ -export interface ServicePublishResult { - serviceId: string; - uuid: string; - offerId: string; - expiresAt: number; -} - -/** - * Service publish options - */ -export interface PublishServiceOptions { - username: string; - privateKey: string; - serviceFqn: string; - rtcConfig?: RTCConfiguration; - isPublic?: boolean; - metadata?: Record; - ttl?: number; - onConnection?: (peer: RondevuPeer) => void; -} - -/** - * Service handle for managing an exposed service - */ -export interface ServiceHandle { - serviceId: string; - uuid: string; - offerId: string; - unpublish: () => Promise; -} - -/** - * Rondevu Services API - * Handles service publishing and management - */ -export class RondevuServices { - private usernameApi: RondevuUsername; - private offersApi: RondevuOffers; - - constructor( - private baseUrl: string, - private credentials: { peerId: string; secret: string } - ) { - this.usernameApi = new RondevuUsername(baseUrl); - this.offersApi = new RondevuOffers(baseUrl, credentials); - } - - /** - * Publishes a service - */ - async publishService(options: PublishServiceOptions): Promise { - const { - username, - privateKey, - serviceFqn, - rtcConfig, - isPublic = false, - metadata, - ttl - } = options; - - // Validate FQN format - this.validateServiceFqn(serviceFqn); - - // Create WebRTC peer connection to generate offer - const pc = new RTCPeerConnection(rtcConfig || { - iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] - }); - - // Add a data channel (required for datachannel-based services) - pc.createDataChannel('rondevu-service'); - - // Create offer - const offer = await pc.createOffer(); - await pc.setLocalDescription(offer); - - if (!offer.sdp) { - throw new Error('Failed to generate SDP'); - } - - // Create signature for username verification - const timestamp = Date.now(); - const message = `publish:${username}:${serviceFqn}:${timestamp}`; - const signature = await this.usernameApi.signMessage(message, privateKey); - - // Publish service - const response = await fetch(`${this.baseUrl}/services`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.credentials.peerId}:${this.credentials.secret}` - }, - body: JSON.stringify({ - username, - serviceFqn, - sdp: offer.sdp, - ttl, - isPublic, - metadata, - signature, - message - }) - }); - - if (!response.ok) { - const error = await response.json(); - pc.close(); - throw new Error(error.error || 'Failed to publish service'); - } - - const data = await response.json(); - - // Close the connection for now (would be kept open in a real implementation) - pc.close(); - - return { - serviceId: data.serviceId, - uuid: data.uuid, - offerId: data.offerId, - expiresAt: data.expiresAt - }; - } - - /** - * Unpublishes a service - */ - async unpublishService(serviceId: string, username: string): Promise { - const response = await fetch(`${this.baseUrl}/services/${serviceId}`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.credentials.peerId}:${this.credentials.secret}` - }, - body: JSON.stringify({ username }) - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to unpublish service'); - } - } - - /** - * Exposes a service with an automatic connection handler - * This is a convenience method that publishes the service and manages connections - * - * Set poolSize > 1 to enable offer pooling for handling multiple concurrent connections - */ - async exposeService(options: Omit & { - handler: (channel: RTCDataChannel, peer: RondevuPeer, connectionId?: string) => void; - poolSize?: number; - pollingInterval?: number; - onPoolStatus?: (status: PoolStatus) => void; - onError?: (error: Error, context: string) => void; - }): Promise { - const { - username, - privateKey, - serviceFqn, - rtcConfig, - isPublic, - metadata, - ttl, - handler, - poolSize, - pollingInterval, - onPoolStatus, - onError - } = options; - - // If poolSize > 1, use pooled implementation - if (poolSize && poolSize > 1) { - const pool = new ServicePool(this.baseUrl, this.credentials, { - username, - privateKey, - serviceFqn, - rtcConfig, - isPublic, - metadata, - ttl, - handler: (channel, peer, connectionId) => handler(channel, peer, connectionId), - poolSize, - pollingInterval, - onPoolStatus, - onError - }); - return await pool.start(); - } - - // Otherwise, use existing single-offer logic (UNCHANGED) - // Validate FQN - this.validateServiceFqn(serviceFqn); - - // Create peer connection - const pc = new RTCPeerConnection(rtcConfig || { - iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] - }); - - // Create data channel - const channel = pc.createDataChannel('rondevu-service'); - - // Set up handler - channel.onopen = () => { - const peer = new RondevuPeer( - this.offersApi, - rtcConfig || { - iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] - } - ); - handler(channel, peer); - }; - - // Create offer - const offer = await pc.createOffer(); - await pc.setLocalDescription(offer); - - if (!offer.sdp) { - pc.close(); - throw new Error('Failed to generate SDP'); - } - - // Create signature - const timestamp = Date.now(); - const message = `publish:${username}:${serviceFqn}:${timestamp}`; - const signature = await this.usernameApi.signMessage(message, privateKey); - - // Publish service - const response = await fetch(`${this.baseUrl}/services`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.credentials.peerId}:${this.credentials.secret}` - }, - body: JSON.stringify({ - username, - serviceFqn, - sdp: offer.sdp, - ttl, - isPublic, - metadata, - signature, - message - }) - }); - - if (!response.ok) { - const error = await response.json(); - pc.close(); - throw new Error(error.error || 'Failed to expose service'); - } - - const data = await response.json(); - - return { - serviceId: data.serviceId, - uuid: data.uuid, - offerId: data.offerId, - unpublish: () => this.unpublishService(data.serviceId, username) - }; - } - - /** - * Validates service FQN format - */ - private validateServiceFqn(fqn: string): void { - const parts = fqn.split('@'); - if (parts.length !== 2) { - throw new Error('Service FQN must be in format: service-name@version'); - } - - const [serviceName, version] = parts; - - // Validate service name (reverse domain notation) - const serviceNameRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; - if (!serviceNameRegex.test(serviceName)) { - throw new Error('Service name must be reverse domain notation (e.g., com.example.service)'); - } - - if (serviceName.length < 3 || serviceName.length > 128) { - throw new Error('Service name must be 3-128 characters'); - } - - // Validate version (semantic versioning) - const versionRegex = /^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.-]+)?$/; - if (!versionRegex.test(version)) { - throw new Error('Version must be semantic versioning (e.g., 1.0.0, 2.1.3-beta)'); - } - } - - /** - * Parses a service FQN into name and version - */ - parseServiceFqn(fqn: string): { name: string; version: string } { - const parts = fqn.split('@'); - if (parts.length !== 2) { - throw new Error('Invalid FQN format'); - } - return { name: parts[0], version: parts[1] }; - } -}