An HTTP/1.1 server implemented directly over raw TCP sockets in Go — no net/http, no frameworks, no abstractions.
This project focuses on understanding HTTP at the protocol level by manually implementing request parsing, response formatting, and streaming behavior according to RFC 7230.
- Protocol-level understanding of HTTP/1.1
- Streaming parser design with partial-read handling
- RFC-compliant header parsing and validation
- Chunked transfer encoding and HTTP trailers
- Low-level networking with TCP sockets
- HTTP/1.1 request line parsing (method, target, version)
- Streaming state-machine parser handling partial TCP reads
- RFC-compliant header parsing with token validation (
isToken) - Case-insensitive headers with duplicate merging
Content-Lengthbody parsing with strict enforcement
-
Structured response writer enforcing correct order:
- status line → headers → body
-
Supported status codes: 200, 400, 500
-
Chunked transfer encoding (write path)
-
HTTP trailers support
-
Default headers:
Content-Length,Content-Type,Connection: close
- TCP accept loop using
net.Listener - One goroutine per connection
- Graceful listener shutdown via signals
- Atomic state handling for shutdown safety
- Reverse proxy:
/httpbin/*→https://httpbin.org/* - Streaming proxy responses using chunked encoding
- SHA-256 response hash returned as HTTP trailer
- Binary file serving (
/video) - Basic route handling via switch-based dispatch
conn.Read() → state-machine parser → request object → handler → response writer → conn.Close()
- Accept loop runs continuously in a goroutine
- Each connection is handled independently (
go s.handle(conn)) - Connections are one-shot (
Connection: close)
Initialized → ParsingHeaders → ParsingBody → Done
- Incremental parsing based on available bytes
- Handles arbitrary TCP fragmentation correctly
- Consumes and shifts buffer as parsing progresses
- Initial buffer: 1024 bytes
- Automatically doubles when full
- Ensures efficient handling of large or fragmented requests
The parser is designed around real TCP behavior — data may arrive in arbitrary chunk sizes. A cursor-based buffer system ensures correctness across fragmented reads.
Header field names are validated against RFC 7230 token rules. Invalid inputs are rejected instead of silently accepted.
Implements HTTP/1.1 chunked transfer encoding, including trailer support for metadata like SHA-256 hashes of streamed responses.
Uses a custom chunkReader to simulate real-world network conditions by limiting read sizes, ensuring the parser behaves correctly under partial reads.
This is a learning-focused implementation, not a production server.
- No keep-alive (always
Connection: close) - No request-side chunked decoding
- Limited status code support
- No TLS / HTTPS
- No connection timeouts
- Basic routing only
- No HTTP/2 or HTTP/3
- No production hardening (rate limiting, pooling, etc.)
Most applications rely on high-level abstractions like net/http, which hide how HTTP actually works.
This project removes those abstractions to explore:
- how requests are framed over TCP
- how headers and bodies are parsed
- how responses are constructed at the byte level
Run the server:
go run ./cmd/httpserver/main.goServer runs on:
http://localhost:42069
# Default route
curl -v http://localhost:42069/
# Reverse proxy
curl -v --raw http://localhost:42069/httpbin/get
# Video file
curl -v http://localhost:42069/video --output out.mp4cmd/ # entry points and handlers
internal/
├── request/ # state machine parser
├── headers/ # header parsing + validation
├── response/ # response writer + chunked encoding
└── server/ # TCP loop + connection handling
- Ability to work below frameworks and abstractions
- Understanding of real-world networking behavior
- Implementation of protocol-level logic from specification
- Careful handling of edge cases and streaming data
This project prioritizes depth over completeness — focusing on how HTTP works internally rather than building a production-ready server.
An HTTP/1.1 server built directly over raw TCP sockets in Go — no net/http, no framework, no shortcuts. Every byte of the request is parsed manually according to the HTTP/1.1 specification (RFC 7230).
Built to understand how the protocol actually works at the wire level, not just how to use it.
Request Parsing
- Full HTTP/1.1 request line parsing: method (uppercase-only, validated), request target, version
- Streaming, state-machine-based parser that handles partial TCP reads correctly
- Header parsing with RFC 7230 field-name token validation (
isToken) - Case-insensitive header storage (keys normalized to lowercase)
- Duplicate header merging via
,joining (per spec) Content-Length-based body accumulation with length enforcement
Response Writing
response.Writerabstraction that enforces correct write order: status line → headers → body- Status codes: 200 OK, 400 Bad Request, 500 Internal Server Error
- Chunked transfer encoding (write path): hex chunk sizes,
\r\nframing, terminal chunk - HTTP trailers support
- Default headers:
Content-Length,Content-Type,Connection: close
Server & Concurrency
- TCP accept loop using
net.Listener - One goroutine per accepted connection
atomic.Boolfor safe shutdown signaling- Signal handling (
SIGINT/SIGTERM) for clean listener shutdown
Application Layer
- Reverse proxy:
/httpbin/*→https://httpbin.org/<path>, streamed with chunked encoding - SHA-256 hash of the proxied response body sent as an HTTP trailer (
X-Content-SHA256) - Binary file serving:
/videoreturns a local MP4 withContent-Type: video/mp4 - Hard-coded HTML routes:
/yourproblem(400),/myproblem(500), default (200)
main()
└── server.Serve(port, handler)
├── net.Listen("tcp", ":42069")
└── go s.listen()
└── for { conn, _ := listener.Accept(); go s.handle(conn) }
The accept loop runs in a background goroutine. Each accepted connection is dispatched to its own goroutine immediately, so the loop is never blocked by connection handling.
s.handle(conn net.Conn)
├── request.RequestFromReader(conn) // parse the full HTTP request
├── response.NewWriter(conn) // wrap conn in a response writer
└── s.Handler(rw, req) // dispatch to application handler
└── defer conn.Close()
The request parser uses four explicit states:
Initialized → RequestStateParsingHeaders → ParsingBody → Done
RequestFromReader reads from the connection into a buffer and repeatedly calls parse, which calls parseSingle per state transition. parseSingle returns the number of bytes it consumed, allowing the outer loop to shift the buffer and continue reading. This correctly handles the reality that a single conn.Read() call can return any number of bytes — less than a full line, or spanning multiple headers.
The read buffer starts at 1024 bytes. When it is full (cap(buf) - readToIndex == 0), a new buffer of double the capacity is allocated and the unprocessed data is copied forward. This avoids blocking on large requests while keeping allocation simple.
1. conn.Read() → raw bytes into buffer
2. r.parse() → advance state machine, consume bytes, shift buffer
3. (repeat until Done)
4. Handler(rw, req) → application logic writes status + headers + body
5. conn.Close() → connection torn down (Connection: close always)
TCP is a byte stream. conn.Read() returns however many bytes happen to be available — often less than a full header line or even a full request. The parser is built around this: it tracks a readToIndex cursor, shifts consumed bytes out of the buffer after each parse pass, and only advances state when a complete logical unit (request line, header line, body) has been received. The test suite deliberately exercises this using a chunkReader that limits reads to N bytes per call.
The isToken() function in internal/headers/headers.go validates header field-names against the RFC 7230 §3.2.6 token definition: visible ASCII only, excluding the set of delimiter characters (", (, ), ,, /, :, ;, <, =, >, ?, @, [, \, ], {, }). Invalid field-names return a parse error rather than silently accepting bad input.
When the same field-name appears more than once, values are combined as "existing, new" — the correct behavior per RFC 7230 §3.2.2. Header storage is a map[string]string with lowercase keys, so Host, host, and HOST all resolve to the same slot.
The response writer implements the chunked encoding wire format:
<hex-length>\r\n
<chunk-data>\r\n
...
0\r\n
<trailer-name>: <trailer-value>\r\n
\r\n
The reverse proxy uses this to stream httpbin.org responses without buffering the entire body, then appends X-Content-SHA256 and X-Content-Length as HTTP trailers computed over the full streamed body.
Rather than passing complete request strings to the parser, the request tests use a chunkReader — a custom io.Reader that returns exactly N bytes per Read() call (configurable per test). This directly stress-tests the incremental parsing logic under conditions that match real network behavior.
This is an educational implementation. The following are deliberate non-goals:
| Area | Status |
|---|---|
| Keep-alive / persistent connections | Not implemented — always Connection: close |
| Request-side chunked decoding | Not implemented — only Content-Length on ingress |
| Status codes | Only 200, 400, 500 |
| Connection timeouts | Not set — SetReadDeadline/SetWriteDeadline never called |
| TLS / HTTPS | Not implemented |
| Routing | Plain switch + one prefix check; no method dispatch, no path params |
| Query string parsing | Not implemented |
| HTTP pipelining | Not implemented |
| HTTP/2 or HTTP/3 | Out of scope |
| Production hardening | Rate limiting, max connections, graceful drain — none present |
The project is also not a general-purpose HTTP library. It exists to demonstrate protocol understanding, not to be reused.
Most Go programs use net/http and never see what happens on the wire. This project removes that abstraction to answer the question: what actually goes over a TCP connection when you make an HTTP request?
Working through request framing, CRLF delimiters, header token rules, chunked encoding, and trailers at the byte level builds a kind of understanding that reading documentation does not. This project is that exercise.
Prerequisites: Go 1.21+
Run the server:
go run ./cmd/httpserver/main.goThe server listens on port 42069.
Example requests:
# Default route
curl -v http://localhost:42069/
# 400 route
curl -v http://localhost:42069/yourproblem
# 500 route
curl -v http://localhost:42069/myproblem
# Reverse proxy with chunked encoding and SHA-256 trailer
curl -v --raw http://localhost:42069/httpbin/get
# Video file (requires assets/vim.mp4 to exist)
curl -v http://localhost:42069/video --output out.mp4Run tests:
go test ./....
├── cmd/
│ ├── httpserver/main.go # entry point, routing, application handlers
│ ├── tcplistener/ # standalone TCP listener (exploratory)
│ └── udpsender/ # standalone UDP sender (exploratory)
└── internal/
├── request/
│ ├── request.go # state machine parser, RequestFromReader
│ └── request_test.go # partial-read tests via chunkReader
├── headers/
│ ├── headers.go # header map, Parse, isToken, Set/Get/Delete
│ └── headers_test.go
├── response/
│ └── response.go # Writer, WriteStatusLine, chunked TE, trailers
└── server/
└── server.go # TCP accept loop, goroutine dispatch, Handler type
- Systems-level thinking: working directly with TCP streams, byte buffers, and wire protocols
- Protocol implementation: following an RFC rather than using an abstraction
- Streaming parser design: state machines, partial reads, buffer management
- Go concurrency primitives: goroutines,
sync/atomic, signal handling - Deliberate testing: stress-testing partial reads, not just happy-path strings