7 Commits

Author SHA1 Message Date
177ee2ec2d feat: update client to use service-based signaling endpoints
BREAKING CHANGE: Client API now uses service UUIDs for WebRTC signaling

- Replace answerOffer() with answerService()
- Replace getAnswer() with getServiceAnswer()
- Replace addIceCandidates() with addServiceIceCandidates()
- Replace getIceCandidates() with getServiceIceCandidates()
- Update RondevuSignaler to use service UUID instead of offer ID for signaling
- Automatically track offerId returned from service endpoints
- Bump version to 0.12.0

Matches server v0.4.0 service-based API refactor.
2025-12-07 22:17:36 +01:00
d06b2166c1 chore: Bump version to 0.11.0 2025-12-07 22:01:34 +01:00
cbb0cc3f83 docs: Update README with semver and privacy features 2025-12-07 21:58:20 +01:00
fbd3be57d4 Add RTCConfiguration support to ServiceHost and ServiceClient
- WebRTCContext now accepts optional RTCConfiguration
- ServiceHost and ServiceClient accept optional rtcConfiguration option
- Allows custom STUN/TURN server configuration
- Version bump to 0.10.1

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-07 19:43:34 +01:00
54355323d9 Add ServiceHost, ServiceClient, and RondevuService for high-level service management
- Add RondevuService: High-level API for username claiming and service publishing with Ed25519 signatures
- Add ServiceHost: Manages offer pool for hosting services with auto-replacement
- Add ServiceClient: Connects to hosted services with automatic reconnection
- Add NoOpSignaler: Placeholder signaler for connection setup
- Integrate Ed25519 signature functionality from @noble/ed25519
- Add ESLint and Prettier configuration with 4-space indentation
- Add demo with local signaling test
- Version bump to 0.10.0

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-07 19:37:43 +01:00
945d5a8792 Add comprehensive usage documentation 2025-12-07 17:41:16 +01:00
58cd610694 Implement RondevuAPI and RondevuSignaler classes
Added comprehensive API client and signaling implementation:

**RondevuAPI** - Single class for all Rondevu endpoints:
- Authentication: register()
- Offers: createOffers(), getOffer(), answerOffer(), getAnswer(), searchOffers()
- ICE Candidates: addIceCandidates(), getIceCandidates()
- Services: publishService(), getService(), searchServices()
- Usernames: checkUsername(), claimUsername()

**RondevuSignaler** - ICE candidate exchange:
- addIceCandidate() - Send local candidates to server
- addListener() - Poll for remote candidates (1 second intervals)
- Returns cleanup function (Binnable) to stop polling
- Handles offer expiration gracefully

**WebRTCRondevuConnection** - WebRTC connection wrapper:
- Handles offer/answer creation
- Manages ICE candidate exchange via Signaler
- Type-safe event bus for state changes and messages
- Queue and send message interfaces

**Utilities**:
- createBin() - Cleanup function collector
- Binnable type - Cleanup function signature

All classes use the shared RondevuAPI client for consistent
error handling and authentication.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-07 17:40:17 +01:00
19 changed files with 5584 additions and 741 deletions

9
.prettierrc.json Normal file
View File

@@ -0,0 +1,9 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 4,
"useTabs": false,
"trailingComma": "es5",
"printWidth": 100,
"arrowParens": "avoid"
}

View File

@@ -1,120 +0,0 @@
# EventBus Usage Examples
## Type-Safe Event Bus
The `EventBus` class provides fully type-safe event handling with TypeScript type inference.
### Basic Usage
```typescript
import { EventBus } from '@xtr-dev/rondevu-client';
// Define your event mapping
interface AppEvents {
'user:connected': { userId: string; timestamp: number };
'user:disconnected': { userId: string };
'message:received': string;
'connection:error': Error;
}
// Create the event bus
const events = new EventBus<AppEvents>();
// Subscribe to events - TypeScript knows the exact data type!
events.on('user:connected', (data) => {
// data is { userId: string; timestamp: number }
console.log(`User ${data.userId} connected at ${data.timestamp}`);
});
events.on('message:received', (data) => {
// data is string
console.log(data.toUpperCase());
});
// Emit events - TypeScript validates the data type
events.emit('user:connected', {
userId: '123',
timestamp: Date.now()
});
events.emit('message:received', 'Hello World');
// Type errors caught at compile time:
// events.emit('user:connected', 'wrong type'); // ❌ Error!
// events.emit('message:received', { wrong: 'type' }); // ❌ Error!
```
### One-Time Listeners
```typescript
// Subscribe once - handler auto-unsubscribes after first call
events.once('connection:error', (error) => {
console.error('Connection failed:', error.message);
});
```
### Unsubscribing
```typescript
const handler = (data: string) => {
console.log('Message:', data);
};
events.on('message:received', handler);
// Later, unsubscribe
events.off('message:received', handler);
```
### Utility Methods
```typescript
// Clear all handlers for a specific event
events.clear('message:received');
// Clear all handlers for all events
events.clear();
// Get listener count
const count = events.listenerCount('user:connected');
// Get all event names with handlers
const eventNames = events.eventNames();
```
## Connection Events Example
```typescript
interface ConnectionEvents {
'connection:state': { state: 'connected' | 'disconnected' | 'connecting' };
'connection:message': { from: string; data: string | ArrayBuffer };
'connection:error': { code: string; message: string };
}
class ConnectionManager {
private events = new EventBus<ConnectionEvents>();
on<K extends keyof ConnectionEvents>(
event: K,
handler: (data: ConnectionEvents[K]) => void
) {
this.events.on(event, handler);
}
private handleStateChange(state: 'connected' | 'disconnected' | 'connecting') {
this.events.emit('connection:state', { state });
}
private handleMessage(from: string, data: string | ArrayBuffer) {
this.events.emit('connection:message', { from, data });
}
}
```
## Benefits
-**Full type safety** - TypeScript validates event names and data types
-**IntelliSense support** - Auto-completion for event names and data properties
-**Compile-time errors** - Catch type mismatches before runtime
-**Self-documenting** - Event interface serves as documentation
-**Refactoring-friendly** - Rename events or change types with confidence

