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.
Kits de inicio de alta calidad con flujo de autenticación integrada (Auth.js), carga de objetos (AWS, Clouflare R2, Firebase Storage, Supabase Storage), pagos integrados (Stripe, LemonSqueezy), flujo de verificación de correo electrónico (Resend, Postmark, Sendgrid) y mucho más . Compatible con cualquier base de datos (Redis, Postgres, MongoDB, SQLite, Firestore).
Get all 3 kits Bundle ↗- Next.js Starter Kit
- SvelteKit Starter Kit
- Astro Starter Kit
One-time license · Lifetime updates
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>El código anterior implementa el seguimiento del historial de versiones para el editor de texto colaborativo usando un Cloudflare Durable Object (Document). Esto es lo que hace:
-
Interfaz Version: Define un objeto
Versionpara representar una instantánea del documento. Cada versión conserva untimestamp, elcontentdel documento en ese momento y eluserIddel usuario que realizó el cambio. -
Inicialización: En el constructor de
Document, carga tanto el contenido actual del documento como todas las versiones anteriores desde el almacenamiento persistente dentro deblockConcurrencyWhile. Esto garantiza que los datos más recientes estén disponibles antes de procesar cualquier solicitud. -
En cada cambio: Dentro del handler
'change'(que procesa las ediciones de los usuarios):- Aplica la operación de cambio al contenido del documento.
- Crea un nuevo objeto
Version, capturando el nuevo estado del documento, la marca de tiempo actual y el ID de usuario del editor, y lo añade al arrayversions. - Para evitar un crecimiento ilimitado del almacenamiento, solo conserva las últimas 100 versiones.
- Tanto el nuevo contenido como el historial de versiones actualizado se guardan de forma persistente con
this.state.storage.put.
-
Difusión: Tras actualizar el contenido y las versiones, notifica a todos los clientes conectados (excepto al que envió el cambio) de la actualización mediante
this.broadcast.
Este mecanismo habilita funciones como deshacer/historial, permite a los usuarios ver y restaurar versiones anteriores en la interfaz del editor y hace que la edición colaborativa sea auditable y resistente a pérdidas accidentales de datos.
Gestionar el historial de versiones
Agregue seguimiento del historial de versiones al Durable Object:
// Add to Document class in src/worker.ts
interface Version { timestamp: number content: string userId: string}
// In constructor:private versions: Version[] = []
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 || []})
// In 'change' handler:case 'change': { const { changes } = data if (changes) { const before = this.content.slice(0, changes.from) const after = this.content.slice(changes.to) this.content = before + changes.insert + after
// Save version this.versions.push({ timestamp: Date.now(), content: this.content, userId: this.sessions.get(ws)?.userId || 'unknown', })
// Keep last 100 versions if (this.versions.length > 100) { this.versions = this.versions.slice(-100) }
await this.state.storage.put('content', this.content) await this.state.storage.put('versions', this.versions)
this.broadcast({ type: 'change', changes, userId: this.sessions.get(ws)?.userId }, ws) } break}Desplegar en Cloudflare Workers
Despliegue su editor colaborativo en producción:
# Build the projectnpm run build
# Deploy to Cloudflare Workersnpx wrangler deploySu editor colaborativo ya está en vivo y ejecutándose en la red global de Cloudflare.
Conclusión
Cloudflare Durable Objects hacen accesible la edición colaborativa sin servidores complejos de transformación operacional ni bibliotecas CRDT. Al aprovechar la consistencia fuerte y el soporte WebSocket integrado, puede construir funciones de colaboración en tiempo real que escalan globalmente con una infraestructura mínima.
El patrón que ha construido — estado del documento + difusión de cambios + seguimiento de cursores — escala a cualquier caso de uso colaborativo. Agréguelo a aplicaciones de notas, editores de código, herramientas de diseño o cualquier aplicación que necesite edición multiusuario en tiempo real.
Si tiene preguntas o comentarios, no dude en contactarme en Twitter.