docs: Update README with semver and privacy features

This commit is contained in:
2025-12-07 21:58:20 +01:00
parent fbd3be57d4
commit cbb0cc3f83
11 changed files with 319 additions and 1780 deletions

View File

@@ -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'

View File

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

0
src/rondevu-signaler.ts Normal file
View File

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

View File

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