837
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) [![npm version](https://img.shields.io/npm/v/@xtr-dev/rondevu-client)](https://www.npmjs.com/package/@xtr-dev/rondevu-client)
🌐 **WebRTC with durable connections and automatic reconnection** 🌐 **Simple, high-level WebRTC peer-to-peer connections**
TypeScript/JavaScript client for Rondevu, providing durable WebRTC connections that survive network interruptions with automatic reconnection and message queuing. TypeScript/JavaScript client for Rondevu, providing easy-to-use WebRTC connections with automatic signaling, username-based discovery, and built-in reconnection support.
**Related repositories:** **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)) - [@xtr-dev/rondevu-client](https://github.com/xtr-dev/rondevu-client) - TypeScript client library ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-client))
@@ -15,14 +15,16 @@ TypeScript/JavaScript client for Rondevu, providing durable WebRTC connections t
## Features ## Features
- **Durable Connections**: Automatic reconnection on network drops - **High-Level Wrappers**: ServiceHost and ServiceClient eliminate WebRTC boilerplate
- **Message Queuing**: Messages sent during disconnections are queued and flushed on reconnect - **Username-Based Discovery**: Connect to peers by username, not complex offer/answer exchange
- **Durable Channels**: RTCDataChannel wrappers that survive connection drops - **Semver-Compatible Matching**: Requesting chat@1.0.0 matches any compatible 1.x.x version
- **TTL Auto-Refresh**: Services automatically republish before expiration - **Privacy-First Design**: Services are hidden by default - no enumeration possible
- **Username Claiming**: Cryptographic ownership with Ed25519 signatures - **Automatic Reconnection**: Built-in retry logic with exponential backoff
- **Service Publishing**: Package-style naming (com.example.chat@1.0.0) - **Message Queuing**: Messages sent while disconnected are queued and flushed on reconnect
- **Cryptographic Username Claiming**: Secure ownership with Ed25519 signatures
- **Service Publishing**: Package-style naming (chat.app@1.0.0) with multiple simultaneous offers
- **TypeScript**: Full type safety and autocomplete - **TypeScript**: Full type safety and autocomplete
- **Configurable**: All timeouts, retry limits, and queue sizes are configurable - **Configurable Polling**: Exponential backoff with jitter to reduce server load
## Install ## Install
@@ -32,588 +34,400 @@ npm install @xtr-dev/rondevu-client
## Quick Start ## Quick Start
### Publishing a Service (Alice) ### Hosting a Service (Alice)
```typescript ```typescript
import { Rondevu } from '@xtr-dev/rondevu-client'; import { RondevuService, ServiceHost } from '@xtr-dev/rondevu-client'
// Initialize client and register // Step 1: Create and initialize service
const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' }); const service = new RondevuService({
await client.register(); apiUrl: 'https://api.ronde.vu',
username: 'alice'
})
// Step 1: Claim username (one-time) await service.initialize() // Generates keypair
const claim = await client.usernames.claimUsername('alice'); await service.claimUsername() // Claims username with signature
client.usernames.saveKeypairToStorage('alice', claim.publicKey, claim.privateKey);
// Step 2: Expose service with handler // Step 2: Create ServiceHost
const keypair = client.usernames.loadKeypairFromStorage('alice'); const host = new ServiceHost({
service: 'chat.app@1.0.0',
rondevuService: service,
maxPeers: 5, // Accept up to 5 connections
ttl: 300000 // 5 minutes
})
const service = await client.exposeService({ // Step 3: Listen for incoming connections
username: 'alice', host.events.on('connection', (connection) => {
privateKey: keypair.privateKey, console.log('✅ New connection!')
serviceFqn: 'chat@1.0.0',
isPublic: true,
poolSize: 10, // Handle 10 concurrent connections
handler: (channel, connectionId) => {
console.log(`📡 New connection: ${connectionId}`);
channel.on('message', (data) => { connection.events.on('message', (msg) => {
console.log('📥 Received:', data); console.log('📨 Received:', msg)
channel.send(`Echo: ${data}`); connection.sendMessage('Hello from Alice!')
}); })
channel.on('close', () => { connection.events.on('state-change', (state) => {
console.log(`👋 Connection ${connectionId} closed`); console.log('Connection state:', state)
}); })
} })
});
// Start the service host.events.on('error', (error) => {
const info = await service.start(); console.error('Host error:', error)
console.log(`Service published with UUID: ${info.uuid}`); })
console.log('Waiting for connections...');
// Later: stop the service // Step 4: Start hosting
await service.stop(); await host.start()
console.log('Service is now live! Others can connect to @alice')
// Later: stop hosting
host.dispose()
``` ```
### Connecting to a Service (Bob) ### Connecting to a Service (Bob)
```typescript ```typescript
import { Rondevu } from '@xtr-dev/rondevu-client'; import { RondevuService, ServiceClient } from '@xtr-dev/rondevu-client'
// Initialize client and register // Step 1: Create and initialize service
const client = new Rondevu({ baseUrl: 'https://api.ronde.vu' }); const service = new RondevuService({
await client.register(); apiUrl: 'https://api.ronde.vu',
username: 'bob'
})
// Connect to Alice's service await service.initialize()
const connection = await client.connect('alice', 'chat@1.0.0', { await service.claimUsername()
maxReconnectAttempts: 5
});
// Create a durable channel // Step 2: Create ServiceClient
const channel = connection.createChannel('main'); const client = new ServiceClient({
username: 'alice', // Connect to Alice
serviceFqn: 'chat.app@1.0.0',
rondevuService: service,
autoReconnect: true,
maxReconnectAttempts: 5
})
channel.on('message', (data) => { // Step 3: Listen for connection events
console.log('📥 Received:', data); client.events.on('connected', (connection) => {
}); console.log('✅ Connected to Alice!')
channel.on('open', () => { connection.events.on('message', (msg) => {
console.log('✅ Channel open'); console.log('📨 Received:', msg)
channel.send('Hello Alice!'); })
});
// Listen for connection events // Send a message
connection.on('connected', () => { connection.sendMessage('Hello from Bob!')
console.log('🎉 Connected to Alice'); })
});
connection.on('reconnecting', (attempt, max, delay) => { client.events.on('disconnected', () => {
console.log(`🔄 Reconnecting... (${attempt}/${max}, retry in ${delay}ms)`); console.log('🔌 Disconnected')
}); })
connection.on('disconnected', () => { client.events.on('reconnecting', ({ attempt, maxAttempts }) => {
console.log('🔌 Disconnected'); console.log(`🔄 Reconnecting (${attempt}/${maxAttempts})...`)
}); })
connection.on('failed', (error) => { client.events.on('error', (error) => {
console.error('❌ Connection failed permanently:', error); console.error('❌ Error:', error)
}); })
// Establish the connection // Step 4: Connect
await connection.connect(); await client.connect()
// Messages sent during disconnection are automatically queued // Later: disconnect
channel.send('This will be queued if disconnected'); client.dispose()
// Later: close the connection
await connection.close();
``` ```
## Core Concepts ## Core Concepts
### DurableConnection ### RondevuService
Manages WebRTC peer lifecycle with automatic reconnection: Handles authentication and username management:
- Automatically reconnects when connection drops - Generates Ed25519 keypair for signing
- Exponential backoff with jitter (1s → 2s → 4s → 8s → ... max 30s) - Claims usernames with cryptographic proof
- Configurable max retry attempts (default: 10) - Provides API client for signaling server
- Manages multiple DurableChannel instances
### DurableChannel ### ServiceHost
Wraps RTCDataChannel with message queuing: High-level wrapper for hosting a WebRTC service:
- Queues messages during disconnection - Automatically creates and publishes offers
- Flushes queue on reconnection - Handles incoming connections
- Configurable queue size and message age limits - Manages ICE candidate exchange
- RTCDataChannel-compatible API with event emitters - Supports multiple simultaneous peers
### DurableService ### ServiceClient
Server-side service with TTL auto-refresh: High-level wrapper for connecting to services:
- Automatically republishes service before TTL expires - Discovers services by username
- Creates DurableConnection for each incoming peer - Handles offer/answer exchange automatically
- Manages connection pool for multiple simultaneous connections - Built-in auto-reconnection with exponential backoff
- Event-driven API
### RTCDurableConnection
Low-level connection wrapper (used internally):
- Manages WebRTC PeerConnection lifecycle
- Handles ICE candidate polling
- Provides message queue for reliability
- State management and events
## API Reference ## API Reference
### Main Client ### RondevuService
```typescript ```typescript
const client = new Rondevu({ const service = new RondevuService({
baseUrl: 'https://api.ronde.vu', // optional, default shown apiUrl: string, // Signaling server URL
credentials?: { peerId, secret }, // optional, skip registration username: string, // Your username
fetch?: customFetch, // optional, for Node.js < 18 keypair?: Keypair // Optional: reuse existing keypair
RTCPeerConnection?: RTCPeerConnection, // optional, for Node.js })
RTCSessionDescription?: RTCSessionDescription,
RTCIceCandidate?: RTCIceCandidate
});
// Register and get credentials // Initialize service (generates keypair if not provided)
const creds = await client.register(); await service.initialize(): Promise<void>
// { peerId: '...', secret: '...' }
// Check if authenticated // Claim username with cryptographic signature
client.isAuthenticated(); // boolean await service.claimUsername(): Promise<void>
// Get current credentials // Check if username is claimed
client.getCredentials(); // { peerId, secret } | undefined service.isUsernameClaimed(): boolean
// Get current username
service.getUsername(): string
// Get keypair
service.getKeypair(): Keypair
// Get API client
service.getAPI(): RondevuAPI
``` ```
### Username API ### ServiceHost
```typescript ```typescript
// Check username availability const host = new ServiceHost({
const check = await client.usernames.checkUsername('alice'); service: string, // Service FQN (e.g., 'chat.app@1.0.0')
// { available: true } or { available: false, expiresAt: number, publicKey: string } rondevuService: RondevuService,
maxPeers?: number, // Default: 5
ttl?: number, // Default: 300000 (5 minutes)
isPublic?: boolean, // Default: true
rtcConfiguration?: RTCConfiguration
})
// Claim username with new keypair // Start hosting
const claim = await client.usernames.claimUsername('alice'); await host.start(): Promise<void>
// { username, publicKey, privateKey, claimedAt, expiresAt }
// Save keypair to localStorage // Stop hosting and cleanup
client.usernames.saveKeypairToStorage('alice', claim.publicKey, claim.privateKey); host.dispose(): void
// Load keypair from localStorage // Get all active connections
const keypair = client.usernames.loadKeypairFromStorage('alice'); host.getConnections(): RTCDurableConnection[]
// { publicKey, privateKey } | null
// Events
host.events.on('connection', (conn: RTCDurableConnection) => {})
host.events.on('error', (error: Error) => {})
``` ```
**Username Rules:** ### ServiceClient
- Format: Lowercase alphanumeric + dash (`a-z`, `0-9`, `-`)
- Length: 3-32 characters
- Pattern: `^[a-z0-9][a-z0-9-]*[a-z0-9]$`
- Validity: 365 days from claim/last use
- Ownership: Secured by Ed25519 public key
### Durable Service API
```typescript ```typescript
// Expose a durable service const client = new ServiceClient({
const service = await client.exposeService({ username: string, // Host username to connect to
username: 'alice', serviceFqn: string, // Service FQN (e.g., 'chat.app@1.0.0')
privateKey: keypair.privateKey, rondevuService: RondevuService,
serviceFqn: 'chat@1.0.0', autoReconnect?: boolean, // Default: true
maxReconnectAttempts?: number, // Default: 5
rtcConfiguration?: RTCConfiguration
})
// Service options // Connect to service
isPublic: true, // optional, default: false await client.connect(): Promise<RTCDurableConnection>
metadata: { version: '1.0' }, // optional
ttl: 300000, // optional, default: 5 minutes
ttlRefreshMargin: 0.2, // optional, refresh at 80% of TTL
// Connection pooling // Disconnect and cleanup
poolSize: 10, // optional, default: 1 client.dispose(): void
pollingInterval: 2000, // optional, default: 2000ms
// Connection options (applied to incoming connections) // Get current connection
maxReconnectAttempts: 10, // optional, default: 10 client.getConnection(): RTCDurableConnection | null
reconnectBackoffBase: 1000, // optional, default: 1000ms
reconnectBackoffMax: 30000, // optional, default: 30000ms
reconnectJitter: 0.2, // optional, default: 0.2 (±20%)
connectionTimeout: 30000, // optional, default: 30000ms
// Message queuing // Events
maxQueueSize: 1000, // optional, default: 1000 client.events.on('connected', (conn: RTCDurableConnection) => {})
maxMessageAge: 60000, // optional, default: 60000ms (1 minute) client.events.on('disconnected', () => {})
client.events.on('reconnecting', (info: { attempt: number, maxAttempts: number }) => {})
// WebRTC configuration client.events.on('error', (error: Error) => {})
rtcConfig: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
},
// Connection handler
handler: (channel, connectionId) => {
// Handle incoming connection
channel.on('message', (data) => {
console.log('Received:', data);
channel.send(`Echo: ${data}`);
});
}
});
// Start the service
const info = await service.start();
// { serviceId: '...', uuid: '...', expiresAt: 1234567890 }
// Get active connections
const connections = service.getActiveConnections();
// ['conn-123', 'conn-456']
// Get service info
const serviceInfo = service.getServiceInfo();
// { serviceId: '...', uuid: '...', expiresAt: 1234567890 } | null
// Stop the service
await service.stop();
``` ```
**Service Events:** ### RTCDurableConnection
```typescript ```typescript
service.on('published', (serviceId, uuid) => { // Connection state
console.log(`Service published: ${uuid}`); connection.state: 'connected' | 'connecting' | 'disconnected'
});
service.on('connection', (connectionId) => { // Send message (returns true if sent, false if queued)
console.log(`New connection: ${connectionId}`); await connection.sendMessage(message: string): Promise<boolean>
});
service.on('disconnection', (connectionId) => { // Queue message for sending when connected
console.log(`Connection closed: ${connectionId}`); await connection.queueMessage(message: string, options?: QueueMessageOptions): Promise<void>
});
service.on('ttl-refreshed', (expiresAt) => { // Disconnect
console.log(`TTL refreshed, expires at: ${new Date(expiresAt)}`); connection.disconnect(): void
});
service.on('error', (error, context) => { // Events
console.error(`Service error (${context}):`, error); connection.events.on('message', (msg: string) => {})
}); connection.events.on('state-change', (state: ConnectionStates) => {})
service.on('closed', () => {
console.log('Service stopped');
});
``` ```
### Durable Connection API ## Configuration
### Polling Configuration
The signaling uses configurable polling with exponential backoff:
```typescript ```typescript
// Connect by username and service FQN // Default polling config
const connection = await client.connect('alice', 'chat@1.0.0', { {
// Connection options initialInterval: 500, // Start at 500ms
maxReconnectAttempts: 10, // optional, default: 10 maxInterval: 5000, // Max 5 seconds
reconnectBackoffBase: 1000, // optional, default: 1000ms backoffMultiplier: 1.5, // Increase by 1.5x each time
reconnectBackoffMax: 30000, // optional, default: 30000ms maxRetries: 50, // Max 50 attempts
reconnectJitter: 0.2, // optional, default: 0.2 (±20%) jitter: true // Add random 0-100ms to prevent thundering herd
connectionTimeout: 30000, // optional, default: 30000ms
// Message queuing
maxQueueSize: 1000, // optional, default: 1000
maxMessageAge: 60000, // optional, default: 60000ms
// WebRTC configuration
rtcConfig: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
}
});
// Connect by UUID
const connection2 = await client.connectByUuid('service-uuid-here', {
maxReconnectAttempts: 5
});
// Create channels before connecting
const channel = connection.createChannel('main');
const fileChannel = connection.createChannel('files', {
ordered: false,
maxRetransmits: 3
});
// Get existing channel
const existingChannel = connection.getChannel('main');
// Check connection state
const state = connection.getState();
// 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'failed' | 'closed'
const isConnected = connection.isConnected();
// Connect
await connection.connect();
// Close connection
await connection.close();
```
**Connection Events:**
```typescript
connection.on('state', (newState, previousState) => {
console.log(`State: ${previousState}${newState}`);
});
connection.on('connected', () => {
console.log('Connected');
});
connection.on('reconnecting', (attempt, maxAttempts, delay) => {
console.log(`Reconnecting (${attempt}/${maxAttempts}) in ${delay}ms`);
});
connection.on('disconnected', () => {
console.log('Disconnected');
});
connection.on('failed', (error, permanent) => {
console.error('Connection failed:', error, 'Permanent:', permanent);
});
connection.on('closed', () => {
console.log('Connection closed');
});
```
### Durable Channel API
```typescript
const channel = connection.createChannel('chat', {
ordered: true, // optional, default: true
maxRetransmits: undefined // optional, for unordered channels
});
// Send data (queued if disconnected)
channel.send('Hello!');
channel.send(new ArrayBuffer(1024));
channel.send(new Blob(['data']));
// Check state
const state = channel.readyState;
// 'connecting' | 'open' | 'closing' | 'closed'
// Get buffered amount
const buffered = channel.bufferedAmount;
// Set buffered amount low threshold
channel.bufferedAmountLowThreshold = 16 * 1024; // 16KB
// Get queue size (for debugging)
const queueSize = channel.getQueueSize();
// Close channel
channel.close();
```
**Channel Events:**
```typescript
channel.on('open', () => {
console.log('Channel open');
});
channel.on('message', (data) => {
console.log('Received:', data);
});
channel.on('error', (error) => {
console.error('Channel error:', error);
});
channel.on('close', () => {
console.log('Channel closed');
});
channel.on('bufferedAmountLow', () => {
console.log('Buffer drained, safe to send more');
});
channel.on('queueOverflow', (droppedCount) => {
console.warn(`Queue overflow: ${droppedCount} messages dropped`);
});
```
## Configuration Options
### Connection Configuration
```typescript
interface DurableConnectionConfig {
maxReconnectAttempts?: number; // default: 10
reconnectBackoffBase?: number; // default: 1000 (1 second)
reconnectBackoffMax?: number; // default: 30000 (30 seconds)
reconnectJitter?: number; // default: 0.2 (±20%)
connectionTimeout?: number; // default: 30000 (30 seconds)
maxQueueSize?: number; // default: 1000 messages
maxMessageAge?: number; // default: 60000 (1 minute)
rtcConfig?: RTCConfiguration;
} }
``` ```
### Service Configuration This is handled automatically - no configuration needed.
### WebRTC Configuration
Provide custom STUN/TURN servers:
```typescript ```typescript
interface DurableServiceConfig extends DurableConnectionConfig { const host = new ServiceHost({
username: string; service: 'chat.app@1.0.0',
privateKey: string; rondevuService: service,
serviceFqn: string; rtcConfiguration: {
isPublic?: boolean; // default: false iceServers: [
metadata?: Record<string, any>; { urls: 'stun:stun.l.google.com:19302' },
ttl?: number; // default: 300000 (5 minutes) {
ttlRefreshMargin?: number; // default: 0.2 (refresh at 80%) urls: 'turn:turn.example.com:3478',
poolSize?: number; // default: 1 username: 'user',
pollingInterval?: number; // default: 2000 (2 seconds) credential: 'pass'
} }
]
}
})
``` ```
## Username Rules
- **Format**: Lowercase alphanumeric + dash (`a-z`, `0-9`, `-`)
- **Length**: 3-32 characters
- **Pattern**: `^[a-z0-9][a-z0-9-]*[a-z0-9]$`
- **Validity**: 365 days from claim/last use
- **Ownership**: Secured by Ed25519 public key signature
## Examples ## Examples
### Chat Application ### Chat Application
```typescript See [demo/demo.js](./demo/demo.js) for a complete working example.
// Server
const client = new Rondevu();
await client.register();
const claim = await client.usernames.claimUsername('alice'); ### Persistent Keypair
client.usernames.saveKeypairToStorage('alice', claim.publicKey, claim.privateKey);
const keypair = client.usernames.loadKeypairFromStorage('alice');
const service = await client.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'chat@1.0.0',
isPublic: true,
poolSize: 50, // Handle 50 concurrent users
handler: (channel, connectionId) => {
console.log(`User ${connectionId} joined`);
channel.on('message', (data) => {
console.log(`[${connectionId}]: ${data}`);
// Broadcast to all users (implement your broadcast logic)
});
channel.on('close', () => {
console.log(`User ${connectionId} left`);
});
}
});
await service.start();
// Client
const client2 = new Rondevu();
await client2.register();
const connection = await client2.connect('alice', 'chat@1.0.0');
const channel = connection.createChannel('chat');
channel.on('message', (data) => {
console.log('Message:', data);
});
await connection.connect();
channel.send('Hello everyone!');
```
### File Transfer with Progress
```typescript ```typescript
// Server // Save keypair to localStorage
const service = await client.exposeService({ const service = new RondevuService({
username: 'alice', apiUrl: 'https://api.ronde.vu',
privateKey: keypair.privateKey, username: 'alice'
serviceFqn: 'files@1.0.0', })
handler: (channel, connectionId) => {
channel.on('message', async (data) => {
const request = JSON.parse(data);
if (request.action === 'download') { await service.initialize()
const file = await fs.readFile(request.path); await service.claimUsername()
const chunkSize = 16 * 1024; // 16KB chunks
for (let i = 0; i < file.byteLength; i += chunkSize) { // Save for later
const chunk = file.slice(i, i + chunkSize); localStorage.setItem('rondevu-keypair', JSON.stringify(service.getKeypair()))
channel.send(chunk); localStorage.setItem('rondevu-username', service.getUsername())
// Wait for buffer to drain if needed // Load on next session
while (channel.bufferedAmount > 16 * 1024 * 1024) { // 16MB const savedKeypair = JSON.parse(localStorage.getItem('rondevu-keypair'))
await new Promise(resolve => setTimeout(resolve, 100)); const savedUsername = localStorage.getItem('rondevu-username')
}
}
channel.send(JSON.stringify({ done: true })); const service2 = new RondevuService({
} apiUrl: 'https://api.ronde.vu',
}); username: savedUsername,
} keypair: savedKeypair
}); })
await service.start(); await service2.initialize() // Reuses keypair
// Client
const connection = await client.connect('alice', 'files@1.0.0');
const channel = connection.createChannel('files');
const chunks = [];
channel.on('message', (data) => {
if (typeof data === 'string') {
const msg = JSON.parse(data);
if (msg.done) {
const blob = new Blob(chunks);
console.log('Download complete:', blob.size, 'bytes');
}
} else {
chunks.push(data);
console.log('Progress:', chunks.length * 16 * 1024, 'bytes');
}
});
await connection.connect();
channel.send(JSON.stringify({ action: 'download', path: '/file.zip' }));
``` ```
## Platform-Specific Setup ### Message Queue Example
```typescript
// Messages are automatically queued if not connected yet
client.events.on('connected', (connection) => {
// Send immediately
connection.sendMessage('Hello!')
})
// Or queue for later
await client.connect()
const conn = client.getConnection()
await conn.queueMessage('This will be sent when connected', {
expiresAt: Date.now() + 60000 // Expire after 1 minute
})
```
## Migration from v0.9.x
v0.11.0+ introduces high-level wrappers, RESTful API changes, and semver-compatible discovery:
**API Changes:**
- Server endpoints restructured (`/usernames/*``/users/*`)
- Added `ServiceHost` and `ServiceClient` wrappers
- Message queue fully implemented
- Configurable polling with exponential backoff
- Removed deprecated `cleanup()` methods (use `dispose()`)
- **v0.11.0+**: Services use `offers` array instead of single `sdp`
- **v0.11.0+**: Semver-compatible service discovery (chat@1.0.0 matches 1.x.x)
- **v0.11.0+**: All services are hidden - no listing endpoint
- **v0.11.0+**: Services support multiple simultaneous offers for connection pooling
**Migration Guide:**
```typescript
// Before (v0.9.x) - Manual WebRTC setup
const signaler = new RondevuSignaler(service, 'chat@1.0.0')
const context = new WebRTCContext()
const pc = context.createPeerConnection()
// ... 50+ lines of boilerplate
// After (v0.11.0) - ServiceHost wrapper
const host = new ServiceHost({
service: 'chat@1.0.0',
rondevuService: service
})
await host.start()
// Done!
```
## Platform Support
### Modern Browsers ### Modern Browsers
Works out of the box - no additional setup needed. Works out of the box - no additional setup needed.
### Node.js 18+ ### Node.js 18+
Native fetch is available, but you need WebRTC polyfills: Native fetch is available, but WebRTC requires polyfills:
```bash ```bash
npm install wrtc npm install wrtc
``` ```
```typescript ```typescript
import { Rondevu } from '@xtr-dev/rondevu-client'; import { WebRTCContext } from '@xtr-dev/rondevu-client'
import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'wrtc'; import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'wrtc'
const client = new Rondevu({ // Configure WebRTC context
baseUrl: 'https://api.ronde.vu', const context = new WebRTCContext({
RTCPeerConnection, RTCPeerConnection,
RTCSessionDescription, RTCSessionDescription,
RTCIceCandidate RTCIceCandidate
}); } as any)
```
### Node.js < 18
Install both fetch and WebRTC polyfills:
```bash
npm install node-fetch wrtc
```
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
import fetch from 'node-fetch';
import { RTCPeerConnection, RTCSessionDescription, RTCIceCandidate } from 'wrtc';
const client = new Rondevu({
baseUrl: 'https://api.ronde.vu',
fetch: fetch as any,
RTCPeerConnection,
RTCSessionDescription,
RTCIceCandidate
});
``` ```
## TypeScript ## TypeScript
@@ -622,38 +436,23 @@ All types are exported:
```typescript ```typescript
import type { import type {
// Client types RondevuServiceOptions,
Credentials, ServiceHostOptions,
RondevuOptions, ServiceHostEvents,
ServiceClientOptions,
// Username types ServiceClientEvents,
UsernameCheckResult, ConnectionInterface,
UsernameClaimResult, ConnectionEvents,
ConnectionStates,
// Durable connection types Message,
DurableConnectionState, QueueMessageOptions,
DurableChannelState, Signaler,
DurableConnectionConfig, PollingConfig,
DurableChannelConfig, Credentials,
DurableServiceConfig, Keypair
QueuedMessage, } from '@xtr-dev/rondevu-client'
DurableConnectionEvents,
DurableChannelEvents,
DurableServiceEvents,
ConnectionInfo,
ServiceInfo
} from '@xtr-dev/rondevu-client';
``` ```
## Migration from v0.8.x
v0.9.0 is a **breaking change** that replaces the low-level APIs with high-level durable connections. See [MIGRATION.md](./MIGRATION.md) for detailed migration guide.
**Key Changes:**
- ❌ Removed: `client.services.*`, `client.discovery.*`, `client.createPeer()` (low-level APIs)
- ✅ Added: `client.exposeService()`, `client.connect()`, `client.connectByUuid()` (durable APIs)
- ✅ Changed: Focus on durable connections with automatic reconnection and message queuing
## License ## License
MIT MIT

281
USAGE.md Normal file
View File

@@ -0,0 +1,281 @@
# Rondevu Client Usage Guide
## Installation
```bash
npm install @xtr-dev/rondevu-client
```
## Quick Start
### 1. Register and Create Connection
```typescript
import { RondevuAPI, RondevuSignaler, WebRTCRondevuConnection } from '@xtr-dev/rondevu-client';
const API_URL = 'https://api.ronde.vu';
// Register to get credentials
const api = new RondevuAPI(API_URL);
const credentials = await api.register();
// Create authenticated API client
const authenticatedApi = new RondevuAPI(API_URL, credentials);
```
### 2. Create an Offer (Offerer Side)
```typescript
// Create a connection
const connection = new WebRTCRondevuConnection(
'connection-id',
'host-username',
'service-name'
);
// Wait for local description
await connection.ready;
// Create offer on server
const offers = await authenticatedApi.createOffers([{
sdp: connection.connection.localDescription!.sdp!,
ttl: 300000 // 5 minutes
}]);
const offerId = offers[0].id;
// Set up signaler for ICE candidate exchange
const signaler = new RondevuSignaler(authenticatedApi, offerId);
connection.setSignaler(signaler);
// Poll for answer
const checkAnswer = setInterval(async () => {
const answer = await authenticatedApi.getAnswer(offerId);
if (answer) {
clearInterval(checkAnswer);
await connection.connection.setRemoteDescription({
type: 'answer',
sdp: answer.sdp
});
console.log('Connection established!');
}
}, 1000);
```
### 3. Answer an Offer (Answerer Side)
```typescript
// Get the offer
const offer = await authenticatedApi.getOffer(offerId);
// Create connection with remote offer
const connection = new WebRTCRondevuConnection(
'connection-id',
'peer-username',
'service-name',
{
type: 'offer',
sdp: offer.sdp
}
);
// Wait for local description (answer)
await connection.ready;
// Send answer to server
await authenticatedApi.answerOffer(
offerId,
connection.connection.localDescription!.sdp!
);
// Set up signaler for ICE candidate exchange
const signaler = new RondevuSignaler(authenticatedApi, offerId);
connection.setSignaler(signaler);
console.log('Connection established!');
```
## Using Services
### Publish a Service
```typescript
import { RondevuAPI } from '@xtr-dev/rondevu-client';
const api = new RondevuAPI(API_URL, credentials);
const service = await api.publishService({
username: 'my-username',
serviceFqn: 'chat.app@1.0.0',
sdp: localDescription.sdp,
ttl: 300000,
isPublic: true,
metadata: { description: 'My chat service' },
signature: '...', // Ed25519 signature
message: '...' // Signed message
});
console.log('Service UUID:', service.uuid);
```
### Connect to a Service
```typescript
// Search for services
const services = await api.searchServices('username', 'chat.app@1.0.0');
if (services.length > 0) {
// Get service details with offer
const service = await api.getService(services[0].uuid);
// Create connection with service offer
const connection = new WebRTCRondevuConnection(
service.serviceId,
service.username,
service.serviceFqn,
{
type: 'offer',
sdp: service.sdp
}
);
await connection.ready;
// Answer the service offer
await api.answerOffer(
service.offerId,
connection.connection.localDescription!.sdp!
);
// Set up signaler
const signaler = new RondevuSignaler(api, service.offerId);
connection.setSignaler(signaler);
}
```
## Event Handling
```typescript
import { EventBus } from '@xtr-dev/rondevu-client';
// Connection events
connection.events.on('state-change', (state) => {
console.log('Connection state:', state);
});
connection.events.on('message', (message) => {
console.log('Received message:', message);
});
// Custom events with EventBus
interface MyEvents {
'user:connected': { userId: string; timestamp: number };
'message:sent': string;
}
const events = new EventBus<MyEvents>();
events.on('user:connected', (data) => {
console.log(`User ${data.userId} connected at ${data.timestamp}`);
});
events.emit('user:connected', {
userId: '123',
timestamp: Date.now()
});
```
## Cleanup
```typescript
import { createBin } from '@xtr-dev/rondevu-client';
const bin = createBin();
// Add cleanup functions
bin(
() => console.log('Cleanup 1'),
() => console.log('Cleanup 2')
);
// Clean all
bin.clean();
```
## API Reference
### RondevuAPI
Complete API client for Rondevu signaling server.
**Methods:**
- `register()` - Register new peer
- `createOffers(offers)` - Create offers
- `getOffer(offerId)` - Get offer by ID
- `answerOffer(offerId, sdp)` - Answer an offer
- `getAnswer(offerId)` - Poll for answer
- `searchOffers(topic)` - Search by topic
- `addIceCandidates(offerId, candidates)` - Add ICE candidates
- `getIceCandidates(offerId, since)` - Get ICE candidates (polling)
- `publishService(service)` - Publish service
- `getService(uuid)` - Get service by UUID
- `searchServices(username, serviceFqn)` - Search services
- `checkUsername(username)` - Check availability
- `claimUsername(username, publicKey, signature, message)` - Claim username
### RondevuSignaler
Handles ICE candidate exchange via polling.
**Constructor:**
```typescript
new RondevuSignaler(api: RondevuAPI, offerId: string)
```
**Methods:**
- `addIceCandidate(candidate)` - Send local candidate
- `addListener(callback)` - Poll for remote candidates (returns cleanup function)
### WebRTCRondevuConnection
WebRTC connection wrapper with type-safe events.
**Constructor:**
```typescript
new WebRTCRondevuConnection(
id: string,
host: string,
service: string,
offer?: RTCSessionDescriptionInit
)
```
**Properties:**
- `id` - Connection ID
- `host` - Host username
- `service` - Service FQN
- `state` - Connection state
- `events` - EventBus for state changes and messages
- `ready` - Promise that resolves when local description is set
**Methods:**
- `setSignaler(signaler)` - Set signaler for ICE exchange
- `queueMessage(message, options)` - Queue message for sending
- `sendMessage(message)` - Send message immediately
### EventBus<TEvents>
Type-safe event emitter with inferred types.
**Methods:**
- `on(event, handler)` - Subscribe
- `once(event, handler)` - Subscribe once
- `off(event, handler)` - Unsubscribe
- `emit(event, data)` - Emit event
- `clear(event?)` - Clear handlers
- `listenerCount(event)` - Get listener count
- `eventNames()` - Get event names
## Examples
See the demo application at https://github.com/xtr-dev/rondevu-demo for a complete working example.

52
eslint.config.js Normal file
View File

@@ -0,0 +1,52 @@
import js from '@eslint/js'
import tsPlugin from '@typescript-eslint/eslint-plugin'
import tsParser from '@typescript-eslint/parser'
import prettierConfig from 'eslint-config-prettier'
import prettierPlugin from 'eslint-plugin-prettier'
import unicorn from 'eslint-plugin-unicorn'
import globals from 'globals'
export default [
js.configs.recommended,
{
files: ['**/*.ts', '**/*.tsx', '**/*.js'],
languageOptions: {
parser: tsParser,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
globals: {
...globals.browser,
...globals.node,
RTCPeerConnection: 'readonly',
RTCIceCandidate: 'readonly',
RTCSessionDescriptionInit: 'readonly',
RTCIceCandidateInit: 'readonly',
BufferSource: 'readonly',
},
},
plugins: {
'@typescript-eslint': tsPlugin,
prettier: prettierPlugin,
unicorn: unicorn,
},
rules: {
...tsPlugin.configs.recommended.rules,
...prettierConfig.rules,
'prettier/prettier': 'error',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'unicorn/filename-case': [
'error',
{
case: 'kebabCase',
ignore: ['^README\\.md$'],
},
],
},
},
{
ignores: ['dist/**', 'node_modules/**', '*.config.js'],
},
]

2951
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@xtr-dev/rondevu-client", "name": "@xtr-dev/rondevu-client",
"version": "0.9.2", "version": "0.12.0",
"description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing", "description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -8,6 +8,10 @@
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"dev": "vite",
"lint": "eslint src demo --ext .ts,.tsx,.js",
"lint:fix": "eslint src demo --ext .ts,.tsx,.js --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,js}\" \"demo/**/*.{ts,tsx,js,html}\"",
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
}, },
"keywords": [ "keywords": [
@@ -20,14 +24,23 @@
"author": "", "author": "",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"typescript": "^5.9.3" "@eslint/js": "^9.39.1",
"@typescript-eslint/eslint-plugin": "^8.48.1",
"@typescript-eslint/parser": "^8.48.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-unicorn": "^62.0.0",
"globals": "^16.5.0",
"prettier": "^3.7.4",
"typescript": "^5.9.3",
"vite": "^7.2.6"
}, },
"files": [ "files": [
"dist", "dist",
"README.md" "README.md"
], ],
"dependencies": { "dependencies": {
"@noble/ed25519": "^3.0.0", "@noble/ed25519": "^3.0.0"
"@xtr-dev/rondevu-client": "^0.9.2"
} }
} }

466
src/api.ts Normal file
View File

@@ -0,0 +1,466 @@
/**
* Rondevu API Client - Single class for all API endpoints
*/
import * as ed25519 from '@noble/ed25519'
// Set SHA-512 hash function for ed25519 (required in @noble/ed25519 v3+)
ed25519.hashes.sha512Async = async (message: Uint8Array) => {
return new Uint8Array(await crypto.subtle.digest('SHA-512', message as BufferSource))
}
export interface Credentials {
peerId: string
secret: string
}
export interface Keypair {
publicKey: string
privateKey: string
}
export interface OfferRequest {
sdp: string
topics?: string[]
ttl?: number
secret?: string
}
export interface Offer {
id: string
peerId: string
sdp: string
topics: string[]
ttl: number
createdAt: number
expiresAt: number
answererPeerId?: string
}
export interface OfferRequest {
sdp: string
}
export interface ServiceRequest {
username: string
serviceFqn: string
offers: OfferRequest[]
ttl?: number
isPublic?: boolean
metadata?: Record<string, any>
signature: string
message: string
}
export interface ServiceOffer {
offerId: string
sdp: string
createdAt: number
expiresAt: number
}
export interface Service {
serviceId: string
uuid: string
offers: ServiceOffer[]
username: string
serviceFqn: string
isPublic: boolean
metadata?: Record<string, any>
createdAt: number
expiresAt: number
}
export interface IceCandidate {
candidate: RTCIceCandidateInit
createdAt: number
}
/**
* Helper: Convert Uint8Array to base64 string
*/
function bytesToBase64(bytes: Uint8Array): string {
const binString = Array.from(bytes, byte => String.fromCodePoint(byte)).join('')
return btoa(binString)
}
/**
* Helper: Convert base64 string to Uint8Array
*/
function base64ToBytes(base64: string): Uint8Array {
const binString = atob(base64)
return Uint8Array.from(binString, char => char.codePointAt(0)!)
}
/**
* RondevuAPI - Complete API client for Rondevu signaling server
*/
export class RondevuAPI {
constructor(
private baseUrl: string,
private credentials?: Credentials
) {}
/**
* Set credentials for authentication
*/
setCredentials(credentials: Credentials): void {
this.credentials = credentials
}
/**
* Authentication header
*/
private getAuthHeader(): Record<string, string> {
if (!this.credentials) {
return {}
}
return {
Authorization: `Bearer ${this.credentials.peerId}:${this.credentials.secret}`,
}
}
// ============================================
// Ed25519 Cryptography Helpers
// ============================================
/**
* Generate an Ed25519 keypair for username claiming and service publishing
*/
static async generateKeypair(): Promise<Keypair> {
const privateKey = ed25519.utils.randomSecretKey()
const publicKey = await ed25519.getPublicKeyAsync(privateKey)
return {
publicKey: bytesToBase64(publicKey),
privateKey: bytesToBase64(privateKey),
}
}
/**
* Sign a message with an Ed25519 private key
*/
static async signMessage(message: string, privateKeyBase64: string): Promise<string> {
const privateKey = base64ToBytes(privateKeyBase64)
const encoder = new TextEncoder()
const messageBytes = encoder.encode(message)
const signature = await ed25519.signAsync(messageBytes, privateKey)
return bytesToBase64(signature)
}
/**
* Verify a signature
*/
static async verifySignature(
message: string,
signatureBase64: string,
publicKeyBase64: string
): Promise<boolean> {
const publicKey = base64ToBytes(publicKeyBase64)
const signature = base64ToBytes(signatureBase64)
const encoder = new TextEncoder()
const messageBytes = encoder.encode(message)
return await ed25519.verifyAsync(signature, messageBytes, publicKey)
}
// ============================================
// Authentication
// ============================================
/**
* Register a new peer and get credentials
*/
async register(): Promise<Credentials> {
const response = await fetch(`${this.baseUrl}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Registration failed: ${error.error || response.statusText}`)
}
return await response.json()
}
// ============================================
// Offers
// ============================================
/**
* Create one or more offers
*/
async createOffers(offers: OfferRequest[]): Promise<Offer[]> {
const response = await fetch(`${this.baseUrl}/offers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader(),
},
body: JSON.stringify({ offers }),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to create offers: ${error.error || response.statusText}`)
}
return await response.json()
}
/**
* Get offer by ID
*/
async getOffer(offerId: string): Promise<Offer> {
const response = await fetch(`${this.baseUrl}/offers/${offerId}`, {
headers: this.getAuthHeader(),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to get offer: ${error.error || response.statusText}`)
}
return await response.json()
}
/**
* Answer a service
*/
async answerService(serviceUuid: string, sdp: string): Promise<{ offerId: string }> {
const response = await fetch(`${this.baseUrl}/services/${serviceUuid}/answer`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader(),
},
body: JSON.stringify({ sdp }),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to answer service: ${error.error || response.statusText}`)
}
return await response.json()
}
/**
* Get answer for a service (offerer polls this)
*/
async getServiceAnswer(serviceUuid: string): Promise<{ sdp: string; offerId: string } | null> {
const response = await fetch(`${this.baseUrl}/services/${serviceUuid}/answer`, {
headers: this.getAuthHeader(),
})
if (!response.ok) {
// 404 means not yet answered
if (response.status === 404) {
return null
}
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to get answer: ${error.error || response.statusText}`)
}
const data = await response.json()
return { sdp: data.sdp, offerId: data.offerId }
}
/**
* Search offers by topic
*/
async searchOffers(topic: string): Promise<Offer[]> {
const response = await fetch(`${this.baseUrl}/offers?topic=${encodeURIComponent(topic)}`, {
headers: this.getAuthHeader(),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to search offers: ${error.error || response.statusText}`)
}
return await response.json()
}
// ============================================
// ICE Candidates
// ============================================
/**
* Add ICE candidates to a service
*/
async addServiceIceCandidates(serviceUuid: string, candidates: RTCIceCandidateInit[], offerId?: string): Promise<{ offerId: string }> {
const response = await fetch(`${this.baseUrl}/services/${serviceUuid}/ice-candidates`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader(),
},
body: JSON.stringify({ candidates, offerId }),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to add ICE candidates: ${error.error || response.statusText}`)
}
return await response.json()
}
/**
* Get ICE candidates for a service (with polling support)
*/
async getServiceIceCandidates(serviceUuid: string, since: number = 0, offerId?: string): Promise<{ candidates: IceCandidate[]; offerId: string }> {
const url = new URL(`${this.baseUrl}/services/${serviceUuid}/ice-candidates`)
url.searchParams.set('since', since.toString())
if (offerId) {
url.searchParams.set('offerId', offerId)
}
const response = await fetch(url.toString(), { headers: this.getAuthHeader() })
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to get ICE candidates: ${error.error || response.statusText}`)
}
const data = await response.json()
return {
candidates: data.candidates || [],
offerId: data.offerId
}
}
// ============================================
// Services
// ============================================
/**
* Publish a service
*/
async publishService(service: ServiceRequest): Promise<Service> {
const response = await fetch(`${this.baseUrl}/users/${encodeURIComponent(service.username)}/services`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader(),
},
body: JSON.stringify(service),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to publish service: ${error.error || response.statusText}`)
}
return await response.json()
}
/**
* Get service by UUID
*/
async getService(uuid: string): Promise<Service & { offerId: string; sdp: string }> {
const response = await fetch(`${this.baseUrl}/services/${uuid}`, {
headers: this.getAuthHeader(),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to get service: ${error.error || response.statusText}`)
}
return await response.json()
}
/**
* Search services by username - lists all services for a username
*/
async searchServicesByUsername(username: string): Promise<Service[]> {
const response = await fetch(
`${this.baseUrl}/users/${encodeURIComponent(username)}/services`,
{ headers: this.getAuthHeader() }
)
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to search services: ${error.error || response.statusText}`)
}
const data = await response.json()
return data.services || []
}
/**
* Search services by username AND FQN - returns full service details
*/
async searchServices(username: string, serviceFqn: string): Promise<Service[]> {
const response = await fetch(
`${this.baseUrl}/users/${encodeURIComponent(username)}/services/${encodeURIComponent(serviceFqn)}`,
{ headers: this.getAuthHeader() }
)
if (!response.ok) {
if (response.status === 404) {
return []
}
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to search services: ${error.error || response.statusText}`)
}
const service = await response.json()
return [service]
}
// ============================================
// Usernames
// ============================================
/**
* Check if username is available
*/
async checkUsername(username: string): Promise<{ available: boolean; owner?: string }> {
const response = await fetch(
`${this.baseUrl}/users/${encodeURIComponent(username)}`
)
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to check username: ${error.error || response.statusText}`)
}
return await response.json()
}
/**
* Claim a username (requires Ed25519 signature)
*/
async claimUsername(
username: string,
publicKey: string,
signature: string,
message: string
): Promise<{ success: boolean; username: string }> {
const response = await fetch(`${this.baseUrl}/users/${encodeURIComponent(username)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader(),
},
body: JSON.stringify({
publicKey,
signature,
message,
}),
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to claim username: ${error.error || response.statusText}`)
}
return await response.json()
}
}

