// oxlint-disable no-use-before-define -- helper functions and class methods reference later-defined utilities /** * YjsDocumentManager - Manages local Y.Doc instances for collaborative editing. * * Responsibilities: * - Create and manage Y.Doc instances per document field * - Handle Yjs sync protocol (state vector exchange) * - Apply local and remote updates * - Track connection state per document * - Generate derived content from Y.Doc */ import * as Y from "yjs"; import { clearPersistedYjsDocuments, DEFAULT_PERSISTED_YJS_PREFIX, } from "./persistence.js"; import { deriveProsemirrorContent, normalizeDerivedContent, seedProsemirrorFragment, } from "./prosemirror-content.js"; import { calculateRetryDelay, normalizeRetryConfig } from "./retry.js"; import type { ClientMessage, ConnectOptions, DocumentConnectionState, DocumentKey, LiveEditingErrorMessage, LiveEditingRetryConfig, ServerMessage, YjsDocumentManagerConfig, YjsSyncStep2Message, YjsTransport, YjsTransportConnectionState, YjsUpdateMessage, } from "./types.js"; import { fromDocumentKeyString, isLiveEditingErrorMessage, isRetryableLiveEditingErrorCode, isYjsSyncStep2Message, isYjsUpdateMessage, toDocumentKeyString, } from "./types.js"; interface DocumentState { doc: Y.Doc; connectionState: DocumentConnectionState; lastSeq: number; refCount: number; pendingLocalUpdates: Uint8Array[]; unsubscribe?: () => void; pendingInitialContent?: string; retryAttempts: number; retryTimer?: ReturnType; } /** * Manages Y.Doc instances for collaborative editing. */ export class YjsDocumentManager { private readonly docs = new Map(); private readonly remoteUpdateOrigin = { source: "remote" } as const; private readonly config: YjsDocumentManagerConfig; private readonly liveEditingRetryConfig: LiveEditingRetryConfig; private persistenceKeyPrefix: string; private transport: YjsTransport | null = null; private transportConnectionState: YjsTransportConnectionState = "disconnected"; private unsubscribeTransportMessage: (() => void) | null = null; private unsubscribeTransportConnection: (() => void) | null = null; private readonly connectionStateCallbacks = new Map< string, Set<(state: DocumentConnectionState) => void> >(); private readonly contentCallbacks = new Map< string, Set<(content: string) => void> >(); private reconnectReplayGeneration = 0; constructor(config: YjsDocumentManagerConfig) { this.config = config; this.liveEditingRetryConfig = normalizeRetryConfig(config.liveEditingRetry); this.persistenceKeyPrefix = config.persistenceKeyPrefix ?? DEFAULT_PERSISTED_YJS_PREFIX; } setPersistenceKeyPrefix(prefix: string): void { this.persistenceKeyPrefix = prefix || DEFAULT_PERSISTED_YJS_PREFIX; } clearPersistedDocuments(): void { clearPersistedYjsDocuments(this.persistenceKeyPrefix); } setTransport(transport: YjsTransport): void { this.unsubscribeTransportMessage?.(); this.unsubscribeTransportConnection?.(); this.transport = transport; this.transportConnectionState = "disconnected"; this.unsubscribeTransportMessage = transport.onMessage((message) => { this.handleMessage(message); }); this.unsubscribeTransportConnection = transport.onConnectionStateChange( (state) => { this.handleTransportConnectionStateChange(state); } ); this.handleTransportConnectionStateChange( transport.isConnected() ? "connected" : "disconnected" ); } getDocument(docKey: DocumentKey): Y.Doc { const keyString = toDocumentKeyString(docKey); return this.getOrCreateState(keyString).doc; } /** * Connect to a document for collaborative editing. */ connect(docKey: DocumentKey, options: ConnectOptions = {}): void { const keyString = toDocumentKeyString(docKey); const state = this.getOrCreateState(keyString); const wasActive = YjsDocumentManager.isDocActive(state); const shouldRetryDisconnected = wasActive && state.connectionState === "disconnected"; if (!shouldRetryDisconnected) { state.refCount += 1; } if (wasActive && !shouldRetryDisconnected) { return; } YjsDocumentManager.resetRetryState(state); if (!wasActive) { this.attachLocalUpdateHandler(docKey, keyString, state); const existingState = YjsDocumentManager.getEncodedDocumentState( state.doc ); if (existingState) { state.pendingLocalUpdates.push(existingState); this.persistDocument(keyString, state.doc); } } state.pendingInitialContent = options.initialContent; this.requestSyncStep1(docKey, keyString, state); } disconnect(docKey: DocumentKey): void { const keyString = toDocumentKeyString(docKey); const state = this.docs.get(keyString); if (!(state && YjsDocumentManager.isDocActive(state))) { return; } state.refCount -= 1; if (state.refCount > 0) { return; } state.pendingInitialContent = undefined; YjsDocumentManager.resetRetryState(state); YjsDocumentManager.detachLocalUpdateHandler(state); this.setConnectionState(keyString, "disconnected"); } getConnectionState(docKey: DocumentKey): DocumentConnectionState { const keyString = toDocumentKeyString(docKey); return this.docs.get(keyString)?.connectionState ?? "disconnected"; } onConnectionStateChange( docKey: DocumentKey, // oxlint-disable-next-line prefer-await-to-callbacks -- event listener registration callback: (state: DocumentConnectionState) => void ): () => void { const keyString = toDocumentKeyString(docKey); let callbacks = this.connectionStateCallbacks.get(keyString); if (!callbacks) { callbacks = new Set(); this.connectionStateCallbacks.set(keyString, callbacks); } callbacks.add(callback); const currentState = this.getConnectionState(docKey); // oxlint-disable-next-line prefer-await-to-callbacks -- event listener registration callback(currentState); return () => { callbacks?.delete(callback); if (callbacks?.size === 0) { this.connectionStateCallbacks.delete(keyString); } }; } onContentChange( docKey: DocumentKey, // oxlint-disable-next-line prefer-await-to-callbacks -- event listener registration callback: (content: string) => void ): () => void { const keyString = toDocumentKeyString(docKey); let callbacks = this.contentCallbacks.get(keyString); if (!callbacks) { callbacks = new Set(); this.contentCallbacks.set(keyString, callbacks); } callbacks.add(callback); const currentContent = this.getDerivedContent(docKey); // oxlint-disable-next-line prefer-await-to-callbacks -- event listener registration callback(currentContent); return () => { callbacks?.delete(callback); if (callbacks?.size === 0) { this.contentCallbacks.delete(keyString); } }; } /** * Apply a remote update to a document. */ applyRemoteUpdate(docKey: DocumentKey, update: Uint8Array): void { const keyString = toDocumentKeyString(docKey); const state = this.docs.get(keyString); if (!state) { return; } Y.applyUpdate(state.doc, update, this.remoteUpdateOrigin); this.persistDocument(keyString, state.doc); this.notifyContentChange(keyString); } /** * Apply a snapshot (full state) to a document. * Semantically identical to applyRemoteUpdate. A Yjs snapshot * is applied using the same Y.applyUpdate mechanism. */ applySnapshot(docKey: DocumentKey, snapshot: Uint8Array): void { this.applyRemoteUpdate(docKey, snapshot); } getDerivedContent(docKey: DocumentKey): string { const state = this.docs.get(toDocumentKeyString(docKey)); if (!state) { return ""; } const docContent = deriveProsemirrorContent(state.doc); if (docContent.length > 0 || state.pendingInitialContent === undefined) { return docContent; } return normalizeDerivedContent(state.pendingInitialContent); } getStateVector(docKey: DocumentKey): Uint8Array { const state = this.docs.get(toDocumentKeyString(docKey)); if (!state) { return new Uint8Array(); } return Y.encodeStateVector(state.doc); } getUpdatesSince( docKey: DocumentKey, stateVector: Uint8Array ): Uint8Array | null { const state = this.docs.get(toDocumentKeyString(docKey)); if (!state) { return null; } const update = Y.encodeStateAsUpdate(state.doc, stateVector); // Check if update is empty (no changes) if (update.length <= 2) { return null; } return update; } getEncodedState(docKey: DocumentKey): Uint8Array | null { const state = this.docs.get(toDocumentKeyString(docKey)); if (!state) { return null; } return Y.encodeStateAsUpdate(state.doc); } destroy(docKey: DocumentKey): void { const keyString = toDocumentKeyString(docKey); const state = this.docs.get(keyString); if (state) { state.refCount = 0; state.pendingInitialContent = undefined; YjsDocumentManager.resetRetryState(state); YjsDocumentManager.detachLocalUpdateHandler(state); this.setConnectionState(keyString, "disconnected"); state.doc.destroy(); this.docs.delete(keyString); } this.connectionStateCallbacks.delete(keyString); this.contentCallbacks.delete(keyString); } destroyAll(): void { for (const [keyString] of this.docs) { const docKey = fromDocumentKeyString(keyString); if (docKey) { this.destroy(docKey); } } } private getOrCreateState(keyString: string): DocumentState { let state = this.docs.get(keyString); if (!state) { state = YjsDocumentManager.createDocumentState(); this.restorePersistedDocument(keyString, state.doc); this.docs.set(keyString, state); } return state; } private static createDocumentState(): DocumentState { return { connectionState: "disconnected", doc: new Y.Doc(), lastSeq: 0, pendingLocalUpdates: [], refCount: 0, retryAttempts: 0, }; } private handleTransportConnectionStateChange( state: YjsTransportConnectionState ): void { const wasConnected = this.transportConnectionState === "connected"; const isConnected = state === "connected"; this.transportConnectionState = state; this.reconnectReplayGeneration += 1; const replayGeneration = this.reconnectReplayGeneration; if (!isConnected) { this.markActiveDocumentsAsConnecting(); return; } if (!wasConnected) { const replayTargets = this.getActiveReconnectReplayTargets(); if (replayTargets.length === 0) { return; } enqueueReconnectReplay(() => { if ( this.reconnectReplayGeneration !== replayGeneration || this.transportConnectionState !== "connected" ) { return; } for (const { docKey, keyString } of replayTargets) { const currentState = this.docs.get(keyString); if (!(currentState && YjsDocumentManager.isDocActive(currentState))) { continue; } this.requestSyncStep1(docKey, keyString, currentState); } }); } } private markActiveDocumentsAsConnecting(): void { for (const [keyString, state] of this.docs) { if (!YjsDocumentManager.isDocActive(state)) { continue; } this.setConnectionState(keyString, "connecting"); } } private getActiveReconnectReplayTargets(): { docKey: DocumentKey; keyString: string; }[] { const replayTargets: { docKey: DocumentKey; keyString: string }[] = []; for (const [keyString, state] of this.docs) { if (!YjsDocumentManager.isDocActive(state)) { continue; } const docKey = fromDocumentKeyString(keyString); if (!docKey) { continue; } replayTargets.push({ docKey, keyString }); } return replayTargets; } private requestSyncStep1( docKey: DocumentKey, keyString: string, state: DocumentState ): void { if (!YjsDocumentManager.isDocActive(state)) { this.setConnectionState(keyString, "disconnected"); return; } if (!this.transport?.isConnected()) { this.setConnectionState(keyString, "connecting"); return; } this.setConnectionState(keyString, "syncing"); this.sendSyncStep1(docKey); } /** * Seed pending initial content into the Y.Doc. Returns true if content was seeded. */ private static seedPendingContent(state: DocumentState): boolean { const { pendingInitialContent } = state; if (pendingInitialContent === undefined) { return false; } state.pendingInitialContent = undefined; // Seed unless the doc already has real text. seedProsemirrorFragment itself // is text-aware and clears textless children, so a stray empty paragraph // from a half-synced remote doc no longer blocks canonical seeding. if (deriveProsemirrorContent(state.doc).length > 0) { return false; } state.doc.transact(() => { seedProsemirrorFragment(state.doc, pendingInitialContent); }); return true; } private static getEncodedDocumentState(doc: Y.Doc): Uint8Array | null { const encodedState = Y.encodeStateAsUpdate(doc); return encodedState.length <= 2 ? null : encodedState; } private static clearRetryTimer(state: DocumentState): void { if (state.retryTimer !== undefined) { clearTimeout(state.retryTimer); state.retryTimer = undefined; } } private static resetRetryState(state: DocumentState): void { YjsDocumentManager.clearRetryTimer(state); state.retryAttempts = 0; } private scheduleRetrySyncStep1( docKey: DocumentKey, keyString: string, state: DocumentState ): void { if (!YjsDocumentManager.isDocActive(state)) { this.setConnectionState(keyString, "disconnected"); return; } if (state.retryTimer !== undefined) { return; } if (state.retryAttempts >= this.liveEditingRetryConfig.maxRetries) { YjsDocumentManager.resetRetryState(state); this.setConnectionState(keyString, "disconnected"); return; } const retryDelayMs = calculateRetryDelay( state.retryAttempts, this.liveEditingRetryConfig ); state.retryAttempts += 1; this.setConnectionState(keyString, "connecting"); state.retryTimer = setTimeout(() => { const currentState = this.docs.get(keyString); if (!currentState) { return; } currentState.retryTimer = undefined; if (!YjsDocumentManager.isDocActive(currentState)) { this.setConnectionState(keyString, "disconnected"); return; } this.requestSyncStep1(docKey, keyString, currentState); }, retryDelayMs); } private attachLocalUpdateHandler( docKey: DocumentKey, keyString: string, state: DocumentState ): void { if (state.unsubscribe) { return; } const handler = (update: Uint8Array, origin: unknown) => { // Only process updates that originated locally. if (origin === this.remoteUpdateOrigin) { return; } this.persistDocument(keyString, state.doc); this.notifyContentChange(keyString); // During reconnect/syncing or transport outages, buffer local updates and // flush once we are connected again. if ( state.connectionState === "connected" && this.transport?.isConnected() === true ) { this.sendUpdate(docKey, update); return; } state.pendingLocalUpdates.push(update); }; state.doc.on("update", handler); state.unsubscribe = () => { state.doc.off("update", handler); }; } private static detachLocalUpdateHandler(state: DocumentState): void { state.unsubscribe?.(); state.unsubscribe = undefined; } private static isDocActive(state: DocumentState): boolean { return state.refCount > 0; } private shouldApplyRemoteMessage(state: DocumentState): boolean { return ( YjsDocumentManager.isDocActive(state) && this.transport?.isConnected() === true ); } private sendIfConnected(message: ClientMessage): void { if (this.transport?.isConnected()) { this.transport.send(message); } } private setConnectionState( keyString: string, state: DocumentConnectionState ): void { const docState = this.docs.get(keyString); if (docState) { docState.connectionState = state; } const callbacks = this.connectionStateCallbacks.get(keyString); if (callbacks) { for (const callback of callbacks) { // oxlint-disable-next-line prefer-await-to-callbacks -- event listener registration callback(state); } } } private notifyContentChange(keyString: string): void { const callbacks = this.contentCallbacks.get(keyString); if (!callbacks) { return; } const docKey = fromDocumentKeyString(keyString); if (!docKey) { return; } const content = this.getDerivedContent(docKey); for (const callback of callbacks) { // oxlint-disable-next-line prefer-await-to-callbacks -- event listener registration callback(content); } } private sendSyncStep1(docKey: DocumentKey): void { this.sendIfConnected({ clientId: this.config.clientId, entityId: docKey.entityId, entityType: docKey.entityType, fieldName: docKey.fieldName, payload: base64Encode(this.getStateVector(docKey)), type: "yjs_sync_step1", }); } private sendUpdate(docKey: DocumentKey, update: Uint8Array): void { this.sendIfConnected({ clientId: this.config.clientId, connId: this.config.connId, entityId: docKey.entityId, entityType: docKey.entityType, fieldName: docKey.fieldName, payload: base64Encode(update), type: "yjs_update", }); } private flushPendingLocalUpdates( docKey: DocumentKey, state: DocumentState ): void { if (state.pendingLocalUpdates.length === 0) { return; } const mergedUpdate = Y.mergeUpdates(state.pendingLocalUpdates); state.pendingLocalUpdates = []; this.sendUpdate(docKey, mergedUpdate); } private handleMessage(message: ServerMessage): void { if (isYjsSyncStep2Message(message)) { this.handleSyncStep2(message); } else if (isYjsUpdateMessage(message)) { this.handleUpdate(message); } else if (isLiveEditingErrorMessage(message)) { this.handleError(message); } } private handleSyncStep2(message: YjsSyncStep2Message): void { const keyString = toDocumentKeyString(message); const state = this.docs.get(keyString); if (!(state && this.shouldApplyRemoteMessage(state))) { return; } if (message.seq < state.lastSeq) { return; } if ( message.seq === state.lastSeq && state.connectionState === "connected" ) { return; } const update = tryBase64Decode(message.payload); if (!update) { return; } if (!tryApplyUpdate(state.doc, update, this.remoteUpdateOrigin)) { return; } YjsDocumentManager.seedPendingContent(state); state.lastSeq = message.seq; YjsDocumentManager.resetRetryState(state); this.persistDocument(keyString, state.doc); this.flushPendingLocalUpdates(message, state); this.setConnectionState(keyString, "connected"); this.notifyContentChange(keyString); } private handleUpdate(message: YjsUpdateMessage): void { // Ignore updates echoed to the same live connection. if (message.connId === this.config.connId) { return; } const keyString = toDocumentKeyString(message); const state = this.docs.get(keyString); if (!(state && this.shouldApplyRemoteMessage(state))) { return; } if (state.connectionState !== "connected") { return; } if (message.seq <= state.lastSeq) { return; } const expectedSeq = state.lastSeq + 1; if (message.seq > expectedSeq) { this.requestSyncStep1(message, keyString, state); return; } const update = tryBase64Decode(message.payload); if (!update) { return; } if (!tryApplyUpdate(state.doc, update, this.remoteUpdateOrigin)) { return; } state.lastSeq = message.seq; this.persistDocument(keyString, state.doc); this.notifyContentChange(keyString); } private handleError(message: LiveEditingErrorMessage): void { const keyString = toDocumentKeyString(message); const state = this.docs.get(keyString); if (!state) { return; } if (!isRetryableLiveEditingErrorCode(message.code)) { YjsDocumentManager.resetRetryState(state); this.setConnectionState(keyString, "disconnected"); return; } this.scheduleRetrySyncStep1(message, keyString, state); } private getPersistedDocumentKey(keyString: string): string { return `${this.persistenceKeyPrefix}${keyString}`; } private restorePersistedDocument(keyString: string, doc: Y.Doc): void { if (typeof localStorage === "undefined") { return; } try { const encodedUpdate = localStorage.getItem( this.getPersistedDocumentKey(keyString) ); if (!encodedUpdate) { return; } const update = base64Decode(encodedUpdate); Y.applyUpdate(doc, update, this.remoteUpdateOrigin); } catch { // Ignore persistence read errors and continue with empty document state. } } private persistDocument(keyString: string, doc: Y.Doc): void { if (typeof localStorage === "undefined") { return; } try { const encodedUpdate = base64Encode(Y.encodeStateAsUpdate(doc)); localStorage.setItem( this.getPersistedDocumentKey(keyString), encodedUpdate ); } catch { // Ignore persistence write errors (e.g. quota exceeded) and keep syncing. } } } // Base64 encoding/decoding utilities const base64Encode = (data: Uint8Array): string => { if (typeof Buffer !== "undefined") { return Buffer.from(data).toString("base64"); } if (typeof btoa === "function") { let binary = ""; for (const byte of data) { binary += String.fromCodePoint(byte); } return btoa(binary); } throw new Error("No base64 encoder available"); }; const base64Decode = (str: string): Uint8Array => { const normalized = str.replaceAll(/\s+/g, ""); if (!BASE64_PATTERN.test(normalized)) { throw new Error("Invalid base64 payload"); } if (typeof Buffer !== "undefined") { return new Uint8Array(Buffer.from(normalized, "base64")); } if (typeof atob === "function") { const binary = atob(normalized); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) { bytes[i] = binary.codePointAt(i) as number; } return bytes; } throw new Error("No base64 decoder available"); }; const tryBase64Decode = (str: string): Uint8Array | null => { try { return base64Decode(str); } catch { return null; } }; const tryApplyUpdate = ( doc: Y.Doc, update: Uint8Array, origin: unknown ): boolean => { try { Y.applyUpdate(doc, update, origin); return true; } catch { return false; } }; const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; // oxlint-disable-next-line prefer-await-to-callbacks -- callback is a synchronous replay function, not a Node-style callback const enqueueReconnectReplay = (callback: () => void): void => { const runCallback = (): void => { try { // oxlint-disable-next-line prefer-await-to-callbacks -- synchronous invocation callback(); } catch { // replay failures are handled by the connection lifecycle } }; if (typeof queueMicrotask === "function") { queueMicrotask(runCallback); return; } // oxlint-disable-next-line prefer-await-to-then, no-callback-in-promise, no-void -- fallback for runtimes without queueMicrotask void Promise.resolve().then(runCallback); };