234 lines
9.5 KiB
Zig
234 lines
9.5 KiB
Zig
//! 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), in which case the shipped
|
|
//! sibling is decompressed and byte-compared against its base file, and a
|
|
//! `.gz` with no base file at all is rejected. 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")) {
|
|
if (!contains(names, name[0 .. name.len - 3])) {
|
|
// The server only ever reaches a `.gz` through its base file's
|
|
// entry, so an orphan is unservable bytes in the binary.
|
|
std.process.fatal("'{s}' has no base file; nothing can serve it", .{name});
|
|
}
|
|
// 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)) shipped: {
|
|
const shipped = dist.readFileAlloc(io, sibling, arena, .limited(max_asset_bytes)) catch |err| {
|
|
std.process.fatal("cannot read '{s}': {t}", .{ sibling, err });
|
|
};
|
|
try verifyShipped(arena, sibling, shipped, bytes);
|
|
break :shipped shipped;
|
|
} 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;
|
|
}
|
|
|
|
/// Fatal unless `gz` decompresses to exactly `base`.
|
|
///
|
|
/// The server answers a compressed request with the sibling's bytes under the
|
|
/// *base* file's identity, so a sibling the dist shipped from a stale or
|
|
/// truncated build would hand every client the wrong content behind a correct
|
|
/// ETag. Nothing downstream can catch that: gzip carries a CRC of whatever it
|
|
/// was compressed from, not of what it was supposed to be.
|
|
fn verifyShipped(arena: Allocator, sub_path: []const u8, gz: []const u8, base: []const u8) !void {
|
|
var input: std.Io.Reader = .fixed(gz);
|
|
const window = try arena.alloc(u8, std.compress.flate.max_window_len);
|
|
var decompress: std.compress.flate.Decompress = .init(&input, .gzip, window);
|
|
const plain = decompress.reader.allocRemaining(arena, .limited(max_asset_bytes)) catch |err| {
|
|
// `Reader` collapses every decode failure into `ReadFailed` and stashes
|
|
// the real one; the stashed name is what identifies the bad file.
|
|
std.process.fatal("'{s}' is not readable gzip: {t}", .{ sub_path, decompress.err orelse err });
|
|
};
|
|
if (!std.mem.eql(u8, plain, base)) {
|
|
std.process.fatal(
|
|
"'{s}' does not decompress to its base file ({d} bytes out, {d} expected)",
|
|
.{ sub_path, plain.len, base.len },
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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();
|
|
}
|