Low-allocation RFC 6455 websocket primitives for Zig with a specialized frame hot path, strict handshake validation, permessage-deflate, and zhttp integration helpers.
- 🧱 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. - 🔁 Convenience helpers:
readMessage,echoFrame,writeText,writeBinary,writePing,writePong, andwriteClose. - 🪝
zhttphelpers: accept websocket upgrade requests fromzhttphandlers, build101 Switching Protocolsresponses, and adapt upgrade runners so thrown errors become websocket close1011. - 🧪 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("zwebsocket");
fn runEcho(reader: *std.Io.Reader, writer: *std.Io.Writer) !void {
var conn = zws.ServerConn.init(reader, writer, .{});
var scratch: [4096]u8 = undefined;
while (true) {
_ = conn.echoFrame(scratch[0..]) catch |err| switch (err) {
error.ConnectionClosed => break,
else => |e| return e,
};
try conn.flush();
}
}For explicit handshake validation on a raw stream:
const accepted = try zws.acceptServerHandshake(req, .{});
try zws.writeServerHandshakeResponse(writer, accepted);For a full standalone echo server example:
zig build example-echo-server -- --port=9001 --compressionAdd as a dependency:
zig fetch --save <git-or-tarball-url>build.zig:
const zws_dep = b.dependency("zwebsocket", .{
.target = target,
.optimize = optimize,
});
exe.root_module.addImport("zwebsocket", zws_dep.module("zwebsocket"));zws.ConnType(.{ ... })creates a websocket connection type specialized for a fixed role and policy set.zws.Conn,zws.ServerConn, andzws.ClientConnare the common aliases.- Low-level read path:
beginFrame,readFrameChunk,readFrameAll,discardFrame,readFrameBorrowed. - Convenience read path:
readFrame,readMessage,echoFrame. - Write path:
writeFrame,writeText,writeBinary,writePing,writePong,writeClose,flush. - Handshake path:
computeAcceptKey,acceptServerHandshake,writeServerHandshakeResponse,serverHandshake. - Compression path:
PerMessageDeflate,PerMessageDeflateConfig,ServerHandshakeResponse.permessage_deflate,Config.permessage_deflate.
zhttp already provides the raw stream handoff needed for websocket upgrades. zwebsocket includes helpers for the current zhttp model:
zws.zhttpRequest(req)converts azhttpupgrade request intoServerHandshakeRequest.zws.acceptZhttpUpgrade(req, opts)validates the websocket handshake directly from azhttprequest.zws.fillZhttpResponseHeaders(...)andzws.makeZhttpUpgradeResponse(...)build the101response header set.zws.adaptZhttpRunner(AppRunner, .{})wraps an existing upgrade runner type and turns thrown runner errors into a best-effort websocket close1011.
The integration is still two-phase:
- the HTTP upgrade handler validates the request and returns the
101 Switching Protocolsresponse - the websocket runner owns the upgraded stream after takeover
If you want thrown runner errors to become websocket close 1011, wrap the runner type with zws.adaptZhttpRunner(...) and keep the runner body written as normal !void.
Example handler shape:
const std = @import("std");
const zhttp = @import("zhttp");
const zws = @import("zwebsocket");
const WsHeaders = struct {
connection: zhttp.parse.Optional(zhttp.parse.String),
upgrade: zhttp.parse.Optional(zhttp.parse.String),
sec_websocket_key: zhttp.parse.Optional(zhttp.parse.String),
sec_websocket_version: zhttp.parse.Optional(zhttp.parse.String),
sec_websocket_protocol: zhttp.parse.Optional(zhttp.parse.String),
sec_websocket_extensions: zhttp.parse.Optional(zhttp.parse.String),
origin: zhttp.parse.Optional(zhttp.parse.String),
host: zhttp.parse.Optional(zhttp.parse.String),
};
fn upgrade(req: anytype) !zhttp.Res {
const accepted = try zws.acceptZhttpUpgrade(req, .{});
const ZhttpHeader = std.meta.Child(@FieldType(zhttp.Res, "headers"));
var headers: [4]ZhttpHeader = undefined;
return try zws.makeZhttpUpgradeResponse(zhttp.Res, ZhttpHeader, headers[0..], accepted);
}The route must declare those websocket headers in its zhttp .headers schema so req.header(...) is available.
Wrap the upgrade runner instead of changing its shape:
const AppRunner = struct {
pub const Data = struct {
compression: bool = false,
};
pub fn run(io: std.Io, _: std.mem.Allocator, stream: std.Io.net.Stream, data: *const Data) !void {
var owned_stream = stream;
var read_buf: [4096]u8 = undefined;
var write_buf: [4096]u8 = undefined;
var reader = owned_stream.reader(io, &read_buf);
var writer = owned_stream.writer(io, &write_buf);
var conn = zws.ServerConn.init(&reader.interface, &writer.interface, .{});
_ = data;
while (true) {
_ = try conn.echoFrame(read_buf[0..]);
try conn.flush();
}
}
};
const WsRunner = zws.adaptZhttpRunner(AppRunner, .{});WsRunner is the type you hand to zhttp as .upgrade = WsRunner. If AppRunner.run(...) throws, the adapter catches it and best-effort writes close 1011 on the upgraded stream before returning.
docs/API_STABILITY.md: compatibility contract and which surfaces are stable vs provisional.docs/TRANSPORTS.md: stream/runtime expectations,zhttpintegration shape, and deployment notes.docs/VALIDATION.md: fuzz/property tests, interop matrix, soak runners, and benchmark entry points.
src/root.zig: public package surfacesrc/conn.zig: connection state machine and frame I/Osrc/handshake.zig: server handshake validation and response generationsrc/zhttp_compat.zig:zhttpadapter helperssrc/extensions.zig: extension negotiation helpersbenchmark/bench.zig: benchmark clientbenchmark/zwebsocket_server.zig: standalone benchmark serverexamples/echo_server.zig: standalone echo server examplevalidation/: interop peers and soak runners
Benchmark support lives under benchmark/.
zig build bench-compare -Doptimize=ReleaseFastEnvironment overrides:
CONNS=16 ITERS=200000 WARMUP=10000 MSG_SIZE=16 zig build bench-compare -Doptimize=ReleaseFastFor benchmark details, see benchmark/README.md.
zig build test
zig build interop
zig build soak
zig build validate
zig build bench-server
zig build bench-compare -Doptimize=ReleaseFastzwebsocket 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.- No TLS or HTTP server framework is bundled; use the raw stream API,
zhttp, or the example server as the integration point. - The
zhttpadapter targets the current upgrade-route model rather than trying to wrap all ofzhttp. - Compression support links against system
zlib. If you do not enablepermessage-deflate, the core RFC 6455 path remains pure Zig.