@edryslabs/genericprovider 1.0.4 → 1.5.2

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 (64) hide show
  1. package/README.md +129 -5
  2. package/dist/index.d.ts +903 -60
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +2723 -428
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib.d.ts +1 -0
  7. package/dist/lib.d.ts.map +1 -1
  8. package/dist/lib.js +3 -0
  9. package/dist/lib.js.map +1 -1
  10. package/dist/providers/ably/index.d.ts +203 -0
  11. package/dist/providers/ably/index.d.ts.map +1 -0
  12. package/dist/providers/ably/index.js +561 -0
  13. package/dist/providers/ably/index.js.map +1 -0
  14. package/dist/providers/chunking.d.ts +26 -0
  15. package/dist/providers/chunking.d.ts.map +1 -0
  16. package/dist/providers/chunking.js +52 -0
  17. package/dist/providers/chunking.js.map +1 -0
  18. package/dist/providers/dummy/index.d.ts +138 -2
  19. package/dist/providers/dummy/index.d.ts.map +1 -1
  20. package/dist/providers/dummy/index.js +266 -4
  21. package/dist/providers/dummy/index.js.map +1 -1
  22. package/dist/providers/gun/index.d.ts +11 -4
  23. package/dist/providers/gun/index.d.ts.map +1 -1
  24. package/dist/providers/gun/index.js +104 -22
  25. package/dist/providers/gun/index.js.map +1 -1
  26. package/dist/providers/indexeddb/index.d.ts +44 -20
  27. package/dist/providers/indexeddb/index.d.ts.map +1 -1
  28. package/dist/providers/indexeddb/index.js +85 -71
  29. package/dist/providers/indexeddb/index.js.map +1 -1
  30. package/dist/providers/matrix/index.d.ts +6 -1
  31. package/dist/providers/matrix/index.d.ts.map +1 -1
  32. package/dist/providers/matrix/index.js +44 -6
  33. package/dist/providers/matrix/index.js.map +1 -1
  34. package/dist/providers/nostr/index.d.ts +69 -5
  35. package/dist/providers/nostr/index.d.ts.map +1 -1
  36. package/dist/providers/nostr/index.js +204 -36
  37. package/dist/providers/nostr/index.js.map +1 -1
  38. package/dist/providers/peerjs/index.d.ts +11 -1
  39. package/dist/providers/peerjs/index.d.ts.map +1 -1
  40. package/dist/providers/peerjs/index.js +32 -2
  41. package/dist/providers/peerjs/index.js.map +1 -1
  42. package/dist/providers/pubnub/index.d.ts +35 -1
  43. package/dist/providers/pubnub/index.d.ts.map +1 -1
  44. package/dist/providers/pubnub/index.js +56 -35
  45. package/dist/providers/pubnub/index.js.map +1 -1
  46. package/dist/providers/simple-peer/index.d.ts +10 -15
  47. package/dist/providers/simple-peer/index.d.ts.map +1 -1
  48. package/dist/providers/simple-peer/index.js +81 -109
  49. package/dist/providers/simple-peer/index.js.map +1 -1
  50. package/dist/providers/supabase/index.d.ts +43 -1
  51. package/dist/providers/supabase/index.d.ts.map +1 -1
  52. package/dist/providers/supabase/index.js +211 -18
  53. package/dist/providers/supabase/index.js.map +1 -1
  54. package/dist/providers/trystero/index.d.ts +19 -1
  55. package/dist/providers/trystero/index.d.ts.map +1 -1
  56. package/dist/providers/trystero/index.js +37 -2
  57. package/dist/providers/trystero/index.js.map +1 -1
  58. package/dist/providers/websocket/index.d.ts +9 -1
  59. package/dist/providers/websocket/index.d.ts.map +1 -1
  60. package/dist/providers/websocket/index.js +7 -1
  61. package/dist/providers/websocket/index.js.map +1 -1
  62. package/dist/transport.d.ts +69 -16
  63. package/dist/transport.d.ts.map +1 -1
  64. package/package.json +35 -24
package/dist/index.d.ts CHANGED
@@ -2,6 +2,53 @@ import * as Y from 'yjs';
2
2
  import * as awarenessProtocol from 'y-protocols/awareness';
3
3
  import { Observable } from 'lib0/observable';
4
4
  import type { Transport, ConnectionConfig, ConnectionStatus } from './transport';
5
+ /**
6
+ * Cheap, peer-deterministic hash of the document's delete set - the half of
7
+ * a Yjs document's identity that the state vector does NOT cover (yjs
8
+ * INTERNALS.md: "deletions are tracked in the DeleteSet, and do not update
9
+ * the state vector"). Two docs that differ only by a lost delete-only
10
+ * update have identical state vectors, so `computeDocHash` can never
11
+ * detect that divergence; this hash can, at heartbeat granularity (see
12
+ * `_encodeSyncStep1()` / `_handleDigest()`).
13
+ *
14
+ * Cost: `Y.createDeleteSetFromStructStore` walks every struct (Yjs keeps no
15
+ * incremental delete set), so this is O(items) - fine once per heartbeat
16
+ * (every empty SyncStep2 already did this exact walk inside
17
+ * `encodeStateAsUpdate`), NOT fine per update; hence the cache in
18
+ * `_deleteSetHash()`. Per-client runs come out already sorted and merged;
19
+ * the only per-peer non-determinism is `Map` insertion order, fixed by
20
+ * sorting client IDs before hashing.
21
+ *
22
+ * Exported for the property check in test/dummy/bench-idle-room.ts.
23
+ * @internal
24
+ */
25
+ export declare function computeDeleteSetHash(doc: Y.Doc): number;
26
+ /**
27
+ * The Yjs updates a CRC-wrapped frame (as handed to `Transport.send`)
28
+ * carries: the update of a MESSAGE_SYNC_VERIFIED or MESSAGE_SYNC
29
+ * Update/SyncStep2 message, the whole document of a MESSAGE_SYNC_PUSH,
30
+ * each such sub-message of a MESSAGE_BATCH - and nothing for awareness,
31
+ * pub/sub, digest beacons and SyncStep1 requests, which carry no document
32
+ * state. For persistence transports (`providers/indexeddb`, LiaScript's
33
+ * Dexie cache): store only what this returns, and only this. Storing whole
34
+ * frames and replaying them on the next load resurrects the previous
35
+ * session's clientID as a phantom peer (its presence, its beacons - which
36
+ * the provider then answers into the store, multiplying rows) and keeps
37
+ * ~10x the bytes (a keystroke's frame carries its cursor). A frame this
38
+ * cannot parse yields `[]`, never a partial read. Frames of a provider with
39
+ * `compressionThresholdBytes` set (a leading flag byte) are not supported.
40
+ * Round 7, item 6; measured in test/dummy/bench-persist-log.ts.
41
+ */
42
+ export declare function extractDocUpdates(frame: Uint8Array): Uint8Array[];
43
+ /**
44
+ * The counterpart of `extractDocUpdates()` for the load path of a
45
+ * persistence transport: wraps one (merged) update as a CRC-wrapped
46
+ * MESSAGE_SYNC SyncStep2 frame. Handed to the `onMessage` callback, the
47
+ * provider applies it as the answer to its own request - `synced` fires,
48
+ * nothing is sent back - exactly what a local copy is: the peer that had
49
+ * our document.
50
+ */
51
+ export declare function frameDocUpdate(update: Uint8Array): Uint8Array;
5
52
  /**
6
53
  * PubSub channel for real-time messaging alongside Yjs.
7
54
  * Allows sending ephemeral messages that don't need CRDT properties.
@@ -88,7 +135,15 @@ export declare class GenericProvider extends Observable<string> {
88
135
  readonly doc: Y.Doc;
89
136
  readonly transport: Transport;
90
137
  readonly awareness: awarenessProtocol.Awareness;
91
- readonly appAwareness: awarenessProtocol.Awareness;
138
+ /**
139
+ * A second awareness instance for application/module state (cursors,
140
+ * per-module presence), isolated from `awareness`: the core one carries
141
+ * the room's own identity/presence and drives peer bookkeeping, this one
142
+ * is handed to untrusted third-party modules. Separate wire type, separate
143
+ * throttle, separate state - the two never mix.
144
+ */
145
+ private _appAwareness?;
146
+ get appAwareness(): awarenessProtocol.Awareness;
92
147
  readonly pubsub: PubSubChannel;
93
148
  private _status;
94
149
  private _synced;
