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/peer-conn.js
ADDED
|
@@ -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
|
+
}
|
package/src/provider.js
ADDED
|
@@ -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
|
+
}
|