yrby-client 0.4.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,160 @@
1
+ # yrby-client
2
+
3
+ The **client core** for the [`yrby`](https://github.com/jpcamara/yrby)
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
+ Three layers, use whichever you need:
9
+
10
+ - **`ActionCableProvider`** — a ready-made Yjs provider for ActionCable /
11
+ AnyCable. Pass a `Y.Doc`, a cable consumer, and a channel; it wires the
12
+ subscription and you're collaborating. Awareness/presence rides AnyCable
13
+ `whisper` when available via an awareness-only envelope and falls back to
14
+ normal sends on plain ActionCable; document updates always go through the
15
+ server as reliable recorded/acked updates.
16
+ - **`YProtocolSession`** — the transport-agnostic core. Binds to a `Y.Doc` (+ optional
17
+ `Awareness`) and owns the y-protocols **message encode/decode**, the
18
+ **sync-step handshake** (SyncStep1 / SyncStep2 / Update), **awareness**, and
19
+ reliable delivery. Speaks raw `Uint8Array` frames; you wire any socket.
20
+ - **`ReliableSync`** — the zero-dependency reliable-delivery state machine on its
21
+ own: ack-tracked queue, **sync-since-last-ack** (the unacked tail merged into
22
+ one causally-complete delta), cumulative acks, retransmit, and reconnect
23
+ replay. Compose it yourself if you already have your own framing.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ npm install yrby-client
29
+ ```
30
+
31
+ `ActionCableProvider` and `YProtocolSession` need `yjs` and `y-protocols` (peers — your
32
+ app already has them), plus an ActionCable/AnyCable consumer. `ReliableSync` has
33
+ **no dependencies**; import it on its own via `yrby-client/reliable` if
34
+ that's all you want.
35
+
36
+ Written in **TypeScript** and ships bundled type declarations, so TS projects get
37
+ full types (typed options, methods, and errors) with no `@types` package — and
38
+ plain-JS projects use the same compiled ESM with nothing extra to install.
39
+
40
+ ## ActionCableProvider (the easy path)
41
+
42
+ ```js
43
+ import { ActionCableProvider } from "yrby-client";
44
+ import * as Y from "yjs";
45
+ import { createConsumer } from "@anycable/web"; // or @rails/actioncable
46
+
47
+ const doc = new Y.Doc();
48
+ const consumer = createConsumer();
49
+ const provider = new ActionCableProvider(doc, consumer, "DocumentChannel", { id: docId });
50
+
51
+ provider.connect(); // does not auto-connect — wire your editor binding first
52
+
53
+ // Observe the connection (one signal, no separate "sync" event):
54
+ provider.onStatusChange(({ status }) => render(status)); // returns an unsubscribe fn
55
+ // "connecting" -> subscription created, transport not up yet
56
+ // "connected" -> transport up, exchanging sync steps (show "syncing")
57
+ // "synced" -> caught up with the server
58
+ // "disconnected"-> torn down via disconnect()/destroy()
59
+ // (a dropped transport ActionCable will retry shows as "connecting")
60
+
61
+ // provider.status -> the current status (same union as above)
62
+ // provider.awareness -> the provider's Awareness instance (always a fresh one)
63
+ // provider.synced -> caught up with the server
64
+ // provider.hasPending -> unacked local edits in flight
65
+ // provider.destroy() -> tear down
66
+ ```
67
+
68
+ On `disconnect()` / `destroy()` — and on browser `pagehide` — the provider
69
+ broadcasts a presence removal so peers drop your cursor immediately instead of
70
+ waiting for the awareness timeout. `destroy()` is synchronous (the unsubscribe is
71
+ deferred one microtask so that removal flushes first) and tears down the
72
+ `Awareness` it created. (`ActionCableProvider` always creates its own; to bring
73
+ your own `Awareness`, drop down to `YProtocolSession`, which leaves it for you to
74
+ own.)
75
+
76
+ On the server, include `Y::ActionCable::Sync` in a channel named
77
+ `DocumentChannel` (the [`yrby-actioncable`](https://rubygems.org/gems/yrby-actioncable)
78
+ gem). The server subscribes document broadcasts and AnyCable awareness whispers
79
+ on separate streams, so the document stream is not whisper-enabled. Need a
80
+ different transport or framing? Drop down to `YProtocolSession` and supply your
81
+ own `send`.
82
+
83
+ The provider uses one JSON envelope shape:
84
+
85
+ ```txt
86
+ client -> server document frame { update: "<base64 frame>", id: 42 }
87
+ server -> client document frame { update: "<base64 frame>" }
88
+ server -> client acknowledgement { ack: 42 }
89
+ AnyCable awareness whisper { awareness: "<base64 awareness frame>" }
90
+ ```
91
+
92
+ ## YProtocolSession
93
+
94
+ ```js
95
+ import { YProtocolSession, toBase64, fromBase64 } from "yrby-client";
96
+ import * as Y from "yjs";
97
+ import { Awareness } from "y-protocols/awareness";
98
+
99
+ const doc = new Y.Doc();
100
+ const awareness = new Awareness(doc);
101
+
102
+ const session = new YProtocolSession(doc, {
103
+ awareness,
104
+ // transmit one raw frame; `id` is set for reliable doc updates -> tag your envelope
105
+ send: (frame, id) => {
106
+ const payload = { update: toBase64(frame) };
107
+ if (id !== undefined) payload.id = id;
108
+ subscription.send(payload);
109
+ },
110
+ });
111
+
112
+ // wire your transport's callbacks:
113
+ subscription.connected = () => session.onConnect(); // handshake + replay
114
+ subscription.disconnected = () => session.onDisconnect(); // pause + clear presence
115
+ subscription.received = (msg) => {
116
+ if (msg.ack !== undefined) return session.ack(msg.ack); // reliable ack envelope
117
+ const reply = session.receive(fromBase64(msg.update)); // decode + apply
118
+ if (reply) subscription.send({ update: toBase64(reply) }); // e.g. answer a SyncStep1
119
+ };
120
+ // session.synced -> caught up; session.hasPending -> unacked edits in flight
121
+ // session.destroy() -> detach listeners + stop retransmits
122
+ ```
123
+
124
+ Local document edits and awareness changes are picked up automatically from the
125
+ doc's / awareness's `update` events — you never call anything for outbound edits.
126
+
127
+ Pass `onError(error, context)` (on either `ActionCableProvider` or
128
+ `YProtocolSession`) to observe dropped frames: a malformed or truncated message
129
+ is decoded defensively, dropped, and reported here rather than thrown into your
130
+ transport callback. Defaults to a `console.warn`.
131
+
132
+ ## ReliableSync (standalone)
133
+
134
+ ```js
135
+ import { ReliableSync } from "yrby-client/reliable"; // zero-dep
136
+ import * as Y from "yjs";
137
+
138
+ const rs = new ReliableSync({
139
+ send: (update, id) => { /* frame + transmit */ },
140
+ merge: Y.mergeUpdates,
141
+ });
142
+
143
+ rs.enqueue(update); // a local document update
144
+ rs.onAck(id); // an { ack: id } arrived
145
+ rs.onConnect(); // (re)connected — replay the tail, resume retransmits
146
+ rs.onDisconnect(); // dropped — keep the queue, pause
147
+ ```
148
+
149
+ Pending updates are retained and replayed until the server acknowledges them.
150
+ Document delivery stays queued and ack-tracked for the lifetime of the session.
151
+
152
+ ## How it fits
153
+
154
+ The server counterpart — ack *generation*, gap detection, record-before-distribute
155
+ — is the `yrby-actioncable` gem's `Y::ActionCable::Sync`. This package
156
+ is the client half of the same protocol.
157
+
158
+ ## License
159
+
160
+ MIT
@@ -0,0 +1,65 @@
1
+ import { YProtocolSession, type YProtocolSessionOptions } from "./y_protocol_session.js";
2
+ import { Awareness } from "y-protocols/awareness";
3
+ import type { Doc } from "yjs";
4
+ /**
5
+ * Connection lifecycle, folded into one signal (no separate "sync" event):
6
+ * connecting (subscription created, transport not up yet), connected (transport
7
+ * up, exchanging sync steps; UI: "syncing"), synced (caught up), and disconnected
8
+ * (torn down via disconnect()/destroy()). A dropped transport that ActionCable
9
+ * will retry shows as "connecting", not "disconnected".
10
+ */
11
+ export type ProviderStatus = "connecting" | "connected" | "synced" | "disconnected";
12
+ /** Payload passed to onStatusChange listeners. */
13
+ export interface StatusEvent {
14
+ status: ProviderStatus;
15
+ }
16
+ /** The minimal slice of an ActionCable/AnyCable subscription this provider uses. */
17
+ export interface CableSubscription {
18
+ send(data: unknown): unknown;
19
+ /** AnyCable client-to-client broadcast; absent on plain ActionCable. */
20
+ whisper?(data: unknown): unknown;
21
+ unsubscribe?(): void;
22
+ }
23
+ /** The minimal slice of an ActionCable/AnyCable consumer this provider uses. */
24
+ export interface CableConsumer {
25
+ subscriptions: {
26
+ create(params: object, mixin: object): CableSubscription;
27
+ remove(subscription: CableSubscription): void;
28
+ };
29
+ }
30
+ export type ActionCableProviderOptions = Pick<YProtocolSessionOptions, "resendInterval" | "onError">;
31
+ export declare class ActionCableProvider {
32
+ #private;
33
+ readonly doc: Doc;
34
+ readonly consumer: CableConsumer;
35
+ readonly channelName: string;
36
+ readonly channelParams: object;
37
+ readonly awareness: Awareness;
38
+ readonly session: YProtocolSession;
39
+ constructor(doc: Doc, consumer: CableConsumer, channelName: string, channelParams?: object, opts?: ActionCableProviderOptions);
40
+ /** True once the document has caught up with the server (received a SyncStep2). */
41
+ get synced(): boolean;
42
+ /** True while there are unacknowledged local document updates in flight. */
43
+ get hasPending(): boolean;
44
+ /**
45
+ * Apply a bootstrap/restore update (initial HTTP state, a server snapshot, an
46
+ * import) without re-sending it to the server as a local edit. Call it once per
47
+ * chunk of already-durable state when seeding the doc, before `connect()`:
48
+ *
49
+ * provider.applyRemoteUpdate(fromBase64(initialState));
50
+ * priorUpdates.forEach((u) => provider.applyRemoteUpdate(fromBase64(u)));
51
+ * provider.connect();
52
+ *
53
+ * See {@link YProtocolSession.applyRemoteUpdate} for why a bare `Y.applyUpdate`
54
+ * would be re-broadcast as a pending change instead.
55
+ */
56
+ applyRemoteUpdate(update: Uint8Array): void;
57
+ /** Current connection status. See {@link ProviderStatus}. */
58
+ get status(): ProviderStatus;
59
+ /** Subscribe to status changes. Returns an unsubscribe function. */
60
+ onStatusChange(listener: (event: StatusEvent) => void): () => void;
61
+ connect(): void;
62
+ disconnect(): void;
63
+ destroy(): void;
64
+ }
65
+ //# sourceMappingURL=actioncable_provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"actioncable_provider.d.ts","sourceRoot":"","sources":["../src/actioncable_provider.ts"],"names":[],"mappings":"AAeA,OAAO,EAAE,gBAAgB,EAAe,KAAK,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AAEtG,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAClD,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,KAAK,CAAC;AAE/B;;;;;;GAMG;AACH,MAAM,MAAM,cAAc,GAAG,YAAY,GAAG,WAAW,GAAG,QAAQ,GAAG,cAAc,CAAC;AAEpF,kDAAkD;AAClD,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,cAAc,CAAC;CACxB;AAED,oFAAoF;AACpF,MAAM,WAAW,iBAAiB;IAChC,IAAI,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC;IAC7B,wEAAwE;IACxE,OAAO,CAAC,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC;IACjC,WAAW,CAAC,IAAI,IAAI,CAAC;CACtB;AAED,gFAAgF;AAChF,MAAM,WAAW,aAAa;IAC5B,aAAa,EAAE;QACb,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,iBAAiB,CAAC;QACzD,MAAM,CAAC,YAAY,EAAE,iBAAiB,GAAG,IAAI,CAAC;KAC/C,CAAC;CACH;AAED,MAAM,MAAM,0BAA0B,GAAG,IAAI,CAAC,uBAAuB,EAAE,gBAAgB,GAAG,SAAS,CAAC,CAAC;AAQrG,qBAAa,mBAAmB;;IAC9B,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAClB,QAAQ,CAAC,QAAQ,EAAE,aAAa,CAAC;IACjC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,gBAAgB,CAAC;gBASjC,GAAG,EAAE,GAAG,EACR,QAAQ,EAAE,aAAa,EACvB,WAAW,EAAE,MAAM,EACnB,aAAa,GAAE,MAAW,EAC1B,IAAI,GAAE,0BAA+B;IAiBvC,mFAAmF;IACnF,IAAI,MAAM,IAAI,OAAO,CAEpB;IAED,4EAA4E;IAC5E,IAAI,UAAU,IAAI,OAAO,CAExB;IAED;;;;;;;;;;;OAWG;IACH,iBAAiB,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI;IAI3C,6DAA6D;IAC7D,IAAI,MAAM,IAAI,cAAc,CAE3B;IAED,oEAAoE;IACpE,cAAc,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,GAAG,MAAM,IAAI;IAKlE,OAAO,IAAI,IAAI;IAgDf,UAAU,IAAI,IAAI;IAelB,OAAO,IAAI,IAAI;CAqDhB"}
@@ -0,0 +1,197 @@
1
+ // Yjs provider for the yrby y-websocket protocol over ActionCable / AnyCable.
2
+ // It owns the cable subscription and translates between the cable's JSON envelope
3
+ // (`{ update, id }` / `{ ack }`, base64) and raw protocol frames. Everything else
4
+ // (sync steps, encode/decode, awareness, reliable delivery) lives in
5
+ // YProtocolSession; this is the transport glue.
6
+ //
7
+ // Awareness frames use AnyCable's `whisper` when available, under a separate
8
+ // awareness-only envelope. Document frames always use `send` so they go through
9
+ // the server's persistence/ack path.
10
+ //
11
+ // The constructor does not auto-connect: wire up your editor binding first, then
12
+ // call `connect()`. Watch the connection with `onStatusChange(({ status }) => ...)`
13
+ // or the `status` getter. On `disconnect()`/`destroy()`, and on browser
14
+ // `pagehide`, the provider broadcasts a presence removal so peers drop our cursor
15
+ // right away instead of waiting for the awareness timeout.
16
+ import { YProtocolSession, MessageType } from "./y_protocol_session.js";
17
+ import { toBase64, fromBase64 } from "./base64.js";
18
+ import { Awareness } from "y-protocols/awareness";
19
+ export class ActionCableProvider {
20
+ doc;
21
+ consumer;
22
+ channelName;
23
+ channelParams;
24
+ awareness;
25
+ session;
26
+ #subscription = null;
27
+ #onError;
28
+ #connected = false;
29
+ #status = "disconnected";
30
+ #statusListeners = new Set();
31
+ #onUnload = null;
32
+ constructor(doc, consumer, channelName, channelParams = {}, opts = {}) {
33
+ this.doc = doc;
34
+ this.consumer = consumer;
35
+ this.channelName = channelName;
36
+ this.channelParams = channelParams;
37
+ this.awareness = new Awareness(doc);
38
+ this.#onError = opts.onError ?? ((error, context) => console.warn(`[yrby] ${context}:`, error));
39
+ this.session = new YProtocolSession(doc, {
40
+ awareness: this.awareness,
41
+ resendInterval: opts.resendInterval,
42
+ onError: this.#onError,
43
+ send: (frame, id) => this.#send(frame, id),
44
+ });
45
+ }
46
+ /** True once the document has caught up with the server (received a SyncStep2). */
47
+ get synced() {
48
+ return this.session.synced;
49
+ }
50
+ /** True while there are unacknowledged local document updates in flight. */
51
+ get hasPending() {
52
+ return this.session.hasPending;
53
+ }
54
+ /**
55
+ * Apply a bootstrap/restore update (initial HTTP state, a server snapshot, an
56
+ * import) without re-sending it to the server as a local edit. Call it once per
57
+ * chunk of already-durable state when seeding the doc, before `connect()`:
58
+ *
59
+ * provider.applyRemoteUpdate(fromBase64(initialState));
60
+ * priorUpdates.forEach((u) => provider.applyRemoteUpdate(fromBase64(u)));
61
+ * provider.connect();
62
+ *
63
+ * See {@link YProtocolSession.applyRemoteUpdate} for why a bare `Y.applyUpdate`
64
+ * would be re-broadcast as a pending change instead.
65
+ */
66
+ applyRemoteUpdate(update) {
67
+ this.session.applyRemoteUpdate(update);
68
+ }
69
+ /** Current connection status. See {@link ProviderStatus}. */
70
+ get status() {
71
+ return this.#status;
72
+ }
73
+ /** Subscribe to status changes. Returns an unsubscribe function. */
74
+ onStatusChange(listener) {
75
+ this.#statusListeners.add(listener);
76
+ return () => this.#statusListeners.delete(listener);
77
+ }
78
+ connect() {
79
+ if (this.#subscription)
80
+ return;
81
+ const provider = this;
82
+ this.#subscription = this.consumer.subscriptions.create({ channel: this.channelName, ...this.channelParams }, {
83
+ received(message) {
84
+ // Reliable-delivery ack: confirm + prune the local queue.
85
+ if (message && message.ack !== undefined) {
86
+ provider.session.ack(message.ack);
87
+ return;
88
+ }
89
+ const awarenessPayload = message && message.awareness;
90
+ const payload = message && (awarenessPayload ?? message.update);
91
+ if (typeof payload !== "string")
92
+ return;
93
+ // Guard base64 decode too: a malformed envelope must not throw into
94
+ // the cable callback (session.receive is itself defensive).
95
+ let frame;
96
+ try {
97
+ frame = fromBase64(payload);
98
+ }
99
+ catch (error) {
100
+ provider.#onError(error, "received");
101
+ return;
102
+ }
103
+ if (awarenessPayload !== undefined && frame[0] !== MessageType.Awareness) {
104
+ provider.#onError(new Error("awareness envelope carried a non-awareness frame"), "received");
105
+ return;
106
+ }
107
+ const reply = provider.session.receive(frame);
108
+ if (reply)
109
+ provider.#send(reply, undefined); // e.g. SyncStep2 answering a SyncStep1
110
+ provider.#refreshStatus(); // a SyncStep2 may have just flipped us to "synced"
111
+ },
112
+ connected() {
113
+ provider.#connected = true;
114
+ provider.session.onConnect(); // handshake + replay the unacked tail
115
+ provider.#refreshStatus();
116
+ },
117
+ disconnected() {
118
+ provider.#connected = false;
119
+ provider.session.onDisconnect(); // pause retransmits, clear remote presence
120
+ provider.#refreshStatus(); // subscription still set -> "connecting" (retrying)
121
+ },
122
+ });
123
+ this.#installUnloadHandler();
124
+ this.#refreshStatus(); // -> "connecting"
125
+ }
126
+ disconnect() {
127
+ if (!this.#subscription)
128
+ return;
129
+ const sub = this.#subscription;
130
+ // Tell peers we're gone while the transport is still live, then pause and
131
+ // detach. Defer the unsubscribe one microtask so the removal frame flushes
132
+ // before the channel tears down.
133
+ this.session.removeLocalAwareness();
134
+ this.session.onDisconnect();
135
+ this.#connected = false;
136
+ this.#subscription = null;
137
+ this.#removeUnloadHandler();
138
+ queueMicrotask(() => this.consumer.subscriptions.remove(sub));
139
+ this.#refreshStatus(); // -> "disconnected"
140
+ }
141
+ destroy() {
142
+ this.disconnect();
143
+ this.session.destroy();
144
+ this.awareness.destroy(); // stops its reaper timer
145
+ this.#statusListeners.clear();
146
+ }
147
+ #computeStatus() {
148
+ if (!this.#subscription)
149
+ return "disconnected";
150
+ if (!this.#connected)
151
+ return "connecting";
152
+ return this.session.synced ? "synced" : "connected";
153
+ }
154
+ #refreshStatus() {
155
+ const next = this.#computeStatus();
156
+ if (next === this.#status)
157
+ return;
158
+ this.#status = next;
159
+ for (const listener of this.#statusListeners)
160
+ listener({ status: next });
161
+ }
162
+ // Best-effort presence removal when the tab/page goes away (close, navigation,
163
+ // bfcache). `pagehide` fires while the socket is still live and is bfcache-safe
164
+ // (unlike `beforeunload`, which can block it). Sends are not guaranteed to
165
+ // flush on unload, so the server-side awareness timeout remains the backstop.
166
+ #installUnloadHandler() {
167
+ if (typeof window === "undefined" || this.#onUnload)
168
+ return;
169
+ this.#onUnload = () => this.session.removeLocalAwareness();
170
+ window.addEventListener("pagehide", this.#onUnload);
171
+ }
172
+ #removeUnloadHandler() {
173
+ if (typeof window === "undefined" || !this.#onUnload)
174
+ return;
175
+ window.removeEventListener("pagehide", this.#onUnload);
176
+ this.#onUnload = null;
177
+ }
178
+ // Send one raw protocol frame over the cable. Awareness frames are whispered
179
+ // when AnyCable exposes `subscription.whisper`; otherwise they fall back to a
180
+ // normal send. `id` (reliable doc updates) is tagged onto the envelope so the
181
+ // server can ack. A no-op while disconnected: reliable frames stay queued in
182
+ // the session and flush on the next connect().
183
+ #send(frame, id) {
184
+ const sub = this.#subscription;
185
+ if (!sub)
186
+ return;
187
+ const update = toBase64(frame);
188
+ const isAwareness = frame[0] === MessageType.Awareness;
189
+ if (isAwareness && typeof sub.whisper === "function") {
190
+ sub.whisper({ awareness: update });
191
+ return;
192
+ }
193
+ const payload = id === undefined ? { update } : { update, id };
194
+ sub.send(payload);
195
+ }
196
+ }
197
+ //# sourceMappingURL=actioncable_provider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"actioncable_provider.js","sourceRoot":"","sources":["../src/actioncable_provider.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,3 @@
1
+ export declare const toBase64: (bytes: Uint8Array) => string;
2
+ export declare const fromBase64: (str: string) => Uint8Array;
3
+ //# sourceMappingURL=base64.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"base64.d.ts","sourceRoot":"","sources":["../src/base64.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,QAAQ,GAAI,OAAO,UAAU,KAAG,MACoB,CAAC;AAElE,eAAO,MAAM,UAAU,GAAI,KAAK,MAAM,KAAG,UAAgE,CAAC"}
package/dist/base64.js ADDED
@@ -0,0 +1,6 @@
1
+ // base64 codecs for transports that carry binary frames as strings (e.g.
2
+ // ActionCable's JSON envelope). Optional; a binary WebSocket transport sends the
3
+ // raw frames directly and never needs these.
4
+ export const toBase64 = (bytes) => btoa(Array.from(bytes, (b) => String.fromCharCode(b)).join(""));
5
+ export const fromBase64 = (str) => Uint8Array.from(atob(str), (c) => c.charCodeAt(0));
6
+ //# sourceMappingURL=base64.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"base64.js","sourceRoot":"","sources":["../src/base64.ts"],"names":[],"mappings":"AAAA,yEAAyE;AACzE,iFAAiF;AACjF,6CAA6C;AAE7C,MAAM,CAAC,MAAM,QAAQ,GAAG,CAAC,KAAiB,EAAU,EAAE,CACpD,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC;AAElE,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,GAAW,EAAc,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC"}
@@ -0,0 +1,8 @@
1
+ export { ReliableSync } from "./reliable_sync.js";
2
+ export type { ReliableSyncOptions, TimerHandle } from "./reliable_sync.js";
3
+ export { YProtocolSession, MessageType } from "./y_protocol_session.js";
4
+ export type { YProtocolSessionOptions } from "./y_protocol_session.js";
5
+ export { ActionCableProvider } from "./actioncable_provider.js";
6
+ export type { ActionCableProviderOptions, ProviderStatus, StatusEvent, CableConsumer, CableSubscription, } from "./actioncable_provider.js";
7
+ export { toBase64, fromBase64 } from "./base64.js";
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,YAAY,EAAE,mBAAmB,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAI3E,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AACxE,YAAY,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AAIvE,OAAO,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAChE,YAAY,EACV,0BAA0B,EAC1B,cAAc,EACd,WAAW,EACX,aAAa,EACb,iBAAiB,GAClB,MAAM,2BAA2B,CAAC;AAGnC,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ // Zero-dependency reliable-delivery core. Safe to import on its own.
2
+ export { ReliableSync } from "./reliable_sync.js";
3
+ // Protocol session (sync steps + encode/decode + awareness).
4
+ // Requires `yjs` and `y-protocols` as peers.
5
+ export { YProtocolSession, MessageType } from "./y_protocol_session.js";
6
+ // ActionCable / AnyCable provider built on YProtocolSession.
7
+ // Bring your own provider instead by composing YProtocolSession.
8
+ export { ActionCableProvider } from "./actioncable_provider.js";
9
+ // Optional base64 helpers for transports that carry frames as strings.
10
+ export { toBase64, fromBase64 } from "./base64.js";
11
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,qEAAqE;AACrE,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAGlD,6DAA6D;AAC7D,6CAA6C;AAC7C,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAGxE,6DAA6D;AAC7D,iEAAiE;AACjE,OAAO,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAShE,uEAAuE;AACvE,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC"}
@@ -0,0 +1,59 @@
1
+ /** An opaque timer handle (number in browsers, Timeout in Node). */
2
+ export type TimerHandle = unknown;
3
+ export interface ReliableSyncOptions {
4
+ /**
5
+ * Transmit one update. `update` is the raw merged update bytes; `id` is the
6
+ * cumulative sequence to ack against.
7
+ */
8
+ send: (update: Uint8Array, id: number) => void;
9
+ /** Merge an array of update byte-arrays into one (typically Y.mergeUpdates). */
10
+ merge: (updates: Uint8Array[]) => Uint8Array;
11
+ /** Milliseconds between retransmits of the unacked tail (default 1000). */
12
+ resendInterval?: number;
13
+ /** Injectable timer hooks (default to globals); handy for tests. */
14
+ setInterval?: (handler: () => void, ms: number) => TimerHandle;
15
+ clearInterval?: (handle: TimerHandle) => void;
16
+ }
17
+ interface Pending {
18
+ seq: number;
19
+ update: Uint8Array;
20
+ }
21
+ export declare class ReliableSync {
22
+ #private;
23
+ /** Unacked local updates, in order. */
24
+ pending: Pending[];
25
+ constructor(opts: ReliableSyncOptions);
26
+ /** True while there are unacknowledged local updates. */
27
+ get hasPending(): boolean;
28
+ /**
29
+ * Record a local document update. It is queued and the unacked tail is
30
+ * flushed; the update remains retained until the server acknowledges it.
31
+ */
32
+ enqueue(update: Uint8Array): void;
33
+ /**
34
+ * Send the whole unacked tail as one merged delta. The id is the highest seq
35
+ * in the batch, so a single { ack } cumulatively confirms everything up to it.
36
+ * No-op while disconnected (the tail is replayed on the next onConnect).
37
+ */
38
+ flush(): void;
39
+ /**
40
+ * Confirm delivery up to `id`: prune every queued update with seq <= id.
41
+ * Acks arrive over the wire, so validate before pruning. A malformed value
42
+ * (NaN/string/negative) or an impossible future id must not silently drop the
43
+ * queue; invalid acks are ignored.
44
+ */
45
+ onAck(id: number): void;
46
+ /** Transport (re)connected: replay the unacked tail and resume retransmits. */
47
+ onConnect(): void;
48
+ /** Transport dropped: keep the queue (for reconnect replay), pause the timer. */
49
+ onDisconnect(): void;
50
+ /**
51
+ * One retransmit tick. Exposed for deterministic testing; normally driven by
52
+ * the internal timer.
53
+ */
54
+ onTick(): void;
55
+ /** Stop timers and drop references. Call when the provider is destroyed. */
56
+ destroy(): void;
57
+ }
58
+ export {};
59
+ //# sourceMappingURL=reliable_sync.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reliable_sync.d.ts","sourceRoot":"","sources":["../src/reliable_sync.ts"],"names":[],"mappings":"AAgBA,oEAAoE;AACpE,MAAM,MAAM,WAAW,GAAG,OAAO,CAAC;AAElC,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,IAAI,EAAE,CAAC,MAAM,EAAE,UAAU,EAAE,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/C,gFAAgF;IAChF,KAAK,EAAE,CAAC,OAAO,EAAE,UAAU,EAAE,KAAK,UAAU,CAAC;IAC7C,2EAA2E;IAC3E,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,oEAAoE;IACpE,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,KAAK,WAAW,CAAC;IAC/D,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,IAAI,CAAC;CAC/C;AAID,UAAU,OAAO;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,UAAU,CAAC;CACpB;AAED,qBAAa,YAAY;;IACvB,uCAAuC;IACvC,OAAO,EAAE,OAAO,EAAE,CAAM;gBAeZ,IAAI,EAAE,mBAAmB;IAiBrC,yDAAyD;IACzD,IAAI,UAAU,IAAI,OAAO,CAExB;IAED;;;OAGG;IACH,OAAO,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI;IAOjC;;;;OAIG;IACH,KAAK,IAAI,IAAI;IAKb;;;;;OAKG;IACH,KAAK,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAQvB,+EAA+E;IAC/E,SAAS,IAAI,IAAI;IAMjB,iFAAiF;IACjF,YAAY,IAAI,IAAI;IAKpB;;;OAGG;IACH,MAAM,IAAI,IAAI;IAKd,4EAA4E;IAC5E,OAAO,IAAI,IAAI;CA2BhB"}
@@ -0,0 +1,139 @@
1
+ // Transport-agnostic reliable-delivery core for the yrby y-websocket
2
+ // protocol: an ack-tracked queue of unacknowledged local updates,
3
+ // sync-since-last-ack (the unacked tail goes out as one merged, causally-complete
4
+ // delta so the server never sees an internal gap), cumulative acks, periodic
5
+ // retransmit, and reconnect replay.
6
+ //
7
+ // It doesn't touch the transport, the Yjs binding, or wire encoding. Inject two
8
+ // functions:
9
+ // send(update, id) transmits one update (raw merged bytes plus a cumulative
10
+ // sequence id; you frame, base64, and put it on the socket), and merge(updates)
11
+ // merges update byte-arrays into one (usually Y.mergeUpdates). Drive it from the
12
+ // provider lifecycle: enqueue(update) on each local edit, onAck(id) when an
13
+ // { ack: id } frame arrives, and onConnect()/onDisconnect() on transport changes.
14
+ //
15
+ // Awareness/presence stays out of scope; it's fire-and-forget in the provider.
16
+ const DEFAULTS = { resendInterval: 1000 };
17
+ export class ReliableSync {
18
+ /** Unacked local updates, in order. */
19
+ pending = [];
20
+ #send;
21
+ #merge;
22
+ #resendInterval;
23
+ #setInterval;
24
+ #clearInterval;
25
+ #nextSeq = 1;
26
+ #connected = false;
27
+ #timer = undefined;
28
+ // Memoized merge of the unacked tail. The tail only changes on enqueue/ack, so
29
+ // retransmit ticks reuse this instead of re-merging the whole queue each time.
30
+ #tailCache = undefined;
31
+ constructor(opts) {
32
+ const { send, merge, resendInterval } = opts ?? {};
33
+ if (typeof send !== "function")
34
+ throw new TypeError("ReliableSync requires a send(update, id) function");
35
+ if (typeof merge !== "function")
36
+ throw new TypeError("ReliableSync requires a merge(updates) function");
37
+ this.#send = send;
38
+ this.#merge = merge;
39
+ const interval = resendInterval ?? DEFAULTS.resendInterval;
40
+ if (!Number.isFinite(interval) || interval <= 0) {
41
+ throw new TypeError("ReliableSync resendInterval must be a positive number");
42
+ }
43
+ this.#resendInterval = interval;
44
+ // Injectable timer hooks make the resend loop testable; default to globals.
45
+ this.#setInterval = opts.setInterval ?? ((fn, ms) => setInterval(fn, ms));
46
+ this.#clearInterval = opts.clearInterval ?? ((h) => clearInterval(h));
47
+ }
48
+ /** True while there are unacknowledged local updates. */
49
+ get hasPending() {
50
+ return this.pending.length > 0;
51
+ }
52
+ /**
53
+ * Record a local document update. It is queued and the unacked tail is
54
+ * flushed; the update remains retained until the server acknowledges it.
55
+ */
56
+ enqueue(update) {
57
+ this.pending.push({ seq: this.#nextSeq++, update });
58
+ this.#tailCache = undefined; // tail changed
59
+ if (this.#connected)
60
+ this.#startTimer();
61
+ this.flush();
62
+ }
63
+ /**
64
+ * Send the whole unacked tail as one merged delta. The id is the highest seq
65
+ * in the batch, so a single { ack } cumulatively confirms everything up to it.
66
+ * No-op while disconnected (the tail is replayed on the next onConnect).
67
+ */
68
+ flush() {
69
+ if (!this.#connected || this.pending.length === 0)
70
+ return;
71
+ this.#send(this.#mergedTail(), this.pending[this.pending.length - 1].seq);
72
+ }
73
+ /**
74
+ * Confirm delivery up to `id`: prune every queued update with seq <= id.
75
+ * Acks arrive over the wire, so validate before pruning. A malformed value
76
+ * (NaN/string/negative) or an impossible future id must not silently drop the
77
+ * queue; invalid acks are ignored.
78
+ */
79
+ onAck(id) {
80
+ if (!Number.isSafeInteger(id) || id < 0)
81
+ return; // malformed / impossible
82
+ if (this.pending.length > 0 && id > this.pending[this.pending.length - 1].seq)
83
+ return; // future ack
84
+ this.pending = this.pending.filter((p) => p.seq > id);
85
+ this.#tailCache = undefined; // tail changed
86
+ if (this.pending.length === 0)
87
+ this.#stopTimer();
88
+ }
89
+ /** Transport (re)connected: replay the unacked tail and resume retransmits. */
90
+ onConnect() {
91
+ this.#connected = true;
92
+ this.flush();
93
+ if (this.pending.length > 0)
94
+ this.#startTimer();
95
+ }
96
+ /** Transport dropped: keep the queue (for reconnect replay), pause the timer. */
97
+ onDisconnect() {
98
+ this.#connected = false;
99
+ this.#stopTimer();
100
+ }
101
+ /**
102
+ * One retransmit tick. Exposed for deterministic testing; normally driven by
103
+ * the internal timer.
104
+ */
105
+ onTick() {
106
+ if (!this.#connected || this.pending.length === 0)
107
+ return;
108
+ this.flush();
109
+ }
110
+ /** Stop timers and drop references. Call when the provider is destroyed. */
111
+ destroy() {
112
+ this.#connected = false;
113
+ this.#stopTimer();
114
+ this.pending = [];
115
+ this.#tailCache = undefined;
116
+ }
117
+ /** The unacked tail merged into one delta (memoized between tail changes). */
118
+ #mergedTail() {
119
+ if (this.#tailCache === undefined) {
120
+ const updates = this.pending.map((p) => p.update);
121
+ this.#tailCache = updates.length === 1 ? updates[0] : this.#merge(updates);
122
+ }
123
+ return this.#tailCache;
124
+ }
125
+ #startTimer() {
126
+ if (this.#timer !== undefined)
127
+ return;
128
+ this.#timer = this.#setInterval(() => this.onTick(), this.#resendInterval);
129
+ const t = this.#timer;
130
+ if (t && typeof t.unref === "function")
131
+ t.unref();
132
+ }
133
+ #stopTimer() {
134
+ if (this.#timer !== undefined)
135
+ this.#clearInterval(this.#timer);
136
+ this.#timer = undefined;
137
+ }
138
+ }
139
+ //# sourceMappingURL=reliable_sync.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reliable_sync.js","sourceRoot":"","sources":["../src/reliable_sync.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,76 @@
1
+ import { Doc } from "yjs";
2
+ import { type Awareness } from "y-protocols/awareness";
3
+ import { type TimerHandle } from "./reliable_sync.js";
4
+ export declare const MessageType: {
5
+ readonly Sync: 0;
6
+ readonly Awareness: 1;
7
+ };
8
+ export interface YProtocolSessionOptions {
9
+ /**
10
+ * Transmit one raw protocol frame. `id` is set only for reliable document
11
+ * updates (tag it onto your envelope so the server can ack). Awareness frames
12
+ * are identifiable by their first byte (`MessageType.Awareness`) if a transport
13
+ * needs to route them separately.
14
+ */
15
+ send: (frame: Uint8Array, id: number | undefined) => void;
16
+ /** Optional awareness/presence. When omitted, awareness frames are ignored. */
17
+ awareness?: Awareness | null;
18
+ /** Forwarded to ReliableSync. */
19
+ resendInterval?: number;
20
+ /**
21
+ * Called when an incoming frame can't be decoded/applied (malformed bytes,
22
+ * truncated message, unexpected structure). The frame is dropped and the
23
+ * session keeps running. `context` names where it happened (e.g. "receive").
24
+ * Defaults to a `console.warn`.
25
+ */
26
+ onError?: (error: unknown, context: string) => void;
27
+ /** Injectable timer hooks (forwarded to ReliableSync); handy for tests. */
28
+ setInterval?: (handler: () => void, ms: number) => TimerHandle;
29
+ clearInterval?: (handle: TimerHandle) => void;
30
+ }
31
+ export declare class YProtocolSession {
32
+ #private;
33
+ readonly doc: Doc;
34
+ readonly awareness: Awareness | null;
35
+ constructor(doc: Doc, opts: YProtocolSessionOptions);
36
+ /** True once we've received the server's SyncStep2 (the document is caught up). */
37
+ get synced(): boolean;
38
+ /** True while there are unacknowledged local document updates in flight. */
39
+ get hasPending(): boolean;
40
+ /** Transport connected: send the opening handshake and replay the unacked tail. */
41
+ onConnect(): void;
42
+ /** Transport dropped: pause retransmits (queue kept) and clear remote presence. */
43
+ onDisconnect(): void;
44
+ /**
45
+ * Broadcast that our local presence is gone (sets local state to null, which
46
+ * emits a removal awareness frame through `send`). Call this while the
47
+ * transport is still live so peers drop our cursor immediately instead of
48
+ * waiting for the awareness timeout. A no-op when there's no local state.
49
+ */
50
+ removeLocalAwareness(): void;
51
+ /** A reliable-delivery `{ ack: id }` envelope arrived. */
52
+ ack(id: number): void;
53
+ /**
54
+ * Apply an update without treating it as a local edit, so it isn't queued for
55
+ * re-delivery to the server. Use it for bootstrap/restore: initial state loaded
56
+ * over HTTP, a server snapshot, an import. These are bytes the server already
57
+ * has.
58
+ *
59
+ * The session re-sends any doc update whose origin isn't itself (that's how a
60
+ * keystroke becomes an outbound frame), so a bare `Y.applyUpdate(doc, update)`
61
+ * would look like a local edit and get echoed back on the next connect. Going
62
+ * through here applies under the session's own origin, which the outbound
63
+ * filter skips. Safe to call before `onConnect()`: the state folds into the
64
+ * SyncStep1 handshake instead of being re-sent.
65
+ */
66
+ applyRemoteUpdate(update: Uint8Array): void;
67
+ /**
68
+ * Decode and apply one incoming binary protocol frame (document sync or
69
+ * awareness). Returns a reply frame to transmit (e.g. SyncStep2 answering a
70
+ * SyncStep1), or null if there's nothing to send.
71
+ */
72
+ receive(frame: Uint8Array): Uint8Array | null;
73
+ /** Detach doc/awareness listeners and stop retransmits. */
74
+ destroy(): void;
75
+ }
76
+ //# sourceMappingURL=y_protocol_session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"y_protocol_session.d.ts","sourceRoot":"","sources":["../src/y_protocol_session.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,GAAG,EAA6B,MAAM,KAAK,CAAC;AAIrD,OAAO,EAIL,KAAK,SAAS,EACf,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAgB,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAIpE,eAAO,MAAM,WAAW;;;CAAqC,CAAC;AAE9D,MAAM,WAAW,uBAAuB;IACtC;;;;;OAKG;IACH,IAAI,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,EAAE,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,CAAC;IAC1D,+EAA+E;IAC/E,SAAS,CAAC,EAAE,SAAS,GAAG,IAAI,CAAC;IAC7B,iCAAiC;IACjC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACpD,2EAA2E;IAC3E,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,KAAK,WAAW,CAAC;IAC/D,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,IAAI,CAAC;CAC/C;AAID,qBAAa,gBAAgB;;IAC3B,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAClB,QAAQ,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI,CAAC;gBASzB,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,uBAAuB;IA6CnD,mFAAmF;IACnF,IAAI,MAAM,IAAI,OAAO,CAEpB;IAED,4EAA4E;IAC5E,IAAI,UAAU,IAAI,OAAO,CAExB;IAED,mFAAmF;IACnF,SAAS,IAAI,IAAI;IAQjB,mFAAmF;IACnF,YAAY,IAAI,IAAI;IASpB;;;;;OAKG;IACH,oBAAoB,IAAI,IAAI;IAM5B,0DAA0D;IAC1D,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAIrB;;;;;;;;;;;;OAYG;IACH,iBAAiB,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI;IAI3C;;;;OAIG;IACH,OAAO,CAAC,KAAK,EAAE,UAAU,GAAG,UAAU,GAAG,IAAI;IA8B7C,2DAA2D;IAC3D,OAAO,IAAI,IAAI;CAwDhB"}
@@ -0,0 +1,216 @@
1
+ // Transport-agnostic session for the yrby y-websocket protocol. Handles the
2
+ // y-protocols framing, the sync handshake (SyncStep1/Step2/Update), and awareness
3
+ // encode/apply on top of ReliableSync. Bind it to a Y.Doc (and an optional
4
+ // Awareness); it works in raw Uint8Array frames and leaves the transport to the
5
+ // caller: base64, the { update, id } / { ack } envelope, and a socket.
6
+ //
7
+ // Call onConnect() when the transport connects, onDisconnect() when it drops,
8
+ // ack(id) on an { ack } envelope, and receive(frame) for an inbound frame (it
9
+ // returns a reply to send, or null). Local doc and awareness edits send
10
+ // themselves via the "update" events.
11
+ import { Doc, mergeUpdates, applyUpdate } from "yjs";
12
+ import * as encoding from "lib0/encoding";
13
+ import * as decoding from "lib0/decoding";
14
+ import { readSyncMessage, writeSyncStep1, writeUpdate, messageYjsSyncStep2 } from "y-protocols/sync";
15
+ import { encodeAwarenessUpdate, applyAwarenessUpdate, removeAwarenessStates, } from "y-protocols/awareness";
16
+ import { ReliableSync } from "./reliable_sync.js";
17
+ // The y-protocols frame types yrby speaks, as the leading byte of a frame.
18
+ // Other y-protocols types (auth = 2, query-awareness = 3) are not handled.
19
+ export const MessageType = { Sync: 0, Awareness: 1 };
20
+ export class YProtocolSession {
21
+ doc;
22
+ awareness;
23
+ #send;
24
+ #onError;
25
+ #synced = false;
26
+ #delivery;
27
+ #onDocUpdate;
28
+ #onAwarenessUpdate;
29
+ constructor(doc, opts) {
30
+ const { send, awareness = null, resendInterval, onError, setInterval: setIntervalFn, clearInterval: clearIntervalFn, } = opts ?? {};
31
+ if (!doc)
32
+ throw new TypeError("YProtocolSession requires a Y.Doc");
33
+ if (typeof send !== "function")
34
+ throw new TypeError("YProtocolSession requires a send(frame, id) function");
35
+ this.doc = doc;
36
+ this.awareness = awareness;
37
+ this.#send = send;
38
+ this.#onError = onError ?? ((error, context) => console.warn(`[yrby] ${context}:`, error));
39
+ this.#delivery = new ReliableSync({
40
+ merge: mergeUpdates,
41
+ send: (update, id) => this.#send(this.#frameUpdate(update), id),
42
+ resendInterval,
43
+ setInterval: setIntervalFn,
44
+ clearInterval: clearIntervalFn,
45
+ });
46
+ this.#onDocUpdate = (update, origin) => {
47
+ if (origin === this)
48
+ return; // applied from the server; don't echo it back
49
+ this.#delivery.enqueue(update);
50
+ };
51
+ this.doc.on("update", this.#onDocUpdate);
52
+ if (this.awareness) {
53
+ this.#onAwarenessUpdate = ({ added, updated, removed }, origin) => {
54
+ // Only broadcast our own presence changes. Updates applied from a peer,
55
+ // and our own remote-cleanup in onDisconnect, carry origin === this;
56
+ // re-sending those would echo presence and broadcast tombstones for
57
+ // other clients' cursors.
58
+ if (origin === this)
59
+ return;
60
+ const changed = added.concat(updated, removed);
61
+ this.#send(this.#frameAwareness(changed), undefined); // fire-and-forget
62
+ };
63
+ this.awareness.on("update", this.#onAwarenessUpdate);
64
+ }
65
+ }
66
+ /** True once we've received the server's SyncStep2 (the document is caught up). */
67
+ get synced() {
68
+ return this.#synced;
69
+ }
70
+ /** True while there are unacknowledged local document updates in flight. */
71
+ get hasPending() {
72
+ return this.#delivery.hasPending;
73
+ }
74
+ /** Transport connected: send the opening handshake and replay the unacked tail. */
75
+ onConnect() {
76
+ this.#send(this.#frameSyncStep1(), undefined);
77
+ if (this.awareness && this.awareness.getLocalState() !== null) {
78
+ this.#send(this.#frameAwareness([this.doc.clientID]), undefined);
79
+ }
80
+ this.#delivery.onConnect();
81
+ }
82
+ /** Transport dropped: pause retransmits (queue kept) and clear remote presence. */
83
+ onDisconnect() {
84
+ this.#synced = false;
85
+ this.#delivery.onDisconnect();
86
+ if (this.awareness) {
87
+ const remote = [...this.awareness.getStates().keys()].filter((c) => c !== this.doc.clientID);
88
+ if (remote.length)
89
+ removeAwarenessStates(this.awareness, remote, this);
90
+ }
91
+ }
92
+ /**
93
+ * Broadcast that our local presence is gone (sets local state to null, which
94
+ * emits a removal awareness frame through `send`). Call this while the
95
+ * transport is still live so peers drop our cursor immediately instead of
96
+ * waiting for the awareness timeout. A no-op when there's no local state.
97
+ */
98
+ removeLocalAwareness() {
99
+ if (this.awareness && this.awareness.getLocalState() !== null) {
100
+ this.awareness.setLocalState(null); // fires "update" -> sends the removal frame
101
+ }
102
+ }
103
+ /** A reliable-delivery `{ ack: id }` envelope arrived. */
104
+ ack(id) {
105
+ this.#delivery.onAck(id);
106
+ }
107
+ /**
108
+ * Apply an update without treating it as a local edit, so it isn't queued for
109
+ * re-delivery to the server. Use it for bootstrap/restore: initial state loaded
110
+ * over HTTP, a server snapshot, an import. These are bytes the server already
111
+ * has.
112
+ *
113
+ * The session re-sends any doc update whose origin isn't itself (that's how a
114
+ * keystroke becomes an outbound frame), so a bare `Y.applyUpdate(doc, update)`
115
+ * would look like a local edit and get echoed back on the next connect. Going
116
+ * through here applies under the session's own origin, which the outbound
117
+ * filter skips. Safe to call before `onConnect()`: the state folds into the
118
+ * SyncStep1 handshake instead of being re-sent.
119
+ */
120
+ applyRemoteUpdate(update) {
121
+ applyUpdate(this.doc, update, this);
122
+ }
123
+ /**
124
+ * Decode and apply one incoming binary protocol frame (document sync or
125
+ * awareness). Returns a reply frame to transmit (e.g. SyncStep2 answering a
126
+ * SyncStep1), or null if there's nothing to send.
127
+ */
128
+ receive(frame) {
129
+ // A malformed/truncated frame must never take down the transport callback:
130
+ // decode + apply defensively, drop the frame on error, keep the session live.
131
+ try {
132
+ const validatedType = this.#validateFrame(frame);
133
+ if (validatedType === null)
134
+ return null;
135
+ const decoder = decoding.createDecoder(frame);
136
+ const encoder = encoding.createEncoder();
137
+ const type = decoding.readVarUint(decoder);
138
+ switch (type) {
139
+ case MessageType.Sync: {
140
+ encoding.writeVarUint(encoder, MessageType.Sync);
141
+ const syncType = readSyncMessage(decoder, encoder, this.doc, this);
142
+ if (!this.#synced && syncType === messageYjsSyncStep2)
143
+ this.#synced = true;
144
+ break;
145
+ }
146
+ case MessageType.Awareness:
147
+ if (this.awareness)
148
+ applyAwarenessUpdate(this.awareness, decoding.readVarUint8Array(decoder), this);
149
+ break;
150
+ default:
151
+ return null; // a y-protocols type yrby doesn't speak (auth, query-awareness): ignore
152
+ }
153
+ return encoding.length(encoder) > 1 ? encoding.toUint8Array(encoder) : null;
154
+ }
155
+ catch (error) {
156
+ this.#onError(error, "receive");
157
+ return 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)
164
+ this.awareness.off("update", this.#onAwarenessUpdate);
165
+ this.#delivery.destroy();
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
+ #frameUpdate(update) {
174
+ const e = encoding.createEncoder();
175
+ encoding.writeVarUint(e, MessageType.Sync);
176
+ writeUpdate(e, update);
177
+ return encoding.toUint8Array(e);
178
+ }
179
+ #frameAwareness(clients) {
180
+ const e = encoding.createEncoder();
181
+ encoding.writeVarUint(e, MessageType.Awareness);
182
+ encoding.writeVarUint8Array(e, encodeAwarenessUpdate(this.awareness, clients));
183
+ return encoding.toUint8Array(e);
184
+ }
185
+ #validateFrame(frame) {
186
+ const decoder = decoding.createDecoder(frame);
187
+ const type = decoding.readVarUint(decoder);
188
+ switch (type) {
189
+ case MessageType.Sync: {
190
+ const scratchDoc = new Doc();
191
+ try {
192
+ const scratchEncoder = encoding.createEncoder();
193
+ encoding.writeVarUint(scratchEncoder, MessageType.Sync);
194
+ readSyncMessage(decoder, scratchEncoder, scratchDoc, this);
195
+ }
196
+ finally {
197
+ scratchDoc.destroy();
198
+ }
199
+ break;
200
+ }
201
+ case MessageType.Awareness:
202
+ decoding.readVarUint8Array(decoder);
203
+ break;
204
+ default:
205
+ return null; // a y-protocols type yrby doesn't speak: ignore
206
+ }
207
+ // This protocol is one message per frame. Anything left after a complete
208
+ // message is malformed (trailing garbage, or low-level packed messages whose
209
+ // tail we'd silently drop), so reject it before mutating local state.
210
+ if (decoding.hasContent(decoder)) {
211
+ throw new Error("frame has trailing bytes after a complete message");
212
+ }
213
+ return type;
214
+ }
215
+ }
216
+ //# sourceMappingURL=y_protocol_session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"y_protocol_session.js","sourceRoot":"","sources":["../src/y_protocol_session.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "yrby-client",
3
+ "version": "0.4.0",
4
+ "description": "JavaScript client for the yrby y-websocket protocol: a ready-made ActionCable/AnyCable provider, a transport-agnostic protocol session (sync steps, encode/decode, awareness), and a reliable-delivery core (ack-tracked queue, sync-since-last-ack, retransmit + reconnect replay). Written in TypeScript with bundled types; usable from plain JS.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./reliable": {
15
+ "types": "./dist/reliable_sync.d.ts",
16
+ "default": "./dist/reliable_sync.js"
17
+ },
18
+ "./base64": {
19
+ "types": "./dist/base64.d.ts",
20
+ "default": "./dist/base64.js"
21
+ }
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "README.md"
26
+ ],
27
+ "scripts": {
28
+ "clean": "rm -rf dist",
29
+ "build": "npm run clean && tsc",
30
+ "typecheck": "tsc --noEmit",
31
+ "prepack": "npm run build",
32
+ "test": "npm run build && node --test"
33
+ },
34
+ "license": "MIT",
35
+ "author": "JP Camara <jp@jpcamara.com>",
36
+ "homepage": "https://github.com/jpcamara/yrby/tree/main/packages/client#readme",
37
+ "bugs": {
38
+ "url": "https://github.com/jpcamara/yrby/issues"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/jpcamara/yrby.git",
43
+ "directory": "packages/client"
44
+ },
45
+ "keywords": [
46
+ "yjs",
47
+ "crdt",
48
+ "yrby",
49
+ "reliable-delivery",
50
+ "actioncable",
51
+ "y-websocket",
52
+ "typescript"
53
+ ],
54
+ "publishConfig": {
55
+ "access": "public"
56
+ },
57
+ "peerDependencies": {
58
+ "yjs": "^13.6.0",
59
+ "y-protocols": "^1.0.5"
60
+ },
61
+ "peerDependenciesMeta": {
62
+ "yjs": {
63
+ "optional": true
64
+ },
65
+ "y-protocols": {
66
+ "optional": true
67
+ }
68
+ },
69
+ "dependencies": {
70
+ "lib0": "^0.2.117"
71
+ },
72
+ "devDependencies": {
73
+ "yjs": "^13.6.0",
74
+ "y-protocols": "^1.0.5",
75
+ "typescript": "^5.6.0"
76
+ }
77
+ }