Trabalho

Projeto pessoal · 2026 · em desenvolvimento

Blippy

Plataforma de comunidades com servidores, canais de texto e voz, anexos e tela. Sou o autor principal; o projeto tem um colaborador nos recursos de mídia avançada.

Papel
Autor principal (arquitetura e implementação)
Stack
NestJS, PostgreSQL, Redis, Go, NATS JetStream, Protobuf, LiveKit, Vue 3, Electron
  • 3nós no cluster NATS JetStream
  • ~94%dos commits, como autor principal

O problema

Queria estudar de perto o que sustenta um chat em tempo real: entregar a mensagem rápido, não perdê-la quando algo cai e não duplicá-la quando o cliente tenta de novo. O Blippy é uma plataforma de comunidades no estilo Discord, com servidores, canais de texto e voz, permissões, anexos e compartilhamento de tela.

Os problemas difíceis estão no meio do caminho. Se a API grava a mensagem no banco e morre antes de avisar o broker, ninguém recebe. Se publica antes de gravar, anuncia algo que não existe. Se o cliente reenvia um POST que deu timeout, a mensagem aparece duas vezes.

Desenhei o contrato pensando em escala grande, mas implementei só o que um MVP pede, em fatias verificáveis.

O que eu fiz

Dois planos

Separei o sistema em control plane e data plane. A API em NestJS (Fastify), com PostgreSQL e Redis, decide e persiste. Um gateway de tempo real em Go mantém os WebSockets e empurra os eventos. NATS JetStream em cluster liga os dois. Se a API cai, o sistema degrada. Se o gateway cai, o tempo real para. Há uma checagem de CI que falha se o gateway importar um driver de banco.

O gateway em Go foi escrito com forte apoio de IA. O contrato Protobuf, as decisões e a revisão são minhas.

Envio idempotente e outbox

O cliente gera um nonce e mostra a mensagem como pendente. A API grava mensagem, nonce e evento de saída (outbox) na mesma transação. Só depois publica no NATS. Se o processo morrer entre o commit e a publicação, um reconciliador republica.

blippy · #geral
a
ana n-7f3a

bora call às 21h?

l
leo n-91bc

fechou, abro o canal de voz

log do cliente

  1. envie uma mensagem…
Simplificado. O nonce é gerado no cliente; o servidor guarda os nonces recentes e responde ao reenvio com a mensagem que já existe.
// Dentro da transação de envio (pseudocódigo)
try {
  await tx.query(
    `INSERT INTO message_nonces (channel_id, author_id, nonce, message_id)
     VALUES ($1, $2, $3, $4)`, [canal, autor, nonce, id]);
} catch (err) {
  // Retry do mesmo envio: devolve o id que já existe
  if (isUniqueViolation(err)) return { duplicate: true, id: await buscarPorNonce(...) };
  throw err;
}
await tx.query('INSERT INTO messages ...');
await tx.query('INSERT INTO outbox ...'); // evento na mesma transação

Contrato único e IDs

O contrato entre TypeScript e Go é Protobuf gerado, com checagem na CI. Os IDs são Snowflake, com lease do node-id no Redis. As tabelas de mensagens são particionadas no Postgres.

Voz, tela e desktop

Voz e tela usam LiveKit, um SFU self-hosted. O app desktop é Electron, com pipeline de release multiplataforma. Os recursos de mídia avançada, como supressão de ruído e múltiplas lives, são de um colaborador do projeto.

Qualidade

Gates com semgrep, gitleaks, actions fixadas por SHA e mutation testing. O outbox tem teste com injeção de falha: a API "morre" depois do commit e o teste confere que o reconciliador entrega o evento.

Decisões e trade-offs

NATS com JetStream em vez de Kafka, RabbitMQ ou Redis Pub/Sub. Os subjects hierárquicos com curinga, como guild.*.channel.*.msg, casam com o roteamento por servidor e canal. Kafka pesa para tópicos tão granulares. Redis Pub/Sub não persiste nada. O custo: hoje o gateway assina o modo simples do NATS e ninguém consome o stream persistido, que só acumula mensagens. É preparação para consumidores futuros, e precisa de limite de retenção.

Outbox transacional em vez de publicar e torcer. Garante que nenhum evento se perde. O custo é aceitar entrega pelo menos uma vez, com possível duplicata. O consumidor precisa tolerar isso.

Postgres particionado em vez de Scylla. O modelo de dados segue o formato do Scylla, com chave por canal e janela de tempo, mas roda em Postgres. Evita custo operacional prematuro e deixa o caminho de migração aberto. O custo: o schema é criado em código, no boot, sem migrations versionadas.

Payload opaco no gateway. O gateway numera os eventos sem decodificar o conteúdo. Fica mais barato por mensagem e eventos novos não exigem mudar o gateway. O custo é perder visibilidade do conteúdo naquela camada.

Resultado

O Blippy roda de ponta a ponta: servidores, canais de texto e voz, anexos, compartilhamento de tela e app desktop. Sou o autor principal, com cerca de 94% dos commits. O restante é, em sua maior parte, de um colaborador, nos recursos de mídia.

Teve incidente real: o LiveKit chegou a anunciar a porta ICE errada, e corrigi.

Não há números de usuários, e não vou inventá-los. O valor aqui está no desenho e nas garantias que consegui testar.

O que eu faria diferente

Usaria migrations versionadas desde o começo. Schema criado no boot não altera nada e ainda pode colidir quando várias réplicas sobem juntas.

Colocaria FOR UPDATE SKIP LOCKED no reconciliador do outbox. Com mais de uma réplica da API, todas varreriam as mesmas linhas e publicariam duplicado.

Resolveria o resume de sessão fora da memória do processo. Hoje, depois de um deploy, o cliente refaz a sessão do zero.

Trocaria o mutex único do gateway por mapas particionados e definiria retenção para o stream do JetStream e para a tabela de nonces.

Maycon Liborio

São José dos Campos, SP

Aceito projetos freelance pontuais.

© 2026 · Feito à mão com Nuxt, CSS moderno e zero bibliotecas de UI. Dados gerados do meu perfil-mestre a cada build.

esc

Navegar

Projetos

Ações

↑↓ navegar↵ abrir/ ou ⌘K alterna