Ein Support-Dashboard synchronisieren

CloudflareBeginner
Jetzt üben

Einführung

Ein persistenter Agent kann sich eine Support-Warteschlange merken. Ein nützliches Dashboard muss jedoch außerdem alle verbundenen Ansichten aktuell halten. Beim Polling fordert der Client immer wieder eine neue Kopie vom Server an. Das Cloudflare Agents SDK öffnet stattdessen einen WebSocket: eine langlebige Verbindung in beide Richtungen, über die bei einer Zustandsänderung sofort ein Update an alle Clients desselben benannten Agents gesendet werden kann.

Sie erstellen ein bewusst kleines Dashboard ohne LLM. Zwei unabhängige Vanilla-JavaScript-Clients – Dispatcher und Observer – verbinden sich mit SupportDashboard:planning. Dispatcher ruft eine mit @callable() markierte Servermethode auf. Die Methode validiert das Ticket, aktualisiert den Agent-Zustand genau einmal und das SDK sendet den resultierenden Zustand an beide Clients. Ein ungültiger Titel wird auf dem Server abgelehnt und erhöht die gemeinsame Revision nicht.

In diesem Lab lernen Sie nur die vier Komponenten kennen, die die Anwendung tatsächlich benötigt:

  1. AgentClient verwaltet die WebSocket-Verbindung des Browsers.
  2. onStateUpdate zeichnet die Ansicht neu, nachdem der Server den Zustand verteilt hat.
  3. @callable() stellt eine bestimmte Servermethode für verbundene Clients bereit.
  4. setState() speichert den maßgeblichen nächsten Zustand und löst die Synchronisierung aus.

Das Beispiel verwendet künstliche Support-Texte und einen öffentlichen, kurzlebigen Worker, damit Sie sich auf das Protokoll konzentrieren können. Eingabevalidierung ist keine Benutzerauthentifizierung. Ein produktives Support-Tool muss vor der Bereitstellung von Kundendaten oder Änderungsfunktionen eine Identitäts- und Autorisierungsschicht ergänzen.

Bevor Sie diesen Kurs direkt beginnen, absolvieren Sie Connect LabEx to Your Cloudflare Account. Jede neue LabEx-VM benötigt eine eigene Wrangler-Autorisierung. S01 wird empfohlen, da dieses Lab auf einer benannten Agent-Identität, persistentem Zustand und einer expliziten Bereinigung aufbaut. Kenntnisse über React oder KI-Modelle werden jedoch nicht vorausgesetzt.

Die VM autorisieren und das Dashboard konfigurieren

In diesem Schritt autorisieren Sie die frische VM, bestätigen das vorgesehene Cloudflare-Konto und deklarieren den einen Agent-Namespace, den das Dashboard verwendet.

Wechseln Sie in das vorbereitete Projekt und bestätigen Sie die festgelegten Laufzeitversionen. Das Setup hat die Abhängigkeiten installiert und nur das Gerüst der visuellen Seite bereitgestellt. Cloudflare wurde dadurch weder autorisiert noch der Agent implementiert.

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

Erwartet werden Node.js v22.22.0, Wrangler 4.134.0, Agents SDK 0.23.0, Vite 8.3.0 und das Cloudflare-Vite-Plugin 1.55.0.

Autorisieren Sie diese VM und prüfen Sie die strukturierte Identität:

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

Öffnen Sie den ausgegebenen Link in einem Browser, geben Sie den Kurzcode ein, bestätigen Sie Ihr persönliches Lernkonto und prüfen Sie die Berechtigungen, bevor Sie die Autorisierung bestätigen. Prüfen Sie danach im Terminal, dass loggedIn: true angezeigt wird. Wählen Sie anschließend das Konto anhand seines bestätigten Anzeigenamens aus, ohne dessen ID auszugeben:

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"

Wenn Ihr Lernkonto einen anderen Namen verwendet, ersetzen Sie nach der Bestätigung des richtigen Kontos nur LabEx Learning. Erstellen Sie die Konfiguration:

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

Das Binding wählt den Namespace der Agent-Klasse aus. Der Instanzname wird von jedem Browser-Client übergeben. Die Konfiguration allein erstellt noch keine Cloud-Ressource.

Eine validierte aufrufbare Methode implementieren

In diesem Schritt implementieren Sie den gemeinsamen Warteschlangenzustand und die einzige Mutation, die der Browser aufrufen darf.

