Skip to content

Commit 8e6b2f6

Browse files
committed
feat(rpc): add webzockets dependency and integrate into build
- Add webzockets as a local path dependency in build.zig.zon - Wire up webzockets module in build.zig - Re-export webzockets module from src/rpc/lib.zig to include in build - Update libxev dependency with fix for error returns (compatible with zig 0.14): Syndica/libxev@11a00ee
1 parent 5c26bc1 commit 8e6b2f6

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+13672
-2
lines changed

build.zig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ pub fn build(b: *Build) !void {
230230
const poseidon_mod = b.dependency("poseidon", dep_opts).module("poseidon");
231231
const xev_mod = b.dependency("xev", dep_opts).module("xev");
232232
const pretty_table_mod = b.dependency("prettytable", dep_opts).module("prettytable");
233+
const webzockets_mod = b.dependency("webzockets", dep_opts).module("webzockets");
233234

234235
const lsquic_dep = b.dependency("lsquic", .{
235236
.target = config.target,
@@ -295,6 +296,7 @@ pub fn build(b: *Build) !void {
295296
.{ .name = "sqlite", .module = sqlite_mod },
296297
.{ .name = "ssl", .module = ssl_mod },
297298
.{ .name = "tracy", .module = tracy_mod },
299+
.{ .name = "webzockets", .module = webzockets_mod },
298300
.{ .name = "xev", .module = xev_mod },
299301
.{ .name = "zstd", .module = zstd_mod },
300302
.{ .name = "table", .module = gh_table },

build.zig.zon

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@
2626
.hash = "lsquic-0.1.0-oZGGDdOZHQFsyhwdJq9eAB2GFIPzpNm90y4-8ncaYNku",
2727
},
2828
.xev = .{
29-
.url = "git+https://github.com/Syndica/libxev#bfb37ec5ad81a92f3fdc41f0d36e605a0490374d",
30-
.hash = "libxev-0.0.0-86vtc5XGEwDhneYf_GapeLISR0pVPeBA5tSlqRS__1d-",
29+
.url = "git+https://github.com/Syndica/libxev#11a00ee9c97e09c80a075af73599be4e1f549efa",
30+
.hash = "libxev-0.0.0-86vtc9IbEwBz7sjfmLPH8S3EMopSuBz87NFQetU6HVs4",
3131
},
3232
.prettytable = .{
3333
.url = "git+https://github.com/dying-will-bullet/prettytable-zig#46b6ad9b5970def35fa43c9613cd244f28862fa9",
@@ -49,6 +49,9 @@
4949
.url = "git+https://github.com/Syndica/tracy-zig#fd2576ccbe1b2eff5557ad6d91f2834311c8009c",
5050
.hash = "zig_tracy-0.13.0-4TLLRxFkAADP0fRkT2yR8jDk-2I4xg12GbfAF8Pgln_m",
5151
},
52+
.webzockets = .{
53+
.path = "src/rpc/webzockets",
54+
},
5255
.sqlite = .{
5356
.url = "https://www.sqlite.org/2025/sqlite-amalgamation-3490200.zip",
5457
.hash = "N-V-__8AAH-mpwB7g3MnqYU-ooUBF1t99RP27dZ9addtMVXD",
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
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.

src/rpc/lib.zig

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub const webzockets = @import("webzockets");
12
pub const client = @import("client.zig");
23
pub const http = @import("http.zig");
34
pub const methods = @import("methods.zig");

src/rpc/webzockets/.gitignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# build outputs
2+
/.zig-cache
3+
/zig-cache
4+
/zig-out
5+
6+
# desktop app config
7+
.DS_Store
8+
.vscode/
9+
10+
# Autobahn testsuite reports
11+
/autobahn/client/reports
12+
/autobahn/server/reports
13+
/autobahn/server/server.log

0 commit comments

Comments
 (0)