Cloudflare
Durable Objects
WebSocket
Realtime
Multiplayer

Objets durables et WebSockets : multijoueur sans serveur dédié

Comment Durable Objects résout le problème de l'état partagé entre les connexions WebSocket en temps réel, avec l'API Hibernation qui élimine le coût des connexions inactives.

Objets durables et WebSockets : multijoueur sans serveur dédié

L'hypothèse qui brise la plupart des implémentations de WebSocket dans sans serveur est que le fait d'avoir plusieurs Workers acceptant les connexions résout le problème de mise à l'échelle. Cela ne résout pas le problème : cela crée plusieurs mondes isolés où chaque client ne parle qu'aux instances de son propre Worker, sans aucune visibilité sur qui est connecté aux autres. Deux clients ouvrant une connexion au même point de terminaison peuvent se trouver sur des Workers complètement différents, sans aucun canal pour échanger des messages entre eux. Cet isolement est exactement ce qui permet à Workers d'évoluer – et c'est exactement ce qui rend toute présence en temps réel ou fonctionnalité collaborative irréalisable sans une couche de coordination externe.

Pourquoi les travailleurs isolés ne suffisent pas pour le multijoueur

Imaginez une discussion en salle. Dix clients connectés. La salle existe en tant que concept dans l'application, mais elle n'existe en tant qu'objet en mémoire nulle part dans Worker. Chaque connexion WebSocket est acceptée par un Worker qui ne connaît pas les autres. Lorsque le client A envoie un message, le Worker qui reçoit ce message n'a aucun moyen d'atteindre les neuf autres clients connectés aux autres Workers.

La solution classique consiste à ajouter une couche externe : Pub/Sub (Redis, Upstash), database pour la persistance des messages et des sondages, ou un service WebSocket dédié comme Ably ou Pusher. Toutes ces solutions fonctionnent, mais elles ajoutent un peu de latence, un service à gérer et un coût qui évolue en fonction des connexions actives, et non de l'utilisation réelle.

Un objet durable modifie le point de coordination. La salle n'est pas implicite dans le concept d'application : elle devient un DO identifié nommément. Tous les clients qui souhaitent entrer dans « room-456 » sont acheminés vers le même DO, qui conserve la liste des connexions WebSocket en mémoire et peut transmettre des messages individuels sans aller-retour externe. La coordination est locale au DO.

Le modèle de base et son coût sans hibernation

La mise en œuvre directe de la diffusion au sein d’un DO est simple. Le DO conserve Set sur WebSocket objets en mémoire, accepte de nouvelles connexions et les parcourt tous pour transmettre des messages :

export class Room implements DurableObject { private sessions: Set<WebSocket> = new Set(); async fetch(request: Request): Promise<Response> { if (request.headers.get('Upgrade') !== 'websocket') { return new Response('Expected WebSocket', { status: 426 }); } const pair = new WebSocketPair(); const [client, server] = Object.values(pair); server.accept(); this.sessions.add(server); server.addEventListener('message', (event) => { for (const session of this.sessions) { if (session !== server) { session.send(event.data as string); } } }); server.addEventListener('close', () => { this.sessions.delete(server); }); return new Response(null, { status: 101, webSocket: client }); } }

Ce code fonctionne. Le problème de coût apparaît lorsque vous analysez le modèle de facturation : pendant que ce DO a des connexions WebSocket ouvertes et traite les écouteurs d'événements, il est éveillé. Même si aucun client n'envoie de messages, le DO est toujours actif et consomme des Go-secondes. Pour une salle avec cinq utilisateurs inactifs pendant huit heures, le DO est actif pendant huit heures – et vous payez pour chaque seconde de calcul pendant cette période.

L'API Hibernation et ce qu'elle change dans le modèle de coût

L'API WebSocket Hibernation annule ce coût. Au lieu de conserver les connexions actives en mémoire, vous transmettez le contrôle à Cloudflare en utilisant this.ctx.acceptWebSocket(ws) au lieu de ws.accept(). À partir de là, Cloudflare maintient les connexions WebSocket ouvertes même lorsque le DO est en veille. Lorsqu'un client envoie un message, Cloudflare réveille le DO, transmet le message via la méthode webSocketMessage() et le DO peut se remettre en veille une fois le traitement terminé.

export class Room implements DurableObject { constructor(private ctx: DurableObjectState, private env: Env) {} async fetch(request: Request): Promise<Response> { if (request.headers.get('Upgrade') !== 'websocket') { return new Response('Expected WebSocket', { status: 426 }); } const pair = new WebSocketPair(); const [client, server] = Object.values(pair); this.ctx.acceptWebSocket(server); return new Response(null, { status: 101, webSocket: client }); } async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> { const sockets = this.ctx.getWebSockets(); for (const session of sockets) { if (session !== ws) { session.send(message as string); } } } async webSocketClose(ws: WebSocket, code: number): Promise<void> { ws.close(code); } }