Der Server besitzt die Änderungsregel. Ein Browser darf ein Update anfordern, aber nicht selbst entscheiden, ob ein Titel oder eine Priorität gültig ist. Erstellen Sie 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() definiert eine explizite RPC-Grenze: Über das Agent-Client-Protokoll können nur dekorierte Methoden aufgerufen werden. Die Validierung findet vor setState() statt. Abgelehnte Aufrufe können die Revision daher nicht erhöhen. Da nur die sechs neuesten künstlichen Tickets gespeichert werden, bleibt der Demonstrationszustand begrenzt. Das strukturierte Log enthält Instanz, Revision und Anzahl, aber keinen Tickettext.

Zwei Vanilla-Browser-Clients verbinden

In diesem Schritt konfigurieren Sie den aktuellen Decorator-Build-Pfad und verbinden zwei unabhängige Vanilla-Clients mit einem benannten Agent.

Der aktuelle SDK-Decorator verwendet die standardisierte JavaScript-Decorator-Transformation. Ein manuell erstelltes Projekt benötigt daher sowohl das Agents-TypeScript-Preset als auch das Agents-Vite-Plugin. Aktivieren Sie nicht den veralteten TypeScript-Modus experimentalDecorators.

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

Erstellen Sie 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

Es handelt sich um zwei echte WebSocket-Clients, auch wenn sie auf einer Seite angezeigt werden. Beide werden an dieselbe Klasse und denselben Namen weitergeleitet und erhalten daher dieselbe Zustandsübertragung. Nur Dispatcher führt den Aufruf aus. Observer zeigt, dass die Synchronisierung vom Server gesteuert wird und nicht aus einer kopierten DOM-Aktualisierung stammt.

Typen generieren und beide Seiten erstellen

In diesem Schritt prüfen Sie den gemeinsamen Zustandsvertrag statisch und erstellen Worker sowie Browseranwendung, bevor Sie eine Laufzeit starten.

Generieren Sie die Umgebungstypen aus der exakten Binding-Konfiguration:

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

Führen Sie TypeScript für den Worker, den Browser-Client und die Vite-Konfiguration aus:

npm run check

Wenn keine Compilerdiagnosen erscheinen, stimmen Zustandsform, aufrufbare Servermethode und DOM-Client überein. Erstellen Sie die beiden Produktionsziele:

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

Vite meldet eine Worker-Umgebung und eine Client-Umgebung. Das Cloudflare-Plugin erstellt das Worker-Bundle und bindet die erstellte statische Seite ein. Das Agents-Plugin wendet die aktuelle Decorator-Transformation an. Ein erfolgreicher Build bestätigt die Paketierung, aber noch nicht das WebSocket-Verhalten, den Kontobesitz oder eine entfernte Bereitstellung.

Lokale Synchronisierung und Ablehnung beobachten

In diesem Schritt beobachten Sie, wie zwei lokale Clients nach einem gültigen Update zusammenlaufen und sich nach einem ungültigen Update nicht verändern.

Starten Sie die lokale Vite- und Workers-Laufzeit als Hintergrundprozess:

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

Öffnen Sie http://localhost:5173 im Browser innerhalb des LabEx-Desktops. Warten Sie, bis der grüne Status anzeigt, dass beide Clients verbunden sind. Beide Karten beginnen mit Revision 0 und ohne Tickets.

Behalten Sie den vorbereiteten Titel bei und klicken Sie auf Add with Dispatcher. Beide Karten sollten auf Revision 1 wechseln und dasselbe Ticket anzeigen. Dispatcher sendet zunächst einen RPC-Frame über seinen WebSocket. addTicket() validiert die Argumente im Agent. Anschließend speichert setState(next) die Revision 1 und verteilt sie. Beide onStateUpdate-Handler zeichnen ihre Karten unabhängig voneinander neu.

Ersetzen Sie nun den Titel durch x und senden Sie das Formular erneut ab. Die Seite zeigt title must contain 3-80 characters; beide Karten bleiben auf Revision 1. Das ist ein hilfreicher Nachweis dafür, dass die Validierung vor dem Schreiben des Zustands stattgefunden hat.

Führen Sie die unabhängige lokale Prüfung aus:

python3 .labex/verify.py local

