Sincronizar um painel de suporte

CloudflareBeginner
Pratique Agora

Introdução

Um Agent durável pode memorizar uma fila de suporte, mas um painel útil também precisa manter todas as telas conectadas atualizadas. O polling solicita ao servidor uma nova cópia repetidamente. Já o Cloudflare Agents SDK abre um WebSocket: uma conexão bidirecional de longa duração que pode enviar uma atualização a todos os clientes do mesmo Agent nomeado assim que o estado mudar.

Você criará um painel deliberadamente pequeno e sem LLM. Dois clientes independentes de JavaScript vanilla — Dispatcher e Observer — conectam-se a SupportDashboard:planning. O Dispatcher chama um método do servidor marcado com @callable(). Esse método valida o ticket, atualiza o estado do Agent uma vez, e o SDK transmite o estado resultante aos dois clientes. Um título inválido é rejeitado no servidor e não incrementa a revisão compartilhada.

Este laboratório apresenta apenas quatro componentes, conforme a aplicação precisa deles:

  1. AgentClient mantém a conexão WebSocket do navegador.
  2. onStateUpdate redesenha a interface depois que o servidor transmite o estado.
  3. @callable() expõe um método específico do servidor aos clientes conectados.
  4. setState() persiste um próximo estado autoritativo e inicia a sincronização.

O exemplo usa texto sintético de suporte e um Worker público descartável, para que você possa se concentrar no protocolo. A validação de entrada não é autenticação de usuário. Uma ferramenta de suporte em produção precisa adicionar uma camada de identidade e autorização antes de expor dados de clientes ou operações de alteração.

Antes de entrar diretamente neste curso, conclua Conectar o LabEx à sua conta da Cloudflare. Cada nova VM do LabEx precisa de sua própria autorização do Wrangler. O S01 é recomendado porque este laboratório se baseia em identidade de Agent nomeada, estado durável e limpeza explícita, mas não pressupõe conhecimento de React ou de modelos de IA.

Autorizar a VM e configurar o painel

Nesta etapa, você autorizará a VM recém-criada, confirmará a conta da Cloudflare pretendida e declarará o único namespace de Agent usado pelo painel.

Entre no projeto preparado e confirme o runtime fixado. A configuração instalou as dependências e forneceu apenas a estrutura visual da página; ela não autorizou a Cloudflare nem implementou o Agent.

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

Você deve obter Node.js v22.22.0, Wrangler 4.134.0, Agents SDK 0.23.0, Vite 8.3.0 e o plugin Vite da Cloudflare 1.55.0.

Autorize esta VM e inspecione a identidade estruturada:

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

Abra o link exibido em um navegador, informe o código curto, confirme sua conta de aprendizagem dedicada e verifique as permissões antes de autorizar. De volta ao terminal, confirme loggedIn: true. Em seguida, selecione a conta pelo nome de exibição confirmado sem imprimir o 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"

Se sua conta de aprendizagem usar outro nome, substitua apenas LabEx Learning depois de confirmar que essa é a conta pretendida. Crie a configuração:

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

O binding seleciona o namespace da classe do Agent; o nome da instância será fornecido por cada cliente do navegador. A configuração, por si só, não cria nenhum recurso na nuvem.

Implementar um método chamável validado

Nesta etapa, você implementará o estado compartilhado da fila e a única alteração que pode ser chamada pelo navegador.

O servidor é responsável pela regra de alteração. Um navegador pode solicitar uma atualização, mas não deve decidir se um título ou uma prioridade é válido. Crie 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() é um limite explícito de RPC: somente os métodos decorados podem ser chamados pelo protocolo do cliente do Agent. A validação acontece antes de setState(), portanto chamadas rejeitadas não podem incrementar a revisão. Manter apenas os seis tickets sintéticos mais recentes limita o tamanho do estado da demonstração. O log estruturado contém a instância, a revisão e a quantidade, mas não o texto do ticket.

Conectar dois clientes de navegador vanilla

Nesta etapa, você configurará o caminho atual de compilação dos decorators e conectará dois clientes vanilla independentes ao mesmo Agent nomeado.

O decorator atual do SDK usa a transformação padrão de decorators do JavaScript. Por isso, um projeto manual precisa tanto do preset TypeScript do Agents quanto do plugin Agents para Vite. Não habilite o modo legado experimentalDecorators do TypeScript.

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

Crie 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

