@prabhask5/stellar-engine 1.1.17 → 1.2.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 (73) hide show
  1. package/README.md +55 -1
  2. package/dist/bin/install-pwa.js +234 -317
  3. package/dist/bin/install-pwa.js.map +1 -1
  4. package/dist/config.d.ts +11 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +8 -2
  7. package/dist/config.js.map +1 -1
  8. package/dist/crdt/awareness.d.ts +128 -0
  9. package/dist/crdt/awareness.d.ts.map +1 -0
  10. package/dist/crdt/awareness.js +284 -0
  11. package/dist/crdt/awareness.js.map +1 -0
  12. package/dist/crdt/channel.d.ts +165 -0
  13. package/dist/crdt/channel.d.ts.map +1 -0
  14. package/dist/crdt/channel.js +522 -0
  15. package/dist/crdt/channel.js.map +1 -0
  16. package/dist/crdt/config.d.ts +58 -0
  17. package/dist/crdt/config.d.ts.map +1 -0
  18. package/dist/crdt/config.js +123 -0
  19. package/dist/crdt/config.js.map +1 -0
  20. package/dist/crdt/helpers.d.ts +104 -0
  21. package/dist/crdt/helpers.d.ts.map +1 -0
  22. package/dist/crdt/helpers.js +116 -0
  23. package/dist/crdt/helpers.js.map +1 -0
  24. package/dist/crdt/offline.d.ts +58 -0
  25. package/dist/crdt/offline.d.ts.map +1 -0
  26. package/dist/crdt/offline.js +130 -0
  27. package/dist/crdt/offline.js.map +1 -0
  28. package/dist/crdt/persistence.d.ts +65 -0
  29. package/dist/crdt/persistence.d.ts.map +1 -0
  30. package/dist/crdt/persistence.js +171 -0
  31. package/dist/crdt/persistence.js.map +1 -0
  32. package/dist/crdt/provider.d.ts +109 -0
  33. package/dist/crdt/provider.d.ts.map +1 -0
  34. package/dist/crdt/provider.js +543 -0
  35. package/dist/crdt/provider.js.map +1 -0
  36. package/dist/crdt/store.d.ts +111 -0
  37. package/dist/crdt/store.d.ts.map +1 -0
  38. package/dist/crdt/store.js +158 -0
  39. package/dist/crdt/store.js.map +1 -0
  40. package/dist/crdt/types.d.ts +281 -0
  41. package/dist/crdt/types.d.ts.map +1 -0
  42. package/dist/crdt/types.js +26 -0
  43. package/dist/crdt/types.js.map +1 -0
  44. package/dist/database.d.ts +1 -1
  45. package/dist/database.d.ts.map +1 -1
  46. package/dist/database.js +28 -7
  47. package/dist/database.js.map +1 -1
  48. package/dist/diagnostics.d.ts +75 -0
  49. package/dist/diagnostics.d.ts.map +1 -1
  50. package/dist/diagnostics.js +114 -2
  51. package/dist/diagnostics.js.map +1 -1
  52. package/dist/engine.d.ts.map +1 -1
  53. package/dist/engine.js +21 -1
  54. package/dist/engine.js.map +1 -1
  55. package/dist/entries/crdt.d.ts +32 -0
  56. package/dist/entries/crdt.d.ts.map +1 -0
  57. package/dist/entries/crdt.js +52 -0
  58. package/dist/entries/crdt.js.map +1 -0
  59. package/dist/entries/types.d.ts +1 -0
  60. package/dist/entries/types.d.ts.map +1 -1
  61. package/dist/index.d.ts +3 -0
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +7 -0
  64. package/dist/index.js.map +1 -1
  65. package/package.json +9 -2
  66. package/dist/operations.d.ts +0 -73
  67. package/dist/operations.d.ts.map +0 -1
  68. package/dist/operations.js +0 -227
  69. package/dist/operations.js.map +0 -1
  70. package/dist/reconnectHandler.d.ts +0 -16
  71. package/dist/reconnectHandler.d.ts.map +0 -1
  72. package/dist/reconnectHandler.js +0 -21
  73. package/dist/reconnectHandler.js.map +0 -1
