Zig e Server-Sent Events: Streams HTTP Simples para Tempo Real

Nem toda interface em tempo real precisa de WebSocket. Muitas telas só precisam receber atualizações do servidor: progresso de um job, logs de deploy, status de uma fila, notificação simples, métrica operacional ou evento de auditoria. Para esse tipo de fluxo, Server-Sent Events (SSE) costuma ser mais simples: continua sendo HTTP, usa o EventSource nativo do navegador e mantém uma conexão aberta em que o servidor escreve eventos de texto.

Zig combina bem com SSE porque o protocolo é pequeno e explícito. Não há handshake WebSocket, frame binário mascarado, subprotocolo nem biblioteca pesada obrigatória. O servidor precisa cuidar de headers, flush, keepalive, limites de conexão, encerramento e backpressure. Isso é exatamente o tipo de contrato que Zig incentiva a deixar visível.

Este artigo mostra quando escolher SSE em vez de WebSocket, como formatar eventos, como desenhar um endpoint em Zig, quais cuidados entram em produção e como encaixar o recurso no restante de uma aplicação HTTP. Se você ainda está montando a base do backend, leia também Zig HTTP Server em Produção, Zig e WebSockets, filas e workers em background e networking com sockets TCP e UDP.

O que é Server-Sent Events

SSE é um padrão do navegador para receber eventos enviados pelo servidor sobre uma conexão HTTP longa. No cliente, a API é simples:

const events = new EventSource("/events");

events.addEventListener("job-progress", (event) => {
  const payload = JSON.parse(event.data);
  console.log(payload.percent, payload.message);
});

events.onerror = () => {
  console.log("conexão SSE interrompida; o navegador tentará reconectar");
};

O servidor responde com Content-Type: text/event-stream e escreve blocos de texto. Cada evento termina com uma linha em branco:

event: job-progress
id: 42
data: {"percent":65,"message":"compactando artefatos"}

O navegador mantém a conexão aberta, entrega eventos para os listeners e tenta reconectar automaticamente quando a conexão cai. Se o servidor enviar id:, o navegador passa esse valor no header Last-Event-ID na reconexão. Isso permite retomar um fluxo quando a aplicação guarda um pequeno histórico de eventos.

Quando usar SSE em Zig

SSE é excelente quando o fluxo é majoritariamente servidor -> cliente. Exemplos práticos:

  • logs ao vivo de uma tarefa interna;
  • barra de progresso de importação, backup, build ou deploy;
  • painel de status de workers;
  • notificações administrativas simples;
  • feed de auditoria em uma dashboard;
  • métricas leves que mudam a cada poucos segundos;
  • acompanhamento de uma fila sem polling agressivo.

Nesses casos, WebSocket pode funcionar, mas adiciona uma superfície maior. Você precisa lidar com upgrade HTTP, frames, ping/pong, mensagens do cliente, multiplexação própria e bibliotecas específicas. SSE continua dentro do modelo HTTP conhecido. Proxy, logs, autenticação por cookie ou header e observabilidade ficam mais próximos do restante da aplicação.

Use WebSocket quando o cliente também precisa mandar mensagens frequentes para o servidor com baixa latência: chat bidirecional, colaboração em tempo real, jogo, terminal remoto ou sincronização interativa. Use polling quando a atualização é rara e não vale manter conexão aberta. Use SSE quando o servidor precisa empurrar eventos pequenos, ordenados e legíveis.

Formato do evento

O formato text/event-stream é linha a linha. Os campos mais usados são:

CampoFunção
event:nome lógico do evento, como job-progress ou deploy-log
data:conteúdo entregue ao cliente; pode aparecer em múltiplas linhas
id:identificador usado em reconexão com Last-Event-ID
retry:sugestão de tempo de reconexão em milissegundos
:comentário; útil para heartbeat

Um heartbeat mínimo pode ser apenas:

: keepalive

Esse comentário não dispara evento no cliente, mas mantém a conexão ativa em proxies que encerram conexões ociosas. Em produção, enviar heartbeat a cada 15 a 30 segundos costuma ser mais seguro do que esperar o primeiro evento real depois de minutos de silêncio.

Endpoint mínimo em Zig

O detalhe mais importante é responder com os headers corretos e escrever no corpo sem encerrar imediatamente. A API exata depende da versão do Zig e da camada HTTP usada, mas a estrutura é esta:

const std = @import("std");

