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:
2025-12-07 19:37:43 +01:00
parent 945d5a8792
commit 54355323d9
21 changed files with 5066 additions and 307 deletions

9
.prettierrc.json Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -1,9 +0,0 @@
/**
* ConnectionManager - Manages WebRTC peer connections
*/
export class ConnectionManager {
constructor() {
// TODO: Initialize connection manager
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View 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
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
export default defineConfig({
root: 'demo',
server: {
port: 3000,
open: true,
allowedHosts: ['241284034b20.ngrok-free.app']
}
});