29 Commits

Author SHA1 Message Date
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
Bas
4d90cce9d0 Merge pull request #3 from xtr-dev/claude/fix-issue-2-6zYvD
Fix issue #2
2025-12-14 11:03:47 +01:00
Bas
f5e202384a Merge pull request #4 from xtr-dev/add-claude-github-actions-1765706414732
Add Claude Code GitHub Workflow
2025-12-14 11:01:07 +01:00
Bas
a21fb04a6f "Claude Code Review workflow" 2025-12-14 11:00:16 +01:00
Bas
6ee1d7b5a9 "Claude PR Assistant workflow" 2025-12-14 11:00:15 +01:00
Claude
62a6cdcb99 Update package-lock.json 2025-12-14 09:57:07 +00:00
Claude
febe3b7270 Fix early ICE candidates lost due to late handler setup in createOffer()
Queue ICE candidates that are generated before we have the offerId from
the server. When the factory calls setLocalDescription(), ICE gathering
starts immediately, but we couldn't send candidates until we had the
offerId from publishService(). Now we:

1. Set up a queuing handler immediately after getting the pc from factory
2. Buffer any early candidates while publishing to get the offerId
3. Flush all queued candidates once we have the offerId
4. Continue handling future candidates normally

Fixes #2
2025-12-14 09:56:27 +00:00
83831cae77 v0.18.0 - Add WebRTC polyfill support for Node.js 2025-12-13 18:47:38 +01:00
e954a70aa7 Add WebRTC polyfill support for Node.js environments
Allow users to pass WebRTC polyfills (RTCPeerConnection, RTCIceCandidate) through RondevuOptions instead of manually setting global variables. The client now automatically applies these to globalThis when provided.

This simplifies Node.js integration:
- Before: Users had to manually set globalThis.RTCPeerConnection
- After: Pass rtcPeerConnection and rtcIceCandidate options

Example:
  const rondevu = await Rondevu.connect({
    apiUrl: 'https://api.example.com',
    username: 'alice',
    cryptoAdapter: new NodeCryptoAdapter(),
    rtcPeerConnection: wrtc.RTCPeerConnection,
    rtcIceCandidate: wrtc.RTCIceCandidate
  })
2025-12-13 18:47:27 +01:00
17 changed files with 2500 additions and 839 deletions

View File

@@ -0,0 +1,57 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
Please review this pull request and provide feedback on:
- Code quality and best practices
- Potential bugs or issues
- Performance considerations
- Security concerns
- Test coverage
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'

