Введение
Постоянный Agent может хранить очередь поддержки, но полезная панель должна также поддерживать актуальное состояние на каждом подключённом экране. При опросе клиент снова и снова запрашивает у сервера новую копию данных. Вместо этого Cloudflare Agents SDK открывает WebSocket — долговременное двустороннее соединение, которое сразу после изменения состояния может передать обновление всем клиентам одного именованного Agent.
Вы создадите намеренно небольшую панель без LLM. Два независимых клиента на обычном JavaScript — Dispatcher и Observer — подключаются к SupportDashboard:planning. Dispatcher вызывает серверный метод, отмеченный @callable(). Метод проверяет тикет, один раз обновляет состояние Agent, а SDK рассылает полученное состояние обоим клиентам. Сервер отклоняет недопустимый заголовок, поэтому общая ревизия не увеличивается.
В этой лабораторной работе вы познакомитесь только с четырьмя компонентами, по мере необходимости приложения:
AgentClientподдерживает WebSocket-соединение браузера.onStateUpdateперерисовывает представление после того, как сервер рассылает состояние.@callable()открывает подключённым клиентам доступ к конкретному серверному методу.setState()сохраняет одно авторитетное следующее состояние и запускает синхронизацию.
В примере используются искусственные тексты обращений и общедоступный временный Worker, чтобы вы могли сосредоточиться на протоколе. Проверка входных данных не является аутентификацией пользователей. Перед публикацией инструмента поддержки, работающего с данными клиентов или изменяющего их, необходимо добавить уровень идентификации и авторизации.
Прежде чем напрямую приступить к этому курсу, завершите лабораторную работу Connect LabEx to Your Cloudflare Account. Для каждой новой виртуальной машины LabEx требуется отдельная авторизация Wrangler. Рекомендуется пройти S01: эта лабораторная работа использует именованную идентичность Agent, постоянное состояние и явную очистку, но знания React или моделей ИИ не требуются.
Авторизация виртуальной машины и настройка панели
На этом шаге вы авторизуете новую виртуальную машину, проверите нужный аккаунт 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.
Авторизуйте эту виртуальную машину и просмотрите структурированные данные об идентификации:
npx wrangler login --device --browser=false
npx wrangler whoami --json
Откройте выведенную ссылку в браузере, введите короткий код, подтвердите выделенный учебный аккаунт и перед авторизацией проверьте разрешения. Вернувшись в терминал, убедитесь, что loggedIn: true, затем выберите аккаунт по подтверждённому отображаемому имени, не выводя его идентификатор:
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(), поэтому отклонённые вызовы не увеличивают ревизию. Ограничение очереди шестью последними искусственными тикетами не даёт демонстрационному состоянию неограниченно расти. В структурированном журнале содержатся экземпляр, ревизия и количество тикетов, но нет текста тикета.
Подключение двух клиентов на обычном JavaScript
На этом шаге вы настроите текущий путь сборки с декораторами и подключите двух независимых клиентов на обычном JavaScript к одному именованному 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
Запустите TypeScript-проверку Worker, браузерного клиента и конфигурации Vite:
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 и сопоставите результат с данными Dashboard.
Остановите именно локальный процесс и разверните рабочую сборку:
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npm run deploy
Wrangler применяет миграцию v1, загружает Worker вместе со статическим клиентом и выводит URL workers.dev. Сохраните этот точный 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. Обе карточки должны показать одну и ту же ревизию и красный маркер срочности. Затем отправьте x; сообщение об отклонении появится, а обе ревизии останутся без изменений. Это новые экземпляры Agent, принадлежащие облаку; состояние локального Vite намеренно отделено от них.

В этом тестовом запуске Dispatcher выполнил запись, а Observer получил ту же рассылку. Текст тикета и ревизия приведены только для примера из временного учебного ресурса; у вас значения могут отличаться.

