yrby-client 0.4.3 → 0.6.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.
Files changed (44) hide show
  1. package/README.md +250 -36
  2. package/dist/actioncable_provider.d.ts +26 -1
  3. package/dist/actioncable_provider.d.ts.map +1 -1
  4. package/dist/actioncable_provider.js +295 -150
  5. package/dist/actioncable_provider.js.map +1 -1
  6. package/dist/cjs/actioncable_provider.d.ts +26 -1
  7. package/dist/cjs/actioncable_provider.js +296 -151
  8. package/dist/cjs/document_element.d.ts +24 -0
  9. package/dist/cjs/document_element.js +260 -0
  10. package/dist/cjs/document_session.d.ts +62 -0
  11. package/dist/cjs/document_session.js +335 -0
  12. package/dist/cjs/index.d.ts +2 -0
  13. package/dist/cjs/index.js +4 -1
  14. package/dist/cjs/reliable_sync.d.ts +18 -31
  15. package/dist/cjs/reliable_sync.js +128 -98
  16. package/dist/cjs/turbo_adapter.d.ts +9 -0
  17. package/dist/cjs/turbo_adapter.js +81 -0
  18. package/dist/cjs/y_protocol_session.d.ts +7 -14
  19. package/dist/cjs/y_protocol_session.js +97 -92
  20. package/dist/document_element.d.ts +25 -0
  21. package/dist/document_element.d.ts.map +1 -0
  22. package/dist/document_element.js +225 -0
  23. package/dist/document_element.js.map +1 -0
  24. package/dist/document_session.d.ts +63 -0
  25. package/dist/document_session.d.ts.map +1 -0
  26. package/dist/document_session.js +296 -0
  27. package/dist/document_session.js.map +1 -0
  28. package/dist/index.d.ts +2 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +2 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/reliable_sync.d.ts +18 -31
  33. package/dist/reliable_sync.d.ts.map +1 -1
  34. package/dist/reliable_sync.js +128 -98
  35. package/dist/reliable_sync.js.map +1 -1
  36. package/dist/turbo_adapter.d.ts +10 -0
  37. package/dist/turbo_adapter.d.ts.map +1 -0
  38. package/dist/turbo_adapter.js +78 -0
  39. package/dist/turbo_adapter.js.map +1 -0
  40. package/dist/y_protocol_session.d.ts +7 -14
  41. package/dist/y_protocol_session.d.ts.map +1 -1
  42. package/dist/y_protocol_session.js +97 -92
  43. package/dist/y_protocol_session.js.map +1 -1
  44. package/package.json +29 -6
@@ -14,45 +14,32 @@ export interface ReliableSyncOptions {
14
14
  setInterval?: (handler: () => void, ms: number) => TimerHandle;
15
15
  clearInterval?: (handle: TimerHandle) => void;
16
16
  }
