From 121a4d490a5c92d0122d84d3560d1d7d5775f55d Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Tue, 16 Dec 2025 22:36:28 +0100 Subject: [PATCH] v0.20.0: Connection persistence with offer rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement connection persistence for offerer side through "offer rotation". When a connection fails, the same OffererConnection object is rebound to a new offer instead of being destroyed, preserving message buffers and event listeners. Features: - Connection objects persist across disconnections - Message buffering works seamlessly through rotations - Event listeners remain active after rotation - New `connection:rotated` event for tracking offer changes - Max rotation attempts limit (default: 5) with fallback Implementation: - Add OffererConnection.rebindToOffer() method with AsyncLock protection - Add rotation tracking: rotating flag, rotationAttempts counter - Add OfferPool.createNewOfferForRotation() helper method - Modify OfferPool failure handler to rotate instead of destroy - Add connection:rotated event to OfferPoolEvents interface - Forward connection:rotated event in Rondevu class - Add edge case handling for cleanup during rotation - Reset rotation attempts on successful connection Documentation: - Add "Connection Persistence" section to README with examples - Update "New in v0.20.0" feature list - Add v0.20.0 changelog entry - Document rotation benefits and behavior Benefits: - Same connection object remains usable through disconnections - Message buffer preserved during temporary disconnections - Event listeners don't need to be re-registered - Simpler user code - no need to track new connections 100% backward compatible - no breaking changes. 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- README.md | 54 ++++++++++++++++++++++-- package.json | 2 +- src/offer-pool.ts | 80 ++++++++++++++++++++++++++++++++--- src/offerer-connection.ts | 88 +++++++++++++++++++++++++++++++++++++++ src/rondevu.ts | 4 ++ 5 files changed, 218 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6e22542..f6acbc5 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.19.0 +### ✨ New in v0.20.0 - **🔄 Automatic Reconnection**: Built-in exponential backoff for failed connections - **📦 Message Buffering**: Queues messages during disconnections, replays on reconnect +- **🔄 Connection Persistence**: OffererConnection objects persist across disconnections via offer rotation - **📊 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 +- **🎯 Rich Event System**: 20+ events for monitoring connection health including `connection:rotated` +- **⚡ Improved Reliability**: ICE polling lifecycle management, proper cleanup, rotation fallback - **🏗️ Internal Refactoring**: Cleaner codebase with OfferPool extraction and consolidated ICE polling ### Core Features @@ -361,13 +362,58 @@ const connection = await rondevu.connectToService({ - Advanced usage patterns - Username rules and service FQN format +## Connection Persistence (v0.20.0+) + +Connection objects now persist across disconnections via **"offer rotation"**. When a connection fails, the same connection object is rebound to a new offer instead of being destroyed: + +```typescript +rondevu.on('connection:opened', (offerId, connection) => { + console.log(`Connection ${offerId} opened`) + + // Listen for offer rotation + rondevu.on('connection:rotated', (oldOfferId, newOfferId, conn) => { + if (conn === connection) { + console.log(`Connection rotated: ${oldOfferId} → ${newOfferId}`) + // Same connection object! Event listeners still work + // Message buffer preserved + } + }) + + connection.on('message', (data) => { + console.log('Received:', data) + // This listener continues working even after rotation + }) + + connection.on('failed', () => { + console.log('Connection failed, will auto-rotate to new offer') + }) +}) +``` + +**Benefits:** +- ✅ Same connection object remains usable through disconnections +- ✅ Message buffer preserved during temporary disconnections +- ✅ Event listeners don't need to be re-registered +- ✅ Seamless reconnection experience for offerer side + ## Examples - [React Demo](https://github.com/xtr-dev/rondevu-demo) - Full browser UI ([live](https://ronde.vu)) ## Changelog -### v0.19.0 (Latest) +### v0.20.0 (Latest) +- **Connection Persistence** - OffererConnection objects now persist across disconnections +- **Offer Rotation** - When connection fails, same object is rebound to new offer +- **Message Buffering** - Now works seamlessly on offerer side through rotations +- **New Event**: `connection:rotated` emitted when offer is rotated +- **Internal**: Added `OffererConnection.rebindToOffer()` method +- **Internal**: Modified OfferPool failure handler to rotate offers instead of destroying connections +- **Internal**: Added rotation lock to prevent concurrent rotations +- **Internal**: Added max rotation attempts limit (default: 5) +- 100% backward compatible - no breaking changes + +### v0.19.0 - **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) diff --git a/package.json b/package.json index 30be1b3..8fda9e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xtr-dev/rondevu-client", - "version": "0.19.0", + "version": "0.20.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/offer-pool.ts b/src/offer-pool.ts index 66764c0..8f03a2e 100644 --- a/src/offer-pool.ts +++ b/src/offer-pool.ts @@ -24,6 +24,7 @@ interface OfferPoolEvents { 'connection:opened': (offerId: string, connection: OffererConnection) => void 'offer:created': (offerId: string, serviceFqn: string) => void 'offer:failed': (offerId: string, error: Error) => void + 'connection:rotated': (oldOfferId: string, newOfferId: string, connection: OffererConnection) => void } /** @@ -99,6 +100,9 @@ export class OfferPool extends EventEmitter { // Close all active connections for (const [offerId, connection] of this.activeConnections.entries()) { + if (connection.isRotating()) { + this.debug(`Connection ${offerId} is rotating, will close anyway`) + } this.debug(`Closing connection ${offerId}`) connection.close() } @@ -163,6 +167,52 @@ export class OfferPool extends EventEmitter { }) } + /** + * Create a new offer for rotation (reuses existing creation logic) + * Similar to createOffer() but only creates the offer, doesn't create connection + */ + private async createNewOfferForRotation(): Promise<{ + newOfferId: string + pc: RTCPeerConnection + dc?: RTCDataChannel + }> { + const rtcConfig: RTCConfiguration = { + iceServers: this.iceServers + } + + this.debug('Creating new offer for rotation...') + + // 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 newOfferId = result.offers[0].offerId + + this.debug(`New offer created for rotation: ${newOfferId}`) + + return { newOfferId, pc, dc } + } + /** * Create a single offer and publish it to the server */ @@ -218,11 +268,31 @@ export class OfferPool extends EventEmitter { 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('failed', async (error) => { + const currentOfferId = connection.getOfferId() + this.debug(`Connection failed for offer ${currentOfferId}, rotating...`) + + try { + // Create new offer and rebind existing connection + const { newOfferId, pc, dc } = await this.createNewOfferForRotation() + + // Rebind the connection to new offer + await connection.rebindToOffer(newOfferId, pc, dc) + + // Update map: remove old offerId, add new offerId with same connection + this.activeConnections.delete(currentOfferId) + this.activeConnections.set(newOfferId, connection) + + this.emit('connection:rotated', currentOfferId, newOfferId, connection) + this.debug(`Connection rotated: ${currentOfferId} → ${newOfferId}`) + + } catch (rotationError) { + // If rotation fails, fall back to destroying connection + this.debug(`Rotation failed for ${currentOfferId}:`, rotationError) + this.activeConnections.delete(currentOfferId) + this.emit('offer:failed', currentOfferId, error) + this.fillOffers() // Create replacement + } }) connection.on('closed', () => { diff --git a/src/offerer-connection.ts b/src/offerer-connection.ts index 1813c27..99cd9d8 100644 --- a/src/offerer-connection.ts +++ b/src/offerer-connection.ts @@ -6,6 +6,7 @@ import { RondevuConnection } from './connection.js' import { ConnectionState } from './connection-events.js' import { RondevuAPI } from './api.js' import { ConnectionConfig } from './connection-config.js' +import { AsyncLock } from './async-lock.js' export interface OffererOptions { api: RondevuAPI @@ -24,6 +25,12 @@ export class OffererConnection extends RondevuConnection { private serviceFqn: string private offerId: string + // Rotation tracking + private rotationLock = new AsyncLock() + private rotating = false + private rotationAttempts = 0 + private static readonly MAX_ROTATION_ATTEMPTS = 5 + constructor(options: OffererOptions) { // Force reconnectEnabled: false for offerer connections (offers are ephemeral) super(undefined, { @@ -115,6 +122,87 @@ export class OffererConnection extends RondevuConnection { } } + /** + * Rebind this connection to a new offer (when previous offer failed) + * Keeps the same connection object alive but with new underlying WebRTC + */ + async rebindToOffer( + newOfferId: string, + newPc: RTCPeerConnection, + newDc?: RTCDataChannel + ): Promise { + return this.rotationLock.run(async () => { + if (this.rotating) { + throw new Error('Rotation already in progress') + } + this.rotating = true + + try { + this.rotationAttempts++ + if (this.rotationAttempts > OffererConnection.MAX_ROTATION_ATTEMPTS) { + throw new Error('Max rotation attempts exceeded') + } + + this.debug(`Rebinding connection from ${this.offerId} to ${newOfferId}`) + + // 1. Clean up old peer connection + if (this.pc) { + this.pc.close() + } + if (this.dc && this.dc !== newDc) { + this.dc.close() + } + + // 2. Update to new offer + this.offerId = newOfferId + this.pc = newPc + this.dc = newDc || null + + // 3. Reset answer processing flags + this.answerProcessed = false + this.answerSdpFingerprint = null + + // 4. Setup event handlers for new peer connection + this.pc.onicecandidate = (event) => this.handleIceCandidate(event) + this.pc.oniceconnectionstatechange = () => this.handleIceConnectionStateChange() + this.pc.onconnectionstatechange = () => this.handleConnectionStateChange() + this.pc.onicegatheringstatechange = () => this.handleIceGatheringStateChange() + + // 5. Setup data channel handlers if we have one + if (this.dc) { + this.setupDataChannelHandlers(this.dc) + } + + // 6. Restart connection timeout + this.startConnectionTimeout() + + // 7. Transition to SIGNALING state (waiting for answer) + this.transitionTo(ConnectionState.SIGNALING, 'Offer rotated, waiting for answer') + + // Note: Message buffer is NOT cleared - it persists! + this.debug(`Rebind complete. Buffer has ${this.messageBuffer?.size() ?? 0} messages`) + } finally { + this.rotating = false + } + }) + } + + /** + * Check if connection is currently rotating + */ + isRotating(): boolean { + return this.rotating + } + + /** + * Override onConnected to reset rotation attempts + */ + protected onConnected(): void { + super.onConnected() + this.rotationAttempts = 0 + this.debug('Connection established, rotation attempts reset') + } + /** * Generate a hash fingerprint of SDP for deduplication */ diff --git a/src/rondevu.ts b/src/rondevu.ts index 51429bd..0836302 100644 --- a/src/rondevu.ts +++ b/src/rondevu.ts @@ -470,6 +470,10 @@ export class Rondevu extends EventEmitter { this.emit('offer:created', offerId, serviceFqn) }) + this.offerPool.on('connection:rotated', (oldOfferId, newOfferId, connection) => { + this.emit('connection:rotated', oldOfferId, newOfferId, connection) + }) + this.usernameClaimed = true }