fn writeSseEvent(
    writer: anytype,
    event_name: []const u8,
    id: u64,
    data_json: []const u8,
) !void {
    try writer.print("event: {s}\n", .{event_name});
    try writer.print("id: {d}\n", .{id});
    try writer.print("data: {s}\n\n", .{data_json});
}

fn writeHeartbeat(writer: anytype) !void {
    try writer.writeAll(": keepalive\n\n");
}

Em um handler HTTP real, os headers devem deixar claro que a resposta é um stream e não deve ser cacheada:

try request.respondStreaming(.{
    .status = .ok,
    .extra_headers = &.{
        .{ .name = "content-type", .value = "text/event-stream; charset=utf-8" },
        .{ .name = "cache-control", .value = "no-cache, no-transform" },
        .{ .name = "connection", .value = "keep-alive" },
        .{ .name = "x-accel-buffering", .value = "no" },
    },
});

x-accel-buffering: no é especialmente útil atrás de Nginx, porque evita que o proxy segure eventos pequenos em buffer antes de entregar ao navegador. Em Caddy, Traefik, Cloudflare Tunnel ou outro proxy, revise a configuração equivalente para streaming e timeouts.

Gerando JSON com segurança

O campo data: geralmente carrega JSON. Evite montar JSON com concatenação manual quando os valores vêm de usuário, logs ou sistemas externos. Use std.json.stringify para serializar uma struct ou um objeto conhecido.

const Progress = struct {
    percent: u8,
    message: []const u8,
};

fn sendProgress(writer: anytype, id: u64, progress: Progress) !void {
    var buffer: [512]u8 = undefined;
    var stream = std.io.fixedBufferStream(&buffer);
    try std.json.stringify(progress, .{}, stream.writer());

    try writeSseEvent(
        writer,
        "job-progress",
        id,
        stream.getWritten(),
    );
}

Para payloads maiores, troque o buffer fixo por um allocator com limite claro. O ponto é manter a decisão explícita: qual é o tamanho máximo de um evento? O que acontece se a mensagem passar desse tamanho? Em uma aplicação operacional, é melhor descartar ou truncar um log grande do que deixar uma conexão SSE consumir memória sem controle.

Arquitetura com filas e assinantes

Um endpoint SSE isolado não resolve distribuição de eventos. Em geral, a aplicação tem produtores e assinantes:

worker / rota / scheduler
  -> barramento local de eventos
  -> conexões SSE inscritas
  -> navegador

Para um serviço pequeno, um barramento em memória com mutex, condition variable e lista de assinantes pode bastar. Cada conexão SSE registra um canal, recebe eventos enquanto estiver viva e remove sua inscrição ao desconectar. Para múltiplos processos ou múltiplas réplicas, use uma fonte compartilhada: Redis Pub/Sub, NATS, Postgres LISTEN/NOTIFY, uma fila persistente ou uma tabela de eventos consultada por cursor.

Zig entra bem no caso local e previsível. Você controla quantas conexões são aceitas, quantos eventos ficam em buffer por cliente e o que fazer quando um cliente lento não acompanha. Esse último ponto é crítico: um painel aberto em uma aba esquecida não pode travar todos os produtores.

Backpressure e cliente lento

SSE parece simples, mas ainda é I/O de rede. O cliente pode estar em conexão móvel, navegador suspenso, VPN instável ou proxy corporativo. Se o servidor escreve eventos mais rápido do que a rede entrega, algum buffer cresce. O projeto precisa escolher uma política.

Políticas comuns:

  • manter um buffer pequeno por conexão e desconectar cliente lento quando encher;
  • agrupar eventos frequentes em snapshots periódicos;
  • enviar apenas o estado mais recente para painéis de status;
  • persistir eventos em banco e permitir retomada por Last-Event-ID;
  • separar logs detalhados de eventos de UI.

Para progresso de job, normalmente basta enviar o estado mais recente. Se o cliente perdeu os eventos de 31%, 32% e 33%, receber 34% já resolve. Para logs de auditoria, perda pode ser inaceitável; nesse caso, SSE deve ser apenas transporte de leitura sobre uma fonte persistente.

Autenticação e autorização

SSE não remove a necessidade de segurança. Um endpoint /events pode vazar logs, IDs internos, nomes de arquivos, status de deploy, mensagens de erro e dados de usuário. Trate-o como qualquer rota autenticada.

Em aplicações web, EventSource envia cookies por padrão para a mesma origem. Isso facilita integração com sessões existentes. Para APIs com bearer token, a API nativa EventSource não permite configurar headers arbitrários diretamente; nesse caso, prefira cookie seguro, URL assinada curta ou uma implementação baseada em fetch com stream manual quando o ambiente permitir.

