Transmitir atualizações de uma sala

CloudflareBeginner
Pratique Agora

Introdução

Uma solicitação HTTP comum é aberta, recebe uma resposta e termina. Um WebSocket transforma essa primeira solicitação HTTP em uma conexão bidirecional que permanece aberta, permitindo que o servidor envie uma atualização assim que algo mudar. Mensagens de chat, cursores colaborativos e painéis de pedidos em tempo real se beneficiam desse caminho em tempo real.

Um Durable Object fornece um ponto de coordenação para cada sala. O Worker de entrada transforma um nome de sala validado, como planning, em uma identidade estável de objeto. O objeto selecionado aceita as conexões WebSocket dessa sala, valida cada mensagem recebida e transmite uma única atualização aprovada apenas aos próprios clientes conectados. Um nome diferente seleciona outro objeto, portanto support não pode receber tráfego de planning.

Este laboratório usa deliberadamente a API WebSocket padrão e mantém o conjunto de sockets ativos na memória. Isso torna visível o comportamento de conexão e transmissão antes que o O06 apresente o WebSocket Hibernation e os anexos de conexão. O SQLite armazena um pequeno histórico de mensagens para que você possa provar que uma entrada malformada não alterou o estado persistente; isso não torna o próprio socket aberto persistente.

Você implementará o protocolo, conectará dois clientes fornecidos a uma sala e um terceiro cliente a outra, observará uma transmissão válida, rejeitará uma entrada malformada, repetirá o teste na Cloudflare, inspecionará o cliente do navegador e o Dashboard e, em seguida, removerá exatamente os recursos descartáveis.

Cada VM nova precisa da própria autorização do Wrangler. Você já deve entender nomes estáveis de Durable Objects, bindings, RPC e estado baseado em SQLite dos laboratórios O01–O04. A configuração instala Node.js 22.22.0, o Wrangler 4.132.0 local do projeto e o cliente de teste ws em /home/labex/project/room-broadcast. Ela fornece os clientes do navegador e de teste, mas não cria o seu Worker, não autoriza a Cloudflare nem faz qualquer implantação.

Autorize a VM e declare o namespace da sala

Nesta etapa, você autorizará a VM nova e declarará uma classe de Durable Object com suporte a SQLite para salas em tempo real.

Entre no projeto preparado, confirme a versão fixada do Wrangler e autorize esta VM:

cd /home/labex/project/room-broadcast
npx wrangler --version
npx wrangler login --device --browser=false

O Wrangler deve informar 4.132.0. Abra no navegador a URL da Cloudflare exibida, insira o código curto, confirme a conta de aprendizado correta e autorize-a. O navegador concede acesso ao Wrangler; sua senha nunca é enviada à VM.

Leia apenas campos de identidade seguros, selecione a conta que você confirmou e gere um nome exclusivo para o Worker descartável:

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-c10-o05-$(openssl rand -hex 6)"
printf '%s\n' "$RUN" | tee .labex/run-name

Se a sua conta de aprendizado dedicada tiver outro nome de exibição, substitua o nome pelo que você confirmou. Agora, grave a configuração:

cat > wrangler.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/index.js",
  "compatibility_date": "2026-09-18",
  "workers_dev": true,
  "preview_urls": false,
  "observability": { "enabled": true, "head_sampling_rate": 1 },
  "durable_objects": { "bindings": [
    { "name": "ROOMS", "class_name": "RoomBroadcast" }
  ] },
  "exports": {
    "RoomBroadcast": { "type": "durable-object", "storage": "sqlite" }
  }
}
JSON

O binding ROOMS é a rota do Worker para o namespace da classe. A chamada getByName("planning") sempre selecionará a mesma sala lógica, enquanto getByName("support") selecionará um objeto independente. A exportação fornece armazenamento SQLite privado para cada sala selecionada. Nenhum recurso na nuvem existe antes da implantação.

Implemente o protocolo WebSocket validado

Nesta etapa, você definirá um pequeno contrato de mensagens e implementará o objeto da sala que aceita e transmite mensagens WebSocket.

