Skip to content

SmallThingz/zws

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

81 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸš€ zws

Low-allocation RFC 6455 websocket primitives for Zig with a specialized frame hot path, strict handshake validation, and permessage-deflate.

zig protocol core mode

⚑ Features

  • 🧱 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, with server_no_context_takeover and client_no_context_takeover.
  • 🧠 Optional context takeover: compile-time Conn.Type toggle (permessage_deflate_context_takeover) enables cross-message compression state when negotiated.
  • πŸŽ› Per-message compression policy: compile-time Conn.Type knobs 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, and writeClose.
  • 🧩 Typed message handlers: Handler.run(...) with typed user state, sync/async execution modes, and response coercion from []const u8, [][]const u8, or structs with body.
  • 🧬 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.

πŸš€ Quick Start

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 --compression

For a frame-oriented echo server that stays on the low-level frame APIs:

zig build examples -Dexample=frame-echo-server -- --port=9002

For 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=hello

For 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);

πŸ“¦ Installation

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"));

🧩 Library API (At a Glance)

  • zws.Conn.Type(.{ ... }) creates a websocket connection type specialized for a fixed role and policy set.
  • zws.Conn.Default, zws.Conn.Server, and zws.Conn.Client are 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.

πŸ“š Docs

  • DOCUMENTATION.md: API stability, transport/runtime expectations, deployment notes, and validation entry points.

πŸ“Ž In-Tree Files

  • src/root.zig: public package surface
  • src/conn.zig: connection state machine and frame I/O
  • src/handshake.zig: server upgrade parsing, validation, and 101 response writing
  • src/extensions.zig: extension negotiation helpers
  • benchmark/bench.zig: benchmark client
  • benchmark/zws_server.zig: standalone benchmark server
  • examples/echo_server.zig: standalone echo server example
  • examples/frame_echo_server.zig: frame-level echo server example using readFrameBorrowed
  • examples/ws_client.zig: standalone client example with manual HTTP upgrade
  • validation/: Zig interop and soak runners, plus peer dependency metadata

🏁 Benchmarking

Benchmark support lives under benchmark/.

zig build bench-compare -Doptimize=ReleaseFast

Environment 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=ReleaseFast

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

Latest Benchmark Comparison

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.

πŸ§ͺ Build and Validation

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=ReleaseFast

⚠️ Current Scope

zws is intentionally focused on a small websocket core.

  • Server-side RFC 6455 handshake validation is included.
  • Connection state is synchronous and stream-oriented.
  • permessage-deflate is 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 = false by default).
  • Context takeover support is disabled by default (Conn.StaticConfig.permessage_deflate_context_takeover = false).
  • When enabled, Conn.StaticConfig defaults (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-deflate framing is implemented with std.compress.flate (both non-takeover and optional context-takeover paths).

About

Websocket implementation in zig

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Contributors

Languages