@@ -0,0 +1,165 @@
1
+ /**
2
+ * @fileoverview CRDT Broadcast Channel — Supabase Realtime Transport
3
+ *
4
+ * Manages one Supabase Broadcast + Presence channel per open CRDT document.
5
+ * Responsible for:
6
+ * - Distributing Yjs updates to remote peers via Broadcast
7
+ * - Receiving and applying remote updates (with echo suppression)
8
+ * - Debouncing outbound updates and merging via `Y.mergeUpdates()`
9
+ * - Chunking payloads that exceed the Broadcast size limit
10
+ * - Running the sync protocol on join (exchange state vectors, send deltas)
11
+ * - Cross-tab sync via browser `BroadcastChannel` API (avoids Supabase for same-device)
12
+ * - Reconnection with exponential backoff
13
+ *
14
+ * Channel naming convention: `crdt:${prefix}:${documentId}`
15
+ *
16
+ * @see {@link ./provider.ts} for the orchestrator that creates channels
17
+ * @see {@link ./types.ts} for message type definitions
18
+ * @see {@link ./awareness.ts} for Presence management (separate concern)
19
+ */
20
+ import * as Y from 'yjs';
21
+ import type { CRDTConnectionState } from './types';
22
+ /**
23
+ * Manages the Supabase Broadcast channel for a single CRDT document.
24
+ *
25
+ * Handles update distribution, echo suppression, debouncing, chunking,
26
+ * the sync protocol, cross-tab sync, and reconnection.
27
+ *
28
+ * @internal — consumers interact via {@link ./provider.ts}, not this class directly.
29
+ */
30
+ export declare class CRDTChannel {
31
+ readonly documentId: string;
32
+ private readonly doc;
33
+ private readonly deviceId;
34
+ private readonly channelName;
35
+ /** Supabase Realtime channel instance. */
36
+ private channel;
37
+ /** Browser BroadcastChannel for cross-tab sync (same device). */
38
+ private localChannel;
39
+ /** Current connection state. */
40
+ private _connectionState;
41
+ /** Callback invoked when connection state changes. */
42
+ private onConnectionStateChange;
43
+ /** Initial presence info for Supabase Presence tracking. */
44
+ private presenceInfo;
45
+ private pendingUpdates;
46
+ private debounceTimer;
47
+ private chunkBuffers;
48
+ private reconnectAttempts;
49
+ private reconnectTimer;
50
+ private destroyed;
51
+ private syncResolvers;
52
+ constructor(documentId: string, doc: Y.Doc, onConnectionStateChange?: (state: CRDTConnectionState) => void);
53
+ /** Current connection state of the channel. */
54
+ get connectionState(): CRDTConnectionState;
55
+ /**
56
+ * Set the local user's presence info for Supabase Presence tracking.
57
+ *
58
+ * Call this before `join()` to announce presence immediately on channel subscribe,
59
+ * or after join to update the tracked presence.
60
+ */
61
+ setPresenceInfo(info: {
62
+ name: string;
63
+ avatarUrl?: string;
64
+ }): void;
65
+ /**
66
+ * Join the Broadcast channel and start receiving messages.
67
+ *
68
+ * After subscribing, initiates the sync protocol by sending a sync-step-1
69
+ * message with the local state vector so peers can respond with deltas.
70
+ */
71
+ join(): Promise<void>;
72
+ /**
73
+ * Leave the channel and clean up all resources.
74
+ */
75
+ leave(): Promise<void>;
76
+ /**
77
+ * Queue a Yjs update for broadcasting to remote peers.
78
+ *
79
+ * Updates are debounced for `broadcastDebounceMs` (default 100ms) and merged
80
+ * via `Y.mergeUpdates()` before sending. This reduces network traffic for
81
+ * rapid keystrokes while keeping latency under 100ms.
82
+ *
83
+ * @param update - The Yjs update delta from `doc.on('update')`.
84
+ */
85
+ broadcastUpdate(update: Uint8Array): void;
86
+ /**
87
+ * Wait for the sync protocol to complete after joining.
88
+ *
89
+ * Resolves when at least one peer responds with sync-step-2, or times out
90
+ * after `syncPeerTimeoutMs` (default 3s) if no peers are available.
91
+ *
92
+ * @returns `true` if a peer responded, `false` if timed out (no peers).
93
+ */
94
+ waitForSync(): Promise<boolean>;
95
+ /**
96
+ * Handle an incoming Broadcast message from a remote peer.
97
+ *
98
+ * Dispatches to type-specific handlers and performs echo suppression
99
+ * (skip messages from our own device).
100
+ */
101
+ private handleBroadcastMessage;
102
+ /**
103
+ * Apply a remote Yjs update to the local document.
104
+ */
105
+ private handleRemoteUpdate;
106
+ /**
107
+ * Handle sync-step-1: a peer is requesting missing updates.
108
+ *
109
+ * We compute the delta between our state and their state vector,
110
+ * then send it back as sync-step-2.
111
+ */
112
+ private handleSyncStep1;
113
+ /**
114
+ * Handle sync-step-2: a peer responded to our sync-step-1 with a delta.
115
+ */
116
+ private handleSyncStep2;
117
+ /**
118
+ * Handle a chunk message — part of a large payload that was split.
119
+ *
120
+ * Buffers chunks until all parts arrive, then reassembles and processes
121
+ * the full payload as a regular message.
122
+ */
123
+ private handleChunk;
124
+ /**
125
+ * Flush all pending updates: merge, encode, and send via Broadcast.
126
+ *
127
+ * If the merged payload exceeds the max size, it is chunked.
128
+ */
129
+ private flushUpdates;
130
+ /**
131
+ * Send sync-step-1 to request missing updates from connected peers.
132
+ */
133
+ private sendSyncStep1;
134
+ /**
135
+ * Send a message via the Supabase Broadcast channel.
136
+ */
137
+ private sendMessage;
138
+ /**
139
+ * Split a large base64 payload into chunks and send each one.
140
+ */
141
+ private sendChunked;
142
+ /**
143
+ * Set up the browser BroadcastChannel for same-device tab sync.
144
+ *
145
+ * This avoids Supabase Broadcast for updates between tabs on the same device,
146
+ * which is faster and doesn't consume any network bandwidth.
147
+ */
148
+ private setupLocalChannel;
149
+ /**
150
+ * Handle a channel disconnect — attempt reconnection with exponential backoff.
151
+ */
152
+ private handleDisconnect;
153
+ /**
154
+ * Track the local user's presence on the Supabase Presence channel.
155
+ *
156
+ * Sends the user's name, avatar, color, and device ID so other collaborators
157
+ * can display cursor badges and avatar lists.
158
+ */
159
+ private trackPresence;
160
+ /**
161
+ * Update the connection state and notify the listener.
162
+ */
163
+ private setConnectionState;
164
+ }
165
+ //# sourceMappingURL=channel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../../src/crdt/channel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,CAAC,MAAM,KAAK,CAAC;AAQzB,OAAO,KAAK,EAMV,mBAAmB,EACpB,MAAM,SAAS,CAAC;AAqCjB;;;;;;;GAOG;AACH,qBAAa,WAAW;IACtB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAQ;IAC5B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IAErC,0CAA0C;IAC1C,OAAO,CAAC,OAAO,CAAgC;IAE/C,iEAAiE;IACjE,OAAO,CAAC,YAAY,CAAiC;IAErD,gCAAgC;IAChC,OAAO,CAAC,gBAAgB,CAAuC;IAE/D,sDAAsD;IACtD,OAAO,CAAC,uBAAuB,CAAuD;IAEtF,4DAA4D;IAC5D,OAAO,CAAC,YAAY,CAAqD;IAGzE,OAAO,CAAC,cAAc,CAAoB;IAC1C,OAAO,CAAC,aAAa,CAA8C;IAGnE,OAAO,CAAC,YAAY,CAA0E;IAG9F,OAAO,CAAC,iBAAiB,CAAK;IAC9B,OAAO,CAAC,cAAc,CAA8C;IACpE,OAAO,CAAC,SAAS,CAAS;IAG1B,OAAO,CAAC,aAAa,CAAsC;gBAGzD,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,CAAC,CAAC,GAAG,EACV,uBAAuB,CAAC,EAAE,CAAC,KAAK,EAAE,mBAAmB,KAAK,IAAI;IAahE,+CAA+C;IAC/C,IAAI,eAAe,IAAI,mBAAmB,CAEzC;IAED;;;;;OAKG;IACH,eAAe,CAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI;IAQjE;;;;;OAKG;IACG,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAyD3B;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAsC5B;;;;;;;;OAQG;IACH,eAAe,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI;IAsBzC;;;;;;;OAOG;IACH,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC;IA8B/B;;;;;OAKG;IACH,OAAO,CAAC,sBAAsB;IAyB9B;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAQ1B;;;;;OAKG;IACH,OAAO,CAAC,eAAe;IAgBvB;;OAEG;IACH,OAAO,CAAC,eAAe;IAcvB;;;;;OAKG;IACH,OAAO,CAAC,WAAW;IAiCnB;;;;OAIG;IACH,OAAO,CAAC,YAAY;IA4BpB;;OAEG;IACH,OAAO,CAAC,aAAa;IAYrB;;OAEG;IACH,OAAO,CAAC,WAAW;IASnB;;OAEG;IACH,OAAO,CAAC,WAAW;IA6BnB;;;;;OAKG;IACH,OAAO,CAAC,iBAAiB;IAsBzB;;OAEG;IACH,OAAO,CAAC,gBAAgB;IAsCxB;;;;;OAKG;IACH,OAAO,CAAC,aAAa;IAerB;;OAEG;IACH,OAAO,CAAC,kBAAkB;CAK3B"}
@@ -0,0 +1,522 @@
1
+ /**
2
+ * @fileoverview CRDT Broadcast Channel — Supabase Realtime Transport
3
+ *
4
+ * Manages one Supabase Broadcast + Presence channel per open CRDT document.
5
+ * Responsible for:
6
+ * - Distributing Yjs updates to remote peers via Broadcast
7
+ * - Receiving and applying remote updates (with echo suppression)
8
+ * - Debouncing outbound updates and merging via `Y.mergeUpdates()`
9
+ * - Chunking payloads that exceed the Broadcast size limit
10
+ * - Running the sync protocol on join (exchange state vectors, send deltas)
11
+ * - Cross-tab sync via browser `BroadcastChannel` API (avoids Supabase for same-device)
12
+ * - Reconnection with exponential backoff
13
+ *
14
+ * Channel naming convention: `crdt:${prefix}:${documentId}`
15
+ *
16
+ * @see {@link ./provider.ts} for the orchestrator that creates channels
17
+ * @see {@link ./types.ts} for message type definitions
18
+ * @see {@link ./awareness.ts} for Presence management (separate concern)
19
+ */
20
+ import * as Y from 'yjs';
21
+ import { supabase } from '../supabase/client';
22
+ import { getDeviceId } from '../deviceId';
23
+ import { debugLog, debugWarn } from '../debug';
24
+ import { getCRDTConfig, getCRDTPrefix } from './config';
25
+ import { handlePresenceJoin, handlePresenceLeave, assignColor } from './awareness';
26
+ // =============================================================================
27
+ // Binary ↔ Base64 Encoding Utilities
28
+ // =============================================================================
29
+ /**
30
+ * Encode a `Uint8Array` to a base64 string for JSON transport.
31
+ *
32
+ * Uses the browser's built-in `btoa()` via a binary string intermediary.
33
+ * This is necessary because Supabase Broadcast payloads are JSON — binary
34
+ * data must be string-encoded.
35
+ */
36
+ function uint8ToBase64(bytes) {
37
+ let binary = '';
38
+ for (let i = 0; i < bytes.length; i++) {
39
+ binary += String.fromCharCode(bytes[i]);
40
+ }
41
+ return btoa(binary);
42
+ }
43
+ /**
44
+ * Decode a base64 string back to a `Uint8Array`.
45
+ */
46
+ function base64ToUint8(base64) {
47
+ const binary = atob(base64);
48
+ const bytes = new Uint8Array(binary.length);
49
+ for (let i = 0; i < binary.length; i++) {
50
+ bytes[i] = binary.charCodeAt(i);
51
+ }
52
+ return bytes;
53
+ }
54
+ // =============================================================================
55
+ // CRDTChannel Class
56
+ // =============================================================================
57
+ /**
58
+ * Manages the Supabase Broadcast channel for a single CRDT document.
59
+ *
60
+ * Handles update distribution, echo suppression, debouncing, chunking,
61
+ * the sync protocol, cross-tab sync, and reconnection.
62
+ *
63
+ * @internal — consumers interact via {@link ./provider.ts}, not this class directly.
64
+ */
65
+ export class CRDTChannel {
66
+ constructor(documentId, doc, onConnectionStateChange) {
67
+ /** Supabase Realtime channel instance. */
68
+ this.channel = null;
69
+ /** Browser BroadcastChannel for cross-tab sync (same device). */
70
+ this.localChannel = null;
71
+ /** Current connection state. */
72
+ this._connectionState = 'disconnected';
73
+ /** Callback invoked when connection state changes. */
74
+ this.onConnectionStateChange = null;
75
+ /** Initial presence info for Supabase Presence tracking. */
76
+ this.presenceInfo = null;
77
+ // --- Debounce state ---
78
+ this.pendingUpdates = [];
79
+ this.debounceTimer = null;
80
+ // --- Chunk reassembly state ---
81
+ this.chunkBuffers = new Map();
82
+ // --- Reconnection state ---
83
+ this.reconnectAttempts = 0;
84
+ this.reconnectTimer = null;
85
+ this.destroyed = false;
86
+ // --- Sync protocol state ---
87
+ this.syncResolvers = new Map();
88
+ this.documentId = documentId;
89
+ this.doc = doc;
90
+ this.deviceId = getDeviceId();
91
+ this.channelName = `crdt:${getCRDTPrefix()}:${documentId}`;
92
+ this.onConnectionStateChange = onConnectionStateChange ?? null;
93
+ }
94
+ // ===========================================================================
95
+ // Public API
96
+ // ===========================================================================
97
+ /** Current connection state of the channel. */
98
+ get connectionState() {
99
+ return this._connectionState;
100
+ }
101
+ /**
102
+ * Set the local user's presence info for Supabase Presence tracking.
103
+ *
104
+ * Call this before `join()` to announce presence immediately on channel subscribe,
105
+ * or after join to update the tracked presence.
106
+ */
107
+ setPresenceInfo(info) {
108
+ this.presenceInfo = info;
109
+ /* If already connected, track immediately. */
110
+ if (this.channel && this._connectionState === 'connected') {
111
+ this.trackPresence();
112
+ }
113
+ }
114
+ /**
115
+ * Join the Broadcast channel and start receiving messages.
116
+ *
117
+ * After subscribing, initiates the sync protocol by sending a sync-step-1
118
+ * message with the local state vector so peers can respond with deltas.
119
+ */
120
+ async join() {
121
+ if (this.destroyed)
122
+ return;
123
+ this.setConnectionState('connecting');
124
+ debugLog(`[CRDT] Channel ${this.channelName} joining`);
125
+ /* Create Supabase Broadcast channel. */
126
+ this.channel = supabase.channel(this.channelName, {
127
+ config: { broadcast: { self: false } }
128
+ });
129
+ /* Listen for Broadcast messages. */
130
+ this.channel.on('broadcast', { event: 'crdt' }, (payload) => {
131
+ this.handleBroadcastMessage(payload.payload);
132
+ });
133
+ /* Listen for Presence events (join/leave). */
134
+ this.channel.on('presence', { event: 'join' }, ({ newPresences }) => {
135
+ for (const presence of newPresences) {
136
+ const state = presence;
137
+ if (state.deviceId !== this.deviceId) {
138
+ handlePresenceJoin(this.documentId, state);
139
+ }
140
+ }
141
+ });
142
+ this.channel.on('presence', { event: 'leave' }, ({ leftPresences }) => {
143
+ for (const presence of leftPresences) {
144
+ const state = presence;
145
+ handlePresenceLeave(this.documentId, state.userId, state.deviceId);
146
+ }
147
+ });
148
+ /* Subscribe to the channel. */
149
+ this.channel.subscribe((status) => {
150
+ if (status === 'SUBSCRIBED') {
151
+ this.setConnectionState('connected');
152
+ this.reconnectAttempts = 0;
153
+ debugLog(`[CRDT] Channel ${this.channelName} subscribed`);
154
+ /* Track presence if info is available. */
155
+ this.trackPresence();
156
+ /* Initiate sync protocol — request missing updates from peers. */
157
+ this.sendSyncStep1();
158
+ }
159
+ else if (status === 'CHANNEL_ERROR') {
160
+ debugWarn(`[CRDT] Channel ${this.channelName} error`);
161
+ this.handleDisconnect();
162
+ }
163
+ else if (status === 'CLOSED') {
164
+ this.setConnectionState('disconnected');
165
+ }
166
+ });
167
+ /* Set up cross-tab sync via browser BroadcastChannel API. */
168
+ this.setupLocalChannel();
169
+ }
170
+ /**
171
+ * Leave the channel and clean up all resources.
172
+ */
173
+ async leave() {
174
+ this.destroyed = true;
175
+ debugLog(`[CRDT] Channel ${this.channelName} leaving`);
176
+ /* Clear debounce timer. */
177
+ if (this.debounceTimer) {
178
+ clearTimeout(this.debounceTimer);
179
+ this.debounceTimer = null;
180
+ }
181
+ /* Clear reconnect timer. */
182
+ if (this.reconnectTimer) {
183
+ clearTimeout(this.reconnectTimer);
184
+ this.reconnectTimer = null;
185
+ }
186
+ /* Flush any pending updates before leaving. */
187
+ if (this.pendingUpdates.length > 0) {
188
+ this.flushUpdates();
189
+ }
190
+ /* Unsubscribe from Supabase channel. */
191
+ if (this.channel) {
192
+ await supabase.removeChannel(this.channel);
193
+ this.channel = null;
194
+ }
195
+ /* Close browser BroadcastChannel. */
196
+ if (this.localChannel) {
197
+ this.localChannel.close();
198
+ this.localChannel = null;
199
+ }
200
+ this.setConnectionState('disconnected');
201
+ this.chunkBuffers.clear();
202
+ this.syncResolvers.clear();
203
+ }
204
+ /**
205
+ * Queue a Yjs update for broadcasting to remote peers.
206
+ *
207
+ * Updates are debounced for `broadcastDebounceMs` (default 100ms) and merged
208
+ * via `Y.mergeUpdates()` before sending. This reduces network traffic for
209
+ * rapid keystrokes while keeping latency under 100ms.
210
+ *
211
+ * @param update - The Yjs update delta from `doc.on('update')`.
212
+ */
213
+ broadcastUpdate(update) {
214
+ if (this.destroyed || this._connectionState !== 'connected')
215
+ return;
216
+ this.pendingUpdates.push(update);
217
+ /* Also broadcast to same-device tabs immediately (no debounce). */
218
+ this.localChannel?.postMessage({
219
+ type: 'update',
220
+ data: uint8ToBase64(update),
221
+ deviceId: this.deviceId
222
+ });
223
+ /* Debounce the Supabase Broadcast send. */
224
+ if (!this.debounceTimer) {
225
+ const config = getCRDTConfig();
226
+ this.debounceTimer = setTimeout(() => {
227
+ this.debounceTimer = null;
228
+ this.flushUpdates();
229
+ }, config.broadcastDebounceMs);
230
+ }
231
+ }
232
+ /**
233
+ * Wait for the sync protocol to complete after joining.
234
+ *
235
+ * Resolves when at least one peer responds with sync-step-2, or times out
236
+ * after `syncPeerTimeoutMs` (default 3s) if no peers are available.
237
+ *
238
+ * @returns `true` if a peer responded, `false` if timed out (no peers).
239
+ */
240
+ waitForSync() {
241
+ const config = getCRDTConfig();
242
+ return new Promise((resolve) => {
243
+ const key = `sync-${Date.now()}`;
244
+ let resolved = false;
245
+ this.syncResolvers.set(key, () => {
246
+ if (!resolved) {
247
+ resolved = true;
248
+ resolve(true);
249
+ }
250
+ });
251
+ setTimeout(() => {
252
+ this.syncResolvers.delete(key);
253
+ if (!resolved) {
254
+ resolved = true;
255
+ debugLog(`[CRDT] Document ${this.documentId}: no peers responded within ${config.syncPeerTimeoutMs}ms, fetching from Supabase`);
256
+ resolve(false);
257
+ }
258
+ }, config.syncPeerTimeoutMs);
259
+ });
260
+ }
261
+ // ===========================================================================
262
+ // Message Handling
263
+ // ===========================================================================
264
+ /**
265
+ * Handle an incoming Broadcast message from a remote peer.
266
+ *
267
+ * Dispatches to type-specific handlers and performs echo suppression
268
+ * (skip messages from our own device).
269
+ */
270
+ handleBroadcastMessage(message) {
271
+ /* Echo suppression — skip messages from our own device. */
272
+ if (message.deviceId === this.deviceId) {
273
+ debugLog(`[CRDT] Document ${this.documentId}: skipped own-device echo (deviceId=${this.deviceId})`);
274
+ return;
275
+ }
276
+ switch (message.type) {
277
+ case 'update':
278
+ this.handleRemoteUpdate(message);
279
+ break;
280
+ case 'sync-step-1':
281
+ this.handleSyncStep1(message);
282
+ break;
283
+ case 'sync-step-2':
284
+ this.handleSyncStep2(message);
285
+ break;
286
+ case 'chunk':
287
+ this.handleChunk(message);
288
+ break;
289
+ }
290
+ }
291
+ /**
292
+ * Apply a remote Yjs update to the local document.
293
+ */
294
+ handleRemoteUpdate(message) {
295
+ const update = base64ToUint8(message.data);
296
+ debugLog(`[CRDT] Document ${this.documentId}: received remote update from device ${message.deviceId} (${update.byteLength} bytes)`);
297
+ Y.applyUpdate(this.doc, update);
298
+ }
299
+ /**
300
+ * Handle sync-step-1: a peer is requesting missing updates.
301
+ *
302
+ * We compute the delta between our state and their state vector,
303
+ * then send it back as sync-step-2.
304
+ */
305
+ handleSyncStep1(message) {
306
+ const remoteStateVector = base64ToUint8(message.stateVector);
307
+ const update = Y.encodeStateAsUpdate(this.doc, remoteStateVector);
308
+ if (update.byteLength > 0) {
309
+ debugLog(`[CRDT] Document ${this.documentId}: sync-step-2 sent to ${message.deviceId} (${update.byteLength} bytes)`);
310
+ this.sendMessage({
311
+ type: 'sync-step-2',
312
+ update: uint8ToBase64(update),
313
+ deviceId: this.deviceId
314
+ });
315
+ }
316
+ }
317
+ /**
318
+ * Handle sync-step-2: a peer responded to our sync-step-1 with a delta.
319
+ */
320
+ handleSyncStep2(message) {
321
+ const update = base64ToUint8(message.update);
322
+ debugLog(`[CRDT] Document ${this.documentId}: sync-step-2 received from ${message.deviceId} (${update.byteLength} bytes)`);
323
+ Y.applyUpdate(this.doc, update);
324
+ /* Resolve any pending sync waiters. */
325
+ for (const resolver of this.syncResolvers.values()) {
326
+ resolver();
327
+ }
328
+ this.syncResolvers.clear();
329
+ }
330
+ /**
331
+ * Handle a chunk message — part of a large payload that was split.
332
+ *
333
+ * Buffers chunks until all parts arrive, then reassembles and processes
334
+ * the full payload as a regular message.
335
+ */
336
+ handleChunk(message) {
337
+ const { chunkId, index, total, data } = message;
338
+ let buffer = this.chunkBuffers.get(chunkId);
339
+ if (!buffer) {
340
+ buffer = { total, chunks: new Map() };
341
+ this.chunkBuffers.set(chunkId, buffer);
342
+ }
343
+ buffer.chunks.set(index, data);
344
+ /* Check if all chunks have arrived. */
345
+ if (buffer.chunks.size === buffer.total) {
346
+ /* Reassemble in order. */
347
+ let fullBase64 = '';
348
+ for (let i = 0; i < buffer.total; i++) {
349
+ fullBase64 += buffer.chunks.get(i) ?? '';
350
+ }
351
+ this.chunkBuffers.delete(chunkId);
352
+ /* Process as an update message. */
353
+ const update = base64ToUint8(fullBase64);
354
+ debugLog(`[CRDT] Document ${this.documentId}: reassembled ${buffer.total} chunks (${update.byteLength} bytes)`);
355
+ Y.applyUpdate(this.doc, update);
356
+ }
357
+ }
358
+ // ===========================================================================
359
+ // Outbound Message Sending
360
+ // ===========================================================================
361
+ /**
362
+ * Flush all pending updates: merge, encode, and send via Broadcast.
363
+ *
364
+ * If the merged payload exceeds the max size, it is chunked.
365
+ */
366
+ flushUpdates() {
367
+ if (this.pendingUpdates.length === 0)
368
+ return;
369
+ const updates = this.pendingUpdates;
370
+ this.pendingUpdates = [];
371
+ /* Merge all buffered updates into a single binary payload. */
372
+ const merged = updates.length === 1 ? updates[0] : Y.mergeUpdates(updates);
373
+ const config = getCRDTConfig();
374
+ debugLog(`[CRDT] Document ${this.documentId}: ${updates.length} updates buffered (${merged.byteLength} bytes), broadcasting`);
375
+ const base64Data = uint8ToBase64(merged);
376
+ if (base64Data.length > config.maxBroadcastPayloadBytes) {
377
+ /* Payload too large — chunk it. */
378
+ this.sendChunked(base64Data);
379
+ }
380
+ else {
381
+ this.sendMessage({
382
+ type: 'update',
383
+ data: base64Data,
384
+ deviceId: this.deviceId
385
+ });
386
+ }
387
+ }
388
+ /**
389
+ * Send sync-step-1 to request missing updates from connected peers.
390
+ */
391
+ sendSyncStep1() {
392
+ const stateVector = Y.encodeStateVector(this.doc);
393
+ debugLog(`[CRDT] Document ${this.documentId}: sync-step-1 sent (stateVector ${stateVector.byteLength} bytes)`);
394
+ this.sendMessage({
395
+ type: 'sync-step-1',
396
+ stateVector: uint8ToBase64(stateVector),
397
+ deviceId: this.deviceId
398
+ });
399
+ }
400
+ /**
401
+ * Send a message via the Supabase Broadcast channel.
402
+ */
403
+ sendMessage(message) {
404
+ if (!this.channel || this._connectionState !== 'connected')
405
+ return;
406
+ this.channel.send({
407
+ type: 'broadcast',
408
+ event: 'crdt',
409
+ payload: message
410
+ });
411
+ }
412
+ /**
413
+ * Split a large base64 payload into chunks and send each one.
414
+ */
415
+ sendChunked(base64Data) {
416
+ const config = getCRDTConfig();
417
+ /* Use ~200KB per chunk to stay safely below the limit. */
418
+ const chunkSize = Math.floor(config.maxBroadcastPayloadBytes * 0.8);
419
+ const totalChunks = Math.ceil(base64Data.length / chunkSize);
420
+ const chunkId = `${this.deviceId}-${Date.now()}`;
421
+ debugWarn(`[CRDT] Document ${this.documentId}: chunking broadcast payload (${base64Data.length} bytes > ${config.maxBroadcastPayloadBytes} bytes, ${totalChunks} chunks)`);
422
+ for (let i = 0; i < totalChunks; i++) {
423
+ const start = i * chunkSize;
424
+ const end = Math.min(start + chunkSize, base64Data.length);
425
+ this.sendMessage({
426
+ type: 'chunk',
427
+ chunkId,
428
+ index: i,
429
+ total: totalChunks,
430
+ data: base64Data.slice(start, end),
431
+ deviceId: this.deviceId
432
+ });
433
+ }
434
+ }
435
+ // ===========================================================================
436
+ // Cross-Tab Sync (Browser BroadcastChannel)
437
+ // ===========================================================================
438
+ /**
439
+ * Set up the browser BroadcastChannel for same-device tab sync.
440
+ *
441
+ * This avoids Supabase Broadcast for updates between tabs on the same device,
442
+ * which is faster and doesn't consume any network bandwidth.
443
+ */
444
+ setupLocalChannel() {
445
+ if (typeof BroadcastChannel === 'undefined')
446
+ return;
447
+ this.localChannel = new BroadcastChannel(this.channelName);
448
+ this.localChannel.onmessage = (event) => {
449
+ const message = event.data;
450
+ /* Skip our own messages (same tab). */
451
+ if (message.deviceId === this.deviceId)
452
+ return;
453
+ /* Apply update from another tab on the same device. */
454
+ if (message.type === 'update') {
455
+ const update = base64ToUint8(message.data);
456
+ Y.applyUpdate(this.doc, update);
457
+ }
458
+ };
459
+ }
460
+ // ===========================================================================
461
+ // Reconnection
462
+ // ===========================================================================
463
+ /**
464
+ * Handle a channel disconnect — attempt reconnection with exponential backoff.
465
+ */
466
+ handleDisconnect() {
467
+ if (this.destroyed)
468
+ return;
469
+ this.setConnectionState('disconnected');
470
+ const config = getCRDTConfig();
471
+ if (this.reconnectAttempts >= config.maxReconnectAttempts) {
472
+ debugWarn(`[CRDT] Channel ${this.channelName} max reconnect attempts reached (${config.maxReconnectAttempts})`);
473
+ return;
474
+ }
475
+ this.reconnectAttempts++;
476
+ const delay = config.reconnectBaseDelayMs * Math.pow(2, this.reconnectAttempts - 1);
477
+ debugLog(`[CRDT] Channel ${this.channelName} reconnecting (attempt ${this.reconnectAttempts}/${config.maxReconnectAttempts}, delay ${delay}ms)`);
478
+ this.reconnectTimer = setTimeout(async () => {
479
+ this.reconnectTimer = null;
480
+ if (this.destroyed)
481
+ return;
482
+ /* Clean up old channel before rejoining. */
483
+ if (this.channel) {
484
+ await supabase.removeChannel(this.channel);
485
+ this.channel = null;
486
+ }
487
+ await this.join();
488
+ }, delay);
489
+ }
490
+ // ===========================================================================
491
+ // State Management
492
+ // ===========================================================================
493
+ /**
494
+ * Track the local user's presence on the Supabase Presence channel.
495
+ *
496
+ * Sends the user's name, avatar, color, and device ID so other collaborators
497
+ * can display cursor badges and avatar lists.
498
+ */
499
+ trackPresence() {
500
+ if (!this.channel || !this.presenceInfo || this._connectionState !== 'connected')
501
+ return;
502
+ const presenceState = {
503
+ userId: this.deviceId, // Will be replaced with actual userId when auth is available
504
+ name: this.presenceInfo.name,
505
+ avatarUrl: this.presenceInfo.avatarUrl,
506
+ color: assignColor(this.deviceId),
507
+ deviceId: this.deviceId,
508
+ lastActiveAt: new Date().toISOString()
509
+ };
510
+ this.channel.track(presenceState);
511
+ }
512
+ /**
513
+ * Update the connection state and notify the listener.
514
+ */
515
+ setConnectionState(state) {
516
+ if (this._connectionState === state)
517
+ return;
518
+ this._connectionState = state;
519
+ this.onConnectionStateChange?.(state);
520
+ }
521
+ }
522
+ //# sourceMappingURL=channel.js.map