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.
@@ -0,0 +1,137 @@
1
+ /**
2
+ * @file sync.smoke.js
3
+ * @description Phase 3 smoketest — Yjs doc and awareness actually sync between
4
+ * two providers over the Reticulum mesh.
5
+ *
6
+ * Two providers connect to the same room, each waits to be marked `synced`,
7
+ * then: an edit made on provider A's Doc appears on provider B's Doc, and a
8
+ * local awareness state set on A appears in B's awareness map. This exercises
9
+ * the full sync handshake (syncStep1/2) plus the doc-update and awareness
10
+ * broadcast paths.
11
+ */
12
+
13
+ import assert from "node:assert/strict";
14
+ import net from "node:net";
15
+ import test from "node:test";
16
+ import { Identity, Reticulum } from "@reticulum/core";
17
+ import { TCPClientInterface, TCPServerInterface } from "@reticulum/node";
18
+ import * as Y from "yjs";
19
+ import { ReticulumProvider } from "../src/index.js";
20
+ import { nudgeAnnounce } from "./loopback.js";
21
+
22
+ const ROOM = "y-reticulum-sync-smoke";
23
+ const HOST = "127.0.0.1";
24
+
25
+ /** Resolves with a free localhost TCP port (ephemeral, immediately released). */
26
+ function getFreePort() {
27
+ return new Promise((resolve, reject) => {
28
+ const probe = net.createServer();
29
+ probe.unref();
30
+ probe.on("error", reject);
31
+ probe.listen({ host: HOST, port: 0 }, () => {
32
+ const { port } = /** @type {net.AddressInfo} */ (probe.address());
33
+ probe.close(() => resolve(port));
34
+ });
35
+ });
36
+ }
37
+
38
+ /** Two in-process Reticulum instances wired over a TCP loopback (A listens, B dials). */
39
+ async function makeLoopback() {
40
+ const port = await getFreePort();
41
+ const rnsA = new Reticulum();
42
+ const rnsB = new Reticulum();
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
+ 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
+ test("Doc and awareness sync between two providers", {
83
+ timeout: 15000,
84
+ }, async () => {
85
+ const { rnsA, rnsB, close } = await makeLoopback();
86
+ const docA = new Y.Doc();
87
+ const docB = new Y.Doc();
88
+ const idA = await Identity.generate();
89
+ const idB = await Identity.generate();
90
+
91
+ const providerA = new ReticulumProvider(ROOM, docA, {
92
+ reticulum: rnsA,
93
+ identity: idA,
94
+ });
95
+ const providerB = new ReticulumProvider(ROOM, docB, {
96
+ reticulum: rnsB,
97
+ identity: idB,
98
+ });
99
+
100
+ /** @type {boolean} */ let aSynced = false;
101
+ /** @type {boolean} */ let bSynced = false;
102
+ providerA.on("synced", (/** @type {any} */ e) => {
103
+ if (e.synced) aSynced = true;
104
+ });
105
+ providerB.on("synced", (/** @type {any} */ e) => {
106
+ if (e.synced) bSynced = true;
107
+ });
108
+
109
+ await providerA.connect();
110
+ await providerB.connect();
111
+
112
+ // The periodic re-announce cadence is now @reticulum/core's job (and clamped
113
+ // to ≥60 s), so nudge one explicit announce per side to mesh up fast.
114
+ await nudgeAnnounce(providerA, providerB);
115
+
116
+ // Both sides complete the syncStep1/2 handshake.
117
+ await waitFor(() => aSynced && bSynced, 10000);
118
+
119
+ // --- Doc sync: an edit on A appears on B ----------------------------
120
+ docA.getMap("doc").set("hello", "world");
121
+ await waitFor(() => docB.getMap("doc").get("hello") === "world", 5000);
122
+ assert.equal(docB.getMap("doc").get("hello"), "world");
123
+
124
+ // --- Awareness sync: a local state set on A appears on B ------------
125
+ providerA.awareness.setLocalState({ user: "alice" });
126
+ await waitFor(() => {
127
+ const s = providerB.awareness.getStates().get(docA.clientID);
128
+ return s != null && s.user === "alice";
129
+ }, 5000);
130
+ assert.deepEqual(providerB.awareness.getStates().get(docA.clientID), {
131
+ user: "alice",
132
+ });
133
+
134
+ await providerA.destroy();
135
+ await providerB.destroy();
136
+ await close();
137
+ });
@@ -0,0 +1,197 @@
1
+ /**
2
+ * @file transport.smoke.js
3
+ * @description Phase 1 smoketest — validates the Reticulum transport foundation
4
+ * before any Yjs sync semantics land.
5
+ *
6
+ * Spins up two in-process Reticulum instances wired together over a TCP
7
+ * loopback (instance A listens, instance B dials), derives the *same* room
8
+ * destination name on each, has both announce, discovers the peer via the
9
+ * transport `announce` event (filtered by room `nameHash`), establishes a
10
+ * Link, and exchanges raw bytes both ways (ping → pong).
11
+ *
12
+ * If this passes, discovery + Link transport work; Phase 3 can build the Yjs
13
+ * sync protocol on top with confidence.
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import net from "node:net";
18
+ import test from "node:test";
19
+ import {
20
+ ContextType,
21
+ Destination,
22
+ DestType,
23
+ Identity,
24
+ Packet,
25
+ PacketType,
26
+ Reticulum,
27
+ } from "@reticulum/core";
28
+ import { TCPClientInterface, TCPServerInterface } from "@reticulum/node";
29
+ import { roomDestinationName } from "../src/destination.js";
30
+
31
+ const ROOM_NAME = "y-reticulum-transport-smoke";
32
+ const HOST = "127.0.0.1";
33
+
34
+ /** Resolves with a free localhost TCP port (ephemeral, immediately released). */
35
+ function getFreePort() {
36
+ return new Promise((resolve, reject) => {
37
+ const probe = net.createServer();
38
+ probe.unref();
39
+ probe.on("error", reject);
40
+ probe.listen({ host: HOST, port: 0 }, () => {
41
+ const { port } = /** @type {net.AddressInfo} */ (probe.address());
42
+ probe.close(() => resolve(port));
43
+ });
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Constant-time-ish equality for two Uint8Arrays of equal length.
49
+ * @param {Uint8Array} a
50
+ * @param {Uint8Array} b
51
+ */
52
+ function bytesEqual(a, b) {
53
+ if (a.length !== b.length) return false;
54
+ let diff = 0;
55
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
56
+ return diff === 0;
57
+ }
58
+
59
+ /**
60
+ * Resolves with the first validated announce seen on `rns.transport` whose
61
+ * `nameHash` matches `roomNameHash` (i.e. a peer in the same room).
62
+ *
63
+ * @param {Reticulum} rns
64
+ * @param {Uint8Array} roomNameHash
65
+ * @returns {Promise<{ destinationHash: Uint8Array, identity: Identity }>}
66
+ */
67
+ function waitForPeer(rns, roomNameHash) {
68
+ return new Promise((resolve) => {
69
+ const listener = (/** @type {any} */ event) => {
70
+ const detail = event.detail;
71
+ if (
72
+ bytesEqual(/** @type {Uint8Array} */ (detail.nameHash), roomNameHash)
73
+ ) {
74
+ rns.transport.removeEventListener("announce", listener);
75
+ resolve({
76
+ destinationHash: detail.destinationHash,
77
+ identity: detail.identity,
78
+ });
79
+ }
80
+ };
81
+ rns.transport.addEventListener("announce", listener);
82
+ });
83
+ }
84
+
85
+ /** Builds a Link DATA packet carrying `payload` (ContextType.NONE). */
86
+ function linkDataPacket(linkId, payload) {
87
+ return new Packet({
88
+ packetType: PacketType.DATA,
89
+ destinationType: DestType.LINK,
90
+ destinationHash: linkId,
91
+ contextByte: ContextType.NONE,
92
+ payload,
93
+ });
94
+ }
95
+
96
+ const encoder = new TextEncoder();
97
+ const decoder = new TextDecoder();
98
+
99
+ test("two peers discover each other and exchange bytes over a Link", {
100
+ timeout: 15000,
101
+ }, async () => {
102
+ // --- 1. TCP loopback: A listens, B dials -----------------------------
103
+ const port = await getFreePort();
104
+
105
+ const rnsA = new Reticulum();
106
+ const rnsB = new Reticulum();
107
+
108
+ // A listens. The TCPServerInterface itself has no writable stream (it only
109
+ // spawns a fresh TCPClientInterface per accepted connection), so it must NOT
110
+ // be passed to addInterface — only its spawned children get wired into the
111
+ // transport, which we do in the "connection" handler below.
112
+ const server = new TCPServerInterface({ port });
113
+ await server.connect();
114
+ const spawned = new Promise((resolve) => {
115
+ server.addEventListener(
116
+ "connection",
117
+ (/** @type {any} */ event) => {
118
+ // Mark default so this leaf node can emit link/handshake packets
119
+ // (e.g. the responder's LRPROOF) via the default-interface fallback.
120
+ rnsA.addInterface(event.detail, true);
121
+ resolve(event.detail);
122
+ },
123
+ { once: true },
124
+ );
125
+ });
126
+
127
+ const client = new TCPClientInterface({ host: HOST, port });
128
+ await client.connect();
129
+ rnsB.addInterface(client, true);
130
+ await spawned; // ensure the server-side interface is wired before traffic
131
+
132
+ try {
133
+ // --- 2. Room destinations (same aspect → same nameHash) ------------
134
+ const appName = await roomDestinationName(ROOM_NAME);
135
+
136
+ const idA = await Identity.generate();
137
+ idA.setAppData("y-reticulum A");
138
+ const destA = await Destination.IN(appName, DestType.SINGLE, idA, rnsA);
139
+ rnsA.transport.bindLocalDestination(destA);
140
+
141
+ const idB = await Identity.generate();
142
+ idB.setAppData("y-reticulum B");
143
+ const destB = await Destination.IN(appName, DestType.SINGLE, idB, rnsB);
144
+ rnsB.transport.bindLocalDestination(destB);
145
+
146
+ // --- 3. Discover each other before announcing ----------------------
147
+ const aSeesB = waitForPeer(rnsA, destB.nameHash);
148
+ const bSeesA = waitForPeer(rnsB, destA.nameHash);
149
+
150
+ await destA.announce();
151
+ await destB.announce();
152
+
153
+ const peerAOnB = await bSeesA;
154
+ const peerBOnA = await aSeesB;
155
+ assert.deepEqual(peerAOnB.destinationHash, destA.destinationHash);
156
+ assert.deepEqual(peerBOnA.destinationHash, destB.destinationHash);
157
+
158
+ // --- 4. A accepts links and echoes inbound data --------------------
159
+ destA.addEventListener("link_request", async (/** @type {any} */ event) => {
160
+ const link = await destA.acceptLink(event.detail.packet);
161
+ link.addEventListener("data", (/** @type {any} */ dataEvent) => {
162
+ link.send(
163
+ linkDataPacket(
164
+ link.linkId,
165
+ encoder.encode(
166
+ `pong:${decoder.decode(dataEvent.detail.packet.payload)}`,
167
+ ),
168
+ ),
169
+ );
170
+ });
171
+ });
172
+
173
+ // --- 5. B opens a Link to A and ping/pongs -------------------------
174
+ const outDest = await Destination.OUT(
175
+ appName,
176
+ DestType.SINGLE,
177
+ peerAOnB.identity,
178
+ rnsB,
179
+ );
180
+ const link = await outDest.createLink();
181
+
182
+ const pong = new Promise((resolve) => {
183
+ link.addEventListener(
184
+ "data",
185
+ (/** @type {any} */ event) =>
186
+ resolve(decoder.decode(event.detail.packet.payload)),
187
+ { once: true },
188
+ );
189
+ });
190
+ await link.send(linkDataPacket(link.linkId, encoder.encode("ping")));
191
+
192
+ assert.equal(await pong, "pong:ping");
193
+ } finally {
194
+ await client.disconnect().catch(() => {});
195
+ await server.disconnect().catch(() => {});
196
+ }
197
+ });