17
- interface Pending {
18
- seq: number;
19
- update: Uint8Array;
17
+ /** One queued update and the sequence number an ack must reach to remove it. */
18
+ export interface Pending {
19
+ readonly seq: number;
20
+ readonly update: Uint8Array;
20
21
  }
21
22
  export declare class ReliableSync {
22
23
  #private;
23
- /** Unacked local updates, in order. */
24
- pending: Pending[];
25
24
  constructor(opts: ReliableSyncOptions);
25
+ /** A snapshot of unacknowledged local updates, oldest first. Editing it does not change the queue. */
26
+ get pending(): readonly Pending[];
26
27
  /** True while there are unacknowledged local updates. */
27
28
  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
- */
29
+ /** Queue a local update and, while connected, send the tail. Ignored after destroy(). */
32
30
  enqueue(update: Uint8Array): void;
33
31
  /**
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.
32
+ * Confirm delivery through `id`, removing every queued update with
33
+ * seq <= id. Acks come off the wire, so ignore a malformed value or an id
34
+ * beyond anything sent.
53
35
  */
54
- onTick(): void;
55
- /** Stop timers and drop references. Call when the provider is destroyed. */
36
+ acknowledge(id: number): void;
37
+ /** Call when the transport is up. Replays the tail and keeps retransmitting until it is acknowledged. */
38
+ resume(): void;
39
+ /** Call when the transport is down. Keeps the queue and stops retransmitting. */
40
+ pause(): void;
41
+ /** Send the tail again if anything is unacknowledged. The internal timer calls this, and a host with its own scheduler can too. */
42
+ retransmit(): void;
43
+ /** Stop the timer and drop the queue. Later enqueues are ignored. */
56
44
  destroy(): void;
57
45
  }
58
- export {};
@@ -1,142 +1,172 @@
1
1
  "use strict";
2
- // Transport-agnostic reliable-delivery core for the yrby y-websocket
3
- // protocol: an ack-tracked queue of unacknowledged local updates,
4
- // sync-since-last-ack (the unacked tail goes out as one merged, causally-complete
5
- // delta so the server never sees an internal gap), cumulative acks, periodic
6
- // retransmit, and reconnect replay.
2
+ // Transport-agnostic reliable delivery for the yrby y-websocket protocol.
7
3
  //
8
- // It doesn't touch the transport, the Yjs binding, or wire encoding. Inject two
9
- // functions:
10
- // send(update, id) transmits one update (raw merged bytes plus a cumulative
11
- // sequence id; you frame, base64, and put it on the socket), and merge(updates)
12
- // merges update byte-arrays into one (usually Y.mergeUpdates). Drive it from the
13
- // provider lifecycle: enqueue(update) on each local edit, onAck(id) when an
14
- // { ack: id } frame arrives, and onConnect()/onDisconnect() on transport changes.
4
+ // Each local update gets a sequence number and sits in an ordered queue until
5
+ // the server acknowledges it. While the transport is up, ReliableSync sends the
6
+ // whole unacknowledged tail as one merged, causally complete delta so the
7
+ // server never sees an internal gap, and resends it on a timer until an ack
8
+ // arrives. Acks are cumulative, so one { ack: n } clears everything up to n.
9
+ // While the transport is down, ReliableSync sends nothing and drops nothing,
10
+ // and it replays the tail when the transport reconnects.
11
+ //
12
+ // It doesn't touch the transport or Yjs itself. You inject two functions.
13
+ // send(update, id) transmits one delta with the sequence id the server should
14
+ // acknowledge, and merge(updates) combines update byte arrays into one (usually
15
+ // Y.mergeUpdates). Call enqueue(update) on each local edit, acknowledge(id) when
16
+ // an { ack: id } envelope arrives, resume() when the transport connects, and
17
+ // pause() when it drops.
15
18
  //
16
19
  // Awareness/presence stays out of scope; it's fire-and-forget in the provider.
17
20
  Object.defineProperty(exports, "__esModule", { value: true });
18
21
  exports.ReliableSync = void 0;
19
- const DEFAULTS = { resendInterval: 1000 };
22
+ const DEFAULT_RESEND_INTERVAL = 1000;
20
23
  class ReliableSync {
21
- /** Unacked local updates, in order. */
22
- pending = [];
24
+ #pending = [];
23
25
  #send;
24
26
  #merge;
25
27
  #resendInterval;
26
28
  #setInterval;
27
29
  #clearInterval;
28
30
  #nextSeq = 1;
29
- #connected = false;
30
- #timer = undefined;
31
- // Memoized merge of the unacked tail. The tail only changes on enqueue/ack, so
32
- // retransmit ticks reuse this instead of re-merging the whole queue each time.
33
- #tailCache = undefined;
31
+ #phase = "paused";
32
+ #timer;
33
+ // Incremented on every queue or phase change. Injected send/merge/timer
34
+ // functions can call back into this object, so code that calls one compares
35
+ // the version afterwards to detect changes made during the call.
36
+ #version = 0;
37
+ // The queue merged into one delta. It's memoized until the queue changes so
38
+ // retransmit ticks don't re-merge the whole queue every second.
39
+ #tail;
34
40
  constructor(opts) {
35
- const { send, merge, resendInterval } = opts ?? {};
41
+ const { send, merge, resendInterval, setInterval: setTimer, clearInterval: clearTimer } = opts ?? {};
36
42
  if (typeof send !== "function")
37
43
  throw new TypeError("ReliableSync requires a send(update, id) function");
38
44
  if (typeof merge !== "function")
39
45
  throw new TypeError("ReliableSync requires a merge(updates) function");
40
- this.#send = send;
41
- this.#merge = merge;
42
- const interval = resendInterval ?? DEFAULTS.resendInterval;
46
+ const interval = resendInterval ?? DEFAULT_RESEND_INTERVAL;
43
47
  if (!Number.isFinite(interval) || interval <= 0) {
44
48
  throw new TypeError("ReliableSync resendInterval must be a positive number");
45
49
  }
50
+ this.#send = send;
51
+ this.#merge = merge;
46
52
  this.#resendInterval = interval;
47
- // Injectable timer hooks make the resend loop testable; default to globals.
48
- this.#setInterval = opts.setInterval ?? ((fn, ms) => setInterval(fn, ms));
49
- this.#clearInterval = opts.clearInterval ?? ((h) => clearInterval(h));
53
+ this.#setInterval = setTimer ?? ((fn, ms) => setInterval(fn, ms));
54
+ this.#clearInterval = clearTimer ?? ((h) => clearInterval(h));
55
+ }
56
+ /** A snapshot of unacknowledged local updates, oldest first. Editing it does not change the queue. */
57
+ get pending() {
58
+ return this.#pending.map(({ seq, update }) => ({ seq, update: update.slice() }));
50
59
  }
51
60
  /** True while there are unacknowledged local updates. */
52
61
  get hasPending() {
53
- return this.pending.length > 0;
62
+ return this.#pending.length > 0;
54
63
  }
55
- /**
56
- * Record a local document update. It is queued and the unacked tail is
57
- * flushed; the update remains retained until the server acknowledges it.
58
- */
64
+ /** Queue a local update and, while connected, send the tail. Ignored after destroy(). */
59
65
  enqueue(update) {
60
- this.pending.push({ seq: this.#nextSeq++, update });
61
- this.#tailCache = undefined; // tail changed
62
- if (this.#connected)
63
- this.#startTimer();
64
- this.flush();
66
+ if (this.#phase === "destroyed")
67
+ return;
68
+ this.#pending.push({ seq: this.#nextSeq++, update: new Uint8Array(update) });
69
+ this.#queueChanged();
70
+ this.#flush();
65
71
  }
66
72
  /**
67
- * Send the whole unacked tail as one merged delta. The id is the highest seq
68
- * in the batch, so a single { ack } cumulatively confirms everything up to it.
69
- * No-op while disconnected (the tail is replayed on the next onConnect).
73
+ * Confirm delivery through `id`, removing every queued update with
74
+ * seq <= id. Acks come off the wire, so ignore a malformed value or an id
75
+ * beyond anything sent.
70
76
  */
71
- flush() {
72
- if (!this.#connected || this.pending.length === 0)
77
+ acknowledge(id) {
78
+ if (this.#phase === "destroyed" || !Number.isSafeInteger(id) || id < 0)
79
+ return;
80
+ const newest = this.#pending.at(-1);
81
+ if (newest && id > newest.seq)
73
82
  return;
74
- this.#send(this.#mergedTail(), this.pending[this.pending.length - 1].seq);
83
+ this.#pending = this.#pending.filter((p) => p.seq > id);
84
+ this.#queueChanged();
75
85
  }
76
- /**
77
- * Confirm delivery up to `id`: prune every queued update with seq <= id.
78
- * Acks arrive over the wire, so validate before pruning. A malformed value
79
- * (NaN/string/negative) or an impossible future id must not silently drop the
80
- * queue; invalid acks are ignored.
81
- */
82
- onAck(id) {
83
- if (!Number.isSafeInteger(id) || id < 0)
84
- return; // malformed / impossible
85
- if (this.pending.length > 0 && id > this.pending[this.pending.length - 1].seq)
86
- return; // future ack
87
- this.pending = this.pending.filter((p) => p.seq > id);
88
- this.#tailCache = undefined; // tail changed
89
- if (this.pending.length === 0)
90
- this.#stopTimer();
86
+ /** Call when the transport is up. Replays the tail and keeps retransmitting until it is acknowledged. */
87
+ resume() {
88
+ if (this.#phase === "destroyed")
89
+ return;
90
+ this.#phase = "live";
91
+ this.#version++;
92
+ this.#updateTimer();
93
+ this.#flush();
91
94
  }
92
- /** Transport (re)connected: replay the unacked tail and resume retransmits. */
93
- onConnect() {
94
- this.#connected = true;
95
- this.flush();
96
- if (this.pending.length > 0)
97
- this.#startTimer();
95
+ /** Call when the transport is down. Keeps the queue and stops retransmitting. */
96
+ pause() {
97
+ if (this.#phase === "destroyed")
98
+ return;
99
+ this.#phase = "paused";
100
+ this.#version++;
101
+ this.#updateTimer();
98
102
  }
99
- /** Transport dropped: keep the queue (for reconnect replay), pause the timer. */
100
- onDisconnect() {
101
- this.#connected = false;
102
- this.#stopTimer();
103
+ /** Send the tail again if anything is unacknowledged. The internal timer calls this, and a host with its own scheduler can too. */
104
+ retransmit() {
105
+ this.#flush();
103
106
  }
104
- /**
105
- * One retransmit tick. Exposed for deterministic testing; normally driven by
106
- * the internal timer.
107
- */
108
- onTick() {
109
- if (!this.#connected || this.pending.length === 0)
107
+ /** Stop the timer and drop the queue. Later enqueues are ignored. */
108
+ destroy() {
109
+ if (this.#phase === "destroyed")
110
110
  return;
111
- this.flush();
111
+ this.#phase = "destroyed";
112
+ this.#pending = [];
113
+ this.#queueChanged();
112
114
  }
113
- /** Stop timers and drop references. Call when the provider is destroyed. */
114
- destroy() {
115
- this.#connected = false;
116
- this.#stopTimer();
117
- this.pending = [];
118
- this.#tailCache = undefined;
115
+ #queueChanged() {
116
+ this.#version++;
117
+ this.#tail = undefined;
118
+ this.#updateTimer();
119
119
  }
120
- /** The unacked tail merged into one delta (memoized between tail changes). */
121
- #mergedTail() {
122
- if (this.#tailCache === undefined) {
123
- const updates = this.pending.map((p) => p.update);
124
- this.#tailCache = updates.length === 1 ? updates[0] : this.#merge(updates);
120
+ // Start or stop the retransmit timer so it runs only while live with work queued.
121
+ #updateTimer() {
122
+ const wanted = this.#phase === "live" && this.hasPending;
123
+ if (wanted === (this.#timer !== undefined))
124
+ return;
125
+ if (!wanted) {
126
+ const timer = this.#timer;
127
+ this.#timer = undefined;
128
+ timer.stop();
129
+ return;
125
130
  }
126
- return this.#tailCache;
127
- }
128
- #startTimer() {
129
- if (this.#timer !== undefined)
131
+ // Install the timer before calling setInterval so a tick during that call
132
+ // finds its own timer. Stopping it before the handle exists is a no-op, and
133
+ // the check after setInterval returns cancels the real handle.
134
+ const timer = this.#timer = { stop: () => { } };
135
+ let handle;
136
+ try {
137
+ handle = this.#setInterval(() => { if (this.#timer === timer)
138
+ this.#flush(); }, this.#resendInterval);
139
+ }
140
+ catch (error) {
141
+ // The queue is untouched, and the next resume or queue change tries again.
142
+ if (this.#timer === timer)
143
+ this.#timer = undefined;
144
+ throw error;
145
+ }
146
+ timer.stop = () => this.#clearInterval(handle);
147
+ // An injected timer may tick before returning its handle, and that tick
148
+ // can pause or drain delivery.
149
+ if (this.#timer !== timer) {
150
+ timer.stop();
130
151
  return;
131
- this.#timer = this.#setInterval(() => this.onTick(), this.#resendInterval);
132
- const t = this.#timer;
133
- if (t && typeof t.unref === "function")
134
- t.unref();
152
+ }
153
+ handle?.unref?.();
135
154
  }
136
- #stopTimer() {
137
- if (this.#timer !== undefined)
138
- this.#clearInterval(this.#timer);
139
- this.#timer = undefined;
155
+ // Send the whole tail as one delta, tagged with its highest seq so one ack
156
+ // covers all of it. Does nothing while disconnected.
157
+ #flush() {
158
+ if (this.#phase !== "live" || !this.#pending.length)
159
+ return;
160
+ if (this.#tail === undefined) {
161
+ const version = this.#version;
162
+ const updates = this.#pending.map((p) => p.update);
163
+ const tail = updates.length === 1 ? updates[0] : this.#merge(updates);
164
+ // merge may have changed the queue, paused, or already sent through a nested resume.
165
+ if (this.#version !== version)
166
+ return;
167
+ this.#tail = tail;
168
+ }
169
+ this.#send(this.#tail, this.#pending.at(-1).seq);
140
170
  }
141
171
  }
142
172
  exports.ReliableSync = ReliableSync;
@@ -0,0 +1,9 @@
1
+ export interface DocumentMount {
2
+ readonly ownerDocument: Document;
3
+ readonly isConnected: boolean;
4
+ activate(): void;
5
+ deactivate(): void;
6
+ }
7
+ export declare function registerDocumentMount(mount: DocumentMount): () => void;
8
+ /** Tears down the adapter, for application shutdown or tests. */
9
+ export declare function disconnectTurbo(document: Document): void;
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerDocumentMount = registerDocumentMount;
4
+ exports.disconnectTurbo = disconnectTurbo;
5
+ const adapters = new WeakMap();
6
+ // Turbo (Hotwire) and Turbolinks 5 fire the same lifecycle events under different names.
7
+ const CACHE_EVENTS = ["turbo:before-cache", "turbolinks:before-cache"];
8
+ const RENDER_EVENTS = ["turbo:render", "turbo:load", "turbo:fetch-request-error", "turbolinks:render", "turbolinks:load"];
9
+ const PREVIEW_ATTRIBUTES = ["data-turbo-preview", "data-turbolinks-preview"];
10
+ function registerDocumentMount(mount) {
11
+ let adapter = adapters.get(mount.ownerDocument);
12
+ if (!adapter)
13
+ adapters.set(mount.ownerDocument, adapter = new TurboAdapter(mount.ownerDocument));
14
+ adapter.mounts.add(mount);
15
+ adapter.reconcile();
16
+ return () => {
17
+ adapter.mounts.delete(mount);
18
+ if (!adapter.mounts.size)
19
+ adapter.destroy();
20
+ };
21
+ }
22
+ /** Tears down the adapter, for application shutdown or tests. */
23
+ function disconnectTurbo(document) { adapters.get(document)?.destroy(); }
24
+ class TurboAdapter {
25
+ document;
26
+ mounts = new Set();
27
+ #state = { phase: "active" };
28
+ constructor(document) {
29
+ this.document = document;
30
+ for (const event of CACHE_EVENTS)
31
+ document.addEventListener(event, this.#beforeCache);
32
+ for (const event of RENDER_EVENTS)
33
+ document.addEventListener(event, this.reconcile);
34
+ }
35
+ reconcile = () => {
36
+ if (this.#state.phase !== "active")
37
+ return;
38
+ const html = this.document.documentElement;
39
+ const preview = PREVIEW_ATTRIBUTES.some(attribute => html?.hasAttribute(attribute));
40
+ for (const mount of this.mounts) {
41
+ if (!mount.isConnected || preview)
42
+ mount.deactivate();
43
+ else
44
+ mount.activate();
45
+ }
46
+ };
47
+ #beforeCache = () => {
48
+ const state = this.#state;
49
+ if (state.phase !== "active")
50
+ return;
51
+ for (const mount of this.mounts)
52
+ mount.deactivate();
53
+ if (this.#state !== state)
54
+ return;
55
+ // A canceled or failed navigation fires no render or load event, so we
56
+ // reconcile ourselves after Turbo finishes cloning the snapshot. The clone
57
+ // takes two turns, which is why the timers are nested.
58
+ clearTimeout(state.timer);
59
+ state.timer = setTimeout(() => {
60
+ if (this.#state === state)
61
+ state.timer = setTimeout(this.reconcile, 0);
62
+ }, 0);
63
+ };
64
+ destroy() {
65
+ const state = this.#state;
66
+ if (state.phase === "destroyed")
67
+ return;
68
+ this.#state = { phase: "destroyed" };
69
+ clearTimeout(state.timer);
70
+ // Leave the registry first so cleanup callbacks can register a replacement
71
+ // adapter for this document.
72
+ adapters.delete(this.document);
73
+ for (const event of CACHE_EVENTS)
74
+ this.document.removeEventListener(event, this.#beforeCache);
75
+ for (const event of RENDER_EVENTS)
76
+ this.document.removeEventListener(event, this.reconcile);
77
+ for (const mount of this.mounts)
78
+ mount.deactivate();
79
+ this.mounts.clear();
80
+ }
81
+ }
@@ -37,10 +37,10 @@ export declare class YProtocolSession {
37
37
  get synced(): boolean;
38
38
  /** True while there are unacknowledged local document updates in flight. */
39
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;
40
+ /** Call when the transport is up. Sends the opening handshake, re-announces presence, and replays the unacked tail. */
41
+ resume(): void;
42
+ /** Call when the transport is down. Keeps the queue, stops retransmits, and clears peers' presence. */
43
+ pause(): void;
44
44
  /**
45
45
  * Broadcast that our local presence is gone (sets local state to null, which
46
46
  * emits a removal awareness frame through `send`). Call this while the
@@ -48,15 +48,8 @@ export declare class YProtocolSession {
48
48
  * waiting for the awareness timeout. A no-op when there's no local state.
49
49
  */
50
50
  removeLocalAwareness(): void;
51
- /**
52
- * A reliable-delivery `{ ack: id }` envelope arrived. `dropped` is set when
53
- * the server settled the update WITHOUT recording it (rejected as an
54
- * unhealable causal gap after repeated resyncs). The queue is pruned either
55
- * way — retransmitting an unhealable update would loop forever — but a
56
- * dropped settle is surfaced via onError ("ack-dropped") so the app can warn
57
- * or reload instead of silently reporting synced over lost data.
58
- */
59
- ack(id: number, dropped?: boolean): void;
51
+ /** A reliable-delivery `{ ack: id }` envelope arrived. */
52
+ acknowledge(id: number): void;
60
53
  /**
61
54
  * Apply an update without treating it as a local edit, so it isn't queued for
62
55
  * re-delivery to the server. Use it for bootstrap/restore: initial state loaded
@@ -67,7 +60,7 @@ export declare class YProtocolSession {
67
60
  * keystroke becomes an outbound frame), so a bare `Y.applyUpdate(doc, update)`
68
61
  * would look like a local edit and get echoed back on the next connect. Going
69
62
  * through here applies under the session's own origin, which the outbound
70
- * filter skips. Safe to call before `onConnect()`: the state folds into the
63
+ * filter skips. Safe to call before `resume()`: the state folds into the
71
64
  * SyncStep1 handshake instead of being re-sent.
72
65
  */
73
66
  applyRemoteUpdate(update: Uint8Array): void;