// static/collab-client.js // // Lightweight step-authority client for Diffdown's prosemirror-collab protocol. // Works with both source (text-patch steps) and rich (PM steps) modes. export class CollabClient { constructor(rkey, initialVersion, applyRemoteSteps) { this.rkey = rkey; this.version = initialVersion; // authoritative for source mode this.applyRemoteSteps = applyRemoteSteps; this._inflight = false; this._queue = []; // source mode only this._pmGetSendable = null; // rich mode: () => sendableSteps(state) | null this._pmConfirm = null; // rich mode: (steps, clientIDs) => void — confirms sent steps } // Source mode: queue explicit step objects and flush. sendSteps(steps) { this._queue.push(...steps); this._flush(); } // Rich mode: register the sendable-steps provider and confirmation callback. // getSendable: () => { steps, version } | null (calls sendableSteps(pmState)) // confirm: (steps, clientIDs) => void (calls receiveTransaction on own steps) // getVersion: () => number (calls getVersion(pmState)) setPMHandlers(getSendable, confirm, getVersion) { this._pmGetSendable = getSendable; this._pmConfirm = confirm; this._pmGetVersion = getVersion || null; this._queue = []; } // Rich mode: call after every PM transaction. notifyPMChange() { this._flush(); } async _flush() { if (this._inflight) return; const isRichMode = !!this._pmGetSendable; let toSend, clientVersion, sentSteps; if (isRichMode) { const sendable = this._pmGetSendable(); if (!sendable || sendable.steps.length === 0) return; clientVersion = sendable.version; sentSteps = sendable.steps; // keep reference to confirm on success toSend = sentSteps.map(s => ({type: 'pm-step', json: JSON.stringify(s.toJSON())})); } else { if (this._queue.length === 0) return; clientVersion = this.version; toSend = this._queue.slice(); } this._inflight = true; try { const resp = await fetch(`/api/docs/${this.rkey}/steps`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ clientVersion, steps: toSend.map(s => JSON.stringify(s)), clientID: this._clientID || '', }), }); if (resp.ok) { const {version} = await resp.json(); this.version = version; if (isRichMode) { // Confirm our own steps in the collab plugin so it advances its // version counter and clears the unconfirmed buffer. if (this._pmConfirm && sentSteps) { this._pmConfirm(sentSteps, sentSteps.map(() => this._clientID || '')); } } else { this._queue = this._queue.slice(toSend.length); } } else if (resp.status === 409) { const {steps: missedJSON} = await resp.json(); let missed = missedJSON.map(s => JSON.parse(s)); // Skip steps already applied via WS while the POST was inflight. // Without this, the same steps are applied twice, corrupting the // document state and producing TransformError / position-out-of-range. if (isRichMode && this._pmGetVersion) { const currentVersion = this._pmGetVersion(); const alreadyApplied = Math.max(0, currentVersion - clientVersion); if (alreadyApplied > 0) missed = missed.slice(alreadyApplied); } if (missed.length > 0) { this.applyRemoteSteps(missed); } setTimeout(() => this._flush(), 30); return; // skip finally retry } else { console.error('CollabClient: unexpected status', resp.status); } } catch (e) { console.error('CollabClient: fetch error', e); } finally { this._inflight = false; const hasMore = isRichMode ? (this._pmGetSendable()?.steps.length > 0) : this._queue.length > 0; if (hasMore) { setTimeout(() => this._flush(), 50); } } } handleWSMessage(msg, myClientID) { if (msg.type !== 'steps') return; if (msg.clientID === myClientID) { // Our own steps echoed back — version already advanced by POST success path. return; } const steps = msg.steps.map(s => JSON.parse(s)); this.version = msg.version; this.applyRemoteSteps(steps); } setClientID(id) { this._clientID = id; } }