Synchroniser un tableau de bord de support

CloudflareBeginner
Pratiquer maintenant

Introduction

Un Agent durable peut mémoriser une file de support, mais un tableau de bord utile doit également maintenir à jour chaque écran connecté. L’interrogation périodique demande sans cesse au serveur une nouvelle copie. Le SDK Cloudflare Agents ouvre plutôt un WebSocket : une connexion bidirectionnelle de longue durée capable d’envoyer une mise à jour à tous les clients du même Agent nommé dès que l’état change.

Vous allez créer un tableau de bord volontairement réduit, sans LLM. Deux clients JavaScript vanilla indépendants — Dispatcher et Observer — se connectent à SupportDashboard:planning. Dispatcher appelle une méthode serveur marquée @callable(). Cette méthode valide le ticket, met à jour l’état de l’Agent une seule fois, puis le SDK diffuse l’état obtenu aux deux clients. Un titre non valide est rejeté par le serveur et ne fait pas avancer la révision partagée.

Ce laboratoire présente uniquement quatre éléments, au moment où l’application en a besoin :

  1. AgentClient maintient la connexion WebSocket du navigateur.
  2. onStateUpdate redessine la vue après la diffusion de l’état par le serveur.
  3. @callable() expose une méthode serveur précise aux clients connectés.
  4. setState() conserve un état suivant faisant autorité et déclenche la synchronisation.

L’exemple utilise un texte de support synthétique et un Worker public temporaire afin que vous puissiez vous concentrer sur le protocole. La validation des entrées ne constitue pas une authentification des utilisateurs. Un outil de support destiné à la production doit ajouter une couche d’identité et d’autorisation avant d’exposer des données client ou des opérations de modification.

Avant d’accéder directement à ce cours, terminez Connect LabEx to Your Cloudflare Account. Chaque nouvelle VM LabEx nécessite sa propre autorisation Wrangler. S01 est recommandé, car ce laboratoire s’appuie sur une identité d’Agent nommée, un état durable et un nettoyage explicite, mais aucune connaissance de React ou des modèles d’IA n’est requise.

Autoriser la VM et configurer le tableau de bord

Dans cette étape, vous allez autoriser la nouvelle VM, confirmer le compte Cloudflare prévu et déclarer l’unique espace de noms d’Agent utilisé par le tableau de bord.

Accédez au projet préparé et confirmez les versions d’exécution verrouillées. La configuration a installé les dépendances et fourni uniquement la structure visuelle de la page ; elle n’a pas autorisé Cloudflare ni implémenté l’Agent.

cd /home/labex/project/support-dashboard-agent
node --version
npx wrangler --version
npm list agents vite @cloudflare/vite-plugin --depth=0

Vous devez obtenir Node.js v22.22.0, Wrangler 4.134.0, le SDK Agents 0.23.0, Vite 8.3.0 et le plugin Vite Cloudflare 1.55.0.

Autorisez cette VM et inspectez l’identité structurée :

npx wrangler login --device --browser=false
npx wrangler whoami --json

Ouvrez le lien affiché dans un navigateur, saisissez le code court, confirmez qu’il s’agit bien de votre compte d’apprentissage dédié et vérifiez les autorisations avant d’autoriser l’accès. De retour dans le terminal, confirmez loggedIn: true, puis sélectionnez le compte à l’aide de son nom d’affichage confirmé, sans afficher son ID :

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-c11-s02-$(openssl rand -hex 6)"
printf '%s\n' "$RUN"

Si votre compte d’apprentissage porte un autre nom, remplacez uniquement LabEx Learning après avoir confirmé qu’il s’agit du compte voulu. Créez la configuration :

cat > wrangler.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/server.ts",
  "compatibility_date": "2026-09-18",
  "compatibility_flags": ["nodejs_compat"],
  "workers_dev": true,
  "preview_urls": false,
  "observability": { "enabled": true },
  "durable_objects": {
    "bindings": [
      { "name": "SupportDashboard", "class_name": "SupportDashboard" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["SupportDashboard"] }
  ]
}
JSON

