diff --git a/README.md b/README.md index b056e84..6e22542 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,13 @@ TypeScript/JavaScript client for Rondevu, providing WebRTC signaling with **auto ## Features -### ✨ New in v0.18.11 +### ✨ New in v0.19.0 - **🔄 Automatic Reconnection**: Built-in exponential backoff for failed connections - **📦 Message Buffering**: Queues messages during disconnections, replays on reconnect - **📊 Connection State Machine**: Explicit lifecycle tracking with native RTC events - **🎯 Rich Event System**: 20+ events for monitoring connection health - **⚡ Improved Reliability**: ICE polling lifecycle management, proper cleanup +- **🏗️ Internal Refactoring**: Cleaner codebase with OfferPool extraction and consolidated ICE polling ### Core Features - **Username Claiming**: Secure ownership with Ed25519 signatures @@ -366,7 +367,15 @@ const connection = await rondevu.connectToService({ ## Changelog -### v0.18.11 (Latest) +### v0.19.0 (Latest) +- **Internal Refactoring** - Improved codebase maintainability (no API changes) +- Extract OfferPool class for offer lifecycle management +- Consolidate ICE polling logic (remove ~86 lines of duplicate code) +- Add AsyncLock utility for race-free concurrent operations +- Disable reconnection for offerer connections (offers are ephemeral) +- 100% backward compatible - upgrade without code changes + +### v0.18.11 - Restore EventEmitter-based durable connections (same as v0.18.9) - Durable WebRTC connections with state machine - Automatic reconnection with exponential backoff diff --git a/package.json b/package.json index d3c3e00..30be1b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xtr-dev/rondevu-client", - "version": "0.18.11", + "version": "0.19.0", "description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing", "type": "module", "main": "dist/index.js", diff --git a/src/answerer-connection.ts b/src/answerer-connection.ts index 1aac8f1..ab6b926 100644 --- a/src/answerer-connection.ts +++ b/src/answerer-connection.ts @@ -96,39 +96,24 @@ export class AnswererConnection extends RondevuConnection { } /** - * Poll for remote ICE candidates (from offerer) + * Get the API instance */ - protected pollIceCandidates(): void { - this.api - .getOfferIceCandidates(this.serviceFqn, this.offerId, this.lastIcePollTime) - .then((result) => { - if (result.candidates.length > 0) { - this.debug(`Received ${result.candidates.length} remote ICE candidates`) + protected getApi(): any { + return this.api + } - for (const iceCandidate of result.candidates) { - // Only process ICE candidates from the offerer - if (iceCandidate.role === 'offerer' && iceCandidate.candidate && this.pc) { - const candidate = iceCandidate.candidate - this.pc - .addIceCandidate(new RTCIceCandidate(candidate)) - .then(() => { - this.emit('ice:candidate:remote', new RTCIceCandidate(candidate)) - }) - .catch((error) => { - this.debug('Failed to add ICE candidate:', error) - }) - } + /** + * Get the service FQN + */ + protected getServiceFqn(): string { + return this.serviceFqn + } - // Update last poll time - if (iceCandidate.createdAt > this.lastIcePollTime) { - this.lastIcePollTime = iceCandidate.createdAt - } - } - } - }) - .catch((error) => { - this.debug('Failed to poll ICE candidates:', error) - }) + /** + * Answerers accept ICE candidates from offerers only + */ + protected getIceCandidateRole(): 'offerer' | null { + return 'offerer' } /** diff --git a/src/async-lock.ts b/src/async-lock.ts new file mode 100644 index 0000000..b56a3ae --- /dev/null +++ b/src/async-lock.ts @@ -0,0 +1,77 @@ +/** + * AsyncLock provides a mutual exclusion primitive for asynchronous operations. + * Ensures only one async operation can proceed at a time while queuing others. + */ +export class AsyncLock { + private locked = false + private queue: Array<() => void> = [] + + /** + * Acquire the lock. If already locked, waits until released. + * @returns Promise that resolves when lock is acquired + */ + async acquire(): Promise { + if (!this.locked) { + this.locked = true + return + } + + // Lock is held, wait in queue + return new Promise(resolve => { + this.queue.push(resolve) + }) + } + + /** + * Release the lock. If others are waiting, grants lock to next in queue. + */ + release(): void { + const next = this.queue.shift() + if (next) { + // Grant lock to next waiter + next() + } else { + // No waiters, mark as unlocked + this.locked = false + } + } + + /** + * Run a function with the lock acquired, automatically releasing after. + * This is the recommended way to use AsyncLock to prevent forgetting to release. + * + * @param fn - Async function to run with lock held + * @returns Promise resolving to the function's return value + * + * @example + * ```typescript + * const lock = new AsyncLock() + * const result = await lock.run(async () => { + * // Critical section - only one caller at a time + * return await doSomething() + * }) + * ``` + */ + async run(fn: () => Promise): Promise { + await this.acquire() + try { + return await fn() + } finally { + this.release() + } + } + + /** + * Check if lock is currently held + */ + isLocked(): boolean { + return this.locked + } + + /** + * Get number of operations waiting for the lock + */ + getQueueLength(): number { + return this.queue.length + } +} diff --git a/src/connection.ts b/src/connection.ts index cf940f9..c6972de 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -329,6 +329,73 @@ export abstract class RondevuConnection extends EventEmitter this.emit('ice:polling:stopped') } + /** + * Get the API instance - subclasses must provide + */ + protected abstract getApi(): any + + /** + * Get the service FQN - subclasses must provide + */ + protected abstract getServiceFqn(): string + + /** + * Get the offer ID - subclasses must provide + */ + protected abstract getOfferId(): string + + /** + * Get the ICE candidate role this connection should accept. + * Returns null for no filtering (offerer), or specific role (answerer accepts 'offerer'). + */ + protected abstract getIceCandidateRole(): 'offerer' | null + + /** + * Poll for remote ICE candidates (consolidated implementation) + * Subclasses implement getIceCandidateRole() to specify filtering + */ + protected pollIceCandidates(): void { + const acceptRole = this.getIceCandidateRole() + const api = this.getApi() + const serviceFqn = this.getServiceFqn() + const offerId = this.getOfferId() + + api + .getOfferIceCandidates(serviceFqn, offerId, this.lastIcePollTime) + .then((result: any) => { + if (result.candidates.length > 0) { + this.debug(`Received ${result.candidates.length} remote ICE candidates`) + + for (const iceCandidate of result.candidates) { + // Filter by role if specified (answerer only filters for 'offerer') + if (acceptRole !== null && iceCandidate.role !== acceptRole) { + continue + } + + if (iceCandidate.candidate && this.pc) { + const candidate = iceCandidate.candidate + this.pc + .addIceCandidate(new RTCIceCandidate(candidate)) + .then(() => { + this.emit('ice:candidate:remote', new RTCIceCandidate(candidate)) + }) + .catch((error) => { + this.debug('Failed to add ICE candidate:', error) + }) + } + + // Update last poll time + if (iceCandidate.createdAt > this.lastIcePollTime) { + this.lastIcePollTime = iceCandidate.createdAt + } + } + } + }) + .catch((error: any) => { + this.debug('Failed to poll ICE candidates:', error) + }) + } + /** * Start connection timeout */ @@ -562,6 +629,5 @@ export abstract class RondevuConnection extends EventEmitter // Abstract methods to be implemented by subclasses protected abstract onLocalIceCandidate(candidate: RTCIceCandidate): void - protected abstract pollIceCandidates(): void protected abstract attemptReconnect(): void } diff --git a/src/offer-pool.ts b/src/offer-pool.ts new file mode 100644 index 0000000..66764c0 --- /dev/null +++ b/src/offer-pool.ts @@ -0,0 +1,281 @@ +import { EventEmitter } from 'eventemitter3' +import { RondevuAPI } from './api.js' +import { OffererConnection } from './offerer-connection.js' +import { ConnectionConfig } from './connection-config.js' +import { AsyncLock } from './async-lock.js' + +export type OfferFactory = (pc: RTCPeerConnection) => Promise<{ + dc?: RTCDataChannel + offer: RTCSessionDescriptionInit +}> + +export interface OfferPoolOptions { + api: RondevuAPI + serviceFqn: string + maxOffers: number + offerFactory: OfferFactory + ttl: number + iceServers: RTCIceServer[] + connectionConfig?: Partial + debugEnabled?: boolean +} + +interface OfferPoolEvents { + 'connection:opened': (offerId: string, connection: OffererConnection) => void + 'offer:created': (offerId: string, serviceFqn: string) => void + 'offer:failed': (offerId: string, error: Error) => void +} + +/** + * OfferPool manages a pool of WebRTC offers for a published service. + * Maintains a target number of active offers and automatically replaces + * offers that fail or get answered. + */ +export class OfferPool extends EventEmitter { + private readonly api: RondevuAPI + private readonly serviceFqn: string + private readonly maxOffers: number + private readonly offerFactory: OfferFactory + private readonly ttl: number + private readonly iceServers: RTCIceServer[] + private readonly connectionConfig?: Partial + private readonly debugEnabled: boolean + + // State + private readonly activeConnections = new Map() + private readonly fillLock = new AsyncLock() + private running = false + private pollingInterval: ReturnType | null = null + private lastPollTimestamp = 0 + + private static readonly POLLING_INTERVAL_MS = 1000 + + constructor(options: OfferPoolOptions) { + super() + this.api = options.api + this.serviceFqn = options.serviceFqn + this.maxOffers = options.maxOffers + this.offerFactory = options.offerFactory + this.ttl = options.ttl + this.iceServers = options.iceServers + this.connectionConfig = options.connectionConfig + this.debugEnabled = options.debugEnabled || false + } + + /** + * Start filling offers and polling for answers + */ + async start(): Promise { + if (this.running) { + this.debug('Already running') + return + } + + this.debug('Starting offer pool') + this.running = true + + // Fill initial offers + await this.fillOffers() + + // Start polling for answers + this.pollingInterval = setInterval(() => { + this.pollInternal() + }, OfferPool.POLLING_INTERVAL_MS) + } + + /** + * Stop filling offers and polling + * Closes all active connections + */ + stop(): void { + this.debug('Stopping offer pool') + this.running = false + + // Stop polling + if (this.pollingInterval) { + clearInterval(this.pollingInterval) + this.pollingInterval = null + } + + // Close all active connections + for (const [offerId, connection] of this.activeConnections.entries()) { + this.debug(`Closing connection ${offerId}`) + connection.close() + } + + this.activeConnections.clear() + } + + /** + * Get count of active offers + */ + getOfferCount(): number { + return this.activeConnections.size + } + + /** + * Get all active connections + */ + getActiveConnections(): Map { + return this.activeConnections + } + + /** + * Check if a specific offer is connected + */ + isConnected(offerId: string): boolean { + const connection = this.activeConnections.get(offerId) + return connection ? connection.getState() === 'connected' : false + } + + /** + * Disconnect all active offers + */ + disconnectAll(): void { + this.debug('Disconnecting all offers') + for (const [offerId, connection] of this.activeConnections.entries()) { + this.debug(`Closing connection ${offerId}`) + connection.close() + } + this.activeConnections.clear() + } + + /** + * Fill offers to reach maxOffers count + * Uses AsyncLock to prevent concurrent fills + */ + private async fillOffers(): Promise { + if (!this.running) return + + return this.fillLock.run(async () => { + const currentCount = this.activeConnections.size + const needed = this.maxOffers - currentCount + + this.debug(`Filling offers: current=${currentCount}, needed=${needed}`) + + for (let i = 0; i < needed; i++) { + try { + await this.createOffer() + } catch (err) { + console.error('[OfferPool] Failed to create offer:', err) + } + } + }) + } + + /** + * Create a single offer and publish it to the server + */ + private async createOffer(): Promise { + const rtcConfig: RTCConfiguration = { + iceServers: this.iceServers + } + + this.debug('Creating new offer...') + + // 1. Create RTCPeerConnection + const pc = new RTCPeerConnection(rtcConfig) + + // 2. Call the factory to create offer + let dc: RTCDataChannel | undefined + let offer: RTCSessionDescriptionInit + try { + const factoryResult = await this.offerFactory(pc) + dc = factoryResult.dc + offer = factoryResult.offer + } catch (err) { + pc.close() + throw err + } + + // 3. Publish to server to get offerId + const result = await this.api.publishService({ + serviceFqn: this.serviceFqn, + offers: [{ sdp: offer.sdp! }], + ttl: this.ttl, + signature: '', + message: '', + }) + + const offerId = result.offers[0].offerId + + // 4. Create OffererConnection instance + const connection = new OffererConnection({ + api: this.api, + serviceFqn: this.serviceFqn, + offerId, + pc, + dc, + config: { + ...this.connectionConfig, + debug: this.debugEnabled, + }, + }) + + // Setup connection event handlers + connection.on('connected', () => { + this.debug(`Connection established for offer ${offerId}`) + this.emit('connection:opened', offerId, connection) + }) + + connection.on('failed', (error) => { + this.debug(`Connection failed for offer ${offerId}:`, error) + this.activeConnections.delete(offerId) + this.emit('offer:failed', offerId, error) + this.fillOffers() // Replace failed offer + }) + + connection.on('closed', () => { + this.debug(`Connection closed for offer ${offerId}`) + this.activeConnections.delete(offerId) + this.fillOffers() // Replace closed offer + }) + + // Store active connection + this.activeConnections.set(offerId, connection) + + // Initialize the connection + await connection.initialize() + + this.debug(`Offer created: ${offerId}`) + this.emit('offer:created', offerId, this.serviceFqn) + } + + /** + * Poll for answers and delegate to OffererConnections + */ + private async pollInternal(): Promise { + if (!this.running) return + + try { + const result = await this.api.poll(this.lastPollTimestamp) + + // Process answers - delegate to OffererConnections + for (const answer of result.answers) { + const connection = this.activeConnections.get(answer.offerId) + if (connection) { + try { + await connection.processAnswer(answer.sdp, answer.answererId) + this.lastPollTimestamp = Math.max(this.lastPollTimestamp, answer.answeredAt) + + // Create replacement offer + this.fillOffers() + } catch (err) { + this.debug(`Failed to process answer for offer ${answer.offerId}:`, err) + } + } + } + } catch (err) { + console.error('[OfferPool] Polling error:', err) + } + } + + /** + * Debug logging (only if debug enabled) + */ + private debug(...args: unknown[]): void { + if (this.debugEnabled) { + console.log('[OfferPool]', ...args) + } + } +} diff --git a/src/offerer-connection.ts b/src/offerer-connection.ts index 889cc86..1813c27 100644 --- a/src/offerer-connection.ts +++ b/src/offerer-connection.ts @@ -25,7 +25,11 @@ export class OffererConnection extends RondevuConnection { private offerId: string constructor(options: OffererOptions) { - super(undefined, options.config) // rtcConfig not needed, PC already created + // Force reconnectEnabled: false for offerer connections (offers are ephemeral) + super(undefined, { + ...options.config, + reconnectEnabled: false + }) this.api = options.api this.serviceFqn = options.serviceFqn this.offerId = options.offerId @@ -155,38 +159,24 @@ export class OffererConnection extends RondevuConnection { } /** - * Poll for remote ICE candidates + * Get the API instance */ - protected pollIceCandidates(): void { - this.api - .getOfferIceCandidates(this.serviceFqn, this.offerId, this.lastIcePollTime) - .then((result) => { - if (result.candidates.length > 0) { - this.debug(`Received ${result.candidates.length} remote ICE candidates`) + protected getApi(): any { + return this.api + } - for (const iceCandidate of result.candidates) { - if (iceCandidate.candidate && this.pc) { - const candidate = iceCandidate.candidate - this.pc - .addIceCandidate(new RTCIceCandidate(candidate)) - .then(() => { - this.emit('ice:candidate:remote', new RTCIceCandidate(candidate)) - }) - .catch((error) => { - this.debug('Failed to add ICE candidate:', error) - }) - } + /** + * Get the service FQN + */ + protected getServiceFqn(): string { + return this.serviceFqn + } - // Update last poll time - if (iceCandidate.createdAt > this.lastIcePollTime) { - this.lastIcePollTime = iceCandidate.createdAt - } - } - } - }) - .catch((error) => { - this.debug('Failed to poll ICE candidates:', error) - }) + /** + * Offerers accept all ICE candidates (no filtering) + */ + protected getIceCandidateRole(): 'offerer' | null { + return null } /** diff --git a/src/rondevu.ts b/src/rondevu.ts index 46cea28..51429bd 100644 --- a/src/rondevu.ts +++ b/src/rondevu.ts @@ -4,6 +4,7 @@ import { EventEmitter } from 'eventemitter3' import { OffererConnection } from './offerer-connection.js' import { AnswererConnection } from './answerer-connection.js' import { ConnectionConfig } from './connection-config.js' +import { OfferPool } from './offer-pool.js' // ICE server preset names export type IceServerPreset = 'ipv4-turn' | 'hostname-turns' | 'google-stun' | 'relay-only' @@ -253,17 +254,8 @@ export class Rondevu extends EventEmitter { // Service management private currentService: string | null = null - private maxOffers = 0 - private offerFactory: OfferFactory | null = null - private ttl = Rondevu.DEFAULT_TTL_MS - private activeConnections = new Map() private connectionConfig?: Partial - - // Polling - private filling = false - private fillingSemaphore = false // Semaphore to prevent concurrent fillOffers calls - private pollingInterval: ReturnType | null = null - private lastPollTimestamp = 0 + private offerPool: OfferPool | null = null private constructor( apiUrl: string, @@ -450,157 +442,35 @@ export class Rondevu extends EventEmitter { const { service, maxOffers, offerFactory, ttl, connectionConfig } = options this.currentService = service - this.maxOffers = maxOffers - this.offerFactory = offerFactory || this.defaultOfferFactory.bind(this) - this.ttl = ttl || Rondevu.DEFAULT_TTL_MS this.connectionConfig = connectionConfig - this.debug(`Publishing service: ${service} with maxOffers: ${maxOffers}`) - this.usernameClaimed = true - } - - /** - * Create a single offer and publish it to the server using OffererConnection - */ - private async createOffer(): Promise { - if (!this.currentService || !this.offerFactory) { - throw new Error('Service not published. Call publishService() first.') - } - - const rtcConfig: RTCConfiguration = { - iceServers: this.iceServers - } - // Auto-append username to service - const serviceFqn = `${this.currentService}@${this.username}` + const serviceFqn = `${service}@${this.username}` - this.debug('Creating new offer...') + this.debug(`Publishing service: ${service} with maxOffers: ${maxOffers}`) - // 1. Create RTCPeerConnection using factory (for now, keep compatibility) - const pc = new RTCPeerConnection(rtcConfig) - - // 2. Call the factory to create offer - let dc: RTCDataChannel | undefined - let offer: RTCSessionDescriptionInit - try { - const factoryResult = await this.offerFactory(pc) - dc = factoryResult.dc - offer = factoryResult.offer - } catch (err) { - pc.close() - throw err - } - - // 3. Publish to server to get offerId - const result = await this.api.publishService({ - serviceFqn, - offers: [{ sdp: offer.sdp! }], - ttl: this.ttl, - signature: '', - message: '', - }) - - const offerId = result.offers[0].offerId - - // 4. Create OffererConnection instance with already-created PC and DC - const connection = new OffererConnection({ + // Create OfferPool (but don't start it yet - call startFilling() to begin) + this.offerPool = new OfferPool({ api: this.api, serviceFqn, - offerId, - pc, // Pass the peer connection from factory - dc, // Pass the data channel from factory - config: { - ...this.connectionConfig, - debug: this.debugEnabled, - }, + maxOffers, + offerFactory: offerFactory || this.defaultOfferFactory.bind(this), + ttl: ttl || Rondevu.DEFAULT_TTL_MS, + iceServers: this.iceServers, + connectionConfig, + debugEnabled: this.debugEnabled, }) - // Setup connection event handlers - connection.on('connected', () => { - this.debug(`Connection established for offer ${offerId}`) + // Forward events from OfferPool + this.offerPool.on('connection:opened', (offerId, connection) => { this.emit('connection:opened', offerId, connection) }) - connection.on('failed', (error) => { - this.debug(`Connection failed for offer ${offerId}:`, error) - this.activeConnections.delete(offerId) - this.fillOffers() // Replace failed offer + this.offerPool.on('offer:created', (offerId, serviceFqn) => { + this.emit('offer:created', offerId, serviceFqn) }) - connection.on('closed', () => { - this.debug(`Connection closed for offer ${offerId}`) - this.activeConnections.delete(offerId) - this.fillOffers() // Replace closed offer - }) - - // Store active connection - this.activeConnections.set(offerId, connection) - - // Initialize the connection - await connection.initialize() - - this.debug(`Offer created: ${offerId}`) - this.emit('offer:created', offerId, serviceFqn) - } - - /** - * Fill offers to reach maxOffers count with semaphore protection - */ - private async fillOffers(): Promise { - if (!this.filling || !this.currentService) return - - // Semaphore to prevent concurrent fills - if (this.fillingSemaphore) { - this.debug('fillOffers already in progress, skipping') - return - } - - this.fillingSemaphore = true - try { - const currentCount = this.activeConnections.size - const needed = this.maxOffers - currentCount - - this.debug(`Filling offers: current=${currentCount}, needed=${needed}`) - - for (let i = 0; i < needed; i++) { - try { - await this.createOffer() - } catch (err) { - console.error('[Rondevu] Failed to create offer:', err) - } - } - } finally { - this.fillingSemaphore = false - } - } - - /** - * Poll for answers and ICE candidates (internal use for automatic offer management) - */ - private async pollInternal(): Promise { - if (!this.filling) return - - try { - const result = await this.api.poll(this.lastPollTimestamp) - - // Process answers - delegate to OffererConnections - for (const answer of result.answers) { - const connection = this.activeConnections.get(answer.offerId) - if (connection) { - try { - await connection.processAnswer(answer.sdp, answer.answererId) - this.lastPollTimestamp = Math.max(this.lastPollTimestamp, answer.answeredAt) - - // Create replacement offer - this.fillOffers() - } catch (err) { - this.debug(`Failed to process answer for offer ${answer.offerId}:`, err) - } - } - } - } catch (err) { - console.error('[Rondevu] Polling error:', err) - } + this.usernameClaimed = true } /** @@ -608,25 +478,12 @@ export class Rondevu extends EventEmitter { * Call this after publishService() to begin accepting connections */ async startFilling(): Promise { - if (this.filling) { - this.debug('Already filling') - return - } - - if (!this.currentService) { + if (!this.offerPool) { throw new Error('No service published. Call publishService() first.') } this.debug('Starting offer filling and polling') - this.filling = true - - // Fill initial offers - await this.fillOffers() - - // Start polling - this.pollingInterval = setInterval(() => { - this.pollInternal() - }, Rondevu.POLLING_INTERVAL_MS) + await this.offerPool.start() } /** @@ -635,22 +492,7 @@ export class Rondevu extends EventEmitter { */ stopFilling(): void { this.debug('Stopping offer filling and polling') - this.filling = false - this.fillingSemaphore = false - - // Stop polling - if (this.pollingInterval) { - clearInterval(this.pollingInterval) - this.pollingInterval = null - } - - // Close all active connections - for (const [offerId, connection] of this.activeConnections.entries()) { - this.debug(`Closing connection ${offerId}`) - connection.close() - } - - this.activeConnections.clear() + this.offerPool?.stop() } /** @@ -658,7 +500,7 @@ export class Rondevu extends EventEmitter { * @returns Number of active offers */ getOfferCount(): number { - return this.activeConnections.size + return this.offerPool?.getOfferCount() ?? 0 } /** @@ -667,33 +509,26 @@ export class Rondevu extends EventEmitter { * @returns True if the offer exists and is connected */ isConnected(offerId: string): boolean { - const connection = this.activeConnections.get(offerId) - return connection ? connection.getState() === 'connected' : false + return this.offerPool?.isConnected(offerId) ?? false } /** * Disconnect all active offers * Similar to stopFilling() but doesn't stop the polling/filling process */ - async disconnectAll(): Promise { + disconnectAll(): void { this.debug('Disconnecting all offers') - for (const [offerId, connection] of this.activeConnections.entries()) { - this.debug(`Closing connection ${offerId}`) - connection.close() - } - this.activeConnections.clear() + this.offerPool?.disconnectAll() } /** * Get the current service status * @returns Object with service state information */ - getServiceStatus(): { active: boolean; offerCount: number; maxOffers: number; filling: boolean } { + getServiceStatus(): { active: boolean; offerCount: number } { return { active: this.currentService !== null, - offerCount: this.activeConnections.size, - maxOffers: this.maxOffers, - filling: this.filling + offerCount: this.offerPool?.getOfferCount() ?? 0 } } @@ -942,7 +777,7 @@ export class Rondevu extends EventEmitter { * Get active connections (for offerer side) */ getActiveConnections(): Map { - return this.activeConnections + return this.offerPool?.getActiveConnections() ?? new Map() } /** @@ -951,7 +786,8 @@ export class Rondevu extends EventEmitter { */ getActiveOffers(): ActiveOffer[] { const offers: ActiveOffer[] = [] - for (const [offerId, connection] of this.activeConnections.entries()) { + const connections = this.offerPool?.getActiveConnections() ?? new Map() + for (const [offerId, connection] of connections.entries()) { const pc = connection.getPeerConnection() const dc = connection.getDataChannel() if (pc) {