yrb-lite-reliable 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 ADDED
@@ -0,0 +1,90 @@
1
+ # yrb-lite-reliable
2
+
3
+ The **client core** for the [`yrb-lite`](https://github.com/jpcamara/yrb-lite)
4
+ y-websocket protocol — everything a Yjs provider needs *except the transport*.
5
+ Bring your own socket (ActionCable, AnyCable, raw WebSocket); this owns the
6
+ protocol.
7
+
8
+ Two layers, use whichever you need:
9
+
10
+ - **`SyncEngine`** — batteries-included. Binds to a `Y.Doc` (+ optional
11
+ `Awareness`) and owns the y-protocols **message encode/decode**, the
12
+ **sync-step handshake** (SyncStep1 / SyncStep2 / Update), **awareness**, and
13
+ reliable delivery. Speaks raw `Uint8Array` frames; you only wire the socket.
14
+ - **`ReliableSync`** — the zero-dependency reliable-delivery state machine on its
15
+ own: ack-tracked queue, **sync-since-last-ack** (the unacked tail merged into
16
+ one causally-complete delta), cumulative acks, retransmit + "server doesn't
17
+ support acks" fallback, and reconnect replay. Compose it yourself if you
18
+ already have your own framing.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ npm install yrb-lite-reliable
24
+ ```
25
+
26
+ `SyncEngine` needs `yjs` and `y-protocols` (peers — your provider already has
27
+ them). `ReliableSync` has **no dependencies**; import it on its own via
28
+ `yrb-lite-reliable/reliable` if that's all you want.
29
+
30
+ ## SyncEngine
31
+
32
+ ```js
33
+ import { SyncEngine, toBase64, fromBase64 } from "yrb-lite-reliable";
34
+ import * as Y from "yjs";
35
+ import { Awareness } from "y-protocols/awareness";
36
+
37
+ const doc = new Y.Doc();
38
+ const awareness = new Awareness(doc);
39
+
40
+ const engine = new SyncEngine(doc, {
41
+ awareness,
42
+ // transmit one raw frame; `id` is set for reliable doc updates -> tag your envelope
43
+ send: (frame, id) => {
44
+ const payload = { update: toBase64(frame) };
45
+ if (id !== undefined) payload.id = id;
46
+ subscription.send(payload);
47
+ },
48
+ });
49
+
50
+ // wire your transport's callbacks:
51
+ subscription.connected = () => engine.onConnect(); // handshake + replay
52
+ subscription.disconnected = () => engine.onDisconnect(); // pause + clear presence
53
+ subscription.received = (msg) => {
54
+ if (msg.ack !== undefined) return engine.ack(msg.ack); // reliable ack envelope
55
+ const reply = engine.receive(fromBase64(msg.update || msg.m)); // decode + apply
56
+ if (reply) subscription.send({ update: toBase64(reply) }); // e.g. answer a SyncStep1
57
+ };
58
+ // engine.synced -> caught up; engine.hasPending -> unacked edits in flight
59
+ // engine.destroy() -> detach listeners + stop retransmits
60
+ ```
61
+
62
+ Local document edits and awareness changes are picked up automatically from the
63
+ doc's / awareness's `update` events — you never call anything for outbound edits.
64
+
65
+ ## ReliableSync (standalone)
66
+
67
+ ```js
68
+ import { ReliableSync } from "yrb-lite-reliable/reliable"; // zero-dep
69
+ import * as Y from "yjs";
70
+
71
+ const rs = new ReliableSync({
72
+ send: (update, id) => { /* frame + transmit */ },
73
+ merge: Y.mergeUpdates,
74
+ });
75
+
76
+ rs.enqueue(update); // a local document update
77
+ rs.onAck(id); // an { ack: id } arrived
78
+ rs.onConnect(); // (re)connected — replay the tail, resume retransmits
79
+ rs.onDisconnect(); // dropped — keep the queue, pause
80
+ ```
81
+
82
+ ## How it fits
83
+
84
+ The server counterpart — ack *generation*, gap detection, record-before-distribute
85
+ — is the `yrb-lite-actioncable` gem's `YrbLite::ActionCable::Sync`. This package
86
+ is the client half of the same protocol.
87
+
88
+ ## License
89
+
90
+ MIT
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "yrb-lite-reliable",
3
+ "version": "0.1.0",
4
+ "description": "Client core for the yrb-lite y-websocket protocol: sync steps, message encode/decode, awareness, and reliable delivery (ack-tracked queue, sync-since-last-ack, retransmit + reconnect replay). Bring your own transport.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "module": "src/index.js",
8
+ "exports": {
9
+ ".": "./src/index.js",
10
+ "./reliable": "./src/reliable_sync.js",
11
+ "./base64": "./src/base64.js"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --test"
19
+ },
20
+ "license": "MIT",
21
+ "author": "JP Camara <jp@jpcamara.com>",
22
+ "homepage": "https://github.com/jpcamara/yrb-lite/tree/main/packages/yrb-lite-reliable#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/jpcamara/yrb-lite/issues"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/jpcamara/yrb-lite.git",
29
+ "directory": "packages/yrb-lite-reliable"
30
+ },
31
+ "keywords": [
32
+ "yjs",
33
+ "crdt",
34
+ "yrb-lite",
35
+ "reliable-delivery",
36
+ "actioncable",
37
+ "y-websocket"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "peerDependencies": {
43
+ "yjs": "^13.6.0",
44
+ "y-protocols": "^1.0.5"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "yjs": {
48
+ "optional": true
49
+ },
50
+ "y-protocols": {
51
+ "optional": true
52
+ }
53
+ },
54
+ "devDependencies": {
55
+ "yjs": "^13.6.0",
56
+ "y-protocols": "^1.0.5"
57
+ }
58
+ }
package/src/base64.js ADDED
@@ -0,0 +1,9 @@
1
+ // Convenience codecs for transports that carry binary frames as base64 strings
2
+ // (e.g. ActionCable's JSON envelope). Optional -- a binary WebSocket transport
3
+ // sends the raw frames directly and never needs these.
4
+
5
+ /** @param {Uint8Array} bytes */
6
+ export const toBase64 = (bytes) => btoa(Array.from(bytes, (b) => String.fromCharCode(b)).join(""));
7
+
8
+ /** @param {string} str */
9
+ export const fromBase64 = (str) => Uint8Array.from(atob(str), (c) => c.charCodeAt(0));
package/src/index.js ADDED
@@ -0,0 +1,9 @@
1
+ // Zero-dependency reliable-delivery core. Safe to import on its own.
2
+ export { ReliableSync } from "./reliable_sync.js";
3
+
4
+ // Batteries-included protocol client (sync steps + encode/decode + awareness).
5
+ // Requires `yjs` and `y-protocols` as peers.
6
+ export { SyncEngine, MessageType } from "./sync_engine.js";
7
+
8
+ // Optional base64 helpers for transports that carry frames as strings.
9
+ export { toBase64, fromBase64 } from "./base64.js";
@@ -0,0 +1,155 @@
1
+ // Transport-agnostic reliable-delivery core for the yrb-lite y-websocket
2
+ // protocol. This owns the "nuances" a provider would otherwise re-implement:
3
+ // an ack-tracked queue of unacknowledged local updates, "sync since last ack"
4
+ // (the unacked tail is sent as one MERGED, causally-complete delta so the server
5
+ // never sees an internal gap), cumulative acks, periodic retransmit with a
6
+ // "server doesn't support acks" fallback, and reconnect replay.
7
+ //
8
+ // It does NOT touch any transport, Yjs binding, or wire encoding. You inject:
9
+ // - send(update, id): transmit one update. `update` is the raw merged update
10
+ // bytes; `id` is the cumulative sequence (or undefined,
11
+ // post-fallback). Frame + base64 + put it on your socket.
12
+ // - merge(updates): merge an array of update byte-arrays into one
13
+ // (typically Y.mergeUpdates from yjs).
14
+ // and you drive it from your provider's lifecycle:
15
+ // - enqueue(update) on every local document update (not server echoes)
16
+ // - onAck(id) when an { ack: id } frame arrives
17
+ // - onConnect()/onDisconnect() on transport (re)connect / drop
18
+ //
19
+ // Awareness/presence is intentionally out of scope -- it stays fire-and-forget
20
+ // in the provider.
21
+
22
+ const DEFAULTS = { resendInterval: 1000, maxUnconfirmedResends: 8 };
23
+
24
+ export class ReliableSync {
25
+ /**
26
+ * @param {object} opts
27
+ * @param {(update: Uint8Array, id: number|undefined) => void} opts.send
28
+ * @param {(updates: Uint8Array[]) => Uint8Array} opts.merge
29
+ * @param {number} [opts.resendInterval=1000] ms between retransmits
30
+ * @param {number} [opts.maxUnconfirmedResends=8] resends with no ack before
31
+ * deciding the server doesn't support reliable delivery and falling back
32
+ * @param {() => void} [opts.onFallback] called once if that fallback trips
33
+ * @param {(fn: () => void, ms: number) => any} [opts.setInterval]
34
+ * @param {(handle: any) => void} [opts.clearInterval]
35
+ */
36
+ constructor({
37
+ send,
38
+ merge,
39
+ resendInterval = DEFAULTS.resendInterval,
40
+ maxUnconfirmedResends = DEFAULTS.maxUnconfirmedResends,
41
+ onFallback,
42
+ setInterval: setIntervalFn,
43
+ clearInterval: clearIntervalFn,
44
+ } = {}) {
45
+ if (typeof send !== "function") throw new TypeError("ReliableSync requires a send(update, id) function");
46
+ if (typeof merge !== "function") throw new TypeError("ReliableSync requires a merge(updates) function");
47
+
48
+ this._send = send;
49
+ this._merge = merge;
50
+ this.resendInterval = resendInterval;
51
+ this.maxUnconfirmedResends = maxUnconfirmedResends;
52
+ this._onFallback = onFallback;
53
+ // Injectable timer hooks make the resend loop testable; default to globals.
54
+ this._setInterval = setIntervalFn || ((fn, ms) => setInterval(fn, ms));
55
+ this._clearInterval = clearIntervalFn || ((h) => clearInterval(h));
56
+
57
+ this.reliable = true; // flips false after the no-ack fallback
58
+ this.pending = []; // unacked local updates: [{ seq, update }], in order
59
+ this.nextSeq = 1;
60
+ this.everAcked = false;
61
+ this._resendsSinceProgress = 0;
62
+ this._connected = false;
63
+ this._timer = undefined;
64
+ }
65
+
66
+ /** True while there are unacknowledged local updates. */
67
+ get hasPending() {
68
+ return this.pending.length > 0;
69
+ }
70
+
71
+ /**
72
+ * Record a local document update. While reliable, it's queued and the unacked
73
+ * tail is flushed; once we've fallen back, it's sent fire-and-forget.
74
+ * @param {Uint8Array} update
75
+ */
76
+ enqueue(update) {
77
+ if (!this.reliable) {
78
+ this._send(update, undefined);
79
+ return;
80
+ }
81
+ this.pending.push({ seq: this.nextSeq++, update });
82
+ this.flush();
83
+ }
84
+
85
+ /**
86
+ * Send the whole unacked tail as one merged delta. The id is the highest seq
87
+ * in the batch, so a single { ack } cumulatively confirms everything up to it.
88
+ * No-op while disconnected (the tail is replayed on the next onConnect).
89
+ */
90
+ flush() {
91
+ if (!this._connected || this.pending.length === 0) return;
92
+ const updates = this.pending.map((p) => p.update);
93
+ const merged = updates.length === 1 ? updates[0] : this._merge(updates);
94
+ const id = this.pending[this.pending.length - 1].seq;
95
+ this._send(merged, id);
96
+ }
97
+
98
+ /**
99
+ * Confirm delivery up to `id`: prune every queued update with seq <= id.
100
+ * @param {number} id
101
+ */
102
+ onAck(id) {
103
+ this.everAcked = true;
104
+ this._resendsSinceProgress = 0;
105
+ this.pending = this.pending.filter((p) => p.seq > id);
106
+ }
107
+
108
+ /** Transport (re)connected: replay the unacked tail and resume retransmits. */
109
+ onConnect() {
110
+ this._connected = true;
111
+ this.flush();
112
+ this._startTimer();
113
+ }
114
+
115
+ /** Transport dropped: keep the queue (for reconnect replay), pause the timer. */
116
+ onDisconnect() {
117
+ this._connected = false;
118
+ this._stopTimer();
119
+ }
120
+
121
+ /**
122
+ * One retransmit tick. Exposed for deterministic testing; normally driven by
123
+ * the internal timer. If we keep resending on a live connection and never get
124
+ * an ack, the server doesn't support reliable delivery, so fall back to
125
+ * fire-and-forget (and stop tracking, since idempotent CRDT sync covers it).
126
+ */
127
+ onTick() {
128
+ if (!this._connected || this.pending.length === 0) return;
129
+ if (!this.everAcked && ++this._resendsSinceProgress > this.maxUnconfirmedResends) {
130
+ this.reliable = false;
131
+ this.pending = [];
132
+ this._stopTimer();
133
+ this._onFallback?.();
134
+ return;
135
+ }
136
+ this.flush();
137
+ }
138
+
139
+ /** Stop timers and drop references. Call when the provider is destroyed. */
140
+ destroy() {
141
+ this._stopTimer();
142
+ this.pending = [];
143
+ }
144
+
145
+ _startTimer() {
146
+ if (this._timer || !this.reliable) return;
147
+ this._timer = this._setInterval(() => this.onTick(), this.resendInterval);
148
+ if (this._timer && typeof this._timer.unref === "function") this._timer.unref();
149
+ }
150
+
151
+ _stopTimer() {
152
+ if (this._timer !== undefined) this._clearInterval(this._timer);
153
+ this._timer = undefined;
154
+ }
155
+ }
@@ -0,0 +1,187 @@
1
+ // A batteries-included client core for the yrb-lite y-websocket protocol.
2
+ //
3
+ // SyncEngine composes ReliableSync and additionally owns the parts a provider
4
+ // would otherwise re-implement: the y-protocols message framing (encode/decode),
5
+ // the sync-step handshake (SyncStep1 / SyncStep2 / Update), and awareness
6
+ // encode/apply. It binds to a Y.Doc (and optional Awareness) and speaks in raw
7
+ // Uint8Array frames -- you bring only the transport: base64 + the
8
+ // `{ update, id }` / `{ ack }` envelope and the socket.
9
+ //
10
+ // Drive it from your transport:
11
+ // onConnect() -> sends the opening handshake, replays the unacked tail
12
+ // onDisconnect() -> pauses retransmits, clears remote presence
13
+ // ack(id) -> a `{ ack: id }` envelope arrived
14
+ // const reply = receive(frame) -> a binary protocol frame arrived; send `reply` if non-null
15
+ // Local document edits and awareness changes are picked up automatically via the
16
+ // doc's / awareness's "update" events.
17
+ import * as Y from "yjs";
18
+ import * as encoding from "lib0/encoding";
19
+ import * as decoding from "lib0/decoding";
20
+ import { readSyncMessage, writeSyncStep1, writeUpdate, messageYjsSyncStep2 } from "y-protocols/sync";
21
+ import { encodeAwarenessUpdate, applyAwarenessUpdate, removeAwarenessStates } from "y-protocols/awareness";
22
+ import { readAuthMessage } from "y-protocols/auth";
23
+ import { ReliableSync } from "./reliable_sync.js";
24
+
25
+ export const MessageType = { Sync: 0, Awareness: 1, Auth: 2, QueryAwareness: 3 };
26
+
27
+ export class SyncEngine {
28
+ /**
29
+ * @param {Y.Doc} doc
30
+ * @param {object} opts
31
+ * @param {(frame: Uint8Array, id: number|undefined) => void} opts.send
32
+ * transmit one raw protocol frame; `id` is set only for reliable document
33
+ * updates (tag it onto your envelope so the server can ack).
34
+ * @param {import("y-protocols/awareness").Awareness} [opts.awareness]
35
+ * @param {boolean} [opts.reliable=true] use ack-tracked reliable delivery
36
+ * @param {number} [opts.resendInterval] forwarded to ReliableSync
37
+ * @param {number} [opts.maxUnconfirmedResends] forwarded to ReliableSync
38
+ * @param {() => void} [opts.onFallback] forwarded to ReliableSync
39
+ */
40
+ constructor(
41
+ doc,
42
+ {
43
+ send,
44
+ awareness = null,
45
+ reliable = true,
46
+ resendInterval,
47
+ maxUnconfirmedResends,
48
+ onFallback,
49
+ setInterval: setIntervalFn,
50
+ clearInterval: clearIntervalFn,
51
+ } = {}
52
+ ) {
53
+ if (!doc) throw new TypeError("SyncEngine requires a Y.Doc");
54
+ if (typeof send !== "function") throw new TypeError("SyncEngine requires a send(frame, id) function");
55
+
56
+ this.doc = doc;
57
+ this.awareness = awareness;
58
+ this.reliable = reliable;
59
+ this._send = send;
60
+ this._synced = false;
61
+
62
+ this._delivery = new ReliableSync({
63
+ merge: Y.mergeUpdates,
64
+ send: (update, id) => this._send(this._frameUpdate(update), id),
65
+ resendInterval,
66
+ maxUnconfirmedResends,
67
+ onFallback,
68
+ setInterval: setIntervalFn,
69
+ clearInterval: clearIntervalFn,
70
+ });
71
+
72
+ this._onDocUpdate = (update, origin) => {
73
+ if (origin === this) return; // applied from the server; don't echo it back
74
+ if (this.reliable && this._delivery.reliable) this._delivery.enqueue(update);
75
+ else this._send(this._frameUpdate(update), undefined);
76
+ };
77
+ this.doc.on("update", this._onDocUpdate);
78
+
79
+ if (this.awareness) {
80
+ this._onAwarenessUpdate = ({ added, updated, removed }) => {
81
+ const changed = added.concat(updated, removed);
82
+ this._send(this._frameAwareness(changed), undefined); // presence: fire-and-forget
83
+ };
84
+ this.awareness.on("update", this._onAwarenessUpdate);
85
+ }
86
+ }
87
+
88
+ /** True once we've received the server's SyncStep2 (the document is caught up). */
89
+ get synced() {
90
+ return this._synced;
91
+ }
92
+
93
+ /** True while there are unacknowledged local document updates in flight. */
94
+ get hasPending() {
95
+ return this._delivery.hasPending;
96
+ }
97
+
98
+ /** Transport connected: send the opening handshake and replay the unacked tail. */
99
+ onConnect() {
100
+ this._send(this._frameSyncStep1(), undefined);
101
+ if (this.awareness && this.awareness.getLocalState() !== null) {
102
+ this._send(this._frameAwareness([this.doc.clientID]), undefined);
103
+ }
104
+ if (this.reliable) this._delivery.onConnect();
105
+ }
106
+
107
+ /** Transport dropped: pause retransmits (queue kept) and clear remote presence. */
108
+ onDisconnect() {
109
+ this._synced = false;
110
+ this._delivery.onDisconnect();
111
+ if (this.awareness) {
112
+ const remote = [...this.awareness.getStates().keys()].filter((c) => c !== this.doc.clientID);
113
+ if (remote.length) removeAwarenessStates(this.awareness, remote, this);
114
+ }
115
+ }
116
+
117
+ /** A reliable-delivery `{ ack: id }` envelope arrived. */
118
+ ack(id) {
119
+ this._delivery.onAck(id);
120
+ }
121
+
122
+ /**
123
+ * Decode and apply one incoming binary protocol frame (document sync, awareness,
124
+ * query, or auth). Returns a reply frame to transmit (e.g. SyncStep2 answering a
125
+ * SyncStep1, or an awareness reply to a query), or null if there's nothing to send.
126
+ * @param {Uint8Array} frame
127
+ * @returns {Uint8Array|null}
128
+ */
129
+ receive(frame) {
130
+ const decoder = decoding.createDecoder(frame);
131
+ const encoder = encoding.createEncoder();
132
+ const type = decoding.readVarUint(decoder);
133
+ switch (type) {
134
+ case MessageType.Sync: {
135
+ encoding.writeVarUint(encoder, MessageType.Sync);
136
+ const syncType = readSyncMessage(decoder, encoder, this.doc, this);
137
+ if (!this._synced && syncType === messageYjsSyncStep2) this._synced = true;
138
+ break;
139
+ }
140
+ case MessageType.Awareness:
141
+ if (this.awareness) applyAwarenessUpdate(this.awareness, decoding.readVarUint8Array(decoder), this);
142
+ return null;
143
+ case MessageType.QueryAwareness:
144
+ if (!this.awareness) return null;
145
+ encoding.writeVarUint(encoder, MessageType.Awareness);
146
+ encoding.writeVarUint8Array(
147
+ encoder,
148
+ encodeAwarenessUpdate(this.awareness, [...this.awareness.getStates().keys()])
149
+ );
150
+ break;
151
+ case MessageType.Auth:
152
+ readAuthMessage(decoder, this.doc, (_doc, reason) => console.warn(`[yrb-lite] auth denied: ${reason}`));
153
+ return null;
154
+ default:
155
+ return null;
156
+ }
157
+ return encoding.length(encoder) > 1 ? encoding.toUint8Array(encoder) : null;
158
+ }
159
+
160
+ /** Detach doc/awareness listeners and stop retransmits. */
161
+ destroy() {
162
+ this.doc.off("update", this._onDocUpdate);
163
+ if (this.awareness && this._onAwarenessUpdate) this.awareness.off("update", this._onAwarenessUpdate);
164
+ this._delivery.destroy();
165
+ }
166
+
167
+ _frameSyncStep1() {
168
+ const e = encoding.createEncoder();
169
+ encoding.writeVarUint(e, MessageType.Sync);
170
+ writeSyncStep1(e, this.doc);
171
+ return encoding.toUint8Array(e);
172
+ }
173
+
174
+ _frameUpdate(update) {
175
+ const e = encoding.createEncoder();
176
+ encoding.writeVarUint(e, MessageType.Sync);
177
+ writeUpdate(e, update);
178
+ return encoding.toUint8Array(e);
179
+ }
180
+
181
+ _frameAwareness(clients) {
182
+ const e = encoding.createEncoder();
183
+ encoding.writeVarUint(e, MessageType.Awareness);
184
+ encoding.writeVarUint8Array(e, encodeAwarenessUpdate(this.awareness, clients));
185
+ return encoding.toUint8Array(e);
186
+ }
187
+ }