Le binding sélectionne l’espace de noms de la classe Agent ; le nom de l’instance sera fourni par chaque client de navigateur. La configuration seule ne crée aucune ressource cloud.

Implémenter une méthode callable validée

Dans cette étape, vous allez implémenter l’état partagé de la file et l’unique opération de modification appelable depuis le navigateur.

Le serveur est responsable de la règle de modification. Un navigateur peut demander une mise à jour, mais il ne doit pas décider si un titre ou une priorité est valide. Créez src/server.ts :

cat > src/server.ts <<'TS'
import { Agent, callable, routeAgentRequest } from "agents";

type Priority = "normal" | "urgent";
type Ticket = {
  id: number;
  title: string;
  priority: Priority;
};

export type DashboardState = {
  tickets: Ticket[];
  revision: number;
  lastUpdatedBy: string;
};

interface Env {
  SupportDashboard: DurableObjectNamespace<SupportDashboard>;
}

export class SupportDashboard extends Agent<Env, DashboardState> {
  initialState: DashboardState = {
    tickets: [],
    revision: 0,
    lastUpdatedBy: "system"
  };

  @callable()
  addTicket(titleInput: string, priorityInput: string): DashboardState {
    const title = typeof titleInput === "string" ? titleInput.trim() : "";
    if (title.length < 3 || title.length > 80) {
      throw new Error("title must contain 3-80 characters");
    }
    if (priorityInput !== "normal" && priorityInput !== "urgent") {
      throw new Error("priority must be normal or urgent");
    }
    const priority: Priority = priorityInput;
    const next: DashboardState = {
      tickets: [
        ...this.state.tickets,
        { id: this.state.revision + 1, title, priority }
      ].slice(-6),
      revision: this.state.revision + 1,
      lastUpdatedBy: "dispatcher"
    };
    this.setState(next);
    console.log(JSON.stringify({
      event: "support_queue_updated",
      instance: this.name,
      revision: next.revision,
      ticketCount: next.tickets.length
    }));
    return next;
  }
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    return (await routeAgentRequest(request, env)) ??
      new Response("Not found", { status: 404 });
  }
};
TS

@callable() constitue une limite RPC explicite : seules les méthodes décorées peuvent être appelées via le protocole client de l’Agent. La validation a lieu avant setState(), de sorte que les appels rejetés ne peuvent pas faire avancer la révision. La conservation des six derniers tickets synthétiques limite la taille de l’état de démonstration. Le journal structuré contient l’instance, la révision et le nombre de tickets, mais aucun texte de ticket.

Connecter deux clients de navigateur vanilla

Dans cette étape, vous allez configurer le chemin de compilation actuel des décorateurs et connecter deux clients vanilla indépendants à un même Agent nommé.

Les décorateurs du SDK actuel utilisent la transformation standard des décorateurs JavaScript. Un projet créé manuellement a donc besoin à la fois du préréglage TypeScript d’Agents et du plugin Vite d’Agents. N’activez pas le mode historique experimentalDecorators de TypeScript.

cat > tsconfig.json <<'JSON'
{
  "extends": "agents/tsconfig",
  "compilerOptions": {
    "noEmit": true
  },
  "include": [
    "src/**/*.ts",
    "vite.config.ts",
    "worker-configuration.d.ts"
  ]
}
JSON

cat > vite.config.ts <<'TS'
import { cloudflare } from "@cloudflare/vite-plugin";
import agents from "agents/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [agents(), cloudflare()]
});
TS

Créez src/client.ts :

cat > src/client.ts <<'TS'
import { AgentClient } from "agents/client";
import type { DashboardState } from "./server";

function required<T>(selector: string): T {
  const element = document.querySelector(selector);
  if (!element) throw new Error(`Missing page element: ${selector}`);
  return element as unknown as T;
}

const dispatcherView = required<HTMLDivElement>("#dispatcher");
const observerView = required<HTMLDivElement>("#observer");
const statusView = required<HTMLParagraphElement>("#status");
const errorView = required<HTMLParagraphElement>("#error");
const titleInput = required<HTMLInputElement>("#title");
const priorityInput = required<HTMLSelectElement>("#priority");
const form = required<HTMLFormElement>("#ticket-form");

