What It Is
websocketC is a WebSocket server written in ~630 lines of C with absolutely zero external dependencies — no OpenSSL, no libwebsockets, no libevent. It implements the RFC 6455 WebSocket protocol (still on progress) from the ground up: the HTTP upgrade handshake, SHA-1 hashing, Base64 encoding, binary frame parsing, payload unmasking, ping/pong, and close frame handling. The only things it links against are libc and libpthread.
Why I Built It
WebSocket libraries abstract away the protocol so completely that you never see what's actually on the wire. I wanted to understand every byte: how the handshake upgrades from HTTP, how the frame header encodes opcode and payload length, how masking works, and how TCP fragmentation complicates frame parsing. Building it without dependencies meant I couldn't skip anything.
Module Design
utils — Manual implementations of SHA-1 (RFC 3174) and Base64, the two cryptographic primitives the WebSocket handshake requires. SHA-1 follows the spec exactly: message padding, 512-bit block decomposition, 80-word message schedule, four rounds of 20 compression steps. It's about 70 lines and validated against the RFC test vectors. Base64 encodes 3-byte groups into 4 output characters with = padding. These exist only because importing OpenSSL for a single hash of a short string felt absurd.
ws_handshake — Parses the HTTP upgrade request using strstr/strchr rather than a full HTTP parser, since the only valid request is a WebSocket upgrade with a well-defined format. Validation returns a typed enum (ws_handshake_result) instead of a boolean, so the server can respond with semantically correct HTTP errors — 400 for a bad request, 426 for a missing upgrade header, 505 for wrong HTTP version.
ws_frame — The frame layer is split into three files: frame_parser for zero-copy parsing, frame_dispatch for opcode routing, and frame_send for outbound frames. The parser returns a ws_frame struct that points directly into the recv buffer rather than copying payload data — no malloc per frame on the hot path. It distinguishes between WS_PARSE_INCOMPLETE (need more bytes, normal under TCP) and WS_PARSE_INVALID_LEN (malformed, close the connection). Payload unmasking is a separate explicit call (ws_unmask_payload) rather than automatic, keeping the parser pure and the ownership contract clear. Dispatch is table-driven via a switch on the opcode.
ws_session — Manages the per-client read loop with a reassembly buffer that handles TCP fragmentation. A single WebSocket frame can arrive split across multiple recv calls, or multiple frames can arrive in one call. After each frame is successfully parsed and consumed, the processed bytes are shifted out with memmove.
ws_server — Owns the socket lifecycle: bind, listen, accept. Each accepted connection spawns a detached pthread. The client file descriptor is heap-allocated before being passed to the new thread to avoid a race between the accept loop and thread startup.
Key Decisions
Zero-copy frame parsing avoids allocating memory for every inbound frame. The tradeoff is that the caller must not reuse the recv buffer while a parsed frame is still live, but in a single-threaded-per-client model this is easy to guarantee.
Thread-per-client over epoll was a deliberate simplicity choice. An epoll event loop would scale to thousands of connections, but for a protocol-learning project, one thread per client is far easier to reason about and debug. The architecture could be swapped to epoll later without changing the frame or handshake layers.
Typed error enums everywhere instead of booleans. The handshake returns specific failure reasons, the frame parser returns incomplete vs. invalid, and dispatch returns per-opcode results. This makes debugging protocol issues straightforward — you always know exactly what went wrong and where.
What I Learned
The WebSocket protocol is deceptively simple on paper but full of edge cases in practice. TCP fragmentation means you can never assume a complete frame arrives in a single recv. The masking mechanism (XOR with a 4-byte rotating key) exists purely to prevent proxy cache poisoning — it's not encryption and wasn't obvious why it exists until I read the RFC's security considerations. And implementing SHA-1 by hand gave me an appreciation for how much a single #include <openssl/sha.h> hides.
What's Next
Potential extensions include an epoll-based event loop for high-concurrency scenarios, TLS support (likely via a minimal BearSSL integration), and WebSocket extension negotiation (per-message compression via permessage-deflate).