28 Commits

Author SHA1 Message Date
96cbff1bc8 Merge branch 'main' of github.com:xtr-dev/rondevu-client
# Conflicts:
#	package.json
2025-12-16 23:08:36 +01:00
3e55166cea Reorganize src/ directory into feature/domain-based structure
Restructure flat src/ directory (17 files) into organized folders:

Structure:
- src/core/ - Main API (rondevu.ts, offer-pool.ts, types.ts, index.ts)
- src/connections/ - WebRTC connections (base.ts, offerer.ts, answerer.ts, config.ts, events.ts)
- src/api/ - HTTP layer (client.ts, batcher.ts)
- src/crypto/ - Crypto adapters (adapter.ts, node.ts, web.ts)
- src/utils/ - Utilities (async-lock.ts, exponential-backoff.ts, message-buffer.ts)

Changes:
- Move all 17 files to appropriate feature folders
- Update all import paths to reflect new structure
- Update package.json main/types to point to dist/core/index.js
- Preserve git history with git mv

Benefits:
- Clear separation of concerns
- Easier navigation and maintenance
- Better scalability for future features
- Logical grouping of related files

🤖 Generated with Claude Code
2025-12-16 22:51:23 +01:00
121a4d490a v0.20.0: Connection persistence with offer rotation
Implement connection persistence for offerer side through "offer rotation".
When a connection fails, the same OffererConnection object is rebound to a
new offer instead of being destroyed, preserving message buffers and event
listeners.

Features:
- Connection objects persist across disconnections
- Message buffering works seamlessly through rotations
- Event listeners remain active after rotation
- New `connection:rotated` event for tracking offer changes
- Max rotation attempts limit (default: 5) with fallback

Implementation:
- Add OffererConnection.rebindToOffer() method with AsyncLock protection
- Add rotation tracking: rotating flag, rotationAttempts counter
- Add OfferPool.createNewOfferForRotation() helper method
- Modify OfferPool failure handler to rotate instead of destroy
- Add connection:rotated event to OfferPoolEvents interface
- Forward connection:rotated event in Rondevu class
- Add edge case handling for cleanup during rotation
- Reset rotation attempts on successful connection

Documentation:
- Add "Connection Persistence" section to README with examples
- Update "New in v0.20.0" feature list
- Add v0.20.0 changelog entry
- Document rotation benefits and behavior

Benefits:
- Same connection object remains usable through disconnections
- Message buffer preserved during temporary disconnections
- Event listeners don't need to be re-registered
- Simpler user code - no need to track new connections

100% backward compatible - no breaking changes.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-16 22:36:28 +01:00
c30e554525 v0.19.0: Internal refactoring for improved maintainability
Internal improvements (100% backward compatible):

- Extract OfferPool class from Rondevu for offer lifecycle management
- Consolidate ICE polling logic into base RondevuConnection class
  (removes ~86 lines of duplicate code)
- Add AsyncLock utility for race-free concurrent operations
- Disable reconnection for offerer connections (offers are ephemeral)
- Fix compilation with abstract method implementations

Architecture improvements:
- rondevu.ts: Reduced complexity by extracting OfferPool
- connection.ts: Added consolidated pollIceCandidates() implementation
- offerer-connection.ts: Force reconnectEnabled: false in constructor
- answerer-connection.ts: Implement abstract methods from base class

New files:
- src/async-lock.ts: Mutual exclusion primitive for async operations
- src/offer-pool.ts: Manages WebRTC offer lifecycle independently

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-16 22:03:32 +01:00
7c903f7e23 v0.18.11 - Restore EventEmitter-based durable connections
- Revert the v0.18.10 revert, going back to EventEmitter API
- Durable WebRTC connections with auto-reconnect and message buffering
- AnswererConnection and OffererConnection classes
- Rich event system with 20+ events
- Updated README with v0.18.11 references and changelog
2025-12-16 21:30:59 +01:00
9f6479438f Merge remote-tracking branch 'origin/main'
# Conflicts:
#	package.json
2025-12-14 22:45:26 +01:00
aef61ab67f v0.18.10 - Revert to callback-based API (pre-v0.18.8)
Remove EventEmitter-based durable connections introduced in v0.18.8/v0.18.9:
- Remove OffererConnection/AnswererConnection classes
- Remove auto-reconnect and message buffering
- Restore callback-based API with offerFactory and onConnection
- Update package description to reflect simpler API

This version returns to the stable v0.18.7 API while keeping all bug fixes.
2025-12-14 22:41:16 +01:00
cb0bbe342c Update README for v0.18.9: Document durable connections and breaking changes
- Add v0.18.9 features section highlighting durable connections
- Document breaking change: connectToService() returns AnswererConnection
- Update Quick Start examples with correct event-driven API
- Add explicit warnings about waiting for 'connected' event
- Include Connection Configuration and Events documentation
- Add Migration Guide section with upgrade instructions
- Update changelog with v0.18.9 changes

This addresses user issues where messages were buffered instead of sent
due to sending before connection established.
2025-12-14 18:27:31 +01:00
919aeb7b90 Bump version to 0.18.9
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 16:53:45 +01:00
a480fa3ba4 Add durable WebRTC connections with auto-reconnect and message buffering (v0.18.8)
- Add connection state machine with proper lifecycle management
- Implement automatic reconnection with exponential backoff
- Add message buffering during disconnections
- Create RondevuConnection base class with state tracking
- Create OffererConnection and AnswererConnection classes
- Fix ICE polling lifecycle (now stops when connected)
- Add fillOffers() semaphore to prevent exceeding maxOffers
- Implement answer fingerprinting to prevent duplicate processing
- Add dual ICE state monitoring (iceConnectionState + connectionState)
- Fix data channel handler timing issues
- Add comprehensive event system (20+ events)
- Add connection timeouts and proper cleanup

Breaking changes:
- connectToService() now returns AnswererConnection instead of ConnectionContext
- connection:opened event signature changed: (offerId, dc) → (offerId, connection)
- Direct DataChannel access replaced with connection wrapper API

See MIGRATION.md for upgrade guide.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 16:52:57 +01:00
9a4fbb63f8 v0.18.7 - Revert to v0.18.3 answer processing logic 2025-12-14 14:39:26 +01:00
f8fb842935 Revert to v0.18.3 answer processing logic
Reverted pollInternal to exactly match v0.18.3 which was the last
working version. The changes in v0.18.5 and v0.18.6 that moved the
answered flag and timestamp updates were causing issues.

v0.18.3 working logic restored:
- Check !activeOffer.answered
- Call setRemoteDescription (no try/catch)
- Set answered = true AFTER
- Update lastPollTimestamp AFTER
- No pre-emptive timestamp updates

The only difference from v0.18.3 is the eventemitter3 import which
should not affect answer processing behavior.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 14:39:15 +01:00
50d49d80d3 v0.18.6 - Fix duplicate answer bug (timestamp update) 2025-12-14 14:29:06 +01:00
b3b1751f63 Fix duplicate answer bug - update timestamp before processing
Root cause: When setRemoteDescription failed, lastPollTimestamp was
never updated, causing the server to return the same answer repeatedly.

Solution:
1. Update lastPollTimestamp BEFORE processing answers
2. Calculate max timestamp from all received answers upfront
3. Don't throw on setRemoteDescription errors - just log and continue
4. This ensures we advance the timestamp even if processing fails

This prevents the infinite loop of:
- Poll returns answer
- Processing fails
- Timestamp not updated
- Next poll returns same answer
- Repeat

Now the timestamp advances regardless of processing success,
preventing duplicate answer fetches from the server.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 14:28:57 +01:00
e48b3bb17a v0.18.5 - Fix duplicate answer processing regression 2025-12-14 14:19:44 +01:00
8f7e15e633 Fix duplicate answer processing regression
The activeOffer.answered flag was being set AFTER the async
setRemoteDescription() call, creating a race condition window
where the same answer could be processed multiple times.

Root cause:
- Check `!activeOffer.answered` happens
- setRemoteDescription() starts (async operation)
- Before it completes, another check could happen
- Same answer gets processed twice → "stable" state error

Fix:
- Set activeOffer.answered = true BEFORE setRemoteDescription
- Add try/catch to reset flag if setRemoteDescription fails
- This prevents duplicate processing while allowing retries on error

This regression was introduced when the answered flag assignment
was not moved along with other polling logic changes.

Fixes: #6 regression

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 14:19:34 +01:00
fcd0f8ead0 0.18.4 2025-12-14 14:06:57 +01:00
8fd4b249de Fix EventEmitter for cross-platform compatibility (v0.18.3)
Replace Node.js 'events' module with 'eventemitter3' package
to ensure compatibility in both browser and Node.js environments.

Changes:
- Replace import from 'events' to 'eventemitter3'
- Add eventemitter3 as dependency
- Remove @types/node (no longer needed)

