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,204 @@
1
+ /**
2
+ * @file peer-conn.js
3
+ * @description Wrapper around a Reticulum {@link Link} to a single Yjs peer.
4
+ *
5
+ * Owns link lifecycle and the low-level send/receive of raw framed bytes. It
6
+ * carries no Yjs semantics of its own: the Room plugs the sync/awareness
7
+ * protocol into the bytes that flow through here.
8
+ *
9
+ * Small payloads travel as reliable {@link Channel} messages: the channel adds
10
+ * automatic retries, send-window flow control, and in-order / dedup'd delivery
11
+ * over the link, so a sync update or awareness change dropped on a lossy hop is
12
+ * retransmitted rather than lost. Payloads larger than the channel MDU (an
13
+ * initial doc state or a large update) cannot fit in a single channel message
14
+ * and are instead transported as a chunked, integrity-checked, bz2-compressed
15
+ * Reticulum {@link Resource}, reassembled before delivery.
16
+ */
17
+ import {
18
+ CEType,
19
+ ChannelException,
20
+ MessageBase,
21
+ Resource,
22
+ toHex,
23
+ } from "@reticulum/core";
24
+
25
+ /**
26
+ * Application message type used on every y-reticulum {@link Channel}. Its body
27
+ * is a single raw Yjs wire frame (the y-webrtc tag + lib0 payload), carried
28
+ * verbatim — the channel envelope adds the framing Reticulum needs for
29
+ * reliability, ordering, and flow control, so nothing here touches the Yjs
30
+ * bytes.
31
+ *
32
+ * @extends {MessageBase}
33
+ */
34
+ class YjsSyncMessage extends MessageBase {
35
+ /** Unique y-reticulum message type on the channel (< 0xf000). */
36
+ static MSGTYPE = 0x0001;
37
+
38
+ constructor() {
39
+ super();
40
+ /** @type {Uint8Array} */
41
+ this.data = new Uint8Array(0);
42
+ }
43
+
44
+ /** @returns {Uint8Array} */
45
+ pack() {
46
+ return this.data;
47
+ }
48
+
49
+ /** @param {Uint8Array} raw */
50
+ unpack(raw) {
51
+ this.data = raw;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * A peer-to-peer connection over a Reticulum Link.
57
+ *
58
+ * Exactly one PeerConn exists per established Link. `send` writes a raw byte
59
+ * payload — as a reliable Channel message when it fits the channel MDU, else as
60
+ * a compressed Resource. Inbound payloads arrive via the channel's message
61
+ * handler (small) or the link's `resource` event (large) and are forwarded to
62
+ * the room. The peer id is the hex link_id — identical on both ends of a link,
63
+ * so both peers agree on the id.
64
+ */
65
+ export class PeerConn {
66
+ /**
67
+ * @param {object} options
68
+ * @param {import("@reticulum/core").Link} options.link
69
+ * @param {Uint8Array|null} options.remoteDestHash
70
+ * The peer's destination hash. Known on the initiator side (from the
71
+ * announce that triggered the link); `null` on the responder side.
72
+ * @param {import("@digitaldefiance/bzip2-wasm").default | null} [options.bz2]
73
+ * Shared bzip2 provider; set on the link so inbound Resources can be
74
+ * decompressed, and used to compress outbound ones. `null` disables it.
75
+ * @param {(payload: Uint8Array, peer: PeerConn) => void} options.onData
76
+ * @param {(peer: PeerConn) => void} options.onClose
77
+ */
78
+ constructor({ link, remoteDestHash, bz2, onData, onClose }) {
79
+ this.link = link;
80
+ // The receiver decompresses via the link's bz2 (Resource.accept reads it).
81
+ this.link.bz2 = bz2 ?? undefined;
82
+ this.remoteDestHash = remoteDestHash;
83
+ /** @type {import("@digitaldefiance/bzip2-wasm").default | undefined} */
84
+ this.bz2 = bz2 ?? undefined;
85
+ /** Reliable typed-message channel over the link (retries + flow control). */
86
+ this.channel = link.getChannel();
87
+ this.channel.registerMessageType(YjsSyncMessage);
88
+ this._onChannelMessage = this._onChannelMessage.bind(this);
89
+ this.channel.addMessageHandler(this._onChannelMessage);
90
+ /** Hex link_id; used as the peer id (symmetric across both ends). */
91
+ this.peerId = toHex(link.linkId);
92
+ /** Whether the Yjs doc is synced with this peer. */
93
+ this.synced = false;
94
+ this.closed = false;
95
+ this._onData = onData;
96
+ this._onClose = onClose;
97
+
98
+ // Large, chunked payloads: reassemble, then feed the same path.
99
+ link.addEventListener("resource", (/** @type {any} */ event) => {
100
+ const resource = event.detail.resource;
101
+ resource
102
+ .whenComplete()
103
+ .then(() => {
104
+ if (resource.data) this._onData(resource.data, this);
105
+ })
106
+ .catch(() => {
107
+ // Transfer failed (peer dropped, corrupt, rejected) — nothing to
108
+ // deliver; the room's sync will converge on the next exchange.
109
+ });
110
+ });
111
+ link.addEventListener("close", () => this._handleClose());
112
+ }
113
+
114
+ /**
115
+ * Inbound Yjs channel message: forward the raw body to the room. Returns
116
+ * `true` to claim the message (no other handlers are registered).
117
+ * @param {import("@reticulum/core").MessageBase} msg
118
+ * @returns {boolean}
119
+ */
120
+ _onChannelMessage(msg) {
121
+ if (!(msg instanceof YjsSyncMessage)) return false;
122
+ this._onData(msg.data, this);
123
+ return true;
124
+ }
125
+
126
+ /**
127
+ * Sends a raw byte payload to the peer. Small payloads go as a reliable
128
+ * Channel message (waiting for the send window if it is momentarily full);
129
+ * payloads larger than the channel MDU travel as a compressed Resource.
130
+ * @param {Uint8Array} payload
131
+ */
132
+ async send(payload) {
133
+ if (this.closed) return;
134
+ if (payload.length > this.channel.mdu) {
135
+ await this._sendResource(payload);
136
+ return;
137
+ }
138
+ await this._sendChannel(payload);
139
+ }
140
+
141
+ /**
142
+ * Sends `payload` as a reliable Channel message. Waits while the send window
143
+ * is full (backpressure) and re-arms if the window fills between the readiness
144
+ * check and the serialized send — matching the library's buffer-layer loop.
145
+ * @param {Uint8Array} payload
146
+ */
147
+ async _sendChannel(payload) {
148
+ const message = new YjsSyncMessage();
149
+ message.data = payload;
150
+ for (;;) {
151
+ if (this.closed || this.channel._shutDown) return;
152
+ while (!this.channel.isReadyToSend()) {
153
+ if (this.closed || this.channel._shutDown) return;
154
+ await new Promise((r) => setTimeout(r, 50));
155
+ }
156
+ try {
157
+ await this.channel.send(message);
158
+ return;
159
+ } catch (err) {
160
+ if (
161
+ err instanceof ChannelException &&
162
+ err.type === CEType.ME_LINK_NOT_READY
163
+ ) {
164
+ continue; // window filled between the check and the serialized send
165
+ }
166
+ throw err;
167
+ }
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Transfers `payload` as a chunked Reticulum Resource, bz2-compressed when
173
+ * that shrinks it. Only the advertisement is awaited; the chunked transfer
174
+ * then proceeds on the link and the receiver reassembles (and decompresses)
175
+ * it before delivery.
176
+ * @param {Uint8Array} payload
177
+ */
178
+ async _sendResource(payload) {
179
+ const resource = new Resource({
180
+ data: payload,
181
+ link: this.link,
182
+ bz2: this.bz2,
183
+ autoCompress: true,
184
+ });
185
+ await resource.advertise();
186
+ }
187
+
188
+ /**
189
+ * Silently tears down the link (does not invoke `onClose` — the caller is
190
+ * responsible for bookkeeping, e.g. a bulk disconnect).
191
+ */
192
+ destroy() {
193
+ if (this.closed) return;
194
+ this.closed = true;
195
+ this.link.teardown().catch(() => {});
196
+ }
197
+
198
+ /** Internal: a `close` event arrived from the link (peer dropped / timeout). */
199
+ _handleClose() {
200
+ if (this.closed) return;
201
+ this.closed = true;
202
+ this._onClose(this);
203
+ }
204
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * @file provider.js
3
+ * @description Reticulum provider for Yjs.
4
+ *
5
+ * Wraps a {@link Y.Doc} and synchronizes it with peers discovered over the
6
+ * Reticulum mesh. Each provider owns (or borrows) a {@link Reticulum} instance
7
+ * and a {@link Room} that announces a destination derived from the room name
8
+ * and maintains pairwise Links to peers.
9
+ *
10
+ * Phase 2 (this file) implements the connection lifecycle and peer mesh:
11
+ * announcing, discovery and `peers` events. Yjs sync/awareness over those Links
12
+ * lands in Phase 3.
13
+ */
14
+
15
+ import { Identity } from "@reticulum/core";
16
+ import { ObservableV2 } from "lib0/observable";
17
+ import * as awarenessProtocol from "y-protocols/awareness";
18
+ import * as Y from "yjs";
19
+ import { roomDestinationName } from "./destination.js";
20
+ import { Room } from "./room.js";
21
+
22
+ /**
23
+ * Options accepted by {@link ReticulumProvider}.
24
+ *
25
+ * @typedef {Object} ProviderOptions
26
+ * @property {import("@reticulum/core").Reticulum} reticulum
27
+ * A configured Reticulum instance with at least one (default) interface
28
+ * attached. The provider does not open interfaces itself.
29
+ * @property {import("@reticulum/core").Identity} [identity]
30
+ * Identity for this peer's room destination. Generated (non-persistent) if
31
+ * omitted; supply your own to keep a stable address across restarts.
32
+ * @property {awarenessProtocol.Awareness} [awareness]
33
+ * Reuse an existing Awareness instance. A fresh one is created when omitted.
34
+ * @property {number} [maxConns]
35
+ * Upper bound on simultaneous peer Links. Mirrors y-webrtc's `maxConns`.
36
+ * @property {number} [announceIntervalMs]
37
+ * Cadence (ms) at which the room destination is re-announced for discovery.
38
+ * Forwarded to `Destination.startAnnouncing`, which clamps it to the
39
+ * §9.7 60 s floor (sub-minute intervals trigger ingress rate limiting).
40
+ */
41
+
42
+ /**
43
+ * Events emitted by {@link ReticulumProvider}. Mirrors the y-webrtc event
44
+ * surface so consumers can switch providers with minimal changes.
45
+ *
46
+ * @typedef {Object} ReticulumProviderEvents
47
+ * @property {(event: { connected: boolean }) => void} status
48
+ * Fired when the provider (dis)connects from the mesh.
49
+ * @property {(event: { synced: boolean }) => void} synced
50
+ * Fired when sync state with the peer mesh changes. (Phase 3.)
51
+ * @property {(event: { added: Array<string>, removed: Array<string> }) => void} peers
52
+ * Fired when peers are discovered or drop off.
53
+ */
54
+
55
+ /**
56
+ * Reticulum provider for Yjs.
57
+ *
58
+ * @extends {ObservableV2<ReticulumProviderEvents>}
59
+ */
60
+ export class ReticulumProvider extends ObservableV2 {
61
+ /**
62
+ * @param {string} roomName
63
+ * @param {Y.Doc} doc
64
+ * @param {ProviderOptions} opts
65
+ */
66
+ constructor(roomName, doc, opts) {
67
+ super();
68
+ if (!opts || !opts.reticulum) {
69
+ throw new Error("ReticulumProvider requires a `reticulum` instance.");
70
+ }
71
+ this.roomName = roomName;
72
+ this.doc = doc;
73
+ this.reticulum = opts.reticulum;
74
+ /** @type {awarenessProtocol.Awareness} */
75
+ this.awareness = opts.awareness ?? new awarenessProtocol.Awareness(doc);
76
+ this.maxConns = opts.maxConns ?? 20;
77
+ this.announceIntervalMs = opts.announceIntervalMs ?? 60_000;
78
+
79
+ /** Resolved with the room destination's identity on connect(). */
80
+ this.identityPromise = opts.identity
81
+ ? Promise.resolve(opts.identity)
82
+ : Identity.generate();
83
+ /** @type {Identity|null} */
84
+ this.identity = opts.identity ?? null;
85
+
86
+ /** @type {Room|null} */
87
+ this.room = null;
88
+ this.shouldConnect = false;
89
+ }
90
+
91
+ /**
92
+ * Whether the provider is announcing and accepting peer Links. Does not imply
93
+ * that any peer is reachable; only that we are looking.
94
+ *
95
+ * @type {boolean}
96
+ */
97
+ get connected() {
98
+ return this.room !== null && this.shouldConnect;
99
+ }
100
+
101
+ /** Begin announcing and maintaining the peer mesh. */
102
+ async connect() {
103
+ if (this.shouldConnect) return;
104
+ this.shouldConnect = true;
105
+ this.identity ??= await this.identityPromise;
106
+
107
+ const appName = await roomDestinationName(this.roomName);
108
+ this.room = new Room({
109
+ doc: this.doc,
110
+ awareness: this.awareness,
111
+ reticulum: this.reticulum,
112
+ identity: /** @type {Identity} */ (this.identity),
113
+ appName,
114
+ maxConns: this.maxConns,
115
+ announceIntervalMs: this.announceIntervalMs,
116
+ callbacks: {
117
+ onPeers: (
118
+ /** @type {string[]} */ added,
119
+ /** @type {string[]} */ removed,
120
+ ) => this.emit("peers", [{ added, removed }]),
121
+ onSynced: (/** @type {boolean} */ synced) =>
122
+ this.emit("synced", [{ synced }]),
123
+ },
124
+ });
125
+ await this.room.connect();
126
+ this.emit("status", [{ connected: true }]);
127
+ }
128
+
129
+ /** Stop announcing, tear down all peer Links, and release the destination. */
130
+ async disconnect() {
131
+ if (!this.shouldConnect) return;
132
+ this.shouldConnect = false;
133
+ if (this.room) {
134
+ await this.room.disconnect();
135
+ this.room = null;
136
+ }
137
+ this.emit("status", [{ connected: false }]);
138
+ }
139
+
140
+ /** Permanently release all resources. */
141
+ async destroy() {
142
+ await this.disconnect();
143
+ super.destroy();
144
+ }
145
+ }