|
| 1 | +# Webzockets |
| 2 | + |
| 3 | +A WebSocket (RFC 6455) library for Zig 0.14.1, built on `libxev`. Server and client. No hidden allocators — all memory is allocated through caller-provided allocators with caller-configured pool sizes and limits. Sends are zero-copy. Server connections are memory-pooled; client connections are caller-owned. |
| 4 | + |
| 5 | +## Quick Start |
| 6 | + |
| 7 | +See [examples/echo_server.zig](examples/echo_server.zig) and [examples/simple_client.zig](examples/simple_client.zig). |
| 8 | + |
| 9 | +## Usage Rules |
| 10 | + |
| 11 | +### Buffer Lifetime |
| 12 | + |
| 13 | +- **Server `sendText`/`sendBinary`**: zero-copy. Keep buffer alive until `onWriteComplete`. |
| 14 | +- **Client `sendText`/`sendBinary`**: masks in-place (XOR). Don't read/free/reuse until `onWriteComplete`. |
| 15 | +- **Read data in callbacks**: transient — points into internal buffers reused after callback returns. Copy if needed. |
| 16 | +- **`sendPing`/`sendPong`**: copies internally. Buffer can be freed immediately. Does not trigger `onWriteComplete`. |
| 17 | + |
| 18 | +### Write Concurrency |
| 19 | + |
| 20 | +- **One data write at a time.** Second `sendText`/`sendBinary` before completion returns `error.WriteBusy`. Queue from `onWriteComplete`. |
| 21 | +- **Control frames** (`sendPing`/`sendPong`) use a separate internal queue (256 bytes). `error.QueueFull` on overflow. |
| 22 | + |
| 23 | +### Connection State |
| 24 | + |
| 25 | +- **Sends on non-`.open` connections** return `error.InvalidState`. `close()` no-ops if already closing. |
| 26 | +- **`onClose` fires exactly once.** Server connections are pool-released afterward — don't reference them. Client connections require caller `deinit()`. |
| 27 | +- **`onWriteComplete` fires even on disconnect** (so callers can free buffers). |
| 28 | +- **Idle timeout** (optional, server only) sends `close(.going_away, "")`, following normal close handshake. |
| 29 | +- **Close-handshake timeout** force-disconnects if peer doesn't respond. |
| 30 | + |
| 31 | +### Handler Lifecycle (Server) |
| 32 | + |
| 33 | +- **`Handler.init()`** runs before the 101 response. Return error to reject (socket closed, no HTTP response). |
| 34 | +- **Handler Context:** handlers declare `pub const Context = T` (or `void`). If non-void, set `Config.handler_context: *T`; it’s passed to `Handler.init` as the second parameter. The pointer must remain valid for any handshake/connection that might call `init` or `onHandshakeFailed`. |
| 35 | +- **`onHandshakeFailed`** (optional): called if the handshake fails _after_ `init` succeeds (e.g., connection pool exhausted, write error, server shutdown). Use it to clean up resources allocated in `init`. Neither `onOpen` nor `onClose` will fire. |
| 36 | +- **Handler is embedded by value** in the pooled connection — no self-referential fields. |
| 37 | + |
| 38 | +### Platform |
| 39 | + |
| 40 | +- **macOS (kqueue) / Linux (epoll):** `xev.Loop` must be initialized with a `ThreadPool`. libxev dispatches socket close operations to the thread pool on these backends; without one, closes fail and FDs leak. Both `Server.init` and `Client.init` assert that the thread pool is set. |
| 41 | + |
| 42 | +### Timers |
| 43 | + |
| 44 | +- **Idle timeout** (`idle_timeout_ms`, server only, default `null`): sends close on inactivity. Resets on each read. |
| 45 | +- **Close-handshake timeout** (`close_timeout_ms`, default 5000ms): force-disconnects if peer doesn't complete close handshake. |
| 46 | +- **libxev tip:** prefer `Timer.cancel()` over raw `.cancel` completions (different behavior across backends). Note: cancellation still delivers the original callback with `error.Canceled`. |
| 47 | + |
| 48 | +### Event Loop |
| 49 | + |
| 50 | +Single-threaded. All callbacks run on the `loop.run()` thread. No locking needed; handlers must not block. |
| 51 | + |
| 52 | +### Client PRNG |
| 53 | + |
| 54 | +- Client handshake key generation and RFC 6455 masking require a caller-provided `ClientMaskPRNG` (a thin wrapper around `std.Random.DefaultCsprng`). |
| 55 | +- `ClientMaskPRNG` is **not thread-safe**; only use it from the `loop.run()` thread and do not share it across loops/threads. |
| 56 | +- The pointer must remain valid and **must not move** for the lifetime of any `ClientConnection` using it. |
| 57 | + |
| 58 | +### UTF-8 Validation |
| 59 | + |
| 60 | +The library delivers text messages to `onMessage` without validating UTF-8. Per RFC 6455 §8.1, endpoints must close the connection on invalid UTF-8 in text frames. Validate in your handler: |
| 61 | + |
| 62 | +See [autobahn/server/server.zig](autobahn/server/server.zig) for a complete example (required to pass Autobahn section 6.x tests). |
| 63 | + |
| 64 | +## Architecture |
| 65 | + |
| 66 | +``` |
| 67 | + ┌──────────────────────┐ |
| 68 | + │ User Application │ |
| 69 | + │ (defines Handler) │ |
| 70 | + └──┬───────┬────────┬──┘ |
| 71 | + │ │ │ |
| 72 | + ┌─────────────────┘ │ └─────────────────┐ |
| 73 | + ▼ │ ▼ |
| 74 | +┌─────────────────────┐ │ ┌─────────────────────┐ |
| 75 | +│ Server │ │ │ Client (transient)│ |
| 76 | +│ TCP accept loop + │ │ │ TCP connect + │ |
| 77 | +│ memory pools │ │ │ handshake, then │ |
| 78 | +└──────────┬──────────┘ │ │ can be discarded │ |
| 79 | + │ │ └──────────┬──────────┘ |
| 80 | + ┌──────┴──────┐ │ ▼ |
| 81 | + ▼ ▼ │ ┌─────────────────────┐ |
| 82 | + ┌────────┐ ┌────────┐ │ │ ClientConnection │ |
| 83 | + │ Hand- │ │ Hand- │ │ │ (caller-provided) │ |
| 84 | + │ shake │ │ shake │ │ └──────────┬──────────┘ |
| 85 | + │ pooled │ │ pooled │ │ │ |
| 86 | + └───┬────┘ └───┬────┘ │ │ |
| 87 | + ▼ ▼ │ │ |
| 88 | + ┌────────┐ ┌────────┐ │ │ |
| 89 | + │ Conn │ │ Conn │ │ │ |
| 90 | + │ pooled │ │ pooled │ │ │ |
| 91 | + └───┬────┘ └───┬────┘ │ │ |
| 92 | + └─────┬─────┘ │ │ |
| 93 | + └─────────────────────────┼──────────────────────────┘ |
| 94 | + ▼ |
| 95 | + ┌────────────────────────────────────┐ |
| 96 | + │ libxev Event Loop │ |
| 97 | + └────────────────────────────────────┘ |
| 98 | +``` |
| 99 | + |
| 100 | +**Server-side:** Each `Handshake` and `Connection` is a self-contained pooled type with its own read buffer and back-pointer to the server. |
| 101 | + |
| 102 | +**Client-side:** The client is transient — connects TCP, handshakes, initializes a caller-provided `*ClientConnection`, then can be discarded. |
| 103 | + |
| 104 | +### Connection Lifecycle |
| 105 | + |
| 106 | +**Server:** |
| 107 | +`TCP accept → Handshake (pool) → read HTTP upgrade → validate → Handler.init() → 101 response → Connection (pool) → onOpen → read loop (parse/unmask/reassemble/dispatch) → close handshake → onClose → release to pool` |
| 108 | + |
| 109 | +**Client:** |
| 110 | +`Client.connect() → write HTTP upgrade → read 101 → validate Sec-WebSocket-Accept → init ClientConnection (zero-copy handoff of leftover bytes) → onOpen → read loop (parse/reassemble/dispatch) → close handshake → onClose → deinit` |
| 111 | + |
| 112 | +## File Structure |
| 113 | + |
| 114 | +``` |
| 115 | +src/ |
| 116 | +├── root.zig Public API re-exports |
| 117 | +├── types.zig Protocol types, enums, error sets |
| 118 | +├── mask.zig XOR masking (SIMD-accelerated) |
| 119 | +├── frame.zig Frame parsing/encoding (RFC 6455 §5) |
| 120 | +├── http.zig HTTP parsing/encoding |
| 121 | +├── reader.zig Frame reader with buffer management |
| 122 | +├── buffer.zig Buffer pool for large messages |
| 123 | +├── control_queue.zig Ring buffer for outbound control frames |
| 124 | +├── server/ |
| 125 | +│ ├── server.zig TCP listener, accept loop, graceful shutdown |
| 126 | +│ ├── slot_pool.zig Memory pool with active count tracking |
| 127 | +│ ├── handshake.zig HTTP upgrade handshake (poolable) |
| 128 | +│ └── connection.zig WebSocket state machine (poolable) |
| 129 | +└── client/ |
| 130 | + ├── client.zig Transient: connect, handshake, init connection |
| 131 | + ├── handshake.zig Client-side HTTP upgrade state machine |
| 132 | + └── connection.zig WebSocket state machine (caller-owned) |
| 133 | +
|
| 134 | +examples/ Echo server and client examples |
| 135 | +e2e_tests/ Client-server integration tests |
| 136 | + server/ Server behavior tests |
| 137 | + client/ Client behavior tests |
| 138 | + support/ Shared test helpers, raw client |
| 139 | +autobahn/ Autobahn conformance suite runners |
| 140 | +``` |
| 141 | + |
| 142 | +### Module Dependencies |
| 143 | + |
| 144 | +``` |
| 145 | +root.zig |
| 146 | +├── types.zig |
| 147 | +├── mask.zig |
| 148 | +├── buffer.zig |
| 149 | +├── control_queue.zig ← types |
| 150 | +├── frame.zig ← types, mask |
| 151 | +├── http.zig ← types |
| 152 | +├── reader.zig ← types, frame, buffer |
| 153 | +├── server/ |
| 154 | +│ ├── server.zig ← slot_pool, server/handshake, server/connection, xev |
| 155 | +│ ├── slot_pool.zig ← std.heap.MemoryPool |
| 156 | +│ ├── handshake.zig ← http, types, server/connection, xev |
| 157 | +│ └── connection.zig ← types, frame, reader, buffer, control_queue, xev |
| 158 | +└── client/ |
| 159 | + ├── client.zig ← client/handshake, client/connection, buffer, xev |
| 160 | + ├── handshake.zig ← http, xev |
| 161 | + └── connection.zig ← types, frame, reader, buffer, mask, control_queue, xev |
| 162 | +``` |
| 163 | + |
| 164 | +## API Reference |
| 165 | + |
| 166 | +### Server Config |
| 167 | + |
| 168 | +```zig |
| 169 | +const EchoServer = ws.Server(EchoHandler, 4096, 64 * 1024); |
| 170 | +// Handler ^read ^pool buffer |
| 171 | +// buf sz size |
| 172 | +
|
| 173 | +const Config = struct { |
| 174 | + address: std.net.Address, |
| 175 | + tcp_accept_backlog: u31 = 128, |
| 176 | + max_message_size: usize = 16 * 1024 * 1024, |
| 177 | + initial_handshake_pool_size: usize = 16, |
| 178 | + initial_connection_pool_size: usize = 64, |
| 179 | + max_handshakes: ?usize = null, |
| 180 | + max_connections: ?usize = null, |
| 181 | + buffer_pool_preheat: usize = 8, |
| 182 | + idle_timeout_ms: ?u32 = null, |
| 183 | + close_timeout_ms: u32 = 5_000, |
| 184 | + handler_context: …, // if Handler.Context != void: *Handler.Context, else: void ({}) |
| 185 | +}; |
| 186 | +``` |
| 187 | + |
| 188 | +### Client Config |
| 189 | + |
| 190 | +```zig |
| 191 | +const SimpleClient = ws.Client(ClientHandler, 4096); |
| 192 | +// Handler ^read buf sz |
| 193 | +
|
| 194 | +const Config = struct { |
| 195 | + address: std.net.Address, |
| 196 | + path: []const u8 = "/", |
| 197 | + max_message_size: usize = 16 * 1024 * 1024, |
| 198 | + close_timeout_ms: u32 = 5_000, |
| 199 | +}; |
| 200 | +``` |
| 201 | + |
| 202 | +The client is a transient value type. `init` doesn't allocate. Caller provides a `*ClientConnection`, allocator, `*BufferPool`, and `*ClientMaskPRNG`: |
| 203 | + |
| 204 | +```zig |
| 205 | +var seed: [ws.ClientMaskPRNG.secret_seed_length]u8 = undefined; |
| 206 | +std.crypto.random.bytes(&seed); |
| 207 | +var csprng = ws.ClientMaskPRNG.init(seed); |
| 208 | +
|
| 209 | +var conn: SimpleClient.Conn = undefined; |
| 210 | +var client = SimpleClient.init(allocator, &loop, &handler, &conn, &buf_pool, &csprng, .{ |
| 211 | + .address = std.net.Address.parseIp4("127.0.0.1", 8080) catch unreachable, |
| 212 | + .path = "/", |
| 213 | + .max_message_size = 16 * 1024 * 1024, |
| 214 | + .close_timeout_ms = 5_000, |
| 215 | +}); |
| 216 | +try client.connect(); |
| 217 | +// After handshake, `conn` is live — client can be discarded |
| 218 | +``` |
| 219 | + |
| 220 | +### Handler Interface |
| 221 | + |
| 222 | +```zig |
| 223 | +// Required |
| 224 | +fn onMessage(self: *Handler, conn: *Conn, message: Message) void |
| 225 | +fn onWriteComplete(self: *Handler, conn: *Conn) void |
| 226 | +fn onClose(self: *Handler, conn: *Conn) void |
| 227 | +
|
| 228 | +// Optional |
| 229 | +fn onOpen(self: *Handler, conn: *Conn) void |
| 230 | +fn onPing(self: *Handler, conn: *Conn, data: []const u8) void |
| 231 | +fn onPong(self: *Handler, conn: *Conn, data: []const u8) void |
| 232 | +
|
| 233 | +// Optional (client-only) |
| 234 | +fn onSocketClose(self: *Handler) void |
| 235 | +
|
| 236 | +// Optional (server-only) |
| 237 | +fn onHandshakeFailed(self: *Handler) void |
| 238 | +
|
| 239 | +// Server-only (required) |
| 240 | +pub const Context = void; // or a real type T |
| 241 | +fn init(request: http.Request, context: if (Context == void) void else *Context) !Handler |
| 242 | +``` |
| 243 | + |
| 244 | +If `onPing` is not declared, auto-pong replies with latest-wins semantics. If declared, auto-pong is disabled — handler must call `conn.sendPong()`. |
| 245 | + |
| 246 | +Server `init` runs before 101. Return error to reject. `onHandshakeFailed` fires if the handshake fails after `init` succeeds (pool exhaustion, write error, shutdown); use it to clean up `init`-allocated resources. |
| 247 | + |
| 248 | +### Connection Methods |
| 249 | + |
| 250 | +```zig |
| 251 | +fn sendText(data) !void // server: []const u8 (zero-copy), client: []u8 (zero-copy, masked in-place) |
| 252 | +fn sendBinary(data) !void // same as above |
| 253 | +fn sendPing(data) !void // copies internally, max 125 bytes |
| 254 | +fn sendPong(data) !void // copies internally, max 125 bytes |
| 255 | +fn close(code: CloseCode, reason: []const u8) void |
| 256 | +``` |
| 257 | + |
| 258 | +## Tests |
| 259 | + |
| 260 | +Unit tests colocated in source files. E2E tests in `e2e_tests/`. |
| 261 | + |
| 262 | +```bash |
| 263 | +zig build test --summary all |
| 264 | +``` |
| 265 | + |
| 266 | +## Autobahn Testsuite |
| 267 | + |
| 268 | +Industry-standard WebSocket conformance suite. **Requires Docker.** |
| 269 | + |
| 270 | +```bash |
| 271 | +bash autobahn/server/run.sh # Results: autobahn/server/reports/index.html |
| 272 | +bash autobahn/client/run.sh # Results: autobahn/client/reports/index.html |
| 273 | +``` |
| 274 | + |
| 275 | +**Excluded:** 12.x / 13.x (permessage-deflate not implemented) |
| 276 | + |
| 277 | +## Current Limitations |
| 278 | + |
| 279 | +- **No custom response headers in the upgrade response (server):** The 101 response is fixed — no way to add `Sec-WebSocket-Protocol` or other headers. |
| 280 | +- **No permessage-deflate (compression):** RFC 7692 is not implemented. Adds complexity around buffer ownership for the send API since compressed frames can't be zero-copy in the same way. |
| 281 | +- **No DNS resolution (client):** `Config.address` takes a `std.net.Address` (IP only). The `Host` header is formatted from this address, but real-world servers typically expect the domain name. |
| 282 | +- **No TLS:** Most important for the client — servers can sit behind a TLS terminator. |
0 commit comments