Синхронизация панели поддержки

CloudflareBeginner
Практиковаться сейчас

Введение

Постоянный Agent может хранить очередь поддержки, но полезная панель должна также поддерживать актуальное состояние на каждом подключённом экране. При опросе клиент снова и снова запрашивает у сервера новую копию данных. Вместо этого Cloudflare Agents SDK открывает WebSocket — долговременное двустороннее соединение, которое сразу после изменения состояния может передать обновление всем клиентам одного именованного Agent.

Вы создадите намеренно небольшую панель без LLM. Два независимых клиента на обычном JavaScript — Dispatcher и Observer — подключаются к SupportDashboard:planning. Dispatcher вызывает серверный метод, отмеченный @callable(). Метод проверяет тикет, один раз обновляет состояние Agent, а SDK рассылает полученное состояние обоим клиентам. Сервер отклоняет недопустимый заголовок, поэтому общая ревизия не увеличивается.

В этой лабораторной работе вы познакомитесь только с четырьмя компонентами, по мере необходимости приложения:

  1. AgentClient поддерживает WebSocket-соединение браузера.
  2. onStateUpdate перерисовывает представление после того, как сервер рассылает состояние.
  3. @callable() открывает подключённым клиентам доступ к конкретному серверному методу.
  4. 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, а также проверьте ревизию и количество тикетов; заголовок тикета намеренно отсутствует.

Обзор Worker с временным Worker, доменом, привязкой и нулевым числом ошибок

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

Представление Bindings, соединяющее Worker с Durable Object SupportDashboard

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

Обзор пространства имён SupportDashboard с SQL-хранилищем

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

Структурированное событие support_queue_updated с ограниченным набором полей

Развёрнутое событие содержит искусственное имя экземпляра, ревизию и количество тикетов, но не заголовок тикета. Это намеренное ограничение данных: журналы должны помогать диагностировать поведение, не копируя потенциально конфиденциальное содержимое пользователей.

Данные 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 and Pages после удаления временного Worker

После удаления тестовый аккаунт вернулся к обзору Workers & Pages. В вашем учебном аккаунте могут находиться другие Worker, поэтому убедитесь, что исчезло точное имя labex-c11-s02-..., а не ожидайте пустого аккаунта.

Обзор Durable Objects после удаления пространства имён SupportDashboard

Принятый тестовый аккаунт также вернулся к пустому обзору 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 и авторизацию виртуальной машины.