---
title: "Chat en tiempo real en Astro con Cloudflare Durable Objects e hibernación WebSocket"
description: "Aprenda cómo construir una aplicación de chat en tiempo real usando Astro y Cloudflare Durable Objects con hibernación WebSocket. Implemente conexiones WebSocket rentables, persistencia de mensajes e indicadores de presencia de usuario en el edge."
source: "https://www.launchfa.st/es/blog/real-time-chat-astro-cloudflare-durable-objects"
author: "Rishi Raj Jain"
created_at: 2025-12-04T12:00:00.000Z
keywords: "astro, cloudflare, durable objects, websockets, websocket hibernation, real-time, chat, edge computing"
---

> **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="Chat 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-realtime-chat-durable-objects.png" />

Las funciones en tiempo real como el chat, las notificaciones o la edición colaborativa suelen requerir infraestructura compleja con servidores WebSocket, Redis para pub/sub y gestión de estado distribuido. [Cloudflare Durable Objects](https://developers.cloudflare.com/durable-objects/) cambia esto al ofrecer objetos con estado y fuertemente consistentes en el edge que pueden gestionar conexiones WebSocket de forma nativa.

En esta guía completa, aprenderá a construir una aplicación de chat en tiempo real usando Astro y Cloudflare Durable Objects con la [API de hibernación WebSocket](https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server/). Cubriremos conexiones WebSocket, persistencia de mensajes, indicadores de presencia de usuario y chat por salas, todo ejecutándose en el edge sin gestión de servidores y con **costos significativamente reducidos** gracias a la hibernación.

- [Ver proyecto de ejemplo en GitHub](https://github.com/rishi-raj-jain/my-realtime-chat-app)

## Demo

<img width="1600" height="900" alt="Demo de chat 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-realtime-chat-demo.gif" />

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

## Comprender Durable Objects y WebSockets

**Durable Objects** son la solución de Cloudflare para gestionar aplicaciones con estado en el edge. A diferencia de las funciones serverless tradicionales, que son sin estado, los Durable Objects mantienen el estado en memoria y pueden gestionar conexiones WebSocket.

Características clave:

- **Ejecución de un solo hilo**: Cada instancia de Durable Object se ejecuta en una única ubicación, eliminando condiciones de carrera
- **Fuerte consistencia**: Los cambios de estado son inmediatamente visibles para todas las conexiones a ese objeto
- **Almacenamiento integrado**: Almacenamiento clave-valor persistente que sobrevive a los reinicios
- **Soporte WebSocket**: Soporte nativo para mantener conexiones WebSocket de larga duración
- **Hibernación WebSocket**: Los Durable Objects pueden hibernar manteniendo las conexiones WebSocket abiertas, lo que reduce significativamente los costos

## ¿Por qué la hibernación WebSocket?

La [API de hibernación WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/) de Cloudflare es el enfoque recomendado para aplicaciones WebSocket en Durable Objects. Estas son las razones:

**Sin hibernación:**
- El Durable Object permanece en memoria mientras las conexiones WebSocket estén abiertas
- Se le factura durante toda la duración, incluso en periodos de inactividad
- Mayor uso de memoria en toda la aplicación

**Con hibernación:**
- El Durable Object puede ser expulsado de la memoria durante la inactividad
- Las conexiones WebSocket permanecen abiertas incluso cuando el DO está hibernado
- Cuando llega un mensaje, el DO se recrea y el mensaje se entrega
- **Cargos de duración significativamente menores**, solo paga cuando el DO está procesando activamente

La API de hibernación utiliza métodos especiales (`webSocketMessage`, `webSocketClose`, `webSocketError`) en lugar de event listeners, y usa `serializeAttachment`/`deserializeAttachment` para persistir el estado de la sesión a través de los ciclos de hibernación.

## Cómo difieren los Durable Objects de los servidores WebSocket tradicionales

Arquitectura WebSocket tradicional:

```
Client → Load Balancer → WebSocket Server (Node.js) → Redis (Pub/Sub) → Database
                        ↓
                    Múltiples instancias de servidor requieren coordinación
```

Arquitectura con Durable Objects:

```
Client → Cloudflare Edge → Durable Object (single instance per chat room)
                           ↓
                        Estado + almacenamiento integrados
```

Beneficios:

- **Sin Redis**: El estado está en memoria en el Durable Object
- **Sin complejidad de coordinación**: Una instancia = sin condiciones de carrera
- **Despliegue global**: Se ejecuta en el edge de Cloudflare, cerca de los usuarios
- **Escalado automático**: Cloudflare crea instancias bajo demanda
- **Cero gestión de servidores**: Sin servidores que configurar o mantener

## Descripción general de la arquitectura

Nuestra aplicación de chat tendrá esta estructura:

```
1. Página Astro (src/pages/chat/[roomId].astro)
   → Renderiza la interfaz de chat
   → Incluye JavaScript del lado del cliente para WebSocket

2. Endpoint de API WebSocket (src/pages/api/chat/[roomId].ts)
   → Gestiona solicitudes de upgrade WebSocket
   → Conecta clientes al Durable Object correspondiente

3. Worker con Durable Object ChatRoom (src/worker.ts)
   → Exporta la clase Durable Object ChatRoom
   → Usa la API de hibernación WebSocket para eficiencia de costos
   → Gestiona conexiones WebSocket para una sala
   → Transmite mensajes a todos los clientes conectados
   → Persiste el historial de mensajes y los datos de sesión
   → Rastrea la presencia de usuarios a través de ciclos de hibernación
```

Cada sala de chat obtiene su propia instancia de Durable Object, identificada por el ID de la sala. Todos los clientes en esa sala se conectan a la misma instancia, garantizando una fuerte consistencia.

## Crear una nueva aplicación Astro

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

```bash
npm create astro@latest realtime-chat
```

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
cd realtime-chat
npm install wrangler
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 Cloudflare KV, necesita instalar el adaptador de Cloudflare. Ejecute el comando siguiente:

```bash
npx astro add cloudflare
```

Cuando se le solicite, elija Yes en cada prompt.

Esto instala `@astrojs/cloudflare` y configura su proyecto para renderizado del lado del servidor en Cloudflare Workers.

Ahora actualice su `astro.config.mjs` para especificar el punto de entrada del worker:

```javascript
// 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: ['ChatRoom']
    }
  })
});
```

La configuración `workerEntryPoint` indica a Astro dónde encontrar su clase Durable Object. El array `namedExports` especifica qué clases exportar desde el worker.

### Configurar wrangler.jsonc

Cree un archivo `wrangler.jsonc` en la raíz de su proyecto:

```jsonc
{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "main": "dist/_worker.js/index.js",
  "name": "realtime-chat",
  "compatibility_date": "2025-12-04",
  "compatibility_flags": [
    "nodejs_compat",
    "global_fetch_strictly_public"
  ],
  "assets": {
    "binding": "ASSETS",
    "directory": "./dist"
  },
  "observability": {
    "enabled": true
  },
  "durable_objects": {
    "bindings": [
      {
        "name": "ChatRoom",
        "class_name": "ChatRoom"
      }
    ]
  },
  "migrations": [
    {
      "tag": "v1",
      "new_sqlite_classes": [
        "ChatRoom"
      ]
    }
  ]
}
```

La sección `durable_objects.bindings` crea un binding llamado `ChatRoom` que su código puede usar para acceder a la clase Durable Object `ChatRoom`. La migración `new_sqlite_classes` habilita almacenamiento respaldado por SQLite para mejor rendimiento. Las `compatibility_flags` garantizan compatibilidad con Node.js para funciones como `crypto.randomUUID()`.

### Configurar tipos de entorno

Actualice `src/env.d.ts` para añadir definiciones TypeScript para Durable Objects:

```typescript
/// <reference types="astro/client" />
/// <reference types="@cloudflare/workers-types" />

type DurableObjectNamespace = import('@cloudflare/workers-types').DurableObjectNamespace

type ENV = {
  ChatRoom: DurableObjectNamespace
}

type Runtime = import('@astrojs/cloudflare').Runtime<ENV>

declare namespace App {
  interface Locals extends Runtime {
    // Add custom locals here
  }
}
```

Esto proporciona seguridad de tipos al trabajar con Durable Objects en su aplicación Astro.

## Crear el Durable Object ChatRoom con hibernación

Los Durable Objects se definen en el archivo worker junto con las exportaciones del handler. Usaremos la [API de hibernación WebSocket](https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server/) para una mejor eficiencia de costos. Cree `src/worker.ts`:

```typescript
// File: src/worker.ts

import { handle } from '@astrojs/cloudflare/handler';
import type { SSRManifest } from 'astro';
import { App } from 'astro/app';
import { DurableObject } from 'cloudflare:workers';

interface SessionAttachment {
  id: string
  userId: string
  username: string
}

interface ChatMessage {
  id: string
  type: 'message' | 'join' | 'leave' | 'presence'
  userId: string
  username: string
  content?: string
  timestamp: number
}

class ChatRoom extends DurableObject<ENV> {
  // Map of WebSocket -> session data
  // When the DO hibernates, this gets reconstructed in the constructor
  private sessions: Map<WebSocket, SessionAttachment>
  private messageHistory: ChatMessage[]

  constructor(ctx: DurableObjectState, env: ENV) {
    super(ctx, env)
    
    this.sessions = new Map()
    this.messageHistory = []
    
    // Restore hibernating WebSocket connections
    // When the DO wakes up, we need to restore session data from attachments
    this.ctx.getWebSockets().forEach((ws) => {
      const attachment = ws.deserializeAttachment() as SessionAttachment | null
      if (attachment) {
        this.sessions.set(ws, attachment)
      }
    })
    
    // Set up automatic ping/pong responses
    // This keeps connections alive without waking the DO
    this.ctx.setWebSocketAutoResponse(
      new WebSocketRequestResponsePair('ping', 'pong')
    )
    
    // Load message history from storage on initialization
    this.ctx.blockConcurrencyWhile(async () => {
      const stored = await this.ctx.storage.get<ChatMessage[]>('messages')
      if (stored) this.messageHistory = stored
    })
  }

  /**
   * Handle HTTP requests to this Durable Object
   * This is called when a client wants to establish a WebSocket connection
   */
  async fetch(request: Request): Promise<Response> {
    // Parse the URL to get query parameters (userId and username)
    const url = new URL(request.url)
    const userId = url.searchParams.get('userId')
    const username = url.searchParams.get('username')
    if (!userId || !username)
      return new Response('Missing userId or username', { status: 400 })
    // Expect a WebSocket upgrade request
    const upgradeHeader = request.headers.get('Upgrade')
    if (upgradeHeader !== 'websocket')
      return new Response('Expected WebSocket upgrade', { status: 426 })
    // Create a WebSocket pair (client and server)
    const pair = new WebSocketPair()
    const [client, server] = Object.values(pair)

    // Accept the WebSocket connection using the Hibernation API
    // Unlike server.accept(), this allows the DO to hibernate while
    // keeping the WebSocket connection open
    this.ctx.acceptWebSocket(server)

    // Generate a unique session ID
    const id = crypto.randomUUID()

    // Create session attachment data
    const attachment: SessionAttachment = { id, userId, username }

    // Serialize the attachment to the WebSocket
    // This data persists across hibernation cycles
    server.serializeAttachment(attachment)

    // Add to active sessions
    this.sessions.set(server, attachment)

    // Send message history to the newly connected client
    this.sendMessageHistory(server)

    // Broadcast join notification to all clients
    this.broadcast({
      id: crypto.randomUUID(),
      type: 'join',
      userId,
      username,
      timestamp: Date.now(),
    })

    // Send current presence to the new user
    this.sendPresence(server)

    // Return the client WebSocket in the response
    return new Response(null, {
      status: 101,
      webSocket: client,
    })
  }

  /**
   * Handle incoming WebSocket messages (Hibernation API)
   * Called when a message is received, even after hibernation
   */
  async webSocketMessage(ws: WebSocket, message: ArrayBuffer | string): Promise<void> {
    // Get session data from the map (or deserialize if just woken)
    let session = this.sessions.get(ws)
    if (!session) {
      session = ws.deserializeAttachment() as SessionAttachment
      if (session) this.sessions.set(ws, session)
    }
    if (!session) return

    try {
      const parsed = JSON.parse(message as string)
      if (parsed.type === 'message' && parsed.content) {
        // Create a chat message
        const chatMessage: ChatMessage = {
          id: crypto.randomUUID(),
          type: 'message',
          userId: session.userId,
          username: session.username,
          content: parsed.content,
          timestamp: Date.now(),
        }

        // Add to history
        this.messageHistory.push(chatMessage)
        // Persist to storage (limit to last 100 messages)
        if (this.messageHistory.length > 100)
          this.messageHistory = this.messageHistory.slice(-100)
        this.ctx.storage.put('messages', this.messageHistory)

        // Broadcast to all connected clients
        this.broadcast(chatMessage)
      }
    } catch (error) {
      console.error('Error handling message:', error)
    }
  }

  /**
   * Handle WebSocket close events (Hibernation API)
   * Called when a client disconnects
   */
  async webSocketClose(
    ws: WebSocket,
    code: number,
    reason: string,
    wasClean: boolean
  ): Promise<void> {
    // Get session data
    const session = this.sessions.get(ws) || 
      (ws.deserializeAttachment() as SessionAttachment | null)
    
    // Remove from sessions
    this.sessions.delete(ws)

    // Broadcast leave notification
    if (session) {
      this.broadcast({
        id: crypto.randomUUID(),
        type: 'leave',
        userId: session.userId,
        username: session.username,
        timestamp: Date.now(),
      })
    }

    // Close the WebSocket
    ws.close(code, 'Durable Object is closing WebSocket')
  }

  /**
   * Handle WebSocket errors (Hibernation API)
   */
  async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
    console.error('WebSocket error:', error)
    // Treat errors as disconnections
    await this.webSocketClose(ws, 1011, 'WebSocket error', false)
  }

  /**
   * Broadcast a message to all connected clients
   */
  private broadcast(message: ChatMessage): void {
    const messageStr = JSON.stringify(message)

    // Send to all active sessions
    this.sessions.forEach((_, ws) => {
      try {
        ws.send(messageStr)
      } catch (error) {
        // Connection might be closed, will be cleaned up by close handler
        console.error('Error broadcasting to session:', error)
      }
    })
  }

  /**
   * Send message history to a specific WebSocket
   */
  private sendMessageHistory(ws: WebSocket): void {
    const historyMessage = JSON.stringify({
      type: 'history',
      messages: this.messageHistory,
    })
    try {
      ws.send(historyMessage)
    } catch (error) {
      console.error('Error sending history:', error)
    }
  }

  /**
   * Send current user presence to a specific WebSocket
   */
  private sendPresence(ws: WebSocket): void {
    const users = Array.from(this.sessions.values()).map((s) => ({
      userId: s.userId,
      username: s.username,
    }))
    const presenceMessage = JSON.stringify({
      type: 'presence',
      users,
    })

    try {
      ws.send(presenceMessage)
    } catch (error) {
      console.error('Error sending presence:', error)
    }
  }

  /**
   * Handle Durable Object alarm for cleanup tasks
   */
  async alarm(): Promise<void> {
    // Clean up old messages (older than 24 hours)
    const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000
    this.messageHistory = this.messageHistory.filter((msg) => msg.timestamp > oneDayAgo)
    await this.ctx.storage.put('messages', this.messageHistory)
    // Schedule next cleanup in 1 hour
    await this.ctx.storage.setAlarm(Date.now() + 60 * 60 * 1000)
  }
}

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>,
    ChatRoom,
  }
}
```

Este archivo worker implementa la **API de hibernación WebSocket**, que es el enfoque recomendado por Cloudflare para aplicaciones WebSocket:

1. **Define el Durable Object ChatRoom** con lógica de sala de chat consciente de la hibernación:
   - **Hibernación WebSocket**: Usa `ctx.acceptWebSocket()` en lugar de `server.accept()`, permitiendo que el DO hiberne
   - **Persistencia de sesión**: Usa `serializeAttachment()`/`deserializeAttachment()` para preservar los datos de sesión a través de la hibernación
   - **Ping/pong automático**: Usa `setWebSocketAutoResponse()` para mantener las conexiones vivas sin despertar el DO
   - **Handlers conscientes de hibernación**: Implementa los métodos `webSocketMessage`, `webSocketClose` y `webSocketError`
   - **Restauración de sesión**: Reconstruye el Map de sesiones desde WebSockets hibernados en el constructor
   - **Persistencia de mensajes**: Almacena los últimos 100 mensajes en el almacenamiento del Durable Object
   - **Presencia de usuarios**: Rastrea quién está actualmente en la sala
   - **Limpieza automática**: Usa alarmas para limpiar mensajes antiguos

2. **Exporta el worker y el Durable Object** mediante la función `createExports`:
   - La exportación `default` gestiona todas las solicitudes HTTP a través del handler de Astro
   - La exportación `ChatRoom` hace que el Durable Object esté disponible para Cloudflare
   - La función recibe el manifiesto SSR del proceso de compilación de Astro

### Diferencias clave con el enfoque sin hibernación

| Característica | Sin hibernación | Con hibernación |
|---------|---------------------|------------------|
| Aceptar WebSocket | `server.accept()` | `ctx.acceptWebSocket(server)` |
| Gestionar mensajes | `addEventListener('message')` | Método `webSocketMessage()` |
| Gestionar cierre | `addEventListener('close')` | Método `webSocketClose()` |
| Almacenamiento de sesión | Solo en memoria | `serializeAttachment()` |
| Uso de memoria del DO | Siempre en memoria | Puede ser expulsado |
| Facturación | Toda la duración de la conexión | Solo al procesar |

## Implementar el manejo de conexiones WebSocket con hibernación

El método `fetch` del Durable Object gestiona las solicitudes de upgrade WebSocket. Con la API de hibernación, cuando un cliente se conecta:

1. Valida los parámetros `userId` y `username`
2. Crea un par WebSocket (uno para el cliente, uno para el servidor)
3. Acepta el WebSocket del lado del servidor usando `ctx.acceptWebSocket()` (habilita la hibernación)
4. Crea datos de sesión y los serializa al WebSocket usando `serializeAttachment()`
5. Añade el WebSocket al Map de sesiones
6. Envía el historial de mensajes al nuevo cliente
7. Transmite una notificación de unión a todos los demás clientes
8. Devuelve el WebSocket del lado del cliente en la respuesta

La idea clave es que cada sala de chat es una única instancia de Durable Object. Todos los clientes que se conectan a la misma sala se conectan a la misma instancia. Con la hibernación, el DO puede ser expulsado de la memoria durante periodos de inactividad mientras las conexiones WebSocket permanecen abiertas. Cuando llega un mensaje, Cloudflare recrea la instancia del DO y entrega el mensaje al método `webSocketMessage`.

### Restauración de sesión tras la hibernación

Cuando el DO se despierta después de la hibernación, el constructor se ejecuta de nuevo. Los datos de sesión se restauran mediante:

```typescript
// In the constructor
this.ctx.getWebSockets().forEach((ws) => {
  const attachment = ws.deserializeAttachment() as SessionAttachment | null
  if (attachment) {
    this.sessions.set(ws, attachment)
  }
})
```

Esto recupera todas las conexiones WebSocket activas y deserializa sus datos de sesión adjuntos, restaurando completamente el estado que existía antes de la hibernación.

## Construir el endpoint de API WebSocket

Ahora cree un endpoint de API que enrute las conexiones WebSocket al Durable Object correspondiente. Cree `src/pages/api/chat/[roomId].ts`:

```typescript
// File: src/pages/api/chat/[roomId].ts

import type { APIRoute } from 'astro'

/**
 * GET /api/chat/:roomId
 * 
 * Handles WebSocket upgrade requests and connects clients to the chat room
 */
export const GET: APIRoute = async ({ params, request, locals }) => {
  const { roomId } = params
  if (!roomId)
    return new Response('Room ID is required', { status: 400 })
  // Get the ChatRoom Durable Object namespace
  const ChatRoomNamespace = locals.runtime.env.ChatRoom
  if (!ChatRoomNamespace)
    return new Response('Chat room binding not found', { status: 500 })
  // Get a Durable Object ID for this room
  // idFromName ensures the same room ID always maps to the same Durable Object
  const durableObjectId = ChatRoomNamespace.idFromName(roomId)
  // Get a stub (reference) to the Durable Object
  const durableObjectStub = ChatRoomNamespace.get(durableObjectId)
  // Forward the request to the Durable Object
  // The Durable Object's fetch method will handle the WebSocket upgrade
  return durableObjectStub.fetch(request)
}
```

Este endpoint es simple pero crucial:

1. Extrae el `roomId` de la URL
2. Obtiene el namespace del Durable Object `ChatRoom` del entorno (nota: esto coincide con el nombre del binding en `wrangler.jsonc`)
3. Crea un ID de Durable Object usando `idFromName(roomId)`, esto garantiza que el mismo ID de sala siempre obtenga la misma instancia de Durable Object
4. Obtiene un stub (referencia) a ese Durable Object
5. Reenvía toda la solicitud al método `fetch` del Durable Object

El método `idFromName` es importante: mapea de forma determinista una cadena (el ID de la sala) a una instancia específica de Durable Object. Esto significa que todos los clientes que se unan a "room-123" se conectarán exactamente a la misma instancia de Durable Object.

## Crear la interfaz del cliente de chat

Cree una página de sala de chat en `src/pages/chat/[roomId].astro`:

```astro
---
// File: src/pages/chat/[roomId].astro

const { roomId } = Astro.params;

if (!roomId) return Astro.redirect("/");

// Generate a random user ID and username (in production, use authentication)
const userId = crypto.randomUUID();
const defaultUsername = `User${Math.floor(Math.random() * 10000)}`;
---

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Chat Room: {roomId}</title>
  </head>
  <body>
    <div id="app">
      <h1>Chat Room: {roomId}</h1>
      
      <div id="connection-status">
        <span id="status-indicator">●</span>
        <span id="status-text">Connecting...</span>
      </div>

      <div id="presence-container">
        <strong>Online Users:</strong>
        <div id="user-list"></div>
      </div>

      <div id="messages-container"></div>

      <form id="message-form">
        <input
          type="text"
          id="message-input"
          placeholder="Type a message..."
          autocomplete="off"
          required
        />
        <button type="submit">Send</button>
      </form>
    </div>

    <script define:vars={{ roomId, userId, defaultUsername }}>
      // WebSocket connection
      let ws = null;
      let reconnectAttempts = 0;
      const maxReconnectAttempts = 5;
      let username = defaultUsername;

      // DOM elements
      const statusIndicator = document.getElementById("status-indicator");
      const statusText = document.getElementById("status-text");
      const messagesContainer =
        document.getElementById("messages-container");
      const messageForm = document.getElementById("message-form");
      const messageInput = document.getElementById("message-input");
      const userList = document.getElementById("user-list");

      /**
       * Connect to the WebSocket server
       */
      function connect() {
        // Determine WebSocket protocol (ws:// or wss://)
        const protocol =
          window.location.protocol === "https:" ? "wss:" : "ws:";
        const host = window.location.host;

        // Build WebSocket URL
        const wsUrl = `${protocol}//${host}/api/chat/${roomId}?userId=${userId}&username=${encodeURIComponent(username)}`;

        // Create WebSocket connection
        ws = new WebSocket(wsUrl);

        // Connection opened
        ws.addEventListener("open", () => {
          console.log("WebSocket connected");
          reconnectAttempts = 0;
          updateConnectionStatus("connected");
        });

        // Listen for messages
        ws.addEventListener("message", (event) => {
          handleMessage(event.data);
        });

        // Connection closed
        ws.addEventListener("close", () => {
          console.log("WebSocket closed");
          updateConnectionStatus("disconnected");
          attemptReconnect();
        });

        // Connection error
        ws.addEventListener("error", (error) => {
          console.error("WebSocket error:", error);
          updateConnectionStatus("error");
        });
      }

      /**
       * Handle incoming WebSocket messages
       */
      function handleMessage(data) {
        try {
          const message = JSON.parse(data);

          switch (message.type) {
            case "history":
              // Received message history
              message.messages.forEach((msg) =>
                displayMessage(msg),
              );
              break;

            case "message":
              // Received a new chat message
              displayMessage(message);
              break;

            case "join":
              // User joined
              displaySystemMessage(
                `${message.username} joined the room`,
              );
              break;

            case "leave":
              // User left
              displaySystemMessage(
                `${message.username} left the room`,
              );
              break;

            case "presence":
              // Update user list
              updateUserList(message.users);
              break;

            default:
              console.warn("Unknown message type:", message.type);
          }
        } catch (error) {
          console.error("Error parsing message:", error);
        }
      }

      /**
       * Display a chat message in the UI
       */
      function displayMessage(message) {
        const messageEl = document.createElement("div");
        messageEl.className = "message";

        const isOwnMessage = message.userId === userId;
        if (isOwnMessage) {
          messageEl.classList.add("own-message");
        }

        const timestamp = new Date(
          message.timestamp,
        ).toLocaleTimeString();

        messageEl.innerHTML = `
          <div class="message-header">
            <strong>${escapeHtml(message.username)}</strong>
            <span class="message-time">${timestamp}</span>
          </div>
          <div class="message-content">${escapeHtml(message.content)}</div>
        `;

        messagesContainer.appendChild(messageEl);
        messagesContainer.scrollTop = messagesContainer.scrollHeight;
      }

      /**
       * Display a system message (join/leave notifications)
       */
      function displaySystemMessage(text) {
        const messageEl = document.createElement("div");
        messageEl.className = "system-message";
        messageEl.textContent = text;
        messagesContainer.appendChild(messageEl);
        messagesContainer.scrollTop = messagesContainer.scrollHeight;
      }

      /**
       * Update the connection status indicator
       */
      function updateConnectionStatus(status) {
        if (status === "connected") {
          statusIndicator.style.color = "#10b981";
          statusText.textContent = "Connected";
        } else if (status === "disconnected") {
          statusIndicator.style.color = "#ef4444";
          statusText.textContent = "Disconnected";
        } else if (status === "error") {
          statusIndicator.style.color = "#f59e0b";
          statusText.textContent = "Connection error";
        } else {
          statusIndicator.style.color = "#6b7280";
          statusText.textContent = "Connecting...";
        }
      }

      /**
       * Update the user list
       */
      function updateUserList(users) {
        userList.innerHTML = "";
        users.forEach((user) => {
          const userEl = document.createElement("span");
          userEl.className = "user-badge";
          userEl.textContent = user.username;
          userList.appendChild(userEl);
        });
      }

      /**
       * Attempt to reconnect if connection is lost
       */
      function attemptReconnect() {
        if (reconnectAttempts >= maxReconnectAttempts) {
          displaySystemMessage(
            "Failed to reconnect. Please refresh the page.",
          );
          return;
        }

        reconnectAttempts++;
        const delay = Math.min(
          1000 * Math.pow(2, reconnectAttempts),
          10000,
        );

        displaySystemMessage(
          `Reconnecting in ${delay / 1000} seconds... (attempt ${reconnectAttempts}/${maxReconnectAttempts})`,
        );

        setTimeout(() => {
          connect();
        }, delay);
      }

      /**
       * Send a message
       */
      function sendMessage(content) {
        if (!ws || ws.readyState !== WebSocket.OPEN) {
          displaySystemMessage("Cannot send message: not connected");
          return;
        }

        ws.send(
          JSON.stringify({
            type: "message",
            content: content.trim(),
          }),
        );
      }

      /**
       * Escape HTML to prevent XSS
       */
      function escapeHtml(text) {
        const div = document.createElement("div");
        div.textContent = text;
        return div.innerHTML;
      }

      // Handle form submission
      messageForm.addEventListener("submit", (e) => {
        e.preventDefault();

        const content = messageInput.value.trim();
        if (content) {
          sendMessage(content);
          messageInput.value = "";
        }
      });

      // Prompt for username on page load
      const savedUsername = localStorage.getItem("chat-username");
      if (savedUsername) {
        username = savedUsername;
      } else {
        const promptedUsername = prompt(
          "Enter your username:",
          defaultUsername,
        );
        if (promptedUsername && promptedUsername.trim()) {
          username = promptedUsername.trim();
          localStorage.setItem("chat-username", username);
        }
      }

      // Connect on page load
      connect();

      // Clean up on page unload
      window.addEventListener("beforeunload", () => {
        if (ws) {
          ws.close();
        }
      });
    </script>
  </body>
</html>
```

Esta página proporciona una interfaz de chat completamente funcional. Gestiona la conexión WebSocket con el backend, maneja el envío y recepción de mensajes en tiempo real, y muestra notificaciones cuando los usuarios se unen o abandonan el chat. La interfaz también muestra la lista de usuarios actualmente en línea, mantiene el estado de la conexión con un indicador visual, e intenta reconectarse automáticamente si la conexión se interrumpe, usando una estrategia de retraso progresivo. Para mantener los nombres de usuario consistentes entre sesiones, los almacena en el localStorage del navegador. Todo el contenido generado por el usuario se escapa cuidadosamente antes de mostrarse, lo que ayuda a proteger la aplicación contra ataques de cross-site scripting (XSS).

Las partes clave:

- **Construcción de la URL WebSocket**: Usa el hostname actual y el endpoint `/api/chat/[roomId]`
- **Protocolo de mensajes**: Envía/recibe mensajes JSON con un campo `type`
- **Estrategia de reconexión**: Backoff exponencial (1 s, 2 s, 4 s, 8 s y 10 s (máx.))
- **Persistencia del nombre de usuario**: Almacena el nombre de usuario en localStorage

## Añadir persistencia de mensajes con almacenamiento de Durable Object

La persistencia de mensajes ya está implementada en el Durable Object `ChatRoom`. Así es como funciona:

```typescript
// File: src/worker.ts

// In the constructor, load message history from storage
this.ctx.blockConcurrencyWhile(async () => {
  const stored = await this.ctx.storage.get<ChatMessage[]>('messages')
  if (stored) {
    this.messageHistory = stored
  }
})

// In webSocketMessage(), when a new message arrives, persist it
this.messageHistory.push(chatMessage)

// Limit to last 100 messages
if (this.messageHistory.length > 100) {
  this.messageHistory = this.messageHistory.slice(-100)
}

// Save to storage
this.ctx.storage.put('messages', this.messageHistory)
```

El método `blockConcurrencyWhile` garantiza que el almacenamiento se cargue antes de aceptar cualquier solicitud. Esto previene condiciones de carrera durante la inicialización. Con la hibernación, el historial de mensajes se recarga desde el almacenamiento cada vez que el DO se despierta, garantizando la consistencia de los datos.

## Implementar seguimiento de presencia de usuarios

La presencia de usuarios se rastrea usando un Map de conexiones WebSocket a datos de sesión:

```typescript
// File: src/worker.ts

// When a user connects (in fetch method)
this.sessions.set(server, attachment)

// Send current presence to the new user
const users = Array.from(this.sessions.values()).map((s) => ({
  userId: s.userId,
  username: s.username,
}))

// When a user disconnects (in webSocketClose method)
this.sessions.delete(ws)

// Broadcast leave notification
this.broadcast({
  type: 'leave',
  userId: session.userId,
  username: session.username,
  timestamp: Date.now(),
})
```

Esto proporciona actualizaciones de presencia en tiempo real:

- Los nuevos usuarios ven la lista de usuarios actual inmediatamente
- Todos los usuarios son notificados cuando alguien se une o abandona la sala
- Los datos de sesión se preservan a través de la hibernación mediante `serializeAttachment()`
- Si una conexión se interrumpe, el usuario se elimina automáticamente mediante `webSocketClose()`
- Cuando el DO se despierta de la hibernación, `ctx.getWebSockets()` restaura todas las conexiones activas

## Gestionar reconexión y recuperación de errores

El cliente implementa lógica de reconexión robusta:

```typescript
// File: src/pages/chat/[roomId].astro

function attemptReconnect() {
  if (reconnectAttempts >= maxReconnectAttempts) {
    displaySystemMessage('Failed to reconnect. Please refresh the page.')
    return
  }

  reconnectAttempts++
  // Exponential backoff: 1s, 2s, 4s, 8s, 10s (max)
  const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000)
  
  setTimeout(() => {
    connect()
  }, delay)
}
```

El backoff exponencial ayuda a evitar saturar el servidor con intentos de reconexión repetidos. Da tiempo al servidor para recuperarse y ayuda a mantener la estabilidad de la red. El cliente intentará reconectarse automáticamente si el WebSocket se cierra, encuentra un error o el servidor se reinicia.

## Ping/pong automático con hibernación WebSocket

Una característica potente de la API de hibernación son las respuestas ping/pong automáticas que no despiertan el Durable Object:

```typescript
// In the constructor
this.ctx.setWebSocketAutoResponse(
  new WebSocketRequestResponsePair('ping', 'pong')
)
```

Esto indica a Cloudflare que responda automáticamente a mensajes `"ping"` con `"pong"` a nivel de runtime, sin invocar su Durable Object. Esto:

- **Mantiene las conexiones vivas** sin incurrir en cargos de facturación
- **Reduce la latencia** para mensajes de heartbeat
- **Reduce los costos** ya que el DO no se despierta para los pings

Puede verificar que las respuestas automáticas se gestionaron comprobando `ws.getLastAutoResponseTimestamp()` si necesita rastrear la salud de la conexión.

## Desplegar en Cloudflare Workers

Despliegue su aplicación de chat en tiempo real en producción:

```bash
# Build the project
npm run build

# Deploy to Cloudflare Workers
npx wrangler deploy
```

## Conclusión

Con Astro y la API de hibernación WebSocket de Cloudflare Durable Objects, ahora tiene una aplicación de chat en tiempo real escalable y de baja latencia. Los mensajes se transmiten instantáneamente a todos los clientes, la presencia de usuarios y las salas se gestionan de forma eficiente, y el almacenamiento de mensajes está integrado. La hibernación mantiene los costos del servidor bajos, y el ping/pong automático mantiene las conexiones sin sobrecarga adicional. Para uso en producción, recuerde implementar autenticación, protección contra abusos, monitorización, y medidas robustas de reconexión y seguridad.

Si tiene preguntas o comentarios, no dude en contactarme en [Twitter](https://twitter.com/direct_messages/create/rishi_raj_jain_).
