Add durable WebRTC connections with auto-reconnect and message buffering (v0.18.8)

- Add connection state machine with proper lifecycle management
- Implement automatic reconnection with exponential backoff
- Add message buffering during disconnections
- Create RondevuConnection base class with state tracking
- Create OffererConnection and AnswererConnection classes
- Fix ICE polling lifecycle (now stops when connected)
- Add fillOffers() semaphore to prevent exceeding maxOffers
- Implement answer fingerprinting to prevent duplicate processing
- Add dual ICE state monitoring (iceConnectionState + connectionState)
- Fix data channel handler timing issues
- Add comprehensive event system (20+ events)
- Add connection timeouts and proper cleanup

Breaking changes:
- connectToService() now returns AnswererConnection instead of ConnectionContext
- connection:opened event signature changed: (offerId, dc) → (offerId, connection)
- Direct DataChannel access replaced with connection wrapper API

See MIGRATION.md for upgrade guide.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-14 16:52:57 +01:00
parent 9a4fbb63f8
commit a480fa3ba4
11 changed files with 1905 additions and 799 deletions

183
src/answerer-connection.ts Normal file
View File

@@ -0,0 +1,183 @@
/**
* Answerer-side WebRTC connection with answer creation and offer processing
*/
import { RondevuConnection } from './connection.js'
import { ConnectionState } from './connection-events.js'
import { RondevuAPI } from './api.js'
import { ConnectionConfig } from './connection-config.js'
export interface AnswererOptions {
api: RondevuAPI
serviceFqn: string
offerId: string
offerSdp: string
rtcConfig?: RTCConfiguration
config?: Partial<ConnectionConfig>
}
/**
* Answerer connection - processes offers and creates answers
*/
export class AnswererConnection extends RondevuConnection {
private api: RondevuAPI
private serviceFqn: string
private offerId: string
private offerSdp: string
constructor(options: AnswererOptions) {
super(options.rtcConfig, options.config)
this.api = options.api
this.serviceFqn = options.serviceFqn
this.offerId = options.offerId
this.offerSdp = options.offerSdp
}
/**
* Initialize the connection by processing offer and creating answer
*/
async initialize(): Promise<void> {
this.debug('Initializing answerer connection')
// Create peer connection
this.createPeerConnection()
if (!this.pc) throw new Error('Peer connection not created')
// Setup ondatachannel handler BEFORE setting remote description
// This is critical to avoid race conditions
this.pc.ondatachannel = (event) => {
this.debug('Received data channel')
this.dc = event.channel
this.setupDataChannelHandlers(this.dc)
}
// Start connection timeout
this.startConnectionTimeout()
// Set remote description (offer)
await this.pc.setRemoteDescription({
type: 'offer',
sdp: this.offerSdp,
})
this.transitionTo(ConnectionState.SIGNALING, 'Offer received, creating answer')
// Create and set local description (answer)
const answer = await this.pc.createAnswer()
await this.pc.setLocalDescription(answer)
this.debug('Answer created, sending to server')
// Send answer to server
await this.api.answerOffer(this.serviceFqn, this.offerId, answer.sdp!)
this.debug('Answer sent successfully')
}
/**
* Handle local ICE candidate generation
*/
protected onLocalIceCandidate(candidate: RTCIceCandidate): void {
this.debug('Generated local ICE candidate')
// For answerer, we add ICE candidates to the offer
// The server will make them available for the offerer to poll
this.api
.addOfferIceCandidates(this.serviceFqn, this.offerId, [
{
candidate: candidate.candidate,
sdpMLineIndex: candidate.sdpMLineIndex,
sdpMid: candidate.sdpMid,
},
])
.catch((error) => {
this.debug('Failed to send ICE candidate:', error)
})
}
/**
* Poll for remote ICE candidates (from offerer)
*/
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`)
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)
})
}
// Update last poll time
if (iceCandidate.createdAt > this.lastIcePollTime) {
this.lastIcePollTime = iceCandidate.createdAt
}
}
}
})
.catch((error) => {
this.debug('Failed to poll ICE candidates:', error)
})
}
/**
* Attempt to reconnect
*/
protected attemptReconnect(): void {
this.debug('Attempting to reconnect')
// For answerer, we need to fetch a new offer and create a new answer
// Clean up old connection
if (this.pc) {
this.pc.close()
this.pc = null
}
if (this.dc) {
this.dc.close()
this.dc = null
}
// Fetch new offer from service
this.api
.getService(this.serviceFqn)
.then((service) => {
if (!service || !service.offers || service.offers.length === 0) {
throw new Error('No offers available for reconnection')
}
// Pick a random offer
const offer = service.offers[Math.floor(Math.random() * service.offers.length)]
this.offerId = offer.offerId
this.offerSdp = offer.sdp
// Reinitialize with new offer
return this.initialize()
})
.then(() => {
this.emit('reconnect:success')
})
.catch((error) => {
this.debug('Reconnection failed:', error)
this.emit('reconnect:failed', error as Error)
this.scheduleReconnect()
})
}
/**
* Get the offer ID we're answering
*/
getOfferId(): string {
return this.offerId
}
}