同步支持仪表板

CloudflareBeginner
立即练习

简介

持久化 Agent 可以记住支持队列,但实用的仪表板还必须让每个已连接的屏幕保持最新。轮询会反复向服务器请求新副本。Cloudflare Agents SDK 则会建立一个 WebSocket:这是一种长期存在的双向连接,状态发生变化后,可以立即将更新发送给同一个命名 Agent 的所有客户端。

你将构建一个刻意保持精简、且不使用 LLM 的仪表板。两个相互独立的原生 JavaScript 客户端——DispatcherObserver——连接到 SupportDashboard:planning。Dispatcher 调用一个标记为 @callable() 的服务器方法。该方法会验证工单、更新一次 Agent 状态,然后由 SDK 将结果状态广播给两个客户端。无效的标题会在服务器端被拒绝,不会推进共享修订号。

本实验只在应用需要时介绍以下四个部分:

  1. AgentClient 维护浏览器的 WebSocket 连接。
  2. onStateUpdate 在服务器广播状态后重新绘制视图。
  3. @callable() 向已连接的客户端公开指定的服务器方法。
  4. setState() 持久化一个权威的下一状态,并触发同步。

示例使用虚构的支持文本和公开的临时 Worker,让你可以专注于协议本身。输入验证不等同于用户身份验证。生产环境中的支持工具在公开客户数据或允许执行修改操作前,必须增加身份识别和授权层。

在直接进入本课程之前,请先完成 将 LabEx 连接到 Cloudflare 账户 每个新的 LabEx VM 都需要单独进行 Wrangler 授权。建议先完成 S01,因为本实验建立在命名 Agent 身份、持久化状态和显式清理之上,但不要求具备 React 或 AI 模型知识。

授权 VM 并配置仪表板

在本步骤中,你将授权全新的 VM,确认目标 Cloudflare 账户,并声明仪表板使用的唯一 Agent 命名空间。

进入准备好的项目并确认固定的运行时版本。安装过程已经安装依赖并提供了可视化页面框架,但尚未授权 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 插件 1.55.0

授权此 VM 并查看结构化身份信息:

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

绑定配置会选择 Agent 类命名空间;每个浏览器客户端会提供实例名称。仅创建配置不会创建任何云资源。

实现经过验证的可调用方法

在本步骤中,你将实现共享队列状态,以及浏览器唯一可以调用的修改方法。

修改规则由服务器负责。浏览器可以请求更新,但不能自行决定标题或优先级是否有效。创建 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() 之前,因此被拒绝的调用不会推进修订号。只保留最近六个虚构工单,可以限制演示状态的大小。结构化日志包含实例、修订号和数量,但不包含工单文本。

连接两个原生浏览器客户端

在本步骤中,你将配置当前的装饰器构建路径,并将两个相互独立的原生客户端连接到同一个命名 Agent。

当前 SDK 的装饰器使用 JavaScript 标准装饰器转换。手动创建的项目因此同时需要 Agents TypeScript 预设和 Agents Vite 插件。不要启用 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

虽然这两个客户端显示在同一个页面上,但它们确实是两个真实的 WebSocket 客户端。两个客户端都路由到同一个类和名称,因此都会收到相同的状态广播。只有 Dispatcher 发起调用;Observer 用于展示同步由服务器驱动,而不是通过复制 DOM 更新实现。

生成类型并构建两端应用

在本步骤中,你将检查共享状态契约,并在启动运行时之前构建 Worker 和浏览器应用。

根据精确的绑定配置生成环境类型:

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

对 Worker、浏览器客户端和 Vite 配置运行 TypeScript 检查:

npm run check

没有编译器诊断信息,表示状态结构、可调用服务器方法和 DOM 客户端彼此一致。构建两个生产目标:

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

Vite 会报告 Worker 环境和客户端环境。Cloudflare 插件会生成 Worker 包并附加构建后的静态页面;Agents 插件会应用当前的装饰器转换。构建成功只能证明打包正常,不能证明 WebSocket 行为、账户归属或远程部署正常。

观察本地同步和拒绝行为

在本步骤中,你将观察两个本地客户端在有效更新后趋于一致,并确认无效更新不会改变状态。

将本地 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 并广播该状态。两个 onStateUpdate 处理函数会分别重新绘制各自的卡片。

现在将标题替换为 x,然后再次提交。页面应显示 title must contain 3-80 characters;两张卡片仍保持修订号 1。这可以证明验证发生在写入状态之前。

运行独立的本地检查:

python3 .labex/verify.py local

验证器会使用全新的、每次运行唯一的名称,而不是盲目信任页面中显示的示例。它会打开两个客户端,验证两者趋于一致,检查另一个名称是否保持修订号零,发送一次无效更新,并确认共享修订号没有变化。

部署并检查云端仪表板

在本步骤中,你将部署生产包,在 Cloudflare 上验证相同的双客户端契约,并将运行行为与仪表板中的证据对应起来。

停止指定的本地进程并部署生产构建:

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