Der Verifizierer verwendet frische, für diesen Durchlauf eindeutige Namen, statt dem sichtbaren Beispiel zu vertrauen. Er öffnet zwei Clients, weist deren Zusammenlaufen nach, prüft, dass ein anderer Name auf Revision null bleibt, sendet ein ungültiges Update und bestätigt, dass sich die gemeinsame Revision nicht ändert.

Bereitstellen und das Cloud-Dashboard prüfen

In diesem Schritt stellen Sie das Produktions-Bundle bereit, weisen denselben Zwei-Client-Vertrag auf Cloudflare nach und verknüpfen dieses Verhalten mit Nachweisen im Dashboard.

Beenden Sie den exakt gestarteten lokalen Prozess und stellen Sie den Produktions-Build bereit:

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

Wrangler wendet die Migration v1 an, lädt den Worker zusammen mit dem statischen Client hoch und gibt eine workers.dev-URL aus. Speichern Sie genau diese URL:

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

Öffnen Sie die URL im integrierten Browser. Fügen Sie Cloud dashboard ticket mit der Priorität Urgent hinzu. Beide Karten sollten dieselbe Revision und eine rote Markierung für „urgent“ anzeigen. Senden Sie anschließend x. Die Ablehnung wird angezeigt, während beide Revisionen unverändert bleiben. Dies sind neue, der Cloud zugeordnete Agent-Instanzen. Der lokale Vite-Zustand ist absichtlich getrennt.

Beide Cloud-Clients zeigen dasselbe dringende Ticket bei Revision eins

In diesem tatsächlichen Testlauf führte Dispatcher die Änderung aus, während Observer dieselbe Übertragung empfing. Tickettext und Revision sind Beispiele aus der kurzlebigen Kursressource; Ihre eigenen Werte können abweichen.

Ein kurzer Titel wird abgelehnt, während beide Clients auf Revision eins bleiben

Der Fehler erscheint neben dem Eingabefeld, aber keine der beiden Karten wechselt die Revision. Lesen Sie die unveränderte Revision auf beiden Karten als entscheidenden Hinweis: Der Server hat das Argument abgelehnt, bevor setState() aufgerufen wurde.

Öffnen Sie Workers & Pages im Cloudflare-Dashboard und wählen Sie Ihren exakten Worker labex-c11-s02-... aus. Bestätigen Sie auf der Registerkarte Bindings, dass SupportDashboard auf die Durable-Object-Klasse SupportDashboard verweist. Prüfen Sie unter Durable Objects, dass der Namespace SQL-Speicher verwendet. Öffnen Sie anschließend Observability → Logs, filtern Sie nach support_queue_updated und erweitern Sie ein Ereignis. Vergleichen Sie die Instanz planning, die Revision und die Ticketanzahl. Der Tickettext fehlt absichtlich.

Worker-Übersicht mit dem kurzlebigen Worker, der Domain, dem Binding und null Fehlern

Die Übersicht verbindet mehrere zuvor getrennt verwendete Konzepte: Die workers.dev-Domain erreicht den Worker, das Binding verbindet ihn mit dem persistenten Zustand und der Fehlerzähler null ist ein schnelles Gesundheitssignal. Der in diesem Screenshot angezeigte Workername stammt aus einem akzeptierten Testlauf.

Bindings-Ansicht, die den Worker mit dem SupportDashboard-Durable-Object verbindet

Der Binding-Graph sollte Ihren exakten Worker mit einem Durable Object namens SupportDashboard verbinden. Dies ist ein Konfigurationsnachweis und ersetzt nicht die Prüfung des Verhaltens mit zwei Clients.

Übersicht des SupportDashboard-Namespaces mit SQL-Speicher

Die Namespace-Seite identifiziert den persistenten Speicher hinter der Agent-Klasse und meldet Storage: SQL. Die undurchsichtige Namespace-ID ist im Lehrbild aus Datenschutzgründen ausgeblendet. Sie müssen sie nicht kopieren.

Strukturiertes support_queue_updated-Ereignis mit begrenzten Feldern

Das erweiterte Ereignis enthält den künstlichen Instanznamen, die Revision und die Ticketanzahl, aber nicht den Tickettext. Diese Datenminimierung ist beabsichtigt: Logs sollen bei der Diagnose helfen, ohne möglicherweise sensible Benutzerinhalte zu kopieren.

