4 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
a550641993 Release v0.9.1: Add detailed ICE candidate exchange logging 2025-12-07 11:13:32 +01:00
04603cfe2d Add detailed ICE candidate exchange logging
Added comprehensive logging to track WebRTC ICE candidate exchange:
- Log local candidate generation with type (host/srflx/relay)
- Log when candidates are sent to signaling server
- Log remote candidate reception and addition
- Log ICE gathering state changes
- Log ICE connection state changes
- Enhanced ICE error logging with details

This will help diagnose connection issues and TURN server problems.

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

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

4
package-lock.json generated
View File

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

View File

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

View File

@@ -40,13 +40,22 @@ export class ExchangingIceState extends PeerState {
this.lastIceTimestamp
);
if (candidates.length > 0) {
console.log(`📥 Received ${candidates.length} remote ICE candidate(s)`);
}
for (const cand of candidates) {
if (cand.candidate && cand.candidate.candidate && cand.candidate.candidate !== '') {
const type = cand.candidate.candidate.includes('typ host') ? 'host' :
cand.candidate.candidate.includes('typ srflx') ? 'srflx' :
cand.candidate.candidate.includes('typ relay') ? 'relay' : 'unknown';
console.log(`🧊 Adding remote ${type} ICE candidate:`, cand.candidate.candidate);
try {
await this.peer.pc.addIceCandidate(new this.peer.RTCIceCandidate(cand.candidate));
console.log(`✅ Added remote ${type} ICE candidate`);
this.lastIceTimestamp = cand.createdAt;
} catch (err) {
console.warn('Failed to add ICE candidate:', err);
console.warn(`⚠️ Failed to add remote ${type} ICE candidate:`, err);
this.lastIceTimestamp = cand.createdAt;
}
} else {
@@ -54,7 +63,7 @@ export class ExchangingIceState extends PeerState {
}
}
} catch (err) {
console.error('Error polling for ICE candidates:', err);
console.error('Error polling for ICE candidates:', err);
if (err instanceof Error && err.message.includes('not found')) {
this.cleanup();
const { FailedState } = await import('./failed-state.js');

View File

@@ -105,18 +105,23 @@ export default class RondevuPeer extends EventEmitter<PeerEvents> {
*/
private setupPeerConnection(): void {
this.connectionStateChangeHandler = () => {
console.log(`🔌 Connection state changed: ${this.pc.connectionState}`);
switch (this.pc.connectionState) {
case 'connected':
console.log('✅ WebRTC connection established');
this.setState(new ConnectedState(this));
this.emitEvent('connected');
break;
case 'disconnected':
console.log('⚠️ WebRTC connection disconnected');
this.emitEvent('disconnected');
break;
case 'failed':
console.log('❌ WebRTC connection failed');
this.setState(new FailedState(this, new Error('Connection failed')));
break;
case 'closed':
console.log('🔒 WebRTC connection closed');
this.setState(new ClosedState(this));
this.emitEvent('disconnected');
break;
@@ -124,6 +129,18 @@ export default class RondevuPeer extends EventEmitter<PeerEvents> {
};
this.pc.addEventListener('connectionstatechange', this.connectionStateChangeHandler);
// Add ICE connection state logging
const iceConnectionStateHandler = () => {
console.log(`🧊 ICE connection state: ${this.pc.iceConnectionState}`);
};
this.pc.addEventListener('iceconnectionstatechange', iceConnectionStateHandler);
// Add ICE gathering state logging
const iceGatheringStateHandler = () => {
console.log(`🔍 ICE gathering state: ${this.pc.iceGatheringState}`);
};
this.pc.addEventListener('icegatheringstatechange', iceGatheringStateHandler);
this.dataChannelHandler = (event: RTCDataChannelEvent) => {
this.emitEvent('datachannel', event.channel);
};
@@ -135,7 +152,13 @@ export default class RondevuPeer extends EventEmitter<PeerEvents> {
this.pc.addEventListener('track', this.trackHandler);
this.iceCandidateErrorHandler = (event: Event) => {
console.error('ICE candidate error:', event);
const iceError = event as RTCPeerConnectionIceErrorEvent;
console.error(`❌ ICE candidate error: ${iceError.errorText || 'Unknown error'}`, {
errorCode: iceError.errorCode,
url: iceError.url,
address: iceError.address,
port: iceError.port
});
};
this.pc.addEventListener('icecandidateerror', this.iceCandidateErrorHandler);
}

View File

@@ -40,12 +40,19 @@ export abstract class PeerState {
if (event.candidate && this.peer.offerId) {
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(`🧊 Generated ${type} ICE candidate:`, candidateData.candidate);
try {
await this.peer.offersApi.addIceCandidates(this.peer.offerId, [candidateData]);
console.log(`✅ Sent ${type} ICE candidate to server`);
} catch (err) {
console.error('Error sending ICE candidate:', err);
console.error(`Error sending ${type} ICE candidate:`, err);
}
}
} else if (!event.candidate) {
console.log('🧊 ICE gathering complete (null candidate)');
}
};
this.peer.pc.addEventListener('icecandidate', this.iceCandidateHandler);

View File

@@ -385,6 +385,8 @@ export class ServicePool {
try {
// Create peer connections and generate offers
const offerRequests = [];
const pendingCandidates: RTCIceCandidateInit[][] = []; // Store candidates before we have offer IDs
for (let i = 0; i < batchSize; i++) {
const pc = new RTCPeerConnection(this.options.rtcConfig || {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
@@ -394,9 +396,28 @@ export class ServicePool {
const channel = pc.createDataChannel('rondevu-service');
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
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await pc.setLocalDescription(offer); // ICE gathering starts here, candidates go to collector
if (!offer.sdp) {
pc.close();
@@ -417,19 +438,37 @@ export class ServicePool {
const createdOffers = await this.offersApi.create(offerRequests);
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++) {
const pc = peerConnections[i];
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) => {
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 late ${type} ICE candidate:`, candidateData.candidate);
try {
await this.offersApi.addIceCandidates(offerId, [candidateData]);
console.log(`✅ Sent late ${type} ICE candidate`);
} 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');
// 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
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await pc.setLocalDescription(offer); // ICE gathering starts here
if (!offer.sdp) {
pc.close();
@@ -512,15 +570,31 @@ export class ServicePool {
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) => {
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 late ${type} ICE candidate:`, candidateData.candidate);
try {
await this.offersApi.addIceCandidates(data.offerId, [candidateData]);
console.log(`✅ Sent late ${type} ICE candidate`);
} catch (err) {
console.error('Error sending ICE candidate:', err);
console.error(`Error sending ${type} ICE candidate:`, err);
}
}
}