Ошибка отображается рядом с полем ввода, но ни одна карточка не переходит на следующую ревизию. Важная подсказка — неизменившаяся ревизия на обеих карточках: сервер отклонил аргумент до вызова setState().
Откройте Workers & Pages в Cloudflare Dashboard и выберите свой Worker с точным именем labex-c11-s02-.... На вкладке Bindings убедитесь, что SupportDashboard указывает на класс Durable Object SupportDashboard. В разделе Durable Objects проверьте, что пространство имён использует SQL-хранилище. Затем откройте Observability → Logs, отфильтруйте записи по support_queue_updated и разверните одно событие. Сопоставьте его instance со значением planning, а также проверьте ревизию и количество тикетов; заголовок тикета намеренно отсутствует.

На странице обзора объединены несколько использованных вами понятий: домен workers.dev направляет запросы к Worker, привязка соединяет его с постоянным состоянием, а счётчик с нулём ошибок служит быстрым признаком исправности. Имя Worker на этом снимке экрана относится к одному из принятых тестовых запусков.

На графе привязок ваш Worker должен быть соединён с Durable Object под именем SupportDashboard. Это подтверждает конфигурацию, но не заменяет проверку поведения двух клиентов.

Страница пространства имён определяет постоянное хранилище, стоящее за классом Agent, и сообщает Storage: SQL. Непрозрачный идентификатор пространства имён скрыт на учебном изображении из соображений конфиденциальности; копировать его не требуется.

Развёрнутое событие содержит искусственное имя экземпляра, ревизию и количество тикетов, но не заголовок тикета. Это намеренное ограничение данных: журналы должны помогать диагностировать поведение, не копируя потенциально конфиденциальное содержимое пользователей.
Данные Dashboard могут поступать с задержкой, поэтому пустой список последних журналов не является окончательным доказательством. Авторизованные настройки, принадлежащее вам пространство имён и независимые проверки работающего AgentClient имеют приоритет.
python3 .labex/verify.py deployed
python3 .labex/verify.py observed
Первая проверка создаёт новые удалённые имена и подтверждает синхронизацию, изоляцию и отклонение, не полагаясь на видимый пример planning. Вторая оставляет доступными точные принадлежащие вам ресурсы для проверки в Dashboard только на чтение.
Удаление пространства имён панели и Worker
На этом шаге вы явно удалите пространство имён класса Agent, а затем оставшийся 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. Если в аккаунте есть другие пространства имён, сохраните их и убедитесь, что удалено только пространство имён, созданное этой лабораторной работой.
Отзыв авторизации этой виртуальной машины
На этом шаге вы удалите OAuth-авторизацию, сохранённую только на этой временной виртуальной машине, и проверите структурированное состояние выхода из системы.
Очистка в облаке завершена, но на этой временной виртуальной машине всё ещё хранится локальное разрешение OAuth. Удалите его и запросите структурированный статус:
npx wrangler logout
npx wrangler whoami --json
python3 .labex/verify.py logout
JSON должен явно содержать "loggedIn": false. Ошибка сети не подтверждает выход из системы; повторите чтение статуса, когда соединение восстановится. Общедоступная искусственная панель, её постоянное состояние и авторизация этой виртуальной машины теперь удалены.
Итоги
Вы превратили один постоянный именованный Agent в настоящее браузерное приложение реального времени, не добавляя React или языковую модель. Два подключения AgentClient выбрали SupportDashboard:planning, проверяемый метод @callable() отвечал за изменение данных, setState() сохранял одну авторитетную ревизию, а SDK рассылал это состояние обоим обработчикам onStateUpdate.
Вы также узнали, почему для текущего пути с декораторами нужны и agents/tsconfig, и agents/vite, отличили RPC через WebSocket от прямых изменений состояния на клиенте, доказали отсутствие эффекта от недопустимого ввода, повторно проверили синхронизацию и изоляцию имён в Cloudflare, изучили свидетельства с ограниченным объёмом данных и явно удалили пространство имён класса, Worker и авторизацию виртуальной машины.