Dashboard-Daten können verspätet eintreffen. Eine leere Ansicht der letzten Logs ist daher nicht aussagekräftig. Die authentifizierten Einstellungen, der Namespace in Ihrem Besitz und die unabhängigen Live-Prüfungen mit AgentClient sind maßgeblich.

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

Die erste Prüfung erstellt neue Namen auf Cloudflare und weist Synchronisierung, Isolation und Ablehnung nach, ohne dem sichtbaren Beispiel planning zu vertrauen. Die zweite Prüfung hält die exakt zu Ihrem Konto gehörenden Ressourcen für Ihre schreibgeschützte Dashboard-Prüfung verfügbar.

Den Dashboard-Namespace und den Worker entfernen

In diesem Schritt löschen Sie ausdrücklich den Namespace der Agent-Klasse und anschließend den verbleibenden Worker, während die VM noch autorisiert ist.

Die Warteschlange wird im Namespace der Durable-Object-Klasse gespeichert. Löschen Sie diese Klasse daher ausdrücklich, bevor Sie den verbleibenden zustandslosen Worker entfernen. Erstellen Sie einen Cleanup-Einstiegspunkt:

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

Behalten Sie die ursprüngliche Migration bei und fügen Sie v2 für die Löschung hinzu:

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

Die Migrationshistorie ist nur erweiterbar: Wenn Sie v1 umschreiben würden, würde die Datei den bereits auf Cloudflare angewendeten Übergang nicht mehr beschreiben. Bestätigen Sie im Dashboard, dass der exakte Worker und sein SupportDashboard-Namespace nicht mehr vorhanden sind. Lassen Sie nicht zu diesem Lab gehörende Ressourcen bestehen, falls Ihr Konto weitere Ressourcen enthält.

Workers-&-Pages-Übersicht, nachdem der kurzlebige Worker entfernt wurde

Das getestete Konto kehrte nach der Löschung zur Übersicht von Workers & Pages zurück. Ihr Lernkonto kann andere Worker enthalten. Prüfen Sie daher, dass genau der Name labex-c11-s02-... verschwunden ist, statt ein leeres Konto zu erwarten.

Durable-Objects-Übersicht, nachdem der SupportDashboard-Namespace entfernt wurde

Auch das akzeptierte Testkonto kehrte zu einer leeren Durable-Objects-Übersicht zurück. Bewahren Sie in einem Konto mit weiteren Namespaces diese auf und bestätigen Sie, dass nur der von diesem Lab erstellte Namespace entfernt wurde.

Die Autorisierung dieser VM widerrufen

In diesem Schritt entfernen Sie die OAuth-Autorisierung, die nur in dieser kurzlebigen VM gespeichert ist, und prüfen den strukturierten Status „abgemeldet“.

Die Cloud-Bereinigung ist abgeschlossen, aber diese kurzlebige VM enthält weiterhin ihre lokale OAuth-Berechtigung. Entfernen Sie sie und fordern Sie den strukturierten Status an:

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

Das JSON muss ausdrücklich "loggedIn": false enthalten. Ein Netzwerkfehler ist kein Nachweis für eine Abmeldung. Wiederholen Sie die Statusabfrage, sobald die Verbindung wieder verfügbar ist. Das öffentliche künstliche Dashboard, sein persistenter Zustand und die Autorisierung dieser VM sind nun vollständig entfernt.

Zusammenfassung

Sie haben aus einem persistenten, benannten Agent eine echte Browseranwendung in Echtzeit gemacht, ohne React oder ein Sprachmodell einzuführen. Zwei AgentClient-Verbindungen wählten SupportDashboard:planning aus. Eine validierte @callable()-Methode verwaltete die Änderung, setState() speicherte eine maßgebliche Revision und das SDK verteilte diesen Zustand an beide onStateUpdate-Handler.

Außerdem haben Sie gelernt, warum der aktuelle Decorator-Pfad sowohl agents/tsconfig als auch agents/vite benötigt. Sie haben einen WebSocket-RPC von direkten Zustandsänderungen im Client unterschieden, nachgewiesen, dass ungültige Eingaben keine Wirkung haben, Synchronisierung und Namensisolation auf Cloudflare wiederholt, datenschutzbegrenzte Nachweise geprüft und Namespace der Klasse, Worker sowie die Autorisierung der VM ausdrücklich entfernt.