Avec l'hibernation, le coût de calcul du DO est proportionnel au temps de traitement des messages, et non au temps d'ouverture des connexions. Une salle avec cinq utilisateurs en silence pendant huit heures ne coûte pratiquement rien en calcul. Le coût réel apparaît lorsque les utilisateurs échangent activement des messages. Pour les applications collaboratives où les périodes d'inactivité sont courantes (un document partagé que la plupart des collaborateurs ouvrent mais ne modifient pas continuellement), la différence de coût entre le modèle direct et le modèle hiberné peut être d'un ou deux ordres de grandeur.

this.ctx.getWebSockets() renvoie toutes les connexions actives gérées par le cadre de veille — équivalent au Set que vous maintiendriez manuellement, mais conservé par la plateforme entre les mises en veille. Cela signifie que vous n'avez pas besoin de reconstruire la liste des sessions au réveil du DO : elle est déjà disponible.

État persistant entre les hibernations

Un détail qui accroche ceux qui viennent du modèle direct : quand le DO dort et se réveille, le constructeur est rappelé, mais l'état en mémoire (this.sessions, this.roomName, toute variable d'instance) est perdu. Seules les connexions de stockage et WebSocket gérées par le framework d'hibernation survivent.

Pour les états qui doivent survivre aux hibernations (métadonnées de la salle, historique des messages, présence de l'utilisateur), le stockage est le bon endroit :

async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> { const data = JSON.parse(message as string); if (data.type === 'join') { const users = await this.ctx.storage.get<string[]>('users') ?? []; users.push(data.userId); await this.ctx.storage.put('users', users); } const sockets = this.ctx.getWebSockets(); for (const session of sockets) { session.send(message as string); } }

Le modèle qui fonctionne : conserver en mémoire uniquement ce qui peut être dérivé du stockage et peut être supprimé entre les hibernations. Conserver dans le stockage tout ce qui a besoin de survivre. Démarrez à partir de blockConcurrencyWhile() dans le constructeur si nécessaire.

Ce que vous obtenez avec ce modèle

La combinaison de la sérialisation des requêtes, de l'hibernation WebSocket et de l'API d'alarme résout un ensemble spécifique de problèmes sans infrastructure supplémentaire. Édition collaborative en temps réel où plusieurs clients modifient le même document et ont besoin de voir les mises à jour avec une faible latence. Présence d'utilisateurs – savoir qui est en ligne dans une salle – où la liste doit être cohérente même avec des entrées et des sorties simultanées. Jeux multijoueurs avec état de session simple où la fréquence des mises à jour justifie une coordination centralisée. Des chronomètres partagés entre les participants, comme les comptes à rebours lors d’une réunion, qui doivent être cohérents pour tout le monde.

Le plafond de débit existe toujours : un DO qui traite chaque message en 2 ms peut gérer 500 messages par seconde provenant de différents clients. Pour les salles comptant quelques dizaines d’utilisateurs actifs simultanément, ce plafond n’est jamais atteint. Dans les cas où des centaines de clients envoient des messages en continu au même DO, le partitionnement par salle ou groupe de salles devient nécessaire.

Ce que ce modèle ne résout pas

La persistance de l’historique des messages pour les utilisateurs arrivant hors ligne est un problème distinct. Le stockage DO stocke l'historique tant que le DO existe, mais ce n'est pas une banque de requêtes. Pour rechercher des messages d'une période, filtrer par utilisateur ou effectuer toute opération tirant parti de SQL, vous avez besoin de D1 comme stockage complémentaire que le DO remplit avec chaque message.

L'échelle géographique a également des limites. Un DO existe dans un seul PoP. Pour les applications avec des utilisateurs de régions très éloignées collaborant dans la même pièce, la latence des messages inclut l'aller-retour jusqu'au PoP où se trouve le DO, qui pourrait être Francfort pour un utilisateur de São Paulo. Pour la plupart des applications collaboratives, cette latence est acceptable. Pour les jeux nécessitant une latence inférieure à 50 ms pour tous les joueurs, l'architecture doit être différente.

Durable Objects avec l'hibernation WebSocket résout le multijoueur sans serveur dédié pour un véritable ensemble de cas d'utilisation, avec un modèle de coût qui favorise les applications où les utilisateurs passent beaucoup plus de temps à lire qu'à écrire. En dehors de cette enveloppe, les limites apparaissent rapidement.

A lire aussi

-Cloudflare Durable Objects : État cohérent à la périphérie – ce qui change vraiment