Fixes browser bundling error where 'events' module was not available.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 14:05:36 +01:00
275c156c64 0.18.3 2025-12-14 13:35:45 +01:00
c60a5f332a Merge remote-tracking branch 'origin/main' 2025-12-14 13:35:38 +01:00
Bas
ecd6be7f8a Merge pull request #7 from xtr-dev/claude/fix-issue-6-CTKj9
Fix issue #6
2025-12-14 13:35:19 +01:00
Claude
e652fdc130 Fix duplicate answer processing race condition (#6)
Add polling guard to prevent concurrent pollInternal() execution.
The setInterval callback doesn't await the async pollInternal(),
which could cause multiple polls to process the same answer before
lastPollTimestamp is updated, resulting in "Called in wrong state:
stable" errors from setRemoteDescription().
2025-12-14 11:59:43 +00:00
0f469e234d 0.18.2 2025-12-14 12:41:22 +01:00
68c3ffb4ea Add DX improvements and EventEmitter support (v0.18.1)
This release introduces several developer experience improvements:

Breaking Changes:
- Add EventEmitter support - Rondevu now extends EventEmitter
- Consolidate discovery methods into findService() (getService, discoverService, discoverServices methods still exist but findService is the new unified API)

New Features:
- EventEmitter lifecycle events:
  - offer:created (offerId, serviceFqn)
  - offer:answered (offerId, peerUsername)
  - connection:opened (offerId, dataChannel)
  - connection:closed (offerId)
  - ice:candidate:local (offerId, candidate) - locally generated ICE
  - ice:candidate:remote (offerId, candidate, role) - remote ICE from server
  - error (error, context)

- Unified findService() method with modes:
  - 'direct' - direct lookup by FQN with username
  - 'random' - random discovery without username
  - 'paginated' - paginated results with limit/offset

- Typed error classes for better error handling:
  - RondevuError (base class with context)
  - NetworkError (network/API failures)
  - ValidationError (input validation)
  - ConnectionError (WebRTC connection issues)

- Convenience methods:
  - getOfferCount() - get active offer count
  - isConnected(offerId) - check connection status
  - disconnectAll() - close all connections
  - getServiceStatus() - get service state

Type Exports:
- Export ActiveOffer interface for getActiveOffers() typing
- Export FindServiceOptions, ServiceResult, PaginatedServiceResult
- Export all error classes

Dependencies:
- Add @types/node for EventEmitter support

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 12:03:41 +01:00
02e8e971be 0.18.1 2025-12-14 12:03:41 +01:00
Bas
1ca9a91056 Merge pull request #5 from xtr-dev/claude/fix-issue-2-6zYvD
Refactor OfferFactory to receive pc from Rondevu
2025-12-14 11:24:54 +01:00
claude[bot]
a0dc9ddad0 Address code review suggestions
- Update README.md example to match new OfferFactory signature
- Add error handling and RTCPeerConnection cleanup on factory failure
- Document setupIceCandidateHandler() method usage
- Use undefined instead of null for offerId variable

Co-authored-by: Bas <bvdaakster@users.noreply.github.com>
2025-12-14 10:21:43 +00:00
Claude
df231c192d Refactor OfferFactory to receive pc from Rondevu
Change the OfferFactory signature to receive the RTCPeerConnection as a
parameter instead of rtcConfig. This allows Rondevu to:

1. Create the RTCPeerConnection itself
2. Set up ICE candidate handlers BEFORE the factory runs
3. Ensure no candidates are lost when setLocalDescription() triggers
   ICE gathering

This is a cleaner fix for #2 that eliminates the race condition at the
source rather than working around it with queuing.

BREAKING CHANGE: OfferFactory signature changed from
  (rtcConfig: RTCConfiguration) => Promise<OfferContext>
to
  (pc: RTCPeerConnection) => Promise<OfferContext>

OfferContext no longer includes 'pc' since it's now provided by Rondevu.
2025-12-14 10:10:12 +00:00
22 changed files with 2972 additions and 1001 deletions

View File

@@ -1,547 +1,441 @@
# Migration Guide: v0.8.x → v0.9.0
# Migration Guide: v0.18.x → v0.18.8
This guide helps you migrate from Rondevu Client v0.8.x to v0.9.0.
Version 0.18.8 introduces significant improvements to connection durability and reliability. While we've maintained backward compatibility where possible, there are some breaking changes to be aware of.
## Overview
## Overview of Changes
v0.9.0 is a **breaking change** that completely replaces low-level APIs with high-level durable connections featuring automatic reconnection and message queuing.
### New Features
- **Automatic reconnection** with exponential backoff
- **Message buffering** during disconnections
- **Connection state machine** with proper lifecycle management
- **Rich event system** for connection monitoring
- **ICE polling lifecycle** (stops when connected, no more resource leaks)
### What's New
### Breaking Changes
- `connectToService()` now returns `AnswererConnection` instead of `ConnectionContext`
- `connection:opened` event signature changed for offerer side
- Direct DataChannel access replaced with connection wrapper API
**Durable Connections**: Automatic reconnection on network drops
**Message Queuing**: Messages sent during disconnections are queued and flushed on reconnect
**Durable Channels**: RTCDataChannel wrappers that survive connection drops
**TTL Auto-Refresh**: Services automatically republish before expiration
**Simplified API**: Direct methods on main client instead of nested APIs
---
### What's Removed
## Migration Steps
**Low-level APIs**: `client.services.*`, `client.discovery.*`, `client.createPeer()` no longer exported
**Manual Connection Management**: No need to handle WebRTC peer lifecycle manually
**Service Handles**: Replaced with DurableService instances
### 1. Answerer Side (connectToService)
## Breaking Changes
#### Old API (v0.18.7 and earlier)
### 1. Service Exposure
#### v0.8.x (Old)
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
const context = await rondevu.connectToService({
serviceFqn: 'chat:1.0.0@alice',
onConnection: ({ dc, pc, peerUsername }) => {
console.log('Connected to', peerUsername)
const client = new Rondevu();
await client.register();
dc.addEventListener('message', (event) => {
console.log('Received:', event.data)
})
const handle = await client.services.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'chat@1.0.0',
isPublic: true,
handler: (channel, peer) => {
channel.onmessage = (e) => {
console.log('Received:', e.data);
channel.send(`Echo: ${e.data}`);
};
dc.addEventListener('open', () => {
dc.send('Hello!')
})
}
});
})
// Unpublish
await handle.unpublish();
// Access peer connection
context.pc.getStats()
```
#### v0.9.0 (New)
#### New API (v0.18.8)
```typescript
import { Rondevu } from '@xtr-dev/rondevu-client';
const client = new Rondevu();
await client.register();
const service = await client.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'chat@1.0.0',
isPublic: true,
poolSize: 10, // NEW: Handle multiple concurrent connections
handler: (channel, connectionId) => {
// NEW: DurableChannel with event emitters
channel.on('message', (data) => {
console.log('Received:', data);
channel.send(`Echo: ${data}`);
});
const connection = await rondevu.connectToService({
serviceFqn: 'chat:1.0.0@alice',
connectionConfig: {
reconnectEnabled: true, // Optional: enable auto-reconnect
bufferEnabled: true, // Optional: enable message buffering
connectionTimeout: 30000 // Optional: connection timeout (ms)
}
});
// NEW: Start the service
await service.start();
// NEW: Stop the service
await service.stop();
```
**Key Differences:**
- `client.services.exposeService()``client.exposeService()`
- Returns `DurableService` instead of `ServiceHandle`
- Handler receives `DurableChannel` instead of `RTCDataChannel`
- Handler receives `connectionId` string instead of `RondevuPeer`
- DurableChannel uses `.on('message', ...)` instead of `.onmessage = ...`
- Must call `service.start()` to begin accepting connections
- Use `service.stop()` instead of `handle.unpublish()`
### 2. Connecting to Services
#### v0.8.x (Old)
```typescript
// Connect by username + FQN
const { peer, channel } = await client.discovery.connect(
'alice',
'chat@1.0.0'
);
channel.onmessage = (e) => {
console.log('Received:', e.data);
};
channel.onopen = () => {
channel.send('Hello!');
};
peer.on('connected', () => {
console.log('Connected');
});
peer.on('failed', (error) => {
console.error('Failed:', error);
});
```
#### v0.9.0 (New)
```typescript
// Connect by username + FQN
const connection = await client.connect('alice', 'chat@1.0.0', {
maxReconnectAttempts: 10 // NEW: Configurable reconnection
});
// NEW: Create durable channel
const channel = connection.createChannel('main');
channel.on('message', (data) => {
console.log('Received:', data);
});
channel.on('open', () => {
channel.send('Hello!');
});
// NEW: Connection lifecycle events
connection.on('connected', () => {
console.log('Connected');
});
connection.on('reconnecting', (attempt, max, delay) => {
console.log(`Reconnecting (${attempt}/${max})...`);
});
connection.on('failed', (error) => {
console.error('Failed permanently:', error);
});
// NEW: Must explicitly connect
await connection.connect();
```
**Key Differences:**
- `client.discovery.connect()``client.connect()`
- Returns `DurableConnection` instead of `{ peer, channel }`
- Must create channels with `connection.createChannel()`
- Must call `connection.connect()` to establish connection
- Automatic reconnection with configurable retry limits
- Messages sent during disconnection are automatically queued
### 3. Connecting by UUID
#### v0.8.x (Old)
```typescript
const { peer, channel } = await client.discovery.connectByUuid('service-uuid');
channel.onmessage = (e) => {
console.log('Received:', e.data);
};
```
#### v0.9.0 (New)
```typescript
const connection = await client.connectByUuid('service-uuid', {
maxReconnectAttempts: 5
});
const channel = connection.createChannel('main');
channel.on('message', (data) => {
console.log('Received:', data);
});
await connection.connect();
```
**Key Differences:**
- `client.discovery.connectByUuid()``client.connectByUuid()`
- Returns `DurableConnection` instead of `{ peer, channel }`
- Must create channels and connect explicitly
### 4. Multi-Connection Services (Offer Pooling)
#### v0.8.x (Old)
```typescript
const handle = await client.services.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'chat@1.0.0',
poolSize: 5,
pollingInterval: 2000,
handler: (channel, peer, connectionId) => {
console.log(`Connection: ${connectionId}`);
},
onPoolStatus: (status) => {
console.log('Pool status:', status);
}
});
const status = handle.getStatus();
await handle.addOffers(3);
```
#### v0.9.0 (New)
```typescript
const service = await client.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'chat@1.0.0',
poolSize: 5, // SAME: Pool size
pollingInterval: 2000, // SAME: Polling interval
handler: (channel, connectionId) => {
console.log(`Connection: ${connectionId}`);
}
});
await service.start();
// Get active connections
const connections = service.getActiveConnections();
})
// Listen for connection events
service.on('connection', (connectionId) => {
console.log('New connection:', connectionId);
});
connection.on('connected', () => {
console.log('Connected!')
connection.send('Hello!')
})
connection.on('message', (data) => {
console.log('Received:', data)
})
// Optional: monitor reconnection
connection.on('reconnecting', (attempt) => {
console.log(`Reconnecting, attempt ${attempt}`)
})
connection.on('reconnect:success', () => {
console.log('Reconnection successful!')
})
// Access peer connection if needed
const pc = connection.getPeerConnection()
const dc = connection.getDataChannel()
```
**Key Differences:**
- `onPoolStatus` callback removed (use `service.on('connection')` instead)
- `handle.getStatus()` replaced with `service.getActiveConnections()`
- `handle.addOffers()` removed (pool auto-manages offers)
- Handler receives `DurableChannel` instead of `RTCDataChannel`
**Key Changes:**
- ❌ Removed `onConnection` callback
- ✅ Use event listeners instead: `connection.on('connected', ...)`
- ❌ Removed direct `dc.send()` access
- ✅ Use `connection.send()` for automatic buffering support
- ✅ Added automatic reconnection and message buffering
## Feature Comparison
---
| Feature | v0.8.x | v0.9.0 |
|---------|--------|--------|
| Service exposure | `client.services.exposeService()` | `client.exposeService()` |
| Connection | `client.discovery.connect()` | `client.connect()` |
| Connection by UUID | `client.discovery.connectByUuid()` | `client.connectByUuid()` |
| Channel type | `RTCDataChannel` | `DurableChannel` |
| Event handling | `.onmessage`, `.onopen`, etc. | `.on('message')`, `.on('open')`, etc. |
| Automatic reconnection | ❌ No | ✅ Yes (configurable) |
| Message queuing | ❌ No | ✅ Yes (during disconnections) |
| TTL auto-refresh | ❌ No | ✅ Yes (configurable) |
| Peer lifecycle | Manual | Automatic |
| Connection pooling | ✅ Yes | ✅ Yes (same API) |
### 2. Offerer Side (publishService)
## API Mapping
### Removed Exports
These are no longer exported in v0.9.0:
#### Old API (v0.18.7 and earlier)
```typescript
// ❌ Removed
import {
RondevuServices,
RondevuDiscovery,
RondevuPeer,
ServiceHandle,
PooledServiceHandle,
ConnectResult
} from '@xtr-dev/rondevu-client';
await rondevu.publishService({
service: 'chat:1.0.0',
maxOffers: 5
})
await rondevu.startFilling()
// Handle connections
rondevu.on('connection:opened', (offerId, dc) => {
console.log('New connection:', offerId)
dc.addEventListener('message', (event) => {
console.log('Received:', event.data)
})
dc.send('Welcome!')
})
```
### New Exports
These are new in v0.9.0:
#### New API (v0.18.8)
```typescript
// ✅ New
import {
DurableConnection,
DurableChannel,
DurableService,
DurableConnectionState,
DurableChannelState,
DurableConnectionConfig,
DurableChannelConfig,
DurableServiceConfig,
DurableConnectionEvents,
DurableChannelEvents,
DurableServiceEvents,
ConnectionInfo,
ServiceInfo,
QueuedMessage
} from '@xtr-dev/rondevu-client';
```
### Unchanged Exports
These work the same in both versions:
```typescript
// ✅ Unchanged
import {
Rondevu,
RondevuAuth,
RondevuUsername,
Credentials,
UsernameClaimResult,
UsernameCheckResult
} from '@xtr-dev/rondevu-client';
```
## Configuration Options
### New Connection Options
v0.9.0 adds extensive configuration for automatic reconnection and message queuing:
```typescript
const connection = await client.connect('alice', 'chat@1.0.0', {
// Reconnection
maxReconnectAttempts: 10, // default: 10
reconnectBackoffBase: 1000, // default: 1000ms
reconnectBackoffMax: 30000, // default: 30000ms (30 seconds)
reconnectJitter: 0.2, // default: 0.2 (±20%)
connectionTimeout: 30000, // default: 30000ms
// Message queuing
maxQueueSize: 1000, // default: 1000 messages
maxMessageAge: 60000, // default: 60000ms (1 minute)
// WebRTC
rtcConfig: {
iceServers: [...]
await rondevu.publishService({
service: 'chat:1.0.0',
maxOffers: 5,
connectionConfig: {
reconnectEnabled: true,
bufferEnabled: true
}
});
})
await rondevu.startFilling()
// Handle connections - signature changed!
rondevu.on('connection:opened', (offerId, connection) => {
console.log('New connection:', offerId)
connection.on('message', (data) => {
console.log('Received:', data)
})
connection.on('disconnected', () => {
console.log('Connection lost, will auto-reconnect')
})
connection.send('Welcome!')
})
```
### New Service Options
**Key Changes:**
- ⚠️ Event signature changed: `(offerId, dc)``(offerId, connection)`
- ❌ Removed direct DataChannel access
- ✅ Use `connection.send()` and `connection.on('message', ...)`
- ✅ Connection object provides lifecycle events
Services can now auto-refresh TTL:
---
## New Connection Configuration
All connection-related options are now configured via `connectionConfig`:
```typescript
const service = await client.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'chat@1.0.0',
interface ConnectionConfig {
// Timeouts
connectionTimeout: number // Default: 30000ms (30s)
iceGatheringTimeout: number // Default: 10000ms (10s)
// TTL auto-refresh (NEW)
ttl: 300000, // default: 300000ms (5 minutes)
ttlRefreshMargin: 0.2, // default: 0.2 (refresh at 80% of TTL)
// Reconnection
reconnectEnabled: boolean // Default: true
maxReconnectAttempts: number // Default: 5
reconnectBackoffBase: number // Default: 1000ms
reconnectBackoffMax: number // Default: 30000ms (30s)
// All connection options also apply to incoming connections
maxReconnectAttempts: 10,
maxQueueSize: 1000,
// ...
});
// Message buffering
bufferEnabled: boolean // Default: true
maxBufferSize: number // Default: 100 messages
maxBufferAge: number // Default: 60000ms (1 min)
// Debug
debug: boolean // Default: false
}
```
## Migration Checklist
### Example Usage
- [ ] Replace `client.services.exposeService()` with `client.exposeService()`
- [ ] Add `await service.start()` after creating service
- [ ] Replace `handle.unpublish()` with `service.stop()`
- [ ] Replace `client.discovery.connect()` with `client.connect()`
- [ ] Replace `client.discovery.connectByUuid()` with `client.connectByUuid()`
- [ ] Create channels with `connection.createChannel()` instead of receiving them directly
- [ ] Add `await connection.connect()` to establish connection
- [ ] Update handlers from `(channel, peer, connectionId?)` to `(channel, connectionId)`
- [ ] Replace `.onmessage` with `.on('message', ...)`
- [ ] Replace `.onopen` with `.on('open', ...)`
- [ ] Replace `.onclose` with `.on('close', ...)`
- [ ] Replace `.onerror` with `.on('error', ...)`
- [ ] Add reconnection event handlers (`connection.on('reconnecting', ...)`)
- [ ] Review and configure reconnection options if needed
- [ ] Review and configure message queue limits if needed
- [ ] Update TypeScript imports to use new types
- [ ] Test automatic reconnection behavior
- [ ] Test message queuing during disconnections
```typescript
const connection = await rondevu.connectToService({
serviceFqn: 'chat:1.0.0@alice',
connectionConfig: {
// Disable auto-reconnect if you want manual control
reconnectEnabled: false,
// Disable buffering if messages are time-sensitive
bufferEnabled: false,
// Increase timeout for slow networks
connectionTimeout: 60000,
// Reduce retry attempts
maxReconnectAttempts: 3
}
})
```
---
## New Event System
### Connection Lifecycle Events
```typescript
connection.on('state:changed', ({ oldState, newState, reason }) => {})
connection.on('connecting', () => {})
connection.on('connected', () => {})
connection.on('disconnected', (reason) => {})
connection.on('failed', (error) => {})
connection.on('closed', (reason) => {})
```
### Reconnection Events
```typescript
connection.on('reconnect:scheduled', ({ attempt, delay, maxAttempts }) => {})
connection.on('reconnect:attempting', (attempt) => {})
connection.on('reconnect:success', () => {})
connection.on('reconnect:failed', (error) => {})
connection.on('reconnect:exhausted', (attempts) => {})
```
### Message Events
```typescript
connection.on('message', (data) => {})
connection.on('message:sent', (data, buffered) => {})
connection.on('message:buffered', (data) => {})
connection.on('message:replayed', (message) => {})
connection.on('message:buffer:overflow', (discardedMessage) => {})
```
### ICE Events
```typescript
connection.on('ice:candidate:local', (candidate) => {})
connection.on('ice:candidate:remote', (candidate) => {})
connection.on('ice:connection:state', (state) => {})
connection.on('ice:polling:started', () => {})
connection.on('ice:polling:stopped', () => {})
```
---
## Common Migration Patterns
### Pattern 1: Simple Echo Service
### Pattern 1: Simple Message Handler
#### Before (v0.8.x)
**Before:**
```typescript
await client.services.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'echo@1.0.0',
handler: (channel) => {
channel.onmessage = (e) => {
channel.send(`Echo: ${e.data}`);
};
}
});
dc.addEventListener('message', (event) => {
console.log(event.data)
})
dc.send('Hello')
```
#### After (v0.9.0)
**After:**
```typescript
const service = await client.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'echo@1.0.0',
handler: (channel) => {
channel.on('message', (data) => {
channel.send(`Echo: ${data}`);
});
}
});
await service.start();
connection.on('message', (data) => {
console.log(data)
})
connection.send('Hello')
```
### Pattern 2: Connection with Error Handling
---
#### Before (v0.8.x)
### Pattern 2: Connection State Monitoring
**Before:**
```typescript
try {
const { peer, channel } = await client.discovery.connect('alice', 'chat@1.0.0');
channel.onopen = () => {
channel.send('Hello!');
};
peer.on('failed', (error) => {
console.error('Connection failed:', error);
// Manual reconnection logic here
});
} catch (error) {
console.error('Failed to connect:', error);
pc.oniceconnectionstatechange = () => {
console.log('ICE state:', pc.iceConnectionState)
}
```
#### After (v0.9.0)
**After:**
```typescript
const connection = await client.connect('alice', 'chat@1.0.0', {
maxReconnectAttempts: 5
});
connection.on('ice:connection:state', (state) => {
console.log('ICE state:', state)
})
const channel = connection.createChannel('main');
// Or use higher-level events
connection.on('connected', () => console.log('Connected!'))
connection.on('disconnected', () => console.log('Disconnected!'))
```
channel.on('open', () => {
channel.send('Hello!');
});
---
connection.on('reconnecting', (attempt, max, delay) => {
console.log(`Reconnecting (${attempt}/${max}) in ${delay}ms`);
});
### Pattern 3: Handling Connection Failures
connection.on('failed', (error) => {
console.error('Connection failed permanently:', error);
});
try {
await connection.connect();
} catch (error) {
console.error('Initial connection failed:', error);
**Before:**
```typescript
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'failed') {
// Manual reconnection logic
pc.close()
await setupNewConnection()
}
}
```
### Pattern 3: Multi-User Chat Server
#### Before (v0.8.x)
**After:**
```typescript
const connections = new Map();
// Automatic reconnection built-in!
connection.on('reconnecting', (attempt) => {
console.log(`Reconnecting... attempt ${attempt}`)
})
await client.services.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'chat@1.0.0',
poolSize: 10,
handler: (channel, peer, connectionId) => {
connections.set(connectionId, channel);
connection.on('reconnect:success', () => {
console.log('Back online!')
})
channel.onmessage = (e) => {
// Broadcast to all
for (const [id, ch] of connections) {
if (id !== connectionId) {
ch.send(e.data);
}
}
};
channel.onclose = () => {
connections.delete(connectionId);
};
}
});
connection.on('reconnect:exhausted', (attempts) => {
console.log(`Failed after ${attempts} attempts`)
// Fallback logic here
})
```
#### After (v0.9.0)
---
### Pattern 4: Accessing Raw RTCPeerConnection/DataChannel
If you need low-level access:
```typescript
const channels = new Map();
const connection = await rondevu.connectToService({ ... })
const service = await client.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'chat@1.0.0',
poolSize: 10,
handler: (channel, connectionId) => {
channels.set(connectionId, channel);
// Get raw objects if needed
const pc = connection.getPeerConnection()
const dc = connection.getDataChannel()
channel.on('message', (data) => {
// Broadcast to all
for (const [id, ch] of channels) {
if (id !== connectionId) {
ch.send(data);
// Use them directly (bypasses buffering/reconnection features)
if (dc) {
dc.addEventListener('message', (event) => {
console.log(event.data)
})
}
}
});
channel.on('close', () => {
channels.delete(connectionId);
});
}
});
await service.start();
// Optional: Track connections
service.on('connection', (connectionId) => {
console.log(`User ${connectionId} joined`);
});
service.on('disconnection', (connectionId) => {
console.log(`User ${connectionId} left`);
});
```
## Benefits of Migration
**Note:** Using raw DataChannel bypasses automatic buffering and reconnection features.
1. **Reliability**: Automatic reconnection handles network hiccups transparently
2. **Simplicity**: No need to manage WebRTC peer lifecycle manually
3. **Durability**: Messages sent during disconnections are queued and delivered when connection restores
4. **Uptime**: Services automatically refresh TTL before expiration
5. **Type Safety**: Better TypeScript types with DurableChannel event emitters
6. **Debugging**: Queue size monitoring, connection state tracking, and detailed events
---
## Getting Help
## Backward Compatibility Notes
If you encounter issues during migration:
1. Check the [README](./README.md) for complete API documentation
2. Review the examples for common patterns
3. Open an issue on [GitHub](https://github.com/xtr-dev/rondevu-client/issues)
### What Still Works
`publishService()` API (just add `connectionConfig` optionally)
`findService()` API (unchanged)
✅ All RondevuAPI methods (unchanged)
✅ ICE server presets (unchanged)
✅ Username and keypair management (unchanged)
### What Changed
⚠️ `connectToService()` return type: `ConnectionContext``AnswererConnection`
⚠️ `connection:opened` event signature: `(offerId, dc)``(offerId, connection)`
⚠️ Direct DataChannel access replaced with connection wrapper
### What's New
✨ Automatic reconnection with exponential backoff
✨ Message buffering during disconnections
✨ Rich event system (20+ events)
✨ Connection state machine
✨ ICE polling lifecycle management (no more resource leaks)
---
## Troubleshooting
### Issue: "connection.send is not a function"
You're trying to use the old `dc.send()` API. Update to:
```typescript
// Old
dc.send('Hello')
// New
connection.send('Hello')
```
---
### Issue: "Cannot read property 'addEventListener' of undefined"
You're trying to access `dc` directly. Update to event listeners:
```typescript
// Old
dc.addEventListener('message', (event) => {
console.log(event.data)
})
// New
connection.on('message', (data) => {
console.log(data)
})
```
---
### Issue: Messages not being delivered
Check if buffering is enabled and connection is established:
```typescript
connection.on('connected', () => {
// Only send after connected
connection.send('Hello')
})
// Monitor buffer
connection.on('message:buffered', (data) => {
console.log('Message buffered, will send when reconnected')
})
```
---
## Need Help?
- Check the updated README for full API documentation
- See examples in the `demo/` directory
- File issues at: https://github.com/xtr-dev/rondevu/issues
---
## Summary Checklist
When migrating from v0.18.7 to v0.18.8:
- [ ] Update `connectToService()` to use returned `AnswererConnection`
- [ ] Replace `dc.addEventListener('message', ...)` with `connection.on('message', ...)`
- [ ] Replace `dc.send()` with `connection.send()`
- [ ] Update `connection:opened` event handler signature
- [ ] Consider adding reconnection event handlers
- [ ] Optionally configure `connectionConfig` for your use case
- [ ] Test connection resilience (disconnect network, should auto-reconnect)
- [ ] Remove manual reconnection logic (now built-in)

377
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)
🌐 **Simple WebRTC signaling client with username-based discovery**
🌐 **WebRTC signaling client with durable connections**
TypeScript/JavaScript client for Rondevu, providing WebRTC signaling with username claiming, service publishing/discovery, and efficient batch polling.
TypeScript/JavaScript client for Rondevu, providing WebRTC signaling with **automatic reconnection**, **message buffering**, username claiming, service publishing/discovery, and efficient batch polling.
**Related repositories:**
- [@xtr-dev/rondevu-client](https://github.com/xtr-dev/rondevu-client) - TypeScript client library ([npm](https://www.npmjs.com/package/@xtr-dev/rondevu-client))
@@ -15,15 +15,24 @@ TypeScript/JavaScript client for Rondevu, providing WebRTC signaling with userna
## Features
### ✨ New in v0.20.0
- **🔄 Automatic Reconnection**: Built-in exponential backoff for failed connections
- **📦 Message Buffering**: Queues messages during disconnections, replays on reconnect
- **🔄 Connection Persistence**: OffererConnection objects persist across disconnections via offer rotation
- **📊 Connection State Machine**: Explicit lifecycle tracking with native RTC events
- **🎯 Rich Event System**: 20+ events for monitoring connection health including `connection:rotated`
- **⚡ Improved Reliability**: ICE polling lifecycle management, proper cleanup, rotation fallback
- **🏗️ Internal Refactoring**: Cleaner codebase with OfferPool extraction and consolidated ICE polling
### Core Features
- **Username Claiming**: Secure ownership with Ed25519 signatures
- **Anonymous Users**: Auto-generated anonymous usernames for quick testing
- **Service Publishing**: Publish services with multiple offers for connection pooling
- **Service Discovery**: Direct lookup, random discovery, or paginated search
- **Efficient Batch Polling**: Single endpoint for answers and ICE candidates (50% fewer requests)
- **Efficient Batch Polling**: Single endpoint for answers and ICE candidates
- **Semantic Version Matching**: Compatible version resolution (chat:1.0.0 matches any 1.x.x)
- **TypeScript**: Full type safety and autocomplete
- **Keypair Management**: Generate or reuse Ed25519 keypairs
- **Automatic Signatures**: All authenticated requests signed automatically
## Installation
@@ -49,27 +58,35 @@ const rondevu = await Rondevu.connect({
await rondevu.publishService({
service: 'chat:1.0.0',
maxOffers: 5, // Maintain up to 5 concurrent offers
offerFactory: async (rtcConfig) => {
const pc = new RTCPeerConnection(rtcConfig)
const dc = pc.createDataChannel('chat')
dc.addEventListener('open', () => {
console.log('Connection opened!')
dc.send('Hello from Alice!')
})
dc.addEventListener('message', (e) => {
console.log('Received:', e.data)
})
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
return { pc, dc, offer }
connectionConfig: {
reconnectEnabled: true, // Auto-reconnect on failures
bufferEnabled: true, // Buffer messages during disconnections
connectionTimeout: 30000 // 30 second timeout
}
})
// 3. Start accepting connections
await rondevu.startFilling()
// 4. Handle incoming connections
rondevu.on('connection:opened', (offerId, connection) => {
console.log('New connection:', offerId)
// Listen for messages
connection.on('message', (data) => {
console.log('Received:', data)
})
// Monitor connection state
connection.on('connected', () => {
console.log('Fully connected!')
connection.send('Hello from Alice!')
})
connection.on('disconnected', () => {
console.log('Connection lost, will auto-reconnect')
})
})
```
### Connecting to a Service (Answerer)
@@ -84,25 +101,38 @@ const rondevu = await Rondevu.connect({
iceServers: 'ipv4-turn'
})
// 2. Connect to service (automatic WebRTC setup)
// 2. Connect to service - returns AnswererConnection
const connection = await rondevu.connectToService({
serviceFqn: 'chat:1.0.0@alice',
onConnection: ({ dc, peerUsername }) => {
console.log('Connected to', peerUsername)
dc.addEventListener('message', (e) => {
console.log('Received:', e.data)
})
dc.addEventListener('open', () => {
dc.send('Hello from Bob!')
})
connectionConfig: {
reconnectEnabled: true,
bufferEnabled: true,
maxReconnectAttempts: 5
}
})
// Access connection
connection.dc.send('Another message')
connection.pc.close() // Close when done
// 3. Setup event handlers
connection.on('connected', () => {
console.log('Connected to alice!')
connection.send('Hello from Bob!')
})
connection.on('message', (data) => {
console.log('Received:', data)
})
// 4. Monitor connection health
connection.on('reconnecting', (attempt) => {
console.log(`Reconnecting... attempt ${attempt}`)
})
connection.on('reconnect:success', () => {
console.log('Back online!')
})
connection.on('failed', (error) => {
console.error('Connection failed:', error)
})
```
## Core API
@@ -126,52 +156,299 @@ await rondevu.publishService({
service: string, // e.g., 'chat:1.0.0' (username auto-appended)
maxOffers: number, // Maximum concurrent offers to maintain
offerFactory?: OfferFactory, // Optional: custom offer creation
ttl?: number // Optional: offer lifetime in ms (default: 300000)
ttl?: number, // Optional: offer lifetime in ms (default: 300000)
connectionConfig?: Partial<ConnectionConfig> // Optional: durability settings
})
await rondevu.startFilling() // Start accepting connections
rondevu.stopFilling() // Stop and close all connections
```
### Service Discovery
```typescript
// Direct lookup (with username)
await rondevu.getService('chat:1.0.0@alice')
// Random discovery (without username)
await rondevu.discoverService('chat:1.0.0')
// Paginated discovery
await rondevu.discoverServices('chat:1.0.0', limit, offset)
```
### Connecting to Services
**⚠️ Breaking Change in v0.18.9+:** `connectToService()` now returns `AnswererConnection` instead of `ConnectionContext`.
```typescript
// New API (v0.18.9/v0.18.11+)
const connection = await rondevu.connectToService({
serviceFqn?: string, // Full FQN like 'chat:1.0.0@alice'
service?: string, // Service without username (for discovery)
username?: string, // Target username (combined with service)
onConnection?: (context) => void, // Called when data channel opens
connectionConfig?: Partial<ConnectionConfig>, // Durability settings
rtcConfig?: RTCConfiguration // Optional: override ICE servers
})
// Setup event handlers
connection.on('connected', () => {
connection.send('Hello!')
})
connection.on('message', (data) => {
console.log(data)
})
```
### Connection Configuration
```typescript
interface ConnectionConfig {
// Timeouts
connectionTimeout: number // Default: 30000ms (30s)
iceGatheringTimeout: number // Default: 10000ms (10s)
// Reconnection
reconnectEnabled: boolean // Default: true
maxReconnectAttempts: number // Default: 5 (0 = infinite)
reconnectBackoffBase: number // Default: 1000ms
reconnectBackoffMax: number // Default: 30000ms (30s)
// Message buffering
bufferEnabled: boolean // Default: true
maxBufferSize: number // Default: 100 messages
maxBufferAge: number // Default: 60000ms (1 min)
// Debug
debug: boolean // Default: false
}
```
### Connection Events
```typescript
// Lifecycle events
connection.on('connecting', () => {})
connection.on('connected', () => {})
connection.on('disconnected', (reason) => {})
connection.on('failed', (error) => {})
connection.on('closed', (reason) => {})
// Reconnection events
connection.on('reconnecting', (attempt) => {})
connection.on('reconnect:success', () => {})
connection.on('reconnect:failed', (error) => {})
connection.on('reconnect:exhausted', (attempts) => {})
// Message events
connection.on('message', (data) => {})
connection.on('message:buffered', (data) => {})
connection.on('message:replayed', (message) => {})
// ICE events
connection.on('ice:connection:state', (state) => {})
connection.on('ice:polling:started', () => {})
connection.on('ice:polling:stopped', () => {})
```
### Service Discovery
```typescript
// Unified discovery API
const service = await rondevu.findService(
'chat:1.0.0@alice', // Direct lookup (with username)
{ mode: 'direct' }
)
const service = await rondevu.findService(
'chat:1.0.0', // Random discovery (without username)
{ mode: 'random' }
)
const result = await rondevu.findService(
'chat:1.0.0',
{
mode: 'paginated',
limit: 20,
offset: 0
}
)
```
## Migration Guide
**Upgrading from v0.18.10 or earlier?** See [MIGRATION.md](./MIGRATION.md) for detailed upgrade instructions.
### Quick Migration Summary
**Before (v0.18.7/v0.18.10):**
```typescript
const context = await rondevu.connectToService({
serviceFqn: 'chat:1.0.0@alice',
onConnection: ({ dc }) => {
dc.addEventListener('message', (e) => console.log(e.data))
dc.send('Hello')
}
})
```
**After (v0.18.9/v0.18.11):**
```typescript
const connection = await rondevu.connectToService({
serviceFqn: 'chat:1.0.0@alice'
})
connection.on('connected', () => {
connection.send('Hello') // Use connection.send()
})
connection.on('message', (data) => {
console.log(data) // data is already extracted
})
```
## Advanced Usage
### Custom Offer Factory
```typescript
await rondevu.publishService({
service: 'file-transfer:1.0.0',
maxOffers: 3,
offerFactory: async (pc) => {
// Customize data channel settings
const dc = pc.createDataChannel('files', {
ordered: true,
maxRetransmits: 10
})
// Add custom listeners
dc.addEventListener('open', () => {
console.log('Transfer channel ready')
})
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
return { dc, offer }
}
})
```
### Accessing Raw RTCPeerConnection
```typescript
const connection = await rondevu.connectToService({ ... })
// Get raw objects if needed
const pc = connection.getPeerConnection()
const dc = connection.getDataChannel()
// Note: Using raw DataChannel bypasses buffering/reconnection features
if (dc) {
dc.addEventListener('message', (e) => {
console.log('Raw message:', e.data)
})
}
```
### Disabling Durability Features
```typescript
const connection = await rondevu.connectToService({
serviceFqn: 'chat:1.0.0@alice',
connectionConfig: {
reconnectEnabled: false, // Disable auto-reconnect
bufferEnabled: false, // Disable message buffering
}
})
```
## Documentation
📚 **[MIGRATION.md](./MIGRATION.md)** - Upgrade guide from v0.18.7 to v0.18.9
📚 **[ADVANCED.md](./ADVANCED.md)** - Comprehensive guide including:
- Detailed API reference for all methods
- Type definitions and interfaces
- Platform support (Browser & Node.js)
- Advanced usage patterns
- Username rules and service FQN format
- Examples and migration guides
## Connection Persistence (v0.20.0+)
Connection objects now persist across disconnections via **"offer rotation"**. When a connection fails, the same connection object is rebound to a new offer instead of being destroyed:
```typescript
rondevu.on('connection:opened', (offerId, connection) => {
console.log(`Connection ${offerId} opened`)
// Listen for offer rotation
rondevu.on('connection:rotated', (oldOfferId, newOfferId, conn) => {
if (conn === connection) {
console.log(`Connection rotated: ${oldOfferId}${newOfferId}`)
// Same connection object! Event listeners still work
// Message buffer preserved
}
})
connection.on('message', (data) => {
console.log('Received:', data)
// This listener continues working even after rotation
})
connection.on('failed', () => {
console.log('Connection failed, will auto-rotate to new offer')
})
})
```
**Benefits:**
- ✅ Same connection object remains usable through disconnections
- ✅ Message buffer preserved during temporary disconnections
- ✅ Event listeners don't need to be re-registered
- ✅ Seamless reconnection experience for offerer side
## Examples
- [React Demo](https://github.com/xtr-dev/rondevu-demo) - Full browser UI ([live](https://ronde.vu))
## Changelog
### v0.20.0 (Latest)
- **Connection Persistence** - OffererConnection objects now persist across disconnections
- **Offer Rotation** - When connection fails, same object is rebound to new offer
- **Message Buffering** - Now works seamlessly on offerer side through rotations
- **New Event**: `connection:rotated` emitted when offer is rotated
- **Internal**: Added `OffererConnection.rebindToOffer()` method
- **Internal**: Modified OfferPool failure handler to rotate offers instead of destroying connections
- **Internal**: Added rotation lock to prevent concurrent rotations
- **Internal**: Added max rotation attempts limit (default: 5)
- 100% backward compatible - no breaking changes
### v0.19.0
- **Internal Refactoring** - Improved codebase maintainability (no API changes)
- Extract OfferPool class for offer lifecycle management
- Consolidate ICE polling logic (remove ~86 lines of duplicate code)
- Add AsyncLock utility for race-free concurrent operations
- Disable reconnection for offerer connections (offers are ephemeral)
- 100% backward compatible - upgrade without code changes
### v0.18.11
- Restore EventEmitter-based durable connections (same as v0.18.9)
- Durable WebRTC connections with state machine
- Automatic reconnection with exponential backoff
- Message buffering during disconnections
- ICE polling lifecycle management
- **Breaking:** `connectToService()` returns `AnswererConnection` instead of `ConnectionContext`
- See [MIGRATION.md](./MIGRATION.md) for upgrade guide
### v0.18.10
- Temporary revert to callback-based API (reverted in v0.18.11)
### v0.18.9
- Add durable WebRTC connections with state machine
- Implement automatic reconnection with exponential backoff
- Add message buffering during disconnections
- Fix ICE polling lifecycle (stops when connected)
- Add fillOffers() semaphore to prevent exceeding maxOffers
- **Breaking:** `connectToService()` returns `AnswererConnection` instead of `ConnectionContext`
- **Breaking:** `connection:opened` event signature changed
- See [MIGRATION.md](./MIGRATION.md) for upgrade guide
### v0.18.8
- Initial durable connections implementation
### v0.18.3
- Fix EventEmitter cross-platform compatibility
## License
MIT

34
package-lock.json generated
View File

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

View File

@@ -1,10 +1,10 @@
{
"name": "@xtr-dev/rondevu-client",
"version": "0.18.0",
"version": "0.20.0",
"description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"main": "dist/core/index.js",
"types": "dist/core/index.d.ts",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
@@ -41,6 +41,7 @@
"README.md"
],
"dependencies": {
"@noble/ed25519": "^3.0.0"
"@noble/ed25519": "^3.0.0",
"eventemitter3": "^5.0.1"
}
}

View File

@@ -2,12 +2,12 @@
* Rondevu API Client - RPC interface
*/
import { CryptoAdapter, Keypair } from './crypto-adapter.js'
import { WebCryptoAdapter } from './web-crypto-adapter.js'
import { RpcBatcher, BatcherOptions } from './rpc-batcher.js'
import { CryptoAdapter, Keypair } from '../crypto/adapter.js'
import { WebCryptoAdapter } from '../crypto/web.js'
import { RpcBatcher, BatcherOptions } from './batcher.js'
export type { Keypair } from './crypto-adapter.js'
export type { BatcherOptions } from './rpc-batcher.js'
export type { Keypair } from '../crypto/adapter.js'
export type { BatcherOptions } from './batcher.js'
export interface OfferRequest {
sdp: string
@@ -39,6 +39,7 @@ export interface Service {
export interface IceCandidate {
candidate: RTCIceCandidateInit | null
role: 'offerer' | 'answerer'
createdAt: number
}

168
src/connections/answerer.ts Normal file
View File

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

633
src/connections/base.ts Normal file
View File

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

64
src/connections/config.ts Normal file
View File

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

102
src/connections/events.ts Normal file
View File

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

291
src/connections/offerer.ts Normal file
View File

@@ -0,0 +1,291 @@
/**
* Offerer-side WebRTC connection with offer creation and answer processing
*/
import { RondevuConnection } from './base.js'
import { ConnectionState } from './events.js'
import { RondevuAPI } from '../api/client.js'
import { ConnectionConfig } from './config.js'
import { AsyncLock } from '../utils/async-lock.js'
export interface OffererOptions {
api: RondevuAPI
serviceFqn: string
offerId: string
pc: RTCPeerConnection // Accept already-created peer connection
dc?: RTCDataChannel // Accept already-created data channel (optional)
config?: Partial<ConnectionConfig>
}
/**
* Offerer connection - manages already-created offers and waits for answers
*/
export class OffererConnection extends RondevuConnection {
private api: RondevuAPI
private serviceFqn: string
private offerId: string
// Rotation tracking
private rotationLock = new AsyncLock()
private rotating = false
private rotationAttempts = 0
private static readonly MAX_ROTATION_ATTEMPTS = 5
constructor(options: OffererOptions) {
// Force reconnectEnabled: false for offerer connections (offers are ephemeral)
super(undefined, {
...options.config,
reconnectEnabled: false
})
this.api = options.api
this.serviceFqn = options.serviceFqn
this.offerId = options.offerId
// Use the already-created peer connection and data channel
this.pc = options.pc
this.dc = options.dc || null
}
/**
* Initialize the connection - setup handlers for already-created offer
*/
async initialize(): Promise<void> {
this.debug('Initializing offerer connection')
if (!this.pc) throw new Error('Peer connection not provided')
// Setup peer connection event handlers
this.pc.onicecandidate = (event) => this.handleIceCandidate(event)
this.pc.oniceconnectionstatechange = () => this.handleIceConnectionStateChange()
this.pc.onconnectionstatechange = () => this.handleConnectionStateChange()
this.pc.onicegatheringstatechange = () => this.handleIceGatheringStateChange()
// Setup data channel handlers if we have one
if (this.dc) {
this.setupDataChannelHandlers(this.dc)
}
// Start connection timeout
this.startConnectionTimeout()
// Transition to signaling state (offer already created and published)
this.transitionTo(ConnectionState.SIGNALING, 'Offer published, waiting for answer')
}
/**
* Process an answer from the answerer
*/
async processAnswer(sdp: string, answererId: string): Promise<void> {
if (!this.pc) {
this.debug('Cannot process answer: peer connection not initialized')
return
}
// Generate SDP fingerprint for deduplication
const fingerprint = await this.hashSdp(sdp)
// Check for duplicate answer
if (this.answerProcessed) {
if (this.answerSdpFingerprint === fingerprint) {
this.debug('Duplicate answer detected (same fingerprint), skipping')
this.emit('answer:duplicate', this.offerId)
return
} else {
throw new Error('Received different answer after already processing one (protocol violation)')
}
}
// Validate state
if (this.state !== ConnectionState.SIGNALING && this.state !== ConnectionState.CHECKING) {
this.debug(`Cannot process answer in state ${this.state}`)
return
}
// Mark as processed BEFORE setRemoteDescription to prevent race conditions
this.answerProcessed = true
this.answerSdpFingerprint = fingerprint
try {
await this.pc.setRemoteDescription({
type: 'answer',
sdp,
})
this.debug(`Answer processed successfully from ${answererId}`)
this.emit('answer:processed', this.offerId, answererId)
} catch (error) {
// Reset flags on error so we can try again
this.answerProcessed = false
this.answerSdpFingerprint = null
this.debug('Failed to set remote description:', error)
throw error
}
}
/**
* Rebind this connection to a new offer (when previous offer failed)
* Keeps the same connection object alive but with new underlying WebRTC
*/
async rebindToOffer(
newOfferId: string,
newPc: RTCPeerConnection,
newDc?: RTCDataChannel
): Promise<void> {
return this.rotationLock.run(async () => {
if (this.rotating) {
throw new Error('Rotation already in progress')
}
this.rotating = true
try {
this.rotationAttempts++
if (this.rotationAttempts > OffererConnection.MAX_ROTATION_ATTEMPTS) {
throw new Error('Max rotation attempts exceeded')
}
this.debug(`Rebinding connection from ${this.offerId} to ${newOfferId}`)
// 1. Clean up old peer connection
if (this.pc) {
this.pc.close()
}
if (this.dc && this.dc !== newDc) {
this.dc.close()
}
// 2. Update to new offer
this.offerId = newOfferId
this.pc = newPc
this.dc = newDc || null
// 3. Reset answer processing flags
this.answerProcessed = false
this.answerSdpFingerprint = null
// 4. Setup event handlers for new peer connection
this.pc.onicecandidate = (event) => this.handleIceCandidate(event)
this.pc.oniceconnectionstatechange = () => this.handleIceConnectionStateChange()
this.pc.onconnectionstatechange = () => this.handleConnectionStateChange()
this.pc.onicegatheringstatechange = () => this.handleIceGatheringStateChange()
// 5. Setup data channel handlers if we have one
if (this.dc) {
this.setupDataChannelHandlers(this.dc)
}
// 6. Restart connection timeout
this.startConnectionTimeout()
// 7. Transition to SIGNALING state (waiting for answer)
this.transitionTo(ConnectionState.SIGNALING, 'Offer rotated, waiting for answer')
// Note: Message buffer is NOT cleared - it persists!
this.debug(`Rebind complete. Buffer has ${this.messageBuffer?.size() ?? 0} messages`)
} finally {
this.rotating = false
}
})
}
/**
* Check if connection is currently rotating
*/
isRotating(): boolean {
return this.rotating
}
/**
* Override onConnected to reset rotation attempts
*/
protected onConnected(): void {
super.onConnected()
this.rotationAttempts = 0
this.debug('Connection established, rotation attempts reset')
}
/**
* Generate a hash fingerprint of SDP for deduplication
*/
private async hashSdp(sdp: string): Promise<string> {
// Simple hash using built-in crypto if available
if (typeof crypto !== 'undefined' && crypto.subtle) {
const encoder = new TextEncoder()
const data = encoder.encode(sdp)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
} else {
// Fallback: use simple string hash
let hash = 0
for (let i = 0; i < sdp.length; i++) {
const char = sdp.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash
}
return hash.toString(16)
}
}
/**
* Handle local ICE candidate generation
*/
protected onLocalIceCandidate(candidate: RTCIceCandidate): void {
this.debug('Generated local ICE candidate')
// Send ICE candidate to server
this.api
.addOfferIceCandidates(this.serviceFqn, this.offerId, [
{
candidate: candidate.candidate,
sdpMLineIndex: candidate.sdpMLineIndex,
sdpMid: candidate.sdpMid,
},
])
.catch((error) => {
this.debug('Failed to send ICE candidate:', error)
})
}
/**
* Get the API instance
*/
protected getApi(): any {
return this.api
}
/**
* Get the service FQN
*/
protected getServiceFqn(): string {
return this.serviceFqn
}
/**
* Offerers accept all ICE candidates (no filtering)
*/
protected getIceCandidateRole(): 'offerer' | null {
return null
}
/**
* Attempt to reconnect
*
* Note: For offerer connections, reconnection is handled by the Rondevu instance
* creating a new offer via fillOffers(). This method is a no-op.
*/
protected attemptReconnect(): void {
this.debug('Reconnection not applicable for offerer - new offer will be created by Rondevu instance')
// Offerer reconnection is handled externally by Rondevu.fillOffers()
// which creates entirely new offers. We don't reconnect the same offer.
// Just emit failure and let the parent handle it.
this.emit('reconnect:failed', new Error('Offerer reconnection handled by parent'))
}
/**
* Get the offer ID
*/
getOfferId(): string {
return this.offerId
}
}

75
src/core/index.ts Normal file
View File

@@ -0,0 +1,75 @@
/**
* @xtr-dev/rondevu-client
* WebRTC peer signaling client
*/
export { Rondevu, RondevuError, NetworkError, ValidationError, ConnectionError } from './rondevu.js'
export { RondevuAPI } from '../api/client.js'
export { RpcBatcher } from '../api/batcher.js'
// Export connection classes
export { RondevuConnection } from '../connections/base.js'
export { OffererConnection } from '../connections/offerer.js'
export { AnswererConnection } from '../connections/answerer.js'
// Export utilities
export { ExponentialBackoff } from '../utils/exponential-backoff.js'
export { MessageBuffer } from '../utils/message-buffer.js'
// Export crypto adapters
export { WebCryptoAdapter } from '../crypto/web.js'
export { NodeCryptoAdapter } from '../crypto/node.js'
// Export types
export type {
Signaler,
Binnable,
} from './types.js'
export type {
Keypair,
OfferRequest,
ServiceRequest,
Service,
ServiceOffer,
IceCandidate,
} from '../api/client.js'
export type {
RondevuOptions,
PublishServiceOptions,
ConnectToServiceOptions,
ConnectionContext,
OfferContext,
OfferFactory,
ActiveOffer,
FindServiceOptions,
ServiceResult,
PaginatedServiceResult
} from './rondevu.js'
export type { CryptoAdapter } from '../crypto/adapter.js'
// Export connection types
export type {
ConnectionConfig,
} from '../connections/config.js'
export type {
ConnectionState,
BufferedMessage,
ReconnectInfo,
StateChangeInfo,
ConnectionEventMap,
ConnectionEventName,
ConnectionEventArgs,
} from '../connections/events.js'
export type {
OffererOptions,
} from '../connections/offerer.js'
export type {
AnswererOptions,
} from '../connections/answerer.js'

351
src/core/offer-pool.ts Normal file
View File

@@ -0,0 +1,351 @@
import { EventEmitter } from 'eventemitter3'
import { RondevuAPI } from '../api/client.js'
import { OffererConnection } from '../connections/offerer.js'
import { ConnectionConfig } from '../connections/config.js'
import { AsyncLock } from '../utils/async-lock.js'
export type OfferFactory = (pc: RTCPeerConnection) => Promise<{
dc?: RTCDataChannel
offer: RTCSessionDescriptionInit
}>
export interface OfferPoolOptions {
api: RondevuAPI
serviceFqn: string
maxOffers: number
offerFactory: OfferFactory
ttl: number
iceServers: RTCIceServer[]
connectionConfig?: Partial<ConnectionConfig>
debugEnabled?: boolean
}
interface OfferPoolEvents {
'connection:opened': (offerId: string, connection: OffererConnection) => void
'offer:created': (offerId: string, serviceFqn: string) => void
'offer:failed': (offerId: string, error: Error) => void
'connection:rotated': (oldOfferId: string, newOfferId: string, connection: OffererConnection) => void
}
/**
* OfferPool manages a pool of WebRTC offers for a published service.
* Maintains a target number of active offers and automatically replaces
* offers that fail or get answered.
*/
export class OfferPool extends EventEmitter<OfferPoolEvents> {
private readonly api: RondevuAPI
private readonly serviceFqn: string
private readonly maxOffers: number
private readonly offerFactory: OfferFactory
private readonly ttl: number
private readonly iceServers: RTCIceServer[]
private readonly connectionConfig?: Partial<ConnectionConfig>
private readonly debugEnabled: boolean
// State
private readonly activeConnections = new Map<string, OffererConnection>()
private readonly fillLock = new AsyncLock()
private running = false
private pollingInterval: ReturnType<typeof setInterval> | null = null
private lastPollTimestamp = 0
private static readonly POLLING_INTERVAL_MS = 1000
constructor(options: OfferPoolOptions) {
super()
this.api = options.api
this.serviceFqn = options.serviceFqn
this.maxOffers = options.maxOffers
this.offerFactory = options.offerFactory
this.ttl = options.ttl
this.iceServers = options.iceServers
this.connectionConfig = options.connectionConfig
this.debugEnabled = options.debugEnabled || false
}
/**
* Start filling offers and polling for answers
*/
async start(): Promise<void> {
if (this.running) {
this.debug('Already running')
return
}
this.debug('Starting offer pool')
this.running = true
// Fill initial offers
await this.fillOffers()
// Start polling for answers
this.pollingInterval = setInterval(() => {
this.pollInternal()
}, OfferPool.POLLING_INTERVAL_MS)
}
/**
* Stop filling offers and polling
* Closes all active connections
*/
stop(): void {
this.debug('Stopping offer pool')
this.running = false
// Stop polling
if (this.pollingInterval) {
clearInterval(this.pollingInterval)
this.pollingInterval = null
}
// Close all active connections
for (const [offerId, connection] of this.activeConnections.entries()) {
if (connection.isRotating()) {
this.debug(`Connection ${offerId} is rotating, will close anyway`)
}
this.debug(`Closing connection ${offerId}`)
connection.close()
}
this.activeConnections.clear()
}
/**
* Get count of active offers
*/
getOfferCount(): number {
return this.activeConnections.size
}
/**
* Get all active connections
*/
getActiveConnections(): Map<string, OffererConnection> {
return this.activeConnections
}
/**
* Check if a specific offer is connected
*/
isConnected(offerId: string): boolean {
const connection = this.activeConnections.get(offerId)
return connection ? connection.getState() === 'connected' : false
}
/**
* Disconnect all active offers
*/
disconnectAll(): void {
this.debug('Disconnecting all offers')
for (const [offerId, connection] of this.activeConnections.entries()) {
this.debug(`Closing connection ${offerId}`)
connection.close()
}
this.activeConnections.clear()
}
/**
* Fill offers to reach maxOffers count
* Uses AsyncLock to prevent concurrent fills
*/
private async fillOffers(): Promise<void> {
if (!this.running) return
return this.fillLock.run(async () => {
const currentCount = this.activeConnections.size
const needed = this.maxOffers - currentCount
this.debug(`Filling offers: current=${currentCount}, needed=${needed}`)
for (let i = 0; i < needed; i++) {
try {
await this.createOffer()
} catch (err) {
console.error('[OfferPool] Failed to create offer:', err)
}
}
})
}
/**
* Create a new offer for rotation (reuses existing creation logic)
* Similar to createOffer() but only creates the offer, doesn't create connection
*/
private async createNewOfferForRotation(): Promise<{
newOfferId: string
pc: RTCPeerConnection
dc?: RTCDataChannel
}> {
const rtcConfig: RTCConfiguration = {
iceServers: this.iceServers
}
this.debug('Creating new offer for rotation...')
// 1. Create RTCPeerConnection
const pc = new RTCPeerConnection(rtcConfig)
// 2. Call the factory to create offer
let dc: RTCDataChannel | undefined
let offer: RTCSessionDescriptionInit
try {
const factoryResult = await this.offerFactory(pc)
dc = factoryResult.dc
offer = factoryResult.offer
} catch (err) {
pc.close()
throw err
}
// 3. Publish to server to get offerId
const result = await this.api.publishService({
serviceFqn: this.serviceFqn,
offers: [{ sdp: offer.sdp! }],
ttl: this.ttl,
signature: '',
message: '',
})
const newOfferId = result.offers[0].offerId
this.debug(`New offer created for rotation: ${newOfferId}`)
return { newOfferId, pc, dc }
}
/**
* Create a single offer and publish it to the server
*/
private async createOffer(): Promise<void> {
const rtcConfig: RTCConfiguration = {
iceServers: this.iceServers
}
this.debug('Creating new offer...')
// 1. Create RTCPeerConnection
const pc = new RTCPeerConnection(rtcConfig)
// 2. Call the factory to create offer
let dc: RTCDataChannel | undefined
let offer: RTCSessionDescriptionInit
try {
const factoryResult = await this.offerFactory(pc)
dc = factoryResult.dc
offer = factoryResult.offer
} catch (err) {
pc.close()
throw err
}
// 3. Publish to server to get offerId
const result = await this.api.publishService({
serviceFqn: this.serviceFqn,
offers: [{ sdp: offer.sdp! }],
ttl: this.ttl,
signature: '',
message: '',
})
const offerId = result.offers[0].offerId
// 4. Create OffererConnection instance
const connection = new OffererConnection({
api: this.api,
serviceFqn: this.serviceFqn,
offerId,
pc,
dc,
config: {
...this.connectionConfig,
debug: this.debugEnabled,
},
})
// Setup connection event handlers
connection.on('connected', () => {
this.debug(`Connection established for offer ${offerId}`)
this.emit('connection:opened', offerId, connection)
})
connection.on('failed', async (error) => {
const currentOfferId = connection.getOfferId()
this.debug(`Connection failed for offer ${currentOfferId}, rotating...`)
try {
// Create new offer and rebind existing connection
const { newOfferId, pc, dc } = await this.createNewOfferForRotation()
// Rebind the connection to new offer
await connection.rebindToOffer(newOfferId, pc, dc)
// Update map: remove old offerId, add new offerId with same connection
this.activeConnections.delete(currentOfferId)
this.activeConnections.set(newOfferId, connection)
this.emit('connection:rotated', currentOfferId, newOfferId, connection)
this.debug(`Connection rotated: ${currentOfferId}${newOfferId}`)
} catch (rotationError) {
// If rotation fails, fall back to destroying connection
this.debug(`Rotation failed for ${currentOfferId}:`, rotationError)
this.activeConnections.delete(currentOfferId)
this.emit('offer:failed', currentOfferId, error)
this.fillOffers() // Create replacement
}
})
connection.on('closed', () => {
this.debug(`Connection closed for offer ${offerId}`)
this.activeConnections.delete(offerId)
this.fillOffers() // Replace closed offer
})
// Store active connection
this.activeConnections.set(offerId, connection)
// Initialize the connection
await connection.initialize()
this.debug(`Offer created: ${offerId}`)
this.emit('offer:created', offerId, this.serviceFqn)
}
/**
* Poll for answers and delegate to OffererConnections
*/
private async pollInternal(): Promise<void> {
if (!this.running) return
try {
const result = await this.api.poll(this.lastPollTimestamp)
// Process answers - delegate to OffererConnections
for (const answer of result.answers) {
const connection = this.activeConnections.get(answer.offerId)
if (connection) {
try {
await connection.processAnswer(answer.sdp, answer.answererId)
this.lastPollTimestamp = Math.max(this.lastPollTimestamp, answer.answeredAt)
// Create replacement offer
this.fillOffers()
} catch (err) {
this.debug(`Failed to process answer for offer ${answer.offerId}:`, err)
}
}
}
} catch (err) {
console.error('[OfferPool] Polling error:', err)
}
}
/**
* Debug logging (only if debug enabled)
*/
private debug(...args: unknown[]): void {
if (this.debugEnabled) {
console.log('[OfferPool]', ...args)
}
}
}

View File

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

View File

@@ -4,7 +4,7 @@
*/
import * as ed25519 from '@noble/ed25519'
import { CryptoAdapter, Keypair } from './crypto-adapter.js'
import { CryptoAdapter, Keypair } from './adapter.js'
/**
* Node.js Crypto implementation using Node.js built-in APIs
@@ -81,13 +81,11 @@ export class NodeCryptoAdapter implements CryptoAdapter {
bytesToBase64(bytes: Uint8Array): string {
// Node.js Buffer provides native base64 encoding
// @ts-expect-error - Buffer is available in Node.js but not in browser TypeScript definitions
return Buffer.from(bytes).toString('base64')
}
base64ToBytes(base64: string): Uint8Array {
// Node.js Buffer provides native base64 decoding
// @ts-expect-error - Buffer is available in Node.js but not in browser TypeScript definitions
return new Uint8Array(Buffer.from(base64, 'base64'))
}

View File

@@ -3,7 +3,7 @@
*/
import * as ed25519 from '@noble/ed25519'
import { CryptoAdapter, Keypair } from './crypto-adapter.js'
import { CryptoAdapter, Keypair } from './adapter.js'
// Set SHA-512 hash function for ed25519 (required in @noble/ed25519 v3+)
ed25519.hashes.sha512Async = async (message: Uint8Array) => {

View File

@@ -1,39 +0,0 @@
/**
* @xtr-dev/rondevu-client
* WebRTC peer signaling client
*/
export { Rondevu } from './rondevu.js'
export { RondevuAPI } from './api.js'
export { RpcBatcher } from './rpc-batcher.js'
// Export crypto adapters
export { WebCryptoAdapter } from './web-crypto-adapter.js'
export { NodeCryptoAdapter } from './node-crypto-adapter.js'
// Export types
export type {
Signaler,
Binnable,
} from './types.js'
export type {
Keypair,
OfferRequest,
ServiceRequest,
Service,
ServiceOffer,
IceCandidate,
} from './api.js'
export type {
RondevuOptions,
PublishServiceOptions,
ConnectToServiceOptions,
ConnectionContext,
OfferContext,
OfferFactory
} from './rondevu.js'
export type { CryptoAdapter } from './crypto-adapter.js'

77
src/utils/async-lock.ts Normal file
View File

@@ -0,0 +1,77 @@
/**
* AsyncLock provides a mutual exclusion primitive for asynchronous operations.
* Ensures only one async operation can proceed at a time while queuing others.
*/
export class AsyncLock {
private locked = false
private queue: Array<() => void> = []
/**
* Acquire the lock. If already locked, waits until released.
* @returns Promise that resolves when lock is acquired
*/
async acquire(): Promise<void> {
if (!this.locked) {
this.locked = true
return
}
// Lock is held, wait in queue
return new Promise<void>(resolve => {
this.queue.push(resolve)
})
}
/**
* Release the lock. If others are waiting, grants lock to next in queue.
*/
release(): void {
const next = this.queue.shift()
if (next) {
// Grant lock to next waiter
next()
} else {
// No waiters, mark as unlocked
this.locked = false
}
}
/**
* Run a function with the lock acquired, automatically releasing after.
* This is the recommended way to use AsyncLock to prevent forgetting to release.
*
* @param fn - Async function to run with lock held
* @returns Promise resolving to the function's return value
*
* @example
* ```typescript
* const lock = new AsyncLock()
* const result = await lock.run(async () => {
* // Critical section - only one caller at a time
* return await doSomething()
* })
* ```
*/
async run<T>(fn: () => Promise<T>): Promise<T> {
await this.acquire()
try {
return await fn()
} finally {
this.release()
}
}
/**
* Check if lock is currently held
*/
isLocked(): boolean {
return this.locked
}
/**
* Get number of operations waiting for the lock
*/
getQueueLength(): number {
return this.queue.length
}
}

View File

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

125
src/utils/message-buffer.ts Normal file
View File

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