@jxsuite/collab 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -0
- package/package.json +40 -0
- package/src/awareness-types.ts +54 -0
- package/src/diff.ts +277 -0
- package/src/envelope.ts +137 -0
- package/src/index.ts +45 -0
- package/src/op-bridge.ts +218 -0
- package/src/ops.ts +128 -0
- package/src/provider.ts +45 -0
- package/src/schema.ts +205 -0
- package/src/ws-client.ts +436 -0
- package/src/ws-room.ts +424 -0
package/src/ws-client.ts
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser half of the wire envelope: one WebSocket per project, multiplexing every open
|
|
3
|
+
* document. `createWsCollabConnection` owns the socket (exponential-backoff reconnect, re-open of
|
|
4
|
+
* live docs on reconnect), the shared project-level Awareness, and per-doc sync state;
|
|
5
|
+
* `openDoc(path)` hands out {@link CollabHandle}s for `StudioPlatform.collab`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as Y from "yjs";
|
|
9
|
+
import * as decoding from "lib0/decoding";
|
|
10
|
+
import * as encoding from "lib0/encoding";
|
|
11
|
+
import {
|
|
12
|
+
applyAwarenessUpdate,
|
|
13
|
+
Awareness,
|
|
14
|
+
encodeAwarenessUpdate,
|
|
15
|
+
removeAwarenessStates,
|
|
16
|
+
} from "y-protocols/awareness";
|
|
17
|
+
import * as syncProtocol from "y-protocols/sync";
|
|
18
|
+
import { decodeFrame, encodeFrame } from "./envelope.ts";
|
|
19
|
+
import type { CollabFrame, ControlMessage } from "./envelope.ts";
|
|
20
|
+
import type { CollabHandle, CollabIdentity, CollabStatus } from "./provider.ts";
|
|
21
|
+
|
|
22
|
+
/** The subset of the WebSocket API the connection uses (injectable for tests). */
|
|
23
|
+
export interface WsLike {
|
|
24
|
+
binaryType: string;
|
|
25
|
+
readyState: number;
|
|
26
|
+
send: (data: Uint8Array) => void;
|
|
27
|
+
close: () => void;
|
|
28
|
+
onopen: (() => void) | null;
|
|
29
|
+
onclose: (() => void) | null;
|
|
30
|
+
onerror: (() => void) | null;
|
|
31
|
+
onmessage: ((event: { data: unknown }) => void) | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WsCollabConnectionOptions {
|
|
35
|
+
/** The ws(s):// endpoint for this project's collab socket. */
|
|
36
|
+
url: string;
|
|
37
|
+
/** WebSocket constructor override (tests, non-browser runtimes). */
|
|
38
|
+
webSocketImpl?: new (url: string) => WsLike;
|
|
39
|
+
/**
|
|
40
|
+
* Called when the server refuses a doc with content-not-loaded: hydrate the file over HTTP (which
|
|
41
|
+
* caches it server-side) so the retry can seed. One retry per open.
|
|
42
|
+
*/
|
|
43
|
+
hydratePath?: (path: string) => Promise<void>;
|
|
44
|
+
/** Give up on an openDoc after this long (falls back to solo editing). */
|
|
45
|
+
openTimeoutMs?: number;
|
|
46
|
+
/** Initial reconnect backoff (doubles to 30s). */
|
|
47
|
+
reconnectDelayMs?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface WsCollabConnection {
|
|
51
|
+
openDoc: (path: string) => Promise<CollabHandle | null>;
|
|
52
|
+
status: () => CollabStatus;
|
|
53
|
+
destroy: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const WS_OPEN = 1;
|
|
57
|
+
|
|
58
|
+
interface DocEntry {
|
|
59
|
+
doc: Y.Doc;
|
|
60
|
+
epoch: number;
|
|
61
|
+
opened: boolean;
|
|
62
|
+
synced: boolean;
|
|
63
|
+
hydrated: boolean;
|
|
64
|
+
resolveOpen: ((ok: boolean) => void) | null;
|
|
65
|
+
resolveSynced: () => void;
|
|
66
|
+
whenSynced: Promise<void>;
|
|
67
|
+
resetCbs: Set<() => void>;
|
|
68
|
+
pendingFlush: Set<() => void>;
|
|
69
|
+
handleDestroyed: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createWsCollabConnection(options: WsCollabConnectionOptions): WsCollabConnection {
|
|
73
|
+
const WebSocketCtor =
|
|
74
|
+
options.webSocketImpl ?? (globalThis.WebSocket as unknown as new (url: string) => WsLike);
|
|
75
|
+
const openTimeoutMs = options.openTimeoutMs ?? 10_000;
|
|
76
|
+
const docs = new Map<string, DocEntry>();
|
|
77
|
+
const statusCbs = new Set<(status: CollabStatus) => void>();
|
|
78
|
+
const awarenessDoc = new Y.Doc();
|
|
79
|
+
const awareness = new Awareness(awarenessDoc);
|
|
80
|
+
/* Clear the constructor's implicit {} state: it sits at clock 0, which applyAwarenessUpdate on
|
|
81
|
+
the receiving side can never apply (currClock 0 < clock 0 is false) — publishing it would
|
|
82
|
+
poison the server's controlled-id tracking. Real state arrives via setLocalState (clock ≥ 2). */
|
|
83
|
+
awareness.setLocalState(null);
|
|
84
|
+
|
|
85
|
+
let socket: WsLike | null = null;
|
|
86
|
+
let identity: CollabIdentity | null = null;
|
|
87
|
+
let status: CollabStatus = "connecting";
|
|
88
|
+
let destroyed = false;
|
|
89
|
+
let retryMs = options.reconnectDelayMs ?? 1000;
|
|
90
|
+
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
91
|
+
|
|
92
|
+
const setStatus = (next: CollabStatus) => {
|
|
93
|
+
if (status === next) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
status = next;
|
|
97
|
+
for (const cb of statusCbs) {
|
|
98
|
+
cb(next);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const sendFrame = (frame: CollabFrame) => {
|
|
103
|
+
if (socket && socket.readyState === WS_OPEN) {
|
|
104
|
+
try {
|
|
105
|
+
socket.send(encodeFrame(frame));
|
|
106
|
+
} catch {
|
|
107
|
+
// The close handler owns recovery.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const sendOpen = (path: string) => {
|
|
113
|
+
sendFrame({ message: { path, type: "open" }, type: "control" });
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Local awareness changes always go to the wire (queued sends drop; awareness self-heals on
|
|
117
|
+
// Reconnect via the full-state re-publish below).
|
|
118
|
+
awareness.on(
|
|
119
|
+
"update",
|
|
120
|
+
(changes: { added: number[]; updated: number[]; removed: number[] }, origin: unknown) => {
|
|
121
|
+
if (origin === "remote") {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const changed = [...changes.added, ...changes.updated, ...changes.removed];
|
|
125
|
+
sendFrame({ body: encodeAwarenessUpdate(awareness, changed), type: "awareness" });
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const handleControl = (message: ControlMessage) => {
|
|
130
|
+
switch (message.type) {
|
|
131
|
+
case "hello": {
|
|
132
|
+
identity = {
|
|
133
|
+
color: message.color,
|
|
134
|
+
login: message.login,
|
|
135
|
+
permission: message.permission,
|
|
136
|
+
...(message.name === undefined ? {} : { name: message.name }),
|
|
137
|
+
...(message.avatarUrl === undefined ? {} : { avatarUrl: message.avatarUrl }),
|
|
138
|
+
};
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
case "opened": {
|
|
142
|
+
const entry = docs.get(message.path);
|
|
143
|
+
if (!entry) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (entry.opened && entry.epoch !== message.epoch) {
|
|
147
|
+
// The doc's history moved while we were away: this handle is dead.
|
|
148
|
+
fireReset(message.path, entry);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
entry.epoch = message.epoch;
|
|
152
|
+
entry.opened = true;
|
|
153
|
+
entry.resolveOpen?.(true);
|
|
154
|
+
entry.resolveOpen = null;
|
|
155
|
+
// Client-initiated step1 (mirrors y-websocket): fetch the server's state.
|
|
156
|
+
const body = encoding.createEncoder();
|
|
157
|
+
syncProtocol.writeSyncStep1(body, entry.doc);
|
|
158
|
+
sendFrame({
|
|
159
|
+
body: encoding.toUint8Array(body),
|
|
160
|
+
epoch: entry.epoch,
|
|
161
|
+
path: message.path,
|
|
162
|
+
type: "doc-sync",
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
case "doc-reset": {
|
|
167
|
+
const entry = docs.get(message.path);
|
|
168
|
+
if (entry) {
|
|
169
|
+
fireReset(message.path, entry);
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
case "flush-ack": {
|
|
174
|
+
const entry = docs.get(message.path);
|
|
175
|
+
if (entry) {
|
|
176
|
+
for (const resolve of entry.pendingFlush) {
|
|
177
|
+
resolve();
|
|
178
|
+
}
|
|
179
|
+
entry.pendingFlush.clear();
|
|
180
|
+
}
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
case "error": {
|
|
184
|
+
if (!message.path) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const entry = docs.get(message.path);
|
|
188
|
+
if (!entry) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (message.code === "content-not-loaded" && options.hydratePath && !entry.hydrated) {
|
|
192
|
+
entry.hydrated = true;
|
|
193
|
+
void options.hydratePath(message.path).then(
|
|
194
|
+
() => sendOpen(message.path!),
|
|
195
|
+
() => {
|
|
196
|
+
entry.resolveOpen?.(false);
|
|
197
|
+
entry.resolveOpen = null;
|
|
198
|
+
},
|
|
199
|
+
);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (!entry.opened) {
|
|
203
|
+
// Fatal refusal (binary-file, too-large, unresolved hydration): fall back to solo.
|
|
204
|
+
entry.resolveOpen?.(false);
|
|
205
|
+
entry.resolveOpen = null;
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
default: {
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const handleDocSync = (path: string, epoch: number, body: Uint8Array) => {
|
|
216
|
+
const entry = docs.get(path);
|
|
217
|
+
if (!entry || !entry.opened || entry.epoch !== epoch) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const decoder = decoding.createDecoder(body);
|
|
221
|
+
const reply = encoding.createEncoder();
|
|
222
|
+
const messageType = syncProtocol.readSyncMessage(decoder, reply, entry.doc, "remote");
|
|
223
|
+
if (encoding.length(reply) > 0) {
|
|
224
|
+
sendFrame({ body: encoding.toUint8Array(reply), epoch, path, type: "doc-sync" });
|
|
225
|
+
}
|
|
226
|
+
if (messageType === syncProtocol.messageYjsSyncStep2 && !entry.synced) {
|
|
227
|
+
entry.synced = true;
|
|
228
|
+
entry.resolveSynced();
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const fireReset = (path: string, entry: DocEntry) => {
|
|
233
|
+
docs.delete(path);
|
|
234
|
+
entry.resolveOpen?.(false);
|
|
235
|
+
entry.resolveOpen = null;
|
|
236
|
+
for (const cb of entry.resetCbs) {
|
|
237
|
+
cb();
|
|
238
|
+
}
|
|
239
|
+
entry.doc.destroy();
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const connect = () => {
|
|
243
|
+
if (destroyed) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
setStatus("connecting");
|
|
247
|
+
const ws = new WebSocketCtor(options.url);
|
|
248
|
+
socket = ws;
|
|
249
|
+
ws.binaryType = "arraybuffer";
|
|
250
|
+
/* Each connect() builds a FRESH socket; single-assignment handlers are the point (and keep
|
|
251
|
+
the injectable WsLike test surface minimal). */
|
|
252
|
+
// oxlint-disable-next-line unicorn/prefer-add-event-listener
|
|
253
|
+
ws.onopen = () => {
|
|
254
|
+
retryMs = options.reconnectDelayMs ?? 1000;
|
|
255
|
+
setStatus("connected");
|
|
256
|
+
// Re-open every live doc and re-publish our awareness state.
|
|
257
|
+
for (const path of docs.keys()) {
|
|
258
|
+
sendOpen(path);
|
|
259
|
+
}
|
|
260
|
+
const local = awareness.getLocalState();
|
|
261
|
+
if (local !== null) {
|
|
262
|
+
sendFrame({
|
|
263
|
+
body: encodeAwarenessUpdate(awareness, [awareness.clientID]),
|
|
264
|
+
type: "awareness",
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
// oxlint-disable-next-line unicorn/prefer-add-event-listener
|
|
269
|
+
ws.onmessage = (event) => {
|
|
270
|
+
const raw = event.data;
|
|
271
|
+
const data =
|
|
272
|
+
raw instanceof ArrayBuffer ? new Uint8Array(raw) : raw instanceof Uint8Array ? raw : null;
|
|
273
|
+
if (!data) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
let frame: CollabFrame;
|
|
277
|
+
try {
|
|
278
|
+
frame = decodeFrame(data);
|
|
279
|
+
} catch {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
switch (frame.type) {
|
|
283
|
+
case "control": {
|
|
284
|
+
handleControl(frame.message);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
case "doc-sync": {
|
|
288
|
+
handleDocSync(frame.path, frame.epoch, frame.body);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
case "awareness": {
|
|
292
|
+
applyAwarenessUpdate(awareness, frame.body, "remote");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
default: {
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
// oxlint-disable-next-line unicorn/prefer-add-event-listener
|
|
301
|
+
ws.onclose = () => {
|
|
302
|
+
if (destroyed) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
setStatus("offline");
|
|
306
|
+
for (const entry of docs.values()) {
|
|
307
|
+
entry.opened = false;
|
|
308
|
+
}
|
|
309
|
+
retryTimer = setTimeout(connect, retryMs);
|
|
310
|
+
retryMs = Math.min(retryMs * 2, 30_000);
|
|
311
|
+
};
|
|
312
|
+
// oxlint-disable-next-line unicorn/prefer-add-event-listener
|
|
313
|
+
ws.onerror = () => {
|
|
314
|
+
// The close handler follows and owns retry.
|
|
315
|
+
};
|
|
316
|
+
};
|
|
317
|
+
connect();
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
destroy() {
|
|
321
|
+
if (destroyed) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
destroyed = true;
|
|
325
|
+
if (retryTimer) {
|
|
326
|
+
clearTimeout(retryTimer);
|
|
327
|
+
}
|
|
328
|
+
removeAwarenessStates(awareness, [awareness.clientID], "destroy");
|
|
329
|
+
awareness.destroy();
|
|
330
|
+
awarenessDoc.destroy();
|
|
331
|
+
for (const [path, entry] of docs) {
|
|
332
|
+
entry.resolveOpen?.(false);
|
|
333
|
+
entry.doc.destroy();
|
|
334
|
+
docs.delete(path);
|
|
335
|
+
}
|
|
336
|
+
socket?.close();
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
async openDoc(path: string): Promise<CollabHandle | null> {
|
|
340
|
+
if (destroyed) {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
const existing = docs.get(path);
|
|
344
|
+
if (existing && !existing.handleDestroyed) {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
let resolveSynced: () => void = () => {};
|
|
348
|
+
const whenSynced = new Promise<void>((resolve) => {
|
|
349
|
+
resolveSynced = resolve;
|
|
350
|
+
});
|
|
351
|
+
const entry: DocEntry = {
|
|
352
|
+
doc: new Y.Doc(),
|
|
353
|
+
epoch: 0,
|
|
354
|
+
handleDestroyed: false,
|
|
355
|
+
hydrated: false,
|
|
356
|
+
opened: false,
|
|
357
|
+
pendingFlush: new Set(),
|
|
358
|
+
resetCbs: new Set(),
|
|
359
|
+
resolveOpen: null,
|
|
360
|
+
resolveSynced,
|
|
361
|
+
synced: false,
|
|
362
|
+
whenSynced,
|
|
363
|
+
};
|
|
364
|
+
docs.set(path, entry);
|
|
365
|
+
|
|
366
|
+
// Local doc updates go to the wire (unless they came FROM the wire).
|
|
367
|
+
entry.doc.on("update", (update: Uint8Array, origin: unknown) => {
|
|
368
|
+
if (origin === "remote" || !entry.opened) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const body = encoding.createEncoder();
|
|
372
|
+
syncProtocol.writeUpdate(body, update);
|
|
373
|
+
sendFrame({
|
|
374
|
+
body: encoding.toUint8Array(body),
|
|
375
|
+
epoch: entry.epoch,
|
|
376
|
+
path,
|
|
377
|
+
type: "doc-sync",
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const openedOk = await new Promise<boolean>((resolve) => {
|
|
382
|
+
entry.resolveOpen = resolve;
|
|
383
|
+
const timer = setTimeout(() => {
|
|
384
|
+
entry.resolveOpen = null;
|
|
385
|
+
resolve(false);
|
|
386
|
+
}, openTimeoutMs);
|
|
387
|
+
entry.resolveOpen = (ok) => {
|
|
388
|
+
clearTimeout(timer);
|
|
389
|
+
resolve(ok);
|
|
390
|
+
};
|
|
391
|
+
sendOpen(path);
|
|
392
|
+
});
|
|
393
|
+
if (!openedOk) {
|
|
394
|
+
if (docs.get(path) === entry) {
|
|
395
|
+
docs.delete(path);
|
|
396
|
+
}
|
|
397
|
+
entry.doc.destroy();
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const handle: CollabHandle = {
|
|
402
|
+
awareness,
|
|
403
|
+
destroy: () => {
|
|
404
|
+
if (entry.handleDestroyed) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
entry.handleDestroyed = true;
|
|
408
|
+
if (docs.get(path) === entry) {
|
|
409
|
+
docs.delete(path);
|
|
410
|
+
sendFrame({ path, type: "doc-close" });
|
|
411
|
+
}
|
|
412
|
+
entry.doc.destroy();
|
|
413
|
+
},
|
|
414
|
+
doc: entry.doc,
|
|
415
|
+
flush: () =>
|
|
416
|
+
new Promise<void>((resolve) => {
|
|
417
|
+
entry.pendingFlush.add(resolve);
|
|
418
|
+
sendFrame({ message: { path, type: "flush" }, type: "control" });
|
|
419
|
+
}),
|
|
420
|
+
identity: () => identity,
|
|
421
|
+
onReset: (cb) => {
|
|
422
|
+
entry.resetCbs.add(cb);
|
|
423
|
+
return () => entry.resetCbs.delete(cb);
|
|
424
|
+
},
|
|
425
|
+
onStatus: (cb) => {
|
|
426
|
+
statusCbs.add(cb);
|
|
427
|
+
return () => statusCbs.delete(cb);
|
|
428
|
+
},
|
|
429
|
+
whenSynced: entry.whenSynced,
|
|
430
|
+
};
|
|
431
|
+
return handle;
|
|
432
|
+
},
|
|
433
|
+
|
|
434
|
+
status: () => status,
|
|
435
|
+
};
|
|
436
|
+
}
|