milestone 8: web server, rest api, sse, auth, metrics and static assets
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user