WebSockets em Zig entram quando HTTP tradicional começa a ficar desconfortável: chat interno, painel de métricas, jogo simples, stream de eventos, sincronização entre clientes, logs ao vivo ou uma ferramenta de DevOps que precisa mostrar progresso sem ficar fazendo polling. A ideia é manter uma conexão TCP aberta depois de um HTTP Upgrade e trocar frames bidirecionais enquanto cliente e servidor estiverem vivos.
Este tutorial mostra como pensar em WebSocket server em Zig sem esconder os detalhes importantes. Vamos passar pelo handshake, pelo formato de frames, por um servidor mínimo, por limites de payload, por ping/pong e por decisões de produção. Se você ainda não montou um servidor HTTP, comece pelo guia de Zig Server HTTP com std.http.Server. Se seu objetivo é consumir um WebSocket externo, veja também a receita de cliente WebSocket em Zig.
Quando usar WebSocket em Zig
WebSocket não é substituto universal para REST. Ele vale a pena quando o servidor precisa enviar dados sem esperar uma nova requisição do cliente, ou quando a latência de abrir uma requisição HTTP repetida atrapalha a experiência.
| Caso de uso | WebSocket ajuda? | Observação prática |
|---|---|---|
| Chat, presença e colaboração | Sim | Conexão persistente simplifica broadcast |
| Dashboard de logs ou métricas | Sim | Envie apenas deltas, não snapshots gigantes |
| API CRUD comum | Não | REST ou HTTP JSON continua mais simples |
| Notificação eventual | Talvez | Server-Sent Events pode bastar |
| Jogo ou simulação leve | Sim | Controle limites, frequência e backpressure |
Zig combina bem com esse tipo de servidor porque deixa custos explícitos: cada conexão tem buffer, cada mensagem tem limite, cada alocação precisa de dono. Isso é ótimo para sistemas de tempo real, mas cobra disciplina. Um WebSocket sem limite de payload, sem timeout e sem política de fechamento vira uma forma eficiente de derrubar seu processo.
O protocolo em duas fases
Um WebSocket começa como HTTP. O cliente envia um GET com headers especiais:
GET /ws HTTP/1.1
Host: exemplo.local
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
O servidor valida os headers e responde com 101 Switching Protocols. O valor de Sec-WebSocket-Accept é calculado concatenando a chave recebida com o GUID fixo do protocolo, fazendo SHA-1 e codificando em Base64.
Depois disso, a conexão deixa de ser HTTP comum e passa a trocar frames WebSocket. Cada frame tem opcode, flag de finalização, tamanho e payload. Clientes de navegador sempre enviam frames mascarados; servidores normalmente enviam frames sem máscara. Esse detalhe é obrigatório pelo protocolo e não deve ser ignorado.
Handshake em Zig
O trecho abaixo foca na parte que costuma gerar dúvida: calcular Sec-WebSocket-Accept. Em um servidor real, você também validaria método, path, versão, Upgrade, Connection e limite de tamanho dos headers.
const std = @import("std");
const websocket_guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
fn websocketAcceptKey(key: []const u8, out: *[28]u8) ![]const u8 {
var sha1 = std.crypto.hash.Sha1.init(.{});
sha1.update(key);
sha1.update(websocket_guid);
var digest: [20]u8 = undefined;
sha1.final(&digest);
return std.base64.standard.Encoder.encode(out, &digest);
}
Com a chave calculada, a resposta fica direta:
fn writeUpgradeResponse(writer: anytype, accept_key: []const u8) !void {
try writer.print(
"HTTP/1.1 101 Switching Protocols\r\n" ++
"Upgrade: websocket\r\n" ++
"Connection: Upgrade\r\n" ++
"Sec-WebSocket-Accept: {s}\r\n" ++
"\r\n",
.{accept_key},
);
}
Não use parsing ingênuo em produção para headers HTTP se você aceita tráfego público. O exemplo serve para entender o mecanismo. Em um serviço sério, reaproveite a camada de servidor HTTP, limite headers, normalize nomes e rejeite upgrades incompletos com status claro.
Lendo frames com limite de payload
O frame WebSocket começa com dois bytes. O primeiro indica FIN e opcode. O segundo indica se há máscara e o tamanho inicial. Tamanhos 126 e 127 significam que o tamanho real vem nos próximos bytes.
Para tutorial, vamos limitar mensagens a 64 KiB e tratar apenas frames texto, ping, pong e close. Isso já cobre chat, logs pequenos e comandos de controle.
const Opcode = enum(u4) {
continuation = 0x0,
text = 0x1,
binary = 0x2,
close = 0x8,
ping = 0x9,
pong = 0xA,
};
const Frame = struct {
opcode: Opcode,
payload: []u8,
};
fn readFrame(allocator: std.mem.Allocator, reader: anytype) !Frame {
var header: [2]u8 = undefined;
try reader.readNoEof(&header);
const opcode: Opcode = @enumFromInt(header[0] & 0x0F);
const masked = (header[1] & 0x80) != 0;
var len: u64 = header[1] & 0x7F;
if (len == 126) {
var ext: [2]u8 = undefined;
try reader.readNoEof(&ext);
len = std.mem.readInt(u16, &ext, .big);
} else if (len == 127) {
var ext: [8]u8 = undefined;
try reader.readNoEof(&ext);
len = std.mem.readInt(u64, &ext, .big);
}
if (len > 64 * 1024) return error.PayloadTooLarge;
var mask: [4]u8 = .{ 0, 0, 0, 0 };
if (masked) try reader.readNoEof(&mask);
const payload = try allocator.alloc(u8, @intCast(len));
errdefer allocator.free(payload);
try reader.readNoEof(payload);
if (masked) {
for (payload, 0..) |*byte, i| {
byte.* ^= mask[i % 4];
}
}
return .{ .opcode = opcode, .payload = payload };
}
O ponto mais importante não é decorar bits. É notar as defesas: limite antes de alocar, errdefer para não vazar, leitura exata e tratamento explícito de máscara. Em WebSocket público, você também deve validar UTF-8 em mensagens texto, recusar frames fragmentados se não implementou continuação e fechar a conexão com código apropriado quando algo violar o protocolo.
Escrevendo frames do servidor
Servidores não precisam mascarar frames. Um envio simples de texto pode ser assim:
fn writeTextFrame(writer: anytype, payload: []const u8) !void {
if (payload.len > 64 * 1024) return error.PayloadTooLarge;
try writer.writeByte(0x80 | @intFromEnum(Opcode.text));
if (payload.len < 126) {
try writer.writeByte(@intCast(payload.len));
} else {
try writer.writeByte(126);
var len_buf: [2]u8 = undefined;
std.mem.writeInt(u16, &len_buf, @intCast(payload.len), .big);
try writer.writeAll(&len_buf);
}
try writer.writeAll(payload);
}
Para ping, pong e close, a estrutura é parecida, mudando opcode e payload. Responda ping com pong rapidamente. Se o cliente mandar close, envie close de volta e encerre o stream. Não mantenha conexão zumbi.
Loop de conexão
Um loop mínimo por conexão pode usar uma arena por mensagem. Isso evita acumular buffers quando o servidor fica horas aberto:
fn handleWebSocket(allocator: std.mem.Allocator, stream: std.net.Stream) !void {
defer stream.close();
const reader = stream.reader();
const writer = stream.writer();
while (true) {
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
const frame = readFrame(arena.allocator(), reader) catch |err| {
std.log.warn("websocket read failed: {s}", .{@errorName(err)});
break;
};
switch (frame.opcode) {
.text => try writeTextFrame(writer, frame.payload),
.ping => try writePongFrame(writer, frame.payload),
.pong => {},
.close => break,
else => return error.UnsupportedFrame,
}
}
}
Esse exemplo é um eco controlado. Em um chat real, você teria um registro de conexões, uma fila de saída por cliente e uma política para desconectar consumidores lentos. Sem isso, um cliente que não lê pode travar broadcast para todos.
Arquitetura para produção
Antes de publicar um WebSocket em Zig, defina estes limites:
- Tamanho máximo de header e payload.
- Número máximo de conexões simultâneas.
- Timeout de handshake e leitura.
- Frequência máxima de mensagens por cliente.
- Tamanho da fila de saída por conexão.
- Política para ping/pong e conexões ociosas.
- Logs estruturados sem payload sensível.
- Métricas de conexões abertas, mensagens, bytes, erros e desconexões.
Esses pontos conectam diretamente com o guia de HTTP server em produção e com observabilidade em Zig. Um WebSocket parece simples em desenvolvimento local, mas em produção ele é um recurso reservado por cliente. Cada aba aberta mantém file descriptor, memória e estado.
Se você está comparando stacks para serviços real-time, Go ainda tem bibliotecas e exemplos mais maduros. O cluster de programação do portfólio tem um bom ponto de partida em Go para backend e serviços concorrentes. A escolha pragmática não é transformar Zig em framework web pesado; é usar Zig quando controle de binário, latência previsível e integração com sistemas importam mais que velocidade de prototipagem.
Próximos passos
Para evoluir este tutorial, siga esta ordem:
- Revise sockets TCP e UDP em Zig para entender a base da conexão.
- Leia Zig Server HTTP com
std.http.Serverpara tratar oHTTP Upgradedentro da pilha web. - Use a receita de cliente WebSocket em Zig para testar interoperabilidade.
- Combine com io_uring em Zig se o serviço precisa de muitas conexões no Linux.
- Feche com observabilidade em Zig antes de colocar tráfego real.
WebSocket em Zig é viável, mas o valor está menos no exemplo curto e mais na clareza operacional. Limite entrada antes de alocar, trate fechamento como parte do protocolo, monitore conexões abertas e evite prometer broadcast infinito. Com esses cuidados, Zig vira uma boa opção para servidores real-time pequenos, agentes locais, dashboards técnicos e ferramentas internas que precisam ser rápidas, previsíveis e fáceis de distribuir.