Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com
at main 125 lines 5.1 kB view raw
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}