42
src/bin.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* Binnable - A cleanup function that can be synchronous or asynchronous
*
* Used to unsubscribe from events, close connections, or perform other cleanup operations.
*/
export type Binnable = () => void | Promise<void>
/**
* Create a cleanup function collector (garbage bin)
*
* Collects cleanup functions and provides a single `clean()` method to execute all of them.
* Useful for managing multiple cleanup operations in a single place.
*
* @returns A function that accepts cleanup functions and has a `clean()` method
*
* @example
* ```typescript
* const bin = createBin();
*
* // Add cleanup functions
* bin(
* () => console.log('Cleanup 1'),
* () => connection.close(),
* () => clearInterval(timer)
* );
*
* // Later, clean everything
* bin.clean(); // Executes all cleanup functions
* ```
*/
export const createBin = () => {
const bin: Binnable[] = []
return Object.assign((...rubbish: Binnable[]) => bin.push(...rubbish), {
/**
* Execute all cleanup functions and clear the bin
*/
clean: (): void => {
bin.forEach(binnable => binnable())
bin.length = 0
},
})
}

View File

@@ -1,9 +0,0 @@
/**
* ConnectionManager - Manages WebRTC peer connections
*/
export class ConnectionManager {
constructor() {
// TODO: Initialize connection manager
}
}

