@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/src/ws-room.ts ADDED
@@ -0,0 +1,424 @@
1
+ /**
2
+ * Transport-agnostic collab host: the server half of the wire envelope, shared by the Bun dev
3
+ * server and the Cloudflare platform DO. The embedder supplies sockets ({@link RoomSocket}),
4
+ * identity per connection, and source loading; the host owns rooms (one Y.Doc per path,
5
+ * server-seeded `source` per the schema contract), the y-protocols sync handshake, project-level
6
+ * awareness relay, read-only enforcement, and the docEpoch/doc-reset lifecycle.
7
+ */
8
+
9
+ import * as Y from "yjs";
10
+ import * as decoding from "lib0/decoding";
11
+ import * as encoding from "lib0/encoding";
12
+ import {
13
+ applyAwarenessUpdate,
14
+ Awareness,
15
+ encodeAwarenessUpdate,
16
+ removeAwarenessStates,
17
+ } from "y-protocols/awareness";
18
+ import * as syncProtocol from "y-protocols/sync";
19
+ import { decodeFrame, encodeFrame } from "./envelope.ts";
20
+ import type { CollabFrame, ControlMessage } from "./envelope.ts";
21
+ import type { CollabIdentity } from "./provider.ts";
22
+ import { sourceText } from "./schema.ts";
23
+
24
+ export interface RoomSocket {
25
+ send: (data: Uint8Array) => void;
26
+ close: (code?: number, reason?: string) => void;
27
+ }
28
+
29
+ export interface CollabHostOptions {
30
+ /** Current file text for seeding a room's source, or null when unavailable. */
31
+ loadSource: (path: string) => Promise<string | null>;
32
+ /** Refuse a path with an error code (e.g. "binary-file"), or null to allow. */
33
+ rejectPath?: (path: string) => string | null;
34
+ /** A room's source text changed (debounce and persist in the embedder). */
35
+ onSourceChange?: (path: string) => void;
36
+ /** A client asked to persist this path now; resolve when durable. */
37
+ onFlush?: (path: string) => Promise<void> | void;
38
+ /** The last subscriber left a room (schedule teardown in the embedder). */
39
+ onEmpty?: (path: string) => void;
40
+ }
41
+
42
+ interface Room {
43
+ doc: Y.Doc;
44
+ epoch: number;
45
+ subscribers: Set<HostConnection>;
46
+ ready: Promise<void>;
47
+ }
48
+
49
+ export interface HostConnection {
50
+ handleMessage: (data: Uint8Array) => void;
51
+ close: () => void;
52
+ }
53
+
54
+ export interface CollabHost {
55
+ connect: (socket: RoomSocket, identity: CollabIdentity) => HostConnection;
56
+ /**
57
+ * Out-of-band content replacement: bump the path's epoch, drop its Y history, and tell every
58
+ * subscriber to re-open. The invariant "Y history never dies without an epoch bump" lives here.
59
+ */
60
+ resetDoc: (path: string) => void;
61
+ /** The room's current source text, or null when no room is live. */
62
+ sourceOf: (path: string) => string | null;
63
+ /** Live subscriber count (0 when the room is absent). */
64
+ subscriberCount: (path: string) => number;
65
+ /** Destroy an empty room (embedder teardown after its grace period). */
66
+ destroyRoomIfEmpty: (path: string) => void;
67
+ destroy: () => void;
68
+ }
69
+
70
+ export function createCollabHost(options: CollabHostOptions): CollabHost {
71
+ const rooms = new Map<string, Room>();
72
+ /** Epochs survive room teardown so re-opened docs never reuse a dead history's epoch. */
73
+ const epochs = new Map<string, number>();
74
+ const connections = new Set<InternalConnection>();
75
+ const awarenessDoc = new Y.Doc();
76
+ const awareness = new Awareness(awarenessDoc);
77
+ // The host's own (empty) local state must not leak into rosters.
78
+ awareness.setLocalState(null);
79
+
80
+ interface InternalConnection extends HostConnection {
81
+ socket: RoomSocket;
82
+ identity: CollabIdentity;
83
+ subscribed: Set<string>;
84
+ /** Awareness clientIDs this socket controls (removed on close). */
85
+ controlledIds: Set<number>;
86
+ warnedReadOnly: boolean;
87
+ closed: boolean;
88
+ }
89
+
90
+ const send = (conn: InternalConnection, frame: CollabFrame) => {
91
+ try {
92
+ conn.socket.send(encodeFrame(frame));
93
+ } catch {
94
+ // A dying socket cleans up through its close handler.
95
+ }
96
+ };
97
+
98
+ const sendControl = (conn: InternalConnection, message: ControlMessage) => {
99
+ send(conn, { message, type: "control" });
100
+ };
101
+
102
+ const broadcastToRoom = (room: Room, frame: CollabFrame, except?: InternalConnection) => {
103
+ const data = encodeFrame(frame);
104
+ for (const subscriber of room.subscribers) {
105
+ const target = subscriber as InternalConnection;
106
+ if (target !== except && !target.closed) {
107
+ try {
108
+ target.socket.send(data);
109
+ } catch {
110
+ // Skip dying sockets.
111
+ }
112
+ }
113
+ }
114
+ };
115
+
116
+ const ensureRoom = (path: string): Room => {
117
+ let room = rooms.get(path);
118
+ if (room) {
119
+ return room;
120
+ }
121
+ const doc = new Y.Doc();
122
+ const epoch = epochs.get(path) ?? 0;
123
+ epochs.set(path, epoch);
124
+ room = {
125
+ doc,
126
+ epoch,
127
+ ready: (async () => {
128
+ const source = await options.loadSource(path);
129
+ if (source === null) {
130
+ throw new Error("content-not-loaded");
131
+ }
132
+ doc.transact(() => {
133
+ sourceText(doc).insert(0, source);
134
+ }, "seed");
135
+ })(),
136
+ subscribers: new Set(),
137
+ };
138
+ doc.on("update", (update: Uint8Array, origin: unknown) => {
139
+ const current = rooms.get(path);
140
+ if (!current || current.doc !== doc) {
141
+ return;
142
+ }
143
+ const body = encoding.createEncoder();
144
+ syncProtocol.writeUpdate(body, update);
145
+ broadcastToRoom(
146
+ current,
147
+ { body: encoding.toUint8Array(body), epoch: current.epoch, path, type: "doc-sync" },
148
+ origin instanceof Object && "socket" in origin ? (origin as InternalConnection) : undefined,
149
+ );
150
+ options.onSourceChange?.(path);
151
+ });
152
+ rooms.set(path, room);
153
+ return room;
154
+ };
155
+
156
+ const handleOpen = async (conn: InternalConnection, path: string) => {
157
+ const rejection = options.rejectPath?.(path) ?? null;
158
+ if (rejection) {
159
+ sendControl(conn, { code: rejection, message: `Refused: ${rejection}`, path, type: "error" });
160
+ return;
161
+ }
162
+ const room = ensureRoom(path);
163
+ try {
164
+ await room.ready;
165
+ } catch {
166
+ rooms.delete(path);
167
+ sendControl(conn, {
168
+ code: "content-not-loaded",
169
+ message: "The file is not available for co-editing yet",
170
+ path,
171
+ type: "error",
172
+ });
173
+ return;
174
+ }
175
+ if (conn.closed || rooms.get(path) !== room) {
176
+ return;
177
+ }
178
+ room.subscribers.add(conn);
179
+ conn.subscribed.add(path);
180
+ sendControl(conn, { epoch: room.epoch, path, type: "opened" });
181
+ // Server-initiated step1: learn the client's state (it replies step2 + its own step1).
182
+ const body = encoding.createEncoder();
183
+ syncProtocol.writeSyncStep1(body, room.doc);
184
+ send(conn, { body: encoding.toUint8Array(body), epoch: room.epoch, path, type: "doc-sync" });
185
+ };
186
+
187
+ const handleDocSync = (
188
+ conn: InternalConnection,
189
+ path: string,
190
+ epoch: number,
191
+ body: Uint8Array,
192
+ ) => {
193
+ const room = rooms.get(path);
194
+ if (!room || !conn.subscribed.has(path) || epoch !== room.epoch) {
195
+ sendControl(conn, {
196
+ epoch: epochs.get(path) ?? 0,
197
+ path,
198
+ type: "doc-reset",
199
+ });
200
+ return;
201
+ }
202
+ const decoder = decoding.createDecoder(body);
203
+ const messageType = decoding.readVarUint(decoder);
204
+ const readOnly = conn.identity.permission === "read";
205
+ if (readOnly && messageType !== syncProtocol.messageYjsSyncStep1) {
206
+ if (!conn.warnedReadOnly) {
207
+ conn.warnedReadOnly = true;
208
+ sendControl(conn, {
209
+ code: "read-only",
210
+ message: "Write access required",
211
+ path,
212
+ type: "error",
213
+ });
214
+ }
215
+ return;
216
+ }
217
+ const reply = encoding.createEncoder();
218
+ switch (messageType) {
219
+ case syncProtocol.messageYjsSyncStep1: {
220
+ syncProtocol.readSyncStep1(decoder, reply, room.doc);
221
+ break;
222
+ }
223
+ case syncProtocol.messageYjsSyncStep2: {
224
+ syncProtocol.readSyncStep2(decoder, room.doc, conn);
225
+ break;
226
+ }
227
+ case syncProtocol.messageYjsUpdate: {
228
+ syncProtocol.readUpdate(decoder, room.doc, conn);
229
+ break;
230
+ }
231
+ default: {
232
+ return;
233
+ }
234
+ }
235
+ if (encoding.length(reply) > 0) {
236
+ send(conn, { body: encoding.toUint8Array(reply), epoch: room.epoch, path, type: "doc-sync" });
237
+ }
238
+ };
239
+
240
+ const handleAwareness = (conn: InternalConnection, body: Uint8Array) => {
241
+ // Track which clientIDs this socket controls so its states drop on close.
242
+ try {
243
+ const decoder = decoding.createDecoder(body);
244
+ const count = decoding.readVarUint(decoder);
245
+ for (let i = 0; i < count; i++) {
246
+ conn.controlledIds.add(decoding.readVarUint(decoder));
247
+ decoding.readVarUint(decoder);
248
+ decoding.readVarString(decoder);
249
+ }
250
+ } catch {
251
+ return;
252
+ }
253
+ applyAwarenessUpdate(awareness, body, conn);
254
+ const data = encodeFrame({ body, type: "awareness" });
255
+ for (const other of connections) {
256
+ if (other !== conn && !other.closed) {
257
+ try {
258
+ other.socket.send(data);
259
+ } catch {
260
+ // Skip dying sockets.
261
+ }
262
+ }
263
+ }
264
+ };
265
+
266
+ const unsubscribe = (conn: InternalConnection, path: string) => {
267
+ const room = rooms.get(path);
268
+ conn.subscribed.delete(path);
269
+ if (room) {
270
+ room.subscribers.delete(conn);
271
+ if (room.subscribers.size === 0) {
272
+ options.onEmpty?.(path);
273
+ }
274
+ }
275
+ };
276
+
277
+ return {
278
+ connect(socket, identity) {
279
+ const conn: InternalConnection = {
280
+ close: () => {
281
+ if (conn.closed) {
282
+ return;
283
+ }
284
+ conn.closed = true;
285
+ // Detached copy: unsubscribe mutates the set mid-walk.
286
+ const paths = [...conn.subscribed];
287
+ for (const path of paths) {
288
+ unsubscribe(conn, path);
289
+ }
290
+ connections.delete(conn);
291
+ // Only ids the awareness actually adopted can be removed/encoded (a client may have
292
+ // Announced ids whose updates were stale and never applied).
293
+ const known = [...conn.controlledIds].filter((id) => awareness.meta.has(id));
294
+ if (known.length > 0) {
295
+ removeAwarenessStates(awareness, known, "disconnect");
296
+ const removal = encodeAwarenessUpdate(awareness, known);
297
+ const data = encodeFrame({ body: removal, type: "awareness" });
298
+ for (const other of connections) {
299
+ if (!other.closed) {
300
+ try {
301
+ other.socket.send(data);
302
+ } catch {
303
+ // Skip dying sockets.
304
+ }
305
+ }
306
+ }
307
+ }
308
+ },
309
+ closed: false,
310
+ controlledIds: new Set(),
311
+ handleMessage: (data) => {
312
+ let frame: CollabFrame;
313
+ try {
314
+ frame = decodeFrame(data);
315
+ } catch {
316
+ sendControl(conn, {
317
+ code: "unknown-frame",
318
+ message: "Malformed frame",
319
+ type: "error",
320
+ });
321
+ return;
322
+ }
323
+ switch (frame.type) {
324
+ case "control": {
325
+ const { message } = frame;
326
+ if (message.type === "open") {
327
+ void handleOpen(conn, message.path);
328
+ } else if (message.type === "flush") {
329
+ void (async () => {
330
+ await options.onFlush?.(message.path);
331
+ sendControl(conn, { path: message.path, type: "flush-ack" });
332
+ })();
333
+ }
334
+ return;
335
+ }
336
+ case "doc-sync": {
337
+ handleDocSync(conn, frame.path, frame.epoch, frame.body);
338
+ return;
339
+ }
340
+ case "doc-close": {
341
+ unsubscribe(conn, frame.path);
342
+ return;
343
+ }
344
+ default: {
345
+ // The only remaining variant is the awareness frame.
346
+ handleAwareness(conn, frame.body);
347
+ }
348
+ }
349
+ },
350
+ identity,
351
+ socket,
352
+ subscribed: new Set(),
353
+ warnedReadOnly: false,
354
+ };
355
+ connections.add(conn);
356
+ sendControl(conn, {
357
+ color: identity.color,
358
+ login: identity.login,
359
+ permission: identity.permission,
360
+ type: "hello",
361
+ ...(identity.name === undefined ? {} : { name: identity.name }),
362
+ ...(identity.avatarUrl === undefined ? {} : { avatarUrl: identity.avatarUrl }),
363
+ });
364
+ // Late joiners receive the current presence roster immediately.
365
+ const states = awareness.getStates();
366
+ if (states.size > 0) {
367
+ send(conn, {
368
+ body: encodeAwarenessUpdate(awareness, [...states.keys()]),
369
+ type: "awareness",
370
+ });
371
+ }
372
+ return conn;
373
+ },
374
+
375
+ destroy() {
376
+ // Detached copy: close() removes connections mid-walk.
377
+ const live = [...connections];
378
+ for (const conn of live) {
379
+ conn.close();
380
+ }
381
+ for (const room of rooms.values()) {
382
+ room.doc.destroy();
383
+ }
384
+ rooms.clear();
385
+ awareness.destroy();
386
+ awarenessDoc.destroy();
387
+ },
388
+
389
+ destroyRoomIfEmpty(path) {
390
+ const room = rooms.get(path);
391
+ if (room && room.subscribers.size === 0) {
392
+ room.doc.destroy();
393
+ rooms.delete(path);
394
+ }
395
+ },
396
+
397
+ resetDoc(path) {
398
+ const nextEpoch = (epochs.get(path) ?? 0) + 1;
399
+ epochs.set(path, nextEpoch);
400
+ const room = rooms.get(path);
401
+ if (!room) {
402
+ return;
403
+ }
404
+ broadcastToRoom(room, {
405
+ message: { epoch: nextEpoch, path, type: "doc-reset" },
406
+ type: "control",
407
+ });
408
+ for (const subscriber of room.subscribers) {
409
+ (subscriber as InternalConnection).subscribed.delete(path);
410
+ }
411
+ room.doc.destroy();
412
+ rooms.delete(path);
413
+ },
414
+
415
+ sourceOf(path) {
416
+ const room = rooms.get(path);
417
+ return room ? sourceText(room.doc).toString() : null;
418
+ },
419
+
420
+ subscriberCount(path) {
421
+ return rooms.get(path)?.subscribers.size ?? 0;
422
+ },
423
+ };
424
+ }