Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com
at 9199d4f348146b93a8d99c8a2fb6f3bf911d45ca 89 lines 3.3 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 /** 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}