290
src/durable-connection.ts Normal file
View File

@@ -0,0 +1,290 @@
import {
ConnectionEvents,
ConnectionInterface,
ConnectionStates,
isConnectionState,
Message,
QueueMessageOptions,
Signaler,
} from './types.js'
import { EventBus } from './event-bus.js'
import { createBin } from './bin.js'
import { WebRTCContext } from './webrtc-context'
export type WebRTCRondevuConnectionOptions = {
offer?: RTCSessionDescriptionInit | null
context: WebRTCContext
signaler: Signaler
}
/**
* WebRTCRondevuConnection - WebRTC peer connection wrapper with Rondevu signaling
*
* Manages a WebRTC peer connection lifecycle including:
* - Automatic offer/answer creation based on role
* - ICE candidate exchange via Rondevu signaling server
* - Connection state management with type-safe events
* - Data channel creation and message handling
*
* The connection automatically determines its role (offerer or answerer) based on whether
* an offer is provided in the constructor. The offerer creates the data channel, while
* the answerer receives it via the 'datachannel' event.
*
* @example
* ```typescript
* // Offerer side (creates offer)
* const connection = new WebRTCRondevuConnection(
* 'conn-123',
* 'peer-username',
* 'chat.service@1.0.0'
* );
*
* await connection.ready; // Wait for local offer
* const sdp = connection.connection.localDescription!.sdp!;
* // Send sdp to signaling server...
*
* // Answerer side (receives offer)
* const connection = new WebRTCRondevuConnection(
* 'conn-123',
* 'peer-username',
* 'chat.service@1.0.0',
* { type: 'offer', sdp: remoteOfferSdp }
* );
*
* await connection.ready; // Wait for local answer
* const answerSdp = connection.connection.localDescription!.sdp!;
* // Send answer to signaling server...
*
* // Both sides: Set up signaler and listen for state changes
* connection.setSignaler(signaler);
* connection.events.on('state-change', (state) => {
* console.log('Connection state:', state);
* });
* ```
*/
export class RTCDurableConnection implements ConnectionInterface {
private readonly side: 'offer' | 'answer'
public readonly expiresAt: number = 0
public readonly lastActive: number = 0
public readonly events: EventBus<ConnectionEvents> = new EventBus()
public readonly ready: Promise<void>
private iceBin = createBin()
private context: WebRTCContext
private readonly signaler: Signaler
private _conn: RTCPeerConnection | null = null
private _state: ConnectionInterface['state'] = 'disconnected'
private _dataChannel: RTCDataChannel | null = null
private messageQueue: Array<{
message: Message
options: QueueMessageOptions
timestamp: number
}> = []
constructor({ context, offer, signaler }: WebRTCRondevuConnectionOptions) {
this.context = context
this.signaler = signaler
this._conn = context.createPeerConnection()
this.side = offer ? 'answer' : 'offer'
// setup data channel
if (offer) {
this._conn.addEventListener('datachannel', e => {
this._dataChannel = e.channel
this.setupDataChannelListeners(this._dataChannel)
})
} else {
this._dataChannel = this._conn.createDataChannel('vu.ronde.protocol')
this.setupDataChannelListeners(this._dataChannel)
}
// setup description exchange
this.ready = offer
? this._conn
.setRemoteDescription(offer)
.then(() => this._conn?.createAnswer())
.then(async answer => {
if (!answer || !this._conn) throw new Error('Connection disappeared')
await this._conn.setLocalDescription(answer)
return await signaler.setAnswer(answer)
})
: this._conn.createOffer().then(async offer => {
if (!this._conn) throw new Error('Connection disappeared')
await this._conn.setLocalDescription(offer)
return await signaler.setOffer(offer)
})
// propagate connection state changes
this._conn.addEventListener('connectionstatechange', () => {
console.log(this.side, 'connection state changed: ', this._conn!.connectionState)
const state = isConnectionState(this._conn!.connectionState)
? this._conn!.connectionState
: 'disconnected'
this.setState(state)
})
this._conn.addEventListener('iceconnectionstatechange', () => {
console.log(this.side, 'ice connection state changed: ', this._conn!.iceConnectionState)
})
// start ICE candidate exchange when gathering begins
this._conn.addEventListener('icegatheringstatechange', () => {
if (this._conn!.iceGatheringState === 'gathering') {
this.startIce()
} else if (this._conn!.iceGatheringState === 'complete') {
this.stopIce()
}
})
}
/**
* Getter method for retrieving the current connection.
*
* @return {RTCPeerConnection|null} The current connection instance.
*/
public get connection(): RTCPeerConnection | null {
return this._conn
}
/**
* Update connection state and emit state-change event
*/
private setState(state: ConnectionInterface['state']) {
this._state = state
this.events.emit('state-change', state)
}
/**
* Start ICE candidate exchange when gathering begins
*/
private startIce() {
const listener = ({ candidate }: { candidate: RTCIceCandidate | null }) => {
if (candidate) this.signaler.addIceCandidate(candidate)
}
if (!this._conn) throw new Error('Connection disappeared')
this._conn.addEventListener('icecandidate', listener)
this.iceBin(
this.signaler.addListener((candidate: RTCIceCandidate) =>
this._conn?.addIceCandidate(candidate)
),
() => this._conn?.removeEventListener('icecandidate', listener)
)
}
/**
* Stop ICE candidate exchange when gathering completes
*/
private stopIce() {
this.iceBin.clean()
}
/**
* Disconnects the current connection and cleans up resources.
* Closes the active connection if it exists, resets the connection instance to null,
* stops the ICE process, and updates the state to 'disconnected'.
*
* @return {void} No return value.
*/
disconnect(): void {
this._conn?.close()
this._conn = null
this.stopIce()
this.setState('disconnected')
}
/**
* Current connection state
*/
get state() {
return this._state
}
/**
* Setup data channel event listeners
*/
private setupDataChannelListeners(channel: RTCDataChannel): void {
channel.addEventListener('message', e => {
this.events.emit('message', e.data)
})
channel.addEventListener('open', () => {
// Channel opened - flush queued messages
this.flushQueue().catch(err => {
console.error('Failed to flush message queue:', err)
})
})
channel.addEventListener('error', err => {
console.error('Data channel error:', err)
})
channel.addEventListener('close', () => {
console.log('Data channel closed')
})
}
/**
* Flush the message queue
*/
private async flushQueue(): Promise<void> {
while (this.messageQueue.length > 0 && this._state === 'connected') {
const item = this.messageQueue.shift()!
// Check expiration
if (item.options.expiresAt && Date.now() > item.options.expiresAt) {
continue
}
const success = await this.sendMessage(item.message)
if (!success) {
// Re-queue on failure
this.messageQueue.unshift(item)
break
}
}
}
/**
* Queue a message for sending when connection is established
*
* @param message - Message to queue (string or ArrayBuffer)
* @param options - Queue options (e.g., expiration time)
*/
async queueMessage(message: Message, options: QueueMessageOptions = {}): Promise<void> {
this.messageQueue.push({
message,
options,
timestamp: Date.now()
})
// Try immediate send if connected
if (this._state === 'connected') {
await this.flushQueue()
}
}
/**
* Send a message immediately
*
* @param message - Message to send (string or ArrayBuffer)
* @returns Promise resolving to true if sent successfully
*/
async sendMessage(message: Message): Promise<boolean> {
if (this._state !== 'connected' || !this._dataChannel) {
return false
}
if (this._dataChannel.readyState !== 'open') {
return false
}
try {
// TypeScript has trouble with the union type, so we cast to any
// Both string and ArrayBuffer are valid for RTCDataChannel.send()
this._dataChannel.send(message as any)
return true
} catch (err) {
console.error('Send failed:', err)
return false
}
}
}

