mirror of
https://github.com/xtr-dev/rondevu-client.git
synced 2025-12-10 02:43:25 +00:00
Add ServiceHost, ServiceClient, and RondevuService for high-level service management
- Add RondevuService: High-level API for username claiming and service publishing with Ed25519 signatures - Add ServiceHost: Manages offer pool for hosting services with auto-replacement - Add ServiceClient: Connects to hosted services with automatic reconnection - Add NoOpSignaler: Placeholder signaler for connection setup - Integrate Ed25519 signature functionality from @noble/ed25519 - Add ESLint and Prettier configuration with 4-space indentation - Add demo with local signaling test - Version bump to 0.10.0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
9
.prettierrc.json
Normal file
9
.prettierrc.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": false,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"arrowParens": "avoid"
|
||||||
|
}
|
||||||
141
demo/README.md
Normal file
141
demo/README.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# Rondevu WebRTC Local Test
|
||||||
|
|
||||||
|
Simple side-by-side demo for testing `WebRTCRondevuConnection` with **local signaling** (no server required).
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Opens browser at `http://localhost:3000`
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
This demo uses **local in-memory signaling** to test WebRTC connections between two peers on the same page. The `LocalSignaler` class simulates a signaling server by directly exchanging ICE candidates and SDP between peers.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
- **LocalSignaler**: Implements the `Signaler` interface with local peer-to-peer communication
|
||||||
|
- **Host (Peer A)**: Creates the offer (offerer role)
|
||||||
|
- **Client (Peer B)**: Receives the offer and creates answer (answerer role)
|
||||||
|
- **ICE Exchange**: Candidates are automatically exchanged between peers through the linked signalers
|
||||||
|
|
||||||
|
## Usage Steps
|
||||||
|
|
||||||
|
1. **Create Host** (Peer A)
|
||||||
|
- Click "1️⃣ Create Host Connection" on the left side
|
||||||
|
- The host will create an offer and display it
|
||||||
|
- Status changes to "Connecting"
|
||||||
|
|
||||||
|
2. **Create Client** (Peer B)
|
||||||
|
- Click "2️⃣ Create Client Connection" on the right side
|
||||||
|
- The client receives the host's offer automatically
|
||||||
|
- Creates an answer and sends it back to the host
|
||||||
|
- Both peers exchange ICE candidates
|
||||||
|
|
||||||
|
3. **Connection Established**
|
||||||
|
- Watch the status indicators turn green ("Connected")
|
||||||
|
- Activity logs show the connection progress
|
||||||
|
|
||||||
|
4. **Send Messages**
|
||||||
|
- Type a message in either peer's input field
|
||||||
|
- Click "📤 Send" or press Enter
|
||||||
|
- Messages appear in the other peer's activity log
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- ✅ **No signaling server required** - Everything runs locally
|
||||||
|
- ✅ **Automatic ICE candidate exchange** - Signalers handle candidate exchange
|
||||||
|
- ✅ **Real-time activity logs** - See exactly what's happening
|
||||||
|
- ✅ **Connection state indicators** - Visual feedback for connection status
|
||||||
|
- ✅ **Bidirectional messaging** - Send messages in both directions
|
||||||
|
|
||||||
|
## Code Structure
|
||||||
|
|
||||||
|
### demo.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// LocalSignaler - Implements local signaling
|
||||||
|
class LocalSignaler {
|
||||||
|
addIceCandidate(candidate) // Called when local peer has a candidate
|
||||||
|
addListener(callback) // Listen for remote candidates
|
||||||
|
linkTo(remoteSignaler) // Connect two signalers together
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and link signalers
|
||||||
|
const hostSignaler = new LocalSignaler('HOST', 'CLIENT')
|
||||||
|
const clientSignaler = new LocalSignaler('CLIENT', 'HOST')
|
||||||
|
hostSignaler.linkTo(clientSignaler)
|
||||||
|
clientSignaler.linkTo(hostSignaler)
|
||||||
|
|
||||||
|
// Create connections
|
||||||
|
const hostConnection = new WebRTCRondevuConnection({
|
||||||
|
id: 'test-connection',
|
||||||
|
host: 'client-peer',
|
||||||
|
service: 'test.demo@1.0.0',
|
||||||
|
offer: null, // No offer = offerer role
|
||||||
|
context: new WebRTCContext(hostSignaler)
|
||||||
|
})
|
||||||
|
|
||||||
|
const clientConnection = new WebRTCRondevuConnection({
|
||||||
|
id: 'test-connection',
|
||||||
|
host: 'host-peer',
|
||||||
|
service: 'test.demo@1.0.0',
|
||||||
|
offer: hostConnection.connection.localDescription, // With offer = answerer role
|
||||||
|
context: new WebRTCContext(clientSignaler)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### index.html
|
||||||
|
|
||||||
|
- Side-by-side layout for Host and Client
|
||||||
|
- Status indicators (disconnected/connecting/connected)
|
||||||
|
- SDP display areas (offer/answer)
|
||||||
|
- Message input and send buttons
|
||||||
|
- Activity logs for each peer
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
Open the browser console to see detailed logs:
|
||||||
|
|
||||||
|
- `[HOST]` - Logs from the host connection
|
||||||
|
- `[CLIENT]` - Logs from the client connection
|
||||||
|
- ICE candidate exchange
|
||||||
|
- Connection state changes
|
||||||
|
- Message send/receive events
|
||||||
|
|
||||||
|
## Comparison: Local vs Remote Signaling
|
||||||
|
|
||||||
|
### Local Signaling (This Demo)
|
||||||
|
```javascript
|
||||||
|
const signaler = new LocalSignaler('HOST', 'CLIENT')
|
||||||
|
signaler.linkTo(remoteSignaler) // Direct link
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**: No server, instant testing, no network latency
|
||||||
|
**Cons**: Only works for same-page testing
|
||||||
|
|
||||||
|
### Remote Signaling (Production)
|
||||||
|
```javascript
|
||||||
|
const api = new RondevuAPI('https://api.ronde.vu', credentials)
|
||||||
|
const signaler = new RondevuSignaler(api, offerId)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros**: Real peer discovery, works across networks
|
||||||
|
**Cons**: Requires signaling server, network latency
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
After testing locally, you can:
|
||||||
|
|
||||||
|
1. Switch to `RondevuSignaler` for real signaling server testing
|
||||||
|
2. Test across different browsers/devices
|
||||||
|
3. Test with STUN/TURN servers for NAT traversal
|
||||||
|
4. Implement production signaling with Rondevu API
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `index.html` - UI layout and styling
|
||||||
|
- `demo.js` - Local signaling implementation and WebRTC logic
|
||||||
|
- `README.md` - This file
|
||||||
304
demo/demo.js
Normal file
304
demo/demo.js
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import { WebRTCRondevuConnection } from '../src/index.js'
|
||||||
|
import { WebRTCContext } from '../src/webrtc-context.js'
|
||||||
|
|
||||||
|
// Local signaling implementation for testing
|
||||||
|
class LocalSignaler {
|
||||||
|
constructor(name, remoteName) {
|
||||||
|
this.name = name
|
||||||
|
this.remoteName = remoteName
|
||||||
|
this.iceCandidates = []
|
||||||
|
this.iceListeners = []
|
||||||
|
this.remote = null
|
||||||
|
this.remoteIceCandidates = []
|
||||||
|
this.offerCallbacks = []
|
||||||
|
this.answerCallbacks = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link two signalers together
|
||||||
|
linkTo(remoteSignaler) {
|
||||||
|
this.remote = remoteSignaler
|
||||||
|
this.remoteIceCandidates = remoteSignaler.iceCandidates
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set local offer (called when offer is created)
|
||||||
|
setOffer(offer) {
|
||||||
|
console.log(`[${this.name}] Setting offer`)
|
||||||
|
// Notify remote peer about the offer
|
||||||
|
if (this.remote) {
|
||||||
|
this.remote.offerCallbacks.forEach(callback => callback(offer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set local answer (called when answer is created)
|
||||||
|
setAnswer(answer) {
|
||||||
|
console.log(`[${this.name}] Setting answer`)
|
||||||
|
// Notify remote peer about the answer
|
||||||
|
if (this.remote) {
|
||||||
|
this.remote.answerCallbacks.forEach(callback => callback(answer))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for offers from remote peer
|
||||||
|
addOfferListener(callback) {
|
||||||
|
this.offerCallbacks.push(callback)
|
||||||
|
return () => {
|
||||||
|
const index = this.offerCallbacks.indexOf(callback)
|
||||||
|
if (index > -1) {
|
||||||
|
this.offerCallbacks.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for answers from remote peer
|
||||||
|
addAnswerListener(callback) {
|
||||||
|
this.answerCallbacks.push(callback)
|
||||||
|
return () => {
|
||||||
|
const index = this.answerCallbacks.indexOf(callback)
|
||||||
|
if (index > -1) {
|
||||||
|
this.answerCallbacks.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add local ICE candidate (called by local connection)
|
||||||
|
addIceCandidate(candidate) {
|
||||||
|
console.log(`[${this.name}] Adding ICE candidate:`, candidate.candidate)
|
||||||
|
this.iceCandidates.push(candidate)
|
||||||
|
|
||||||
|
// Immediately send to remote peer if linked
|
||||||
|
if (this.remote) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.remote.iceListeners.forEach(listener => {
|
||||||
|
console.log(`[${this.name}] Sending ICE to ${this.remoteName}`)
|
||||||
|
listener(candidate)
|
||||||
|
})
|
||||||
|
}, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for remote ICE candidates
|
||||||
|
addListener(callback) {
|
||||||
|
console.log(`[${this.name}] Adding ICE listener`)
|
||||||
|
this.iceListeners.push(callback)
|
||||||
|
|
||||||
|
// Send any existing remote candidates
|
||||||
|
this.remoteIceCandidates.forEach(candidate => {
|
||||||
|
setTimeout(() => callback(candidate), 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const index = this.iceListeners.indexOf(callback)
|
||||||
|
if (index > -1) {
|
||||||
|
this.iceListeners.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create signalers for host and client
|
||||||
|
const hostSignaler = new LocalSignaler('HOST', 'CLIENT')
|
||||||
|
const clientSignaler = new LocalSignaler('CLIENT', 'HOST')
|
||||||
|
|
||||||
|
// Link them together for bidirectional communication
|
||||||
|
hostSignaler.linkTo(clientSignaler)
|
||||||
|
clientSignaler.linkTo(hostSignaler)
|
||||||
|
|
||||||
|
// Store connections
|
||||||
|
let hostConnection = null
|
||||||
|
let clientConnection = null
|
||||||
|
|
||||||
|
// UI Update functions
|
||||||
|
function updateStatus(peer, state) {
|
||||||
|
const statusEl = document.getElementById(`status-${peer}`)
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.className = `status ${state}`
|
||||||
|
statusEl.textContent = state.charAt(0).toUpperCase() + state.slice(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLog(peer, message) {
|
||||||
|
const logEl = document.getElementById(`log-${peer}`)
|
||||||
|
if (logEl) {
|
||||||
|
const time = new Date().toLocaleTimeString()
|
||||||
|
logEl.innerHTML += `<div class="log-entry">[${time}] ${message}</div>`
|
||||||
|
logEl.scrollTop = logEl.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Host (Offerer)
|
||||||
|
async function createHost() {
|
||||||
|
try {
|
||||||
|
addLog('a', 'Creating host connection (offerer)...')
|
||||||
|
|
||||||
|
const hostContext = new WebRTCContext(hostSignaler)
|
||||||
|
|
||||||
|
hostConnection = new WebRTCRondevuConnection({
|
||||||
|
id: 'test-connection',
|
||||||
|
host: 'client-peer',
|
||||||
|
service: 'test.demo@1.0.0',
|
||||||
|
offer: null,
|
||||||
|
context: hostContext,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Listen for state changes
|
||||||
|
hostConnection.events.on('state-change', state => {
|
||||||
|
console.log('[HOST] State changed:', state)
|
||||||
|
updateStatus('a', state)
|
||||||
|
addLog('a', `State changed to: ${state}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Listen for messages
|
||||||
|
hostConnection.events.on('message', message => {
|
||||||
|
console.log('[HOST] Received message:', message)
|
||||||
|
addLog('a', `📨 Received: ${message}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
addLog('a', '✅ Host connection created')
|
||||||
|
updateStatus('a', 'connecting')
|
||||||
|
|
||||||
|
// Wait for host to be ready (offer created and set)
|
||||||
|
await hostConnection.ready
|
||||||
|
addLog('a', '✅ Host offer created')
|
||||||
|
|
||||||
|
// Get the offer
|
||||||
|
const offer = hostConnection.connection.localDescription
|
||||||
|
document.getElementById('offer-a').value = JSON.stringify(offer, null, 2)
|
||||||
|
|
||||||
|
addLog('a', 'Offer ready to send to client')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[HOST] Error:', error)
|
||||||
|
addLog('a', `❌ Error: ${error.message}`)
|
||||||
|
updateStatus('a', 'disconnected')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Client (Answerer)
|
||||||
|
async function createClient() {
|
||||||
|
try {
|
||||||
|
addLog('b', 'Creating client connection (answerer)...')
|
||||||
|
|
||||||
|
// Get offer from host
|
||||||
|
if (!hostConnection) {
|
||||||
|
alert('Please create host first!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const offer = hostConnection.connection.localDescription
|
||||||
|
if (!offer) {
|
||||||
|
alert('Host offer not ready yet!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addLog('b', 'Got offer from host')
|
||||||
|
|
||||||
|
const clientContext = new WebRTCContext(clientSignaler)
|
||||||
|
|
||||||
|
clientConnection = new WebRTCRondevuConnection({
|
||||||
|
id: 'test-connection',
|
||||||
|
host: 'host-peer',
|
||||||
|
service: 'test.demo@1.0.0',
|
||||||
|
offer: offer,
|
||||||
|
context: clientContext,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Listen for state changes
|
||||||
|
clientConnection.events.on('state-change', state => {
|
||||||
|
console.log('[CLIENT] State changed:', state)
|
||||||
|
updateStatus('b', state)
|
||||||
|
addLog('b', `State changed to: ${state}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Listen for messages
|
||||||
|
clientConnection.events.on('message', message => {
|
||||||
|
console.log('[CLIENT] Received message:', message)
|
||||||
|
addLog('b', `📨 Received: ${message}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
addLog('b', '✅ Client connection created')
|
||||||
|
updateStatus('b', 'connecting')
|
||||||
|
|
||||||
|
// Wait for client to be ready
|
||||||
|
await clientConnection.ready
|
||||||
|
addLog('b', '✅ Client answer created')
|
||||||
|
|
||||||
|
// Get the answer
|
||||||
|
const answer = clientConnection.connection.localDescription
|
||||||
|
document.getElementById('answer-b').value = JSON.stringify(answer, null, 2)
|
||||||
|
|
||||||
|
// Set answer on host
|
||||||
|
addLog('b', 'Setting answer on host...')
|
||||||
|
await hostConnection.connection.setRemoteDescription(answer)
|
||||||
|
addLog('b', '✅ Answer set on host')
|
||||||
|
addLog('a', '✅ Answer received from client')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[CLIENT] Error:', error)
|
||||||
|
addLog('b', `❌ Error: ${error.message}`)
|
||||||
|
updateStatus('b', 'disconnected')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send test message from host to client
|
||||||
|
function sendFromHost() {
|
||||||
|
if (!hostConnection) {
|
||||||
|
alert('Please create host first!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = document.getElementById('message-a').value || 'Hello from Host!'
|
||||||
|
addLog('a', `📤 Sending: ${message}`)
|
||||||
|
hostConnection
|
||||||
|
.sendMessage(message)
|
||||||
|
.then(success => {
|
||||||
|
if (success) {
|
||||||
|
addLog('a', '✅ Message sent successfully')
|
||||||
|
} else {
|
||||||
|
addLog('a', '⚠️ Message queued (not connected)')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
addLog('a', `❌ Error sending: ${error.message}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send test message from client to host
|
||||||
|
function sendFromClient() {
|
||||||
|
if (!clientConnection) {
|
||||||
|
alert('Please create client first!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = document.getElementById('message-b').value || 'Hello from Client!'
|
||||||
|
addLog('b', `📤 Sending: ${message}`)
|
||||||
|
clientConnection
|
||||||
|
.sendMessage(message)
|
||||||
|
.then(success => {
|
||||||
|
if (success) {
|
||||||
|
addLog('b', '✅ Message sent successfully')
|
||||||
|
} else {
|
||||||
|
addLog('b', '⚠️ Message queued (not connected)')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
addLog('b', `❌ Error sending: ${error.message}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach event listeners when DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Clear all textareas on load
|
||||||
|
document.getElementById('offer-a').value = ''
|
||||||
|
document.getElementById('answer-b').value = ''
|
||||||
|
|
||||||
|
// Make functions globally available (for console testing)
|
||||||
|
window.createHost = createHost
|
||||||
|
window.createClient = createClient
|
||||||
|
window.sendFromHost = sendFromHost
|
||||||
|
window.sendFromClient = sendFromClient
|
||||||
|
|
||||||
|
console.log('🚀 Local signaling test loaded')
|
||||||
|
console.log('Steps:')
|
||||||
|
console.log('1. Click "Create Host" (Peer A)')
|
||||||
|
console.log('2. Click "Create Client" (Peer B)')
|
||||||
|
console.log('3. Wait for connection to establish')
|
||||||
|
console.log('4. Send messages between peers')
|
||||||
|
})
|
||||||
280
demo/index.html
Normal file
280
demo/index.html
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Rondevu WebRTC Local Test</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
|
||||||
|
Cantarell, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peers {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer h2 {
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
border-bottom: 2px solid #667eea;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.disconnected {
|
||||||
|
background: #fee;
|
||||||
|
color: #c33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.connecting {
|
||||||
|
background: #ffeaa7;
|
||||||
|
color: #d63031;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.connected {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h3 {
|
||||||
|
color: #555;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text'] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text']:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #5568d3;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-box {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-box input {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-box button {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.peers {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>🔗 Rondevu WebRTC Local Test</h1>
|
||||||
|
|
||||||
|
<div class="peers">
|
||||||
|
<!-- Peer A (Host) -->
|
||||||
|
<div class="peer">
|
||||||
|
<h2>Peer A (Host/Offerer)</h2>
|
||||||
|
|
||||||
|
<div class="status disconnected" id="status-a">Disconnected</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<button onclick="createHost()">1️⃣ Create Host Connection</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Local Offer (SDP)</h3>
|
||||||
|
<textarea
|
||||||
|
id="offer-a"
|
||||||
|
readonly
|
||||||
|
placeholder="Offer will appear here..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Send Message</h3>
|
||||||
|
<div class="message-box">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="message-a"
|
||||||
|
placeholder="Type a message..."
|
||||||
|
onkeypress="if (event.key === 'Enter') sendFromHost()"
|
||||||
|
/>
|
||||||
|
<button onclick="sendFromHost()">📤 Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Activity Log</h3>
|
||||||
|
<div class="log" id="log-a"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Peer B (Client) -->
|
||||||
|
<div class="peer">
|
||||||
|
<h2>Peer B (Client/Answerer)</h2>
|
||||||
|
|
||||||
|
<div class="status disconnected" id="status-b">Disconnected</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<button onclick="createClient()">2️⃣ Create Client Connection</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Local Answer (SDP)</h3>
|
||||||
|
<textarea
|
||||||
|
id="answer-b"
|
||||||
|
readonly
|
||||||
|
placeholder="Answer will appear here..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Send Message</h3>
|
||||||
|
<div class="message-box">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="message-b"
|
||||||
|
placeholder="Type a message..."
|
||||||
|
onkeypress="if (event.key === 'Enter') sendFromClient()"
|
||||||
|
/>
|
||||||
|
<button onclick="sendFromClient()">📤 Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Activity Log</h3>
|
||||||
|
<div class="log" id="log-b"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/demo.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
52
eslint.config.js
Normal file
52
eslint.config.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import tsPlugin from '@typescript-eslint/eslint-plugin'
|
||||||
|
import tsParser from '@typescript-eslint/parser'
|
||||||
|
import prettierConfig from 'eslint-config-prettier'
|
||||||
|
import prettierPlugin from 'eslint-plugin-prettier'
|
||||||
|
import unicorn from 'eslint-plugin-unicorn'
|
||||||
|
import globals from 'globals'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
js.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ['**/*.ts', '**/*.tsx', '**/*.js'],
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node,
|
||||||
|
RTCPeerConnection: 'readonly',
|
||||||
|
RTCIceCandidate: 'readonly',
|
||||||
|
RTCSessionDescriptionInit: 'readonly',
|
||||||
|
RTCIceCandidateInit: 'readonly',
|
||||||
|
BufferSource: 'readonly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'@typescript-eslint': tsPlugin,
|
||||||
|
prettier: prettierPlugin,
|
||||||
|
unicorn: unicorn,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...tsPlugin.configs.recommended.rules,
|
||||||
|
...prettierConfig.rules,
|
||||||
|
'prettier/prettier': 'error',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||||
|
'unicorn/filename-case': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
case: 'kebabCase',
|
||||||
|
ignore: ['^README\\.md$'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: ['dist/**', 'node_modules/**', '*.config.js'],
|
||||||
|
},
|
||||||
|
]
|
||||||
2947
package-lock.json
generated
2947
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@xtr-dev/rondevu-client",
|
"name": "@xtr-dev/rondevu-client",
|
||||||
"version": "0.9.2",
|
"version": "0.10.0",
|
||||||
"description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
|
"description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -8,6 +8,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
"dev": "vite",
|
||||||
|
"lint": "eslint src demo --ext .ts,.tsx,.js",
|
||||||
|
"lint:fix": "eslint src demo --ext .ts,.tsx,.js --fix",
|
||||||
|
"format": "prettier --write \"src/**/*.{ts,tsx,js}\" \"demo/**/*.{ts,tsx,js,html}\"",
|
||||||
"prepublishOnly": "npm run build"
|
"prepublishOnly": "npm run build"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -20,14 +24,23 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.9.3"
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
||||||
|
"@typescript-eslint/parser": "^8.48.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-prettier": "^5.5.4",
|
||||||
|
"eslint-plugin-unicorn": "^62.0.0",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"prettier": "^3.7.4",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.2.6"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/ed25519": "^3.0.0",
|
"@noble/ed25519": "^3.0.0"
|
||||||
"@xtr-dev/rondevu-client": "^0.9.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
295
src/api.ts
295
src/api.ts
@@ -2,55 +2,83 @@
|
|||||||
* Rondevu API Client - Single class for all API endpoints
|
* Rondevu API Client - Single class for all API endpoints
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as ed25519 from '@noble/ed25519'
|
||||||
|
|
||||||
|
// Set SHA-512 hash function for ed25519 (required in @noble/ed25519 v3+)
|
||||||
|
ed25519.hashes.sha512Async = async (message: Uint8Array) => {
|
||||||
|
return new Uint8Array(await crypto.subtle.digest('SHA-512', message as BufferSource))
|
||||||
|
}
|
||||||
|
|
||||||
export interface Credentials {
|
export interface Credentials {
|
||||||
peerId: string;
|
peerId: string
|
||||||
secret: string;
|
secret: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Keypair {
|
||||||
|
publicKey: string
|
||||||
|
privateKey: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OfferRequest {
|
export interface OfferRequest {
|
||||||
sdp: string;
|
sdp: string
|
||||||
topics?: string[];
|
topics?: string[]
|
||||||
ttl?: number;
|
ttl?: number
|
||||||
secret?: string;
|
secret?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Offer {
|
export interface Offer {
|
||||||
id: string;
|
id: string
|
||||||
peerId: string;
|
peerId: string
|
||||||
sdp: string;
|
sdp: string
|
||||||
topics: string[];
|
topics: string[]
|
||||||
ttl: number;
|
ttl: number
|
||||||
createdAt: number;
|
createdAt: number
|
||||||
expiresAt: number;
|
expiresAt: number
|
||||||
answererPeerId?: string;
|
answererPeerId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServiceRequest {
|
export interface ServiceRequest {
|
||||||
username: string;
|
username: string
|
||||||
serviceFqn: string;
|
serviceFqn: string
|
||||||
sdp: string;
|
sdp: string
|
||||||
ttl?: number;
|
ttl?: number
|
||||||
isPublic?: boolean;
|
isPublic?: boolean
|
||||||
metadata?: Record<string, any>;
|
metadata?: Record<string, any>
|
||||||
signature: string;
|
signature: string
|
||||||
message: string;
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Service {
|
export interface Service {
|
||||||
serviceId: string;
|
serviceId: string
|
||||||
uuid: string;
|
uuid: string
|
||||||
offerId: string;
|
offerId: string
|
||||||
username: string;
|
username: string
|
||||||
serviceFqn: string;
|
serviceFqn: string
|
||||||
isPublic: boolean;
|
isPublic: boolean
|
||||||
metadata?: Record<string, any>;
|
metadata?: Record<string, any>
|
||||||
createdAt: number;
|
createdAt: number
|
||||||
expiresAt: number;
|
expiresAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IceCandidate {
|
export interface IceCandidate {
|
||||||
candidate: RTCIceCandidateInit;
|
candidate: RTCIceCandidateInit
|
||||||
createdAt: number;
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Convert Uint8Array to base64 string
|
||||||
|
*/
|
||||||
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
const binString = Array.from(bytes, byte => String.fromCodePoint(byte)).join('')
|
||||||
|
return btoa(binString)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Convert base64 string to Uint8Array
|
||||||
|
*/
|
||||||
|
function base64ToBytes(base64: string): Uint8Array {
|
||||||
|
const binString = atob(base64)
|
||||||
|
return Uint8Array.from(binString, char => char.codePointAt(0)!)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,11 +95,56 @@ export class RondevuAPI {
|
|||||||
*/
|
*/
|
||||||
private getAuthHeader(): Record<string, string> {
|
private getAuthHeader(): Record<string, string> {
|
||||||
if (!this.credentials) {
|
if (!this.credentials) {
|
||||||
return {};
|
return {}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
'Authorization': `Bearer ${this.credentials.peerId}:${this.credentials.secret}`
|
Authorization: `Bearer ${this.credentials.peerId}:${this.credentials.secret}`,
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Ed25519 Cryptography Helpers
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an Ed25519 keypair for username claiming and service publishing
|
||||||
|
*/
|
||||||
|
static async generateKeypair(): Promise<Keypair> {
|
||||||
|
const privateKey = ed25519.utils.randomSecretKey()
|
||||||
|
const publicKey = await ed25519.getPublicKeyAsync(privateKey)
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKey: bytesToBase64(publicKey),
|
||||||
|
privateKey: bytesToBase64(privateKey),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a message with an Ed25519 private key
|
||||||
|
*/
|
||||||
|
static async signMessage(message: string, privateKeyBase64: string): Promise<string> {
|
||||||
|
const privateKey = base64ToBytes(privateKeyBase64)
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const messageBytes = encoder.encode(message)
|
||||||
|
|
||||||
|
const signature = await ed25519.signAsync(messageBytes, privateKey)
|
||||||
|
return bytesToBase64(signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a signature
|
||||||
|
*/
|
||||||
|
static async verifySignature(
|
||||||
|
message: string,
|
||||||
|
signatureBase64: string,
|
||||||
|
publicKeyBase64: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const publicKey = base64ToBytes(publicKeyBase64)
|
||||||
|
const signature = base64ToBytes(signatureBase64)
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const messageBytes = encoder.encode(message)
|
||||||
|
|
||||||
|
return await ed25519.verifyAsync(signature, messageBytes, publicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -84,15 +157,15 @@ export class RondevuAPI {
|
|||||||
async register(): Promise<Credentials> {
|
async register(): Promise<Credentials> {
|
||||||
const response = await fetch(`${this.baseUrl}/register`, {
|
const response = await fetch(`${this.baseUrl}/register`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||||
throw new Error(`Registration failed: ${error.error || response.statusText}`);
|
throw new Error(`Registration failed: ${error.error || response.statusText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -107,17 +180,17 @@ export class RondevuAPI {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...this.getAuthHeader()
|
...this.getAuthHeader(),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ offers })
|
body: JSON.stringify({ offers }),
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||||
throw new Error(`Failed to create offers: ${error.error || response.statusText}`);
|
throw new Error(`Failed to create offers: ${error.error || response.statusText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,15 +198,15 @@ export class RondevuAPI {
|
|||||||
*/
|
*/
|
||||||
async getOffer(offerId: string): Promise<Offer> {
|
async getOffer(offerId: string): Promise<Offer> {
|
||||||
const response = await fetch(`${this.baseUrl}/offers/${offerId}`, {
|
const response = await fetch(`${this.baseUrl}/offers/${offerId}`, {
|
||||||
headers: this.getAuthHeader()
|
headers: this.getAuthHeader(),
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||||
throw new Error(`Failed to get offer: ${error.error || response.statusText}`);
|
throw new Error(`Failed to get offer: ${error.error || response.statusText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,14 +217,14 @@ export class RondevuAPI {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...this.getAuthHeader()
|
...this.getAuthHeader(),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ sdp, secret })
|
body: JSON.stringify({ sdp, secret }),
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||||
throw new Error(`Failed to answer offer: ${error.error || response.statusText}`);
|
throw new Error(`Failed to answer offer: ${error.error || response.statusText}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,19 +233,19 @@ export class RondevuAPI {
|
|||||||
*/
|
*/
|
||||||
async getAnswer(offerId: string): Promise<{ sdp: string } | null> {
|
async getAnswer(offerId: string): Promise<{ sdp: string } | null> {
|
||||||
const response = await fetch(`${this.baseUrl}/offers/${offerId}/answer`, {
|
const response = await fetch(`${this.baseUrl}/offers/${offerId}/answer`, {
|
||||||
headers: this.getAuthHeader()
|
headers: this.getAuthHeader(),
|
||||||
});
|
})
|
||||||
|
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
return null; // No answer yet
|
return null // No answer yet
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||||
throw new Error(`Failed to get answer: ${error.error || response.statusText}`);
|
throw new Error(`Failed to get answer: ${error.error || response.statusText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -180,15 +253,15 @@ export class RondevuAPI {
|
|||||||
*/
|
*/
|
||||||
async searchOffers(topic: string): Promise<Offer[]> {
|
async searchOffers(topic: string): Promise<Offer[]> {
|
||||||
const response = await fetch(`${this.baseUrl}/offers?topic=${encodeURIComponent(topic)}`, {
|
const response = await fetch(`${this.baseUrl}/offers?topic=${encodeURIComponent(topic)}`, {
|
||||||
headers: this.getAuthHeader()
|
headers: this.getAuthHeader(),
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||||
throw new Error(`Failed to search offers: ${error.error || response.statusText}`);
|
throw new Error(`Failed to search offers: ${error.error || response.statusText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -203,14 +276,14 @@ export class RondevuAPI {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...this.getAuthHeader()
|
...this.getAuthHeader(),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ candidates })
|
body: JSON.stringify({ candidates }),
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||||
throw new Error(`Failed to add ICE candidates: ${error.error || response.statusText}`);
|
throw new Error(`Failed to add ICE candidates: ${error.error || response.statusText}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,14 +294,14 @@ export class RondevuAPI {
|
|||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${this.baseUrl}/offers/${offerId}/ice-candidates?since=${since}`,
|
`${this.baseUrl}/offers/${offerId}/ice-candidates?since=${since}`,
|
||||||
{ headers: this.getAuthHeader() }
|
{ headers: this.getAuthHeader() }
|
||||||
);
|
)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||||
throw new Error(`Failed to get ICE candidates: ${error.error || response.statusText}`);
|
throw new Error(`Failed to get ICE candidates: ${error.error || response.statusText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -243,17 +316,17 @@ export class RondevuAPI {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...this.getAuthHeader()
|
...this.getAuthHeader(),
|
||||||
},
|
},
|
||||||
body: JSON.stringify(service)
|
body: JSON.stringify(service),
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||||
throw new Error(`Failed to publish service: ${error.error || response.statusText}`);
|
throw new Error(`Failed to publish service: ${error.error || response.statusText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -261,15 +334,15 @@ export class RondevuAPI {
|
|||||||
*/
|
*/
|
||||||
async getService(uuid: string): Promise<Service & { offerId: string; sdp: string }> {
|
async getService(uuid: string): Promise<Service & { offerId: string; sdp: string }> {
|
||||||
const response = await fetch(`${this.baseUrl}/services/${uuid}`, {
|
const response = await fetch(`${this.baseUrl}/services/${uuid}`, {
|
||||||
headers: this.getAuthHeader()
|
headers: this.getAuthHeader(),
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||||
throw new Error(`Failed to get service: ${error.error || response.statusText}`);
|
throw new Error(`Failed to get service: ${error.error || response.statusText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -279,14 +352,14 @@ export class RondevuAPI {
|
|||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${this.baseUrl}/services?username=${encodeURIComponent(username)}`,
|
`${this.baseUrl}/services?username=${encodeURIComponent(username)}`,
|
||||||
{ headers: this.getAuthHeader() }
|
{ headers: this.getAuthHeader() }
|
||||||
);
|
)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||||
throw new Error(`Failed to search services: ${error.error || response.statusText}`);
|
throw new Error(`Failed to search services: ${error.error || response.statusText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -296,14 +369,14 @@ export class RondevuAPI {
|
|||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${this.baseUrl}/services?serviceFqn=${encodeURIComponent(serviceFqn)}`,
|
`${this.baseUrl}/services?serviceFqn=${encodeURIComponent(serviceFqn)}`,
|
||||||
{ headers: this.getAuthHeader() }
|
{ headers: this.getAuthHeader() }
|
||||||
);
|
)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||||
throw new Error(`Failed to search services: ${error.error || response.statusText}`);
|
throw new Error(`Failed to search services: ${error.error || response.statusText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -313,14 +386,14 @@ export class RondevuAPI {
|
|||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${this.baseUrl}/services?username=${encodeURIComponent(username)}&serviceFqn=${encodeURIComponent(serviceFqn)}`,
|
`${this.baseUrl}/services?username=${encodeURIComponent(username)}&serviceFqn=${encodeURIComponent(serviceFqn)}`,
|
||||||
{ headers: this.getAuthHeader() }
|
{ headers: this.getAuthHeader() }
|
||||||
);
|
)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||||
throw new Error(`Failed to search services: ${error.error || response.statusText}`);
|
throw new Error(`Failed to search services: ${error.error || response.statusText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -333,14 +406,14 @@ export class RondevuAPI {
|
|||||||
async checkUsername(username: string): Promise<{ available: boolean; owner?: string }> {
|
async checkUsername(username: string): Promise<{ available: boolean; owner?: string }> {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${this.baseUrl}/usernames/${encodeURIComponent(username)}/check`
|
`${this.baseUrl}/usernames/${encodeURIComponent(username)}/check`
|
||||||
);
|
)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||||
throw new Error(`Failed to check username: ${error.error || response.statusText}`);
|
throw new Error(`Failed to check username: ${error.error || response.statusText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -356,20 +429,20 @@ export class RondevuAPI {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...this.getAuthHeader()
|
...this.getAuthHeader(),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
publicKey,
|
publicKey,
|
||||||
signature,
|
signature,
|
||||||
message
|
message,
|
||||||
})
|
}),
|
||||||
});
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||||
throw new Error(`Failed to claim username: ${error.error || response.statusText}`);
|
throw new Error(`Failed to claim username: ${error.error || response.statusText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/bin.ts
49
src/bin.ts
@@ -1,15 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Binnable - A cleanup function that can be synchronous or asynchronous
|
||||||
|
*
|
||||||
|
* Used to unsubscribe from events, close connections, or perform other cleanup operations.
|
||||||
|
*/
|
||||||
export type Binnable = () => void | Promise<void>
|
export type Binnable = () => void | Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a cleanup function collector (garbage bin)
|
||||||
|
*
|
||||||
|
* Collects cleanup functions and provides a single `clean()` method to execute all of them.
|
||||||
|
* Useful for managing multiple cleanup operations in a single place.
|
||||||
|
*
|
||||||
|
* @returns A function that accepts cleanup functions and has a `clean()` method
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const bin = createBin();
|
||||||
|
*
|
||||||
|
* // Add cleanup functions
|
||||||
|
* bin(
|
||||||
|
* () => console.log('Cleanup 1'),
|
||||||
|
* () => connection.close(),
|
||||||
|
* () => clearInterval(timer)
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* // Later, clean everything
|
||||||
|
* bin.clean(); // Executes all cleanup functions
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
export const createBin = () => {
|
export const createBin = () => {
|
||||||
const bin: Binnable[] = []
|
const bin: Binnable[] = []
|
||||||
return Object.assign(
|
return Object.assign((...rubbish: Binnable[]) => bin.push(...rubbish), {
|
||||||
(...rubbish: Binnable[]) => bin.push(...rubbish),
|
/**
|
||||||
{
|
* Execute all cleanup functions and clear the bin
|
||||||
clean: (): void => {
|
*/
|
||||||
bin.forEach(binnable => binnable())
|
clean: (): void => {
|
||||||
bin.length = 0
|
bin.forEach(binnable => binnable())
|
||||||
}
|
bin.length = 0
|
||||||
}
|
},
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
/**
|
|
||||||
* ConnectionManager - Manages WebRTC peer connections
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class ConnectionManager {
|
|
||||||
constructor() {
|
|
||||||
// TODO: Initialize connection manager
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +1,229 @@
|
|||||||
import {ConnectionEvents, ConnectionInterface, Message, QueueMessageOptions, Signaler} from "./types";
|
import {
|
||||||
import {EventBus} from "./event-bus";
|
ConnectionEvents,
|
||||||
import {createBin} from "./bin";
|
ConnectionInterface,
|
||||||
|
ConnectionStates,
|
||||||
|
isConnectionState,
|
||||||
|
Message,
|
||||||
|
QueueMessageOptions,
|
||||||
|
Signaler,
|
||||||
|
} from './types.js'
|
||||||
|
import { EventBus } from './event-bus.js'
|
||||||
|
import { createBin } from './bin.js'
|
||||||
|
import { WebRTCContext } from './webrtc-context'
|
||||||
|
|
||||||
|
export type WebRTCRondevuConnectionOptions = {
|
||||||
|
id: string
|
||||||
|
service: string
|
||||||
|
offer: RTCSessionDescriptionInit | null
|
||||||
|
context: WebRTCContext
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebRTCRondevuConnection - WebRTC peer connection wrapper with Rondevu signaling
|
||||||
|
*
|
||||||
|
* Manages a WebRTC peer connection lifecycle including:
|
||||||
|
* - Automatic offer/answer creation based on role
|
||||||
|
* - ICE candidate exchange via Rondevu signaling server
|
||||||
|
* - Connection state management with type-safe events
|
||||||
|
* - Data channel creation and message handling
|
||||||
|
*
|
||||||
|
* The connection automatically determines its role (offerer or answerer) based on whether
|
||||||
|
* an offer is provided in the constructor. The offerer creates the data channel, while
|
||||||
|
* the answerer receives it via the 'datachannel' event.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Offerer side (creates offer)
|
||||||
|
* const connection = new WebRTCRondevuConnection(
|
||||||
|
* 'conn-123',
|
||||||
|
* 'peer-username',
|
||||||
|
* 'chat.service@1.0.0'
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* await connection.ready; // Wait for local offer
|
||||||
|
* const sdp = connection.connection.localDescription!.sdp!;
|
||||||
|
* // Send sdp to signaling server...
|
||||||
|
*
|
||||||
|
* // Answerer side (receives offer)
|
||||||
|
* const connection = new WebRTCRondevuConnection(
|
||||||
|
* 'conn-123',
|
||||||
|
* 'peer-username',
|
||||||
|
* 'chat.service@1.0.0',
|
||||||
|
* { type: 'offer', sdp: remoteOfferSdp }
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* await connection.ready; // Wait for local answer
|
||||||
|
* const answerSdp = connection.connection.localDescription!.sdp!;
|
||||||
|
* // Send answer to signaling server...
|
||||||
|
*
|
||||||
|
* // Both sides: Set up signaler and listen for state changes
|
||||||
|
* connection.setSignaler(signaler);
|
||||||
|
* connection.events.on('state-change', (state) => {
|
||||||
|
* console.log('Connection state:', state);
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
export class WebRTCRondevuConnection implements ConnectionInterface {
|
export class WebRTCRondevuConnection implements ConnectionInterface {
|
||||||
private readonly connection: RTCPeerConnection;
|
private readonly side: 'offer' | 'answer'
|
||||||
private readonly side: 'offer' | 'answer';
|
public readonly expiresAt: number = 0
|
||||||
public readonly expiresAt: number = 0;
|
public readonly lastActive: number = 0
|
||||||
public readonly lastActive: number = 0;
|
public readonly events: EventBus<ConnectionEvents> = new EventBus()
|
||||||
public readonly events: EventBus<ConnectionEvents> = new EventBus();
|
public readonly ready: Promise<void>
|
||||||
private signaler!: Signaler; // Will be set by setSignaler()
|
|
||||||
private readonly _ready: Promise<void>;
|
|
||||||
private _state: ConnectionInterface['state'] = 'disconnected';
|
|
||||||
private iceBin = createBin()
|
private iceBin = createBin()
|
||||||
|
private ctx: WebRTCContext
|
||||||
|
public id: string
|
||||||
|
public service: string
|
||||||
|
private _conn: RTCPeerConnection | null = null
|
||||||
|
private _state: ConnectionInterface['state'] = 'disconnected'
|
||||||
|
|
||||||
constructor(
|
constructor({ context: ctx, offer, id, service }: WebRTCRondevuConnectionOptions) {
|
||||||
public readonly id: string,
|
this.ctx = ctx
|
||||||
public readonly host: string,
|
this.id = id
|
||||||
public readonly service: string,
|
this.service = service
|
||||||
offer?: RTCSessionDescriptionInit) {
|
this._conn = ctx.createPeerConnection()
|
||||||
this.connection = new RTCPeerConnection();
|
this.side = offer ? 'answer' : 'offer'
|
||||||
this.side = offer ? 'answer' : 'offer';
|
|
||||||
const ready = offer
|
|
||||||
? this.connection.setRemoteDescription(offer)
|
|
||||||
.then(() => this.connection.createAnswer())
|
|
||||||
.then(answer => this.connection.setLocalDescription(answer))
|
|
||||||
: this.connection.createOffer()
|
|
||||||
.then(offer => this.connection.setLocalDescription(offer));
|
|
||||||
this._ready = ready.then(() => this.setState('connecting'))
|
|
||||||
.then(() => this.startIceListeners())
|
|
||||||
}
|
|
||||||
|
|
||||||
private setState(state: ConnectionInterface['state']) {
|
// setup data channel
|
||||||
this._state = state;
|
if (offer) {
|
||||||
this.events.emit('state-change', state);
|
this._conn.addEventListener('datachannel', e => {
|
||||||
}
|
const channel = e.channel
|
||||||
|
channel.addEventListener('message', e => {
|
||||||
private startIceListeners() {
|
console.log('Message from peer:', e)
|
||||||
const listener = ({candidate}: {candidate: RTCIceCandidate | null}) => {
|
})
|
||||||
if (candidate) this.signaler.addIceCandidate(candidate)
|
channel.addEventListener('open', () => {
|
||||||
|
channel.send('I am ' + this.side)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const channel = this._conn.createDataChannel('vu.ronde.protocol')
|
||||||
|
channel.addEventListener('message', e => {
|
||||||
|
console.log('Message from peer:', e)
|
||||||
|
})
|
||||||
|
channel.addEventListener('open', () => {
|
||||||
|
channel.send('I am ' + this.side)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
this.connection.addEventListener('icecandidate', listener)
|
|
||||||
|
// setup description exchange
|
||||||
|
this.ready = offer
|
||||||
|
? this._conn
|
||||||
|
.setRemoteDescription(offer)
|
||||||
|
.then(() => this._conn?.createAnswer())
|
||||||
|
.then(async answer => {
|
||||||
|
if (!answer || !this._conn) throw new Error('Connection disappeared')
|
||||||
|
await this._conn.setLocalDescription(answer)
|
||||||
|
return await ctx.signaler.setAnswer(answer)
|
||||||
|
})
|
||||||
|
: this._conn.createOffer().then(async offer => {
|
||||||
|
if (!this._conn) throw new Error('Connection disappeared')
|
||||||
|
await this._conn.setLocalDescription(offer)
|
||||||
|
return await ctx.signaler.setOffer(offer)
|
||||||
|
})
|
||||||
|
|
||||||
|
// propagate connection state changes
|
||||||
|
this._conn.addEventListener('connectionstatechange', () => {
|
||||||
|
console.log(this.side, 'connection state changed: ', this._conn!.connectionState)
|
||||||
|
const state = isConnectionState(this._conn!.connectionState)
|
||||||
|
? this._conn!.connectionState
|
||||||
|
: 'disconnected'
|
||||||
|
this.setState(state)
|
||||||
|
})
|
||||||
|
|
||||||
|
this._conn.addEventListener('iceconnectionstatechange', () => {
|
||||||
|
console.log(this.side, 'ice connection state changed: ', this._conn!.iceConnectionState)
|
||||||
|
})
|
||||||
|
|
||||||
|
// start ICE candidate exchange when gathering begins
|
||||||
|
this._conn.addEventListener('icegatheringstatechange', () => {
|
||||||
|
if (this._conn!.iceGatheringState === 'gathering') {
|
||||||
|
this.startIce()
|
||||||
|
} else if (this._conn!.iceGatheringState === 'complete') {
|
||||||
|
this.stopIce()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getter method for retrieving the current connection.
|
||||||
|
*
|
||||||
|
* @return {RTCPeerConnection|null} The current connection instance.
|
||||||
|
*/
|
||||||
|
public get connection(): RTCPeerConnection | null {
|
||||||
|
return this._conn
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update connection state and emit state-change event
|
||||||
|
*/
|
||||||
|
private setState(state: ConnectionInterface['state']) {
|
||||||
|
this._state = state
|
||||||
|
this.events.emit('state-change', state)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start ICE candidate exchange when gathering begins
|
||||||
|
*/
|
||||||
|
private startIce() {
|
||||||
|
const listener = ({ candidate }: { candidate: RTCIceCandidate | null }) => {
|
||||||
|
if (candidate) this.ctx.signaler.addIceCandidate(candidate)
|
||||||
|
}
|
||||||
|
if (!this._conn) throw new Error('Connection disappeared')
|
||||||
|
this._conn.addEventListener('icecandidate', listener)
|
||||||
this.iceBin(
|
this.iceBin(
|
||||||
this.signaler.addListener((candidate: RTCIceCandidate) => this.connection.addIceCandidate(candidate)),
|
this.ctx.signaler.addListener((candidate: RTCIceCandidate) =>
|
||||||
() => this.connection.removeEventListener('icecandidate', listener)
|
this._conn?.addIceCandidate(candidate)
|
||||||
|
),
|
||||||
|
() => this._conn?.removeEventListener('icecandidate', listener)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private stopIceListeners() {
|
/**
|
||||||
|
* Stop ICE candidate exchange when gathering completes
|
||||||
|
*/
|
||||||
|
private stopIce() {
|
||||||
this.iceBin.clean()
|
this.iceBin.clean()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the signaler for ICE candidate exchange
|
* Disconnects the current connection and cleans up resources.
|
||||||
* Must be called before connection is ready
|
* Closes the active connection if it exists, resets the connection instance to null,
|
||||||
|
* stops the ICE process, and updates the state to 'disconnected'.
|
||||||
|
*
|
||||||
|
* @return {void} No return value.
|
||||||
*/
|
*/
|
||||||
setSignaler(signaler: Signaler): void {
|
disconnect(): void {
|
||||||
this.signaler = signaler;
|
this._conn?.close()
|
||||||
|
this._conn = null
|
||||||
|
this.stopIce()
|
||||||
|
this.setState('disconnected')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current connection state
|
||||||
|
*/
|
||||||
get state() {
|
get state() {
|
||||||
return this._state;
|
return this._state
|
||||||
}
|
|
||||||
|
|
||||||
get ready(): Promise<void> {
|
|
||||||
return this._ready;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a message for sending when connection is established
|
||||||
|
*
|
||||||
|
* @param message - Message to queue (string or ArrayBuffer)
|
||||||
|
* @param options - Queue options (e.g., expiration time)
|
||||||
|
*/
|
||||||
queueMessage(message: Message, options: QueueMessageOptions = {}): Promise<void> {
|
queueMessage(message: Message, options: QueueMessageOptions = {}): Promise<void> {
|
||||||
return Promise.resolve(undefined);
|
// TODO: Implement message queuing
|
||||||
|
return Promise.resolve(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message immediately
|
||||||
|
*
|
||||||
|
* @param message - Message to send (string or ArrayBuffer)
|
||||||
|
* @returns Promise resolving to true if sent successfully
|
||||||
|
*/
|
||||||
sendMessage(message: Message): Promise<boolean> {
|
sendMessage(message: Message): Promise<boolean> {
|
||||||
return Promise.resolve(false);
|
// TODO: Implement message sending via data channel
|
||||||
|
return Promise.resolve(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
110
src/event-bus.ts
110
src/event-bus.ts
@@ -2,7 +2,7 @@
|
|||||||
* Type-safe EventBus with event name to payload type mapping
|
* Type-safe EventBus with event name to payload type mapping
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type EventHandler<T = any> = (data: T) => void;
|
type EventHandler<T = any> = (data: T) => void
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EventBus - Type-safe event emitter with inferred event data types
|
* EventBus - Type-safe event emitter with inferred event data types
|
||||||
@@ -27,64 +27,68 @@ type EventHandler<T = any> = (data: T) => void;
|
|||||||
* });
|
* });
|
||||||
*/
|
*/
|
||||||
export class EventBus<TEvents extends Record<string, any>> {
|
export class EventBus<TEvents extends Record<string, any>> {
|
||||||
private handlers: Map<keyof TEvents, Set<EventHandler>>;
|
private handlers: Map<keyof TEvents, Set<EventHandler>>
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.handlers = new Map();
|
this.handlers = new Map()
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to an event
|
|
||||||
*/
|
|
||||||
on<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): void {
|
|
||||||
if (!this.handlers.has(event)) {
|
|
||||||
this.handlers.set(event, new Set());
|
|
||||||
}
|
}
|
||||||
this.handlers.get(event)!.add(handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to an event once (auto-unsubscribe after first call)
|
* Subscribe to an event
|
||||||
*/
|
* Returns a cleanup function to unsubscribe
|
||||||
once<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): void {
|
*/
|
||||||
const wrappedHandler = (data: TEvents[K]) => {
|
on<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): () => void {
|
||||||
handler(data);
|
if (!this.handlers.has(event)) {
|
||||||
this.off(event, wrappedHandler);
|
this.handlers.set(event, new Set())
|
||||||
};
|
}
|
||||||
this.on(event, wrappedHandler);
|
this.handlers.get(event)!.add(handler)
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// Return cleanup function
|
||||||
* Unsubscribe from an event
|
return () => this.off(event, handler)
|
||||||
*/
|
|
||||||
off<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): void {
|
|
||||||
const eventHandlers = this.handlers.get(event);
|
|
||||||
if (eventHandlers) {
|
|
||||||
eventHandlers.delete(handler);
|
|
||||||
if (eventHandlers.size === 0) {
|
|
||||||
this.handlers.delete(event);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emit an event with data
|
* Subscribe to an event once (auto-unsubscribe after first call)
|
||||||
*/
|
*/
|
||||||
emit<K extends keyof TEvents>(event: K, data: TEvents[K]): void {
|
once<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): void {
|
||||||
const eventHandlers = this.handlers.get(event);
|
const wrappedHandler = (data: TEvents[K]) => {
|
||||||
if (eventHandlers) {
|
handler(data)
|
||||||
eventHandlers.forEach(handler => handler(data));
|
this.off(event, wrappedHandler)
|
||||||
|
}
|
||||||
|
this.on(event, wrappedHandler)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove all handlers for a specific event, or all handlers if no event specified
|
* Unsubscribe from an event
|
||||||
*/
|
*/
|
||||||
clear<K extends keyof TEvents>(event?: K): void {
|
off<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): void {
|
||||||
if (event !== undefined) {
|
const eventHandlers = this.handlers.get(event)
|
||||||
this.handlers.delete(event);
|
if (eventHandlers) {
|
||||||
} else {
|
eventHandlers.delete(handler)
|
||||||
this.handlers.clear();
|
if (eventHandlers.size === 0) {
|
||||||
|
this.handlers.delete(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
/**
|
||||||
|
* Emit an event with data
|
||||||
|
*/
|
||||||
|
emit<K extends keyof TEvents>(event: K, data: TEvents[K]): void {
|
||||||
|
const eventHandlers = this.handlers.get(event)
|
||||||
|
if (eventHandlers) {
|
||||||
|
eventHandlers.forEach(handler => handler(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all handlers for a specific event, or all handlers if no event specified
|
||||||
|
*/
|
||||||
|
clear<K extends keyof TEvents>(event?: K): void {
|
||||||
|
if (event !== undefined) {
|
||||||
|
this.handlers.delete(event)
|
||||||
|
} else {
|
||||||
|
this.handlers.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
49
src/index.ts
49
src/index.ts
@@ -3,29 +3,38 @@
|
|||||||
* WebRTC peer signaling client
|
* WebRTC peer signaling client
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { ConnectionManager } from './connection-manager.js';
|
export { EventBus } from './event-bus.js'
|
||||||
export { EventBus } from './event-bus.js';
|
export { RondevuAPI } from './api.js'
|
||||||
export { RondevuAPI } from './api.js';
|
export { RondevuService } from './rondevu-service.js'
|
||||||
export { RondevuSignaler } from './signaler.js';
|
export { RondevuSignaler } from './signaler.js'
|
||||||
export { WebRTCRondevuConnection } from './connection.js';
|
export { ServiceHost } from './service-host.js'
|
||||||
export { createBin } from './bin.js';
|
export { ServiceClient } from './service-client.js'
|
||||||
|
export { WebRTCRondevuConnection } from './connection.js'
|
||||||
|
export { createBin } from './bin.js'
|
||||||
|
|
||||||
// Export types
|
// Export types
|
||||||
export type {
|
export type {
|
||||||
ConnectionInterface,
|
ConnectionInterface,
|
||||||
QueueMessageOptions,
|
QueueMessageOptions,
|
||||||
Message,
|
Message,
|
||||||
ConnectionEvents,
|
ConnectionEvents,
|
||||||
Signaler
|
Signaler,
|
||||||
} from './types.js';
|
} from './types.js'
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
Credentials,
|
Credentials,
|
||||||
OfferRequest,
|
Keypair,
|
||||||
Offer,
|
OfferRequest,
|
||||||
ServiceRequest,
|
Offer,
|
||||||
Service,
|
ServiceRequest,
|
||||||
IceCandidate
|
Service,
|
||||||
} from './api.js';
|
IceCandidate,
|
||||||
|
} from './api.js'
|
||||||
|
|
||||||
export type { Binnable } from './bin.js';
|
export type { Binnable } from './bin.js'
|
||||||
|
|
||||||
|
export type { RondevuServiceOptions, PublishServiceOptions } from './rondevu-service.js'
|
||||||
|
|
||||||
|
export type { ServiceHostOptions, ServiceHostEvents } from './service-host.js'
|
||||||
|
|
||||||
|
export type { ServiceClientOptions, ServiceClientEvents } from './service-client.js'
|
||||||
|
|||||||
35
src/noop-signaler.ts
Normal file
35
src/noop-signaler.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Signaler } from './types.js'
|
||||||
|
import { Binnable } from './bin.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NoOpSignaler - A signaler that does nothing
|
||||||
|
* Used as a placeholder during connection setup before the real signaler is available
|
||||||
|
*/
|
||||||
|
export class NoOpSignaler implements Signaler {
|
||||||
|
addIceCandidate(_candidate: RTCIceCandidate): void {
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
|
||||||
|
addListener(_callback: (candidate: RTCIceCandidate) => void): Binnable {
|
||||||
|
// Return no-op cleanup function
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
addOfferListener(_callback: (offer: RTCSessionDescriptionInit) => void): Binnable {
|
||||||
|
// Return no-op cleanup function
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
addAnswerListener(_callback: (answer: RTCSessionDescriptionInit) => void): Binnable {
|
||||||
|
// Return no-op cleanup function
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setOffer(_offer: RTCSessionDescriptionInit): Promise<void> {
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAnswer(_answer: RTCSessionDescriptionInit): Promise<void> {
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
}
|
||||||
168
src/rondevu-service.ts
Normal file
168
src/rondevu-service.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { RondevuAPI, Credentials, Keypair, Service, ServiceRequest } from './api.js'
|
||||||
|
|
||||||
|
export interface RondevuServiceOptions {
|
||||||
|
apiUrl: string
|
||||||
|
username: string
|
||||||
|
keypair?: Keypair
|
||||||
|
credentials?: Credentials
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublishServiceOptions {
|
||||||
|
serviceFqn: string
|
||||||
|
sdp: string
|
||||||
|
ttl?: number
|
||||||
|
isPublic?: boolean
|
||||||
|
metadata?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RondevuService - High-level service management with automatic signature handling
|
||||||
|
*
|
||||||
|
* Provides a simplified API for:
|
||||||
|
* - Username claiming with Ed25519 signatures
|
||||||
|
* - Service publishing with automatic signature generation
|
||||||
|
* - Keypair management
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // Initialize service (generates keypair automatically)
|
||||||
|
* const service = new RondevuService({
|
||||||
|
* apiUrl: 'https://signal.example.com',
|
||||||
|
* username: 'myusername',
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* await service.initialize()
|
||||||
|
*
|
||||||
|
* // Claim username (one time)
|
||||||
|
* await service.claimUsername()
|
||||||
|
*
|
||||||
|
* // Publish a service
|
||||||
|
* const publishedService = await service.publishService({
|
||||||
|
* serviceFqn: 'chat.app@1.0.0',
|
||||||
|
* sdp: offerSdp,
|
||||||
|
* ttl: 300000,
|
||||||
|
* isPublic: true,
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class RondevuService {
|
||||||
|
private readonly api: RondevuAPI
|
||||||
|
private readonly username: string
|
||||||
|
private keypair: Keypair | null = null
|
||||||
|
private usernameClaimed = false
|
||||||
|
|
||||||
|
constructor(options: RondevuServiceOptions) {
|
||||||
|
this.username = options.username
|
||||||
|
this.keypair = options.keypair || null
|
||||||
|
this.api = new RondevuAPI(options.apiUrl, options.credentials)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the service - generates keypair if not provided
|
||||||
|
* Call this before using other methods
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
if (!this.keypair) {
|
||||||
|
this.keypair = await RondevuAPI.generateKeypair()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register with API if no credentials provided
|
||||||
|
if (!this.api['credentials']) {
|
||||||
|
const credentials = await this.api.register()
|
||||||
|
;(this.api as any).credentials = credentials
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim the username with Ed25519 signature
|
||||||
|
* Should be called once before publishing services
|
||||||
|
*/
|
||||||
|
async claimUsername(): Promise<void> {
|
||||||
|
if (!this.keypair) {
|
||||||
|
throw new Error('Service not initialized. Call initialize() first.')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username is already claimed
|
||||||
|
const check = await this.api.checkUsername(this.username)
|
||||||
|
if (!check.available) {
|
||||||
|
// Verify it's claimed by us
|
||||||
|
if (check.owner === this.keypair.publicKey) {
|
||||||
|
this.usernameClaimed = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error(`Username "${this.username}" is already claimed by another user`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate signature for username claim
|
||||||
|
const message = `claim-username-${this.username}-${Date.now()}`
|
||||||
|
const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey)
|
||||||
|
|
||||||
|
// Claim the username
|
||||||
|
await this.api.claimUsername(this.username, this.keypair.publicKey, signature, message)
|
||||||
|
this.usernameClaimed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a service with automatic signature generation
|
||||||
|
*/
|
||||||
|
async publishService(options: PublishServiceOptions): Promise<Service> {
|
||||||
|
if (!this.keypair) {
|
||||||
|
throw new Error('Service not initialized. Call initialize() first.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.usernameClaimed) {
|
||||||
|
throw new Error(
|
||||||
|
'Username not claimed. Call claimUsername() first or the server will reject the service.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { serviceFqn, sdp, ttl, isPublic, metadata } = options
|
||||||
|
|
||||||
|
// Generate signature for service publication
|
||||||
|
const message = `publish-${this.username}-${serviceFqn}-${Date.now()}`
|
||||||
|
const signature = await RondevuAPI.signMessage(message, this.keypair.privateKey)
|
||||||
|
|
||||||
|
// Create service request
|
||||||
|
const serviceRequest: ServiceRequest = {
|
||||||
|
username: this.username,
|
||||||
|
serviceFqn,
|
||||||
|
sdp,
|
||||||
|
signature,
|
||||||
|
message,
|
||||||
|
ttl,
|
||||||
|
isPublic,
|
||||||
|
metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish to server
|
||||||
|
return await this.api.publishService(serviceRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current keypair (for backup/storage)
|
||||||
|
*/
|
||||||
|
getKeypair(): Keypair | null {
|
||||||
|
return this.keypair
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the public key
|
||||||
|
*/
|
||||||
|
getPublicKey(): string | null {
|
||||||
|
return this.keypair?.publicKey || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if username has been claimed
|
||||||
|
*/
|
||||||
|
isUsernameClaimed(): boolean {
|
||||||
|
return this.usernameClaimed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access to underlying API for advanced operations
|
||||||
|
*/
|
||||||
|
getAPI(): RondevuAPI {
|
||||||
|
return this.api
|
||||||
|
}
|
||||||
|
}
|
||||||
244
src/service-client.ts
Normal file
244
src/service-client.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { WebRTCRondevuConnection } from './connection.js'
|
||||||
|
import { WebRTCContext } from './webrtc-context.js'
|
||||||
|
import { RondevuService } from './rondevu-service.js'
|
||||||
|
import { RondevuSignaler } from './signaler.js'
|
||||||
|
import { EventBus } from './event-bus.js'
|
||||||
|
import { createBin } from './bin.js'
|
||||||
|
import { ConnectionInterface } from './types.js'
|
||||||
|
|
||||||
|
export interface ServiceClientOptions {
|
||||||
|
username: string
|
||||||
|
serviceFqn: string
|
||||||
|
rondevuService: RondevuService
|
||||||
|
autoReconnect?: boolean
|
||||||
|
reconnectDelay?: number
|
||||||
|
maxReconnectAttempts?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceClientEvents {
|
||||||
|
connected: ConnectionInterface
|
||||||
|
disconnected: { reason: string }
|
||||||
|
reconnecting: { attempt: number; maxAttempts: number }
|
||||||
|
error: Error
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ServiceClient - Connects to a hosted service
|
||||||
|
*
|
||||||
|
* Searches for available service offers and establishes a WebRTC connection.
|
||||||
|
* Optionally supports automatic reconnection on failure.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const rondevuService = new RondevuService({
|
||||||
|
* apiUrl: 'https://signal.example.com',
|
||||||
|
* username: 'client-user',
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* await rondevuService.initialize()
|
||||||
|
*
|
||||||
|
* const client = new ServiceClient({
|
||||||
|
* username: 'host-user',
|
||||||
|
* serviceFqn: 'chat.app@1.0.0',
|
||||||
|
* rondevuService,
|
||||||
|
* autoReconnect: true,
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* await client.connect()
|
||||||
|
*
|
||||||
|
* client.events.on('connected', (conn) => {
|
||||||
|
* console.log('Connected to service')
|
||||||
|
* conn.sendMessage('Hello!')
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class ServiceClient {
|
||||||
|
private readonly username: string
|
||||||
|
private readonly serviceFqn: string
|
||||||
|
private readonly rondevuService: RondevuService
|
||||||
|
private readonly autoReconnect: boolean
|
||||||
|
private readonly reconnectDelay: number
|
||||||
|
private readonly maxReconnectAttempts: number
|
||||||
|
private connection: WebRTCRondevuConnection | null = null
|
||||||
|
private reconnectAttempts = 0
|
||||||
|
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
private readonly bin = createBin()
|
||||||
|
private isConnecting = false
|
||||||
|
|
||||||
|
public readonly events = new EventBus<ServiceClientEvents>()
|
||||||
|
|
||||||
|
constructor(options: ServiceClientOptions) {
|
||||||
|
this.username = options.username
|
||||||
|
this.serviceFqn = options.serviceFqn
|
||||||
|
this.rondevuService = options.rondevuService
|
||||||
|
this.autoReconnect = options.autoReconnect !== false
|
||||||
|
this.reconnectDelay = options.reconnectDelay || 2000
|
||||||
|
this.maxReconnectAttempts = options.maxReconnectAttempts || 5
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to the service
|
||||||
|
*/
|
||||||
|
async connect(): Promise<WebRTCRondevuConnection> {
|
||||||
|
if (this.isConnecting) {
|
||||||
|
throw new Error('Already connecting')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.connection && this.connection.state === 'connected') {
|
||||||
|
return this.connection
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isConnecting = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Search for available services
|
||||||
|
const services = await this.rondevuService
|
||||||
|
.getAPI()
|
||||||
|
.searchServices(this.username, this.serviceFqn)
|
||||||
|
|
||||||
|
if (services.length === 0) {
|
||||||
|
throw new Error(`No services found for ${this.username}/${this.serviceFqn}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first available service
|
||||||
|
const service = services[0]
|
||||||
|
|
||||||
|
// Get service details including SDP
|
||||||
|
const serviceDetails = await this.rondevuService.getAPI().getService(service.uuid)
|
||||||
|
|
||||||
|
// Create WebRTC context with signaler for this offer
|
||||||
|
const signaler = new RondevuSignaler(
|
||||||
|
this.rondevuService.getAPI(),
|
||||||
|
serviceDetails.offerId
|
||||||
|
)
|
||||||
|
const context = new WebRTCContext(signaler)
|
||||||
|
|
||||||
|
// Create connection (answerer role)
|
||||||
|
const conn = new WebRTCRondevuConnection({
|
||||||
|
id: `client-${this.serviceFqn}-${Date.now()}`,
|
||||||
|
service: this.serviceFqn,
|
||||||
|
offer: {
|
||||||
|
type: 'offer',
|
||||||
|
sdp: serviceDetails.sdp,
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for answer to be created
|
||||||
|
await conn.ready
|
||||||
|
|
||||||
|
// Get answer SDP
|
||||||
|
if (!conn.connection?.localDescription?.sdp) {
|
||||||
|
throw new Error('Failed to create answer SDP')
|
||||||
|
}
|
||||||
|
|
||||||
|
const answerSdp = conn.connection.localDescription.sdp
|
||||||
|
|
||||||
|
// Send answer to server
|
||||||
|
await this.rondevuService.getAPI().answerOffer(serviceDetails.offerId, answerSdp)
|
||||||
|
|
||||||
|
// Track connection
|
||||||
|
this.connection = conn
|
||||||
|
this.reconnectAttempts = 0
|
||||||
|
|
||||||
|
// Listen for state changes
|
||||||
|
const cleanup = conn.events.on('state-change', state => {
|
||||||
|
this.handleConnectionStateChange(state)
|
||||||
|
})
|
||||||
|
this.bin(cleanup)
|
||||||
|
|
||||||
|
this.isConnecting = false
|
||||||
|
|
||||||
|
// Emit connected event when actually connected
|
||||||
|
if (conn.state === 'connected') {
|
||||||
|
this.events.emit('connected', conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn
|
||||||
|
} catch (error) {
|
||||||
|
this.isConnecting = false
|
||||||
|
this.events.emit('error', error as Error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from the service
|
||||||
|
*/
|
||||||
|
disconnect(): void {
|
||||||
|
if (this.reconnectTimeout) {
|
||||||
|
clearTimeout(this.reconnectTimeout)
|
||||||
|
this.reconnectTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.connection) {
|
||||||
|
this.connection.disconnect()
|
||||||
|
this.connection = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bin.clean()
|
||||||
|
this.reconnectAttempts = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current connection
|
||||||
|
*/
|
||||||
|
getConnection(): WebRTCRondevuConnection | null {
|
||||||
|
return this.connection
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if currently connected
|
||||||
|
*/
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.connection?.state === 'connected'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle connection state changes
|
||||||
|
*/
|
||||||
|
private handleConnectionStateChange(state: ConnectionInterface['state']): void {
|
||||||
|
if (state === 'connected') {
|
||||||
|
this.events.emit('connected', this.connection!)
|
||||||
|
this.reconnectAttempts = 0
|
||||||
|
} else if (state === 'disconnected') {
|
||||||
|
this.events.emit('disconnected', { reason: 'Connection closed' })
|
||||||
|
|
||||||
|
// Attempt reconnection if enabled
|
||||||
|
if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||||
|
this.scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a reconnection attempt
|
||||||
|
*/
|
||||||
|
private scheduleReconnect(): void {
|
||||||
|
if (this.reconnectTimeout) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconnectAttempts++
|
||||||
|
|
||||||
|
this.events.emit('reconnecting', {
|
||||||
|
attempt: this.reconnectAttempts,
|
||||||
|
maxAttempts: this.maxReconnectAttempts,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Exponential backoff
|
||||||
|
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1)
|
||||||
|
|
||||||
|
this.reconnectTimeout = setTimeout(() => {
|
||||||
|
this.reconnectTimeout = null
|
||||||
|
this.connect().catch(error => {
|
||||||
|
this.events.emit('error', error as Error)
|
||||||
|
|
||||||
|
// Schedule next attempt if we haven't exceeded max attempts
|
||||||
|
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||||
|
this.scheduleReconnect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
236
src/service-host.ts
Normal file
236
src/service-host.ts
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { WebRTCRondevuConnection } from './connection.js'
|
||||||
|
import { WebRTCContext } from './webrtc-context.js'
|
||||||
|
import { RondevuService } from './rondevu-service.js'
|
||||||
|
import { RondevuSignaler } from './signaler.js'
|
||||||
|
import { NoOpSignaler } from './noop-signaler.js'
|
||||||
|
import { EventBus } from './event-bus.js'
|
||||||
|
import { createBin } from './bin.js'
|
||||||
|
import { ConnectionInterface } from './types.js'
|
||||||
|
|
||||||
|
export interface ServiceHostOptions {
|
||||||
|
service: string
|
||||||
|
rondevuService: RondevuService
|
||||||
|
maxPeers?: number
|
||||||
|
ttl?: number
|
||||||
|
isPublic?: boolean
|
||||||
|
metadata?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceHostEvents {
|
||||||
|
connection: ConnectionInterface
|
||||||
|
'connection-closed': { connectionId: string; reason: string }
|
||||||
|
error: Error
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ServiceHost - Manages a pool of WebRTC offers for a service
|
||||||
|
*
|
||||||
|
* Maintains up to maxPeers concurrent offers, automatically replacing
|
||||||
|
* them when connections are established or expire.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const rondevuService = new RondevuService({
|
||||||
|
* apiUrl: 'https://signal.example.com',
|
||||||
|
* username: 'myusername',
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* await rondevuService.initialize()
|
||||||
|
* await rondevuService.claimUsername()
|
||||||
|
*
|
||||||
|
* const host = new ServiceHost({
|
||||||
|
* service: 'chat.app@1.0.0',
|
||||||
|
* rondevuService,
|
||||||
|
* maxPeers: 5,
|
||||||
|
* })
|
||||||
|
*
|
||||||
|
* await host.start()
|
||||||
|
*
|
||||||
|
* host.events.on('connection', (conn) => {
|
||||||
|
* console.log('New connection:', conn.id)
|
||||||
|
* conn.events.on('message', (msg) => {
|
||||||
|
* console.log('Message:', msg)
|
||||||
|
* })
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class ServiceHost {
|
||||||
|
private connections = new Map<string, WebRTCRondevuConnection>()
|
||||||
|
private readonly service: string
|
||||||
|
private readonly rondevuService: RondevuService
|
||||||
|
private readonly maxPeers: number
|
||||||
|
private readonly ttl: number
|
||||||
|
private readonly isPublic: boolean
|
||||||
|
private readonly metadata?: Record<string, any>
|
||||||
|
private readonly bin = createBin()
|
||||||
|
private isStarted = false
|
||||||
|
|
||||||
|
public readonly events = new EventBus<ServiceHostEvents>()
|
||||||
|
|
||||||
|
constructor(options: ServiceHostOptions) {
|
||||||
|
this.service = options.service
|
||||||
|
this.rondevuService = options.rondevuService
|
||||||
|
this.maxPeers = options.maxPeers || 20
|
||||||
|
this.ttl = options.ttl || 300000
|
||||||
|
this.isPublic = options.isPublic !== false
|
||||||
|
this.metadata = options.metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start hosting the service - creates initial pool of offers
|
||||||
|
*/
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.isStarted) {
|
||||||
|
throw new Error('ServiceHost already started')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isStarted = true
|
||||||
|
await this.fillOfferPool()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop hosting - closes all connections and cleans up
|
||||||
|
*/
|
||||||
|
stop(): void {
|
||||||
|
this.isStarted = false
|
||||||
|
this.connections.forEach(conn => conn.disconnect())
|
||||||
|
this.connections.clear()
|
||||||
|
this.bin.clean()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current number of active connections
|
||||||
|
*/
|
||||||
|
getConnectionCount(): number {
|
||||||
|
return Array.from(this.connections.values()).filter(conn => conn.state === 'connected')
|
||||||
|
.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current number of pending offers
|
||||||
|
*/
|
||||||
|
getPendingOfferCount(): number {
|
||||||
|
return Array.from(this.connections.values()).filter(conn => conn.state === 'connecting')
|
||||||
|
.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill the offer pool up to maxPeers
|
||||||
|
*/
|
||||||
|
private async fillOfferPool(): Promise<void> {
|
||||||
|
const currentOffers = this.connections.size
|
||||||
|
const needed = this.maxPeers - currentOffers
|
||||||
|
|
||||||
|
if (needed <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create multiple offers in parallel
|
||||||
|
const offerPromises: Promise<void>[] = []
|
||||||
|
for (let i = 0; i < needed; i++) {
|
||||||
|
offerPromises.push(this.createOffer())
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(offerPromises)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a single offer and publish it
|
||||||
|
*/
|
||||||
|
private async createOffer(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Create temporary context with NoOp signaler
|
||||||
|
const tempContext = new WebRTCContext(new NoOpSignaler())
|
||||||
|
|
||||||
|
// Create connection (offerer role)
|
||||||
|
const conn = new WebRTCRondevuConnection({
|
||||||
|
id: `${this.service}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||||
|
service: this.service,
|
||||||
|
offer: null,
|
||||||
|
context: tempContext,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for offer to be created
|
||||||
|
await conn.ready
|
||||||
|
|
||||||
|
// Get offer SDP
|
||||||
|
if (!conn.connection?.localDescription?.sdp) {
|
||||||
|
throw new Error('Failed to create offer SDP')
|
||||||
|
}
|
||||||
|
|
||||||
|
const sdp = conn.connection.localDescription.sdp
|
||||||
|
|
||||||
|
// Publish service offer
|
||||||
|
const service = await this.rondevuService.publishService({
|
||||||
|
serviceFqn: this.service,
|
||||||
|
sdp,
|
||||||
|
ttl: this.ttl,
|
||||||
|
isPublic: this.isPublic,
|
||||||
|
metadata: this.metadata,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Replace with real signaler now that we have offerId
|
||||||
|
const realSignaler = new RondevuSignaler(this.rondevuService.getAPI(), service.offerId)
|
||||||
|
;(tempContext as any).signaler = realSignaler
|
||||||
|
|
||||||
|
// Track connection
|
||||||
|
this.connections.set(conn.id, conn)
|
||||||
|
|
||||||
|
// Listen for state changes
|
||||||
|
const cleanup = conn.events.on('state-change', state => {
|
||||||
|
this.handleConnectionStateChange(conn, state)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.bin(cleanup)
|
||||||
|
} catch (error) {
|
||||||
|
this.events.emit('error', error as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle connection state changes
|
||||||
|
*/
|
||||||
|
private handleConnectionStateChange(
|
||||||
|
conn: WebRTCRondevuConnection,
|
||||||
|
state: ConnectionInterface['state']
|
||||||
|
): void {
|
||||||
|
if (state === 'connected') {
|
||||||
|
// Connection established - emit event
|
||||||
|
this.events.emit('connection', conn)
|
||||||
|
|
||||||
|
// Create new offer to replace this one
|
||||||
|
if (this.isStarted) {
|
||||||
|
this.fillOfferPool().catch(error => {
|
||||||
|
this.events.emit('error', error as Error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (state === 'disconnected') {
|
||||||
|
// Connection closed - remove and create new offer
|
||||||
|
this.connections.delete(conn.id)
|
||||||
|
this.events.emit('connection-closed', {
|
||||||
|
connectionId: conn.id,
|
||||||
|
reason: state,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.isStarted) {
|
||||||
|
this.fillOfferPool().catch(error => {
|
||||||
|
this.events.emit('error', error as Error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all active connections
|
||||||
|
*/
|
||||||
|
getConnections(): WebRTCRondevuConnection[] {
|
||||||
|
return Array.from(this.connections.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific connection by ID
|
||||||
|
*/
|
||||||
|
getConnection(connectionId: string): WebRTCRondevuConnection | undefined {
|
||||||
|
return this.connections.get(connectionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Signaler} from "./types";
|
import { Signaler } from './types.js'
|
||||||
import {Binnable} from "./bin";
|
import { Binnable } from './bin.js'
|
||||||
import {RondevuAPI} from "./api";
|
import { RondevuAPI } from './api.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RondevuSignaler - Handles ICE candidate exchange via Rondevu API
|
* RondevuSignaler - Handles ICE candidate exchange via Rondevu API
|
||||||
@@ -12,18 +12,31 @@ export class RondevuSignaler implements Signaler {
|
|||||||
private offerId: string
|
private offerId: string
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
addOfferListener(callback: (offer: RTCSessionDescriptionInit) => void): Binnable {
|
||||||
|
throw new Error('Method not implemented.')
|
||||||
|
}
|
||||||
|
addAnswerListener(callback: (answer: RTCSessionDescriptionInit) => void): Binnable {
|
||||||
|
throw new Error('Method not implemented.')
|
||||||
|
}
|
||||||
|
setOffer(offer: RTCSessionDescriptionInit): Promise<void> {
|
||||||
|
throw new Error('Method not implemented.')
|
||||||
|
}
|
||||||
|
setAnswer(answer: RTCSessionDescriptionInit): Promise<void> {
|
||||||
|
throw new Error('Method not implemented.')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send local ICE candidate to signaling server
|
* Send a local ICE candidate to signaling server
|
||||||
*/
|
*/
|
||||||
async addIceCandidate(candidate: RTCIceCandidate): Promise<void> {
|
async addIceCandidate(candidate: RTCIceCandidate): Promise<void> {
|
||||||
const candidateData = candidate.toJSON();
|
const candidateData = candidate.toJSON()
|
||||||
|
|
||||||
// Skip empty candidates
|
// Skip empty candidates
|
||||||
if (!candidateData.candidate || candidateData.candidate === '') {
|
if (!candidateData.candidate || candidateData.candidate === '') {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.api.addIceCandidates(this.offerId, [candidateData]);
|
await this.api.addIceCandidates(this.offerId, [candidateData])
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,52 +44,61 @@ export class RondevuSignaler implements Signaler {
|
|||||||
* Returns cleanup function to stop polling
|
* Returns cleanup function to stop polling
|
||||||
*/
|
*/
|
||||||
addListener(callback: (candidate: RTCIceCandidate) => void): Binnable {
|
addListener(callback: (candidate: RTCIceCandidate) => void): Binnable {
|
||||||
let lastTimestamp = 0;
|
let lastTimestamp = 0
|
||||||
let polling = true;
|
let polling = true
|
||||||
|
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
while (polling) {
|
while (polling) {
|
||||||
try {
|
try {
|
||||||
const candidates = await this.api.getIceCandidates(this.offerId, lastTimestamp);
|
const candidates = await this.api.getIceCandidates(this.offerId, lastTimestamp)
|
||||||
|
|
||||||
// Process each candidate
|
// Process each candidate
|
||||||
for (const item of candidates) {
|
for (const item of candidates) {
|
||||||
if (item.candidate && item.candidate.candidate && item.candidate.candidate !== '') {
|
if (
|
||||||
|
item.candidate &&
|
||||||
|
item.candidate.candidate &&
|
||||||
|
item.candidate.candidate !== ''
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const rtcCandidate = new RTCIceCandidate(item.candidate);
|
const rtcCandidate = new RTCIceCandidate(item.candidate)
|
||||||
callback(rtcCandidate);
|
callback(rtcCandidate)
|
||||||
lastTimestamp = item.createdAt;
|
lastTimestamp = item.createdAt
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to process ICE candidate:', err);
|
console.warn('Failed to process ICE candidate:', err)
|
||||||
lastTimestamp = item.createdAt;
|
lastTimestamp = item.createdAt
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
lastTimestamp = item.createdAt;
|
lastTimestamp = item.createdAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// If offer not found or expired, stop polling
|
// If offer not found or expired, stop polling
|
||||||
if (err instanceof Error && (err.message.includes('404') || err.message.includes('410'))) {
|
if (
|
||||||
console.warn('Offer not found or expired, stopping ICE polling');
|
err instanceof Error &&
|
||||||
polling = false;
|
(err.message.includes('404') || err.message.includes('410'))
|
||||||
break;
|
) {
|
||||||
|
console.warn('Offer not found or expired, stopping ICE polling')
|
||||||
|
polling = false
|
||||||
|
break
|
||||||
}
|
}
|
||||||
console.error('Error polling for ICE candidates:', err);
|
console.error('Error polling for ICE candidates:', err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll every second
|
// Poll every second
|
||||||
if (polling) {
|
if (polling) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// Start polling in background
|
// Start polling in the background
|
||||||
poll();
|
poll().then(() => {
|
||||||
|
console.log('ICE polling started')
|
||||||
|
})
|
||||||
|
|
||||||
// Return cleanup function
|
// Return cleanup function
|
||||||
return () => {
|
return () => {
|
||||||
polling = false;
|
polling = false
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
src/types.ts
44
src/types.ts
@@ -1,34 +1,42 @@
|
|||||||
/**
|
/**
|
||||||
* Core connection types
|
* Core connection types
|
||||||
*/
|
*/
|
||||||
import {EventBus} from "./event-bus";
|
import { EventBus } from './event-bus.js'
|
||||||
import {Binnable} from "./bin";
|
import { Binnable } from './bin.js'
|
||||||
|
|
||||||
export type Message = string | ArrayBuffer;
|
export type Message = string | ArrayBuffer
|
||||||
|
|
||||||
export interface QueueMessageOptions {
|
export interface QueueMessageOptions {
|
||||||
expiresAt?: number;
|
expiresAt?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConnectionEvents {
|
export interface ConnectionEvents {
|
||||||
'state-change': ConnectionInterface['state']
|
'state-change': ConnectionInterface['state']
|
||||||
'message': Message;
|
message: Message
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConnectionInterface {
|
export const ConnectionStates = ['connected', 'disconnected', 'connecting'] as const
|
||||||
id: string;
|
|
||||||
host: string;
|
|
||||||
service: string;
|
|
||||||
state: 'connected' | 'disconnected' | 'connecting';
|
|
||||||
lastActive: number;
|
|
||||||
expiresAt?: number;
|
|
||||||
events: EventBus<ConnectionEvents>;
|
|
||||||
|
|
||||||
queueMessage(message: Message, options?: QueueMessageOptions): Promise<void>;
|
export const isConnectionState = (state: string): state is (typeof ConnectionStates)[number] =>
|
||||||
sendMessage(message: Message): Promise<boolean>;
|
ConnectionStates.includes(state as any)
|
||||||
|
|
||||||
|
export interface ConnectionInterface {
|
||||||
|
id: string
|
||||||
|
service: string
|
||||||
|
state: (typeof ConnectionStates)[number]
|
||||||
|
lastActive: number
|
||||||
|
expiresAt?: number
|
||||||
|
events: EventBus<ConnectionEvents>
|
||||||
|
|
||||||
|
queueMessage(message: Message, options?: QueueMessageOptions): Promise<void>
|
||||||
|
sendMessage(message: Message): Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Signaler {
|
export interface Signaler {
|
||||||
addIceCandidate(candidate: RTCIceCandidate): Promise<void> | void;
|
addIceCandidate(candidate: RTCIceCandidate): Promise<void> | void
|
||||||
addListener(callback: (candidate: RTCIceCandidate) => void): Binnable;
|
addListener(callback: (candidate: RTCIceCandidate) => void): Binnable
|
||||||
}
|
addOfferListener(callback: (offer: RTCSessionDescriptionInit) => void): Binnable
|
||||||
|
addAnswerListener(callback: (answer: RTCSessionDescriptionInit) => void): Binnable
|
||||||
|
setOffer(offer: RTCSessionDescriptionInit): Promise<void>
|
||||||
|
setAnswer(answer: RTCSessionDescriptionInit): Promise<void>
|
||||||
|
}
|
||||||
|
|||||||
35
src/webrtc-context.ts
Normal file
35
src/webrtc-context.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Signaler } from './types'
|
||||||
|
|
||||||
|
export class WebRTCContext {
|
||||||
|
constructor(public readonly signaler: Signaler) {}
|
||||||
|
|
||||||
|
createPeerConnection(): RTCPeerConnection {
|
||||||
|
return new RTCPeerConnection({
|
||||||
|
iceServers: [
|
||||||
|
{
|
||||||
|
urls: 'stun:stun.relay.metered.ca:80',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urls: 'turn:standard.relay.metered.ca:80',
|
||||||
|
username: 'c53a9c971da5e6f3bc959d8d',
|
||||||
|
credential: 'QaccPqtPPaxyokXp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urls: 'turn:standard.relay.metered.ca:80?transport=tcp',
|
||||||
|
username: 'c53a9c971da5e6f3bc959d8d',
|
||||||
|
credential: 'QaccPqtPPaxyokXp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urls: 'turn:standard.relay.metered.ca:443',
|
||||||
|
username: 'c53a9c971da5e6f3bc959d8d',
|
||||||
|
credential: 'QaccPqtPPaxyokXp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urls: 'turns:standard.relay.metered.ca:443?transport=tcp',
|
||||||
|
username: 'c53a9c971da5e6f3bc959d8d',
|
||||||
|
credential: 'QaccPqtPPaxyokXp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
10
vite.config.js
Normal file
10
vite.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: 'demo',
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
open: true,
|
||||||
|
allowedHosts: ['241284034b20.ngrok-free.app']
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user