WebServ — HTTP/1.1 server from scratch in C++98

September 1, 2024

The problem

Build a production-grade HTTP/1.1 server from scratch — no third-party libraries, no Node.js, no Python. The goal: understand HTTP, sockets, CGI, and concurrent I/O at the system level.

What I built

An HTTP/1.1-compliant server written in C++98 that:

  • Parses raw HTTP requests by hand (request line, headers, body, chunked transfer)
  • Handles 50+ concurrent connections via non-blocking sockets and a single-threaded event loop
  • Uses poll() (or epoll / kqueue depending on platform) for I/O multiplexing
  • Supports CGI for executing scripts (PHP, Python) per RFC 3875
  • Reads NGINX-style configuration files (server blocks, location blocks, error pages, autoindex)
  • Implements GET, POST, DELETE methods with file uploads

Architecture decisions

  • Incremental request parsing — requests are parsed by a byte-at-a-time state machine (request line → headers → body). Each poll() wakeup feeds available bytes into the current state, and the parser only advances once that state's terminator is found — so a request split across many reads never blocks the loop.
  • CGI process spawning — each CGI request forks and execves the script, with pipe() pairs wired to the child's stdin/stdout and added to the same poll() set as regular sockets. A slow CGI script never blocks other connections; the parent just treats the pipe fd like any other multiplexed I/O source.
  • Configuration parser — a hand-written tokenizer feeding a recursive-descent parser, mirroring NGINX's block structure (server → location → directives). A generic key-value format wouldn't capture the nesting, or location blocks inheriting from their parent server block.
  • Memory model — strict RAII via small wrapper classes for fds and buffers, since smart pointers weren't available pre-C++11. Every resource-owning object frees in its destructor, so an exception or early return can't leak an fd — at the cost of more wrapper boilerplate than C++11 would need.

What I'd do differently

  • Move to epoll/kqueue from the start instead of retrofitting it later — poll()'s O(n) fd scan showed up under load testing well before 50 connections.
  • Separate CGI timeout/cleanup logic from the main event loop earlier — it ended up tangled with regular request handling and was the hardest part to debug.

Tech stack

C++98 · POSIX sockets · poll / epoll · CGI (RFC 3875) · HTTP/1.1 (RFC 7230)

GitHub
LinkedIn