50
.github/workflows/claude.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'

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 ### 1. Answerer Side (connectToService)
**Manual Connection Management**: No need to handle WebRTC peer lifecycle manually
**Service Handles**: Replaced with DurableService instances
## Breaking Changes #### Old API (v0.18.7 and earlier)
### 1. Service Exposure
#### v0.8.x (Old)
```typescript ```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(); dc.addEventListener('message', (event) => {
await client.register(); console.log('Received:', event.data)
})
const handle = await client.services.exposeService({ dc.addEventListener('open', () => {
username: 'alice', dc.send('Hello!')
privateKey: keypair.privateKey, })
serviceFqn: 'chat@1.0.0',
isPublic: true,
handler: (channel, peer) => {
channel.onmessage = (e) => {
console.log('Received:', e.data);
channel.send(`Echo: ${e.data}`);
};
} }
}); })
// Unpublish // Access peer connection
await handle.unpublish(); context.pc.getStats()
``` ```
#### v0.9.0 (New) #### New API (v0.18.8)
```typescript ```typescript
import { Rondevu } from '@xtr-dev/rondevu-client'; const connection = await rondevu.connectToService({
serviceFqn: 'chat:1.0.0@alice',
const client = new Rondevu(); connectionConfig: {
await client.register(); reconnectEnabled: true, // Optional: enable auto-reconnect
bufferEnabled: true, // Optional: enable message buffering
const service = await client.exposeService({ connectionTimeout: 30000 // Optional: connection timeout (ms)
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'chat@1.0.0',
isPublic: true,
poolSize: 10, // NEW: Handle multiple concurrent connections
handler: (channel, connectionId) => {
// NEW: DurableChannel with event emitters
channel.on('message', (data) => {
console.log('Received:', data);
channel.send(`Echo: ${data}`);
});
} }
}); })
// NEW: Start the service
await service.start();
// NEW: Stop the service
await service.stop();
```
**Key Differences:**
- `client.services.exposeService()``client.exposeService()`
- Returns `DurableService` instead of `ServiceHandle`
- Handler receives `DurableChannel` instead of `RTCDataChannel`
- Handler receives `connectionId` string instead of `RondevuPeer`
- DurableChannel uses `.on('message', ...)` instead of `.onmessage = ...`
- Must call `service.start()` to begin accepting connections
- Use `service.stop()` instead of `handle.unpublish()`
### 2. Connecting to Services
#### v0.8.x (Old)
```typescript
// Connect by username + FQN
const { peer, channel } = await client.discovery.connect(
'alice',
'chat@1.0.0'
);
channel.onmessage = (e) => {
console.log('Received:', e.data);
};
channel.onopen = () => {
channel.send('Hello!');
};
peer.on('connected', () => {
console.log('Connected');
});
peer.on('failed', (error) => {
console.error('Failed:', error);
});
```
#### v0.9.0 (New)
```typescript
// Connect by username + FQN
const connection = await client.connect('alice', 'chat@1.0.0', {
maxReconnectAttempts: 10 // NEW: Configurable reconnection
});
// NEW: Create durable channel
const channel = connection.createChannel('main');
channel.on('message', (data) => {
console.log('Received:', data);
});
channel.on('open', () => {
channel.send('Hello!');
});
// NEW: Connection lifecycle events
connection.on('connected', () => {
console.log('Connected');
});
connection.on('reconnecting', (attempt, max, delay) => {
console.log(`Reconnecting (${attempt}/${max})...`);
});
connection.on('failed', (error) => {
console.error('Failed permanently:', error);
});
// NEW: Must explicitly connect
await connection.connect();
```
**Key Differences:**
- `client.discovery.connect()``client.connect()`
- Returns `DurableConnection` instead of `{ peer, channel }`
- Must create channels with `connection.createChannel()`
- Must call `connection.connect()` to establish connection
- Automatic reconnection with configurable retry limits
- Messages sent during disconnection are automatically queued
### 3. Connecting by UUID
#### v0.8.x (Old)
```typescript
const { peer, channel } = await client.discovery.connectByUuid('service-uuid');
channel.onmessage = (e) => {
console.log('Received:', e.data);
};
```
#### v0.9.0 (New)
```typescript
const connection = await client.connectByUuid('service-uuid', {
maxReconnectAttempts: 5
});
const channel = connection.createChannel('main');
channel.on('message', (data) => {
console.log('Received:', data);
});
await connection.connect();
```
**Key Differences:**
- `client.discovery.connectByUuid()``client.connectByUuid()`
- Returns `DurableConnection` instead of `{ peer, channel }`
- Must create channels and connect explicitly
### 4. Multi-Connection Services (Offer Pooling)
#### v0.8.x (Old)
```typescript
const handle = await client.services.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'chat@1.0.0',
poolSize: 5,
pollingInterval: 2000,
handler: (channel, peer, connectionId) => {
console.log(`Connection: ${connectionId}`);
},
onPoolStatus: (status) => {
console.log('Pool status:', status);
}
});
const status = handle.getStatus();
await handle.addOffers(3);
```
#### v0.9.0 (New)
```typescript
const service = await client.exposeService({
username: 'alice',
privateKey: keypair.privateKey,
serviceFqn: 'chat@1.0.0',
poolSize: 5, // SAME: Pool size
pollingInterval: 2000, // SAME: Polling interval
handler: (channel, connectionId) => {
console.log(`Connection: ${connectionId}`);
}
});
await service.start();
// Get active connections
const connections = service.getActiveConnections();
// Listen for connection events // Listen for connection events
service.on('connection', (connectionId) => { connection.on('connected', () => {
console.log('New connection:', connectionId); 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:** **Key Changes:**
- `onPoolStatus` callback removed (use `service.on('connection')` instead) - ❌ Removed `onConnection` callback
- `handle.getStatus()` replaced with `service.getActiveConnections()` - ✅ Use event listeners instead: `connection.on('connected', ...)`
- `handle.addOffers()` removed (pool auto-manages offers) - ❌ Removed direct `dc.send()` access
- Handler receives `DurableChannel` instead of `RTCDataChannel` - ✅ Use `connection.send()` for automatic buffering support
- ✅ Added automatic reconnection and message buffering
## Feature Comparison ---
| Feature | v0.8.x | v0.9.0 | ### 2. Offerer Side (publishService)
|---------|--------|--------|
| Service exposure | `client.services.exposeService()` | `client.exposeService()` |
| Connection | `client.discovery.connect()` | `client.connect()` |
| Connection by UUID | `client.discovery.connectByUuid()` | `client.connectByUuid()` |
| Channel type | `RTCDataChannel` | `DurableChannel` |
| Event handling | `.onmessage`, `.onopen`, etc. | `.on('message')`, `.on('open')`, etc. |
| Automatic reconnection | ❌ No | ✅ Yes (configurable) |
| Message queuing | ❌ No | ✅ Yes (during disconnections) |
| TTL auto-refresh | ❌ No | ✅ Yes (configurable) |
| Peer lifecycle | Manual | Automatic |
| Connection pooling | ✅ Yes | ✅ Yes (same API) |
## API Mapping #### Old API (v0.18.7 and earlier)
### Removed Exports
These are no longer exported in v0.9.0:
```typescript ```typescript
// ❌ Removed await rondevu.publishService({
import { service: 'chat:1.0.0',
RondevuServices, maxOffers: 5
RondevuDiscovery, })
RondevuPeer,
ServiceHandle, await rondevu.startFilling()
PooledServiceHandle,
ConnectResult // Handle connections
} from '@xtr-dev/rondevu-client'; 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 #### New API (v0.18.8)
These are new in v0.9.0:
```typescript ```typescript
// ✅ New await rondevu.publishService({
import { service: 'chat:1.0.0',
DurableConnection, maxOffers: 5,
DurableChannel, connectionConfig: {
DurableService, reconnectEnabled: true,
DurableConnectionState, bufferEnabled: true
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.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 ```typescript
const service = await client.exposeService({ interface ConnectionConfig {
username: 'alice', // Timeouts
privateKey: keypair.privateKey, connectionTimeout: number // Default: 30000ms (30s)
serviceFqn: 'chat@1.0.0', iceGatheringTimeout: number // Default: 10000ms (10s)
// TTL auto-refresh (NEW) // Reconnection
ttl: 300000, // default: 300000ms (5 minutes) reconnectEnabled: boolean // Default: true
ttlRefreshMargin: 0.2, // default: 0.2 (refresh at 80% of TTL) maxReconnectAttempts: number // Default: 5
reconnectBackoffBase: number // Default: 1000ms
reconnectBackoffMax: number // Default: 30000ms (30s)
// All connection options also apply to incoming connections // Message buffering
maxReconnectAttempts: 10, bufferEnabled: boolean // Default: true
maxQueueSize: 1000, 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()` ```typescript
- [ ] Add `await service.start()` after creating service const connection = await rondevu.connectToService({
- [ ] Replace `handle.unpublish()` with `service.stop()` serviceFqn: 'chat:1.0.0@alice',
- [ ] Replace `client.discovery.connect()` with `client.connect()` connectionConfig: {
- [ ] Replace `client.discovery.connectByUuid()` with `client.connectByUuid()` // Disable auto-reconnect if you want manual control
- [ ] Create channels with `connection.createChannel()` instead of receiving them directly reconnectEnabled: false,
- [ ] Add `await connection.connect()` to establish connection
- [ ] Update handlers from `(channel, peer, connectionId?)` to `(channel, connectionId)` // Disable buffering if messages are time-sensitive
- [ ] Replace `.onmessage` with `.on('message', ...)` bufferEnabled: false,
- [ ] Replace `.onopen` with `.on('open', ...)`
- [ ] Replace `.onclose` with `.on('close', ...)` // Increase timeout for slow networks
- [ ] Replace `.onerror` with `.on('error', ...)` connectionTimeout: 60000,
- [ ] Add reconnection event handlers (`connection.on('reconnecting', ...)`)
- [ ] Review and configure reconnection options if needed // Reduce retry attempts
- [ ] Review and configure message queue limits if needed maxReconnectAttempts: 3
- [ ] Update TypeScript imports to use new types }
- [ ] Test automatic reconnection behavior })
- [ ] Test message queuing during disconnections ```
---
## 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 ## Common Migration Patterns
### Pattern 1: Simple Echo Service ### Pattern 1: Simple Message Handler
#### Before (v0.8.x) **Before:**
```typescript ```typescript
await client.services.exposeService({ dc.addEventListener('message', (event) => {
username: 'alice', console.log(event.data)
privateKey: keypair.privateKey, })
serviceFqn: 'echo@1.0.0', dc.send('Hello')
handler: (channel) => {
channel.onmessage = (e) => {
channel.send(`Echo: ${e.data}`);
};
}
});
``` ```
#### After (v0.9.0) **After:**
```typescript ```typescript
const service = await client.exposeService({ connection.on('message', (data) => {
username: 'alice', console.log(data)
privateKey: keypair.privateKey, })
serviceFqn: 'echo@1.0.0', connection.send('Hello')
handler: (channel) => {
channel.on('message', (data) => {
channel.send(`Echo: ${data}`);
});
}
});
await service.start();
``` ```
### Pattern 2: Connection with Error Handling ---
#### Before (v0.8.x) ### Pattern 2: Connection State Monitoring
**Before:**
```typescript ```typescript
try { pc.oniceconnectionstatechange = () => {
const { peer, channel } = await client.discovery.connect('alice', 'chat@1.0.0'); console.log('ICE state:', pc.iceConnectionState)
channel.onopen = () => {
channel.send('Hello!');
};
peer.on('failed', (error) => {
console.error('Connection failed:', error);
// Manual reconnection logic here
});
} catch (error) {
console.error('Failed to connect:', error);
} }
``` ```
#### After (v0.9.0) **After:**
```typescript ```typescript
const connection = await client.connect('alice', 'chat@1.0.0', { connection.on('ice:connection:state', (state) => {
maxReconnectAttempts: 5 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) => { ### Pattern 3: Handling Connection Failures
console.log(`Reconnecting (${attempt}/${max}) in ${delay}ms`);
});
connection.on('failed', (error) => { **Before:**
console.error('Connection failed permanently:', error); ```typescript
}); pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'failed') {
try { // Manual reconnection logic
await connection.connect(); pc.close()
} catch (error) { await setupNewConnection()
console.error('Initial connection failed:', error); }
} }
``` ```
### Pattern 3: Multi-User Chat Server **After:**
#### Before (v0.8.x)
```typescript ```typescript
const connections = new Map(); // Automatic reconnection built-in!
connection.on('reconnecting', (attempt) => {
console.log(`Reconnecting... attempt ${attempt}`)
})
await client.services.exposeService({ connection.on('reconnect:success', () => {
username: 'alice', console.log('Back online!')
privateKey: keypair.privateKey, })
serviceFqn: 'chat@1.0.0',
poolSize: 10,
handler: (channel, peer, connectionId) => {
connections.set(connectionId, channel);
channel.onmessage = (e) => { connection.on('reconnect:exhausted', (attempts) => {
// Broadcast to all console.log(`Failed after ${attempts} attempts`)
for (const [id, ch] of connections) { // Fallback logic here
if (id !== connectionId) { })
ch.send(e.data);
}
}
};
channel.onclose = () => {
connections.delete(connectionId);
};
}
});
``` ```
#### After (v0.9.0) ---
### Pattern 4: Accessing Raw RTCPeerConnection/DataChannel
If you need low-level access:
```typescript ```typescript
const channels = new Map(); const connection = await rondevu.connectToService({ ... })
const service = await client.exposeService({ // Get raw objects if needed
username: 'alice', const pc = connection.getPeerConnection()
privateKey: keypair.privateKey, const dc = connection.getDataChannel()
serviceFqn: 'chat@1.0.0',
poolSize: 10,
handler: (channel, connectionId) => {
channels.set(connectionId, channel);
channel.on('message', (data) => { // Use them directly (bypasses buffering/reconnection features)
// Broadcast to all if (dc) {
for (const [id, ch] of channels) { dc.addEventListener('message', (event) => {
if (id !== connectionId) { console.log(event.data)
ch.send(data); })
} }
}
});
channel.on('close', () => {
channels.delete(connectionId);
});
}
});
await service.start();
// Optional: Track connections
service.on('connection', (connectionId) => {
console.log(`User ${connectionId} joined`);
});
service.on('disconnection', (connectionId) => {
console.log(`User ${connectionId} left`);
});
``` ```
## Benefits of Migration **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: ### What Still Works
1. Check the [README](./README.md) for complete API documentation `publishService()` API (just add `connectionConfig` optionally)
2. Review the examples for common patterns `findService()` API (unchanged)
3. Open an issue on [GitHub](https://github.com/xtr-dev/rondevu-client/issues) ✅ 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)

310
README.md
View File

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

42
package-lock.json generated
View File

@@ -1,16 +1,16 @@
{ {
"name": "@xtr-dev/rondevu-client", "name": "@xtr-dev/rondevu-client",
"version": "0.17.1", "version": "0.18.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@xtr-dev/rondevu-client", "name": "@xtr-dev/rondevu-client",
"version": "0.17.1", "version": "0.18.7",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@noble/ed25519": "^3.0.0", "@noble/ed25519": "^3.0.0",
"@xtr-dev/rondevu-client": "^0.9.2" "eventemitter3": "^5.0.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
@@ -1076,6 +1076,18 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.48.1", "version": "8.48.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz",
@@ -1310,15 +1322,6 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/@xtr-dev/rondevu-client": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.9.2.tgz",
"integrity": "sha512-DVow5AOPU40dqQtlfQK7J2GNX8dz2/4UzltMqublaPZubbkRYgocvp0b76NQu5F6v150IstMV2N49uxAYqogVw==",
"license": "MIT",
"dependencies": {
"@noble/ed25519": "^3.0.0"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -2000,6 +2003,12 @@
"node": ">=0.10.0" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2838,6 +2847,15 @@
"node": ">=14.17" "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": { "node_modules/update-browserslist-db": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@xtr-dev/rondevu-client", "name": "@xtr-dev/rondevu-client",
"version": "0.17.1", "version": "0.18.9",
"description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing", "description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
@@ -41,6 +41,7 @@
"README.md" "README.md"
], ],
"dependencies": { "dependencies": {
"@noble/ed25519": "^3.0.0" "@noble/ed25519": "^3.0.0",
"eventemitter3": "^5.0.1"
} }
} }

183
src/answerer-connection.ts Normal file
View File

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

View File

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

64
src/connection-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/connection-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]

567
src/connection.ts Normal file
View File

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

View File

@@ -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
}
}

View File

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

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

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

View File

@@ -81,13 +81,11 @@ export class NodeCryptoAdapter implements CryptoAdapter {
bytesToBase64(bytes: Uint8Array): string { bytesToBase64(bytes: Uint8Array): string {
// Node.js Buffer provides native base64 encoding // 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') return Buffer.from(bytes).toString('base64')
} }
base64ToBytes(base64: string): Uint8Array { base64ToBytes(base64: string): Uint8Array {
// Node.js Buffer provides native base64 decoding // 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')) return new Uint8Array(Buffer.from(base64, 'base64'))
} }

213
src/offerer-connection.ts Normal file
View File

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

View File

@@ -1,5 +1,9 @@
import { RondevuAPI, Keypair, IceCandidate, BatcherOptions } from './api.js' import { RondevuAPI, Keypair, IceCandidate, BatcherOptions } from './api.js'
import { CryptoAdapter } from './crypto-adapter.js' import { CryptoAdapter } from './crypto-adapter.js'
import { EventEmitter } from 'eventemitter3'
import { OffererConnection } from './offerer-connection.js'
import { AnswererConnection } from './answerer-connection.js'
import { ConnectionConfig } from './connection-config.js'
// ICE server preset names // ICE server preset names
export type IceServerPreset = 'ipv4-turn' | 'hostname-turns' | 'google-stun' | 'relay-only' export type IceServerPreset = 'ipv4-turn' | 'hostname-turns' | 'google-stun' | 'relay-only'
@@ -57,21 +61,32 @@ export interface RondevuOptions {
batching?: BatcherOptions | false // Optional, defaults to enabled with default options batching?: BatcherOptions | false // Optional, defaults to enabled with default options
iceServers?: IceServerPreset | RTCIceServer[] // Optional: preset name or custom STUN/TURN servers iceServers?: IceServerPreset | RTCIceServer[] // Optional: preset name or custom STUN/TURN servers
debug?: boolean // Optional: enable debug logging (default: false) debug?: boolean // Optional: enable debug logging (default: false)
// WebRTC polyfills for Node.js environments (e.g., wrtc)
rtcPeerConnection?: typeof RTCPeerConnection
rtcIceCandidate?: typeof RTCIceCandidate
} }
export interface OfferContext { export interface OfferContext {
pc: RTCPeerConnection
dc?: RTCDataChannel dc?: RTCDataChannel
offer: RTCSessionDescriptionInit 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 { export interface PublishServiceOptions {
service: string // Service name and version (e.g., "chat:2.0.0") - username will be auto-appended 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 maxOffers: number // Maximum number of concurrent offers to maintain
offerFactory?: OfferFactory // Optional: custom offer creation (defaults to simple data channel) offerFactory?: OfferFactory // Optional: custom offer creation (defaults to simple data channel)
ttl?: number // Time-to-live for offers in milliseconds (default: 300000) ttl?: number // Time-to-live for offers in milliseconds (default: 300000)
connectionConfig?: Partial<ConnectionConfig> // Optional: connection durability configuration
} }
export interface ConnectionContext { export interface ConnectionContext {
@@ -86,11 +101,11 @@ export interface ConnectToServiceOptions {
serviceFqn?: string // Full FQN like 'chat:2.0.0@alice' serviceFqn?: string // Full FQN like 'chat:2.0.0@alice'
service?: string // Service without username (for discovery) service?: string // Service without username (for discovery)
username?: string // Target username (combined with service) 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 rtcConfig?: RTCConfiguration // Optional: override default ICE servers
connectionConfig?: Partial<ConnectionConfig> // Optional: connection durability configuration
} }
interface ActiveOffer { export interface ActiveOffer {
offerId: string offerId: string
serviceFqn: string serviceFqn: string
pc: RTCPeerConnection pc: RTCPeerConnection
@@ -99,15 +114,81 @@ interface ActiveOffer {
createdAt: number 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: * v1.0.0 introduces breaking changes:
* - Implicit username claiming (auto-claimed on first authenticated request) * - connectToService() now returns AnswererConnection instead of ConnectionContext
* - Service publishing with automatic signature generation * - Automatic reconnection and message buffering built-in
* - Service discovery (direct, random, paginated) * - Connection objects expose .send() method instead of raw DataChannel
* - WebRTC signaling (offer/answer exchange, ICE relay) * - Rich event system for connection lifecycle (connected, disconnected, reconnecting, etc.)
* - Keypair management
* *
* @example * @example
* ```typescript * ```typescript
@@ -118,42 +199,42 @@ interface ActiveOffer {
* iceServers: 'ipv4-turn' // Use preset: 'ipv4-turn', 'hostname-turns', 'google-stun', or 'relay-only' * 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 * // Publish a service with automatic offer management
* await rondevu.publishService({ * await rondevu.publishService({
* service: 'chat:2.0.0', * service: 'chat:2.0.0',
* maxOffers: 5, // Maintain up to 5 concurrent offers * maxOffers: 5 // Maintain up to 5 concurrent offers
* offerFactory: async (rtcConfig) => {
* const pc = new RTCPeerConnection(rtcConfig)
* const dc = pc.createDataChannel('chat')
* const offer = await pc.createOffer()
* await pc.setLocalDescription(offer)
* return { pc, dc, offer }
* }
* }) * })
* *
* // Start accepting connections (auto-fills offers and polls) * // Start accepting connections (auto-fills offers and polls)
* await rondevu.startFilling() * await rondevu.startFilling()
* *
* // Access active connections * // Listen for connections (v1.0.0 API)
* for (const offer of rondevu.getActiveOffers()) { * rondevu.on('connection:opened', (offerId, connection) => {
* offer.dc?.addEventListener('message', (e) => console.log(e.data)) * connection.on('connected', () => console.log('Connected!'))
* } * connection.on('message', (data) => console.log('Received:', data))
* connection.send('Hello!')
* })
* *
* // Stop when done * // Connect to a service (v1.0.0 - returns AnswererConnection)
* rondevu.stopFilling() * 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 // Constants
private static readonly DEFAULT_TTL_MS = 300000 // 5 minutes private static readonly DEFAULT_TTL_MS = 300000 // 5 minutes
private static readonly POLLING_INTERVAL_MS = 1000 // 1 second private static readonly POLLING_INTERVAL_MS = 1000 // 1 second
@@ -167,16 +248,20 @@ export class Rondevu {
private batchingOptions?: BatcherOptions | false private batchingOptions?: BatcherOptions | false
private iceServers: RTCIceServer[] private iceServers: RTCIceServer[]
private debugEnabled: boolean private debugEnabled: boolean
private rtcPeerConnection?: typeof RTCPeerConnection
private rtcIceCandidate?: typeof RTCIceCandidate
// Service management // Service management
private currentService: string | null = null private currentService: string | null = null
private maxOffers = 0 private maxOffers = 0
private offerFactory: OfferFactory | null = null private offerFactory: OfferFactory | null = null
private ttl = Rondevu.DEFAULT_TTL_MS private ttl = Rondevu.DEFAULT_TTL_MS
private activeOffers = new Map<string, ActiveOffer>() private activeConnections = new Map<string, OffererConnection>()
private connectionConfig?: Partial<ConnectionConfig>
// Polling // Polling
private filling = false private filling = false
private fillingSemaphore = false // Semaphore to prevent concurrent fillOffers calls
private pollingInterval: ReturnType<typeof setInterval> | null = null private pollingInterval: ReturnType<typeof setInterval> | null = null
private lastPollTimestamp = 0 private lastPollTimestamp = 0
@@ -188,8 +273,11 @@ export class Rondevu {
iceServers: RTCIceServer[], iceServers: RTCIceServer[],
cryptoAdapter?: CryptoAdapter, cryptoAdapter?: CryptoAdapter,
batchingOptions?: BatcherOptions | false, batchingOptions?: BatcherOptions | false,
debugEnabled = false debugEnabled = false,
rtcPeerConnection?: typeof RTCPeerConnection,
rtcIceCandidate?: typeof RTCIceCandidate
) { ) {
super()
this.apiUrl = apiUrl this.apiUrl = apiUrl
this.username = username this.username = username
this.keypair = keypair this.keypair = keypair
@@ -198,6 +286,8 @@ export class Rondevu {
this.cryptoAdapter = cryptoAdapter this.cryptoAdapter = cryptoAdapter
this.batchingOptions = batchingOptions this.batchingOptions = batchingOptions
this.debugEnabled = debugEnabled this.debugEnabled = debugEnabled
this.rtcPeerConnection = rtcPeerConnection
this.rtcIceCandidate = rtcIceCandidate
this.debug('Instance created:', { this.debug('Instance created:', {
username: this.username, username: this.username,
@@ -230,6 +320,14 @@ export class Rondevu {
static async connect(options: RondevuOptions): Promise<Rondevu> { static async connect(options: RondevuOptions): Promise<Rondevu> {
const username = options.username || Rondevu.generateAnonymousUsername() const username = options.username || Rondevu.generateAnonymousUsername()
// Apply WebRTC polyfills to global scope if provided (Node.js environments)
if (options.rtcPeerConnection) {
globalThis.RTCPeerConnection = options.rtcPeerConnection as any
}
if (options.rtcIceCandidate) {
globalThis.RTCIceCandidate = options.rtcIceCandidate as any
}
// Handle preset string or custom array // Handle preset string or custom array
let iceServers: RTCIceServer[] let iceServers: RTCIceServer[]
if (typeof options.iceServers === 'string') { if (typeof options.iceServers === 'string') {
@@ -277,7 +375,9 @@ export class Rondevu {
iceServers, iceServers,
options.cryptoAdapter, options.cryptoAdapter,
options.batching, options.batching,
options.debug || false options.debug || false,
options.rtcPeerConnection,
options.rtcIceCandidate
) )
} }
@@ -318,15 +418,15 @@ export class Rondevu {
/** /**
* Default offer factory - creates a simple data channel connection * 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> { private async defaultOfferFactory(pc: RTCPeerConnection): Promise<OfferContext> {
const pc = new RTCPeerConnection(rtcConfig)
const dc = pc.createDataChannel('default') const dc = pc.createDataChannel('default')
const offer = await pc.createOffer() const offer = await pc.createOffer()
await pc.setLocalDescription(offer) await pc.setLocalDescription(offer)
return { pc, dc, offer } return { dc, offer }
} }
/** /**
@@ -337,55 +437,30 @@ export class Rondevu {
* ```typescript * ```typescript
* await rondevu.publishService({ * await rondevu.publishService({
* service: 'chat:2.0.0', * service: 'chat:2.0.0',
* maxOffers: 5 * maxOffers: 5,
* connectionConfig: {
* reconnectEnabled: true,
* bufferEnabled: true
* }
* }) * })
* await rondevu.startFilling() * await rondevu.startFilling()
* ``` * ```
*/ */
async publishService(options: PublishServiceOptions): Promise<void> { async publishService(options: PublishServiceOptions): Promise<void> {
const { service, maxOffers, offerFactory, ttl } = options const { service, maxOffers, offerFactory, ttl, connectionConfig } = options
this.currentService = service this.currentService = service
this.maxOffers = maxOffers this.maxOffers = maxOffers
this.offerFactory = offerFactory || this.defaultOfferFactory.bind(this) this.offerFactory = offerFactory || this.defaultOfferFactory.bind(this)
this.ttl = ttl || Rondevu.DEFAULT_TTL_MS this.ttl = ttl || Rondevu.DEFAULT_TTL_MS
this.connectionConfig = connectionConfig
this.debug(`Publishing service: ${service} with maxOffers: ${maxOffers}`) this.debug(`Publishing service: ${service} with maxOffers: ${maxOffers}`)
this.usernameClaimed = true this.usernameClaimed = true
} }
/** /**
* Set up ICE candidate handler to send candidates to the server * Create a single offer and publish it to the server using OffererConnection
*/
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> { private async createOffer(): Promise<void> {
if (!this.currentService || !this.offerFactory) { if (!this.currentService || !this.offerFactory) {
@@ -396,15 +471,27 @@ export class Rondevu {
iceServers: this.iceServers iceServers: this.iceServers
} }
this.debug('Creating new offer...')
// Create the offer using the factory
const { pc, dc, offer } = await this.offerFactory(rtcConfig)
// Auto-append username to service // Auto-append username to service
const serviceFqn = `${this.currentService}@${this.username}` const serviceFqn = `${this.currentService}@${this.username}`
// Publish to server this.debug('Creating new offer...')
// 1. Create RTCPeerConnection using factory (for now, keep compatibility)
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({ const result = await this.api.publishService({
serviceFqn, serviceFqn,
offers: [{ sdp: offer.sdp! }], offers: [{ sdp: offer.sdp! }],
@@ -415,49 +502,75 @@ export class Rondevu {
const offerId = result.offers[0].offerId const offerId = result.offers[0].offerId
// Store active offer // 4. Create OffererConnection instance with already-created PC and DC
this.activeOffers.set(offerId, { const connection = new OffererConnection({
offerId, api: this.api,
serviceFqn, serviceFqn,
pc, offerId,
dc, pc, // Pass the peer connection from factory
answered: false, dc, // Pass the data channel from factory
createdAt: Date.now() 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', (error) => {
this.debug(`Connection failed for offer ${offerId}:`, error)
this.activeConnections.delete(offerId)
this.fillOffers() // Replace failed offer
})
connection.on('closed', () => {
this.debug(`Connection closed for offer ${offerId}`)
this.activeConnections.delete(offerId)
this.fillOffers() // Replace closed offer
})
// Store active connection
this.activeConnections.set(offerId, connection)
// Initialize the connection
await connection.initialize()
this.debug(`Offer created: ${offerId}`) this.debug(`Offer created: ${offerId}`)
this.emit('offer:created', offerId, serviceFqn)
// Set up ICE candidate handler
this.setupIceCandidateHandler(pc, serviceFqn, offerId)
// 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 * Fill offers to reach maxOffers count with semaphore protection
*/ */
private async fillOffers(): Promise<void> { private async fillOffers(): Promise<void> {
if (!this.filling || !this.currentService) return if (!this.filling || !this.currentService) return
const currentCount = this.activeOffers.size // Semaphore to prevent concurrent fills
const needed = this.maxOffers - currentCount if (this.fillingSemaphore) {
this.debug('fillOffers already in progress, skipping')
return
}
this.debug(`Filling offers: current=${currentCount}, needed=${needed}`) this.fillingSemaphore = true
try {
const currentCount = this.activeConnections.size
const needed = this.maxOffers - currentCount
for (let i = 0; i < needed; i++) { this.debug(`Filling offers: current=${currentCount}, needed=${needed}`)
try {
await this.createOffer() for (let i = 0; i < needed; i++) {
} catch (err) { try {
console.error('[Rondevu] Failed to create offer:', err) await this.createOffer()
} catch (err) {
console.error('[Rondevu] Failed to create offer:', err)
}
} }
} finally {
this.fillingSemaphore = false
} }
} }
@@ -470,36 +583,18 @@ export class Rondevu {
try { try {
const result = await this.api.poll(this.lastPollTimestamp) const result = await this.api.poll(this.lastPollTimestamp)
// Process answers // Process answers - delegate to OffererConnections
for (const answer of result.answers) { for (const answer of result.answers) {
const activeOffer = this.activeOffers.get(answer.offerId) const connection = this.activeConnections.get(answer.offerId)
if (activeOffer && !activeOffer.answered) { if (connection) {
this.debug(`Received answer for offer ${answer.offerId}`) try {
await connection.processAnswer(answer.sdp, answer.answererId)
this.lastPollTimestamp = Math.max(this.lastPollTimestamp, answer.answeredAt)
await activeOffer.pc.setRemoteDescription({ // Create replacement offer
type: 'answer', this.fillOffers()
sdp: answer.sdp } catch (err) {
}) this.debug(`Failed to process answer for offer ${answer.offerId}:`, err)
activeOffer.answered = true
this.lastPollTimestamp = answer.answeredAt
// Create replacement offer
this.fillOffers()
}
}
// Process ICE candidates
for (const [offerId, candidates] of Object.entries(result.iceCandidates)) {
const activeOffer = this.activeOffers.get(offerId)
if (activeOffer) {
const answererCandidates = candidates.filter(c => c.role === 'answerer')
for (const item of answererCandidates) {
if (item.candidate) {
await activeOffer.pc.addIceCandidate(new RTCIceCandidate(item.candidate))
this.lastPollTimestamp = Math.max(this.lastPollTimestamp, item.createdAt)
}
} }
} }
} }
@@ -541,6 +636,7 @@ export class Rondevu {
stopFilling(): void { stopFilling(): void {
this.debug('Stopping offer filling and polling') this.debug('Stopping offer filling and polling')
this.filling = false this.filling = false
this.fillingSemaphore = false
// Stop polling // Stop polling
if (this.pollingInterval) { if (this.pollingInterval) {
@@ -549,13 +645,56 @@ export class Rondevu {
} }
// Close all active connections // Close all active connections
for (const [offerId, offer] of this.activeOffers.entries()) { for (const [offerId, connection] of this.activeConnections.entries()) {
this.debug(`Closing offer ${offerId}`) this.debug(`Closing connection ${offerId}`)
offer.dc?.close() connection.close()
offer.pc.close()
} }
this.activeOffers.clear() this.activeConnections.clear()
}
/**
* Get the count of active offers
* @returns Number of active offers
*/
getOfferCount(): number {
return this.activeConnections.size
}
/**
* Check if an offer is currently connected
* @param offerId - The offer ID to check
* @returns True if the offer exists and is connected
*/
isConnected(offerId: string): boolean {
const connection = this.activeConnections.get(offerId)
return connection ? connection.getState() === 'connected' : false
}
/**
* Disconnect all active offers
* Similar to stopFilling() but doesn't stop the polling/filling process
*/
async disconnectAll(): Promise<void> {
this.debug('Disconnecting all offers')
for (const [offerId, connection] of this.activeConnections.entries()) {
this.debug(`Closing connection ${offerId}`)
connection.close()
}
this.activeConnections.clear()
}
/**
* Get the current service status
* @returns Object with service state information
*/
getServiceStatus(): { active: boolean; offerCount: number; maxOffers: number; filling: boolean } {
return {
active: this.currentService !== null,
offerCount: this.activeConnections.size,
maxOffers: this.maxOffers,
filling: this.filling
}
} }
/** /**
@@ -572,7 +711,7 @@ export class Rondevu {
} else if (service) { } else if (service) {
// Discovery mode - get random service // Discovery mode - get random service
this.debug(`Discovering service: ${service}`) this.debug(`Discovering service: ${service}`)
const discovered = await this.discoverService(service) const discovered = await this.findService(service) as ServiceResult
return discovered.serviceFqn return discovered.serviceFqn
} else { } else {
throw new Error('Either serviceFqn or service must be provided') throw new Error('Either serviceFqn or service must be provided')
@@ -580,62 +719,43 @@ export class Rondevu {
} }
/** /**
* Start polling for remote ICE candidates * Connect to a service (answerer side) - v1.0.0 API
* Returns the polling interval ID * Returns an AnswererConnection with automatic reconnection and buffering
*/ *
private startIcePolling( * BREAKING CHANGE: This now returns AnswererConnection instead of ConnectionContext
pc: RTCPeerConnection,
serviceFqn: string,
offerId: string
): ReturnType<typeof setInterval> {
let lastIceTimestamp = 0
return setInterval(async () => {
try {
const result = await this.api.getOfferIceCandidates(
serviceFqn,
offerId,
lastIceTimestamp
)
for (const item of result.candidates) {
if (item.candidate) {
await pc.addIceCandidate(new RTCIceCandidate(item.candidate))
lastIceTimestamp = item.createdAt
}
}
} catch (err) {
console.error('[Rondevu] Failed to poll ICE candidates:', err)
}
}, Rondevu.POLLING_INTERVAL_MS)
}
/**
* Automatically connect to a service (answerer side)
* Handles the entire connection flow: discovery, WebRTC setup, answer exchange, ICE candidates
* *
* @example * @example
* ```typescript * ```typescript
* // Connect to specific user * // Connect to specific user
* const connection = await rondevu.connectToService({ * const connection = await rondevu.connectToService({
* serviceFqn: 'chat:2.0.0@alice', * serviceFqn: 'chat:2.0.0@alice',
* onConnection: ({ dc, peerUsername }) => { * connectionConfig: {
* console.log('Connected to', peerUsername) * reconnectEnabled: true,
* dc.addEventListener('message', (e) => console.log(e.data)) * bufferEnabled: true
* dc.addEventListener('open', () => dc.send('Hello!'))
* } * }
* }) * })
* *
* connection.on('connected', () => {
* console.log('Connected!')
* connection.send('Hello!')
* })
*
* connection.on('message', (data) => {
* console.log('Received:', data)
* })
*
* connection.on('reconnecting', (attempt) => {
* console.log(`Reconnecting, attempt ${attempt}`)
* })
*
* // Discover random service * // Discover random service
* const connection = await rondevu.connectToService({ * const connection = await rondevu.connectToService({
* service: 'chat:2.0.0', * service: 'chat:2.0.0'
* onConnection: ({ dc, peerUsername }) => {
* console.log('Connected to', peerUsername)
* }
* }) * })
* ``` * ```
*/ */
async connectToService(options: ConnectToServiceOptions): Promise<ConnectionContext> { async connectToService(options: ConnectToServiceOptions): Promise<AnswererConnection> {
const { onConnection, rtcConfig } = options const { rtcConfig, connectionConfig } = options
// Validate inputs // Validate inputs
if (options.serviceFqn !== undefined && typeof options.serviceFqn === 'string' && !options.serviceFqn.trim()) { if (options.serviceFqn !== undefined && typeof options.serviceFqn === 'string' && !options.serviceFqn.trim()) {
@@ -652,86 +772,32 @@ export class Rondevu {
const fqn = await this.resolveServiceFqn(options) const fqn = await this.resolveServiceFqn(options)
this.debug(`Connecting to service: ${fqn}`) this.debug(`Connecting to service: ${fqn}`)
// 1. Get service offer // Get service offer
const serviceData = await this.api.getService(fqn) const serviceData = await this.api.getService(fqn)
this.debug(`Found service from @${serviceData.username}`) this.debug(`Found service from @${serviceData.username}`)
// 2. Create RTCPeerConnection // Create RTCConfiguration
const rtcConfiguration = rtcConfig || { const rtcConfiguration = rtcConfig || {
iceServers: this.iceServers iceServers: this.iceServers
} }
const pc = new RTCPeerConnection(rtcConfiguration)
// 3. Set up data channel handler (answerer receives it from offerer) // Create AnswererConnection
let dc: RTCDataChannel | null = null const connection = new AnswererConnection({
const dataChannelPromise = new Promise<RTCDataChannel>((resolve) => { api: this.api,
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,
serviceFqn: serviceData.serviceFqn, serviceFqn: serviceData.serviceFqn,
offerId: serviceData.offerId, offerId: serviceData.offerId,
peerUsername: serviceData.username offerSdp: serviceData.sdp,
} rtcConfig: rtcConfiguration,
config: {
...connectionConfig,
debug: this.debugEnabled,
},
})
// 9. Set up connection state monitoring // Initialize the connection
pc.onconnectionstatechange = () => { await connection.initialize()
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 return connection
if (dc.readyState === 'open') {
this.debug('Data channel already open')
if (onConnection) {
await onConnection(context)
}
} else {
await new Promise<void>((resolve) => {
dc!.addEventListener('open', async () => {
this.debug('Data channel opened')
if (onConnection) {
await onConnection(context)
}
resolve()
})
})
}
return context
} }
// ============================================ // ============================================
@@ -739,56 +805,43 @@ export class Rondevu {
// ============================================ // ============================================
/** /**
* Get service by FQN (with username) - Direct lookup * Find a service - unified discovery method
* Example: chat:1.0.0@alice *
* @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<{ async findService(
serviceId: string serviceFqn: string,
username: string options?: FindServiceOptions
serviceFqn: string ): Promise<ServiceResult | PaginatedServiceResult> {
offerId: string const { mode, limit = 10, offset = 0 } = options || {}
sdp: string
createdAt: number
expiresAt: number
}> {
return await this.api.getService(serviceFqn)
}
/** // Auto-detect mode if not specified
* Discover a random available service without knowing the username const hasUsername = serviceFqn.includes('@')
* Example: chat:1.0.0 (without @username) const effectiveMode = mode || (hasUsername ? 'direct' : 'random')
*/
async discoverService(serviceVersion: string): Promise<{
serviceId: string
username: string
serviceFqn: string
offerId: string
sdp: string
createdAt: number
expiresAt: number
}> {
return await this.api.getService(serviceVersion)
}
/** if (effectiveMode === 'paginated') {
* Discover multiple available services with pagination return await this.api.getService(serviceFqn, { limit, offset })
* Example: chat:1.0.0 (without @username) } else {
*/ // Both 'direct' and 'random' use the same API call
async discoverServices(serviceVersion: string, limit: number = 10, offset: number = 0): Promise<{ return await this.api.getService(serviceFqn)
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 })
} }
// ============================================ // ============================================
@@ -885,6 +938,36 @@ export class Rondevu {
return this.keypair.publicKey return this.keypair.publicKey
} }
/**
* Get active connections (for offerer side)
*/
getActiveConnections(): Map<string, OffererConnection> {
return this.activeConnections
}
/**
* Get all active offers (legacy compatibility)
* @deprecated Use getActiveConnections() instead
*/
getActiveOffers(): ActiveOffer[] {
const offers: ActiveOffer[] = []
for (const [offerId, connection] of this.activeConnections.entries()) {
const pc = connection.getPeerConnection()
const dc = connection.getDataChannel()
if (pc) {
offers.push({
offerId,
serviceFqn: this.currentService ? `${this.currentService}@${this.username}` : '',
pc,
dc: dc || undefined,
answered: connection.getState() === 'connected',
createdAt: Date.now(),
})
}
}
return offers
}
/** /**
* Access to underlying API for advanced operations * Access to underlying API for advanced operations
* @deprecated Use direct methods on Rondevu instance instead * @deprecated Use direct methods on Rondevu instance instead