function render(target: HTMLDivElement, state: DashboardState | undefined) {
  if (!state) {
    target.innerHTML = '<p class="empty">Waiting for initial state…</p>';
    return;
  }
  const tickets = state.tickets.map((ticket) =>
    `<div class="ticket ${ticket.priority}"><strong>#${ticket.id}</strong> ${ticket.title}<br><small>${ticket.priority}</small></div>`
  ).join("");
  target.innerHTML = `<span class="revision">Revision ${state.revision}</span>${tickets || '<p class="empty">No tickets yet</p>'}`;
}

const shared = {
  agent: "SupportDashboard",
  name: "planning",
  host: window.location.host
};

const dispatcher = new AgentClient<DashboardState>({
  ...shared,
  onStateUpdate: (state) => render(dispatcherView, state)
});
const observer = new AgentClient<DashboardState>({
  ...shared,
  onStateUpdate: (state) => render(observerView, state)
});

Promise.all([dispatcher.ready, observer.ready]).then(() => {
  render(dispatcherView, dispatcher.state);
  render(observerView, observer.state);
  statusView.textContent = "Both clients are connected to SupportDashboard:planning";
});

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  errorView.textContent = "";
  try {
    await dispatcher.call("addTicket", [titleInput.value, priorityInput.value]);
  } catch (cause) {
    errorView.textContent = cause instanceof Error ? cause.message : String(cause);
  }
});
TS

Il s’agit de deux véritables clients WebSocket, même s’ils apparaissent sur une seule page. Tous deux utilisent la même classe et le même nom ; ils reçoivent donc la même diffusion d’état. Seul Dispatcher effectue l’appel ; Observer montre que la synchronisation est pilotée par le serveur et ne consiste pas en une simple copie du DOM.

Générer les types et construire les deux parties

Dans cette étape, vous allez vérifier le contrat d’état partagé et construire le Worker ainsi que l’application du navigateur avant de démarrer une exécution.

Générez les types d’environnement à partir de la configuration exacte du binding :

npx wrangler types
grep -n "SupportDashboard" worker-configuration.d.ts | head

Lancez TypeScript sur le Worker, le client du navigateur et la configuration Vite :

npm run check

L’absence de diagnostics du compilateur signifie que la forme de l’état, le serveur callable et le client DOM sont compatibles. Construisez les deux cibles de production :

npm run build
find dist -maxdepth 3 -type f | sort | sed -n '1,16p'

Vite signale un environnement Worker et un environnement client. Le plugin Cloudflare produit le bundle du Worker et lui associe la page statique construite ; le plugin Agents applique la transformation actuelle des décorateurs. Une construction réussie prouve la préparation du paquet, mais pas le fonctionnement des WebSocket, la propriété du compte ni le déploiement distant.

Observer la synchronisation locale et le rejet

Dans cette étape, vous allez observer deux clients locaux converger après une mise à jour valide, puis rester inchangés après une mise à jour non valide.

Démarrez l’environnement d’exécution local Vite et Workers en tâche de fond :

CI=true npm run dev > .labex/dev.log 2>&1 < /dev/null &
echo $! > .labex/dev.pid
for attempt in $(seq 1 40); do
  if curl --silent --fail http://127.0.0.1:5173/ > /dev/null; then
    break
  fi
  sleep 1
done
curl --silent --head http://127.0.0.1:5173/ | head

Ouvrez http://localhost:5173 dans le navigateur de l’environnement de bureau LabEx. Attendez que le statut vert indique que les deux clients sont connectés. Les deux cartes commencent à la révision 0, sans ticket.

Conservez le titre préparé et cliquez sur Add with Dispatcher. Les deux cartes doivent passer à la révision 1 et afficher le même ticket. Dispatcher envoie d’abord une trame RPC sur son WebSocket. addTicket() valide les arguments dans l’Agent, puis setState(next) conserve la révision 1 et la diffuse. Les deux gestionnaires onStateUpdate redessinent chacun leur carte.

