1 Commits

Author SHA1 Message Date
db28a133bf 0.18.1 2025-12-14 11:04:21 +01:00
15 changed files with 865 additions and 2372 deletions

View File

@@ -1,441 +1,547 @@
# Migration Guide: v0.18.x → v0.18.8
# Migration Guide: v0.8.x → v0.9.0
Version 0.18.8 introduces significant improvements to connection durability and reliability. While we've maintained backward compatibility where possible, there are some breaking changes to be aware of.
This guide helps you migrate from Rondevu Client v0.8.x to v0.9.0.
## Overview of Changes
## Overview
### New Features
- **Automatic reconnection** with exponential backoff
- **Message buffering** during disconnections
- **Connection state machine** with proper lifecycle management
- **Rich event system** for connection monitoring
- **ICE polling lifecycle** (stops when connected, no more resource leaks)
v0.9.0 is a **breaking change** that completely replaces low-level APIs with high-level durable connections featuring automatic reconnection and message queuing.
### Breaking Changes
- `connectToService()` now returns `AnswererConnection` instead of `ConnectionContext`
- `connection:opened` event signature changed for offerer side
- Direct DataChannel access replaced with connection wrapper API
### 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
## Migration Steps
### What's Removed
### 1. Answerer Side (connectToService)
**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
#### Old API (v0.18.7 and earlier)
## Breaking Changes
### 1. Service Exposure
#### v0.8.x (Old)
```typescript
const context = await rondevu.connectToService({
serviceFqn: 'chat:1.0.0@alice',
onConnection: ({ dc, pc, peerUsername }) => {
console.log('Connected to', peerUsername)
import { Rondevu } from '@xtr-dev/rondevu-client';
dc.addEventListener('message', (event) => {
console.log('Received:', event.data)
})
const client = new Rondevu();
await client.register();
dc.addEventListener('open', () => {
dc.send('Hello!')
})
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}`);
};
}
})
});
// Access peer connection
context.pc.getStats()
// Unpublish
await handle.unpublish();
```
#### New API (v0.18.8)
#### v0.9.0 (New)
```typescript
const connection = await rondevu.connectToService({
serviceFqn: 'chat:1.0.0@alice',
connectionConfig: {
reconnectEnabled: true, // Optional: enable auto-reconnect
bufferEnabled: true, // Optional: enable message buffering
connectionTimeout: 30000 // Optional: connection timeout (ms)
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
connection.on('connected', () => {
console.log('Connected!')
connection.send('Hello!')
})
connection.on('message', (data) => {
console.log('Received:', data)
})
// Optional: monitor reconnection
connection.on('reconnecting', (attempt) => {
console.log(`Reconnecting, attempt ${attempt}`)
})
connection.on('reconnect:success', () => {
console.log('Reconnection successful!')
})
// Access peer connection if needed
const pc = connection.getPeerConnection()
const dc = connection.getDataChannel()
service.on('connection', (connectionId) => {
console.log('New connection:', connectionId);
});
```
**Key Changes:**
- ❌ Removed `onConnection` callback
- ✅ Use event listeners instead: `connection.on('connected', ...)`
- ❌ Removed direct `dc.send()` access
- ✅ Use `connection.send()` for automatic buffering support
- ✅ Added automatic reconnection and message buffering
**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
### 2. Offerer Side (publishService)
| 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) |
#### Old API (v0.18.7 and earlier)
## API Mapping
### Removed Exports
These are no longer exported in v0.9.0:
```typescript
await rondevu.publishService({
service: 'chat:1.0.0',
maxOffers: 5
})
await rondevu.startFilling()
// Handle connections
rondevu.on('connection:opened', (offerId, dc) => {
console.log('New connection:', offerId)
dc.addEventListener('message', (event) => {
console.log('Received:', event.data)
})
dc.send('Welcome!')
})
// ❌ Removed
import {
RondevuServices,
RondevuDiscovery,
RondevuPeer,
ServiceHandle,
PooledServiceHandle,
ConnectResult
} from '@xtr-dev/rondevu-client';
```
#### New API (v0.18.8)
### New Exports
These are new in v0.9.0:
```typescript
await rondevu.publishService({
service: 'chat:1.0.0',
maxOffers: 5,
connectionConfig: {
reconnectEnabled: true,
bufferEnabled: true
}
})
await rondevu.startFilling()
// Handle connections - signature changed!
rondevu.on('connection:opened', (offerId, connection) => {
console.log('New connection:', offerId)
connection.on('message', (data) => {
console.log('Received:', data)
})
connection.on('disconnected', () => {
console.log('Connection lost, will auto-reconnect')
})
connection.send('Welcome!')
})
// ✅ New
import {
DurableConnection,
DurableChannel,
DurableService,
DurableConnectionState,
DurableChannelState,
DurableConnectionConfig,
DurableChannelConfig,
DurableServiceConfig,
DurableConnectionEvents,
DurableChannelEvents,
DurableServiceEvents,
ConnectionInfo,
ServiceInfo,
QueuedMessage
} from '@xtr-dev/rondevu-client';
```
**Key Changes:**
- ⚠️ Event signature changed: `(offerId, dc)``(offerId, connection)`
- ❌ Removed direct DataChannel access
- ✅ Use `connection.send()` and `connection.on('message', ...)`
- ✅ Connection object provides lifecycle events
### Unchanged Exports
---
## New Connection Configuration
All connection-related options are now configured via `connectionConfig`:
These work the same in both versions:
```typescript
interface ConnectionConfig {
// Timeouts
connectionTimeout: number // Default: 30000ms (30s)
iceGatheringTimeout: number // Default: 10000ms (10s)
// ✅ 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
reconnectEnabled: boolean // Default: true
maxReconnectAttempts: number // Default: 5
reconnectBackoffBase: number // Default: 1000ms
reconnectBackoffMax: number // Default: 30000ms (30s)
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 buffering
bufferEnabled: boolean // Default: true
maxBufferSize: number // Default: 100 messages
maxBufferAge: number // Default: 60000ms (1 min)
// Message queuing
maxQueueSize: 1000, // default: 1000 messages
maxMessageAge: 60000, // default: 60000ms (1 minute)
// Debug
debug: boolean // Default: false
}
```
### Example Usage
```typescript
const connection = await rondevu.connectToService({
serviceFqn: 'chat:1.0.0@alice',
connectionConfig: {
// Disable auto-reconnect if you want manual control
reconnectEnabled: false,
// Disable buffering if messages are time-sensitive
bufferEnabled: false,
// Increase timeout for slow networks
connectionTimeout: 60000,
// Reduce retry attempts
maxReconnectAttempts: 3
// WebRTC
rtcConfig: {
iceServers: [...]
}
})
});
```
---
### New Service Options
## New Event System
### Connection Lifecycle Events
Services can now auto-refresh TTL:
```typescript
connection.on('state:changed', ({ oldState, newState, reason }) => {})
connection.on('connecting', () => {})
connection.on('connected', () => {})
connection.on('disconnected', (reason) => {})
connection.on('failed', (error) => {})
connection.on('closed', (reason) => {})
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,
// ...
});
```
### Reconnection Events
## Migration Checklist
```typescript
connection.on('reconnect:scheduled', ({ attempt, delay, maxAttempts }) => {})
connection.on('reconnect:attempting', (attempt) => {})
connection.on('reconnect:success', () => {})
connection.on('reconnect:failed', (error) => {})
connection.on('reconnect:exhausted', (attempts) => {})
```
### Message Events
```typescript
connection.on('message', (data) => {})
connection.on('message:sent', (data, buffered) => {})
connection.on('message:buffered', (data) => {})
connection.on('message:replayed', (message) => {})
connection.on('message:buffer:overflow', (discardedMessage) => {})
```
### ICE Events
```typescript
connection.on('ice:candidate:local', (candidate) => {})
connection.on('ice:candidate:remote', (candidate) => {})
connection.on('ice:connection:state', (state) => {})
connection.on('ice:polling:started', () => {})
connection.on('ice:polling:stopped', () => {})
```
---
- [ ] 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 Message Handler
### Pattern 1: Simple Echo Service
**Before:**
#### Before (v0.8.x)
```typescript
dc.addEventListener('message', (event) => {
console.log(event.data)
})
dc.send('Hello')
```
**After:**
```typescript
connection.on('message', (data) => {
console.log(data)
})
connection.send('Hello')
```
---
### Pattern 2: Connection State Monitoring
**Before:**
```typescript
pc.oniceconnectionstatechange = () => {
console.log('ICE state:', pc.iceConnectionState)
}
```
**After:**
```typescript
connection.on('ice:connection:state', (state) => {
console.log('ICE state:', state)
})
// Or use higher-level events
connection.on('connected', () => console.log('Connected!'))
connection.on('disconnected', () => console.log('Disconnected!'))
```
---
### Pattern 3: Handling Connection Failures
**Before:**
```typescript
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'failed') {
// Manual reconnection logic
pc.close()
await setupNewConnection()
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:**
#### After (v0.9.0)
```typescript
// Automatic reconnection built-in!
connection.on('reconnecting', (attempt) => {
console.log(`Reconnecting... attempt ${attempt}`)
})
const connection = await client.connect('alice', 'chat@1.0.0', {
maxReconnectAttempts: 5
});
connection.on('reconnect:success', () => {
console.log('Back online!')
})
const channel = connection.createChannel('main');
connection.on('reconnect:exhausted', (attempts) => {
console.log(`Failed after ${attempts} attempts`)
// Fallback logic here
})
```
channel.on('open', () => {
channel.send('Hello!');
});
---
connection.on('reconnecting', (attempt, max, delay) => {
console.log(`Reconnecting (${attempt}/${max}) in ${delay}ms`);
});
### Pattern 4: Accessing Raw RTCPeerConnection/DataChannel
connection.on('failed', (error) => {
console.error('Connection failed permanently:', error);
});
If you need low-level access:
```typescript
const connection = await rondevu.connectToService({ ... })
// Get raw objects if needed
const pc = connection.getPeerConnection()
const dc = connection.getDataChannel()
// Use them directly (bypasses buffering/reconnection features)
if (dc) {
dc.addEventListener('message', (event) => {
console.log(event.data)
})
try {
await connection.connect();
} catch (error) {
console.error('Initial connection failed:', error);
}
```
**Note:** Using raw DataChannel bypasses automatic buffering and reconnection features.
---
## Backward Compatibility Notes
### What Still Works
`publishService()` API (just add `connectionConfig` optionally)
`findService()` API (unchanged)
✅ All RondevuAPI methods (unchanged)
✅ ICE server presets (unchanged)
✅ Username and keypair management (unchanged)
### What Changed
⚠️ `connectToService()` return type: `ConnectionContext``AnswererConnection`
⚠️ `connection:opened` event signature: `(offerId, dc)``(offerId, connection)`
⚠️ Direct DataChannel access replaced with connection wrapper
### What's New
✨ Automatic reconnection with exponential backoff
✨ Message buffering during disconnections
✨ Rich event system (20+ events)
✨ Connection state machine
✨ ICE polling lifecycle management (no more resource leaks)
---
## Troubleshooting
### Issue: "connection.send is not a function"
You're trying to use the old `dc.send()` API. Update to:
### Pattern 3: Multi-User Chat Server
#### Before (v0.8.x)
```typescript
// Old
dc.send('Hello')
const connections = new Map();
// New
connection.send('Hello')
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);
};
}
});
```
---
### Issue: "Cannot read property 'addEventListener' of undefined"
You're trying to access `dc` directly. Update to event listeners:
#### After (v0.9.0)
```typescript
// Old
dc.addEventListener('message', (event) => {
console.log(event.data)
})
const channels = new Map();
// New
connection.on('message', (data) => {
console.log(data)
})
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
### Issue: Messages not being delivered
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
Check if buffering is enabled and connection is established:
## Getting Help
```typescript
connection.on('connected', () => {
// Only send after connected
connection.send('Hello')
})
// Monitor buffer
connection.on('message:buffered', (data) => {
console.log('Message buffered, will send when reconnected')
})
```
---
## Need Help?
- Check the updated README for full API documentation
- See examples in the `demo/` directory
- File issues at: https://github.com/xtr-dev/rondevu/issues
---
## Summary Checklist
When migrating from v0.18.7 to v0.18.8:
- [ ] Update `connectToService()` to use returned `AnswererConnection`
- [ ] Replace `dc.addEventListener('message', ...)` with `connection.on('message', ...)`
- [ ] Replace `dc.send()` with `connection.send()`
- [ ] Update `connection:opened` event handler signature
- [ ] Consider adding reconnection event handlers
- [ ] Optionally configure `connectionConfig` for your use case
- [ ] Test connection resilience (disconnect network, should auto-reconnect)
- [ ] Remove manual reconnection logic (now built-in)
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)

312
README.md
View File

@@ -2,9 +2,9 @@
[![npm version](https://img.shields.io/npm/v/@xtr-dev/rondevu-client)](https://www.npmjs.com/package/@xtr-dev/rondevu-client)
🌐 **WebRTC signaling client with durable connections**
🌐 **Simple WebRTC signaling client with username-based discovery**
TypeScript/JavaScript client for Rondevu, providing WebRTC signaling with **automatic reconnection**, **message buffering**, username claiming, service publishing/discovery, and efficient batch polling.
TypeScript/JavaScript client for Rondevu, providing WebRTC signaling with username claiming, service publishing/discovery, and efficient batch polling.
**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,22 +15,15 @@ TypeScript/JavaScript client for Rondevu, providing WebRTC signaling with **auto
## Features
### ✨ New in v0.18.9
- **🔄 Automatic Reconnection**: Built-in exponential backoff for failed connections
- **📦 Message Buffering**: Queues messages during disconnections, replays on reconnect
- **📊 Connection State Machine**: Explicit lifecycle tracking with native RTC events
- **🎯 Rich Event System**: 20+ events for monitoring connection health
- **⚡ Improved Reliability**: ICE polling lifecycle management, proper cleanup
### Core Features
- **Username Claiming**: Secure ownership with Ed25519 signatures
- **Anonymous Users**: Auto-generated anonymous usernames for quick testing
- **Service Publishing**: Publish services with multiple offers for connection pooling
- **Service Discovery**: Direct lookup, random discovery, or paginated search
- **Efficient Batch Polling**: Single endpoint for answers and ICE candidates
- **Efficient Batch Polling**: Single endpoint for answers and ICE candidates (50% fewer requests)
- **Semantic Version Matching**: Compatible version resolution (chat:1.0.0 matches any 1.x.x)
- **TypeScript**: Full type safety and autocomplete
- **Keypair Management**: Generate or reuse Ed25519 keypairs
- **Automatic Signatures**: All authenticated requests signed automatically
## Installation
@@ -56,35 +49,27 @@ const rondevu = await Rondevu.connect({
await rondevu.publishService({
service: 'chat:1.0.0',
maxOffers: 5, // Maintain up to 5 concurrent offers
connectionConfig: {
reconnectEnabled: true, // Auto-reconnect on failures
bufferEnabled: true, // Buffer messages during disconnections
connectionTimeout: 30000 // 30 second timeout
offerFactory: async (rtcConfig) => {
const pc = new RTCPeerConnection(rtcConfig)
const dc = pc.createDataChannel('chat')
dc.addEventListener('open', () => {
console.log('Connection opened!')
dc.send('Hello from Alice!')
})
dc.addEventListener('message', (e) => {
console.log('Received:', e.data)
})
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
return { pc, dc, offer }
}
})
// 3. Start accepting connections
await rondevu.startFilling()
// 4. Handle incoming connections
rondevu.on('connection:opened', (offerId, connection) => {
console.log('New connection:', offerId)
// Listen for messages
connection.on('message', (data) => {
console.log('Received:', data)
})
// Monitor connection state
connection.on('connected', () => {
console.log('Fully connected!')
connection.send('Hello from Alice!')
})
connection.on('disconnected', () => {
console.log('Connection lost, will auto-reconnect')
})
})
```
### Connecting to a Service (Answerer)
@@ -99,38 +84,25 @@ const rondevu = await Rondevu.connect({
iceServers: 'ipv4-turn'
})
// 2. Connect to service - returns AnswererConnection
// 2. Connect to service (automatic WebRTC setup)
const connection = await rondevu.connectToService({
serviceFqn: 'chat:1.0.0@alice',
connectionConfig: {
reconnectEnabled: true,
bufferEnabled: true,
maxReconnectAttempts: 5
onConnection: ({ dc, peerUsername }) => {
console.log('Connected to', peerUsername)
dc.addEventListener('message', (e) => {
console.log('Received:', e.data)
})
dc.addEventListener('open', () => {
dc.send('Hello from Bob!')
})
}
})
// 3. Setup event handlers
connection.on('connected', () => {
console.log('Connected to alice!')
connection.send('Hello from Bob!')
})
connection.on('message', (data) => {
console.log('Received:', data)
})
// 4. Monitor connection health
connection.on('reconnecting', (attempt) => {
console.log(`Reconnecting... attempt ${attempt}`)
})
connection.on('reconnect:success', () => {
console.log('Back online!')
})
connection.on('failed', (error) => {
console.error('Connection failed:', error)
})
// Access connection
connection.dc.send('Another message')
connection.pc.close() // Close when done
```
## Core API
@@ -154,234 +126,52 @@ await rondevu.publishService({
service: string, // e.g., 'chat:1.0.0' (username auto-appended)
maxOffers: number, // Maximum concurrent offers to maintain
offerFactory?: OfferFactory, // Optional: custom offer creation
ttl?: number, // Optional: offer lifetime in ms (default: 300000)
connectionConfig?: Partial<ConnectionConfig> // Optional: durability settings
ttl?: number // Optional: offer lifetime in ms (default: 300000)
})
await rondevu.startFilling() // Start accepting connections
rondevu.stopFilling() // Stop and close all connections
```
### Connecting to Services
**⚠️ Breaking Change in v0.18.9:** `connectToService()` now returns `AnswererConnection` instead of `ConnectionContext`.
### Service Discovery
```typescript
// Direct lookup (with username)
await rondevu.getService('chat:1.0.0@alice')
// Random discovery (without username)
await rondevu.discoverService('chat:1.0.0')
// Paginated discovery
await rondevu.discoverServices('chat:1.0.0', limit, offset)
```
### Connecting to Services
```typescript
// New API (v0.18.9+)
const connection = await rondevu.connectToService({
serviceFqn?: string, // Full FQN like 'chat:1.0.0@alice'
service?: string, // Service without username (for discovery)
username?: string, // Target username (combined with service)
connectionConfig?: Partial<ConnectionConfig>, // Durability settings
onConnection?: (context) => void, // Called when data channel opens
rtcConfig?: RTCConfiguration // Optional: override ICE servers
})
// Setup event handlers
connection.on('connected', () => {
connection.send('Hello!')
})
connection.on('message', (data) => {
console.log(data)
})
```
### Connection Configuration
```typescript
interface ConnectionConfig {
// Timeouts
connectionTimeout: number // Default: 30000ms (30s)
iceGatheringTimeout: number // Default: 10000ms (10s)
// Reconnection
reconnectEnabled: boolean // Default: true
maxReconnectAttempts: number // Default: 5 (0 = infinite)
reconnectBackoffBase: number // Default: 1000ms
reconnectBackoffMax: number // Default: 30000ms (30s)
// Message buffering
bufferEnabled: boolean // Default: true
maxBufferSize: number // Default: 100 messages
maxBufferAge: number // Default: 60000ms (1 min)
// Debug
debug: boolean // Default: false
}
```
### Connection Events
```typescript
// Lifecycle events
connection.on('connecting', () => {})
connection.on('connected', () => {})
connection.on('disconnected', (reason) => {})
connection.on('failed', (error) => {})
connection.on('closed', (reason) => {})
// Reconnection events
connection.on('reconnecting', (attempt) => {})
connection.on('reconnect:success', () => {})
connection.on('reconnect:failed', (error) => {})
connection.on('reconnect:exhausted', (attempts) => {})
// Message events
connection.on('message', (data) => {})
connection.on('message:buffered', (data) => {})
connection.on('message:replayed', (message) => {})
// ICE events
connection.on('ice:connection:state', (state) => {})
connection.on('ice:polling:started', () => {})
connection.on('ice:polling:stopped', () => {})
```
### Service Discovery
```typescript
// Unified discovery API
const service = await rondevu.findService(
'chat:1.0.0@alice', // Direct lookup (with username)
{ mode: 'direct' }
)
const service = await rondevu.findService(
'chat:1.0.0', // Random discovery (without username)
{ mode: 'random' }
)
const result = await rondevu.findService(
'chat:1.0.0',
{
mode: 'paginated',
limit: 20,
offset: 0
}
)
```
## Migration Guide
**Upgrading from v0.18.7 or earlier?** See [MIGRATION.md](./MIGRATION.md) for detailed upgrade instructions.
### Quick Migration Summary
**Before (v0.18.7):**
```typescript
const context = await rondevu.connectToService({
serviceFqn: 'chat:1.0.0@alice',
onConnection: ({ dc }) => {
dc.addEventListener('message', (e) => console.log(e.data))
dc.send('Hello')
}
})
```
**After (v0.18.9):**
```typescript
const connection = await rondevu.connectToService({
serviceFqn: 'chat:1.0.0@alice'
})
connection.on('connected', () => {
connection.send('Hello') // Use connection.send()
})
connection.on('message', (data) => {
console.log(data) // data is already extracted
})
```
## Advanced Usage
### Custom Offer Factory
```typescript
await rondevu.publishService({
service: 'file-transfer:1.0.0',
maxOffers: 3,
offerFactory: async (pc) => {
// Customize data channel settings
const dc = pc.createDataChannel('files', {
ordered: true,
maxRetransmits: 10
})
// Add custom listeners
dc.addEventListener('open', () => {
console.log('Transfer channel ready')
})
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
return { dc, offer }
}
})
```
### Accessing Raw RTCPeerConnection
```typescript
const connection = await rondevu.connectToService({ ... })
// Get raw objects if needed
const pc = connection.getPeerConnection()
const dc = connection.getDataChannel()
// Note: Using raw DataChannel bypasses buffering/reconnection features
if (dc) {
dc.addEventListener('message', (e) => {
console.log('Raw message:', e.data)
})
}
```
### Disabling Durability Features
```typescript
const connection = await rondevu.connectToService({
serviceFqn: 'chat:1.0.0@alice',
connectionConfig: {
reconnectEnabled: false, // Disable auto-reconnect
bufferEnabled: false, // Disable message buffering
}
})
```
## Documentation
📚 **[MIGRATION.md](./MIGRATION.md)** - Upgrade guide from v0.18.7 to v0.18.9
📚 **[ADVANCED.md](./ADVANCED.md)** - Comprehensive guide including:
- Detailed API reference for all methods
- Type definitions and interfaces
- Platform support (Browser & Node.js)
- Advanced usage patterns
- Username rules and service FQN format
- Examples and migration guides
## Examples
- [React Demo](https://github.com/xtr-dev/rondevu-demo) - Full browser UI ([live](https://ronde.vu))
## Changelog
### v0.18.9 (Latest)
- Add durable WebRTC connections with state machine
- Implement automatic reconnection with exponential backoff
- Add message buffering during disconnections
- Fix ICE polling lifecycle (stops when connected)
- Add fillOffers() semaphore to prevent exceeding maxOffers
- **Breaking:** `connectToService()` returns `AnswererConnection` instead of `ConnectionContext`
- **Breaking:** `connection:opened` event signature changed
- See [MIGRATION.md](./MIGRATION.md) for upgrade guide
### v0.18.8
- Initial durable connections implementation
### v0.18.3
- Fix EventEmitter cross-platform compatibility
## License
MIT

34
package-lock.json generated
View File

@@ -1,16 +1,15 @@
{
"name": "@xtr-dev/rondevu-client",
"version": "0.18.7",
"version": "0.18.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@xtr-dev/rondevu-client",
"version": "0.18.7",
"version": "0.18.1",
"license": "MIT",
"dependencies": {
"@noble/ed25519": "^3.0.0",
"eventemitter3": "^5.0.1"
"@noble/ed25519": "^3.0.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
@@ -1076,18 +1075,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.0.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.2.tgz",
"integrity": "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.48.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz",
@@ -2003,12 +1990,6 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2847,15 +2828,6 @@
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/update-browserslist-db": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "@xtr-dev/rondevu-client",
"version": "0.18.10",
"description": "TypeScript client for Rondevu WebRTC signaling with username-based discovery",
"version": "0.18.1",
"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",
@@ -41,7 +41,6 @@
"README.md"
],
"dependencies": {
"@noble/ed25519": "^3.0.0",
"eventemitter3": "^5.0.1"
"@noble/ed25519": "^3.0.0"
}
}

View File

@@ -1,183 +0,0 @@
/**
* Answerer-side WebRTC connection with answer creation and offer processing
*/
import { RondevuConnection } from './connection.js'
import { ConnectionState } from './connection-events.js'
import { RondevuAPI } from './api.js'
import { ConnectionConfig } from './connection-config.js'
export interface AnswererOptions {
api: RondevuAPI
serviceFqn: string
offerId: string
offerSdp: string
rtcConfig?: RTCConfiguration
config?: Partial<ConnectionConfig>
}
/**
* Answerer connection - processes offers and creates answers
*/
export class AnswererConnection extends RondevuConnection {
private api: RondevuAPI
private serviceFqn: string
private offerId: string
private offerSdp: string
constructor(options: AnswererOptions) {
super(options.rtcConfig, options.config)
this.api = options.api
this.serviceFqn = options.serviceFqn
this.offerId = options.offerId
this.offerSdp = options.offerSdp
}
/**
* Initialize the connection by processing offer and creating answer
*/
async initialize(): Promise<void> {
this.debug('Initializing answerer connection')
// Create peer connection
this.createPeerConnection()
if (!this.pc) throw new Error('Peer connection not created')
// Setup ondatachannel handler BEFORE setting remote description
// This is critical to avoid race conditions
this.pc.ondatachannel = (event) => {
this.debug('Received data channel')
this.dc = event.channel
this.setupDataChannelHandlers(this.dc)
}
// Start connection timeout
this.startConnectionTimeout()
// Set remote description (offer)
await this.pc.setRemoteDescription({
type: 'offer',
sdp: this.offerSdp,
})
this.transitionTo(ConnectionState.SIGNALING, 'Offer received, creating answer')
// Create and set local description (answer)
const answer = await this.pc.createAnswer()
await this.pc.setLocalDescription(answer)
this.debug('Answer created, sending to server')
// Send answer to server
await this.api.answerOffer(this.serviceFqn, this.offerId, answer.sdp!)
this.debug('Answer sent successfully')
}
/**
* Handle local ICE candidate generation
*/
protected onLocalIceCandidate(candidate: RTCIceCandidate): void {
this.debug('Generated local ICE candidate')
// For answerer, we add ICE candidates to the offer
// The server will make them available for the offerer to poll
this.api
.addOfferIceCandidates(this.serviceFqn, this.offerId, [
{
candidate: candidate.candidate,
sdpMLineIndex: candidate.sdpMLineIndex,
sdpMid: candidate.sdpMid,
},
])
.catch((error) => {
this.debug('Failed to send ICE candidate:', error)
})
}
/**
* Poll for remote ICE candidates (from offerer)
*/
protected pollIceCandidates(): void {
this.api
.getOfferIceCandidates(this.serviceFqn, this.offerId, this.lastIcePollTime)
.then((result) => {
if (result.candidates.length > 0) {
this.debug(`Received ${result.candidates.length} remote ICE candidates`)
for (const iceCandidate of result.candidates) {
// Only process ICE candidates from the offerer
if (iceCandidate.role === 'offerer' && iceCandidate.candidate && this.pc) {
const candidate = iceCandidate.candidate
this.pc
.addIceCandidate(new RTCIceCandidate(candidate))
.then(() => {
this.emit('ice:candidate:remote', new RTCIceCandidate(candidate))
})
.catch((error) => {
this.debug('Failed to add ICE candidate:', error)
})
}
// Update last poll time
if (iceCandidate.createdAt > this.lastIcePollTime) {
this.lastIcePollTime = iceCandidate.createdAt
}
}
}
})
.catch((error) => {
this.debug('Failed to poll ICE candidates:', error)
})
}
/**
* Attempt to reconnect
*/
protected attemptReconnect(): void {
this.debug('Attempting to reconnect')
// For answerer, we need to fetch a new offer and create a new answer
// Clean up old connection
if (this.pc) {
this.pc.close()
this.pc = null
}
if (this.dc) {
this.dc.close()
this.dc = null
}
// Fetch new offer from service
this.api
.getService(this.serviceFqn)
.then((service) => {
if (!service || !service.offers || service.offers.length === 0) {
throw new Error('No offers available for reconnection')
}
// Pick a random offer
const offer = service.offers[Math.floor(Math.random() * service.offers.length)]
this.offerId = offer.offerId
this.offerSdp = offer.sdp
// Reinitialize with new offer
return this.initialize()
})
.then(() => {
this.emit('reconnect:success')
})
.catch((error) => {
this.debug('Reconnection failed:', error)
this.emit('reconnect:failed', error as Error)
this.scheduleReconnect()
})
}
/**
* Get the offer ID we're answering
*/
getOfferId(): string {
return this.offerId
}
}

View File

@@ -39,7 +39,6 @@ export interface Service {
export interface IceCandidate {
candidate: RTCIceCandidateInit | null
role: 'offerer' | 'answerer'
createdAt: number
}

View File

@@ -1,64 +0,0 @@
/**
* Connection configuration interfaces and defaults
*/
export interface ConnectionConfig {
// Timeouts
connectionTimeout: number // Maximum time to wait for connection establishment (ms)
iceGatheringTimeout: number // Maximum time to wait for ICE gathering to complete (ms)
// Reconnection
reconnectEnabled: boolean // Enable automatic reconnection on failures
maxReconnectAttempts: number // Maximum number of reconnection attempts (0 = infinite)
reconnectBackoffBase: number // Base delay for exponential backoff (ms)
reconnectBackoffMax: number // Maximum delay between reconnection attempts (ms)
reconnectJitter: number // Jitter factor for backoff (0-1, adds randomness to prevent thundering herd)
// Message buffering
bufferEnabled: boolean // Enable automatic message buffering during disconnections
maxBufferSize: number // Maximum number of messages to buffer
maxBufferAge: number // Maximum age of buffered messages (ms)
preserveBufferOnClose: boolean // Keep buffer on explicit close (vs. clearing it)
// ICE polling
icePollingInterval: number // Interval for polling remote ICE candidates (ms)
icePollingTimeout: number // Maximum time to poll for ICE candidates (ms)
// Debug
debug: boolean // Enable debug logging
}
export const DEFAULT_CONNECTION_CONFIG: ConnectionConfig = {
// Timeouts
connectionTimeout: 30000, // 30 seconds
iceGatheringTimeout: 10000, // 10 seconds
// Reconnection
reconnectEnabled: true,
maxReconnectAttempts: 5, // 5 attempts before giving up
reconnectBackoffBase: 1000, // Start with 1 second
reconnectBackoffMax: 30000, // Cap at 30 seconds
reconnectJitter: 0.1, // 10% jitter
// Message buffering
bufferEnabled: true,
maxBufferSize: 100, // 100 messages
maxBufferAge: 60000, // 1 minute
preserveBufferOnClose: false, // Clear buffer on close
// ICE polling
icePollingInterval: 500, // Poll every 500ms
icePollingTimeout: 30000, // Stop polling after 30s
// Debug
debug: false,
}
export function mergeConnectionConfig(
userConfig?: Partial<ConnectionConfig>
): ConnectionConfig {
return {
...DEFAULT_CONNECTION_CONFIG,
...userConfig,
}
}

View File

@@ -1,102 +0,0 @@
/**
* TypeScript event type definitions for RondevuConnection
*/
export enum ConnectionState {
INITIALIZING = 'initializing', // Creating peer connection
GATHERING = 'gathering', // ICE gathering in progress
SIGNALING = 'signaling', // Exchanging offer/answer
CHECKING = 'checking', // ICE connectivity checks
CONNECTING = 'connecting', // ICE connection attempts
CONNECTED = 'connected', // Data channel open, working
DISCONNECTED = 'disconnected', // Temporarily disconnected
RECONNECTING = 'reconnecting', // Attempting reconnection
FAILED = 'failed', // Connection failed
CLOSED = 'closed', // Connection closed permanently
}
export interface BufferedMessage {
id: string
data: string | ArrayBuffer | Blob
timestamp: number
attempts: number
}
export interface ReconnectInfo {
attempt: number
delay: number
maxAttempts: number
}
export interface StateChangeInfo {
oldState: ConnectionState
newState: ConnectionState
reason?: string
}
/**
* Event map for RondevuConnection
* Maps event names to their payload types
*/
export interface ConnectionEventMap {
// Lifecycle events
'state:changed': [StateChangeInfo]
'connecting': []
'connected': []
'disconnected': [reason?: string]
'failed': [error: Error]
'closed': [reason?: string]
// Reconnection events
'reconnect:scheduled': [ReconnectInfo]
'reconnect:attempting': [attempt: number]
'reconnect:success': []
'reconnect:failed': [error: Error]
'reconnect:exhausted': [attempts: number]
// Message events
'message': [data: string | ArrayBuffer | Blob]
'message:sent': [data: string | ArrayBuffer | Blob, buffered: boolean]
'message:buffered': [data: string | ArrayBuffer | Blob]
'message:replayed': [message: BufferedMessage]
'message:buffer:overflow': [discardedMessage: BufferedMessage]
'message:buffer:expired': [message: BufferedMessage]
// ICE events
'ice:candidate:local': [candidate: RTCIceCandidate | null]
'ice:candidate:remote': [candidate: RTCIceCandidate | null]
'ice:connection:state': [state: RTCIceConnectionState]
'ice:gathering:state': [state: RTCIceGatheringState]
'ice:polling:started': []
'ice:polling:stopped': []
// Answer processing events (offerer only)
'answer:processed': [offerId: string, answererId: string]
'answer:duplicate': [offerId: string]
// Data channel events
'datachannel:open': []
'datachannel:close': []
'datachannel:error': [error: Event]
// Cleanup events
'cleanup:started': []
'cleanup:complete': []
// Connection events (mirrors RTCPeerConnection.connectionState)
'connection:state': [state: RTCPeerConnectionState]
// Timeout events
'connection:timeout': []
'ice:gathering:timeout': []
}
/**
* Helper type to extract event names from the event map
*/
export type ConnectionEventName = keyof ConnectionEventMap
/**
* Helper type to extract event arguments for a specific event
*/
export type ConnectionEventArgs<T extends ConnectionEventName> = ConnectionEventMap[T]

View File

@@ -1,567 +0,0 @@
/**
* Base connection class with state machine, reconnection, and message buffering
*/
import { EventEmitter } from 'eventemitter3'
import { ConnectionConfig, mergeConnectionConfig } from './connection-config.js'
import {
ConnectionState,
ConnectionEventMap,
ConnectionEventName,
ConnectionEventArgs,
BufferedMessage,
} from './connection-events.js'
import { ExponentialBackoff } from './exponential-backoff.js'
import { MessageBuffer } from './message-buffer.js'
/**
* Abstract base class for WebRTC connections with durability features
*/
export abstract class RondevuConnection extends EventEmitter<ConnectionEventMap> {
protected pc: RTCPeerConnection | null = null
protected dc: RTCDataChannel | null = null
protected state: ConnectionState = ConnectionState.INITIALIZING
protected config: ConnectionConfig
// Message buffering
protected messageBuffer: MessageBuffer | null = null
// Reconnection
protected backoff: ExponentialBackoff | null = null
protected reconnectTimeout: ReturnType<typeof setTimeout> | null = null
protected reconnectAttempts = 0
// Timeouts
protected connectionTimeout: ReturnType<typeof setTimeout> | null = null
protected iceGatheringTimeout: ReturnType<typeof setTimeout> | null = null
// ICE polling
protected icePollingInterval: ReturnType<typeof setInterval> | null = null
protected lastIcePollTime = 0
// Answer fingerprinting (for offerer)
protected answerProcessed = false
protected answerSdpFingerprint: string | null = null
constructor(
protected rtcConfig?: RTCConfiguration,
userConfig?: Partial<ConnectionConfig>
) {
super()
this.config = mergeConnectionConfig(userConfig)
// Initialize message buffer if enabled
if (this.config.bufferEnabled) {
this.messageBuffer = new MessageBuffer({
maxSize: this.config.maxBufferSize,
maxAge: this.config.maxBufferAge,
})
}
// Initialize backoff if reconnection enabled
if (this.config.reconnectEnabled) {
this.backoff = new ExponentialBackoff({
base: this.config.reconnectBackoffBase,
max: this.config.reconnectBackoffMax,
jitter: this.config.reconnectJitter,
})
}
}
/**
* Transition to a new state and emit events
*/
protected transitionTo(newState: ConnectionState, reason?: string): void {
if (this.state === newState) return
const oldState = this.state
this.state = newState
this.debug(`State transition: ${oldState}${newState}${reason ? ` (${reason})` : ''}`)
this.emit('state:changed', { oldState, newState, reason })
// Emit specific lifecycle events
switch (newState) {
case ConnectionState.CONNECTING:
this.emit('connecting')
break
case ConnectionState.CONNECTED:
this.emit('connected')
break
case ConnectionState.DISCONNECTED:
this.emit('disconnected', reason)
break
case ConnectionState.FAILED:
this.emit('failed', new Error(reason || 'Connection failed'))
break
case ConnectionState.CLOSED:
this.emit('closed', reason)
break
}
}
/**
* Create and configure RTCPeerConnection
*/
protected createPeerConnection(): RTCPeerConnection {
this.pc = new RTCPeerConnection(this.rtcConfig)
// Setup event handlers BEFORE any signaling
this.pc.onicecandidate = (event) => this.handleIceCandidate(event)
this.pc.oniceconnectionstatechange = () => this.handleIceConnectionStateChange()
this.pc.onconnectionstatechange = () => this.handleConnectionStateChange()
this.pc.onicegatheringstatechange = () => this.handleIceGatheringStateChange()
return this.pc
}
/**
* Setup data channel event handlers
*/
protected setupDataChannelHandlers(dc: RTCDataChannel): void {
dc.onopen = () => this.handleDataChannelOpen()
dc.onclose = () => this.handleDataChannelClose()
dc.onerror = (error) => this.handleDataChannelError(error)
dc.onmessage = (event) => this.handleMessage(event)
}
/**
* Handle local ICE candidate generation
*/
protected handleIceCandidate(event: RTCPeerConnectionIceEvent): void {
this.emit('ice:candidate:local', event.candidate)
if (event.candidate) {
this.onLocalIceCandidate(event.candidate)
}
}
/**
* Handle ICE connection state changes (primary state driver)
*/
protected handleIceConnectionStateChange(): void {
if (!this.pc) return
const iceState = this.pc.iceConnectionState
this.emit('ice:connection:state', iceState)
this.debug(`ICE connection state: ${iceState}`)
switch (iceState) {
case 'checking':
if (this.state === ConnectionState.SIGNALING) {
this.transitionTo(ConnectionState.CHECKING, 'ICE checking started')
}
this.startIcePolling()
break
case 'connected':
case 'completed':
this.stopIcePolling()
// Wait for data channel to open before transitioning to CONNECTED
if (this.dc?.readyState === 'open') {
this.transitionTo(ConnectionState.CONNECTED, 'ICE connected and data channel open')
this.onConnected()
}
break
case 'disconnected':
if (this.state === ConnectionState.CONNECTED) {
this.transitionTo(ConnectionState.DISCONNECTED, 'ICE disconnected')
this.scheduleReconnect()
}
break
case 'failed':
this.stopIcePolling()
this.transitionTo(ConnectionState.FAILED, 'ICE connection failed')
this.scheduleReconnect()
break
case 'closed':
this.stopIcePolling()
this.transitionTo(ConnectionState.CLOSED, 'ICE connection closed')
break
}
}
/**
* Handle connection state changes (backup validation)
*/
protected handleConnectionStateChange(): void {
if (!this.pc) return
const connState = this.pc.connectionState
this.emit('connection:state', connState)
this.debug(`Connection state: ${connState}`)
// Connection state provides backup validation
if (connState === 'failed' && this.state !== ConnectionState.FAILED) {
this.transitionTo(ConnectionState.FAILED, 'PeerConnection failed')
this.scheduleReconnect()
} else if (connState === 'closed' && this.state !== ConnectionState.CLOSED) {
this.transitionTo(ConnectionState.CLOSED, 'PeerConnection closed')
}
}
/**
* Handle ICE gathering state changes
*/
protected handleIceGatheringStateChange(): void {
if (!this.pc) return
const gatheringState = this.pc.iceGatheringState
this.emit('ice:gathering:state', gatheringState)
this.debug(`ICE gathering state: ${gatheringState}`)
if (gatheringState === 'gathering' && this.state === ConnectionState.INITIALIZING) {
this.transitionTo(ConnectionState.GATHERING, 'ICE gathering started')
this.startIceGatheringTimeout()
} else if (gatheringState === 'complete') {
this.clearIceGatheringTimeout()
}
}
/**
* Handle data channel open event
*/
protected handleDataChannelOpen(): void {
this.debug('Data channel opened')
this.emit('datachannel:open')
// Only transition to CONNECTED if ICE is also connected
if (this.pc && (this.pc.iceConnectionState === 'connected' || this.pc.iceConnectionState === 'completed')) {
this.transitionTo(ConnectionState.CONNECTED, 'Data channel opened and ICE connected')
this.onConnected()
}
}
/**
* Handle data channel close event
*/
protected handleDataChannelClose(): void {
this.debug('Data channel closed')
this.emit('datachannel:close')
if (this.state === ConnectionState.CONNECTED) {
this.transitionTo(ConnectionState.DISCONNECTED, 'Data channel closed')
this.scheduleReconnect()
}
}
/**
* Handle data channel error event
*/
protected handleDataChannelError(error: Event): void {
this.debug('Data channel error:', error)
this.emit('datachannel:error', error)
}
/**
* Handle incoming message
*/
protected handleMessage(event: MessageEvent): void {
this.emit('message', event.data)
}
/**
* Called when connection is successfully established
*/
protected onConnected(): void {
this.clearConnectionTimeout()
this.reconnectAttempts = 0
this.backoff?.reset()
// Replay buffered messages
if (this.messageBuffer && !this.messageBuffer.isEmpty()) {
const messages = this.messageBuffer.getValid()
this.debug(`Replaying ${messages.length} buffered messages`)
for (const message of messages) {
try {
this.sendDirect(message.data)
this.emit('message:replayed', message)
this.messageBuffer.remove(message.id)
} catch (error) {
this.debug('Failed to replay message:', error)
}
}
// Remove expired messages
const expired = this.messageBuffer.getExpired()
for (const msg of expired) {
this.emit('message:buffer:expired', msg)
}
}
}
/**
* Start ICE candidate polling
*/
protected startIcePolling(): void {
if (this.icePollingInterval) return
this.debug('Starting ICE polling')
this.emit('ice:polling:started')
this.lastIcePollTime = Date.now()
this.icePollingInterval = setInterval(() => {
const elapsed = Date.now() - this.lastIcePollTime
if (elapsed > this.config.icePollingTimeout) {
this.debug('ICE polling timeout')
this.stopIcePolling()
return
}
this.pollIceCandidates()
}, this.config.icePollingInterval)
}
/**
* Stop ICE candidate polling
*/
protected stopIcePolling(): void {
if (!this.icePollingInterval) return
this.debug('Stopping ICE polling')
clearInterval(this.icePollingInterval)
this.icePollingInterval = null
this.emit('ice:polling:stopped')
}
/**
* Start connection timeout
*/
protected startConnectionTimeout(): void {
this.clearConnectionTimeout()
this.connectionTimeout = setTimeout(() => {
if (this.state !== ConnectionState.CONNECTED) {
this.debug('Connection timeout')
this.emit('connection:timeout')
this.transitionTo(ConnectionState.FAILED, 'Connection timeout')
this.scheduleReconnect()
}
}, this.config.connectionTimeout)
}
/**
* Clear connection timeout
*/
protected clearConnectionTimeout(): void {
if (this.connectionTimeout) {
clearTimeout(this.connectionTimeout)
this.connectionTimeout = null
}
}
/**
* Start ICE gathering timeout
*/
protected startIceGatheringTimeout(): void {
this.clearIceGatheringTimeout()
this.iceGatheringTimeout = setTimeout(() => {
if (this.pc && this.pc.iceGatheringState !== 'complete') {
this.debug('ICE gathering timeout')
this.emit('ice:gathering:timeout')
}
}, this.config.iceGatheringTimeout)
}
/**
* Clear ICE gathering timeout
*/
protected clearIceGatheringTimeout(): void {
if (this.iceGatheringTimeout) {
clearTimeout(this.iceGatheringTimeout)
this.iceGatheringTimeout = null
}
}
/**
* Schedule reconnection attempt
*/
protected scheduleReconnect(): void {
if (!this.config.reconnectEnabled || !this.backoff) return
// Check if we've exceeded max attempts
if (this.config.maxReconnectAttempts > 0 && this.reconnectAttempts >= this.config.maxReconnectAttempts) {
this.debug('Max reconnection attempts reached')
this.emit('reconnect:exhausted', this.reconnectAttempts)
return
}
const delay = this.backoff.next()
this.reconnectAttempts++
this.debug(`Scheduling reconnection attempt ${this.reconnectAttempts} in ${delay}ms`)
this.emit('reconnect:scheduled', {
attempt: this.reconnectAttempts,
delay,
maxAttempts: this.config.maxReconnectAttempts,
})
this.transitionTo(ConnectionState.RECONNECTING, `Attempt ${this.reconnectAttempts}`)
this.reconnectTimeout = setTimeout(() => {
this.emit('reconnect:attempting', this.reconnectAttempts)
this.attemptReconnect()
}, delay)
}
/**
* Cancel scheduled reconnection
*/
protected cancelReconnect(): void {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
}
/**
* Send a message directly (bypasses buffer)
*/
protected sendDirect(data: string | ArrayBuffer | Blob): void {
if (!this.dc || this.dc.readyState !== 'open') {
throw new Error('Data channel is not open')
}
// Handle different data types explicitly
this.dc.send(data as any)
}
/**
* Send a message with automatic buffering
*/
send(data: string | ArrayBuffer | Blob): void {
if (this.state === ConnectionState.CONNECTED && this.dc?.readyState === 'open') {
// Send directly
try {
this.sendDirect(data)
this.emit('message:sent', data, false)
} catch (error) {
this.debug('Failed to send message:', error)
this.bufferMessage(data)
}
} else {
// Buffer for later
this.bufferMessage(data)
}
}
/**
* Buffer a message for later delivery
*/
protected bufferMessage(data: string | ArrayBuffer | Blob): void {
if (!this.messageBuffer) {
this.debug('Message buffering disabled, message dropped')
return
}
if (this.messageBuffer.isFull()) {
const oldest = this.messageBuffer.getAll()[0]
this.emit('message:buffer:overflow', oldest)
}
const message = this.messageBuffer.add(data)
this.emit('message:buffered', data)
this.emit('message:sent', data, true)
this.debug(`Message buffered (${this.messageBuffer.size()}/${this.config.maxBufferSize})`)
}
/**
* Get current connection state
*/
getState(): ConnectionState {
return this.state
}
/**
* Get the data channel
*/
getDataChannel(): RTCDataChannel | null {
return this.dc
}
/**
* Get the peer connection
*/
getPeerConnection(): RTCPeerConnection | null {
return this.pc
}
/**
* Close the connection
*/
close(): void {
this.debug('Closing connection')
this.transitionTo(ConnectionState.CLOSED, 'User requested close')
this.cleanup()
}
/**
* Complete cleanup of all resources
*/
protected cleanup(): void {
this.debug('Cleaning up connection')
this.emit('cleanup:started')
// Clear all timeouts
this.clearConnectionTimeout()
this.clearIceGatheringTimeout()
this.cancelReconnect()
// Stop ICE polling
this.stopIcePolling()
// Close data channel
if (this.dc) {
this.dc.onopen = null
this.dc.onclose = null
this.dc.onerror = null
this.dc.onmessage = null
if (this.dc.readyState !== 'closed') {
this.dc.close()
}
this.dc = null
}
// Close peer connection
if (this.pc) {
this.pc.onicecandidate = null
this.pc.oniceconnectionstatechange = null
this.pc.onconnectionstatechange = null
this.pc.onicegatheringstatechange = null
if (this.pc.connectionState !== 'closed') {
this.pc.close()
}
this.pc = null
}
// Clear message buffer if not preserving
if (this.messageBuffer && !this.config.preserveBufferOnClose) {
this.messageBuffer.clear()
}
this.emit('cleanup:complete')
}
/**
* Debug logging helper
*/
protected debug(...args: any[]): void {
if (this.config.debug) {
console.log('[RondevuConnection]', ...args)
}
}
// Abstract methods to be implemented by subclasses
protected abstract onLocalIceCandidate(candidate: RTCIceCandidate): void
protected abstract pollIceCandidates(): void
protected abstract attemptReconnect(): void
}

View File

@@ -1,59 +0,0 @@
/**
* Exponential backoff utility for connection reconnection
*/
export interface BackoffConfig {
base: number // Base delay in milliseconds
max: number // Maximum delay in milliseconds
jitter: number // Jitter factor (0-1) to add randomness
}
export class ExponentialBackoff {
private attempt: number = 0
constructor(private config: BackoffConfig) {
if (config.jitter < 0 || config.jitter > 1) {
throw new Error('Jitter must be between 0 and 1')
}
}
/**
* Calculate the next delay based on the current attempt number
* Formula: min(base * 2^attempt, max) with jitter
*/
next(): number {
const exponentialDelay = this.config.base * Math.pow(2, this.attempt)
const cappedDelay = Math.min(exponentialDelay, this.config.max)
// Add jitter: delay ± (jitter * delay)
const jitterAmount = cappedDelay * this.config.jitter
const jitter = (Math.random() * 2 - 1) * jitterAmount // Random value between -jitterAmount and +jitterAmount
const finalDelay = Math.max(0, cappedDelay + jitter)
this.attempt++
return Math.round(finalDelay)
}
/**
* Get the current attempt number
*/
getAttempt(): number {
return this.attempt
}
/**
* Reset the backoff state
*/
reset(): void {
this.attempt = 0
}
/**
* Peek at what the next delay would be without incrementing
*/
peek(): number {
const exponentialDelay = this.config.base * Math.pow(2, this.attempt)
const cappedDelay = Math.min(exponentialDelay, this.config.max)
return cappedDelay
}
}

View File

@@ -3,19 +3,10 @@
* WebRTC peer signaling client
*/
export { Rondevu, RondevuError, NetworkError, ValidationError, ConnectionError } from './rondevu.js'
export { Rondevu } from './rondevu.js'
export { RondevuAPI } from './api.js'
export { RpcBatcher } from './rpc-batcher.js'
// Export connection classes
export { RondevuConnection } from './connection.js'
export { OffererConnection } from './offerer-connection.js'
export { AnswererConnection } from './answerer-connection.js'
// Export utilities
export { ExponentialBackoff } from './exponential-backoff.js'
export { MessageBuffer } from './message-buffer.js'
// Export crypto adapters
export { WebCryptoAdapter } from './web-crypto-adapter.js'
export { NodeCryptoAdapter } from './node-crypto-adapter.js'
@@ -41,35 +32,8 @@ export type {
ConnectToServiceOptions,
ConnectionContext,
OfferContext,
OfferFactory,
ActiveOffer,
FindServiceOptions,
ServiceResult,
PaginatedServiceResult
OfferFactory
} from './rondevu.js'
export type { CryptoAdapter } from './crypto-adapter.js'
// Export connection types
export type {
ConnectionConfig,
} from './connection-config.js'
export type {
ConnectionState,
BufferedMessage,
ReconnectInfo,
StateChangeInfo,
ConnectionEventMap,
ConnectionEventName,
ConnectionEventArgs,
} from './connection-events.js'
export type {
OffererOptions,
} from './offerer-connection.js'
export type {
AnswererOptions,
} from './answerer-connection.js'

View File

@@ -1,125 +0,0 @@
/**
* Message buffering system for storing messages during disconnections
*/
import { BufferedMessage } from './connection-events.js'
export interface MessageBufferConfig {
maxSize: number // Maximum number of messages to buffer
maxAge: number // Maximum age of messages in milliseconds
}
export class MessageBuffer {
private buffer: BufferedMessage[] = []
private messageIdCounter = 0
constructor(private config: MessageBufferConfig) {}
/**
* Add a message to the buffer
* Returns the buffered message with metadata
*/
add(data: string | ArrayBuffer | Blob): BufferedMessage {
const message: BufferedMessage = {
id: `msg_${Date.now()}_${this.messageIdCounter++}`,
data,
timestamp: Date.now(),
attempts: 0,
}
// Check if buffer is full
if (this.buffer.length >= this.config.maxSize) {
// Remove oldest message
const discarded = this.buffer.shift()
if (discarded) {
return message // Signal overflow by returning the new message
}
}
this.buffer.push(message)
return message
}
/**
* Get all messages in the buffer
*/
getAll(): BufferedMessage[] {
return [...this.buffer]
}
/**
* Get messages that haven't exceeded max age
*/
getValid(): BufferedMessage[] {
const now = Date.now()
return this.buffer.filter((msg) => now - msg.timestamp < this.config.maxAge)
}
/**
* Get and remove expired messages
*/
getExpired(): BufferedMessage[] {
const now = Date.now()
const expired: BufferedMessage[] = []
this.buffer = this.buffer.filter((msg) => {
if (now - msg.timestamp >= this.config.maxAge) {
expired.push(msg)
return false
}
return true
})
return expired
}
/**
* Remove a specific message by ID
*/
remove(messageId: string): BufferedMessage | null {
const index = this.buffer.findIndex((msg) => msg.id === messageId)
if (index === -1) return null
const [removed] = this.buffer.splice(index, 1)
return removed
}
/**
* Clear all messages from the buffer
*/
clear(): BufferedMessage[] {
const cleared = [...this.buffer]
this.buffer = []
return cleared
}
/**
* Increment attempt count for a message
*/
incrementAttempt(messageId: string): boolean {
const message = this.buffer.find((msg) => msg.id === messageId)
if (!message) return false
message.attempts++
return true
}
/**
* Get the current size of the buffer
*/
size(): number {
return this.buffer.length
}
/**
* Check if buffer is empty
*/
isEmpty(): boolean {
return this.buffer.length === 0
}
/**
* Check if buffer is full
*/
isFull(): boolean {
return this.buffer.length >= this.config.maxSize
}
}

View File

@@ -81,11 +81,13 @@ export class NodeCryptoAdapter implements CryptoAdapter {
bytesToBase64(bytes: Uint8Array): string {
// Node.js Buffer provides native base64 encoding
// @ts-expect-error - Buffer is available in Node.js but not in browser TypeScript definitions
return Buffer.from(bytes).toString('base64')
}
base64ToBytes(base64: string): Uint8Array {
// Node.js Buffer provides native base64 decoding
// @ts-expect-error - Buffer is available in Node.js but not in browser TypeScript definitions
return new Uint8Array(Buffer.from(base64, 'base64'))
}

View File

@@ -1,213 +0,0 @@
/**
* Offerer-side WebRTC connection with offer creation and answer processing
*/
import { RondevuConnection } from './connection.js'
import { ConnectionState } from './connection-events.js'
import { RondevuAPI } from './api.js'
import { ConnectionConfig } from './connection-config.js'
export interface OffererOptions {
api: RondevuAPI
serviceFqn: string
offerId: string
pc: RTCPeerConnection // Accept already-created peer connection
dc?: RTCDataChannel // Accept already-created data channel (optional)
config?: Partial<ConnectionConfig>
}
/**
* Offerer connection - manages already-created offers and waits for answers
*/
export class OffererConnection extends RondevuConnection {
private api: RondevuAPI
private serviceFqn: string
private offerId: string
constructor(options: OffererOptions) {
super(undefined, options.config) // rtcConfig not needed, PC already created
this.api = options.api
this.serviceFqn = options.serviceFqn
this.offerId = options.offerId
// Use the already-created peer connection and data channel
this.pc = options.pc
this.dc = options.dc || null
}
/**
* Initialize the connection - setup handlers for already-created offer
*/
async initialize(): Promise<void> {
this.debug('Initializing offerer connection')
if (!this.pc) throw new Error('Peer connection not provided')
// Setup peer connection event handlers
this.pc.onicecandidate = (event) => this.handleIceCandidate(event)
this.pc.oniceconnectionstatechange = () => this.handleIceConnectionStateChange()
this.pc.onconnectionstatechange = () => this.handleConnectionStateChange()
this.pc.onicegatheringstatechange = () => this.handleIceGatheringStateChange()
// Setup data channel handlers if we have one
if (this.dc) {
this.setupDataChannelHandlers(this.dc)
}
// Start connection timeout
this.startConnectionTimeout()
// Transition to signaling state (offer already created and published)
this.transitionTo(ConnectionState.SIGNALING, 'Offer published, waiting for answer')
}
/**
* Process an answer from the answerer
*/
async processAnswer(sdp: string, answererId: string): Promise<void> {
if (!this.pc) {
this.debug('Cannot process answer: peer connection not initialized')
return
}
// Generate SDP fingerprint for deduplication
const fingerprint = await this.hashSdp(sdp)
// Check for duplicate answer
if (this.answerProcessed) {
if (this.answerSdpFingerprint === fingerprint) {
this.debug('Duplicate answer detected (same fingerprint), skipping')
this.emit('answer:duplicate', this.offerId)
return
} else {
throw new Error('Received different answer after already processing one (protocol violation)')
}
}
// Validate state
if (this.state !== ConnectionState.SIGNALING && this.state !== ConnectionState.CHECKING) {
this.debug(`Cannot process answer in state ${this.state}`)
return
}
// Mark as processed BEFORE setRemoteDescription to prevent race conditions
this.answerProcessed = true
this.answerSdpFingerprint = fingerprint
try {
await this.pc.setRemoteDescription({
type: 'answer',
sdp,
})
this.debug(`Answer processed successfully from ${answererId}`)
this.emit('answer:processed', this.offerId, answererId)
} catch (error) {
// Reset flags on error so we can try again
this.answerProcessed = false
this.answerSdpFingerprint = null
this.debug('Failed to set remote description:', error)
throw error
}
}
/**
* Generate a hash fingerprint of SDP for deduplication
*/
private async hashSdp(sdp: string): Promise<string> {
// Simple hash using built-in crypto if available
if (typeof crypto !== 'undefined' && crypto.subtle) {
const encoder = new TextEncoder()
const data = encoder.encode(sdp)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
} else {
// Fallback: use simple string hash
let hash = 0
for (let i = 0; i < sdp.length; i++) {
const char = sdp.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash
}
return hash.toString(16)
}
}
/**
* Handle local ICE candidate generation
*/
protected onLocalIceCandidate(candidate: RTCIceCandidate): void {
this.debug('Generated local ICE candidate')
// Send ICE candidate to server
this.api
.addOfferIceCandidates(this.serviceFqn, this.offerId, [
{
candidate: candidate.candidate,
sdpMLineIndex: candidate.sdpMLineIndex,
sdpMid: candidate.sdpMid,
},
])
.catch((error) => {
this.debug('Failed to send ICE candidate:', error)
})
}
/**
* Poll for remote ICE candidates
*/
protected pollIceCandidates(): void {
this.api
.getOfferIceCandidates(this.serviceFqn, this.offerId, this.lastIcePollTime)
.then((result) => {
if (result.candidates.length > 0) {
this.debug(`Received ${result.candidates.length} remote ICE candidates`)
for (const iceCandidate of result.candidates) {
if (iceCandidate.candidate && this.pc) {
const candidate = iceCandidate.candidate
this.pc
.addIceCandidate(new RTCIceCandidate(candidate))
.then(() => {
this.emit('ice:candidate:remote', new RTCIceCandidate(candidate))
})
.catch((error) => {
this.debug('Failed to add ICE candidate:', error)
})
}
// Update last poll time
if (iceCandidate.createdAt > this.lastIcePollTime) {
this.lastIcePollTime = iceCandidate.createdAt
}
}
}
})
.catch((error) => {
this.debug('Failed to poll ICE candidates:', error)
})
}
/**
* Attempt to reconnect
*
* Note: For offerer connections, reconnection is handled by the Rondevu instance
* creating a new offer via fillOffers(). This method is a no-op.
*/
protected attemptReconnect(): void {
this.debug('Reconnection not applicable for offerer - new offer will be created by Rondevu instance')
// Offerer reconnection is handled externally by Rondevu.fillOffers()
// which creates entirely new offers. We don't reconnect the same offer.
// Just emit failure and let the parent handle it.
this.emit('reconnect:failed', new Error('Offerer reconnection handled by parent'))
}
/**
* Get the offer ID
*/
getOfferId(): string {
return this.offerId
}
}

View File

@@ -1,9 +1,5 @@
import { RondevuAPI, Keypair, IceCandidate, BatcherOptions } from './api.js'
import { CryptoAdapter } from './crypto-adapter.js'
import { EventEmitter } from 'eventemitter3'
import { OffererConnection } from './offerer-connection.js'
import { AnswererConnection } from './answerer-connection.js'
import { ConnectionConfig } from './connection-config.js'
// ICE server preset names
export type IceServerPreset = 'ipv4-turn' | 'hostname-turns' | 'google-stun' | 'relay-only'
@@ -67,26 +63,18 @@ export interface RondevuOptions {
}
export interface OfferContext {
pc: RTCPeerConnection
dc?: RTCDataChannel
offer: RTCSessionDescriptionInit
}
/**
* Factory function for creating WebRTC offers.
* Rondevu creates the RTCPeerConnection and passes it to the factory,
* allowing ICE candidate handlers to be set up before setLocalDescription() is called.
*
* @param pc - The RTCPeerConnection created by Rondevu (already configured with ICE servers)
* @returns Promise containing the data channel (optional) and offer SDP
*/
export type OfferFactory = (pc: RTCPeerConnection) => Promise<OfferContext>
export type OfferFactory = (rtcConfig: RTCConfiguration) => Promise<OfferContext>
export interface PublishServiceOptions {
service: string // Service name and version (e.g., "chat:2.0.0") - username will be auto-appended
maxOffers: number // Maximum number of concurrent offers to maintain
offerFactory?: OfferFactory // Optional: custom offer creation (defaults to simple data channel)
ttl?: number // Time-to-live for offers in milliseconds (default: 300000)
connectionConfig?: Partial<ConnectionConfig> // Optional: connection durability configuration
}
export interface ConnectionContext {
@@ -101,11 +89,11 @@ export interface ConnectToServiceOptions {
serviceFqn?: string // Full FQN like 'chat:2.0.0@alice'
service?: string // Service without username (for discovery)
username?: string // Target username (combined with service)
onConnection?: (context: ConnectionContext) => void | Promise<void> // Called when data channel opens
rtcConfig?: RTCConfiguration // Optional: override default ICE servers
connectionConfig?: Partial<ConnectionConfig> // Optional: connection durability configuration
}
export interface ActiveOffer {
interface ActiveOffer {
offerId: string
serviceFqn: string
pc: RTCPeerConnection
@@ -114,81 +102,15 @@ export interface ActiveOffer {
createdAt: number
}
export interface FindServiceOptions {
mode?: 'direct' | 'random' | 'paginated' // Default: 'direct' if serviceFqn has username, 'random' otherwise
limit?: number // For paginated mode (default: 10)
offset?: number // For paginated mode (default: 0)
}
export interface ServiceResult {
serviceId: string
username: string
serviceFqn: string
offerId: string
sdp: string
createdAt: number
expiresAt: number
}
export interface PaginatedServiceResult {
services: ServiceResult[]
count: number
limit: number
offset: number
}
/**
* Base error class for Rondevu errors
*/
export class RondevuError extends Error {
constructor(message: string, public context?: Record<string, any>) {
super(message)
this.name = 'RondevuError'
Object.setPrototypeOf(this, RondevuError.prototype)
}
}
/**
* Network-related errors (API calls, connectivity)
*/
export class NetworkError extends RondevuError {
constructor(message: string, context?: Record<string, any>) {
super(message, context)
this.name = 'NetworkError'
Object.setPrototypeOf(this, NetworkError.prototype)
}
}
/**
* Validation errors (invalid input, malformed data)
*/
export class ValidationError extends RondevuError {
constructor(message: string, context?: Record<string, any>) {
super(message, context)
this.name = 'ValidationError'
Object.setPrototypeOf(this, ValidationError.prototype)
}
}
/**
* WebRTC connection errors (peer connection failures, ICE issues)
*/
export class ConnectionError extends RondevuError {
constructor(message: string, context?: Record<string, any>) {
super(message, context)
this.name = 'ConnectionError'
Object.setPrototypeOf(this, ConnectionError.prototype)
}
}
/**
* Rondevu - Complete WebRTC signaling client with durable connections
* Rondevu - Complete WebRTC signaling client
*
* v1.0.0 introduces breaking changes:
* - connectToService() now returns AnswererConnection instead of ConnectionContext
* - Automatic reconnection and message buffering built-in
* - Connection objects expose .send() method instead of raw DataChannel
* - Rich event system for connection lifecycle (connected, disconnected, reconnecting, etc.)
* Provides a unified API for:
* - Implicit username claiming (auto-claimed on first authenticated request)
* - Service publishing with automatic signature generation
* - Service discovery (direct, random, paginated)
* - WebRTC signaling (offer/answer exchange, ICE relay)
* - Keypair management
*
* @example
* ```typescript
@@ -199,42 +121,42 @@ export class ConnectionError extends RondevuError {
* iceServers: 'ipv4-turn' // Use preset: 'ipv4-turn', 'hostname-turns', 'google-stun', or 'relay-only'
* })
*
* // Or use custom ICE servers
* const rondevu2 = await Rondevu.connect({
* apiUrl: 'https://signal.example.com',
* username: 'bob',
* iceServers: [
* { urls: 'stun:stun.l.google.com:19302' },
* { urls: 'turn:turn.example.com:3478', username: 'user', credential: 'pass' }
* ]
* })
*
* // Publish a service with automatic offer management
* await rondevu.publishService({
* service: 'chat:2.0.0',
* maxOffers: 5 // Maintain up to 5 concurrent offers
* maxOffers: 5, // Maintain up to 5 concurrent offers
* offerFactory: async (rtcConfig) => {
* const pc = new RTCPeerConnection(rtcConfig)
* const dc = pc.createDataChannel('chat')
* const offer = await pc.createOffer()
* await pc.setLocalDescription(offer)
* return { pc, dc, offer }
* }
* })
*
* // Start accepting connections (auto-fills offers and polls)
* await rondevu.startFilling()
*
* // Listen for connections (v1.0.0 API)
* rondevu.on('connection:opened', (offerId, connection) => {
* connection.on('connected', () => console.log('Connected!'))
* connection.on('message', (data) => console.log('Received:', data))
* connection.send('Hello!')
* })
* // Access active connections
* for (const offer of rondevu.getActiveOffers()) {
* offer.dc?.addEventListener('message', (e) => console.log(e.data))
* }
*
* // Connect to a service (v1.0.0 - returns AnswererConnection)
* const connection = await rondevu.connectToService({
* serviceFqn: 'chat:2.0.0@bob'
* })
*
* connection.on('connected', () => {
* console.log('Connected!')
* connection.send('Hello!')
* })
*
* connection.on('message', (data) => {
* console.log('Received:', data)
* })
*
* connection.on('reconnecting', (attempt) => {
* console.log(`Reconnecting, attempt ${attempt}`)
* })
* // Stop when done
* rondevu.stopFilling()
* ```
*/
export class Rondevu extends EventEmitter {
export class Rondevu {
// Constants
private static readonly DEFAULT_TTL_MS = 300000 // 5 minutes
private static readonly POLLING_INTERVAL_MS = 1000 // 1 second
@@ -256,12 +178,10 @@ export class Rondevu extends EventEmitter {
private maxOffers = 0
private offerFactory: OfferFactory | null = null
private ttl = Rondevu.DEFAULT_TTL_MS
private activeConnections = new Map<string, OffererConnection>()
private connectionConfig?: Partial<ConnectionConfig>
private activeOffers = new Map<string, ActiveOffer>()
// Polling
private filling = false
private fillingSemaphore = false // Semaphore to prevent concurrent fillOffers calls
private pollingInterval: ReturnType<typeof setInterval> | null = null
private lastPollTimestamp = 0
@@ -277,7 +197,6 @@ export class Rondevu extends EventEmitter {
rtcPeerConnection?: typeof RTCPeerConnection,
rtcIceCandidate?: typeof RTCIceCandidate
) {
super()
this.apiUrl = apiUrl
this.username = username
this.keypair = keypair
@@ -418,15 +337,15 @@ export class Rondevu extends EventEmitter {
/**
* Default offer factory - creates a simple data channel connection
* The RTCPeerConnection is created by Rondevu and passed in
*/
private async defaultOfferFactory(pc: RTCPeerConnection): Promise<OfferContext> {
private async defaultOfferFactory(rtcConfig: RTCConfiguration): Promise<OfferContext> {
const pc = new RTCPeerConnection(rtcConfig)
const dc = pc.createDataChannel('default')
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
return { dc, offer }
return { pc, dc, offer }
}
/**
@@ -437,30 +356,55 @@ export class Rondevu extends EventEmitter {
* ```typescript
* await rondevu.publishService({
* service: 'chat:2.0.0',
* maxOffers: 5,
* connectionConfig: {
* reconnectEnabled: true,
* bufferEnabled: true
* }
* maxOffers: 5
* })
* await rondevu.startFilling()
* ```
*/
async publishService(options: PublishServiceOptions): Promise<void> {
const { service, maxOffers, offerFactory, ttl, connectionConfig } = options
const { service, maxOffers, offerFactory, ttl } = options
this.currentService = service
this.maxOffers = maxOffers
this.offerFactory = offerFactory || this.defaultOfferFactory.bind(this)
this.ttl = ttl || Rondevu.DEFAULT_TTL_MS
this.connectionConfig = connectionConfig
this.debug(`Publishing service: ${service} with maxOffers: ${maxOffers}`)
this.usernameClaimed = true
}
/**
* Create a single offer and publish it to the server using OffererConnection
* Set up ICE candidate handler to send candidates to the server
*/
private setupIceCandidateHandler(
pc: RTCPeerConnection,
serviceFqn: string,
offerId: string
): void {
pc.onicecandidate = async (event) => {
if (event.candidate) {
try {
// Handle both browser and Node.js (wrtc) environments
// Browser: candidate.toJSON() exists
// Node.js wrtc: candidate is already a plain object
const candidateData = typeof event.candidate.toJSON === 'function'
? event.candidate.toJSON()
: event.candidate
await this.api.addOfferIceCandidates(
serviceFqn,
offerId,
[candidateData]
)
} catch (err) {
console.error('[Rondevu] Failed to send ICE candidate:', err)
}
}
}
}
/**
* Create a single offer and publish it to the server
*/
private async createOffer(): Promise<void> {
if (!this.currentService || !this.offerFactory) {
@@ -471,27 +415,46 @@ export class Rondevu extends EventEmitter {
iceServers: this.iceServers
}
this.debug('Creating new offer...')
// Create the offer using the factory
// Note: The factory may call setLocalDescription() which triggers ICE gathering
const { pc, dc, offer } = await this.offerFactory(rtcConfig)
// Auto-append username to service
const serviceFqn = `${this.currentService}@${this.username}`
this.debug('Creating new offer...')
// Queue to buffer ICE candidates generated before we have the offerId
// This fixes the race condition where ICE candidates are lost because
// they're generated before we can set up the handler with the offerId
const earlyIceCandidates: RTCIceCandidateInit[] = []
let offerId: string | null = null
// 1. Create RTCPeerConnection using factory (for now, keep compatibility)
const pc = new RTCPeerConnection(rtcConfig)
// Set up a queuing ICE candidate handler immediately after getting the pc
// This captures any candidates that fire before we have the offerId
pc.onicecandidate = async (event) => {
if (event.candidate) {
// Handle both browser and Node.js (wrtc) environments
const candidateData = typeof event.candidate.toJSON === 'function'
? event.candidate.toJSON()
: event.candidate
// 2. Call the factory to create offer
let dc: RTCDataChannel | undefined
let offer: RTCSessionDescriptionInit
try {
const factoryResult = await this.offerFactory(pc)
dc = factoryResult.dc
offer = factoryResult.offer
} catch (err) {
pc.close()
throw err
if (offerId) {
// We have the offerId, send directly
try {
await this.api.addOfferIceCandidates(serviceFqn, offerId, [candidateData])
} catch (err) {
console.error('[Rondevu] Failed to send ICE candidate:', err)
}
} else {
// Queue for later - we don't have the offerId yet
this.debug('Queuing early ICE candidate')
earlyIceCandidates.push(candidateData)
}
}
}
// 3. Publish to server to get offerId
// Publish to server
const result = await this.api.publishService({
serviceFqn,
offers: [{ sdp: offer.sdp! }],
@@ -500,77 +463,58 @@ export class Rondevu extends EventEmitter {
message: '',
})
const offerId = result.offers[0].offerId
offerId = result.offers[0].offerId
// 4. Create OffererConnection instance with already-created PC and DC
const connection = new OffererConnection({
api: this.api,
serviceFqn,
// Store active offer
this.activeOffers.set(offerId, {
offerId,
pc, // Pass the peer connection from factory
dc, // Pass the data channel from factory
config: {
...this.connectionConfig,
debug: this.debugEnabled,
},
serviceFqn,
pc,
dc,
answered: false,
createdAt: Date.now()
})
// Setup connection event handlers
connection.on('connected', () => {
this.debug(`Connection established for offer ${offerId}`)
this.emit('connection:opened', offerId, connection)
})
connection.on('failed', (error) => {
this.debug(`Connection failed for offer ${offerId}:`, error)
this.activeConnections.delete(offerId)
this.fillOffers() // Replace failed offer
})
connection.on('closed', () => {
this.debug(`Connection closed for offer ${offerId}`)
this.activeConnections.delete(offerId)
this.fillOffers() // Replace closed offer
})
// Store active connection
this.activeConnections.set(offerId, connection)
// Initialize the connection
await connection.initialize()
this.debug(`Offer created: ${offerId}`)
this.emit('offer:created', offerId, serviceFqn)
// Send any queued early ICE candidates
if (earlyIceCandidates.length > 0) {
this.debug(`Sending ${earlyIceCandidates.length} early ICE candidates`)
try {
await this.api.addOfferIceCandidates(serviceFqn, offerId, earlyIceCandidates)
} catch (err) {
console.error('[Rondevu] Failed to send early ICE candidates:', err)
}
}
// Monitor connection state
pc.onconnectionstatechange = () => {
this.debug(`Offer ${offerId} connection state: ${pc.connectionState}`)
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
this.activeOffers.delete(offerId!)
this.fillOffers() // Try to replace failed offer
}
}
}
/**
* Fill offers to reach maxOffers count with semaphore protection
* Fill offers to reach maxOffers count
*/
private async fillOffers(): Promise<void> {
if (!this.filling || !this.currentService) return
// Semaphore to prevent concurrent fills
if (this.fillingSemaphore) {
this.debug('fillOffers already in progress, skipping')
return
}
const currentCount = this.activeOffers.size
const needed = this.maxOffers - currentCount
this.fillingSemaphore = true
try {
const currentCount = this.activeConnections.size
const needed = this.maxOffers - currentCount
this.debug(`Filling offers: current=${currentCount}, needed=${needed}`)
this.debug(`Filling offers: current=${currentCount}, needed=${needed}`)
for (let i = 0; i < needed; i++) {
try {
await this.createOffer()
} catch (err) {
console.error('[Rondevu] Failed to create offer:', err)
}
for (let i = 0; i < needed; i++) {
try {
await this.createOffer()
} catch (err) {
console.error('[Rondevu] Failed to create offer:', err)
}
} finally {
this.fillingSemaphore = false
}
}
@@ -583,18 +527,36 @@ export class Rondevu extends EventEmitter {
try {
const result = await this.api.poll(this.lastPollTimestamp)
// Process answers - delegate to OffererConnections
// Process answers
for (const answer of result.answers) {
const connection = this.activeConnections.get(answer.offerId)
if (connection) {
try {
await connection.processAnswer(answer.sdp, answer.answererId)
this.lastPollTimestamp = Math.max(this.lastPollTimestamp, answer.answeredAt)
const activeOffer = this.activeOffers.get(answer.offerId)
if (activeOffer && !activeOffer.answered) {
this.debug(`Received answer for offer ${answer.offerId}`)
// Create replacement offer
this.fillOffers()
} catch (err) {
this.debug(`Failed to process answer for offer ${answer.offerId}:`, err)
await activeOffer.pc.setRemoteDescription({
type: 'answer',
sdp: answer.sdp
})
activeOffer.answered = true
this.lastPollTimestamp = answer.answeredAt
// Create replacement offer
this.fillOffers()
}
}
// Process ICE candidates
for (const [offerId, candidates] of Object.entries(result.iceCandidates)) {
const activeOffer = this.activeOffers.get(offerId)
if (activeOffer) {
const answererCandidates = candidates.filter(c => c.role === 'answerer')
for (const item of answererCandidates) {
if (item.candidate) {
await activeOffer.pc.addIceCandidate(new RTCIceCandidate(item.candidate))
this.lastPollTimestamp = Math.max(this.lastPollTimestamp, item.createdAt)
}
}
}
}
@@ -636,7 +598,6 @@ export class Rondevu extends EventEmitter {
stopFilling(): void {
this.debug('Stopping offer filling and polling')
this.filling = false
this.fillingSemaphore = false
// Stop polling
if (this.pollingInterval) {
@@ -645,56 +606,13 @@ export class Rondevu extends EventEmitter {
}
// Close all active connections
for (const [offerId, connection] of this.activeConnections.entries()) {
this.debug(`Closing connection ${offerId}`)
connection.close()
for (const [offerId, offer] of this.activeOffers.entries()) {
this.debug(`Closing offer ${offerId}`)
offer.dc?.close()
offer.pc.close()
}
this.activeConnections.clear()
}
/**
* Get the count of active offers
* @returns Number of active offers
*/
getOfferCount(): number {
return this.activeConnections.size
}
/**
* Check if an offer is currently connected
* @param offerId - The offer ID to check
* @returns True if the offer exists and is connected
*/
isConnected(offerId: string): boolean {
const connection = this.activeConnections.get(offerId)
return connection ? connection.getState() === 'connected' : false
}
/**
* Disconnect all active offers
* Similar to stopFilling() but doesn't stop the polling/filling process
*/
async disconnectAll(): Promise<void> {
this.debug('Disconnecting all offers')
for (const [offerId, connection] of this.activeConnections.entries()) {
this.debug(`Closing connection ${offerId}`)
connection.close()
}
this.activeConnections.clear()
}
/**
* Get the current service status
* @returns Object with service state information
*/
getServiceStatus(): { active: boolean; offerCount: number; maxOffers: number; filling: boolean } {
return {
active: this.currentService !== null,
offerCount: this.activeConnections.size,
maxOffers: this.maxOffers,
filling: this.filling
}
this.activeOffers.clear()
}
/**
@@ -711,7 +629,7 @@ export class Rondevu extends EventEmitter {
} else if (service) {
// Discovery mode - get random service
this.debug(`Discovering service: ${service}`)
const discovered = await this.findService(service) as ServiceResult
const discovered = await this.discoverService(service)
return discovered.serviceFqn
} else {
throw new Error('Either serviceFqn or service must be provided')
@@ -719,43 +637,62 @@ export class Rondevu extends EventEmitter {
}
/**
* Connect to a service (answerer side) - v1.0.0 API
* Returns an AnswererConnection with automatic reconnection and buffering
*
* BREAKING CHANGE: This now returns AnswererConnection instead of ConnectionContext
* Start polling for remote ICE candidates
* Returns the polling interval ID
*/
private startIcePolling(
pc: RTCPeerConnection,
serviceFqn: string,
offerId: string
): ReturnType<typeof setInterval> {
let lastIceTimestamp = 0
return setInterval(async () => {
try {
const result = await this.api.getOfferIceCandidates(
serviceFqn,
offerId,
lastIceTimestamp
)
for (const item of result.candidates) {
if (item.candidate) {
await pc.addIceCandidate(new RTCIceCandidate(item.candidate))
lastIceTimestamp = item.createdAt
}
}
} catch (err) {
console.error('[Rondevu] Failed to poll ICE candidates:', err)
}
}, Rondevu.POLLING_INTERVAL_MS)
}
/**
* Automatically connect to a service (answerer side)
* Handles the entire connection flow: discovery, WebRTC setup, answer exchange, ICE candidates
*
* @example
* ```typescript
* // Connect to specific user
* const connection = await rondevu.connectToService({
* serviceFqn: 'chat:2.0.0@alice',
* connectionConfig: {
* reconnectEnabled: true,
* bufferEnabled: true
* onConnection: ({ dc, peerUsername }) => {
* console.log('Connected to', peerUsername)
* dc.addEventListener('message', (e) => console.log(e.data))
* dc.addEventListener('open', () => dc.send('Hello!'))
* }
* })
*
* connection.on('connected', () => {
* console.log('Connected!')
* connection.send('Hello!')
* })
*
* connection.on('message', (data) => {
* console.log('Received:', data)
* })
*
* connection.on('reconnecting', (attempt) => {
* console.log(`Reconnecting, attempt ${attempt}`)
* })
*
* // Discover random service
* const connection = await rondevu.connectToService({
* service: 'chat:2.0.0'
* service: 'chat:2.0.0',
* onConnection: ({ dc, peerUsername }) => {
* console.log('Connected to', peerUsername)
* }
* })
* ```
*/
async connectToService(options: ConnectToServiceOptions): Promise<AnswererConnection> {
const { rtcConfig, connectionConfig } = options
async connectToService(options: ConnectToServiceOptions): Promise<ConnectionContext> {
const { onConnection, rtcConfig } = options
// Validate inputs
if (options.serviceFqn !== undefined && typeof options.serviceFqn === 'string' && !options.serviceFqn.trim()) {
@@ -772,32 +709,86 @@ export class Rondevu extends EventEmitter {
const fqn = await this.resolveServiceFqn(options)
this.debug(`Connecting to service: ${fqn}`)
// Get service offer
// 1. Get service offer
const serviceData = await this.api.getService(fqn)
this.debug(`Found service from @${serviceData.username}`)
// Create RTCConfiguration
// 2. Create RTCPeerConnection
const rtcConfiguration = rtcConfig || {
iceServers: this.iceServers
}
const pc = new RTCPeerConnection(rtcConfiguration)
// Create AnswererConnection
const connection = new AnswererConnection({
api: this.api,
serviceFqn: serviceData.serviceFqn,
offerId: serviceData.offerId,
offerSdp: serviceData.sdp,
rtcConfig: rtcConfiguration,
config: {
...connectionConfig,
debug: this.debugEnabled,
},
// 3. Set up data channel handler (answerer receives it from offerer)
let dc: RTCDataChannel | null = null
const dataChannelPromise = new Promise<RTCDataChannel>((resolve) => {
pc.ondatachannel = (event) => {
this.debug('Data channel received from offerer')
dc = event.channel
resolve(dc)
}
})
// Initialize the connection
await connection.initialize()
// 4. Set up ICE candidate exchange
this.setupIceCandidateHandler(pc, serviceData.serviceFqn, serviceData.offerId)
return connection
// 5. Poll for remote ICE candidates
const icePollInterval = this.startIcePolling(pc, serviceData.serviceFqn, serviceData.offerId)
// 6. Set remote description
await pc.setRemoteDescription({
type: 'offer',
sdp: serviceData.sdp
})
// 7. Create and send answer
const answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
await this.api.answerOffer(
serviceData.serviceFqn,
serviceData.offerId,
answer.sdp!
)
// 8. Wait for data channel to be established
dc = await dataChannelPromise
// Create connection context
const context: ConnectionContext = {
pc,
dc,
serviceFqn: serviceData.serviceFqn,
offerId: serviceData.offerId,
peerUsername: serviceData.username
}
// 9. Set up connection state monitoring
pc.onconnectionstatechange = () => {
this.debug(`Connection state: ${pc.connectionState}`)
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
clearInterval(icePollInterval)
}
}
// 10. Wait for data channel to open and call onConnection
if (dc.readyState === 'open') {
this.debug('Data channel already open')
if (onConnection) {
await onConnection(context)
}
} else {
await new Promise<void>((resolve) => {
dc!.addEventListener('open', async () => {
this.debug('Data channel opened')
if (onConnection) {
await onConnection(context)
}
resolve()
})
})
}
return context
}
// ============================================
@@ -805,43 +796,56 @@ export class Rondevu extends EventEmitter {
// ============================================
/**
* Find a service - unified discovery method
*
* @param serviceFqn - Service identifier (e.g., 'chat:1.0.0' or 'chat:1.0.0@alice')
* @param options - Discovery options
*
* @example
* ```typescript
* // Direct lookup (has username)
* const service = await rondevu.findService('chat:1.0.0@alice')
*
* // Random discovery (no username)
* const service = await rondevu.findService('chat:1.0.0')
*
* // Paginated discovery
* const result = await rondevu.findService('chat:1.0.0', {
* mode: 'paginated',
* limit: 20,
* offset: 0
* })
* ```
* Get service by FQN (with username) - Direct lookup
* Example: chat:1.0.0@alice
*/
async findService(
serviceFqn: string,
options?: FindServiceOptions
): Promise<ServiceResult | PaginatedServiceResult> {
const { mode, limit = 10, offset = 0 } = options || {}
async getService(serviceFqn: string): Promise<{
serviceId: string
username: string
serviceFqn: string
offerId: string
sdp: string
createdAt: number
expiresAt: number
}> {
return await this.api.getService(serviceFqn)
}
// Auto-detect mode if not specified
const hasUsername = serviceFqn.includes('@')
const effectiveMode = mode || (hasUsername ? 'direct' : 'random')
/**
* Discover a random available service without knowing the username
* Example: chat:1.0.0 (without @username)
*/
async discoverService(serviceVersion: string): Promise<{
serviceId: string
username: string
serviceFqn: string
offerId: string
sdp: string
createdAt: number
expiresAt: number
}> {
return await this.api.getService(serviceVersion)
}
if (effectiveMode === 'paginated') {
return await this.api.getService(serviceFqn, { limit, offset })
} else {
// Both 'direct' and 'random' use the same API call
return await this.api.getService(serviceFqn)
}
/**
* Discover multiple available services with pagination
* Example: chat:1.0.0 (without @username)
*/
async discoverServices(serviceVersion: string, limit: number = 10, offset: number = 0): Promise<{
services: Array<{
serviceId: string
username: string
serviceFqn: string
offerId: string
sdp: string
createdAt: number
expiresAt: number
}>
count: number
limit: number
offset: number
}> {
return await this.api.getService(serviceVersion, { limit, offset })
}
// ============================================
@@ -938,36 +942,6 @@ export class Rondevu extends EventEmitter {
return this.keypair.publicKey
}
/**
* Get active connections (for offerer side)
*/
getActiveConnections(): Map<string, OffererConnection> {
return this.activeConnections
}
/**
* Get all active offers (legacy compatibility)
* @deprecated Use getActiveConnections() instead
*/
getActiveOffers(): ActiveOffer[] {
const offers: ActiveOffer[] = []
for (const [offerId, connection] of this.activeConnections.entries()) {
const pc = connection.getPeerConnection()
const dc = connection.getDataChannel()
if (pc) {
offers.push({
offerId,
serviceFqn: this.currentService ? `${this.currentService}@${this.username}` : '',
pc,
dc: dc || undefined,
answered: connection.getState() === 'connected',
createdAt: Date.now(),
})
}
}
return offers
}
/**
* Access to underlying API for advanced operations
* @deprecated Use direct methods on Rondevu instance instead