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 +90 -0
- package/package.json +58 -0
- package/src/base64.js +9 -0
- package/src/index.js +9 -0
- package/src/reliable_sync.js +155 -0
- package/src/sync_engine.js +187 -0
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
|
+
}
|