Low-allocation RFC 6455 websocket primitives for Zig with a specialized frame hot path, strict handshake validation, and permessage-deflate.
- π§± RFC 6455 core: frame parsing, masking, fragmentation, ping/pong, close handling, and server handshake validation.
- π Tight hot path:
Conn(comptime static)specializes role and policy at comptime to keep runtime branches out of the core path. - π¦ Low-allocation reads: stream frames chunk-by-chunk, read full frames, or borrow buffered payload slices when they fit.
- π§ Strict protocol checks: rejects malformed control frames, invalid close payloads, bad UTF-8, bad mask bits, and non-minimal extended lengths.
- π
permessage-deflate: handshake negotiation plus compressed message read/write support, withserver_no_context_takeoverandclient_no_context_takeover. - π§ Optional context takeover: compile-time
Conn.Typetoggle (permessage_deflate_context_takeover) enables cross-message compression state when negotiated. - π Per-message compression policy: compile-time
Conn.Typeknobs decide when messages are compressed (permessage_deflate_min_payload_len,permessage_deflate_require_compression_gain). - β± Timeout hooks: optional read, write, and flush time budgets with typed runtime hooks for framework-owned transports.
- π Convenience helpers:
readMessage,writeText,writeBinary,writePing,writePong, andwriteClose. - π§© Typed message handlers:
Handler.run(...)with typed user state, sync/async execution modes, and response coercion from[]const u8,[][]const u8, or structs withbody. - 𧬠Reactor-friendly frame codec:
FrameServer.Server(.{}).consume(...), raw-byte upgrade helpers, and fd/writev frame writers for frameworks that own epoll/kqueue/io_uring. - π§ͺ Validation stack: unit tests, fuzz/property tests, a cross-library interop matrix, soak runners, and benchmarks live alongside the library.
After you have already accepted the websocket upgrade and have a reader/writer pair:
const std = @import("std");
const zws = @import("zws");
fn runEcho(reader: *std.Io.Reader, writer: *std.Io.Writer) !void {
var conn = zws.Conn.Server.init(reader, writer, .{});
var message_buf: [4096]u8 = undefined;
while (true) {
const message = conn.readMessage(message_buf[0..]) catch |err| switch (err) {
error.ConnectionClosed => break,
else => |e| return e,
};
switch (message.opcode) {
.text => try conn.writeText(message.payload),
.binary => try conn.writeBinary(message.payload),
}
try conn.flush();
}
}For explicit handshake validation on a raw stream:
const negotiated = try zws.Handshake.upgrade(reader, writer);
_ = negotiated;
try writer.flush();For a full standalone echo server example:
zig build examples -Dexample=echo-server -- --port=9001 --compressionFor a frame-oriented echo server that stays on the low-level frame APIs:
zig build examples -Dexample=frame-echo-server -- --port=9002For a simple websocket client that performs the HTTP upgrade and then uses zws.Conn.Client:
zig build examples -Dexample=client -- --host=127.0.0.1 --port=9001 --message=helloFor a typed per-message handler loop with user-owned state:
const io = std.Io.Threaded.global_single_threaded.io();
var app_state = AppState{};
var scratch = zws.Handler.Scratch(.{
.receive_mode = .solid_slice,
}).init();
fn onMessage(ctx: *zws.Handler.SliceContext(.{
.receive_mode = .solid_slice,
}, zws.Conn.Server, AppState)) ![]const u8 {
ctx.state.seen += 1;
return ctx.message.payload;
}
try zws.Handler.run(.{
.receive_mode = .solid_slice,
}, io, &conn, &app_state, &scratch, onMessage);For high-fanout reactors that already own epoll/eventfd, use zws only as the websocket codec:
const Codec = zws.FrameServer.Server(.{ .pending_len = 4096 });
var upgrade_response: [256]u8 = undefined;
const accepted = try zws.Handshake.acceptUpgradeBytes(http_request, upgrade_response[0..]);
try writeAll(fd, accepted.response);
var state: Codec.ReactorState = .{};
fn onFrame(app: *App, ctx: *Codec.ReactorContext) !void {
switch (ctx.frame.opcode) {
.binary => try app.applyClientMessage(ctx.frame.payload),
.ping => try zws.FrameServer.writePongFd(app.fd, ctx.frame.payload),
.close => return error.ConnectionClosed,
else => {},
}
}
_ = try Codec.consume(&state, read_buffer[0..n], app, onFrame);Add as a dependency:
zig fetch --save <git-or-tarball-url>build.zig:
const zws_dep = b.dependency("zws", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("zws", zws_dep.module("zws"));zws.Conn.Type(.{ ... })creates a websocket connection type specialized for a fixed role and policy set.zws.Conn.Default,zws.Conn.Server, andzws.Conn.Clientare the common aliases.- Low-level read path:
beginFrame,readFrameChunk,readFrameAll,discardFrame,readFrameBorrowed. - Convenience read path:
readFrame,readMessage,readMessageBorrowed. - Write path:
writeFrame,writeText,writeBinary,writePing,writePong,writeClose,flush. - Handshake path:
Handshake.computeAcceptKey,Handshake.acceptUpgradeBytes,Handshake.upgrade. - Reactor/frame-codec path:
FrameServer.Server(.{}).ReactorState,FrameServer.Server(.{}).consume,FrameServer.encodeBinaryHeader,FrameServer.writeBinaryFd,FrameServer.writeFrameFd. - Compression path:
Extensions.PerMessageDeflate,Conn.PerMessageDeflateConfig,Conn.Config.permessage_deflate. - Runtime hooks:
Observe.TimeoutConfig,Observe.DefaultRuntimeHooks,Conn.TypeWithHooks(...). - Message handler loop:
Handler.run(...),Handler.Options,Handler.SliceContext(...),Handler.StreamContext(...),Handler.Response.
DOCUMENTATION.md: API stability, transport/runtime expectations, deployment notes, and validation entry points.
src/root.zig: public package surfacesrc/conn.zig: connection state machine and frame I/Osrc/handshake.zig: server upgrade parsing, validation, and101response writingsrc/extensions.zig: extension negotiation helpersbenchmark/bench.zig: benchmark clientbenchmark/zws_server.zig: standalone benchmark serverexamples/echo_server.zig: standalone echo server exampleexamples/frame_echo_server.zig: frame-level echo server example usingreadFrameBorrowedexamples/ws_client.zig: standalone client example with manual HTTP upgradevalidation/: Zig interop and soak runners, plus peer dependency metadata
Benchmark support lives under benchmark/.
zig build bench-compare -Doptimize=ReleaseFastEnvironment overrides:
LOW_CONNS=1 HIGH_CONNS=250000 ITERS=200000 WARMUP=10000 MSG_SIZE=16 zig build bench-compare -Doptimize=ReleaseFast
LOW_CONNS=1 HIGH_CONNS=250000 MAX_TOTAL_MSGS=4000000 MAX_TOTAL_WARMUP_MSGS=200000 PIPELINE_DEPTH=8 MSG_SIZE=16 zig build bench-compare -Doptimize=ReleaseFast
BENCH_CPU_POLICY=all BENCH_SERVER_WORKERS=0 zig build bench-compare -Doptimize=ReleaseFast
ROUNDS=1 BENCH_TIMEOUT_MS=300000 ZWS_DEADLINE_MS=30000 UWS_DEADLINE_MS=30000 zig build bench-compare -Doptimize=ReleaseFastBENCH_CPU_POLICY=single is the default and pins every benchmark server process to CPU 0 on Linux for fair single-core comparisons. Use BENCH_CPU_POLICY=all when intentionally comparing all-core server scaling.
For high-connection zws runs, the benchmark server switches to its public adaptive frame-server path at BENCH_ADAPTIVE_CONNS connections, default 1000; below that, it uses the requested sync/async handler mode.
High-connection clients are sharded across destination ports with BENCH_SHARD_CONNS, default 25000, to avoid the loopback ephemeral-port ceiling per destination.
High-connection deadline peers use at least BENCH_TIMEOUT_MS as the deadline budget so the 250k connection setup phase does not fail the deadline variants before measurement.
The comparison also writes benchmark/results/latest.html with browser-theme-aware average and peak graphs. Each graph is scaled independently, so 1, 1000, and 250000 connection loads are readable without one load flattening the others.
Source: benchmark/results/latest.json
Config: host=127.0.0.1 path=/ rounds=1 low_conns=1 mid_conns=1000 high_conns=250000 iters=200000 warmup=10000 max_total_msgs=4000000 max_total_warmup_msgs=200000 pipeline_depth=8 msg_size=16 bench_timeout_ms=600000 server_workers=1 adaptive_conns=1000 shard_conns=25000 cpu_policy=single zws_deadline_ms=30000 uws_deadline_ms=30000
| Suite | sync vs uWS | sync+dl vs uWS | async vs uWS | async+dl vs uWS | best zws vs fastwebsockets |
|---|---|---|---|---|---|
| 1 conn throughput / non-pipelined | -1.49% | +8.16% | +23.16% | +19.30% | +14.49% |
| 1 conn throughput / pipelined | -0.19% | +0.01% | -0.40% | +5.63% | +9.53% |
| 1000 conn throughput / non-pipelined | +21.43% | +4.85% | +4.81% | -1.06% | +11.43% |
| 1000 conn throughput / pipelined | -3.91% | -4.49% | +64.63% | +44.02% | +101.78% |
| 250000 conn throughput / non-pipelined | +45.90% | +18.85% | +43.81% | -20.77% | +76.48% |
| 250000 conn throughput / pipelined | -13.80% | +37.27% | +235.98% | +250.01% | +532.21% |
Values show zws vs matching uWS throughput delta, plus the best zws mode vs fastwebsockets.
Fairness notes: all peers use the same benchmark client, identical per-suite client settings, randomized per-connection service order, fresh server processes per round, and the configured server CPU policy.
For benchmark details, see benchmark/README.md.
zig build test
zig build bench -- --conns=1 --iters=2000 --warmup=100
zig build interop
zig build soak
zig build validate
zig build examples
zig build bench-compare -Doptimize=ReleaseFastzws is intentionally focused on a small websocket core.
- Server-side RFC 6455 handshake validation is included.
- Connection state is synchronous and stream-oriented.
permessage-deflateis implemented and negotiated when enabled.- Compression is disabled by default (
Config.permessage_deflate = null). - Even when negotiated, outgoing compression is opt-in (
Conn.PerMessageDeflateConfig.compress_outgoing = falseby default). - Context takeover support is disabled by default (
Conn.StaticConfig.permessage_deflate_context_takeover = false). - When enabled,
Conn.StaticConfigdefaults (permessage_deflate_min_payload_len = 64,permessage_deflate_require_compression_gain = true) skip tiny messages and avoid non-beneficial compression. - No TLS or HTTP server framework is bundled; use the raw stream API or the example server as the integration point.
permessage-deflateframing is implemented withstd.compress.flate(both non-takeover and optional context-takeover paths).