Saltar al contenido

Construir un editor colaborativo en tiempo real con CRDTs

Implementación paso a paso de un editor colaborativo con CRDTs: la librería Yjs, sincronización por WebSocket, el protocolo awareness y edición offline.

6 min de lectura
Diagrama de arquitectura de un editor colaborativo mostrando múltiples clientes sincronizando el estado del documento mediante operaciones de fusión CRDT

Operational Transformation (OT)—el enfoque de Google Docs—requiere un servidor central para resolver conflictos. Los CRDTs invierten este modelo por completo. Los Conflict-free Replicated Data Types son estructuras de datos que se pueden fusionar sin coordinación, lo que significa que cada cliente puede editar de forma independiente y la convergencia está garantizada matemáticamente. No se necesita ninguna autoridad central.

Esto hace que los CRDTs sean ideales para editores colaborativos que deben funcionar sobre redes poco fiables, admitir edición offline y escalar sin un punto único de coordinación.

Configurar el documento CRDT

Yjs es la librería CRDT más probada en producción para JavaScript. Proporciona tipos de datos compartidos que se sincronizan automáticamente entre clientes.

tstypescript
import * as Y from "yjs";
 
// Create a Yjs document — this is the CRDT container
const ydoc = new Y.Doc();
 
// Get a shared text type — edits to this are automatically
// conflict-free across all connected peers
const ytext = ydoc.getText("editor");
 
// Observe changes to the shared text
ytext.observe((event) => {
  event.delta.forEach((op) => {
    if (op.insert) {
      console.log(`Inserted: "${op.insert}"`);
    }
    if (op.delete) {
      console.log(`Deleted ${op.delete} characters`);
    }
    if (op.retain) {
      console.log(`Retained ${op.retain} characters`);
    }
  });
});
 
// Edits from any connected client automatically merge
ytext.insert(0, "Hello, ");
ytext.insert(7, "world!");
console.log(ytext.toString()); // "Hello, world!"

La clave: no hay ninguna "resolución de conflictos" que implementar. Yjs maneja la semántica de fusión internamente usando una variante del algoritmo YATA. Dos usuarios escribiendo en la misma posición verán ambas inserciones aparecer en un orden determinista.

Proveedor WebSocket para sincronización en tiempo real

El documento CRDT necesita una capa de transporte para intercambiar actualizaciones entre clientes. Aquí hay un servidor WebSocket que retransmite actualizaciones CRDT.

tstypescript
// ❌ Naive approach: sending full document state on every change
ws.on("message", (data) => {
  const fullState = JSON.parse(data);
  // Broadcasting entire document — wastes bandwidth,
  // doesn't handle concurrent edits
  broadcast(fullState);
});
tstypescript
// ✅ CRDT-aware sync: exchange only incremental updates
import { WebSocketServer } from "ws";
import * as Y from "yjs";
import {
  encodeStateAsUpdate,
  applyUpdate,
  encodeStateVector,
} from "yjs";
 
interface Room {
  doc: Y.Doc;
  clients: Set<WebSocket>;
}
 
const rooms: Map<string, Room> = new Map();
 
function getOrCreateRoom(roomId: string): Room {
  let room = rooms.get(roomId);
  if (!room) {
    room = { doc: new Y.Doc(), clients: new Set() };
    rooms.set(roomId, room);
  }
  return room;
}
 
const wss = new WebSocketServer({ port: 4000 });
 
wss.on("connection", (ws, req) => {
  const roomId = new URL(
    req.url ?? "/",
    "http://localhost"
  ).searchParams.get("room");
 
  if (!roomId) {
    ws.close(1008, "Room ID required");
    return;
  }
 
  const room = getOrCreateRoom(roomId);
  room.clients.add(ws);
 
  // Send current document state to new client
  const stateVector = encodeStateVector(room.doc);
  const update = encodeStateAsUpdate(room.doc);
  ws.send(
    JSON.stringify({ type: "sync-step-1", update: toBase64(update) })
  );
 
  ws.on("message", (data) => {
    const msg = JSON.parse(data.toString());
 
    if (msg.type === "update") {
      const updateBytes = fromBase64(msg.update);
 
      // Apply update to server document
      applyUpdate(room.doc, updateBytes);
 
      // Broadcast to all other clients in the room
      for (const client of room.clients) {
        if (client !== ws && client.readyState === 1) {
          client.send(data.toString());
        }
      }
    }
  });
 
  ws.on("close", () => {
    room.clients.delete(ws);
    if (room.clients.size === 0) {
      // Persist document before cleanup
      persistDocument(roomId, room.doc);
    }
  });
});
 
