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/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Reticulum connector for [Yjs](https://github.com/yjs/yjs)
|
|
2
|
+
|
|
3
|
+
Propagates document updates over [Reticulum](https://reticulum.network/) mesh network.
|
|
4
|
+
|
|
5
|
+
* Public key encryption and authorization using [Reticulum Identities](https://reticulum.network/manual/zen.html#identity-and-nomadism)
|
|
6
|
+
* Flexible network topology and multiple interfaces ranging from TCP to LoRa and HF radio links
|
|
7
|
+
* Very little setup needed with Reticulum announce and discovery mechanisms
|
|
8
|
+
* Sync and awareness traffic rides a reliable, in-order, windowed Link Channel
|
|
9
|
+
(retransmitted on lossy hops) for performant CRDT synchronization
|
|
10
|
+
* Larger CRDT updates are automatically transported as bz2 compressed Resources
|
|
11
|
+
|
|
12
|
+
Built on [reticulum-js](https://reticulum.js.org/) with aim to support browsers, Node.js, and Deno. For browsers, please read the [browser connectivity](https://reticulum.js.org/documents/Browser_Connectivity.html) notes.
|
|
13
|
+
|
|
14
|
+
## Status
|
|
15
|
+
|
|
16
|
+
Just getting started
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm i y-reticulum
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
Clients connected to the same room name share document updates. In addition to
|
|
27
|
+
a `Y.Doc`, you pass a configured [@reticulum/core](https://reticulum.js.org/)
|
|
28
|
+
instance — the provider does not open network interfaces itself.
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
import * as Y from "yjs"
|
|
32
|
+
import { Identity, Reticulum } from "@reticulum/core"
|
|
33
|
+
import { TCPClientInterface } from "@reticulum/node"
|
|
34
|
+
import { ReticulumProvider } from "y-reticulum"
|
|
35
|
+
|
|
36
|
+
// 1. Connect to the Reticulum mesh. Prefer the local shared instance (e.g. a
|
|
37
|
+
// running `rnsd`); fall back to a direct TCP interface when there is none.
|
|
38
|
+
const rns = new Reticulum()
|
|
39
|
+
const shared = await rns.connectToSharedInstance()
|
|
40
|
+
if (!shared) {
|
|
41
|
+
const tcp = new TCPClientInterface({ host: "127.0.0.1", port: 42424 })
|
|
42
|
+
await tcp.connect()
|
|
43
|
+
rns.addInterface(tcp, true)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 2. An identity for this peer (persist it between runs in real apps so your
|
|
47
|
+
// Reticulum address stays stable).
|
|
48
|
+
const identity = await Identity.generate()
|
|
49
|
+
|
|
50
|
+
// 3. Create the Yjs document and the provider.
|
|
51
|
+
const ydoc = new Y.Doc()
|
|
52
|
+
const provider = new ReticulumProvider("your-room-name", ydoc, {
|
|
53
|
+
reticulum: rns,
|
|
54
|
+
identity,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
provider.on("status", ({ connected }) => console.log("connected:", connected))
|
|
58
|
+
provider.on("synced", ({ synced }) => console.log("synced:", synced))
|
|
59
|
+
provider.on("peers", ({ added, removed }) =>
|
|
60
|
+
console.log("peers added:", added, "removed:", removed),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
await provider.connect()
|
|
64
|
+
|
|
65
|
+
const yarray = ydoc.getArray("array")
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## API
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
new ReticulumProvider(roomName, ydoc[, opts])
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`opts` accepts the following (all optional except `reticulum`):
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
{
|
|
78
|
+
// A configured Reticulum instance with at least one (default) interface
|
|
79
|
+
// attached. Required — the provider does not open interfaces itself.
|
|
80
|
+
reticulum,
|
|
81
|
+
// Identity for this peer's room destination. Generated (non-persistent) if
|
|
82
|
+
// omitted; supply your own to keep a stable address across restarts.
|
|
83
|
+
identity,
|
|
84
|
+
// Reuse an existing Awareness instance - see https://github.com/yjs/y-protocols
|
|
85
|
+
awareness: new awarenessProtocol.Awareness(ydoc),
|
|
86
|
+
// Upper bound on simultaneous peer Links. Mirrors y-webrtc's `maxConns`.
|
|
87
|
+
maxConns: 20,
|
|
88
|
+
// Cadence (ms) at which the room destination is re-announced for discovery.
|
|
89
|
+
// Delegated to @reticulum/core's Destination.startAnnouncing, which clamps
|
|
90
|
+
// to the 60s floor from the Reticulum spec (sub-minute intervals trigger
|
|
91
|
+
// ingress rate limiting).
|
|
92
|
+
announceIntervalMs: 60_000,
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The provider extends `ObservableV2` and emits:
|
|
97
|
+
|
|
98
|
+
| Event | Payload | When |
|
|
99
|
+
| --- | --- | --- |
|
|
100
|
+
| `status` | `{ connected: boolean }` | the provider (dis)connects from the mesh |
|
|
101
|
+
| `synced` | `{ synced: boolean }` | sync state with the peer mesh changes |
|
|
102
|
+
| `peers` | `{ added: string[], removed: string[] }` | peers are discovered or drop off |
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
Licensed under the [EUPL 1.2](https://interoperable-europe.ec.europa.eu/collection/eupl/eupl-text-eupl-12).
|
package/SPEC.md
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# y-reticulum — Reticulum provider for Yjs
|
|
2
|
+
|
|
3
|
+
A Yjs provider that synchronizes documents over the [Reticulum Network System
|
|
4
|
+
(RNS)](https://reticulum.network/) mesh. The goal is to reach feature parity
|
|
5
|
+
with the [y-webrtc](https://github.com/yjs/y-webrtc) provider, substituting
|
|
6
|
+
Reticulum's transport and discovery primitives for WebRTC + signaling servers.
|
|
7
|
+
|
|
8
|
+
## Goals
|
|
9
|
+
|
|
10
|
+
- Synchronize `Y.Doc` state and `Awareness` between peers over Reticulum.
|
|
11
|
+
- Work anywhere `reticulum-js` runs (Node.js, Deno, browsers).
|
|
12
|
+
- Provide an API and event surface familiar to anyone who has used
|
|
13
|
+
`WebrtcProvider` (`status`, `synced`, `peers`).
|
|
14
|
+
- Be roughly on the same feature level as y-webrtc.
|
|
15
|
+
|
|
16
|
+
## Non-goals (for now)
|
|
17
|
+
|
|
18
|
+
- Acting as a Reticulum *transport* (routing) node. We are a leaf node, same as
|
|
19
|
+
the rest of `reticulum-js`.
|
|
20
|
+
- E2E encryption of the room beyond what Reticulum's Links already provide.
|
|
21
|
+
- A `BroadcastChannel` same-origin/tab shortcut. Reticulum is the single
|
|
22
|
+
transport.
|
|
23
|
+
|
|
24
|
+
## Architecture
|
|
25
|
+
|
|
26
|
+
### How y-webrtc works (reference)
|
|
27
|
+
|
|
28
|
+
- A `WebrtcProvider` wraps a `Y.Doc` and opens a `Room` (one per room name).
|
|
29
|
+
- Peer discovery happens through one or more **signaling servers** (WebSocket):
|
|
30
|
+
clients `announce` their `peerId` and relay WebRTC `offer`/`answer`/`signal`
|
|
31
|
+
messages through the server.
|
|
32
|
+
- Each pair of peers opens a **WebRTC data channel** (`simple-peer`).
|
|
33
|
+
- Doc/awareness updates are encoded with `lib0` and framed with a 1-byte
|
|
34
|
+
message-type tag, then sent over every peer channel.
|
|
35
|
+
- An optional `BroadcastChannel` path shortcuts same-origin tabs.
|
|
36
|
+
|
|
37
|
+
The message wire protocol (reused verbatim here):
|
|
38
|
+
|
|
39
|
+
| Tag | Meaning |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `0` | sync (carries `messageYjsSyncStep1` / `Step2` / `update`) |
|
|
42
|
+
| `1` | awareness update |
|
|
43
|
+
| `3` | query awareness |
|
|
44
|
+
| `4` | broadcastchannel peer-id add/remove — **not needed** (no BC) |
|
|
45
|
+
|
|
46
|
+
### Reticulum primitives we build on
|
|
47
|
+
|
|
48
|
+
- **`Destination`** (IN/OUT, SINGLE/GROUP/PLAIN) + **`Announce`** for
|
|
49
|
+
authenticated, signed peer discovery. Each peer's announce carries its public
|
|
50
|
+
identity, which others recall to open Links.
|
|
51
|
+
- **`Link`** — an ephemeral, encrypted channel between two destinations,
|
|
52
|
+
established via a `LINKREQUEST`/`LRPROOF` handshake. The base for everything
|
|
53
|
+
below.
|
|
54
|
+
- **`Channel`** — a reliable, in-order, windowed typed-message layer over a
|
|
55
|
+
Link (`link.getChannel()`). Adds automatic retries (retransmit on a missing
|
|
56
|
+
proof), send-window flow control, and dedup — so a sync update or awareness
|
|
57
|
+
change dropped on a lossy hop is retransmitted rather than lost. Carries the
|
|
58
|
+
bulk of Yjs traffic.
|
|
59
|
+
- **`Resource`** — chunked, hash-verified large-payload transport over a Link,
|
|
60
|
+
with automatic bz2 compression. Used for oversized sync payloads (initial
|
|
61
|
+
state, big `syncStep2`) that exceed the channel MDU.
|
|
62
|
+
- **`request()`/`response()`** RPC built on Links — *not* used directly; we run
|
|
63
|
+
our own framing so the message-type tag matches y-webrtc's semantics.
|
|
64
|
+
|
|
65
|
+
### Concept mapping
|
|
66
|
+
|
|
67
|
+
| y-webrtc | y-reticulum |
|
|
68
|
+
|---|---|
|
|
69
|
+
| Signaling server `announce`/`publish` | Reticulum `Announce` to a deterministic destination |
|
|
70
|
+
| WebRTC data channel | `Link` + `Channel` |
|
|
71
|
+
| Raw peer `.send(bytes)` | reliable `Channel` message (small) — `ContextType.CHANNEL` DATA packets with retries + send window |
|
|
72
|
+
| Large peer payloads (none) | `Resource` (bz2-compressed) |
|
|
73
|
+
| BroadcastChannel (same-origin) | not applicable |
|
|
74
|
+
| `WebrtcProvider` (`ObservableV2`: `status`/`synced`/`peers`) | `ReticulumProvider` with the same events |
|
|
75
|
+
| Message tags 0/1/3 | reused verbatim |
|
|
76
|
+
|
|
77
|
+
### Discovery model (chosen: deterministic destination per room)
|
|
78
|
+
|
|
79
|
+
Each peer creates a **`SINGLE` IN destination** whose full app name is derived
|
|
80
|
+
from the room name, e.g.:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
y-reticulum.sync.<hex(hash(roomName))>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The peer announces this destination. Other peers running the same room name
|
|
87
|
+
learn the announcer's identity from the announce and open a `Link` to it.
|
|
88
|
+
Because every peer both announces and listens, the topology is a full mesh of
|
|
89
|
+
pairwise Links (bounded by `maxConns`, same as y-webrtc).
|
|
90
|
+
|
|
91
|
+
> A pure group-destination broadcast (one destination, no Links) was considered
|
|
92
|
+
> and rejected: it loses Link-level reliability/encryption/ordering and makes
|
|
93
|
+
> `peers`/`maxConns` semantics awkward. We keep the "announce → connect to peers
|
|
94
|
+
> individually" model that mirrors y-webrtc.
|
|
95
|
+
|
|
96
|
+
### Room identity and the destination hash
|
|
97
|
+
|
|
98
|
+
- The room name is hashed to form the destination aspect so that two peers that
|
|
99
|
+
type the same room name arrive at the same destination namespace without
|
|
100
|
+
leaking the cleartext room name in announce app data.
|
|
101
|
+
- Each peer generates (and persists, when a storage adapter is available) its
|
|
102
|
+
own `Identity`. The announce carries a small `app_data` blob identifying this
|
|
103
|
+
peer (peer id + provider version) for diagnostics.
|
|
104
|
+
|
|
105
|
+
### Wire protocol on a Link
|
|
106
|
+
|
|
107
|
+
Identical framing to y-webrtc, minus tag `4`:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
<1-byte tag><payload encoded with lib0>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
- tag `0` sync → `syncProtocol.readSyncMessage` / `writeSyncStep1/2` / `writeUpdate`
|
|
114
|
+
- tag `1` awareness → `awarenessProtocol.encodeAwarenessUpdate` / `applyAwarenessUpdate`
|
|
115
|
+
- tag `3` query awareness → reply with tag `1`
|
|
116
|
+
|
|
117
|
+
Small messages travel as reliable `Channel` messages (a `MessageBase` whose
|
|
118
|
+
body is the raw y-webrtc frame), giving automatic retries, in-order delivery,
|
|
119
|
+
and send-window flow control over the Link. Messages exceeding the channel MDU
|
|
120
|
+
(link MDU minus the 6-byte channel envelope) cannot fit in a single channel
|
|
121
|
+
message and are transported via a `Resource` (bz2-compressed), reassembled on
|
|
122
|
+
the receiver before being handed to the same `readMessage` path.
|
|
123
|
+
|
|
124
|
+
## Public API (target)
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
import * as Y from "yjs";
|
|
128
|
+
import { ReticulumProvider } from "y-reticulum";
|
|
129
|
+
|
|
130
|
+
const doc = new Y.Doc();
|
|
131
|
+
const provider = new ReticulumProvider("my-room", doc, {
|
|
132
|
+
identity, // optional; generated/persisted if omitted
|
|
133
|
+
reticulum, // optional pre-configured Reticulum instance
|
|
134
|
+
awareness, // optional; created if omitted
|
|
135
|
+
maxConns, // optional; default 20-ish like y-webrtc
|
|
136
|
+
announceInterval,// optional
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
provider.on("status", ({ connected }) => { /* ... */ });
|
|
140
|
+
provider.on("synced", ({ synced }) => { /* ... */ });
|
|
141
|
+
provider.on("peers", ({ added, removed }) => { /* ... */ });
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Methods mirror y-webrtc: `connect()`, `disconnect()`, `destroy()`.
|
|
145
|
+
|
|
146
|
+
## Project layout
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
src/
|
|
150
|
+
index.js # public exports
|
|
151
|
+
provider.js # ReticulumProvider
|
|
152
|
+
room.js # Room abstraction (announces, tracks peer Links)
|
|
153
|
+
peer-conn.js # one peer-to-peer Link wrapper
|
|
154
|
+
messages.js # message tags + readMessage/broadcast helpers
|
|
155
|
+
destination.js # room-name → deterministic destination name helpers
|
|
156
|
+
test/
|
|
157
|
+
*.smoke.js # smoketests per layer
|
|
158
|
+
examples/ # demo clients (later)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Type safety
|
|
162
|
+
|
|
163
|
+
All source is plain JavaScript (`.js`) with **JSDoc type annotations verified by
|
|
164
|
+
the TypeScript checker** — no hand-written `.ts` source files. This matches the
|
|
165
|
+
conventions of both `y-webrtc` and `reticulum-js`.
|
|
166
|
+
|
|
167
|
+
- `tsconfig.json` enables `allowJs: true` and `checkJs: true` (plus
|
|
168
|
+
`declaration` / `emitDeclarationOnly: true` so a `.d.ts` bundle is produced).
|
|
169
|
+
- Every function, method, and constructor gets `@param {Type} name` /
|
|
170
|
+
`@returns {Type}` annotations; module-level `@typedef`s describe option objects
|
|
171
|
+
and event payloads; `@import` (or `import` in `@type`) references types from
|
|
172
|
+
`yjs`, `y-protocols`, and `@reticulum/core`.
|
|
173
|
+
- `npm run types` (`tsc`) **must pass after every change** — this is enforced by
|
|
174
|
+
`AGENTS.md`. Treat type errors as build failures, not warnings.
|
|
175
|
+
- `lib0` types (`encoding.Encoder`, `decoding.Decoder`, `observable.ObservableV2`,
|
|
176
|
+
etc.) are referenced the same way `y-webrtc` references them. Note `lib0` is a
|
|
177
|
+
transitive dependency of `y-protocols`; if it is not resolvable it must be added
|
|
178
|
+
as a direct dependency (ask first, per `AGENTS.md`).
|
|
179
|
+
- Emitted `.d.ts` files are excluded from version control (already in
|
|
180
|
+
`.gitignore`).
|
|
181
|
+
|
|
182
|
+
## Implementation phases
|
|
183
|
+
|
|
184
|
+
### Phase 0 — Scaffolding
|
|
185
|
+
- Add `tsconfig.json` (`allowJs` + `checkJs` + `declaration` +
|
|
186
|
+
`emitDeclarationOnly`) so `npm run types` passes against `src/`.
|
|
187
|
+
- Empty, fully JSDoc-annotated `src/index.js` re-exporting the (upcoming)
|
|
188
|
+
provider.
|
|
189
|
+
- Confirm `npm run types` and `npm run format` are green.
|
|
190
|
+
|
|
191
|
+
### Phase 1 — Transport smoketest (foundation)
|
|
192
|
+
- A smoketest that spins up two in-process `Reticulum` instances (loopback
|
|
193
|
+
interface), derives the same room destination name on each, announces, and
|
|
194
|
+
establishes a `Link`, then exchanges raw bytes both ways.
|
|
195
|
+
- Validates discovery + Link transport before sync semantics land.
|
|
196
|
+
|
|
197
|
+
### Phase 2 — Provider skeleton
|
|
198
|
+
- `ReticulumProvider` constructor wiring (`Y.Doc`, `Awareness`, identity,
|
|
199
|
+
Reticulum connect), `connect()`/`disconnect()`, `status` events.
|
|
200
|
+
- `Room` that announces and listens for announces/links; `peers` emission.
|
|
201
|
+
- No Yjs sync yet — just connection lifecycle.
|
|
202
|
+
|
|
203
|
+
### Phase 3 — Sync protocol layer
|
|
204
|
+
- `readMessage` / broadcast over peer Links.
|
|
205
|
+
- Doc `update` handler → broadcast sync `update`.
|
|
206
|
+
- Awareness update/query handlers.
|
|
207
|
+
- `synced` tracking across peers (mirror `checkIsSynced`).
|
|
208
|
+
- Smoketest: two providers, one edits, the other observes the change.
|
|
209
|
+
|
|
210
|
+
### Phase 4 — Resource-backed large transfers
|
|
211
|
+
- Route oversized payloads through `Resource`; reassemble and feed into the
|
|
212
|
+
same `readMessage` path.
|
|
213
|
+
- Smoketest: large initial-doc sync.
|
|
214
|
+
|
|
215
|
+
### Phase 5 — Hardening & parity
|
|
216
|
+
- `maxConns` enforcement, reconnection, keepalive/timeout behavior, clean
|
|
217
|
+
teardown (`destroy`), graceful announce removal on disconnect.
|
|
218
|
+
- Parity checklist against y-webrtc features.
|
|
219
|
+
|
|
220
|
+
## Open questions
|
|
221
|
+
|
|
222
|
+
- Default announce cadence: now delegated to `@reticulum/core`'s
|
|
223
|
+
`Destination.startAnnouncing`, which enforces the §9.7 60 s floor; y-reticulum
|
|
224
|
+
defaults to that floor and forwards any user override (clamped). Open:
|
|
225
|
+
whether to send a path request up front to accelerate first-peer discovery
|
|
226
|
+
on a fresh mesh.
|
|
227
|
+
- Whether/how to expose the configured Reticulum interfaces (auto vs. explicit
|
|
228
|
+
TCP/WebSocket) or always prefer `connectToSharedInstance()` with a fallback.
|
|
229
|
+
- `maxConns` semantics: do we cap total Links, or per-room Links?
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "y-reticulum",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reticulum provider for Yjs",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"reticulum",
|
|
7
|
+
"rns",
|
|
8
|
+
"yjs"
|
|
9
|
+
],
|
|
10
|
+
"homepage": "https://github.com/bergie/y-reticulum#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/bergie/y-reticulum/issues"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git://github.com/bergie/y-reticulum.git"
|
|
17
|
+
},
|
|
18
|
+
"license": "EUPL-1.2",
|
|
19
|
+
"author": "Henri Bergius <henri.bergius@iki.fi>",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"main": "src/index.js",
|
|
22
|
+
"scripts": {
|
|
23
|
+
"lint": "npx @biomejs/biome check --use-editorconfig=true src/",
|
|
24
|
+
"format": "npx @biomejs/biome check --use-editorconfig=true --write src/ test/",
|
|
25
|
+
"types": "npx tsc",
|
|
26
|
+
"test": "node --test --test-force-exit --test-timeout 5000 test/*.js test/**/*.js",
|
|
27
|
+
"test:deno": "deno test --no-check --allow-net --allow-env --allow-read=node_modules test/*.js",
|
|
28
|
+
"test:bun": "bun test"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@digitaldefiance/bzip2-wasm": "^1.1.1",
|
|
32
|
+
"@reticulum/core": "^0.5.0",
|
|
33
|
+
"@reticulum/node": "^0.5.0",
|
|
34
|
+
"lib0": "^0.2.117",
|
|
35
|
+
"y-protocols": "^1.0.7"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "^26.1.2",
|
|
39
|
+
"typescript": "^6.0.3",
|
|
40
|
+
"yjs": "^13.6.31"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file compression.js
|
|
3
|
+
* @description Shared bzip2 provider for compressing Reticulum Resources.
|
|
4
|
+
*
|
|
5
|
+
* `@digitaldefiance/bzip2-wasm` is a hard dependency of y-reticulum, so both
|
|
6
|
+
* peers can always compress/decompress large sync payloads (an initial doc
|
|
7
|
+
* state or a big update). The WASM module needs a one-time async `init()`; this
|
|
8
|
+
* module exposes a shared, lazily-initialized instance. If init ever fails we
|
|
9
|
+
* resolve to `null` and sync transparently falls back to uncompressed Resources.
|
|
10
|
+
*/
|
|
11
|
+
import BZip2 from "@digitaldefiance/bzip2-wasm";
|
|
12
|
+
|
|
13
|
+
/** @type {Promise<import("@digitaldefiance/bzip2-wasm").default | null> | null} */
|
|
14
|
+
let initPromise = null;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Returns a shared, initialized BZip2 instance, or `null` if the WASM module
|
|
18
|
+
* failed to load. Safe to call repeatedly — initialization runs only once.
|
|
19
|
+
*
|
|
20
|
+
* @returns {Promise<import("@digitaldefiance/bzip2-wasm").default | null>}
|
|
21
|
+
*/
|
|
22
|
+
export function getCompressionProvider() {
|
|
23
|
+
if (!initPromise) {
|
|
24
|
+
initPromise = (async () => {
|
|
25
|
+
try {
|
|
26
|
+
const bz2 = new BZip2();
|
|
27
|
+
await bz2.init();
|
|
28
|
+
return bz2;
|
|
29
|
+
} catch {
|
|
30
|
+
// WASM unavailable / failed to load — Resources will go uncompressed.
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
})();
|
|
34
|
+
}
|
|
35
|
+
return initPromise;
|
|
36
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file destination.js
|
|
3
|
+
* @description Helpers mapping a Yjs room name to a Reticulum destination.
|
|
4
|
+
*
|
|
5
|
+
* Two peers that pass the same room name must arrive at the same Reticulum
|
|
6
|
+
* "aspect" so they can discover each other via the Announce mechanism. We hash
|
|
7
|
+
* the room name into the aspect so the cleartext name is not leaked on the wire,
|
|
8
|
+
* and so the resulting 10-byte `nameHash` doubles as the room-membership filter
|
|
9
|
+
* when comparing inbound announces (see SPEC.md → Discovery model).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* App-name prefix shared by every y-reticulum sync destination. The trailing
|
|
14
|
+
* segment is a hex digest of the room name (see {@link roomDestinationName}).
|
|
15
|
+
*/
|
|
16
|
+
export const DESTINATION_APP_PREFIX = "y-reticulum.sync";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Derives the deterministic Reticulum destination app-name for a Yjs room.
|
|
20
|
+
*
|
|
21
|
+
* The room name is hashed (first 8 bytes of its SHA-256, rendered as 16 hex
|
|
22
|
+
* chars) so the on-wire aspect does not leak the cleartext room name. Two peers
|
|
23
|
+
* that pass the same `roomName` arrive at the same app-name — and therefore the
|
|
24
|
+
* same 10-byte `nameHash` — which is exactly what room peer-discovery filters on
|
|
25
|
+
* when comparing inbound announces.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} roomName
|
|
28
|
+
* @returns {Promise<string>} app-name like `y-reticulum.sync.<16 hex chars>`
|
|
29
|
+
*/
|
|
30
|
+
export async function roomDestinationName(roomName) {
|
|
31
|
+
const digest = await crypto.subtle.digest(
|
|
32
|
+
"SHA-256",
|
|
33
|
+
new TextEncoder().encode(roomName),
|
|
34
|
+
);
|
|
35
|
+
const bytes = new Uint8Array(digest);
|
|
36
|
+
let hex = "";
|
|
37
|
+
for (let i = 0; i < 8; i++) {
|
|
38
|
+
hex += bytes[i].toString(16).padStart(2, "0");
|
|
39
|
+
}
|
|
40
|
+
return `${DESTINATION_APP_PREFIX}.${hex}`;
|
|
41
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file index.js
|
|
3
|
+
* @description Public entry point for the `y-reticulum` package.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { getCompressionProvider } from "./compression.js";
|
|
7
|
+
export { roomDestinationName } from "./destination.js";
|
|
8
|
+
export { messageAwareness, messageSync, readMessage } from "./messages.js";
|
|
9
|
+
export { PeerConn } from "./peer-conn.js";
|
|
10
|
+
export { ReticulumProvider } from "./provider.js";
|
|
11
|
+
export { Room } from "./room.js";
|
package/src/messages.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file messages.js
|
|
3
|
+
* @description The Yjs sync wire protocol used over a peer Link.
|
|
4
|
+
*
|
|
5
|
+
* This is y-webrtc's framing, minus its BroadcastChannel peer-id message
|
|
6
|
+
* (tag 4) which has no Reticulum equivalent. Each message is a 1-byte tag
|
|
7
|
+
* followed by a lib0-encoded body:
|
|
8
|
+
*
|
|
9
|
+
* 0 sync — carries syncStep1 / syncStep2 / update (y-protocols/sync)
|
|
10
|
+
* 1 awareness — an awareness update (y-protocols/awareness)
|
|
11
|
+
* 3 queryAwareness — request the peer's full awareness state
|
|
12
|
+
*
|
|
13
|
+
* Bytes flow through {@link PeerConn}; this module only knows how to decode
|
|
14
|
+
* them and apply them to a Doc / Awareness.
|
|
15
|
+
*/
|
|
16
|
+
import * as decoding from "lib0/decoding";
|
|
17
|
+
import * as encoding from "lib0/encoding";
|
|
18
|
+
import * as awarenessProtocol from "y-protocols/awareness";
|
|
19
|
+
import * as syncProtocol from "y-protocols/sync";
|
|
20
|
+
|
|
21
|
+
/** @type {0} */
|
|
22
|
+
export const messageSync = 0;
|
|
23
|
+
/** @type {1} */
|
|
24
|
+
export const messageAwareness = 1;
|
|
25
|
+
/** @type {3} */
|
|
26
|
+
export const messageQueryAwareness = 3;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Decodes one inbound framed message, applying it to the doc / awareness, and
|
|
30
|
+
* returns the bytes of a reply to send back to the same peer (or `null`).
|
|
31
|
+
*
|
|
32
|
+
* Mirrors y-webrtc's `readMessage`: a `syncStep1` requests our state and so
|
|
33
|
+
* produces a `syncStep2` reply; a `syncStep2` delivers the peer's state and
|
|
34
|
+
* marks the room synced (once, via `onSynced`); `queryAwareness` produces an
|
|
35
|
+
* awareness reply.
|
|
36
|
+
*
|
|
37
|
+
* @param {import("yjs").Doc} doc
|
|
38
|
+
* @param {awarenessProtocol.Awareness} awareness
|
|
39
|
+
* @param {Uint8Array} buf
|
|
40
|
+
* @param {any} origin - transactionOrigin for any updates this applies.
|
|
41
|
+
* @param {boolean} roomSynced - whether the room is already synced (gates the
|
|
42
|
+
* one-shot `onSynced` callback, matching y-webrtc).
|
|
43
|
+
* @param {() => void} onSynced - invoked once when a syncStep2 first arrives.
|
|
44
|
+
* @returns {Uint8Array | null} reply bytes, or `null` when no reply is needed.
|
|
45
|
+
*/
|
|
46
|
+
export function readMessage(doc, awareness, buf, origin, roomSynced, onSynced) {
|
|
47
|
+
const decoder = decoding.createDecoder(buf);
|
|
48
|
+
const encoder = encoding.createEncoder();
|
|
49
|
+
const messageType = decoding.readVarUint(decoder);
|
|
50
|
+
let sendReply = false;
|
|
51
|
+
switch (messageType) {
|
|
52
|
+
case messageSync: {
|
|
53
|
+
encoding.writeVarUint(encoder, messageSync);
|
|
54
|
+
const syncMessageType = syncProtocol.readSyncMessage(
|
|
55
|
+
decoder,
|
|
56
|
+
encoder,
|
|
57
|
+
doc,
|
|
58
|
+
origin,
|
|
59
|
+
);
|
|
60
|
+
if (syncMessageType === syncProtocol.messageYjsSyncStep2 && !roomSynced) {
|
|
61
|
+
onSynced();
|
|
62
|
+
}
|
|
63
|
+
if (syncMessageType === syncProtocol.messageYjsSyncStep1) {
|
|
64
|
+
sendReply = true;
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
case messageQueryAwareness:
|
|
69
|
+
encoding.writeVarUint(encoder, messageAwareness);
|
|
70
|
+
encoding.writeVarUint8Array(
|
|
71
|
+
encoder,
|
|
72
|
+
awarenessProtocol.encodeAwarenessUpdate(
|
|
73
|
+
awareness,
|
|
74
|
+
Array.from(awareness.getStates().keys()),
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
sendReply = true;
|
|
78
|
+
break;
|
|
79
|
+
case messageAwareness:
|
|
80
|
+
awarenessProtocol.applyAwarenessUpdate(
|
|
81
|
+
awareness,
|
|
82
|
+
decoding.readVarUint8Array(decoder),
|
|
83
|
+
origin,
|
|
84
|
+
);
|
|
85
|
+
break;
|
|
86
|
+
default:
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return sendReply ? encoding.toUint8Array(encoder) : null;
|
|
90
|
+
}
|