diff --git a/frameworks/zeemo-tuned/.gitignore b/frameworks/zeemo-tuned/.gitignore new file mode 100644 index 00000000..6c0ccc8e --- /dev/null +++ b/frameworks/zeemo-tuned/.gitignore @@ -0,0 +1,4 @@ +.zig-cache/ +zig-out/ +zig-pkg/ +*.bak diff --git a/frameworks/zeemo-tuned/Dockerfile b/frameworks/zeemo-tuned/Dockerfile new file mode 100644 index 00000000..1dbbd0d0 --- /dev/null +++ b/frameworks/zeemo-tuned/Dockerfile @@ -0,0 +1,34 @@ +FROM ubuntu:24.04 AS build +ARG ZIG_VERSION=0.16.0 +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl xz-utils \ + libssl-dev pkg-config \ + && rm -rf /var/lib/apt/lists/* +# Architecture comes from `uname -m` so the image matches the host arch. +RUN set -eu; \ + ZIG_ARCH="$(uname -m)"; \ + case "${ZIG_ARCH}" in \ + x86_64|aarch64) ;; \ + *) echo "unsupported arch: ${ZIG_ARCH}" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-${ZIG_ARCH}-linux-${ZIG_VERSION}.tar.xz" | tar -xJ -C /opt; \ + mv "/opt/zig-${ZIG_ARCH}-linux-${ZIG_VERSION}" /opt/zig +ENV PATH="/opt/zig:${PATH}" + +WORKDIR /src +COPY build.zig build.zig.zon ./ +COPY src ./src +# Native build (glibc + dynamic libssl/libcrypto). musl-static would need a +# from-source OpenSSL build; the runtime image carries libssl3 instead. +RUN zig build --release=fast + +# Runtime: Ubuntu slim so libssl3 is present without extra setup. HttpArena +# mounts /data (dataset + static) and /certs (TLS cert + key) when running. +FROM ubuntu:24.04 +RUN apt-get update && apt-get install -y --no-install-recommends \ + libssl3 ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /src/zig-out/bin/zeemo-tuned /zeemo-tuned +EXPOSE 8080 8081 +ENTRYPOINT ["/zeemo-tuned"] diff --git a/frameworks/zeemo-tuned/build.zig b/frameworks/zeemo-tuned/build.zig new file mode 100644 index 00000000..3ca3595f --- /dev/null +++ b/frameworks/zeemo-tuned/build.zig @@ -0,0 +1,24 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{ .preferred_optimize_mode = .ReleaseFast }); + + const zeemo_dep = b.dependency("zeemo", .{ + .target = target, + }); + + const exe_mod = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .strip = true, + }); + exe_mod.addImport("zeemo", zeemo_dep.module("zeemo")); + + const exe = b.addExecutable(.{ + .name = "zeemo-tuned", + .root_module = exe_mod, + }); + b.installArtifact(exe); +} diff --git a/frameworks/zeemo-tuned/build.zig.zon b/frameworks/zeemo-tuned/build.zig.zon new file mode 100644 index 00000000..08229e9c --- /dev/null +++ b/frameworks/zeemo-tuned/build.zig.zon @@ -0,0 +1,48 @@ +.{ + // This is the default name used by packages depending on this one. For + // example, when a user runs `zig fetch --save `, this field is used + // as the key in the `dependencies` table. Although the user can choose a + // different name, most users will stick with this provided value. + // + // It is redundant to include "zig" in this name because it is already + // within the Zig package namespace. + .name = .zeemo_tuned, + // This is a [Semantic Version](https://semver.org/). + // In a future version of Zig it will be used for package deduplication. + .version = "0.0.0", + // Together with name, this represents a globally unique package + // identifier. This field is generated by the Zig toolchain when the + // package is first created, and then *never changes*. This allows + // unambiguous detection of one package being an updated version of + // another. + // + // When forking a Zig project, this id should be regenerated (delete the + // field and run `zig build`) if the upstream project is still maintained. + // Otherwise, the fork is *hostile*, attempting to take control over the + // original project's identity. Thus it is recommended to leave the comment + // on the following line intact, so that it shows up in code reviews that + // modify the field. + .fingerprint = 0xdd574928a6deee1b, // Changing this has security and trust implications. + // Tracks the earliest Zig version that the package considers to be a + // supported use case. + .minimum_zig_version = "0.16.0", + // This field is optional. + // Each dependency must either provide a `url` and `hash`, or a `path`. + // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. + // Once all dependencies are fetched, `zig build` no longer requires + // internet connectivity. + .dependencies = .{ + .zeemo = .{ + .url = "https://github.com/skylightis666/zeemo/archive/8b47c8e7f8387f7f05c08991df8f4efbd12580c8.tar.gz", + .hash = "zeemo-0.1.0-Mq5M4ebxAQCqKgmq_4VN2Hx4S4Gh7_1Z5WjRnVPW4PM7", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + // For example... + //"LICENSE", + //"README.md", + }, +} diff --git a/frameworks/zeemo-tuned/meta.json b/frameworks/zeemo-tuned/meta.json new file mode 100644 index 00000000..56021d19 --- /dev/null +++ b/frameworks/zeemo-tuned/meta.json @@ -0,0 +1,20 @@ +{ + "display_name": "zeemo-tuned", + "language": "Zig", + "engine": "io_uring", + "type": "tuned", + "description": "First Zig tuned entry. Built on the zeemo Zig HTTP library (io_uring transport, multishot accept, fork-workers with SO_REUSEPORT). JSON responses are serialized per request from native struct data via zeemo's comptime serializer — no pre-rendered fragments. Static files are loaded into memory at startup and pre-baked into the full HTTP response per encoding variant.", + "repo": "https://github.com/skylightis666/zeemo", + "enabled": true, + "tests": [ + "baseline", + "pipelined", + "limited-conn", + "json", + "json-comp", + "json-tls", + "static", + "upload" + ], + "maintainers": ["skylightis666"] +} diff --git a/frameworks/zeemo-tuned/src/dataset.zig b/frameworks/zeemo-tuned/src/dataset.zig new file mode 100644 index 00000000..15aba2ea --- /dev/null +++ b/frameworks/zeemo-tuned/src/dataset.zig @@ -0,0 +1,104 @@ +//! Dataset loader for the HttpArena `json` profile. Stores items as +//! native structs and lets the response handler serialize them per +//! request via zeemo's generic comptime serializer. No pre-rendered +//! response fragments — the `tuned` rules ban that shortcut. + +const std = @import("std"); +const linux = std.os.linux; + +pub const Rating = struct { + score: i32, + count: i32, +}; + +pub const Item = struct { + id: i32, + name: []const u8, + category: []const u8, + price: i32, + quantity: i32, + active: bool, + tags: []const []const u8, + rating: Rating, + /// Computed per request as `price * quantity * m`. Stored on the value + /// the handler hands to the serializer so it lands as the last field. + total: i64 = 0, +}; + +pub const Dataset = struct { + items: []Item, + arena: std.heap.ArenaAllocator, + + pub fn deinit(self: *Dataset) void { + self.arena.deinit(); + } +}; + +pub fn load(gpa: std.mem.Allocator, path: []const u8) !Dataset { + var arena = std.heap.ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const aa = arena.allocator(); + + const raw = try readFileAlloc(aa, path, 4 * 1024 * 1024); + var parsed = try std.json.parseFromSlice(std.json.Value, aa, raw, .{}); + defer parsed.deinit(); + + const arr = switch (parsed.value) { + .array => |a| a, + else => return error.BadDataset, + }; + + const items = try aa.alloc(Item, arr.items.len); + for (arr.items, 0..) |elem, i| { + const obj = switch (elem) { + .object => |o| o, + else => return error.BadDataset, + }; + const tags_v = obj.get("tags") orelse return error.BadDataset; + const tags_arr = switch (tags_v) { + .array => |a| a, + else => return error.BadDataset, + }; + const tags = try aa.alloc([]const u8, tags_arr.items.len); + for (tags_arr.items, 0..) |t, k| tags[k] = try aa.dupe(u8, t.string); + const rating_v = obj.get("rating") orelse return error.BadDataset; + const rating_obj = switch (rating_v) { + .object => |o| o, + else => return error.BadDataset, + }; + + items[i] = .{ + .id = @intCast(obj.get("id").?.integer), + .name = try aa.dupe(u8, obj.get("name").?.string), + .category = try aa.dupe(u8, obj.get("category").?.string), + .price = @intCast(obj.get("price").?.integer), + .quantity = @intCast(obj.get("quantity").?.integer), + .active = obj.get("active").?.bool, + .tags = tags, + .rating = .{ + .score = @intCast(rating_obj.get("score").?.integer), + .count = @intCast(rating_obj.get("count").?.integer), + }, + }; + } + return .{ .items = items, .arena = arena }; +} + +fn readFileAlloc(aa: std.mem.Allocator, path: []const u8, max: usize) ![]u8 { + var path_z: [std.posix.PATH_MAX]u8 = undefined; + if (path.len >= path_z.len) return error.NameTooLong; + @memcpy(path_z[0..path.len], path); + path_z[path.len] = 0; + const fd = try std.posix.openatZ(std.posix.AT.FDCWD, @ptrCast(&path_z), .{ .ACCMODE = .RDONLY }, 0); + defer _ = std.posix.system.close(fd); + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(aa); + while (buf.items.len < max) { + try buf.ensureUnusedCapacity(aa, 32 * 1024); + const dst = buf.unusedCapacitySlice(); + const n = try std.posix.read(fd, dst); + if (n == 0) break; + buf.items.len += n; + } + return try buf.toOwnedSlice(aa); +} diff --git a/frameworks/zeemo-tuned/src/main.zig b/frameworks/zeemo-tuned/src/main.zig new file mode 100644 index 00000000..021fdf09 --- /dev/null +++ b/frameworks/zeemo-tuned/src/main.zig @@ -0,0 +1,134 @@ +//! HttpArena `tuned` entry built on the zeemo Zig HTTP library. +//! +//! Handlers go through zeemo's framework HTTP server (no raw sockets), +//! JSON is serialized per request from native struct data via zeemo's +//! comptime serializer (no pre-rendered fragments), and static files are +//! cached + pre-baked at startup (allowed for `tuned`). + +const std = @import("std"); +const zeemo = @import("zeemo"); +const dataset = @import("dataset.zig"); + +var DS: dataset.Dataset = undefined; +var STATIC: zeemo.static.Dir = undefined; + +const STATIC_FILES = struct { + const Entry = struct { name: []const u8, ct: []const u8, compress: bool }; + const all = [_]Entry{ + .{ .name = "reset.css", .ct = "text/css", .compress = true }, + .{ .name = "layout.css", .ct = "text/css", .compress = true }, + .{ .name = "theme.css", .ct = "text/css", .compress = true }, + .{ .name = "components.css", .ct = "text/css", .compress = true }, + .{ .name = "utilities.css", .ct = "text/css", .compress = true }, + .{ .name = "analytics.js", .ct = "application/javascript", .compress = true }, + .{ .name = "helpers.js", .ct = "application/javascript", .compress = true }, + .{ .name = "app.js", .ct = "application/javascript", .compress = true }, + .{ .name = "vendor.js", .ct = "application/javascript", .compress = true }, + .{ .name = "router.js", .ct = "application/javascript", .compress = true }, + .{ .name = "header.html", .ct = "text/html; charset=utf-8", .compress = true }, + .{ .name = "footer.html", .ct = "text/html; charset=utf-8", .compress = true }, + .{ .name = "regular.woff2", .ct = "font/woff2", .compress = false }, + .{ .name = "bold.woff2", .ct = "font/woff2", .compress = false }, + .{ .name = "logo.svg", .ct = "image/svg+xml", .compress = true }, + .{ .name = "icon-sprite.svg", .ct = "image/svg+xml", .compress = true }, + .{ .name = "hero.webp", .ct = "image/webp", .compress = false }, + .{ .name = "thumb1.webp", .ct = "image/webp", .compress = false }, + .{ .name = "thumb2.webp", .ct = "image/webp", .compress = false }, + .{ .name = "manifest.json", .ct = "application/json", .compress = true }, + }; +}; + +pub fn main() !void { + var dba: std.heap.DebugAllocator(.{}) = .init; + defer _ = dba.deinit(); + const gpa = dba.allocator(); + + DS = try dataset.load(gpa, "/data/dataset.json"); + STATIC = zeemo.static.Dir.init(gpa, "/data/static"); + for (STATIC_FILES.all) |f| + try STATIC.add(f.name, f.ct, .{ .compressible = f.compress }); + zeemo.static.setDir(&STATIC); + std.log.info("zeemo-tuned: {d} items, {d} static files", .{ DS.items.len, STATIC.map.count() }); + + var server = zeemo.Server.init(gpa, .{ + .port = 8080, + // tuned-allowed knobs: + .write_inline_bytes = 4 * 1024, + // Sized for HttpArena's 20 MiB upload profile. The recv buffer + // (parser_header_buf) collects raw bytes — headers + body — so + // it has to be ≥ body size + headers room. Both buffers are + // page_allocator-backed and lazily faulted: ~22 MiB virtual per + // slot, near-zero RSS unless an upload actually lands. + .parser_header_buf = 22 * 1024 * 1024, + .parser_body_buf = 20 * 1024 * 1024, + .big_buf_path_prefix = "/json/", + // json-tls profile: HTTPS on 8081, ALPN http/1.1, certs mounted + // by the runner at /certs. + .tls = .{ + .cert_path = "/certs/server.crt", + .key_path = "/certs/server.key", + .port = 8081, + .alpn = "http/1.1", + }, + }); + defer server.deinit(); + + try server.get("/baseline11", baseline11); + try server.post("/baseline11", baseline11); + try server.get("/pipeline", pipeline); + try server.get("/json/:count", jsonHandler); + try server.post("/upload", uploadHandler); + try server.staticMount("/static/", "/data/static", zeemo.static.registerHandler()); + + try server.run(); +} + +fn baseline11(req: *const zeemo.Request, res: *zeemo.Response) !void { + var sum: i64 = 0; + if (req.queryInt("a", i64)) |a| sum += a; + if (req.queryInt("b", i64)) |b| sum += b; + if (req.method == .POST and req.body.len > 0) { + sum += std.fmt.parseInt(i64, req.body, 10) catch 0; + } + try res.printText("{d}", .{sum}); +} + +fn pipeline(_: *const zeemo.Request, res: *zeemo.Response) !void { + try res.text("ok"); +} + +fn uploadHandler(req: *const zeemo.Request, res: *zeemo.Response) !void { + // HttpArena's `upload` profile sends a 20 MiB POST body and expects + // the byte count back. The parser accumulates the full body in + // `req.body` before we get here. + try res.printText("{d}", .{req.body.len}); +} + +fn jsonHandler(req: *const zeemo.Request, res: *zeemo.Response) !void { + const count = try req.param("count", u8); + const m = req.queryInt("m", i64) orelse 1; + if (count == 0 or count > DS.items.len) { + res.status(400); + try res.text("bad count"); + return; + } + var items: [50]dataset.Item = undefined; + for (0..count) |i| { + items[i] = DS.items[i]; + items[i].total = @as(i64, items[i].price) * @as(i64, items[i].quantity) * m; + } + // json-comp profile sets Accept-Encoding: gzip — same handler, just + // gzip the body in place. json profile sends no Accept-Encoding and + // takes the plain branch. + if (req.accepts_gzip) { + try res.jsonGzipped(.{ + .items = items[0..count], + .count = @as(u32, count), + }); + } else { + try res.json(.{ + .items = items[0..count], + .count = @as(u32, count), + }); + } +}