A solicitação inicial deve conter Upgrade: websocket. Depois da atualização, as mensagens são frames, e não novas solicitações HTTP. Um cliente pode enviar qualquer texto dentro de um frame, portanto analisar JSON é apenas a primeira verificação. A validação também deve exigir o type esperado, um campo text não vazio e limitado por tamanho, além de nenhum campo inesperado, antes de alterar o estado persistente.

Crie os helpers compartilhados do protocolo:

cat > src/protocol.js <<'JS'
const ROOM_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;

export function parseRoomPath(pathname) {
  const match = pathname.match(/^\/rooms\/([^/]+)\/(connect|state)$/);
  if (!match || !ROOM_PATTERN.test(match[1])) return null;
  return { room: match[1], action: match[2] };
}

export function parseClientMessage(raw) {
  if (typeof raw !== "string" || raw.length > 512) return { ok: false };
  let value;
  try { value = JSON.parse(raw); } catch { return { ok: false }; }
  if (!value || typeof value !== "object" || Array.isArray(value)) return { ok: false };
  const keys = Object.keys(value).sort();
  if (keys.length !== 2 || keys[0] !== "text" || keys[1] !== "type") return { ok: false };
  if (value.type !== "update" || typeof value.text !== "string") return { ok: false };
  const text = value.text.trim();
  if (text.length < 1 || text.length > 80) return { ok: false };
  return { ok: true, text };
}
JS

Crie o Worker de entrada e a classe do Durable Object:

cat > src/index.js <<'JS'
import { DurableObject } from "cloudflare:workers";
import { CLIENT_HTML } from "./client-html.js";
import { parseClientMessage, parseRoomPath } from "./protocol.js";

const json = (body, status = 200) => Response.json(body, { status });

export class RoomBroadcast extends DurableObject {
  constructor(ctx, env) {
    super(ctx, env);
    this.sessions = new Set();
    this.ctx.blockConcurrencyWhile(async () => {
      this.ctx.storage.sql.exec(`
        CREATE TABLE IF NOT EXISTS messages (
          sequence INTEGER PRIMARY KEY AUTOINCREMENT,
          text TEXT NOT NULL,
          created_at INTEGER NOT NULL
        )
      `);
    });
  }

  async fetch(request) {
    if ((request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
      return json({ error: "websocket_upgrade_required" }, 426);
    }
    const pair = new WebSocketPair();
    const [client, server] = Object.values(pair);
    server.accept();
    this.sessions.add(server);
    server.addEventListener("message", event => this.receive(server, event.data));
    const forget = () => this.sessions.delete(server);
    server.addEventListener("close", forget);
    server.addEventListener("error", forget);
    server.send(JSON.stringify({ type: "ready" }));
    return new Response(null, { status: 101, webSocket: client });
  }

  receive(sender, raw) {
    const message = parseClientMessage(raw);
    if (!message.ok) {
      sender.send(JSON.stringify({
        type: "error",
        code: "invalid_message",
        detail: "Send only {type: update, text: 1-80 characters}."
      }));
      return;
    }
    const createdAt = Date.now();
    const row = this.ctx.storage.sql.exec(`
      INSERT INTO messages (text, created_at)
      VALUES (?, ?)
      RETURNING sequence
    `, message.text, createdAt).one();
    const update = JSON.stringify({
      type: "update",
      sequence: row.sequence,
      text: message.text,
      createdAt
    });
    for (const socket of this.sessions) {
      try { socket.send(update); } catch { this.sessions.delete(socket); }
    }
    console.log(JSON.stringify({ event: "room_update", sequence: row.sequence, connected: this.sessions.size }));
  }

  async getState() {
    const messages = this.ctx.storage.sql.exec(`
      SELECT sequence, text, created_at AS createdAt
      FROM messages ORDER BY sequence
    `).toArray();
    return {
      messageCount: messages.length,
      latestSequence: messages.at(-1)?.sequence ?? 0,
      messages
    };
  }
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname === "/" && request.method === "GET") {
      return new Response(CLIENT_HTML, { headers: { "content-type": "text/html; charset=utf-8" } });
    }
    const route = parseRoomPath(url.pathname);
    if (!route) return json({ error: "not_found" }, 404);
    if (route.action === "connect") {
      if (request.method !== "GET" || (request.headers.get("Upgrade") || "").toLowerCase() !== "websocket") {
        return json({ error: "websocket_upgrade_required" }, 426);
      }
      return env.ROOMS.getByName(route.room).fetch(request);
    }
    if (request.method !== "GET") return json({ error: "method_not_allowed" }, 405);
    const state = await env.ROOMS.getByName(route.room).getState();
    return json({ room: route.room, ...state });
  }
};
JS

