サポートダッシュボードを同期する

CloudflareBeginner
オンラインで実践に進む

はじめに

durable Agent はサポートキューを記憶できます。ただし、実用的なダッシュボードでは、接続中のすべての画面を常に最新の状態に保つ必要があります。ポーリングでは、サーバーに新しいコピーを何度も要求します。一方、Cloudflare Agents SDK は WebSocket を開きます。WebSocket は、状態が変化すると同じ名前の Agent に接続しているすべてのクライアントへ更新をすぐに送信できる、長時間維持される双方向接続です。

この実験では、意図的に小さくした LLM を使わないダッシュボードを構築します。2 つの独立したバニラ JavaScript クライアント、DispatcherObserverSupportDashboard:planning に接続します。Dispatcher は @callable() が付いたサーバーメソッドを呼び出します。このメソッドはチケットを検証して Agent の状態を 1 回更新し、SDK はその結果の状態を両方のクライアントへブロードキャストします。タイトルが無効な場合はサーバーで拒否され、共有リビジョンは進みません。

この実験では、アプリケーションに必要な次の 4 つの要素だけを扱います。

  1. AgentClient はブラウザの WebSocket 接続を維持します。
  2. onStateUpdate は、サーバーが状態をブロードキャストした後に画面を再描画します。
  3. @callable() は、接続中のクライアントから呼び出せる特定のサーバーメソッドを公開します。
  4. setState() は、正規の次の状態を 1 つ保存し、同期を開始します。

この例では合成したサポートテキストと、公開された使い捨て用 Worker を使用します。目的はプロトコルに集中することです。入力検証はユーザー認証ではありません。実際のサポートツールで顧客データや変更操作を公開する前に、ID と認可のレイヤーを追加する必要があります。

このコースを直接開始する前に、LabEx を Cloudflare アカウントに接続するを完了してください。 新しい LabEx VM ごとに、独自の Wrangler 認証が必要です。S01 を推奨します。この実験は名前付き Agent の ID、durable state、明示的なクリーンアップを基にしていますが、React や AI モデルの知識は必要ありません。

VM を認証してダッシュボードを設定する

このステップでは、新しい VM を認証し、対象の Cloudflare アカウントを確認して、ダッシュボードで使用する 1 つの Agent namespace を宣言します。

準備済みのプロジェクトへ移動し、固定されたランタイムを確認します。セットアップでは依存関係をインストールし、画面の外枠だけを用意しています。Cloudflare の認証や Agent の実装はまだ行っていません。

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

Node.js は v22.22.0、Wrangler は 4.134.0、Agents SDK は 0.23.0、Vite は 8.3.0、Cloudflare Vite plugin は 1.55.0 と表示されるはずです。

この VM を認証し、構造化された ID 情報を確認します。

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

表示されたリンクをブラウザで開き、短いコードを入力します。専用の学習用アカウントであることを確認し、認証する前に権限を確認してください。ターミナルに戻ったら loggedIn: true を確認し、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"

学習用アカウントの名前が別の場合は、対象アカウントを確認したうえで、LabEx Learning の部分だけを置き換えてください。設定を作成します。

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

この binding によって Agent クラスの namespace が選択されます。インスタンス名は各ブラウザクライアントから渡されます。設定だけでは、クラウド上にリソースは作成されません。

検証済み callable メソッドを実装する

このステップでは、共有キューの状態と、ブラウザから呼び出せる唯一の変更処理を実装します。

変更ルールはサーバーが管理します。ブラウザは更新を要求できますが、タイトルや優先度が有効かどうかを判断してはいけません。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() は明示的な RPC 境界です。デコレートされたメソッドだけが Agent クライアントプロトコル経由で呼び出せます。検証は setState() の前に実行されるため、拒否された呼び出しによってリビジョンが進むことはありません。合成チケットを最新の 6 件だけに制限することで、デモの状態サイズを抑えています。構造化ログにはインスタンス、リビジョン、件数が含まれますが、チケット本文は含まれません。

2 つのバニラブラウザクライアントを接続する

このステップでは、現在のデコレータビルドパスを設定し、2 つの独立したバニラクライアントを 1 つの名前付き Agent に接続します。

現在の SDK デコレータは、JavaScript 標準デコレータ変換を使用します。そのため、手動で作成したプロジェクトには Agents TypeScript preset と Agents Vite plugin の両方が必要です。TypeScript の従来の 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

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

