From df231c192d663d2f2ebec94d05abfbd7a384d427 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Dec 2025 10:10:12 +0000 Subject: [PATCH 1/2] Refactor OfferFactory to receive pc from Rondevu Change the OfferFactory signature to receive the RTCPeerConnection as a parameter instead of rtcConfig. This allows Rondevu to: 1. Create the RTCPeerConnection itself 2. Set up ICE candidate handlers BEFORE the factory runs 3. Ensure no candidates are lost when setLocalDescription() triggers ICE gathering This is a cleaner fix for #2 that eliminates the race condition at the source rather than working around it with queuing. BREAKING CHANGE: OfferFactory signature changed from (rtcConfig: RTCConfiguration) => Promise to (pc: RTCPeerConnection) => Promise OfferContext no longer includes 'pc' since it's now provided by Rondevu. --- src/rondevu.ts | 55 +++++++++++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/src/rondevu.ts b/src/rondevu.ts index fecd208..90ce68b 100644 --- a/src/rondevu.ts +++ b/src/rondevu.ts @@ -63,12 +63,19 @@ export interface RondevuOptions { } export interface OfferContext { - pc: RTCPeerConnection dc?: RTCDataChannel offer: RTCSessionDescriptionInit } -export type OfferFactory = (rtcConfig: RTCConfiguration) => Promise +/** + * Factory function for creating WebRTC offers. + * Rondevu creates the RTCPeerConnection and passes it to the factory, + * allowing ICE candidate handlers to be set up before setLocalDescription() is called. + * + * @param pc - The RTCPeerConnection created by Rondevu (already configured with ICE servers) + * @returns Promise containing the data channel (optional) and offer SDP + */ +export type OfferFactory = (pc: RTCPeerConnection) => Promise export interface PublishServiceOptions { service: string // Service name and version (e.g., "chat:2.0.0") - username will be auto-appended @@ -135,12 +142,12 @@ interface ActiveOffer { * await rondevu.publishService({ * service: 'chat:2.0.0', * maxOffers: 5, // Maintain up to 5 concurrent offers - * offerFactory: async (rtcConfig) => { - * const pc = new RTCPeerConnection(rtcConfig) + * offerFactory: async (pc) => { + * // pc is created by Rondevu with ICE handlers already attached * const dc = pc.createDataChannel('chat') * const offer = await pc.createOffer() * await pc.setLocalDescription(offer) - * return { pc, dc, offer } + * return { dc, offer } * } * }) * @@ -337,15 +344,15 @@ export class Rondevu { /** * Default offer factory - creates a simple data channel connection + * The RTCPeerConnection is created by Rondevu and passed in */ - private async defaultOfferFactory(rtcConfig: RTCConfiguration): Promise { - const pc = new RTCPeerConnection(rtcConfig) + private async defaultOfferFactory(pc: RTCPeerConnection): Promise { const dc = pc.createDataChannel('default') const offer = await pc.createOffer() await pc.setLocalDescription(offer) - return { pc, dc, offer } + return { dc, offer } } /** @@ -415,23 +422,20 @@ export class Rondevu { iceServers: this.iceServers } - this.debug('Creating new offer...') - - // Create the offer using the factory - // Note: The factory may call setLocalDescription() which triggers ICE gathering - const { pc, dc, offer } = await this.offerFactory(rtcConfig) - // Auto-append username to service const serviceFqn = `${this.currentService}@${this.username}` - // Queue to buffer ICE candidates generated before we have the offerId - // This fixes the race condition where ICE candidates are lost because - // they're generated before we can set up the handler with the offerId + this.debug('Creating new offer...') + + // 1. Create the RTCPeerConnection - Rondevu controls this to set up handlers early + const pc = new RTCPeerConnection(rtcConfig) + + // 2. Set up ICE candidate handler with queuing BEFORE the factory runs + // This ensures we capture all candidates, even those generated immediately + // when setLocalDescription() is called in the factory const earlyIceCandidates: RTCIceCandidateInit[] = [] let offerId: string | null = null - // Set up a queuing ICE candidate handler immediately after getting the pc - // This captures any candidates that fire before we have the offerId pc.onicecandidate = async (event) => { if (event.candidate) { // Handle both browser and Node.js (wrtc) environments @@ -454,7 +458,12 @@ export class Rondevu { } } - // Publish to server + // 3. Call the factory with the pc - factory creates data channel and offer + // When factory calls setLocalDescription(), ICE gathering starts and + // candidates are captured by the handler we set up above + const { dc, offer } = await this.offerFactory(pc) + + // 4. Publish to server to get offerId const result = await this.api.publishService({ serviceFqn, offers: [{ sdp: offer.sdp! }], @@ -465,7 +474,7 @@ export class Rondevu { offerId = result.offers[0].offerId - // Store active offer + // 5. Store active offer this.activeOffers.set(offerId, { offerId, serviceFqn, @@ -477,7 +486,7 @@ export class Rondevu { this.debug(`Offer created: ${offerId}`) - // Send any queued early ICE candidates + // 6. Send any queued early ICE candidates if (earlyIceCandidates.length > 0) { this.debug(`Sending ${earlyIceCandidates.length} early ICE candidates`) try { @@ -487,7 +496,7 @@ export class Rondevu { } } - // Monitor connection state + // 7. Monitor connection state pc.onconnectionstatechange = () => { this.debug(`Offer ${offerId} connection state: ${pc.connectionState}`) From a0dc9ddad00b3170092f68c3e329b30fd3e93b6f Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sun, 14 Dec 2025 10:21:43 +0000 Subject: [PATCH 2/2] Address code review suggestions - Update README.md example to match new OfferFactory signature - Add error handling and RTCPeerConnection cleanup on factory failure - Document setupIceCandidateHandler() method usage - Use undefined instead of null for offerId variable Co-authored-by: Bas --- README.md | 6 +++--- src/rondevu.ts | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c0bd52b..f6561b0 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,8 @@ const rondevu = await Rondevu.connect({ await rondevu.publishService({ service: 'chat:1.0.0', maxOffers: 5, // Maintain up to 5 concurrent offers - offerFactory: async (rtcConfig) => { - const pc = new RTCPeerConnection(rtcConfig) + offerFactory: async (pc) => { + // pc is created by Rondevu with ICE handlers already attached const dc = pc.createDataChannel('chat') dc.addEventListener('open', () => { @@ -64,7 +64,7 @@ await rondevu.publishService({ const offer = await pc.createOffer() await pc.setLocalDescription(offer) - return { pc, dc, offer } + return { dc, offer } } }) diff --git a/src/rondevu.ts b/src/rondevu.ts index 90ce68b..a6b9a0a 100644 --- a/src/rondevu.ts +++ b/src/rondevu.ts @@ -382,6 +382,10 @@ export class Rondevu { /** * Set up ICE candidate handler to send candidates to the server + * + * Note: This is used by connectToService() where the offerId is already known. + * For createOffer(), we use inline ICE handling with early candidate queuing + * since the offerId isn't available until after the factory completes. */ private setupIceCandidateHandler( pc: RTCPeerConnection, @@ -434,7 +438,7 @@ export class Rondevu { // This ensures we capture all candidates, even those generated immediately // when setLocalDescription() is called in the factory const earlyIceCandidates: RTCIceCandidateInit[] = [] - let offerId: string | null = null + let offerId: string | undefined pc.onicecandidate = async (event) => { if (event.candidate) { @@ -461,7 +465,17 @@ export class Rondevu { // 3. Call the factory with the pc - factory creates data channel and offer // When factory calls setLocalDescription(), ICE gathering starts and // candidates are captured by the handler we set up above - const { dc, offer } = await this.offerFactory(pc) + let dc: RTCDataChannel | undefined + let offer: RTCSessionDescriptionInit + try { + const factoryResult = await this.offerFactory(pc) + dc = factoryResult.dc + offer = factoryResult.offer + } catch (err) { + // Clean up the connection if factory fails + pc.close() + throw err + } // 4. Publish to server to get offerId const result = await this.api.publishService({