Como Criar um Cliente WebSocket em Zig

Introdução

WebSocket é um protocolo de comunicação bidirecional que opera sobre TCP. Diferente do HTTP tradicional (request-response), WebSocket permite que tanto o cliente quanto o servidor enviem mensagens a qualquer momento, sendo ideal para aplicações em tempo real como chats, dashboards e jogos online.

Nesta receita, você aprenderá a implementar um cliente WebSocket em Zig, desde o handshake HTTP até o envio e recebimento de frames.

Pré-requisitos

Protocolo WebSocket

O WebSocket começa com um handshake HTTP e depois troca frames binários:

  1. Handshake: O cliente envia um HTTP Upgrade request
  2. Comunicação: Mensagens são trocadas como frames WebSocket
  3. Fechamento: Troca de frames Close para encerrar

Cliente WebSocket Básico

Implementação de um cliente WebSocket usando a biblioteca padrão:

const std = @import("std");
const net = std.net;
const crypto = std.crypto;

const WebSocketClient = struct {
    stream: net.Stream,
    allocator: std.mem.Allocator,

    const Opcode = enum(u4) {
        continuation = 0x0,
        text = 0x1,
        binary = 0x2,
        close = 0x8,
        ping = 0x9,
        pong = 0xA,
    };

    pub fn connect(
        allocator: std.mem.Allocator,
        host: []const u8,
        port: u16,
        path: []const u8,
    ) !WebSocketClient {
        // Conectar via TCP
        const stream = try net.tcpConnectToHost(allocator, host, port);
        errdefer stream.close();

        // Gerar chave WebSocket aleatória
        var key_bytes: [16]u8 = undefined;
        crypto.random.bytes(&key_bytes);

        const key = std.base64.standard.Encoder.encode(&[_]u8{0} ** 24, &key_bytes);

        // Enviar handshake HTTP
        var buf: [1024]u8 = undefined;
        const request = try std.fmt.bufPrint(&buf,
            "GET {s} HTTP/1.1\r\n" ++
            "Host: {s}\r\n" ++
            "Upgrade: websocket\r\n" ++
            "Connection: Upgrade\r\n" ++
            "Sec-WebSocket-Key: {s}\r\n" ++
            "Sec-WebSocket-Version: 13\r\n" ++
            "\r\n",
            .{ path, host, key },
        );

        _ = try stream.write(request);

        // Ler resposta do handshake
        var response_buf: [1024]u8 = undefined;
        const n = try stream.read(&response_buf);
        const response = response_buf[0..n];

        // Verificar se o upgrade foi aceito
        if (std.mem.indexOf(u8, response, "101") == null) {
            return error.HandshakeFailed;
        }

        return .{
            .stream = stream,
            .allocator = allocator,
        };
    }

    /// Enviar uma mensagem de texto
    pub fn sendText(self: *WebSocketClient, message: []const u8) !void {
        try self.sendFrame(.text, message);
    }

    /// Enviar um frame WebSocket mascarado
    fn sendFrame(self: *WebSocketClient, opcode: Opcode, payload: []const u8) !void {
        var header: [14]u8 = undefined;
        var header_len: usize = 2;

        // Byte 1: FIN + opcode
        header[0] = 0x80 | @intFromEnum(opcode);

        // Byte 2: MASK + payload length
        if (payload.len < 126) {
            header[1] = 0x80 | @intCast(payload.len);
        } else if (payload.len <= 65535) {
            header[1] = 0x80 | 126;
            header[2] = @intCast((payload.len >> 8) & 0xFF);
            header[3] = @intCast(payload.len & 0xFF);
            header_len = 4;
        } else {
            header[1] = 0x80 | 127;
            const len: u64 = @intCast(payload.len);
            inline for (0..8) |i| {
                header[2 + i] = @intCast((len >> @intCast(56 - i * 8)) & 0xFF);
            }
            header_len = 10;
        }

        // Gerar máscara
        var mask: [4]u8 = undefined;
        crypto.random.bytes(&mask);

        @memcpy(header[header_len..][0..4], &mask);
        header_len += 4;

        // Enviar header
        _ = try self.stream.write(header[0..header_len]);

        // Enviar payload mascarado
        var masked = try self.allocator.alloc(u8, payload.len);
        defer self.allocator.free(masked);

        for (payload, 0..) |byte, i| {
            masked[i] = byte ^ mask[i % 4];
        }
        _ = try self.stream.write(masked);
    }

    /// Receber uma mensagem
    pub fn receive(self: *WebSocketClient, buffer: []u8) !?struct {
        opcode: Opcode,
        data: []const u8,
    } {
        var header: [2]u8 = undefined;
        const header_read = try self.stream.read(&header);
        if (header_read == 0) return null;

        const opcode: Opcode = @enumFromInt(header[0] & 0x0F);
        const masked = (header[1] & 0x80) != 0;
        var payload_len: u64 = header[1] & 0x7F;

        if (payload_len == 126) {
            var ext: [2]u8 = undefined;
            _ = try self.stream.read(&ext);
            payload_len = (@as(u64, ext[0]) << 8) | ext[1];
        } else if (payload_len == 127) {
            var ext: [8]u8 = undefined;
            _ = try self.stream.read(&ext);
            payload_len = 0;
            inline for (0..8) |i| {
                payload_len |= @as(u64, ext[i]) << @intCast(56 - i * 8);
            }
        }

        // Ler máscara se presente
        var mask: [4]u8 = .{ 0, 0, 0, 0 };
        if (masked) {
            _ = try self.stream.read(&mask);
        }

        // Ler payload
        const len: usize = @intCast(@min(payload_len, buffer.len));
        var total_read: usize = 0;
        while (total_read < len) {
            const n = try self.stream.read(buffer[total_read..len]);
            if (n == 0) break;
            total_read += n;
        }

        // Desmascarar se necessário
        if (masked) {
            for (buffer[0..total_read], 0..) |*byte, i| {
                byte.* ^= mask[i % 4];
            }
        }

        return .{
            .opcode = opcode,
            .data = buffer[0..total_read],
        };
    }

    /// Enviar frame de ping
    pub fn ping(self: *WebSocketClient) !void {
        try self.sendFrame(.ping, "");
    }

    /// Fechar a conexão WebSocket
    pub fn close(self: *WebSocketClient) void {
        self.sendFrame(.close, "") catch {};
        self.stream.close();
    }
};

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    // Conectar ao servidor WebSocket
    var ws = try WebSocketClient.connect(
        allocator,
        "echo.websocket.org",
        80,
        "/",
    );
    defer ws.close();

    std.debug.print("Conectado ao WebSocket!\n", .{});

    // Enviar mensagem
    try ws.sendText("Olá, WebSocket!");

    // Receber resposta
    var buffer: [4096]u8 = undefined;
    if (try ws.receive(&buffer)) |msg| {
        std.debug.print("Recebido ({s}): {s}\n", .{
            @tagName(msg.opcode),
            msg.data,
        });
    }
}

