@xtr-dev/rondevu-client 0.3.5 → 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,280 +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.hasConnected = false;
11
- this.id = params.id;
12
- this.role = params.role;
13
- this.pc = params.pc;
14
- this.localPeerId = params.localPeerId;
15
- this.remotePeerId = params.remotePeerId;
16
- this.client = client;
17
- this.dataChannels = new Map();
18
- this.pollingIntervalMs = params.pollingInterval;
19
- this.connectionTimeoutMs = params.connectionTimeout;
20
- this.wrtc = params.wrtc;
21
- // Use injected WebRTC polyfill or fall back to global
22
- this.RTCIceCandidate = params.wrtc?.RTCIceCandidate || globalThis.RTCIceCandidate;
23
- this.setupEventHandlers();
24
- 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;
25
17
  }
26
18
  /**
27
- * Setup RTCPeerConnection event handlers
19
+ * Get the primary data channel (if created)
28
20
  */
29
- setupEventHandlers() {
30
- // ICE candidate gathering
31
- this.pc.onicecandidate = (event) => {
32
- if (event.candidate && !this.isClosed) {
33
- this.sendIceCandidate(event.candidate).catch((err) => {
34
- this.emit('error', new Error(`Failed to send ICE candidate: ${err.message}`));
35
- });
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();
37
+ }
38
+ /**
39
+ * Set up peer connection event handlers
40
+ */
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
+ }
36
64
  }
37
65
  };
38
- // Connection state changes
39
66
  this.pc.onconnectionstatechange = () => {
40
- 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
+ }
41
83
  };
42
- // Remote data channels
43
84
  this.pc.ondatachannel = (event) => {
44
- this.handleRemoteDataChannel(event.channel);
85
+ this.dataChannel = event.channel;
86
+ this.emit('datachannel', event.channel);
45
87
  };
46
- // Remote media streams
47
88
  this.pc.ontrack = (event) => {
48
- if (event.streams && event.streams[0]) {
49
- this.emit('stream', event.streams[0]);
50
- }
89
+ this.emit('track', event);
51
90
  };
52
- // ICE connection state changes
53
- this.pc.oniceconnectionstatechange = () => {
54
- const state = this.pc.iceConnectionState;
55
- if (state === 'failed' || state === 'closed') {
56
- this.emit('error', new Error(`ICE connection ${state}`));
57
- if (state === 'failed') {
58
- this.close();
59
- }
60
- }
91
+ this.pc.onicecandidateerror = (event) => {
92
+ console.error('ICE candidate error:', event);
61
93
  };
62
94
  }
63
95
  /**
64
- * Handle RTCPeerConnection state changes
96
+ * Flush buffered ICE candidates (trickle ICE support)
65
97
  */
66
- handleConnectionStateChange() {
67
- const state = this.pc.connectionState;
68
- switch (state) {
69
- case 'connected':
70
- if (!this.hasConnected) {
71
- this.hasConnected = true;
72
- this.clearConnectionTimeout();
73
- this.stopPolling();
74
- this.emit('connect');
75
- }
76
- break;
77
- case 'disconnected':
78
- this.emit('disconnect');
79
- break;
80
- case 'failed':
81
- this.emit('error', new Error('Connection failed'));
82
- this.close();
83
- break;
84
- case 'closed':
85
- this.emit('disconnect');
86
- 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
+ }
87
107
  }
88
108
  }
89
109
  /**
90
- * Send an ICE candidate to the remote peer via signaling server
110
+ * Create an offer and advertise on topics
91
111
  */
92
- async sendIceCandidate(candidate) {
93
- try {
94
- await this.client.sendAnswer({
95
- code: this.id,
96
- candidate: JSON.stringify(candidate.toJSON()),
97
- side: this.role,
98
- });
99
- }
100
- catch (err) {
101
- 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);
102
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;
103
134
  }
104
135
  /**
105
- * Start polling for remote session data (answer/candidates)
136
+ * Answer an existing offer
106
137
  */
107
- startPolling() {
108
- if (this.isPolling || this.isClosed) {
109
- return;
110
- }
111
- this.isPolling = true;
112
- // Poll immediately
113
- this.poll().catch((err) => {
114
- 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
115
144
  });
116
- // Set up interval polling
117
- this.pollingInterval = setInterval(() => {
118
- this.poll().catch((err) => {
119
- this.emit('error', new Error(`Poll error: ${err.message}`));
120
- });
121
- }, this.pollingIntervalMs);
122
- }
123
- /**
124
- * Stop polling
125
- */
126
- stopPolling() {
127
- this.isPolling = false;
128
- if (this.pollingInterval) {
129
- clearInterval(this.pollingInterval);
130
- this.pollingInterval = undefined;
131
- }
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();
132
158
  }
133
159
  /**
134
- * Poll the signaling server for remote data
160
+ * Start polling for answers (offerer only)
135
161
  */
