Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8e6b2f6
feat(rpc): add webzockets dependency and integrate into build
jbuckmccready Feb 9, 2026
79fb4b2
docs(webzockets): remove module dependencies from README.md
jbuckmccready Feb 9, 2026
10c03c2
fix: run docs/generate.py
jbuckmccready Feb 9, 2026
9c3e188
ci(webzockets): add Linux and macOS test jobs
jbuckmccready Feb 10, 2026
94d60ef
test(webzockets): improve null value diagnostics and fix flakey tests
jbuckmccready Feb 10, 2026
9d058e6
fix(style): skip cache directories in style script
jbuckmccready Feb 10, 2026
12ea7ce
test(webzockets): stabilize e2e frame and close waits
jbuckmccready Feb 10, 2026
8bb390a
test(webzockets): handle NoResponse on close frame wait
jbuckmccready Feb 10, 2026
9402c82
docs(webzockets): cleanup doc comments for autobahn client
jbuckmccready Feb 11, 2026
aa9cb5c
docs(webzockets): simplify server struct field comments
jbuckmccready Feb 11, 2026
744fc9a
test(webzockets): use gpa allocator for autobahn client
jbuckmccready Feb 11, 2026
96a343e
refactor(webzockets): pass mask keys by value in mask api
jbuckmccready Feb 11, 2026
6a75ec7
refactor(webzockets): remove BufferPool mutex
jbuckmccready Feb 11, 2026
189852a
docs(webzockets): simplify reader struct field comments
jbuckmccready Feb 11, 2026
fa599f3
refactor(webzockets): use header_len for outstanding writes
jbuckmccready Feb 12, 2026
279a715
chore(deps): bump libxev to fix io_uring shutdown unexpected error
jbuckmccready Feb 13, 2026
0028053
feat(webzockets): add pause/resume read flow control for client and s…
jbuckmccready Feb 14, 2026
c5b8056
docs(webzockets): add onBytesRead callback and read flow control APIs
jbuckmccready Feb 14, 2026
51641cc
refactor(webzockets): remove BufferPool
jbuckmccready Feb 14, 2026
82de667
chore: run docs/generate.py
jbuckmccready Feb 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,36 @@ jobs:
name: Run Tests
command: zig-out/bin/test 2>&1 | cat

test_webzockets:
executor: linux-executor
steps:
- checkout
- attach_workspace:
at: workspace
- restore_cache:
key: linux-x86_64-0.14.1-{{ checksum "build.zig.zon" }}-selfhosted
- run:
name: Build and Run Webzockets Tests
command: |
cd src/rpc/webzockets
../../../workspace/zig/zig build test -Dcpu=x86_64_v3 --summary all

test_webzockets_macos:
executor: macos-executor
steps:
- checkout
- attach_workspace:
at: workspace
- restore_cache:
key: macos-aarch64-0.14.1-{{ checksum "build.zig.zon" }}
- run:
name: Build and Run Webzockets Tests
command: |
ulimit -Hn unlimited
ulimit -Sn unlimited
cd src/rpc/webzockets
../../../workspace/zig/zig build test -Dcpu=apple_m4 --summary all

