@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.
- package/README.md +129 -5
- package/dist/index.d.ts +903 -60
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2723 -428
- package/dist/index.js.map +1 -1
- package/dist/lib.d.ts +1 -0
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +3 -0
- package/dist/lib.js.map +1 -1
- package/dist/providers/ably/index.d.ts +203 -0
- package/dist/providers/ably/index.d.ts.map +1 -0
- package/dist/providers/ably/index.js +561 -0
- package/dist/providers/ably/index.js.map +1 -0
- package/dist/providers/chunking.d.ts +26 -0
- package/dist/providers/chunking.d.ts.map +1 -0
- package/dist/providers/chunking.js +52 -0
- package/dist/providers/chunking.js.map +1 -0
- package/dist/providers/dummy/index.d.ts +138 -2
- package/dist/providers/dummy/index.d.ts.map +1 -1
- package/dist/providers/dummy/index.js +266 -4
- package/dist/providers/dummy/index.js.map +1 -1
- package/dist/providers/gun/index.d.ts +11 -4
- package/dist/providers/gun/index.d.ts.map +1 -1
- package/dist/providers/gun/index.js +104 -22
- package/dist/providers/gun/index.js.map +1 -1
- package/dist/providers/indexeddb/index.d.ts +44 -20
- package/dist/providers/indexeddb/index.d.ts.map +1 -1
- package/dist/providers/indexeddb/index.js +85 -71
- package/dist/providers/indexeddb/index.js.map +1 -1
- package/dist/providers/matrix/index.d.ts +6 -1
- package/dist/providers/matrix/index.d.ts.map +1 -1
- package/dist/providers/matrix/index.js +44 -6
- package/dist/providers/matrix/index.js.map +1 -1
- package/dist/providers/nostr/index.d.ts +69 -5
- package/dist/providers/nostr/index.d.ts.map +1 -1
- package/dist/providers/nostr/index.js +204 -36
- package/dist/providers/nostr/index.js.map +1 -1
- package/dist/providers/peerjs/index.d.ts +11 -1
- package/dist/providers/peerjs/index.d.ts.map +1 -1
- package/dist/providers/peerjs/index.js +32 -2
- package/dist/providers/peerjs/index.js.map +1 -1
- package/dist/providers/pubnub/index.d.ts +35 -1
- package/dist/providers/pubnub/index.d.ts.map +1 -1
- package/dist/providers/pubnub/index.js +56 -35
- package/dist/providers/pubnub/index.js.map +1 -1
- package/dist/providers/simple-peer/index.d.ts +10 -15
- package/dist/providers/simple-peer/index.d.ts.map +1 -1
- package/dist/providers/simple-peer/index.js +81 -109
- package/dist/providers/simple-peer/index.js.map +1 -1
- package/dist/providers/supabase/index.d.ts +43 -1
- package/dist/providers/supabase/index.d.ts.map +1 -1
- package/dist/providers/supabase/index.js +211 -18
- package/dist/providers/supabase/index.js.map +1 -1
- package/dist/providers/trystero/index.d.ts +19 -1
- package/dist/providers/trystero/index.d.ts.map +1 -1
- package/dist/providers/trystero/index.js +37 -2
- package/dist/providers/trystero/index.js.map +1 -1
- package/dist/providers/websocket/index.d.ts +9 -1
- package/dist/providers/websocket/index.d.ts.map +1 -1
- package/dist/providers/websocket/index.js +7 -1
- package/dist/providers/websocket/index.js.map +1 -1
- package/dist/transport.d.ts +69 -16
- package/dist/transport.d.ts.map +1 -1
- 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
|
-
|
|
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
|
|
128
|
-
private _localId?;
|
|
129
|
-
private _syncMode;
|
|
225
|
+
private _appAwarenessUpdateHandler?;
|
|
130
226
|
private _updateHandler?;
|
|
131
227
|
private _awarenessUpdateHandler?;
|
|
132
|
-
private
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
316
|
+
* dropped by every provider whose localId differs.
|
|
192
317
|
*/
|
|
193
318
|
localId?: string;
|
|
194
319
|
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
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
|
-
*
|
|
224
|
-
* to let other peers' replies pre-empt a redundant one
|
|
225
|
-
* suppression). Only engages once at least 2 other peers
|
|
226
|
-
* awareness.
|
|
227
|
-
*
|
|
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
|
-
*
|
|
293
|
-
*
|
|
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
|
-
*
|
|
304
|
-
*
|
|
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*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
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
|
|
390
|
-
*
|
|
391
|
-
*
|
|
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
|
|
1077
|
+
private _encodeAck;
|
|
394
1078
|
/**
|
|
395
|
-
* Send
|
|
396
|
-
*
|
|
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
|
-
*
|
|
403
|
-
*
|
|
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
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
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
|
*/
|