Wrangler 会应用迁移 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 ticket,并将优先级设为 Urgent。两张卡片都应显示相同的修订号和红色的 urgent 标记。然后提交 x;页面会显示拒绝信息,同时两张卡片的修订号保持不变。这些是由云端管理的新 Agent 实例;本地 Vite 状态与它们有意分离。

两个云端客户端在修订号一显示相同的紧急工单

在这次真实测试中,Dispatcher 执行了写入,而 Observer 收到了相同的广播。工单文本和修订号只是临时课程资源中的示例;你自己的值可能不同。

短标题被拒绝,而两个客户端都保持修订号一

错误信息会显示在输入框旁边,但两张卡片都不会推进。应重点查看两张卡片中未发生变化的修订号:这说明服务器在调用 setState() 之前就拒绝了参数。

在 Cloudflare Dashboard 中打开 Workers & Pages,选择确切的 labex-c11-s02-... Worker。使用 Bindings 标签页确认 SupportDashboard 指向 SupportDashboard Durable Object 类。在 Durable Objects 下,确认其命名空间使用 SQL 存储。最后打开 Observability → Logs,筛选 support_queue_updated 并展开一条事件。核对其 planning 实例、修订号和工单数量;工单标题应有意缺失。

Worker 概览显示临时 Worker、域名、绑定和零错误

概览页面将你分别使用过的几个概念连接起来:workers.dev 域名可以访问 Worker,绑定将 Worker 连接到持久化状态,而零错误计数是快速的健康信号。截图中的 Worker 名称来自一次已接受的测试运行。

Bindings 视图将 Worker 连接到 SupportDashboard Durable Object

绑定关系图应将你的确切 Worker 连接到名为 SupportDashboardDurable Object。这是配置证据,不能替代双客户端行为检查。

SupportDashboard 命名空间概览显示 SQL 存储

命名空间页面标识出 Agent 类背后的持久化存储,并报告 Storage: SQL。出于隐私考虑,教学图片隐藏了不透明的命名空间 ID;学习者不需要复制它。

包含有限字段的结构化 support_queue_updated 事件

展开的事件包含虚构的实例名称、修订号和工单数量,但不包含工单标题。这是有意的数据最小化设计:日志应帮助诊断行为,同时避免复制可能包含敏感信息的用户内容。

仪表板数据可能会延迟到达,因此最近日志视图为空并不能说明没有事件。经过身份验证的设置、归属明确的命名空间以及独立的实时 AgentClient 检查,才是权威证据。

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

第一次检查会创建全新的远程名称,并在不依赖页面中显示的 planning 示例的情况下验证同步、隔离和拒绝行为。第二次检查会保留确切的自有资源,供你在仪表板中进行只读检查。

删除仪表板命名空间和 Worker

在本步骤中,你将显式删除 Agent 类命名空间,然后在 VM 仍处于授权状态时删除剩余的 Worker。

队列存储在 Durable Object 类命名空间中,因此应先显式删除该类,再删除剩余的无状态 Worker。创建清理入口文件:

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

保留原有迁移,并追加用于删除的 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

迁移历史只能追加:重写 v1 无法描述已经在 Cloudflare 中应用的迁移过程。在 Dashboard 中确认确切的 Worker 及其 SupportDashboard 命名空间都已消失。如果你的账户中有其他资源,请保留它们。

删除临时 Worker 后的 Workers and Pages 概览

测试账户在删除后恢复到 Workers & Pages 概览。你的学习账户可能包含其他 Worker,因此请确认确切的 labex-c11-s02-... 名称已经消失,而不要期待账户为空。

删除 SupportDashboard 命名空间后的 Durable Objects 概览

通过测试的账户也恢复到了空的 Durable Objects 概览。如果账户中有其他命名空间,请保留它们,并确认只有本实验创建的命名空间被删除。

撤销此 VM 的授权

在本步骤中,你将删除仅存储在此临时 VM 中的 OAuth 授权,并验证结构化的登出状态。

云端清理已经完成,但此临时 VM 仍保留本地 OAuth 授权。删除该授权并请求结构化状态:

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

JSON 必须明确包含 "loggedIn": false。网络错误不能证明已经登出;网络恢复后,请重新读取状态。公开的虚构仪表板、其中的持久化状态以及此 VM 的授权现在都已删除。

总结

你在没有引入 React 或语言模型的情况下,将一个持久化的命名 Agent 转变成了真正的实时浏览器应用。两个 AgentClient 连接选择了 SupportDashboard:planning,经过验证的 @callable() 方法负责执行修改,setState() 持久化一个权威修订号,SDK 再将该状态广播给两个 onStateUpdate 处理函数。

你还学习了当前装饰器路径为什么同时需要 agents/tsconfigagents/vite,区分了 WebSocket RPC 与直接修改客户端状态的差异,验证了被拒绝的输入不会产生影响,在 Cloudflare 上重复验证了同步和名称隔离,检查了经过隐私限制的证据,并显式删除了类命名空间、Worker 和 VM 授权。