4 Commits

Author SHA1 Message Date
claude[bot]
a0dc9ddad0 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 <bvdaakster@users.noreply.github.com>
2025-12-14 10:21:43 +00:00
Claude
df231c192d 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<OfferContext>
to
  (pc: RTCPeerConnection) => Promise<OfferContext>

OfferContext no longer includes 'pc' since it's now provided by Rondevu.
2025-12-14 10:10:12 +00:00
Claude
62a6cdcb99 Update package-lock.json 2025-12-14 09:57:07 +00:00
Claude
febe3b7270 Fix early ICE candidates lost due to late handler setup in createOffer()
Queue ICE candidates that are generated before we have the offerId from
the server. When the factory calls setLocalDescription(), ICE gathering
starts immediately, but we couldn't send candidates until we had the
offerId from publishService(). Now we:

1. Set up a queuing handler immediately after getting the pc from factory
2. Buffer any early candidates while publishing to get the offerId
3. Flush all queued candidates once we have the offerId
4. Continue handling future candidates normally

Fixes #2
2025-12-14 09:56:27 +00:00
3 changed files with 85 additions and 34 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 }
} }
}) })

12
package-lock.json generated
View File

@@ -9,8 +9,7 @@
"version": "0.18.0", "version": "0.18.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@noble/ed25519": "^3.0.0", "@noble/ed25519": "^3.0.0"
"@xtr-dev/rondevu-client": "^0.9.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
@@ -1310,15 +1309,6 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/@xtr-dev/rondevu-client": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.9.2.tgz",
"integrity": "sha512-DVow5AOPU40dqQtlfQK7J2GNX8dz2/4UzltMqublaPZubbkRYgocvp0b76NQu5F6v150IstMV2N49uxAYqogVw==",
"license": "MIT",
"dependencies": {
"@noble/ed25519": "^3.0.0"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",

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,15 +426,58 @@ export class Rondevu {
iceServers: this.iceServers iceServers: this.iceServers
} }
this.debug('Creating new offer...')
// Create the offer using the factory
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}`
// Publish to server 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 | undefined
pc.onicecandidate = async (event) => {
if (event.candidate) {
// Handle both browser and Node.js (wrtc) environments
const candidateData = typeof event.candidate.toJSON === 'function'
? event.candidate.toJSON()
: event.candidate
if (offerId) {
// We have the offerId, send directly
try {
await this.api.addOfferIceCandidates(serviceFqn, offerId, [candidateData])
} catch (err) {
console.error('[Rondevu] Failed to send ICE candidate:', err)
}
} else {
// Queue for later - we don't have the offerId yet
this.debug('Queuing early ICE candidate')
earlyIceCandidates.push(candidateData)
}
}
}
// 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! }],
@@ -432,9 +486,9 @@ export class Rondevu {
message: '', message: '',
}) })
const 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,
@@ -446,15 +500,22 @@ export class Rondevu {
this.debug(`Offer created: ${offerId}`) this.debug(`Offer created: ${offerId}`)
// Set up ICE candidate handler // 6. Send any queued early ICE candidates
this.setupIceCandidateHandler(pc, serviceFqn, offerId) if (earlyIceCandidates.length > 0) {
this.debug(`Sending ${earlyIceCandidates.length} early ICE candidates`)
try {
await this.api.addOfferIceCandidates(serviceFqn, offerId, earlyIceCandidates)
} catch (err) {
console.error('[Rondevu] Failed to send early ICE candidates:', err)
}
}
// 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}`)
if (pc.connectionState === 'failed' || pc.connectionState === 'closed') { if (pc.connectionState === 'failed' || pc.connectionState === 'closed') {
this.activeOffers.delete(offerId) this.activeOffers.delete(offerId!)
this.fillOffers() // Try to replace failed offer this.fillOffers() // Try to replace failed offer
} }
} }