@@ -97,6 +152,15 @@ export declare class GenericProvider extends Observable<string> {
97
152
  private _syncIntervalId?;
98
153
  private _verifyUpdates;
99
154
  private _disableBc;
155
+ private _idleBackoffEnabled;
156
+ private _idleBackoffMaxMs;
157
+ private _currentSyncIntervalMs;
158
+ private _lastActivityTime;
159
+ private _lastPeriodicTickTime;
160
+ private _periodicScheduler?;
161
+ private _trickleK;
162
+ private _equalBeaconsHeard;
163
+ private _beaconForced;
100
164
  private _bcChannel;
101
165
  private _bcConnected;
102
166
  private _bcSubscriber?;
@@ -104,32 +168,66 @@ export declare class GenericProvider extends Observable<string> {
104
168
  private _lastResyncAttemptTime;
105
169
  private _pendingResyncTimeoutId?;
106
170
  private _syncRequestTimes;
171
+ private _syncReplyTimes;
107
172
  private _maxSyncRequestsPerWindow;
108
173
  private _syncRequestWindowMs;
109
174
  private _pendingSyncReply;
110
175
  private _pendingSyncReplyTimeoutId?;
111
176
  private _syncReplySuppressionMs;
177
+ private _pendingSyncReplyIsAck;
178
+ private _pendingSyncReplyTargetSv;
179
+ private _responseWaitTimer?;
180
+ private _responseWaitAttempts;
181
+ private _responseSeen;
182
+ private _equalUnsettledSeen;
183
+ private _responseWaitFlags;
184
+ private _pendingCheckTimer?;
185
+ private _behindCheckTimer?;
186
+ private _behindSv;
187
+ private _rttSamples;
188
+ private _requestSentAt;
189
+ private _confirmed;
190
+ private _knownPeers;
191
+ private _peerAddress;
192
+ private _presencePending;
193
+ private _presenceCovered;
194
+ private _presenceResponseTimer?;
195
+ private _pendingAwarenessRemoval;
196
+ private _pendingAwarenessRemovalTimeoutId?;
197
+ private _peerConnectDebounceMs;
198
+ private _pendingPeerConnectSyncTimeoutId?;
199
+ private _pendingPeerConnectIds;
200
+ private _confirmedSv;
201
+ private _confirmedDsHash;
202
+ private _loading;
203
+ private _compressionThresholdBytes?;
204
+ private _dsHashCache;
112
205
  private _localSeqNum;
113
206
  private _remoteSeqInfo;
114
207
  private _gapCheckTimers;
115
208
  private _seqWindowSize;
116
209
  private _gapGraceMs;
117
210
  private _batchUpdates;
211
+ private _excludeOrigins;
212
+ private _localId?;
213
+ private _syncMode;
118
214
  private _pendingUpdate;
119
215
  private _batchTimeoutId?;
216
+ private _flushScheduled;
120
217
  private _awarenessInterval;
218
+ private static readonly AWARENESS_AUTO_MS_PER_PEER;
121
219
  private _pendingAwarenessClients;
122
220
  private _awarenessTimeoutId?;
123
221
  private _lastAwarenessTime;
124
222
  private _pendingAppAwarenessClients;
125
223
  private _appAwarenessTimeoutId?;
126
224
  private _lastAppAwarenessTime;
127
- private _excludeOrigins;
128
- private _localId?;
129
- private _syncMode;
225
+ private _appAwarenessUpdateHandler?;
130
226
  private _updateHandler?;
131
227
  private _awarenessUpdateHandler?;
132
- private _appAwarenessUpdateHandler?;
228
+ private _awarenessTimeoutMs;
229
+ private _awarenessSweepId?;
230
+ private _ownsAwareness;
133
231
  private _unsubscribeTransport?;
134
232
  private _beforeUnloadHandler?;
135
233
  /**
@@ -141,6 +239,10 @@ export declare class GenericProvider extends Observable<string> {
141
239
  */
142
240
  constructor(doc: Y.Doc, transport: Transport, options?: {
143
241
  awareness?: awarenessProtocol.Awareness;
242
+ /**
243
+ * Awareness instance for the application/module channel. Defaults to a
244
+ * fresh instance on the same doc. See `GenericProvider.appAwareness`.
245
+ */
144
246
  appAwareness?: awarenessProtocol.Awareness;
145
247
  /**
146
248
  * Interval in milliseconds for periodic sync retries.
@@ -157,7 +259,12 @@ export declare class GenericProvider extends Observable<string> {
157
259
  /**
158
260
  * Batch (debounce) document updates to reduce network traffic.
159
261
  * Updates are collected and sent after this delay in milliseconds.
160
- * Set to 0 to send updates immediately (no batching).
262
+ * 0 sends at the end of the current task (a microtask: no timer, no
263
+ * measurable delay) - the transactions one input event produces
264
+ * leave as one message, together with the cursor awareness the
265
+ * editor binding sets in the same task (round 5, item 1; measured in
266
+ * test/dummy/bench-typing-census.ts). Before round 5, 0 sent
267
+ * synchronously from inside the Y.Doc 'update' event.
161
268
  * Recommended: 50-200ms for good balance between latency and efficiency.
162
269
  * @default the transport's `preferredBatchMs` hint if it declares one,
163
270
  * otherwise 0 (disabled - immediate transmission)
@@ -176,9 +283,27 @@ export declare class GenericProvider extends Observable<string> {
176
283
  * Awareness updates (cursors, presence) are batched and sent at this interval.
177
284
  * Set to 0 for immediate transmission (not recommended for high-frequency updates).
178
285
  * This prevents awareness from flooding document sync on limited transports.
179
- * @default 100 (100ms between awareness broadcasts)
286
+ * `'auto'` (round 6, item 9) scales the interval with room size instead
287
+ * of a fixed value: `max(transport hint ?? 100, 20 * peerCount)` ms -
288
+ * cursor-only traffic (no typing) is rate * (N-1) per mover and
289
+ * otherwise unbounded by room size. A latency trade (slower cursors in
290
+ * large rooms for fewer messages), so opt-in only; see
291
+ * docs/superpowers/specs/2026-09-07-sync-optimization-round-6.md.
292
+ * @default the transport's `preferredAwarenessMs` hint if it declares one, else 100
180
293
  */
181
- awarenessInterval?: number;
294
+ awarenessInterval?: number | 'auto';
295
+ /**
296
+ * Max number of sync requests (digest beacons and syncNow() pushes
297
+ * combined) this provider will send within `syncRequestWindowMs` -
298
+ * and, as a separate budget of the same size, max number of sync
299
+ * replies (SyncStep2, acks) it will send in that window.
300
+ * Protects against self-inflicted resync storms (e.g. many hash
301
+ * mismatches firing in a short window under packet loss). Raise this
302
+ * if legitimate resyncs are being throttled under heavy loss; lower
303
+ * it to bound worst-case traffic more aggressively per peer.
304
+ * @default 20
305
+ */
306
+ maxSyncRequestsPerWindow?: number;
182
307
  /**
183
308
  * Transaction origins whose updates should not be sent to peers.
184
309
  * Updates from these origins stay local (never reach the transport).
@@ -188,31 +313,17 @@ export declare class GenericProvider extends Observable<string> {
188
313
  /**
189
314
  * This provider's identity for targeted pubsub (publishTo).
190
315
  * On transports without sendTo, targeted messages are broadcast and
191
- * dropped unless their target matches this id.
316
+ * dropped by every provider whose localId differs.
192
317
  */
193
318
  localId?: string;
194
319
  /**
195
- * Connect-time sync strategy.
196
- * - 'push-pull' (default): push full local state to peers, then request
197
- * remote state. Correct for P2P transports where there is no
198
- * authoritative peer to pull from (e.g. offline edits must be pushed).
199
- * - 'pull': only request remote state on connect (SyncStep1), never push
200
- * full local state. Use with relay/server transports (e.g. y-websocket)
201
- * where the server holds authoritative state and a reconnecting client
202
- * should adopt it rather than push a competing local copy.
320
+ * 'pull' never sends local state unasked: no connect-time push, no
321
+ * periodic push - the provider only answers requests and applies what
322
+ * it receives. For read-mostly replicas that must not write into the
323
+ * room.
203
324
  * @default 'push-pull'
204
325
  */
205
326
  syncMode?: 'push-pull' | 'pull';
206
- /**
207
- * Max number of sync requests (SyncStep1 pulls and syncNow() pushes
208
- * combined) this provider will send within `syncRequestWindowMs`.
209
- * Protects against self-inflicted resync storms (e.g. many hash
210
- * mismatches firing in a short window under packet loss). Raise this
211
- * if legitimate resyncs are being throttled under heavy loss; lower
212
- * it to bound worst-case traffic more aggressively per peer.
213
- * @default 20
214
- */
215
- maxSyncRequestsPerWindow?: number;
216
327
  /**
217
328
  * Rolling time window (ms) over which `maxSyncRequestsPerWindow` is
218
329
  * enforced.
@@ -220,14 +331,30 @@ export declare class GenericProvider extends Observable<string> {
220
331
  */
221
332
  syncRequestWindowMs?: number;
222
333
  /**
223
- * Max random delay (ms) before replying to a SyncStep1 request, used
224
- * to let other peers' replies pre-empt a redundant one (NACK-style
225
- * suppression). Only engages once at least 2 other peers are known via
226
- * awareness. Larger values suppress more redundant traffic in large
227
- * rooms at the cost of higher requester-perceived latency.
334
+ * Base max random delay (ms) before replying to a SyncStep1 request,
335
+ * used to let other peers' replies pre-empt a redundant one
336
+ * (NACK-style suppression). Only engages once at least 2 other peers
337
+ * are known via awareness. The actual max delay scales up from this
338
+ * base with room size (see `_replySuppressionMaxDelay()`) - a larger
339
+ * room has more repliers racing within the same window, so it's given
340
+ * more time for the "someone already answered" signal to be overheard
341
+ * before more repliers commit. This option is the small-room baseline
342
+ * and the growth-rate multiplier's unit, not a hard cap (see the
343
+ * `200`ms cap in `_replySuppressionMaxDelay()`).
228
344
  * @default 30
229
345
  */
230
346
  syncReplySuppressionMs?: number;
347
+ /**
348
+ * Debounce window (ms) for coalescing onPeerConnect-triggered
349
+ * syncNow() calls. Mesh transports (peerjs, simple-peer) fire
350
+ * onPeerConnect once per newly-connected remote peer; without
351
+ * coalescing, N peers joining within a short window each
352
+ * independently trigger a full-state broadcast to everyone already
353
+ * connected - an O(N^2) burst. A burst of onPeerConnect events within
354
+ * this window collapses into a single syncNow() call.
355
+ * @default 50
356
+ */
357
+ peerConnectDebounceMs?: number;
231
358
  /**
232
359
  * Grace period (ms) after detecting a suspected sequence-number gap
233
360
  * before requesting a resync. Tolerates mere network reordering
@@ -244,6 +371,141 @@ export declare class GenericProvider extends Observable<string> {
244
371
  * @default 64
245
372
  */
246
373
  seqWindowSize?: number;
374
+ /**
375
+ * Minimum payload size (bytes, measured on the CRC32-wrapped bytes
376
+ * about to be sent) above which a message is compressed
377
+ * (`deflate-raw`, via the standard CompressionStream/
378
+ * DecompressionStream Web API) before being handed to the network
379
+ * transport. Below this size, messages are sent byte-for-byte as they
380
+ * are today.
381
+ *
382
+ * Measured on synthetic Yjs docs (test/dummy/bench-compression-ratio.ts):
383
+ * a single-keystroke update (~20 bytes) actually gets BIGGER under
384
+ * gzip (fixed ~18-byte header/trailer) and is break-even at best under
385
+ * deflate-raw - not worth the async round trip through the
386
+ * Compression Streams API for a handful of bytes saved. A clean
387
+ * ~3.3KB doc compresses ~17x; a ~45KB doc with heavy edit-history
388
+ * churn (tombstones from insert/delete cycles) still compresses ~8x.
389
+ * 2048 is chosen so ordinary typing traffic (tens to a few hundred
390
+ * bytes per update - the majority of real-world traffic per this
391
+ * project's prior benchmark rounds) NEVER crosses it and is completely
392
+ * unaffected, while a full-document push/reply large enough to matter
393
+ * (and, on chunking transports like PubNub/Ably, large enough to
394
+ * multiply into several wire messages) reliably compresses down well
395
+ * below its own pre-compression size.
396
+ *
397
+ * `deflate-raw` (not `gzip`) is used deliberately: gzip's fixed
398
+ * header/trailer overhead makes it a net loss for anything under
399
+ * roughly 200 bytes (measured), while deflate-raw has ~0 fixed
400
+ * overhead and compresses at least as well for every size measured.
401
+ *
402
+ * IMPORTANT - wire-format compatibility: this project has no
403
+ * versioned wire-protocol negotiation. Enabling this (any truthy
404
+ * value) changes the wire format for EVERY message this instance
405
+ * sends: a 1-byte compressed/uncompressed flag is prepended ahead of
406
+ * the existing CRC32 wrapper on every message, compressed or not, so
407
+ * the receiving side can unambiguously tell them apart. A peer NOT
408
+ * running this option (or running an older version of this library)
409
+ * will misinterpret that leading flag byte as the start of the CRC32
410
+ * wrapper and reject every message as corrupted. All peers in a room
411
+ * must set this the same way (all enabled, or all disabled) for the
412
+ * room to function. This is a real, deliberate tradeoff - not a
413
+ * detail - which is why this defaults to fully disabled rather than
414
+ * auto-enabling above some size unconditionally.
415
+ *
416
+ * `0` disables compression entirely and keeps the wire format
417
+ * byte-for-byte identical to before this option existed; `undefined`
418
+ * takes the transport's `preferredCompressMinBytes` hint (2048 on
419
+ * PubNub, Matrix and Nostr, which carry the frame as opaque bytes),
420
+ * else disabled. NOT usable with a transport that strips the CRC32
421
+ * header or reads the message type at a fixed offset (Ably, Supabase,
422
+ * Gun today): the flag byte sits ahead of that header, so such a
423
+ * transport hands the receiver a frame it cannot parse - measured in
424
+ * the Nostr end-to-end test before that provider was made
425
+ * frame-transparent.
426
+ * @default the transport's `preferredCompressMinBytes` hint, else undefined
427
+ */
428
+ compressionThresholdBytes?: number;
429
+ /**
430
+ * Awareness lease (ms): a peer whose presence has not been refreshed
431
+ * for this long is removed; our own state is re-announced after half
432
+ * of it without any digest, update or ack from us. Replaces
433
+ * y-protocols/awareness's fixed 30 s / 15 s when the provider created
434
+ * the Awareness instance (an `awareness` passed in keeps y-protocols'
435
+ * own sweep and constants). Every peer of a room must use the same
436
+ * value: a removal is authoritative, so the shortest lease in the room
437
+ * decides for everyone and a longer-lease peer would flap. Longer =
438
+ * fewer renewal broadcasts (N(N-1) per lease/2), but a crashed peer's
439
+ * presence lingers up to this long on transports without a leave
440
+ * signal.
441
+ * @default 300000 when the transport implements `onPeerDisconnect`
442
+ * (departures are reported, the lease is only a safety net), else
443
+ * 30000 (y-protocols' value)
444
+ */
445
+ awarenessTimeoutMs?: number;
446
+ /**
447
+ * Trickle redundancy constant (RFC 6206 §4.2) for the periodic
448
+ * beacon: the tick stays silent when at least this many periodic
449
+ * beacons with a digest equal to ours were overheard since the
450
+ * previous tick - the room has already compared itself against our
451
+ * exact state, so our beacon would add nothing. A settled idle room
452
+ * then sends ~3 beacons per interval in total instead of one per
453
+ * peer (round 5, item 3). 1 = fewest messages; 2 = one lost beacon
454
+ * does not silence a window; 0 = off (every tick beacons, as before
455
+ * round 5). JOIN/CONFIRM/resync requests and the beacon re-armed by a
456
+ * local edit are never suppressed.
457
+ * @default 1
458
+ */
459
+ trickleK?: number;
460
+ /**
461
+ * Back off the periodic-sync interval (see `syncInterval`) when the
462
+ * room is idle, instead of ticking at a fixed cadence forever. After
463
+ * each periodic tick that saw no activity since the previous tick -
464
+ * no LOCAL document edit, no corrupted/rejected wire message - the
465
+ * interval DOUBLES (capped at `idleBackoffMaxMs`) for the next tick.
466
+ * Local activity re-arms the tick at once, at a random point inside
467
+ * `syncInterval`. Remote updates and awareness changes do not count
468
+ * (phase 1e): a listener has nothing a beacon would announce, and the
469
+ * editor's own beacon heals a listener that lost the keystroke - so
470
+ * one typist no longer keeps every peer at the base cadence
471
+ * (N*(N-1) deliveries per interval). Deliberately does NOT
472
+ * count the periodic tick's own routine SyncStep1/SyncStep2 exchange
473
+ * as activity (see `_markActivity()`'s doc comment for why treating
474
+ * that as activity would make this option a no-op - an earlier draft
475
+ * of this feature made exactly that mistake, caught by this option's
476
+ * own bench script). Still composes with the existing +/-20% jitter
477
+ * (`_jitteredSyncInterval()`) at whatever the current backed-off value
478
+ * is.
479
+ *
480
+ * The tradeoff this used to carry - a message dropped right before
481
+ * the room went quiet was caught only by the loser's OWN next tick,
482
+ * up to `idleBackoffMaxMs` away - is gone since phase 1d: the sender
483
+ * of that message had activity, so its interval is at the base, and
484
+ * its next beacon shows the loser it is behind; the loser asks after a
485
+ * short grace (`_scheduleBehindCheck`). Measured in
486
+ * test/dummy/bench-idle-backoff.ts (300 ms base / 2.4 s cap so the
487
+ * effect fits a short run): recovery median 1,741 ms with the old
488
+ * rule, see the phase-1d design doc's "After Task 4" for the number
489
+ * with this one. What remains is the cadence of a fully idle room:
490
+ * one beacon per peer per `idleBackoffMaxMs` instead of per
491
+ * `syncInterval`. Off restores the fixed cadence.
492
+ * @default true
493
+ */
494
+ idleBackoffEnabled?: boolean;
495
+ /**
496
+ * Ceiling (ms) for the backed-off periodic-sync interval when
497
+ * `idleBackoffEnabled` is true. Doubling from a 5000ms base reaches
498
+ * this in 4 idle ticks (5s/10s/20s/40s/60s). 60000 is chosen so the
499
+ * worst-case loss-recovery latency this trades away stays the same
500
+ * order of magnitude as `y-protocols/awareness`'s own built-in
501
+ * peer-removal timeout (30s, halved from its 60s+ `_checkInterval`
502
+ * sweep window) rather than growing unbounded - a room silent long
503
+ * enough to be fully backed off is, on this timescale, already close
504
+ * to "everyone's gone idle/timed out" territory anyway. Ignored when
505
+ * `idleBackoffEnabled` is false.
506
+ * @default 60000
507
+ */
508
+ idleBackoffMaxMs?: number;
247
509
  });
248
510
  /**
249
511
  * Connect to the backend and start syncing.
@@ -277,11 +539,132 @@ export declare class GenericProvider extends Observable<string> {
277
539
  * Whether the document is synced with remote peers
278
540
  */
279
541
  get synced(): boolean;
542
+ /**
543
+ * Push local state + request remote state, gated by the shared rate
544
+ * limiter. Returns whether it actually reserved a slot and sent anything
545
+ * - `false` means the caller was rate-limited right now. Extracted out of
546
+ * `syncNow()` so `_requestResync()`'s scheduled retry (see below) can tell
547
+ * the difference between "sent" and "silently skipped" and react to it,
548
+ * instead of assuming a resync always succeeds once it fires.
549
+ *
550
+ * @param push - whether to also broadcast full local document state.
551
+ * `_requestResync()`'s retry passes `false` (pull-only is enough for a
552
+ * resync trigger - see its call site).
553
+ * @param buildExtra - optional callback, invoked ONLY once a rate-limit
554
+ * slot is actually reserved (so it never runs, and never mutates
555
+ * whatever state it touches, on a call that ends up rate-limited),
556
+ * returning additional already-encoded sub-messages to fold into the SAME
557
+ * batched wire send as the push/pull messages below - e.g. an awareness
558
+ * update that's ready to go out "now" anyway (see
559
+ * `_tryImmediateAwarenessMessage()`). Pure wire-framing: whether a caller
560
+ * passes this never changes whether/when the push+pull half itself sends,
561
+ * only how many separate `transport.send()`/`bc.publish()` calls it costs.
562
+ * @param flags - digest beacon flags: DIGEST_FLAG_JOIN from syncNow(), 0
563
+ * from the peer-connect debounce and the resync retry (see _syncNow()).
564
+ */
565
+ private _trySyncPushPull;
280
566
  /**
281
567
  * Force an immediate sync with remote peers.
282
568
  * Useful after network interruptions or to manually trigger re-sync.
569
+ * Sends the beacon with DIGEST_FLAG_JOIN: peers answer with their
570
+ * presence and, if our state already matches theirs, with an ack beacon
571
+ * so `synced` flips without a data round trip.
283
572
  */
284
573
  syncNow(): void;
574
+ /**
575
+ * syncNow() body. `flags` = 0 for callers that must NOT request presence:
576
+ * `_schedulePeerConnectSync()` (mesh transports already re-broadcast
577
+ * presence to a newcomer via their own onPeerConnect -> syncNow()).
578
+ */
579
+ private _syncNow;
580
+ /**
581
+ * Compute the next periodic-sync delay, jittered by ~+/-20% around
582
+ * `_currentSyncIntervalMs` (== `_syncInterval` unless `idleBackoffEnabled`
583
+ * has backed it off - see that option's doc comment). Re-jittered fresh
584
+ * each tick (not computed once per connect()) so a room's peers - which
585
+ * commonly all connect() within a short window of each other - drift
586
+ * apart over time instead of staying loosely synchronized. Extracted to
587
+ * its own method purely so benchmarks can shadow it to compare against
588
+ * the unjittered baseline.
589
+ */
590
+ private _jitteredSyncInterval;
591
+ /**
592
+ * Record that "activity" happened right now, for `idleBackoffEnabled`'s
593
+ * benefit. Cheap (one timestamp write) and called unconditionally
594
+ * regardless of whether idle backoff is enabled, so there's no behavioral
595
+ * branch to keep in sync - the backoff decision in connect()'s periodic
596
+ * tick is the only place that actually reads this.
597
+ *
598
+ * Call sites are deliberately NOT "any inbound wire message" - an earlier
599
+ * version of this hooked `_handleIncomingMessage()` unconditionally, which
600
+ * made the periodic tick's OWN SyncStep1 request and the SyncStep2 reply
601
+ * answering it (empty payload - nothing to sync) each count as "activity",
602
+ * permanently resetting the backoff on every single tick and making the
603
+ * whole feature a no-op (caught by this bench script's own first run: ON
604
+ * and OFF produced statistically indistinguishable message counts). Both
605
+ * Yjs's `doc.emit('update', ...)` and y-protocols' `awareness.emit('update', ...)`
606
+ * already only fire when something with actual content changed
607
+ * (`hasContent`/non-empty added+updated+removed - confirmed by reading
608
+ * yjs's `Transaction.js` and y-protocols' `awareness.js` directly), so
609
+ * hooking THOSE instead is exactly "local or remote document/awareness
610
+ * change" with no extra filtering needed - a no-op SyncStep2 reply, a
611
+ * digest beacon, or a duplicate/no-change awareness re-announce (e.g. a
612
+ * JOIN-triggered presence response that changed nothing) never reaches
613
+ * these handlers. A corrupted (CRC32
614
+ * mismatch) message is real evidence of wire activity that neither
615
+ * handler would ever see (it's rejected before decoding) - see the
616
+ * explicit call in `_processWrappedMessage()`'s corruption branch.
617
+ *
618
+ * Call sites: `_setupDocumentSync()`'s update handler (LOCAL document
619
+ * edits only, since phase 1e) and `_processWrappedMessage()`'s
620
+ * corrupted-message branch (wire noise, not silence).
621
+ */
622
+ private _markActivity;
623
+ /**
624
+ * The lease sweep: y-protocols' own (awareness.js `_checkInterval`: renew
625
+ * at outdatedTimeout/2, remove at outdatedTimeout, every
626
+ * outdatedTimeout/10) replaced by the same loop at `_awarenessTimeoutMs`,
627
+ * the period jittered so a room that joined together does not renew in
628
+ * one burst (measured: all 49 listeners of a 50-peer room renewed inside
629
+ * the same 10 s window). The renew/remove half runs only on an awareness
630
+ * we created (`_ownsAwareness`); the peer-table prune (round 7, item 3)
631
+ * runs regardless. Armed by connect(), cleared by disconnect() - round 7,
632
+ * item 2: started from the constructor it outlived disconnect(), ticking
633
+ * ~20 times a minute and keeping the dropped provider reachable
634
+ * (test/dummy/bench-reload-phantoms.ts, part 2).
635
+ */
636
+ private _startAwarenessSweep;
637
+ /**
638
+ * A digest, verified update or ack from `clientID` (or one we are about
639
+ * to send, for our own id) is proof of presence: refresh the lease the
640
+ * sweep above checks. Only for ids with a state - a departed peer's
641
+ * `meta` entry survives its removal (y-protocols keeps it for the clock)
642
+ * and must not be revived by a late message.
643
+ */
644
+ private _touchPeer;
645
+ /**
646
+ * Transport.onPeerDisconnect: the peer at `peerId` is gone. Forget its
647
+ * address and id, drop its awareness state with origin 'peer-left': the
648
+ * broadcast goes through the same suppression as a timeout removal, but
649
+ * with a long window - every peer gets the leave signal in the same
650
+ * millisecond, and at the reply window (~170 ms at N=50) 28 of 49
651
+ * survivors broadcast before the first broadcast could be overheard
652
+ * (bench-awareness-removal-burst, DUMMY_PEER_EVENTS=1). One broadcast
653
+ * room-wide is still worth having: it corrects a joiner that received
654
+ * this peer in a relayed presence table but had no channel to it yet.
655
+ */
656
+ private _handlePeerLeave;
657
+ /** Cached delete-set hash - see computeDeleteSetHash(). */
658
+ private _deleteSetHash;
659
+ /**
660
+ * Debounce onPeerConnect-triggered syncNow() calls. A burst of connect
661
+ * events within `_peerConnectDebounceMs` collapses into one call instead
662
+ * of one per event - without this, N peers joining a mesh in a short
663
+ * window each independently broadcast full state to everyone already
664
+ * connected (O(N^2) traffic), since onPeerConnect fires once per
665
+ * newly-opened peer connection with no coalescing of its own.
666
+ */
667
+ private _schedulePeerConnectSync;
285
668
  /**
286
669
  * Setup automatic document synchronization.
287
670
  * Listens to document updates and sends them to the transport.
@@ -289,21 +672,128 @@ export declare class GenericProvider extends Observable<string> {
289
672
  */
290
673
  private _setupDocumentSync;
291
674
  /**
292
- * Batch/debounce updates to reduce network traffic.
293
- * Merges multiple updates and sends after delay.
675
+ * Merge a local update into the pending batch and schedule its flush:
676
+ * after `batchUpdates` ms (debounced) when that is > 0, otherwise at the
677
+ * end of the current task via queueMicrotask - see `_pendingUpdate`.
294
678
  */
295
679
  private _batchUpdate;
680
+ /**
681
+ * Send the pending update batch as one wire message, carrying any
682
+ * awareness change the throttle is holding (see _takePendingAwareness).
683
+ * Shared by the microtask flush, the timed flush, and the
684
+ * disconnect()/destroy() flush.
685
+ */
686
+ private _flushPendingUpdate;
296
687
  /**
297
688
  * Setup automatic awareness synchronization.
298
689
  * Listens to awareness changes and broadcasts them.
299
690
  */
300
691
  private _setupAwarenessSync;
301
692
  /**
302
- * Handle incoming messages from the transport.
303
- * Verifies message integrity with CRC32 before processing.
304
- * Corrupt messages are rejected immediately without attempting to decode.
693
+ * Handle incoming messages from the transport (or BroadcastChannel).
694
+ *
695
+ * When compressionThresholdBytes is disabled (the default), this is a
696
+ * fully synchronous fast path, byte-for-byte the same behavior as before
697
+ * that option existed: straight into _processWrappedMessage().
698
+ *
699
+ * When enabled, every message - from the network transport AND from
700
+ * BroadcastChannel (see _send()) - carries a leading compressed(1)/
701
+ * uncompressed(0) flag byte ahead of the usual CRC32 wrapper. Reading
702
+ * that flag and, if set, decompressing is inherently async (the
703
+ * Compression Streams API has no synchronous form), so this method
704
+ * dispatches to a promise chain instead of processing inline in that
705
+ * case. This means a large (compressed) message and a small (uncompressed
706
+ * or below-threshold) message that arrive back-to-back can finish
707
+ * processing out of arrival order - acceptable here because Yjs updates
708
+ * are idempotent/commutative (see MESSAGE_SYNC_VERIFIED's handling below)
709
+ * and because the compression threshold keeps this path almost entirely
710
+ * to large, full-state syncs, not the per-keystroke incremental updates
711
+ * that per-sender gap detection actually relies on ordering-sensitive
712
+ * heuristics for.
305
713
  */
306
714
  private _handleIncomingMessage;
715
+ /**
716
+ * Verify message integrity with CRC32 and decode. Corrupt messages are
717
+ * rejected immediately without attempting to decode. Operates on bytes
718
+ * that have already had any compression flag/decompression handled by
719
+ * _handleIncomingMessage() - this is the pre-compression-feature
720
+ * implementation, unchanged.
721
+ */
722
+ private _processWrappedMessage;
723
+ /**
724
+ * Decode and act on one already-integrity-verified, already-decompressed
725
+ * message. Split out of `_processWrappedMessage()` so `MESSAGE_BATCH`
726
+ * (see `_sendBatch()`) can recurse into this for each sub-message it
727
+ * unwraps, running the EXACT SAME per-message-type logic used for a
728
+ * top-level message rather than a parallel reimplementation. A thrown
729
+ * error partway through a batch's sub-messages aborts the REST of that
730
+ * batch (propagates up to `_processWrappedMessage()`'s catch) - same as
731
+ * a logic error aborting a single top-level message today, just now
732
+ * scoped to "the rest of this batch" instead of "this one message".
733
+ */
734
+ private _dispatchMessage;
735
+ /**
736
+ * Handle a digest beacon (MESSAGE_SYNC_DIGEST). Reply rule (design doc
737
+ * §3): SyncStep2 if the sender is behind us or its delete-set hash
738
+ * differs from ours (the SyncStep2 always carries our full delete set, so
739
+ * it also heals a lost delete on their side - and their beacon does the
740
+ * same for us, symmetrically, within one interval); our own beacon as an
741
+ * ack if the beacon is JOIN-flagged and states are equal; nothing
742
+ * otherwise - which is what removes the ~5-12 empty replies per heartbeat
743
+ * measured at N=50 in test/dummy/bench-idle-room.ts. "Sender is ahead of
744
+ * us" triggers no reply: our own next beacon fetches it. Nothing here
745
+ * removes a recovery path (the round-2 lesson in
746
+ * 2026-09-04-resync-message-reduction-design.md's addendum), only
747
+ * replies that carry no information.
748
+ *
749
+ * `synced`: a beacon we are not behind, with equal delete-set hash, is a
750
+ * stronger statement than the empty SyncStep2 it replaces ("you lack
751
+ * nothing I have"), so it marks us synced too - this is what keeps two
752
+ * fresh peers, or a whole concurrent join burst, converging to `synced`
753
+ * with no acks needing to survive the rate limiter.
754
+ */
755
+ private _handleDigest;
756
+ /**
757
+ * Answer a JOIN beacon's presence request once for all JOIN beacons that
758
+ * arrive within `clamp(2 * minRTT, 100, 500)` ms of the first - long
759
+ * enough to cover a join burst spread by latency, short enough that a
760
+ * lone joiner sees the room's presence within a few round trips.
761
+ */
762
+ private _schedulePresenceResponse;
763
+ /**
764
+ * Max random delay (ms) before replying to a SyncStep1 request, scaled by
765
+ * a room-size signal already available (`this.awareness.getStates().size`
766
+ * - the same signal read at the `>= 3` suppression gate). A fixed window
767
+ * (the pre-fix behavior: always `_syncReplySuppressionMs`) doesn't scale
768
+ * with room size, so a larger room has more independent repliers racing
769
+ * to answer the same request within the same window - more of them lose
770
+ * the race and get silently dropped by the `_sendSyncReply()` rate-limit
771
+ * backstop instead of never sending in the first place. Measured in
772
+ * test/dummy/bench-corruption-storm.ts: the SyncStep2/SyncStep1 ratio (
773
+ * ideally ~1 if suppression alone were sufficient) grew from ~1.1-1.3 at
774
+ * N=2 to ~4.5-5.9 at N=10 with the fixed 30ms window.
775
+ *
776
+ * `min(cap, base * log2(peerCount))` - log2 growth spreads replies over a
777
+ * wider window as the room grows without the delay exploding at very high
778
+ * N. Capped at 200ms: the slowest-profile round trip this project
779
+ * benchmarks against (Matrix, ~350ms one-way) already tolerates hundreds
780
+ * of ms of latency, so 200ms of extra requester-perceived delay stays
781
+ * well inside that budget while still giving a 100-peer room roughly
782
+ * 6-7x the base window instead of an unbounded one.
783
+ */
784
+ /**
785
+ * How many peers we believe are in the room: awareness states (includes
786
+ * ourselves) or, if larger, the distinct beacon/update senders we have
787
+ * heard plus ourselves. See `_knownPeers`.
788
+ */
789
+ private _peerCount;
790
+ /**
791
+ * Resolves `_awarenessInterval` to a concrete ms value: the configured
792
+ * fixed number, or (round 6, item 9) `max(transport hint ?? 100,
793
+ * AWARENESS_AUTO_MS_PER_PEER * peerCount)` when set to `'auto'`.
794
+ */
795
+ private _effectiveAwarenessInterval;
796
+ private _replySuppressionMaxDelay;
307
797
  /**
308
798
  * Schedule a SyncStep2 reply after a short random delay instead of
309
799
  * sending immediately. If another peer's reply is overheard in the
@@ -311,15 +801,199 @@ export declare class GenericProvider extends Observable<string> {
311
801
  * redundant - the requester likely already got what it needed.
312
802
  *
313
803
  * A reply that is already pending when this is called answers a
314
- * *different* SyncStep1 request (e.g. peer A's request, followed 5ms
315
- * later by peer B's) - it must not be silently overwritten by the new
316
- * one. Flush it immediately, then schedule the new reply fresh. The only
317
- * sanctioned way a reply gets dropped is `_cancelPendingSyncReply()`,
318
- * because we overheard someone else's SyncStep2 for the SAME request.
804
+ * *different* request (e.g. peer A's request, followed 5ms later by
805
+ * peer B's) - it must not be silently overwritten by the new one. Flush
806
+ * it immediately, then schedule the new reply fresh. The only sanctioned
807
+ * ways a reply gets dropped are `_cancelPendingSyncReply()` (we overheard
808
+ * someone else's SyncStep2 for the SAME request), `_cancelPendingAck()`,
809
+ * and the identical-bytes case below.
810
+ *
811
+ * Identical-bytes case (Task 3c in the design doc): K peers with the same
812
+ * state asking at once (K empty joiners in a burst) get K byte-identical
813
+ * SyncStep2s from us - the same full document K times, one flushed
814
+ * immediately per arriving request, each burning a rate-limit slot. If
815
+ * the new reply's bytes equal the pending reply's bytes, the pending one
816
+ * already answers this request too: keep it (same delay, same
817
+ * suppression) and drop the new one. Measured in
818
+ * test/dummy/bench-join-after-burst.ts.
319
819
  */
320
820
  private _scheduleSyncReply;
321
821
  /** Cancel a pending suppressed reply, if any. */
322
822
  private _cancelPendingSyncReply;
823
+ /**
824
+ * Cancel a pending reply only if it is a digest ack - see
825
+ * `_pendingSyncReplyIsAck`. Called from `_handleDigest()` on every
826
+ * overheard beacon whose digest equals ours.
827
+ */
828
+ private _cancelPendingAck;
829
+ /**
830
+ * Route a SyncStep2 (or digest-ack) reply through the redundancy
831
+ * suppression when there's genuine redundancy (>= 2 other known peers via
832
+ * awareness - below that there's no "someone else" to rely on), else send
833
+ * immediately. Both paths are rate-limited by `_sendSyncReply()`. Shared
834
+ * by the MESSAGE_SYNC, MESSAGE_SYNC_VERIFIED and MESSAGE_SYNC_DIGEST cases.
835
+ */
836
+ private _replyToSyncRequest;
837
+ /** Whether a reply to `clientID` can go over Transport.sendTo. */
838
+ private _canUnicast;
839
+ /**
840
+ * Responder self-selection for unicast replies: the three peers whose
841
+ * hash for this requester ranks lowest among the peers we know answer
842
+ * it. Every candidate ranks itself against the same known set, so the
843
+ * sets agree wherever the views agree, and the peer that ranks first in
844
+ * the true order always ranks first in its own view - the selection is
845
+ * never empty. A 2 s time bucket in the hash rotates the ranking, so
846
+ * three departed peers at the top only delay a reply until the
847
+ * requester's next attempt. Everyone answers in rooms of four or fewer.
848
+ * (A first cut chose each responder independently with probability 3/N;
849
+ * ~5 % of requests then selected nobody and waited for the 1 s retry.)
850
+ */
851
+ private _selectedResponder;
852
+ /**
853
+ * How many known peers rank below us for `requester` in the current 2 s
854
+ * bucket (counting stops at `cap`). Shared by unicast self-selection
855
+ * (rank < 3 answers) and, since phase 1e, the relay-mode reply delay
856
+ * (rank r waits r slots, see _replyDelay()).
857
+ */
858
+ private _responderRank;
859
+ /**
860
+ * Delay before a suppressible reply goes out (relay path). Phase 1e:
861
+ * ranked, not uniform. A uniform draw from [0, W] lets ~N * L / W
862
+ * repliers fire before the first reply is overheard (L = one-way
863
+ * latency): 10-27 SyncStep2 sends per request at N=100 in
864
+ * test/dummy/bench-join-census.ts, and the WebRTC join-burst cell's
865
+ * 16-34k spread. With the responder rank (the same hash the unicast
866
+ * self-selection uses) rank 0 answers at once and rank r waits r
867
+ * windows (W = _replySuppressionMaxDelay(), 1.5x the minimum round
868
+ * trip: with request arrival spread 2jL and reply flight L(1+j), rank 1
869
+ * has overheard rank 0 iff the slot is >= L(1+3j), which 3L(1-j) covers
870
+ * up to j~0.33). Ranks >= 8 add a random window on top so a room whose
871
+ * first eight ranked peers are all gone does not answer in one
872
+ * avalanche. Without an RTT sample or a requester id (legacy SyncStep1)
873
+ * the uniform window stays.
874
+ */
875
+ private _replyDelay;
876
+ /**
877
+ * Send one already-encoded message to a single peer over
878
+ * Transport.sendTo, with the same CRC32 wrapping and optional compression
879
+ * as a broadcast. Not mirrored to BroadcastChannel (a same-browser tab
880
+ * never appears as an addressable peer). Returns false if the peer's
881
+ * address is unknown or the transport cannot unicast.
882
+ */
883
+ private _sendDirect;
884
+ /**
885
+ * Re-check Yjs's pending-struct store after the gap grace period and
886
+ * request a resync (a beacon, see _requestResync) only if something is
887
+ * still missing. One timer; a check scheduled while one is pending is
888
+ * absorbed. Cleared on disconnect/destroy.
889
+ */
890
+ /**
891
+ * A beacon (a peer's periodic tick, or its request) just showed its
892
+ * sender ahead of us. Until phase 1d nothing happened with that: a peer
893
+ * whose last update was lost (no later message to open a sequence gap
894
+ * against), or whose request was answered by a responder that was
895
+ * itself behind, waited for its OWN next periodic beacon - up to
896
+ * syncInterval, up to idleBackoffMaxMs with idle backoff on. Now we
897
+ * check again after a grace and, if still behind that state, ask through
898
+ * the resync coordinator (coalesced, backed off, rate-limited).
899
+ *
900
+ * The grace is what keeps this quiet during typing: at Matrix latency
901
+ * almost every receiver of a periodic beacon is "behind" by a keystroke
902
+ * that is still in flight (jitter +-140 ms); max(gapGraceMs, 2 x minRTT)
903
+ * later it has arrived and the check finds nothing to do. A lost
904
+ * keystroke that opened a sequence gap is already being requested by the
905
+ * gap check - the outstanding response wait tells us so, and we stay
906
+ * quiet. One timer, the newest state vector: a later beacon that shows us
907
+ * behind by more replaces the reference, the timer keeps running.
908
+ */
909
+ private _scheduleBehindCheck;
910
+ /** Design E: after a reply or push, pending structs mean the sender had the same hole - arm the grace check. */
911
+ private _checkPendingAfterReply;
912
+ private _schedulePendingCheck;
913
+ /**
914
+ * Return the update payload of a SyncStep2/Update sync sub-message
915
+ * without advancing `decoder` (null for SyncStep1 or malformed input).
916
+ * y-protocols frames both as [subType varUint][update varUint8Array].
917
+ */
918
+ private _peekSyncUpdate;
919
+ /**
920
+ * Whether an update we just applied was already superseded here: every
921
+ * client it touches ends at a clock we were at or beyond BEFORE this
922
+ * update (i.e. it added nothing). Uses the update's own metadata
923
+ * (`Y.parseUpdateMeta`), O(clients in the update).
924
+ */
925
+ private _isLateUpdate;
926
+ /** Flip `synced` once and emit; idempotent. */
927
+ private _markSynced;
928
+ /**
929
+ * Wait for a response to the JOIN or resync beacon we just sent. If
930
+ * neither a SyncStep2 nor an equal ack/beacon arrives within 1s (then
931
+ * 2s, 4s), ask again - with a CONFIRM beacon after a JOIN (so an equal
932
+ * room acks), with a plain beacon after a resync (only peers ahead of us
933
+ * need to answer; an equal room's silence is the correct answer and its
934
+ * periodic beacons end the wait) - three times at most; after that the
935
+ * periodic beacon is the fallback, as before. Requester-side retry is how the protocol
936
+ * stays loss-tolerant now that reply suppression leaves ~1 reply per
937
+ * request; N-fold redundant replies were the old (accidental) way.
938
+ */
939
+ private _armResponseWait;
940
+ /**
941
+ * A SyncStep2 or an equal ack/beacon arrived - whatever we asked for is
942
+ * answered. `sample` = it was a direct reply (SyncStep2/ack), so its
943
+ * timing is a round-trip sample; an equal periodic beacon from a settled
944
+ * peer also ends the wait but says nothing about latency.
945
+ */
946
+ private _noteResponse;
947
+ /** Minimum of the recent round-trip samples, or null before the first reply. */
948
+ private _rttMinMs;
949
+ /**
950
+ * Delay a pure timeout-removal awareness broadcast and drop it if
951
+ * another peer's broadcast of the SAME removal is overheard first (see
952
+ * the `origin === this` branch in `_setupAwarenessSync()`'s handler,
953
+ * which calls `_cancelPendingAwarenessRemovalIfOverlaps()`) - the exact
954
+ * same NACK-style suppression `_scheduleSyncReply()` already applies to
955
+ * SyncStep2 replies, reusing the same room-size-scaled delay
956
+ * (`_replySuppressionMaxDelay()`).
957
+ *
958
+ * A pending removal already queued when this is called is for a
959
+ * DIFFERENT departure (two peers timing out, or leaving, within the same
960
+ * window): since round 5 the ids are merged into the pending set and its
961
+ * timer kept - one broadcast carries both - instead of flushing the
962
+ * first as an unsuppressed broadcast (with the long leave window below a
963
+ * burst of departures would have flushed on every peer). An overheard
964
+ * broadcast trims only the ids it covers from the pending set
965
+ * (`_cancelPendingAwarenessRemovalIfOverlaps()`).
966
+ *
967
+ * Window: the reply-suppression window for timeouts (sweeps are spread
968
+ * over seconds anyway); for leaves reported by the transport - all
969
+ * survivors learn of them in the same millisecond - ten times that,
970
+ * at least a second, so the first broadcast is overheard before the
971
+ * rest fire. A departure is not urgent: every peer already dropped the
972
+ * state locally.
973
+ */
974
+ private _scheduleAwarenessRemoval;
975
+ /**
976
+ * Peek at an awareness-update payload (still in
977
+ * `awarenessProtocol.encodeAwarenessUpdate()`'s wire encoding) for
978
+ * clientIDs whose state is `null` (a removal), without applying it.
979
+ * y-protocols/awareness.js doesn't export a standalone decoder for this,
980
+ * only `applyAwarenessUpdate()` (which also mutates state) and
981
+ * `modifyAwarenessUpdate()` (which re-encodes) - so this mirrors the
982
+ * format by hand: varUint length, then per entry
983
+ * [varUint clientID][varUint clock][varString JSON state]. Used to cancel
984
+ * a pending suppressed removal (see `_scheduleAwarenessRemoval()`) at the
985
+ * wire-message level, before `applyAwarenessUpdate()` runs - see the
986
+ * `MESSAGE_AWARENESS` case's comment for why timing matters here.
987
+ */
988
+ private _scanAwarenessPayload;
989
+ /**
990
+ * Trim a pending suppressed removal broadcast by `removedClientIds` -
991
+ * someone else already broadcast those departures; what they did not
992
+ * cover stays queued.
993
+ */
994
+ private _cancelPendingAwarenessRemovalIfOverlaps;
995
+ /** Cancel a pending suppressed awareness-removal broadcast, if any. */
996
+ private _cancelPendingAwarenessRemoval;
323
997
  /**
324
998
  * Send a SyncStep2 reply, gated by the same shared per-peer budget as
325
999
  * SyncStep1 requests/syncNow() pushes (`_tryReserveSyncSlot()`).
@@ -385,34 +1059,67 @@ export declare class GenericProvider extends Observable<string> {
385
1059
  * its own uncapped or separately-capped allowance.
386
1060
  */
387
1061
  private _tryReserveSyncSlot;
1062
+ /** Same limiter, separate budget, for SyncStep2 replies and acks. */
1063
+ private _tryReserveReplySlot;
1064
+ private _tryReserveSlot;
1065
+ /**
1066
+ * Encode the digest beacon that replaces SyncStep1 (see
1067
+ * MESSAGE_SYNC_DIGEST). Still the one place every "request sync" path
1068
+ * goes through (connect()'s syncNow(), the periodic tick,
1069
+ * _requestResync()'s retry), so they all switched together.
1070
+ */
1071
+ private _encodeSyncStep1;
388
1072
  /**
389
- * Encode and send a SyncStep1 message requesting missing updates.
390
- * Does not check the rate limiter itself - callers must reserve a slot
391
- * via `_tryReserveSyncSlot()` first.
1073
+ * Encode an ack for a JOIN beacon: same framing as a beacon, DIGEST_FLAG_ACK
1074
+ * set, and the JOINER's state vector + delete-set hash echoed back instead
1075
+ * of ours (see DIGEST_FLAG_ACK for why it must never carry our own state).
392
1076
  */
393
- private _writeSyncStep1;
1077
+ private _encodeAck;
394
1078
  /**
395
- * Send SyncStep1 message to request missing updates.
396
- * This is sent when first connecting to sync with remote peers.
397
- * Note: SyncStep1 is just a request and doesn't include hash verification.
398
- * Rate limited to prevent spam.
1079
+ * Send the periodic digest beacon. Rate limited to prevent spam. Returns
1080
+ * whether it actually sent (false means rate-limited).
399
1081
  */
400
1082
  private _sendSyncStep1;
401
1083
  /**
402
- * Send a document update to the transport.
403
- * If verifyUpdates is enabled, includes sequence number and document hash for ordering and desync detection.
1084
+ * Encode a document update, without sending it. Extracted from the old
1085
+ * `_sendUpdate()` so `_trySyncPushPull()` can fold the push half into a
1086
+ * batched wire send (see `_sendBatch()`); `_sendUpdate()` below is the
1087
+ * send-immediately form still used by every other update-emitting path
1088
+ * (the doc-update handler, batch-flush, disconnect/destroy flush) since
1089
+ * those aren't part of this batching effort's scope.
1090
+ *
1091
+ * NOTE: has a side effect (`_localSeqNum++`) - call exactly once per
1092
+ * logical update, same as before.
1093
+ */
1094
+ private _encodeUpdate;
1095
+ /**
1096
+ * Encode a full-state push (see MESSAGE_SYNC_PUSH): the document as one
1097
+ * update, deliberately without the hash and sequence number that
1098
+ * `_encodeUpdate()` adds to incremental updates.
1099
+ */
1100
+ private _encodePush;
1101
+ /**
1102
+ * Send a document update to the transport, with whatever awareness change
1103
+ * the throttle is holding folded into the same wire message (round 5,
1104
+ * item 1). If verifyUpdates is enabled, the update carries a sequence
1105
+ * number and document hash for ordering and desync detection.
404
1106
  */
405
1107
  private _sendUpdate;
1108
+ /**
1109
+ * Send awareness update to the transport.
1110
+ */
1111
+ private _sendAwarenessUpdate;
406
1112
  /**
407
1113
  * Send a pub/sub message.
408
1114
  * Internal method called by PubSubChannel.
409
1115
  */
410
1116
  _sendPubSub(topic: string, message: any): void;
411
1117
  /**
412
- * Send a targeted pub/sub message.
413
- * Uses transport.sendTo when available (direct delivery), otherwise
414
- * broadcasts a targeted frame that non-target providers drop.
415
- * Internal method called by PubSubChannel.
1118
+ * Send a pub/sub message to a single target.
1119
+ *
1120
+ * With `Transport.sendTo` the frame is unicast to that peer; without it the
1121
+ * frame is broadcast with the target embedded and dropped on receipt by
1122
+ * every provider whose `localId` differs.
416
1123
  */
417
1124
  _sendPubSubTo(target: string, topic: string, message: any): void;
418
1125
  /**
@@ -421,29 +1128,165 @@ export declare class GenericProvider extends Observable<string> {
421
1128
  * Multiple rapid updates are batched together.
422
1129
  */
423
1130
  private _broadcastAwareness;
1131
+ /**
1132
+ * Register the app channel's update listener. Same echo suppression as the
1133
+ * core channel (never re-broadcast what came off the wire), but none of its
1134
+ * presence/removal handling - this channel has no bearing on who the room
1135
+ * thinks is present.
1136
+ */
1137
+ /**
1138
+ * Announce local app-channel state, if the channel is in use and has any.
1139
+ *
1140
+ * Reads the private field, never the getter: an announce must never be the
1141
+ * thing that constructs the instance (that would re-introduce the eager
1142
+ * timer the lazy getter exists to avoid). A provider whose modules never
1143
+ * touch the channel therefore announces nothing, which is correct - there
1144
+ * is no local state to announce.
1145
+ */
1146
+ private _announceAppAwareness;
1147
+ private _attachAppAwareness;
1148
+ /**
1149
+ * Broadcast app-channel awareness. Mirrors `_broadcastAwareness()`'s
1150
+ * throttle, against its own pending set and timer, and deliberately does
1151
+ * NOT piggyback on the core sync batch: module cursor churn must not pull
1152
+ * document or presence traffic onto its cadence (or vice versa).
1153
+ */
1154
+ private _broadcastAppAwareness;
1155
+ /** Encode and send an app-channel awareness update immediately. */
1156
+ private _sendAppAwarenessNow;
1157
+ /**
1158
+ * Round 5, item 1: the awareness change the throttle is holding rides
1159
+ * along with a wire message that is leaving anyway. Returns the encoded
1160
+ * awareness sub-message (or nothing) and commits the throttle state
1161
+ * exactly as the timer's own flush would. A piggybacked broadcast costs
1162
+ * no message, only its payload bytes, so it goes out early instead of as
1163
+ * its own message up to `_awarenessInterval` later. Measured in
1164
+ * test/dummy/bench-typing-census.ts: a keystroke in an editor binding is
1165
+ * a text insert plus a cursor update - two broadcasts per keystroke
1166
+ * before this, one after. Broadcast paths only: `_sendDirect` and the
1167
+ * BroadcastChannel-only publishes never call this.
1168
+ */
1169
+ private _takePendingAwareness;
1170
+ /**
1171
+ * The counterpart for the timed batch: a `batchUpdates > 0` batch that is
1172
+ * still waiting rides along with an awareness flush (Matrix: both
1173
+ * default to 2 s, so a typist's cursor and text leave as one PUT).
1174
+ */
1175
+ private _takePendingUpdate;
1176
+ /**
1177
+ * Encode an awareness update, without sending it. Extracted from the old
1178
+ * `_sendAwarenessNow()` so `_tryImmediateAwarenessMessage()` can fold it
1179
+ * into a batched wire send instead of always sending it as its own
1180
+ * message.
1181
+ */
1182
+ private _encodeAwareness;
424
1183
  /**
425
1184
  * Send awareness update immediately without throttling.
426
1185
  */
427
1186
  private _sendAwarenessNow;
1187
+ /**
1188
+ * Attempt to build an awareness broadcast message for immediate
1189
+ * inclusion in the same wire send as a sync message a caller is about to
1190
+ * send anyway (see `_trySyncPushPull`'s `buildExtra` parameter), instead
1191
+ * of going through
1192
+ * `_broadcastAwareness()`'s independent debounce.
1193
+ *
1194
+ * Only returns non-null when the throttle would have let an immediate
1195
+ * send through anyway - i.e. no debounced broadcast is already pending
1196
+ * AND (throttling is disabled, or at least `_awarenessInterval` ms have
1197
+ * passed since the last broadcast) - so this never changes awareness
1198
+ * throttle semantics, only whether the resulting message travels as its
1199
+ * own wire send or bundled with a sync message that happens to be going
1200
+ * out "now" too.
1201
+ *
1202
+ * Mutates the same state `_broadcastAwareness()`'s own immediate-send
1203
+ * branches mutate (`_pendingAwarenessClients`, `_lastAwarenessTime`) -
1204
+ * once this returns non-null, the state is already committed as "sent
1205
+ * now", so the caller MUST actually send the returned message (bundled
1206
+ * or standalone) rather than discarding it.
1207
+ */
1208
+ private _tryImmediateAwarenessMessage;
428
1209
  /**
429
1210
  * Setup BroadcastChannel for cross-tab communication.
430
1211
  * Automatically disabled in non-browser environments.
431
1212
  */
432
1213
  private _setupBroadcastChannel;
433
- /**
434
- * Encode and publish an awareness update for the local client to the BroadcastChannel.
435
- */
436
- private _publishAwarenessToBroadcastChannel;
437
1214
  /**
438
1215
  * Disconnect from BroadcastChannel and mark local client as offline.
439
1216
  */
440
1217
  private _disconnectBroadcastChannel;
1218
+ /**
1219
+ * Send N already-encoded, already-typed sub-messages as ONE wire message
1220
+ * instead of N separate `transport.send()`/`bc.publish()` calls, when
1221
+ * there's more than one to send. Used at trigger points that
1222
+ * conceptually produce a single event but historically sent multiple
1223
+ * independent messages for it (sync push, sync pull, awareness) - see
1224
+ * `_trySyncPushPull()` (connect-time push + digest beacon + awareness in
1225
+ * one wire message).
1226
+ *
1227
+ * Design (see the task's framing requirements):
1228
+ * - Each sub-message is length-prefixed with `writeVarUint8Array`,
1229
+ * consistent with how this codebase already frames variable-length
1230
+ * payloads elsewhere (e.g. MESSAGE_AWARENESS). On receipt,
1231
+ * `_dispatchMessage()`'s `MESSAGE_BATCH` case unwraps and re-dispatches
1232
+ * each one through the EXACT SAME per-message-type logic used for a
1233
+ * top-level message - no parallel reimplementation.
1234
+ * - Sub-messages are NOT individually CRC32-wrapped here - the whole
1235
+ * batch envelope goes through the normal, single `_send()` pipeline
1236
+ * below, which wraps the WHOLE envelope in exactly one CRC32 checksum
1237
+ * (and, if `compressionThresholdBytes` is configured, one compression
1238
+ * pass) - built and computed exactly like any other outgoing message,
1239
+ * so this composes with the existing compression pipeline for free
1240
+ * rather than fighting it with a second, nested wrap/compress step.
1241
+ * The tradeoff: a single corrupted bit anywhere in a batched wire
1242
+ * message now invalidates every sub-message it carried, not just one -
1243
+ * per-sub-message CRC32s would avoid that, at the cost of ~4 extra
1244
+ * bytes per sub-message for a benefit that only matters under active
1245
+ * corruption. This tradeoff is exactly what
1246
+ * test/dummy/bench-corruption-storm.ts and bench-packet-loss.ts exist
1247
+ * to measure empirically, per this task's validation requirements,
1248
+ * rather than deciding it by design argument alone.
1249
+ * - BroadcastChannel (cross-tab) traffic is NOT specially batched beyond
1250
+ * whatever `_send()` already does per call - same-tab-group cross-tab
1251
+ * traffic is local/cheap, and `_send()` already only issues one
1252
+ * `bc.publish()` per call regardless, so a batch of N sub-messages
1253
+ * already becomes exactly one `bc.publish()` call for free once routed
1254
+ * through here - no separate BC-specific batching logic needed.
1255
+ */
1256
+ private _sendBatch;
1257
+ /** The MESSAGE_BATCH envelope of `_sendBatch`, without sending it. */
1258
+ private _encodeBatch;
441
1259
  /**
442
1260
  * Send data through both BroadcastChannel (if connected) and transport.
443
1261
  * All messages are wrapped with CRC32 checksum for integrity verification.
444
1262
  * This ensures updates reach both local tabs and remote peers with corruption detection.
445
1263
  */
446
1264
  private _send;
1265
+ /**
1266
+ * Publish already-CRC32-wrapped bytes to the other tabs. BroadcastChannel
1267
+ * is same-process - never worth compressing - but when
1268
+ * compressionThresholdBytes is enabled every message still needs the
1269
+ * leading flag byte _handleIncomingMessage() expects regardless of
1270
+ * source, so this sends flag=0 in that case. The ONE place for every BC
1271
+ * publish: the connect-time burst in _setupBroadcastChannel() used to
1272
+ * publish without the flag, and with compression on (the transport
1273
+ * hints made that a default) the other tab read a CRC byte as the flag
1274
+ * and failed to inflate three messages per join (Nostr playground,
1275
+ * 2026-09-06).
1276
+ */
1277
+ private _bcPublish;
1278
+ /**
1279
+ * Send already-CRC32-wrapped bytes to the network transport, compressing
1280
+ * first if compressionThresholdBytes is configured and this payload
1281
+ * clears it. See that option's doc comment for the size threshold
1282
+ * reasoning and the wire-format compatibility tradeoff of enabling it.
1283
+ */
1284
+ private _sendToTransport;
1285
+ /**
1286
+ * Hand fully-framed bytes to transport.send() - or transport.sendTo() when
1287
+ * a peer address is given - tolerating a sync or async result.
1288
+ */
1289
+ private _dispatchToTransport;
447
1290
  /**
448
1291
  * Update connection status and emit event.
449
1292
  */