@edryslabs/genericprovider 1.0.2 → 1.0.4
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/dist/index.d.ts +150 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +494 -148
- package/dist/index.js.map +1 -1
- package/dist/providers/gun/index.d.ts +18 -0
- package/dist/providers/gun/index.d.ts.map +1 -1
- package/dist/providers/gun/index.js +61 -12
- package/dist/providers/gun/index.js.map +1 -1
- package/dist/providers/matrix/index.d.ts +6 -0
- package/dist/providers/matrix/index.d.ts.map +1 -1
- package/dist/providers/matrix/index.js +6 -0
- package/dist/providers/matrix/index.js.map +1 -1
- package/dist/providers/nostr/index.d.ts +7 -0
- package/dist/providers/nostr/index.d.ts.map +1 -1
- package/dist/providers/nostr/index.js +7 -0
- package/dist/providers/nostr/index.js.map +1 -1
- package/dist/providers/simple-peer/index.d.ts +29 -0
- package/dist/providers/simple-peer/index.d.ts.map +1 -1
- package/dist/providers/simple-peer/index.js +93 -12
- package/dist/providers/simple-peer/index.js.map +1 -1
- package/dist/transport.d.ts +11 -0
- package/dist/transport.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -11,6 +11,9 @@ const MESSAGE_AWARENESS = 1;
|
|
|
11
11
|
const MESSAGE_PUBSUB = 2;
|
|
12
12
|
const MESSAGE_SYNC_VERIFIED = 3; // Sync message with hash verification
|
|
13
13
|
const MESSAGE_PUBSUB_TARGETED = 4; // Pub/sub message aimed at a single target
|
|
14
|
+
// Sub-channel inside a MESSAGE_AWARENESS frame: [opcode 1][channel][update].
|
|
15
|
+
const AWARENESS_CHANNEL_MAIN = 0;
|
|
16
|
+
const AWARENESS_CHANNEL_APP = 1;
|
|
14
17
|
/**
|
|
15
18
|
* CRC32 lookup table for fast computation.
|
|
16
19
|
* Generated once and reused for all CRC calculations.
|
|
@@ -78,16 +81,24 @@ function unwrapAndVerifyMessage(wrapped) {
|
|
|
78
81
|
return message;
|
|
79
82
|
}
|
|
80
83
|
/**
|
|
81
|
-
* Compute a
|
|
82
|
-
* Uses a fast non-cryptographic hash for performance.
|
|
84
|
+
* Compute a cheap hash of document state for desync detection.
|
|
83
85
|
*
|
|
84
|
-
* Hashes the state VECTOR, not encodeStateAsUpdate
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
86
|
+
* Hashes the state VECTOR, not encodeStateAsUpdate, for two independent reasons:
|
|
87
|
+
*
|
|
88
|
+
* 1. CORRECTNESS: the full update byte stream is NOT canonical across
|
|
89
|
+
* CRDT-convergent replicas (client-block and tombstone ordering differ per
|
|
90
|
+
* peer), so hashing it flags false divergence and triggers an endless
|
|
91
|
+
* re-sync loop. The state vector (clientID -> clock) is serialized in sorted
|
|
92
|
+
* clientID order by Yjs, so two convergent docs hash identically, while a
|
|
93
|
+
* missed update still shows up as a differing clock — exactly the "did we
|
|
94
|
+
* fall behind?" signal this check exists to provide.
|
|
95
|
+
* 2. COST: O(number of distinct clients) instead of O(document content size),
|
|
96
|
+
* so this no longer re-serializes the entire document on every update.
|
|
97
|
+
*
|
|
98
|
+
* Two peers can only reach the same state vector by having applied the same set
|
|
99
|
+
* of operations, so real content divergence is still caught. (CRC32 already
|
|
100
|
+
* guards wire corruption, and sequence tracking guards reordering/loss — this
|
|
101
|
+
* hash is the last line of defense against logical divergence between peers.)
|
|
91
102
|
*/
|
|
92
103
|
function computeDocHash(doc) {
|
|
93
104
|
const state = Y.encodeStateVector(doc);
|
|
@@ -211,19 +222,41 @@ export class GenericProvider extends Observable {
|
|
|
211
222
|
// BroadcastChannel state for cross-tab sync
|
|
212
223
|
this._bcChannel = '';
|
|
213
224
|
this._bcConnected = false;
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
|
|
225
|
+
// Unified resync-request coordinator. Previously hash-mismatch,
|
|
226
|
+
// corrupted-message, and gap-confirmed triggers each coalesced only
|
|
227
|
+
// against themselves (three separate pending-timer fields, three
|
|
228
|
+
// separate escalation counters), so under sustained wire corruption they
|
|
229
|
+
// could each independently burn through the shared _tryReserveSyncSlot()
|
|
230
|
+
// budget in the same window - a resync storm that grew combinatorially
|
|
231
|
+
// with peer count (see test/dummy/bench-corruption-storm.ts: at 10
|
|
232
|
+
// simulated peers, 5% per-link corruption drove message volume to ~11x
|
|
233
|
+
// the corruption-free baseline). Now there is exactly ONE pending timer
|
|
234
|
+
// and ONE shared escalation counter for all three triggers - only one
|
|
235
|
+
// resync is ever in flight at a time, and any trigger that fires while
|
|
236
|
+
// one is already pending is absorbed into it instead of scheduling its
|
|
237
|
+
// own. See _requestResync().
|
|
238
|
+
this._resyncAttemptCount = 0;
|
|
239
|
+
this._lastResyncAttemptTime = 0;
|
|
217
240
|
// Rate limiting for sync requests
|
|
218
241
|
this._syncRequestTimes = [];
|
|
219
|
-
|
|
220
|
-
|
|
242
|
+
// SyncStep2 reply suppression (NACK-suppression style): delay a reply to
|
|
243
|
+
// a SyncStep1 request briefly, and drop it if another peer's reply is
|
|
244
|
+
// overheard first - since every reply is broadcast to the whole room
|
|
245
|
+
// anyway, this avoids every peer answering the same request redundantly.
|
|
246
|
+
// Only engages when there's genuine redundancy (see _handleIncomingMessage's
|
|
247
|
+
// MESSAGE_SYNC and MESSAGE_SYNC_VERIFIED cases) - with 0-1 other known
|
|
248
|
+
// peers there's no "someone else" to rely on, so replies go out
|
|
249
|
+
// immediately as before.
|
|
250
|
+
this._pendingSyncReply = null;
|
|
221
251
|
// Sequence numbers for causal ordering
|
|
222
252
|
this._localSeqNum = 0; // Our sequence number counter
|
|
223
|
-
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
253
|
+
// Per-sender sequence tracking for reordering-tolerant gap detection.
|
|
254
|
+
// Applying a Yjs update is always safe even for duplicates or out-of-order
|
|
255
|
+
// arrivals (Yjs updates are idempotent/commutative) — this state exists
|
|
256
|
+
// only to detect genuine gaps (likely packet loss) without false
|
|
257
|
+
// positives from mere network reordering. See _trackRemoteSeq().
|
|
258
|
+
this._remoteSeqInfo = new Map();
|
|
259
|
+
this._gapCheckTimers = new Map();
|
|
227
260
|
// Update batching/debouncing
|
|
228
261
|
this._batchUpdates = 0; // milliseconds delay (0 = disabled)
|
|
229
262
|
this._pendingUpdate = null;
|
|
@@ -231,6 +264,9 @@ export class GenericProvider extends Observable {
|
|
|
231
264
|
this._awarenessInterval = 100; // ms between awareness broadcasts
|
|
232
265
|
this._pendingAwarenessClients = new Set();
|
|
233
266
|
this._lastAwarenessTime = 0;
|
|
267
|
+
// Independent throttle state for the app awareness channel.
|
|
268
|
+
this._pendingAppAwarenessClients = new Set();
|
|
269
|
+
this._lastAppAwarenessTime = 0;
|
|
234
270
|
// Origins whose updates are never sent to the transport (local-only txns).
|
|
235
271
|
this._excludeOrigins = new Set();
|
|
236
272
|
// Connect-time sync strategy: 'push-pull' (default) sends full local state
|
|
@@ -240,14 +276,22 @@ export class GenericProvider extends Observable {
|
|
|
240
276
|
this.transport = transport;
|
|
241
277
|
this.pubsub = new PubSubChannel(this);
|
|
242
278
|
this.awareness = options.awareness || new awarenessProtocol.Awareness(doc);
|
|
279
|
+
this.appAwareness =
|
|
280
|
+
options.appAwareness || new awarenessProtocol.Awareness(doc);
|
|
243
281
|
this._syncInterval = options.syncInterval ?? 5000;
|
|
244
282
|
this._verifyUpdates = options.verifyUpdates ?? true;
|
|
245
|
-
this._batchUpdates =
|
|
283
|
+
this._batchUpdates =
|
|
284
|
+
options.batchUpdates ?? transport.preferredBatchMs ?? 0;
|
|
246
285
|
this._disableBc = options.disableBc ?? false;
|
|
247
286
|
this._awarenessInterval = options.awarenessInterval ?? 100;
|
|
248
287
|
this._excludeOrigins = new Set(options.excludeOrigins ?? []);
|
|
249
288
|
this._localId = options.localId;
|
|
250
289
|
this._syncMode = options.syncMode ?? 'push-pull';
|
|
290
|
+
this._maxSyncRequestsPerWindow = options.maxSyncRequestsPerWindow ?? 20;
|
|
291
|
+
this._syncRequestWindowMs = options.syncRequestWindowMs ?? 10000;
|
|
292
|
+
this._syncReplySuppressionMs = options.syncReplySuppressionMs ?? 30;
|
|
293
|
+
this._gapGraceMs = options.gapGraceMs ?? 300;
|
|
294
|
+
this._seqWindowSize = options.seqWindowSize ?? 64;
|
|
251
295
|
this._setupDocumentSync();
|
|
252
296
|
this._setupAwarenessSync();
|
|
253
297
|
}
|
|
@@ -306,18 +350,15 @@ export class GenericProvider extends Observable {
|
|
|
306
350
|
}
|
|
307
351
|
// Broadcast local awareness state
|
|
308
352
|
this._broadcastAwareness([this.doc.clientID]);
|
|
353
|
+
this._broadcastAwareness([this.doc.clientID], AWARENESS_CHANNEL_APP);
|
|
309
354
|
// Start periodic sync to handle packet loss
|
|
310
355
|
// Just request sync without sending full state (avoid redundant broadcasts)
|
|
356
|
+
// _sendSyncStep1() already checks the shared rate limiter internally
|
|
357
|
+
// and silently drops the request if it's exceeded.
|
|
311
358
|
if (this._syncInterval > 0) {
|
|
312
359
|
this._syncIntervalId = setInterval(() => {
|
|
313
360
|
if (this.transport.isConnected && !this._destroying) {
|
|
314
|
-
|
|
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
|
|
361
|
+
this._sendSyncStep1();
|
|
321
362
|
}
|
|
322
363
|
}, this._syncInterval);
|
|
323
364
|
}
|
|
@@ -340,9 +381,26 @@ export class GenericProvider extends Observable {
|
|
|
340
381
|
clearInterval(this._syncIntervalId);
|
|
341
382
|
this._syncIntervalId = undefined;
|
|
342
383
|
}
|
|
343
|
-
// Reset
|
|
344
|
-
this.
|
|
345
|
-
this.
|
|
384
|
+
// Reset resync escalation tracking
|
|
385
|
+
this._resyncAttemptCount = 0;
|
|
386
|
+
this._lastResyncAttemptTime = 0;
|
|
387
|
+
// Cancel any pending unified resync - it would otherwise still fire
|
|
388
|
+
// syncNow() after disconnect/reconnect against a transport that may be
|
|
389
|
+
// in a completely different state by then.
|
|
390
|
+
if (this._pendingResyncTimeoutId !== undefined) {
|
|
391
|
+
clearTimeout(this._pendingResyncTimeoutId);
|
|
392
|
+
this._pendingResyncTimeoutId = undefined;
|
|
393
|
+
}
|
|
394
|
+
// Reset the sync rate-limit budget. Without this, a reconnect inherits
|
|
395
|
+
// whatever budget was left over from before the disconnect - and since
|
|
396
|
+
// syncNow()'s full-state push now shares this same limiter (see
|
|
397
|
+
// _tryReserveSyncSlot()), a rate-limited reconnect could silently skip
|
|
398
|
+
// the very push that delivers edits made while offline.
|
|
399
|
+
this._syncRequestTimes = [];
|
|
400
|
+
// Drop any pending suppressed sync reply - safe to simply discard (not
|
|
401
|
+
// flush/send like batched updates/awareness below), since a suppressed
|
|
402
|
+
// reply is by design redundant with whatever the room already has.
|
|
403
|
+
this._cancelPendingSyncReply();
|
|
346
404
|
// Flush any pending batched updates before disconnecting
|
|
347
405
|
if (this._batchTimeoutId !== undefined) {
|
|
348
406
|
clearTimeout(this._batchTimeoutId);
|
|
@@ -365,6 +423,16 @@ export class GenericProvider extends Observable {
|
|
|
365
423
|
}
|
|
366
424
|
}
|
|
367
425
|
this._pendingAwarenessClients.clear();
|
|
426
|
+
// Flush pending app awareness updates before disconnecting
|
|
427
|
+
if (this._appAwarenessTimeoutId !== undefined) {
|
|
428
|
+
clearTimeout(this._appAwarenessTimeoutId);
|
|
429
|
+
this._appAwarenessTimeoutId = undefined;
|
|
430
|
+
if (this._pendingAppAwarenessClients.size > 0 &&
|
|
431
|
+
this.transport.isConnected) {
|
|
432
|
+
this._sendAwarenessNow(Array.from(this._pendingAppAwarenessClients), AWARENESS_CHANNEL_APP);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
this._pendingAppAwarenessClients.clear();
|
|
368
436
|
// Disconnect BroadcastChannel
|
|
369
437
|
this._disconnectBroadcastChannel();
|
|
370
438
|
if (this._unsubscribeTransport) {
|
|
@@ -373,6 +441,7 @@ export class GenericProvider extends Observable {
|
|
|
373
441
|
}
|
|
374
442
|
// Mark local client as offline in awareness
|
|
375
443
|
awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], 'disconnect');
|
|
444
|
+
awarenessProtocol.removeAwarenessStates(this.appAwareness, [this.doc.clientID], 'disconnect');
|
|
376
445
|
this.transport.disconnect();
|
|
377
446
|
this._synced = false;
|
|
378
447
|
this._setStatus({ state: 'disconnected' });
|
|
@@ -388,6 +457,14 @@ export class GenericProvider extends Observable {
|
|
|
388
457
|
clearInterval(this._syncIntervalId);
|
|
389
458
|
this._syncIntervalId = undefined;
|
|
390
459
|
}
|
|
460
|
+
// Stop any pending gap-check timers
|
|
461
|
+
for (const timer of this._gapCheckTimers.values()) {
|
|
462
|
+
clearTimeout(timer);
|
|
463
|
+
}
|
|
464
|
+
this._gapCheckTimers.clear();
|
|
465
|
+
// Drop any pending suppressed sync reply (disconnect() will also do
|
|
466
|
+
// this, but be explicit)
|
|
467
|
+
this._cancelPendingSyncReply();
|
|
391
468
|
// Flush any pending batched updates before destroying
|
|
392
469
|
if (this._batchTimeoutId !== undefined) {
|
|
393
470
|
clearTimeout(this._batchTimeoutId);
|
|
@@ -404,17 +481,22 @@ export class GenericProvider extends Observable {
|
|
|
404
481
|
this.doc.off('update', this._updateHandler);
|
|
405
482
|
this._updateHandler = undefined;
|
|
406
483
|
}
|
|
407
|
-
// Remove awareness update
|
|
484
|
+
// Remove awareness update listeners
|
|
408
485
|
if (this._awarenessUpdateHandler) {
|
|
409
486
|
this.awareness.off('update', this._awarenessUpdateHandler);
|
|
410
487
|
this._awarenessUpdateHandler = undefined;
|
|
411
488
|
}
|
|
489
|
+
if (this._appAwarenessUpdateHandler) {
|
|
490
|
+
this.appAwareness.off('update', this._appAwarenessUpdateHandler);
|
|
491
|
+
this._appAwarenessUpdateHandler = undefined;
|
|
492
|
+
}
|
|
412
493
|
// Remove beforeunload handler
|
|
413
494
|
if (this._beforeUnloadHandler && typeof window !== 'undefined') {
|
|
414
495
|
window.removeEventListener('beforeunload', this._beforeUnloadHandler);
|
|
415
496
|
this._beforeUnloadHandler = undefined;
|
|
416
497
|
}
|
|
417
498
|
this.awareness.destroy();
|
|
499
|
+
this.appAwareness.destroy();
|
|
418
500
|
super.destroy();
|
|
419
501
|
}
|
|
420
502
|
/**
|
|
@@ -450,16 +532,32 @@ export class GenericProvider extends Observable {
|
|
|
450
532
|
console.warn('Cannot sync: transport not connected');
|
|
451
533
|
return;
|
|
452
534
|
}
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
535
|
+
// Push (full document state) and pull (SyncStep1 request) share a
|
|
536
|
+
// single rate-limit reservation. syncNow() is called from several
|
|
537
|
+
// triggers that can all fire in a short window when many peers are
|
|
538
|
+
// converging at once (hash-mismatch resyncs, gap-check confirmations,
|
|
539
|
+
// per-peer connect events on mesh transports) - without this gate the
|
|
540
|
+
// push above had NO limit at all, so each trigger broadcast the full
|
|
541
|
+
// document state to the whole room, and those broadcasts caused more
|
|
542
|
+
// reordering/mismatches elsewhere, causing more triggers. Measured in
|
|
543
|
+
// test/dummy/bench-user-scaling.ts: at 100 simulated users this drove
|
|
544
|
+
// message counts to 20-200x the theoretical linear cost. See
|
|
545
|
+
// docs/superpowers/specs/2026-07-26-dummy-benchmark-scaling-design.md.
|
|
546
|
+
if (this._tryReserveSyncSlot()) {
|
|
547
|
+
// Send our current document state to all peers
|
|
548
|
+
// This ensures any changes made while offline are transmitted
|
|
549
|
+
const update = Y.encodeStateAsUpdate(this.doc);
|
|
550
|
+
if (update.length > 0) {
|
|
551
|
+
this._sendUpdate(update);
|
|
552
|
+
}
|
|
553
|
+
// Send sync request to get updates from others
|
|
554
|
+
this._writeSyncStep1();
|
|
458
555
|
}
|
|
459
|
-
//
|
|
460
|
-
|
|
461
|
-
//
|
|
556
|
+
// Broadcast current awareness state - independently throttled and much
|
|
557
|
+
// cheaper than a full document push, so it isn't gated by the sync
|
|
558
|
+
// rate limiter above even when the sync half is skipped.
|
|
462
559
|
this._broadcastAwareness([this.doc.clientID]);
|
|
560
|
+
this._broadcastAwareness([this.doc.clientID], AWARENESS_CHANNEL_APP);
|
|
463
561
|
}
|
|
464
562
|
/**
|
|
465
563
|
* Setup automatic document synchronization.
|
|
@@ -532,10 +630,16 @@ export class GenericProvider extends Observable {
|
|
|
532
630
|
this._broadcastAwareness(changedClients);
|
|
533
631
|
};
|
|
534
632
|
this.awareness.on('update', this._awarenessUpdateHandler);
|
|
633
|
+
this._appAwarenessUpdateHandler = ({ added, updated, removed, }, _origin) => {
|
|
634
|
+
const changedClients = added.concat(updated).concat(removed);
|
|
635
|
+
this._broadcastAwareness(changedClients, AWARENESS_CHANNEL_APP);
|
|
636
|
+
};
|
|
637
|
+
this.appAwareness.on('update', this._appAwarenessUpdateHandler);
|
|
535
638
|
// Cleanup: mark as offline and disconnect BC when page unloads
|
|
536
639
|
if (typeof window !== 'undefined') {
|
|
537
640
|
this._beforeUnloadHandler = () => {
|
|
538
641
|
awarenessProtocol.removeAwarenessStates(this.awareness, [this.doc.clientID], 'window unload');
|
|
642
|
+
awarenessProtocol.removeAwarenessStates(this.appAwareness, [this.doc.clientID], 'window unload');
|
|
539
643
|
// Disconnect BroadcastChannel to notify other tabs
|
|
540
644
|
this._disconnectBroadcastChannel();
|
|
541
645
|
};
|
|
@@ -552,23 +656,12 @@ export class GenericProvider extends Observable {
|
|
|
552
656
|
const message = unwrapAndVerifyMessage(data);
|
|
553
657
|
if (message === null) {
|
|
554
658
|
// Message is corrupted - reject it immediately
|
|
555
|
-
|
|
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. ` +
|
|
659
|
+
console.warn(`[GenericProvider] 💥 Corrupted message rejected: CRC32 checksum mismatch. ` +
|
|
563
660
|
`This is expected if data corruption simulation is enabled.`);
|
|
564
|
-
// Request re-sync to recover any lost data
|
|
565
|
-
//
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
if (this.transport.isConnected && !this._destroying) {
|
|
569
|
-
this._sendSyncStep1();
|
|
570
|
-
}
|
|
571
|
-
}, delay);
|
|
661
|
+
// Request re-sync to recover any lost data - routed through the
|
|
662
|
+
// shared coordinator so this doesn't stack an independent timer on
|
|
663
|
+
// top of any hash-mismatch/gap-confirmed resync already pending.
|
|
664
|
+
this._requestResync();
|
|
572
665
|
return; // Don't process corrupted message
|
|
573
666
|
}
|
|
574
667
|
// Message integrity verified - safe to decode
|
|
@@ -580,20 +673,35 @@ export class GenericProvider extends Observable {
|
|
|
580
673
|
const encoder = encoding.createEncoder();
|
|
581
674
|
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
|
582
675
|
const syncMessageType = syncProtocol.readSyncMessage(decoder, encoder, this.doc, this);
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
!this._synced) {
|
|
586
|
-
|
|
587
|
-
|
|
676
|
+
if (syncMessageType === syncProtocol.messageYjsSyncStep2) {
|
|
677
|
+
// If we received SyncStep2, we're synced
|
|
678
|
+
if (!this._synced) {
|
|
679
|
+
this._synced = true;
|
|
680
|
+
this.emit('synced', [true]);
|
|
681
|
+
}
|
|
682
|
+
// Someone else's SyncStep2 reply just arrived - our own pending
|
|
683
|
+
// reply (if any) is now most likely redundant.
|
|
684
|
+
this._cancelPendingSyncReply();
|
|
588
685
|
}
|
|
589
|
-
// Send reply if needed
|
|
686
|
+
// Send reply if needed. Suppression only engages with genuine
|
|
687
|
+
// redundancy (>=2 other known peers via awareness) - below that,
|
|
688
|
+
// there's no "someone else" to rely on, so reply immediately
|
|
689
|
+
// (still rate-limited via _sendSyncReply() as a hard backstop).
|
|
590
690
|
if (encoding.length(encoder) > 1) {
|
|
591
|
-
this.
|
|
691
|
+
if (this.awareness.getStates().size >= 3) {
|
|
692
|
+
this._scheduleSyncReply(encoding.toUint8Array(encoder));
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
this._sendSyncReply(encoding.toUint8Array(encoder));
|
|
696
|
+
}
|
|
592
697
|
}
|
|
593
698
|
break;
|
|
594
699
|
}
|
|
595
700
|
case MESSAGE_AWARENESS: {
|
|
596
|
-
|
|
701
|
+
const channel = decoding.readVarUint(decoder);
|
|
702
|
+
awarenessProtocol.applyAwarenessUpdate(channel === AWARENESS_CHANNEL_APP
|
|
703
|
+
? this.appAwareness
|
|
704
|
+
: this.awareness, decoding.readVarUint8Array(decoder), this);
|
|
597
705
|
break;
|
|
598
706
|
}
|
|
599
707
|
case MESSAGE_PUBSUB: {
|
|
@@ -636,58 +744,61 @@ export class GenericProvider extends Observable {
|
|
|
636
744
|
// Read sequence number and clientID first
|
|
637
745
|
const seqNum = decoding.readVarUint(decoder);
|
|
638
746
|
const senderClientID = decoding.readVarUint(decoder);
|
|
639
|
-
//
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
//
|
|
647
|
-
|
|
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)
|
|
747
|
+
// Track for gap detection only — does NOT gate whether we apply
|
|
748
|
+
// the update below (see _trackRemoteSeq() for why).
|
|
749
|
+
this._trackRemoteSeq(senderClientID, seqNum);
|
|
750
|
+
// Always apply the update. Yjs updates are idempotent/commutative,
|
|
751
|
+
// so re-applying an already-seen update is a harmless no-op.
|
|
752
|
+
// Under reordering, a merely-late (not actually duplicate) update
|
|
753
|
+
// must still be applied here — the old "skip if seqNum <= last
|
|
754
|
+
// seen" logic silently dropped such updates forever whenever a
|
|
755
|
+
// later-numbered message happened to arrive first.
|
|
658
756
|
const encoder = encoding.createEncoder();
|
|
659
757
|
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
|
660
758
|
const syncMessageType = syncProtocol.readSyncMessage(decoder, encoder, this.doc, this);
|
|
759
|
+
// Someone else's SyncStep2 reply just arrived - our own pending
|
|
760
|
+
// reply (if any) is now most likely redundant. Mirrors the
|
|
761
|
+
// MESSAGE_SYNC case: the reply encoded above is always a plain
|
|
762
|
+
// MESSAGE_SYNC-typed message regardless of which message type
|
|
763
|
+
// triggered it, so the same suppression scheme applies here too.
|
|
764
|
+
if (syncMessageType === syncProtocol.messageYjsSyncStep2) {
|
|
765
|
+
this._cancelPendingSyncReply();
|
|
766
|
+
}
|
|
661
767
|
// Read the expected hash from sender (signed integer)
|
|
662
768
|
const expectedHash = decoding.readVarInt(decoder);
|
|
663
769
|
// Compute our local hash after applying the update
|
|
664
770
|
const localHash = computeDocHash(this.doc);
|
|
665
771
|
// Verify hash match
|
|
666
772
|
if (localHash !== expectedHash) {
|
|
667
|
-
this
|
|
668
|
-
|
|
669
|
-
//
|
|
670
|
-
|
|
671
|
-
|
|
773
|
+
// If we already know this sender has a suspected reordering gap
|
|
774
|
+
// (see _trackRemoteSeq()/_scheduleGapCheck()), a hash mismatch
|
|
775
|
+
// right now is the *expected* transient state — we're missing a
|
|
776
|
+
// piece that's very likely still in flight, not actually
|
|
777
|
+
// diverged. Let the pending gap-check grace period resolve it
|
|
778
|
+
// instead of also escalating the hash-mismatch backoff: under
|
|
779
|
+
// heavy reordering this previously caused a burst of mismatches
|
|
780
|
+
// to rack up the exponential backoff to its 10s cap within a
|
|
781
|
+
// single edit burst, purely from timing, not real divergence.
|
|
782
|
+
// A hash mismatch with NO pending gap (in-order, but still
|
|
783
|
+
// wrong) is not explained by reordering and still escalates
|
|
784
|
+
// normally below.
|
|
785
|
+
const reorderingSuspected = this._gapCheckTimers.has(senderClientID);
|
|
786
|
+
if (!reorderingSuspected) {
|
|
787
|
+
// Push our full state AND request theirs (syncNow() does
|
|
788
|
+
// both). A hash mismatch means the two peers have diverged -
|
|
789
|
+
// one side may have edits the other lacks. Routed through the
|
|
790
|
+
// shared coordinator so this doesn't stack an independent
|
|
791
|
+
// timer on top of any corrupted-message/gap-confirmed resync
|
|
792
|
+
// already pending.
|
|
793
|
+
this._requestResync();
|
|
794
|
+
// Logged with the shared attempt counter (kept as "#N" for
|
|
795
|
+
// compatibility with existing tooling/benchmarks that grep
|
|
796
|
+
// for this exact "Hash mismatch #" pattern) - it now reflects
|
|
797
|
+
// the unified resync-attempt count rather than a
|
|
798
|
+
// hash-mismatch-specific one, since the two escalation
|
|
799
|
+
// counters were merged.
|
|
800
|
+
console.warn(`[GenericProvider] Hash mismatch #${this._resyncAttemptCount} detected! Local: ${localHash}, Expected: ${expectedHash}`);
|
|
672
801
|
}
|
|
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
802
|
}
|
|
692
803
|
// If we received SyncStep2, we're synced (unless hash mismatched)
|
|
693
804
|
if (syncMessageType === syncProtocol.messageYjsSyncStep2 &&
|
|
@@ -696,9 +807,21 @@ export class GenericProvider extends Observable {
|
|
|
696
807
|
this._synced = true;
|
|
697
808
|
this.emit('synced', [true]);
|
|
698
809
|
}
|
|
699
|
-
// Send reply if needed (as standard MESSAGE_SYNC)
|
|
810
|
+
// Send reply if needed (as standard MESSAGE_SYNC). Suppression
|
|
811
|
+
// only engages with genuine redundancy (>=2 other known peers via
|
|
812
|
+
// awareness) - below that, reply immediately (still rate-limited
|
|
813
|
+
// via _sendSyncReply() as a hard backstop). Matches the
|
|
814
|
+
// MESSAGE_SYNC case's gate exactly; without this, a hash-mismatch
|
|
815
|
+
// resync burst under packet loss bypassed suppression entirely,
|
|
816
|
+
// since every peer answering a post-mismatch SyncStep1 replied
|
|
817
|
+
// immediately via this path.
|
|
700
818
|
if (encoding.length(encoder) > 1) {
|
|
701
|
-
this.
|
|
819
|
+
if (this.awareness.getStates().size >= 3) {
|
|
820
|
+
this._scheduleSyncReply(encoding.toUint8Array(encoder));
|
|
821
|
+
}
|
|
822
|
+
else {
|
|
823
|
+
this._sendSyncReply(encoding.toUint8Array(encoder));
|
|
824
|
+
}
|
|
702
825
|
}
|
|
703
826
|
break;
|
|
704
827
|
}
|
|
@@ -713,22 +836,210 @@ export class GenericProvider extends Observable {
|
|
|
713
836
|
}
|
|
714
837
|
}
|
|
715
838
|
/**
|
|
716
|
-
*
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
*
|
|
839
|
+
* Schedule a SyncStep2 reply after a short random delay instead of
|
|
840
|
+
* sending immediately. If another peer's reply is overheard in the
|
|
841
|
+
* meantime (`_cancelPendingSyncReply`), this reply is dropped as
|
|
842
|
+
* redundant - the requester likely already got what it needed.
|
|
843
|
+
*
|
|
844
|
+
* A reply that is already pending when this is called answers a
|
|
845
|
+
* *different* SyncStep1 request (e.g. peer A's request, followed 5ms
|
|
846
|
+
* later by peer B's) - it must not be silently overwritten by the new
|
|
847
|
+
* one. Flush it immediately, then schedule the new reply fresh. The only
|
|
848
|
+
* sanctioned way a reply gets dropped is `_cancelPendingSyncReply()`,
|
|
849
|
+
* because we overheard someone else's SyncStep2 for the SAME request.
|
|
720
850
|
*/
|
|
721
|
-
|
|
851
|
+
_scheduleSyncReply(reply) {
|
|
852
|
+
if (this._pendingSyncReplyTimeoutId !== undefined) {
|
|
853
|
+
if (this._pendingSyncReply) {
|
|
854
|
+
this._sendSyncReply(this._pendingSyncReply);
|
|
855
|
+
}
|
|
856
|
+
clearTimeout(this._pendingSyncReplyTimeoutId);
|
|
857
|
+
this._pendingSyncReplyTimeoutId = undefined;
|
|
858
|
+
}
|
|
859
|
+
this._pendingSyncReply = reply;
|
|
860
|
+
const delay = Math.random() * this._syncReplySuppressionMs;
|
|
861
|
+
this._pendingSyncReplyTimeoutId = setTimeout(() => {
|
|
862
|
+
this._pendingSyncReplyTimeoutId = undefined;
|
|
863
|
+
if (this._pendingSyncReply) {
|
|
864
|
+
this._sendSyncReply(this._pendingSyncReply);
|
|
865
|
+
this._pendingSyncReply = null;
|
|
866
|
+
}
|
|
867
|
+
}, delay);
|
|
868
|
+
}
|
|
869
|
+
/** Cancel a pending suppressed reply, if any. */
|
|
870
|
+
_cancelPendingSyncReply() {
|
|
871
|
+
if (this._pendingSyncReplyTimeoutId !== undefined) {
|
|
872
|
+
clearTimeout(this._pendingSyncReplyTimeoutId);
|
|
873
|
+
this._pendingSyncReplyTimeoutId = undefined;
|
|
874
|
+
}
|
|
875
|
+
this._pendingSyncReply = null;
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Send a SyncStep2 reply, gated by the same shared per-peer budget as
|
|
879
|
+
* SyncStep1 requests/syncNow() pushes (`_tryReserveSyncSlot()`).
|
|
880
|
+
*
|
|
881
|
+
* Previously SyncStep2 replies were completely unrated - the only
|
|
882
|
+
* defense against redundant replies was the best-effort NACK-style
|
|
883
|
+
* suppression in `_scheduleSyncReply()`/`_cancelPendingSyncReply()`,
|
|
884
|
+
* which itself is just an ordinary broadcast message subject to the same
|
|
885
|
+
* wire corruption as everything else. Under sustained corruption, more
|
|
886
|
+
* competing repliers independently miss the "someone already answered"
|
|
887
|
+
* signal as peer count grows, and none of that traffic was bounded.
|
|
888
|
+
* Measured in test/dummy/bench-corruption-storm.ts: SyncStep2/SyncStep1
|
|
889
|
+
* ratio grew from ~1.1-1.3 at N=2 to ~4.5-5.9 at N=10 (should stay near
|
|
890
|
+
* 1 if suppression alone were sufficient). This is a hard backstop on
|
|
891
|
+
* top of that suppression, not a replacement for it - a rate-limited
|
|
892
|
+
* reply is dropped silently (no warn) since under normal, uncorrupted
|
|
893
|
+
* operation this path is rarely exercised and logging every drop here
|
|
894
|
+
* would itself become log spam exactly when things are already noisy.
|
|
895
|
+
*/
|
|
896
|
+
_sendSyncReply(reply) {
|
|
897
|
+
if (!this._tryReserveSyncSlot()) {
|
|
898
|
+
return; // Rate limited - drop the reply silently
|
|
899
|
+
}
|
|
900
|
+
this._send(reply);
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Track a received sequence number for reordering-tolerant gap detection.
|
|
904
|
+
* Does not gate whether the update gets applied — only decides whether a
|
|
905
|
+
* gap looks suspicious enough to (eventually) request a resync.
|
|
906
|
+
*/
|
|
907
|
+
_trackRemoteSeq(senderClientID, seqNum) {
|
|
908
|
+
let info = this._remoteSeqInfo.get(senderClientID);
|
|
909
|
+
if (!info) {
|
|
910
|
+
info = { highest: -1, seen: new Set() };
|
|
911
|
+
this._remoteSeqInfo.set(senderClientID, info);
|
|
912
|
+
}
|
|
913
|
+
if (info.seen.has(seqNum)) {
|
|
914
|
+
return; // genuine duplicate - nothing new to track
|
|
915
|
+
}
|
|
916
|
+
info.seen.add(seqNum);
|
|
917
|
+
if (seqNum > info.highest) {
|
|
918
|
+
if (info.highest >= 0 && seqNum > info.highest + 1) {
|
|
919
|
+
this._scheduleGapCheck(senderClientID, info.highest + 1, seqNum - 1);
|
|
920
|
+
}
|
|
921
|
+
info.highest = seqNum;
|
|
922
|
+
}
|
|
923
|
+
// Bound memory: forget seqNums far behind the current high-water mark.
|
|
924
|
+
const floor = info.highest - this._seqWindowSize;
|
|
925
|
+
if (floor > 0) {
|
|
926
|
+
for (const s of info.seen) {
|
|
927
|
+
if (s < floor)
|
|
928
|
+
info.seen.delete(s);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Re-check a suspected sequence gap after a short grace period instead of
|
|
934
|
+
* requesting a resync immediately. Pure network reordering (a message
|
|
935
|
+
* that's merely late, not lost) typically resolves itself within the
|
|
936
|
+
* grace window, so this avoids the resync storms that immediate gap
|
|
937
|
+
* detection caused under jitter. Real packet loss still gets caught —
|
|
938
|
+
* just `_gapGraceMs` later — and the periodic sync interval / hash
|
|
939
|
+
* verification remain as further safety nets regardless.
|
|
940
|
+
*/
|
|
941
|
+
_scheduleGapCheck(clientID, gapStart, gapEnd) {
|
|
942
|
+
// Only one pending check per sender; a newly-opened gap while a check
|
|
943
|
+
// is already scheduled will still be caught by periodic sync / hash
|
|
944
|
+
// verification even if not by this specific check.
|
|
945
|
+
if (this._gapCheckTimers.has(clientID))
|
|
946
|
+
return;
|
|
947
|
+
const timer = setTimeout(() => {
|
|
948
|
+
this._gapCheckTimers.delete(clientID);
|
|
949
|
+
const info = this._remoteSeqInfo.get(clientID);
|
|
950
|
+
if (!info || this._destroying)
|
|
951
|
+
return;
|
|
952
|
+
let stillMissing = 0;
|
|
953
|
+
for (let s = gapStart; s <= gapEnd; s++) {
|
|
954
|
+
if (!info.seen.has(s))
|
|
955
|
+
stillMissing++;
|
|
956
|
+
}
|
|
957
|
+
if (stillMissing > 0 && this.transport.isConnected) {
|
|
958
|
+
console.warn(`[GenericProvider] Sequence gap confirmed from client ${clientID}: ` +
|
|
959
|
+
`${stillMissing} message(s) still missing after ${this._gapGraceMs}ms grace period`);
|
|
960
|
+
// Routed through the shared coordinator (previously called
|
|
961
|
+
// _sendSyncStep1() directly with NO coalescing at all - the one
|
|
962
|
+
// remaining gap that let this trigger steal rate-limit slots
|
|
963
|
+
// independently of the hash-mismatch/corrupted-message triggers).
|
|
964
|
+
this._requestResync();
|
|
965
|
+
}
|
|
966
|
+
}, this._gapGraceMs);
|
|
967
|
+
this._gapCheckTimers.set(clientID, timer);
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Unified entry point for ALL resync triggers (hash mismatch, corrupted
|
|
971
|
+
* message, confirmed sequence gap). Coalesces them behind a single
|
|
972
|
+
* pending timer and a single shared escalation counter, so a burst of
|
|
973
|
+
* triggers from different causes in a short window schedules exactly one
|
|
974
|
+
* resync instead of three independent ones each able to draw on the
|
|
975
|
+
* shared `_tryReserveSyncSlot()` budget on their own.
|
|
976
|
+
*
|
|
977
|
+
* Always resolves to `syncNow()` (push + pull) rather than distinguishing
|
|
978
|
+
* a push-only/pull-only variant per trigger. `syncNow()`'s push half is
|
|
979
|
+
* already a no-op when there's nothing to send (it only calls
|
|
980
|
+
* `_sendUpdate()` when `update.length > 0`), so unifying on push+pull is
|
|
981
|
+
* strictly simpler than threading a `push` flag through a *shared*
|
|
982
|
+
* coordinator (where the "right" answer for an absorbed trigger is
|
|
983
|
+
* ambiguous anyway - was it push-worthy or not?). It also closes a latent
|
|
984
|
+
* gap where the corrupted-message and gap-confirmed triggers previously
|
|
985
|
+
* called pull-only `_sendSyncStep1()` and could never deliver this peer's
|
|
986
|
+
* own surplus edits made during a divergence window.
|
|
987
|
+
*/
|
|
988
|
+
_requestResync() {
|
|
989
|
+
// Coalesced: if a resync is already pending (regardless of which
|
|
990
|
+
// trigger scheduled it), this trigger is absorbed into it instead of
|
|
991
|
+
// stacking another independent timer/broadcast. Escalation only
|
|
992
|
+
// advances when we actually schedule a NEW timer below - incrementing
|
|
993
|
+
// unconditionally here (once per absorbed trigger too) would let a
|
|
994
|
+
// burst of many corrupted/mismatched messages while one resync is
|
|
995
|
+
// already pending ratchet the counter straight to its cap, so the
|
|
996
|
+
// *next* resync (after this one fires) always schedules at the max
|
|
997
|
+
// backoff instead of escalating gradually.
|
|
998
|
+
if (this._pendingResyncTimeoutId !== undefined) {
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
this._resyncAttemptCount++;
|
|
1002
|
+
const now = Date.now();
|
|
1003
|
+
// Reset the escalation counter if it's been stable for 10 seconds -
|
|
1004
|
+
// same quiet-period reset the old per-trigger counters used.
|
|
1005
|
+
if (now - this._lastResyncAttemptTime > 10000) {
|
|
1006
|
+
this._resyncAttemptCount = 1;
|
|
1007
|
+
}
|
|
1008
|
+
this._lastResyncAttemptTime = now;
|
|
1009
|
+
// Exponential backoff: 100ms, 500ms, 2.5s, then cap at 5s.
|
|
1010
|
+
const delay = Math.min(5000, 100 * Math.pow(5, Math.min(this._resyncAttemptCount - 1, 3)));
|
|
1011
|
+
console.warn(`[GenericProvider] Resync scheduled in ${delay}ms (attempt #${this._resyncAttemptCount})...`);
|
|
1012
|
+
this._pendingResyncTimeoutId = setTimeout(() => {
|
|
1013
|
+
this._pendingResyncTimeoutId = undefined;
|
|
1014
|
+
if (this.transport.isConnected && !this._destroying) {
|
|
1015
|
+
this.syncNow();
|
|
1016
|
+
}
|
|
1017
|
+
}, delay);
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Reserve a slot in the sync rate limiter (max `_maxSyncRequestsPerWindow`
|
|
1021
|
+
* per `_syncRequestWindowMs`), recording the request if there's room.
|
|
1022
|
+
* Shared by `_sendSyncStep1()` and `syncNow()` so a burst of triggers from
|
|
1023
|
+
* different sources (periodic sync, hash-mismatch resyncs, gap-check
|
|
1024
|
+
* confirmations) draws from one combined budget instead of each having
|
|
1025
|
+
* its own uncapped or separately-capped allowance.
|
|
1026
|
+
*/
|
|
1027
|
+
_tryReserveSyncSlot() {
|
|
722
1028
|
const now = Date.now();
|
|
723
1029
|
// Clean up old entries outside the rate limit window
|
|
724
1030
|
this._syncRequestTimes = this._syncRequestTimes.filter((t) => now - t < this._syncRequestWindowMs);
|
|
725
|
-
// Check rate limit
|
|
726
1031
|
if (this._syncRequestTimes.length >= this._maxSyncRequestsPerWindow) {
|
|
727
|
-
|
|
728
|
-
return; // Drop the request
|
|
1032
|
+
return false;
|
|
729
1033
|
}
|
|
730
|
-
// Record this request
|
|
731
1034
|
this._syncRequestTimes.push(now);
|
|
1035
|
+
return true;
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Encode and send a SyncStep1 message requesting missing updates.
|
|
1039
|
+
* Does not check the rate limiter itself - callers must reserve a slot
|
|
1040
|
+
* via `_tryReserveSyncSlot()` first.
|
|
1041
|
+
*/
|
|
1042
|
+
_writeSyncStep1() {
|
|
732
1043
|
const encoder = encoding.createEncoder();
|
|
733
1044
|
// SyncStep1 is always sent as standard MESSAGE_SYNC (no verification)
|
|
734
1045
|
// It's just a request, not an assertion of state
|
|
@@ -736,6 +1047,19 @@ export class GenericProvider extends Observable {
|
|
|
736
1047
|
syncProtocol.writeSyncStep1(encoder, this.doc);
|
|
737
1048
|
this._send(encoding.toUint8Array(encoder));
|
|
738
1049
|
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Send SyncStep1 message to request missing updates.
|
|
1052
|
+
* This is sent when first connecting to sync with remote peers.
|
|
1053
|
+
* Note: SyncStep1 is just a request and doesn't include hash verification.
|
|
1054
|
+
* Rate limited to prevent spam.
|
|
1055
|
+
*/
|
|
1056
|
+
_sendSyncStep1() {
|
|
1057
|
+
if (!this._tryReserveSyncSlot()) {
|
|
1058
|
+
console.warn(`[GenericProvider] Sync rate limit exceeded (${this._maxSyncRequestsPerWindow} requests per ${this._syncRequestWindowMs / 1000}s), throttling...`);
|
|
1059
|
+
return; // Drop the request
|
|
1060
|
+
}
|
|
1061
|
+
this._writeSyncStep1();
|
|
1062
|
+
}
|
|
739
1063
|
/**
|
|
740
1064
|
* Send a document update to the transport.
|
|
741
1065
|
* If verifyUpdates is enabled, includes sequence number and document hash for ordering and desync detection.
|
|
@@ -760,15 +1084,6 @@ export class GenericProvider extends Observable {
|
|
|
760
1084
|
}
|
|
761
1085
|
this._send(encoding.toUint8Array(encoder));
|
|
762
1086
|
}
|
|
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
1087
|
/**
|
|
773
1088
|
* Send a pub/sub message.
|
|
774
1089
|
* Internal method called by PubSubChannel.
|
|
@@ -839,45 +1154,62 @@ export class GenericProvider extends Observable {
|
|
|
839
1154
|
* Throttled to prevent awareness updates from flooding document sync.
|
|
840
1155
|
* Multiple rapid updates are batched together.
|
|
841
1156
|
*/
|
|
842
|
-
_broadcastAwareness(clients) {
|
|
1157
|
+
_broadcastAwareness(clients, channel = AWARENESS_CHANNEL_MAIN) {
|
|
843
1158
|
if (clients.length === 0)
|
|
844
1159
|
return;
|
|
1160
|
+
const isApp = channel === AWARENESS_CHANNEL_APP;
|
|
1161
|
+
const pending = isApp
|
|
1162
|
+
? this._pendingAppAwarenessClients
|
|
1163
|
+
: this._pendingAwarenessClients;
|
|
845
1164
|
// If throttling is disabled, send immediately
|
|
846
1165
|
if (this._awarenessInterval <= 0) {
|
|
847
|
-
this._sendAwarenessNow(clients);
|
|
1166
|
+
this._sendAwarenessNow(clients, channel);
|
|
848
1167
|
return;
|
|
849
1168
|
}
|
|
850
1169
|
// Add clients to pending set
|
|
851
1170
|
for (const client of clients) {
|
|
852
|
-
|
|
1171
|
+
pending.add(client);
|
|
853
1172
|
}
|
|
854
1173
|
// If we already have a scheduled broadcast, let it handle the batched clients
|
|
855
|
-
if (this.
|
|
1174
|
+
if ((isApp ? this._appAwarenessTimeoutId : this._awarenessTimeoutId) !==
|
|
1175
|
+
undefined) {
|
|
856
1176
|
return;
|
|
857
1177
|
}
|
|
858
1178
|
// Calculate delay - respect minimum interval since last broadcast
|
|
859
1179
|
const now = Date.now();
|
|
860
|
-
const
|
|
861
|
-
const delay = Math.max(0, this._awarenessInterval -
|
|
1180
|
+
const lastTime = isApp ? this._lastAppAwarenessTime : this._lastAwarenessTime;
|
|
1181
|
+
const delay = Math.max(0, this._awarenessInterval - (now - lastTime));
|
|
862
1182
|
// Schedule the batched broadcast
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
1183
|
+
const timeoutId = setTimeout(() => {
|
|
1184
|
+
if (isApp) {
|
|
1185
|
+
this._appAwarenessTimeoutId = undefined;
|
|
1186
|
+
this._lastAppAwarenessTime = Date.now();
|
|
1187
|
+
}
|
|
1188
|
+
else {
|
|
1189
|
+
this._awarenessTimeoutId = undefined;
|
|
1190
|
+
this._lastAwarenessTime = Date.now();
|
|
1191
|
+
}
|
|
866
1192
|
// Send all pending clients in one message
|
|
867
|
-
const clientsToSend = Array.from(
|
|
868
|
-
|
|
1193
|
+
const clientsToSend = Array.from(pending);
|
|
1194
|
+
pending.clear();
|
|
869
1195
|
if (clientsToSend.length > 0) {
|
|
870
|
-
this._sendAwarenessNow(clientsToSend);
|
|
1196
|
+
this._sendAwarenessNow(clientsToSend, channel);
|
|
871
1197
|
}
|
|
872
1198
|
}, delay);
|
|
1199
|
+
if (isApp)
|
|
1200
|
+
this._appAwarenessTimeoutId = timeoutId;
|
|
1201
|
+
else
|
|
1202
|
+
this._awarenessTimeoutId = timeoutId;
|
|
873
1203
|
}
|
|
874
1204
|
/**
|
|
875
1205
|
* Send awareness update immediately without throttling.
|
|
876
1206
|
*/
|
|
877
|
-
_sendAwarenessNow(clients) {
|
|
1207
|
+
_sendAwarenessNow(clients, channel = AWARENESS_CHANNEL_MAIN) {
|
|
1208
|
+
const aw = channel === AWARENESS_CHANNEL_APP ? this.appAwareness : this.awareness;
|
|
878
1209
|
const encoder = encoding.createEncoder();
|
|
879
1210
|
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
|
|
880
|
-
encoding.
|
|
1211
|
+
encoding.writeVarUint(encoder, channel);
|
|
1212
|
+
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(aw, clients));
|
|
881
1213
|
this._send(encoding.toUint8Array(encoder));
|
|
882
1214
|
}
|
|
883
1215
|
/**
|
|
@@ -920,13 +1252,21 @@ export class GenericProvider extends Observable {
|
|
|
920
1252
|
bc.publish(this._bcChannel, wrapMessageWithChecksum(encoding.toUint8Array(encoderState)), this);
|
|
921
1253
|
// Broadcast local awareness state via BroadcastChannel (wrapped with CRC32)
|
|
922
1254
|
if (this.awareness.getLocalState() !== null) {
|
|
923
|
-
|
|
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);
|
|
1255
|
+
this._publishAwarenessToBroadcastChannel(this.awareness, AWARENESS_CHANNEL_MAIN);
|
|
929
1256
|
}
|
|
1257
|
+
if (this.appAwareness.getLocalState() !== null) {
|
|
1258
|
+
this._publishAwarenessToBroadcastChannel(this.appAwareness, AWARENESS_CHANNEL_APP);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* Encode and publish an awareness update for the local client to the BroadcastChannel.
|
|
1263
|
+
*/
|
|
1264
|
+
_publishAwarenessToBroadcastChannel(awareness, channel) {
|
|
1265
|
+
const encoder = encoding.createEncoder();
|
|
1266
|
+
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
|
|
1267
|
+
encoding.writeVarUint(encoder, channel);
|
|
1268
|
+
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(awareness, [this.doc.clientID]));
|
|
1269
|
+
bc.publish(this._bcChannel, wrapMessageWithChecksum(encoding.toUint8Array(encoder)), this);
|
|
930
1270
|
}
|
|
931
1271
|
/**
|
|
932
1272
|
* Disconnect from BroadcastChannel and mark local client as offline.
|
|
@@ -936,10 +1276,16 @@ export class GenericProvider extends Observable {
|
|
|
936
1276
|
return;
|
|
937
1277
|
}
|
|
938
1278
|
// Broadcast awareness state with null (indicating disconnect) - wrapped with CRC32
|
|
939
|
-
const
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1279
|
+
for (const [awareness, channel] of [
|
|
1280
|
+
[this.awareness, AWARENESS_CHANNEL_MAIN],
|
|
1281
|
+
[this.appAwareness, AWARENESS_CHANNEL_APP],
|
|
1282
|
+
]) {
|
|
1283
|
+
const encoder = encoding.createEncoder();
|
|
1284
|
+
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
|
|
1285
|
+
encoding.writeVarUint(encoder, channel);
|
|
1286
|
+
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(awareness, [this.doc.clientID], new Map()));
|
|
1287
|
+
bc.publish(this._bcChannel, wrapMessageWithChecksum(encoding.toUint8Array(encoder)), this);
|
|
1288
|
+
}
|
|
943
1289
|
// Unsubscribe from channel
|
|
944
1290
|
bc.unsubscribe(this._bcChannel, this._bcSubscriber);
|
|
945
1291
|
this._bcConnected = false;
|