Loop de Mensagens

Para uma aplicação interativa que mantém a conexão aberta:

const std = @import("std");

// Usando o WebSocketClient definido acima

pub fn messageLoop(ws: *WebSocketClient) !void {
    var buffer: [4096]u8 = undefined;

    // Enviar algumas mensagens
    const messages = [_][]const u8{
        "Primeira mensagem",
        "Segunda mensagem",
        "Terceira mensagem",
    };

    for (messages) |msg| {
        try ws.sendText(msg);
        std.debug.print("Enviado: {s}\n", .{msg});

        // Esperar resposta
        if (try ws.receive(&buffer)) |response| {
            switch (response.opcode) {
                .text => std.debug.print("Texto: {s}\n", .{response.data}),
                .ping => {
                    std.debug.print("Ping recebido, enviando pong...\n", .{});
                    // Responder com pong automaticamente
                },
                .close => {
                    std.debug.print("Servidor solicitou fechamento.\n", .{});
                    return;
                },
                else => std.debug.print("Frame tipo: {s}\n", .{@tagName(response.opcode)}),
            }
        }

        std.time.sleep(std.time.ns_per_s);
    }
}

Dicas e Boas Práticas

  1. Sempre mascare frames: O protocolo WebSocket exige que frames do cliente sejam mascarados com uma chave aleatória de 4 bytes.

  2. Responda a Pings: Servidores enviam frames Ping para manter a conexão. Responda sempre com Pong.

  3. Trate desconexões: A conexão pode ser fechada pelo servidor a qualquer momento. Implemente reconexão automática se necessário.

  4. Use TLS para segurança: Em produção, use wss:// (WebSocket Secure) em vez de ws://.

  5. Fragmente mensagens grandes: O protocolo suporta fragmentação para mensagens maiores que o buffer.

Receitas Relacionadas

Tutoriais Relacionados

Continue aprendendo Zig

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