@xtr-dev/rondevu-client 0.20.1 → 0.21.3
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.
- package/README.md +83 -385
- package/dist/api/batcher.d.ts +60 -38
- package/dist/api/batcher.js +121 -77
- package/dist/api/client.d.ts +104 -61
- package/dist/api/client.js +273 -185
- package/dist/connections/answerer.d.ts +15 -6
- package/dist/connections/answerer.js +56 -19
- package/dist/connections/base.d.ts +6 -4
- package/dist/connections/base.js +26 -16
- package/dist/connections/config.d.ts +30 -0
- package/dist/connections/config.js +20 -0
- package/dist/connections/events.d.ts +6 -6
- package/dist/connections/offerer.d.ts +37 -8
- package/dist/connections/offerer.js +92 -24
- package/dist/core/ice-config.d.ts +35 -0
- package/dist/core/ice-config.js +111 -0
- package/dist/core/index.d.ts +18 -18
- package/dist/core/index.js +18 -13
- package/dist/core/offer-pool.d.ts +30 -11
- package/dist/core/offer-pool.js +90 -76
- package/dist/core/peer.d.ts +158 -0
- package/dist/core/peer.js +254 -0
- package/dist/core/polling-manager.d.ts +71 -0
- package/dist/core/polling-manager.js +122 -0
- package/dist/core/rondevu-errors.d.ts +59 -0
- package/dist/core/rondevu-errors.js +75 -0
- package/dist/core/rondevu-types.d.ts +125 -0
- package/dist/core/rondevu-types.js +6 -0
- package/dist/core/rondevu.d.ts +106 -209
- package/dist/core/rondevu.js +222 -349
- package/dist/crypto/adapter.d.ts +25 -9
- package/dist/crypto/node.d.ts +27 -5
- package/dist/crypto/node.js +96 -25
- package/dist/crypto/web.d.ts +26 -4
- package/dist/crypto/web.js +102 -25
- package/dist/utils/message-buffer.js +4 -4
- package/dist/webrtc/adapter.d.ts +22 -0
- package/dist/webrtc/adapter.js +5 -0
- package/dist/webrtc/browser.d.ts +12 -0
- package/dist/webrtc/browser.js +15 -0
- package/dist/webrtc/node.d.ts +32 -0
- package/dist/webrtc/node.js +32 -0
- package/package.json +17 -6
package/dist/core/offer-pool.js
CHANGED
|
@@ -2,7 +2,7 @@ import { EventEmitter } from 'eventemitter3';
|
|
|
2
2
|
import { OffererConnection } from '../connections/offerer.js';
|
|
3
3
|
import { AsyncLock } from '../utils/async-lock.js';
|
|
4
4
|
/**
|
|
5
|
-
* OfferPool manages a pool of WebRTC offers for
|
|
5
|
+
* OfferPool manages a pool of WebRTC offers for published tags.
|
|
6
6
|
* Maintains a target number of active offers and automatically replaces
|
|
7
7
|
* offers that fail or get answered.
|
|
8
8
|
*/
|
|
@@ -13,19 +13,21 @@ export class OfferPool extends EventEmitter {
|
|
|
13
13
|
this.activeConnections = new Map();
|
|
14
14
|
this.fillLock = new AsyncLock();
|
|
15
15
|
this.running = false;
|
|
16
|
-
this.pollingInterval = null;
|
|
17
|
-
this.lastPollTimestamp = 0;
|
|
18
16
|
this.api = options.api;
|
|
19
|
-
this.
|
|
17
|
+
this.tags = options.tags;
|
|
18
|
+
this.ownerUsername = options.ownerUsername;
|
|
19
|
+
this.webrtcAdapter = options.webrtcAdapter;
|
|
20
20
|
this.maxOffers = options.maxOffers;
|
|
21
21
|
this.offerFactory = options.offerFactory;
|
|
22
22
|
this.ttl = options.ttl;
|
|
23
23
|
this.iceServers = options.iceServers;
|
|
24
|
+
this.iceTransportPolicy = options.iceTransportPolicy;
|
|
24
25
|
this.connectionConfig = options.connectionConfig;
|
|
25
26
|
this.debugEnabled = options.debugEnabled || false;
|
|
26
27
|
}
|
|
27
28
|
/**
|
|
28
|
-
* Start filling offers
|
|
29
|
+
* Start filling offers
|
|
30
|
+
* Polling is managed externally by Rondevu's PollingManager
|
|
29
31
|
*/
|
|
30
32
|
async start() {
|
|
31
33
|
if (this.running) {
|
|
@@ -36,23 +38,14 @@ export class OfferPool extends EventEmitter {
|
|
|
36
38
|
this.running = true;
|
|
37
39
|
// Fill initial offers
|
|
38
40
|
await this.fillOffers();
|
|
39
|
-
// Start polling for answers
|
|
40
|
-
this.pollingInterval = setInterval(() => {
|
|
41
|
-
this.pollInternal();
|
|
42
|
-
}, OfferPool.POLLING_INTERVAL_MS);
|
|
43
41
|
}
|
|
44
42
|
/**
|
|
45
|
-
* Stop filling offers
|
|
43
|
+
* Stop filling offers
|
|
46
44
|
* Closes all active connections
|
|
47
45
|
*/
|
|
48
46
|
stop() {
|
|
49
47
|
this.debug('Stopping offer pool');
|
|
50
48
|
this.running = false;
|
|
51
|
-
// Stop polling
|
|
52
|
-
if (this.pollingInterval) {
|
|
53
|
-
clearInterval(this.pollingInterval);
|
|
54
|
-
this.pollingInterval = null;
|
|
55
|
-
}
|
|
56
49
|
// Close all active connections
|
|
57
50
|
for (const [offerId, connection] of this.activeConnections.entries()) {
|
|
58
51
|
if (connection.isRotating()) {
|
|
@@ -115,16 +108,30 @@ export class OfferPool extends EventEmitter {
|
|
|
115
108
|
});
|
|
116
109
|
}
|
|
117
110
|
/**
|
|
118
|
-
* Create
|
|
119
|
-
*
|
|
111
|
+
* Create and publish an offer to the server.
|
|
112
|
+
* Shared logic used by both createOffer() and createNewOfferForRotation().
|
|
113
|
+
*
|
|
114
|
+
* @returns The offer ID, RTCPeerConnection, and optional data channel
|
|
120
115
|
*/
|
|
121
|
-
async
|
|
116
|
+
async createOfferAndPublish() {
|
|
122
117
|
const rtcConfig = {
|
|
123
|
-
iceServers: this.iceServers
|
|
118
|
+
iceServers: this.iceServers,
|
|
119
|
+
iceTransportPolicy: this.iceTransportPolicy,
|
|
120
|
+
};
|
|
121
|
+
// 1. Create RTCPeerConnection using adapter
|
|
122
|
+
const pc = this.webrtcAdapter.createPeerConnection(rtcConfig);
|
|
123
|
+
// Collect ICE candidates during offer creation
|
|
124
|
+
// We need to set this up BEFORE setLocalDescription is called
|
|
125
|
+
const collectedCandidates = [];
|
|
126
|
+
pc.onicecandidate = event => {
|
|
127
|
+
if (event.candidate) {
|
|
128
|
+
collectedCandidates.push({
|
|
129
|
+
candidate: event.candidate.candidate,
|
|
130
|
+
sdpMLineIndex: event.candidate.sdpMLineIndex,
|
|
131
|
+
sdpMid: event.candidate.sdpMid,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
124
134
|
};
|
|
125
|
-
this.debug('Creating new offer for rotation...');
|
|
126
|
-
// 1. Create RTCPeerConnection
|
|
127
|
-
const pc = new RTCPeerConnection(rtcConfig);
|
|
128
135
|
// 2. Call the factory to create offer
|
|
129
136
|
let dc;
|
|
130
137
|
let offer;
|
|
@@ -138,51 +145,45 @@ export class OfferPool extends EventEmitter {
|
|
|
138
145
|
throw err;
|
|
139
146
|
}
|
|
140
147
|
// 3. Publish to server to get offerId
|
|
141
|
-
const result = await this.api.
|
|
142
|
-
|
|
148
|
+
const result = await this.api.publish({
|
|
149
|
+
tags: this.tags,
|
|
143
150
|
offers: [{ sdp: offer.sdp }],
|
|
144
151
|
ttl: this.ttl,
|
|
145
152
|
});
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
153
|
+
const offerId = result.offers[0].offerId;
|
|
154
|
+
// 4. Send any ICE candidates we've already collected
|
|
155
|
+
if (collectedCandidates.length > 0) {
|
|
156
|
+
this.debug(`Sending ${collectedCandidates.length} early ICE candidates for offer ${offerId}`);
|
|
157
|
+
this.api.addOfferIceCandidates(offerId, collectedCandidates).catch(err => {
|
|
158
|
+
this.debug('Failed to send early ICE candidates:', err);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return { offerId, pc, dc };
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Create a new offer for rotation (reuses existing creation logic)
|
|
165
|
+
* Similar to createOffer() but only creates the offer, doesn't create connection
|
|
166
|
+
*/
|
|
167
|
+
async createNewOfferForRotation() {
|
|
168
|
+
this.debug('Creating new offer for rotation...');
|
|
169
|
+
const { offerId, pc, dc } = await this.createOfferAndPublish();
|
|
170
|
+
this.debug(`New offer created for rotation: ${offerId}`);
|
|
171
|
+
return { newOfferId: offerId, pc, dc };
|
|
149
172
|
}
|
|
150
173
|
/**
|
|
151
174
|
* Create a single offer and publish it to the server
|
|
152
175
|
*/
|
|
153
176
|
async createOffer() {
|
|
154
|
-
const rtcConfig = {
|
|
155
|
-
iceServers: this.iceServers
|
|
156
|
-
};
|
|
157
177
|
this.debug('Creating new offer...');
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
// 2. Call the factory to create offer
|
|
161
|
-
let dc;
|
|
162
|
-
let offer;
|
|
163
|
-
try {
|
|
164
|
-
const factoryResult = await this.offerFactory(pc);
|
|
165
|
-
dc = factoryResult.dc;
|
|
166
|
-
offer = factoryResult.offer;
|
|
167
|
-
}
|
|
168
|
-
catch (err) {
|
|
169
|
-
pc.close();
|
|
170
|
-
throw err;
|
|
171
|
-
}
|
|
172
|
-
// 3. Publish to server to get offerId
|
|
173
|
-
const result = await this.api.publishService({
|
|
174
|
-
serviceFqn: this.serviceFqn,
|
|
175
|
-
offers: [{ sdp: offer.sdp }],
|
|
176
|
-
ttl: this.ttl,
|
|
177
|
-
});
|
|
178
|
-
const offerId = result.offers[0].offerId;
|
|
179
|
-
// 4. Create OffererConnection instance
|
|
178
|
+
const { offerId, pc, dc } = await this.createOfferAndPublish();
|
|
179
|
+
// Create OffererConnection instance
|
|
180
180
|
const connection = new OffererConnection({
|
|
181
181
|
api: this.api,
|
|
182
|
-
|
|
182
|
+
ownerUsername: this.ownerUsername,
|
|
183
183
|
offerId,
|
|
184
184
|
pc,
|
|
185
185
|
dc,
|
|
186
|
+
webrtcAdapter: this.webrtcAdapter,
|
|
186
187
|
config: {
|
|
187
188
|
...this.connectionConfig,
|
|
188
189
|
debug: this.debugEnabled,
|
|
@@ -195,7 +196,14 @@ export class OfferPool extends EventEmitter {
|
|
|
195
196
|
});
|
|
196
197
|
connection.on('failed', async (error) => {
|
|
197
198
|
const currentOfferId = connection.getOfferId();
|
|
198
|
-
this.debug(`Connection failed for offer ${currentOfferId}
|
|
199
|
+
this.debug(`Connection failed for offer ${currentOfferId}`);
|
|
200
|
+
// Double-check connection state before rotating
|
|
201
|
+
// (polling events may have already recovered the connection)
|
|
202
|
+
if (connection.getState() !== 'failed') {
|
|
203
|
+
this.debug(`Connection ${currentOfferId} recovered, skipping rotation`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
this.debug(`Proceeding with rotation for offer ${currentOfferId}`);
|
|
199
207
|
try {
|
|
200
208
|
// Create new offer and rebind existing connection
|
|
201
209
|
const { newOfferId, pc, dc } = await this.createNewOfferForRotation();
|
|
@@ -225,35 +233,42 @@ export class OfferPool extends EventEmitter {
|
|
|
225
233
|
// Initialize the connection
|
|
226
234
|
await connection.initialize();
|
|
227
235
|
this.debug(`Offer created: ${offerId}`);
|
|
228
|
-
this.emit('offer:created', offerId, this.
|
|
236
|
+
this.emit('offer:created', offerId, this.tags);
|
|
229
237
|
}
|
|
230
238
|
/**
|
|
231
|
-
*
|
|
239
|
+
* Handle poll:answer event from PollingManager
|
|
240
|
+
* Called by Rondevu when a poll:answer event is received
|
|
232
241
|
*/
|
|
233
|
-
async
|
|
242
|
+
async handlePollAnswer(data) {
|
|
234
243
|
if (!this.running)
|
|
235
244
|
return;
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
this.fillOffers();
|
|
247
|
-
}
|
|
248
|
-
catch (err) {
|
|
249
|
-
this.debug(`Failed to process answer for offer ${answer.offerId}:`, err);
|
|
250
|
-
}
|
|
251
|
-
}
|
|
245
|
+
const connection = this.activeConnections.get(data.offerId);
|
|
246
|
+
if (connection) {
|
|
247
|
+
this.debug(`Processing answer for offer ${data.offerId}`);
|
|
248
|
+
try {
|
|
249
|
+
await connection.processAnswer(data.sdp, data.answererId);
|
|
250
|
+
// Create replacement offer
|
|
251
|
+
this.fillOffers();
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
this.debug(`Failed to process answer for offer ${data.offerId}:`, err);
|
|
252
255
|
}
|
|
253
256
|
}
|
|
254
|
-
|
|
255
|
-
|
|
257
|
+
// Silently ignore answers for offers we don't have - they may be for other connections
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Handle poll:ice event from PollingManager
|
|
261
|
+
* Called by Rondevu when a poll:ice event is received
|
|
262
|
+
*/
|
|
263
|
+
handlePollIce(data) {
|
|
264
|
+
if (!this.running)
|
|
265
|
+
return;
|
|
266
|
+
const connection = this.activeConnections.get(data.offerId);
|
|
267
|
+
if (connection) {
|
|
268
|
+
this.debug(`Processing ${data.candidates.length} ICE candidates for offer ${data.offerId}`);
|
|
269
|
+
connection.handleRemoteIceCandidates(data.candidates);
|
|
256
270
|
}
|
|
271
|
+
// Silently ignore ICE candidates for offers we don't have
|
|
257
272
|
}
|
|
258
273
|
/**
|
|
259
274
|
* Debug logging (only if debug enabled)
|
|
@@ -264,4 +279,3 @@ export class OfferPool extends EventEmitter {
|
|
|
264
279
|
}
|
|
265
280
|
}
|
|
266
281
|
}
|
|
267
|
-
OfferPool.POLLING_INTERVAL_MS = 1000;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peer - Clean DX wrapper for peer-to-peer connections
|
|
3
|
+
*
|
|
4
|
+
* Provides a simple interface for connecting to a peer by tags/username,
|
|
5
|
+
* with automatic reconnection and message buffering.
|
|
6
|
+
*/
|
|
7
|
+
import { EventEmitter } from 'eventemitter3';
|
|
8
|
+
import { RondevuAPI } from '../api/client.js';
|
|
9
|
+
import { AnswererConnection } from '../connections/answerer.js';
|
|
10
|
+
import { ConnectionConfig } from '../connections/config.js';
|
|
11
|
+
import { WebRTCAdapter } from '../webrtc/adapter.js';
|
|
12
|
+
/**
|
|
13
|
+
* Simplified peer state (maps from ConnectionState)
|
|
14
|
+
*/
|
|
15
|
+
export type PeerState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting' | 'failed' | 'closed';
|
|
16
|
+
/**
|
|
17
|
+
* Event map for Peer
|
|
18
|
+
*/
|
|
19
|
+
export interface PeerEventMap {
|
|
20
|
+
/** Emitted when connection state changes */
|
|
21
|
+
state: [state: PeerState, previousState: PeerState];
|
|
22
|
+
/** Emitted when connection is established */
|
|
23
|
+
open: [];
|
|
24
|
+
/** Emitted when connection is closed */
|
|
25
|
+
close: [reason?: string];
|
|
26
|
+
/** Emitted when a message is received */
|
|
27
|
+
message: [data: string | ArrayBuffer | Blob];
|
|
28
|
+
/** Emitted when an error occurs */
|
|
29
|
+
error: [error: Error];
|
|
30
|
+
/** Emitted when reconnection is attempted */
|
|
31
|
+
reconnecting: [attempt: number, maxAttempts: number];
|
|
32
|
+
}
|
|
33
|
+
export type PeerEventName = keyof PeerEventMap;
|
|
34
|
+
/**
|
|
35
|
+
* Options for creating a Peer connection
|
|
36
|
+
*/
|
|
37
|
+
export interface PeerOptions {
|
|
38
|
+
/** Tags to match for peer discovery */
|
|
39
|
+
tags: string[];
|
|
40
|
+
/** Optional: connect to specific username */
|
|
41
|
+
username?: string;
|
|
42
|
+
/** Optional: custom RTC configuration */
|
|
43
|
+
rtcConfig?: RTCConfiguration;
|
|
44
|
+
/** Optional: connection behavior configuration */
|
|
45
|
+
config?: Partial<ConnectionConfig>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Internal options passed from Rondevu
|
|
49
|
+
*/
|
|
50
|
+
export interface PeerInternalOptions extends PeerOptions {
|
|
51
|
+
api: RondevuAPI;
|
|
52
|
+
iceServers: RTCIceServer[];
|
|
53
|
+
iceTransportPolicy?: RTCIceTransportPolicy;
|
|
54
|
+
webrtcAdapter?: WebRTCAdapter;
|
|
55
|
+
debug?: boolean;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Peer - A clean interface for peer-to-peer connections
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```typescript
|
|
62
|
+
* const peer = await rondevu.peer({
|
|
63
|
+
* username: 'alice',
|
|
64
|
+
* tags: ['chat']
|
|
65
|
+
* })
|
|
66
|
+
*
|
|
67
|
+
* peer.on('open', () => {
|
|
68
|
+
* console.log('Connected to', peer.peerUsername)
|
|
69
|
+
* peer.send('Hello!')
|
|
70
|
+
* })
|
|
71
|
+
*
|
|
72
|
+
* peer.on('message', (data) => {
|
|
73
|
+
* console.log('Received:', data)
|
|
74
|
+
* })
|
|
75
|
+
*
|
|
76
|
+
* peer.on('state', (state) => {
|
|
77
|
+
* console.log('Connection state:', state)
|
|
78
|
+
* })
|
|
79
|
+
*
|
|
80
|
+
* // Access underlying WebRTC objects
|
|
81
|
+
* const pc = peer.peerConnection // RTCPeerConnection
|
|
82
|
+
* const dc = peer.dataChannel // RTCDataChannel
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export declare class Peer extends EventEmitter<PeerEventMap> {
|
|
86
|
+
private connection;
|
|
87
|
+
private api;
|
|
88
|
+
private tags;
|
|
89
|
+
private targetUsername?;
|
|
90
|
+
private iceServers;
|
|
91
|
+
private iceTransportPolicy?;
|
|
92
|
+
private webrtcAdapter?;
|
|
93
|
+
private connectionConfig?;
|
|
94
|
+
private debugEnabled;
|
|
95
|
+
private _state;
|
|
96
|
+
private _peerUsername;
|
|
97
|
+
private _offerId;
|
|
98
|
+
constructor(options: PeerInternalOptions);
|
|
99
|
+
/**
|
|
100
|
+
* Initialize the peer connection (called internally by Rondevu.peer())
|
|
101
|
+
*/
|
|
102
|
+
initialize(): Promise<void>;
|
|
103
|
+
/**
|
|
104
|
+
* Setup event handlers to forward from AnswererConnection
|
|
105
|
+
*/
|
|
106
|
+
private setupEventHandlers;
|
|
107
|
+
/**
|
|
108
|
+
* Map internal ConnectionState to simplified PeerState
|
|
109
|
+
*/
|
|
110
|
+
private mapState;
|
|
111
|
+
/**
|
|
112
|
+
* Current connection state
|
|
113
|
+
*/
|
|
114
|
+
get state(): PeerState;
|
|
115
|
+
/**
|
|
116
|
+
* Username of the connected peer
|
|
117
|
+
*/
|
|
118
|
+
get peerUsername(): string;
|
|
119
|
+
/**
|
|
120
|
+
* The offer ID being used for this connection
|
|
121
|
+
*/
|
|
122
|
+
get offerId(): string;
|
|
123
|
+
/**
|
|
124
|
+
* Tags used for discovery
|
|
125
|
+
*/
|
|
126
|
+
get peerTags(): string[];
|
|
127
|
+
/**
|
|
128
|
+
* The underlying RTCPeerConnection (null if not connected)
|
|
129
|
+
*/
|
|
130
|
+
get peerConnection(): RTCPeerConnection | null;
|
|
131
|
+
/**
|
|
132
|
+
* The underlying RTCDataChannel (null if not connected)
|
|
133
|
+
*/
|
|
134
|
+
get dataChannel(): RTCDataChannel | null;
|
|
135
|
+
/**
|
|
136
|
+
* Whether the peer is currently connected
|
|
137
|
+
*/
|
|
138
|
+
get isConnected(): boolean;
|
|
139
|
+
/**
|
|
140
|
+
* Send a message to the peer
|
|
141
|
+
* Messages are buffered if not connected (when buffering is enabled)
|
|
142
|
+
*
|
|
143
|
+
* @param data - String, ArrayBuffer, or Blob to send
|
|
144
|
+
*/
|
|
145
|
+
send(data: string | ArrayBuffer | Blob): void;
|
|
146
|
+
/**
|
|
147
|
+
* Close the peer connection
|
|
148
|
+
*/
|
|
149
|
+
close(): void;
|
|
150
|
+
/**
|
|
151
|
+
* Get the underlying AnswererConnection for advanced use cases
|
|
152
|
+
*/
|
|
153
|
+
getConnection(): AnswererConnection | null;
|
|
154
|
+
/**
|
|
155
|
+
* Debug logging
|
|
156
|
+
*/
|
|
157
|
+
private debug;
|
|
158
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peer - Clean DX wrapper for peer-to-peer connections
|
|
3
|
+
*
|
|
4
|
+
* Provides a simple interface for connecting to a peer by tags/username,
|
|
5
|
+
* with automatic reconnection and message buffering.
|
|
6
|
+
*/
|
|
7
|
+
import { EventEmitter } from 'eventemitter3';
|
|
8
|
+
import { AnswererConnection } from '../connections/answerer.js';
|
|
9
|
+
import { ConnectionState } from '../connections/events.js';
|
|
10
|
+
/**
|
|
11
|
+
* Peer - A clean interface for peer-to-peer connections
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* const peer = await rondevu.peer({
|
|
16
|
+
* username: 'alice',
|
|
17
|
+
* tags: ['chat']
|
|
18
|
+
* })
|
|
19
|
+
*
|
|
20
|
+
* peer.on('open', () => {
|
|
21
|
+
* console.log('Connected to', peer.peerUsername)
|
|
22
|
+
* peer.send('Hello!')
|
|
23
|
+
* })
|
|
24
|
+
*
|
|
25
|
+
* peer.on('message', (data) => {
|
|
26
|
+
* console.log('Received:', data)
|
|
27
|
+
* })
|
|
28
|
+
*
|
|
29
|
+
* peer.on('state', (state) => {
|
|
30
|
+
* console.log('Connection state:', state)
|
|
31
|
+
* })
|
|
32
|
+
*
|
|
33
|
+
* // Access underlying WebRTC objects
|
|
34
|
+
* const pc = peer.peerConnection // RTCPeerConnection
|
|
35
|
+
* const dc = peer.dataChannel // RTCDataChannel
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export class Peer extends EventEmitter {
|
|
39
|
+
constructor(options) {
|
|
40
|
+
super();
|
|
41
|
+
this.connection = null;
|
|
42
|
+
this._state = 'connecting';
|
|
43
|
+
this._peerUsername = '';
|
|
44
|
+
this._offerId = '';
|
|
45
|
+
this.api = options.api;
|
|
46
|
+
this.tags = options.tags;
|
|
47
|
+
this.targetUsername = options.username;
|
|
48
|
+
this.iceServers = options.iceServers;
|
|
49
|
+
this.iceTransportPolicy = options.iceTransportPolicy;
|
|
50
|
+
this.webrtcAdapter = options.webrtcAdapter;
|
|
51
|
+
this.connectionConfig = options.config;
|
|
52
|
+
this.debugEnabled = options.debug || false;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Initialize the peer connection (called internally by Rondevu.peer())
|
|
56
|
+
*/
|
|
57
|
+
async initialize() {
|
|
58
|
+
this.debug('Initializing peer connection');
|
|
59
|
+
this.debug(`Tags: ${this.tags.join(', ')}${this.targetUsername ? `, username: ${this.targetUsername}` : ''}`);
|
|
60
|
+
// Discover offers
|
|
61
|
+
const result = (await this.api.discover({
|
|
62
|
+
tags: this.tags,
|
|
63
|
+
limit: 100,
|
|
64
|
+
}));
|
|
65
|
+
if (!result.offers || result.offers.length === 0) {
|
|
66
|
+
throw new Error(`No peers found for tags: ${this.tags.join(', ')}`);
|
|
67
|
+
}
|
|
68
|
+
// Filter by username if specified
|
|
69
|
+
let availableOffers = result.offers;
|
|
70
|
+
if (this.targetUsername) {
|
|
71
|
+
availableOffers = result.offers.filter((o) => o.username === this.targetUsername);
|
|
72
|
+
if (availableOffers.length === 0) {
|
|
73
|
+
throw new Error(`No peers found for tags: ${this.tags.join(', ')} from @${this.targetUsername}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Pick a random offer
|
|
77
|
+
const offer = availableOffers[Math.floor(Math.random() * availableOffers.length)];
|
|
78
|
+
this._peerUsername = offer.username;
|
|
79
|
+
this._offerId = offer.offerId;
|
|
80
|
+
this.debug(`Selected offer ${offer.offerId} from @${offer.username}`);
|
|
81
|
+
// Create the underlying AnswererConnection
|
|
82
|
+
this.connection = new AnswererConnection({
|
|
83
|
+
api: this.api,
|
|
84
|
+
ownerUsername: offer.username,
|
|
85
|
+
tags: offer.tags,
|
|
86
|
+
offerId: offer.offerId,
|
|
87
|
+
offerSdp: offer.sdp,
|
|
88
|
+
rtcConfig: {
|
|
89
|
+
iceServers: this.iceServers,
|
|
90
|
+
iceTransportPolicy: this.iceTransportPolicy,
|
|
91
|
+
},
|
|
92
|
+
webrtcAdapter: this.webrtcAdapter,
|
|
93
|
+
config: {
|
|
94
|
+
...this.connectionConfig,
|
|
95
|
+
debug: this.debugEnabled,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
// Wire up events
|
|
99
|
+
this.setupEventHandlers();
|
|
100
|
+
// Start connection
|
|
101
|
+
await this.connection.initialize();
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Setup event handlers to forward from AnswererConnection
|
|
105
|
+
*/
|
|
106
|
+
setupEventHandlers() {
|
|
107
|
+
if (!this.connection)
|
|
108
|
+
return;
|
|
109
|
+
// Map ConnectionState to PeerState
|
|
110
|
+
this.connection.on('state:changed', ({ oldState, newState, reason }) => {
|
|
111
|
+
const mappedOld = this.mapState(oldState);
|
|
112
|
+
const mappedNew = this.mapState(newState);
|
|
113
|
+
if (mappedOld !== mappedNew) {
|
|
114
|
+
this._state = mappedNew;
|
|
115
|
+
this.emit('state', mappedNew, mappedOld);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
// Forward connection events
|
|
119
|
+
this.connection.on('connected', () => {
|
|
120
|
+
this._state = 'connected';
|
|
121
|
+
this.emit('open');
|
|
122
|
+
});
|
|
123
|
+
this.connection.on('closed', reason => {
|
|
124
|
+
this._state = 'closed';
|
|
125
|
+
this.emit('close', reason);
|
|
126
|
+
});
|
|
127
|
+
this.connection.on('failed', error => {
|
|
128
|
+
this._state = 'failed';
|
|
129
|
+
this.emit('error', error);
|
|
130
|
+
});
|
|
131
|
+
// Forward message events
|
|
132
|
+
this.connection.on('message', data => {
|
|
133
|
+
this.emit('message', data);
|
|
134
|
+
});
|
|
135
|
+
// Forward reconnection events
|
|
136
|
+
this.connection.on('reconnect:scheduled', info => {
|
|
137
|
+
this._state = 'reconnecting';
|
|
138
|
+
this.emit('reconnecting', info.attempt, info.maxAttempts);
|
|
139
|
+
});
|
|
140
|
+
this.connection.on('reconnect:success', () => {
|
|
141
|
+
// State will be updated by 'connected' event
|
|
142
|
+
});
|
|
143
|
+
this.connection.on('reconnect:failed', error => {
|
|
144
|
+
this.emit('error', error);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Map internal ConnectionState to simplified PeerState
|
|
149
|
+
*/
|
|
150
|
+
mapState(state) {
|
|
151
|
+
switch (state) {
|
|
152
|
+
case ConnectionState.INITIALIZING:
|
|
153
|
+
case ConnectionState.GATHERING:
|
|
154
|
+
case ConnectionState.SIGNALING:
|
|
155
|
+
case ConnectionState.CHECKING:
|
|
156
|
+
case ConnectionState.CONNECTING:
|
|
157
|
+
return 'connecting';
|
|
158
|
+
case ConnectionState.CONNECTED:
|
|
159
|
+
return 'connected';
|
|
160
|
+
case ConnectionState.DISCONNECTED:
|
|
161
|
+
return 'disconnected';
|
|
162
|
+
case ConnectionState.RECONNECTING:
|
|
163
|
+
return 'reconnecting';
|
|
164
|
+
case ConnectionState.FAILED:
|
|
165
|
+
return 'failed';
|
|
166
|
+
case ConnectionState.CLOSED:
|
|
167
|
+
return 'closed';
|
|
168
|
+
default:
|
|
169
|
+
return 'connecting';
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// ========================================
|
|
173
|
+
// Public Properties
|
|
174
|
+
// ========================================
|
|
175
|
+
/**
|
|
176
|
+
* Current connection state
|
|
177
|
+
*/
|
|
178
|
+
get state() {
|
|
179
|
+
return this._state;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Username of the connected peer
|
|
183
|
+
*/
|
|
184
|
+
get peerUsername() {
|
|
185
|
+
return this._peerUsername;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The offer ID being used for this connection
|
|
189
|
+
*/
|
|
190
|
+
get offerId() {
|
|
191
|
+
return this._offerId;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Tags used for discovery
|
|
195
|
+
*/
|
|
196
|
+
get peerTags() {
|
|
197
|
+
return this.tags;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The underlying RTCPeerConnection (null if not connected)
|
|
201
|
+
*/
|
|
202
|
+
get peerConnection() {
|
|
203
|
+
return this.connection?.getPeerConnection() ?? null;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The underlying RTCDataChannel (null if not connected)
|
|
207
|
+
*/
|
|
208
|
+
get dataChannel() {
|
|
209
|
+
return this.connection?.getDataChannel() ?? null;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Whether the peer is currently connected
|
|
213
|
+
*/
|
|
214
|
+
get isConnected() {
|
|
215
|
+
return this._state === 'connected';
|
|
216
|
+
}
|
|
217
|
+
// ========================================
|
|
218
|
+
// Public Methods
|
|
219
|
+
// ========================================
|
|
220
|
+
/**
|
|
221
|
+
* Send a message to the peer
|
|
222
|
+
* Messages are buffered if not connected (when buffering is enabled)
|
|
223
|
+
*
|
|
224
|
+
* @param data - String, ArrayBuffer, or Blob to send
|
|
225
|
+
*/
|
|
226
|
+
send(data) {
|
|
227
|
+
if (!this.connection) {
|
|
228
|
+
throw new Error('Peer not initialized');
|
|
229
|
+
}
|
|
230
|
+
this.connection.send(data);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Close the peer connection
|
|
234
|
+
*/
|
|
235
|
+
close() {
|
|
236
|
+
this.debug('Closing peer connection');
|
|
237
|
+
this.connection?.close();
|
|
238
|
+
this._state = 'closed';
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Get the underlying AnswererConnection for advanced use cases
|
|
242
|
+
*/
|
|
243
|
+
getConnection() {
|
|
244
|
+
return this.connection;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Debug logging
|
|
248
|
+
*/
|
|
249
|
+
debug(...args) {
|
|
250
|
+
if (this.debugEnabled) {
|
|
251
|
+
console.log('[Peer]', ...args);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|