test_kcov_linux:
executor: linux-executor
steps:
Expand Down Expand Up @@ -357,6 +387,9 @@ workflows:
- test_linux:
requires:
- build_linux
- test_webzockets:
requires:
- setup_zig_linux
- test_kcov_linux:
requires:
- setup_zig_linux
Expand Down Expand Up @@ -393,3 +426,6 @@ workflows:
- test_macos_hashmap_ledger:
requires:
- build_check_macos
- test_webzockets_macos:
requires:
- setup_zig_macos
2 changes: 2 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ pub fn build(b: *Build) !void {
const poseidon_mod = b.dependency("poseidon", dep_opts).module("poseidon");
const xev_mod = b.dependency("xev", dep_opts).module("xev");
const pretty_table_mod = b.dependency("prettytable", dep_opts).module("prettytable");
const webzockets_mod = b.dependency("webzockets", dep_opts).module("webzockets");

const lsquic_dep = b.dependency("lsquic", .{
.target = config.target,
Expand Down Expand Up @@ -295,6 +296,7 @@ pub fn build(b: *Build) !void {
.{ .name = "sqlite", .module = sqlite_mod },
.{ .name = "ssl", .module = ssl_mod },
.{ .name = "tracy", .module = tracy_mod },
.{ .name = "webzockets", .module = webzockets_mod },
.{ .name = "xev", .module = xev_mod },
.{ .name = "zstd", .module = zstd_mod },
.{ .name = "table", .module = gh_table },
Expand Down
7 changes: 5 additions & 2 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
.hash = "lsquic-0.1.0-oZGGDdOZHQFsyhwdJq9eAB2GFIPzpNm90y4-8ncaYNku",
},
.xev = .{
.url = "git+https://github.com/Syndica/libxev#bfb37ec5ad81a92f3fdc41f0d36e605a0490374d",
.hash = "libxev-0.0.0-86vtc5XGEwDhneYf_GapeLISR0pVPeBA5tSlqRS__1d-",
.url = "git+https://github.com/Syndica/libxev#c16162f0f9047f27d6230c548e1e25070b9be163",
.hash = "libxev-0.0.0-86vtcyQcEwAxRuv5vgdyq8LCRKHNGLgtKfS4KNHdic2V",
},
.prettytable = .{
.url = "git+https://github.com/dying-will-bullet/prettytable-zig#46b6ad9b5970def35fa43c9613cd244f28862fa9",
Expand All @@ -49,6 +49,9 @@
.url = "git+https://github.com/Syndica/tracy-zig#fd2576ccbe1b2eff5557ad6d91f2834311c8009c",
.hash = "zig_tracy-0.13.0-4TLLRxFkAADP0fRkT2yR8jDk-2I4xg12GbfAF8Pgln_m",
},
.webzockets = .{
.path = "src/rpc/webzockets",
},
.sqlite = .{
.url = "https://www.sqlite.org/2025/sqlite-amalgamation-3490200.zip",
.hash = "N-V-__8AAH-mpwB7g3MnqYU-ooUBF1t99RP27dZ9addtMVXD",
Expand Down
265 changes: 265 additions & 0 deletions docs/docusaurus/docs/code/webzockets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
# Webzockets

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.

## Quick Start

See [examples/echo_server.zig](examples/echo_server.zig) and [examples/simple_client.zig](examples/simple_client.zig).

## Usage Rules

### Buffer Lifetime

- **Server `sendText`/`sendBinary`**: zero-copy. Keep buffer alive until `onWriteComplete`.
- **Client `sendText`/`sendBinary`**: masks in-place (XOR). Don't read/free/reuse until `onWriteComplete`.
- **Read data in callbacks**: transient — points into internal buffers reused after callback returns. Copy if needed.
- **`sendPing`/`sendPong`**: copies internally. Buffer can be freed immediately. Does not trigger `onWriteComplete`.

### Write Concurrency

- **One data write at a time.** Second `sendText`/`sendBinary` before completion returns `error.WriteBusy`. Queue from `onWriteComplete`.
- **Control frames** (`sendPing`/`sendPong`) use a separate internal queue (256 bytes). `error.QueueFull` on overflow.

### Connection State

- **Sends on non-`.open` connections** return `error.InvalidState`. `close()` no-ops if already closing.
- **`onClose` fires exactly once.** Server connections are pool-released afterward — don't reference them. Client connections require caller `deinit()`.
- **`onWriteComplete` fires even on disconnect** (so callers can free buffers).
- **Idle timeout** (optional, server only) sends `close(.going_away, "")`, following normal close handshake.
- **Close-handshake timeout** force-disconnects if peer doesn't respond.

### Handler Lifecycle (Server)

- **`Handler.init()`** runs before the 101 response. Return error to reject (socket closed, no HTTP response).
- **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`.
- **`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.
- **Handler is embedded by value** in the pooled connection — no self-referential fields.

### Platform

- **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.

### Timers

- **Idle timeout** (`idle_timeout_ms`, server only, default `null`): sends close on inactivity. Resets on each read.
- **Close-handshake timeout** (`close_timeout_ms`, default 5000ms): force-disconnects if peer doesn't complete close handshake.
- **libxev tip:** prefer `Timer.cancel()` over raw `.cancel` completions (different behavior across backends). Note: cancellation still delivers the original callback with `error.Canceled`.

### Event Loop

Single-threaded. All callbacks run on the `loop.run()` thread. No locking needed; handlers must not block.

### Client PRNG

- Client handshake key generation and RFC 6455 masking require a caller-provided `ClientMaskPRNG` (a thin wrapper around `std.Random.DefaultCsprng`).
- `ClientMaskPRNG` is **not thread-safe**; only use it from the `loop.run()` thread and do not share it across loops/threads.
- The pointer must remain valid and **must not move** for the lifetime of any `ClientConnection` using it.

### UTF-8 Validation

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:

See [autobahn/server/server.zig](autobahn/server/server.zig) for a complete example (required to pass Autobahn section 6.x tests).

## Architecture

```
┌──────────────────────┐
│ User Application │
│ (defines Handler) │
└──┬───────┬────────┬──┘
│ │ │
┌─────────────────┘ │ └─────────────────┐
▼ │ ▼
┌─────────────────────┐ │ ┌─────────────────────┐
│ Server │ │ │ Client (transient)│
│ TCP accept loop + │ │ │ TCP connect + │
│ memory pools │ │ │ handshake, then │
└──────────┬──────────┘ │ │ can be discarded │
│ │ └──────────┬──────────┘
┌──────┴──────┐ │ ▼
▼ ▼ │ ┌─────────────────────┐
┌────────┐ ┌────────┐ │ │ ClientConnection │
│ Hand- │ │ Hand- │ │ │ (caller-provided) │
│ shake │ │ shake │ │ └──────────┬──────────┘
│ pooled │ │ pooled │ │ │
└───┬────┘ └───┬────┘ │ │
▼ ▼ │ │
┌────────┐ ┌────────┐ │ │
│ Conn │ │ Conn │ │ │
│ pooled │ │ pooled │ │ │
└───┬────┘ └───┬────┘ │ │
└─────┬─────┘ │ │
└─────────────────────────┼──────────────────────────┘
┌────────────────────────────────────┐
│ libxev Event Loop │
└────────────────────────────────────┘
```

**Server-side:** Each `Handshake` and `Connection` is a self-contained pooled type with its own read buffer and back-pointer to the server.

**Client-side:** The client is transient — connects TCP, handshakes, initializes a caller-provided `*ClientConnection`, then can be discarded.

### Connection Lifecycle

**Server:**
`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`

**Client:**
`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`

## File Structure

```
src/
├── root.zig Public API re-exports
├── types.zig Protocol types, enums, error sets
├── mask.zig XOR masking (SIMD-accelerated)
├── frame.zig Frame parsing/encoding (RFC 6455 §5)
├── http.zig HTTP parsing/encoding
├── reader.zig Frame reader with buffer management
├── control_queue.zig Ring buffer for outbound control frames
├── server/
│ ├── server.zig TCP listener, accept loop, graceful shutdown
│ ├── slot_pool.zig Memory pool with active count tracking
│ ├── handshake.zig HTTP upgrade handshake (poolable)
│ └── connection.zig WebSocket state machine (poolable)
└── client/
├── client.zig Transient: connect, handshake, init connection
├── handshake.zig Client-side HTTP upgrade state machine
└── connection.zig WebSocket state machine (caller-owned)

examples/ Echo server and client examples
e2e_tests/ Client-server integration tests
server/ Server behavior tests
client/ Client behavior tests
support/ Shared test helpers, raw client
autobahn/ Autobahn conformance suite runners
```

## API Reference

### Server Config

```zig
const EchoServer = ws.Server(EchoHandler, 4096);
// Handler ^read buf sz

const Config = struct {
address: std.net.Address,
tcp_accept_backlog: u31 = 128,
max_message_size: usize = 16 * 1024 * 1024,
initial_handshake_pool_size: usize = 16,
initial_connection_pool_size: usize = 64,
max_handshakes: ?usize = null,
max_connections: ?usize = null,
idle_timeout_ms: ?u32 = null,
close_timeout_ms: u32 = 5_000,
handler_context: …, // if Handler.Context != void: *Handler.Context, else: void ({})
};
```

### Client Config

```zig
const SimpleClient = ws.Client(ClientHandler, 4096);
// Handler ^read buf sz

const Config = struct {
address: std.net.Address,
path: []const u8 = "/",
max_message_size: usize = 16 * 1024 * 1024,
close_timeout_ms: u32 = 5_000,
};
```

The client is a transient value type. `init` doesn't allocate. Caller provides a `*ClientConnection`, allocator, and `*ClientMaskPRNG`:

```zig
var seed: [ws.ClientMaskPRNG.secret_seed_length]u8 = undefined;
std.crypto.random.bytes(&seed);
var csprng = ws.ClientMaskPRNG.init(seed);

var conn: SimpleClient.Conn = undefined;
var client = SimpleClient.init(allocator, &loop, &handler, &conn, &csprng, .{
.address = std.net.Address.parseIp4("127.0.0.1", 8080) catch unreachable,
.path = "/",
.max_message_size = 16 * 1024 * 1024,
.close_timeout_ms = 5_000,
});
try client.connect();
// After handshake, `conn` is live — client can be discarded
```

### Handler Interface

```zig
// Required
fn onMessage(self: *Handler, conn: *Conn, message: Message) void
fn onWriteComplete(self: *Handler, conn: *Conn) void
fn onClose(self: *Handler, conn: *Conn) void

// Optional
fn onOpen(self: *Handler, conn: *Conn) void
fn onPing(self: *Handler, conn: *Conn, data: []const u8) void
fn onPong(self: *Handler, conn: *Conn, data: []const u8) void

// Optional
fn onBytesRead(self: *Handler, conn: *Conn, bytes_read: usize) void

// Optional (client-only)
fn onSocketClose(self: *Handler) void

// Optional (server-only)
fn onHandshakeFailed(self: *Handler) void

// Server-only (required)
pub const Context = void; // or a real type T
fn init(request: http.Request, context: if (Context == void) void else *Context) !Handler
```

If `onPing` is not declared, auto-pong replies with latest-wins semantics. If declared, auto-pong is disabled — handler must call `conn.sendPong()`.

`onBytesRead` fires on every TCP read completion regardless of whether reads are paused. Combine with `peekBufferedBytes()` to observe raw data arrival (e.g. for byte-counting or deciding when to pause).

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.

### Connection Methods

```zig
fn sendText(data) !void // server: []const u8 (zero-copy), client: []u8 (zero-copy, masked in-place)
fn sendBinary(data) !void // same as above
fn sendPing(data) !void // copies internally, max 125 bytes
fn sendPong(data) !void // copies internally, max 125 bytes
fn close(code: CloseCode, reason: []const u8) void
fn pauseReads() void // pause frame dispatch; TCP reads continue until buffer full
fn resumeReads() void // resume dispatch; drains buffered frames
fn peekBufferedBytes() []const u8 // raw bytes in read buffer (transient slice)
```

## Tests

Unit tests colocated in source files. E2E tests in `e2e_tests/`.

```bash
zig build test --summary all
```

## Autobahn Testsuite

Industry-standard WebSocket conformance suite. **Requires Docker.**

```bash
bash autobahn/server/run.sh # Results: autobahn/server/reports/index.html
bash autobahn/client/run.sh # Results: autobahn/client/reports/index.html
```

**Excluded:** 12.x / 13.x (permessage-deflate not implemented)

## Current Limitations

- **No custom response headers in the upgrade response (server):** The 101 response is fixed — no way to add `Sec-WebSocket-Protocol` or other headers.
- **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.
- **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.
- **No TLS:** Most important for the client — servers can sit behind a TLS terminator.
9 changes: 9 additions & 0 deletions scripts/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ def main():

def get_files(args):
files_to_check = []
excluded_dirs = {
".git",
".zig-cache",
"zig-cache",
"zig-out",
"__pycache__",
}
dirs = [*args.dirs]
while len(dirs) > 0:
d = dirs.pop()
Expand All @@ -40,6 +47,8 @@ def get_files(args):
for file in files:
full_path = os.path.join(d, file)
if os.path.isdir(full_path):
if file in excluded_dirs:
continue
dirs.append(full_path)
else:
if file.endswith(".zig"):
Expand Down
1 change: 1 addition & 0 deletions src/rpc/lib.zig
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub const webzockets = @import("webzockets");
pub const client = @import("client.zig");
pub const http = @import("http.zig");
pub const methods = @import("methods.zig");
Expand Down
13 changes: 13 additions & 0 deletions src/rpc/webzockets/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# build outputs
/.zig-cache
/zig-cache
/zig-out

# desktop app config
.DS_Store
.vscode/

# Autobahn testsuite reports
/autobahn/client/reports
/autobahn/server/reports
/autobahn/server/server.log
Loading