Edición colaborativa en tiempo real en Astro… | LaunchFast
LaunchFast Logo LaunchFast
Blog
5186 palabras 26 min de lectura

Edición colaborativa en tiempo real en Astro con Cloudflare Durable Objects

Cree colaboración en tiempo real al estilo Google Docs en Astro con Cloudflare Durable Objects. Implemente transformaciones operacionales, resolución de conflictos y seguimiento de cursores con conexiones WebSocket persistentes en el edge.

Rishi Raj Jain
Rishi Raj Jain Autor
Edición colaborativa en tiempo real en Astro con Cloudflare Durable Objects

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.

Sponsored

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 ↗

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:

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 documento

Crear una nueva aplicación Astro

Comencemos creando un nuevo proyecto Astro. Ejecute el siguiente comando:

Terminal window
npm create astro@latest my-astro-collab-editor
cd my-astro-collab-editor
npm install wrangler

Cuando se le solicite, elija:

  • Use minimal (empty) template cuando se le pregunte cómo iniciar el nuevo proyecto.
  • Yes cuando se le pregunte si desea instalar dependencias.
  • Yes cuando se le pregunte si desea inicializar un repositorio git.

Una vez hecho esto, puede moverse al directorio del proyecto e iniciar la aplicación:

Terminal window
npm run dev

La 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:

Terminal window
npx astro add cloudflare --yes

Actualice astro.config.mjs para especificar el punto de entrada del worker:

astro.config.mjs
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:

wrangler.jsonc
{
// ...
"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:

src/env.d.ts
/// <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:

src/worker.ts
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 broadcast se 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 createExports integra 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 clase Document como 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:

src/pages/api/doc/[id].ts
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 id de la URL para identificar el documento colaborativo (y, por tanto, su instancia de Durable Object asociada).

  • Obtiene el ID del Durable Object llamando a idFromName en el namespace de Durable Objects, usando el id del 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 fetch del 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">&times;</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 Version para representar una instantánea del documento. Cada versión conserva un timestamp, el content del documento en ese momento y el userId del 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 de blockConcurrencyWhile. 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 array versions.
    • 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:

Terminal window
# Build the project
npm run build
# Deploy to Cloudflare Workers
npx wrangler deploy

Su 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.

Sigue leyendo