mirror of
https://github.com/xtr-dev/rondevu-client.git
synced 2025-12-14 21:03:23 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb0bbe342c | |||
| 919aeb7b90 | |||
| a480fa3ba4 | |||
| 9a4fbb63f8 | |||
| f8fb842935 | |||
| 50d49d80d3 | |||
| b3b1751f63 | |||
| e48b3bb17a | |||
| 8f7e15e633 | |||
| fcd0f8ead0 | |||
| 8fd4b249de | |||
| 275c156c64 | |||
| c60a5f332a | |||
|
|
ecd6be7f8a | ||
|
|
e652fdc130 | ||
| 0f469e234d | |||
| 68c3ffb4ea | |||
| 02e8e971be | |||
|
|
1ca9a91056 | ||
|
|
a0dc9ddad0 | ||
|
|
df231c192d | ||
|
|
4d90cce9d0 | ||
|
|
f5e202384a | ||
|
|
a21fb04a6f | ||
|
|
6ee1d7b5a9 | ||
|
|
62a6cdcb99 | ||
|
|
febe3b7270 | ||
| 83831cae77 | |||
| e954a70aa7 |
57
.github/workflows/claude-code-review.yml
vendored
Normal file
57
.github/workflows/claude-code-review.yml
vendored
Normal 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
50
.github/workflows/claude.yml
vendored
Normal 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:*)'
|
||||||
|
|
||||||
832
MIGRATION.md
832
MIGRATION.md
@@ -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
310
README.md
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
[](https://www.npmjs.com/package/@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
42
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
183
src/answerer-connection.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
64
src/connection-config.ts
Normal 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
102
src/connection-events.ts
Normal 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
567
src/connection.ts
Normal 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
|
||||||
|
}
|
||||||
59
src/exponential-backoff.ts
Normal file
59
src/exponential-backoff.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/index.ts
40
src/index.ts
@@ -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
125
src/message-buffer.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
213
src/offerer-connection.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
687
src/rondevu.ts
687
src/rondevu.ts
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user