WebSockets en un Durable Object: el hogar del tiempo real
El modelo HTTP es pedir y responder: el cliente pregunta, el servidor contesta, la conexion se despide. El tiempo real invierte ese trato, porque el servidor debe empujar sin que le pregunten y muchos clientes han de ver lo mismo en el mismo instante. El WebSocket ofrece el canal persistente y de doble sentido, pero un canal necesita dos extremos que perduren, y un Worker sin estado es puro transito: nace y muere con la peticion. La pregunta no es si un Worker puede abrir un WebSocket, sino quien sostiene el otro extremo y coordina a todos. La respuesta tiene un nombre que ya conoces. Vemos el limite del pedir-y-responder, por que el objeto es el extremo natural del canal y como se abre con WebSocketPair y la respuesta 101.
Todo lo que has construido hasta aquí obedece a un compás de dos tiempos: el cliente pide, el servidor responde, y la conexión se despide. Es el ritmo de HTTP, perfecto para servir una página o devolver un JSON. Pero hay una clase de aplicaciones que ese compás no sabe tocar: las que ocurren en vivo. En un chat, en un documento compartido, en una partida, el servidor tiene que hablar cuando nadie le ha preguntado, y todos los presentes deben oír lo mismo a la vez. El WebSocket abre ese canal permanente y bidireccional; pero un canal necesita dos extremos que perduren, y un Worker, que nace y muere con la petición, no puede sostener el suyo. Hace falta algo que viva, que recuerde quién está conectado y que ponga a todos en la misma sala. Ese algo ya lo conoces.
- Entender por qué el modelo pedir-y-responder de HTTP se queda corto para el tiempo real.
- Reconocer el WebSocket como un canal persistente y bidireccional que nace de un
upgrade. - Justificar por qué un Durable Object —único, con estado y de un hilo— es el extremo natural del canal.
- Crear el par de sockets con
WebSocketPairy devolver la respuesta101que lo abre.
El límite del pedir-y-responder
HTTP es una conversación por turnos. Cada petición abre una conexión, lleva una pregunta, recibe una respuesta y se cierra; el servidor nunca habla primero. Para servir contenido eso es una virtud, pero para el tiempo real es una camisa de fuerza. El apaño clásico es el sondeo: el cliente vuelve a preguntar una y otra vez —“¿hay algo nuevo?, ¿y ahora?”—, un desperdicio de peticiones que además llega siempre tarde, al ritmo del reloj y no al de los hechos.
El WebSocket resuelve el problema de raíz. La conexión empieza como una petición HTTP normal, pero con dos cabeceras que anuncian un cambio de intención: Upgrade: websocket y Connection: Upgrade. El servidor, en vez de responder un 200 con un cuerpo, contesta 101 Switching Protocols, y desde ese instante la misma conexión TCP deja de hablar HTTP y empieza a hablar el protocolo de tramas de WebSocket. No se abre una conexión nueva: se transforma la que ya había. A partir de ahí el turno desaparece —ambos extremos envían cuando quieren, mientras el canal siga vivo.
Esa metamorfosis es la clave. Lo que llega al servidor es, al principio, una petición HTTP como cualquier otra, y por eso puede enrutarse con las mismas herramientas que ya dominas. Pero lo que queda después no es una respuesta: es un tubo abierto que alguien tiene que sostener por el lado del servidor. Y ahí aparece el problema que define este nivel entero.
Por qué el objeto es el otro extremo
Un WebSocket tiene dos extremos y los dos deben perdurar. El del cliente vive en el navegador, sin misterio. El del servidor es el difícil: algo tiene que mantener ese socket abierto, recordarlo y —lo decisivo— coordinarlo con los demás sockets de la misma sala. Un Worker sin estado no puede: está diseñado para morir con la petición. Y aunque pudiera sostener un socket, dos usuarios del mismo chat podrían caer en dos isolates distintos en dos continentes, cada uno con media sala en la mano y ninguno capaz de alcanzar al otro. El broadcast se vuelve imposible porque no hay un solo lugar donde converjan todas las conexiones.
El Durable Object es exactamente ese lugar único. Es único —una instancia por nombre, y todos aterrizan en la misma—; persiste —vive entre mensaje y mensaje, y sostiene los sockets en su memoria—; y es de un solo hilo —serializa los eventos, así que el estado de la sala nunca se corrompe—. Las tres propiedades que lo hicieron el actor del estado coordinado lo convierten, sin añadir nada, en el actor de las conexiones coordinadas. Una sala es un Durable Object; los sockets de todos sus miembros los guarda ese objeto; difundir es recorrerlos uno a uno.
Un solo punto de reunión
Todas las conexiones de una sala convergen en el mismo objeto. Sin un punto único, difundir a todos es imposible.
Memoria que perdura
El objeto vive entre mensajes y sostiene los sockets abiertos. El Worker, que muere con la petición, no puede.
Eventos en orden
Al ser de un hilo, los mensajes entrantes se serializan y el estado compartido de la sala nunca se corrompe.
La sala es el nombre
El nombre del objeto es la sala. Quien se conecta a sala:general cae siempre en la misma instancia, donde están los demás.
WebSocketPair: abrir el canal
Vayamos a la mecánica. El upgrade llega al Worker como una petición normal; el Worker reconoce la cabecera y reenvía la petición entera al objeto por fetch —este es el caso en que fetch, y no el RPC, es la puerta, porque lo que viaja es literalmente una petición HTTP—. Dentro del objeto no aceptas el socket del cliente directamente: acuñas una pareja con WebSocketPair, dos sockets enlazados, uno que te quedas —el extremo servidor— y otro que devuelves al cliente dentro de la respuesta 101.
export class Sala extends DurableObject {
async fetch(request: Request): Promise<Response> {
const par = new WebSocketPair();
const [cliente, servidor] = Object.values(par);
servidor.accept();
return new Response(null, { status: 101, webSocket: cliente });
}
}
new WebSocketPair() devuelve un objeto con dos sockets enlazados: el 0, el del cliente, y el 1, el del servidor. Object.values los desempaqueta. La llamada servidor.accept() le dice al runtime que el objeto se hace cargo de ese extremo y queda listo para recibir. La respuesta es un 101 sin cuerpo con un campo especial, webSocket, que lleva el extremo del cliente de vuelta por el cable hasta el navegador. Desde ese momento el objeto sostiene un extremo y el navegador el otro: el tubo está abierto.
Del lado del Worker, el enrutado convierte el nombre de la sala en la identidad del objeto:
export default {
async fetch(request, env): Promise<Response> {
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("Se esperaba un WebSocket", { status: 426 });
}
const id = env.SALA.idFromName("sala:general");
return env.SALA.get(id).fetch(request);
},
} satisfies ExportedHandler<Env>;
Todo el que se conecta a sala:general cae en el mismo objeto, y allí sus sockets se encuentran. El nombre es la sala: cámbialo por el identificador de un documento, de una partida o de un usuario, y tendrás tantas salas como nombres sepas acuñar.
sequenceDiagram participant C as Cliente participant W as Worker participant DO as Objeto Sala C->>W: GET con Upgrade websocket W->>DO: fetch reenvia la peticion entera DO-->>C: 101 y el canal queda abierto C-->>DO: mensajes en los dos sentidos
Merece la pena detenerse en la coincidencia, porque no es una coincidencia. El tiempo real, en el fondo, siempre ha sido el mismo problema: un punto de verdad compartido que muchos observan a la vez y que cambia sin avisar. Una sala de chat es eso; un documento colaborativo es eso; un marcador en vivo es eso. Durante años, montar esto en el edge era imposible, y no porque faltara el WebSocket —el protocolo lleva más de una década entre nosotros— sino porque faltaba el otro extremo: un lugar único, con estado, capaz de sostener las conexiones de todos los participantes y de decidir el orden en que se ven las cosas. El cómputo del edge era ubicuo y efímero, justo lo contrario de lo que un canal persistente reclama. El Durable Object, que se diseñó para dar identidad y memoria al estado, resulta ser —sin que nadie lo forzara— el hogar exacto que el tiempo real pedía. Y no es azar, porque “coordinar conexiones en vivo” y “coordinar estado mutable con identidad” son el mismo problema con dos disfraces: ambos exigen un punto único donde converjan muchos, que recuerde, que serialice y que empuje. La lección honda es que no le añadimos una función nueva a los Durable Objects para que hablaran WebSockets; descubrimos que ya eran la forma correcta. Por eso, cuando tengas un problema de tiempo real, no preguntes “dónde pongo el servidor de sockets”; pregunta “cuál es la sala, el documento, la partida —la entidad— cuyo nombre reúne a todos los que deben verse”. Nombra esa entidad y ya tienes tu objeto; los WebSockets son solo la puerta por la que entran sus miembros, y el 101 es el umbral que cruzan.
- Explica con tus palabras por qué el modelo pedir-y-responder de HTTP no basta para un chat, y qué aporta el WebSocket que el sondeo no puede dar.
- Escribe el handler del Worker que rechaza con
426lo que no traiga la cabeceraUpgradey reenvía porfetchel resto al objeto de la sala. - Implementa el
fetchdel Durable Object que crea unWebSocketPair, llama aaccepten el extremo servidor y devuelve el101con el extremo del cliente. - Argumenta por qué un Worker sin estado no puede ser el extremo servidor de una sala, y nombra las tres propiedades del objeto que lo convierten en el sitio natural.