Remplacez maintenant le titre par x et envoyez de nouveau le formulaire. La page affiche title must contain 3-80 characters ; les deux cartes restent à la révision 1. Cela montre que la validation a eu lieu avant l’écriture de l’état.

Exécutez la vérification locale indépendante :

python3 .labex/verify.py local

Le vérificateur utilise des noms nouveaux et propres à chaque exécution au lieu de faire confiance à l’exemple visible. Il ouvre deux clients, prouve leur convergence, vérifie qu’un autre nom reste à la révision zéro, envoie une mise à jour non valide et confirme que la révision partagée ne change pas.

Déployer et inspecter le tableau de bord cloud

Dans cette étape, vous allez déployer le bundle de production, vérifier le même contrat à deux clients sur Cloudflare et relier ce comportement aux éléments visibles dans le Dashboard.

Arrêtez le processus local exact et déployez la construction de production :

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npm run deploy

Wrangler applique la migration v1, téléverse le Worker ainsi que le client statique et affiche une URL workers.dev. Enregistrez cette URL exacte :

WORKER_URL="https://YOUR_WORKER_URL"
for attempt in $(seq 1 30); do
  if curl --silent --fail "$WORKER_URL/" > /dev/null; then
    break
  fi
  sleep 2
done

Ouvrez l’URL dans le navigateur intégré. Ajoutez Cloud dashboard ticket avec la priorité Urgent. Les deux cartes doivent afficher la même révision et le même marqueur rouge urgent. Envoyez ensuite x ; le rejet apparaît tandis que les deux révisions restent inchangées. Il s’agit de nouvelles instances d’Agent gérées par le cloud ; l’état Vite local est volontairement séparé.

Les deux clients cloud affichent le même ticket urgent à la révision un

Lors de ce test réel, Dispatcher a effectué l’écriture tandis qu’Observer a reçu la même diffusion. Le texte du ticket et la révision sont des exemples issus de la ressource temporaire du cours ; vos propres valeurs peuvent différer.

Un titre court est rejeté tandis que les deux clients restent à la révision un

L’erreur apparaît à côté du champ de saisie, mais aucune carte n’avance. Lisez la révision inchangée sur les deux cartes comme l’indice important : le serveur a rejeté l’argument avant d’appeler setState().

Ouvrez Workers & Pages dans le Dashboard Cloudflare et sélectionnez votre Worker labex-c11-s02-... exact. Utilisez l’onglet Bindings pour confirmer que SupportDashboard pointe vers la classe Durable Object SupportDashboard. Dans Durable Objects, confirmez que son espace de noms utilise le stockage SQL. Ouvrez enfin Observability → Logs, filtrez sur support_queue_updated et développez un événement. Faites correspondre son instance planning, sa révision et son nombre de tickets ; le titre du ticket est volontairement absent.

Vue d’ensemble du Worker temporaire, du domaine, du binding et de zéro erreur

La vue d’ensemble réunit plusieurs notions utilisées séparément : le domaine workers.dev atteint le Worker, le binding le relie à l’état durable et le compteur d’erreurs nul fournit un indicateur rapide de bon fonctionnement. Le nom du Worker généré dans cette capture appartient à une exécution de test acceptée.

Vue Bindings reliant le Worker au Durable Object SupportDashboard

Le graphe des bindings doit relier votre Worker exact à un Durable Object nommé SupportDashboard. Il s’agit d’un élément de configuration ; il ne remplace pas la vérification du comportement à deux clients.

Vue d’ensemble de l’espace de noms SupportDashboard indiquant le stockage SQL

La page de l’espace de noms identifie le stockage durable situé derrière la classe Agent et indique Storage: SQL. L’ID opaque de l’espace de noms est masqué dans l’image pédagogique pour des raisons de confidentialité ; vous n’avez jamais besoin de le copier.

Événement structuré support_queue_updated avec des champs limités

L’événement développé contient le nom d’instance synthétique, la révision et le nombre de tickets, mais pas le titre du ticket. Cette limitation est volontaire : les journaux doivent aider à diagnostiquer le comportement sans recopier un contenu utilisateur potentiellement sensible.