function toBase64(bytes: Uint8Array): string {
  return Buffer.from(bytes).toString("base64");
}
 
function fromBase64(str: string): Uint8Array {
  return new Uint8Array(Buffer.from(str, "base64"));
}
 
async function persistDocument(
  roomId: string,
  doc: Y.Doc
): Promise<void> {
  const state = encodeStateAsUpdate(doc);
  // Store in database for later retrieval
  console.log(
    `Persisting room ${roomId}: ${state.byteLength} bytes`
  );
}

El servidor solo retransmite actualizaciones CRDT—no las interpreta ni las transforma. Esto significa que el servidor es simple y sin estado en lo que respecta a la lógica de edición. Toda la inteligencia de fusión vive en la librería CRDT de cada cliente.

Integración del lado del cliente con un editor de texto

Conectar Yjs a un editor real requiere vincular el documento CRDT al modelo interno del editor. Aquí hay una integración con un elemento contenteditable básico.

tstypescript
import * as Y from "yjs";
 
class EditorBinding {
  private ytext: Y.Text;
  private element: HTMLElement;
  private isLocalChange = false;
 
  constructor(ydoc: Y.Doc, element: HTMLElement) {
    this.ytext = ydoc.getText("editor");
    this.element = element;
 
    // Initialize editor with current CRDT state
    this.element.textContent = this.ytext.toString();
 
    // Listen for CRDT changes (from remote peers)
    this.ytext.observe(this.onRemoteChange.bind(this));
 
    // Listen for local edits
    this.element.addEventListener(
      "input",
      this.onLocalChange.bind(this)
    );
  }
 
  private onRemoteChange(event: Y.YTextEvent): void {
    if (this.isLocalChange) return;
 
    // Save cursor position
    const selection = window.getSelection();
    const cursorOffset = selection?.focusOffset ?? 0;
 
    // Apply remote changes to the DOM
    this.element.textContent = this.ytext.toString();
 
    // Restore cursor position (simplified)
    this.restoreCursor(cursorOffset);
  }
 
  private onLocalChange(): void {
    this.isLocalChange = true;
 
    const newText = this.element.textContent ?? "";
    const currentText = this.ytext.toString();
 
    // Compute diff and apply as CRDT operations
    const { index, remove, insert } = this.computeDiff(
      currentText,
      newText
    );
 
    this.ytext.doc?.transact(() => {
      if (remove > 0) this.ytext.delete(index, remove);
      if (insert) this.ytext.insert(index, insert);
    });
 
    this.isLocalChange = false;
  }
 
  private computeDiff(
    oldText: string,
    newText: string
  ): { index: number; remove: number; insert: string } {
    let start = 0;
    while (
      start < oldText.length &&
      start < newText.length &&
      oldText[start] === newText[start]
    ) {
      start++;
    }
 
    let oldEnd = oldText.length;
    let newEnd = newText.length;
    while (
      oldEnd > start &&
      newEnd > start &&
      oldText[oldEnd - 1] === newText[newEnd - 1]
    ) {
      oldEnd--;
      newEnd--;
    }
 
    return {
      index: start,
      remove: oldEnd - start,
      insert: newText.slice(start, newEnd),
    };
  }
 
  private restoreCursor(offset: number): void {
    const range = document.createRange();
    const textNode = this.element.firstChild;
    if (textNode) {
      const safeOffset = Math.min(
        offset,
        textNode.textContent?.length ?? 0
      );
      range.setStart(textNode, safeOffset);
      range.collapse(true);
      const selection = window.getSelection();
      selection?.removeAllRanges();
      selection?.addRange(range);
    }
  }
}

Protocolo awareness para compartir cursores

Mostrar los cursores y selecciones de otros usuarios hace que la colaboración se sienta tangible. Yjs proporciona un protocolo awareness para estado efímero que no necesita resolución de conflictos.

tstypescript
import { Awareness } from "y-protocols/awareness";
import * as Y from "yjs";
 
interface UserAwareness {
  name: string;
  color: string;
  cursor: { index: number; length: number } | null;
}
 
class CursorManager {
  private awareness: Awareness;
  private cursorElements: Map<number, HTMLElement> = new Map();
 
