@edryslabs/genericprovider 1.0.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/LICENSE +24 -0
- package/README.md +660 -0
- package/dist/index.d.ts +323 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1001 -0
- package/dist/index.js.map +1 -0
- package/dist/lib.d.ts +36 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +37 -0
- package/dist/lib.js.map +1 -0
- package/dist/providers/dummy/index.d.ts +226 -0
- package/dist/providers/dummy/index.d.ts.map +1 -0
- package/dist/providers/dummy/index.js +326 -0
- package/dist/providers/dummy/index.js.map +1 -0
- package/dist/providers/gun/index.d.ts +269 -0
- package/dist/providers/gun/index.d.ts.map +1 -0
- package/dist/providers/gun/index.js +683 -0
- package/dist/providers/gun/index.js.map +1 -0
- package/dist/providers/indexeddb/index.d.ts +161 -0
- package/dist/providers/indexeddb/index.d.ts.map +1 -0
- package/dist/providers/indexeddb/index.js +369 -0
- package/dist/providers/indexeddb/index.js.map +1 -0
- package/dist/providers/matrix/index.d.ts +109 -0
- package/dist/providers/matrix/index.d.ts.map +1 -0
- package/dist/providers/matrix/index.js +329 -0
- package/dist/providers/matrix/index.js.map +1 -0
- package/dist/providers/nostr/index.d.ts +172 -0
- package/dist/providers/nostr/index.d.ts.map +1 -0
- package/dist/providers/nostr/index.js +280 -0
- package/dist/providers/nostr/index.js.map +1 -0
- package/dist/providers/peerjs/index.d.ts +231 -0
- package/dist/providers/peerjs/index.d.ts.map +1 -0
- package/dist/providers/peerjs/index.js +1038 -0
- package/dist/providers/peerjs/index.js.map +1 -0
- package/dist/providers/pubnub/index.d.ts +106 -0
- package/dist/providers/pubnub/index.d.ts.map +1 -0
- package/dist/providers/pubnub/index.js +357 -0
- package/dist/providers/pubnub/index.js.map +1 -0
- package/dist/providers/simple-peer/index.d.ts +253 -0
- package/dist/providers/simple-peer/index.d.ts.map +1 -0
- package/dist/providers/simple-peer/index.js +783 -0
- package/dist/providers/simple-peer/index.js.map +1 -0
- package/dist/providers/supabase/index.d.ts +80 -0
- package/dist/providers/supabase/index.d.ts.map +1 -0
- package/dist/providers/supabase/index.js +202 -0
- package/dist/providers/supabase/index.js.map +1 -0
- package/dist/providers/trystero/index.d.ts +181 -0
- package/dist/providers/trystero/index.d.ts.map +1 -0
- package/dist/providers/trystero/index.js +187 -0
- package/dist/providers/trystero/index.js.map +1 -0
- package/dist/providers/websocket/index.d.ts +92 -0
- package/dist/providers/websocket/index.d.ts.map +1 -0
- package/dist/providers/websocket/index.js +272 -0
- package/dist/providers/websocket/index.js.map +1 -0
- package/dist/sync-monitor.d.ts +90 -0
- package/dist/sync-monitor.d.ts.map +1 -0
- package/dist/sync-monitor.js +149 -0
- package/dist/sync-monitor.js.map +1 -0
- package/dist/transport.d.ts +116 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +2 -0
- package/dist/transport.js.map +1 -0
- package/package.json +137 -0
|
@@ -0,0 +1,1038 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PeerJS Transport Provider
|
|
3
|
+
*
|
|
4
|
+
* Peer-to-peer transport using WebRTC data channels with PeerJS library.
|
|
5
|
+
* PeerJS provides a simpler API and built-in signaling infrastructure.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Direct peer-to-peer connections
|
|
9
|
+
* - Built-in signaling servers (PeerJS Cloud)
|
|
10
|
+
* - Automatic peer ID management
|
|
11
|
+
* - Simple connection API
|
|
12
|
+
* - Optional encryption
|
|
13
|
+
* - Automatic reconnection
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```typescript
|
|
17
|
+
* import { GenericProvider } from 'y-generic'
|
|
18
|
+
* import { PeerJSTransport } from 'y-generic/providers/peerjs'
|
|
19
|
+
* import Peer from 'peerjs'
|
|
20
|
+
*
|
|
21
|
+
* const doc = new Y.Doc()
|
|
22
|
+
* const transport = new PeerJSTransport({
|
|
23
|
+
* peer: Peer, // Pass the PeerJS constructor
|
|
24
|
+
* password: 'optional-encryption-key'
|
|
25
|
+
* })
|
|
26
|
+
* const provider = new GenericProvider(doc, transport)
|
|
27
|
+
* await provider.connect({ room: 'my-room' })
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* PeerJS transport implementation.
|
|
32
|
+
* Creates direct peer-to-peer connections using PeerJS library.
|
|
33
|
+
*/
|
|
34
|
+
export class PeerJSTransport {
|
|
35
|
+
/**
|
|
36
|
+
* Create a new PeerJS transport.
|
|
37
|
+
*
|
|
38
|
+
* @param options - Configuration options (must include peer constructor)
|
|
39
|
+
*/
|
|
40
|
+
constructor(options) {
|
|
41
|
+
this._connected = false;
|
|
42
|
+
this._room = '';
|
|
43
|
+
this.peer = null; // PeerJS Peer instance
|
|
44
|
+
this.peerId = '';
|
|
45
|
+
this.peers = new Map();
|
|
46
|
+
this.knownPeers = new Set();
|
|
47
|
+
// Cross-browser coordination
|
|
48
|
+
this.isCoordinator = false;
|
|
49
|
+
this.coordinatorPeerId = '';
|
|
50
|
+
this.roomPeers = new Set(); // All peers in room (coordinator tracks this)
|
|
51
|
+
this.reElectionInProgress = false; // Prevent duplicate re-elections
|
|
52
|
+
this._destroying = false; // Set during disconnect() to suppress reconnects
|
|
53
|
+
if (!options.peer) {
|
|
54
|
+
throw new Error('PeerJSTransport requires the "peer" option. ' +
|
|
55
|
+
'Please provide the PeerJS constructor: ' +
|
|
56
|
+
'import Peer from "peerjs"; new PeerJSTransport({ peer: Peer, ... })');
|
|
57
|
+
}
|
|
58
|
+
this.options = {
|
|
59
|
+
peer: options.peer,
|
|
60
|
+
peerOptions: options.peerOptions ?? {},
|
|
61
|
+
password: options.password ?? '',
|
|
62
|
+
maxConns: options.maxConns ?? 20 + Math.floor(Math.random() * 15),
|
|
63
|
+
debug: options.debug ?? false,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Connect to the room and start discovering peers.
|
|
68
|
+
*/
|
|
69
|
+
async connect(config) {
|
|
70
|
+
if (this._connected) {
|
|
71
|
+
throw new Error('Already connected');
|
|
72
|
+
}
|
|
73
|
+
this._room = config.room;
|
|
74
|
+
// Deterministic coordinator ID for this room
|
|
75
|
+
this.coordinatorPeerId = `yjs-coordinator-${this._room}`;
|
|
76
|
+
// Attempting to join room
|
|
77
|
+
// Strategy: Try to claim the coordinator ID first
|
|
78
|
+
// If taken, we'll get an error and become a regular peer
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
try {
|
|
81
|
+
// First, try to create ourselves as the coordinator
|
|
82
|
+
// Attempting to claim coordinator ID
|
|
83
|
+
let coordinatorAttempt = new this.options.peer(this.coordinatorPeerId, {
|
|
84
|
+
debug: this.options.debug ? 3 : 0,
|
|
85
|
+
...this.options.peerOptions,
|
|
86
|
+
});
|
|
87
|
+
// Timeout in case PeerJS doesn't respond
|
|
88
|
+
const coordinatorTimeout = setTimeout(() => {
|
|
89
|
+
// Coordinator claim timeout
|
|
90
|
+
coordinatorAttempt.destroy();
|
|
91
|
+
this.createRegularPeer(resolve, reject);
|
|
92
|
+
// Cross-browser discovery setup complete
|
|
93
|
+
}, 3000);
|
|
94
|
+
coordinatorAttempt.on('open', (id) => {
|
|
95
|
+
clearTimeout(coordinatorTimeout);
|
|
96
|
+
if (id === this.coordinatorPeerId) {
|
|
97
|
+
// Successfully claimed coordinator ID!
|
|
98
|
+
this.log('Coordinator: claimed ID');
|
|
99
|
+
this.peer = coordinatorAttempt;
|
|
100
|
+
this.peerId = id;
|
|
101
|
+
this.isCoordinator = true;
|
|
102
|
+
this.roomPeers.add(this.peerId);
|
|
103
|
+
this._connected = true;
|
|
104
|
+
// Setup peer discovery
|
|
105
|
+
this.setupPeerDiscovery();
|
|
106
|
+
// Listen for incoming connections
|
|
107
|
+
this.peer.on('connection', (conn) => {
|
|
108
|
+
this.handleIncomingConnection(conn);
|
|
109
|
+
});
|
|
110
|
+
this.peer.on('disconnected', () => {
|
|
111
|
+
this.log('Peer disconnected from PeerJS server, attempting reconnect...');
|
|
112
|
+
this._handlePeerServerDisconnect();
|
|
113
|
+
});
|
|
114
|
+
resolve();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
coordinatorAttempt.on('error', (error) => {
|
|
118
|
+
clearTimeout(coordinatorTimeout);
|
|
119
|
+
// Check if error is because ID is taken
|
|
120
|
+
if (error.message.includes('ID is taken') ||
|
|
121
|
+
error.message.includes('taken') ||
|
|
122
|
+
error.message.includes('already') ||
|
|
123
|
+
error.type === 'unavailable-id') {
|
|
124
|
+
// Coordinator already exists
|
|
125
|
+
coordinatorAttempt.destroy();
|
|
126
|
+
this.createRegularPeer(resolve, reject);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
this.log('❌ Unexpected error claiming coordinator:', error);
|
|
130
|
+
coordinatorAttempt.destroy();
|
|
131
|
+
this.createRegularPeer(resolve, reject);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
reject(error);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Create a regular (non-coordinator) peer and connect to coordinator.
|
|
142
|
+
*/
|
|
143
|
+
createRegularPeer(resolve, reject) {
|
|
144
|
+
const peerIdSuffix = Math.random().toString(36).substring(7);
|
|
145
|
+
this.peerId = `yjs-${this._room}-${peerIdSuffix}`;
|
|
146
|
+
// Creating regular peer
|
|
147
|
+
this.peer = new this.options.peer(this.peerId, {
|
|
148
|
+
debug: this.options.debug ? 3 : 0,
|
|
149
|
+
...this.options.peerOptions,
|
|
150
|
+
});
|
|
151
|
+
this.peer.on('open', (id) => {
|
|
152
|
+
this.peerId = id;
|
|
153
|
+
this._connected = true;
|
|
154
|
+
// Setup peer discovery via BroadcastChannel (same-browser tabs)
|
|
155
|
+
this.setupPeerDiscovery();
|
|
156
|
+
// Listen for incoming connections
|
|
157
|
+
this.peer.on('connection', (conn) => {
|
|
158
|
+
this.handleIncomingConnection(conn);
|
|
159
|
+
});
|
|
160
|
+
// Setup cross-browser discovery via coordinator pattern
|
|
161
|
+
this.setupCrossBrowserDiscovery()
|
|
162
|
+
.then(() => {
|
|
163
|
+
// Cross-browser discovery setup complete
|
|
164
|
+
resolve();
|
|
165
|
+
})
|
|
166
|
+
.catch((err) => {
|
|
167
|
+
this.log('⚠️ Cross-browser discovery failed, continuing with BroadcastChannel only:', err);
|
|
168
|
+
// Still resolve - BroadcastChannel will work for same-browser tabs
|
|
169
|
+
resolve();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
this.peer.on('error', (error) => {
|
|
173
|
+
this.log('Peer error:', error);
|
|
174
|
+
if (!this._connected) {
|
|
175
|
+
reject(error);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
this.peer.on('disconnected', () => {
|
|
179
|
+
this.log('Peer disconnected from PeerJS server, attempting reconnect...');
|
|
180
|
+
this._handlePeerServerDisconnect();
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Disconnect from all peers and cleanup.
|
|
185
|
+
*/
|
|
186
|
+
disconnect() {
|
|
187
|
+
if (!this._connected)
|
|
188
|
+
return;
|
|
189
|
+
this._destroying = true;
|
|
190
|
+
// Clear discovery interval
|
|
191
|
+
if (this.discoveryInterval) {
|
|
192
|
+
clearInterval(this.discoveryInterval);
|
|
193
|
+
this.discoveryInterval = undefined;
|
|
194
|
+
}
|
|
195
|
+
// Close BroadcastChannel
|
|
196
|
+
if (this.broadcastChannel) {
|
|
197
|
+
// Announce disconnection
|
|
198
|
+
this.broadcastChannel.postMessage({
|
|
199
|
+
type: 'leave',
|
|
200
|
+
peerId: this.peerId,
|
|
201
|
+
});
|
|
202
|
+
this.broadcastChannel.close();
|
|
203
|
+
this.broadcastChannel = undefined;
|
|
204
|
+
}
|
|
205
|
+
// Disconnect from coordinator if connected
|
|
206
|
+
if (this.coordinatorConn) {
|
|
207
|
+
try {
|
|
208
|
+
this.sendCoordinationMessage(this.coordinatorConn, {
|
|
209
|
+
type: 'leave',
|
|
210
|
+
peerId: this.peerId,
|
|
211
|
+
});
|
|
212
|
+
this.coordinatorConn.close();
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
// Ignore errors during cleanup
|
|
216
|
+
}
|
|
217
|
+
this.coordinatorConn = undefined;
|
|
218
|
+
}
|
|
219
|
+
// If we're coordinator, notify all peers we're leaving
|
|
220
|
+
if (this.isCoordinator) {
|
|
221
|
+
for (const peerId of this.roomPeers) {
|
|
222
|
+
if (peerId !== this.peerId) {
|
|
223
|
+
const peerConn = this.peers.get(peerId);
|
|
224
|
+
if (peerConn && peerConn.connected) {
|
|
225
|
+
try {
|
|
226
|
+
this.sendCoordinationMessage(peerConn.conn, {
|
|
227
|
+
type: 'coordinator-leaving',
|
|
228
|
+
peerId: this.peerId,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
// Ignore
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
this.isCoordinator = false;
|
|
238
|
+
this.roomPeers.clear();
|
|
239
|
+
}
|
|
240
|
+
// Close all peer connections
|
|
241
|
+
for (const peerConn of this.peers.values()) {
|
|
242
|
+
try {
|
|
243
|
+
peerConn.conn.close();
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
// Ignore errors during cleanup
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
this.peers.clear();
|
|
250
|
+
this.knownPeers.clear();
|
|
251
|
+
this.reElectionInProgress = false;
|
|
252
|
+
// Destroy peer
|
|
253
|
+
if (this.peer) {
|
|
254
|
+
this.peer.destroy();
|
|
255
|
+
this.peer = null;
|
|
256
|
+
}
|
|
257
|
+
this._connected = false;
|
|
258
|
+
this._destroying = false;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Handle unexpected disconnect from the PeerJS signaling server.
|
|
262
|
+
* Happens on network changes (WiFi ↔ mobile), server restarts, etc.
|
|
263
|
+
* PeerJS keeps the same Peer ID — we call reconnect() then re-open
|
|
264
|
+
* DataConnections to all peers we already knew about.
|
|
265
|
+
*/
|
|
266
|
+
_handlePeerServerDisconnect() {
|
|
267
|
+
if (this._destroying)
|
|
268
|
+
return;
|
|
269
|
+
// Defer until network is back if we are offline
|
|
270
|
+
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
|
271
|
+
this.log('📴 Offline – deferring PeerJS reconnect until network returns');
|
|
272
|
+
window.addEventListener('online', () => {
|
|
273
|
+
this.log('🌐 Network restored – reconnecting to PeerJS server');
|
|
274
|
+
this._handlePeerServerDisconnect();
|
|
275
|
+
}, { once: true });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// peer.reconnect() re-registers the same ID with the PeerJS server
|
|
279
|
+
try {
|
|
280
|
+
this.peer.reconnect();
|
|
281
|
+
this.log(`🔄 PeerJS reconnect initiated (peer ID: ${this.peerId})`);
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
this.log('❌ peer.reconnect() failed:', err);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
// Once the server accepts us again, re-open DataConnections to known peers
|
|
288
|
+
this.peer.once('open', () => {
|
|
289
|
+
this.log(`✅ Reconnected to PeerJS server — re-connecting to ${this.knownPeers.size} known peer(s)`);
|
|
290
|
+
// Drop stale (disconnected) DataConnection entries from before the network change
|
|
291
|
+
for (const [peerId, peerConn] of this.peers.entries()) {
|
|
292
|
+
if (!peerConn.connected) {
|
|
293
|
+
try {
|
|
294
|
+
peerConn.conn.close();
|
|
295
|
+
}
|
|
296
|
+
catch (_) { }
|
|
297
|
+
this.peers.delete(peerId);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Re-establish connections to all known peers
|
|
301
|
+
for (const peerId of this.knownPeers) {
|
|
302
|
+
if (!this.peers.has(peerId) && peerId !== this.peerId) {
|
|
303
|
+
this.log(`🔌 Re-connecting to known peer: ${peerId}`);
|
|
304
|
+
this.connectToPeer(peerId);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
// Regular peer: if coordinator connection was lost, reconnect to coordinator
|
|
308
|
+
if (!this.isCoordinator && this.coordinatorConn == null) {
|
|
309
|
+
setTimeout(() => this.attemptCoordinatorConnection(), 1000);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Register callback for new peer data-channel connections.
|
|
315
|
+
*/
|
|
316
|
+
onPeerConnect(callback) {
|
|
317
|
+
this._peerConnectCallback = callback;
|
|
318
|
+
return () => {
|
|
319
|
+
this._peerConnectCallback = undefined;
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Send data to all connected peers.
|
|
324
|
+
*/
|
|
325
|
+
send(data) {
|
|
326
|
+
if (!this._connected) {
|
|
327
|
+
this.log('Not connected, cannot send');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
// Encrypt if password is set
|
|
331
|
+
const dataToSend = this.options.password
|
|
332
|
+
? this.encrypt(data, this.options.password)
|
|
333
|
+
: data;
|
|
334
|
+
// Send to all connected peers
|
|
335
|
+
let sentCount = 0;
|
|
336
|
+
for (const peerConn of this.peers.values()) {
|
|
337
|
+
if (peerConn.connected) {
|
|
338
|
+
try {
|
|
339
|
+
peerConn.conn.send(dataToSend);
|
|
340
|
+
sentCount++;
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
this.log('Error sending to peer:', peerConn.peerId, error);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// Sent to peers
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Register callback for incoming messages.
|
|
351
|
+
*/
|
|
352
|
+
onMessage(callback) {
|
|
353
|
+
this._callback = callback;
|
|
354
|
+
return () => {
|
|
355
|
+
this._callback = undefined;
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Check if connected.
|
|
360
|
+
*/
|
|
361
|
+
get isConnected() {
|
|
362
|
+
return this._connected;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Get number of connected peers (for debugging).
|
|
366
|
+
*/
|
|
367
|
+
get connectedPeers() {
|
|
368
|
+
return Array.from(this.peers.values()).filter((p) => p.connected).length;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Setup peer discovery using BroadcastChannel for same-browser tabs.
|
|
372
|
+
*/
|
|
373
|
+
setupPeerDiscovery() {
|
|
374
|
+
if (typeof BroadcastChannel === 'undefined') {
|
|
375
|
+
this.log('❌ BroadcastChannel not available in this browser');
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const channelName = `yjs-peerjs-${this._room}`;
|
|
379
|
+
// Creating BroadcastChannel
|
|
380
|
+
this.broadcastChannel = new BroadcastChannel(channelName);
|
|
381
|
+
this.broadcastChannel.onmessage = (event) => {
|
|
382
|
+
const msg = event.data;
|
|
383
|
+
if (msg.peerId === this.peerId) {
|
|
384
|
+
return; // Ignore own messages
|
|
385
|
+
}
|
|
386
|
+
if (msg.type === 'announce') {
|
|
387
|
+
// Another peer announced
|
|
388
|
+
// Discovered peer via BroadcastChannel
|
|
389
|
+
this.knownPeers.add(msg.peerId);
|
|
390
|
+
this.connectToPeer(msg.peerId);
|
|
391
|
+
}
|
|
392
|
+
else if (msg.type === 'leave') {
|
|
393
|
+
// Peer left
|
|
394
|
+
// Peer left
|
|
395
|
+
this.knownPeers.delete(msg.peerId);
|
|
396
|
+
this.removePeer(msg.peerId);
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
// Announce presence
|
|
400
|
+
this.announcePeer();
|
|
401
|
+
// Periodic announcements for new peers that join later
|
|
402
|
+
this.discoveryInterval = setInterval(() => {
|
|
403
|
+
if (this._connected && this.peers.size < this.options.maxConns) {
|
|
404
|
+
this.announcePeer();
|
|
405
|
+
}
|
|
406
|
+
}, 5000);
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Announce presence to other peers.
|
|
410
|
+
*/
|
|
411
|
+
announcePeer() {
|
|
412
|
+
if (this.broadcastChannel) {
|
|
413
|
+
const message = {
|
|
414
|
+
type: 'announce',
|
|
415
|
+
peerId: this.peerId,
|
|
416
|
+
};
|
|
417
|
+
this.broadcastChannel.postMessage(message);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Setup cross-browser peer discovery by connecting to the coordinator.
|
|
422
|
+
*
|
|
423
|
+
* Note: This method is only called for regular (non-coordinator) peers.
|
|
424
|
+
* The coordinator already exists because we failed to claim its ID.
|
|
425
|
+
*/
|
|
426
|
+
async setupCrossBrowserDiscovery() {
|
|
427
|
+
// Connecting to coordinator
|
|
428
|
+
// Wait a bit for PeerJS server registration to complete
|
|
429
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
430
|
+
return new Promise((resolve, reject) => {
|
|
431
|
+
let connectionAttemptDone = false;
|
|
432
|
+
// Set timeout for network issues
|
|
433
|
+
const timeout = setTimeout(() => {
|
|
434
|
+
if (!connectionAttemptDone) {
|
|
435
|
+
connectionAttemptDone = true;
|
|
436
|
+
const error = new Error('Timeout connecting to coordinator');
|
|
437
|
+
this.log('⏱️ Timeout connecting to coordinator');
|
|
438
|
+
reject(error);
|
|
439
|
+
}
|
|
440
|
+
}, 5000);
|
|
441
|
+
try {
|
|
442
|
+
// Connect to the coordinator (we know it exists)
|
|
443
|
+
const conn = this.peer.connect(this.coordinatorPeerId, {
|
|
444
|
+
reliable: true,
|
|
445
|
+
serialization: 'binary',
|
|
446
|
+
metadata: { role: 'peer', peerId: this.peerId },
|
|
447
|
+
});
|
|
448
|
+
conn.on('open', () => {
|
|
449
|
+
if (connectionAttemptDone)
|
|
450
|
+
return;
|
|
451
|
+
connectionAttemptDone = true;
|
|
452
|
+
clearTimeout(timeout);
|
|
453
|
+
this.log('Connected to coordinator');
|
|
454
|
+
this.coordinatorConn = conn;
|
|
455
|
+
// IMPORTANT: Add coordinator to peers map for Yjs sync
|
|
456
|
+
const peerConn = {
|
|
457
|
+
conn,
|
|
458
|
+
connected: true,
|
|
459
|
+
peerId: this.coordinatorPeerId,
|
|
460
|
+
};
|
|
461
|
+
this.peers.set(this.coordinatorPeerId, peerConn);
|
|
462
|
+
this.knownPeers.add(this.coordinatorPeerId);
|
|
463
|
+
// Notify provider of the coordinator channel opening
|
|
464
|
+
this._peerConnectCallback?.(this.coordinatorPeerId);
|
|
465
|
+
// Request peer list from coordinator (send as JSON-encoded binary)
|
|
466
|
+
this.sendCoordinationMessage(conn, {
|
|
467
|
+
type: 'join',
|
|
468
|
+
peerId: this.peerId,
|
|
469
|
+
});
|
|
470
|
+
// Handle messages from coordinator
|
|
471
|
+
conn.on('data', (data) => {
|
|
472
|
+
// Try to decode as coordination message first
|
|
473
|
+
const uint8Data = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
474
|
+
const coordMessage = this.tryDecodeCoordinationMessage(uint8Data);
|
|
475
|
+
if (coordMessage) {
|
|
476
|
+
// Handle coordination messages
|
|
477
|
+
this.handleCoordinatorMessage(coordMessage);
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
// It's Yjs sync data - process it
|
|
481
|
+
if (this._callback) {
|
|
482
|
+
try {
|
|
483
|
+
// Decrypt if password is set
|
|
484
|
+
const decryptedData = this.options.password
|
|
485
|
+
? this.decrypt(uint8Data, this.options.password)
|
|
486
|
+
: uint8Data;
|
|
487
|
+
this._callback(decryptedData);
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
this.log('Error handling coordinator data:', error);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
conn.on('close', () => {
|
|
496
|
+
this.log('Coordinator disconnected');
|
|
497
|
+
this.peers.delete(this.coordinatorPeerId);
|
|
498
|
+
this.knownPeers.delete(this.coordinatorPeerId);
|
|
499
|
+
this.handleCoordinatorDisconnect();
|
|
500
|
+
});
|
|
501
|
+
resolve();
|
|
502
|
+
});
|
|
503
|
+
conn.on('error', (err) => {
|
|
504
|
+
if (connectionAttemptDone)
|
|
505
|
+
return;
|
|
506
|
+
connectionAttemptDone = true;
|
|
507
|
+
clearTimeout(timeout);
|
|
508
|
+
this.log('❌ Failed to connect to coordinator:', err.message);
|
|
509
|
+
reject(err);
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
catch (err) {
|
|
513
|
+
if (!connectionAttemptDone) {
|
|
514
|
+
connectionAttemptDone = true;
|
|
515
|
+
clearTimeout(timeout);
|
|
516
|
+
this.log('⚠️ Error connecting to coordinator:', err);
|
|
517
|
+
reject(err);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Become the coordinator for this room.
|
|
524
|
+
*/
|
|
525
|
+
becomeCoordinator() {
|
|
526
|
+
this.isCoordinator = true;
|
|
527
|
+
this.roomPeers.add(this.peerId);
|
|
528
|
+
this.log('👑 I am now the coordinator. Room peers:', Array.from(this.roomPeers));
|
|
529
|
+
// Periodically check if we should still be coordinator (have lowest ID)
|
|
530
|
+
setInterval(() => {
|
|
531
|
+
if (this.isCoordinator) {
|
|
532
|
+
const allPeerIds = Array.from(this.roomPeers).sort();
|
|
533
|
+
if (allPeerIds.length > 0 && allPeerIds[0] !== this.peerId) {
|
|
534
|
+
this.log('⚠️ I am no longer the lowest ID peer, stepping down as coordinator');
|
|
535
|
+
this.isCoordinator = false;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}, 10000);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Handle messages from coordinator (when we're a regular peer).
|
|
542
|
+
*/
|
|
543
|
+
handleCoordinatorMessage(data) {
|
|
544
|
+
switch (data.type) {
|
|
545
|
+
case 'peer-list':
|
|
546
|
+
// Received list of all peers in room from coordinator
|
|
547
|
+
// Received peer list
|
|
548
|
+
for (const peerId of data.peers) {
|
|
549
|
+
if (peerId !== this.peerId && !this.peers.has(peerId)) {
|
|
550
|
+
this.knownPeers.add(peerId);
|
|
551
|
+
this.connectToPeer(peerId);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
break;
|
|
555
|
+
case 'peer-joined':
|
|
556
|
+
// A new peer joined the room
|
|
557
|
+
// New peer joined
|
|
558
|
+
if (data.peerId !== this.peerId && !this.peers.has(data.peerId)) {
|
|
559
|
+
this.knownPeers.add(data.peerId);
|
|
560
|
+
this.connectToPeer(data.peerId);
|
|
561
|
+
}
|
|
562
|
+
break;
|
|
563
|
+
case 'peer-left':
|
|
564
|
+
// A peer left the room
|
|
565
|
+
// Peer left
|
|
566
|
+
this.knownPeers.delete(data.peerId);
|
|
567
|
+
this.removePeer(data.peerId);
|
|
568
|
+
break;
|
|
569
|
+
case 'coordinator-change':
|
|
570
|
+
// Coordinator changed
|
|
571
|
+
// Coordinator changed
|
|
572
|
+
this.coordinatorPeerId = data.coordinatorId;
|
|
573
|
+
// We might need to reconnect to the new coordinator
|
|
574
|
+
break;
|
|
575
|
+
case 'coordinator-leaving':
|
|
576
|
+
// Coordinator is leaving
|
|
577
|
+
this.log('Coordinator leaving, re-election starting');
|
|
578
|
+
this.handleCoordinatorDisconnect();
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Handle coordinator disconnect - start re-election.
|
|
584
|
+
*/
|
|
585
|
+
handleCoordinatorDisconnect() {
|
|
586
|
+
// Prevent duplicate re-elections
|
|
587
|
+
if (this.reElectionInProgress) {
|
|
588
|
+
this.log('⏭️ Re-election already in progress, skipping');
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
// If we are currently offline the coordinator disconnected because WE lost
|
|
592
|
+
// the network, not because the coordinator disappeared. Any attempt to
|
|
593
|
+
// create a new PeerJS peer will fail immediately with "Lost connection to
|
|
594
|
+
// server". Defer the whole re-election until we are back online.
|
|
595
|
+
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
|
596
|
+
this.log('📴 Offline – deferring re-election until network returns');
|
|
597
|
+
const handler = () => {
|
|
598
|
+
this.log('🌐 Network restored – resuming re-election');
|
|
599
|
+
this.handleCoordinatorDisconnect();
|
|
600
|
+
};
|
|
601
|
+
window.addEventListener('online', handler, { once: true });
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
this.log('🗳️ Coordinator disconnected, starting re-election');
|
|
605
|
+
this.reElectionInProgress = true;
|
|
606
|
+
this.coordinatorConn = undefined;
|
|
607
|
+
// Gather all known peer IDs
|
|
608
|
+
const knownPeerIds = [
|
|
609
|
+
this.peerId,
|
|
610
|
+
...Array.from(this.knownPeers),
|
|
611
|
+
...Array.from(this.peers.keys()),
|
|
612
|
+
].filter((id) => id !== this.coordinatorPeerId); // Exclude the old coordinator
|
|
613
|
+
// Sort and pick lowest ID
|
|
614
|
+
knownPeerIds.sort();
|
|
615
|
+
const lowestId = knownPeerIds[0];
|
|
616
|
+
this.log('🗳️ Coordinator election: Lowest peer ID is', lowestId);
|
|
617
|
+
if (lowestId === this.peerId) {
|
|
618
|
+
this.log('👑 I have the lowest ID, attempting to claim coordinator role');
|
|
619
|
+
this.transitionToCoordinator();
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
// Waiting for new coordinator
|
|
623
|
+
this.log('⏳ Waiting for new coordinator:', lowestId);
|
|
624
|
+
// Wait a bit for the new coordinator to claim the ID, then try to connect
|
|
625
|
+
setTimeout(() => {
|
|
626
|
+
this.attemptCoordinatorConnection();
|
|
627
|
+
}, 2000);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Attempt to connect to the coordinator.
|
|
632
|
+
* If coordinator doesn't exist yet, retry a few times.
|
|
633
|
+
*/
|
|
634
|
+
attemptCoordinatorConnection(retryCount = 0) {
|
|
635
|
+
const maxRetries = 5;
|
|
636
|
+
if (retryCount >= maxRetries) {
|
|
637
|
+
this.log('❌ Failed to connect to coordinator after', maxRetries, 'attempts');
|
|
638
|
+
// Try to become coordinator ourselves as fallback
|
|
639
|
+
this.log('🔄 Attempting to become coordinator as fallback');
|
|
640
|
+
this.transitionToCoordinator();
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
this.log(`🔌 Attempting to connect to coordinator (attempt ${retryCount + 1}/${maxRetries})`);
|
|
644
|
+
this.setupCrossBrowserDiscovery()
|
|
645
|
+
.then(() => {
|
|
646
|
+
this.log('✅ Successfully connected to new coordinator');
|
|
647
|
+
this.reElectionInProgress = false; // Re-election complete
|
|
648
|
+
})
|
|
649
|
+
.catch((err) => {
|
|
650
|
+
this.log(`⚠️ Failed to connect to coordinator (attempt ${retryCount + 1}):`, err);
|
|
651
|
+
// Retry with exponential backoff
|
|
652
|
+
const delay = 1000 * Math.pow(1.5, retryCount);
|
|
653
|
+
setTimeout(() => {
|
|
654
|
+
this.attemptCoordinatorConnection(retryCount + 1);
|
|
655
|
+
}, delay);
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Transition from regular peer to coordinator by reconnecting with coordinator ID.
|
|
660
|
+
*/
|
|
661
|
+
async transitionToCoordinator() {
|
|
662
|
+
this.log('🔄 Transitioning to coordinator role...');
|
|
663
|
+
// Save current state
|
|
664
|
+
const currentConnections = Array.from(this.peers.entries());
|
|
665
|
+
const callback = this._callback;
|
|
666
|
+
const room = this._room;
|
|
667
|
+
// Close current peer connection
|
|
668
|
+
if (this.peer) {
|
|
669
|
+
this.peer.destroy();
|
|
670
|
+
}
|
|
671
|
+
// Clear state but keep connections info
|
|
672
|
+
this.peers.clear();
|
|
673
|
+
this._connected = false;
|
|
674
|
+
try {
|
|
675
|
+
// Attempt to claim coordinator ID
|
|
676
|
+
await new Promise((resolve, reject) => {
|
|
677
|
+
const coordinatorPeer = new this.options.peer(this.coordinatorPeerId, {
|
|
678
|
+
debug: this.options.debug ? 3 : 0,
|
|
679
|
+
...this.options.peerOptions,
|
|
680
|
+
});
|
|
681
|
+
const timeout = setTimeout(() => {
|
|
682
|
+
this.log('❌ Timeout claiming coordinator ID');
|
|
683
|
+
coordinatorPeer.destroy();
|
|
684
|
+
reject(new Error('Timeout claiming coordinator ID'));
|
|
685
|
+
}, 5000);
|
|
686
|
+
coordinatorPeer.on('open', (id) => {
|
|
687
|
+
clearTimeout(timeout);
|
|
688
|
+
if (id === this.coordinatorPeerId) {
|
|
689
|
+
this.log('✅ Successfully claimed coordinator ID:', id);
|
|
690
|
+
this.peer = coordinatorPeer;
|
|
691
|
+
this.peerId = id;
|
|
692
|
+
this.isCoordinator = true;
|
|
693
|
+
this.roomPeers.clear();
|
|
694
|
+
this.roomPeers.add(this.peerId);
|
|
695
|
+
this._connected = true;
|
|
696
|
+
this._callback = callback;
|
|
697
|
+
this.reElectionInProgress = false; // Re-election complete
|
|
698
|
+
// Setup peer discovery as coordinator
|
|
699
|
+
this.setupPeerDiscovery();
|
|
700
|
+
// Listen for incoming connections
|
|
701
|
+
this.peer.on('connection', (conn) => {
|
|
702
|
+
this.handleIncomingConnection(conn);
|
|
703
|
+
});
|
|
704
|
+
// Re-establish connections with known peers
|
|
705
|
+
this.log('🔗 Re-establishing connections with', currentConnections.length, 'known peers');
|
|
706
|
+
for (const [peerId, _] of currentConnections) {
|
|
707
|
+
if (peerId !== this.coordinatorPeerId) {
|
|
708
|
+
this.knownPeers.add(peerId);
|
|
709
|
+
this.roomPeers.add(peerId);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
resolve();
|
|
713
|
+
}
|
|
714
|
+
});
|
|
715
|
+
coordinatorPeer.on('error', (error) => {
|
|
716
|
+
clearTimeout(timeout);
|
|
717
|
+
this.log('❌ Error claiming coordinator ID:', error);
|
|
718
|
+
coordinatorPeer.destroy();
|
|
719
|
+
reject(error);
|
|
720
|
+
});
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
catch (error) {
|
|
724
|
+
this.log('❌ Failed to transition to coordinator:', error);
|
|
725
|
+
this.reElectionInProgress = false; // Re-election failed
|
|
726
|
+
// Fallback: recreate as regular peer and connect to whoever won the election
|
|
727
|
+
this.log('🔄 Falling back to regular peer');
|
|
728
|
+
this.reestablishAsRegularPeer();
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Recreate the local peer with a fresh random ID and reconnect to the
|
|
733
|
+
* coordinator (whoever won the election while we were transitioning).
|
|
734
|
+
* Called when transitionToCoordinator() fails.
|
|
735
|
+
*/
|
|
736
|
+
reestablishAsRegularPeer() {
|
|
737
|
+
const peerIdSuffix = Math.random().toString(36).substring(7);
|
|
738
|
+
this.peerId = `yjs-${this._room}-${peerIdSuffix}`;
|
|
739
|
+
this.isCoordinator = false;
|
|
740
|
+
this.peer = new this.options.peer(this.peerId, {
|
|
741
|
+
debug: this.options.debug ? 3 : 0,
|
|
742
|
+
...this.options.peerOptions,
|
|
743
|
+
});
|
|
744
|
+
this.peer.on('open', (id) => {
|
|
745
|
+
this.peerId = id;
|
|
746
|
+
this._connected = true;
|
|
747
|
+
this.log('✅ Reestablished as regular peer:', id);
|
|
748
|
+
this.setupPeerDiscovery();
|
|
749
|
+
this.peer.on('connection', (conn) => {
|
|
750
|
+
this.handleIncomingConnection(conn);
|
|
751
|
+
});
|
|
752
|
+
this.peer.on('disconnected', () => {
|
|
753
|
+
this.log('Peer disconnected from PeerJS server, attempting reconnect...');
|
|
754
|
+
this._handlePeerServerDisconnect();
|
|
755
|
+
});
|
|
756
|
+
// Connect to whoever is now coordinator
|
|
757
|
+
this.attemptCoordinatorConnection();
|
|
758
|
+
});
|
|
759
|
+
this.peer.on('error', (error) => {
|
|
760
|
+
this.log('❌ Error reestablishing as regular peer:', error);
|
|
761
|
+
// Destroy the dead peer object so we don't hold a stale reference
|
|
762
|
+
try {
|
|
763
|
+
this.peer?.destroy();
|
|
764
|
+
}
|
|
765
|
+
catch (_) { }
|
|
766
|
+
this.peer = null;
|
|
767
|
+
this._connected = false;
|
|
768
|
+
// If the failure is due to being offline, wait for the network to return
|
|
769
|
+
// and then try again from scratch.
|
|
770
|
+
const retryOnline = () => {
|
|
771
|
+
this.log('🌐 Network restored – retrying reestablish as regular peer');
|
|
772
|
+
this.reestablishAsRegularPeer();
|
|
773
|
+
};
|
|
774
|
+
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
|
775
|
+
window.addEventListener('online', retryOnline, { once: true });
|
|
776
|
+
}
|
|
777
|
+
else {
|
|
778
|
+
// We appear online but still failed – back off briefly and retry
|
|
779
|
+
setTimeout(retryOnline, 3000);
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Connect to a peer by ID.
|
|
785
|
+
* To avoid race conditions, only the peer with the lower ID initiates the connection.
|
|
786
|
+
*/
|
|
787
|
+
connectToPeer(remotePeerId) {
|
|
788
|
+
if (remotePeerId === this.peerId) {
|
|
789
|
+
// Skip self-connection
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
if (this.peers.has(remotePeerId)) {
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (this.peers.size >= this.options.maxConns) {
|
|
796
|
+
// Max connections reached
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
// Only initiate if our peer ID is lower (to prevent dual-initiation race condition)
|
|
800
|
+
if (this.peerId && this.peerId > remotePeerId) {
|
|
801
|
+
// Waiting for peer to initiate
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
// Initiating connection
|
|
805
|
+
try {
|
|
806
|
+
const conn = this.peer.connect(remotePeerId, {
|
|
807
|
+
reliable: true,
|
|
808
|
+
serialization: 'binary',
|
|
809
|
+
});
|
|
810
|
+
if (!conn) {
|
|
811
|
+
this.log('Failed to create connection object for:', remotePeerId);
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
// Connection object created
|
|
815
|
+
this.setupConnection(conn, remotePeerId);
|
|
816
|
+
}
|
|
817
|
+
catch (error) {
|
|
818
|
+
this.log('Error connecting to peer:', remotePeerId, error);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Handle incoming connection from another peer.
|
|
823
|
+
*/
|
|
824
|
+
handleIncomingConnection(conn) {
|
|
825
|
+
const remotePeerId = conn.peer;
|
|
826
|
+
// Incoming connection
|
|
827
|
+
if (this.peers.has(remotePeerId)) {
|
|
828
|
+
// Already connected
|
|
829
|
+
conn.close();
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
if (this.peers.size >= this.options.maxConns) {
|
|
833
|
+
this.log('Max connections reached, rejecting peer:', remotePeerId);
|
|
834
|
+
conn.close();
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
// If we're the coordinator, handle coordination duties
|
|
838
|
+
if (this.isCoordinator) {
|
|
839
|
+
// Add to room peers immediately
|
|
840
|
+
this.roomPeers.add(remotePeerId);
|
|
841
|
+
// Setup close handler to remove from room peers
|
|
842
|
+
conn.on('close', () => {
|
|
843
|
+
// Coordinator: peer left
|
|
844
|
+
this.roomPeers.delete(remotePeerId);
|
|
845
|
+
// Notify all other peers
|
|
846
|
+
for (const peerId of this.roomPeers) {
|
|
847
|
+
if (peerId !== this.peerId) {
|
|
848
|
+
const peerConn = this.peers.get(peerId);
|
|
849
|
+
if (peerConn && peerConn.connected) {
|
|
850
|
+
try {
|
|
851
|
+
this.sendCoordinationMessage(peerConn.conn, {
|
|
852
|
+
type: 'peer-left',
|
|
853
|
+
peerId: remotePeerId,
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
catch (err) {
|
|
857
|
+
this.log('⚠️ Failed to notify peer of departure:', peerId);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
// Setup the connection for Yjs sync (this handles all data including join messages)
|
|
865
|
+
this.setupConnection(conn, remotePeerId);
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Setup a peer connection with event handlers.
|
|
869
|
+
*/
|
|
870
|
+
setupConnection(conn, remotePeerId) {
|
|
871
|
+
// Setting up connection
|
|
872
|
+
const peerConn = {
|
|
873
|
+
conn,
|
|
874
|
+
connected: false,
|
|
875
|
+
peerId: remotePeerId,
|
|
876
|
+
};
|
|
877
|
+
this.peers.set(remotePeerId, peerConn);
|
|
878
|
+
conn.on('open', () => {
|
|
879
|
+
peerConn.connected = true;
|
|
880
|
+
this.knownPeers.add(remotePeerId);
|
|
881
|
+
// Notify provider so it can push its local state to this new peer
|
|
882
|
+
this._peerConnectCallback?.(remotePeerId);
|
|
883
|
+
});
|
|
884
|
+
conn.on('data', (data) => {
|
|
885
|
+
// Convert to Uint8Array if needed
|
|
886
|
+
const uint8Data = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
887
|
+
// Try to decode as JSON coordination message
|
|
888
|
+
const coordMessage = this.tryDecodeCoordinationMessage(uint8Data);
|
|
889
|
+
if (coordMessage) {
|
|
890
|
+
// Handle coordination messages
|
|
891
|
+
if (coordMessage.type === 'coordinator-change' ||
|
|
892
|
+
coordMessage.type === 'coordinator-leaving' ||
|
|
893
|
+
coordMessage.type === 'peer-joined' ||
|
|
894
|
+
coordMessage.type === 'peer-left' ||
|
|
895
|
+
coordMessage.type === 'peer-list') {
|
|
896
|
+
this.handleCoordinatorMessage(coordMessage);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
else if (coordMessage.type === 'join' && this.isCoordinator) {
|
|
900
|
+
// Handle join message if we're the coordinator
|
|
901
|
+
// Coordinator: peer joined
|
|
902
|
+
// Send current peer list (excluding coordinator and the new peer)
|
|
903
|
+
const peerList = Array.from(this.roomPeers).filter((id) => id !== this.peerId && id !== remotePeerId);
|
|
904
|
+
this.sendCoordinationMessage(conn, {
|
|
905
|
+
type: 'peer-list',
|
|
906
|
+
peers: peerList,
|
|
907
|
+
});
|
|
908
|
+
// Notify all other peers about the new peer
|
|
909
|
+
for (const peerId of this.roomPeers) {
|
|
910
|
+
if (peerId !== this.peerId && peerId !== remotePeerId) {
|
|
911
|
+
const peerConn = this.peers.get(peerId);
|
|
912
|
+
if (peerConn && peerConn.connected) {
|
|
913
|
+
try {
|
|
914
|
+
this.sendCoordinationMessage(peerConn.conn, {
|
|
915
|
+
type: 'peer-joined',
|
|
916
|
+
peerId: remotePeerId,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
catch (err) {
|
|
920
|
+
this.log('⚠️ Failed to notify peer of new joiner:', peerId);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
// Otherwise, it's Yjs sync data
|
|
929
|
+
if (this._callback) {
|
|
930
|
+
try {
|
|
931
|
+
// Decrypt if password is set
|
|
932
|
+
const decryptedData = this.options.password
|
|
933
|
+
? this.decrypt(uint8Data, this.options.password)
|
|
934
|
+
: uint8Data;
|
|
935
|
+
this._callback(decryptedData);
|
|
936
|
+
}
|
|
937
|
+
catch (error) {
|
|
938
|
+
this.log('Error handling peer data:', error);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
conn.on('close', () => {
|
|
943
|
+
this.removePeer(remotePeerId);
|
|
944
|
+
});
|
|
945
|
+
conn.on('error', (error) => {
|
|
946
|
+
this.log('⚠️ Connection error:', remotePeerId, error);
|
|
947
|
+
this.removePeer(remotePeerId);
|
|
948
|
+
});
|
|
949
|
+
// Log connection state after a delay to debug
|
|
950
|
+
setTimeout(() => { }, 1000);
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Remove and cleanup a peer connection.
|
|
954
|
+
*/
|
|
955
|
+
removePeer(peerId) {
|
|
956
|
+
const peerConn = this.peers.get(peerId);
|
|
957
|
+
if (peerConn) {
|
|
958
|
+
try {
|
|
959
|
+
peerConn.conn.close();
|
|
960
|
+
}
|
|
961
|
+
catch (error) {
|
|
962
|
+
// Ignore errors during cleanup
|
|
963
|
+
}
|
|
964
|
+
this.peers.delete(peerId);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Send a coordination message (JSON encoded as binary).
|
|
969
|
+
*/
|
|
970
|
+
sendCoordinationMessage(conn, message) {
|
|
971
|
+
try {
|
|
972
|
+
const jsonStr = JSON.stringify(message);
|
|
973
|
+
const encoder = new TextEncoder();
|
|
974
|
+
const encoded = encoder.encode(jsonStr);
|
|
975
|
+
conn.send(encoded);
|
|
976
|
+
}
|
|
977
|
+
catch (error) {
|
|
978
|
+
this.log('Error encoding coordination message:', error);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Try to decode data as a coordination message.
|
|
983
|
+
* Returns the parsed message if successful, null otherwise.
|
|
984
|
+
*/
|
|
985
|
+
tryDecodeCoordinationMessage(data) {
|
|
986
|
+
try {
|
|
987
|
+
const decoder = new TextDecoder();
|
|
988
|
+
const jsonStr = decoder.decode(data);
|
|
989
|
+
const parsed = JSON.parse(jsonStr);
|
|
990
|
+
// Check if it looks like a coordination message (has a type field)
|
|
991
|
+
if (parsed &&
|
|
992
|
+
typeof parsed === 'object' &&
|
|
993
|
+
parsed.type &&
|
|
994
|
+
typeof parsed.type === 'string') {
|
|
995
|
+
return parsed;
|
|
996
|
+
}
|
|
997
|
+
return null;
|
|
998
|
+
}
|
|
999
|
+
catch (error) {
|
|
1000
|
+
// Not JSON, must be Yjs data
|
|
1001
|
+
return null;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Simple XOR encryption (not cryptographically secure, just obfuscation).
|
|
1006
|
+
*/
|
|
1007
|
+
encrypt(data, password) {
|
|
1008
|
+
const key = this.hashPassword(password);
|
|
1009
|
+
const encrypted = new Uint8Array(data.length);
|
|
1010
|
+
for (let i = 0; i < data.length; i++) {
|
|
1011
|
+
encrypted[i] = data[i] ^ key[i % key.length];
|
|
1012
|
+
}
|
|
1013
|
+
return encrypted;
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Simple XOR decryption.
|
|
1017
|
+
*/
|
|
1018
|
+
decrypt(data, password) {
|
|
1019
|
+
// XOR is symmetric, so decrypt is the same as encrypt
|
|
1020
|
+
return this.encrypt(data, password);
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Hash password to key.
|
|
1024
|
+
*/
|
|
1025
|
+
hashPassword(password) {
|
|
1026
|
+
const encoder = new TextEncoder();
|
|
1027
|
+
return encoder.encode(password);
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Log debug messages if enabled.
|
|
1031
|
+
*/
|
|
1032
|
+
log(...args) {
|
|
1033
|
+
if (this.options.debug) {
|
|
1034
|
+
console.log('[PeerJSTransport]', ...args);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
//# sourceMappingURL=index.js.map
|