Também valide escopo. Um usuário que pode ver o job A não deve assinar eventos do job B. Coloque autorização antes de abrir o stream e repita checks quando o evento envolve recurso sensível. Não confie apenas no fato de a conexão já estar aberta.

Produção: proxy, timeout e observabilidade

O checklist de produção para SSE é parecido com o de qualquer servidor HTTP, mas alguns itens ficam mais importantes:

  • desative buffering no proxy para a rota de eventos;
  • configure timeouts longos o suficiente para streams;
  • envie heartbeat periódico;
  • limite conexões simultâneas por usuário ou IP;
  • exponha métrica de conexões SSE abertas;
  • conte desconexões, reconexões e erros de escrita;
  • defina tamanho máximo de evento;
  • faça shutdown gracioso, avisando clientes antes de encerrar.

Uma mensagem de shutdown pode ser um evento nomeado:

event: server-shutdown
data: {"message":"servidor reiniciando; reconecte em alguns segundos"}

Depois disso, o servidor fecha a conexão. O navegador tentará reconectar. Se o deploy troca versão rapidamente, esse comportamento costuma ser suficiente para dashboards internos.

SSE, WebSocket ou polling?

Uma decisão prática:

NecessidadeEscolha provável
Atualização eventual a cada minutoPolling simples
Logs/progresso/status servidor -> navegadorSSE
Chat, colaboração, terminal remotoWebSocket
Mensageria entre serviçosNATS, Kafka, RabbitMQ, Redis ou fila própria
Streaming binário grandeProtocolo específico ou download chunked

O erro comum é escolher WebSocket porque parece mais moderno. Para muitas ferramentas internas, SSE entrega 80% do valor com 30% da complexidade. Em Zig, essa diferença importa porque você tende a escrever mais infraestrutura manualmente. Menos protocolo significa menos código para revisar, testar e manter.

Exemplo de fluxo para progresso de job

Imagine um importador de CSV. A rota POST /imports cria um job e retorna job_id. O frontend abre /imports/{job_id}/events. O worker publica progresso conforme valida linhas, grava registros e finaliza.

Eventos possíveis:

event: import-started
id: 1
data: {"job_id":"imp_123","total_rows":50000}

event: import-progress
id: 2
data: {"processed":10000,"total":50000,"percent":20}

event: import-finished
id: 3
data: {"inserted":49820,"rejected":180}

Esse desenho evita polling a cada segundo, mantém a UI responsiva e deixa o worker independente da conexão do navegador. Se o usuário fecha a aba, o job continua. Se a conexão cai, a tela reconecta e pode buscar o último estado pela API comum.

Onde Zig ajuda e onde não ajuda

Zig ajuda quando você quer um serviço pequeno, com uso de memória previsível e controle sobre cada conexão. É uma boa escolha para painéis internos, ferramentas de deploy, agentes locais, CLIs com UI web, servidores de logs leves e APIs que já têm uma base HTTP em Zig.

Zig não resolve sozinho os problemas distribuídos. Se você precisa entregar eventos para milhares de clientes em múltiplas regiões, com replay durável, permissões complexas e fanout alto, o centro da solução provavelmente será uma infraestrutura de eventos. Zig pode continuar como produtor ou consumidor, mas não precisa reinventar o broker.

Para comparar a cultura operacional de serviços HTTP e streaming em outra linguagem, o Golang Brasil é uma boa referência: Go tem bibliotecas maduras e concorrência mais conveniente. Zig é mais manual, mas oferece binários simples, ausência de garbage collector e controle direto sobre buffers e alocação. A escolha certa depende do tamanho do time, do risco operacional e da vida útil esperada do serviço.

Conclusão

Server-Sent Events é uma ferramenta subestimada para aplicações Zig. Quando o problema é mostrar progresso, logs ou status em tempo real, SSE mantém a solução dentro de HTTP, reduz complexidade e conversa bem com o modelo explícito da linguagem. O essencial é não tratar o stream como brinquedo: defina limites, heartbeat, autorização, política para cliente lento e métricas de produção.

Se o seu próximo backend em Zig já tem uma rota de health check, logs estruturados e workers em background, SSE pode ser a peça que falta para tornar o sistema observável para o usuário sem transformar tudo em WebSocket. Comece pequeno: um endpoint para progresso de job, eventos JSON curtos, buffer limitado e uma reconexão simples no navegador. Depois evolua apenas onde a operação pedir.

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.