View File

@@ -2,7 +2,7 @@
* Type-safe EventBus with event name to payload type mapping * Type-safe EventBus with event name to payload type mapping
*/ */
type EventHandler<T = any> = (data: T) => void; type EventHandler<T = any> = (data: T) => void
/** /**
* EventBus - Type-safe event emitter with inferred event data types * EventBus - Type-safe event emitter with inferred event data types
@@ -27,78 +27,68 @@ type EventHandler<T = any> = (data: T) => void;
* }); * });
*/ */
export class EventBus<TEvents extends Record<string, any>> { export class EventBus<TEvents extends Record<string, any>> {
private handlers: Map<keyof TEvents, Set<EventHandler>>; private handlers: Map<keyof TEvents, Set<EventHandler>>
constructor() { constructor() {
this.handlers = new Map(); this.handlers = new Map()
}
/**
* Subscribe to an event
*/
on<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
} }
this.handlers.get(event)!.add(handler);
}
/** /**
* Subscribe to an event once (auto-unsubscribe after first call) * Subscribe to an event
*/ * Returns a cleanup function to unsubscribe
once<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): void { */
const wrappedHandler = (data: TEvents[K]) => { on<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): () => void {
handler(data); if (!this.handlers.has(event)) {
this.off(event, wrappedHandler); this.handlers.set(event, new Set())
}; }
this.on(event, wrappedHandler); this.handlers.get(event)!.add(handler)
}
/** // Return cleanup function
* Unsubscribe from an event return () => this.off(event, handler)
*/
off<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): void {
const eventHandlers = this.handlers.get(event);
if (eventHandlers) {
eventHandlers.delete(handler);
if (eventHandlers.size === 0) {
this.handlers.delete(event);
}
} }
}
/** /**
* Emit an event with data * Subscribe to an event once (auto-unsubscribe after first call)
*/ */
emit<K extends keyof TEvents>(event: K, data: TEvents[K]): void { once<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): void {
const eventHandlers = this.handlers.get(event); const wrappedHandler = (data: TEvents[K]) => {
if (eventHandlers) { handler(data)
eventHandlers.forEach(handler => handler(data)); this.off(event, wrappedHandler)
}
this.on(event, wrappedHandler)
} }
}
/** /**
* Remove all handlers for a specific event, or all handlers if no event specified * Unsubscribe from an event
*/ */
clear<K extends keyof TEvents>(event?: K): void { off<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): void {
if (event !== undefined) { const eventHandlers = this.handlers.get(event)
this.handlers.delete(event); if (eventHandlers) {
} else { eventHandlers.delete(handler)
this.handlers.clear(); if (eventHandlers.size === 0) {
this.handlers.delete(event)
}
}
} }
}
/** /**
* Get count of handlers for an event * Emit an event with data
*/ */
listenerCount<K extends keyof TEvents>(event: K): number { emit<K extends keyof TEvents>(event: K, data: TEvents[K]): void {
return this.handlers.get(event)?.size ?? 0; const eventHandlers = this.handlers.get(event)
} if (eventHandlers) {
eventHandlers.forEach(handler => handler(data))
}
}
/** /**
* Get all event names that have handlers * Remove all handlers for a specific event, or all handlers if no event specified
*/ */
eventNames(): Array<keyof TEvents> { clear<K extends keyof TEvents>(event?: K): void {
return Array.from(this.handlers.keys()); if (event !== undefined) {
} this.handlers.delete(event)
} } else {
this.handlers.clear()
}
}
}

