@sqlrooms/crdt 0.27.0-rc.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/LICENSE.md +9 -0
- package/README.md +74 -0
- package/dist/createCrdtSlice.d.ts +88 -0
- package/dist/createCrdtSlice.d.ts.map +1 -0
- package/dist/createCrdtSlice.js +222 -0
- package/dist/createCrdtSlice.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/storages/indexedDbStorage.d.ts +26 -0
- package/dist/storages/indexedDbStorage.d.ts.map +1 -0
- package/dist/storages/indexedDbStorage.js +87 -0
- package/dist/storages/indexedDbStorage.js.map +1 -0
- package/dist/storages/localStorageStorage.d.ts +7 -0
- package/dist/storages/localStorageStorage.d.ts.map +1 -0
- package/dist/storages/localStorageStorage.js +44 -0
- package/dist/storages/localStorageStorage.js.map +1 -0
- package/dist/sync/webSocketSyncConnector.d.ts +38 -0
- package/dist/sync/webSocketSyncConnector.d.ts.map +1 -0
- package/dist/sync/webSocketSyncConnector.js +500 -0
- package/dist/sync/webSocketSyncConnector.js.map +1 -0
- package/dist/type-helpers.d.ts +28 -0
- package/dist/type-helpers.d.ts.map +1 -0
- package/dist/type-helpers.js +10 -0
- package/dist/type-helpers.js.map +1 -0
- package/package.json +45 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
function toBase64(bytes) {
|
|
2
|
+
let binary = '';
|
|
3
|
+
bytes.forEach((b) => {
|
|
4
|
+
binary += String.fromCharCode(b);
|
|
5
|
+
});
|
|
6
|
+
return btoa(binary);
|
|
7
|
+
}
|
|
8
|
+
function fromBase64(encoded) {
|
|
9
|
+
const binary = atob(encoded);
|
|
10
|
+
const bytes = new Uint8Array(binary.length);
|
|
11
|
+
for (let i = 0; i < binary.length; i += 1) {
|
|
12
|
+
bytes[i] = binary.charCodeAt(i);
|
|
13
|
+
}
|
|
14
|
+
return bytes;
|
|
15
|
+
}
|
|
16
|
+
export function createLocalStorageDocStorage({ key, }) {
|
|
17
|
+
return {
|
|
18
|
+
async load() {
|
|
19
|
+
if (typeof window === 'undefined')
|
|
20
|
+
return undefined;
|
|
21
|
+
const raw = window.localStorage.getItem(key);
|
|
22
|
+
if (!raw)
|
|
23
|
+
return undefined;
|
|
24
|
+
try {
|
|
25
|
+
return fromBase64(raw);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
console.warn('Failed to decode CRDT snapshot', error);
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
async save(data) {
|
|
33
|
+
if (typeof window === 'undefined')
|
|
34
|
+
return;
|
|
35
|
+
try {
|
|
36
|
+
window.localStorage.setItem(key, toBase64(data));
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
console.warn('Failed to persist CRDT snapshot', error);
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=localStorageStorage.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"localStorageStorage.js","sourceRoot":"","sources":["../../src/storages/localStorageStorage.ts"],"names":[],"mappings":"AAEA,SAAS,QAAQ,CAAC,KAAiB;IACjC,IAAI,MAAM,GAAG,EAAE,CAAC;IAChB,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE;QAClB,MAAM,IAAI,MAAM,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IACnC,CAAC,CAAC,CAAC;IACH,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC;AACtB,CAAC;AAED,SAAS,UAAU,CAAC,OAAe;IACjC,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,CAAC;IAC7B,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAC5C,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QAC1C,KAAK,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;IAClC,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAMD,MAAM,UAAU,4BAA4B,CAAC,EAC3C,GAAG,GAC2B;IAC9B,OAAO;QACL,KAAK,CAAC,IAAI;YACR,IAAI,OAAO,MAAM,KAAK,WAAW;gBAAE,OAAO,SAAS,CAAC;YACpD,MAAM,GAAG,GAAG,MAAM,CAAC,YAAY,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;YAC7C,IAAI,CAAC,GAAG;gBAAE,OAAO,SAAS,CAAC;YAC3B,IAAI,CAAC;gBACH,OAAO,UAAU,CAAC,GAAG,CAAC,CAAC;YACzB,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,OAAO,CAAC,IAAI,CAAC,gCAAgC,EAAE,KAAK,CAAC,CAAC;gBACtD,OAAO,SAAS,CAAC;YACnB,CAAC;QACH,CAAC;QACD,KAAK,CAAC,IAAI,CAAC,IAAgB;YACzB,IAAI,OAAO,MAAM,KAAK,WAAW;gBAAE,OAAO;YAC1C,IAAI,CAAC;gBACH,MAAM,CAAC,YAAY,CAAC,OAAO,CAAC,GAAG,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC;YACnD,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,OAAO,CAAC,IAAI,CAAC,iCAAiC,EAAE,KAAK,CAAC,CAAC;YACzD,CAAC;QACH,CAAC;KACF,CAAC;AACJ,CAAC","sourcesContent":["import {CrdtDocStorage} from '../createCrdtSlice';\n\nfunction toBase64(bytes: Uint8Array): string {\n let binary = '';\n bytes.forEach((b) => {\n binary += String.fromCharCode(b);\n });\n return btoa(binary);\n}\n\nfunction fromBase64(encoded: string): Uint8Array {\n const binary = atob(encoded);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i += 1) {\n bytes[i] = binary.charCodeAt(i);\n }\n return bytes;\n}\n\ntype LocalStorageDocStorageOptions = {\n key: string;\n};\n\nexport function createLocalStorageDocStorage({\n key,\n}: LocalStorageDocStorageOptions): CrdtDocStorage {\n return {\n async load() {\n if (typeof window === 'undefined') return undefined;\n const raw = window.localStorage.getItem(key);\n if (!raw) return undefined;\n try {\n return fromBase64(raw);\n } catch (error) {\n console.warn('Failed to decode CRDT snapshot', error);\n return undefined;\n }\n },\n async save(data: Uint8Array) {\n if (typeof window === 'undefined') return;\n try {\n window.localStorage.setItem(key, toBase64(data));\n } catch (error) {\n console.warn('Failed to persist CRDT snapshot', error);\n }\n },\n };\n}\n"]}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { CrdtConnectionStatus, CrdtSyncConnector } from '../createCrdtSlice';
|
|
2
|
+
type WebSocketLike = {
|
|
3
|
+
readyState: number;
|
|
4
|
+
send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void;
|
|
5
|
+
close: () => void;
|
|
6
|
+
addEventListener: (type: string, listener: (...args: any[]) => void) => void;
|
|
7
|
+
removeEventListener: (type: string, listener: (...args: any[]) => void) => void;
|
|
8
|
+
};
|
|
9
|
+
export type WebSocketSyncOptions = {
|
|
10
|
+
url: string;
|
|
11
|
+
roomId: string;
|
|
12
|
+
token?: string;
|
|
13
|
+
params?: Record<string, string>;
|
|
14
|
+
protocols?: string | string[];
|
|
15
|
+
onStatus?: (status: Exclude<CrdtConnectionStatus, 'idle'>) => void;
|
|
16
|
+
createSocket?: (url: string, protocols?: string | string[]) => WebSocketLike;
|
|
17
|
+
sendSnapshotOnConnect?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Optional per-tab client identifier. If omitted, the connector generates one via
|
|
20
|
+
* `crypto.randomUUID()` and persists it in `sessionStorage` (per-tab) by default.
|
|
21
|
+
*/
|
|
22
|
+
clientId?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Storage key used for persisting the generated clientId in `sessionStorage`.
|
|
25
|
+
*
|
|
26
|
+
* @defaultValue `"sqlrooms-crdt-clientId"`
|
|
27
|
+
*/
|
|
28
|
+
clientIdStorageKey?: string;
|
|
29
|
+
maxRetries?: number;
|
|
30
|
+
initialDelayMs?: number;
|
|
31
|
+
maxDelayMs?: number;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Creates a CRDT sync connector that exchanges Loro updates over WebSocket.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createWebSocketSyncConnector(options: WebSocketSyncOptions): CrdtSyncConnector;
|
|
37
|
+
export {};
|
|
38
|
+
//# sourceMappingURL=webSocketSyncConnector.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webSocketSyncConnector.d.ts","sourceRoot":"","sources":["../../src/sync/webSocketSyncConnector.ts"],"names":[],"mappings":"AACA,OAAO,EAAC,oBAAoB,EAAE,iBAAiB,EAAC,MAAM,oBAAoB,CAAC;AAE3E,KAAK,aAAa,GAAG;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,GAAG,IAAI,GAAG,eAAe,KAAK,IAAI,CAAC;IACxE,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,gBAAgB,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,KAAK,IAAI,CAAC;IAC7E,mBAAmB,EAAE,CACnB,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,KAC/B,IAAI,CAAC;CACX,CAAC;AAsDF,MAAM,MAAM,oBAAoB,GAAG;IACjC,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC9B,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC,oBAAoB,EAAE,MAAM,CAAC,KAAK,IAAI,CAAC;IACnE,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,KAAK,aAAa,CAAC;IAC7E,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF;;GAEG;AACH,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,oBAAoB,GAC5B,iBAAiB,CAqdnB"}
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { LoroDoc } from 'loro-crdt';
|
|
2
|
+
const WS_OPEN = 1;
|
|
3
|
+
const WS_CONNECTING = 0;
|
|
4
|
+
const getOrCreateClientId = (storageKey) => {
|
|
5
|
+
try {
|
|
6
|
+
const ss = globalThis.sessionStorage;
|
|
7
|
+
if (ss) {
|
|
8
|
+
const existing = ss.getItem(storageKey);
|
|
9
|
+
if (existing)
|
|
10
|
+
return existing;
|
|
11
|
+
const created = globalThis.crypto?.randomUUID?.();
|
|
12
|
+
const id = created ?? `client-${Math.random().toString(16).slice(2)}`;
|
|
13
|
+
ss.setItem(storageKey, id);
|
|
14
|
+
return id;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// ignore
|
|
19
|
+
}
|
|
20
|
+
const created = globalThis.crypto?.randomUUID?.();
|
|
21
|
+
return created ?? `client-${Math.random().toString(16).slice(2)}`;
|
|
22
|
+
};
|
|
23
|
+
const toBase64 = (bytes) => {
|
|
24
|
+
const buf = globalThis.Buffer;
|
|
25
|
+
if (buf) {
|
|
26
|
+
return buf.from(bytes).toString('base64');
|
|
27
|
+
}
|
|
28
|
+
let binary = '';
|
|
29
|
+
bytes.forEach((b) => {
|
|
30
|
+
binary += String.fromCharCode(b);
|
|
31
|
+
});
|
|
32
|
+
return btoa(binary);
|
|
33
|
+
};
|
|
34
|
+
const fromBase64 = (value) => {
|
|
35
|
+
const buf = globalThis.Buffer;
|
|
36
|
+
if (buf) {
|
|
37
|
+
return Uint8Array.from(buf.from(value, 'base64'));
|
|
38
|
+
}
|
|
39
|
+
const binary = atob(value);
|
|
40
|
+
const out = new Uint8Array(binary.length);
|
|
41
|
+
for (let i = 0; i < binary.length; i += 1)
|
|
42
|
+
out[i] = binary.charCodeAt(i);
|
|
43
|
+
return out;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Creates a CRDT sync connector that exchanges Loro updates over WebSocket.
|
|
47
|
+
*/
|
|
48
|
+
export function createWebSocketSyncConnector(options) {
|
|
49
|
+
const sendSnapshotOnConnect = options.sendSnapshotOnConnect ?? true;
|
|
50
|
+
const clientId = options.clientId ??
|
|
51
|
+
getOrCreateClientId(options.clientIdStorageKey ?? `sqlrooms-crdt-clientId:${options.roomId}`);
|
|
52
|
+
let socket;
|
|
53
|
+
let unsubscribeLocal;
|
|
54
|
+
let subscribedDoc;
|
|
55
|
+
let attempt = 0;
|
|
56
|
+
let stopped = false;
|
|
57
|
+
let reconnectTimer;
|
|
58
|
+
let joined = false;
|
|
59
|
+
let snapshotApplied = false;
|
|
60
|
+
let snapshotWaitTimer;
|
|
61
|
+
let seededAfterEmptyServerSnapshot = false;
|
|
62
|
+
let connecting = false;
|
|
63
|
+
const pending = [];
|
|
64
|
+
let localSubscribed = false;
|
|
65
|
+
let listeningSocket;
|
|
66
|
+
let detachSocketListeners;
|
|
67
|
+
let statusListener;
|
|
68
|
+
const maxRetries = options.maxRetries ?? Infinity;
|
|
69
|
+
const initialDelay = options.initialDelayMs ?? 500;
|
|
70
|
+
const maxDelay = options.maxDelayMs ?? 5000;
|
|
71
|
+
const maybeSendUpdate = (update) => {
|
|
72
|
+
if (!socket || socket.readyState !== WS_OPEN) {
|
|
73
|
+
pending.push(update);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!joined) {
|
|
77
|
+
pending.push(update);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// IMPORTANT: don't send local updates until we've applied the server snapshot.
|
|
81
|
+
// Otherwise, a refreshing client that starts with empty local state can emit delete ops
|
|
82
|
+
// that wipe the room before the snapshot arrives.
|
|
83
|
+
if (!snapshotApplied) {
|
|
84
|
+
pending.push(update);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
socket.send(update);
|
|
88
|
+
};
|
|
89
|
+
const attachLocalSubscription = (doc) => {
|
|
90
|
+
try {
|
|
91
|
+
const unsub = doc.subscribeLocalUpdates((update) => {
|
|
92
|
+
// Pass through to shared handler so we keep centralized buffering/sending logic.
|
|
93
|
+
maybeSendUpdate(update);
|
|
94
|
+
});
|
|
95
|
+
unsubscribeLocal = () => {
|
|
96
|
+
try {
|
|
97
|
+
unsub();
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
console.warn('[crdt] failed to unsubscribe local updates', error);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
localSubscribed = true;
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
console.warn('[crdt] failed to attach local subscription', error);
|
|
107
|
+
localSubscribed = false;
|
|
108
|
+
unsubscribeLocal = undefined;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Ensures we are subscribed to local updates on the *current* doc.
|
|
113
|
+
*
|
|
114
|
+
* This matters because this connector can be reused across reconnects and
|
|
115
|
+
* `connect(doc)` calls; we must not keep a stale subscription to a prior doc.
|
|
116
|
+
*/
|
|
117
|
+
const ensureLocalSubscription = (doc) => {
|
|
118
|
+
if (subscribedDoc && subscribedDoc !== doc) {
|
|
119
|
+
unsubscribeLocal?.();
|
|
120
|
+
unsubscribeLocal = undefined;
|
|
121
|
+
localSubscribed = false;
|
|
122
|
+
subscribedDoc = undefined;
|
|
123
|
+
}
|
|
124
|
+
if (!localSubscribed || !unsubscribeLocal || subscribedDoc !== doc) {
|
|
125
|
+
attachLocalSubscription(doc);
|
|
126
|
+
subscribedDoc = doc;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const buildUrl = () => {
|
|
130
|
+
const url = new URL(options.url);
|
|
131
|
+
url.searchParams.set('roomId', options.roomId);
|
|
132
|
+
if (options.token)
|
|
133
|
+
url.searchParams.set('token', options.token);
|
|
134
|
+
if (options.params) {
|
|
135
|
+
Object.entries(options.params).forEach(([k, v]) => url.searchParams.set(k, v));
|
|
136
|
+
}
|
|
137
|
+
return url.toString();
|
|
138
|
+
};
|
|
139
|
+
const sendStatus = (status) => {
|
|
140
|
+
options.onStatus?.(status);
|
|
141
|
+
statusListener?.(status);
|
|
142
|
+
};
|
|
143
|
+
const sendJoin = () => {
|
|
144
|
+
if (!socket || socket.readyState !== WS_OPEN)
|
|
145
|
+
return;
|
|
146
|
+
const payload = JSON.stringify({
|
|
147
|
+
type: 'crdt-join',
|
|
148
|
+
roomId: options.roomId,
|
|
149
|
+
clientId,
|
|
150
|
+
});
|
|
151
|
+
socket.send(payload);
|
|
152
|
+
};
|
|
153
|
+
const sendSnapshot = (doc) => {
|
|
154
|
+
if (!socket || socket.readyState !== WS_OPEN)
|
|
155
|
+
return;
|
|
156
|
+
try {
|
|
157
|
+
const snapshot = doc.export({ mode: 'snapshot' });
|
|
158
|
+
const payload = JSON.stringify({
|
|
159
|
+
type: 'crdt-snapshot',
|
|
160
|
+
roomId: options.roomId,
|
|
161
|
+
data: toBase64(snapshot),
|
|
162
|
+
});
|
|
163
|
+
socket.send(payload);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
console.warn('Failed to send CRDT snapshot', error);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
const scheduleReconnect = (doc) => {
|
|
170
|
+
if (stopped)
|
|
171
|
+
return;
|
|
172
|
+
if (reconnectTimer)
|
|
173
|
+
return;
|
|
174
|
+
if (connecting)
|
|
175
|
+
return;
|
|
176
|
+
if (attempt >= maxRetries) {
|
|
177
|
+
sendStatus('closed');
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const delay = Math.min(maxDelay, initialDelay * 2 ** attempt);
|
|
181
|
+
attempt += 1;
|
|
182
|
+
reconnectTimer = setTimeout(() => {
|
|
183
|
+
reconnectTimer = undefined;
|
|
184
|
+
void connect(doc);
|
|
185
|
+
}, delay);
|
|
186
|
+
// Avoid keeping the Node.js event loop alive in tests/SSR environments.
|
|
187
|
+
// No-op in browsers.
|
|
188
|
+
reconnectTimer?.unref?.();
|
|
189
|
+
};
|
|
190
|
+
const getEmptySnapshotLen = () => {
|
|
191
|
+
try {
|
|
192
|
+
return new LoroDoc().export({ mode: 'snapshot' }).byteLength;
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
const connect = async (doc) => {
|
|
199
|
+
if (stopped)
|
|
200
|
+
return;
|
|
201
|
+
if (connecting)
|
|
202
|
+
return;
|
|
203
|
+
// Always ensure we are subscribed to the *current* doc.
|
|
204
|
+
ensureLocalSubscription(doc);
|
|
205
|
+
const attachSocketListenersFor = (ws) => {
|
|
206
|
+
// Ensure browser websockets deliver binary frames as ArrayBuffer (not Blob)
|
|
207
|
+
if ('binaryType' in ws) {
|
|
208
|
+
try {
|
|
209
|
+
ws.binaryType = 'arraybuffer';
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
console.warn('Failed to set binaryType on CRDT websocket', error);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const handleMessage = (event) => {
|
|
216
|
+
// Use the currently connected doc reference (not the doc that created the socket),
|
|
217
|
+
// so a later connect(doc) call can rebind without needing a new WebSocket.
|
|
218
|
+
const activeDoc = subscribedDoc ?? doc;
|
|
219
|
+
if (!activeDoc)
|
|
220
|
+
return;
|
|
221
|
+
// Binary updates flow directly
|
|
222
|
+
if (event.data instanceof ArrayBuffer ||
|
|
223
|
+
ArrayBuffer.isView(event.data)) {
|
|
224
|
+
const bytes = event.data instanceof ArrayBuffer
|
|
225
|
+
? new Uint8Array(event.data)
|
|
226
|
+
: new Uint8Array(event.data.buffer, event.data.byteOffset, event.data.byteLength);
|
|
227
|
+
activeDoc.import(bytes);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (typeof Blob !== 'undefined' && event.data instanceof Blob) {
|
|
231
|
+
void event.data
|
|
232
|
+
.arrayBuffer()
|
|
233
|
+
.then((buf) => {
|
|
234
|
+
const bytes = new Uint8Array(buf);
|
|
235
|
+
activeDoc.import(bytes);
|
|
236
|
+
})
|
|
237
|
+
.catch((error) => console.warn('Failed to decode CRDT binary message', error));
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (typeof event.data === 'string') {
|
|
241
|
+
try {
|
|
242
|
+
const parsed = JSON.parse(event.data);
|
|
243
|
+
if (parsed?.type === 'crdt-joined') {
|
|
244
|
+
joined = true;
|
|
245
|
+
snapshotApplied = false;
|
|
246
|
+
// IMPORTANT: do not flush buffered local updates yet.
|
|
247
|
+
// The server sends `crdt-joined` before `crdt-snapshot`; flushing here can
|
|
248
|
+
// broadcast "empty state" ops from a refreshing client and wipe the room.
|
|
249
|
+
if (snapshotWaitTimer)
|
|
250
|
+
clearTimeout(snapshotWaitTimer);
|
|
251
|
+
snapshotWaitTimer = setTimeout(() => {
|
|
252
|
+
snapshotWaitTimer = undefined;
|
|
253
|
+
if (!ws || ws.readyState !== WS_OPEN)
|
|
254
|
+
return;
|
|
255
|
+
// Fallback: if we never get a snapshot, avoid buffering forever.
|
|
256
|
+
snapshotApplied = true;
|
|
257
|
+
while (pending.length) {
|
|
258
|
+
const update = pending.shift();
|
|
259
|
+
if (update)
|
|
260
|
+
ws.send(update);
|
|
261
|
+
}
|
|
262
|
+
}, 2000);
|
|
263
|
+
snapshotWaitTimer?.unref?.();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (parsed?.type === 'crdt-snapshot' && parsed.data) {
|
|
267
|
+
const bytes = fromBase64(parsed.data);
|
|
268
|
+
// If the server snapshot is empty, but we already have non-empty local state,
|
|
269
|
+
// don't import the empty snapshot (it would wipe local state). Instead, seed
|
|
270
|
+
// the server once with our snapshot.
|
|
271
|
+
try {
|
|
272
|
+
const emptyLen = getEmptySnapshotLen();
|
|
273
|
+
const serverLooksEmpty = bytes.byteLength <= emptyLen + 32;
|
|
274
|
+
const localSnapshotLen = activeDoc.export({
|
|
275
|
+
mode: 'snapshot',
|
|
276
|
+
}).byteLength;
|
|
277
|
+
const localNonEmpty = localSnapshotLen > emptyLen + 32;
|
|
278
|
+
if (serverLooksEmpty &&
|
|
279
|
+
localNonEmpty &&
|
|
280
|
+
!seededAfterEmptyServerSnapshot &&
|
|
281
|
+
ws &&
|
|
282
|
+
ws.readyState === WS_OPEN) {
|
|
283
|
+
seededAfterEmptyServerSnapshot = true;
|
|
284
|
+
// Seed the server; server will accept snapshot only if the room is empty.
|
|
285
|
+
sendSnapshot(activeDoc);
|
|
286
|
+
snapshotApplied = true;
|
|
287
|
+
if (snapshotWaitTimer) {
|
|
288
|
+
clearTimeout(snapshotWaitTimer);
|
|
289
|
+
snapshotWaitTimer = undefined;
|
|
290
|
+
}
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
// ignore
|
|
296
|
+
}
|
|
297
|
+
activeDoc.import(bytes);
|
|
298
|
+
snapshotApplied = true;
|
|
299
|
+
if (snapshotWaitTimer) {
|
|
300
|
+
clearTimeout(snapshotWaitTimer);
|
|
301
|
+
snapshotWaitTimer = undefined;
|
|
302
|
+
}
|
|
303
|
+
// Now that we have base state, flush any local updates buffered during join.
|
|
304
|
+
while (pending.length) {
|
|
305
|
+
const update = pending.shift();
|
|
306
|
+
if (update && ws && ws.readyState === WS_OPEN) {
|
|
307
|
+
ws.send(update);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// Ignore other messages (errors, acks) for now
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
console.warn('Failed to parse CRDT message', error);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
const handleOpen = () => {
|
|
319
|
+
attempt = 0;
|
|
320
|
+
joined = false;
|
|
321
|
+
snapshotApplied = false;
|
|
322
|
+
seededAfterEmptyServerSnapshot = false;
|
|
323
|
+
if (snapshotWaitTimer) {
|
|
324
|
+
clearTimeout(snapshotWaitTimer);
|
|
325
|
+
snapshotWaitTimer = undefined;
|
|
326
|
+
}
|
|
327
|
+
connecting = false;
|
|
328
|
+
sendStatus('open');
|
|
329
|
+
sendJoin();
|
|
330
|
+
if (sendSnapshotOnConnect) {
|
|
331
|
+
sendSnapshot(doc);
|
|
332
|
+
}
|
|
333
|
+
ensureLocalSubscription(doc);
|
|
334
|
+
};
|
|
335
|
+
const handleClose = (event) => {
|
|
336
|
+
console.warn('CRDT WS closed', {
|
|
337
|
+
code: event.code,
|
|
338
|
+
reason: event.reason,
|
|
339
|
+
pending: pending.length,
|
|
340
|
+
joined,
|
|
341
|
+
});
|
|
342
|
+
connecting = false;
|
|
343
|
+
sendStatus('closed');
|
|
344
|
+
if (snapshotWaitTimer) {
|
|
345
|
+
clearTimeout(snapshotWaitTimer);
|
|
346
|
+
snapshotWaitTimer = undefined;
|
|
347
|
+
}
|
|
348
|
+
unsubscribeLocal?.();
|
|
349
|
+
unsubscribeLocal = undefined;
|
|
350
|
+
localSubscribed = false;
|
|
351
|
+
subscribedDoc = undefined;
|
|
352
|
+
joined = false;
|
|
353
|
+
pending.length = 0;
|
|
354
|
+
detachSocketListeners?.();
|
|
355
|
+
detachSocketListeners = undefined;
|
|
356
|
+
listeningSocket = undefined;
|
|
357
|
+
scheduleReconnect(doc);
|
|
358
|
+
};
|
|
359
|
+
const handleError = (event) => {
|
|
360
|
+
console.warn('CRDT WS error', event);
|
|
361
|
+
connecting = false;
|
|
362
|
+
sendStatus('error');
|
|
363
|
+
if (snapshotWaitTimer) {
|
|
364
|
+
clearTimeout(snapshotWaitTimer);
|
|
365
|
+
snapshotWaitTimer = undefined;
|
|
366
|
+
}
|
|
367
|
+
joined = false;
|
|
368
|
+
unsubscribeLocal?.();
|
|
369
|
+
unsubscribeLocal = undefined;
|
|
370
|
+
localSubscribed = false;
|
|
371
|
+
subscribedDoc = undefined;
|
|
372
|
+
detachSocketListeners?.();
|
|
373
|
+
detachSocketListeners = undefined;
|
|
374
|
+
listeningSocket = undefined;
|
|
375
|
+
scheduleReconnect(doc);
|
|
376
|
+
};
|
|
377
|
+
// Some WebSocket implementations expose either addEventListener or on*
|
|
378
|
+
// handlers. Use one style only to avoid duplicate events.
|
|
379
|
+
if (typeof ws.addEventListener === 'function') {
|
|
380
|
+
ws.addEventListener('message', handleMessage);
|
|
381
|
+
ws.addEventListener('open', handleOpen);
|
|
382
|
+
ws.addEventListener('close', handleClose);
|
|
383
|
+
ws.addEventListener('error', handleError);
|
|
384
|
+
detachSocketListeners = () => {
|
|
385
|
+
try {
|
|
386
|
+
ws.removeEventListener('message', handleMessage);
|
|
387
|
+
ws.removeEventListener('open', handleOpen);
|
|
388
|
+
ws.removeEventListener('close', handleClose);
|
|
389
|
+
ws.removeEventListener('error', handleError);
|
|
390
|
+
}
|
|
391
|
+
catch (error) {
|
|
392
|
+
console.warn('[crdt] failed to detach socket listeners', error);
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
ws.onmessage = handleMessage;
|
|
398
|
+
ws.onopen = handleOpen;
|
|
399
|
+
ws.onclose = handleClose;
|
|
400
|
+
ws.onerror = handleError;
|
|
401
|
+
detachSocketListeners = () => {
|
|
402
|
+
try {
|
|
403
|
+
ws.onmessage = null;
|
|
404
|
+
ws.onopen = null;
|
|
405
|
+
ws.onclose = null;
|
|
406
|
+
ws.onerror = null;
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
console.warn('[crdt] failed to clear socket handlers', error);
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
listeningSocket = ws;
|
|
414
|
+
// If we stay in CONNECTING too long, force a reconnect to avoid being stuck.
|
|
415
|
+
const connectingTimeout = setTimeout(() => {
|
|
416
|
+
if (ws && ws.readyState === WS_CONNECTING && !joined) {
|
|
417
|
+
console.warn('[crdt] ws still connecting after timeout; retrying');
|
|
418
|
+
try {
|
|
419
|
+
ws.close();
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
console.warn('[crdt] error closing stuck socket', error);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}, 3000);
|
|
426
|
+
// Avoid keeping the Node.js event loop alive in tests/SSR environments.
|
|
427
|
+
// No-op in browsers.
|
|
428
|
+
connectingTimeout?.unref?.();
|
|
429
|
+
};
|
|
430
|
+
if (socket &&
|
|
431
|
+
(socket.readyState === WS_OPEN || socket.readyState === WS_CONNECTING)) {
|
|
432
|
+
if (listeningSocket !== socket) {
|
|
433
|
+
console.warn('[crdt] existing socket had no listeners; attaching now');
|
|
434
|
+
attachSocketListenersFor(socket);
|
|
435
|
+
// If the socket is already open, we won't get an 'open' event, so act as if
|
|
436
|
+
// we just opened: (re)join and optionally send snapshot.
|
|
437
|
+
if (socket.readyState === WS_OPEN) {
|
|
438
|
+
attempt = 0;
|
|
439
|
+
joined = false;
|
|
440
|
+
sendStatus('open');
|
|
441
|
+
sendJoin();
|
|
442
|
+
if (sendSnapshotOnConnect)
|
|
443
|
+
sendSnapshot(doc);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (reconnectTimer) {
|
|
449
|
+
clearTimeout(reconnectTimer);
|
|
450
|
+
reconnectTimer = undefined;
|
|
451
|
+
}
|
|
452
|
+
connecting = true;
|
|
453
|
+
sendStatus('connecting');
|
|
454
|
+
const wsCreator = options.createSocket ??
|
|
455
|
+
((url, protocols) => new WebSocket(url, protocols));
|
|
456
|
+
const url = buildUrl();
|
|
457
|
+
try {
|
|
458
|
+
socket = wsCreator(url, options.protocols);
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
connecting = false;
|
|
462
|
+
sendStatus('error');
|
|
463
|
+
scheduleReconnect(doc);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
attachSocketListenersFor(socket);
|
|
467
|
+
};
|
|
468
|
+
const disconnect = async () => {
|
|
469
|
+
stopped = true;
|
|
470
|
+
if (reconnectTimer) {
|
|
471
|
+
clearTimeout(reconnectTimer);
|
|
472
|
+
reconnectTimer = undefined;
|
|
473
|
+
}
|
|
474
|
+
unsubscribeLocal?.();
|
|
475
|
+
unsubscribeLocal = undefined;
|
|
476
|
+
localSubscribed = false;
|
|
477
|
+
subscribedDoc = undefined;
|
|
478
|
+
detachSocketListeners?.();
|
|
479
|
+
detachSocketListeners = undefined;
|
|
480
|
+
listeningSocket = undefined;
|
|
481
|
+
if (socket) {
|
|
482
|
+
try {
|
|
483
|
+
socket.close();
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
console.warn('Error closing CRDT websocket', error);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
socket = undefined;
|
|
490
|
+
sendStatus('closed');
|
|
491
|
+
};
|
|
492
|
+
return {
|
|
493
|
+
connect,
|
|
494
|
+
disconnect,
|
|
495
|
+
setStatusListener: (listener) => {
|
|
496
|
+
statusListener = listener;
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
//# sourceMappingURL=webSocketSyncConnector.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webSocketSyncConnector.js","sourceRoot":"","sources":["../../src/sync/webSocketSyncConnector.ts"],"names":[],"mappings":"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","sourcesContent":["import {LoroDoc} from 'loro-crdt';\nimport {CrdtConnectionStatus, CrdtSyncConnector} from '../createCrdtSlice';\n\ntype WebSocketLike = {\n readyState: number;\n send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void;\n close: () => void;\n addEventListener: (type: string, listener: (...args: any[]) => void) => void;\n removeEventListener: (\n type: string,\n listener: (...args: any[]) => void,\n ) => void;\n};\n\nconst WS_OPEN = 1;\nconst WS_CONNECTING = 0;\n\nconst getOrCreateClientId = (storageKey: string) => {\n try {\n const ss = (globalThis as any).sessionStorage as Storage | undefined;\n if (ss) {\n const existing = ss.getItem(storageKey);\n if (existing) return existing;\n const created = (globalThis as any).crypto?.randomUUID?.() as\n | string\n | undefined;\n const id = created ?? `client-${Math.random().toString(16).slice(2)}`;\n ss.setItem(storageKey, id);\n return id;\n }\n } catch {\n // ignore\n }\n const created = (globalThis as any).crypto?.randomUUID?.() as\n | string\n | undefined;\n return created ?? `client-${Math.random().toString(16).slice(2)}`;\n};\n\nconst toBase64 = (bytes: Uint8Array) => {\n const buf = (globalThis as any).Buffer as\n | {from: (input: Uint8Array) => {toString: (enc: string) => string}}\n | undefined;\n if (buf) {\n return buf.from(bytes).toString('base64');\n }\n let binary = '';\n bytes.forEach((b) => {\n binary += String.fromCharCode(b);\n });\n return btoa(binary);\n};\n\nconst fromBase64 = (value: string) => {\n const buf = (globalThis as any).Buffer as\n | {from: (input: string, encoding: string) => Uint8Array}\n | undefined;\n if (buf) {\n return Uint8Array.from(buf.from(value, 'base64'));\n }\n const binary = atob(value);\n const out = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);\n return out;\n};\n\nexport type WebSocketSyncOptions = {\n url: string;\n roomId: string;\n token?: string;\n params?: Record<string, string>;\n protocols?: string | string[];\n onStatus?: (status: Exclude<CrdtConnectionStatus, 'idle'>) => void;\n createSocket?: (url: string, protocols?: string | string[]) => WebSocketLike;\n sendSnapshotOnConnect?: boolean;\n /**\n * Optional per-tab client identifier. If omitted, the connector generates one via\n * `crypto.randomUUID()` and persists it in `sessionStorage` (per-tab) by default.\n */\n clientId?: string;\n /**\n * Storage key used for persisting the generated clientId in `sessionStorage`.\n *\n * @defaultValue `\"sqlrooms-crdt-clientId\"`\n */\n clientIdStorageKey?: string;\n maxRetries?: number;\n initialDelayMs?: number;\n maxDelayMs?: number;\n};\n\n/**\n * Creates a CRDT sync connector that exchanges Loro updates over WebSocket.\n */\nexport function createWebSocketSyncConnector(\n options: WebSocketSyncOptions,\n): CrdtSyncConnector {\n const sendSnapshotOnConnect = options.sendSnapshotOnConnect ?? true;\n const clientId =\n options.clientId ??\n getOrCreateClientId(\n options.clientIdStorageKey ?? `sqlrooms-crdt-clientId:${options.roomId}`,\n );\n let socket: WebSocketLike | undefined;\n let unsubscribeLocal: (() => void) | undefined;\n let subscribedDoc: LoroDoc | undefined;\n let attempt = 0;\n let stopped = false;\n let reconnectTimer: ReturnType<typeof setTimeout> | undefined;\n let joined = false;\n let snapshotApplied = false;\n let snapshotWaitTimer: ReturnType<typeof setTimeout> | undefined;\n let seededAfterEmptyServerSnapshot = false;\n let connecting = false;\n const pending: Uint8Array[] = [];\n let localSubscribed = false;\n let listeningSocket: WebSocketLike | undefined;\n let detachSocketListeners: (() => void) | undefined;\n let statusListener:\n | ((status: Exclude<CrdtConnectionStatus, 'idle'>) => void)\n | undefined;\n\n const maxRetries = options.maxRetries ?? Infinity;\n const initialDelay = options.initialDelayMs ?? 500;\n const maxDelay = options.maxDelayMs ?? 5000;\n\n const maybeSendUpdate = (update: Uint8Array) => {\n if (!socket || socket.readyState !== WS_OPEN) {\n pending.push(update);\n return;\n }\n if (!joined) {\n pending.push(update);\n return;\n }\n // IMPORTANT: don't send local updates until we've applied the server snapshot.\n // Otherwise, a refreshing client that starts with empty local state can emit delete ops\n // that wipe the room before the snapshot arrives.\n if (!snapshotApplied) {\n pending.push(update);\n return;\n }\n socket.send(update);\n };\n\n const attachLocalSubscription = (doc: LoroDoc) => {\n try {\n const unsub = doc.subscribeLocalUpdates((update: Uint8Array) => {\n // Pass through to shared handler so we keep centralized buffering/sending logic.\n maybeSendUpdate(update);\n });\n unsubscribeLocal = () => {\n try {\n unsub();\n } catch (error) {\n console.warn('[crdt] failed to unsubscribe local updates', error);\n }\n };\n localSubscribed = true;\n } catch (error) {\n console.warn('[crdt] failed to attach local subscription', error);\n localSubscribed = false;\n unsubscribeLocal = undefined;\n }\n };\n\n /**\n * Ensures we are subscribed to local updates on the *current* doc.\n *\n * This matters because this connector can be reused across reconnects and\n * `connect(doc)` calls; we must not keep a stale subscription to a prior doc.\n */\n const ensureLocalSubscription = (doc: LoroDoc) => {\n if (subscribedDoc && subscribedDoc !== doc) {\n unsubscribeLocal?.();\n unsubscribeLocal = undefined;\n localSubscribed = false;\n subscribedDoc = undefined;\n }\n if (!localSubscribed || !unsubscribeLocal || subscribedDoc !== doc) {\n attachLocalSubscription(doc);\n subscribedDoc = doc;\n }\n };\n\n const buildUrl = () => {\n const url = new URL(options.url);\n url.searchParams.set('roomId', options.roomId);\n if (options.token) url.searchParams.set('token', options.token);\n if (options.params) {\n Object.entries(options.params).forEach(([k, v]) =>\n url.searchParams.set(k, v),\n );\n }\n return url.toString();\n };\n\n const sendStatus = (status: Exclude<CrdtConnectionStatus, 'idle'>) => {\n options.onStatus?.(status);\n statusListener?.(status);\n };\n\n const sendJoin = () => {\n if (!socket || socket.readyState !== WS_OPEN) return;\n const payload = JSON.stringify({\n type: 'crdt-join',\n roomId: options.roomId,\n clientId,\n });\n socket.send(payload);\n };\n\n const sendSnapshot = (doc: LoroDoc) => {\n if (!socket || socket.readyState !== WS_OPEN) return;\n try {\n const snapshot = doc.export({mode: 'snapshot'});\n const payload = JSON.stringify({\n type: 'crdt-snapshot',\n roomId: options.roomId,\n data: toBase64(snapshot),\n });\n socket.send(payload);\n } catch (error) {\n console.warn('Failed to send CRDT snapshot', error);\n }\n };\n\n const scheduleReconnect = (doc: LoroDoc) => {\n if (stopped) return;\n if (reconnectTimer) return;\n if (connecting) return;\n if (attempt >= maxRetries) {\n sendStatus('closed');\n return;\n }\n const delay = Math.min(maxDelay, initialDelay * 2 ** attempt);\n attempt += 1;\n reconnectTimer = setTimeout(() => {\n reconnectTimer = undefined;\n void connect(doc);\n }, delay);\n // Avoid keeping the Node.js event loop alive in tests/SSR environments.\n // No-op in browsers.\n (reconnectTimer as any)?.unref?.();\n };\n\n const getEmptySnapshotLen = () => {\n try {\n return new LoroDoc().export({mode: 'snapshot'}).byteLength;\n } catch {\n return 0;\n }\n };\n\n const connect = async (doc: LoroDoc) => {\n if (stopped) return;\n if (connecting) return;\n // Always ensure we are subscribed to the *current* doc.\n ensureLocalSubscription(doc);\n\n const attachSocketListenersFor = (ws: WebSocketLike) => {\n // Ensure browser websockets deliver binary frames as ArrayBuffer (not Blob)\n if ('binaryType' in ws) {\n try {\n (ws as any).binaryType = 'arraybuffer';\n } catch (error) {\n console.warn('Failed to set binaryType on CRDT websocket', error);\n }\n }\n\n const handleMessage = (event: any) => {\n // Use the currently connected doc reference (not the doc that created the socket),\n // so a later connect(doc) call can rebind without needing a new WebSocket.\n const activeDoc = subscribedDoc ?? doc;\n if (!activeDoc) return;\n // Binary updates flow directly\n if (\n event.data instanceof ArrayBuffer ||\n ArrayBuffer.isView(event.data)\n ) {\n const bytes =\n event.data instanceof ArrayBuffer\n ? new Uint8Array(event.data)\n : new Uint8Array(\n event.data.buffer,\n event.data.byteOffset,\n event.data.byteLength,\n );\n activeDoc.import(bytes);\n return;\n }\n if (typeof Blob !== 'undefined' && event.data instanceof Blob) {\n void event.data\n .arrayBuffer()\n .then((buf: ArrayBuffer) => {\n const bytes = new Uint8Array(buf);\n activeDoc.import(bytes);\n })\n .catch((error: unknown) =>\n console.warn('Failed to decode CRDT binary message', error),\n );\n return;\n }\n\n if (typeof event.data === 'string') {\n try {\n const parsed = JSON.parse(event.data);\n if (parsed?.type === 'crdt-joined') {\n joined = true;\n snapshotApplied = false;\n // IMPORTANT: do not flush buffered local updates yet.\n // The server sends `crdt-joined` before `crdt-snapshot`; flushing here can\n // broadcast \"empty state\" ops from a refreshing client and wipe the room.\n if (snapshotWaitTimer) clearTimeout(snapshotWaitTimer);\n snapshotWaitTimer = setTimeout(() => {\n snapshotWaitTimer = undefined;\n if (!ws || ws.readyState !== WS_OPEN) return;\n // Fallback: if we never get a snapshot, avoid buffering forever.\n snapshotApplied = true;\n while (pending.length) {\n const update = pending.shift();\n if (update) ws.send(update);\n }\n }, 2000);\n (snapshotWaitTimer as any)?.unref?.();\n return;\n }\n if (parsed?.type === 'crdt-snapshot' && parsed.data) {\n const bytes = fromBase64(parsed.data);\n // If the server snapshot is empty, but we already have non-empty local state,\n // don't import the empty snapshot (it would wipe local state). Instead, seed\n // the server once with our snapshot.\n try {\n const emptyLen = getEmptySnapshotLen();\n const serverLooksEmpty = bytes.byteLength <= emptyLen + 32;\n const localSnapshotLen = activeDoc.export({\n mode: 'snapshot',\n }).byteLength;\n const localNonEmpty = localSnapshotLen > emptyLen + 32;\n if (\n serverLooksEmpty &&\n localNonEmpty &&\n !seededAfterEmptyServerSnapshot &&\n ws &&\n ws.readyState === WS_OPEN\n ) {\n seededAfterEmptyServerSnapshot = true;\n // Seed the server; server will accept snapshot only if the room is empty.\n sendSnapshot(activeDoc);\n snapshotApplied = true;\n if (snapshotWaitTimer) {\n clearTimeout(snapshotWaitTimer);\n snapshotWaitTimer = undefined;\n }\n return;\n }\n } catch {\n // ignore\n }\n\n activeDoc.import(bytes);\n snapshotApplied = true;\n if (snapshotWaitTimer) {\n clearTimeout(snapshotWaitTimer);\n snapshotWaitTimer = undefined;\n }\n // Now that we have base state, flush any local updates buffered during join.\n while (pending.length) {\n const update = pending.shift();\n if (update && ws && ws.readyState === WS_OPEN) {\n ws.send(update);\n }\n }\n }\n // Ignore other messages (errors, acks) for now\n } catch (error) {\n console.warn('Failed to parse CRDT message', error);\n }\n }\n };\n\n const handleOpen = () => {\n attempt = 0;\n joined = false;\n snapshotApplied = false;\n seededAfterEmptyServerSnapshot = false;\n if (snapshotWaitTimer) {\n clearTimeout(snapshotWaitTimer);\n snapshotWaitTimer = undefined;\n }\n connecting = false;\n sendStatus('open');\n sendJoin();\n if (sendSnapshotOnConnect) {\n sendSnapshot(doc);\n }\n ensureLocalSubscription(doc);\n };\n\n const handleClose = (event: CloseEvent) => {\n console.warn('CRDT WS closed', {\n code: event.code,\n reason: event.reason,\n pending: pending.length,\n joined,\n });\n connecting = false;\n sendStatus('closed');\n if (snapshotWaitTimer) {\n clearTimeout(snapshotWaitTimer);\n snapshotWaitTimer = undefined;\n }\n unsubscribeLocal?.();\n unsubscribeLocal = undefined;\n localSubscribed = false;\n subscribedDoc = undefined;\n joined = false;\n pending.length = 0;\n detachSocketListeners?.();\n detachSocketListeners = undefined;\n listeningSocket = undefined;\n scheduleReconnect(doc);\n };\n\n const handleError = (event: Event) => {\n console.warn('CRDT WS error', event);\n connecting = false;\n sendStatus('error');\n if (snapshotWaitTimer) {\n clearTimeout(snapshotWaitTimer);\n snapshotWaitTimer = undefined;\n }\n joined = false;\n unsubscribeLocal?.();\n unsubscribeLocal = undefined;\n localSubscribed = false;\n subscribedDoc = undefined;\n detachSocketListeners?.();\n detachSocketListeners = undefined;\n listeningSocket = undefined;\n scheduleReconnect(doc);\n };\n\n // Some WebSocket implementations expose either addEventListener or on*\n // handlers. Use one style only to avoid duplicate events.\n if (typeof ws.addEventListener === 'function') {\n ws.addEventListener('message', handleMessage);\n ws.addEventListener('open', handleOpen);\n ws.addEventListener('close', handleClose);\n ws.addEventListener('error', handleError);\n detachSocketListeners = () => {\n try {\n ws.removeEventListener('message', handleMessage);\n ws.removeEventListener('open', handleOpen);\n ws.removeEventListener('close', handleClose);\n ws.removeEventListener('error', handleError);\n } catch (error) {\n console.warn('[crdt] failed to detach socket listeners', error);\n }\n };\n } else {\n (ws as any).onmessage = handleMessage;\n (ws as any).onopen = handleOpen;\n (ws as any).onclose = handleClose;\n (ws as any).onerror = handleError;\n detachSocketListeners = () => {\n try {\n (ws as any).onmessage = null;\n (ws as any).onopen = null;\n (ws as any).onclose = null;\n (ws as any).onerror = null;\n } catch (error) {\n console.warn('[crdt] failed to clear socket handlers', error);\n }\n };\n }\n listeningSocket = ws;\n\n // If we stay in CONNECTING too long, force a reconnect to avoid being stuck.\n const connectingTimeout = setTimeout(() => {\n if (ws && ws.readyState === WS_CONNECTING && !joined) {\n console.warn('[crdt] ws still connecting after timeout; retrying');\n try {\n ws.close();\n } catch (error) {\n console.warn('[crdt] error closing stuck socket', error);\n }\n }\n }, 3000);\n // Avoid keeping the Node.js event loop alive in tests/SSR environments.\n // No-op in browsers.\n (connectingTimeout as any)?.unref?.();\n };\n\n if (\n socket &&\n (socket.readyState === WS_OPEN || socket.readyState === WS_CONNECTING)\n ) {\n if (listeningSocket !== socket) {\n console.warn('[crdt] existing socket had no listeners; attaching now');\n attachSocketListenersFor(socket);\n // If the socket is already open, we won't get an 'open' event, so act as if\n // we just opened: (re)join and optionally send snapshot.\n if (socket.readyState === WS_OPEN) {\n attempt = 0;\n joined = false;\n sendStatus('open');\n sendJoin();\n if (sendSnapshotOnConnect) sendSnapshot(doc);\n }\n }\n return;\n }\n if (reconnectTimer) {\n clearTimeout(reconnectTimer);\n reconnectTimer = undefined;\n }\n connecting = true;\n sendStatus('connecting');\n const wsCreator =\n options.createSocket ??\n ((url, protocols) => new WebSocket(url, protocols));\n const url = buildUrl();\n try {\n socket = wsCreator(url, options.protocols);\n } catch (error) {\n connecting = false;\n sendStatus('error');\n scheduleReconnect(doc);\n return;\n }\n attachSocketListenersFor(socket);\n };\n\n const disconnect = async () => {\n stopped = true;\n if (reconnectTimer) {\n clearTimeout(reconnectTimer);\n reconnectTimer = undefined;\n }\n unsubscribeLocal?.();\n unsubscribeLocal = undefined;\n localSubscribed = false;\n subscribedDoc = undefined;\n detachSocketListeners?.();\n detachSocketListeners = undefined;\n listeningSocket = undefined;\n if (socket) {\n try {\n socket.close();\n } catch (error) {\n console.warn('Error closing CRDT websocket', error);\n }\n }\n socket = undefined;\n sendStatus('closed');\n };\n\n return {\n connect,\n disconnect,\n setStatusListener: (listener) => {\n statusListener = listener as any;\n },\n };\n}\n"]}
|