2 Commits

Author SHA1 Message Date
a3b4dfa15f 0.13.0 2025-12-09 22:28:15 +01:00
5c38f8f36c v0.13.0: Major refactoring with unified Rondevu class and service discovery
- Renamed RondevuService to Rondevu as single main entrypoint
- Integrated signaling methods directly into Rondevu class
- Updated service FQN format: service:version@username (colon instead of @)
- Added service discovery (direct, random, paginated)
- Removed high-level abstractions (ServiceHost, ServiceClient, RTCDurableConnection, EventBus, WebRTCContext, Bin)
- Updated RondevuAPI with new endpoint methods (offer-specific routes)
- Simplified types (moved Binnable to types.ts, removed connection types)
- Updated RondevuSignaler to use Rondevu class
- Breaking changes: Complete API overhaul for simplicity

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-09 22:22:15 +01:00
14 changed files with 422 additions and 1155 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@xtr-dev/rondevu-client",
"version": "0.12.0",
"version": "0.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@xtr-dev/rondevu-client",
"version": "0.12.0",
"version": "0.13.0",
"license": "MIT",
"dependencies": {
"@noble/ed25519": "^3.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@xtr-dev/rondevu-client",
"version": "0.12.0",
"version": "0.13.0",
"description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
"type": "module",
"main": "dist/index.js",

View File

@@ -42,12 +42,9 @@ export interface OfferRequest {
}
export interface ServiceRequest {
username: string
serviceFqn: string
serviceFqn: string // Must include username: service:version@username
offers: OfferRequest[]
ttl?: number
isPublic?: boolean
metadata?: Record<string, any>
signature: string
message: string
}
@@ -61,12 +58,9 @@ export interface ServiceOffer {
export interface Service {
serviceId: string
uuid: string
offers: ServiceOffer[]
username: string
serviceFqn: string
isPublic: boolean
metadata?: Record<string, any>
createdAt: number
expiresAt: number
}
@@ -228,10 +222,10 @@ export class RondevuAPI {
}
/**
* Answer a service
* Answer a specific offer from a service
*/
async answerService(serviceUuid: string, sdp: string): Promise<{ offerId: string }> {
const response = await fetch(`${this.baseUrl}/services/${serviceUuid}/answer`, {
async postOfferAnswer(serviceFqn: string, offerId: string, sdp: string): Promise<{ success: boolean; offerId: string }> {
const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/answer`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -242,17 +236,17 @@ export class RondevuAPI {
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(`Failed to answer service: ${error.error || response.statusText}`)
throw new Error(`Failed to answer offer: ${error.error || response.statusText}`)
}
return await response.json()
}
/**
* Get answer for a service (offerer polls this)
* Get answer for a specific offer (offerer polls this)
*/
async getServiceAnswer(serviceUuid: string): Promise<{ sdp: string; offerId: string } | null> {
const response = await fetch(`${this.baseUrl}/services/${serviceUuid}/answer`, {
async getOfferAnswer(serviceFqn: string, offerId: string): Promise<{ sdp: string; offerId: string; answererId: string; answeredAt: number } | null> {
const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/answer`, {
headers: this.getAuthHeader(),
})
@@ -265,8 +259,7 @@ export class RondevuAPI {
throw new Error(`Failed to get answer: ${error.error || response.statusText}`)
}
const data = await response.json()
return { sdp: data.sdp, offerId: data.offerId }
return await response.json()
}
/**
@@ -290,16 +283,16 @@ export class RondevuAPI {
// ============================================
/**
* Add ICE candidates to a service
* Add ICE candidates to a specific offer
*/
async addServiceIceCandidates(serviceUuid: string, candidates: RTCIceCandidateInit[], offerId?: string): Promise<{ offerId: string }> {
const response = await fetch(`${this.baseUrl}/services/${serviceUuid}/ice-candidates`, {
async addOfferIceCandidates(serviceFqn: string, offerId: string, candidates: RTCIceCandidateInit[]): Promise<{ count: number; offerId: string }> {
const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/ice-candidates`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...this.getAuthHeader(),
},
body: JSON.stringify({ candidates, offerId }),
body: JSON.stringify({ candidates }),
})
if (!response.ok) {
@@ -311,14 +304,11 @@ export class RondevuAPI {
}
/**
* Get ICE candidates for a service (with polling support)
* Get ICE candidates for a specific offer (with polling support)
*/
async getServiceIceCandidates(serviceUuid: string, since: number = 0, offerId?: string): Promise<{ candidates: IceCandidate[]; offerId: string }> {
const url = new URL(`${this.baseUrl}/services/${serviceUuid}/ice-candidates`)
async getOfferIceCandidates(serviceFqn: string, offerId: string, since: number = 0): Promise<{ candidates: IceCandidate[]; offerId: string }> {
const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}/offers/${offerId}/ice-candidates`)
url.searchParams.set('since', since.toString())
if (offerId) {
url.searchParams.set('offerId', offerId)
}
const response = await fetch(url.toString(), { headers: this.getAuthHeader() })
@@ -340,9 +330,10 @@ export class RondevuAPI {
/**
* Publish a service
* Service FQN must include username: service:version@username
*/
async publishService(service: ServiceRequest): Promise<Service> {
const response = await fetch(`${this.baseUrl}/users/${encodeURIComponent(service.username)}/services`, {
const response = await fetch(`${this.baseUrl}/services`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -360,10 +351,11 @@ export class RondevuAPI {
}
/**
* Get service by UUID
* Get service by FQN (with username) - Direct lookup
* Example: chat:1.0.0@alice
*/
async getService(uuid: string): Promise<Service & { offerId: string; sdp: string }> {
const response = await fetch(`${this.baseUrl}/services/${uuid}`, {
async getService(serviceFqn: string): Promise<{ serviceId: string; username: string; serviceFqn: string; offerId: string; sdp: string; createdAt: number; expiresAt: number }> {
const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceFqn)}`, {
headers: this.getAuthHeader(),
})
@@ -376,44 +368,44 @@ export class RondevuAPI {
}
/**
* Search services by username - lists all services for a username
* Discover a random available service without knowing the username
* Example: chat:1.0.0 (without @username)
*/
async searchServicesByUsername(username: string): Promise<Service[]> {
const response = await fetch(
`${this.baseUrl}/users/${encodeURIComponent(username)}/services`,
{ headers: this.getAuthHeader() }
)
async discoverService(serviceVersion: string): Promise<{ serviceId: string; username: string; serviceFqn: string; offerId: string; sdp: string; createdAt: number; expiresAt: number }> {
const response = await fetch(`${this.baseUrl}/services/${encodeURIComponent(serviceVersion)}`, {
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}`)
throw new Error(`Failed to discover service: ${error.error || response.statusText}`)
}
const data = await response.json()
return data.services || []
return await response.json()
}
/**
* Search services by username AND FQN - returns full service details
* Discover multiple available services with pagination
* Example: chat:1.0.0 (without @username)
*/
async searchServices(username: string, serviceFqn: string): Promise<Service[]> {
const response = await fetch(
`${this.baseUrl}/users/${encodeURIComponent(username)}/services/${encodeURIComponent(serviceFqn)}`,
{ headers: this.getAuthHeader() }
)
async discoverServices(serviceVersion: string, limit: number = 10, offset: number = 0): Promise<{ services: Array<{ serviceId: string; username: string; serviceFqn: string; offerId: string; sdp: string; createdAt: number; expiresAt: number }>; count: number; limit: number; offset: number }> {
const url = new URL(`${this.baseUrl}/services/${encodeURIComponent(serviceVersion)}`)
url.searchParams.set('limit', limit.toString())
url.searchParams.set('offset', offset.toString())
const response = await fetch(url.toString(), {
headers: this.getAuthHeader(),
})
if (!response.ok) {
if (response.status === 404) {
return []
}
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 discover services: ${error.error || response.statusText}`)
}
const service = await response.json()
return [service]
return await response.json()
}
// ============================================
// Usernames
// ============================================
@@ -421,7 +413,7 @@ export class RondevuAPI {
/**
* Check if username is available
*/
async checkUsername(username: string): Promise<{ available: boolean; owner?: string }> {
async checkUsername(username: string): Promise<{ available: boolean; publicKey?: string; claimedAt?: number; expiresAt?: number }> {
const response = await fetch(
`${this.baseUrl}/users/${encodeURIComponent(username)}`
)

View File

@@ -1,42 +0,0 @@
/**
* 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), {
/**
* Execute all cleanup functions and clear the bin
*/
clean: (): void => {
bin.forEach(binnable => binnable())
bin.length = 0
},
})
}

View File

@@ -1,290 +0,0 @@
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 = {
offer?: RTCSessionDescriptionInit | null
context: WebRTCContext
signaler: Signaler
}
/**
* 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 RTCDurableConnection implements ConnectionInterface {
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 context: WebRTCContext
private readonly signaler: Signaler
private _conn: RTCPeerConnection | null = null
private _state: ConnectionInterface['state'] = 'disconnected'
private _dataChannel: RTCDataChannel | null = null
private messageQueue: Array<{
message: Message
options: QueueMessageOptions
timestamp: number
}> = []
constructor({ context, offer, signaler }: WebRTCRondevuConnectionOptions) {
this.context = context
this.signaler = signaler
this._conn = context.createPeerConnection()
this.side = offer ? 'answer' : 'offer'
// setup data channel
if (offer) {
this._conn.addEventListener('datachannel', e => {
this._dataChannel = e.channel
this.setupDataChannelListeners(this._dataChannel)
})
} else {
this._dataChannel = this._conn.createDataChannel('vu.ronde.protocol')
this.setupDataChannelListeners(this._dataChannel)
}
// 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 signaler.setAnswer(answer)
})
: this._conn.createOffer().then(async offer => {
if (!this._conn) throw new Error('Connection disappeared')
await this._conn.setLocalDescription(offer)
return await 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.signaler.addIceCandidate(candidate)
}
if (!this._conn) throw new Error('Connection disappeared')
this._conn.addEventListener('icecandidate', listener)
this.iceBin(
this.signaler.addListener((candidate: RTCIceCandidate) =>
this._conn?.addIceCandidate(candidate)
),
() => this._conn?.removeEventListener('icecandidate', listener)
)
}
/**
* Stop ICE candidate exchange when gathering completes
*/
private stopIce() {
this.iceBin.clean()
}
/**
* 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.
*/
disconnect(): void {
this._conn?.close()
this._conn = null
this.stopIce()
this.setState('disconnected')
}
/**
* Current connection state
*/
get state() {
return this._state
}
/**
* Setup data channel event listeners
*/
private setupDataChannelListeners(channel: RTCDataChannel): void {
channel.addEventListener('message', e => {
this.events.emit('message', e.data)
})
channel.addEventListener('open', () => {
// Channel opened - flush queued messages
this.flushQueue().catch(err => {
console.error('Failed to flush message queue:', err)
})
})
channel.addEventListener('error', err => {
console.error('Data channel error:', err)
})
channel.addEventListener('close', () => {
console.log('Data channel closed')
})
}
/**
* Flush the message queue
*/
private async flushQueue(): Promise<void> {
while (this.messageQueue.length > 0 && this._state === 'connected') {
const item = this.messageQueue.shift()!
// Check expiration
if (item.options.expiresAt && Date.now() > item.options.expiresAt) {
continue
}
const success = await this.sendMessage(item.message)
if (!success) {
// Re-queue on failure
this.messageQueue.unshift(item)
break
}
}
}
/**
* 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)
*/
async queueMessage(message: Message, options: QueueMessageOptions = {}): Promise<void> {
this.messageQueue.push({
message,
options,
timestamp: Date.now()
})
// Try immediate send if connected
if (this._state === 'connected') {
await this.flushQueue()
}
}
/**
* Send a message immediately
*
* @param message - Message to send (string or ArrayBuffer)
* @returns Promise resolving to true if sent successfully
*/
async sendMessage(message: Message): Promise<boolean> {
if (this._state !== 'connected' || !this._dataChannel) {
return false
}
if (this._dataChannel.readyState !== 'open') {
return false
}
try {
// TypeScript has trouble with the union type, so we cast to any
// Both string and ArrayBuffer are valid for RTCDataChannel.send()
this._dataChannel.send(message as any)
return true
} catch (err) {
console.error('Send failed:', err)
return false
}
}
}

View File

@@ -1,94 +0,0 @@
/**
* Type-safe EventBus with event name to payload type mapping
*/
type EventHandler<T = any> = (data: T) => void
/**
* EventBus - Type-safe event emitter with inferred event data types
*
* @example
* interface MyEvents {
* 'user:connected': { userId: string; timestamp: number };
* 'user:disconnected': { userId: string };
* 'message:received': string;
* }
*
* const bus = new EventBus<MyEvents>();
*
* // TypeScript knows data is { userId: string; timestamp: number }
* bus.on('user:connected', (data) => {
* console.log(data.userId, data.timestamp);
* });
*
* // TypeScript knows data is string
* bus.on('message:received', (data) => {
* console.log(data.toUpperCase());
* });
*/
export class EventBus<TEvents extends Record<string, any>> {
private handlers: Map<keyof TEvents, Set<EventHandler>>
constructor() {
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 {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set())
}
this.handlers.get(event)!.add(handler)
// Return cleanup function
return () => this.off(event, handler)
}
/**
* Subscribe to an event once (auto-unsubscribe after first call)
*/
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)
}
/**
* Unsubscribe from an event
*/
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
*/
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,23 +3,14 @@
* WebRTC peer signaling client
*/
export { EventBus } from './event-bus.js'
export { Rondevu } from './rondevu.js'
export { RondevuAPI } from './api.js'
export { RondevuService } from './rondevu-service.js'
export { RondevuSignaler } from './rondevu-signaler.js'
export { WebRTCContext } from './webrtc-context.js'
export { RTCDurableConnection } from './durable-connection'
export { ServiceHost } from './service-host.js'
export { ServiceClient } from './service-client.js'
export { createBin } from './bin.js'
// Export types
export type {
ConnectionInterface,
QueueMessageOptions,
Message,
ConnectionEvents,
Signaler,
Binnable,
} from './types.js'
export type {
@@ -32,13 +23,7 @@ export type {
IceCandidate,
} from './api.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'
export type { RondevuOptions, PublishServiceOptions } from './rondevu.js'
export type { PollingConfig } from './rondevu-signaler.js'

View File

@@ -1,175 +0,0 @@
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
offers: Array<{ 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',
* offers: [{ 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.setCredentials(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:${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, offers, 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,
offers,
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 username
*/
getUsername(): string {
return this.username
}
/**
* 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
}
}

View File

@@ -1,6 +1,5 @@
import { Signaler } from './types.js'
import { RondevuService } from './rondevu-service.js'
import { Binnable } from './bin.js'
import { Signaler, Binnable } from './types.js'
import { Rondevu } from './rondevu.js'
export interface PollingConfig {
initialInterval?: number // Default: 500ms
@@ -43,7 +42,7 @@ export interface PollingConfig {
*/
export class RondevuSignaler implements Signaler {
private offerId: string | null = null
private serviceUuid: string | null = null
private serviceFqn: string | null = null
private offerListeners: Array<(offer: RTCSessionDescriptionInit) => void> = []
private answerListeners: Array<(answer: RTCSessionDescriptionInit) => void> = []
private iceListeners: Array<(candidate: RTCIceCandidate) => void> = []
@@ -54,7 +53,7 @@ export class RondevuSignaler implements Signaler {
private pollingConfig: Required<PollingConfig>
constructor(
private readonly rondevu: RondevuService,
private readonly rondevu: Rondevu,
private readonly service: string,
private readonly host?: string,
pollingConfig?: PollingConfig
@@ -82,7 +81,6 @@ export class RondevuSignaler implements Signaler {
serviceFqn: this.service,
offers: [{ sdp: offer.sdp }],
ttl: 300000, // 5 minutes
isPublic: true,
})
// Get the first offer from the published service
@@ -91,7 +89,7 @@ export class RondevuSignaler implements Signaler {
}
this.offerId = publishedService.offers[0].offerId
this.serviceUuid = publishedService.uuid
this.serviceFqn = publishedService.serviceFqn
// Start polling for answer
this.startAnswerPolling()
@@ -109,12 +107,12 @@ export class RondevuSignaler implements Signaler {
throw new Error('Answer SDP is required')
}
if (!this.serviceUuid) {
throw new Error('No service UUID available. Must receive offer first.')
if (!this.serviceFqn || !this.offerId) {
throw new Error('No service FQN or offer ID available. Must receive offer first.')
}
// Send answer to the service
const result = await this.rondevu.getAPI().answerService(this.serviceUuid, answer.sdp)
const result = await this.rondevu.getAPI().postOfferAnswer(this.serviceFqn, this.offerId, answer.sdp)
this.offerId = result.offerId
// Start polling for ICE candidates
@@ -162,8 +160,8 @@ export class RondevuSignaler implements Signaler {
* Send an ICE candidate to the remote peer
*/
async addIceCandidate(candidate: RTCIceCandidate): Promise<void> {
if (!this.serviceUuid) {
console.warn('Cannot send ICE candidate: no service UUID')
if (!this.serviceFqn || !this.offerId) {
console.warn('Cannot send ICE candidate: no service FQN or offer ID')
return
}
@@ -175,15 +173,11 @@ export class RondevuSignaler implements Signaler {
}
try {
const result = await this.rondevu.getAPI().addServiceIceCandidates(
this.serviceUuid,
[candidateData],
this.offerId || undefined
await this.rondevu.getAPI().addOfferIceCandidates(
this.serviceFqn,
this.offerId,
[candidateData]
)
// Store offerId if we didn't have it yet
if (!this.offerId) {
this.offerId = result.offerId
}
} catch (err) {
console.error('Failed to send ICE candidate:', err)
}
@@ -216,33 +210,24 @@ export class RondevuSignaler implements Signaler {
this.isPolling = true
try {
// Search for services by username and service FQN
const services = await this.rondevu.getAPI().searchServices(this.host, this.service)
// Get service by FQN (service should include @username)
const serviceFqn = `${this.service}@${this.host}`
const serviceData = await this.rondevu.getAPI().getService(serviceFqn)
if (services.length === 0) {
console.warn(`No services found for ${this.host}/${this.service}`)
if (!serviceData) {
console.warn(`No service found for ${serviceFqn}`)
this.isPolling = false
return
}
// Get the first available service (already has full details from searchServices)
const service = services[0] as any
// Get the first available offer from the service
if (!service.offers || service.offers.length === 0) {
console.warn(`No offers available for service ${this.host}/${this.service}`)
this.isPolling = false
return
}
const firstOffer = service.offers[0]
this.offerId = firstOffer.offerId
this.serviceUuid = service.uuid
// Store service details
this.offerId = serviceData.offerId
this.serviceFqn = serviceData.serviceFqn
// Notify offer listeners
const offer: RTCSessionDescriptionInit = {
type: 'offer',
sdp: firstOffer.sdp,
sdp: serviceData.sdp,
}
this.offerListeners.forEach(listener => {
@@ -262,7 +247,7 @@ export class RondevuSignaler implements Signaler {
* Start polling for answer (offerer side) with exponential backoff
*/
private startAnswerPolling(): void {
if (this.answerPollingTimeout || !this.serviceUuid) {
if (this.answerPollingTimeout || !this.serviceFqn || !this.offerId) {
return
}
@@ -270,13 +255,13 @@ export class RondevuSignaler implements Signaler {
let retries = 0
const poll = async () => {
if (!this.serviceUuid) {
if (!this.serviceFqn || !this.offerId) {
this.stopAnswerPolling()
return
}
try {
const answer = await this.rondevu.getAPI().getServiceAnswer(this.serviceUuid)
const answer = await this.rondevu.getAPI().getOfferAnswer(this.serviceFqn, this.offerId)
if (answer && answer.sdp) {
// Store offerId if we didn't have it yet
@@ -354,14 +339,14 @@ export class RondevuSignaler implements Signaler {
* Start polling for ICE candidates with adaptive backoff
*/
private startIcePolling(): void {
if (this.icePollingTimeout || !this.serviceUuid) {
if (this.icePollingTimeout || !this.serviceFqn || !this.offerId) {
return
}
let interval = this.pollingConfig.initialInterval
const poll = async () => {
if (!this.serviceUuid) {
if (!this.serviceFqn || !this.offerId) {
this.stopIcePolling()
return
}
@@ -369,12 +354,7 @@ export class RondevuSignaler implements Signaler {
try {
const result = await this.rondevu
.getAPI()
.getServiceIceCandidates(this.serviceUuid, this.lastIceTimestamp, this.offerId || undefined)
// Store offerId if we didn't have it yet
if (!this.offerId) {
this.offerId = result.offerId
}
.getOfferIceCandidates(this.serviceFqn, this.offerId, this.lastIceTimestamp)
let foundCandidates = false

335
src/rondevu.ts Normal file
View File

@@ -0,0 +1,335 @@
import { RondevuAPI, Credentials, Keypair, Service, ServiceRequest, IceCandidate } from './api.js'
export interface RondevuOptions {
apiUrl: string
username: string
keypair?: Keypair
credentials?: Credentials
}
export interface PublishServiceOptions {
serviceFqn: string // Must include @username (e.g., "chat:1.0.0@alice")
offers: Array<{ sdp: string }>
ttl?: number
}
/**
* Rondevu - Complete WebRTC signaling client
*
* Provides a unified API for:
* - Username claiming with Ed25519 signatures
* - Service publishing with automatic signature generation
* - Service discovery (direct, random, paginated)
* - WebRTC signaling (offer/answer exchange, ICE relay)
* - Keypair management
*
* @example
* ```typescript
* // Initialize (generates keypair automatically)
* const rondevu = new Rondevu({
* apiUrl: 'https://signal.example.com',
* username: 'alice',
* })
*
* await rondevu.initialize()
*
* // Claim username (one time)
* await rondevu.claimUsername()
*
* // Publish a service
* const publishedService = await rondevu.publishService({
* serviceFqn: 'chat:1.0.0@alice',
* offers: [{ sdp: offerSdp }],
* ttl: 300000,
* })
*
* // Discover a service
* const service = await rondevu.getService('chat:1.0.0@bob')
*
* // Post answer
* await rondevu.postOfferAnswer(service.serviceFqn, service.offerId, answerSdp)
* ```
*/
export class Rondevu {
private readonly api: RondevuAPI
private readonly username: string
private keypair: Keypair | null = null
private usernameClaimed = false
constructor(options: RondevuOptions) {
this.username = options.username
this.keypair = options.keypair || null
this.api = new RondevuAPI(options.apiUrl, options.credentials)
console.log('[Rondevu] Constructor called:', {
username: this.username,
hasKeypair: !!this.keypair,
publicKey: this.keypair?.publicKey
})
}
// ============================================
// Initialization
// ============================================
/**
* Initialize the service - generates keypair if not provided
* Call this before using other methods
*/
async initialize(): Promise<void> {
console.log('[Rondevu] Initialize called, hasKeypair:', !!this.keypair)
if (!this.keypair) {
console.log('[Rondevu] Generating new keypair...')
this.keypair = await RondevuAPI.generateKeypair()
console.log('[Rondevu] Generated keypair, publicKey:', this.keypair.publicKey)
} else {
console.log('[Rondevu] Using existing keypair, publicKey:', this.keypair.publicKey)
}
// Register with API if no credentials provided
if (!this.api['credentials']) {
const credentials = await this.api.register()
this.api.setCredentials(credentials)
}
}
// ============================================
// Username Management
// ============================================
/**
* Claim the username with Ed25519 signature
* Should be called once before publishing services
*/
async claimUsername(): Promise<void> {
if (!this.keypair) {
throw new Error('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.publicKey === 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:${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
}
/**
* Check if username has been claimed (checks with server)
*/
async isUsernameClaimed(): Promise<boolean> {
if (!this.keypair) {
return false
}
try {
const check = await this.api.checkUsername(this.username)
// Debug logging
console.log('[Rondevu] Username check:', {
username: this.username,
available: check.available,
serverPublicKey: check.publicKey,
localPublicKey: this.keypair.publicKey,
match: check.publicKey === this.keypair.publicKey
})
// Username is claimed if it's not available and owned by our public key
const claimed = !check.available && check.publicKey === this.keypair.publicKey
// Update internal flag to match server state
this.usernameClaimed = claimed
return claimed
} catch (err) {
console.error('Failed to check username claim status:', err)
return false
}
}
// ============================================
// Service Publishing
// ============================================
/**
* Publish a service with automatic signature generation
*/
async publishService(options: PublishServiceOptions): Promise<Service> {
if (!this.keypair) {
throw new Error('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, offers, ttl } = 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 = {
serviceFqn, // Must include @username
offers,
signature,
message,
ttl,
}
// Publish to server
return await this.api.publishService(serviceRequest)
}
// ============================================
// Service Discovery
// ============================================
/**
* Get service by FQN (with username) - Direct lookup
* Example: chat:1.0.0@alice
*/
async getService(serviceFqn: string): Promise<{
serviceId: string
username: string
serviceFqn: string
offerId: string
sdp: string
createdAt: number
expiresAt: number
}> {
return await this.api.getService(serviceFqn)
}
/**
* Discover a random available service without knowing the username
* Example: chat:1.0.0 (without @username)
*/
async discoverService(serviceVersion: string): Promise<{
serviceId: string
username: string
serviceFqn: string
offerId: string
sdp: string
createdAt: number
expiresAt: number
}> {
return await this.api.discoverService(serviceVersion)
}
/**
* Discover multiple available services with pagination
* Example: chat:1.0.0 (without @username)
*/
async discoverServices(serviceVersion: string, limit: number = 10, offset: number = 0): Promise<{
services: Array<{
serviceId: string
username: string
serviceFqn: string
offerId: string
sdp: string
createdAt: number
expiresAt: number
}>
count: number
limit: number
offset: number
}> {
return await this.api.discoverServices(serviceVersion, limit, offset)
}
// ============================================
// WebRTC Signaling
// ============================================
/**
* Post answer SDP to specific offer
*/
async postOfferAnswer(serviceFqn: string, offerId: string, sdp: string): Promise<{
success: boolean
offerId: string
}> {
return await this.api.postOfferAnswer(serviceFqn, offerId, sdp)
}
/**
* Get answer SDP (offerer polls this)
*/
async getOfferAnswer(serviceFqn: string, offerId: string): Promise<{
sdp: string
offerId: string
answererId: string
answeredAt: number
} | null> {
return await this.api.getOfferAnswer(serviceFqn, offerId)
}
/**
* Add ICE candidates to specific offer
*/
async addOfferIceCandidates(serviceFqn: string, offerId: string, candidates: RTCIceCandidateInit[]): Promise<{
count: number
offerId: string
}> {
return await this.api.addOfferIceCandidates(serviceFqn, offerId, candidates)
}
/**
* Get ICE candidates for specific offer (with polling support)
*/
async getOfferIceCandidates(serviceFqn: string, offerId: string, since: number = 0): Promise<{
candidates: IceCandidate[]
offerId: string
}> {
return await this.api.getOfferIceCandidates(serviceFqn, offerId, since)
}
// ============================================
// Utility Methods
// ============================================
/**
* Get the current keypair (for backup/storage)
*/
getKeypair(): Keypair | null {
return this.keypair
}
/**
* Get the username
*/
getUsername(): string {
return this.username
}
/**
* Get the public key
*/
getPublicKey(): string | null {
return this.keypair?.publicKey || null
}
/**
* Access to underlying API for advanced operations
* @deprecated Use direct methods on Rondevu instance instead
*/
getAPI(): RondevuAPI {
return this.api
}
}

View File

@@ -1,203 +0,0 @@
import { RondevuService } from './rondevu-service.js'
import { RondevuSignaler } from './rondevu-signaler.js'
import { WebRTCContext } from './webrtc-context.js'
import { RTCDurableConnection } from './durable-connection.js'
import { EventBus } from './event-bus.js'
export interface ServiceClientOptions {
username: string // Host username
serviceFqn: string // e.g., 'chat.app@1.0.0'
rondevuService: RondevuService
autoReconnect?: boolean // Default: true
maxReconnectAttempts?: number // Default: 5
rtcConfiguration?: RTCConfiguration
}
export interface ServiceClientEvents {
connected: RTCDurableConnection
disconnected: void
reconnecting: { attempt: number; maxAttempts: number }
error: Error
}
/**
* ServiceClient - High-level wrapper for connecting to a WebRTC service
*
* Simplifies client connection by handling:
* - Service discovery
* - Offer/answer exchange
* - ICE candidate polling
* - Automatic reconnection
*
* @example
* ```typescript
* const client = new ServiceClient({
* username: 'host-user',
* serviceFqn: 'chat.app@1.0.0',
* rondevuService: myService
* })
*
* client.events.on('connected', conn => {
* conn.events.on('message', msg => console.log('Received:', msg))
* conn.sendMessage('Hello from client!')
* })
*
* await client.connect()
* ```
*/
export class ServiceClient {
events: EventBus<ServiceClientEvents>
private signaler: RondevuSignaler | null = null
private webrtcContext: WebRTCContext
private connection: RTCDurableConnection | null = null
private autoReconnect: boolean
private maxReconnectAttempts: number
private reconnectAttempts = 0
private isConnecting = false
constructor(private options: ServiceClientOptions) {
this.events = new EventBus<ServiceClientEvents>()
this.webrtcContext = new WebRTCContext(options.rtcConfiguration)
this.autoReconnect = options.autoReconnect !== undefined ? options.autoReconnect : true
this.maxReconnectAttempts = options.maxReconnectAttempts || 5
}
/**
* Connect to the service
*/
async connect(): Promise<RTCDurableConnection> {
if (this.isConnecting) {
throw new Error('Connection already in progress')
}
if (this.connection) {
throw new Error('Already connected. Disconnect first.')
}
this.isConnecting = true
try {
// Create signaler
this.signaler = new RondevuSignaler(
this.options.rondevuService,
this.options.serviceFqn,
this.options.username
)
// Wait for remote offer from signaler
const remoteOffer = await new Promise<RTCSessionDescriptionInit>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Service discovery timeout'))
}, 30000)
this.signaler!.addOfferListener((offer) => {
clearTimeout(timeout)
resolve(offer)
})
})
// Create connection with remote offer (makes us the answerer)
const connection = new RTCDurableConnection({
context: this.webrtcContext,
signaler: this.signaler,
offer: remoteOffer
})
// Wait for connection to be ready
await connection.ready
// Set up connection event listeners
connection.events.on('state-change', (state) => {
if (state === 'connected') {
this.reconnectAttempts = 0
this.events.emit('connected', connection)
} else if (state === 'disconnected') {
this.events.emit('disconnected', undefined)
if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
this.attemptReconnect()
}
}
})
this.connection = connection
this.isConnecting = false
return connection
} catch (err) {
this.isConnecting = false
const error = err instanceof Error ? err : new Error(String(err))
this.events.emit('error', error)
throw error
}
}
/**
* Disconnect from the service
*/
dispose(): void {
if (this.signaler) {
this.signaler.dispose()
this.signaler = null
}
if (this.connection) {
this.connection.disconnect()
this.connection = null
}
this.isConnecting = false
this.reconnectAttempts = 0
}
/**
* @deprecated Use dispose() instead
*/
disconnect(): void {
this.dispose()
}
/**
* Attempt to reconnect
*/
private async attemptReconnect(): Promise<void> {
this.reconnectAttempts++
this.events.emit('reconnecting', {
attempt: this.reconnectAttempts,
maxAttempts: this.maxReconnectAttempts
})
// Cleanup old connection
if (this.signaler) {
this.signaler.dispose()
this.signaler = null
}
if (this.connection) {
this.connection = null
}
// Wait a bit before reconnecting
await new Promise(resolve => setTimeout(resolve, 1000 * this.reconnectAttempts))
try {
await this.connect()
} catch (err) {
console.error('Reconnection attempt failed:', err)
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.attemptReconnect()
} else {
const error = new Error('Max reconnection attempts reached')
this.events.emit('error', error)
}
}
}
/**
* Get the current connection
*/
getConnection(): RTCDurableConnection | null {
return this.connection
}
}

View File

@@ -1,158 +0,0 @@
import { RondevuService } from './rondevu-service.js'
import { RondevuSignaler } from './rondevu-signaler.js'
import { WebRTCContext } from './webrtc-context.js'
import { RTCDurableConnection } from './durable-connection.js'
import { EventBus } from './event-bus.js'
export interface ServiceHostOptions {
service: string // e.g., 'chat.app@1.0.0'
rondevuService: RondevuService
maxPeers?: number // Default: 5
ttl?: number // Default: 300000 (5 min)
isPublic?: boolean // Default: true
rtcConfiguration?: RTCConfiguration
metadata?: Record<string, any>
}
export interface ServiceHostEvents {
connection: RTCDurableConnection
error: Error
}
/**
* ServiceHost - High-level wrapper for hosting a WebRTC service
*
* Simplifies hosting by handling:
* - Offer/answer exchange
* - ICE candidate polling
* - Connection pool management
* - Automatic reconnection
*
* @example
* ```typescript
* const host = new ServiceHost({
* service: 'chat.app@1.0.0',
* rondevuService: myService,
* maxPeers: 5
* })
*
* host.events.on('connection', conn => {
* conn.events.on('message', msg => console.log('Received:', msg))
* conn.sendMessage('Hello!')
* })
*
* await host.start()
* ```
*/
export class ServiceHost {
events: EventBus<ServiceHostEvents>
private signaler: RondevuSignaler | null = null
private webrtcContext: WebRTCContext
private connections: RTCDurableConnection[] = []
private maxPeers: number
private running = false
constructor(private options: ServiceHostOptions) {
this.events = new EventBus<ServiceHostEvents>()
this.webrtcContext = new WebRTCContext(options.rtcConfiguration)
this.maxPeers = options.maxPeers || 5
}
/**
* Start hosting the service
*/
async start(): Promise<void> {
if (this.running) {
throw new Error('ServiceHost already running')
}
this.running = true
// Create signaler
this.signaler = new RondevuSignaler(
this.options.rondevuService,
this.options.service
)
// Create first connection (offerer)
const connection = new RTCDurableConnection({
context: this.webrtcContext,
signaler: this.signaler,
offer: null // null means we're the offerer
})
// Wait for connection to be ready
await connection.ready
// Set up connection event listeners
connection.events.on('state-change', (state) => {
if (state === 'connected') {
this.connections.push(connection)
this.events.emit('connection', connection)
// Create next connection if under maxPeers
if (this.connections.length < this.maxPeers) {
this.createNextConnection().catch(err => {
console.error('Failed to create next connection:', err)
this.events.emit('error', err)
})
}
} else if (state === 'disconnected') {
// Remove from connections list
const index = this.connections.indexOf(connection)
if (index > -1) {
this.connections.splice(index, 1)
}
}
})
// Publish service with the offer
const offer = connection.connection?.localDescription
if (!offer?.sdp) {
throw new Error('Offer SDP is empty')
}
await this.signaler.setOffer(offer)
}
/**
* Create the next connection for incoming peers
*/
private async createNextConnection(): Promise<void> {
if (!this.signaler || !this.running) {
return
}
// For now, we'll use the same offer for all connections
// In a production scenario, you'd create multiple offers
// This is a limitation of the current service model
// which publishes one offer per service
}
/**
* Stop hosting the service
*/
dispose(): void {
this.running = false
// Cleanup signaler
if (this.signaler) {
this.signaler.dispose()
this.signaler = null
}
// Disconnect all connections
for (const conn of this.connections) {
conn.disconnect()
}
this.connections = []
}
/**
* Get all active connections
*/
getConnections(): RTCDurableConnection[] {
return [...this.connections]
}
}

View File

@@ -1,39 +1,15 @@
/**
* Core connection types
* Core signaling types
*/
import { EventBus } from './event-bus.js'
import { Binnable } from './bin.js'
export type Message = string | ArrayBuffer
export interface QueueMessageOptions {
expiresAt?: number
}
export interface ConnectionEvents {
'state-change': ConnectionInterface['state']
message: Message
}
export const ConnectionStates = [
'connected',
'disconnected',
'connecting'
] as const
export const isConnectionState = (state: string): state is (typeof ConnectionStates)[number] =>
ConnectionStates.includes(state as any)
export interface ConnectionInterface {
state: (typeof ConnectionStates)[number]
lastActive: number
expiresAt?: number
events: EventBus<ConnectionEvents>
queueMessage(message: Message, options?: QueueMessageOptions): Promise<void>
sendMessage(message: Message): Promise<boolean>
}
/**
* Cleanup function returned by listener methods
*/
export type Binnable = () => void
/**
* Signaler interface for WebRTC offer/answer/ICE exchange
*/
export interface Signaler {
addIceCandidate(candidate: RTCIceCandidate): Promise<void>
addListener(callback: (candidate: RTCIceCandidate) => void): Binnable

View File

@@ -1,39 +0,0 @@
import { Signaler } from './types'
const DEFAULT_RTC_CONFIGURATION: RTCConfiguration = {
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',
},
],
}
export class WebRTCContext {
constructor(
private readonly config?: RTCConfiguration
) {}
createPeerConnection(): RTCPeerConnection {
return new RTCPeerConnection(this.config || DEFAULT_RTC_CONFIGURATION)
}
}