milestone 8: web server, rest api, sse, auth, metrics and static assets

This commit is contained in:
2026-08-02 00:54:13 +02:00
parent a8092bb1b9
commit 5253c47303
59 changed files with 19640 additions and 150 deletions
+201
View File
@@ -0,0 +1,201 @@
//! Build-time web asset indexer (milestone-8 ruling 24).
//!
//! `gen_web_assets <dist-dir> <out-dir>` reads a built web dist directory and
//! writes into `<out-dir>`:
//!
//! - `<name>.gz` next to each asset worth compressing, unless the dist already
//! ships one (a Vite plugin may pre-compress). Compression happens here, at
//! build time, because `flate.Compress` needs a 64 KiB window per stream —
//! a cost the server must not pay per request for immutable content.
//! - `assets.zig`, the module index the server embeds: one entry per servable
//! path with its bytes, content type and a strong ETag. The build system
//! merges `<out-dir>` with a copy of the dist into one WriteFiles directory,
//! so every `@embedFile` path below resolves inside the module root.
//!
//! The entry list is sorted so the output is byte-identical across runs; the
//! build cache keys on it.
const std = @import("std");
const Allocator = std.mem.Allocator;
/// A single asset never legitimately exceeds this; a bigger file is a build
/// mistake, not content to embed.
const max_asset_bytes = 64 * 1024 * 1024;
/// Below this a gzip member's own header and footer eat the savings.
const min_compress_bytes = 128;
const Asset = struct {
/// Request path, `/`-prefixed.
path: []const u8,
/// Path relative to the module root, for `@embedFile`.
file: []const u8,
content_type: []const u8,
etag: [32]u8,
};
pub fn main(init: std.process.Init) !void {
const arena = init.arena.allocator();
const io = init.io;
const args = try init.minimal.args.toSlice(arena);
if (args.len != 3) std.process.fatal("usage: gen_web_assets <dist-dir> <out-dir>", .{});
var dist = std.Io.Dir.cwd().openDir(io, args[1], .{ .iterate = true }) catch |err| {
std.process.fatal("cannot open dist directory '{s}': {t}", .{ args[1], err });
};
defer dist.close(io);
var out = std.Io.Dir.cwd().openDir(io, args[2], .{}) catch |err| {
std.process.fatal("cannot open output directory '{s}': {t}", .{ args[2], err });
};
defer out.close(io);
const names = try collectSorted(arena, io, dist);
var assets: std.ArrayList(Asset) = .empty;
for (names) |name| {
if (std.mem.endsWith(u8, name, ".gz") and contains(names, name[0 .. name.len - 3])) {
// A pre-compressed sibling; indexed alongside its base file below.
continue;
}
const bytes = dist.readFileAlloc(io, name, arena, .limited(max_asset_bytes)) catch |err| {
std.process.fatal("cannot read '{s}': {t}", .{ name, err });
};
try assets.append(arena, .{
.path = try std.fmt.allocPrint(arena, "/{s}", .{name}),
.file = name,
.content_type = contentType(name),
.etag = etagOf(bytes),
});
const sibling = try std.fmt.allocPrint(arena, "{s}.gz", .{name});
const gz = if (contains(names, sibling))
dist.readFileAlloc(io, sibling, arena, .limited(max_asset_bytes)) catch |err| {
std.process.fatal("cannot read '{s}': {t}", .{ sibling, err });
}
else
try compressWorthwhile(arena, io, out, sibling, bytes) orelse continue;
try assets.append(arena, .{
.path = try std.fmt.allocPrint(arena, "/{s}", .{sibling}),
.file = sibling,
.content_type = contentType(name),
.etag = etagOf(gz),
});
}
const index = try renderIndex(arena, assets.items);
try out.writeFile(io, .{ .sub_path = "assets.zig", .data = index });
}
fn collectSorted(arena: Allocator, io: std.Io, dist: std.Io.Dir) ![]const []const u8 {
var names: std.ArrayList([]const u8) = .empty;
var walker = try dist.walk(arena);
defer walker.deinit();
while (try walker.next(io)) |entry| {
if (entry.kind != .file) continue;
for (entry.path) |c| {
if (!std.ascii.isAlphanumeric(c) and std.mem.findScalar(u8, "._-/", c) == null) {
std.process.fatal("asset name '{s}' has a character the index cannot carry", .{entry.path});
}
}
try names.append(arena, try arena.dupe(u8, entry.path));
}
std.mem.sort([]const u8, names.items, {}, lessThan);
return names.items;
}
fn lessThan(_: void, a: []const u8, b: []const u8) bool {
return std.mem.order(u8, a, b) == .lt;
}
fn contains(sorted: []const []const u8, name: []const u8) bool {
for (sorted) |candidate| {
if (std.mem.eql(u8, candidate, name)) return true;
}
return false;
}
/// Gzips `bytes`; writes and returns the result only when it is smaller than
/// the original, else null. Equal-or-larger output means the asset is already
/// compressed (an image, a font) and the sibling would waste binary size.
fn compressWorthwhile(
arena: Allocator,
io: std.Io,
out: std.Io.Dir,
sub_path: []const u8,
bytes: []const u8,
) !?[]const u8 {
if (bytes.len < min_compress_bytes) return null;
var sink: std.Io.Writer.Allocating = try .initCapacity(arena, @max(64, bytes.len / 2));
const window = try arena.alloc(u8, std.compress.flate.max_window_len);
var compress = try std.compress.flate.Compress.init(&sink.writer, window, .gzip, .best);
try compress.writer.writeAll(bytes);
try compress.finish();
const gz = sink.written();
if (gz.len >= bytes.len) return null;
if (std.Io.Dir.path.dirname(sub_path)) |parent| try out.createDirPath(io, parent);
try out.writeFile(io, .{ .sub_path = sub_path, .data = gz });
return gz;
}
fn etagOf(bytes: []const u8) [32]u8 {
var digest: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined;
std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{});
return std.fmt.bytesToHex(digest[0..16].*, .lower);
}
fn contentType(name: []const u8) []const u8 {
const map = [_]struct { ext: []const u8, mime: []const u8 }{
.{ .ext = ".html", .mime = "text/html; charset=utf-8" },
.{ .ext = ".js", .mime = "text/javascript" },
.{ .ext = ".mjs", .mime = "text/javascript" },
.{ .ext = ".css", .mime = "text/css" },
.{ .ext = ".svg", .mime = "image/svg+xml" },
.{ .ext = ".png", .mime = "image/png" },
.{ .ext = ".ico", .mime = "image/x-icon" },
.{ .ext = ".json", .mime = "application/json" },
.{ .ext = ".map", .mime = "application/json" },
.{ .ext = ".webmanifest", .mime = "application/manifest+json" },
.{ .ext = ".txt", .mime = "text/plain; charset=utf-8" },
.{ .ext = ".woff2", .mime = "font/woff2" },
.{ .ext = ".woff", .mime = "font/woff" },
.{ .ext = ".wasm", .mime = "application/wasm" },
};
for (map) |entry| {
if (std.mem.endsWith(u8, name, entry.ext)) return entry.mime;
}
return "application/octet-stream";
}
fn renderIndex(arena: Allocator, assets: []const Asset) ![]const u8 {
var sink: std.Io.Writer.Allocating = try .initCapacity(arena, 4096);
const w = &sink.writer;
try w.writeAll(
\\//! Generated by tools/gen_web_assets.zig. Do not edit.
\\
\\pub const File = struct {
\\ /// Request path, `/`-prefixed.
\\ path: []const u8,
\\ bytes: []const u8,
\\ content_type: []const u8,
\\ /// Strong validator, quotes included, hashed from `bytes` at build time.
\\ etag: []const u8,
\\};
\\
\\pub const files: []const File = &.{
\\
);
for (assets) |asset| {
try w.print(
" .{{ .path = \"{s}\", .bytes = @embedFile(\"{s}\"), " ++
".content_type = \"{s}\", .etag = \"\\\"{s}\\\"\" }},\n",
.{ asset.path, asset.file, asset.content_type, asset.etag },
);
}
try w.writeAll("};\n");
return sink.written();
}