WebSocketPair cria as extremidades de cliente e servidor de uma conexão. Retornar a extremidade do cliente com HTTP 101 conclui a atualização, enquanto server.accept() inicia o socket padrão no lado do servidor. O conjunto sessions mantido na memória é intencionalmente limitado a uma instância de objeto, e o nome estável da sala é o que impede que esse conjunto se torne global entre as salas.

Execute os testes determinísticos do protocolo e peça ao Wrangler para criar o build sem fazer a implantação:

npm test
npx wrangler deploy --dry-run

Você deve ver quatro testes aprovados. A execução de simulação verifica o módulo do Worker e a configuração dos bindings; as etapas ativas posteriores comprovarão o comportamento real dos sockets.

Transmita uma atualização dentro de uma sala

Nesta etapa, você executará o Worker localmente e provará que uma atualização chega a dois clientes que compartilham uma sala, mas não a um cliente de outra sala.

Inicie o Wrangler como um processo em segundo plano. Redirecionar a saída mantém o terminal legível, e o ID do processo salvo permite interromper depois exatamente esse processo:

mkdir -p .labex/local-state
npx wrangler dev --local --ip 127.0.0.1 --port 8787 --persist-to .labex/local-state > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
  curl --silent --fail http://127.0.0.1:8787/ >/dev/null && break
  sleep 1
done
curl --silent --fail http://127.0.0.1:8787/ | grep -o '<title>[^<]*</title>'

O programa de cliente fornecido abre três conexões WebSocket reais: duas chamadas planning e uma chamada support. Ele envia uma atualização a partir do primeiro cliente de planning e aguarda evidências limitadas dos três clientes:

node tools/room-clients.mjs http://127.0.0.1:8787 planning support broadcast | tee .labex/local-broadcast.json

Os objetos sender e peer devem conter o mesmo sequence: 1 e o mesmo texto. otherUpdates deve ser 0. A seção de estado mostra separadamente uma mensagem persistente em planning e zero mensagens em support. Isso demonstra as duas partes do design: o mesmo nome estável reúne os dois primeiros clientes, enquanto o nome diferente mantém o terceiro fora do limite da transmissão.

Rejeite uma mensagem malformada antes de alterar o estado

Nesta etapa, você enviará um frame que contém JSON válido, mas uma entrada inválida para a aplicação, e comparará o estado persistente antes e depois desse envio.

O campo text vazio é a distinção importante: a análise do JSON é bem-sucedida, mas o protocolo da sala o rejeita. Execute a segunda fase fornecida contra os mesmos objetos locais:

node tools/room-clients.mjs http://127.0.0.1:8787 planning support invalid | tee .labex/local-invalid.json

Somente o cliente que enviou a mensagem recebe um erro com o código invalid_message; peerErrors permanece 0. Os históricos before e after são idênticos e contêm uma mensagem. Portanto, um cliente inválido não pode adicionar uma linha, avançar a sequência nem transformar um erro em uma transmissão para toda a sala.

Leia diretamente o estado das duas salas:

curl --silent --fail http://127.0.0.1:8787/rooms/planning/state | jq
curl --silent --fail http://127.0.0.1:8787/rooms/support/state | jq

