WebSockets em Zig: Handshake, Frames e Servidor em Tempo Real

Para implementar WebSockets em Zig, trate o canal como dois contratos distintos: um handshake HTTP Upgrade que valida headers e devolve 101 Switching Protocols, e um loop de frames que lê opcode, tamanho, máscara, payload e control frames (ping, pong, close). Zig não precisa de um framework monolítico para isso. O que precisa ficar explícito é validação, limites de memória, backpressure, autenticação no upgrade e encerramento limpo.

Este guia fecha a lacuna entre o artigo de Server-Sent Events em Zig — ideal para push unidirecional — e a operação real descrita em Zig por trás de Nginx. Se a sua base HTTP ainda está nascendo, combine com servidor HTTP em produção, sockets TCP e UDP e rate limiting com token bucket.

Como a API de std.http e o runtime de I/O ainda evoluem antes do Zig 1.0, confirme zig version e a documentação do release instalado. Os nomes de request, writer e peer address podem mudar; as regras do protocolo RFC 6455 e os cuidados de produção permanecem estáveis.

Resposta rápida

NecessidadeRecomendação
Push servidor → cliente sem upload frequenteprefira SSE
Chat, colaboração, terminal, jogoWebSocket
HandshakeGET + Upgrade: websocket + Sec-WebSocket-Key101
Aceite (Sec-WebSocket-Accept)SHA-1(key + GUID) em Base64
Frames do clientepayload mascarado; servidor não mascara
Heartbeatping/pong com prazo curto
Payload máximolimite duro por mensagem e por conexão
Autenticaçãono HTTP upgrade, antes do 101
ProxyNginx com Upgrade/Connection e timeout alto
Observabilidadesepare handshake, mensagens/s, closes e timeouts

O que WebSocket adiciona ao HTTP

HTTP clássico é request/response. SSE mantém uma resposta longa em que o servidor escreve eventos. WebSocket começa em HTTP e depois troca o significado da conexão: os dois lados passam a enviar frames a qualquer momento sobre o mesmo socket TCP (ou TLS).

O fluxo resumido:

  1. o cliente faz um GET com headers de upgrade;
  2. o servidor valida e responde 101;
  3. a partir daí, ambos falam o framing binário do WebSocket;
  4. qualquer lado pode enviar dados, ping ou close.

Essa flexibilidade tem custo. Você passa a gerenciar estado de conexão, fragmentação, controle de fluxo, autenticação contínua e limpeza de sockets ociosos. Em Zig, esse custo aparece cedo porque grande parte da infraestrutura fica no seu código, não escondida em um framework.

Quando WebSocket é a escolha certa

Escolha WebSocket quando o produto realmente precisa de canal bidirecional de baixa latência:

  • chat e presença online;
  • edição colaborativa com cursores e locks curtos;
  • terminal remoto ou console interativa;
  • painéis que também enviam comandos frequentes;
  • jogos e simulações com estado contínuo;
  • sincronização interativa entre dispositivos.

Prefira SSE quando o cliente só consome progresso, logs ou notificações. Prefira polling curto quando a atualização é rara e manter milhares de sockets abertos não vale a pena. Prefira filas e workers quando a entrega pode ser assíncrona e não precisa de push imediato no navegador.

A pergunta útil não é “WebSocket é moderno?”. É “o cliente precisa falar com frequência, com baixa latência, no mesmo canal?”. Se a resposta for não, SSE ou HTTP comum costumam ser melhores.

Handshake: do HTTP para o canal

O cliente envia algo nesta família:

GET /ws HTTP/1.1
Host: api.exemplo.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: https://app.exemplo.com

O servidor só continua se:

  • o método for GET;
  • Upgrade contiver websocket (compare de forma case-insensitive);
  • Connection contiver upgrade;
  • Sec-WebSocket-Version for 13;
  • Sec-WebSocket-Key existir e tiver tamanho plausível;
  • a rota, origem e autenticação forem aceitáveis para o produto.

O valor de Sec-WebSocket-Accept é:

