Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol diffdown.com

feat(frontend): CollabClient — step-submission and rebase coordinator

+89
+89
static/collab-client.js
··· 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 + 6 + export 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 + }