A primeira resposta informa uma mensagem, e a segunda informa nenhuma. As leituras de estado por HTTP continuam sendo a fonte de autoridade mesmo que um cliente se desconecte após o teste.

Faça a implantação e teste clientes WebSocket na nuvem

Nesta etapa, você interromperá o runtime local, fará a implantação do mesmo código e repetirá o contrato com três clientes por meio da Cloudflare.

Interrompa apenas o processo local registrado anteriormente e faça a implantação:

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npx wrangler deploy | tee .labex/deploy.log
APP_URL="$(grep -Eo 'https://[^ ]+\.workers\.dev' .labex/deploy.log | tail -1)"
test -n "$APP_URL"
printf '%s\n' "$APP_URL" | tee .labex/app-url

Primeiro, a implantação cria o Worker e reconcilia o namespace RoomBroadcast. Uma página inicial bem-sucedida, por si só, não prova que a rota com estado está pronta; portanto, consulte o estado de uma sala vazia e inofensiva para verificar o contrato JSON exato e depois aguarde um curto período de estabilização:

for attempt in $(seq 1 30); do
  READY="$(curl --silent --show-error "$APP_URL/rooms/cloud-observer/state" || true)"
  test "$(printf '%s' "$READY" | jq -r '.messageCount // -1' 2>/dev/null)" = 0 && break
  sleep 2
done
test "$(printf '%s' "$READY" | jq -r .messageCount)" = 0
sleep 5

Execute o mesmo cliente WebSocket real contra salas exclusivas na nuvem:

node tools/room-clients.mjs "$APP_URL" cloud-planning cloud-support broadcast | tee .labex/cloud-broadcast.json
node tools/room-clients.mjs "$APP_URL" cloud-planning cloud-support invalid | tee .labex/cloud-invalid.json

A saída da nuvem deve mostrar o mesmo comportamento do desenvolvimento local: dois clientes de planning recebem a sequência 1, o cliente de support não recebe nenhuma atualização e a entrada malformada deixa o histórico inalterado.

Abra o APP_URL exibido no navegador. Escolha Connect three clients e depois Send planning update. Os clientes A e B devem mostrar a mesma nova update, enquanto o Cliente C mostra apenas a mensagem ready. Escolha Send malformed update e confirme que o erro aparece somente no Cliente A. Quando terminar de observar o resultado, escolha Disconnect clients e aguarde os três cartões exibirem Closed; isso conclui o handshake de fechamento do WebSocket antes que você saia da página. Esta página é um cliente de observação fornecido; a sonda Node e as verificações do backend continuam sendo as evidências oficiais de aceitação.

Inspecione o cliente do navegador e o Durable Object

Nesta etapa, você relacionará as evidências do runtime ao Cloudflare Dashboard e provará que o histórico persistente da sala permanece depois de uma nova implantação sem alterações.

Mantenha a demonstração do navegador conectada tempo suficiente para inspecionar os três cartões. Os dois cartões de planning são evidências visíveis de uma transmissão limitada ao escopo da sala; o cartão silencioso de support é igualmente importante, pois mostra o que não atravessou o limite de identidade.

Dois clientes de planning recebem a mesma atualização enquanto support permanece silencioso

No Cloudflare Dashboard, abra Workers & Pages, selecione o nome exato armazenado em .labex/run-name e inspecione os bindings. ROOMS deve apontar para RoomBroadcast. Em seguida, abra Durable Objects, selecione o namespace chamado <your-worker>_RoomBroadcast e confirme Storage: SQL em Overview.

O binding ROOMS aponta para o Durable Object RoomBroadcast

O namespace RoomBroadcast pertencente ao Worker usa armazenamento SQL

Abra a aba Logs do namespace. Escolha uma linha recente bem-sucedida associada ao navegador ou à sonda Node. Uma mensagem de aplicação estruturada room_update informa a sequência e a quantidade atual de conexões, sem registrar o texto da mensagem. O Dashboard pode entregar os logs depois da solicitação; as respostas do runtime e as verificações independentes continuam sendo a fonte de autoridade.

