@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.
Files changed (63) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +660 -0
  3. package/dist/index.d.ts +323 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +1001 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/lib.d.ts +36 -0
  8. package/dist/lib.d.ts.map +1 -0
  9. package/dist/lib.js +37 -0
  10. package/dist/lib.js.map +1 -0
  11. package/dist/providers/dummy/index.d.ts +226 -0
  12. package/dist/providers/dummy/index.d.ts.map +1 -0
  13. package/dist/providers/dummy/index.js +326 -0
  14. package/dist/providers/dummy/index.js.map +1 -0
  15. package/dist/providers/gun/index.d.ts +269 -0
  16. package/dist/providers/gun/index.d.ts.map +1 -0
  17. package/dist/providers/gun/index.js +683 -0
  18. package/dist/providers/gun/index.js.map +1 -0
  19. package/dist/providers/indexeddb/index.d.ts +161 -0
  20. package/dist/providers/indexeddb/index.d.ts.map +1 -0
  21. package/dist/providers/indexeddb/index.js +369 -0
  22. package/dist/providers/indexeddb/index.js.map +1 -0
  23. package/dist/providers/matrix/index.d.ts +109 -0
  24. package/dist/providers/matrix/index.d.ts.map +1 -0
  25. package/dist/providers/matrix/index.js +329 -0
  26. package/dist/providers/matrix/index.js.map +1 -0
  27. package/dist/providers/nostr/index.d.ts +172 -0
  28. package/dist/providers/nostr/index.d.ts.map +1 -0
  29. package/dist/providers/nostr/index.js +280 -0
  30. package/dist/providers/nostr/index.js.map +1 -0
  31. package/dist/providers/peerjs/index.d.ts +231 -0
  32. package/dist/providers/peerjs/index.d.ts.map +1 -0
  33. package/dist/providers/peerjs/index.js +1038 -0
  34. package/dist/providers/peerjs/index.js.map +1 -0
  35. package/dist/providers/pubnub/index.d.ts +106 -0
  36. package/dist/providers/pubnub/index.d.ts.map +1 -0
  37. package/dist/providers/pubnub/index.js +357 -0
  38. package/dist/providers/pubnub/index.js.map +1 -0
  39. package/dist/providers/simple-peer/index.d.ts +253 -0
  40. package/dist/providers/simple-peer/index.d.ts.map +1 -0
  41. package/dist/providers/simple-peer/index.js +783 -0
  42. package/dist/providers/simple-peer/index.js.map +1 -0
  43. package/dist/providers/supabase/index.d.ts +80 -0
  44. package/dist/providers/supabase/index.d.ts.map +1 -0
  45. package/dist/providers/supabase/index.js +202 -0
  46. package/dist/providers/supabase/index.js.map +1 -0
  47. package/dist/providers/trystero/index.d.ts +181 -0
  48. package/dist/providers/trystero/index.d.ts.map +1 -0
  49. package/dist/providers/trystero/index.js +187 -0
  50. package/dist/providers/trystero/index.js.map +1 -0
  51. package/dist/providers/websocket/index.d.ts +92 -0
  52. package/dist/providers/websocket/index.d.ts.map +1 -0
  53. package/dist/providers/websocket/index.js +272 -0
  54. package/dist/providers/websocket/index.js.map +1 -0
  55. package/dist/sync-monitor.d.ts +90 -0
  56. package/dist/sync-monitor.d.ts.map +1 -0
  57. package/dist/sync-monitor.js +149 -0
  58. package/dist/sync-monitor.js.map +1 -0
  59. package/dist/transport.d.ts +116 -0
  60. package/dist/transport.d.ts.map +1 -0
  61. package/dist/transport.js +2 -0
  62. package/dist/transport.js.map +1 -0
  63. 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