@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,783 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SimplePeer Transport Provider
|
|
3
|
+
*
|
|
4
|
+
* Peer-to-peer transport using WebRTC data channels with simple-peer library.
|
|
5
|
+
* Connects directly to other clients without going through a central server.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Direct peer-to-peer connections
|
|
9
|
+
* - Mesh network (each peer connects to multiple others)
|
|
10
|
+
* - Uses signaling server only for peer discovery (not for data)
|
|
11
|
+
* - Optional encryption
|
|
12
|
+
* - Automatic connection management
|
|
13
|
+
* - Resilient to peer disconnections
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```typescript
|
|
17
|
+
* import { GenericProvider } from 'y-generic'
|
|
18
|
+
* import { SimplePeerTransport } from 'y-generic/providers/simple-peer'
|
|
19
|
+
* import Peer from 'simple-peer'
|
|
20
|
+
*
|
|
21
|
+
* const doc = new Y.Doc()
|
|
22
|
+
* const transport = new SimplePeerTransport({
|
|
23
|
+
* peer: Peer, // Pass the simple-peer constructor
|
|
24
|
+
* signaling: ['wss://signaling.example.com'],
|
|
25
|
+
* password: 'optional-encryption-key'
|
|
26
|
+
* })
|
|
27
|
+
* const provider = new GenericProvider(doc, transport)
|
|
28
|
+
* await provider.connect({ room: 'my-room' })
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Maximum chunk size for WebRTC DataChannel messages.
|
|
33
|
+
* Most browsers support up to 256KB, but we use 64KB for safety.
|
|
34
|
+
*/
|
|
35
|
+
const CHUNK_SIZE = 64 * 1024;
|
|
36
|
+
/**
|
|
37
|
+
* Maximum buffer size before we pause sending (16KB).
|
|
38
|
+
* WebRTC DataChannel buffers data before sending over the network.
|
|
39
|
+
* If we send too much too fast, the buffer overflows.
|
|
40
|
+
*/
|
|
41
|
+
const MAX_BUFFERED_AMOUNT = 16 * 1024;
|
|
42
|
+
/**
|
|
43
|
+
* Message type markers for chunking protocol.
|
|
44
|
+
* - 0x00: Complete message (no chunking, raw data)
|
|
45
|
+
* - 0x01: Chunked message with header
|
|
46
|
+
* - 0x02: Consumer control frame — per-peer side-channel via sendControl()/
|
|
47
|
+
* onControlFrame(), never reaches the provider pipe. Not chunked/encrypted.
|
|
48
|
+
*/
|
|
49
|
+
const MSG_TYPE_COMPLETE = 0x00;
|
|
50
|
+
const MSG_TYPE_CHUNKED = 0x01;
|
|
51
|
+
const MSG_TYPE_CONTROL = 0x02;
|
|
52
|
+
/** Counter for generating unique message IDs */
|
|
53
|
+
let messageIdCounter = 0;
|
|
54
|
+
/**
|
|
55
|
+
* SimplePeer transport implementation using simple-peer library.
|
|
56
|
+
* Creates direct peer-to-peer connections for data transfer.
|
|
57
|
+
*/
|
|
58
|
+
export class SimplePeerTransport {
|
|
59
|
+
/**
|
|
60
|
+
* Create a new SimplePeer transport.
|
|
61
|
+
*
|
|
62
|
+
* @param options - Configuration options (must include peer constructor)
|
|
63
|
+
*/
|
|
64
|
+
constructor(options) {
|
|
65
|
+
this._connected = false;
|
|
66
|
+
this._room = '';
|
|
67
|
+
this._peerConnectCallbacks = new Set();
|
|
68
|
+
this._peerDisconnectCallbacks = new Set();
|
|
69
|
+
this._controlCallbacks = new Set();
|
|
70
|
+
this.peers = new Map();
|
|
71
|
+
this.signalingConns = [];
|
|
72
|
+
this.announcedPeers = new Set();
|
|
73
|
+
if (!options.peer) {
|
|
74
|
+
throw new Error('SimplePeerTransport requires the "peer" option. ' +
|
|
75
|
+
'Please provide the simple-peer constructor: ' +
|
|
76
|
+
'import Peer from "simple-peer"; new SimplePeerTransport({ peer: Peer, ... })');
|
|
77
|
+
}
|
|
78
|
+
// Default ICE servers (Google's public STUN server)
|
|
79
|
+
const defaultIceServers = [
|
|
80
|
+
{ urls: 'stun:stun.l.google.com:19302' },
|
|
81
|
+
];
|
|
82
|
+
// Prepare peerOpts with ICE servers
|
|
83
|
+
const peerOpts = { ...options.peerOpts };
|
|
84
|
+
// Merge ICE servers into peerOpts.config
|
|
85
|
+
if (options.iceServers || !peerOpts.config?.iceServers) {
|
|
86
|
+
const iceServers = options.iceServers ?? defaultIceServers;
|
|
87
|
+
peerOpts.config = {
|
|
88
|
+
...peerOpts.config,
|
|
89
|
+
iceServers: iceServers,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
this.options = {
|
|
93
|
+
peer: options.peer,
|
|
94
|
+
signaling: options.signaling ?? ['wss://signaling.yjs.dev'],
|
|
95
|
+
password: options.password ?? '',
|
|
96
|
+
maxConns: options.maxConns ?? 20 + Math.floor(Math.random() * 15),
|
|
97
|
+
peerOpts,
|
|
98
|
+
debug: options.debug ?? false,
|
|
99
|
+
};
|
|
100
|
+
// Generate unique peer ID
|
|
101
|
+
this.peerId = this.generatePeerId();
|
|
102
|
+
this.log(`Initialized — peerId: ${this.peerId}, maxConns: ${this.options.maxConns}`, `\n signaling: [${this.options.signaling.join(', ')}]`, `\n iceServers: [${(peerOpts.config?.iceServers ?? []).map((s) => (Array.isArray(s.urls) ? s.urls[0] : s.urls)).join(', ')}]`);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Connect to the room via signaling servers and start discovering peers.
|
|
106
|
+
*/
|
|
107
|
+
async connect(config) {
|
|
108
|
+
if (this._connected) {
|
|
109
|
+
throw new Error('Already connected');
|
|
110
|
+
}
|
|
111
|
+
this._room = config.room;
|
|
112
|
+
this.log(`🔌 Connecting to room "${config.room}" as ${this.peerId}`);
|
|
113
|
+
// Try to connect to signaling servers
|
|
114
|
+
// Use Promise.allSettled to allow partial success
|
|
115
|
+
const results = await Promise.allSettled(this.options.signaling.map((url) => this.connectSignaling(url)));
|
|
116
|
+
// Count successful connections
|
|
117
|
+
const successCount = results.filter((r) => r.status === 'fulfilled').length;
|
|
118
|
+
const failCount = results.filter((r) => r.status === 'rejected').length;
|
|
119
|
+
if (successCount > 0) {
|
|
120
|
+
this.log(`📡 Signaling: ${successCount}/${this.options.signaling.length} server(s) connected`);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
console.warn('[SimplePeerTransport] ⚠️ No signaling servers reachable — WebRTC peer discovery disabled. ' +
|
|
124
|
+
'Cross-tab sync via BroadcastChannel will still work.');
|
|
125
|
+
results.forEach((result, index) => {
|
|
126
|
+
if (result.status === 'rejected') {
|
|
127
|
+
console.warn(`[SimplePeerTransport] ✗ ${this.options.signaling[index]}:`, result.reason?.message ?? result.reason);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
// Still mark as connected even if no signaling servers work
|
|
132
|
+
// This allows BroadcastChannel-only mode for same-browser tabs
|
|
133
|
+
this._connected = true;
|
|
134
|
+
this.log(`✅ Connected to room "${this._room}" — ${this.signalingConns.length}/${this.options.signaling.length} signaling server(s)`);
|
|
135
|
+
// Start periodic re-announce to help late joiners discover us
|
|
136
|
+
this.announceInterval = setInterval(() => {
|
|
137
|
+
if (this.peers.size < this.options.maxConns &&
|
|
138
|
+
this.signalingConns.length > 0) {
|
|
139
|
+
this.log('Re-announcing presence...');
|
|
140
|
+
for (const ws of this.signalingConns) {
|
|
141
|
+
this.sendSignaling(ws, {
|
|
142
|
+
type: 'publish',
|
|
143
|
+
topic: this._room,
|
|
144
|
+
from: this.peerId,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}, 5000); // Re-announce every 5 seconds for better peer discovery
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Disconnect from all peers and signaling servers.
|
|
152
|
+
*/
|
|
153
|
+
disconnect() {
|
|
154
|
+
if (!this._connected)
|
|
155
|
+
return;
|
|
156
|
+
this.log(`🔌 Disconnecting — ${this.peers.size} peer(s), ${this.signalingConns.length} signaling server(s)`);
|
|
157
|
+
// Stop re-announce interval
|
|
158
|
+
if (this.announceInterval) {
|
|
159
|
+
clearInterval(this.announceInterval);
|
|
160
|
+
this.announceInterval = undefined;
|
|
161
|
+
}
|
|
162
|
+
// Close all peer connections
|
|
163
|
+
for (const peerConn of this.peers.values()) {
|
|
164
|
+
peerConn.peer.destroy();
|
|
165
|
+
}
|
|
166
|
+
this.peers.clear();
|
|
167
|
+
// Close all signaling connections
|
|
168
|
+
for (const ws of this.signalingConns) {
|
|
169
|
+
ws.close();
|
|
170
|
+
}
|
|
171
|
+
this.signalingConns = [];
|
|
172
|
+
this._connected = false;
|
|
173
|
+
this.announcedPeers.clear();
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Send data to all connected peers.
|
|
177
|
+
* Large messages are automatically chunked to fit within WebRTC DataChannel limits.
|
|
178
|
+
*/
|
|
179
|
+
send(data) {
|
|
180
|
+
if (!this._connected) {
|
|
181
|
+
this.log('Not connected, cannot send');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// Encrypt if password is set
|
|
185
|
+
const dataToSend = this.options.password
|
|
186
|
+
? this.encrypt(data, this.options.password)
|
|
187
|
+
: data;
|
|
188
|
+
// Send to all connected peers
|
|
189
|
+
let sentCount = 0;
|
|
190
|
+
for (const peerConn of this.peers.values()) {
|
|
191
|
+
if (peerConn.connected) {
|
|
192
|
+
try {
|
|
193
|
+
this.sendToPeer(peerConn, dataToSend);
|
|
194
|
+
sentCount++;
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
this.log(`❌ Send failed to ${peerConn.peerId}:`, error.message);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Only log when the picture is non-trivial (missing peers or no peers at all)
|
|
202
|
+
if (sentCount === 0) {
|
|
203
|
+
this.log(`⚠️ Send: 0 peers connected — ${data.length}B dropped`);
|
|
204
|
+
}
|
|
205
|
+
else if (sentCount < this.peers.size) {
|
|
206
|
+
const skipped = this.peers.size - sentCount;
|
|
207
|
+
this.log(`📤 Sent ${data.length}B to ${sentCount}/${this.peers.size} peer(s) — ${skipped} not yet connected`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Send data to a single connected peer by ID (targeted delivery).
|
|
212
|
+
*/
|
|
213
|
+
sendTo(peerId, data) {
|
|
214
|
+
if (!this._connected) {
|
|
215
|
+
this.log('Not connected, cannot sendTo');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const peerConn = this.peers.get(peerId);
|
|
219
|
+
if (!peerConn || !peerConn.connected) {
|
|
220
|
+
this.log(`⚠️ sendTo: peer ${peerId} not connected — ${data.length}B dropped`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const dataToSend = this.options.password
|
|
224
|
+
? this.encrypt(data, this.options.password)
|
|
225
|
+
: data;
|
|
226
|
+
try {
|
|
227
|
+
this.sendToPeer(peerConn, dataToSend);
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
this.log(`❌ sendTo failed to ${peerId}:`, error.message);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Send data to a single peer, chunking if necessary.
|
|
235
|
+
* Uses flow control to avoid overwhelming the WebRTC buffer.
|
|
236
|
+
*/
|
|
237
|
+
sendToPeer(peerConn, data) {
|
|
238
|
+
// Small messages can be sent directly with type marker
|
|
239
|
+
if (data.length <= CHUNK_SIZE - 1) {
|
|
240
|
+
const msg = new Uint8Array(data.length + 1);
|
|
241
|
+
msg[0] = MSG_TYPE_COMPLETE;
|
|
242
|
+
msg.set(data, 1);
|
|
243
|
+
peerConn.peer.send(msg);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
// Large messages need chunking with flow control
|
|
247
|
+
const messageId = messageIdCounter++;
|
|
248
|
+
const totalChunks = Math.ceil(data.length / (CHUNK_SIZE - 13));
|
|
249
|
+
this.log(`📦 Chunking ${data.length}B → ${totalChunks} chunks (msgId=${messageId})`);
|
|
250
|
+
// Queue all chunks and send with backpressure handling
|
|
251
|
+
const chunks = [];
|
|
252
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
253
|
+
const start = i * (CHUNK_SIZE - 13);
|
|
254
|
+
const end = Math.min(start + (CHUNK_SIZE - 13), data.length);
|
|
255
|
+
const chunkData = data.slice(start, end);
|
|
256
|
+
// Chunk header: [type:1][messageId:4][chunkIndex:4][totalChunks:4][data]
|
|
257
|
+
const chunk = new Uint8Array(13 + chunkData.length);
|
|
258
|
+
chunk[0] = MSG_TYPE_CHUNKED;
|
|
259
|
+
new DataView(chunk.buffer).setUint32(1, messageId, true);
|
|
260
|
+
new DataView(chunk.buffer).setUint32(5, i, true);
|
|
261
|
+
new DataView(chunk.buffer).setUint32(9, totalChunks, true);
|
|
262
|
+
chunk.set(chunkData, 13);
|
|
263
|
+
chunks.push(chunk);
|
|
264
|
+
}
|
|
265
|
+
// Send chunks with flow control
|
|
266
|
+
this.sendChunksWithFlowControl(peerConn, chunks);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Send chunks with backpressure handling.
|
|
270
|
+
* Waits for buffer to drain before sending more data.
|
|
271
|
+
*/
|
|
272
|
+
sendChunksWithFlowControl(peerConn, chunks) {
|
|
273
|
+
let index = 0;
|
|
274
|
+
const peer = peerConn.peer;
|
|
275
|
+
const sendNext = () => {
|
|
276
|
+
while (index < chunks.length) {
|
|
277
|
+
// Check if buffer is too full
|
|
278
|
+
const channel = peer._channel;
|
|
279
|
+
if (channel && channel.bufferedAmount > MAX_BUFFERED_AMOUNT) {
|
|
280
|
+
// Wait for buffer to drain
|
|
281
|
+
this.log(`⏸️ Backpressure on ${peerConn.peerId}: buffered ${channel.bufferedAmount}B, waiting...`);
|
|
282
|
+
channel.bufferedAmountLowThreshold = MAX_BUFFERED_AMOUNT / 2;
|
|
283
|
+
channel.onbufferedamountlow = () => {
|
|
284
|
+
channel.onbufferedamountlow = null;
|
|
285
|
+
this.log(`▶️ Buffer drained on ${peerConn.peerId}, resuming chunks`);
|
|
286
|
+
sendNext();
|
|
287
|
+
};
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
// Send next chunk
|
|
291
|
+
try {
|
|
292
|
+
peer.send(chunks[index]);
|
|
293
|
+
index++;
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
this.log('Error sending chunk:', index, error);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
sendNext();
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Register callback for incoming messages.
|
|
305
|
+
*/
|
|
306
|
+
onMessage(callback) {
|
|
307
|
+
this._callback = callback;
|
|
308
|
+
return () => {
|
|
309
|
+
this._callback = undefined;
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Register callback for new peer data-channel connections.
|
|
314
|
+
*/
|
|
315
|
+
onPeerConnect(callback) {
|
|
316
|
+
this._peerConnectCallbacks.add(callback);
|
|
317
|
+
return () => {
|
|
318
|
+
this._peerConnectCallbacks.delete(callback);
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Register callback for peer disconnects (channel close or error). Only fires
|
|
323
|
+
* for peers that had reached the connected state.
|
|
324
|
+
*/
|
|
325
|
+
onPeerDisconnect(callback) {
|
|
326
|
+
this._peerDisconnectCallbacks.add(callback);
|
|
327
|
+
return () => {
|
|
328
|
+
this._peerDisconnectCallbacks.delete(callback);
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Register callback for consumer control frames (MSG_TYPE_CONTROL).
|
|
333
|
+
* These bypass the provider pipe — use for per-peer handshakes/auth.
|
|
334
|
+
*/
|
|
335
|
+
onControlFrame(callback) {
|
|
336
|
+
this._controlCallbacks.add(callback);
|
|
337
|
+
return () => {
|
|
338
|
+
this._controlCallbacks.delete(callback);
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Tear down a single peer connection (e.g. to reject a peer that failed an
|
|
343
|
+
* out-of-band handshake). Fires onPeerDisconnect if the peer was connected.
|
|
344
|
+
*/
|
|
345
|
+
disconnectPeer(peerId) {
|
|
346
|
+
this.removePeer(peerId);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Send a control frame to a single peer. Not chunked or encrypted — keep
|
|
350
|
+
* payloads small (they must fit one DataChannel message).
|
|
351
|
+
*/
|
|
352
|
+
sendControl(peerId, payload) {
|
|
353
|
+
const peerConn = this.peers.get(peerId);
|
|
354
|
+
if (!peerConn || !peerConn.connected) {
|
|
355
|
+
this.log(`⚠️ sendControl: peer ${peerId} not connected — dropped`);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const msg = new Uint8Array(payload.length + 1);
|
|
359
|
+
msg[0] = MSG_TYPE_CONTROL;
|
|
360
|
+
msg.set(payload, 1);
|
|
361
|
+
try {
|
|
362
|
+
peerConn.peer.send(msg);
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
this.log(`❌ sendControl failed to ${peerId}:`, error.message);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Check if connected.
|
|
370
|
+
*/
|
|
371
|
+
get isConnected() {
|
|
372
|
+
return this._connected;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Get number of connected peers (for debugging).
|
|
376
|
+
*/
|
|
377
|
+
get connectedPeers() {
|
|
378
|
+
return Array.from(this.peers.values()).filter((p) => p.connected).length;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Connect to a signaling server.
|
|
382
|
+
*/
|
|
383
|
+
async connectSignaling(url) {
|
|
384
|
+
return new Promise((resolve, reject) => {
|
|
385
|
+
const ws = new WebSocket(url);
|
|
386
|
+
let resolved = false;
|
|
387
|
+
ws.onopen = () => {
|
|
388
|
+
this.log(`🟢 Signaling connected: ${url}`);
|
|
389
|
+
// Subscribe to room
|
|
390
|
+
this.sendSignaling(ws, {
|
|
391
|
+
type: 'subscribe',
|
|
392
|
+
topics: [this._room],
|
|
393
|
+
});
|
|
394
|
+
// Announce presence with topic (room) for y-webrtc protocol
|
|
395
|
+
if (this.peers.size < this.options.maxConns) {
|
|
396
|
+
// y-webrtc uses 'publish' with from field
|
|
397
|
+
this.sendSignaling(ws, {
|
|
398
|
+
type: 'publish',
|
|
399
|
+
topic: this._room,
|
|
400
|
+
from: this.peerId,
|
|
401
|
+
});
|
|
402
|
+
// Also send announce for basic protocol compatibility
|
|
403
|
+
this.sendSignaling(ws, {
|
|
404
|
+
type: 'announce',
|
|
405
|
+
from: this.peerId,
|
|
406
|
+
topic: this._room,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
this.signalingConns.push(ws);
|
|
410
|
+
if (!resolved) {
|
|
411
|
+
resolved = true;
|
|
412
|
+
resolve();
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
ws.onmessage = (event) => {
|
|
416
|
+
try {
|
|
417
|
+
const msg = JSON.parse(event.data);
|
|
418
|
+
// Only log signal-bearing messages to avoid flooding with pure topology pings
|
|
419
|
+
if (msg.signal || msg.type === 'announce') {
|
|
420
|
+
this.log(`📩 Signaling ‹${msg.type}›`, msg.from ? `from=${msg.from.slice(0, 8)}` : '', msg.to ? `to=${msg.to.slice(0, 8)}` : '', msg.signal ? `signal=${msg.signal.type ?? 'candidate'}` : '');
|
|
421
|
+
}
|
|
422
|
+
this.handleSignalingMessage(msg);
|
|
423
|
+
}
|
|
424
|
+
catch (error) {
|
|
425
|
+
this.log(`❌ Failed to parse signaling message: ${error.message} raw=${event.data}`);
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
ws.onerror = (error) => {
|
|
429
|
+
this.log(`❌ Signaling error: ${url}`, error);
|
|
430
|
+
if (!resolved) {
|
|
431
|
+
resolved = true;
|
|
432
|
+
reject(error);
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
ws.onclose = () => {
|
|
436
|
+
this.log(`🔴 Signaling disconnected: ${url}`);
|
|
437
|
+
const index = this.signalingConns.indexOf(ws);
|
|
438
|
+
if (index > -1) {
|
|
439
|
+
this.signalingConns.splice(index, 1);
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
// Timeout after 10 seconds
|
|
443
|
+
setTimeout(() => {
|
|
444
|
+
if (!resolved) {
|
|
445
|
+
resolved = true;
|
|
446
|
+
this.log(`⏱️ Signaling connection timeout: ${url}`);
|
|
447
|
+
reject(new Error('Signaling connection timeout'));
|
|
448
|
+
}
|
|
449
|
+
}, 10000);
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Handle messages from signaling server.
|
|
454
|
+
*/
|
|
455
|
+
handleSignalingMessage(msg) {
|
|
456
|
+
// Skip messages from ourselves
|
|
457
|
+
if (msg.from && msg.from === this.peerId)
|
|
458
|
+
return;
|
|
459
|
+
switch (msg.type) {
|
|
460
|
+
case 'publish':
|
|
461
|
+
// y-webrtc protocol: messages are published to topics
|
|
462
|
+
// This is an envelope, the actual message could be an announce or signal
|
|
463
|
+
if (msg.from) {
|
|
464
|
+
// Treat as announce if it's a publish from another peer
|
|
465
|
+
if (!this.peers.has(msg.from) &&
|
|
466
|
+
this.peers.size < this.options.maxConns &&
|
|
467
|
+
!this.announcedPeers.has(msg.from)) {
|
|
468
|
+
const shouldInitiate = this.peerId > msg.from;
|
|
469
|
+
this.log(`📡 Peer discovered via publish: ${msg.from} — role: ${shouldInitiate ? 'initiator' : 'non-initiator'}`, `(peers: ${this.peers.size + 1}/${this.options.maxConns})`);
|
|
470
|
+
this.announcedPeers.add(msg.from);
|
|
471
|
+
this.createPeerConnection(msg.from, shouldInitiate);
|
|
472
|
+
// If we're NOT the initiator, immediately re-announce so the initiator
|
|
473
|
+
// can discover us (they may have missed our initial publish)
|
|
474
|
+
if (!shouldInitiate) {
|
|
475
|
+
this.log('📢 Re-announcing so initiator can find us...');
|
|
476
|
+
for (const ws of this.signalingConns) {
|
|
477
|
+
this.sendSignaling(ws, {
|
|
478
|
+
type: 'publish',
|
|
479
|
+
topic: this._room,
|
|
480
|
+
from: this.peerId,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
// Handle embedded signal if present
|
|
487
|
+
if (msg.signal && msg.from) {
|
|
488
|
+
if (!msg.to || msg.to === this.peerId) {
|
|
489
|
+
this.handlePeerSignal(msg.from, msg.signal);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
break;
|
|
493
|
+
case 'announce':
|
|
494
|
+
if (!msg.from) {
|
|
495
|
+
this.log('Announce message missing from field');
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
// Another peer announced - connect to them if we have capacity
|
|
499
|
+
if (!this.peers.has(msg.from) &&
|
|
500
|
+
this.peers.size < this.options.maxConns &&
|
|
501
|
+
!this.announcedPeers.has(msg.from)) {
|
|
502
|
+
const shouldInitiate = this.peerId > msg.from;
|
|
503
|
+
this.log(`📡 Peer announced: ${msg.from} — role: ${shouldInitiate ? 'initiator' : 'non-initiator'}`, `(peers: ${this.peers.size + 1}/${this.options.maxConns})`);
|
|
504
|
+
this.announcedPeers.add(msg.from);
|
|
505
|
+
this.createPeerConnection(msg.from, shouldInitiate);
|
|
506
|
+
}
|
|
507
|
+
else if (this.peers.size >= this.options.maxConns) {
|
|
508
|
+
this.log(`⚠️ Peer limit reached (${this.options.maxConns}), ignoring announce from ${msg.from}`);
|
|
509
|
+
}
|
|
510
|
+
break;
|
|
511
|
+
case 'signal':
|
|
512
|
+
if (!msg.from) {
|
|
513
|
+
this.log('Signal message missing from field');
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
// Received WebRTC signal from peer
|
|
517
|
+
if (msg.to === this.peerId && msg.signal) {
|
|
518
|
+
this.handlePeerSignal(msg.from, msg.signal);
|
|
519
|
+
}
|
|
520
|
+
else if (!msg.to && msg.signal) {
|
|
521
|
+
// Signal without explicit target — handle anyway (some servers strip `to`)
|
|
522
|
+
this.handlePeerSignal(msg.from, msg.signal);
|
|
523
|
+
}
|
|
524
|
+
break;
|
|
525
|
+
default:
|
|
526
|
+
this.log(`❓ Unknown signaling message type: ${msg.type}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Send message to signaling server.
|
|
531
|
+
*/
|
|
532
|
+
sendSignaling(ws, msg) {
|
|
533
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
534
|
+
ws.send(JSON.stringify(msg));
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Broadcast message to all signaling servers.
|
|
539
|
+
*/
|
|
540
|
+
broadcastSignaling(msg) {
|
|
541
|
+
for (const ws of this.signalingConns) {
|
|
542
|
+
this.sendSignaling(ws, msg);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Create a WebRTC peer connection.
|
|
547
|
+
*/
|
|
548
|
+
createPeerConnection(remotePeerId, initiator) {
|
|
549
|
+
if (this.peers.has(remotePeerId)) {
|
|
550
|
+
this.log(`⏭️ Peer connection already exists: ${remotePeerId}`);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
this.log(`🤝 Creating ${initiator ? 'outbound (initiator)' : 'inbound (non-initiator)'} connection to ${remotePeerId}`);
|
|
554
|
+
const peer = new this.options.peer({
|
|
555
|
+
initiator,
|
|
556
|
+
...this.options.peerOpts,
|
|
557
|
+
});
|
|
558
|
+
const peerConn = {
|
|
559
|
+
peer,
|
|
560
|
+
connected: false,
|
|
561
|
+
peerId: remotePeerId,
|
|
562
|
+
chunkBuffers: new Map(),
|
|
563
|
+
};
|
|
564
|
+
this.peers.set(remotePeerId, peerConn);
|
|
565
|
+
// Handle signaling data (ICE candidates and SDP)
|
|
566
|
+
peer.on('signal', (signal) => {
|
|
567
|
+
this.log(`📤 Signal to ${remotePeerId}: ${signal.type ?? 'candidate'}`);
|
|
568
|
+
// Send as 'publish' only — broadcasting as BOTH 'publish' and 'signal' causes the
|
|
569
|
+
// receiving peer to apply the same SDP offer twice, which triggers a WebRTC error
|
|
570
|
+
// and destroys the peer before it can fully connect.
|
|
571
|
+
this.broadcastSignaling({
|
|
572
|
+
type: 'publish',
|
|
573
|
+
from: this.peerId,
|
|
574
|
+
to: remotePeerId,
|
|
575
|
+
signal,
|
|
576
|
+
topic: this._room,
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
// Mark channel open and notify provider — called by either 'connect' or the first
|
|
580
|
+
// 'data' event, whichever fires first (WebRTC can deliver data before 'connect' on
|
|
581
|
+
// some browsers, causing replies to be dropped if we wait for 'connect' only).
|
|
582
|
+
const onChannelOpen = (via) => {
|
|
583
|
+
if (peerConn.connected)
|
|
584
|
+
return; // already fired
|
|
585
|
+
peerConn.connected = true;
|
|
586
|
+
const connectedCount = Array.from(this.peers.values()).filter((p) => p.connected).length;
|
|
587
|
+
this.log(`✅ Peer channel open (${via}): ${remotePeerId} — ${connectedCount}/${this.peers.size} peer(s) connected`);
|
|
588
|
+
for (const cb of this._peerConnectCallbacks)
|
|
589
|
+
cb(remotePeerId);
|
|
590
|
+
};
|
|
591
|
+
// Handle connection
|
|
592
|
+
peer.on('connect', () => onChannelOpen('connect'));
|
|
593
|
+
// Handle ICE connection state for debugging
|
|
594
|
+
if (peer._pc) {
|
|
595
|
+
peer._pc.oniceconnectionstatechange = () => {
|
|
596
|
+
this.log(`🧊 ICE ${remotePeerId}: ${peer._pc.iceConnectionState}`);
|
|
597
|
+
};
|
|
598
|
+
peer._pc.onconnectionstatechange = () => {
|
|
599
|
+
this.log(`🔗 Connection ${remotePeerId}: ${peer._pc.connectionState}`);
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
// Handle incoming data
|
|
603
|
+
peer.on('data', (data) => {
|
|
604
|
+
// Data flowing proves the channel is open — handle the race where 'data' fires
|
|
605
|
+
// before 'connect' (seen on Chrome when the remote initiator sends immediately).
|
|
606
|
+
onChannelOpen('data');
|
|
607
|
+
let uint8Data;
|
|
608
|
+
try {
|
|
609
|
+
uint8Data = new Uint8Array(data);
|
|
610
|
+
}
|
|
611
|
+
catch (error) {
|
|
612
|
+
this.log('Error handling peer data:', error);
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (uint8Data.length === 0)
|
|
616
|
+
return;
|
|
617
|
+
// Control frames bypass the provider pipe (identity/auth handshakes etc.)
|
|
618
|
+
if (uint8Data[0] === MSG_TYPE_CONTROL) {
|
|
619
|
+
const payload = uint8Data.slice(1);
|
|
620
|
+
for (const cb of this._controlCallbacks)
|
|
621
|
+
cb(remotePeerId, payload);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
if (!this._callback)
|
|
625
|
+
return;
|
|
626
|
+
try {
|
|
627
|
+
const msgType = uint8Data[0];
|
|
628
|
+
if (msgType === MSG_TYPE_COMPLETE) {
|
|
629
|
+
// Complete message, extract payload (skip type byte)
|
|
630
|
+
const payload = uint8Data.slice(1);
|
|
631
|
+
const decryptedData = this.options.password
|
|
632
|
+
? this.decrypt(payload, this.options.password)
|
|
633
|
+
: payload;
|
|
634
|
+
this._callback(decryptedData);
|
|
635
|
+
}
|
|
636
|
+
else if (msgType === MSG_TYPE_CHUNKED) {
|
|
637
|
+
// Chunked message - reassemble
|
|
638
|
+
const view = new DataView(uint8Data.buffer, uint8Data.byteOffset);
|
|
639
|
+
const messageId = view.getUint32(1, true);
|
|
640
|
+
const chunkIndex = view.getUint32(5, true);
|
|
641
|
+
const totalChunks = view.getUint32(9, true);
|
|
642
|
+
const chunkData = uint8Data.slice(13);
|
|
643
|
+
// Get or create buffer for this message
|
|
644
|
+
let buffer = peerConn.chunkBuffers.get(messageId);
|
|
645
|
+
if (!buffer) {
|
|
646
|
+
buffer = { chunks: new Map(), totalChunks };
|
|
647
|
+
peerConn.chunkBuffers.set(messageId, buffer);
|
|
648
|
+
}
|
|
649
|
+
// Store chunk
|
|
650
|
+
buffer.chunks.set(chunkIndex, chunkData);
|
|
651
|
+
// Check if complete
|
|
652
|
+
if (buffer.chunks.size === totalChunks) {
|
|
653
|
+
// Reassemble message
|
|
654
|
+
let totalLength = 0;
|
|
655
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
656
|
+
totalLength += buffer.chunks.get(i).length;
|
|
657
|
+
}
|
|
658
|
+
const fullMessage = new Uint8Array(totalLength);
|
|
659
|
+
let offset = 0;
|
|
660
|
+
for (let i = 0; i < totalChunks; i++) {
|
|
661
|
+
const chunk = buffer.chunks.get(i);
|
|
662
|
+
fullMessage.set(chunk, offset);
|
|
663
|
+
offset += chunk.length;
|
|
664
|
+
}
|
|
665
|
+
// Clean up buffer
|
|
666
|
+
peerConn.chunkBuffers.delete(messageId);
|
|
667
|
+
// Decrypt and deliver
|
|
668
|
+
const decryptedData = this.options.password
|
|
669
|
+
? this.decrypt(fullMessage, this.options.password)
|
|
670
|
+
: fullMessage;
|
|
671
|
+
this._callback(decryptedData);
|
|
672
|
+
this.log(`📥 Reassembled ${totalLength}B from ${totalChunks} chunks (msgId=${messageId})`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
// Unknown type or legacy message without type marker - try as raw data
|
|
677
|
+
const decryptedData = this.options.password
|
|
678
|
+
? this.decrypt(uint8Data, this.options.password)
|
|
679
|
+
: uint8Data;
|
|
680
|
+
this._callback(decryptedData);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
catch (error) {
|
|
684
|
+
this.log('Error handling peer data:', error);
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
// Handle errors
|
|
688
|
+
peer.on('error', (error) => {
|
|
689
|
+
this.log(`❌ Peer error [${remotePeerId}]: ${error.message ?? error}`);
|
|
690
|
+
this.removePeer(remotePeerId);
|
|
691
|
+
});
|
|
692
|
+
// Handle close
|
|
693
|
+
peer.on('close', () => {
|
|
694
|
+
const connectedCount = Array.from(this.peers.values()).filter((p) => p.connected && p.peerId !== remotePeerId).length;
|
|
695
|
+
this.log(`🔴 Peer channel closed: ${remotePeerId} — ${connectedCount}/${this.peers.size - 1} remaining`);
|
|
696
|
+
this.removePeer(remotePeerId);
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Handle WebRTC signal from peer.
|
|
701
|
+
*/
|
|
702
|
+
handlePeerSignal(remotePeerId, signal) {
|
|
703
|
+
let peerConn = this.peers.get(remotePeerId);
|
|
704
|
+
if (!peerConn) {
|
|
705
|
+
this.log(`📶 Signal from unknown peer ${remotePeerId} (${signal?.type ?? 'candidate'}) — creating non-initiator connection`);
|
|
706
|
+
this.createPeerConnection(remotePeerId, false);
|
|
707
|
+
peerConn = this.peers.get(remotePeerId);
|
|
708
|
+
}
|
|
709
|
+
if (peerConn) {
|
|
710
|
+
try {
|
|
711
|
+
peerConn.peer.signal(signal);
|
|
712
|
+
}
|
|
713
|
+
catch (error) {
|
|
714
|
+
this.log(`❌ Failed to apply signal from ${remotePeerId}: ${error.message}`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
this.log(`❌ Could not create peer connection for signal from ${remotePeerId}`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Remove and cleanup a peer connection.
|
|
723
|
+
*/
|
|
724
|
+
removePeer(peerId) {
|
|
725
|
+
const peerConn = this.peers.get(peerId);
|
|
726
|
+
if (peerConn) {
|
|
727
|
+
const wasConnected = peerConn.connected;
|
|
728
|
+
try {
|
|
729
|
+
peerConn.peer.destroy();
|
|
730
|
+
}
|
|
731
|
+
catch (error) {
|
|
732
|
+
// Ignore errors during cleanup
|
|
733
|
+
}
|
|
734
|
+
this.peers.delete(peerId);
|
|
735
|
+
this.announcedPeers.delete(peerId);
|
|
736
|
+
if (wasConnected)
|
|
737
|
+
for (const cb of this._peerDisconnectCallbacks)
|
|
738
|
+
cb(peerId);
|
|
739
|
+
const connectedCount = Array.from(this.peers.values()).filter((p) => p.connected).length;
|
|
740
|
+
this.log(`🗑️ Removed peer ${peerId} — ${connectedCount} connected / ${this.peers.size} total`);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Generate a unique peer ID.
|
|
745
|
+
*/
|
|
746
|
+
generatePeerId() {
|
|
747
|
+
return Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Simple XOR encryption (not cryptographically secure, just obfuscation).
|
|
751
|
+
*/
|
|
752
|
+
encrypt(data, password) {
|
|
753
|
+
const key = this.hashPassword(password);
|
|
754
|
+
const encrypted = new Uint8Array(data.length);
|
|
755
|
+
for (let i = 0; i < data.length; i++) {
|
|
756
|
+
encrypted[i] = data[i] ^ key[i % key.length];
|
|
757
|
+
}
|
|
758
|
+
return encrypted;
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Simple XOR decryption.
|
|
762
|
+
*/
|
|
763
|
+
decrypt(data, password) {
|
|
764
|
+
// XOR is symmetric, so decrypt is the same as encrypt
|
|
765
|
+
return this.encrypt(data, password);
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Hash password to key.
|
|
769
|
+
*/
|
|
770
|
+
hashPassword(password) {
|
|
771
|
+
const encoder = new TextEncoder();
|
|
772
|
+
return encoder.encode(password);
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* Log debug messages if enabled.
|
|
776
|
+
*/
|
|
777
|
+
log(...args) {
|
|
778
|
+
if (this.options.debug) {
|
|
779
|
+
console.log('[SimplePeerTransport]', ...args);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
//# sourceMappingURL=index.js.map
|