mirror of
https://github.com/xtr-dev/rondevu-client.git
synced 2025-12-14 21:03:23 +00:00
docs: Update README with semver and privacy features
This commit is contained in:
@@ -71,15 +71,11 @@ export class WebRTCRondevuConnection implements ConnectionInterface {
|
||||
public readonly ready: Promise<void>
|
||||
private iceBin = createBin()
|
||||
private ctx: WebRTCContext
|
||||
public id: string
|
||||
public service: string
|
||||
private _conn: RTCPeerConnection | null = null
|
||||
private _state: ConnectionInterface['state'] = 'disconnected'
|
||||
|
||||
constructor({ context: ctx, offer, id, service }: WebRTCRondevuConnectionOptions) {
|
||||
constructor({ context: ctx, offer }: WebRTCRondevuConnectionOptions) {
|
||||
this.ctx = ctx
|
||||
this.id = id
|
||||
this.service = service
|
||||
this._conn = ctx.createPeerConnection()
|
||||
this.side = offer ? 'answer' : 'offer'
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
0
src/rondevu-context.ts
Normal file
0
src/rondevu-context.ts
Normal file
0
src/rondevu-signaler.ts
Normal file
0
src/rondevu-signaler.ts
Normal file
@@ -1,247 +0,0 @@
|
||||
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
|
||||
rtcConfiguration?: RTCConfiguration
|
||||
}
|
||||
|
||||
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 readonly rtcConfiguration?: RTCConfiguration
|
||||
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
|
||||
this.rtcConfiguration = options.rtcConfiguration
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, this.rtcConfiguration)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
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>
|
||||
rtcConfiguration?: RTCConfiguration
|
||||
}
|
||||
|
||||
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 rtcConfiguration?: RTCConfiguration
|
||||
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
|
||||
this.rtcConfiguration = options.rtcConfiguration
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(), this.rtcConfiguration)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user