Estes são dois clientes WebSocket reais, embora apareçam em uma única página. Ambos são encaminhados para a mesma classe e o mesmo nome, portanto recebem a mesma transmissão de estado. Apenas o Dispatcher faz a chamada; o Observer demonstra que a sincronização é controlada pelo servidor, e não uma atualização de DOM copiada.

Gerar os tipos e compilar os dois lados

Nesta etapa, você verificará o contrato de estado compartilhado e compilará o Worker e a aplicação do navegador antes de iniciar um runtime.

Gere os tipos do ambiente a partir da configuração exata do binding:

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

Execute o TypeScript no Worker, no cliente do navegador e na configuração do Vite:

npm run check

A ausência de diagnósticos do compilador significa que o formato do estado, o servidor chamável e o cliente DOM estão de acordo. Compile os dois destinos de produção:

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

O Vite informa um ambiente de Worker e um ambiente de cliente. O plugin da Cloudflare produz o bundle do Worker e anexa a página estática compilada; o plugin Agents aplica a transformação atual dos decorators. Uma compilação bem-sucedida comprova o empacotamento, mas não o comportamento do WebSocket, a propriedade da conta nem a implantação remota.

Observar a sincronização local e a rejeição

Nesta etapa, você observará dois clientes locais convergirem depois de uma atualização válida e permanecerem inalterados depois de uma atualização inválida.

Inicie o runtime local do Vite e do Workers como um processo em segundo plano:

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

Abra http://localhost:5173 no navegador dentro do desktop do LabEx. Aguarde até que o status verde informe que os dois clientes estão conectados. Os dois cartões começam na revisão 0, sem tickets.

Mantenha o título preparado e clique em Add with Dispatcher. Os dois cartões devem avançar para a revisão 1 e exibir o mesmo ticket. Primeiro, o Dispatcher envia um frame de RPC pelo WebSocket. addTicket() valida os argumentos no Agent; em seguida, setState(next) persiste a revisão 1 e a transmite. Os dois handlers onStateUpdate redesenham seus cartões de forma independente.

Agora substitua o título por x e envie novamente. A página exibirá title must contain 3-80 characters; os dois cartões permanecerão na revisão 1. Isso comprova que a validação ocorreu antes da gravação do estado.

Execute a verificação local independente:

python3 .labex/verify.py local

O verificador usa nomes novos e exclusivos para cada execução, em vez de confiar no exemplo visível. Ele abre dois clientes, comprova a convergência, verifica que um nome diferente permanece na revisão zero, envia uma atualização inválida e confirma que a revisão compartilhada não muda.

Implantar e inspecionar o painel na nuvem

Nesta etapa, você implantará o bundle de produção, comprovará o mesmo contrato de dois clientes na Cloudflare e relacionará esse comportamento às evidências do Dashboard.

Pare o processo local exato e implante a compilação de produção:

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

O Wrangler aplica a migração v1, envia o Worker junto com o cliente estático e exibe uma URL workers.dev. Salve essa URL exata:

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

Abra a URL no navegador integrado. Adicione Cloud dashboard ticket como Urgent. Os dois cartões devem mostrar a mesma revisão e o marcador vermelho de urgência. Em seguida, envie x; a rejeição aparecerá, enquanto as duas revisões permanecerão inalteradas. Estas são novas instâncias de Agent pertencentes à nuvem; o estado local do Vite é intencionalmente separado.

Os dois clientes na nuvem mostram o mesmo ticket urgente na revisão um

Nesta execução de teste real, o Dispatcher realizou a gravação enquanto o Observer recebeu a mesma transmissão. O texto do ticket e a revisão são exemplos do recurso descartável do curso; seus próprios valores podem ser diferentes.

Um título curto é rejeitado enquanto os dois clientes permanecem na revisão um

O erro aparece ao lado do campo de entrada, mas nenhum dos cartões avança. Leia a revisão inalterada nos dois cartões como a pista principal: o servidor rejeitou o argumento antes de chamar setState().

Abra Workers & Pages no Cloudflare Dashboard e selecione seu Worker exato labex-c11-s02-.... Use a aba Bindings para confirmar que SupportDashboard aponta para a classe de Durable Object SupportDashboard. Em Durable Objects, confirme que o namespace usa armazenamento SQL. Por fim, abra Observability → Logs, filtre por support_queue_updated e expanda um evento. Compare a instância planning, a revisão e a quantidade de tickets; o título do ticket está ausente de propósito.