Faça a implantação novamente sem alterar o código. As conexões WebSocket abertas são um transporte ativo e não há garantia de que sobrevivam a uma implantação, mas o histórico SQLite pertence ao objeto nomeado e deve permanecer:

npx wrangler deploy
APP_URL="$(cat .labex/app-url)"
curl --silent --fail "$APP_URL/rooms/cloud-planning/state" | jq
curl --silent --fail "$APP_URL/rooms/cloud-support/state" | jq

A sala planning ainda deve informar uma mensagem com a sequência 1; support deve continuar vazia. O sufixo gerado, os timestamps e os totais de tráfego do Dashboard serão diferentes dos exemplos testados.

Exclua o namespace da sala

Nesta etapa, você excluirá exatamente o namespace de Durable Object e o Worker descartáveis, mantendo a VM autorizada por tempo suficiente para que o LabEx confirme que os dois recursos não existem mais.

Confirme que o nome salvo começa com labex-c10-o05-. Crie um ponto de entrada de limpeza sem estado:

RUN="$(cat .labex/run-name)"
case "$RUN" in labex-c10-o05-*) ;; *) echo "Unexpected Worker name" >&2; exit 1;; esac
cat > src/cleanup.js <<'JS'
export default {
  fetch() {
    return Response.json({ status: "cleanup" }, { status: 410 });
  }
};
JS

Crie uma configuração de limpeza para o mesmo Worker e a mesma conta. O marcador state: "deleted" remove somente o namespace da classe deste laboratório, incluindo seus históricos descartáveis:

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.js",
  "compatibility_date": "2026-09-18",
  "workers_dev": true,
  "preview_urls": false,
  "exports": {
    "RoomBroadcast": { "type": "durable-object", "state": "deleted" }
  }
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc

A saída de reconciliação deve informar Deleted: RoomBroadcast. Exclua o Worker sem estado restante. O Wrangler solicitará confirmação porque a exclusão não pode ser desfeita; confirme somente depois que o nome exibido corresponder exatamente ao valor de $RUN:

npx wrangler delete --config wrangler.cleanup.jsonc

No prompt, digite y e pressione Enter. O comando deve terminar com Successfully deleted seguido do nome do Worker gerado.

Mantenha esta VM autorizada para a verificação no final desta etapa. Confirme que o Wrangler ainda informa uma sessão autenticada:

npx wrangler whoami --json | jq '{loggedIn, authType}'

O JSON deve conter "loggedIn": true. Agora o LabEx pode consultar a conta selecionada e provar que tanto o Worker quanto o namespace de Durable Object foram removidos. Um erro de rede ou de autenticação não comprova a limpeza.

Revogue a autorização do Wrangler nesta VM

Nesta etapa, você revogará a autorização OAuth armazenada somente nesta VM nova depois que a exclusão dos recursos na nuvem tiver sido verificada.

wrangler logout remove a autorização local. A verificação estruturada com whoami --json é importante porque a saída legível por humanos pode ser ambígua; o campo loggedIn é o resultado oficial:

npx wrangler logout
npx wrangler whoami --json

O JSON final deve conter "loggedIn": false. Isso não exclui nem desconecta sua conta de aprendizado da Cloudflare no navegador; apenas impede que esta VM faça novas solicitações autenticadas do Wrangler.

Resumo

Você transformou solicitações HTTP em WebSockets, roteou nomes de salas validados para Durable Objects independentes, transmitiu uma atualização aprovada a dois clientes da mesma sala e manteve outra sala isolada. Você separou a análise de JSON da validação da aplicação, provou que uma entrada malformada não alterou o estado da transmissão nem o histórico SQLite, repetiu o comportamento na Cloudflare, inspecionou as visualizações do navegador e do Dashboard, verificou o histórico depois de uma nova implantação e removeu exatamente o namespace descartável.

A regra de design reutilizável é: valide antes de selecionar ou alterar o estado, coordene cada grupo em tempo real por meio da própria identidade estável de objeto e trate as conexões ativas separadamente do histórico persistente da aplicação.