View File

@@ -3,14 +3,42 @@
* WebRTC peer signaling client * WebRTC peer signaling client
*/ */
export { ConnectionManager } from './connection-manager.js'; export { EventBus } from './event-bus.js'
export { EventBus } from './event-bus.js'; export { RondevuAPI } from './api.js'
export { RondevuService } from './rondevu-service.js'
export { RondevuSignaler } from './rondevu-signaler.js'
export { WebRTCContext } from './webrtc-context.js'
export { RTCDurableConnection } from './durable-connection'
export { ServiceHost } from './service-host.js'
export { ServiceClient } from './service-client.js'
export { createBin } from './bin.js'
// Export types // Export types
export type { export type {
ConnectionIdentity, ConnectionInterface,
ConnectionState, QueueMessageOptions,
ConnectionInterface, Message,
Connection, ConnectionEvents,
QueueMessageOptions Signaler,
} from './types.js'; } from './types.js'
export type {
Credentials,
Keypair,
OfferRequest,
Offer,
ServiceRequest,
Service,
IceCandidate,
} from './api.js'
export type { Binnable } from './bin.js'
export type { RondevuServiceOptions, PublishServiceOptions } from './rondevu-service.js'
export type { ServiceHostOptions, ServiceHostEvents } from './service-host.js'
export type { ServiceClientOptions, ServiceClientEvents } from './service-client.js'
export type { PollingConfig } from './rondevu-signaler.js'

175
src/rondevu-service.ts Normal file
View File

@@ -0,0 +1,175 @@
import { RondevuAPI, Credentials, Keypair, Service, ServiceRequest } from './api.js'
export interface RondevuServiceOptions {
apiUrl: string
username: string
keypair?: Keypair
credentials?: Credentials
}
export interface PublishServiceOptions {
serviceFqn: string
offers: Array<{ sdp: string }>
ttl?: number
isPublic?: boolean
metadata?: Record<string, any>
}
/**
* RondevuService - High-level service management with automatic signature handling
*
* Provides a simplified API for:
* - Username claiming with Ed25519 signatures
* - Service publishing with automatic signature generation
* - Keypair management
*
* @example
* ```typescript
* // Initialize service (generates keypair automatically)
* const service = new RondevuService({
* apiUrl: 'https://signal.example.com',
* username: 'myusername',
* })
*
* await service.initialize()
*
* // Claim username (one time)
* await service.claimUsername()
*
* // Publish a service
* const publishedService = await service.publishService({
* serviceFqn: 'chat.app@1.0.0',
* offers: [{ sdp: offerSdp }],
* ttl: 300000,
* isPublic: true,
* })
* ```
*/
export class RondevuService {
private readonly api: RondevuAPI
private readonly username: string
private keypair: Keypair | null = null
private usernameClaimed = false
constructor(options: RondevuServiceOptions) {
this.username = options.username
this.keypair = options.keypair || null
this.api = new RondevuAPI(options.apiUrl, options.credentials)
}
/**
* Initialize the service - generates keypair if not provided
* Call this before using other methods
*/
async initialize(): Promise<void> {
if (!this.keypair) {
this.keypair = await RondevuAPI.generateKeypair()
}
// Register with API if no credentials provided
if (!this.api['credentials']) {
const credentials = await this.api.register()
this.api.setCredentials(credentials)
}
}
/**
* Claim the username with Ed25519 signature
* Should be called once before publishing services
*/
async claimUsername(): Promise<void> {
if (!this.keypair) {
throw new Error('Service not initialized. Call initialize() first.')
}
// Check if username is already claimed
const check = await this.api.checkUsername(this.username)
if (!check.available) {
// Verify it's claimed by us
if (check.owner === this.keypair.publicKey) {
this.usernameClaimed = true
return
}
throw new Error(`Username "${this.username}" is already claimed by another user`)
}
// Generate signature for username claim
const message = `claim:${this.username}:${Date.now()}`
const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey)
// Claim the username
await this.api.claimUsername(this.username, this.keypair.publicKey, signature, message)
this.usernameClaimed = true
}
/**
* Publish a service with automatic signature generation
*/
async publishService(options: PublishServiceOptions): Promise<Service> {
if (!this.keypair) {
throw new Error('Service not initialized. Call initialize() first.')
}
if (!this.usernameClaimed) {
throw new Error(
'Username not claimed. Call claimUsername() first or the server will reject the service.'
)
}
const { serviceFqn, offers, ttl, isPublic, metadata } = options
// Generate signature for service publication
const message = `publish:${this.username}:${serviceFqn}:${Date.now()}`
const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey)
// Create service request
const serviceRequest: ServiceRequest = {
username: this.username,
serviceFqn,
offers,
signature,
message,
ttl,
isPublic,
metadata,
}
// Publish to server
return await this.api.publishService(serviceRequest)
}
/**
* Get the current keypair (for backup/storage)
*/
getKeypair(): Keypair | null {
return this.keypair
}
/**
* Get the username
*/
getUsername(): string {
return this.username
}
/**
* Get the public key
*/
getPublicKey(): string | null {
return this.keypair?.publicKey || null
}
/**
* Check if username has been claimed
*/
isUsernameClaimed(): boolean {
return this.usernameClaimed
}
/**
* Access to underlying API for advanced operations
*/
getAPI(): RondevuAPI {
return this.api
}
}

