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