Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol
diffdown.com
1// static/collab-client.js
2//
3// Lightweight step-authority client for Diffdown's prosemirror-collab protocol.
4// Works with both source (text-patch steps) and rich (PM steps) modes.
5
6export class CollabClient {
7 /**
8 * @param {string} rkey - Document rkey
9 * @param {number} initialVersion - Version the client started at
10 * @param {function(steps: object[]): void} applyRemoteSteps - Called when server confirms steps from others
11 */
12 constructor(rkey, initialVersion, applyRemoteSteps) {
13 this.rkey = rkey;
14 this.version = initialVersion;
15 this.applyRemoteSteps = applyRemoteSteps;
16 this._inflight = false;
17 this._queue = [];
18 }
19
20 /**
21 * Queue local steps and attempt to flush to the server.
22 * @param {object[]} steps - Array of step objects (text-patch or PM step JSON)
23 */
24 sendSteps(steps) {
25 this._queue.push(...steps);
26 this._flush();
27 }
28
29 async _flush() {
30 if (this._inflight || this._queue.length === 0) return;
31 this._inflight = true;
32 const toSend = this._queue.slice();
33 try {
34 const resp = await fetch(`/api/docs/${this.rkey}/steps`, {
35 method: 'POST',
36 headers: {'Content-Type': 'application/json'},
37 body: JSON.stringify({
38 clientVersion: this.version,
39 steps: toSend.map(s => JSON.stringify(s)),
40 clientID: this._clientID || '',
41 }),
42 });
43
44 if (resp.ok) {
45 const {version} = await resp.json();
46 this.version = version;
47 // Remove the steps we just confirmed.
48 this._queue = this._queue.slice(toSend.length);
49 } else if (resp.status === 409) {
50 // Server has steps we haven't seen yet.
51 const {version, steps: missedJSON} = await resp.json();
52 const missed = missedJSON.map(s => JSON.parse(s));
53 // Let the editor rebase local steps on top of missed ones.
54 this.applyRemoteSteps(missed);
55 this.version = version;
56 // Don't clear _queue — resubmit after rebase.
57 } else {
58 console.error('CollabClient: unexpected status', resp.status);
59 }
60 } catch (e) {
61 console.error('CollabClient: fetch error', e);
62 } finally {
63 this._inflight = false;
64 if (this._queue.length > 0) {
65 setTimeout(() => this._flush(), 50);
66 }
67 }
68 }
69
70 /**
71 * Call when a WebSocket "steps" message arrives from the server.
72 * Advances local version and notifies the editor.
73 * @param {object} msg - {type:"steps", steps:[...], version:N, clientID:string}
74 * @param {string} myClientID
75 */
76 handleWSMessage(msg, myClientID) {
77 if (msg.type !== 'steps') return;
78 if (msg.clientID === myClientID) {
79 // Our own steps confirmed — just advance version.
80 this.version = msg.version;
81 return;
82 }
83 const steps = msg.steps.map(s => JSON.parse(s));
84 this.version = msg.version;
85 this.applyRemoteSteps(steps);
86 }
87
88 setClientID(id) { this._clientID = id; }
89}