462
src/rondevu-signaler.ts Normal file
View File

@@ -0,0 +1,462 @@
import { Signaler } from './types.js'
import { RondevuService } from './rondevu-service.js'
import { Binnable } from './bin.js'
export interface PollingConfig {
initialInterval?: number // Default: 500ms
maxInterval?: number // Default: 5000ms
backoffMultiplier?: number // Default: 1.5
maxRetries?: number // Default: 50 (50 seconds max)
jitter?: boolean // Default: true
}
/**
* RondevuSignaler - Handles WebRTC signaling via Rondevu service
*
* Manages offer/answer exchange and ICE candidate polling for establishing
* WebRTC connections through the Rondevu signaling server.
*
* Supports configurable polling with exponential backoff and jitter to reduce
* server load and prevent thundering herd issues.
*
* @example
* ```typescript
* const signaler = new RondevuSignaler(
* rondevuService,
* 'chat.app@1.0.0',
* 'peer-username',
* { initialInterval: 500, maxInterval: 5000, jitter: true }
* )
*
* // For offerer:
* await signaler.setOffer(offer)
* signaler.addAnswerListener(answer => {
* // Handle remote answer
* })
*
* // For answerer:
* signaler.addOfferListener(offer => {
* // Handle remote offer
* })
* await signaler.setAnswer(answer)
* ```
*/
export class RondevuSignaler implements Signaler {
private offerId: string | null = null
private serviceUuid: string | null = null
private offerListeners: Array<(offer: RTCSessionDescriptionInit) => void> = []
private answerListeners: Array<(answer: RTCSessionDescriptionInit) => void> = []
private iceListeners: Array<(candidate: RTCIceCandidate) => void> = []
private answerPollingTimeout: ReturnType<typeof setTimeout> | null = null
private icePollingTimeout: ReturnType<typeof setTimeout> | null = null
private lastIceTimestamp = 0
private isPolling = false
private pollingConfig: Required<PollingConfig>
constructor(
private readonly rondevu: RondevuService,
private readonly service: string,
private readonly host?: string,
pollingConfig?: PollingConfig
) {
this.pollingConfig = {
initialInterval: pollingConfig?.initialInterval ?? 500,
maxInterval: pollingConfig?.maxInterval ?? 5000,
backoffMultiplier: pollingConfig?.backoffMultiplier ?? 1.5,
maxRetries: pollingConfig?.maxRetries ?? 50,
jitter: pollingConfig?.jitter ?? true
}
}
/**
* Publish an offer as a service
* Used by the offerer to make their offer available
*/
async setOffer(offer: RTCSessionDescriptionInit): Promise<void> {
if (!offer.sdp) {
throw new Error('Offer SDP is required')
}
// Publish service with the offer SDP
const publishedService = await this.rondevu.publishService({
serviceFqn: this.service,
offers: [{ sdp: offer.sdp }],
ttl: 300000, // 5 minutes
isPublic: true,
})
// Get the first offer from the published service
if (!publishedService.offers || publishedService.offers.length === 0) {
throw new Error('No offers returned from service publication')
}
this.offerId = publishedService.offers[0].offerId
this.serviceUuid = publishedService.uuid
// Start polling for answer
this.startAnswerPolling()
// Start polling for ICE candidates
this.startIcePolling()
}
/**
* Send an answer to the offerer
* Used by the answerer to respond to an offer
*/
async setAnswer(answer: RTCSessionDescriptionInit): Promise<void> {
if (!answer.sdp) {
throw new Error('Answer SDP is required')
}
if (!this.serviceUuid) {
throw new Error('No service UUID available. Must receive offer first.')
}
// Send answer to the service
const result = await this.rondevu.getAPI().answerService(this.serviceUuid, answer.sdp)
this.offerId = result.offerId
// Start polling for ICE candidates
this.startIcePolling()
}
/**
* Listen for incoming offers
* Used by the answerer to receive offers from the offerer
*/
addOfferListener(callback: (offer: RTCSessionDescriptionInit) => void): Binnable {
this.offerListeners.push(callback)
// If we have a host, start searching for their service
if (this.host && !this.isPolling) {
this.searchForOffer()
}
// Return cleanup function
return () => {
const index = this.offerListeners.indexOf(callback)
if (index > -1) {
this.offerListeners.splice(index, 1)
}
}
}
/**
* Listen for incoming answers
* Used by the offerer to receive the answer from the answerer
*/
addAnswerListener(callback: (answer: RTCSessionDescriptionInit) => void): Binnable {
this.answerListeners.push(callback)
// Return cleanup function
return () => {
const index = this.answerListeners.indexOf(callback)
if (index > -1) {
this.answerListeners.splice(index, 1)
}
}
}
/**
* Send an ICE candidate to the remote peer
*/
async addIceCandidate(candidate: RTCIceCandidate): Promise<void> {
if (!this.serviceUuid) {
console.warn('Cannot send ICE candidate: no service UUID')
return
}
const candidateData = candidate.toJSON()
// Skip empty candidates
if (!candidateData.candidate || candidateData.candidate === '') {
return
}
try {
const result = await this.rondevu.getAPI().addServiceIceCandidates(
this.serviceUuid,
[candidateData],
this.offerId || undefined
)
// Store offerId if we didn't have it yet
if (!this.offerId) {
this.offerId = result.offerId
}
} catch (err) {
console.error('Failed to send ICE candidate:', err)
}
}
/**
* Listen for ICE candidates from the remote peer
*/
addListener(callback: (candidate: RTCIceCandidate) => void): Binnable {
this.iceListeners.push(callback)
// Return cleanup function
return () => {
const index = this.iceListeners.indexOf(callback)
if (index > -1) {
this.iceListeners.splice(index, 1)
}
}
}
/**
* Search for an offer from the host
* Used by the answerer to find the offerer's service
*/
private async searchForOffer(): Promise<void> {
if (!this.host) {
throw new Error('No host specified for offer search')
}
this.isPolling = true
try {
// Search for services by username and service FQN
const services = await this.rondevu.getAPI().searchServices(this.host, this.service)
if (services.length === 0) {
console.warn(`No services found for ${this.host}/${this.service}`)
this.isPolling = false
return
}
// Get the first available service (already has full details from searchServices)
const service = services[0] as any
// Get the first available offer from the service
if (!service.offers || service.offers.length === 0) {
console.warn(`No offers available for service ${this.host}/${this.service}`)
this.isPolling = false
return
}
const firstOffer = service.offers[0]
this.offerId = firstOffer.offerId
this.serviceUuid = service.uuid
// Notify offer listeners
const offer: RTCSessionDescriptionInit = {
type: 'offer',
sdp: firstOffer.sdp,
}
this.offerListeners.forEach(listener => {
try {
listener(offer)
} catch (err) {
console.error('Offer listener error:', err)
}
})
} catch (err) {
console.error('Failed to search for offer:', err)
this.isPolling = false
}
}
/**
* Start polling for answer (offerer side) with exponential backoff
*/
private startAnswerPolling(): void {
if (this.answerPollingTimeout || !this.serviceUuid) {
return
}
let interval = this.pollingConfig.initialInterval
let retries = 0
const poll = async () => {
if (!this.serviceUuid) {
this.stopAnswerPolling()
return
}
try {
const answer = await this.rondevu.getAPI().getServiceAnswer(this.serviceUuid)
if (answer && answer.sdp) {
// Store offerId if we didn't have it yet
if (!this.offerId) {
this.offerId = answer.offerId
}
// Got answer - notify listeners and stop polling
const answerDesc: RTCSessionDescriptionInit = {
type: 'answer',
sdp: answer.sdp,
}
this.answerListeners.forEach(listener => {
try {
listener(answerDesc)
} catch (err) {
console.error('Answer listener error:', err)
}
})
// Stop polling once we get the answer
this.stopAnswerPolling()
return
}
// No answer yet - exponential backoff
retries++
if (retries > this.pollingConfig.maxRetries) {
console.warn('Max retries reached for answer polling')
this.stopAnswerPolling()
return
}
interval = Math.min(
interval * this.pollingConfig.backoffMultiplier,
this.pollingConfig.maxInterval
)
// Add jitter to prevent thundering herd
const finalInterval = this.pollingConfig.jitter
? interval + Math.random() * 100
: interval
this.answerPollingTimeout = setTimeout(poll, finalInterval)
} catch (err) {
// 404 is expected when answer isn't available yet
if (err instanceof Error && !err.message?.includes('404')) {
console.error('Error polling for answer:', err)
}
// Retry with backoff
const finalInterval = this.pollingConfig.jitter
? interval + Math.random() * 100
: interval
this.answerPollingTimeout = setTimeout(poll, finalInterval)
}
}
poll() // Start immediately
}
/**
* Stop polling for answer
*/
private stopAnswerPolling(): void {
if (this.answerPollingTimeout) {
clearTimeout(this.answerPollingTimeout)
this.answerPollingTimeout = null
}
}
/**
* Start polling for ICE candidates with adaptive backoff
*/
private startIcePolling(): void {
if (this.icePollingTimeout || !this.serviceUuid) {
return
}
let interval = this.pollingConfig.initialInterval
const poll = async () => {
if (!this.serviceUuid) {
this.stopIcePolling()
return
}
try {
const result = await this.rondevu
.getAPI()
.getServiceIceCandidates(this.serviceUuid, this.lastIceTimestamp, this.offerId || undefined)
// Store offerId if we didn't have it yet
if (!this.offerId) {
this.offerId = result.offerId
}
let foundCandidates = false
for (const item of result.candidates) {
if (item.candidate && item.candidate.candidate && item.candidate.candidate !== '') {
foundCandidates = true
try {
const rtcCandidate = new RTCIceCandidate(item.candidate)
this.iceListeners.forEach(listener => {
try {
listener(rtcCandidate)
} catch (err) {
console.error('ICE listener error:', err)
}
})
this.lastIceTimestamp = item.createdAt
} catch (err) {
console.warn('Failed to process ICE candidate:', err)
this.lastIceTimestamp = item.createdAt
}
} else {
this.lastIceTimestamp = item.createdAt
}
}
// If candidates found, reset interval to initial value
// Otherwise, increase interval with backoff
if (foundCandidates) {
interval = this.pollingConfig.initialInterval
} else {
interval = Math.min(
interval * this.pollingConfig.backoffMultiplier,
this.pollingConfig.maxInterval
)
}
// Add jitter
const finalInterval = this.pollingConfig.jitter
? interval + Math.random() * 100
: interval
this.icePollingTimeout = setTimeout(poll, finalInterval)
} catch (err) {
// 404/410 means offer expired, stop polling
if (err instanceof Error && (err.message?.includes('404') || err.message?.includes('410'))) {
console.warn('Offer not found or expired, stopping ICE polling')
this.stopIcePolling()
} else if (err instanceof Error && !err.message?.includes('404')) {
console.error('Error polling for ICE candidates:', err)
// Continue polling despite errors
const finalInterval = this.pollingConfig.jitter
? interval + Math.random() * 100
: interval
this.icePollingTimeout = setTimeout(poll, finalInterval)
}
}
}
poll() // Start immediately
}
/**
* Stop polling for ICE candidates
*/
private stopIcePolling(): void {
if (this.icePollingTimeout) {
clearTimeout(this.icePollingTimeout)
this.icePollingTimeout = null
}
}
/**
* Stop all polling and cleanup
*/
dispose(): void {
this.stopAnswerPolling()
this.stopIcePolling()
this.offerListeners = []
this.answerListeners = []
this.iceListeners = []
}
}

