@edryslabs/genericprovider 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +24 -0
- package/README.md +660 -0
- package/dist/index.d.ts +323 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1001 -0
- package/dist/index.js.map +1 -0
- package/dist/lib.d.ts +36 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +37 -0
- package/dist/lib.js.map +1 -0
- package/dist/providers/dummy/index.d.ts +226 -0
- package/dist/providers/dummy/index.d.ts.map +1 -0
- package/dist/providers/dummy/index.js +326 -0
- package/dist/providers/dummy/index.js.map +1 -0
- package/dist/providers/gun/index.d.ts +269 -0
- package/dist/providers/gun/index.d.ts.map +1 -0
- package/dist/providers/gun/index.js +683 -0
- package/dist/providers/gun/index.js.map +1 -0
- package/dist/providers/indexeddb/index.d.ts +161 -0
- package/dist/providers/indexeddb/index.d.ts.map +1 -0
- package/dist/providers/indexeddb/index.js +369 -0
- package/dist/providers/indexeddb/index.js.map +1 -0
- package/dist/providers/matrix/index.d.ts +109 -0
- package/dist/providers/matrix/index.d.ts.map +1 -0
- package/dist/providers/matrix/index.js +329 -0
- package/dist/providers/matrix/index.js.map +1 -0
- package/dist/providers/nostr/index.d.ts +172 -0
- package/dist/providers/nostr/index.d.ts.map +1 -0
- package/dist/providers/nostr/index.js +280 -0
- package/dist/providers/nostr/index.js.map +1 -0
- package/dist/providers/peerjs/index.d.ts +231 -0
- package/dist/providers/peerjs/index.d.ts.map +1 -0
- package/dist/providers/peerjs/index.js +1038 -0
- package/dist/providers/peerjs/index.js.map +1 -0
- package/dist/providers/pubnub/index.d.ts +106 -0
- package/dist/providers/pubnub/index.d.ts.map +1 -0
- package/dist/providers/pubnub/index.js +357 -0
- package/dist/providers/pubnub/index.js.map +1 -0
- package/dist/providers/simple-peer/index.d.ts +253 -0
- package/dist/providers/simple-peer/index.d.ts.map +1 -0
- package/dist/providers/simple-peer/index.js +783 -0
- package/dist/providers/simple-peer/index.js.map +1 -0
- package/dist/providers/supabase/index.d.ts +80 -0
- package/dist/providers/supabase/index.d.ts.map +1 -0
- package/dist/providers/supabase/index.js +202 -0
- package/dist/providers/supabase/index.js.map +1 -0
- package/dist/providers/trystero/index.d.ts +181 -0
- package/dist/providers/trystero/index.d.ts.map +1 -0
- package/dist/providers/trystero/index.js +187 -0
- package/dist/providers/trystero/index.js.map +1 -0
- package/dist/providers/websocket/index.d.ts +92 -0
- package/dist/providers/websocket/index.d.ts.map +1 -0
- package/dist/providers/websocket/index.js +272 -0
- package/dist/providers/websocket/index.js.map +1 -0
- package/dist/sync-monitor.d.ts +90 -0
- package/dist/sync-monitor.d.ts.map +1 -0
- package/dist/sync-monitor.js +149 -0
- package/dist/sync-monitor.js.map +1 -0
- package/dist/transport.d.ts +116 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +2 -0
- package/dist/transport.js.map +1 -0
- package/package.json +137 -0
|
@@ -0,0 +1,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
|