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/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
+ });
@@ -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
+ }