@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
@@ -0,0 +1,683 @@
1
+ /**
2
+ * GunDB Transport Provider
3
+ *
4
+ * Decentralized peer-to-peer transport using GunDB graph database.
5
+ * GunDB provides automatic conflict resolution and offline-first sync.
6
+ *
7
+ * Features:
8
+ * - Decentralized P2P architecture
9
+ * - Automatic conflict resolution (CRDT)
10
+ * - Offline-first with auto-sync
11
+ * - Real-time updates via .on()
12
+ * - Optional relay servers
13
+ * - Graph-based data structure
14
+ * - Password-protected rooms with AES encryption (via SEA)
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * import { GenericProvider } from 'y-generic'
19
+ * import { GunTransport } from 'y-generic/providers/gun'
20
+ * import Gun from 'gun'
21
+ *
22
+ * const doc = new Y.Doc()
23
+ * const transport = new GunTransport({
24
+ * gun: Gun, // Pass the Gun constructor
25
+ * peers: ['https://gun-relay.herokuapp.com/gun']
26
+ * })
27
+ * const provider = new GenericProvider(doc, transport)
28
+ * await provider.connect({ room: 'my-room' })
29
+ * ```
30
+ *
31
+ * @example Password-protected room
32
+ * ```typescript
33
+ * import Gun from 'gun'
34
+ * import 'gun/sea' // Required for encryption
35
+ *
36
+ * const transport = new GunTransport({
37
+ * gun: Gun,
38
+ * sea: Gun.SEA, // Provide SEA module
39
+ * password: 'my-secret-room-password',
40
+ * peers: ['https://gun-relay.herokuapp.com/gun']
41
+ * })
42
+ * ```
43
+ */
44
+ import * as encoding from 'lib0/encoding';
45
+ import * as syncProtocol from 'y-protocols/sync';
46
+ // ---------------------------------------------------------------------------
47
+ // CRC32 helpers — needed to wrap snapshot payloads for GenericProvider
48
+ // ---------------------------------------------------------------------------
49
+ const _CRC32_TABLE = (() => {
50
+ const table = new Uint32Array(256);
51
+ for (let i = 0; i < 256; i++) {
52
+ let c = i;
53
+ for (let j = 0; j < 8; j++)
54
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
55
+ table[i] = c;
56
+ }
57
+ return table;
58
+ })();
59
+ function _crc32(data) {
60
+ let crc = 0xffffffff;
61
+ for (let i = 0; i < data.length; i++)
62
+ crc = (crc >>> 8) ^ _CRC32_TABLE[(crc ^ data[i]) & 0xff];
63
+ return (crc ^ 0xffffffff) >>> 0;
64
+ }
65
+ function addCRC32Header(data) {
66
+ const crc = _crc32(data);
67
+ const wrapped = new Uint8Array(4 + data.length);
68
+ wrapped[0] = (crc >>> 24) & 0xff;
69
+ wrapped[1] = (crc >>> 16) & 0xff;
70
+ wrapped[2] = (crc >>> 8) & 0xff;
71
+ wrapped[3] = crc & 0xff;
72
+ wrapped.set(data, 4);
73
+ return wrapped;
74
+ }
75
+ // Message type identifiers (must match GenericProvider)
76
+ const MESSAGE_SYNC = 0;
77
+ const MESSAGE_AWARENESS = 1;
78
+ /**
79
+ * GunDB transport implementation.
80
+ * Creates decentralized P2P connections using Gun graph database.
81
+ */
82
+ export class GunTransport {
83
+ /**
84
+ * Create a new Gun transport.
85
+ *
86
+ * @param options - Configuration options (must include gun constructor)
87
+ */
88
+ constructor(options) {
89
+ this._connected = false;
90
+ this._room = '';
91
+ this.gun = null;
92
+ this.roomNode = null;
93
+ this.updateListener = null;
94
+ this.lastUpdateTime = 0;
95
+ this.updateBatch = [];
96
+ this.processedUpdates = new Set();
97
+ this.connectionTime = 0;
98
+ this.pendingUpdates = new Map();
99
+ this.updateSlot = 0;
100
+ this.BUFFER_SIZE = 20; // Circular buffer size
101
+ this.awarenessListener = null;
102
+ this.lastAwarenessId = ''; // Track last awareness ID to avoid processing our own
103
+ this.encryptionEnabled = false;
104
+ // Persistence
105
+ this.persistentMode = false;
106
+ this.persistDoc = null;
107
+ this.persistDebounceMs = 2000;
108
+ this.isWritingToGun = false;
109
+ this.savePending = false;
110
+ /** Data loaded from Gun snapshot before onMessage callback is registered */
111
+ this.pendingLoad = null;
112
+ if (!options.gun) {
113
+ throw new Error('GunTransport requires the "gun" option. ' +
114
+ 'Please provide the Gun constructor: ' +
115
+ 'import Gun from "gun"; new GunTransport({ gun: Gun, ... })');
116
+ }
117
+ // Validate SEA is provided when using password
118
+ if (options.password && !options.sea) {
119
+ throw new Error('GunTransport requires the "sea" option when using password encryption. ' +
120
+ 'Please provide Gun.SEA: import "gun/sea"; new GunTransport({ gun: Gun, sea: Gun.SEA, password: "..." })');
121
+ }
122
+ this.options = {
123
+ gun: options.gun,
124
+ peers: options.peers ?? [],
125
+ gunOptions: options.gunOptions ?? {},
126
+ debug: options.debug ?? false,
127
+ batchInterval: options.batchInterval ?? 100, // Debounce: wait 100ms after last update
128
+ password: options.password,
129
+ sea: options.sea,
130
+ };
131
+ this.encryptionEnabled = !!(options.password && options.sea);
132
+ if (this.encryptionEnabled) {
133
+ this.log('🔐 Encryption enabled');
134
+ }
135
+ }
136
+ /**
137
+ * Connect to the room and start syncing.
138
+ */
139
+ async connect(config) {
140
+ if (this._connected) {
141
+ throw new Error('Already connected');
142
+ }
143
+ this._room = config.room;
144
+ this.connectionTime = Date.now();
145
+ this.persistentMode = config.persistent ?? false;
146
+ this.persistDoc = config.doc ?? null;
147
+ this.persistDebounceMs = config.persistDebounceMs ?? 2000;
148
+ if (this.persistentMode && !this.persistDoc) {
149
+ throw new Error('GunTransport: a Y.Doc must be provided via config.doc when persistent is true');
150
+ }
151
+ this.log('🔗 Initializing Gun...');
152
+ // Initialize Gun
153
+ const gunConfig = {
154
+ localStorage: false, // Disable localStorage to prevent quota errors
155
+ radisk: false, // Disable radisk
156
+ ...this.options.gunOptions,
157
+ };
158
+ // Add peers if specified
159
+ if (this.options.peers.length > 0) {
160
+ gunConfig.peers = this.options.peers;
161
+ this.log('📡 Connecting to peers:', this.options.peers);
162
+ }
163
+ this.gun = new this.options.gun(gunConfig);
164
+ // Navigate to room node
165
+ this.roomNode = this.gun.get(`yjs-room-${this._room}`);
166
+ this.log('✅ Gun initialized for room:', this._room);
167
+ // Note: We use a circular buffer (10 slots) to prevent infinite accumulation
168
+ // of update nodes in Gun's graph. This prevents the "1K+ records" warning.
169
+ // Each update overwrites one of the slots (slot-0 through slot-9).
170
+ // Subscribe to updates from Gun (both doc sync and awareness)
171
+ this.setupUpdateListener();
172
+ this.setupAwarenessListener();
173
+ this._connected = true;
174
+ // Persistence: load existing snapshot or clear it for a fresh session
175
+ if (this.persistentMode) {
176
+ this.loadSnapshot();
177
+ }
178
+ else {
179
+ // Overwrite any previously saved snapshot so reconnecting peers start fresh
180
+ this.roomNode
181
+ .get('snapshot')
182
+ .put({ cleared: true, timestamp: Date.now() });
183
+ }
184
+ }
185
+ /**
186
+ * Setup listener for Gun updates.
187
+ */
188
+ setupUpdateListener() {
189
+ let lastProcessTime = 0;
190
+ const THROTTLE_MS = 300; // Process updates at most every 300ms
191
+ let hasLoadedInitial = false;
192
+ // Best Practice: Use .once() for initial load, then .on() only for new inserts
193
+ // This prevents Gun from continuously syncing 1K+ historical records
194
+ // Step 1: Load initial state once
195
+ this.roomNode.get('updates').once((allUpdates) => {
196
+ if (!allUpdates) {
197
+ hasLoadedInitial = true;
198
+ this.log('📭 No existing updates found');
199
+ return;
200
+ }
201
+ this.log('📥 Loading initial state...');
202
+ // Process all existing updates once
203
+ Object.keys(allUpdates).forEach((key) => {
204
+ if (key === '_')
205
+ return; // Skip Gun metadata
206
+ const update = allUpdates[key];
207
+ if (!update || !update.data)
208
+ return;
209
+ const sequence = update.sequence || Math.floor(update.timestamp / 100);
210
+ const updateKey = `${key}-${sequence}`;
211
+ if (!this.processedUpdates.has(updateKey)) {
212
+ this.pendingUpdates.set(updateKey, update);
213
+ }
214
+ });
215
+ // Process initial batch
216
+ this.processPendingUpdates();
217
+ hasLoadedInitial = true;
218
+ this.log('✅ Initial state loaded');
219
+ });
220
+ // Step 2: Listen only for NEW inserts (not historical data)
221
+ this.updateListener = this.roomNode
222
+ .get('updates')
223
+ .map()
224
+ .on((update, updateId) => {
225
+ // Skip until initial load is complete
226
+ if (!hasLoadedInitial)
227
+ return;
228
+ if (!update || !update.data)
229
+ return;
230
+ // Only process updates newer than our connection time
231
+ if (update.timestamp && update.timestamp < this.connectionTime) {
232
+ return;
233
+ }
234
+ // Use sequence number for deduplication
235
+ const sequence = update.sequence || Math.floor(update.timestamp / 100);
236
+ const updateKey = `${updateId}-${sequence}`;
237
+ if (this.processedUpdates.has(updateKey)) {
238
+ return;
239
+ }
240
+ // Store update for throttled processing
241
+ this.pendingUpdates.set(updateKey, update);
242
+ // Throttle processing (batching)
243
+ const now = Date.now();
244
+ if (now - lastProcessTime < THROTTLE_MS) {
245
+ if (!this.throttleTimeout) {
246
+ this.throttleTimeout = setTimeout(() => {
247
+ this.processPendingUpdates();
248
+ lastProcessTime = Date.now();
249
+ this.throttleTimeout = undefined;
250
+ }, THROTTLE_MS);
251
+ }
252
+ return;
253
+ }
254
+ // Process immediately if enough time has passed
255
+ lastProcessTime = now;
256
+ this.processPendingUpdates();
257
+ });
258
+ this.log('👂 Listening for new updates...');
259
+ }
260
+ /**
261
+ * Process all pending updates at once.
262
+ */
263
+ async processPendingUpdates() {
264
+ if (this.pendingUpdates.size === 0)
265
+ return;
266
+ const updates = Array.from(this.pendingUpdates.entries());
267
+ this.pendingUpdates.clear();
268
+ for (const [updateKey, update] of updates) {
269
+ // Mark as processed
270
+ this.processedUpdates.add(updateKey);
271
+ // Clean old entries from processed set (keep last 200)
272
+ if (this.processedUpdates.size > 200) {
273
+ const entries = Array.from(this.processedUpdates);
274
+ entries.slice(0, entries.length - 200).forEach((key) => {
275
+ this.processedUpdates.delete(key);
276
+ });
277
+ }
278
+ try {
279
+ let payload = update.data;
280
+ // Decrypt if encrypted
281
+ if (update.encrypted && this.encryptionEnabled) {
282
+ payload = await this.decrypt(payload);
283
+ if (!payload) {
284
+ this.log('❌ Failed to decrypt update (wrong password?)');
285
+ continue;
286
+ }
287
+ }
288
+ // Decode base64 back to Uint8Array
289
+ const decoded = this.base64ToUint8Array(payload);
290
+ // Pass to Yjs
291
+ if (this._callback) {
292
+ this._callback(decoded);
293
+ }
294
+ if (updates.length === 1) {
295
+ this.log('📥 Received update:', decoded.length, 'bytes', update.encrypted ? '(decrypted)' : '');
296
+ }
297
+ }
298
+ catch (error) {
299
+ this.log('❌ Error processing update:', error);
300
+ }
301
+ }
302
+ if (updates.length > 1) {
303
+ this.log(`📥 Processed ${updates.length} batched updates`);
304
+ }
305
+ }
306
+ /**
307
+ * Disconnect from Gun and cleanup.
308
+ */
309
+ disconnect() {
310
+ if (!this._connected)
311
+ return;
312
+ this.log('👋 Disconnecting...');
313
+ // Clear batch timeout
314
+ if (this.batchTimeout) {
315
+ clearTimeout(this.batchTimeout);
316
+ this.batchTimeout = undefined;
317
+ }
318
+ // Clear persist debounce and flush snapshot synchronously (Gun is async internally)
319
+ if (this.persistTimer) {
320
+ clearTimeout(this.persistTimer);
321
+ this.persistTimer = undefined;
322
+ }
323
+ if (this.persistentMode && this.persistDoc) {
324
+ this.saveSnapshot();
325
+ }
326
+ // Clear throttle timeout
327
+ if (this.throttleTimeout) {
328
+ clearTimeout(this.throttleTimeout);
329
+ this.throttleTimeout = undefined;
330
+ }
331
+ // Process any pending updates before disconnect
332
+ this.processPendingUpdates();
333
+ // Flush any pending updates
334
+ this.flushBatch();
335
+ // Remove listeners
336
+ if (this.updateListener) {
337
+ // Gun doesn't have a clear off() method for map listeners
338
+ // The listener will be garbage collected
339
+ this.updateListener = null;
340
+ }
341
+ if (this.awarenessListener) {
342
+ this.awarenessListener = null;
343
+ }
344
+ this.roomNode = null;
345
+ this.gun = null;
346
+ this._connected = false;
347
+ this.processedUpdates.clear();
348
+ this.pendingUpdates.clear();
349
+ this.persistentMode = false;
350
+ this.persistDoc = null;
351
+ this.pendingLoad = null;
352
+ this.log('✅ Disconnected');
353
+ }
354
+ /**
355
+ * Send data to all peers via Gun.
356
+ * Routes awareness to a separate volatile node, doc sync to circular buffer.
357
+ * Uses debouncing for doc sync - each new update resets the timer.
358
+ */
359
+ send(data) {
360
+ if (!this._connected || !this.roomNode) {
361
+ this.log('⚠️ Not connected, cannot send');
362
+ return;
363
+ }
364
+ // Peek message type (after CRC32 header: 4 bytes CRC + 1 byte type)
365
+ const messageType = this.peekMessageType(data);
366
+ // Route awareness to separate volatile node (immediate, no buffer)
367
+ if (messageType === MESSAGE_AWARENESS) {
368
+ this.sendAwareness(data);
369
+ return;
370
+ }
371
+ // Doc sync goes through batched circular buffer
372
+ this.updateBatch.push(data);
373
+ // Clear existing timeout (debouncing - resets timer on each update)
374
+ if (this.batchTimeout) {
375
+ clearTimeout(this.batchTimeout);
376
+ }
377
+ // Set new timeout to flush batch after period of inactivity
378
+ this.batchTimeout = setTimeout(() => {
379
+ this.flushBatch();
380
+ }, this.options.batchInterval);
381
+ // Schedule a snapshot save for persistent mode
382
+ if (this.persistentMode) {
383
+ this.queuePersist();
384
+ }
385
+ }
386
+ /**
387
+ * Peek at the message type from CRC32-wrapped data.
388
+ * Format: [CRC32 (4 bytes)][message type (varint)]...
389
+ * Returns -1 if cannot determine type.
390
+ */
391
+ peekMessageType(data) {
392
+ // Need at least 5 bytes: 4 for CRC32 + 1 for message type
393
+ if (data.length < 5)
394
+ return -1;
395
+ // Message type is stored as varint after CRC32, but for small values (0-3)
396
+ // it's just a single byte
397
+ return data[4];
398
+ }
399
+ /**
400
+ * Send awareness update to a separate volatile node.
401
+ * Awareness is ephemeral - only the latest state matters.
402
+ * Each client writes to its own awareness slot to avoid overwrites.
403
+ */
404
+ async sendAwareness(data) {
405
+ let payload = this.uint8ArrayToBase64(data);
406
+ // Encrypt if password is set
407
+ if (this.encryptionEnabled) {
408
+ payload = await this.encrypt(payload);
409
+ }
410
+ const awarenessId = `aware-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`;
411
+ // Track this ID so we don't process our own update
412
+ this.lastAwarenessId = awarenessId;
413
+ // Write to a single volatile awareness node
414
+ // Each update overwrites the previous - awareness only needs latest state
415
+ this.roomNode.get('awareness').put({
416
+ data: payload,
417
+ id: awarenessId,
418
+ timestamp: Date.now(),
419
+ encrypted: this.encryptionEnabled,
420
+ });
421
+ this.log('📤 Sent awareness update', this.encryptionEnabled ? '(encrypted)' : '');
422
+ }
423
+ /**
424
+ * Setup listener for awareness updates (separate from doc sync).
425
+ */
426
+ setupAwarenessListener() {
427
+ this.awarenessListener = this.roomNode
428
+ .get('awareness')
429
+ .on(async (awareness) => {
430
+ if (!awareness || !awareness.data)
431
+ return;
432
+ // Skip our own awareness updates
433
+ if (awareness.id === this.lastAwarenessId)
434
+ return;
435
+ // Only process updates newer than our connection
436
+ if (awareness.timestamp && awareness.timestamp < this.connectionTime) {
437
+ return;
438
+ }
439
+ try {
440
+ let payload = awareness.data;
441
+ // Decrypt if encrypted
442
+ if (awareness.encrypted && this.encryptionEnabled) {
443
+ payload = await this.decrypt(payload);
444
+ if (!payload) {
445
+ this.log('❌ Failed to decrypt awareness (wrong password?)');
446
+ return;
447
+ }
448
+ }
449
+ const decoded = this.base64ToUint8Array(payload);
450
+ if (this._callback) {
451
+ this._callback(decoded);
452
+ }
453
+ this.log('📥 Received awareness update', awareness.encrypted ? '(decrypted)' : '');
454
+ }
455
+ catch (error) {
456
+ this.log('❌ Error processing awareness:', error);
457
+ }
458
+ });
459
+ this.log('👂 Listening for awareness updates...');
460
+ }
461
+ /**
462
+ * Flush batched updates to Gun.
463
+ * Called after debounce period (no new updates for batchInterval ms).
464
+ */
465
+ async flushBatch() {
466
+ if (this.updateBatch.length === 0)
467
+ return;
468
+ // Merge all batched updates into one
469
+ const totalLength = this.updateBatch.reduce((sum, arr) => sum + arr.length, 0);
470
+ const merged = new Uint8Array(totalLength);
471
+ let offset = 0;
472
+ for (const update of this.updateBatch) {
473
+ merged.set(update, offset);
474
+ offset += update.length;
475
+ }
476
+ // Clear batch
477
+ this.updateBatch = [];
478
+ // Convert to base64 for Gun storage
479
+ let payload = this.uint8ArrayToBase64(merged);
480
+ // Encrypt if password is set
481
+ if (this.encryptionEnabled) {
482
+ payload = await this.encrypt(payload);
483
+ }
484
+ // Create update object with circular buffer slot
485
+ const updateId = this.generateUpdateId();
486
+ const timestamp = Date.now();
487
+ const sequence = Math.floor(timestamp / 100); // Sequence number per 100ms
488
+ // Mark as processed so we don't receive our own update
489
+ this.processedUpdates.add(`${updateId}-${sequence}`);
490
+ // Store in Gun using circular buffer slot
491
+ const updates = this.roomNode.get('updates');
492
+ updates.get(updateId).put({
493
+ data: payload,
494
+ timestamp: timestamp,
495
+ sequence: sequence,
496
+ size: merged.length,
497
+ encrypted: this.encryptionEnabled,
498
+ });
499
+ this.log('📤 Sent update:', merged.length, 'bytes', this.encryptionEnabled ? '(encrypted)' : '');
500
+ }
501
+ /**
502
+ * Register callback for incoming messages.
503
+ */
504
+ onMessage(callback) {
505
+ this._callback = callback;
506
+ // Flush any snapshot data that arrived before this callback was registered
507
+ if (this.pendingLoad) {
508
+ const data = this.pendingLoad;
509
+ this.pendingLoad = null;
510
+ // Defer by one microtask so GenericProvider finishes its own setup first
511
+ Promise.resolve().then(() => callback(data));
512
+ }
513
+ return () => {
514
+ this._callback = undefined;
515
+ };
516
+ }
517
+ /**
518
+ * Check if connected.
519
+ */
520
+ get isConnected() {
521
+ return this._connected;
522
+ }
523
+ /**
524
+ * Generate a circular buffer slot ID.
525
+ * Uses only BUFFER_SIZE slots to prevent infinite accumulation.
526
+ */
527
+ generateUpdateId() {
528
+ const slotId = `slot-${this.updateSlot}`;
529
+ this.updateSlot = (this.updateSlot + 1) % this.BUFFER_SIZE;
530
+ return slotId;
531
+ }
532
+ // ---------------------------------------------------------------------------
533
+ // Persistence helpers
534
+ // ---------------------------------------------------------------------------
535
+ /**
536
+ * Schedule a debounced snapshot write. Called on every doc update.
537
+ * Always saves the latest full state, never an individual delta.
538
+ */
539
+ queuePersist() {
540
+ if (this.persistTimer)
541
+ clearTimeout(this.persistTimer);
542
+ this.persistTimer = setTimeout(() => this.saveSnapshot(), this.persistDebounceMs);
543
+ }
544
+ /**
545
+ * Encode the full Y.Doc state as a proper y-protocols SYNC_STEP_2 message
546
+ * and write it to the Gun `snapshot` node.
547
+ * Using SYNC_STEP_2 format ensures GenericProvider interprets it correctly.
548
+ */
549
+ async saveSnapshot() {
550
+ if (!this.persistDoc || !this.persistentMode || !this.roomNode)
551
+ return;
552
+ if (this.isWritingToGun) {
553
+ this.savePending = true;
554
+ return;
555
+ }
556
+ this.isWritingToGun = true;
557
+ this.savePending = false;
558
+ try {
559
+ // Encode as a proper y-generic SYNC_STEP_2 message
560
+ const enc = encoding.createEncoder();
561
+ encoding.writeVarUint(enc, 0); // MESSAGE_SYNC
562
+ syncProtocol.writeSyncStep2(enc, this.persistDoc);
563
+ const snapshotBytes = encoding.toUint8Array(enc);
564
+ let payload = this.uint8ArrayToBase64(snapshotBytes);
565
+ if (this.encryptionEnabled) {
566
+ payload = await this.encrypt(payload);
567
+ }
568
+ this.roomNode.get('snapshot').put({
569
+ data: payload,
570
+ timestamp: Date.now(),
571
+ encrypted: this.encryptionEnabled,
572
+ });
573
+ this.log('💾 Snapshot saved', snapshotBytes.length, 'bytes');
574
+ }
575
+ catch (error) {
576
+ this.log('❌ Error saving snapshot:', error.message);
577
+ console.warn('GunTransport: Failed to save snapshot. Will retry later.', error);
578
+ this.savePending = true;
579
+ }
580
+ finally {
581
+ this.isWritingToGun = false;
582
+ if (this.savePending) {
583
+ setTimeout(() => this.saveSnapshot(), 1000);
584
+ }
585
+ }
586
+ }
587
+ /**
588
+ * Load the snapshot from Gun and deliver it to the message callback.
589
+ * Uses a pendingLoad buffer in case the callback isn't registered yet.
590
+ */
591
+ loadSnapshot() {
592
+ this.roomNode.get('snapshot').once(async (snap) => {
593
+ if (!snap || !snap.data || snap.cleared) {
594
+ this.log('📭 No snapshot found in Gun');
595
+ return;
596
+ }
597
+ try {
598
+ let payload = snap.data;
599
+ if (snap.encrypted && this.encryptionEnabled) {
600
+ const decrypted = await this.decrypt(payload);
601
+ if (!decrypted) {
602
+ this.log('❌ Failed to decrypt snapshot (wrong password?)');
603
+ return;
604
+ }
605
+ payload = decrypted;
606
+ }
607
+ const snapshotBytes = this.base64ToUint8Array(payload);
608
+ if (snapshotBytes.length > 0) {
609
+ // Wrap with CRC32 so GenericProvider accepts it
610
+ const wrapped = addCRC32Header(snapshotBytes);
611
+ if (this._callback) {
612
+ this._callback(wrapped);
613
+ }
614
+ else {
615
+ // Callback not yet registered — buffer until onMessage() is called
616
+ this.pendingLoad = wrapped;
617
+ }
618
+ this.log('💾 Loaded snapshot:', snapshotBytes.length, 'bytes');
619
+ }
620
+ }
621
+ catch (error) {
622
+ this.log('❌ Error loading snapshot:', error);
623
+ console.warn('GunTransport: Failed to load snapshot:', error);
624
+ }
625
+ });
626
+ }
627
+ /**
628
+ * Convert Uint8Array to base64 string.
629
+ */
630
+ uint8ArrayToBase64(bytes) {
631
+ let binary = '';
632
+ for (let i = 0; i < bytes.length; i++) {
633
+ binary += String.fromCharCode(bytes[i]);
634
+ }
635
+ return btoa(binary);
636
+ }
637
+ /**
638
+ * Convert base64 string to Uint8Array.
639
+ */
640
+ base64ToUint8Array(base64) {
641
+ const binary = atob(base64);
642
+ const bytes = new Uint8Array(binary.length);
643
+ for (let i = 0; i < binary.length; i++) {
644
+ bytes[i] = binary.charCodeAt(i);
645
+ }
646
+ return bytes;
647
+ }
648
+ /**
649
+ * Encrypt data using SEA with the configured password.
650
+ */
651
+ async encrypt(data) {
652
+ if (!this.options.sea || !this.options.password) {
653
+ return data;
654
+ }
655
+ return await this.options.sea.encrypt(data, this.options.password);
656
+ }
657
+ /**
658
+ * Decrypt data using SEA with the configured password.
659
+ * Returns null if decryption fails (wrong password).
660
+ */
661
+ async decrypt(data) {
662
+ if (!this.options.sea || !this.options.password) {
663
+ return data;
664
+ }
665
+ try {
666
+ const decrypted = await this.options.sea.decrypt(data, this.options.password);
667
+ return decrypted || null;
668
+ }
669
+ catch (error) {
670
+ this.log('❌ Decryption failed:', error);
671
+ return null;
672
+ }
673
+ }
674
+ /**
675
+ * Log debug messages if enabled.
676
+ */
677
+ log(...args) {
678
+ if (this.options.debug) {
679
+ console.log('[GunTransport]', ...args);
680
+ }
681
+ }
682
+ }
683
+ //# sourceMappingURL=index.js.map