203
src/service-client.ts Normal file
View File

@@ -0,0 +1,203 @@
import { RondevuService } from './rondevu-service.js'
import { RondevuSignaler } from './rondevu-signaler.js'
import { WebRTCContext } from './webrtc-context.js'
import { RTCDurableConnection } from './durable-connection.js'
import { EventBus } from './event-bus.js'
export interface ServiceClientOptions {
username: string // Host username
serviceFqn: string // e.g., 'chat.app@1.0.0'
rondevuService: RondevuService
autoReconnect?: boolean // Default: true
maxReconnectAttempts?: number // Default: 5
rtcConfiguration?: RTCConfiguration
}
export interface ServiceClientEvents {
connected: RTCDurableConnection
disconnected: void
reconnecting: { attempt: number; maxAttempts: number }
error: Error
}
/**
* ServiceClient - High-level wrapper for connecting to a WebRTC service
*
* Simplifies client connection by handling:
* - Service discovery
* - Offer/answer exchange
* - ICE candidate polling
* - Automatic reconnection
*
* @example
* ```typescript
* const client = new ServiceClient({
* username: 'host-user',
* serviceFqn: 'chat.app@1.0.0',
* rondevuService: myService
* })
*
* client.events.on('connected', conn => {
* conn.events.on('message', msg => console.log('Received:', msg))
* conn.sendMessage('Hello from client!')
* })
*
* await client.connect()
* ```
*/
export class ServiceClient {
events: EventBus<ServiceClientEvents>
private signaler: RondevuSignaler | null = null
private webrtcContext: WebRTCContext
private connection: RTCDurableConnection | null = null
private autoReconnect: boolean
private maxReconnectAttempts: number
private reconnectAttempts = 0
private isConnecting = false
constructor(private options: ServiceClientOptions) {
this.events = new EventBus<ServiceClientEvents>()
this.webrtcContext = new WebRTCContext(options.rtcConfiguration)
this.autoReconnect = options.autoReconnect !== undefined ? options.autoReconnect : true
this.maxReconnectAttempts = options.maxReconnectAttempts || 5
}
/**
* Connect to the service
*/
async connect(): Promise<RTCDurableConnection> {
if (this.isConnecting) {
throw new Error('Connection already in progress')
}
if (this.connection) {
throw new Error('Already connected. Disconnect first.')
}
this.isConnecting = true
try {
// Create signaler
this.signaler = new RondevuSignaler(
this.options.rondevuService,
this.options.serviceFqn,
this.options.username
)
// Wait for remote offer from signaler
const remoteOffer = await new Promise<RTCSessionDescriptionInit>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Service discovery timeout'))
}, 30000)
this.signaler!.addOfferListener((offer) => {
clearTimeout(timeout)
resolve(offer)
})
})
// Create connection with remote offer (makes us the answerer)
const connection = new RTCDurableConnection({
context: this.webrtcContext,
signaler: this.signaler,
offer: remoteOffer
})
// Wait for connection to be ready
await connection.ready
// Set up connection event listeners
connection.events.on('state-change', (state) => {
if (state === 'connected') {
this.reconnectAttempts = 0
this.events.emit('connected', connection)
} else if (state === 'disconnected') {
this.events.emit('disconnected', undefined)
if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
this.attemptReconnect()
}
}
})
this.connection = connection
this.isConnecting = false
return connection
} catch (err) {
this.isConnecting = false
const error = err instanceof Error ? err : new Error(String(err))
this.events.emit('error', error)
throw error
}
}
/**
* Disconnect from the service
*/
dispose(): void {
if (this.signaler) {
this.signaler.dispose()
this.signaler = null
}
if (this.connection) {
this.connection.disconnect()
this.connection = null
}
this.isConnecting = false
this.reconnectAttempts = 0
}
/**
* @deprecated Use dispose() instead
*/
disconnect(): void {
this.dispose()
}
/**
* Attempt to reconnect
*/
private async attemptReconnect(): Promise<void> {
this.reconnectAttempts++
this.events.emit('reconnecting', {
attempt: this.reconnectAttempts,
maxAttempts: this.maxReconnectAttempts
})
// Cleanup old connection
if (this.signaler) {
this.signaler.dispose()
this.signaler = null
}
if (this.connection) {
this.connection = null
}
// Wait a bit before reconnecting
await new Promise(resolve => setTimeout(resolve, 1000 * this.reconnectAttempts))
try {
await this.connect()
} catch (err) {
console.error('Reconnection attempt failed:', err)
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.attemptReconnect()
} else {
const error = new Error('Max reconnection attempts reached')
this.events.emit('error', error)
}
}
}
/**
* Get the current connection
*/
getConnection(): RTCDurableConnection | null {
return this.connection
}
}

158
src/service-host.ts Normal file
View File

@@ -0,0 +1,158 @@
import { RondevuService } from './rondevu-service.js'
import { RondevuSignaler } from './rondevu-signaler.js'
import { WebRTCContext } from './webrtc-context.js'
import { RTCDurableConnection } from './durable-connection.js'
import { EventBus } from './event-bus.js'
export interface ServiceHostOptions {
service: string // e.g., 'chat.app@1.0.0'
rondevuService: RondevuService
maxPeers?: number // Default: 5
ttl?: number // Default: 300000 (5 min)
isPublic?: boolean // Default: true
rtcConfiguration?: RTCConfiguration
metadata?: Record<string, any>
}
export interface ServiceHostEvents {
connection: RTCDurableConnection
error: Error
}
/**
* ServiceHost - High-level wrapper for hosting a WebRTC service
*
* Simplifies hosting by handling:
* - Offer/answer exchange
* - ICE candidate polling
* - Connection pool management
* - Automatic reconnection
*
* @example
* ```typescript
* const host = new ServiceHost({
* service: 'chat.app@1.0.0',
* rondevuService: myService,
* maxPeers: 5
* })
*
* host.events.on('connection', conn => {
* conn.events.on('message', msg => console.log('Received:', msg))
* conn.sendMessage('Hello!')
* })
*
* await host.start()
* ```
*/
export class ServiceHost {
events: EventBus<ServiceHostEvents>
private signaler: RondevuSignaler | null = null
private webrtcContext: WebRTCContext
private connections: RTCDurableConnection[] = []
private maxPeers: number
private running = false
constructor(private options: ServiceHostOptions) {
this.events = new EventBus<ServiceHostEvents>()
this.webrtcContext = new WebRTCContext(options.rtcConfiguration)
this.maxPeers = options.maxPeers || 5
}
/**
* Start hosting the service
*/
async start(): Promise<void> {
if (this.running) {
throw new Error('ServiceHost already running')
}
this.running = true
// Create signaler
this.signaler = new RondevuSignaler(
this.options.rondevuService,
this.options.service
)
// Create first connection (offerer)
const connection = new RTCDurableConnection({
context: this.webrtcContext,
signaler: this.signaler,
offer: null // null means we're the offerer
})
// Wait for connection to be ready
await connection.ready
// Set up connection event listeners
connection.events.on('state-change', (state) => {
if (state === 'connected') {
this.connections.push(connection)
this.events.emit('connection', connection)
// Create next connection if under maxPeers
if (this.connections.length < this.maxPeers) {
this.createNextConnection().catch(err => {
console.error('Failed to create next connection:', err)
this.events.emit('error', err)
})
}
} else if (state === 'disconnected') {
// Remove from connections list
const index = this.connections.indexOf(connection)
if (index > -1) {
this.connections.splice(index, 1)
}
}
})
// Publish service with the offer
const offer = connection.connection?.localDescription
if (!offer?.sdp) {
throw new Error('Offer SDP is empty')
}
await this.signaler.setOffer(offer)
}
/**
* Create the next connection for incoming peers
*/
private async createNextConnection(): Promise<void> {
if (!this.signaler || !this.running) {
return
}
// For now, we'll use the same offer for all connections
// In a production scenario, you'd create multiple offers
// This is a limitation of the current service model
// which publishes one offer per service
}
/**
* Stop hosting the service
*/
dispose(): void {
this.running = false
// Cleanup signaler
if (this.signaler) {
this.signaler.dispose()
this.signaler = null
}
// Disconnect all connections
for (const conn of this.connections) {
conn.disconnect()
}
this.connections = []
}
/**
* Get all active connections
*/
getConnections(): RTCDurableConnection[] {
return [...this.connections]
}
}

View File

@@ -1,24 +1,44 @@
/** /**
* Core connection types * Core connection types
*/ */
import { EventBus } from './event-bus.js'
import { Binnable } from './bin.js'
export interface ConnectionIdentity { export type Message = string | ArrayBuffer
id: string;
hostUsername: string;
}
export interface ConnectionState {
state: 'connected' | 'disconnected' | 'connecting';
lastActive: number;
}
export interface QueueMessageOptions { export interface QueueMessageOptions {
expiresAt?: number; expiresAt?: number
} }
export interface ConnectionEvents {
'state-change': ConnectionInterface['state']
message: Message
}
export const ConnectionStates = [
'connected',
'disconnected',
'connecting'
] as const
export const isConnectionState = (state: string): state is (typeof ConnectionStates)[number] =>
ConnectionStates.includes(state as any)
export interface ConnectionInterface { export interface ConnectionInterface {
queueMessage(message: string | ArrayBuffer, options?: QueueMessageOptions): void; state: (typeof ConnectionStates)[number]
sendMessage(message: string | ArrayBuffer): void; lastActive: number
expiresAt?: number
events: EventBus<ConnectionEvents>
queueMessage(message: Message, options?: QueueMessageOptions): Promise<void>
sendMessage(message: Message): Promise<boolean>
} }
export type Connection = ConnectionIdentity & ConnectionState & ConnectionInterface; export interface Signaler {
addIceCandidate(candidate: RTCIceCandidate): Promise<void>
addListener(callback: (candidate: RTCIceCandidate) => void): Binnable
addOfferListener(callback: (offer: RTCSessionDescriptionInit) => void): Binnable
addAnswerListener(callback: (answer: RTCSessionDescriptionInit) => void): Binnable
setOffer(offer: RTCSessionDescriptionInit): Promise<void>
setAnswer(answer: RTCSessionDescriptionInit): Promise<void>
}

39
src/webrtc-context.ts Normal file
View File

@@ -0,0 +1,39 @@
import { Signaler } from './types'
const DEFAULT_RTC_CONFIGURATION: RTCConfiguration = {
iceServers: [
{
urls: 'stun:stun.relay.metered.ca:80',
},
{
urls: 'turn:standard.relay.metered.ca:80',
username: 'c53a9c971da5e6f3bc959d8d',
credential: 'QaccPqtPPaxyokXp',
},
{
urls: 'turn:standard.relay.metered.ca:80?transport=tcp',
username: 'c53a9c971da5e6f3bc959d8d',
credential: 'QaccPqtPPaxyokXp',
},
{
urls: 'turn:standard.relay.metered.ca:443',
username: 'c53a9c971da5e6f3bc959d8d',
credential: 'QaccPqtPPaxyokXp',
},
{
urls: 'turns:standard.relay.metered.ca:443?transport=tcp',
username: 'c53a9c971da5e6f3bc959d8d',
credential: 'QaccPqtPPaxyokXp',
},
],
}
export class WebRTCContext {
constructor(
private readonly config?: RTCConfiguration
) {}
createPeerConnection(): RTCPeerConnection {
return new RTCPeerConnection(this.config || DEFAULT_RTC_CONFIGURATION)
}
}