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 constructor(rkey, initialVersion, applyRemoteSteps) {
8 this.rkey = rkey;
9 this.version = initialVersion; // authoritative for source mode
10 this.applyRemoteSteps = applyRemoteSteps;
11 this._inflight = false;
12 this._queue = []; // source mode only
13 this._pmGetSendable = null; // rich mode: () => sendableSteps(state) | null
14 this._pmConfirm = null; // rich mode: (steps, clientIDs) => void — confirms sent steps
15 }
16
17 // Source mode: queue explicit step objects and flush.
18 sendSteps(steps) {
19 this._queue.push(...steps);
20 this._flush();
21 }
22
23 // Rich mode: register the sendable-steps provider and confirmation callback.
24 // getSendable: () => { steps, version } | null (calls sendableSteps(pmState))
25 // confirm: (steps, clientIDs) => void (calls receiveTransaction on own steps)
26 // getVersion: () => number (calls getVersion(pmState))
27 setPMHandlers(getSendable, confirm, getVersion) {
28 this._pmGetSendable = getSendable;
29 this._pmConfirm = confirm;
30 this._pmGetVersion = getVersion || null;
31 this._queue = [];
32 }
33
34 // Rich mode: call after every PM transaction.
35 notifyPMChange() {
36 this._flush();
37 }
38
39 async _flush() {
40 if (this._inflight) return;
41
42 const isRichMode = !!this._pmGetSendable;
43 let toSend, clientVersion, sentSteps;
44
45 if (isRichMode) {
46 const sendable = this._pmGetSendable();
47 if (!sendable || sendable.steps.length === 0) return;
48 clientVersion = sendable.version;
49 sentSteps = sendable.steps; // keep reference to confirm on success
50 toSend = sentSteps.map(s => ({type: 'pm-step', json: JSON.stringify(s.toJSON())}));
51 } else {
52 if (this._queue.length === 0) return;
53 clientVersion = this.version;
54 toSend = this._queue.slice();
55 }
56
57 this._inflight = true;
58 try {
59 const resp = await fetch(`/api/docs/${this.rkey}/steps`, {
60 method: 'POST',
61 headers: {'Content-Type': 'application/json'},
62 body: JSON.stringify({
63 clientVersion,
64 steps: toSend.map(s => JSON.stringify(s)),
65 clientID: this._clientID || '',
66 }),
67 });
68
69 if (resp.ok) {
70 const {version} = await resp.json();
71 this.version = version;
72 if (isRichMode) {
73 // Confirm our own steps in the collab plugin so it advances its
74 // version counter and clears the unconfirmed buffer.
75 if (this._pmConfirm && sentSteps) {
76 this._pmConfirm(sentSteps, sentSteps.map(() => this._clientID || ''));
77 }
78 } else {
79 this._queue = this._queue.slice(toSend.length);
80 }
81 } else if (resp.status === 409) {
82 const {steps: missedJSON} = await resp.json();
83 let missed = missedJSON.map(s => JSON.parse(s));
84 // Skip steps already applied via WS while the POST was inflight.
85 // Without this, the same steps are applied twice, corrupting the
86 // document state and producing TransformError / position-out-of-range.
87 if (isRichMode && this._pmGetVersion) {
88 const currentVersion = this._pmGetVersion();
89 const alreadyApplied = Math.max(0, currentVersion - clientVersion);
90 if (alreadyApplied > 0) missed = missed.slice(alreadyApplied);
91 }
92 if (missed.length > 0) {
93 this.applyRemoteSteps(missed);
94 }
95 setTimeout(() => this._flush(), 30);
96 return; // skip finally retry
97 } else {
98 console.error('CollabClient: unexpected status', resp.status);
99 }
100 } catch (e) {
101 console.error('CollabClient: fetch error', e);
102 } finally {
103 this._inflight = false;
104 const hasMore = isRichMode
105 ? (this._pmGetSendable()?.steps.length > 0)
106 : this._queue.length > 0;
107 if (hasMore) {
108 setTimeout(() => this._flush(), 50);
109 }
110 }
111 }
112
113 handleWSMessage(msg, myClientID) {
114 if (msg.type !== 'steps') return;
115 if (msg.clientID === myClientID) {
116 // Our own steps echoed back — version already advanced by POST success path.
117 return;
118 }
119 const steps = msg.steps.map(s => JSON.parse(s));
120 this.version = msg.version;
121 this.applyRemoteSteps(steps);
122 }
123
124 setClientID(id) { this._clientID = id; }
125}