136
- async poll() {
137
- if (this.isClosed) {
138
- this.stopPolling();
162
+ startAnswerPolling() {
163
+ if (this.role !== 'offerer' || !this.offerId)
139
164
  return;
140
- }
141
- try {
142
- const response = await this.client.poll(this.id, this.role);
143
- if (this.role === 'offerer') {
144
- const offererResponse = response;
145
- // Apply answer if received and not yet applied
146
- 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
147
171
  await this.pc.setRemoteDescription({
148
172
  type: 'answer',
149
- sdp: offererResponse.answer,
173
+ sdp: myAnswer.sdp
150
174
  });
175
+ // Stop answer polling, start ICE polling
176
+ this.stopAnswerPolling();
177
+ this.startIcePolling();
151
178
  }
152
- // Apply ICE candidates
153
- if (offererResponse.answerCandidates && offererResponse.answerCandidates.length > 0) {
154
- for (const candidateStr of offererResponse.answerCandidates) {
155
- try {
156
- const candidate = JSON.parse(candidateStr);
157
- await this.pc.addIceCandidate(new this.RTCIceCandidate(candidate));
158
- }
159
- catch (err) {
160
- console.warn('Failed to add ICE candidate:', err);
161
- }
162
- }
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();
163
185
  }
164
186
  }
165
- else {
166
- // Answerer role
167
- const answererResponse = response;
168
- // Apply ICE candidates from offerer
169
- if (answererResponse.offerCandidates && answererResponse.offerCandidates.length > 0) {
170
- for (const candidateStr of answererResponse.offerCandidates) {
171
- try {
172
- const candidate = JSON.parse(candidateStr);
173
- await this.pc.addIceCandidate(new this.RTCIceCandidate(candidate));
174
- }
175
- catch (err) {
176
- console.warn('Failed to add ICE candidate:', err);
177
- }
178
- }
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;
179
204
  }
180
205
  }
181
- }
182
- catch (err) {
183
- // Session not found or expired
184
- if (err.message.includes('404') || err.message.includes('not found')) {
185
- this.emit('error', new Error('Session not found or expired'));
186
- 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
+ }
187
212
  }
188
- throw err;
189
- }
213
+ }, 1000);
190
214
  }
191
215
  /**
192
- * Handle remotely created data channel
216
+ * Stop answer polling
193
217
  */
194
- handleRemoteDataChannel(channel) {
195
- this.dataChannels.set(channel.label, channel);
196
- this.emit('datachannel', channel);
218
+ stopAnswerPolling() {
219
+ if (this.answerPollingInterval) {
220
+ clearInterval(this.answerPollingInterval);
221
+ this.answerPollingInterval = undefined;
222
+ }
197
223
  }
198
224
  /**
199
- * Get or create a data channel
225
+ * Stop ICE polling
200
226
  */
201
- dataChannel(label, options) {
202
- let channel = this.dataChannels.get(label);
203
- if (!channel) {
204
- channel = this.pc.createDataChannel(label, options);
205
- this.dataChannels.set(label, channel);
227
+ stopIcePolling() {
228
+ if (this.icePollingInterval) {
229
+ clearInterval(this.icePollingInterval);
230
+ this.icePollingInterval = undefined;
206
231
  }
207
- return channel;
208
232
  }
209
233
  /**
210
- * Add a local media stream to the connection
234
+ * Stop all polling
211
235
  */
212
- addStream(stream) {
213
- stream.getTracks().forEach(track => {
214
- this.pc.addTrack(track, stream);
215
- });
236
+ stopPolling() {
237
+ this.stopAnswerPolling();
238
+ this.stopIcePolling();
216
239
  }
217
240
  /**
218
- * Get the underlying RTCPeerConnection for advanced usage
241
+ * Add event listener
219
242
  */
220
- getPeerConnection() {
221
- 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);
222
248
  }
223
249
  /**
224
- * Start connection timeout
250
+ * Remove event listener
225
251
  */
226
- startConnectionTimeout() {
227
- this.connectionTimer = setTimeout(() => {
228
- if (this.pc.connectionState !== 'connected') {
229
- this.emit('error', new Error('Connection timeout'));
230
- this.close();
231
- }
232
- }, this.connectionTimeoutMs);
252
+ off(event, listener) {
253
+ const listeners = this.eventListeners.get(event);
254
+ if (listeners) {
255
+ listeners.delete(listener);
256
+ }
233
257
  }
234
258
  /**
235
- * Clear connection timeout
259
+ * Emit event
236
260
  */
237
- clearConnectionTimeout() {
238
- if (this.connectionTimer) {
239
- clearTimeout(this.connectionTimer);
240
- 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
+ });
241
267
  }
242
268
  }
243
269
  /**
244
- * Leave the session by deleting the offer on the server and closing the connection
245
- * This ends the session for all connected peers
270
+ * Add a media track to the connection
246
271
  */
247
- async leave() {
248
- try {
249
- await this.client.leave(this.id);
250
- }
251
- catch (err) {
252
- // Ignore errors - session might already be expired
253
- console.debug('Leave error (ignored):', err);
254
- }
255
- this.close();
272
+ addTrack(track, ...streams) {
273
+ return this.pc.addTrack(track, ...streams);
256
274
  }
257
275
  /**
258
- * Close the connection and cleanup resources
276
+ * Close the connection and clean up
259
277
  */
260
278
  close() {
261
- if (this.isClosed) {
262
- return;
263
- }
264
- this.isClosed = true;
265
279
  this.stopPolling();
266
- this.clearConnectionTimeout();
267
- // Close all data channels
268
- this.dataChannels.forEach(dc => {
269
- if (dc.readyState === 'open' || dc.readyState === 'connecting') {
270
- dc.close();
271
- }
272
- });
273
- this.dataChannels.clear();
274
- // Close peer connection
275
- if (this.pc.connectionState !== 'closed') {
276
- this.pc.close();
277
- }
278
- this.emit('disconnect');
280
+ this.pc.close();
281
+ this.eventListeners.clear();
279
282
  }
280
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
+ }