  constructor(ydoc: Y.Doc, user: { name: string; color: string }) {
    this.awareness = new Awareness(ydoc);
 
    // Set local user state
    this.awareness.setLocalState({
      name: user.name,
      color: user.color,
      cursor: null,
    });
 
    // Listen for remote awareness changes
    this.awareness.on(
      "change",
      ({ added, updated, removed }: {
        added: number[];
        updated: number[];
        removed: number[];
      }) => {
        for (const id of added.concat(updated)) {
          this.renderRemoteCursor(id);
        }
        for (const id of removed) {
          this.removeRemoteCursor(id);
        }
      }
    );
  }
 
  updateLocalCursor(index: number, length: number = 0): void {
    this.awareness.setLocalStateField("cursor", {
      index,
      length,
    });
  }
 
  private renderRemoteCursor(clientId: number): void {
    if (clientId === this.awareness.clientID) return;
 
    const state = this.awareness.getStates().get(clientId) as
      | UserAwareness
      | undefined;
    if (!state?.cursor) return;
 
    let el = this.cursorElements.get(clientId);
    if (!el) {
      el = document.createElement("span");
      el.className = "remote-cursor";
      document.querySelector(".editor")?.appendChild(el);
      this.cursorElements.set(clientId, el);
    }
 
    el.style.borderLeftColor = state.color;
    el.setAttribute("data-name", state.name);
    // Position based on cursor index (simplified)
  }
 
  private removeRemoteCursor(clientId: number): void {
    const el = this.cursorElements.get(clientId);
    el?.remove();
    this.cursorElements.delete(clientId);
  }
}

Soporte offline y fusión de estado

Los CRDTs brillan cuando los clientes se quedan sin conexión. Las ediciones se acumulan localmente y se fusionan sin fricciones cuando vuelve la conectividad.

tstypescript
class OfflineManager {
  private dbName: string;
  private ydoc: Y.Doc;
 
  constructor(ydoc: Y.Doc, documentId: string) {
    this.dbName = `crdt-${documentId}`;
    this.ydoc = ydoc;
 
    // Persist every update to IndexedDB
    this.ydoc.on("update", (update: Uint8Array) => {
      this.storeUpdate(update);
    });
  }
 
  private async storeUpdate(update: Uint8Array): Promise<void> {
    const db = await this.openDB();
    const tx = db.transaction("updates", "readwrite");
    const store = tx.objectStore("updates");
    await store.add({
      update: update,
      timestamp: Date.now(),
    });
  }
 
  async loadPersistedState(): Promise<void> {
    const db = await this.openDB();
    const tx = db.transaction("updates", "readonly");
    const store = tx.objectStore("updates");
    const allUpdates = await store.getAll();
 
    // Apply all stored updates to the document
    for (const record of allUpdates) {
      Y.applyUpdate(this.ydoc, record.update);
    }
  }
 
  private openDB(): Promise<IDBDatabase> {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, 1);
      request.onupgradeneeded = () => {
        const db = request.result;
        if (!db.objectStoreNames.contains("updates")) {
          db.createObjectStore("updates", {
            autoIncrement: true,
          });
        }
      };
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
}

Cuando el cliente se reconecta, el protocolo de sincronización CRDT intercambia vectores de estado—resúmenes compactos de lo que ha visto cada par—y luego envía solo las actualizaciones faltantes. Sin transferencia del documento completo, sin resolución de conflictos. Las matemáticas garantizan la convergencia sin importar el orden de las ediciones.

Conclusiones clave

Los CRDTs eliminan por completo la resolución de conflictos al usar estructuras de datos que se fusionan de forma determinista sin importar el orden de las operaciones. Yjs proporciona primitivas CRDT de nivel producción—texto, arrays y mapas compartidos—que manejan la complejidad algorítmica para que te concentres en la experiencia del editor. El servidor WebSocket en una arquitectura CRDT es un simple relé que difunde actualizaciones sin interpretarlas, lo que lo hace sin estado y fácil de escalar. Vincula el documento CRDT a tu editor calculando diffs mínimos a partir de los eventos de entrada locales y aplicándolos como operaciones CRDT. Usa el protocolo awareness para estado efímero como posiciones de cursor y presencia de usuarios—estos no necesitan garantías CRDT porque son transitorios. Persiste las actualizaciones CRDT en IndexedDB para soporte offline, y confía en el intercambio de vectores de estado para una sincronización eficiente al reconectar. El resultado es un editor colaborativo que funciona offline, maneja particiones de red con elegancia y escala sin un servidor de coordinación central.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX