@xtr-dev/rondevu-client 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,262 +1,283 @@
1
- import { EventEmitter } from './event-emitter.js';
2
1
  /**
3
- * Represents a WebRTC connection with automatic signaling and ICE exchange
2
+ * High-level WebRTC connection manager for Rondevu
3
+ * Handles offer/answer exchange, ICE candidates, and connection lifecycle
4
4
  */
5
- export class RondevuConnection extends EventEmitter {
6
- constructor(params, client) {
7
- super();
8
- this.isPolling = false;
9
- this.isClosed = false;
10
- this.id = params.id;
11
- this.role = params.role;
12
- this.pc = params.pc;
13
- this.localPeerId = params.localPeerId;
14
- this.remotePeerId = params.remotePeerId;
15
- this.client = client;
16
- this.dataChannels = new Map();
17
- this.pollingIntervalMs = params.pollingInterval;
18
- this.connectionTimeoutMs = params.connectionTimeout;
19
- this.wrtc = params.wrtc;
20
- // Use injected WebRTC polyfill or fall back to global
21
- this.RTCIceCandidate = params.wrtc?.RTCIceCandidate || globalThis.RTCIceCandidate;
22
- this.setupEventHandlers();
23
- this.startConnectionTimeout();
5
+ export class RondevuConnection {
6
+ /**
7
+ * Current connection state
8
+ */
9
+ get connectionState() {
10
+ return this.pc.connectionState;
11
+ }
12
+ /**
13
+ * The offer ID for this connection
14
+ */
15
+ get id() {
16
+ return this.offerId;
17
+ }
18
+ /**
19
+ * Get the primary data channel (if created)
20
+ */
21
+ get channel() {
22
+ return this.dataChannel;
23
+ }
24
+ constructor(offersApi, rtcConfig = {
25
+ iceServers: [
26
+ { urls: 'stun:stun.l.google.com:19302' },
27
+ { urls: 'stun:stun1.l.google.com:19302' }
28
+ ]
29
+ }) {
30
+ this.rtcConfig = rtcConfig;
31
+ this.lastIceTimestamp = Date.now();
32
+ this.eventListeners = new Map();
33
+ this.pendingIceCandidates = [];
34
+ this.offersApi = offersApi;
35
+ this.pc = new RTCPeerConnection(rtcConfig);
36
+ this.setupPeerConnection();
24
37
  }
25
38
  /**
26
- * Setup RTCPeerConnection event handlers
39
+ * Set up peer connection event handlers
27
40
  */
28
- setupEventHandlers() {
29
- // ICE candidate gathering
30
- this.pc.onicecandidate = (event) => {
31
- if (event.candidate && !this.isClosed) {
32
- this.sendIceCandidate(event.candidate).catch((err) => {
33
- this.emit('error', new Error(`Failed to send ICE candidate: ${err.message}`));
34
- });
41
+ setupPeerConnection() {
42
+ this.pc.onicecandidate = async (event) => {
43
+ if (event.candidate) {
44
+ // Convert RTCIceCandidate to RTCIceCandidateInit (plain object)
45
+ const candidateData = {
46
+ candidate: event.candidate.candidate,
47
+ sdpMid: event.candidate.sdpMid,
48
+ sdpMLineIndex: event.candidate.sdpMLineIndex,
49
+ usernameFragment: event.candidate.usernameFragment,
50
+ };
51
+ if (this.offerId) {
52
+ // offerId is set, send immediately (trickle ICE)
53
+ try {
54
+ await this.offersApi.addIceCandidates(this.offerId, [candidateData]);
55
+ }
56
+ catch (err) {
57
+ console.error('Error sending ICE candidate:', err);
58
+ }
59
+ }
60
+ else {
61
+ // offerId not set yet, buffer the candidate
62
+ this.pendingIceCandidates.push(candidateData);
63
+ }
35
64
  }
36
65
  };
37
- // Connection state changes
38
66
  this.pc.onconnectionstatechange = () => {
39
- this.handleConnectionStateChange();
67
+ switch (this.pc.connectionState) {
68
+ case 'connecting':
69
+ this.emit('connecting');
70
+ break;
71
+ case 'connected':
72
+ this.emit('connected');
73
+ // Stop polling once connected - we have all the ICE candidates we need
74
+ this.stopPolling();
75
+ break;
76
+ case 'disconnected':
77
+ case 'failed':
78
+ case 'closed':
79
+ this.emit('disconnected');
80
+ this.stopPolling();
81
+ break;
82
+ }
40
83
  };
41
- // Remote data channels
42
84
  this.pc.ondatachannel = (event) => {
43
- this.handleRemoteDataChannel(event.channel);
85
+ this.dataChannel = event.channel;
86
+ this.emit('datachannel', event.channel);
44
87
  };
45
- // Remote media streams
46
88
  this.pc.ontrack = (event) => {
47
- if (event.streams && event.streams[0]) {
48
- this.emit('stream', event.streams[0]);
49
- }
89
+ this.emit('track', event);
50
90
  };
51
- // ICE connection state changes
52
- this.pc.oniceconnectionstatechange = () => {
53
- const state = this.pc.iceConnectionState;
54
- if (state === 'failed' || state === 'closed') {
55
- this.emit('error', new Error(`ICE connection ${state}`));
56
- if (state === 'failed') {
57
- this.close();
58
- }
59
- }
91
+ this.pc.onicecandidateerror = (event) => {
92
+ console.error('ICE candidate error:', event);
60
93
  };
61
94
  }
62
95
  /**
63
- * Handle RTCPeerConnection state changes
96
+ * Flush buffered ICE candidates (trickle ICE support)
64
97
  */
65
- handleConnectionStateChange() {
66
- const state = this.pc.connectionState;
67
- switch (state) {
68
- case 'connected':
69
- this.clearConnectionTimeout();
70
- this.stopPolling();
71
- this.emit('connect');
72
- break;
73
- case 'disconnected':
74
- this.emit('disconnect');
75
- break;
76
- case 'failed':
77
- this.emit('error', new Error('Connection failed'));
78
- this.close();
79
- break;
80
- case 'closed':
81
- this.emit('disconnect');
82
- break;
98
+ async flushPendingIceCandidates() {
99
+ if (this.pendingIceCandidates.length > 0 && this.offerId) {
100
+ try {
101
+ await this.offersApi.addIceCandidates(this.offerId, this.pendingIceCandidates);
102
+ this.pendingIceCandidates = [];
103
+ }
104
+ catch (err) {
105
+ console.error('Error flushing pending ICE candidates:', err);
106
+ }
83
107
  }
84
108
  }
85
109
  /**
86
- * Send an ICE candidate to the remote peer via signaling server
110
+ * Create an offer and advertise on topics
87
111
  */
88
- async sendIceCandidate(candidate) {
89
- try {
90
- await this.client.sendAnswer({
91
- code: this.id,
92
- candidate: JSON.stringify(candidate.toJSON()),
93
- side: this.role,
94
- });
95
- }
96
- catch (err) {
97
- throw new Error(`Failed to send ICE candidate: ${err.message}`);
112
+ async createOffer(options) {
113
+ this.role = 'offerer';
114
+ // Create data channel if requested
115
+ if (options.createDataChannel !== false) {
116
+ this.dataChannel = this.pc.createDataChannel(options.dataChannelLabel || 'data');
117
+ this.emit('datachannel', this.dataChannel);
98
118
  }
119
+ // Create WebRTC offer
120
+ const offer = await this.pc.createOffer();
121
+ await this.pc.setLocalDescription(offer);
122
+ // Create offer on Rondevu server
123
+ const offers = await this.offersApi.create([{
124
+ sdp: offer.sdp,
125
+ topics: options.topics,
126
+ ttl: options.ttl || 300000
127
+ }]);
128
+ this.offerId = offers[0].id;
129
+ // Flush any ICE candidates that were generated during offer creation
130
+ await this.flushPendingIceCandidates();
131
+ // Start polling for answers
132
+ this.startAnswerPolling();
133
+ return this.offerId;
99
134
  }
100
135
  /**
101
- * Start polling for remote session data (answer/candidates)
136
+ * Answer an existing offer
102
137
  */
103
- startPolling() {
104
- if (this.isPolling || this.isClosed) {
105
- return;
106
- }
107
- this.isPolling = true;
108
- // Poll immediately
109
- this.poll().catch((err) => {
110
- this.emit('error', new Error(`Poll error: ${err.message}`));
138
+ async answer(offerId, offerSdp) {
139
+ this.role = 'answerer';
140
+ // Set remote description
141
+ await this.pc.setRemoteDescription({
142
+ type: 'offer',
143
+ sdp: offerSdp
111
144
  });
112
- // Set up interval polling
113
- this.pollingInterval = setInterval(() => {
114
- this.poll().catch((err) => {
115
- this.emit('error', new Error(`Poll error: ${err.message}`));
116
- });
117
- }, this.pollingIntervalMs);
145
+ // Create answer
146
+ const answer = await this.pc.createAnswer();
147
+ await this.pc.setLocalDescription(answer);
148
+ // Send answer to server FIRST
149
+ // This registers us as the answerer before ICE candidates arrive
150
+ await this.offersApi.answer(offerId, answer.sdp);
151
+ // Now set offerId to enable ICE candidate sending
152
+ // This prevents a race condition where ICE candidates arrive before answer is registered
153
+ this.offerId = offerId;
154
+ // Flush any ICE candidates that were generated during answer creation
155
+ await this.flushPendingIceCandidates();
156
+ // Start polling for ICE candidates
157
+ this.startIcePolling();
118
158
  }
119
159
  /**
120
- * Stop polling
160
+ * Start polling for answers (offerer only)
121
161
  */
122
- stopPolling() {
123
- this.isPolling = false;
124
- if (this.pollingInterval) {
125
- clearInterval(this.pollingInterval);
126
- this.pollingInterval = undefined;
127
- }
128
- }
129
- /**
130
- * Poll the signaling server for remote data
131
- */
132
- async poll() {
133
- if (this.isClosed) {
134
- this.stopPolling();
162
+ startAnswerPolling() {
163
+ if (this.role !== 'offerer' || !this.offerId)
135
164
  return;
136
- }
137
- try {
138
- const response = await this.client.poll(this.id, this.role);
139
- if (this.role === 'offerer') {
140
- const offererResponse = response;
141
- // Apply answer if received and not yet applied
142
- if (offererResponse.answer && !this.pc.currentRemoteDescription) {
165
+ this.answerPollingInterval = setInterval(async () => {
166
+ try {
167
+ const answers = await this.offersApi.getAnswers();
168
+ const myAnswer = answers.find(a => a.offerId === this.offerId);
169
+ if (myAnswer) {
170
+ // Set remote description
143
171
  await this.pc.setRemoteDescription({
144
172
  type: 'answer',
145
- sdp: offererResponse.answer,
173
+ sdp: myAnswer.sdp
146
174
  });
175
+ // Stop answer polling, start ICE polling
176
+ this.stopAnswerPolling();
177
+ this.startIcePolling();
147
178
  }
148
- // Apply ICE candidates
149
- if (offererResponse.answerCandidates && offererResponse.answerCandidates.length > 0) {
150
- for (const candidateStr of offererResponse.answerCandidates) {
151
- try {
152
- const candidate = JSON.parse(candidateStr);
153
- await this.pc.addIceCandidate(new this.RTCIceCandidate(candidate));
154
- }
155
- catch (err) {
156
- console.warn('Failed to add ICE candidate:', err);
157
- }
158
- }
179
+ }
180
+ catch (err) {
181
+ console.error('Error polling for answers:', err);
182
+ // Stop polling if offer expired/not found
183
+ if (err instanceof Error && err.message.includes('not found')) {
184
+ this.stopPolling();
159
185
  }
160
186
  }
161
- else {
162
- // Answerer role
163
- const answererResponse = response;
164
- // Apply ICE candidates from offerer
165
- if (answererResponse.offerCandidates && answererResponse.offerCandidates.length > 0) {
166
- for (const candidateStr of answererResponse.offerCandidates) {
167
- try {
168
- const candidate = JSON.parse(candidateStr);
169
- await this.pc.addIceCandidate(new this.RTCIceCandidate(candidate));
170
- }
171
- catch (err) {
172
- console.warn('Failed to add ICE candidate:', err);
173
- }
174
- }
187
+ }, 2000);
188
+ }
189
+ /**
190
+ * Start polling for ICE candidates
191
+ */
192
+ startIcePolling() {
193
+ if (!this.offerId)
194
+ return;
195
+ this.icePollingInterval = setInterval(async () => {
196
+ if (!this.offerId)
197
+ return;
198
+ try {
199
+ const candidates = await this.offersApi.getIceCandidates(this.offerId, this.lastIceTimestamp);
200
+ for (const cand of candidates) {
201
+ // Use the candidate object directly - it's already RTCIceCandidateInit
202
+ await this.pc.addIceCandidate(new RTCIceCandidate(cand.candidate));
203
+ this.lastIceTimestamp = cand.createdAt;
175
204
  }
176
205
  }
177
- }
178
- catch (err) {
179
- // Session not found or expired
180
- if (err.message.includes('404') || err.message.includes('not found')) {
181
- this.emit('error', new Error('Session not found or expired'));
182
- this.close();
206
+ catch (err) {
207
+ console.error('Error polling for ICE candidates:', err);
208
+ // Stop polling if offer expired/not found
209
+ if (err instanceof Error && err.message.includes('not found')) {
210
+ this.stopPolling();
211
+ }
183
212
  }
184
- throw err;
185
- }
213
+ }, 1000);
186
214
  }
187
215
  /**
188
- * Handle remotely created data channel
216
+ * Stop answer polling
189
217
  */
190
- handleRemoteDataChannel(channel) {
191
- this.dataChannels.set(channel.label, channel);
192
- this.emit('datachannel', channel);
218
+ stopAnswerPolling() {
219
+ if (this.answerPollingInterval) {
220
+ clearInterval(this.answerPollingInterval);
221
+ this.answerPollingInterval = undefined;
222
+ }
193
223
  }
194
224
  /**
195
- * Get or create a data channel
225
+ * Stop ICE polling
196
226
  */
197
- dataChannel(label, options) {
198
- let channel = this.dataChannels.get(label);
199
- if (!channel) {
200
- channel = this.pc.createDataChannel(label, options);
201
- this.dataChannels.set(label, channel);
227
+ stopIcePolling() {
228
+ if (this.icePollingInterval) {
229
+ clearInterval(this.icePollingInterval);
230
+ this.icePollingInterval = undefined;
202
231
  }
203
- return channel;
204
232
  }
205
233
  /**
206
- * Add a local media stream to the connection
234
+ * Stop all polling
207
235
  */
208
- addStream(stream) {
209
- stream.getTracks().forEach(track => {
210
- this.pc.addTrack(track, stream);
211
- });
236
+ stopPolling() {
237
+ this.stopAnswerPolling();
238
+ this.stopIcePolling();
212
239
  }
213
240
  /**
214
- * Get the underlying RTCPeerConnection for advanced usage
241
+ * Add event listener
215
242
  */
216
- getPeerConnection() {
217
- return this.pc;
243
+ on(event, listener) {
244
+ if (!this.eventListeners.has(event)) {
245
+ this.eventListeners.set(event, new Set());
246
+ }
247
+ this.eventListeners.get(event).add(listener);
218
248
  }
219
249
  /**
220
- * Start connection timeout
250
+ * Remove event listener
221
251
  */
222
- startConnectionTimeout() {
223
- this.connectionTimer = setTimeout(() => {
224
- if (this.pc.connectionState !== 'connected') {
225
- this.emit('error', new Error('Connection timeout'));
226
- this.close();
227
- }
228
- }, this.connectionTimeoutMs);
252
+ off(event, listener) {
253
+ const listeners = this.eventListeners.get(event);
254
+ if (listeners) {
255
+ listeners.delete(listener);
256
+ }
229
257
  }
230
258
  /**
231
- * Clear connection timeout
259
+ * Emit event
232
260
  */
233
- clearConnectionTimeout() {
234
- if (this.connectionTimer) {
235
- clearTimeout(this.connectionTimer);
236
- this.connectionTimer = undefined;
261
+ emit(event, ...args) {
262
+ const listeners = this.eventListeners.get(event);
263
+ if (listeners) {
264
+ listeners.forEach(listener => {
265
+ listener(...args);
266
+ });
237
267
  }
238
268
  }
239
269
  /**
240
- * Close the connection and cleanup resources
270
+ * Add a media track to the connection
271
+ */
272
+ addTrack(track, ...streams) {
273
+ return this.pc.addTrack(track, ...streams);
274
+ }
275
+ /**
276
+ * Close the connection and clean up
241
277
  */
242
278
  close() {
243
- if (this.isClosed) {
244
- return;
245
- }
246
- this.isClosed = true;
247
279
  this.stopPolling();
248
- this.clearConnectionTimeout();
249
- // Close all data channels
250
- this.dataChannels.forEach(dc => {
251
- if (dc.readyState === 'open' || dc.readyState === 'connecting') {
252
- dc.close();
253
- }
254
- });
255
- this.dataChannels.clear();
256
- // Close peer connection
257
- if (this.pc.connectionState !== 'closed') {
258
- this.pc.close();
259
- }
260
- this.emit('disconnect');
280
+ this.pc.close();
281
+ this.eventListeners.clear();
261
282
  }
262
283
  }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  /**
2
2
  * @xtr-dev/rondevu-client
3
- * WebRTC peer signaling and discovery client
3
+ * WebRTC peer signaling and discovery client with topic-based discovery
4
4
  */
5
5
  export { Rondevu } from './rondevu.js';
6
+ export type { RondevuOptions } from './rondevu.js';
7
+ export { RondevuAuth } from './auth.js';
8
+ export type { Credentials, FetchFunction } from './auth.js';
9
+ export { RondevuOffers } from './offers.js';
10
+ export type { CreateOfferRequest, Offer, IceCandidate, TopicInfo } from './offers.js';
11
+ export { BloomFilter } from './bloom.js';
6
12
  export { RondevuConnection } from './connection.js';
7
- export { RondevuAPI } from './client.js';
8
- export type { RondevuOptions, ConnectionRole, RondevuConnectionParams, RondevuConnectionEvents, WebRTCPolyfill, Side, CreateOfferRequest, CreateOfferResponse, AnswerRequest, AnswerResponse, PollRequest, PollOffererResponse, PollAnswererResponse, PollResponse, VersionResponse, HealthResponse, ErrorResponse, RondevuClientOptions, } from './types.js';
13
+ export type { ConnectionOptions, RondevuConnectionEvents } from './connection.js';
package/dist/index.js CHANGED
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * @xtr-dev/rondevu-client
3
- * WebRTC peer signaling and discovery client
3
+ * WebRTC peer signaling and discovery client with topic-based discovery
4
4
  */
5
- // Export main WebRTC client class
5
+ // Export main client class
6
6
  export { Rondevu } from './rondevu.js';
7
- // Export connection class
7
+ // Export authentication
8
+ export { RondevuAuth } from './auth.js';
9
+ // Export offers API
10
+ export { RondevuOffers } from './offers.js';
11
+ // Export bloom filter
12
+ export { BloomFilter } from './bloom.js';
13
+ // Export connection manager
8
14
  export { RondevuConnection } from './connection.js';
9
- // Export low-level signaling API (for advanced usage)
10
- export { RondevuAPI } from './client.js';
@@ -0,0 +1,108 @@
1
+ import { Credentials, FetchFunction } from './auth.js';
2
+ export interface CreateOfferRequest {
3
+ id?: string;
4
+ sdp: string;
5
+ topics: string[];
6
+ ttl?: number;
7
+ }
8
+ export interface Offer {
9
+ id: string;
10
+ peerId: string;
11
+ sdp: string;
12
+ topics: string[];
13
+ createdAt?: number;
14
+ expiresAt: number;
15
+ lastSeen: number;
16
+ answererPeerId?: string;
17
+ answerSdp?: string;
18
+ answeredAt?: number;
19
+ }
20
+ /**
21
+ * RTCIceCandidateInit interface for environments without native WebRTC types
22
+ */
23
+ export interface RTCIceCandidateInit {
24
+ candidate?: string;
25
+ sdpMid?: string | null;
26
+ sdpMLineIndex?: number | null;
27
+ usernameFragment?: string | null;
28
+ }
29
+ export interface IceCandidate {
30
+ candidate: RTCIceCandidateInit;
31
+ peerId: string;
32
+ role: 'offerer' | 'answerer';
33
+ createdAt: number;
34
+ }
35
+ export interface TopicInfo {
36
+ topic: string;
37
+ activePeers: number;
38
+ }
39
+ export declare class RondevuOffers {
40
+ private baseUrl;
41
+ private credentials;
42
+ private fetchFn;
43
+ constructor(baseUrl: string, credentials: Credentials, fetchFn?: FetchFunction);
44
+ /**
45
+ * Create one or more offers
46
+ */
47
+ create(offers: CreateOfferRequest[]): Promise<Offer[]>;
48
+ /**
49
+ * Find offers by topic with optional bloom filter
50
+ */
51
+ findByTopic(topic: string, options?: {
52
+ bloomFilter?: Uint8Array;
53
+ limit?: number;
54
+ }): Promise<Offer[]>;
55
+ /**
56
+ * Get all offers from a specific peer
57
+ */
58
+ getByPeerId(peerId: string): Promise<{
59
+ offers: Offer[];
60
+ topics: string[];
61
+ }>;
62
+ /**
63
+ * Get topics with active peer counts (paginated)
64
+ */
65
+ getTopics(options?: {
66
+ limit?: number;
67
+ offset?: number;
68
+ }): Promise<{
69
+ topics: TopicInfo[];
70
+ total: number;
71
+ limit: number;
72
+ offset: number;
73
+ }>;
74
+ /**
75
+ * Get own offers
76
+ */
77
+ getMine(): Promise<Offer[]>;
78
+ /**
79
+ * Update offer heartbeat
80
+ */
81
+ heartbeat(offerId: string): Promise<void>;
82
+ /**
83
+ * Delete an offer
84
+ */
85
+ delete(offerId: string): Promise<void>;
86
+ /**
87
+ * Answer an offer
88
+ */
89
+ answer(offerId: string, sdp: string): Promise<void>;
90
+ /**
91
+ * Get answers to your offers
92
+ */
93
+ getAnswers(): Promise<Array<{
94
+ offerId: string;
95
+ answererId: string;
96
+ sdp: string;
97
+ answeredAt: number;
98
+ topics: string[];
99
+ }>>;
100
+ /**
101
+ * Post ICE candidates for an offer
102
+ */
103
+ addIceCandidates(offerId: string, candidates: RTCIceCandidateInit[]): Promise<void>;
104
+ /**
105
+ * Get ICE candidates for an offer
106
+ */
107
+ getIceCandidates(offerId: string, since?: number): Promise<IceCandidate[]>;
108
+ }