@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
package/dist/index.js
ADDED
|
@@ -0,0 +1,1001 @@
|
|
|
1
|
+
import * as Y from 'yjs';
|
|
2
|
+
import * as awarenessProtocol from 'y-protocols/awareness';
|
|
3
|
+
import * as syncProtocol from 'y-protocols/sync';
|
|
4
|
+
import * as encoding from 'lib0/encoding';
|
|
5
|
+
import * as decoding from 'lib0/decoding';
|
|
6
|
+
import { Observable } from 'lib0/observable';
|
|
7
|
+
import * as bc from 'lib0/broadcastchannel';
|
|
8
|
+
// Message type identifiers
|
|
9
|
+
const MESSAGE_SYNC = 0;
|
|
10
|
+
const MESSAGE_AWARENESS = 1;
|
|
11
|
+
const MESSAGE_PUBSUB = 2;
|
|
12
|
+
const MESSAGE_SYNC_VERIFIED = 3; // Sync message with hash verification
|
|
13
|
+
const MESSAGE_PUBSUB_TARGETED = 4; // Pub/sub message aimed at a single target
|
|
14
|
+
/**
|
|
15
|
+
* CRC32 lookup table for fast computation.
|
|
16
|
+
* Generated once and reused for all CRC calculations.
|
|
17
|
+
*/
|
|
18
|
+
const CRC32_TABLE = (() => {
|
|
19
|
+
const table = new Uint32Array(256);
|
|
20
|
+
for (let i = 0; i < 256; i++) {
|
|
21
|
+
let crc = i;
|
|
22
|
+
for (let j = 0; j < 8; j++) {
|
|
23
|
+
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
|
24
|
+
}
|
|
25
|
+
table[i] = crc;
|
|
26
|
+
}
|
|
27
|
+
return table;
|
|
28
|
+
})();
|
|
29
|
+
/**
|
|
30
|
+
* Compute CRC32 checksum of data for message integrity verification.
|
|
31
|
+
* Fast, non-cryptographic checksum optimized for corruption detection.
|
|
32
|
+
*/
|
|
33
|
+
function computeCRC32(data) {
|
|
34
|
+
let crc = 0xffffffff;
|
|
35
|
+
for (let i = 0; i < data.length; i++) {
|
|
36
|
+
crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ data[i]) & 0xff];
|
|
37
|
+
}
|
|
38
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Wrap message with CRC32 checksum for integrity verification.
|
|
42
|
+
* Format: [CRC32 (4 bytes)][message data]
|
|
43
|
+
*/
|
|
44
|
+
function wrapMessageWithChecksum(message) {
|
|
45
|
+
const crc = computeCRC32(message);
|
|
46
|
+
const wrapped = new Uint8Array(4 + message.length);
|
|
47
|
+
// Write CRC32 as 4 bytes (big-endian)
|
|
48
|
+
wrapped[0] = (crc >>> 24) & 0xff;
|
|
49
|
+
wrapped[1] = (crc >>> 16) & 0xff;
|
|
50
|
+
wrapped[2] = (crc >>> 8) & 0xff;
|
|
51
|
+
wrapped[3] = crc & 0xff;
|
|
52
|
+
// Copy message data
|
|
53
|
+
wrapped.set(message, 4);
|
|
54
|
+
return wrapped;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Unwrap and verify message integrity using CRC32 checksum.
|
|
58
|
+
* Returns the message data if valid, null if corrupted.
|
|
59
|
+
*/
|
|
60
|
+
function unwrapAndVerifyMessage(wrapped) {
|
|
61
|
+
if (wrapped.length < 4) {
|
|
62
|
+
return null; // Too short to contain CRC32
|
|
63
|
+
}
|
|
64
|
+
// Read CRC32 (big-endian)
|
|
65
|
+
const expectedCrc = ((wrapped[0] << 24) |
|
|
66
|
+
(wrapped[1] << 16) |
|
|
67
|
+
(wrapped[2] << 8) |
|
|
68
|
+
wrapped[3]) >>>
|
|
69
|
+
0;
|
|
70
|
+
// Extract message data
|
|
71
|
+
const message = wrapped.subarray(4);
|
|
72
|
+
// Compute actual CRC32
|
|
73
|
+
const actualCrc = computeCRC32(message);
|
|
74
|
+
// Verify integrity
|
|
75
|
+
if (actualCrc !== expectedCrc) {
|
|
76
|
+
return null; // Checksum mismatch - message corrupted
|
|
77
|
+
}
|
|
78
|
+
return message;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Compute a simple hash of document state for desync detection.
|
|
82
|
+
* Uses a fast non-cryptographic hash for performance.
|
|
83
|
+
*
|
|
84
|
+
* Hashes the state VECTOR, not encodeStateAsUpdate: the full update byte stream
|
|
85
|
+
* is NOT canonical across CRDT-convergent replicas (client-block and tombstone
|
|
86
|
+
* ordering differ per peer), so hashing it flags false divergence and triggers
|
|
87
|
+
* an endless re-sync loop. The state vector (clientID -> clock) is serialized in
|
|
88
|
+
* sorted clientID order by Yjs, so two convergent docs hash identically, while a
|
|
89
|
+
* missed update still shows up as a differing clock — which is exactly the
|
|
90
|
+
* "did we fall behind?" signal this check exists to provide.
|
|
91
|
+
*/
|
|
92
|
+
function computeDocHash(doc) {
|
|
93
|
+
const state = Y.encodeStateVector(doc);
|
|
94
|
+
let hash = 0;
|
|
95
|
+
for (let i = 0; i < state.length; i++) {
|
|
96
|
+
hash = ((hash << 5) - hash + state[i]) | 0;
|
|
97
|
+
}
|
|
98
|
+
return hash;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* PubSub channel for real-time messaging alongside Yjs.
|
|
102
|
+
* Allows sending ephemeral messages that don't need CRDT properties.
|
|
103
|
+
*/
|
|
104
|
+
export class PubSubChannel extends Observable {
|
|
105
|
+
constructor(provider) {
|
|
106
|
+
super();
|
|
107
|
+
this.provider = provider;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Publish a message to a topic.
|
|
111
|
+
*
|
|
112
|
+
* @param topic - Topic name (e.g., 'notifications', 'rpc', 'events')
|
|
113
|
+
* @param message - Any JSON-serializable data
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```typescript
|
|
117
|
+
* provider.pubsub.publish('chat', { user: 'Alice', text: 'Hello!' })
|
|
118
|
+
* provider.pubsub.publish('cursor', { x: 100, y: 200 })
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
publish(topic, message) {
|
|
122
|
+
this.provider._sendPubSub(topic, message);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Publish a message to a single target instead of broadcasting.
|
|
126
|
+
*
|
|
127
|
+
* On transports with `sendTo`, `target` is the peer's ID and delivery is
|
|
128
|
+
* direct. On transports without it, the message is broadcast with the
|
|
129
|
+
* target embedded and dropped by every provider whose `localId` differs.
|
|
130
|
+
*
|
|
131
|
+
* @param target - Recipient id (transport peerId, or a `localId`)
|
|
132
|
+
* @param topic - Topic name
|
|
133
|
+
* @param message - Any JSON-serializable data
|
|
134
|
+
*/
|
|
135
|
+
publishTo(target, topic, message) {
|
|
136
|
+
this.provider._sendPubSubTo(target, topic, message);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Subscribe to messages on a topic.
|
|
140
|
+
*
|
|
141
|
+
* @param topic - Topic name to listen to (use '*' for all topics)
|
|
142
|
+
* @param callback - Function called when message received
|
|
143
|
+
* @returns Unsubscribe function
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* ```typescript
|
|
147
|
+
* const unsub = provider.pubsub.subscribe('chat', (msg) => {
|
|
148
|
+
* console.log('Chat:', msg)
|
|
149
|
+
* })
|
|
150
|
+
*
|
|
151
|
+
* // Later: unsub()
|
|
152
|
+
* ```
|
|
153
|
+
*/
|
|
154
|
+
subscribe(topic, callback) {
|
|
155
|
+
const handler = (message, receivedTopic) => {
|
|
156
|
+
if (topic === '*' || topic === receivedTopic) {
|
|
157
|
+
callback(message, receivedTopic);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
this.on('message', handler);
|
|
161
|
+
return () => this.off('message', handler);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Internal: Handle incoming pub/sub message
|
|
165
|
+
*/
|
|
166
|
+
_handleMessage(topic, message) {
|
|
167
|
+
this.emit('message', [message, topic]);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Generic Yjs provider that works with any transport implementation.
|
|
172
|
+
*
|
|
173
|
+
* This provider handles all Yjs synchronization logic including:
|
|
174
|
+
* - Document updates (automatic sync)
|
|
175
|
+
* - Awareness protocol (presence, cursors, etc.)
|
|
176
|
+
* - State vector synchronization
|
|
177
|
+
* - Optional pub/sub channel for real-time messaging
|
|
178
|
+
*
|
|
179
|
+
* You only need to implement the Transport interface for your backend.
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* ```typescript
|
|
183
|
+
* // Create your transport
|
|
184
|
+
* const transport = new MyCustomTransport()
|
|
185
|
+
*
|
|
186
|
+
* // Create provider with Yjs document
|
|
187
|
+
* const doc = new Y.Doc()
|
|
188
|
+
* const provider = new GenericProvider(doc, transport)
|
|
189
|
+
*
|
|
190
|
+
* // Connect
|
|
191
|
+
* await provider.connect({ room: 'my-room' })
|
|
192
|
+
*
|
|
193
|
+
* // Provider automatically syncs all changes
|
|
194
|
+
* const ytext = doc.getText('content')
|
|
195
|
+
* ytext.insert(0, 'Hello') // Automatically synced!
|
|
196
|
+
* ```
|
|
197
|
+
*/
|
|
198
|
+
export class GenericProvider extends Observable {
|
|
199
|
+
/**
|
|
200
|
+
* Create a new generic provider.
|
|
201
|
+
*
|
|
202
|
+
* @param doc - The Yjs document to sync
|
|
203
|
+
* @param transport - Transport implementation for your backend
|
|
204
|
+
* @param options - Optional configuration
|
|
205
|
+
*/
|
|
206
|
+
constructor(doc, transport, options = {}) {
|
|
207
|
+
super();
|
|
208
|
+
this._status = { state: 'disconnected' };
|
|
209
|
+
this._synced = false;
|
|
210
|
+
this._destroying = false;
|
|
211
|
+
// BroadcastChannel state for cross-tab sync
|
|
212
|
+
this._bcChannel = '';
|
|
213
|
+
this._bcConnected = false;
|
|
214
|
+
// Hash verification tracking for exponential backoff
|
|
215
|
+
this._hashMismatchCount = 0;
|
|
216
|
+
this._lastHashMismatchTime = 0;
|
|
217
|
+
// Rate limiting for sync requests
|
|
218
|
+
this._syncRequestTimes = [];
|
|
219
|
+
this._maxSyncRequestsPerWindow = 20; // max requests per 10 seconds
|
|
220
|
+
this._syncRequestWindowMs = 10000; // 10 second window
|
|
221
|
+
// Sequence numbers for causal ordering
|
|
222
|
+
this._localSeqNum = 0; // Our sequence number counter
|
|
223
|
+
this._remoteSeqNums = new Map(); // clientID -> last seen seqNum
|
|
224
|
+
// Message integrity tracking
|
|
225
|
+
this._corruptedMessageCount = 0; // Track rejected corrupted messages
|
|
226
|
+
this._lastCorruptedMessageTime = 0;
|
|
227
|
+
// Update batching/debouncing
|
|
228
|
+
this._batchUpdates = 0; // milliseconds delay (0 = disabled)
|
|
229
|
+
this._pendingUpdate = null;
|
|
230
|
+
// Awareness throttling - prevents awareness from flooding document sync
|
|
231
|
+
this._awarenessInterval = 100; // ms between awareness broadcasts
|
|
232
|
+
this._pendingAwarenessClients = new Set();
|
|
233
|
+
this._lastAwarenessTime = 0;
|
|
234
|
+
// Origins whose updates are never sent to the transport (local-only txns).
|
|
235
|
+
this._excludeOrigins = new Set();
|
|
236
|
+
// Connect-time sync strategy: 'push-pull' (default) sends full local state
|
|
237
|
+
// then requests remote; 'pull' only requests remote state.
|
|
238
|
+
this._syncMode = 'push-pull';
|
|
239
|
+
this.doc = doc;
|
|
240
|
+
this.transport = transport;
|
|
241
|
+
this.pubsub = new PubSubChannel(this);
|
|
242
|
+
this.awareness = options.awareness || new awarenessProtocol.Awareness(doc);
|
|
243
|
+
this._syncInterval = options.syncInterval ?? 5000;
|
|
244
|
+
this._verifyUpdates = options.verifyUpdates ?? true;
|
|
245
|
+
this._batchUpdates = options.batchUpdates ?? 0;
|
|
246
|
+
this._disableBc = options.disableBc ?? false;
|
|
247
|
+
this._awarenessInterval = options.awarenessInterval ?? 100;
|
|
248
|
+
this._excludeOrigins = new Set(options.excludeOrigins ?? []);
|
|
249
|
+
this._localId = options.localId;
|
|
250
|
+
this._syncMode = options.syncMode ?? 'push-pull';
|
|
251
|
+
this._setupDocumentSync();
|
|
252
|
+
this._setupAwarenessSync();
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Connect to the backend and start syncing.
|
|
256
|
+
*
|
|
257
|
+
* @param config - Connection configuration passed to transport
|
|
258
|
+
*/
|
|
259
|
+
async connect(config) {
|
|
260
|
+
if (this._destroying) {
|
|
261
|
+
throw new Error('Provider is being destroyed');
|
|
262
|
+
}
|
|
263
|
+
// Prevent double connect race condition
|
|
264
|
+
if (this._status.state === 'connected') {
|
|
265
|
+
console.warn('[GenericProvider] Already connected, ignoring connect() call');
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (this._status.state === 'connecting') {
|
|
269
|
+
console.warn('[GenericProvider] Connection already in progress, ignoring connect() call');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
this._setStatus({ state: 'connecting' });
|
|
273
|
+
try {
|
|
274
|
+
// Setup BroadcastChannel for cross-tab sync (if enabled and available)
|
|
275
|
+
this._setupBroadcastChannel(config);
|
|
276
|
+
// Connect the transport
|
|
277
|
+
await this.transport.connect(config);
|
|
278
|
+
// Register for incoming messages
|
|
279
|
+
this._unsubscribeTransport = this.transport.onMessage((data) => {
|
|
280
|
+
this._handleIncomingMessage(data);
|
|
281
|
+
});
|
|
282
|
+
// When a new WebRTC peer channel opens, immediately push our full state
|
|
283
|
+
// so peers that reconnected after offline edits receive our changes.
|
|
284
|
+
if (this.transport.onPeerConnect) {
|
|
285
|
+
const unsubPeer = this.transport.onPeerConnect((_peerId) => {
|
|
286
|
+
if (!this._destroying)
|
|
287
|
+
this.syncNow();
|
|
288
|
+
});
|
|
289
|
+
const originalUnsub = this._unsubscribeTransport;
|
|
290
|
+
this._unsubscribeTransport = () => {
|
|
291
|
+
originalUnsub?.();
|
|
292
|
+
unsubPeer();
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
this._setStatus({ state: 'connected' });
|
|
296
|
+
// Send initial sync. In 'push-pull' mode, syncNow() pushes our local
|
|
297
|
+
// state (so offline edits reach currently-connected peers) then requests
|
|
298
|
+
// remote state. In 'pull' mode, only request remote state — a relay/server
|
|
299
|
+
// holds authoritative state and we adopt it rather than pushing a
|
|
300
|
+
// competing local copy on every (re)connect.
|
|
301
|
+
if (this._syncMode === 'pull') {
|
|
302
|
+
this._sendSyncStep1();
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
this.syncNow();
|
|
306
|
+
}
|
|
307
|
+
// Broadcast local awareness state
|
|
308
|
+
this._broadcastAwareness([this.doc.clientID]);
|
|
309
|
+
// Start periodic sync to handle packet loss
|
|
310
|
+
// Just request sync without sending full state (avoid redundant broadcasts)
|
|
311
|
+
if (this._syncInterval > 0) {
|
|
312
|
+
this._syncIntervalId = setInterval(() => {
|
|
313
|
+
if (this.transport.isConnected && !this._destroying) {
|
|
314
|
+
// Check rate limit before syncing
|
|
315
|
+
const now = Date.now();
|
|
316
|
+
this._syncRequestTimes = this._syncRequestTimes.filter((t) => now - t < this._syncRequestWindowMs);
|
|
317
|
+
if (this._syncRequestTimes.length < this._maxSyncRequestsPerWindow) {
|
|
318
|
+
this._sendSyncStep1();
|
|
319
|
+
}
|
|
320
|
+
// If rate limited, skip this periodic sync - will try again next interval
|
|
321
|
+
}
|
|
322
|
+
}, this._syncInterval);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
this._setStatus({
|
|
327
|
+
state: 'error',
|
|
328
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
329
|
+
});
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Disconnect from the backend.
|
|
335
|
+
* The provider can be reconnected later with connect().
|
|
336
|
+
*/
|
|
337
|
+
disconnect() {
|
|
338
|
+
// Stop periodic sync
|
|
339
|
+
if (this._syncIntervalId !== undefined) {
|
|
340
|
+
clearInterval(this._syncIntervalId);
|
|
341
|
+
this._syncIntervalId = undefined;
|
|
342
|
+
}
|
|
343
|
+
// Reset corruption tracking
|
|
344
|
+
this._corruptedMessageCount = 0;
|
|
345
|
+
this._lastCorruptedMessageTime = 0;
|
|
346
|
+
// Flush any pending batched updates before disconnecting
|
|
347
|
+
if (this._batchTimeoutId !== undefined) {
|
|
348
|
+
clearTimeout(this._batchTimeoutId);
|
|
349
|
+
this._batchTimeoutId = undefined;
|
|
350
|
+
// Send pending update if transport is still connected
|
|
351
|
+
if (this._pendingUpdate && this.transport.isConnected) {
|
|
352
|
+
this._sendUpdate(this._pendingUpdate);
|
|
353
|
+
}
|
|
354
|
+
this._pendingUpdate = null;
|
|
355
|
+
}
|
|
356
|
+
// Flush pending awareness updates before disconnecting
|
|
357
|
+
if (this._awarenessTimeoutId !== undefined) {
|
|
358
|
+
clearTimeout(this._awarenessTimeoutId);
|
|
359
|
+
this._awarenessTimeoutId = undefined;
|
|
360
|
+
// Send pending awareness if transport is still connected
|
|
361
|
+
if (this._pendingAwarenessClients.size > 0 &&
|
|
362
|
+
this.transport.isConnected) {
|
|
363
|
+
const clientsToSend = Array.from(this._pendingAwarenessClients);
|
|
364
|
+
this._sendAwarenessNow(clientsToSend);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
this._pendingAwarenessClients.clear();
|
|
368
|
+
// Disconnect BroadcastChannel
|
|
369
|
+
this._disconnectBroadcastChannel();
|
|
370
|
+
if (this._unsubscribeTransport) {
|
|
371
|
+
this._unsubscribeTransport();
|
|
372
|
+
this._unsubscribeTransport = undefined;
|
|
373
|
+
}
|
|
374
|
+
// Mark local client as offline in awareness
|
|
375
|
+
awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], 'disconnect');
|
|
376
|
+
this.transport.disconnect();
|
|
377
|
+
this._synced = false;
|
|
378
|
+
this._setStatus({ state: 'disconnected' });
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Destroy the provider permanently.
|
|
382
|
+
* Removes all event listeners and cleans up resources.
|
|
383
|
+
*/
|
|
384
|
+
destroy() {
|
|
385
|
+
this._destroying = true;
|
|
386
|
+
// Stop periodic sync (disconnect() will also do this, but be explicit)
|
|
387
|
+
if (this._syncIntervalId !== undefined) {
|
|
388
|
+
clearInterval(this._syncIntervalId);
|
|
389
|
+
this._syncIntervalId = undefined;
|
|
390
|
+
}
|
|
391
|
+
// Flush any pending batched updates before destroying
|
|
392
|
+
if (this._batchTimeoutId !== undefined) {
|
|
393
|
+
clearTimeout(this._batchTimeoutId);
|
|
394
|
+
this._batchTimeoutId = undefined;
|
|
395
|
+
// Send pending update if transport is still connected
|
|
396
|
+
if (this._pendingUpdate && this.transport.isConnected) {
|
|
397
|
+
this._sendUpdate(this._pendingUpdate);
|
|
398
|
+
}
|
|
399
|
+
this._pendingUpdate = null;
|
|
400
|
+
}
|
|
401
|
+
this.disconnect();
|
|
402
|
+
// Remove document update listener
|
|
403
|
+
if (this._updateHandler) {
|
|
404
|
+
this.doc.off('update', this._updateHandler);
|
|
405
|
+
this._updateHandler = undefined;
|
|
406
|
+
}
|
|
407
|
+
// Remove awareness update listener
|
|
408
|
+
if (this._awarenessUpdateHandler) {
|
|
409
|
+
this.awareness.off('update', this._awarenessUpdateHandler);
|
|
410
|
+
this._awarenessUpdateHandler = undefined;
|
|
411
|
+
}
|
|
412
|
+
// Remove beforeunload handler
|
|
413
|
+
if (this._beforeUnloadHandler && typeof window !== 'undefined') {
|
|
414
|
+
window.removeEventListener('beforeunload', this._beforeUnloadHandler);
|
|
415
|
+
this._beforeUnloadHandler = undefined;
|
|
416
|
+
}
|
|
417
|
+
this.awareness.destroy();
|
|
418
|
+
super.destroy();
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Current connection status
|
|
422
|
+
*/
|
|
423
|
+
get status() {
|
|
424
|
+
return this._status;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Whether the provider is connected to the backend
|
|
428
|
+
*/
|
|
429
|
+
get connected() {
|
|
430
|
+
return this.transport.isConnected;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Whether BroadcastChannel is connected for cross-tab sync
|
|
434
|
+
*/
|
|
435
|
+
get bcConnected() {
|
|
436
|
+
return this._bcConnected;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Whether the document is synced with remote peers
|
|
440
|
+
*/
|
|
441
|
+
get synced() {
|
|
442
|
+
return this._synced;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Force an immediate sync with remote peers.
|
|
446
|
+
* Useful after network interruptions or to manually trigger re-sync.
|
|
447
|
+
*/
|
|
448
|
+
syncNow() {
|
|
449
|
+
if (!this.transport.isConnected) {
|
|
450
|
+
console.warn('Cannot sync: transport not connected');
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
// Send our current document state to all peers
|
|
454
|
+
// This ensures any changes made while offline are transmitted
|
|
455
|
+
const update = Y.encodeStateAsUpdate(this.doc);
|
|
456
|
+
if (update.length > 0) {
|
|
457
|
+
this._sendUpdate(update);
|
|
458
|
+
}
|
|
459
|
+
// Send sync request to get updates from others
|
|
460
|
+
this._sendSyncStep1();
|
|
461
|
+
// Broadcast current awareness state
|
|
462
|
+
this._broadcastAwareness([this.doc.clientID]);
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Setup automatic document synchronization.
|
|
466
|
+
* Listens to document updates and sends them to the transport.
|
|
467
|
+
* If batchUpdates is enabled, updates are debounced/batched.
|
|
468
|
+
*/
|
|
469
|
+
_setupDocumentSync() {
|
|
470
|
+
this._updateHandler = (update, origin) => {
|
|
471
|
+
// Don't send updates that originated from this provider
|
|
472
|
+
// This prevents infinite loops when receiving updates
|
|
473
|
+
if (origin === this)
|
|
474
|
+
return;
|
|
475
|
+
// Don't send updates from excluded origins (local-only txns)
|
|
476
|
+
if (this._excludeOrigins.has(origin))
|
|
477
|
+
return;
|
|
478
|
+
if (this._batchUpdates > 0) {
|
|
479
|
+
// Batch mode: merge updates and debounce
|
|
480
|
+
this._batchUpdate(update);
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
// Immediate mode: send right away
|
|
484
|
+
this._sendUpdate(update);
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
this.doc.on('update', this._updateHandler);
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Batch/debounce updates to reduce network traffic.
|
|
491
|
+
* Merges multiple updates and sends after delay.
|
|
492
|
+
*/
|
|
493
|
+
_batchUpdate(update) {
|
|
494
|
+
// Merge with pending update if exists
|
|
495
|
+
if (this._pendingUpdate) {
|
|
496
|
+
try {
|
|
497
|
+
// Yjs automatically merges sequential updates
|
|
498
|
+
this._pendingUpdate = Y.mergeUpdates([this._pendingUpdate, update]);
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
console.error('[GenericProvider] Failed to merge updates:', error);
|
|
502
|
+
// Send the pending update immediately to avoid data loss
|
|
503
|
+
this._sendUpdate(this._pendingUpdate);
|
|
504
|
+
// Start a new batch with the current update
|
|
505
|
+
this._pendingUpdate = update;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
this._pendingUpdate = update;
|
|
510
|
+
}
|
|
511
|
+
// Clear existing timeout
|
|
512
|
+
if (this._batchTimeoutId !== undefined) {
|
|
513
|
+
clearTimeout(this._batchTimeoutId);
|
|
514
|
+
}
|
|
515
|
+
// Set new timeout to send after delay
|
|
516
|
+
this._batchTimeoutId = setTimeout(() => {
|
|
517
|
+
if (this._pendingUpdate) {
|
|
518
|
+
this._sendUpdate(this._pendingUpdate);
|
|
519
|
+
this._pendingUpdate = null;
|
|
520
|
+
}
|
|
521
|
+
this._batchTimeoutId = undefined;
|
|
522
|
+
}, this._batchUpdates);
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Setup automatic awareness synchronization.
|
|
526
|
+
* Listens to awareness changes and broadcasts them.
|
|
527
|
+
*/
|
|
528
|
+
_setupAwarenessSync() {
|
|
529
|
+
this._awarenessUpdateHandler = ({ added, updated, removed, }, origin) => {
|
|
530
|
+
// Broadcast awareness changes (unless they came from remote)
|
|
531
|
+
const changedClients = added.concat(updated).concat(removed);
|
|
532
|
+
this._broadcastAwareness(changedClients);
|
|
533
|
+
};
|
|
534
|
+
this.awareness.on('update', this._awarenessUpdateHandler);
|
|
535
|
+
// Cleanup: mark as offline and disconnect BC when page unloads
|
|
536
|
+
if (typeof window !== 'undefined') {
|
|
537
|
+
this._beforeUnloadHandler = () => {
|
|
538
|
+
awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], 'window unload');
|
|
539
|
+
// Disconnect BroadcastChannel to notify other tabs
|
|
540
|
+
this._disconnectBroadcastChannel();
|
|
541
|
+
};
|
|
542
|
+
window.addEventListener('beforeunload', this._beforeUnloadHandler);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Handle incoming messages from the transport.
|
|
547
|
+
* Verifies message integrity with CRC32 before processing.
|
|
548
|
+
* Corrupt messages are rejected immediately without attempting to decode.
|
|
549
|
+
*/
|
|
550
|
+
_handleIncomingMessage(data) {
|
|
551
|
+
// Verify message integrity with CRC32 checksum
|
|
552
|
+
const message = unwrapAndVerifyMessage(data);
|
|
553
|
+
if (message === null) {
|
|
554
|
+
// Message is corrupted - reject it immediately
|
|
555
|
+
this._corruptedMessageCount++;
|
|
556
|
+
const now = Date.now();
|
|
557
|
+
// Reset counter if it's been stable for 10 seconds
|
|
558
|
+
if (now - this._lastCorruptedMessageTime > 10000) {
|
|
559
|
+
this._corruptedMessageCount = 1;
|
|
560
|
+
}
|
|
561
|
+
this._lastCorruptedMessageTime = now;
|
|
562
|
+
console.warn(`[GenericProvider] 💥 Corrupted message rejected (#${this._corruptedMessageCount}): CRC32 checksum mismatch. ` +
|
|
563
|
+
`This is expected if data corruption simulation is enabled.`);
|
|
564
|
+
// Request re-sync to recover any lost data
|
|
565
|
+
// Use exponential backoff: 100ms, 500ms, 2.5s, then cap at 5s
|
|
566
|
+
const delay = Math.min(5000, 100 * Math.pow(5, Math.min(this._corruptedMessageCount - 1, 3)));
|
|
567
|
+
setTimeout(() => {
|
|
568
|
+
if (this.transport.isConnected && !this._destroying) {
|
|
569
|
+
this._sendSyncStep1();
|
|
570
|
+
}
|
|
571
|
+
}, delay);
|
|
572
|
+
return; // Don't process corrupted message
|
|
573
|
+
}
|
|
574
|
+
// Message integrity verified - safe to decode
|
|
575
|
+
try {
|
|
576
|
+
const decoder = decoding.createDecoder(message);
|
|
577
|
+
const messageType = decoding.readVarUint(decoder);
|
|
578
|
+
switch (messageType) {
|
|
579
|
+
case MESSAGE_SYNC: {
|
|
580
|
+
const encoder = encoding.createEncoder();
|
|
581
|
+
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
|
582
|
+
const syncMessageType = syncProtocol.readSyncMessage(decoder, encoder, this.doc, this);
|
|
583
|
+
// If we received SyncStep2, we're synced
|
|
584
|
+
if (syncMessageType === syncProtocol.messageYjsSyncStep2 &&
|
|
585
|
+
!this._synced) {
|
|
586
|
+
this._synced = true;
|
|
587
|
+
this.emit('synced', [true]);
|
|
588
|
+
}
|
|
589
|
+
// Send reply if needed
|
|
590
|
+
if (encoding.length(encoder) > 1) {
|
|
591
|
+
this._send(encoding.toUint8Array(encoder));
|
|
592
|
+
}
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
case MESSAGE_AWARENESS: {
|
|
596
|
+
awarenessProtocol.applyAwarenessUpdate(this.awareness, decoding.readVarUint8Array(decoder), this);
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
case MESSAGE_PUBSUB: {
|
|
600
|
+
// Read topic
|
|
601
|
+
const topic = decoding.readVarString(decoder);
|
|
602
|
+
// Read message payload
|
|
603
|
+
const payloadBytes = decoding.readVarUint8Array(decoder);
|
|
604
|
+
try {
|
|
605
|
+
// Decode JSON payload
|
|
606
|
+
const decoder = new TextDecoder();
|
|
607
|
+
const payloadStr = decoder.decode(payloadBytes);
|
|
608
|
+
const message = JSON.parse(payloadStr);
|
|
609
|
+
// Emit to pubsub channel
|
|
610
|
+
this.pubsub._handleMessage(topic, message);
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
console.error('Error decoding pub/sub message:', error);
|
|
614
|
+
}
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
case MESSAGE_PUBSUB_TARGETED: {
|
|
618
|
+
const target = decoding.readVarString(decoder);
|
|
619
|
+
const topic = decoding.readVarString(decoder);
|
|
620
|
+
const payloadBytes = decoding.readVarUint8Array(decoder);
|
|
621
|
+
// Drop messages aimed at someone else (broadcast-and-filter path).
|
|
622
|
+
if (this._localId !== undefined && target !== this._localId) {
|
|
623
|
+
break;
|
|
624
|
+
}
|
|
625
|
+
try {
|
|
626
|
+
const message = JSON.parse(new TextDecoder().decode(payloadBytes));
|
|
627
|
+
this.pubsub._handleMessage(topic, message);
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
console.error('Error decoding targeted pub/sub message:', error);
|
|
631
|
+
}
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
case MESSAGE_SYNC_VERIFIED: {
|
|
635
|
+
// Sync message with sequence number and hash verification
|
|
636
|
+
// Read sequence number and clientID first
|
|
637
|
+
const seqNum = decoding.readVarUint(decoder);
|
|
638
|
+
const senderClientID = decoding.readVarUint(decoder);
|
|
639
|
+
// Check for duplicate or out-of-order updates
|
|
640
|
+
const lastSeq = this._remoteSeqNums.get(senderClientID) ?? -1;
|
|
641
|
+
if (seqNum <= lastSeq) {
|
|
642
|
+
console.warn(`[GenericProvider] Duplicate or out-of-order update detected from client ${senderClientID}: seqNum ${seqNum} <= lastSeen ${lastSeq}`);
|
|
643
|
+
// Skip this update - it's a duplicate or we already have newer data
|
|
644
|
+
break;
|
|
645
|
+
}
|
|
646
|
+
// Check for sequence gap (potential packet loss)
|
|
647
|
+
if (lastSeq >= 0 && seqNum > lastSeq + 1) {
|
|
648
|
+
const gapSize = seqNum - lastSeq - 1;
|
|
649
|
+
console.warn(`[GenericProvider] Sequence gap detected from client ${senderClientID}: expected ${lastSeq + 1}, got ${seqNum} (gap of ${gapSize} messages)`);
|
|
650
|
+
// Immediately request sync to recover missing updates
|
|
651
|
+
// This is more proactive than waiting for periodic sync or hash mismatch
|
|
652
|
+
this._sendSyncStep1();
|
|
653
|
+
}
|
|
654
|
+
// Update sequence tracker
|
|
655
|
+
this._remoteSeqNums.set(senderClientID, seqNum);
|
|
656
|
+
// Create encoder for reply with standard MESSAGE_SYNC header
|
|
657
|
+
// (replies don't need verification since they're generated immediately)
|
|
658
|
+
const encoder = encoding.createEncoder();
|
|
659
|
+
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
|
660
|
+
const syncMessageType = syncProtocol.readSyncMessage(decoder, encoder, this.doc, this);
|
|
661
|
+
// Read the expected hash from sender (signed integer)
|
|
662
|
+
const expectedHash = decoding.readVarInt(decoder);
|
|
663
|
+
// Compute our local hash after applying the update
|
|
664
|
+
const localHash = computeDocHash(this.doc);
|
|
665
|
+
// Verify hash match
|
|
666
|
+
if (localHash !== expectedHash) {
|
|
667
|
+
this._hashMismatchCount++;
|
|
668
|
+
const now = Date.now();
|
|
669
|
+
// Reset counter if it's been stable for 10 seconds
|
|
670
|
+
if (now - this._lastHashMismatchTime > 10000) {
|
|
671
|
+
this._hashMismatchCount = 1;
|
|
672
|
+
}
|
|
673
|
+
this._lastHashMismatchTime = now;
|
|
674
|
+
// Exponential backoff: 10ms, 50ms, 250ms, 1.25s, 6.25s, then cap at 10s
|
|
675
|
+
const delay = Math.min(10000, 10 * Math.pow(5, this._hashMismatchCount - 1));
|
|
676
|
+
console.warn(`[GenericProvider] Hash mismatch #${this._hashMismatchCount} detected! Local: ${localHash}, Expected: ${expectedHash}`);
|
|
677
|
+
console.warn(`[GenericProvider] Re-sync scheduled in ${delay}ms...`);
|
|
678
|
+
// Push our full state AND request theirs.
|
|
679
|
+
// A hash mismatch means the two peers have diverged — one side may
|
|
680
|
+
// have edits the other lacks. Calling only _sendSyncStep1() (pull)
|
|
681
|
+
// never delivers our own surplus edits to the other side.
|
|
682
|
+
setTimeout(() => {
|
|
683
|
+
if (this.transport.isConnected && !this._destroying) {
|
|
684
|
+
this.syncNow();
|
|
685
|
+
}
|
|
686
|
+
}, delay);
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
// Hash matched - reset failure counter
|
|
690
|
+
this._hashMismatchCount = 0;
|
|
691
|
+
}
|
|
692
|
+
// If we received SyncStep2, we're synced (unless hash mismatched)
|
|
693
|
+
if (syncMessageType === syncProtocol.messageYjsSyncStep2 &&
|
|
694
|
+
!this._synced &&
|
|
695
|
+
localHash === expectedHash) {
|
|
696
|
+
this._synced = true;
|
|
697
|
+
this.emit('synced', [true]);
|
|
698
|
+
}
|
|
699
|
+
// Send reply if needed (as standard MESSAGE_SYNC)
|
|
700
|
+
if (encoding.length(encoder) > 1) {
|
|
701
|
+
this._send(encoding.toUint8Array(encoder));
|
|
702
|
+
}
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
default:
|
|
706
|
+
console.warn('Unknown message type:', messageType);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
catch (error) {
|
|
710
|
+
// This should only happen for logic errors, not corruption
|
|
711
|
+
// (corruption is caught by CRC32 check above)
|
|
712
|
+
console.error('[GenericProvider] Error handling message:', error);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Send SyncStep1 message to request missing updates.
|
|
717
|
+
* This is sent when first connecting to sync with remote peers.
|
|
718
|
+
* Note: SyncStep1 is just a request and doesn't include hash verification.
|
|
719
|
+
* Rate limited to prevent spam.
|
|
720
|
+
*/
|
|
721
|
+
_sendSyncStep1() {
|
|
722
|
+
const now = Date.now();
|
|
723
|
+
// Clean up old entries outside the rate limit window
|
|
724
|
+
this._syncRequestTimes = this._syncRequestTimes.filter((t) => now - t < this._syncRequestWindowMs);
|
|
725
|
+
// Check rate limit
|
|
726
|
+
if (this._syncRequestTimes.length >= this._maxSyncRequestsPerWindow) {
|
|
727
|
+
console.warn(`[GenericProvider] Sync rate limit exceeded (${this._maxSyncRequestsPerWindow} requests per ${this._syncRequestWindowMs / 1000}s), throttling...`);
|
|
728
|
+
return; // Drop the request
|
|
729
|
+
}
|
|
730
|
+
// Record this request
|
|
731
|
+
this._syncRequestTimes.push(now);
|
|
732
|
+
const encoder = encoding.createEncoder();
|
|
733
|
+
// SyncStep1 is always sent as standard MESSAGE_SYNC (no verification)
|
|
734
|
+
// It's just a request, not an assertion of state
|
|
735
|
+
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
|
736
|
+
syncProtocol.writeSyncStep1(encoder, this.doc);
|
|
737
|
+
this._send(encoding.toUint8Array(encoder));
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Send a document update to the transport.
|
|
741
|
+
* If verifyUpdates is enabled, includes sequence number and document hash for ordering and desync detection.
|
|
742
|
+
*/
|
|
743
|
+
_sendUpdate(update) {
|
|
744
|
+
const encoder = encoding.createEncoder();
|
|
745
|
+
if (this._verifyUpdates) {
|
|
746
|
+
// Use verified sync protocol with sequence number and hash
|
|
747
|
+
encoding.writeVarUint(encoder, MESSAGE_SYNC_VERIFIED);
|
|
748
|
+
// Include sequence number and clientID for causal ordering
|
|
749
|
+
encoding.writeVarUint(encoder, this._localSeqNum++);
|
|
750
|
+
encoding.writeVarUint(encoder, this.doc.clientID);
|
|
751
|
+
syncProtocol.writeUpdate(encoder, update);
|
|
752
|
+
// Include document hash after applying this update (signed integer)
|
|
753
|
+
const hash = computeDocHash(this.doc);
|
|
754
|
+
encoding.writeVarInt(encoder, hash);
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
// Standard sync protocol without verification
|
|
758
|
+
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
|
759
|
+
syncProtocol.writeUpdate(encoder, update);
|
|
760
|
+
}
|
|
761
|
+
this._send(encoding.toUint8Array(encoder));
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Send awareness update to the transport.
|
|
765
|
+
*/
|
|
766
|
+
_sendAwarenessUpdate(changedClients) {
|
|
767
|
+
const encoder = encoding.createEncoder();
|
|
768
|
+
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
|
|
769
|
+
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients));
|
|
770
|
+
this._send(encoding.toUint8Array(encoder));
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Send a pub/sub message.
|
|
774
|
+
* Internal method called by PubSubChannel.
|
|
775
|
+
*/
|
|
776
|
+
_sendPubSub(topic, message) {
|
|
777
|
+
if (!this.transport.isConnected) {
|
|
778
|
+
console.warn('Cannot send pub/sub message: not connected');
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
try {
|
|
782
|
+
const encoder = encoding.createEncoder();
|
|
783
|
+
// Write message type
|
|
784
|
+
encoding.writeVarUint(encoder, MESSAGE_PUBSUB);
|
|
785
|
+
// Write topic
|
|
786
|
+
encoding.writeVarString(encoder, topic);
|
|
787
|
+
// Encode message as JSON
|
|
788
|
+
const messageStr = JSON.stringify(message);
|
|
789
|
+
const textEncoder = new TextEncoder();
|
|
790
|
+
const messageBytes = textEncoder.encode(messageStr);
|
|
791
|
+
// Write message payload
|
|
792
|
+
encoding.writeVarUint8Array(encoder, messageBytes);
|
|
793
|
+
this._send(encoding.toUint8Array(encoder));
|
|
794
|
+
}
|
|
795
|
+
catch (error) {
|
|
796
|
+
console.error('Error sending pub/sub message:', error);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Send a targeted pub/sub message.
|
|
801
|
+
* Uses transport.sendTo when available (direct delivery), otherwise
|
|
802
|
+
* broadcasts a targeted frame that non-target providers drop.
|
|
803
|
+
* Internal method called by PubSubChannel.
|
|
804
|
+
*/
|
|
805
|
+
_sendPubSubTo(target, topic, message) {
|
|
806
|
+
if (!this.transport.isConnected) {
|
|
807
|
+
console.warn('Cannot send targeted pub/sub message: not connected');
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
try {
|
|
811
|
+
const encoder = encoding.createEncoder();
|
|
812
|
+
encoding.writeVarUint(encoder, MESSAGE_PUBSUB_TARGETED);
|
|
813
|
+
encoding.writeVarString(encoder, target);
|
|
814
|
+
encoding.writeVarString(encoder, topic);
|
|
815
|
+
const messageBytes = new TextEncoder().encode(JSON.stringify(message));
|
|
816
|
+
encoding.writeVarUint8Array(encoder, messageBytes);
|
|
817
|
+
const frame = encoding.toUint8Array(encoder);
|
|
818
|
+
if (this.transport.sendTo) {
|
|
819
|
+
// Direct delivery to the target peer.
|
|
820
|
+
const wrapped = wrapMessageWithChecksum(frame);
|
|
821
|
+
const result = this.transport.sendTo(target, wrapped);
|
|
822
|
+
if (result instanceof Promise) {
|
|
823
|
+
result.catch((error) => {
|
|
824
|
+
console.error('Error sending targeted pub/sub message:', error);
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
else {
|
|
829
|
+
// Broadcast-and-filter: dropped by non-target providers on receive.
|
|
830
|
+
this._send(frame);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
catch (error) {
|
|
834
|
+
console.error('Error sending targeted pub/sub message:', error);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Broadcast awareness state for the specified clients.
|
|
839
|
+
* Throttled to prevent awareness updates from flooding document sync.
|
|
840
|
+
* Multiple rapid updates are batched together.
|
|
841
|
+
*/
|
|
842
|
+
_broadcastAwareness(clients) {
|
|
843
|
+
if (clients.length === 0)
|
|
844
|
+
return;
|
|
845
|
+
// If throttling is disabled, send immediately
|
|
846
|
+
if (this._awarenessInterval <= 0) {
|
|
847
|
+
this._sendAwarenessNow(clients);
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
// Add clients to pending set
|
|
851
|
+
for (const client of clients) {
|
|
852
|
+
this._pendingAwarenessClients.add(client);
|
|
853
|
+
}
|
|
854
|
+
// If we already have a scheduled broadcast, let it handle the batched clients
|
|
855
|
+
if (this._awarenessTimeoutId !== undefined) {
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
// Calculate delay - respect minimum interval since last broadcast
|
|
859
|
+
const now = Date.now();
|
|
860
|
+
const timeSinceLastBroadcast = now - this._lastAwarenessTime;
|
|
861
|
+
const delay = Math.max(0, this._awarenessInterval - timeSinceLastBroadcast);
|
|
862
|
+
// Schedule the batched broadcast
|
|
863
|
+
this._awarenessTimeoutId = setTimeout(() => {
|
|
864
|
+
this._awarenessTimeoutId = undefined;
|
|
865
|
+
this._lastAwarenessTime = Date.now();
|
|
866
|
+
// Send all pending clients in one message
|
|
867
|
+
const clientsToSend = Array.from(this._pendingAwarenessClients);
|
|
868
|
+
this._pendingAwarenessClients.clear();
|
|
869
|
+
if (clientsToSend.length > 0) {
|
|
870
|
+
this._sendAwarenessNow(clientsToSend);
|
|
871
|
+
}
|
|
872
|
+
}, delay);
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Send awareness update immediately without throttling.
|
|
876
|
+
*/
|
|
877
|
+
_sendAwarenessNow(clients) {
|
|
878
|
+
const encoder = encoding.createEncoder();
|
|
879
|
+
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
|
|
880
|
+
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(this.awareness, clients));
|
|
881
|
+
this._send(encoding.toUint8Array(encoder));
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Setup BroadcastChannel for cross-tab communication.
|
|
885
|
+
* Automatically disabled in non-browser environments.
|
|
886
|
+
*/
|
|
887
|
+
_setupBroadcastChannel(config) {
|
|
888
|
+
// Check if BroadcastChannel is available and not disabled
|
|
889
|
+
if (this._disableBc ||
|
|
890
|
+
typeof BroadcastChannel === 'undefined' ||
|
|
891
|
+
typeof window === 'undefined') {
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
// Create channel name based on room
|
|
895
|
+
this._bcChannel = `yjs-${config.room}`;
|
|
896
|
+
// Setup subscriber for incoming messages from other tabs
|
|
897
|
+
this._bcSubscriber = (data, origin) => {
|
|
898
|
+
// Ignore messages from this provider instance
|
|
899
|
+
if (origin === this) {
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
// Messages from BroadcastChannel are already CRC32-wrapped
|
|
903
|
+
// Pass through to _handleIncomingMessage which will unwrap and verify
|
|
904
|
+
const uint8Data = new Uint8Array(data);
|
|
905
|
+
this._handleIncomingMessage(uint8Data);
|
|
906
|
+
};
|
|
907
|
+
// Subscribe to the channel
|
|
908
|
+
bc.subscribe(this._bcChannel, this._bcSubscriber);
|
|
909
|
+
this._bcConnected = true;
|
|
910
|
+
// Send initial sync via BroadcastChannel (wrapped with CRC32)
|
|
911
|
+
// This allows syncing with other tabs immediately
|
|
912
|
+
const encoderSync = encoding.createEncoder();
|
|
913
|
+
encoding.writeVarUint(encoderSync, MESSAGE_SYNC);
|
|
914
|
+
syncProtocol.writeSyncStep1(encoderSync, this.doc);
|
|
915
|
+
bc.publish(this._bcChannel, wrapMessageWithChecksum(encoding.toUint8Array(encoderSync)), this);
|
|
916
|
+
// Broadcast local state via BroadcastChannel (wrapped with CRC32)
|
|
917
|
+
const encoderState = encoding.createEncoder();
|
|
918
|
+
encoding.writeVarUint(encoderState, MESSAGE_SYNC);
|
|
919
|
+
syncProtocol.writeSyncStep2(encoderState, this.doc);
|
|
920
|
+
bc.publish(this._bcChannel, wrapMessageWithChecksum(encoding.toUint8Array(encoderState)), this);
|
|
921
|
+
// Broadcast local awareness state via BroadcastChannel (wrapped with CRC32)
|
|
922
|
+
if (this.awareness.getLocalState() !== null) {
|
|
923
|
+
const encoderAwareness = encoding.createEncoder();
|
|
924
|
+
encoding.writeVarUint(encoderAwareness, MESSAGE_AWARENESS);
|
|
925
|
+
encoding.writeVarUint8Array(encoderAwareness, awarenessProtocol.encodeAwarenessUpdate(this.awareness, [
|
|
926
|
+
this.doc.clientID,
|
|
927
|
+
]));
|
|
928
|
+
bc.publish(this._bcChannel, wrapMessageWithChecksum(encoding.toUint8Array(encoderAwareness)), this);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Disconnect from BroadcastChannel and mark local client as offline.
|
|
933
|
+
*/
|
|
934
|
+
_disconnectBroadcastChannel() {
|
|
935
|
+
if (!this._bcConnected || !this._bcSubscriber) {
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
// Broadcast awareness state with null (indicating disconnect) - wrapped with CRC32
|
|
939
|
+
const encoder = encoding.createEncoder();
|
|
940
|
+
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
|
|
941
|
+
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(this.awareness, [this.doc.clientID], new Map()));
|
|
942
|
+
bc.publish(this._bcChannel, wrapMessageWithChecksum(encoding.toUint8Array(encoder)), this);
|
|
943
|
+
// Unsubscribe from channel
|
|
944
|
+
bc.unsubscribe(this._bcChannel, this._bcSubscriber);
|
|
945
|
+
this._bcConnected = false;
|
|
946
|
+
this._bcSubscriber = undefined;
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Send data through both BroadcastChannel (if connected) and transport.
|
|
950
|
+
* All messages are wrapped with CRC32 checksum for integrity verification.
|
|
951
|
+
* This ensures updates reach both local tabs and remote peers with corruption detection.
|
|
952
|
+
*/
|
|
953
|
+
_send(data) {
|
|
954
|
+
// Wrap message with CRC32 checksum
|
|
955
|
+
const wrappedData = wrapMessageWithChecksum(data);
|
|
956
|
+
// Send via BroadcastChannel to other tabs first
|
|
957
|
+
if (this._bcConnected) {
|
|
958
|
+
bc.publish(this._bcChannel, wrappedData, this);
|
|
959
|
+
}
|
|
960
|
+
// Send via network transport
|
|
961
|
+
if (!this.transport.isConnected) {
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
try {
|
|
965
|
+
const result = this.transport.send(wrappedData);
|
|
966
|
+
// Handle async send
|
|
967
|
+
if (result instanceof Promise) {
|
|
968
|
+
result.catch((error) => {
|
|
969
|
+
console.error('Error sending data:', error);
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
catch (error) {
|
|
974
|
+
console.error('Error sending data:', error);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Update connection status and emit event.
|
|
979
|
+
*/
|
|
980
|
+
_setStatus(status) {
|
|
981
|
+
this._status = status;
|
|
982
|
+
this.emit('status', [status]);
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* TEST HELPER: Set local sequence number to a specific value.
|
|
986
|
+
* Used for testing sequence number overflow scenarios.
|
|
987
|
+
* @internal
|
|
988
|
+
*/
|
|
989
|
+
_testSetSequenceNumber(seqNum) {
|
|
990
|
+
this._localSeqNum = seqNum;
|
|
991
|
+
console.warn(`[GenericProvider TEST] Sequence number set to ${seqNum} (MAX_SAFE_INTEGER: ${Number.MAX_SAFE_INTEGER})`);
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* TEST HELPER: Get current local sequence number.
|
|
995
|
+
* @internal
|
|
996
|
+
*/
|
|
997
|
+
_testGetSequenceNumber() {
|
|
998
|
+
return this._localSeqNum;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
//# sourceMappingURL=index.js.map
|