An HTTP Server
The parser takes bytes and returns a request. A server is that, plus somewhere for the bytes to come from and somewhere for the answer to go. There is less new code here than you might expect, which is the point of having built the parser first.
The program
This one has no Run button. It binds a listening socket, which the browser sandbox has no notion of, so it is built for the host and executed there instead: our build runs it on every change, serving two real requests over a real port and checking what the client received. The absence of a Run button costs verification nothing.
const std = @import("std");
const http = @import("request.zig");
/// Serve exactly one connection, then return. A real server wraps this in a
/// loop and hands each connection to a thread or an event loop; the shape of
/// what happens per connection is unchanged, which is the point.
fn serve(server: *std.Io.net.Server, io: std.Io) void {
var conn = server.accept(io) catch return;
defer conn.close(io);
var read_buf: [4096]u8 = undefined;
var reader = conn.reader(io, &read_buf);
// Read until the blank line. HTTP puts no length up front, so the server
// cannot know how much to read: it reads until it sees the end of the
// header section, and only then does Content-Length tell it about a body.
var raw: [4096]u8 = undefined;
var used: usize = 0;
while (std.mem.find(u8, raw[0..used], "\r\n\r\n") == null) {
const line = reader.interface.takeDelimiterInclusive('\n') catch break;
if (used + line.len > raw.len) break;
@memcpy(raw[used..][0..line.len], line);
used += line.len;
}
var storage: [16][2][]const u8 = undefined;
const request = http.parse(raw[0..used], &storage) catch {
respond(&conn, io, 400, "text/plain", "bad request\n");
return;
};
if (std.mem.eql(u8, request.target, "/")) {
respond(&conn, io, 200, "text/html", "<h1>hello</h1>\n");
} else {
respond(&conn, io, 404, "text/plain", "not found\n");
}
}
/// A response is a status line, headers, a blank line, and the body. The
/// Content-Length is not optional politeness: without it the client cannot
/// know the body has ended, and waits.
fn respond(conn: *std.Io.net.Stream, io: std.Io, status: u16, content_type: []const u8, body: []const u8) void {
var buf: [1024]u8 = undefined;
var writer = conn.writer(io, &buf);
const w = &writer.interface;
const reason: []const u8 = switch (status) {
200 => "OK",
400 => "Bad Request",
else => "Not Found",
};
w.print("HTTP/1.1 {d} {s}\r\n", .{ status, reason }) catch return;
w.print("Content-Type: {s}\r\n", .{content_type}) catch return;
w.print("Content-Length: {d}\r\n", .{body.len}) catch return;
w.writeAll("Connection: close\r\n\r\n") catch return;
w.writeAll(body) catch return;
w.flush() catch return;
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
var buf: [1024]u8 = undefined;
var file_writer = std.Io.File.stdout().writerStreaming(io, &buf);
const out = &file_writer.interface;
// Port 0 asks the OS for a free one, so this cannot collide with whatever
// is already running on the machine.
const any_port = try std.Io.net.IpAddress.parse("127.0.0.1", 0);
var server = try any_port.listen(io, .{});
defer server.deinit(io);
const addr = server.socket.address;
for ([_][]const u8{ "/", "/nope" }) |target| {
const thread = try std.Thread.spawn(.{}, serve, .{ &server, io });
var stream = try addr.connect(io, .{ .mode = .stream });
defer stream.close(io);
var write_buf: [512]u8 = undefined;
var writer = stream.writer(io, &write_buf);
try writer.interface.print(
"GET {s} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
.{target},
);
try writer.interface.flush();
var read_buf: [1024]u8 = undefined;
var reader = stream.reader(io, &read_buf);
// Read until the peer closes, rather than looping on lines. The server
// said `Connection: close`, so end of stream is the end of the
// response, and this is the one framing rule that needs no header.
var response: [2048]u8 = undefined;
var got: usize = 0;
while (true) {
const n = reader.interface.readSliceShort(response[got..]) catch break;
if (n == 0) break;
got += n;
}
try out.print("--- GET {s}\n", .{target});
var lines = std.mem.splitSequence(u8, response[0..got], "\r\n");
while (lines.next()) |line| {
if (line.len == 0) continue;
try out.print("{s}\n", .{line});
}
thread.join();
}
try out.flush();
}Run it yourself:zig run server.zig
--- GET /
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 15
Connection: close
<h1>hello</h1>
--- GET /nope
HTTP/1.1 404 Not Found
Content-Type: text/plain
Content-Length: 10
Connection: close
not found
What just happened
The server reads until it sees a blank line. Not a fixed number of bytes,
and not one line: a loop that keeps taking lines until the accumulated bytes
contain \r\n\r\n. This is the framing rule from the last chapter arriving
where it actually bites, because now the bytes are trickling in from a socket
and there is no way to know how many are coming.
Content-Length on the response is not politeness. Without it the client
has no idea the body ended, so it waits, and the request appears to hang. The
alternative is to close the connection and let end-of-stream mark the end,
which is what Connection: close announces and what the client here relies on.
Those two mechanisms are the only ways HTTP/1.1 has to end a body, and chunked
encoding is the third that exists because neither works for a response whose
length is not known when the headers are sent.
The routing is one eql. / gets HTML, everything else gets a 404. That
is genuinely all routing is at this level: a comparison against the target, and
everything a framework adds sits on top of this.
The status line carries a number and a phrase. The number is for programs
and the phrase is for people, and nothing reads the phrase. You can send
HTTP/1.1 200 Fine and every client will accept it.
One connection per serve call, then it returns. A real server wraps this
in a loop and hands each connection to a thread or an event loop, and the
many clients chapter covers the shapes that
take. What happens per connection is unchanged, which is why this is worth
isolating.
Check yourself
The server never checks the method. POST / gets the same HTML as GET /.
Beyond being wrong, why does that matter?
Because GET is defined to be safe and idempotent, and things you do not
control rely on it. Browsers prefetch links, proxies cache responses, and
crawlers follow every URL they find. A server that performs an action on GET
will have that action performed by a caching proxy, a browser’s link preview,
and eventually a search engine. The classic version of this is an admin panel
with GET /delete?id=5 links that a crawler dutifully visited.
Checking the method is one comparison, and the reason it matters is entirely about what the rest of the world assumes.
If you have written C
socket, bind, listen, accept in a loop, and read/write on the
returned descriptor. That is the same sequence here, with the difference that
a failed call is an error in the return type rather than -1 plus errno.
Two C-specific hazards this sidesteps. The read into a fixed buffer has to
leave room for a terminator if the result is going to be treated as a string,
and forgetting that is the off-by-one from the
strings chapter on a buffer holding attacker-
controlled bytes. And write can be partial on a socket just as on a pipe, so
the C version needs its own loop around it; writeAll is that loop.
The one thing the C version makes obvious that this hides is that a socket is
just a file descriptor. read and write work on it because they work on
everything, which is the point file descriptors
makes at length.
Where this section goes next
What is here is the protocol and the socket. What a real server adds, in roughly the order it becomes necessary: serving files from disk with a path traversal guard, routing with query strings and percent-decoding, form bodies, cookies and sessions, and a template engine so the HTML stops living inside string literals. Each is a chapter, and each is mostly parsing, which means most of them will run in the page.