mirror of
https://github.com/xtr-dev/rondevu-client.git
synced 2025-12-10 02:43:25 +00:00
feat: Implement proper trickle ICE support
Major improvements to connection establishment: **Trickle ICE Implementation:** - Send offer/answer to server IMMEDIATELY after creating SDP - Don't wait for ICE gathering before sending offer/answer - ICE candidates are now sent as they're discovered (true trickle ICE) - Connection attempts can start with first candidates while more gather **Removed Delays:** - CreatingOfferState: No longer waits 10-15s for ICE before sending offer - AnsweringState: No longer waits 10-15s for ICE before sending answer - Answering state now takes ~50-200ms instead of 15+ seconds **Code Organization:** - Moved peer.ts to peer/index.ts directory structure - Removed unused pendingCandidates buffering - Removed unused waitForIceGathering methods - Cleaned up timeout handling **Breaking Changes:** - "answering" state now transitions much faster to "exchanging-ice" - ICE candidates start trickling immediately instead of in batches This dramatically improves connection speed and follows WebRTC best practices. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
39
package-lock.json
generated
Normal file
39
package-lock.json
generated
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "@xtr-dev/rondevu-client",
|
||||||
|
"version": "0.5.1",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@xtr-dev/rondevu-client",
|
||||||
|
"version": "0.5.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@xtr-dev/rondevu-client": "^0.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xtr-dev/rondevu-client": {
|
||||||
|
"version": "0.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xtr-dev/rondevu-client/-/rondevu-client-0.5.1.tgz",
|
||||||
|
"integrity": "sha512-110ejMCizPUPkHwwwNvcdCSZceLaHeFbf1LNkXvbG6pnLBqCf2uoGOOaRkArb7HNNFABFB+HXzm/AVzNdadosw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@xtr-dev/rondevu-client",
|
"name": "@xtr-dev/rondevu-client",
|
||||||
"version": "0.5.1",
|
"version": "0.6.0",
|
||||||
"description": "TypeScript client for Rondevu topic-based peer discovery and signaling server",
|
"description": "TypeScript client for Rondevu topic-based peer discovery and signaling server",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -25,5 +25,8 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"README.md"
|
"README.md"
|
||||||
]
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@xtr-dev/rondevu-client": "^0.5.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ export type {
|
|||||||
export { BloomFilter } from './bloom.js';
|
export { BloomFilter } from './bloom.js';
|
||||||
|
|
||||||
// Export peer manager
|
// Export peer manager
|
||||||
export { default as RondevuPeer } from './peer.js';
|
export { default as RondevuPeer } from './peer/index.js';
|
||||||
export type {
|
export type {
|
||||||
PeerOptions,
|
PeerOptions,
|
||||||
PeerEvents,
|
PeerEvents,
|
||||||
PeerTimeouts
|
PeerTimeouts
|
||||||
} from './peer.js';
|
} from './peer/index.js';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { RondevuOffers } from './offers.js';
|
import { RondevuOffers } from '../offers.js';
|
||||||
import { EventEmitter } from './event-emitter.js';
|
import { EventEmitter } from '../event-emitter.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Timeout configurations for different connection phases
|
* Timeout configurations for different connection phases
|
||||||
@@ -94,18 +94,15 @@ class IdleState extends PeerState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async answer(offerId: string, offerSdp: string, options: PeerOptions): Promise<void> {
|
async answer(offerId: string, offerSdp: string, options: PeerOptions): Promise<void> {
|
||||||
this.peer.setState(new AnsweringState(this.peer, offerId, offerSdp, options));
|
this.peer.setState(new AnsweringState(this.peer));
|
||||||
return this.peer.state.answer(offerId, offerSdp, options);
|
return this.peer.state.answer(offerId, offerSdp, options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creating offer and gathering ICE candidates
|
* Creating offer and sending to server
|
||||||
*/
|
*/
|
||||||
class CreatingOfferState extends PeerState {
|
class CreatingOfferState extends PeerState {
|
||||||
private timeout?: ReturnType<typeof setTimeout>;
|
|
||||||
private pendingCandidates: any[] = [];
|
|
||||||
|
|
||||||
constructor(peer: RondevuPeer, private options: PeerOptions) {
|
constructor(peer: RondevuPeer, private options: PeerOptions) {
|
||||||
super(peer);
|
super(peer);
|
||||||
}
|
}
|
||||||
@@ -124,25 +121,11 @@ class CreatingOfferState extends PeerState {
|
|||||||
this.peer.emitEvent('datachannel', channel);
|
this.peer.emitEvent('datachannel', channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up ICE candidate buffering
|
|
||||||
this.peer.pc.onicecandidate = (event) => {
|
|
||||||
if (event.candidate) {
|
|
||||||
const candidateData = event.candidate.toJSON();
|
|
||||||
if (candidateData.candidate && candidateData.candidate !== '') {
|
|
||||||
this.pendingCandidates.push(candidateData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create WebRTC offer
|
// Create WebRTC offer
|
||||||
const offer = await this.peer.pc.createOffer();
|
const offer = await this.peer.pc.createOffer();
|
||||||
await this.peer.pc.setLocalDescription(offer);
|
await this.peer.pc.setLocalDescription(offer);
|
||||||
|
|
||||||
// Wait for ICE gathering to complete (or timeout)
|
// Send offer to server immediately (don't wait for ICE)
|
||||||
const iceTimeout = options.timeouts?.iceGathering || 10000;
|
|
||||||
await this.waitForIceGathering(iceTimeout);
|
|
||||||
|
|
||||||
// Create offer on Rondevu server (server generates hash-based ID)
|
|
||||||
const offers = await this.peer.offersApi.create([{
|
const offers = await this.peer.offersApi.create([{
|
||||||
sdp: offer.sdp!,
|
sdp: offer.sdp!,
|
||||||
topics: options.topics,
|
topics: options.topics,
|
||||||
@@ -152,13 +135,7 @@ class CreatingOfferState extends PeerState {
|
|||||||
const offerId = offers[0].id;
|
const offerId = offers[0].id;
|
||||||
this.peer.offerId = offerId;
|
this.peer.offerId = offerId;
|
||||||
|
|
||||||
// Send buffered ICE candidates
|
// Enable trickle ICE - send candidates as they arrive
|
||||||
if (this.pendingCandidates.length > 0) {
|
|
||||||
await this.peer.offersApi.addIceCandidates(offerId, this.pendingCandidates);
|
|
||||||
this.pendingCandidates = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable trickle ICE for future candidates
|
|
||||||
this.peer.pc.onicecandidate = async (event) => {
|
this.peer.pc.onicecandidate = async (event) => {
|
||||||
if (event.candidate && offerId) {
|
if (event.candidate && offerId) {
|
||||||
const candidateData = event.candidate.toJSON();
|
const candidateData = event.candidate.toJSON();
|
||||||
@@ -181,30 +158,6 @@ class CreatingOfferState extends PeerState {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async waitForIceGathering(timeout: number): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const checkState = () => {
|
|
||||||
if (this.peer.pc.iceGatheringState === 'complete') {
|
|
||||||
if (this.timeout) clearTimeout(this.timeout);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.peer.pc.onicegatheringstatechange = checkState;
|
|
||||||
checkState(); // Check immediately in case already complete
|
|
||||||
|
|
||||||
this.timeout = setTimeout(() => {
|
|
||||||
// Timeout is not fatal - we proceed with candidates we have
|
|
||||||
console.warn('ICE gathering timeout - proceeding with gathered candidates');
|
|
||||||
resolve();
|
|
||||||
}, timeout);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup(): void {
|
|
||||||
if (this.timeout) clearTimeout(this.timeout);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -279,18 +232,10 @@ class WaitingForAnswerState extends PeerState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Answering an offer from another peer
|
* Answering an offer and sending to server
|
||||||
*/
|
*/
|
||||||
class AnsweringState extends PeerState {
|
class AnsweringState extends PeerState {
|
||||||
private timeout?: ReturnType<typeof setTimeout>;
|
constructor(peer: RondevuPeer) {
|
||||||
private pendingCandidates: any[] = [];
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
peer: RondevuPeer,
|
|
||||||
private offerId: string,
|
|
||||||
private offerSdp: string,
|
|
||||||
private options: PeerOptions
|
|
||||||
) {
|
|
||||||
super(peer);
|
super(peer);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,25 +246,6 @@ class AnsweringState extends PeerState {
|
|||||||
this.peer.role = 'answerer';
|
this.peer.role = 'answerer';
|
||||||
this.peer.offerId = offerId;
|
this.peer.offerId = offerId;
|
||||||
|
|
||||||
const answerTimeout = options.timeouts?.creatingAnswer || 10000;
|
|
||||||
|
|
||||||
this.timeout = setTimeout(() => {
|
|
||||||
this.peer.setState(new FailedState(
|
|
||||||
this.peer,
|
|
||||||
new Error('Timeout creating answer')
|
|
||||||
));
|
|
||||||
}, answerTimeout);
|
|
||||||
|
|
||||||
// Buffer ICE candidates during answer creation
|
|
||||||
this.peer.pc.onicecandidate = (event) => {
|
|
||||||
if (event.candidate) {
|
|
||||||
const candidateData = event.candidate.toJSON();
|
|
||||||
if (candidateData.candidate && candidateData.candidate !== '') {
|
|
||||||
this.pendingCandidates.push(candidateData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set remote description
|
// Set remote description
|
||||||
await this.peer.pc.setRemoteDescription({
|
await this.peer.pc.setRemoteDescription({
|
||||||
type: 'offer',
|
type: 'offer',
|
||||||
@@ -330,26 +256,10 @@ class AnsweringState extends PeerState {
|
|||||||
const answer = await this.peer.pc.createAnswer();
|
const answer = await this.peer.pc.createAnswer();
|
||||||
await this.peer.pc.setLocalDescription(answer);
|
await this.peer.pc.setLocalDescription(answer);
|
||||||
|
|
||||||
// Clear the answer creation timeout - ICE gathering has its own timeout
|
// Send answer to server immediately (don't wait for ICE)
|
||||||
if (this.timeout) {
|
|
||||||
clearTimeout(this.timeout);
|
|
||||||
this.timeout = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for ICE gathering
|
|
||||||
const iceTimeout = options.timeouts?.iceGathering || 10000;
|
|
||||||
await this.waitForIceGathering(iceTimeout);
|
|
||||||
|
|
||||||
// Send answer to server FIRST
|
|
||||||
await this.peer.offersApi.answer(offerId, answer.sdp!);
|
await this.peer.offersApi.answer(offerId, answer.sdp!);
|
||||||
|
|
||||||
// Send buffered ICE candidates
|
// Enable trickle ICE - send candidates as they arrive
|
||||||
if (this.pendingCandidates.length > 0) {
|
|
||||||
await this.peer.offersApi.addIceCandidates(offerId, this.pendingCandidates);
|
|
||||||
this.pendingCandidates = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable trickle ICE
|
|
||||||
this.peer.pc.onicecandidate = async (event) => {
|
this.peer.pc.onicecandidate = async (event) => {
|
||||||
if (event.candidate && offerId) {
|
if (event.candidate && offerId) {
|
||||||
const candidateData = event.candidate.toJSON();
|
const candidateData = event.candidate.toJSON();
|
||||||
@@ -370,28 +280,6 @@ class AnsweringState extends PeerState {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async waitForIceGathering(timeout: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const checkState = () => {
|
|
||||||
if (this.peer.pc.iceGatheringState === 'complete') {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.peer.pc.onicegatheringstatechange = checkState;
|
|
||||||
checkState();
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
console.warn('ICE gathering timeout - proceeding with gathered candidates');
|
|
||||||
resolve();
|
|
||||||
}, timeout);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup(): void {
|
|
||||||
if (this.timeout) clearTimeout(this.timeout);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { RondevuAuth, Credentials, FetchFunction } from './auth.js';
|
import { RondevuAuth, Credentials, FetchFunction } from './auth.js';
|
||||||
import { RondevuOffers } from './offers.js';
|
import { RondevuOffers } from './offers.js';
|
||||||
import RondevuPeer from './peer.js';
|
import RondevuPeer from './peer/index.js';
|
||||||
|
|
||||||
export interface RondevuOptions {
|
export interface RondevuOptions {
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user