A maildir and Maildir++ library for Zig 0.16: delivery, flags, folders and quota.
7.2 kB
171 lines
1// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>
2// SPDX-License-Identifier: MIT
3
4//! Serves the generated API documentation over HTTP, the way `zig std` serves
5//! the standard library's.
6//!
7//! A server is needed rather than just opening `index.html`, because the
8//! viewer fetches `sources.tar` and `main.wasm` at runtime and a browser
9//! refuses those requests from a `file://` page.
10//!
11//! Run through the build system: `zig build docs-serve`. It is deliberately
12//! minimal, serving one directory to one person on the loopback interface —
13//! it is a convenience for reading `zig build docs`, not a web server, and
14//! nothing about it should be pointed at a network.
15//!
16//! It is worth noting what this is *not*: this repository implements a file
17//! transfer protocol, and none of that is used here. HTTP is what a browser
18//! speaks, so HTTP is what this speaks.
19//!
20//! Every connection gets its own thread, which is not a throughput concern but
21//! a correctness one: a browser opens several connections at once and holds
22//! some of them open without sending anything, so a server that reads them one
23//! at a time blocks on a speculative connection and never answers the real
24//! requests.
25//!
26//! A thread each, rather than a pool: a connection handler blocks until its
27//! client goes away, which can be minutes, and a pool sized for short tasks
28//! wedges once every worker is parked on an idle socket. A browser opens a
29//! handful of connections, so the thread count stays small in practice.
30
31const std = @import("std");
32const Io = std.Io;
33
34/// Nothing in a documentation bundle comes close to this; it exists so that a
35/// stray huge file cannot exhaust memory.
36const max_file_size = 64 * 1024 * 1024;
37
38pub fn main(init: std.process.Init) !void {
39 const gpa = init.gpa;
40 const io = init.io;
41 const args = try init.minimal.args.toSlice(init.arena.allocator());
42
43 var stderr_buffer: [512]u8 = undefined;
44 var stderr_writer: Io.File.Writer = .init(.stderr(), io, &stderr_buffer);
45 const stderr = &stderr_writer.interface;
46
47 if (args.len != 3) {
48 try stderr.writeAll("usage: docs-server <directory> <port>\n");
49 try stderr.flush();
50 std.process.exit(2);
51 }
52 const docs_path = args[1];
53 const port = try std.fmt.parseInt(u16, args[2], 10);
54
55 var docs_dir = Io.Dir.cwd().openDir(io, docs_path, .{}) catch |err| {
56 try stderr.print("cannot open {s}: {s}\n", .{ docs_path, @errorName(err) });
57 try stderr.flush();
58 std.process.exit(1);
59 };
60 defer docs_dir.close(io);
61
62 const address: Io.net.IpAddress = .{ .ip4 = .{ .bytes = .{ 127, 0, 0, 1 }, .port = port } };
63 var server = address.listen(io, .{ .reuse_address = true }) catch |err| {
64 try stderr.print("cannot listen on 127.0.0.1:{d}: {s}\n", .{ port, @errorName(err) });
65 if (err == error.AddressInUse) {
66 try stderr.writeAll("another port can be chosen with -Ddocs-port=N\n");
67 }
68 try stderr.flush();
69 std.process.exit(1);
70 };
71 defer server.deinit(io);
72
73 try stderr.print("serving {s} at http://127.0.0.1:{d}/\npress ctrl-c to stop\n", .{ docs_path, port });
74 try stderr.flush();
75
76 while (true) {
77 const stream = server.accept(io) catch |err| switch (err) {
78 // One client giving up is not a reason to stop serving.
79 error.ConnectionAborted, error.WouldBlock, error.ProtocolFailure => continue,
80 else => return err,
81 };
82
83 const thread = std.Thread.spawn(.{}, handleConnection, .{ io, gpa, docs_dir, stream }) catch {
84 // Out of threads. Serving it here blocks the ones behind it, but
85 // dropping it silently would look like the same hang from the
86 // browser's side with none of the progress.
87 handleConnection(io, gpa, docs_dir, stream);
88 continue;
89 };
90 // Nothing joins these: each ends when its client disconnects, and the
91 // process runs until interrupted.
92 thread.detach();
93 }
94}
95
96/// Serves one connection until the client goes away, then closes it.
97fn handleConnection(io: Io, gpa: std.mem.Allocator, docs_dir: Io.Dir, stream: Io.net.Stream) void {
98 defer stream.close(io);
99
100 var recv_buffer: [16 * 1024]u8 = undefined;
101 var send_buffer: [64 * 1024]u8 = undefined;
102 var stream_reader = stream.reader(io, &recv_buffer);
103 var stream_writer = stream.writer(io, &send_buffer);
104 var http_server: std.http.Server = .init(&stream_reader.interface, &stream_writer.interface);
105
106 // Keep answering on the same connection, so that one page load does not
107 // need a connection per file.
108 while (true) {
109 var request = http_server.receiveHead() catch return;
110 serve(&request, io, gpa, docs_dir) catch return;
111 // A client that asked to close is waiting for exactly that; waiting
112 // for another request it will never send would hang it until it times
113 // out.
114 if (!request.head.keep_alive) return;
115 }
116}
117
118fn serve(
119 request: *std.http.Server.Request,
120 io: Io,
121 gpa: std.mem.Allocator,
122 docs_dir: Io.Dir,
123) !void {
124 const target = request.head.target;
125 const path_end = std.mem.indexOfAny(u8, target, "?#") orelse target.len;
126 var path = target[0..path_end];
127 if (std.mem.startsWith(u8, path, "/")) path = path[1..];
128 if (path.len == 0) path = "index.html";
129
130 // The documentation directory is the whole world this server knows about.
131 if (std.mem.indexOf(u8, path, "..") != null or std.fs.path.isAbsolute(path)) {
132 return request.respond("bad request\n", .{ .status = .bad_request });
133 }
134
135 const content = docs_dir.readFileAlloc(io, path, gpa, .limited(max_file_size)) catch {
136 return request.respond("not found\n", .{ .status = .not_found });
137 };
138 defer gpa.free(content);
139
140 try request.respond(content, .{
141 .extra_headers = &.{.{ .name = "content-type", .value = mimeType(path) }},
142 });
143}
144
145/// Enough of a MIME table for what `zig build docs` emits.
146fn mimeType(path: []const u8) []const u8 {
147 const extension = std.fs.path.extension(path);
148 const table = [_]struct { []const u8, []const u8 }{
149 .{ ".html", "text/html; charset=utf-8" },
150 .{ ".js", "text/javascript; charset=utf-8" },
151 .{ ".css", "text/css; charset=utf-8" },
152 .{ ".wasm", "application/wasm" },
153 .{ ".tar", "application/x-tar" },
154 .{ ".json", "application/json" },
155 .{ ".svg", "image/svg+xml" },
156 };
157 for (table) |entry| {
158 if (std.mem.eql(u8, extension, entry[0])) return entry[1];
159 }
160 return "application/octet-stream";
161}
162
163const testing = std.testing;
164
165test mimeType {
166 try testing.expectEqualStrings("text/html; charset=utf-8", mimeType("index.html"));
167 try testing.expectEqualStrings("application/wasm", mimeType("main.wasm"));
168 try testing.expectEqualStrings("application/x-tar", mimeType("sources.tar"));
169 try testing.expectEqualStrings("text/javascript; charset=utf-8", mimeType("main.js"));
170 try testing.expectEqualStrings("application/octet-stream", mimeType("noextension"));
171}