Les données du Dashboard peuvent arriver avec retard ; une vue vide des journaux récents ne permet donc pas de conclure. Les paramètres authentifiés, l’espace de noms détenu et les vérifications indépendantes en direct avec AgentClient font foi.

python3 .labex/verify.py deployed
python3 .labex/verify.py observed

La première vérification crée de nouveaux noms distants et prouve la synchronisation, l’isolation et le rejet sans faire confiance à l’exemple visible planning. La seconde conserve les ressources exactes détenues afin que vous puissiez les inspecter en lecture seule dans le Dashboard.

Supprimer l’espace de noms et le Worker du tableau de bord

Dans cette étape, vous allez supprimer explicitement l’espace de noms de la classe Agent, puis supprimer le Worker restant tant que la VM est encore autorisée.

La file est stockée dans l’espace de noms de la classe Durable Object. Supprimez donc explicitement cette classe avant de supprimer le Worker sans état restant. Créez un point d’entrée de nettoyage :

cat > src/cleanup.ts <<'TS'
export default {
  fetch() {
    return Response.json({ status: "cleanup" }, { status: 410 });
  }
};
TS

Conservez la migration originale et ajoutez v2 pour la suppression :

RUN="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).name)')"
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.ts",
  "compatibility_date": "2026-09-18",
  "compatibility_flags": ["nodejs_compat"],
  "workers_dev": true,
  "preview_urls": false,
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["SupportDashboard"] },
    { "tag": "v2", "deleted_classes": ["SupportDashboard"] }
  ]
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc --force
python3 .labex/verify.py deleted

L’historique des migrations est append-only : réécrire v1 ne décrirait pas la transition déjà appliquée dans Cloudflare. Dans le Dashboard, confirmez que le Worker exact et son espace de noms SupportDashboard ont disparu. Préservez les ressources sans rapport avec ce laboratoire si votre compte en contient.

Vue Workers and Pages après la suppression du Worker temporaire

Le compte testé est revenu à sa vue d’ensemble Workers & Pages après la suppression. Votre compte d’apprentissage peut contenir des Workers sans rapport avec ce laboratoire ; vérifiez donc que le nom exact labex-c11-s02-... a disparu au lieu d’attendre un compte vide.

Vue d’ensemble Durable Objects après la suppression de l’espace de noms SupportDashboard

Le compte de test accepté est également revenu à une vue d’ensemble Durable Objects vide. Si le compte contient d’autres espaces de noms, préservez-les et confirmez que seul celui créé par ce laboratoire a disparu.

Révoquer l’autorisation de cette VM

Dans cette étape, vous allez supprimer l’autorisation OAuth stockée uniquement dans cette VM temporaire et vérifier l’état structuré de déconnexion.

Le nettoyage cloud est terminé, mais cette VM temporaire conserve encore son autorisation OAuth locale. Supprimez-la et demandez l’état structuré :

npx wrangler logout
npx wrangler whoami --json
python3 .labex/verify.py logout

Le JSON doit contenir explicitement "loggedIn": false. Une erreur réseau ne prouve pas la déconnexion ; réessayez la lecture de l’état lorsque la connectivité sera rétablie. Le tableau de bord public synthétique, son état durable et l’autorisation de cette VM sont maintenant tous supprimés.

Résumé

Vous avez transformé un Agent nommé et durable en application de navigateur en temps réel, sans introduire React ni modèle de langage. Deux connexions AgentClient ont sélectionné SupportDashboard:planning, une méthode @callable() validée a pris en charge la modification, setState() a conservé une révision faisant autorité et le SDK a diffusé cet état aux deux gestionnaires onStateUpdate.

Vous avez également appris pourquoi le chemin actuel des décorateurs nécessite à la fois agents/tsconfig et agents/vite, distingué un RPC WebSocket de modifications directes de l’état client, prouvé qu’une entrée rejetée n’a aucun effet, reproduit la synchronisation et l’isolation des noms sur Cloudflare, inspecté des éléments de preuve limités pour protéger la confidentialité, puis supprimé explicitement l’espace de noms de la classe, le Worker et l’autorisation de la VM.