简介
持久化 Agent 可以记住支持队列,但实用的仪表板还必须让每个已连接的屏幕保持最新。轮询会反复向服务器请求新副本。Cloudflare Agents SDK 则会建立一个 WebSocket:这是一种长期存在的双向连接,状态发生变化后,可以立即将更新发送给同一个命名 Agent 的所有客户端。
你将构建一个刻意保持精简、且不使用 LLM 的仪表板。两个相互独立的原生 JavaScript 客户端——Dispatcher 和 Observer——连接到 SupportDashboard:planning。Dispatcher 调用一个标记为 @callable() 的服务器方法。该方法会验证工单、更新一次 Agent 状态,然后由 SDK 将结果状态广播给两个客户端。无效的标题会在服务器端被拒绝,不会推进共享修订号。
本实验只在应用需要时介绍以下四个部分:
AgentClient维护浏览器的 WebSocket 连接。onStateUpdate在服务器广播状态后重新绘制视图。@callable()向已连接的客户端公开指定的服务器方法。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 实例、修订号和工单数量;工单标题应有意缺失。

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

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

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

展开的事件包含虚构的实例名称、修订号和工单数量,但不包含工单标题。这是有意的数据最小化设计:日志应帮助诊断行为,同时避免复制可能包含敏感信息的用户内容。
仪表板数据可能会延迟到达,因此最近日志视图为空并不能说明没有事件。经过身份验证的设置、归属明确的命名空间以及独立的实时 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 命名空间都已消失。如果你的账户中有其他资源,请保留它们。

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

通过测试的账户也恢复到了空的 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/tsconfig 和 agents/vite,区分了 WebSocket RPC 与直接修改客户端状态的差异,验证了被拒绝的输入不会产生影响,在 Cloudflare 上重复验证了同步和名称隔离,检查了经过隐私限制的证据,并显式删除了类命名空间、Worker 和 VM 授权。



