Introduction
Une requête HTTP ordinaire est ouverte, reçoit une réponse, puis se termine. Un WebSocket transforme cette première requête HTTP en une connexion bidirectionnelle qui reste ouverte. Le serveur peut ainsi envoyer une mise à jour dès qu'une modification survient. Les messages de chat, les curseurs collaboratifs et les tableaux de commandes en direct bénéficient tous de ce canal en temps réel.
Un Durable Object fournit un point de coordination pour chaque salle. Le Worker d'entrée transforme un nom de salle validé, tel que planning, en une identité d'objet stable. L'objet sélectionné accepte les connexions WebSocket de cette salle, valide chaque message entrant et diffuse une seule mise à jour approuvée à ses propres clients connectés. Un autre nom sélectionne un autre objet : support ne peut donc pas recevoir le trafic de planning.
Ce lab utilise volontairement l'API WebSocket standard et conserve en mémoire l'ensemble des sockets actifs. Vous pouvez ainsi observer le fonctionnement des connexions et de la diffusion avant que O06 ne présente la mise en veille WebSocket et les pièces jointes de connexion. SQLite stocke un petit historique des messages afin de prouver qu'une entrée malformée n'a pas modifié l'état durable ; cela ne rend pas un socket ouvert durable.
Vous allez implémenter le protocole, connecter deux clients fournis à une salle et un troisième client à une autre, observer une diffusion valide, rejeter une entrée malformée, répéter le test sur Cloudflare, inspecter le client du navigateur et le Dashboard, puis supprimer exactement les ressources temporaires.
Chaque nouvelle VM doit disposer de sa propre autorisation Wrangler. Vous devez déjà comprendre les noms stables des Durable Objects, les bindings, le RPC et l'état fondé sur SQLite présentés dans O01–O04. La configuration installe Node.js 22.22.0, Wrangler 4.132.0 dans le projet et le client de test ws dans /home/labex/project/room-broadcast. Elle fournit les clients du navigateur et de test, mais n'écrit pas votre Worker, n'autorise pas Cloudflare et n'effectue aucun déploiement.
Autoriser la VM et déclarer l'espace de noms de la salle
Dans cette étape, vous allez autoriser la nouvelle VM et déclarer une classe de Durable Object reposant sur SQLite pour les salles en temps réel.
Accédez au projet préparé, vérifiez la version imposée de Wrangler et autorisez cette VM :
cd /home/labex/project/room-broadcast
npx wrangler --version
npx wrangler login --device --browser=false
Wrangler doit afficher 4.132.0. Ouvrez dans le navigateur l'URL Cloudflare affichée, saisissez le code court, confirmez le compte d'apprentissage prévu, puis autorisez-le. Le navigateur accorde à Wrangler l'accès nécessaire ; votre mot de passe n'est jamais envoyé à la VM.
Lisez uniquement les champs d'identité non sensibles, sélectionnez le compte que vous avez confirmé et générez un nom de Worker temporaire unique :
WHOAMI="$(npx wrangler whoami --json)"
printf '%s\n' "$WHOAMI" | jq '{loggedIn, authType, accounts: [.accounts[] | {name}]}'
ACCOUNT_ID="$(printf '%s\n' "$WHOAMI" | jq -r '.accounts[] | select(.name == "LabEx Learning") | .id')"
test -n "$ACCOUNT_ID"
RUN="labex-c10-o05-$(openssl rand -hex 6)"
printf '%s\n' "$RUN" | tee .labex/run-name
Si votre compte d'apprentissage dédié porte un autre nom affiché, remplacez LabEx Learning par le nom que vous avez confirmé. Écrivez maintenant la configuration :
cat > wrangler.jsonc <<JSON
{
"\$schema": "./node_modules/wrangler/config-schema.json",
"name": "$RUN",
"account_id": "$ACCOUNT_ID",
"main": "src/index.js",
"compatibility_date": "2026-09-18",
"workers_dev": true,
"preview_urls": false,
"observability": { "enabled": true, "head_sampling_rate": 1 },
"durable_objects": { "bindings": [
{ "name": "ROOMS", "class_name": "RoomBroadcast" }
] },
"exports": {
"RoomBroadcast": { "type": "durable-object", "storage": "sqlite" }
}
}
JSON
Le binding ROOMS constitue le point d'accès du Worker à l'espace de noms de la classe. L'appel à getByName("planning") sélectionnera toujours la même salle logique, tandis que getByName("support") sélectionnera un objet indépendant. L'export attribue à chaque salle sélectionnée son propre stockage SQLite privé. Aucune ressource cloud n'existe avant le déploiement.
Implémenter le protocole WebSocket validé
Dans cette étape, vous allez définir un petit contrat de messages et implémenter l'objet de salle qui accepte et diffuse les messages WebSocket.
La requête initiale doit contenir Upgrade: websocket. Après la mise à niveau, les messages sont des trames et non de nouvelles requêtes HTTP. Un client peut envoyer n'importe quel texte dans une trame : l'analyse du JSON n'est donc que la première vérification. La validation doit également exiger le type attendu, un champ text non vide et limité en longueur, ainsi qu'aucun champ inattendu avant toute modification de l'état durable.
Créez les fonctions d'aide partagées du protocole :
cat > src/protocol.js <<'JS'
const ROOM_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
export function parseRoomPath(pathname) {
const match = pathname.match(/^\/rooms\/([^/]+)\/(connect|state)$/);
if (!match || !ROOM_PATTERN.test(match[1])) return null;
return { room: match[1], action: match[2] };
}
export function parseClientMessage(raw) {
if (typeof raw !== "string" || raw.length > 512) return { ok: false };
let value;
try { value = JSON.parse(raw); } catch { return { ok: false }; }
if (!value || typeof value !== "object" || Array.isArray(value)) return { ok: false };
const keys = Object.keys(value).sort();
if (keys.length !== 2 || keys[0] !== "text" || keys[1] !== "type") return { ok: false };
if (value.type !== "update" || typeof value.text !== "string") return { ok: false };
const text = value.text.trim();
if (text.length < 1 || text.length > 80) return { ok: false };
return { ok: true, text };
}
JS
Créez le Worker d'entrée et la classe Durable Object :
cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";
import { CLIENT_HTML } from "./client-html.js";
import { parseClientMessage, parseRoomPath } from "./protocol.js";
const json = (body, status = 200) => Response.json(body, { status });
export class RoomBroadcast extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
this.sessions = new Set();
this.ctx.blockConcurrencyWhile(async () => {
this.ctx.storage.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
created_at INTEGER NOT NULL
)
`);
});
}
async fetch(request) {
if ((request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
return json({ error: "websocket_upgrade_required" }, 426);
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
server.accept();
this.sessions.add(server);
server.addEventListener("message", event => this.receive(server, event.data));
const forget = () => this.sessions.delete(server);
server.addEventListener("close", forget);
server.addEventListener("error", forget);
server.send(JSON.stringify({ type: "ready" }));
return new Response(null, { status: 101, webSocket: client });
}
receive(sender, raw) {
const message = parseClientMessage(raw);
if (!message.ok) {
sender.send(JSON.stringify({
type: "error",
code: "invalid_message",
detail: "Send only {type: update, text: 1-80 characters}."
}));
return;
}
const createdAt = Date.now();
const row = this.ctx.storage.sql.exec(`
INSERT INTO messages (text, created_at)
VALUES (?, ?)
RETURNING sequence
`, message.text, createdAt).one();
const update = JSON.stringify({
type: "update",
sequence: row.sequence,
text: message.text,
createdAt
});
for (const socket of this.sessions) {
try { socket.send(update); } catch { this.sessions.delete(socket); }
}
console.log(JSON.stringify({ event: "room_update", sequence: row.sequence, connected: this.sessions.size }));
}
async getState() {
const messages = this.ctx.storage.sql.exec(`
SELECT sequence, text, created_at AS createdAt
FROM messages ORDER BY sequence
`).toArray();
return {
messageCount: messages.length,
latestSequence: messages.at(-1)?.sequence ?? 0,
messages
};
}
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/" && request.method === "GET") {
return new Response(CLIENT_HTML, { headers: { "content-type": "text/html; charset=utf-8" } });
}
const route = parseRoomPath(url.pathname);
if (!route) return json({ error: "not_found" }, 404);
if (route.action === "connect") {
if (request.method !== "GET" || (request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
return json({ error: "websocket_upgrade_required" }, 426);
}
return env.ROOMS.getByName(route.room).fetch(request);
}
if (request.method !== "GET") return json({ error: "method_not_allowed" }, 405);
const state = await env.ROOMS.getByName(route.room).getState();
return json({ room: route.room, ...state });
}
};
JS
WebSocketPair crée les deux extrémités client et serveur d'une connexion. Le retour de l'extrémité client avec HTTP 101 termine la mise à niveau, tandis que server.accept() démarre le socket côté serveur standard. L'ensemble sessions en mémoire est volontairement limité à une seule instance d'objet. Le nom stable de la salle empêche ainsi cet ensemble de devenir global entre les salles.
Exécutez les tests déterministes du protocole et demandez à Wrangler de construire le projet sans le déployer :
npm test
npx wrangler deploy --dry-run
Vous devez obtenir quatre tests réussis. L'exécution à blanc vérifie le module du Worker et la configuration des bindings ; les étapes en direct vérifieront ensuite le comportement réel des sockets.
Diffuser une mise à jour dans une seule salle
Dans cette étape, vous allez exécuter le Worker en local et prouver qu'une mise à jour est reçue par deux clients partageant une salle, mais pas par un client d'une autre salle.
Démarrez Wrangler en tâche d'arrière-plan. La redirection de sa sortie permet de garder le terminal lisible, et l'identifiant de tâche enregistré vous permettra d'arrêter précisément ce processus plus tard :
mkdir -p .labex/local-state
npx wrangler dev --local --ip 127.0.0.1 --port 8787 --persist-to .labex/local-state > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
curl --silent --fail http://127.0.0.1:8787/ >/dev/null && break
sleep 1
done
curl --silent --fail http://127.0.0.1:8787/ | grep -o '<title>[^<]*</title>'
Le programme client fourni ouvre trois connexions WebSocket réelles : deux nommées planning et une nommée support. Il envoie une mise à jour depuis le premier client planning, puis attend des résultats limités provenant des trois clients :
node tools/room-clients.mjs http://127.0.0.1:8787 planning support broadcast | tee .labex/local-broadcast.json
Les objets sender et peer doivent contenir le même sequence: 1 et le même texte. otherUpdates doit être égal à 0. La section d'état doit également afficher un message durable dans planning et aucun message dans support. Cela démontre les deux aspects de la conception : le nom stable partagé réunit les deux premiers clients, tandis que le nom différent maintient le troisième client en dehors de la limite de diffusion.
Rejeter un message malformé avant toute modification de l'état
Dans cette étape, vous allez envoyer une trame contenant un JSON valide mais une entrée applicative invalide, puis comparer l'état durable avant et après son envoi.
Le champ text vide constitue la distinction importante : l'analyse du JSON réussit, mais le protocole de la salle le rejette. Exécutez la deuxième phase fournie sur les mêmes objets locaux :
node tools/room-clients.mjs http://127.0.0.1:8787 planning support invalid | tee .labex/local-invalid.json
Seul le client émetteur reçoit une erreur avec le code invalid_message ; peerErrors reste égal à 0. Les historiques before et after sont identiques et contiennent un message. Une entrée client invalide ne peut donc ni ajouter une ligne, ni faire avancer la séquence, ni transformer une erreur en diffusion à toute la salle.
Lisez directement l'état des deux salles :
curl --silent --fail http://127.0.0.1:8787/rooms/planning/state | jq
curl --silent --fail http://127.0.0.1:8787/rooms/support/state | jq
La première réponse indique un message et la seconde n'en indique aucun. Les lectures d'état HTTP restent la source de vérité, même si un client se déconnecte après le test.
Déployer et tester les clients WebSocket dans le cloud
Dans cette étape, vous allez arrêter l'exécution locale, déployer le même code et répéter le contrat à trois clients via Cloudflare.
Arrêtez uniquement la tâche locale enregistrée précédemment, puis effectuez le déploiement :
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npx wrangler deploy | tee .labex/deploy.log
APP_URL="$(grep -Eo 'https://[^ ]+\.workers\.dev' .labex/deploy.log | tail -1)"
test -n "$APP_URL"
printf '%s\n' "$APP_URL" | tee .labex/app-url
Le déploiement crée d'abord le Worker et met à jour l'espace de noms RoomBroadcast. Une page d'accueil accessible ne suffit pas à prouver que la route avec état est prête. Interrogez donc une salle vide sans effet de bord afin de vérifier le contrat JSON exact, puis attendez brièvement que le déploiement se stabilise :
for attempt in $(seq 1 30); do
READY="$(curl --silent --show-error "$APP_URL/rooms/cloud-observer/state" || true)"
test "$(printf '%s' "$READY" | jq -r '.messageCount // -1' 2>/dev/null)" = 0 && break
sleep 2
done
test "$(printf '%s' "$READY" | jq -r .messageCount)" = 0
sleep 5
Exécutez le même client WebSocket réel sur des salles cloud uniques :
node tools/room-clients.mjs "$APP_URL" cloud-planning cloud-support broadcast | tee .labex/cloud-broadcast.json
node tools/room-clients.mjs "$APP_URL" cloud-planning cloud-support invalid | tee .labex/cloud-invalid.json
La sortie cloud doit présenter le même comportement qu'en développement local : deux clients planning reçoivent la séquence 1, le client support ne reçoit aucune mise à jour et l'entrée malformée ne modifie pas l'historique.
Ouvrez APP_URL dans un navigateur. Choisissez Connect three clients, puis Send planning update. Les clients A et B doivent afficher la même nouvelle update, tandis que le client C ne doit afficher que son message ready. Choisissez Send malformed update et vérifiez que l'erreur apparaît uniquement pour le client A. Lorsque vous avez fini d'observer le résultat, choisissez Disconnect clients et attendez que les trois cartes affichent Closed ; cela termine la procédure de fermeture WebSocket avant que vous ne quittiez la page. Cette page est un client d'observation fourni ; la sonde Node et les vérifications du backend restent les preuves d'acceptation faisant autorité.
Inspecter le client du navigateur et le Durable Object
Dans cette étape, vous allez relier les résultats d'exécution au Cloudflare Dashboard et prouver que l'historique durable de la salle reste présent après un redéploiement sans modification.
Laissez la démonstration du navigateur connectée assez longtemps pour inspecter ses trois cartes. Les deux cartes planning constituent une preuve visible de la diffusion limitée à une salle ; la carte support, qui reste silencieuse, est tout aussi importante, car elle montre ce qui n'a pas franchi la limite d'identité.

Dans le Cloudflare Dashboard, ouvrez Workers & Pages, sélectionnez exactement le nom enregistré dans .labex/run-name et inspectez ses bindings. ROOMS doit pointer vers RoomBroadcast. Ouvrez ensuite Durable Objects, sélectionnez l'espace de noms nommé <your-worker>_RoomBroadcast et confirmez la présence de Storage: SQL dans Overview.


Ouvrez l'onglet Logs de l'espace de noms. Choisissez une ligne récente réussie associée au navigateur ou à la sonde Node. Un message applicatif structuré room_update indique sa séquence et le nombre actuel de connexions, sans enregistrer le texte du message. Le Dashboard peut fournir les journaux après la requête ; les réponses d'exécution et les vérifications indépendantes restent les sources de vérité.
Redéployez le code sans le modifier. Les connexions WebSocket ouvertes sont un transport actif et leur maintien après un déploiement n'est pas garanti, mais l'historique SQLite appartient à l'objet nommé et doit rester présent :
npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
curl --silent --fail "$APP_URL/rooms/cloud-planning/state" | jq
curl --silent --fail "$APP_URL/rooms/cloud-support/state" | jq
La salle planning doit toujours afficher un message avec la séquence 1 ; support doit rester vide. Votre suffixe généré, les horodatages et les totaux de trafic du Dashboard seront différents de ceux des exemples testés.
Supprimer l'espace de noms de la salle
Dans cette étape, vous allez supprimer exactement l'espace de noms Durable Object et le Worker temporaires, puis conserver l'autorisation de la VM assez longtemps pour que LabEx vérifie l'absence des deux ressources.
Vérifiez que le nom enregistré commence par labex-c10-o05-. Créez un point d'entrée de nettoyage sans état :
RUN="$(cat .labex/run-name)"
case "$RUN" in labex-c10-o05-*) ;; *) echo "Unexpected Worker name" >&2; exit 1;; esac
cat > src/cleanup.js <<'JS'
export default {
fetch() {
return Response.json({ status: "cleanup" }, { status: 410 });
}
};
JS
Créez une configuration de nettoyage pour le même Worker et le même compte. Le marqueur state: "deleted" supprime uniquement l'espace de noms de classe de ce lab, y compris ses historiques temporaires :
ACCOUNT_ID="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).account_id)')"
cat > wrangler.cleanup.jsonc <<JSON
{
"\$schema": "./node_modules/wrangler/config-schema.json",
"name": "$RUN",
"account_id": "$ACCOUNT_ID",
"main": "src/cleanup.js",
"compatibility_date": "2026-09-18",
"workers_dev": true,
"preview_urls": false,
"exports": {
"RoomBroadcast": { "type": "durable-object", "state": "deleted" }
}
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc
La sortie de mise à jour doit indiquer Deleted: RoomBroadcast. Supprimez le Worker sans état restant. Wrangler demande une confirmation, car la suppression est irréversible ; confirmez uniquement lorsque le nom affiché correspond exactement à la valeur de $RUN :
npx wrangler delete --config wrangler.cleanup.jsonc
À l'invite, saisissez y, puis appuyez sur Entrée. La commande doit se terminer par Successfully deleted, suivi du nom de Worker généré.
Laissez cette VM autorisée pour la vérification à la fin de cette étape. Confirmez que Wrangler indique toujours une session authentifiée :
npx wrangler whoami --json | jq '{loggedIn, authType}'
Le JSON doit contenir "loggedIn": true. LabEx peut maintenant interroger le compte sélectionné et prouver que le Worker ainsi que son espace de noms Durable Object ont disparu. Une erreur réseau ou d'authentification ne constitue pas une preuve de nettoyage.
Révoquer l'autorisation Wrangler de cette VM
Dans cette étape, vous allez révoquer l'autorisation OAuth stockée uniquement sur cette nouvelle VM, après vérification de la suppression des ressources cloud.
wrangler logout supprime l'autorisation locale. La vérification structurée avec whoami --json est importante, car la sortie destinée à l'utilisateur peut être ambiguë ; le champ loggedIn constitue le résultat faisant autorité :
npx wrangler logout
npx wrangler whoami --json
Le JSON final doit contenir "loggedIn": false. Cette action ne supprime pas votre compte d'apprentissage Cloudflare et ne vous en déconnecte pas dans le navigateur ; elle empêche uniquement cette VM d'effectuer d'autres requêtes Wrangler authentifiées.
Résumé
Vous avez transformé des requêtes HTTP en WebSockets, acheminé des noms de salles validés vers des Durable Objects indépendants, diffusé une mise à jour approuvée à deux clients de la même salle et isolé une autre salle. Vous avez séparé l'analyse du JSON de la validation applicative, prouvé qu'une entrée malformée ne modifiait ni l'état de diffusion ni l'historique SQLite, répété le comportement sur Cloudflare, inspecté les vues du navigateur et du Dashboard, vérifié l'historique après un redéploiement et supprimé exactement l'espace de noms temporaire.
La règle de conception réutilisable est la suivante : validez avant de sélectionner ou de modifier l'état, coordonnez chaque groupe en temps réel au moyen de sa propre identité d'objet stable et traitez les connexions actives séparément de l'historique applicatif durable.



