The browser is hiding something
You type a URL and a page appears. Somewhere between those two events a TCP
connection opened, bytes went out, bytes came back. You have never seen them.
The browser does not show them. curl does not show them unless you ask.
So HTTP feels like a thing browsers do, not a thing your program could do.
It is bytes. Specifically, it is ASCII text with \r\n at the end of each
line, written into a TCP socket. That is the whole trick. A protocol is an agreement
between two programs about what the bytes mean. HTTP is one of the simplest such
agreements, and you already have every tool you need to speak it: connect,
send_all, recv, and the framing ideas from the last lesson.
The request, byte by byte
Here is a complete HTTP/1.1 request. Every line ends in a carriage return and a
line feed — two bytes, 0x0D 0x0A. An empty line marks the end of the
headers. After that comes an optional body. This request has none.
In memory that is 62 bytes. The request line is three words separated by single
spaces. Headers are Name: value, one per line. The server reads until it
sees the four-byte sequence \r\n\r\n, and then it knows the headers are over.
That is framing — the delimiter kind from the last lesson, with a four-byte delimiter.
The response, byte by byte
Look at Content-Length: 13. That is a length prefix. It is the same
idea as the 4-byte big-endian count you wrote in recv_exact, written in
decimal ASCII and placed in a header instead of at the front. The client reads headers
until \r\n\r\n, parses the number, then reads exactly 13 more bytes.
Without that number the client cannot know where the body ends — TCP will not tell it.
\r\n per line, \r\n\r\n to end).
The body is length-framed (Content-Length). Every HTTP parser you have ever used
is a loop that finds \r\n\r\n, then a loop that reads N bytes.
A client with printf
Since a request is text, you can build it with snprintf and push it
through send_all. Then read the response until the server closes the
connection — that is what Connection: close buys you: recv
returns 0 when the whole response has arrived, so you do not have to parse
Content-Length for this first version.
Run it against any web server and the first thing printed is
HTTP/1.1 200 OK or HTTP/1.1 301 Moved Permanently, followed
by headers, a blank line, and HTML. Those bytes were always there. You just never
looked at the socket directly.
Host header gets you a 400.
One IP address serves many sites. The server cannot tell which one you want from the
TCP connection alone — the only clue is the Host line. HTTP/1.1 makes it
mandatory for exactly that reason.
A 60-line server
The server side is the mirror image. Accept a connection. Read into a buffer until
the buffer contains \r\n\r\n. Pull the method and path out of the first
line. Write a response with a correct Content-Length. Close. Repeat.
Compile it, run it, and point a browser at http://localhost:8080/anything.
The browser is satisfied. It has no idea the server is 60 lines of C with no
library behind it, because the bytes are right, and the bytes are all the browser
ever sees.
Watch the bytes with curl -v
Lines starting with > are bytes curl sent. Lines starting with
< are bytes it received. Each one ended in \r\n on the
wire; curl strips them for display. This is the entire conversation.
Content-Length or Connection: close hangs the client.
The client has read the headers and is now waiting for a body. How many bytes?
It does not know. It will sit in recv until the server closes the
connection or the client gives up. Every "my server works in the browser but curl
hangs" bug is one of these two headers missing.
Keep-alive: reusing the connection
Opening a TCP connection costs a handshake — a full round trip before a single
byte of HTTP moves. A page with 40 images would pay that 40 times. So HTTP/1.1
defaults to keep-alive: unless someone says Connection: close,
the connection stays open and the next request goes down the same socket.
That is why Content-Length matters more than it first appears. With the
connection staying open, the end of the body is the only way the client knows
where one response stops and the next begins. Our server sidesteps this by closing
every time; a real one keeps the socket, loops back to step 1, and reads the next
request from the same buffer.
Content-Length.
Compute the body first, measure it, then write the headers. Never guess the number
and never hard-code it. Off by one and the client either hangs waiting for a byte
that never comes, or reads the first byte of the next response as the last byte of this one.
Where this goes
Every web API you have called is this: a request line, headers, a blank line, a body — usually JSON. A coordination server for a mesh VPN, the kind that tells peers each other's addresses, is this server plus a JSON parser and a table of peers. Real deployments wrap the socket in TLS so the bytes on the wire are not readable; what that hides and what it does not is the subject of the last lesson in this track.
But first there is a problem with the server above. handle blocks in
recv until the client sends a complete request. While it waits, the
server is doing nothing else — accept is not being called, and every
other client is stuck in the listen backlog. One slow client, or one client that
connects and sends nothing, freezes the whole server. Fixing that without a thread
per client is the next lesson.
HTTP is ASCII lines ending in \r\n over a TCP stream — delimiter-framed headers, a length-framed body — and you can write both sides with snprintf, strstr, and the socket calls you already know.