Construir una edición colaborativa al estilo de Google Docs implica resolver la sincronización de estado distribuido, gestionar ediciones concurrentes y manejar la resolución de conflictos. Los enfoques tradicionales requieren servidores de transformación operacional (operational transform), bibliotecas CRDT o servicios gestionados como Yjs con servidores Y-WebSocket: todo ello complejo de configurar y costoso de escalar.
Cloudflare Durable Objects simplifican esto al proporcionar objetos con estado fuertemente consistentes en el edge que manejan conexiones WebSocket de forma nativa. Cada documento obtiene su propia instancia de Durable Object con almacenamiento integrado y ejecución de un solo hilo, eliminando condiciones de carrera y haciendo que la resolución de conflictos sea sencilla.
En esta guía, construirá un editor de texto colaborativo en Astro que sincroniza cambios en tiempo real entre varios usuarios. Implementará difusión de cambios, seguimiento de cursores y persistencia de documentos usando Durable Objects con WebSocket Hibernation para eficiencia de costos.
Demo
Requisitos previos
Necesitará lo siguiente:
- Node.js 20 o posterior
- Una cuenta de Cloudflare
Descripción general de la arquitectura
1. Página del editor (src/pages/doc/[id].astro) → Renderiza la interfaz del editor colaborativo → Establece conexión WebSocket
2. API WebSocket (src/pages/api/doc/[id].ts) → Maneja solicitudes de upgrade WebSocket → Se conecta al Durable Object Document
3. Durable Object Document (src/worker.ts) → Gestiona el estado e historial del documento → Difunde cambios a todos los clientes conectados → Rastrea cursores y selecciones de usuarios → Persiste el contenido del documentoCrear una nueva aplicación Astro
Comencemos creando un nuevo proyecto Astro. Ejecute el siguiente comando:
npm create astro@latest my-astro-collab-editorcd my-astro-collab-editornpm install wranglerCuando se le solicite, elija:
Use minimal (empty) templatecuando se le pregunte cómo iniciar el nuevo proyecto.Yescuando se le pregunte si desea instalar dependencias.Yescuando se le pregunte si desea inicializar un repositorio git.
Una vez hecho esto, puede moverse al directorio del proyecto e iniciar la aplicación:
npm run devLa aplicación debería estar ejecutándose en localhost:4321.
Integrar el adaptador de Cloudflare en su proyecto Astro
Para desplegar su proyecto Astro en Cloudflare Workers y usar Durable Objects, necesita instalar el adaptador de Cloudflare. Ejecute el comando siguiente:
npx astro add cloudflare --yesActualice astro.config.mjs para especificar el punto de entrada del worker:
import { defineConfig } from 'astro/config';import cloudflare from '@astrojs/cloudflare';
export default defineConfig({ output: 'server', adapter: cloudflare({ workerEntryPoint: { path: './src/worker.ts', namedExports: ['Document'] } })});Configurar el binding de Durable Objects
Cree wrangler.jsonc en la raíz de su proyecto:
{ // ... "durable_objects": { "bindings": [ { "name": "Document", "class_name": "Document" } ] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["Document"] } ]}Agregar tipos de TypeScript
Cree src/env.d.ts para agregar los tipos adecuados para el binding de Durable Objects:
/// <reference types="astro/client" />/// <reference types="@cloudflare/workers-types" />
type ENV = { Document: DurableObjectNamespace}
type DurableObjectNamespace = import('@cloudflare/workers-types').DurableObjectNamespace
type Runtime = import('@astrojs/cloudflare').Runtime<ENV>
declare namespace App { interface Locals extends Runtime { }}Crear el Durable Object Document
Construya el Durable Object que gestiona el estado del documento y las conexiones WebSocket:
import { DurableObject } from 'cloudflare:workers';import { handle } from '@astrojs/cloudflare/handler';import type { SSRManifest } from 'astro';import { App } from 'astro/app';
interface SessionData { userId: string userName: string color: string}
interface Version { content: string timestamp: number userId?: string userName?: string}
interface Message { type: 'init' | 'change' | 'cursor' | 'user-join' | 'user-leave' | 'get-versions' | 'versions' | 'restore-version' content?: string userId?: string userName?: string color?: string position?: number changes?: { from: number; to: number; insert: string } users?: SessionData[] versions?: Version[] versionIndex?: number}
export class Document extends DurableObject<ENV> { private state: DurableObjectState private sessions: Map<WebSocket, SessionData> private content: string private versions: Version[]
constructor(ctx: DurableObjectState, env: ENV) { super(ctx, env) this.state = ctx this.sessions = new Map() this.content = '' this.versions = []
// Load document content and versions from storage this.state.blockConcurrencyWhile(async () => { const stored = await this.state.storage.get<string>('content') this.content = stored || ''
const storedVersions = await this.state.storage.get<Version[]>('versions') this.versions = storedVersions || []
// If there's content but no versions, create initial version if (this.content && this.versions.length === 0) { this.versions.push({ content: this.content, timestamp: Date.now() }) await this.state.storage.put('versions', this.versions) } }) }
189 collapsed lines
async fetch(request: Request): Promise<Response> { const upgradeHeader = request.headers.get('Upgrade') if (upgradeHeader !== 'websocket') { return new Response('Expected WebSocket', { status: 426 }) }
const pair = new WebSocketPair() const [client, server] = Object.values(pair)
this.state.acceptWebSocket(server)
return new Response(null, { status: 101, webSocket: client, }) }
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { try { const data = JSON.parse(message.toString()) as Message
switch (data.type) { case 'init': { // New user connecting const sessionData: SessionData = { userId: data.userId!, userName: data.userName!, color: data.color!, }
this.sessions.set(ws, sessionData)
// Send current document state to new user ws.send( JSON.stringify({ type: 'init', content: this.content, users: Array.from(this.sessions.values()), }) )
// Notify others about new user this.broadcast( { type: 'user-join', userId: sessionData.userId, userName: sessionData.userName, color: sessionData.color, }, ws ) break }
case 'change': { // Apply and broadcast document changes const { changes } = data if (changes) { // Simple operational transform: apply change to content const before = this.content.slice(0, changes.from) const after = this.content.slice(changes.to) this.content = before + changes.insert + after
// Persist to storage await this.state.storage.put('content', this.content)
// Save version (limit to 50 versions) const session = this.sessions.get(ws) this.versions.push({ content: this.content, timestamp: Date.now(), userId: session?.userId, userName: session?.userName })
// Keep only last 50 versions if (this.versions.length > 50) { this.versions = this.versions.slice(-50) }
await this.state.storage.put('versions', this.versions)
// Broadcast to all clients this.broadcast( { type: 'change', changes, userId: this.sessions.get(ws)?.userId, }, ws ) } break }
case 'get-versions': { // Send version history to requesting client ws.send( JSON.stringify({ type: 'versions', versions: this.versions }) ) break }
case 'restore-version': { // Restore to a specific version if (data.versionIndex !== undefined && data.versionIndex >= 0 && data.versionIndex < this.versions.length) { const version = this.versions[data.versionIndex] this.content = version.content
await this.state.storage.put('content', this.content)
// Broadcast full content update to all clients this.broadcast({ type: 'init', content: this.content, users: Array.from(this.sessions.values()) }) } break }
case 'cursor': { // Broadcast cursor position to others const session = this.sessions.get(ws) if (session) { this.broadcast( { type: 'cursor', userId: session.userId, position: data.position, }, ws ) } break } } } catch (error) { console.error('WebSocket message error:', error) } }
async webSocketClose(ws: WebSocket, code: number, reason: string) { const session = this.sessions.get(ws) if (session) { // Notify others about user leaving this.broadcast({ type: 'user-leave', userId: session.userId, }) this.sessions.delete(ws) } ws.close(code, reason) }
async webSocketError(ws: WebSocket, error: unknown) { console.error('WebSocket error:', error) ws.close(1011, 'WebSocket error') }
private broadcast(message: Message, exclude?: WebSocket) { const payload = JSON.stringify(message) for (const [ws, _] of this.sessions) { if (ws !== exclude) { try { ws.send(payload) } catch (error) { console.error('Broadcast error:', error) } } } }}
export function createExports(manifest: SSRManifest) { const app = new App(manifest); return { default: { async fetch(request, env, ctx) { // @ts-expect-error - request is not typed correctly return handle(manifest, app, request, env, ctx); } } satisfies ExportedHandler<ENV>, Document, }}El código anterior muestra la implementación principal de una clase Cloudflare Durable Object, Document, para un editor de texto colaborativo en tiempo real. A continuación, un desglose de lo que ocurre:
-
Gestión de sesiones y usuarios: El sistema rastrea a los usuarios conectados (sesiones) mediante conexiones WebSocket. Cuando una WebSocket se cierra o produce un error, la sesión se limpia, se notifica a los clientes restantes que el usuario se ha ido y la conexión se cierra correctamente.
-
Difusión (broadcasting): El método
broadcastse encarga de enviar actualizaciones (como cambios en el documento, entradas/salidas de usuarios, respuestas del historial de versiones, etc.) a todos los clientes WebSocket conectados, excepto opcionalmente al que desencadenó el evento. Esto garantiza que los cambios se sincronicen en tiempo real entre todos los usuarios. -
Exportación del handler y del Durable Object Document: La función
createExportsintegra la aplicación SSR de Astro con el entorno de Cloudflare Worker. Devuelve un objeto que incluye el handler SSR (default.fetch) para solicitudes HTTP, además de exponer la claseDocumentcomo Durable Object, lo que permite a la plataforma enrutar solicitudes de upgrade WebSocket y gestionar el estado persistente de cada documento.
En conjunto, este código conecta toda la funcionalidad del lado del servidor necesaria para la edición colaborativa de documentos: gestión de mensajería en tiempo real, estado persistente del documento, seguimiento del historial de versiones y compatibilidad de las rutas SSR de Astro con funciones de colaboración en vivo.
Crear el endpoint de API WebSocket
Construya la ruta de API que conecta a los clientes con el Durable Object Document:
import type { APIRoute } from 'astro'
export const GET: APIRoute = async ({ params, locals, request }) => { const { id } = params const upgradeHeader = request.headers.get('Upgrade') if (upgradeHeader !== 'websocket') return new Response('Expected WebSocket', { status: 426 }) // Get the Durable Object for this document const documentId = locals.runtime.env.Document.idFromName(id!) const documentStub = locals.runtime.env.Document.get(documentId) // Forward the WebSocket upgrade to the Durable Object return documentStub.fetch(request)}El código anterior define una ruta de API en Astro en src/pages/api/doc/[id].ts que actúa como punto de entrada para clientes WebSocket que desean unirse a una sesión de edición colaborativa de un documento. Así funciona paso a paso:
-
Cuando se realiza una solicitud GET a este endpoint (normalmente por un cliente que intenta unirse o crear una sesión de edición colaborativa), el código comprueba primero si la solicitud es un upgrade WebSocket. Si no lo es, responde con el estado 426 para indicar que se requiere una conexión WebSocket.
-
Si la solicitud es un upgrade WebSocket válido, el endpoint utiliza el parámetro
idde la URL para identificar el documento colaborativo (y, por tanto, su instancia de Durable Object asociada). -
Obtiene el ID del Durable Object llamando a
idFromNameen el namespace de Durable Objects, usando eliddel documento. Luego, obtiene un stub (handle remoto) de la instancia correcta del Durable Object mediante.get. -
Finalmente, el código reenvía la solicitud entrante completa (incluido el upgrade WebSocket) directamente al método
fetchdel Durable Object. Esto transfiere la conexión (y su upgrade) al Durable Object, que gestiona el estado compartido del documento y sincroniza las ediciones en tiempo real entre todos los clientes conectados.
Construir la interfaz del editor colaborativo
Cree la página del editor con sincronización en tiempo real:
---const { id } = Astro.params;---
<html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Collaborative Editor - {id}</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Inter', system-ui, sans-serif; background: #f8fafc; color: #0f172a;452 collapsed lines
} .container { max-width: 900px; margin: 0 auto; padding: 24px; } .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; padding: 16px; background: white; border-radius: 8px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } .users { display: flex; gap: 8px; align-items: center; } .user-badge { padding: 6px 12px; border-radius: 16px; font-size: 14px; font-weight: 500; color: white; } .status { font-size: 14px; color: #64748b; } .status.connected { color: #10b981; } .editor-wrapper { background: white; border-radius: 8px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); padding: 24px; } #editor { width: 100%; min-height: 500px; border: 1px solid #e2e8f0; border-radius: 6px; padding: 16px; font-family: 'Monaco', 'Courier New', monospace; font-size: 16px; line-height: 1.6; resize: vertical; } #editor:focus { outline: none; border-color: #3b82f6; } .cursors { position: relative; } .cursor { position: absolute; width: 2px; height: 20px; pointer-events: none; transition: all 0.1s ease; } .cursor-label { position: absolute; top: -24px; left: -4px; padding: 2px 8px; border-radius: 4px; font-size: 12px; color: white; white-space: nowrap; } .version-btn { padding: 10px 20px; background: #3b82f6; color: white; border: none; border-radius: 6px; font-size: 14px; font-weight: 500; cursor: pointer; transition: background 0.2s; } .version-btn:hover { background: #2563eb; } .modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); z-index: 1000; align-items: center; justify-content: center; } .modal.open { display: flex; } .modal-content { background: white; border-radius: 12px; padding: 24px; max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); } .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } .modal-header h2 { font-size: 20px; font-weight: 600; } .close-btn { background: none; border: none; font-size: 24px; cursor: pointer; color: #64748b; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 4px; } .close-btn:hover { background: #f1f5f9; color: #0f172a; } .version-list { display: flex; flex-direction: column; gap: 12px; } .version-item { padding: 16px; border: 1px solid #e2e8f0; border-radius: 8px; cursor: pointer; transition: all 0.2s; } .version-item:hover { border-color: #3b82f6; background: #f8fafc; } .version-meta { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } .version-time { font-size: 14px; color: #64748b; } .version-user { font-size: 12px; padding: 4px 8px; background: #f1f5f9; border-radius: 4px; color: #475569; } .version-preview { font-size: 13px; color: #475569; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: 'Monaco', 'Courier New', monospace; } .empty-state { text-align: center; padding: 40px 20px; color: #64748b; } </style> </head> <body> <div class="container"> <div class="header"> <div> <h1>Document: {id}</h1> <div id="status" class="status">Connecting...</div> </div> <div style="display: flex; gap: 16px; align-items: center;"> <button id="versionBtn" class="version-btn">View Versions</button> <div class="users" id="users"></div> </div> </div>
<div class="editor-wrapper"> <textarea id="editor" placeholder="Start typing..."></textarea> </div> </div>
<div id="versionModal" class="modal"> <div class="modal-content"> <div class="modal-header"> <h2>Version History</h2> <button id="closeModal" class="close-btn">×</button> </div> <div id="versionList" class="version-list"></div> </div> </div>
<script define:vars={{ documentId: id }}> const editor = document.getElementById('editor') const status = document.getElementById('status') const usersContainer = document.getElementById('users')
// Generate random user info const userId = Math.random().toString(36).substring(7) const colors = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6'] const userColor = colors[Math.floor(Math.random() * colors.length)] const userName = `User ${userId.substring(0, 4)}`
let ws = null let users = new Map() let isRemoteUpdate = false let versions = []
function connect() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' ws = new WebSocket(`${protocol}//${window.location.host}/api/doc/${documentId}`)
ws.onopen = () => { status.textContent = 'Connected' status.classList.add('connected')
// Send init message ws.send(JSON.stringify({ type: 'init', userId, userName, color: userColor, })) }
ws.onmessage = (event) => { const data = JSON.parse(event.data)
switch (data.type) { case 'init': { // Set initial content isRemoteUpdate = true editor.value = data.content lastValue = data.content isRemoteUpdate = false
// Update users list data.users.forEach((user) => { if (user.userId !== userId) { users.set(user.userId, user) } }) updateUsersList() break }
case 'change': { // Apply remote changes if (data.userId !== userId) { isRemoteUpdate = true const { from, to, insert } = data.changes const before = editor.value.slice(0, from) const after = editor.value.slice(to) editor.value = before + insert + after lastValue = editor.value isRemoteUpdate = false } break }
case 'user-join': { users.set(data.userId, { userId: data.userId, userName: data.userName, color: data.color, }) updateUsersList() break }
case 'user-leave': { users.delete(data.userId) updateUsersList() break }
case 'cursor': { // Handle cursor updates from other users console.log('Cursor update:', data) break }
case 'versions': { // Received version history versions = data.versions || [] displayVersions() break } } }
ws.onclose = () => { status.textContent = 'Disconnected' status.classList.remove('connected') setTimeout(connect, 2000) }
ws.onerror = (error) => { console.error('WebSocket error:', error) } }
function updateUsersList() { usersContainer.innerHTML = '' users.forEach((user) => { const badge = document.createElement('div') badge.className = 'user-badge' badge.style.backgroundColor = user.color badge.textContent = user.userName usersContainer.appendChild(badge) }) }
// Track changes let lastValue = '' editor.addEventListener('input', () => { if (isRemoteUpdate || !ws || ws.readyState !== WebSocket.OPEN) { return }
const newValue = editor.value const cursorPos = editor.selectionStart
// Simple diff: find where the change occurred let from = 0 while (from < lastValue.length && from < newValue.length && lastValue[from] === newValue[from]) { from++ }
let lastEnd = lastValue.length let newEnd = newValue.length while (lastEnd > from && newEnd > from && lastValue[lastEnd - 1] === newValue[newEnd - 1]) { lastEnd-- newEnd-- }
const insert = newValue.slice(from, newEnd)
// Send change ws.send(JSON.stringify({ type: 'change', changes: { from, to: lastEnd, insert }, }))
lastValue = newValue })
// Track cursor position editor.addEventListener('selectionchange', () => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'cursor', position: editor.selectionStart, })) } })
// Version modal handling const versionBtn = document.getElementById('versionBtn') const versionModal = document.getElementById('versionModal') const closeModal = document.getElementById('closeModal') const versionList = document.getElementById('versionList')
versionBtn.addEventListener('click', () => { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'get-versions' })) versionModal.classList.add('open') } })
closeModal.addEventListener('click', () => { versionModal.classList.remove('open') })
versionModal.addEventListener('click', (e) => { if (e.target === versionModal) { versionModal.classList.remove('open') } })
function displayVersions() { if (versions.length === 0) { versionList.innerHTML = '<div class="empty-state">No versions yet</div>' return }
versionList.innerHTML = versions .map((version, index) => { const date = new Date(version.timestamp) const timeStr = date.toLocaleString() const preview = version.content.substring(0, 100).replace(/\n/g, ' ') const userInfo = version.userName ? `by ${version.userName}` : ''
return ` <div class="version-item" data-index="${index}"> <div class="version-meta"> <div class="version-time">${timeStr}</div> ${userInfo ? `<div class="version-user">${userInfo}</div>` : ''} </div> <div class="version-preview">${preview || '(empty)'}</div> </div> ` }) .reverse() .join('')
// Add click handlers to version items document.querySelectorAll('.version-item').forEach((item) => { item.addEventListener('click', () => { const index = parseInt(item.dataset.index) if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'restore-version', versionIndex: index })) versionModal.classList.remove('open') } }) }) }
connect() </script> </body></html>