画面上では 1 ページに表示されますが、これらは実際には 2 つの WebSocket クライアントです。両方とも同じクラスと名前にルーティングされるため、同じ状態ブロードキャストを受信します。呼び出しを行うのは Dispatcher だけです。Observer によって、同期がコピーした DOM の更新ではなく、サーバー主導で行われることを確認できます。

型を生成して両方をビルドする

このステップでは、共有状態の契約を型チェックし、ランタイムを起動する前に Worker とブラウザアプリケーションをビルドします。

正確な binding 設定から環境型を生成します。

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

Worker、ブラウザクライアント、Vite 設定をまとめて TypeScript で実行します。

npm run check

コンパイラ診断が表示されなければ、状態の形、callable サーバー、DOM クライアントが一致しています。本番用の 2 つのターゲットをビルドします。

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

Vite は Worker 環境とクライアント環境を報告します。Cloudflare plugin は Worker バンドルを作成し、ビルド済みの静的ページを関連付けます。Agents plugin は現在のデコレータ変換を適用します。ビルドの成功で確認できるのはパッケージングであり、WebSocket の動作、アカウント所有権、リモートデプロイの成否ではありません。

ローカルで同期と拒否を確認する

このステップでは、有効な更新後に 2 つのローカルクライアントが同じ状態へ収束する様子と、無効な更新後も状態が変わらないことを確認します。

ローカルの Vite と Workers ランタイムをバックグラウンドジョブとして起動します。

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

LabEx デスクトップ内のブラウザで http://localhost:5173 を開きます。緑色のステータスに両方のクライアントが接続済みと表示されるまで待ちます。最初は両方のカードがリビジョン 0 で、チケットはありません。

用意されているタイトルをそのまま使い、Add with Dispatcher をクリックします。両方のカードがリビジョン 1 に進み、同じチケットを表示するはずです。Dispatcher はまず WebSocket 経由で RPC フレームを送信します。addTicket() が Agent 内で引数を検証し、setState(next) がリビジョン 1 を保存してブロードキャストします。2 つの onStateUpdate ハンドラーがそれぞれのカードを個別に再描画します。

次に、タイトルを x に置き換えてもう一度送信します。ページに title must contain 3-80 characters と表示され、両方のカードはリビジョン 1 のままになります。これは、状態の書き込み前に検証が行われたことを示す有用な証拠です。

独立したローカルチェックを実行します。

python3 .labex/verify.py local

検証ツールは、画面に表示される例をそのまま信頼せず、実行ごとに新しい一意の名前を使用します。2 つのクライアントを開いて収束を確認し、別の名前ではリビジョンが 0 のままであることを確認します。その後、無効な更新を送信し、共有リビジョンが変化しないことを確認します。

デプロイしてクラウド上のダッシュボードを確認する

このステップでは、本番バンドルをデプロイし、Cloudflare 上で同じ 2 クライアントの契約を確認して、その動作を Dashboard の証拠と結び付けます。

ローカルプロセスを正確に停止してから、本番ビルドをデプロイします。

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

Wrangler は migration v1 を適用し、Worker と静的クライアントをアップロードして、workers.dev URL を表示します。その 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

組み込みブラウザで URL を開きます。Cloud dashboard ticketUrgent として追加します。両方のカードに同じリビジョンと赤い urgent マーカーが表示されるはずです。次に x を送信します。拒否メッセージが表示されますが、両方のリビジョンは変わりません。これらはクラウドが管理する新しい Agent インスタンスであり、ローカルの Vite 状態とは意図的に分離されています。

両方のクラウドクライアントがリビジョン 1 で同じ urgent チケットを表示している

この実際のテスト実行では、Dispatcher が書き込みを行い、Observer が同じブロードキャストを受信しました。チケット本文とリビジョンは、使い捨てのコースリソースで使用した例です。実際の値は異なる場合があります。

両方のクライアントがリビジョン 1 のまま、短いタイトルが拒否されている

エラーは入力欄の横に表示されますが、どちらのカードも進みません。両方のカードに表示された変化しないリビジョンに注目してください。これは、サーバーが setState() を呼び出す前に引数を拒否したことを示します。

Cloudflare Dashboard で Workers & Pages を開き、正確な labex-c11-s02-... Worker を選択します。Bindings タブで、SupportDashboardSupportDashboard Durable Object クラスを指していることを確認します。Durable Objects で、その namespace が SQL ストレージを使用していることを確認します。最後に Observability → Logs を開き、support_queue_updated でフィルターしてイベントを 1 つ展開します。instance が planning であること、リビジョン、チケット件数を照合してください。チケットタイトルは意図的に含まれていません。

