---
title: "Edición colaborativa en tiempo real en Astro con Cloudflare Durable Objects"
description: "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."
source: "https://www.launchfa.st/es/blog/astro-collaborative-editing-durable-objects"
author: "Rishi Raj Jain"
created_at: 2025-12-09T14:00:00.000Z
keywords: "astro, cloudflare, durable objects, collaborative editing, operational transforms, real-time, websockets, crdt"
---

> **Site index for agents**
> Fetch https://www.launchfa.st/llms.txt to discover every page on this site.
> Every page is also available as Markdown by appending `.md` to its URL.

<img width="1600" height="900" alt="Edición colaborativa en tiempo real en Astro con Cloudflare Durable Objects" decoding="async" loading="eager" class="mt-4 border rounded-sm bg-cover bg-center bg-no-repeat transform will-change-auto" src="https://ik.imagekit.io/vjeqenuhn/launchfast-website/astro-collaborative-editing-durable-objects.png" />

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](https://developers.cloudflare.com/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.

- [Ver proyecto de ejemplo en GitHub](https://github.com/rishi-raj-jain/my-astro-collab-editor)

## Demo

<video controls class="rounded aspect-video" autoplay="false">
    <source src="https://ik.imagekit.io/vjeqenuhn/launchfast-website/Screen%20Recording%202025-12-10%20at%201.24.46%E2%80%AFAM.mp4" type="video/mp4">
</video>

## Requisitos previos

Necesitará lo siguiente:

- [Node.js 20](https://nodejs.org/en/blog/announcements/v20-release-announce) o posterior
- Una cuenta de [Cloudflare](https://dash.cloudflare.com/sign-up)

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

```bash
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:

```bash
npm run dev
```

La aplicación debería estar ejecutándose en [localhost:4321](http://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:

```bash
npx astro add cloudflare --yes
```

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

```javascript {6,8-11}
// File: 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:

```jsonc {4-17}
// File: 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:

```typescript
// File: 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:

```typescript collapse={0,66-255}
// File: 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)
      }
    })
  }

  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:

```typescript
// File: 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:

```astro collapse={0,20-472}
---
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;
      }
      .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:

```typescript
// 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:

```bash
# 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](https://twitter.com/direct_messages/create/rishi_raj_jain_).
