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",
|
||||
"version": "0.9.2",
|
||||
"version": "0.10.0",
|
||||
"description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -8,6 +8,10 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"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"
|
||||
},
|
||||
"keywords": [
|
||||
@@ -20,14 +24,23 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"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": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"dependencies": {
|
||||
"@noble/ed25519": "^3.0.0",
|
||||
"@xtr-dev/rondevu-client": "^0.9.2"
|
||||
"@noble/ed25519": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
293
src/api.ts
293
src/api.ts
@@ -2,55 +2,83 @@
|
||||
* Rondevu API Client - Single class for all API endpoints
|
||||
*/
|
||||
|
||||
import * as ed25519 from '@noble/ed25519'
|
||||
|
||||
// Set SHA-512 hash function for ed25519 (required in @noble/ed25519 v3+)
|
||||
ed25519.hashes.sha512Async = async (message: Uint8Array) => {
|
||||
return new Uint8Array(await crypto.subtle.digest('SHA-512', message as BufferSource))
|
||||
}
|
||||
|
||||
export interface Credentials {
|
||||
peerId: string;
|
||||
secret: string;
|
||||
peerId: string
|
||||
secret: string
|
||||
}
|
||||
|
||||
export interface Keypair {
|
||||
publicKey: string
|
||||
privateKey: string
|
||||
}
|
||||
|
||||
export interface OfferRequest {
|
||||
sdp: string;
|
||||
topics?: string[];
|
||||
ttl?: number;
|
||||
secret?: string;
|
||||
sdp: string
|
||||
topics?: string[]
|
||||
ttl?: number
|
||||
secret?: string
|
||||
}
|
||||
|
||||
export interface Offer {
|
||||
id: string;
|
||||
peerId: string;
|
||||
sdp: string;
|
||||
topics: string[];
|
||||
ttl: number;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
answererPeerId?: string;
|
||||
id: string
|
||||
peerId: string
|
||||
sdp: string
|
||||
topics: string[]
|
||||
ttl: number
|
||||
createdAt: number
|
||||
expiresAt: number
|
||||
answererPeerId?: string
|
||||
}
|
||||
|
||||
export interface ServiceRequest {
|
||||
username: string;
|
||||
serviceFqn: string;
|
||||
sdp: string;
|
||||
ttl?: number;
|
||||
isPublic?: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
signature: string;
|
||||
message: string;
|
||||
username: string
|
||||
serviceFqn: string
|
||||
sdp: string
|
||||
ttl?: number
|
||||
isPublic?: boolean
|
||||
metadata?: Record<string, any>
|
||||
signature: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
serviceId: string;
|
||||
uuid: string;
|
||||
offerId: string;
|
||||
username: string;
|
||||
serviceFqn: string;
|
||||
isPublic: boolean;
|
||||
metadata?: Record<string, any>;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
serviceId: string
|
||||
uuid: string
|
||||
offerId: string
|
||||
username: string
|
||||
serviceFqn: string
|
||||
isPublic: boolean
|
||||
metadata?: Record<string, any>
|
||||
createdAt: number
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
export interface IceCandidate {
|
||||
candidate: RTCIceCandidateInit;
|
||||
createdAt: number;
|
||||
candidate: RTCIceCandidateInit
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Convert Uint8Array to base64 string
|
||||
*/
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
const binString = Array.from(bytes, byte => String.fromCodePoint(byte)).join('')
|
||||
return btoa(binString)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Convert base64 string to Uint8Array
|
||||
*/
|
||||
function base64ToBytes(base64: string): Uint8Array {
|
||||
const binString = atob(base64)
|
||||
return Uint8Array.from(binString, char => char.codePointAt(0)!)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,11 +95,56 @@ export class RondevuAPI {
|
||||
*/
|
||||
private getAuthHeader(): Record<string, string> {
|
||||
if (!this.credentials) {
|
||||
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> {
|
||||
const response = await fetch(`${this.baseUrl}/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Registration failed: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
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',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...this.getAuthHeader()
|
||||
...this.getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify({ offers })
|
||||
});
|
||||
body: JSON.stringify({ offers }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to create offers: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
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> {
|
||||
const response = await fetch(`${this.baseUrl}/offers/${offerId}`, {
|
||||
headers: this.getAuthHeader()
|
||||
});
|
||||
headers: this.getAuthHeader(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to get offer: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
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',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...this.getAuthHeader()
|
||||
...this.getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify({ sdp, secret })
|
||||
});
|
||||
body: JSON.stringify({ sdp, secret }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to answer offer: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
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> {
|
||||
const response = await fetch(`${this.baseUrl}/offers/${offerId}/answer`, {
|
||||
headers: this.getAuthHeader()
|
||||
});
|
||||
headers: this.getAuthHeader(),
|
||||
})
|
||||
|
||||
if (response.status === 404) {
|
||||
return null; // No answer yet
|
||||
return null // No answer yet
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to get answer: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
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[]> {
|
||||
const response = await fetch(`${this.baseUrl}/offers?topic=${encodeURIComponent(topic)}`, {
|
||||
headers: this.getAuthHeader()
|
||||
});
|
||||
headers: this.getAuthHeader(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to search offers: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
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',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...this.getAuthHeader()
|
||||
...this.getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify({ candidates })
|
||||
});
|
||||
body: JSON.stringify({ candidates }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to add ICE candidates: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to add ICE candidates: ${error.error || response.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,14 +294,14 @@ export class RondevuAPI {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/offers/${offerId}/ice-candidates?since=${since}`,
|
||||
{ headers: this.getAuthHeader() }
|
||||
);
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to get ICE candidates: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
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',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...this.getAuthHeader()
|
||||
...this.getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify(service)
|
||||
});
|
||||
body: JSON.stringify(service),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to publish service: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
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 }> {
|
||||
const response = await fetch(`${this.baseUrl}/services/${uuid}`, {
|
||||
headers: this.getAuthHeader()
|
||||
});
|
||||
headers: this.getAuthHeader(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to get service: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
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(
|
||||
`${this.baseUrl}/services?username=${encodeURIComponent(username)}`,
|
||||
{ headers: this.getAuthHeader() }
|
||||
);
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to search services: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
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(
|
||||
`${this.baseUrl}/services?serviceFqn=${encodeURIComponent(serviceFqn)}`,
|
||||
{ headers: this.getAuthHeader() }
|
||||
);
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to search services: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
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(
|
||||
`${this.baseUrl}/services?username=${encodeURIComponent(username)}&serviceFqn=${encodeURIComponent(serviceFqn)}`,
|
||||
{ headers: this.getAuthHeader() }
|
||||
);
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to search services: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
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 }> {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/usernames/${encodeURIComponent(username)}/check`
|
||||
);
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to check username: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
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',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...this.getAuthHeader()
|
||||
...this.getAuthHeader(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
publicKey,
|
||||
signature,
|
||||
message
|
||||
message,
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(`Failed to claim username: ${error.error || response.statusText}`);
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(`Failed to claim username: ${error.error || response.statusText}`)
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
|
||||
41
src/bin.ts
41
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>
|
||||
|
||||
/**
|
||||
* Create a cleanup function collector (garbage bin)
|
||||
*
|
||||
* Collects cleanup functions and provides a single `clean()` method to execute all of them.
|
||||
* Useful for managing multiple cleanup operations in a single place.
|
||||
*
|
||||
* @returns A function that accepts cleanup functions and has a `clean()` method
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const bin = createBin();
|
||||
*
|
||||
* // Add cleanup functions
|
||||
* bin(
|
||||
* () => console.log('Cleanup 1'),
|
||||
* () => connection.close(),
|
||||
* () => clearInterval(timer)
|
||||
* );
|
||||
*
|
||||
* // Later, clean everything
|
||||
* bin.clean(); // Executes all cleanup functions
|
||||
* ```
|
||||
*/
|
||||
export const createBin = () => {
|
||||
const bin: Binnable[] = []
|
||||
return Object.assign(
|
||||
(...rubbish: Binnable[]) => bin.push(...rubbish),
|
||||
{
|
||||
return Object.assign((...rubbish: Binnable[]) => bin.push(...rubbish), {
|
||||
/**
|
||||
* Execute all cleanup functions and clear the bin
|
||||
*/
|
||||
clean: (): void => {
|
||||
bin.forEach(binnable => binnable())
|
||||
bin.length = 0
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 {EventBus} from "./event-bus";
|
||||
import {createBin} from "./bin";
|
||||
import {
|
||||
ConnectionEvents,
|
||||
ConnectionInterface,
|
||||
ConnectionStates,
|
||||
isConnectionState,
|
||||
Message,
|
||||
QueueMessageOptions,
|
||||
Signaler,
|
||||
} from './types.js'
|
||||
import { EventBus } from './event-bus.js'
|
||||
import { createBin } from './bin.js'
|
||||
import { WebRTCContext } from './webrtc-context'
|
||||
|
||||
export type WebRTCRondevuConnectionOptions = {
|
||||
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 {
|
||||
private readonly connection: RTCPeerConnection;
|
||||
private readonly side: 'offer' | 'answer';
|
||||
public readonly expiresAt: number = 0;
|
||||
public readonly lastActive: number = 0;
|
||||
public readonly events: EventBus<ConnectionEvents> = new EventBus();
|
||||
private signaler!: Signaler; // Will be set by setSignaler()
|
||||
private readonly _ready: Promise<void>;
|
||||
private _state: ConnectionInterface['state'] = 'disconnected';
|
||||
private readonly side: 'offer' | 'answer'
|
||||
public readonly expiresAt: number = 0
|
||||
public readonly lastActive: number = 0
|
||||
public readonly events: EventBus<ConnectionEvents> = new EventBus()
|
||||
public readonly ready: Promise<void>
|
||||
private iceBin = createBin()
|
||||
private ctx: WebRTCContext
|
||||
public id: string
|
||||
public service: string
|
||||
private _conn: RTCPeerConnection | null = null
|
||||
private _state: ConnectionInterface['state'] = 'disconnected'
|
||||
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly host: string,
|
||||
public readonly service: string,
|
||||
offer?: RTCSessionDescriptionInit) {
|
||||
this.connection = new RTCPeerConnection();
|
||||
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())
|
||||
constructor({ context: ctx, offer, id, service }: WebRTCRondevuConnectionOptions) {
|
||||
this.ctx = ctx
|
||||
this.id = id
|
||||
this.service = service
|
||||
this._conn = ctx.createPeerConnection()
|
||||
this.side = offer ? 'answer' : 'offer'
|
||||
|
||||
// setup data channel
|
||||
if (offer) {
|
||||
this._conn.addEventListener('datachannel', e => {
|
||||
const channel = e.channel
|
||||
channel.addEventListener('message', e => {
|
||||
console.log('Message from peer:', e)
|
||||
})
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
// 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);
|
||||
this._state = state
|
||||
this.events.emit('state-change', state)
|
||||
}
|
||||
|
||||
private startIceListeners() {
|
||||
const listener = ({candidate}: {candidate: RTCIceCandidate | null}) => {
|
||||
if (candidate) this.signaler.addIceCandidate(candidate)
|
||||
/**
|
||||
* Start ICE candidate exchange when gathering begins
|
||||
*/
|
||||
private startIce() {
|
||||
const listener = ({ candidate }: { candidate: RTCIceCandidate | null }) => {
|
||||
if (candidate) this.ctx.signaler.addIceCandidate(candidate)
|
||||
}
|
||||
this.connection.addEventListener('icecandidate', listener)
|
||||
if (!this._conn) throw new Error('Connection disappeared')
|
||||
this._conn.addEventListener('icecandidate', listener)
|
||||
this.iceBin(
|
||||
this.signaler.addListener((candidate: RTCIceCandidate) => this.connection.addIceCandidate(candidate)),
|
||||
() => this.connection.removeEventListener('icecandidate', listener)
|
||||
this.ctx.signaler.addListener((candidate: RTCIceCandidate) =>
|
||||
this._conn?.addIceCandidate(candidate)
|
||||
),
|
||||
() => this._conn?.removeEventListener('icecandidate', listener)
|
||||
)
|
||||
}
|
||||
|
||||
private stopIceListeners() {
|
||||
/**
|
||||
* Stop ICE candidate exchange when gathering completes
|
||||
*/
|
||||
private stopIce() {
|
||||
this.iceBin.clean()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the signaler for ICE candidate exchange
|
||||
* Must be called before connection is ready
|
||||
* Disconnects the current connection and cleans up resources.
|
||||
* Closes the active connection if it exists, resets the connection instance to null,
|
||||
* stops the ICE process, and updates the state to 'disconnected'.
|
||||
*
|
||||
* @return {void} No return value.
|
||||
*/
|
||||
setSignaler(signaler: Signaler): void {
|
||||
this.signaler = signaler;
|
||||
disconnect(): void {
|
||||
this._conn?.close()
|
||||
this._conn = null
|
||||
this.stopIce()
|
||||
this.setState('disconnected')
|
||||
}
|
||||
|
||||
/**
|
||||
* Current connection state
|
||||
*/
|
||||
get state() {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
get ready(): Promise<void> {
|
||||
return this._ready;
|
||||
return this._state
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
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> {
|
||||
return Promise.resolve(false);
|
||||
// TODO: Implement message sending via data channel
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* 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
|
||||
@@ -27,20 +27,24 @@ type EventHandler<T = any> = (data: T) => void;
|
||||
* });
|
||||
*/
|
||||
export class EventBus<TEvents extends Record<string, any>> {
|
||||
private handlers: Map<keyof TEvents, Set<EventHandler>>;
|
||||
private handlers: Map<keyof TEvents, Set<EventHandler>>
|
||||
|
||||
constructor() {
|
||||
this.handlers = new Map();
|
||||
this.handlers = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event
|
||||
* Returns a cleanup function to unsubscribe
|
||||
*/
|
||||
on<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): void {
|
||||
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.set(event, new Set())
|
||||
}
|
||||
this.handlers.get(event)!.add(handler);
|
||||
this.handlers.get(event)!.add(handler)
|
||||
|
||||
// Return cleanup function
|
||||
return () => this.off(event, handler)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,21 +52,21 @@ export class EventBus<TEvents extends Record<string, any>> {
|
||||
*/
|
||||
once<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): void {
|
||||
const wrappedHandler = (data: TEvents[K]) => {
|
||||
handler(data);
|
||||
this.off(event, wrappedHandler);
|
||||
};
|
||||
this.on(event, wrappedHandler);
|
||||
handler(data)
|
||||
this.off(event, wrappedHandler)
|
||||
}
|
||||
this.on(event, wrappedHandler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from an event
|
||||
*/
|
||||
off<K extends keyof TEvents>(event: K, handler: EventHandler<TEvents[K]>): void {
|
||||
const eventHandlers = this.handlers.get(event);
|
||||
const eventHandlers = this.handlers.get(event)
|
||||
if (eventHandlers) {
|
||||
eventHandlers.delete(handler);
|
||||
eventHandlers.delete(handler)
|
||||
if (eventHandlers.size === 0) {
|
||||
this.handlers.delete(event);
|
||||
this.handlers.delete(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,9 +75,9 @@ export class EventBus<TEvents extends Record<string, any>> {
|
||||
* Emit an event with data
|
||||
*/
|
||||
emit<K extends keyof TEvents>(event: K, data: TEvents[K]): void {
|
||||
const eventHandlers = this.handlers.get(event);
|
||||
const eventHandlers = this.handlers.get(event)
|
||||
if (eventHandlers) {
|
||||
eventHandlers.forEach(handler => handler(data));
|
||||
eventHandlers.forEach(handler => handler(data))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,9 +86,9 @@ export class EventBus<TEvents extends Record<string, any>> {
|
||||
*/
|
||||
clear<K extends keyof TEvents>(event?: K): void {
|
||||
if (event !== undefined) {
|
||||
this.handlers.delete(event);
|
||||
this.handlers.delete(event)
|
||||
} else {
|
||||
this.handlers.clear();
|
||||
this.handlers.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/index.ts
31
src/index.ts
@@ -3,12 +3,14 @@
|
||||
* WebRTC peer signaling client
|
||||
*/
|
||||
|
||||
export { ConnectionManager } from './connection-manager.js';
|
||||
export { EventBus } from './event-bus.js';
|
||||
export { RondevuAPI } from './api.js';
|
||||
export { RondevuSignaler } from './signaler.js';
|
||||
export { WebRTCRondevuConnection } from './connection.js';
|
||||
export { createBin } from './bin.js';
|
||||
export { EventBus } from './event-bus.js'
|
||||
export { RondevuAPI } from './api.js'
|
||||
export { RondevuService } from './rondevu-service.js'
|
||||
export { RondevuSignaler } from './signaler.js'
|
||||
export { ServiceHost } from './service-host.js'
|
||||
export { ServiceClient } from './service-client.js'
|
||||
export { WebRTCRondevuConnection } from './connection.js'
|
||||
export { createBin } from './bin.js'
|
||||
|
||||
// Export types
|
||||
export type {
|
||||
@@ -16,16 +18,23 @@ export type {
|
||||
QueueMessageOptions,
|
||||
Message,
|
||||
ConnectionEvents,
|
||||
Signaler
|
||||
} from './types.js';
|
||||
Signaler,
|
||||
} from './types.js'
|
||||
|
||||
export type {
|
||||
Credentials,
|
||||
Keypair,
|
||||
OfferRequest,
|
||||
Offer,
|
||||
ServiceRequest,
|
||||
Service,
|
||||
IceCandidate
|
||||
} 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 {Binnable} from "./bin";
|
||||
import {RondevuAPI} from "./api";
|
||||
import { Signaler } from './types.js'
|
||||
import { Binnable } from './bin.js'
|
||||
import { RondevuAPI } from './api.js'
|
||||
|
||||
/**
|
||||
* RondevuSignaler - Handles ICE candidate exchange via Rondevu API
|
||||
@@ -12,18 +12,31 @@ export class RondevuSignaler implements Signaler {
|
||||
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> {
|
||||
const candidateData = candidate.toJSON();
|
||||
const candidateData = candidate.toJSON()
|
||||
|
||||
// Skip empty candidates
|
||||
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
|
||||
*/
|
||||
addListener(callback: (candidate: RTCIceCandidate) => void): Binnable {
|
||||
let lastTimestamp = 0;
|
||||
let polling = true;
|
||||
let lastTimestamp = 0
|
||||
let polling = true
|
||||
|
||||
const poll = async () => {
|
||||
while (polling) {
|
||||
try {
|
||||
const candidates = await this.api.getIceCandidates(this.offerId, lastTimestamp);
|
||||
const candidates = await this.api.getIceCandidates(this.offerId, lastTimestamp)
|
||||
|
||||
// Process each candidate
|
||||
for (const item of candidates) {
|
||||
if (item.candidate && item.candidate.candidate && item.candidate.candidate !== '') {
|
||||
if (
|
||||
item.candidate &&
|
||||
item.candidate.candidate &&
|
||||
item.candidate.candidate !== ''
|
||||
) {
|
||||
try {
|
||||
const rtcCandidate = new RTCIceCandidate(item.candidate);
|
||||
callback(rtcCandidate);
|
||||
lastTimestamp = item.createdAt;
|
||||
const rtcCandidate = new RTCIceCandidate(item.candidate)
|
||||
callback(rtcCandidate)
|
||||
lastTimestamp = item.createdAt
|
||||
} catch (err) {
|
||||
console.warn('Failed to process ICE candidate:', err);
|
||||
lastTimestamp = item.createdAt;
|
||||
console.warn('Failed to process ICE candidate:', err)
|
||||
lastTimestamp = item.createdAt
|
||||
}
|
||||
} else {
|
||||
lastTimestamp = item.createdAt;
|
||||
lastTimestamp = item.createdAt
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// If offer not found or expired, stop polling
|
||||
if (err instanceof Error && (err.message.includes('404') || err.message.includes('410'))) {
|
||||
console.warn('Offer not found or expired, stopping ICE polling');
|
||||
polling = false;
|
||||
break;
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err.message.includes('404') || err.message.includes('410'))
|
||||
) {
|
||||
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
|
||||
if (polling) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Start polling in background
|
||||
poll();
|
||||
// Start polling in the background
|
||||
poll().then(() => {
|
||||
console.log('ICE polling started')
|
||||
})
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
polling = false;
|
||||
};
|
||||
polling = false
|
||||
}
|
||||
}
|
||||
}
|
||||
42
src/types.ts
42
src/types.ts
@@ -1,34 +1,42 @@
|
||||
/**
|
||||
* Core connection types
|
||||
*/
|
||||
import {EventBus} from "./event-bus";
|
||||
import {Binnable} from "./bin";
|
||||
import { EventBus } from './event-bus.js'
|
||||
import { Binnable } from './bin.js'
|
||||
|
||||
export type Message = string | ArrayBuffer;
|
||||
export type Message = string | ArrayBuffer
|
||||
|
||||
export interface QueueMessageOptions {
|
||||
expiresAt?: number;
|
||||
expiresAt?: number
|
||||
}
|
||||
|
||||
export interface ConnectionEvents {
|
||||
'state-change': ConnectionInterface['state']
|
||||
'message': Message;
|
||||
message: Message
|
||||
}
|
||||
|
||||
export interface ConnectionInterface {
|
||||
id: string;
|
||||
host: string;
|
||||
service: string;
|
||||
state: 'connected' | 'disconnected' | 'connecting';
|
||||
lastActive: number;
|
||||
expiresAt?: number;
|
||||
events: EventBus<ConnectionEvents>;
|
||||
export const ConnectionStates = ['connected', 'disconnected', 'connecting'] as const
|
||||
|
||||
queueMessage(message: Message, options?: QueueMessageOptions): Promise<void>;
|
||||
sendMessage(message: Message): Promise<boolean>;
|
||||
export const isConnectionState = (state: string): state is (typeof ConnectionStates)[number] =>
|
||||
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 {
|
||||
addIceCandidate(candidate: RTCIceCandidate): Promise<void> | void;
|
||||
addListener(callback: (candidate: RTCIceCandidate) => void): Binnable;
|
||||
addIceCandidate(candidate: RTCIceCandidate): Promise<void> | void
|
||||
addListener(callback: (candidate: RTCIceCandidate) => void): Binnable
|
||||
addOfferListener(callback: (offer: RTCSessionDescriptionInit) => void): Binnable
|
||||
addAnswerListener(callback: (answer: RTCSessionDescriptionInit) => void): Binnable
|
||||
setOffer(offer: RTCSessionDescriptionInit): Promise<void>
|
||||
setAnswer(answer: RTCSessionDescriptionInit): Promise<void>
|
||||
}
|
||||
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