Base64( SHA1( Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" ) )

Para a chave de exemplo acima, o aceite canônico é s3pPLMBiTxaQ9kYGzzhZRbK+xOo=. Use esse vetor em teste unitário do handshake.

Resposta mínima:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Depois do 101, não tente reutilizar a abstração de “responder uma request HTTP” como se ainda houvesse body clássico. Você está em outro protocolo sobre o mesmo socket.

Esboço de validação em Zig

A API exata depende da sua camada HTTP, mas a lógica fica parecida com isto:

const std = @import("std");

const websocket_guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";

fn headerContainsToken(value: []const u8, token: []const u8) bool {
    var it = std.mem.splitScalar(u8, value, ',');
    while (it.next()) |part| {
        const trimmed = std.mem.trim(u8, part, " \t");
        if (std.ascii.eqlIgnoreCase(trimmed, token)) return true;
    }
    return false;
}

fn computeAccept(allocator: std.mem.Allocator, key: []const u8) ![]u8 {
    var digest: [20]u8 = undefined;
    var hasher = std.crypto.hash.Sha1.init(.{});
    hasher.update(key);
    hasher.update(websocket_guid);
    hasher.final(&digest);
    return std.base64.standard.Encoder.allocEncode(allocator, &digest);
}

fn handshakeLooksValid(
    method: []const u8,
    upgrade: ?[]const u8,
    connection: ?[]const u8,
    version: ?[]const u8,
    key: ?[]const u8,
) bool {
    if (!std.mem.eql(u8, method, "GET")) return false;
    const up = upgrade orelse return false;
    const conn = connection orelse return false;
    const ver = version orelse return false;
    const k = key orelse return false;
    if (!headerContainsToken(up, "websocket")) return false;
    if (!headerContainsToken(conn, "upgrade")) return false;
    if (!std.mem.eql(u8, ver, "13")) return false;
    if (k.len < 16 or k.len > 64) return false;
    return true;
}

Rejeite cedo com 400 ou 426 quando o pedido for incompleto. Não abra half-open connections “para ver no que dá”.

Frames: o contrato depois do 101

Todo frame WebSocket tem:

  1. byte de flags + opcode;
  2. byte de máscara + tamanho (7 bits, ou 16/64 bits estendidos);
  3. máscara de 4 bytes se o bit de máscara estiver ligado;
  4. payload.

Opcodes essenciais:

OpcodeSignificado
0x1texto UTF-8
0x2binário
0x8close
0x9ping
0xApong
0x0continuação de mensagem fragmentada

Regras práticas que evitam incidentes:

  • frames do cliente para o servidor chegam mascarados; desemascarar é obrigatório;
  • frames do servidor para o cliente não usam máscara;
  • controle (ping/pong/close) não deve ser fragmentado;
  • texto precisa ser UTF-8 válido antes de entrar na regra de negócio;
  • imponha max_frame_size e max_message_size separados.

Leitura mínima de um frame

const Frame = struct {
    fin: bool,
    opcode: u4,
    payload: []u8,
};

fn readFrame(reader: anytype, allocator: std.mem.Allocator, max_payload: usize) !Frame {
    const b0: u8 = try reader.readByte();
    const b1: u8 = try reader.readByte();

    const fin = (b0 & 0x80) != 0;
    const opcode: u4 = @truncate(b0 & 0x0f);
    const masked = (b1 & 0x80) != 0;
    var payload_len: u64 = b1 & 0x7f;

    if (payload_len == 126) {
        payload_len = try reader.readInt(u16, .big);
    } else if (payload_len == 127) {
        payload_len = try reader.readInt(u64, .big);
    }

    if (payload_len > max_payload) return error.PayloadTooLarge;

    var mask: [4]u8 = .{ 0, 0, 0, 0 };
    if (masked) {
        _ = try reader.readAll(&mask);
    }

    const payload = try allocator.alloc(u8, @intCast(payload_len));
    errdefer allocator.free(payload);
    _ = try reader.readAll(payload);

    if (masked) {
        for (payload, 0..) |*byte, i| {
            byte.* ^= mask[i % 4];
        }
    }

    return .{ .fin = fin, .opcode = opcode, .payload = payload };
}

Esse esboço é didático. Em produção, trate short reads, cancelamento, fragmentação, RSV bits ilegais e close com código/razão. Também decida se mensagens grandes serão rejeitadas, spooladas em disco ou processadas em streaming.

Ping, pong e close

Conexões WebSocket ociosas são baratas na demo e caras em produção. Defina:

  • intervalo de ping do servidor (por exemplo, 20–30 s);
  • prazo máximo sem pong (por exemplo, 10–15 s);
  • código e motivo de close legíveis para telemetria;
  • limpeza imediata de mapas de sessão após close local ou remoto.
fn writeServerPing(writer: anytype, payload: []const u8) !void {
    // fin=1, opcode=0x9, sem máscara
    const len: u8 = @intCast(payload.len);
    try writer.writeAll(&.{ 0x89, len });
    if (payload.len > 0) try writer.writeAll(payload);
}

fn writeServerClose(writer: anytype, code: u16) !void {
    var body: [2]u8 = undefined;
    std.mem.writeInt(u16, &body, code, .big);
    try writer.writeAll(&.{ 0x88, 0x02 });
    try writer.writeAll(&body);
}

Códigos comuns: 1000 (normal), 1001 (going away), 1002 (protocol error), 1009 (message too big), 1011 (internal error). Registre o código no log estruturado junto com a duração da sessão.

Modelo de servidor em Zig

Um desenho simples e operável:

  1. accept/handshake em um loop HTTP;
  2. após 101, mova o socket para um connection registry;
  3. cada conexão tem leitor, escritor, last_activity e user_id;
  4. mensagens de aplicação passam por uma fila interna com limite;
  5. broadcasts usam fan-out com drop policy explícita.

Evite um único mutex global em torno de todo I/O. Prefira:

  • shard por conexão ou por sala;
  • canais com capacidade finita;
  • política clara para cliente lento: drop da mensagem, desconectar, ou mudar para modo degradado.

Backpressure é o detalhe que separa demo de serviço. Se um cliente não consome, alguém paga: memória do processo, latência das outras conexões ou perda silenciosa de eventos. Escolha e meça.

Autenticação e autorização

Autentique no handshake, não “depois de umas mensagens”:

  • cookie de sessão HttpOnly, no mesmo site da app;
  • token em header quando a borda e o cliente permitem;
  • ticket de curta duração emitido por uma rota HTTP anterior.

Evite colocar access tokens longevos em query string de ws:// se os logs de proxy forem guardar a URL completa. Se precisar de ticket na query, faça-o descartável, amarrado a IP/user-agent quando fizer sentido, e com TTL curto.

Depois do upgrade:

  • autorize cada ação por mensagem (subscribe, send, join-room);
  • revogue sessões server-side mesmo com o socket ainda aberto;
  • revalide permissões em mudanças de papel ou banimento.

Combine com cookies e sessões, JWT em APIs e a política de IP de X-Forwarded-For confiável quando o limitador ou a auditoria dependerem do cliente real.

Segurança que não pode ficar implícita

Checklist mínimo antes de expor /ws:

RiscoMitigação
Origin aberto demaisvalide Origin para browsers; não confie só nisso para clientes nativos
Flood de conexõeslimite por IP e por conta; combine com token bucket
Mensagens gigantesmax_payload duro e close 1009
Slow consumerfila limitada + política de drop/desconexão
Dados sensíveis em logsnunca logue payload completo por padrão
XSS virando WS abusecookies HttpOnly/Secure/SameSite e CSRF strategy da app
Proxy mal configuradoteste Upgrade ponta a ponta, não só local

WebSocket não substitui TLS. Em produção use wss:// terminado no Nginx/CDN ou no próprio serviço, com a mesma disciplina de certificados do restante do HTTP.

Nginx, timeouts e borda

O artigo de proxy reverso com Nginx já cobre o bloco essencial. Na prática, um location dedicado evita que timeouts curtos de API REST matem o canal:

location /ws {
    proxy_pass http://zig_upstream;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
}

Sem Upgrade/Connection, o handshake morre. Sem timeout alto, o proxy encerra salas quietas. Sem heartbeat no serviço Zig, você acumula sockets zumbis até esgotar file descriptors.

SSE vs WebSocket vs polling

CritérioSSEWebSocketPolling
Direção principalservidor → clientebidirecionalcliente → servidor
ProtocoloHTTP longoHTTP upgrade + framesHTTP curto
Reconexão no browsernativa (EventSource)manual ou libtrivial
Binárioruim/não naturalnativopossível
Complexidade em Zigmenormédiamenor
Melhor paraprogresso, logs, feedchat, collab, terminalatualização rara

Se o seu painel só mostra status, comece por SSE. Se depois o usuário precisa enviar comandos contínuos no mesmo canal, migre esse fluxo para WebSocket sem forçar o restante da aplicação a mudar.

Observabilidade e testes

Meça pelo menos:

  • websocket_handshake_total{result=ok|reject};
  • websocket_active_connections;
  • websocket_messages_total{opcode,direction};
  • websocket_close_total{code};
  • latência de ping/pong;
  • tamanho médio e máximo de payload;
  • conexões desconectadas por backpressure.

Testes que valem ouro:

  1. vetor canônico do Sec-WebSocket-Accept;
  2. frame texto mascarado pequeno;
  3. payload acima do limite;
  4. ping sem pong até timeout;
  5. close local e remoto;
  6. cliente lento com fila cheia;
  7. upgrade atrás do proxy real de staging.

Para o restante da telemetria HTTP, encaixe no guia de observabilidade com logs e Prometheus.

Biblioteca própria ou dependência?

Implementar o núcleo do protocolo em Zig é viável e educativo. Ainda assim, para produto, avalie bibliotecas do ecossistema com histórico de frames, fragmentação e testes. O registry e a comunidade costumam apontar opções como websocket.zig e frameworks HTTP que já embutem upgrade. Qualquer escolha precisa de:

  • política clara de memória/allocator;
  • suporte a ping/pong e close;
  • limites configuráveis;
  • compatibilidade com a sua versão de Zig;
  • caminho de auditoria para CVEs e releases.

Se a stdlib ou o framework HTTP que você usa já entregar um helper de upgrade estável na sua versão, prefira-o para o handshake e mantenha o loop de aplicação explícito.

Roteiro de implementação em um dia

  1. suba um endpoint /ws que só valida headers e responde 101 em desenvolvimento;
  2. leia e escreva um frame texto eco;
  3. adicione ping/pong e close com logs;
  4. autentique no upgrade com sessão existente;
  5. coloque limites de payload, conexões e fila;
  6. configure Nginx/wss em staging;
  7. só então acrescente rooms, broadcast e regras de negócio.

Esse caminho evita o erro clássico: construir um “chat completo” antes de ter um canal que sobrevive a proxy, timeout e cliente lento.

Conclusão

WebSockets em Zig são um contrato de engenharia, não um recurso mágico de framework. O handshake precisa ser chato e correto. Os frames precisam de limites. O servidor precisa de heartbeat, autenticação no upgrade, backpressure e métricas. Com isso no lugar, Zig brilha: o binário fica pequeno, o comportamento fica explícito e o canal em tempo real deixa de ser uma caixa-preta.

Se o seu próximo passo for só empurrar eventos de progresso, volte ao guia de SSE. Se a operação já exige chat, terminal ou colaboração, implemente o canal WebSocket com os limites acima e coloque a borda Nginx/TLS na frente desde o primeiro deploy sério. O restante da aplicação — health checks, graceful shutdown e workers — continua o mesmo: falhar de forma visível e recuperar sem drama.

Perguntas frequentes

Zig tem WebSocket na biblioteca padrão?

Não como produto completo e congelado. Você implementa handshake/frames, usa biblioteca do ecossistema ou combina os dois. O protocolo é pequeno; o difícil é produção.

Quando escolher WebSocket em vez de SSE?

Quando o cliente também envia mensagens frequentes com baixa latência. Para logs e progresso, SSE costuma ser suficiente e mais simples.

O que o handshake precisa validar?

GET, Upgrade: websocket, Connection: upgrade, versão 13 e Sec-WebSocket-Key válida, além de autenticação/origem da sua aplicação.

Como evitar sockets zumbis?

Ping periódico, timeout de pong, close explícito e limites por IP/usuário, com timeout coerente no proxy.

WebSocket autentica sozinho?

Não. Autentique no upgrade HTTP e continue autorizando por mensagem depois do 101.

Dá para usar Nginx na frente?

Sim, com HTTP/1.1, headers de Upgrade/Connection e timeouts longos na rota do canal.

Continue aprendendo Zig

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