Um chat no MayScreen: como foi e por que foi fácil
· Projeto
O MayScreen nasceu para transmitir a tela com áudio por um link, depois que o Discord perdeu o Go Live no Brasil. Faltava o que toda live tem: um lugar para quem assiste falar. Agora a página da transmissão tem um chat ao lado do vídeo, como no YouTube e na Twitch, reações que sobem pela tela de todo mundo, como no Google Meet, e um botão de curtir. Tudo fica salvo: quem abre uma transmissão encerrada lê o chat inteiro, com o tempo de cada mensagem contado desde o início.
Esperava que fosse um projeto grande. Não foi, e o motivo está numa decisão tomada lá atrás, antes de existir chat.
O chat já estava quase pronto
Para o vídeo ir direto de um navegador para o outro, eles precisam trocar ofertas, respostas e candidatos de rede antes. No MayScreen, isso passa por uma sala: um Durable Object da Cloudflare por transmissão, com o WebSocket de quem transmite e de cada pessoa assistindo. Ou seja, já existia um lugar onde todo mundo daquela transmissão estava conectado ao mesmo tempo, com um canal aberto para mandar mensagens.
Um chat é exatamente isso. Bastou ensinar a sala três mensagens novas:
// o que o navegador pode mandar pela sala (shared/protocol.ts)
| { type: 'join'; role: 'viewer'; nickname: string; slug: string }
| { type: 'signal'; to: string; data: SignalPayload }
// novos:
| { type: 'auth'; token: string }
| { type: 'chat'; text: string; nonce: string }
| { type: 'react'; emoji: Reaction } espectador sala da transmissão dono (estúdio)
(Durable Object)
join ──────────────────────► guarda o socket ◄──────── join + chave
offer/answer/ICE ◄─────────► repassa ◄────────► offer/answer/ICE
auth (chave) ──────────────► identifica no socket
chat ──────────────────────► grava no D1, manda a todos ───────► chat
react ─────────────────────► soma em memória, manda a todos ───► reactionNada de servidor de chat separado, fila, Redis ou serviço de terceiros. O lado do servidor inteiro ganhou 477 linhas:
| Parte | Linhas | O que faz |
|---|---|---|
| Sala (Durable Object) | 149 | identificação, mensagens, reações, ritmo por pessoa |
| Banco (D1) | 135 | gravar e paginar mensagens, curtidas, totais de reações |
| API HTTP | 85 | histórico, apagar mensagem, curtir |
| Migração SQL | 28 | três tabelas e uma coluna |
| Tipos e validação | 80 | protocolo e limpeza do texto, usados nos dois lados |
Entrar no chat sem derrubar o vídeo
Assistir continua sem cadastro. Para escrever, reagir ou curtir, é preciso um nickname, e dá para criar um no próprio chat, sem sair da transmissão. O detalhe é o que acontece no instante seguinte.
O jeito óbvio seria reconectar o WebSocket com a identidade nova. Só que é esse socket que sustenta o vídeo: reconectar faz o transmissor renegociar a conexão WebRTC, e a tela fica preta por alguns segundos. Em vez disso, o navegador manda auth com a chave pelo socket que já está aberto. A sala confere a chave no D1 e anota quem é aquela conexão no próprio attachment do socket, o mesmo lugar onde já guardava o papel de cada um. O vídeo nem percebe.
Gravar antes de mostrar
Cada mensagem vai para o D1 antes de chegar a qualquer pessoa. Não é só cuidado: com a hibernação, a sala não guarda nada em memória quando fica quieta. O histórico precisa estar no banco. O próprio INSERT confere se a transmissão ainda está no ar, porque a sala pode viver alguns instantes depois do fim:
INSERT INTO chat_messages (broadcast_id, user_id, body, created_at)
SELECT ?, ?, ?, ?
WHERE EXISTS (SELECT 1 FROM broadcasts WHERE id = ? AND status = 'live')
RETURNING id, created_atO id que volta dessa inserção é a ordem oficial do chat. Quem chega no meio da transmissão busca as 100 mensagens mais recentes pela API e, ao mesmo tempo, recebe as novas pelo socket. Como as duas fontes podem trazer a mesma mensagem, o navegador junta tudo pelo id, sem duplicar e sem depender de qual chegou primeiro.
Outro detalhe: cada mensagem enviada leva um nonce. A sala devolve a linha gravada com esse nonce só para quem mandou. O campo de texto só é limpo quando essa confirmação chega. Se o servidor recusar, a mensagem de erro volta com o mesmo nonce e o texto continua lá para corrigir.
Reações como no Google Meet
São oito emojis. Um toque e o emoji sobe pela tela de todo mundo, com o nickname de quem reagiu embaixo, inclusive em tela cheia. A altura da subida usa unidades de container query (cqh), então é a mesma proporção da imagem em qualquer tamanho de tela.
Reação vem em rajada, e gravar cada toque no D1 gastaria a cota à toa. A sala soma os toques em memória e grava um total por emoji a cada 5 segundos:
// cada toque soma na memória da sala e já aparece para todos
this.pendingReactions.set(emoji, (this.pendingReactions.get(emoji) ?? 0) + 1);
this.flushTimer ??= setTimeout(() => void this.flushReactions(), 5_000);
this.broadcast({ type: 'reaction', emoji, nickname: user.nickname });O setTimeout parece um risco num objeto que hiberna, mas é o contrário: enquanto houver um timer pendente, o Durable Object não hiberna, então a soma é gravada antes de a memória sumir. Só um deploy ou uma falha bem nesses 5 segundos perderia alguns toques.
A conta fecha no plano gratuito, que dá 100 mil escritas no D1 por dia: cada mensagem custa uma escrita, e as reações de uma transmissão custam no máximo uma escrita por emoji a cada 5 segundos, por mais gente que esteja tocando.
Curtidas pelo HTTP
Curtir é diferente: faz sentido também depois que a transmissão acabou, quando a sala já fechou. Por isso a curtida vai pela API HTTP. O total é recontado na mesma transação que grava a curtida, então clicar duas vezes ou curtir em duas abas não infla o número. Se a transmissão está no ar, a API só avisa a sala, que repassa o total novo para quem está assistindo.
Onde foi o trabalho de verdade
Se o servidor ganhou 477 linhas, a interface ganhou 1.920 (733 só de CSS). É ali que um chat parece profissional ou improvisado:
- A lista só acompanha a mensagem mais nova se você já está no fim. Se você subiu para ler algo, ela fica parada e aparece um aviso de “3 mensagens novas”.
- Carregar mensagens antigas no topo não pode fazer a lista pular: a posição de leitura é compensada pela altura que entrou.
- Cada nickname tem uma cor fixa, calculada a partir do nome, que funciona no tema claro e no escuro. O dono do canal tem um selo, e uma mensagem que cita você aparece destacada.
- A página da transmissão ficou mais larga para caber vídeo e chat lado a lado. No estúdio, são três colunas: prévia, chat e controles. No celular, uma embaixo da outra.
- Uma aba aberta por horas não pode crescer sem limite: ela guarda até mil mensagens e oferece o resto como “carregar anteriores”.
Como testei
Os testes do servidor rodam o Worker real dentro do workerd, via Miniflare, com WebSockets de verdade: anônimo não escreve, a identificação funciona com a chave certa e devolve vazio com a errada, o texto é limpo, a sexta mensagem seguida é recusada, o histórico pagina, as reações chegam a todos e os totais sobrevivem ao fim da transmissão.
O teste de ponta a ponta abre dois navegadores. Um transmite. O outro entra anônimo, cria o nickname pelo chat e confere que o vídeo continua rodando. Depois conversa, reage, curte e vê o dono apagar uma mensagem. No fim, a transmissão é encerrada e a página do histórico precisa mostrar o chat, as curtidas e as reações.
Escrever esses testes achou um bug que não era do chat: o utilitário que espera por uma mensagem no socket deixava a espera para trás quando estourava o tempo, e ela engolia a mensagem seguinte. Nos testes antigos, uma espera que estoura o tempo só aparecia no fim. Nos do chat, o teste confere que algo não chegou e segue esperando outras coisas.
Limites
- O limite de 25 espectadores da mesh vale para o chat também, porque o chat mora na mesma sala.
- Tudo o que se escreve fica público para quem tem o link, durante e depois da transmissão. A política de privacidade diz isso.
- Um chat aberto para qualquer um com um nickname criado em dois segundos precisa de moderação. Isso ficou grande o bastante para um post só dele.
O chat está no ar no MayScreen. A lição que fica é antiga, mas vale repetir: quando a peça que junta as pessoas já existe, o recurso novo é só mais um tipo de mensagem passando por ela.