@defensestation/ysync-go 0.1.0-dev.3.3d87b03

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.
@@ -0,0 +1,335 @@
1
+ import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate } from "y-protocols/awareness";
2
+ import * as Y from "yjs";
3
+ import { makeClientId } from "./id.js";
4
+ import type { YjsProviderIdentity, YjsProviderIdentityListener, YjsProviderStatus, YjsProviderStatusListener } from "./provider-status.js";
5
+
6
+ const REMOTE_ORIGIN = "websocket-remote";
7
+ const DEFAULT_RECONNECT_INITIAL_DELAY_MS = 500;
8
+ const DEFAULT_RECONNECT_MAX_DELAY_MS = 5000;
9
+ const DEFAULT_AWARENESS_THROTTLE_MS = 150;
10
+
11
+ type WebSocketMessage = {
12
+ type: string;
13
+ docId?: string;
14
+ clientId?: string;
15
+ senderClientId?: string;
16
+ sequence?: number;
17
+ stateVector?: string;
18
+ update?: string;
19
+ errorCode?: string;
20
+ errorMessage?: string;
21
+ userId?: string;
22
+ userName?: string;
23
+ userColor?: string;
24
+ };
25
+
26
+ export interface WebSocketYjsProviderOptions {
27
+ ydoc: Y.Doc;
28
+ docId: string;
29
+ url?: string;
30
+ baseUrl?: string;
31
+ clientId?: string;
32
+ user: {
33
+ name: string;
34
+ color?: string;
35
+ };
36
+ reconnectInitialDelayMs?: number;
37
+ reconnectMaxDelayMs?: number;
38
+ awarenessThrottleMs?: number;
39
+ }
40
+
41
+ export class WebSocketYjsProvider {
42
+ readonly awareness: Awareness;
43
+ readonly clientId: string;
44
+
45
+ private readonly options: Required<
46
+ Pick<
47
+ WebSocketYjsProviderOptions,
48
+ "reconnectInitialDelayMs" | "reconnectMaxDelayMs" | "awarenessThrottleMs"
49
+ >
50
+ > &
51
+ WebSocketYjsProviderOptions;
52
+ private socket?: WebSocket;
53
+ private pending: WebSocketMessage[] = [];
54
+ private reconnectTimer?: ReturnType<typeof setTimeout>;
55
+ private awarenessSendTimer?: ReturnType<typeof setTimeout>;
56
+ private awarenessDirty = false;
57
+ private reconnectDelayMs: number;
58
+ private destroyed = false;
59
+ private currentStatus: YjsProviderStatus = "connecting";
60
+ private readonly statusListeners = new Set<YjsProviderStatusListener>();
61
+ private currentIdentity?: YjsProviderIdentity;
62
+ private readonly identityListeners = new Set<YjsProviderIdentityListener>();
63
+
64
+ private readonly yjsUpdateHandler = (update: Uint8Array, origin: unknown) => {
65
+ if (origin === REMOTE_ORIGIN) {
66
+ return;
67
+ }
68
+ this.send({
69
+ type: "yjs_update",
70
+ docId: this.options.docId,
71
+ clientId: this.clientId,
72
+ update: bytesToBase64(update),
73
+ });
74
+ };
75
+
76
+ // Only push changes to the LOCAL awareness state. Remote states arrive from
77
+ // the server already fanned out; re-broadcasting them would multiply every
78
+ // cursor move by the number of connected clients.
79
+ private readonly awarenessUpdateHandler = ({ added, updated, removed }: {
80
+ added: number[];
81
+ updated: number[];
82
+ removed: number[];
83
+ }) => {
84
+ const local = this.awareness.clientID;
85
+ if (!added.includes(local) && !updated.includes(local) && !removed.includes(local)) {
86
+ return;
87
+ }
88
+ this.scheduleAwarenessSend();
89
+ };
90
+
91
+ constructor(options: WebSocketYjsProviderOptions) {
92
+ this.options = {
93
+ ...options,
94
+ reconnectInitialDelayMs:
95
+ options.reconnectInitialDelayMs ?? DEFAULT_RECONNECT_INITIAL_DELAY_MS,
96
+ reconnectMaxDelayMs:
97
+ options.reconnectMaxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS,
98
+ awarenessThrottleMs:
99
+ options.awarenessThrottleMs ?? DEFAULT_AWARENESS_THROTTLE_MS,
100
+ };
101
+ this.clientId = options.clientId ?? makeClientId();
102
+ this.reconnectDelayMs = this.options.reconnectInitialDelayMs;
103
+ this.awareness = new Awareness(options.ydoc);
104
+ this.awareness.setLocalStateField("user", {
105
+ name: options.user.name,
106
+ color: options.user.color,
107
+ });
108
+ this.options.ydoc.on("update", this.yjsUpdateHandler);
109
+ // No extra heartbeat timer: y-protocols renews the local awareness state
110
+ // (bumping its clock) roughly every 15s while idle, which fires this
111
+ // handler. A fixed-interval resend of the same clock would be ignored by
112
+ // receivers anyway.
113
+ this.awareness.on("update", this.awarenessUpdateHandler);
114
+ this.connect();
115
+ }
116
+
117
+ get status(): YjsProviderStatus {
118
+ return this.currentStatus;
119
+ }
120
+
121
+ // Server-assigned presence identity; undefined until the join is
122
+ // acknowledged.
123
+ get identity(): YjsProviderIdentity | undefined {
124
+ return this.currentIdentity;
125
+ }
126
+
127
+ // Returns an unsubscribe function.
128
+ onIdentity(listener: YjsProviderIdentityListener): () => void {
129
+ this.identityListeners.add(listener);
130
+ return () => this.identityListeners.delete(listener);
131
+ }
132
+
133
+ private applyIdentity(name?: string, color?: string) {
134
+ if (!name && !color) return;
135
+ const identity: YjsProviderIdentity = {
136
+ name: name || this.options.user.name,
137
+ color: color ?? this.options.user.color,
138
+ };
139
+ this.currentIdentity = identity;
140
+ this.awareness.setLocalStateField("user", { name: identity.name, color: identity.color });
141
+ for (const listener of this.identityListeners) listener(identity);
142
+ }
143
+
144
+ // Returns an unsubscribe function.
145
+ onStatus(listener: YjsProviderStatusListener): () => void {
146
+ this.statusListeners.add(listener);
147
+ return () => this.statusListeners.delete(listener);
148
+ }
149
+
150
+ private setStatus(status: YjsProviderStatus) {
151
+ if (this.currentStatus === status) return;
152
+ this.currentStatus = status;
153
+ for (const listener of this.statusListeners) listener(status);
154
+ }
155
+
156
+ destroy() {
157
+ if (this.destroyed) return;
158
+ this.options.ydoc.off("update", this.yjsUpdateHandler);
159
+ this.awareness.off("update", this.awarenessUpdateHandler);
160
+ // Announce departure while the socket is still usable so peers drop the
161
+ // cursor immediately instead of after the 30s awareness timeout.
162
+ this.awareness.setLocalState(null);
163
+ if (this.socket?.readyState === WebSocket.OPEN) {
164
+ this.sendNow({
165
+ type: "awareness_update",
166
+ docId: this.options.docId,
167
+ clientId: this.clientId,
168
+ update: bytesToBase64(encodeAwarenessUpdate(this.awareness, [this.awareness.clientID])),
169
+ });
170
+ this.sendNow({ type: "leave", docId: this.options.docId, clientId: this.clientId });
171
+ }
172
+ this.destroyed = true;
173
+ this.setStatus("disconnected");
174
+ if (this.reconnectTimer) {
175
+ clearTimeout(this.reconnectTimer);
176
+ }
177
+ if (this.awarenessSendTimer) {
178
+ clearTimeout(this.awarenessSendTimer);
179
+ }
180
+ this.socket?.close();
181
+ }
182
+
183
+ private connect() {
184
+ if (this.destroyed) {
185
+ return;
186
+ }
187
+ this.setStatus("connecting");
188
+ const socket = new WebSocket(this.options.url ?? websocketURL(this.options.baseUrl));
189
+ this.socket = socket;
190
+ socket.addEventListener("open", () => {
191
+ this.setStatus("connected");
192
+ this.reconnectDelayMs = this.options.reconnectInitialDelayMs;
193
+ this.sendNow({
194
+ type: "join",
195
+ docId: this.options.docId,
196
+ clientId: this.clientId,
197
+ stateVector: bytesToBase64(Y.encodeStateVector(this.options.ydoc)),
198
+ userName: this.options.user.name,
199
+ userColor: this.options.user.color,
200
+ });
201
+ this.sendLocalAwarenessNow();
202
+ for (const message of this.pending.splice(0)) {
203
+ this.sendNow(message);
204
+ }
205
+ });
206
+ socket.addEventListener("message", (event) => {
207
+ if (typeof event.data !== "string") {
208
+ return;
209
+ }
210
+ this.applyMessage(JSON.parse(event.data) as WebSocketMessage);
211
+ });
212
+ socket.addEventListener("close", () => {
213
+ if (this.socket === socket) {
214
+ this.socket = undefined;
215
+ }
216
+ this.setStatus("disconnected");
217
+ this.scheduleReconnect();
218
+ });
219
+ socket.addEventListener("error", () => {
220
+ socket.close();
221
+ });
222
+ }
223
+
224
+ private scheduleReconnect() {
225
+ if (this.destroyed || this.reconnectTimer) {
226
+ return;
227
+ }
228
+ const delay = this.reconnectDelayMs;
229
+ this.reconnectDelayMs = Math.min(
230
+ this.reconnectDelayMs * 2,
231
+ this.options.reconnectMaxDelayMs,
232
+ );
233
+ this.reconnectTimer = setTimeout(() => {
234
+ this.reconnectTimer = undefined;
235
+ this.connect();
236
+ }, delay);
237
+ }
238
+
239
+ private send(message: WebSocketMessage) {
240
+ if (this.socket?.readyState === WebSocket.OPEN) {
241
+ this.sendNow(message);
242
+ return;
243
+ }
244
+ this.pending.push(message);
245
+ }
246
+
247
+ private sendNow(message: WebSocketMessage) {
248
+ this.socket?.send(JSON.stringify(message));
249
+ }
250
+
251
+ // Leading + trailing throttle: the first change sends immediately, changes
252
+ // arriving inside the window (e.g. cursor movement while typing or
253
+ // selecting) collapse into one trailing send.
254
+ private scheduleAwarenessSend() {
255
+ if (this.destroyed) {
256
+ return;
257
+ }
258
+ if (this.awarenessSendTimer) {
259
+ this.awarenessDirty = true;
260
+ return;
261
+ }
262
+ this.sendLocalAwarenessNow();
263
+ this.awarenessSendTimer = setTimeout(() => {
264
+ this.awarenessSendTimer = undefined;
265
+ if (this.awarenessDirty) {
266
+ this.awarenessDirty = false;
267
+ this.scheduleAwarenessSend();
268
+ }
269
+ }, this.options.awarenessThrottleMs);
270
+ }
271
+
272
+ private sendLocalAwarenessNow() {
273
+ if (this.destroyed) {
274
+ return;
275
+ }
276
+ this.send({
277
+ type: "awareness_update",
278
+ docId: this.options.docId,
279
+ clientId: this.clientId,
280
+ update: bytesToBase64(encodeAwarenessUpdate(this.awareness, [this.awareness.clientID])),
281
+ });
282
+ }
283
+
284
+ private applyMessage(message: WebSocketMessage) {
285
+ switch (message.type) {
286
+ case "joined":
287
+ this.applyIdentity(message.userName, message.userColor);
288
+ break;
289
+ case "sync_update":
290
+ case "yjs_update":
291
+ if (message.update) {
292
+ Y.applyUpdate(this.options.ydoc, base64ToBytes(message.update), REMOTE_ORIGIN);
293
+ }
294
+ break;
295
+ case "awareness_update":
296
+ if (message.update) {
297
+ applyAwarenessUpdate(this.awareness, base64ToBytes(message.update), REMOTE_ORIGIN);
298
+ }
299
+ break;
300
+ case "error":
301
+ console.error("Yjs WebSocket server error", message.errorCode, message.errorMessage);
302
+ break;
303
+ }
304
+ }
305
+ }
306
+
307
+ function websocketURL(baseUrl?: string): string {
308
+ const fallback =
309
+ typeof window !== "undefined" && window.location?.origin
310
+ ? window.location.origin
311
+ : "http://localhost:8080";
312
+ const url = new URL(baseUrl ?? fallback);
313
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
314
+ url.pathname = "/ysync/ws";
315
+ url.search = "";
316
+ url.hash = "";
317
+ return url.toString();
318
+ }
319
+
320
+ function bytesToBase64(bytes: Uint8Array): string {
321
+ let binary = "";
322
+ for (let i = 0; i < bytes.length; i += 0x8000) {
323
+ binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
324
+ }
325
+ return btoa(binary);
326
+ }
327
+
328
+ function base64ToBytes(base64: string): Uint8Array {
329
+ const binary = atob(base64);
330
+ const bytes = new Uint8Array(binary.length);
331
+ for (let i = 0; i < binary.length; i++) {
332
+ bytes[i] = binary.charCodeAt(i);
333
+ }
334
+ return bytes;
335
+ }