Merge pull request #5 from xtr-dev/claude/fix-issue-2-6zYvD

Refactor OfferFactory to receive pc from Rondevu
This commit is contained in:
Bas
2025-12-14 11:24:54 +01:00
committed by GitHub
2 changed files with 51 additions and 28 deletions

View File

@@ -49,8 +49,8 @@ const rondevu = await Rondevu.connect({
await rondevu.publishService({ await rondevu.publishService({
service: 'chat:1.0.0', service: 'chat:1.0.0',
maxOffers: 5, // Maintain up to 5 concurrent offers maxOffers: 5, // Maintain up to 5 concurrent offers
offerFactory: async (rtcConfig) => { offerFactory: async (pc) => {
const pc = new RTCPeerConnection(rtcConfig) // pc is created by Rondevu with ICE handlers already attached
const dc = pc.createDataChannel('chat') const dc = pc.createDataChannel('chat')
dc.addEventListener('open', () => { dc.addEventListener('open', () => {
@@ -64,7 +64,7 @@ await rondevu.publishService({
const offer = await pc.createOffer() const offer = await pc.createOffer()
await pc.setLocalDescription(offer) await pc.setLocalDescription(offer)
return { pc, dc, offer } return { dc, offer }
} }
}) })

View File

@@ -63,12 +63,19 @@ export interface RondevuOptions {
} }
export interface OfferContext { export interface OfferContext {
pc: RTCPeerConnection
dc?: RTCDataChannel dc?: RTCDataChannel
offer: RTCSessionDescriptionInit offer: RTCSessionDescriptionInit
} }
export type OfferFactory = (rtcConfig: RTCConfiguration) => Promise<OfferContext> /**
* 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<OfferContext>
export interface PublishServiceOptions { export interface PublishServiceOptions {
service: string // Service name and version (e.g., "chat:2.0.0") - username will be auto-appended 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({ * await rondevu.publishService({
* service: 'chat:2.0.0', * service: 'chat:2.0.0',
* maxOffers: 5, // Maintain up to 5 concurrent offers * maxOffers: 5, // Maintain up to 5 concurrent offers
* offerFactory: async (rtcConfig) => { * offerFactory: async (pc) => {
* const pc = new RTCPeerConnection(rtcConfig) * // pc is created by Rondevu with ICE handlers already attached
* const dc = pc.createDataChannel('chat') * const dc = pc.createDataChannel('chat')
* const offer = await pc.createOffer() * const offer = await pc.createOffer()
* await pc.setLocalDescription(offer) * 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 * Default offer factory - creates a simple data channel connection
* The RTCPeerConnection is created by Rondevu and passed in
*/ */
private async defaultOfferFactory(rtcConfig: RTCConfiguration): Promise<OfferContext> { private async defaultOfferFactory(pc: RTCPeerConnection): Promise<OfferContext> {
const pc = new RTCPeerConnection(rtcConfig)
const dc = pc.createDataChannel('default') const dc = pc.createDataChannel('default')
const offer = await pc.createOffer() const offer = await pc.createOffer()
await pc.setLocalDescription(offer) await pc.setLocalDescription(offer)
return { pc, dc, offer } return { dc, offer }
} }
/** /**
@@ -375,6 +382,10 @@ export class Rondevu {
/** /**
* Set up ICE candidate handler to send candidates to the server * 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( private setupIceCandidateHandler(
pc: RTCPeerConnection, pc: RTCPeerConnection,
@@ -415,23 +426,20 @@ export class Rondevu {
iceServers: this.iceServers 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 // Auto-append username to service
const serviceFqn = `${this.currentService}@${this.username}` const serviceFqn = `${this.currentService}@${this.username}`
// Queue to buffer ICE candidates generated before we have the offerId this.debug('Creating new offer...')
// This fixes the race condition where ICE candidates are lost because
// they're generated before we can set up the handler with the offerId // 1. Create the RTCPeerConnection - Rondevu controls this to set up handlers early
const earlyIceCandidates: RTCIceCandidateInit[] = [] const pc = new RTCPeerConnection(rtcConfig)
let offerId: string | null = null
// 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 | undefined
// 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) => { pc.onicecandidate = async (event) => {
if (event.candidate) { if (event.candidate) {
// Handle both browser and Node.js (wrtc) environments // Handle both browser and Node.js (wrtc) environments
@@ -454,7 +462,22 @@ 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
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({ const result = await this.api.publishService({
serviceFqn, serviceFqn,
offers: [{ sdp: offer.sdp! }], offers: [{ sdp: offer.sdp! }],
@@ -465,7 +488,7 @@ export class Rondevu {
offerId = result.offers[0].offerId offerId = result.offers[0].offerId
// Store active offer // 5. Store active offer
this.activeOffers.set(offerId, { this.activeOffers.set(offerId, {
offerId, offerId,
serviceFqn, serviceFqn,
@@ -477,7 +500,7 @@ export class Rondevu {
this.debug(`Offer created: ${offerId}`) this.debug(`Offer created: ${offerId}`)
// Send any queued early ICE candidates // 6. Send any queued early ICE candidates
if (earlyIceCandidates.length > 0) { if (earlyIceCandidates.length > 0) {
this.debug(`Sending ${earlyIceCandidates.length} early ICE candidates`) this.debug(`Sending ${earlyIceCandidates.length} early ICE candidates`)
try { try {
@@ -487,7 +510,7 @@ export class Rondevu {
} }
} }
// Monitor connection state // 7. Monitor connection state
pc.onconnectionstatechange = () => { pc.onconnectionstatechange = () => {
this.debug(`Offer ${offerId} connection state: ${pc.connectionState}`) this.debug(`Offer ${offerId} connection state: ${pc.connectionState}`)