y-reticulum 0.1.0
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 +106 -0
- package/SPEC.md +229 -0
- package/package.json +42 -0
- package/src/compression.js +36 -0
- package/src/destination.js +41 -0
- package/src/index.js +11 -0
- package/src/messages.js +90 -0
- package/src/peer-conn.js +204 -0
- package/src/provider.js +145 -0
- package/src/room.js +403 -0
- package/test/destination.test.js +46 -0
- package/test/large-sync.test.js +67 -0
- package/test/loopback.js +100 -0
- package/test/messages.test.js +167 -0
- package/test/peer-conn.test.js +150 -0
- package/test/provider.smoke.js +146 -0
- package/test/reannounce.test.js +62 -0
- package/test/reconnect.test.js +117 -0
- package/test/sync.test.js +137 -0
- package/test/transport.test.js +197 -0
package/src/room.js
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file room.js
|
|
3
|
+
* @description The per-room mesh for a {@link ReticulumProvider}.
|
|
4
|
+
*
|
|
5
|
+
* A Room owns the local Reticulum destination for a Yjs room, announces it for
|
|
6
|
+
* discovery, learns peers from their announces, and maintains a pairwise
|
|
7
|
+
* {@link PeerConn} (Link) to each one. Over each link it runs the Yjs sync
|
|
8
|
+
* protocol (y-protocols/sync) and awareness protocol, broadcasting local Doc
|
|
9
|
+
* and Awareness updates and applying inbound ones.
|
|
10
|
+
*
|
|
11
|
+
* To avoid the two peers both trying to open a Link to each other (WebRTC
|
|
12
|
+
* "glare"), exactly one side initiates: the peer whose destination hash is
|
|
13
|
+
* lexicographically smaller. The other simply accepts.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Destination, DestType, toHex } from "@reticulum/core";
|
|
17
|
+
import * as encoding from "lib0/encoding";
|
|
18
|
+
import * as awarenessProtocol from "y-protocols/awareness";
|
|
19
|
+
import * as syncProtocol from "y-protocols/sync";
|
|
20
|
+
import * as Y from "yjs";
|
|
21
|
+
import { getCompressionProvider } from "./compression.js";
|
|
22
|
+
import { messageAwareness, messageSync, readMessage } from "./messages.js";
|
|
23
|
+
import { PeerConn } from "./peer-conn.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Delay after a peer Link drops before the initiator re-requests the peer's
|
|
27
|
+
* path, accelerating re-discovery beyond the periodic announce cadence. Small
|
|
28
|
+
* enough to beat the default announce interval, large enough to skip transient
|
|
29
|
+
* blips and to no-op if the peer comes back via the next announce first.
|
|
30
|
+
*/
|
|
31
|
+
const RECONNECT_PATH_REQUEST_DELAY_MS = 1500;
|
|
32
|
+
|
|
33
|
+
/** Constant-time-ish equality for two equal-length byte arrays. */
|
|
34
|
+
function bytesEqual(/** @type {Uint8Array} */ a, /** @type {Uint8Array} */ b) {
|
|
35
|
+
if (a.length !== b.length) return false;
|
|
36
|
+
let diff = 0;
|
|
37
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
38
|
+
return diff === 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {Object} RoomCallbacks
|
|
43
|
+
* @property {(added: string[], removed: string[]) => void} onPeers
|
|
44
|
+
* Fired whenever peers are discovered or drop off. Ids are hex link_ids.
|
|
45
|
+
* @property {(synced: boolean) => void} onSynced
|
|
46
|
+
* Fired when the room's overall sync state changes.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One Yjs room: a local destination that announces for discovery, plus the set
|
|
51
|
+
* of pairwise {@link PeerConn} links to discovered peers, with the Yjs sync
|
|
52
|
+
* and awareness protocols running over each link.
|
|
53
|
+
*/
|
|
54
|
+
export class Room {
|
|
55
|
+
/**
|
|
56
|
+
* @param {object} options
|
|
57
|
+
* @param {Y.Doc} options.doc
|
|
58
|
+
* @param {awarenessProtocol.Awareness} options.awareness
|
|
59
|
+
* @param {import("@reticulum/core").Reticulum} options.reticulum
|
|
60
|
+
* @param {import("@reticulum/core").Identity} options.identity
|
|
61
|
+
* @param {string} options.appName - Deterministic destination app-name for the room.
|
|
62
|
+
* @param {number} options.maxConns
|
|
63
|
+
* @param {number} options.announceIntervalMs
|
|
64
|
+
* @param {RoomCallbacks} options.callbacks
|
|
65
|
+
*/
|
|
66
|
+
constructor({
|
|
67
|
+
doc,
|
|
68
|
+
awareness,
|
|
69
|
+
reticulum,
|
|
70
|
+
identity,
|
|
71
|
+
appName,
|
|
72
|
+
maxConns,
|
|
73
|
+
announceIntervalMs,
|
|
74
|
+
callbacks,
|
|
75
|
+
}) {
|
|
76
|
+
this.doc = doc;
|
|
77
|
+
this.awareness = awareness;
|
|
78
|
+
this.rns = reticulum;
|
|
79
|
+
this.identity = identity;
|
|
80
|
+
this.appName = appName;
|
|
81
|
+
this.maxConns = maxConns;
|
|
82
|
+
this.announceIntervalMs = announceIntervalMs;
|
|
83
|
+
this.callbacks = callbacks;
|
|
84
|
+
|
|
85
|
+
/** @type {import("@reticulum/core").Destination|null} */
|
|
86
|
+
this.dest = null;
|
|
87
|
+
/** Hex of this room destination's hash; set once connected. */
|
|
88
|
+
this.myHex = "";
|
|
89
|
+
this.connected = false;
|
|
90
|
+
/** Whether the Doc is synced with the current peer mesh. */
|
|
91
|
+
this.synced = false;
|
|
92
|
+
/** Shared bzip2 provider for Resource compression; set on connect(). */
|
|
93
|
+
this.bz2 = null;
|
|
94
|
+
|
|
95
|
+
/** @type {Map<string, PeerConn>} hex link_id → conn */
|
|
96
|
+
this.peerConns = new Map();
|
|
97
|
+
/** Destination hashes we currently have an outgoing link to (initiator side). */
|
|
98
|
+
this.linkedDestHexes = new Set();
|
|
99
|
+
/** Destination hashes with an in-flight createLink() (de-bounces announces). */
|
|
100
|
+
this.pendingInitiates = new Set();
|
|
101
|
+
/** Destination hex → scheduled reconnect path-request timer (initiator side). */
|
|
102
|
+
this.pendingPathRequests = new Map();
|
|
103
|
+
|
|
104
|
+
this._onAnnounce = this._onAnnounce.bind(this);
|
|
105
|
+
this._onLinkRequest = this._onLinkRequest.bind(this);
|
|
106
|
+
this._docUpdateHandler = this._docUpdateHandler.bind(this);
|
|
107
|
+
this._awarenessUpdateHandler = this._awarenessUpdateHandler.bind(this);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Creates + binds the room destination, announces, and starts discovery. */
|
|
111
|
+
async connect() {
|
|
112
|
+
if (this.connected) return;
|
|
113
|
+
this.bz2 = await getCompressionProvider();
|
|
114
|
+
this.dest = await Destination.IN(
|
|
115
|
+
this.appName,
|
|
116
|
+
DestType.SINGLE,
|
|
117
|
+
this.identity,
|
|
118
|
+
this.rns,
|
|
119
|
+
);
|
|
120
|
+
this.myHex = toHex(/** @type {Uint8Array} */ (this.dest.destinationHash));
|
|
121
|
+
// registerDestination() has bindLocalDestination commented out upstream, so
|
|
122
|
+
// bind explicitly — otherwise inbound LINKREQUEST/DATA for this destination
|
|
123
|
+
// is dropped by the transport.
|
|
124
|
+
this.rns.transport.bindLocalDestination(this.dest);
|
|
125
|
+
|
|
126
|
+
this.rns.transport.addEventListener("announce", this._onAnnounce);
|
|
127
|
+
this.dest.addEventListener("link_request", this._onLinkRequest);
|
|
128
|
+
this.doc.on("update", this._docUpdateHandler);
|
|
129
|
+
this.awareness.on("update", this._awarenessUpdateHandler);
|
|
130
|
+
|
|
131
|
+
// Delegate the periodic re-announce loop — and its §9.7 60 s floor — to
|
|
132
|
+
// @reticulum/core. startAnnouncing() fires the first announce immediately
|
|
133
|
+
// (so the destination is reachable as soon as connect() returns), then
|
|
134
|
+
// repeats at the interval to keep cached mesh paths fresh against
|
|
135
|
+
// transit-relay TTLs.
|
|
136
|
+
this.dest.startAnnouncing({ intervalMs: this.announceIntervalMs });
|
|
137
|
+
|
|
138
|
+
this.connected = true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Stops announcing, tears down all peer links, and unbinds the destination. */
|
|
142
|
+
async disconnect() {
|
|
143
|
+
if (!this.connected) return;
|
|
144
|
+
this.connected = false;
|
|
145
|
+
|
|
146
|
+
this.dest?.stopAnnouncing();
|
|
147
|
+
for (const timer of this.pendingPathRequests.values()) clearTimeout(timer);
|
|
148
|
+
this.pendingPathRequests.clear();
|
|
149
|
+
this.rns.transport.removeEventListener("announce", this._onAnnounce);
|
|
150
|
+
this.dest?.removeEventListener("link_request", this._onLinkRequest);
|
|
151
|
+
this.doc.off("update", this._docUpdateHandler);
|
|
152
|
+
this.awareness.off("update", this._awarenessUpdateHandler);
|
|
153
|
+
|
|
154
|
+
// Tell peers to drop our awareness state before the links come down.
|
|
155
|
+
awarenessProtocol.removeAwarenessStates(
|
|
156
|
+
this.awareness,
|
|
157
|
+
[this.doc.clientID],
|
|
158
|
+
"disconnect",
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const removed = [...this.peerConns.keys()];
|
|
162
|
+
for (const conn of this.peerConns.values()) conn.destroy();
|
|
163
|
+
this.peerConns.clear();
|
|
164
|
+
this.linkedDestHexes.clear();
|
|
165
|
+
this.pendingInitiates.clear();
|
|
166
|
+
this.synced = false;
|
|
167
|
+
if (removed.length) this.callbacks.onPeers([], removed);
|
|
168
|
+
|
|
169
|
+
if (this.dest) {
|
|
170
|
+
this.rns.transport.unbindLocalDestination(this.dest);
|
|
171
|
+
this.dest = null;
|
|
172
|
+
}
|
|
173
|
+
this.myHex = "";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Initiator path: a peer in our room announced. Open a Link to it unless we
|
|
178
|
+
* already have one, we're at capacity, or the glare rule says the peer should
|
|
179
|
+
* initiate instead.
|
|
180
|
+
* @param {Event} event
|
|
181
|
+
*/
|
|
182
|
+
async _onAnnounce(event) {
|
|
183
|
+
if (!this.connected || !this.dest) return;
|
|
184
|
+
const detail = /** @type {any} */ (event).detail;
|
|
185
|
+
if (
|
|
186
|
+
!bytesEqual(
|
|
187
|
+
/** @type {Uint8Array} */ (detail.nameHash),
|
|
188
|
+
/** @type {Uint8Array} */ (this.dest.nameHash),
|
|
189
|
+
)
|
|
190
|
+
) {
|
|
191
|
+
return; // different room
|
|
192
|
+
}
|
|
193
|
+
const remoteHex = toHex(/** @type {Uint8Array} */ (detail.destinationHash));
|
|
194
|
+
if (remoteHex === this.myHex) return; // self (transport filters this, but be safe)
|
|
195
|
+
if (this.peerConns.size >= this.maxConns) return;
|
|
196
|
+
if (
|
|
197
|
+
this.linkedDestHexes.has(remoteHex) ||
|
|
198
|
+
this.pendingInitiates.has(remoteHex)
|
|
199
|
+
) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// Glare avoidance: only the lexicographically smaller destination initiates.
|
|
203
|
+
if (this.myHex > remoteHex) return;
|
|
204
|
+
|
|
205
|
+
this.pendingInitiates.add(remoteHex);
|
|
206
|
+
try {
|
|
207
|
+
const out = await Destination.OUT(
|
|
208
|
+
this.appName,
|
|
209
|
+
DestType.SINGLE,
|
|
210
|
+
detail.identity,
|
|
211
|
+
this.rns,
|
|
212
|
+
);
|
|
213
|
+
const link = await out.createLink();
|
|
214
|
+
if (!this.connected) {
|
|
215
|
+
await link.teardown();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
this.linkedDestHexes.add(remoteHex);
|
|
219
|
+
this._registerPeer(link, detail.destinationHash);
|
|
220
|
+
} catch {
|
|
221
|
+
// Peer vanished mid-handshake, transport error, etc. — the announce loop
|
|
222
|
+
// will retry on the next announce if the peer is still around.
|
|
223
|
+
} finally {
|
|
224
|
+
this.pendingInitiates.delete(remoteHex);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Responder path: a peer is opening a Link to us. Accept it.
|
|
230
|
+
* @param {Event} event
|
|
231
|
+
*/
|
|
232
|
+
async _onLinkRequest(event) {
|
|
233
|
+
if (!this.connected || !this.dest) return;
|
|
234
|
+
if (this.peerConns.size >= this.maxConns) return;
|
|
235
|
+
const packet = /** @type {any} */ (event).detail.packet;
|
|
236
|
+
try {
|
|
237
|
+
const link = await this.dest.acceptLink(packet);
|
|
238
|
+
if (!this.connected) {
|
|
239
|
+
await link.teardown();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
this._registerPeer(link, null);
|
|
243
|
+
} catch {
|
|
244
|
+
// Handshake failed; nothing to clean up.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Registers a newly active peer and kicks off the Yjs sync handshake
|
|
250
|
+
* (syncStep1 + local awareness), mirroring y-webrtc's peer-on-connect path.
|
|
251
|
+
* @param {import("@reticulum/core").Link} link
|
|
252
|
+
* @param {Uint8Array|null} remoteDestHash
|
|
253
|
+
*/
|
|
254
|
+
_registerPeer(link, remoteDestHash) {
|
|
255
|
+
const peer = new PeerConn({
|
|
256
|
+
link,
|
|
257
|
+
remoteDestHash,
|
|
258
|
+
bz2: this.bz2,
|
|
259
|
+
onData: (payload, p) => this._onPeerData(payload, p),
|
|
260
|
+
onClose: (p) => this._onPeerClose(p),
|
|
261
|
+
});
|
|
262
|
+
this.peerConns.set(peer.peerId, peer);
|
|
263
|
+
this.callbacks.onPeers([peer.peerId], []);
|
|
264
|
+
this._sendInitialSync(peer);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** @param {PeerConn} peer */
|
|
268
|
+
_onPeerClose(peer) {
|
|
269
|
+
if (!this.peerConns.delete(peer.peerId)) return;
|
|
270
|
+
if (peer.remoteDestHash) {
|
|
271
|
+
const remoteHex = toHex(peer.remoteDestHash);
|
|
272
|
+
this.linkedDestHexes.delete(remoteHex);
|
|
273
|
+
// Initiator side only: the responder (remoteDestHash === null) must not
|
|
274
|
+
// re-initiate per the glare rule, so it has nothing to path-request.
|
|
275
|
+
this._scheduleReconnectPathRequest(remoteHex, peer.remoteDestHash);
|
|
276
|
+
}
|
|
277
|
+
this.callbacks.onPeers([], [peer.peerId]);
|
|
278
|
+
this._checkSynced();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Schedules a one-shot path request for a dropped peer so the mesh answers
|
|
283
|
+
* with a fresh path-response announce, beating the periodic announce
|
|
284
|
+
* cadence. Coalesces flaps to one in-flight request per peer and is a no-op
|
|
285
|
+
* if the peer already came back (via a normal announce) by the time it fires.
|
|
286
|
+
*
|
|
287
|
+
* @param {string} remoteHex
|
|
288
|
+
* @param {Uint8Array} remoteDestHash
|
|
289
|
+
*/
|
|
290
|
+
_scheduleReconnectPathRequest(remoteHex, remoteDestHash) {
|
|
291
|
+
if (!this.connected || this.pendingPathRequests.has(remoteHex)) return;
|
|
292
|
+
const timer = setTimeout(() => {
|
|
293
|
+
this.pendingPathRequests.delete(remoteHex);
|
|
294
|
+
if (!this.connected || !this.dest) return;
|
|
295
|
+
if (this.linkedDestHexes.has(remoteHex)) return; // already re-linked
|
|
296
|
+
this.rns.transport.requestPath(remoteDestHash).catch(() => {});
|
|
297
|
+
}, RECONNECT_PATH_REQUEST_DELAY_MS);
|
|
298
|
+
this.pendingPathRequests.set(remoteHex, timer);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Inbound raw bytes from a peer: decode and apply, send back any reply, and
|
|
303
|
+
* mark the peer (and possibly the room) synced.
|
|
304
|
+
* @param {Uint8Array} payload
|
|
305
|
+
* @param {PeerConn} peer
|
|
306
|
+
*/
|
|
307
|
+
_onPeerData(payload, peer) {
|
|
308
|
+
const reply = readMessage(
|
|
309
|
+
this.doc,
|
|
310
|
+
this.awareness,
|
|
311
|
+
payload,
|
|
312
|
+
peer,
|
|
313
|
+
this.synced,
|
|
314
|
+
() => {
|
|
315
|
+
peer.synced = true;
|
|
316
|
+
this._checkSynced();
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
if (reply) this._send(peer, reply);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Local Doc update → broadcast a sync `update` to every peer.
|
|
324
|
+
* @param {Uint8Array} update
|
|
325
|
+
* @param {any} _origin
|
|
326
|
+
*/
|
|
327
|
+
_docUpdateHandler(update, _origin) {
|
|
328
|
+
const encoder = encoding.createEncoder();
|
|
329
|
+
encoding.writeVarUint(encoder, messageSync);
|
|
330
|
+
syncProtocol.writeUpdate(encoder, update);
|
|
331
|
+
this._broadcast(encoding.toUint8Array(encoder));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Local Awareness update → broadcast an awareness update to every peer.
|
|
336
|
+
* @param {{added: number[], updated: number[], removed: number[]}} changes
|
|
337
|
+
* @param {any} _origin
|
|
338
|
+
*/
|
|
339
|
+
_awarenessUpdateHandler({ added, updated, removed }, _origin) {
|
|
340
|
+
const changedClients = added.concat(updated, removed);
|
|
341
|
+
const encoder = encoding.createEncoder();
|
|
342
|
+
encoding.writeVarUint(encoder, messageAwareness);
|
|
343
|
+
encoding.writeVarUint8Array(
|
|
344
|
+
encoder,
|
|
345
|
+
awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients),
|
|
346
|
+
);
|
|
347
|
+
this._broadcast(encoding.toUint8Array(encoder));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Sends the initial sync handshake to a freshly connected peer: a syncStep1
|
|
352
|
+
* (requesting their state) and, if we have any, our awareness state. Both
|
|
353
|
+
* sides do this, so state flows both ways.
|
|
354
|
+
* @param {PeerConn} peer
|
|
355
|
+
*/
|
|
356
|
+
_sendInitialSync(peer) {
|
|
357
|
+
const step1 = encoding.createEncoder();
|
|
358
|
+
encoding.writeVarUint(step1, messageSync);
|
|
359
|
+
syncProtocol.writeSyncStep1(step1, this.doc);
|
|
360
|
+
this._send(peer, encoding.toUint8Array(step1));
|
|
361
|
+
|
|
362
|
+
const clients = Array.from(this.awareness.getStates().keys());
|
|
363
|
+
if (clients.length > 0) {
|
|
364
|
+
const aw = encoding.createEncoder();
|
|
365
|
+
encoding.writeVarUint(aw, messageAwareness);
|
|
366
|
+
encoding.writeVarUint8Array(
|
|
367
|
+
aw,
|
|
368
|
+
awarenessProtocol.encodeAwarenessUpdate(this.awareness, clients),
|
|
369
|
+
);
|
|
370
|
+
this._send(peer, encoding.toUint8Array(aw));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** @param {Uint8Array} bytes */
|
|
375
|
+
_broadcast(bytes) {
|
|
376
|
+
for (const peer of this.peerConns.values()) this._send(peer, bytes);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** @param {PeerConn} peer @param {Uint8Array} bytes */
|
|
380
|
+
_send(peer, bytes) {
|
|
381
|
+
peer.send(bytes).catch(() => {});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Recomputes room-level sync state and emits on change. A room with no peers
|
|
386
|
+
* is *not* synced — an empty mesh carries no sync guarantee — so this flips
|
|
387
|
+
* back to `synced: false` when the last peer drops, rather than vacuously
|
|
388
|
+
* `true`.
|
|
389
|
+
*/
|
|
390
|
+
_checkSynced() {
|
|
391
|
+
let synced = this.peerConns.size > 0;
|
|
392
|
+
for (const peer of this.peerConns.values()) {
|
|
393
|
+
if (!peer.synced) {
|
|
394
|
+
synced = false;
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (synced !== this.synced) {
|
|
399
|
+
this.synced = synced;
|
|
400
|
+
this.callbacks.onSynced(synced);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file destination.test.js
|
|
3
|
+
* @description Unit tests for the room-name → destination aspect derivation.
|
|
4
|
+
*
|
|
5
|
+
* Locks the exact mapping so a refactor can't silently change room membership
|
|
6
|
+
* (two peers must derive the same aspect to find each other).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import { roomDestinationName } from "../src/destination.js";
|
|
12
|
+
|
|
13
|
+
test("roomDestinationName is deterministic for the same input", async () => {
|
|
14
|
+
const a = await roomDestinationName("my-room");
|
|
15
|
+
const b = await roomDestinationName("my-room");
|
|
16
|
+
assert.equal(a, b);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("roomDestinationName differs for different inputs", async () => {
|
|
20
|
+
const a = await roomDestinationName("room-one");
|
|
21
|
+
const b = await roomDestinationName("room-two");
|
|
22
|
+
assert.notEqual(a, b);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("roomDestinationName has the expected shape", async () => {
|
|
26
|
+
const name = await roomDestinationName("anything");
|
|
27
|
+
assert.match(
|
|
28
|
+
name,
|
|
29
|
+
/^y-reticulum\.sync\.[0-9a-f]{16}$/,
|
|
30
|
+
"aspect must be y-reticulum.sync.<16 hex chars>",
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("roomDestinationName maps known inputs to a locked hash", async () => {
|
|
35
|
+
// First 8 bytes of SHA-256("hello") = 2cf24dba5fb0a30e. Pinning this catches
|
|
36
|
+
// accidental changes to the derivation that would fragment a room's peers.
|
|
37
|
+
assert.equal(
|
|
38
|
+
await roomDestinationName("hello"),
|
|
39
|
+
"y-reticulum.sync.2cf24dba5fb0a30e",
|
|
40
|
+
);
|
|
41
|
+
// SHA-256("y-reticulum") prefix = 2b3a1eacdb01545a
|
|
42
|
+
assert.equal(
|
|
43
|
+
await roomDestinationName("y-reticulum"),
|
|
44
|
+
"y-reticulum.sync.2b3a1eacdb01545a",
|
|
45
|
+
);
|
|
46
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file large-sync.smoke.js
|
|
3
|
+
* @description Phase 4 smoketest — an initial Doc state larger than the link
|
|
4
|
+
* MDU syncs via a chunked, compressed Reticulum Resource.
|
|
5
|
+
*
|
|
6
|
+
* Provider A starts with a large payload already in its Doc, so the very first
|
|
7
|
+
* syncStep2 it sends (its full state) cannot fit in a single DATA packet and
|
|
8
|
+
* must travel as a Resource. Provider B receives, reassembles (decompressing if
|
|
9
|
+
* bz2 shrunk it) and ends up with identical state — exercising the oversized
|
|
10
|
+
* path end to end.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import test from "node:test";
|
|
15
|
+
import { Identity } from "@reticulum/core";
|
|
16
|
+
import * as Y from "yjs";
|
|
17
|
+
import { ReticulumProvider } from "../src/index.js";
|
|
18
|
+
import { makeLoopback, nudgeAnnounce, waitFor } from "./loopback.js";
|
|
19
|
+
|
|
20
|
+
const ROOM = "y-reticulum-large-sync-smoke";
|
|
21
|
+
// Comfortably larger than the default link MDU (~431 B).
|
|
22
|
+
const LARGE = "x".repeat(3000);
|
|
23
|
+
|
|
24
|
+
test("an initial state larger than the link MDU syncs via a Resource", {
|
|
25
|
+
timeout: 15000,
|
|
26
|
+
}, async () => {
|
|
27
|
+
const { rnsA, rnsB, close } = await makeLoopback();
|
|
28
|
+
const docA = new Y.Doc();
|
|
29
|
+
const docB = new Y.Doc();
|
|
30
|
+
|
|
31
|
+
// Put the large payload in A *before* connecting, so the initial syncStep2
|
|
32
|
+
// (A's full state) must be chunked into a Resource.
|
|
33
|
+
docA.getText("t").insert(0, LARGE);
|
|
34
|
+
assert.ok(
|
|
35
|
+
Y.encodeStateAsUpdate(docA).length > 500,
|
|
36
|
+
"sanity: payload must exceed the link MDU",
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const providerA = new ReticulumProvider(ROOM, docA, {
|
|
40
|
+
reticulum: rnsA,
|
|
41
|
+
identity: await Identity.generate(),
|
|
42
|
+
});
|
|
43
|
+
const providerB = new ReticulumProvider(ROOM, docB, {
|
|
44
|
+
reticulum: rnsB,
|
|
45
|
+
identity: await Identity.generate(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** @type {boolean} */ let bSynced = false;
|
|
49
|
+
providerB.on("synced", (/** @type {any} */ e) => {
|
|
50
|
+
if (e.synced) bSynced = true;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
await providerA.connect();
|
|
54
|
+
await providerB.connect();
|
|
55
|
+
// The periodic re-announce cadence is now @reticulum/core's job (and clamped
|
|
56
|
+
// to ≥60 s), so nudge one explicit announce per side to mesh up fast.
|
|
57
|
+
await nudgeAnnounce(providerA, providerB);
|
|
58
|
+
|
|
59
|
+
// B becomes synced exactly when it receives + applies A's large step2.
|
|
60
|
+
await waitFor(() => bSynced, 10000);
|
|
61
|
+
await waitFor(() => docB.getText("t").toString() === LARGE, 5000);
|
|
62
|
+
assert.equal(docB.getText("t").toString(), LARGE);
|
|
63
|
+
|
|
64
|
+
await providerA.destroy();
|
|
65
|
+
await providerB.destroy();
|
|
66
|
+
await close();
|
|
67
|
+
});
|
package/test/loopback.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file loopback.js
|
|
3
|
+
* @description Shared TCP-loopback harness for smoketests: wires two
|
|
4
|
+
* in-process Reticulum instances together (one listens, one dials) so a test
|
|
5
|
+
* can build providers on them. Not a test file — just helpers.
|
|
6
|
+
*/
|
|
7
|
+
import net from "node:net";
|
|
8
|
+
import { Reticulum } from "@reticulum/core";
|
|
9
|
+
import { TCPClientInterface, TCPServerInterface } from "@reticulum/node";
|
|
10
|
+
|
|
11
|
+
export const HOST = "127.0.0.1";
|
|
12
|
+
|
|
13
|
+
/** Resolves with a free localhost TCP port (ephemeral, immediately released). */
|
|
14
|
+
export function getFreePort() {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const probe = net.createServer();
|
|
17
|
+
probe.unref();
|
|
18
|
+
probe.on("error", reject);
|
|
19
|
+
probe.listen({ host: HOST, port: 0 }, () => {
|
|
20
|
+
const { port } = /** @type {net.AddressInfo} */ (probe.address());
|
|
21
|
+
probe.close(() => resolve(port));
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Wires two in-process Reticulum instances over a TCP loopback (A listens, B
|
|
28
|
+
* dials) and returns them plus a `close()` that tears the link down.
|
|
29
|
+
*
|
|
30
|
+
* @returns {Promise<{
|
|
31
|
+
* rnsA: Reticulum,
|
|
32
|
+
* rnsB: Reticulum,
|
|
33
|
+
* close: () => Promise<void>,
|
|
34
|
+
* }>}
|
|
35
|
+
*/
|
|
36
|
+
export async function makeLoopback() {
|
|
37
|
+
const port = await getFreePort();
|
|
38
|
+
const rnsA = new Reticulum();
|
|
39
|
+
const rnsB = new Reticulum();
|
|
40
|
+
// A listens. The TCPServerInterface has no writable stream itself, so do not
|
|
41
|
+
// addInterface() it — only its spawned children get wired in, marked default
|
|
42
|
+
// so the leaf can emit handshake packets via the default-interface fallback.
|
|
43
|
+
const server = new TCPServerInterface({ port });
|
|
44
|
+
await server.connect();
|
|
45
|
+
const spawned = new Promise((resolve) => {
|
|
46
|
+
server.addEventListener(
|
|
47
|
+
"connection",
|
|
48
|
+
(/** @type {any} */ event) => {
|
|
49
|
+
rnsA.addInterface(event.detail, true);
|
|
50
|
+
resolve();
|
|
51
|
+
},
|
|
52
|
+
{ once: true },
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
const client = new TCPClientInterface({ host: HOST, port });
|
|
56
|
+
await client.connect();
|
|
57
|
+
rnsB.addInterface(client, true);
|
|
58
|
+
await spawned;
|
|
59
|
+
return {
|
|
60
|
+
rnsA,
|
|
61
|
+
rnsB,
|
|
62
|
+
async close() {
|
|
63
|
+
await client.disconnect().catch(() => {});
|
|
64
|
+
await server.disconnect().catch(() => {});
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Polls `cond()` every 50ms until true, rejecting after `timeoutMs`. */
|
|
70
|
+
export function waitFor(cond, timeoutMs) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const deadline = Date.now() + timeoutMs;
|
|
73
|
+
const tick = () => {
|
|
74
|
+
if (cond()) return resolve(undefined);
|
|
75
|
+
if (Date.now() >= deadline) return reject(new Error("waitFor timed out"));
|
|
76
|
+
setTimeout(tick, 50);
|
|
77
|
+
};
|
|
78
|
+
tick();
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Triggers one immediate re-announce from each provider's room destination.
|
|
84
|
+
*
|
|
85
|
+
* The periodic re-announce cadence is owned by @reticulum/core and clamped to
|
|
86
|
+
* the §9.7 60 s floor, so smoketests can't lean on a sub-minute tick for
|
|
87
|
+
* mutual discovery. Firing one explicit announce from each side once everyone
|
|
88
|
+
* is connected makes mesh-up fast and deterministic — covering the
|
|
89
|
+
* hash-ordering case where the second-connecting peer missed the first's
|
|
90
|
+
* immediate announce (its listener wasn't registered yet) and would otherwise
|
|
91
|
+
* have to wait for the next periodic tick.
|
|
92
|
+
*
|
|
93
|
+
* @param {...import("../src/provider.js").ReticulumProvider} providers
|
|
94
|
+
* @returns {Promise<void>}
|
|
95
|
+
*/
|
|
96
|
+
export async function nudgeAnnounce(...providers) {
|
|
97
|
+
for (const p of providers) {
|
|
98
|
+
await p.room?.dest?.announce();
|
|
99
|
+
}
|
|
100
|
+
}
|