websocket.zig

repository·master·Indexed 19 days ago

https://github.com/karlseguin/websocket.zig

A high-performance WebSocket server and client implementation for Zig. It features a structured handler-based API for managing connections, handshakes, and message routing, with support for non-blocking I/O via epoll/kqueue on supported platforms. The library includes built-in support for compression, buffer pooling, compile-time message framing, and a testing utility for simulating client-server interactions.

Tokens
8.9K
Snippets
27
Records
37
Agent score
66%

What's inside websocket.zig

  1. Implement a custom handler with a background read loop

    master

    For advanced usage, wrap websocket.Client in a custom struct and use a background read loop. This allows you to use callback-style methods instead of manual polling.

    1. Define a struct that contains the websocket.Client.
    2. Implement the required serverMessage(self: *Handler, data: []u8) !void method. To distinguish between text and binary, you can use the overload: serverMessage(self: *Handler, data: []u8, tpe: ws.MessageTextType) !void.
    3. Implement optional callbacks:
      • close(self: *Handler) void: Called exactly once when the read loop exits.
      • serverPing(self: *Handler, data: []u8) !void: Called on ping. If omitted, the library automatically replies with a pong.
      • serverPong(self: *Handler) !void: Called on pong. If omitted, the library ignores the message.
      • serverClose(self: *Handler) !void: Called on close. Note: It is recommended to implement close() instead of serverClose(). In your close() callback, call client.close(.{}) to terminate the connection.
    4. Start the loop using client.readLoop(handler) (blocking) or client.readLoopInNewThread(handler) (non-blocking).

    Warning: It is not safe to call client.read() manually while a read loop is running.

    const ws = @import("websocket");
    
    const Handler = struct {
      client: ws.Client,
    
      fn serverMessage(self: *Handler, data: []u8) !void {
        return self.client.write(data);
      }
    
      fn close(self: *Handler) void {
        // Handle cleanup
      }
    
      pub fn startLoop(self: *Handler) !void {
        const thread = try self.client.readLoopInNewThread(self);
        thread.detach();
      }
    };
  2. Understand websocket.zig thread safety

    master

    The library guarantees that only one message per connection/handler is processed at a time. This means clientMessage, clientPing, clientPong, and clientClose will never be called concurrently for the same handler.

    However, concurrent calls to *websocket.Conn methods (such as conn.write and conn.close) are allowed.

  3. Implement the Handler lifecycle methods

    master

    A Handler is a user-defined struct that manages individual websocket connections. To work with websocket.zig, your struct must implement:

    1. init(h: *ws.Handshake, conn: *ws.Conn, app: *T) !Handler: Called during the handshake. Use h to verify credentials (headers, query params). If init returns an error, the connection is rejected. Do not write to conn here.
    2. clientMessage(self: *Handler, data: []const u8) !void: Called when a message is received. You can use overloads to receive ws.MessageTextType or a thread-local std.mem.Allocator for efficient allocations.

    Optional methods include:

    • afterInit(self: *Handler) !void: Called after the handshake response is sent. This is the first safe time to use conn.
    • close(self: *Handler) void: Called exactly once when the connection closes (shutdown, client disconnect, or manual close). Safe for deinitialization.
    • clientPong / clientPing: Callbacks for pong/ping messages.
    • clientClose: Callback for when a close frame is received from the client.
  4. Use the websocket.Client for simple message exchange

    master

    For simple use cases, you can manually manage the websocket lifecycle by initializing a client, performing a handshake, and then using a loop to read and write messages.

    Note that read is blocking by default. You can use readTimeout(ms) to make read return null if no message is received within the specified duration. After receiving a message, you must call client.done(message) to signal that you have finished processing it.

    When handling messages, you should switch on the message.type (e.g., .text, .binary, .ping, .pong, .close) to respond appropriately.

    // create the client
    var client = try websocket.Client.init(init.io, allocator, .{
      .port = 9224,
      .host = "localhost",
    });
    defer client.deinit();
    
    // send the initial handshake request
    try client.handshake("/ws", .{
      .timeout_ms = 1000,
      .headers = "Host: localhost:9224",
    });
    
    // optional: read will return null after 1 second
    try client.readTimeout(std.time.ms_per_s * 1);
    
    while (true) {
      const message = (try client.read()) orelse continue;
      defer client.done(message);
    
      switch (message.type) {
        .text, .binary => try client.write(message.data),
        .ping => try client.writePong(message.data),
        .pong => {},
        .close => {
          try client.close(.{});
          break;
        },
      }
    }
  5. Initialize a websocket.Server

    master

    To start a websocket server, call ws.Server(Handler).init with your handler type, an IO provider, an allocator, and a configuration object. The server requires a Handler struct that implements specific lifecycle methods. The listen method is blocking and accepts application-specific data to pass into each handler instance.

    const std = @import("std");
    const ws = @import("websocket");
    
    pub fn main(init: std.process.Init) !void {
        const allocator = init.gpa;
    
        var server = try ws.Server(Handler).init(init.io, allocator, .{
            .port = 9224,
            .address = "127.0.0.1",
            .handshake = .{
                .timeout = 3,
                .max_size = 1024,
                .max_headers = 0,
            },
        });
    
        var app = App{};
        try server.listen(&app);
    }
    
    const Handler = struct {
        app: *App,
        conn: *ws.Conn,
    
        pub fn init(h: *ws.Handshake, conn: *ws.Conn, app: *App) !Handler {
            return .{ .app = app, .conn = conn };
        }
    
        pub fn clientMessage(self: *Handler, data: []const u8) !void {
            try self.conn.write(data);
        }
    };
    
    const App = struct {};
  6. Control websocket logging levels

    master

    The library uses Zig's built-in scope logging. You can control the log level for the websocket scope by defining std_options in your program's main.zig file.

    pub const std_options = std.Options{
        .log_scope_levels = &[_]std.log.ScopeLevel{
            .{ .scope = .websocket, .level = .err },
        }
    };
  7. Integrate WebSocket support into an existing web server using Worker

    master

    If you are using an existing HTTP library (like httpz) and want to add WebSocket support without running a full Server, use the Worker(comptime H: type) abstraction. This allows you to manage WebSocket connections, handshakes, and buffers within your existing server architecture.

    To use a Worker, you must first initialize a WorkerState which manages the shared resources like the handshake pool and buffer provider.

    // 1. Initialize WorkerState with your configuration
    const state = try WorkerState.init(io, allocator, config);
    
    // 2. Initialize the Worker with your Handler type
    const worker = try Worker(MyHandler).init(io, allocator, &state);
    
    // 3. When your HTTP server accepts a connection, create a WebSocket connection
    const handler_conn = try worker.createConn(socket, address, now);
    
    // 4. Cleanup when done
    worker.cleanupConn(handler_conn);
    deinit(worker);
    deinit(state);
  8. Implement a ClientHandler for message loops

    master

    For asynchronous or event-driven message handling, implement a ClientHandler. A handler typically wraps a Client and provides callback-style methods to process incoming data. You can use client.readLoop(handler) to continuously read messages and dispatch them to the handler's methods.

    Key handler methods to implement:

    • serverMessage(data, type): Called when a text or binary message is received.
    • serverPing(data): Called when a Ping frame is received.
    • serverPong(data): Called when a Pong frame is received.
    • close(): Logic to execute when the connection is terminated.
    // Example pattern for a handler
    const MyHandler = struct {
        client: Client,
        // ... other state
    
        pub fn serverMessage(self: *MyHandler, data: []u8, tpe: proto.Message.TextType) !void {
            // Handle incoming message
        }
    
        pub fn serverPing(self: *MyHandler, data: []u8) !void {
            // Handle ping
        }
    
        pub fn serverPong(self: *MyHandler, data: []u8) !void {
            // Handle pong
        }
    };
    
    // Usage:
    // try handler.client.readLoop(handler_instance);
  9. Configure the websocket Server

    master

    The Server(H).init function accepts a Config struct as its second parameter to customize server behavior. Key configuration areas include:

    • Network: Set port, address, or unix_path (Unix only).
    • Concurrency: Use worker_count in non-blocking mode (Linux/Mac/BSD) to set listening threads. In blocking mode (Windows), this is ignored.
    • Limits: Control max_conn (per worker), max_message_size, and handshake constraints via handshake.
    • Thread Pool: Configure thread_pool to manage threads that execute your clientXYZ handler methods. Use count for thread number and backlog for pending request limits.
    • Buffers: Optimize memory using buffers. In non-blocking mode, you can use small_pool to share buffers among sporadic clients or disable it to give each connection a dedicated buffer for steady streams.
    • Compression: Enable via the compression field. Set write_threshold to define the minimum message size for compression.
    // Example of initializing a server with custom config
    const server = try websocket.Server(MyHandler).init(.{
        .port = 8080,
        .max_conn = 10_000,
        .thread_pool = .{ .count = 8 },
    }, allocator);
  10. Configure the websocket.Client

    master

    When initializing a websocket.Client, you can provide a configuration object with the following fields:

    FieldTypeDescription
    portRequiredThe port to connect to
    hostRequiredThe host/IP address to connect to. Note: This value is NOT automatically added to the handshake Host header
    max_sizeDefault: 65536Maximum incoming message size. The library dynamically allocates up to this much space per request
    buffer_sizeDefault: 4096Size of the static buffer for processing incoming messages. Minimal memory usage is # of active clients * buffer_size
    tlsDefault: falseWhether to connect over TLS. Only TLS 1.3 is supported
    ca_bundleDefault: nullA custom std.crypto.Certificate.Bundle. Only used if tls = true

    Optimization Tip: Setting max_size == buffer_size ensures no dynamic memory allocation occurs once the connection is established.

  11. Implement a WebSocket Handler

    master

    To use this library, you must define a handler type H that implements specific lifecycle and message methods. The library uses compile-time reflection to determine which methods your handler provides.

    Required/Optional methods:

    • init(handshake: *Handshake.State, conn: *Conn, ctx: anytype) !void: Called after a successful handshake. If this fails, the connection is rejected.
    • clientMessage(data: []const u8) !void: Called when a text or binary message is received.
    • clientMessage(allocator: Allocator, data: []const u8) !void: (Optional) If your handler needs an allocator to process messages.
    • clientMessage(allocator: Allocator, data: []const u8, type: MessageType) !void: (Optional) If you need to know if the data was text or binary.
    • clientPong(data: []const u8) !void: (Optional) Called when a pong is received.
    • clientPing(data: []const u8) !void: (Optional) Called when a ping is received.
    • clientClose(data: []const u8) !void: (Optional) Called when a close frame is received.
    • afterInit(ctx: anytype) !void: (Optional) Called after the connection is fully established.
    • handshakeErrorResponse(err: error) !void: (Optional) Called if init fails during the handshake process.
  12. Test WebSocket handlers with the testing utility

    master

    The library provides a testing module to facilitate testing your Handler implementations. The testing utility opens a local socket pair to simulate a client-server interaction.

    • wt.init(.{}): Initializes the testing environment.
    • wtt.conn: Represents the "server" side of the connection within your test.
    • wtt.expectMessage(.text, "..."): Asserts that the client received a specific text message.
    • wtt.expectClose(): Asserts that the connection was closed.
    const wt = @import("websocket").testing;
    
    test "handler: echo" {
        var wtt = wt.init(.{});
        defer wtt.deinit();
    
        var handler = Handler{
            .conn = &wtt.conn,
        };
    
        try handler.clientMessage("hello world");
        try wtt.expectMessage(.text, "hello world");
    }