rename web/ to admin/, along with the web-named build and cli identifiers
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
//! Build-time web asset indexer (milestone-8 ruling 24).
|
||||
//!
|
||||
//! `gen_admin_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_admin_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_admin_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