使い捨て Worker、ドメイン、binding、エラー数 0 が表示された Worker 概要

概要画面では、これまで個別に扱ってきた複数の要素をまとめて確認できます。workers.dev ドメインが Worker に到達し、binding が durable state に接続し、エラー数 0 のカウンターが簡単なヘルスシグナルになります。このスクリーンショットの Worker 名は、ある合格テスト実行で生成されたものです。

Worker と SupportDashboard Durable Object の接続を示す Bindings ビュー

binding グラフには、正確な Worker と Durable Object SupportDashboard の接続が表示されるはずです。これは設定の証拠であり、2 クライアントの動作確認の代わりにはなりません。

SQL ストレージを示す SupportDashboard namespace の概要

namespace ページには、Agent クラスの背後にある durable storage が表示され、Storage: SQL と報告されます。プライバシー保護のため、教材画像では不透明な namespace ID を隠しています。学習者がこの ID をコピーする必要はありません。

フィールド数を制限した構造化 support_queue_updated イベント

展開したイベントには、合成した instance 名、リビジョン、チケット件数が含まれますが、チケットタイトルは含まれません。これは意図的なデータ最小化です。ログは、機密性のある可能性があるユーザーコンテンツをコピーせずに、動作の診断に役立つ必要があります。

Dashboard のデータは遅れて到着することがあるため、最近のログが空でも判断はできません。認証済みの設定、所有する namespace、独立したライブ AgentClient チェックを正式な根拠とします。

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

最初のチェックでは、新しいリモート名を作成し、画面に表示される planning の例を信頼せずに、同期、分離、拒否を確認します。2 つ目のチェックでは、所有する正確なリソースを残したまま、読み取り専用で Dashboard を確認できます。

Dashboard namespace と Worker を削除する

このステップでは、まず Agent クラスの namespace を明示的に削除し、VM がまだ認証されている間に残りの Worker を削除します。

キューは Durable Object クラスの namespace に保存されます。そのため、残りのステートレス Worker を削除する前に、このクラスを明示的に削除します。クリーンアップ用のエントリーポイントを作成します。

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

元の migration は残したまま、削除用に v2 を追加します。

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

migration の履歴は追加専用です。v1 を書き換えても、Cloudflare ですでに適用された遷移を表せません。Dashboard で、正確な Worker とその SupportDashboard namespace が存在しないことを確認します。アカウントに他のリソースがある場合は、それらを残してください。

使い捨て Worker の削除後の Workers and Pages 概要

テストしたアカウントでは、削除後に Workers & Pages の概要へ戻りました。学習用アカウントには関係のない Worker が含まれている場合があります。そのため、アカウントが空になることを期待せず、正確な labex-c11-s02-... という名前が消えていることを確認してください。

SupportDashboard namespace の削除後の Durable Objects 概要

合格したテストアカウントでは、削除後に空の Durable Objects 概要へ戻りました。他の namespace があるアカウントでは、それらを残し、この実験が所有する namespace だけが削除されたことを確認してください。

この VM の認証を取り消す

このステップでは、この使い捨て VM にだけ保存されている OAuth 認証を削除し、構造化されたログアウト状態を確認します。

クラウドのクリーンアップは完了していますが、この使い捨て VM にはローカルの OAuth grant がまだ残っています。これを削除し、構造化された状態を確認します。

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

JSON に "loggedIn": false が明示的に含まれている必要があります。ネットワークエラーはログアウトの証拠ではありません。接続が戻ったら、状態の読み取りを再試行してください。公開された合成ダッシュボード、その durable state、この VM の認証はすべて削除されました。

まとめ

React や言語モデルを導入せずに、1 つの durable な名前付き Agent をリアルタイムのブラウザアプリケーションへ変換しました。2 つの AgentClient 接続が SupportDashboard:planning を選択し、検証済みの @callable() メソッドが変更処理を管理し、setState() が正規のリビジョンを 1 つ保存し、SDK がその状態を両方の onStateUpdate ハンドラーへブロードキャストしました。

また、現在のデコレータパスで agents/tsconfigagents/vite の両方が必要な理由、WebSocket RPC とクライアントによる直接的な状態変更の違い、拒否された入力が影響を与えないこと、Cloudflare 上での同期と名前の分離、プライバシーを考慮した証拠の確認方法を学びました。最後に、クラス namespace、Worker、VM の認証を明示的に削除しました。