Visão geral do Worker descartável, com domínio, binding e zero erros

A visão geral reúne várias ideias que você usou separadamente: o domínio workers.dev alcança o Worker, o binding o conecta ao estado durável e o contador de zero erros é um indicador rápido de integridade. O nome do Worker mostrado nesta captura pertence a uma execução de teste aceita.

Visualização de Bindings conectando o Worker ao Durable Object SupportDashboard

O grafo de bindings deve conectar seu Worker exato a um Durable Object chamado SupportDashboard. Isso é uma evidência de configuração; não substitui a verificação do comportamento com dois clientes.

Visão geral do namespace SupportDashboard mostrando armazenamento SQL

A página do namespace identifica o armazenamento durável por trás da classe do Agent e informa Storage: SQL. O ID opaco do namespace está oculto na imagem didática por privacidade; os alunos nunca precisam copiá-lo.

Evento estruturado support_queue_updated com campos limitados

O evento expandido contém o nome sintético da instância, a revisão e a quantidade de tickets, mas não o título do ticket. Isso é minimização deliberada de dados: os logs devem ajudar a diagnosticar o comportamento sem copiar conteúdo potencialmente sensível dos usuários.

Os dados do Dashboard podem chegar com atraso, portanto uma visualização vazia de logs recentes não é conclusiva. As configurações autenticadas, o namespace pertencente à conta e as verificações independentes em tempo real com AgentClient são as fontes autoritativas.

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

A primeira verificação cria novos nomes remotos e comprova sincronização, isolamento e rejeição sem confiar no exemplo visível planning. A segunda mantém os recursos exatos pertencentes à conta disponíveis para sua inspeção somente leitura no Dashboard.

Remover o namespace e o Worker do painel

Nesta etapa, você apagará explicitamente o namespace da classe do Agent e depois removerá o Worker restante enquanto a VM ainda estiver autorizada.

A fila está armazenada no namespace da classe do Durable Object; portanto, exclua explicitamente essa classe antes de excluir o Worker restante sem estado. Crie um entrypoint de limpeza:

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

Mantenha a migração original e acrescente v2 para a exclusão:

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

O histórico de migrações é somente de acréscimo: reescrever v1 não descreveria a transição já aplicada na Cloudflare. No Dashboard, confirme que o Worker exato e seu namespace SupportDashboard estão ausentes. Preserve os recursos não relacionados, caso sua conta contenha algum.

Visão geral de Workers and Pages depois da remoção do Worker descartável

A conta testada voltou à visão geral de Workers & Pages depois da exclusão. Sua conta de aprendizagem pode conter Workers não relacionados; verifique se o nome exato labex-c11-s02-... desapareceu, em vez de esperar uma conta vazia.

Visão geral de Durable Objects depois da remoção do namespace SupportDashboard

A conta de teste aceita também voltou a uma visão geral vazia de Durable Objects. Em uma conta com outros namespaces, preserve-os e confirme que apenas o namespace pertencente a este laboratório foi removido.

Revogar a autorização desta VM

Nesta etapa, você removerá a autorização OAuth armazenada somente nesta VM descartável e verificará o estado estruturado de sessão encerrada.

A limpeza na nuvem foi concluída, mas esta VM descartável ainda mantém sua concessão OAuth local. Remova-a e solicite o status estruturado:

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

O JSON deve conter explicitamente "loggedIn": false. Um erro de rede não é evidência de logout; repita a leitura do status quando a conectividade retornar. O painel público sintético, seu estado durável e a autorização desta VM foram removidos.

Resumo

Você transformou um Agent nomeado e durável em uma aplicação de navegador em tempo real sem introduzir React ou um modelo de linguagem. Duas conexões AgentClient selecionaram SupportDashboard:planning, um método @callable() validado controlou a alteração, setState() persistiu uma revisão autoritativa e o SDK transmitiu esse estado aos dois handlers onStateUpdate.

Você também aprendeu por que o caminho atual de decorators precisa de agents/tsconfig e agents/vite, distinguiu um RPC por WebSocket de alterações diretas no estado do cliente, comprovou que uma entrada rejeitada não produz efeito, repetiu a sincronização e o isolamento por nome na Cloudflare, inspecionou evidências com dados limitados por privacidade e removeu explicitamente o namespace da classe, o Worker e a autorização da VM.