2 Commits

Author SHA1 Message Date
eb2c61bdb8 Release v0.9.2: Fix service pool ICE candidate collection 2025-12-07 11:31:33 +01:00
3139897b25 Fix service pool ICE candidate collection and logging
Fixed critical timing issue where ICE candidates were generated before
the handler was attached, causing them to be lost:

- Set up onicecandidate handler BEFORE setLocalDescription()
- Collect candidates in array while waiting for offer ID
- Send all pending candidates once offer ID is available
- Add detailed logging for service pool ICE candidates
- Log candidate type (host/srflx/relay) for debugging

This ensures all ICE candidates are captured and sent to the signaling
server, and provides visibility into what types of candidates are being
generated (especially important for diagnosing TURN relay issues).

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-07 11:31:24 +01:00
3 changed files with 83 additions and 9 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@xtr-dev/rondevu-client", "name": "@xtr-dev/rondevu-client",
"version": "0.9.1", "version": "0.9.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@xtr-dev/rondevu-client", "name": "@xtr-dev/rondevu-client",
"version": "0.9.1", "version": "0.9.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@noble/ed25519": "^3.0.0", "@noble/ed25519": "^3.0.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@xtr-dev/rondevu-client", "name": "@xtr-dev/rondevu-client",
"version": "0.9.1", "version": "0.9.2",
"description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing", "description": "TypeScript client for Rondevu with durable WebRTC connections, automatic reconnection, and message queuing",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -385,6 +385,8 @@ export class ServicePool {
try { try {
// Create peer connections and generate offers // Create peer connections and generate offers
const offerRequests = []; const offerRequests = [];
const pendingCandidates: RTCIceCandidateInit[][] = []; // Store candidates before we have offer IDs
for (let i = 0; i < batchSize; i++) { for (let i = 0; i < batchSize; i++) {
const pc = new RTCPeerConnection(this.options.rtcConfig || { const pc = new RTCPeerConnection(this.options.rtcConfig || {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
@@ -394,9 +396,28 @@ export class ServicePool {
const channel = pc.createDataChannel('rondevu-service'); const channel = pc.createDataChannel('rondevu-service');
dataChannels.push(channel); dataChannels.push(channel);
// Set up temporary candidate collector BEFORE setLocalDescription
const candidatesForThisOffer: RTCIceCandidateInit[] = [];
pendingCandidates.push(candidatesForThisOffer);
pc.onicecandidate = (event) => {
if (event.candidate) {
const candidateData = event.candidate.toJSON();
if (candidateData.candidate && candidateData.candidate !== '') {
const type = candidateData.candidate.includes('typ host') ? 'host' :
candidateData.candidate.includes('typ srflx') ? 'srflx' :
candidateData.candidate.includes('typ relay') ? 'relay' : 'unknown';
console.log(`🧊 Service pool generated ${type} ICE candidate:`, candidateData.candidate);
candidatesForThisOffer.push(candidateData);
}
} else {
console.log('🧊 Service pool ICE gathering complete');
}
};
// Create offer // Create offer
const offer = await pc.createOffer(); const offer = await pc.createOffer();
await pc.setLocalDescription(offer); await pc.setLocalDescription(offer); // ICE gathering starts here, candidates go to collector
if (!offer.sdp) { if (!offer.sdp) {
pc.close(); pc.close();
@@ -417,19 +438,37 @@ export class ServicePool {
const createdOffers = await this.offersApi.create(offerRequests); const createdOffers = await this.offersApi.create(offerRequests);
offers.push(...createdOffers); offers.push(...createdOffers);
// Set up ICE candidate handlers AFTER we have offer IDs // Now send all pending candidates and set up handlers for future ones
for (let i = 0; i < peerConnections.length; i++) { for (let i = 0; i < peerConnections.length; i++) {
const pc = peerConnections[i]; const pc = peerConnections[i];
const offerId = createdOffers[i].id; const offerId = createdOffers[i].id;
const candidates = pendingCandidates[i];
// Send any candidates that were collected while waiting for offer ID
if (candidates.length > 0) {
console.log(`📤 Sending ${candidates.length} pending ICE candidate(s) for offer ${offerId}`);
try {
await this.offersApi.addIceCandidates(offerId, candidates);
console.log(`✅ Sent ${candidates.length} pending ICE candidate(s)`);
} catch (err) {
console.error('❌ Error sending pending ICE candidates:', err);
}
}
// Replace temporary handler with permanent one for any future candidates
pc.onicecandidate = async (event) => { pc.onicecandidate = async (event) => {
if (event.candidate) { if (event.candidate) {
const candidateData = event.candidate.toJSON(); const candidateData = event.candidate.toJSON();
if (candidateData.candidate && candidateData.candidate !== '') { if (candidateData.candidate && candidateData.candidate !== '') {
const type = candidateData.candidate.includes('typ host') ? 'host' :
candidateData.candidate.includes('typ srflx') ? 'srflx' :
candidateData.candidate.includes('typ relay') ? 'relay' : 'unknown';
console.log(`🧊 Service pool generated late ${type} ICE candidate:`, candidateData.candidate);
try { try {
await this.offersApi.addIceCandidates(offerId, [candidateData]); await this.offersApi.addIceCandidates(offerId, [candidateData]);
console.log(`✅ Sent late ${type} ICE candidate`);
} catch (err) { } catch (err) {
console.error('Error sending ICE candidate:', err); console.error(`Error sending ${type} ICE candidate:`, err);
} }
} }
} }
@@ -468,9 +507,28 @@ export class ServicePool {
const dataChannel = pc.createDataChannel('rondevu-service'); const dataChannel = pc.createDataChannel('rondevu-service');
// Collect candidates before we have offer ID
const pendingCandidates: RTCIceCandidateInit[] = [];
// Set up temporary candidate collector BEFORE setLocalDescription
pc.onicecandidate = (event) => {
if (event.candidate) {
const candidateData = event.candidate.toJSON();
if (candidateData.candidate && candidateData.candidate !== '') {
const type = candidateData.candidate.includes('typ host') ? 'host' :
candidateData.candidate.includes('typ srflx') ? 'srflx' :
candidateData.candidate.includes('typ relay') ? 'relay' : 'unknown';
console.log(`🧊 Initial service generated ${type} ICE candidate:`, candidateData.candidate);
pendingCandidates.push(candidateData);
}
} else {
console.log('🧊 Initial service ICE gathering complete');
}
};
// Create offer // Create offer
const offer = await pc.createOffer(); const offer = await pc.createOffer();
await pc.setLocalDescription(offer); await pc.setLocalDescription(offer); // ICE gathering starts here
if (!offer.sdp) { if (!offer.sdp) {
pc.close(); pc.close();
@@ -512,15 +570,31 @@ export class ServicePool {
const data = await response.json(); const data = await response.json();
// Set up ICE candidate handler now that we have the offer ID // Send any pending candidates
if (pendingCandidates.length > 0) {
console.log(`📤 Sending ${pendingCandidates.length} pending ICE candidate(s) for initial service`);
try {
await this.offersApi.addIceCandidates(data.offerId, pendingCandidates);
console.log(`✅ Sent ${pendingCandidates.length} pending ICE candidate(s)`);
} catch (err) {
console.error('❌ Error sending pending ICE candidates:', err);
}
}
// Set up handler for any future candidates
pc.onicecandidate = async (event) => { pc.onicecandidate = async (event) => {
if (event.candidate) { if (event.candidate) {
const candidateData = event.candidate.toJSON(); const candidateData = event.candidate.toJSON();
if (candidateData.candidate && candidateData.candidate !== '') { if (candidateData.candidate && candidateData.candidate !== '') {
const type = candidateData.candidate.includes('typ host') ? 'host' :
candidateData.candidate.includes('typ srflx') ? 'srflx' :
candidateData.candidate.includes('typ relay') ? 'relay' : 'unknown';
console.log(`🧊 Initial service generated late ${type} ICE candidate:`, candidateData.candidate);
try { try {
await this.offersApi.addIceCandidates(data.offerId, [candidateData]); await this.offersApi.addIceCandidates(data.offerId, [candidateData]);
console.log(`✅ Sent late ${type} ICE candidate`);
} catch (err) { } catch (err) {
console.error('Error sending ICE candidate:', err); console.error(`Error sending ${type} ICE candidate:`, err);
} }
} }
} }