carlos / todomvc-bakeoff Public
feat(zig): server-rendered TodoMVC in Zig with SQLite
std.http.Server on std.Io, SQLite amalgamation compiled by Zig, progressive-enhancement script, Makefile targets per the brief.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 files changed,
+1396
−0
apps/zig/DEPS+1 −0apps/zig/Makefile+45 −0apps/zig/README.md+96 −0apps/zig/build.zig+58 −0apps/zig/build.zig.zon+18 −0apps/zig/src/app.zig+337 −0apps/zig/src/db.zig+234 −0apps/zig/src/db_test.zig+71 −0apps/zig/src/main.zig+154 −0apps/zig/src/view.zig+239 −0apps/zig/static/app.css+71 −0apps/zig/static/app.js+72 −0
diff --git a/apps/zig/DEPS b/apps/zig/DEPS| new file mode 100644 |
| index 0000000..6a329e3 |
| --- /dev/null |
| +++ b/apps/zig/DEPS |
| @@ -0,0 +1 @@ |
| +sqlite 3.54.1 |
diff --git a/apps/zig/Makefile b/apps/zig/Makefile| new file mode 100644 |
| index 0000000..f430b8c |
| --- /dev/null |
| +++ b/apps/zig/Makefile |
| @@ -0,0 +1,45 @@ |
| +# todo-zig. Zig comes from the repo's scripts/env.sh; every Zig cache is |
| +# kept under .cache/ so clean-deps can remove compiled artifacts without |
| +# touching anything outside this directory. |
| + |
| +SHELL := bash |
| +ZIG ?= zig |
| +VERSION ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo dev) |
| + |
| +export ZIG_GLOBAL_CACHE_DIR := $(CURDIR)/.cache/zig |
| +export ZIG_LOCAL_CACHE_DIR := $(CURDIR)/.cache/zig-local |
| +export ZIG_LOCAL_PKG_DIR := $(CURDIR)/.cache/pkg |
| + |
| +BUILD = $(ZIG) build -Doptimize=ReleaseSafe -Dversion=$(VERSION) |
| +SRC = build.zig build.zig.zon $(wildcard src/*.zig static/*) ../../shared/base.css ../../shared/index.css |
| + |
| +.PHONY: build build-arm64 test clean clean-deps bump unbump deploy |
| + |
| +build: $(SRC) |
| + $(BUILD) -Dtarget=native-linux-musl --prefix .cache/out-native |
| + install -D .cache/out-native/bin/app dist/app |
| + |
| +build-arm64: $(SRC) |
| + $(BUILD) -Dtarget=aarch64-linux-musl --prefix .cache/out-arm64 |
| + install -D .cache/out-arm64/bin/app dist/app-linux-arm64 |
| + |
| +test: |
| + $(ZIG) build test -Dtarget=native-linux-musl --summary all |
| + |
| +clean: |
| + rm -rf dist .cache/out-native .cache/out-arm64 |
| + |
| +# Keeps the downloaded SQLite sources (.cache/zig/p and .cache/pkg) and |
| +# drops everything compiled from them. |
| +clean-deps: clean |
| + rm -rf .cache/zig-local .cache/zig/o .cache/zig/h .cache/zig/z .cache/zig/b .cache/zig/tmp |
| + |
| +bump: |
| + sed -i 's/^const health_body = "ok";$$/const health_body = "ok (bumped)";/' src/app.zig |
| + |
| +unbump: |
| + sed -i 's/^const health_body = "ok (bumped)";$$/const health_body = "ok";/' src/app.zig |
| + |
| +deploy: |
| + carlos deploy --account bfj --app todo-zig --target linux-arm64 --version $(VERSION) \ |
| + --message "$${MESSAGE:-todo-zig $(VERSION)}" dist/app-linux-arm64 |
diff --git a/apps/zig/README.md b/apps/zig/README.md| new file mode 100644 |
| index 0000000..6b03a31 |
| --- /dev/null |
| +++ b/apps/zig/README.md |
| @@ -0,0 +1,96 @@ |
| +# 🤖 todo-zig |
| + |
| +TodoMVC for the CARLOS bakeoff, in Zig. It's server-rendered and works |
| +with JavaScript turned off; a small script makes it feel like the |
| +reference TodoMVC when JavaScript is on. Live at |
| +[https://todo-zig.bfj.oncarlos.com](https://todo-zig.bfj.oncarlos.com). |
| + |
| +## The stack, and why |
| + |
| +| Piece | Choice | Why | |
| +|---|---|---| |
| +| Language | Zig 0.17.0 | The setup under test | |
| +| HTTP | `std.http.Server` on `std.Io.net` | Zig's standard library has a complete HTTP/1.1 server. A todo app needs no router or framework, and every outside dependency is one more thing to keep building on a language that still changes between releases | |
| +| Concurrency | `std.Io.Threaded`, one task per connection | It's what `std.process.Init` hands `main` by default. Blocking code, no event loop to reason about | |
| +| SQLite | The official amalgamation, fetched by `build.zig.zon` and compiled by Zig as C | Zig compiles C natively, so SQLite needs no binding library: `addTranslateC` turns `sqlite3.h` into a Zig module and `src/db.zig` wraps the dozen calls the app uses | |
| +| Templates | Hand-written functions in `src/view.zig` writing to a `std.Io.Writer` | One page with one loop. A template engine would be more code than the template | |
| +| Front end | `static/app.js`, a plain ES module, about 80 lines | No framework and no build step. It sends the page's own forms with `fetch` and swaps in the server's re-rendered list | |
| + |
| +The result is one static binary (musl, about 2 MB) with the CSS and |
| +JavaScript embedded. |
| + |
| +### How it fits together |
| + |
| +- `src/main.zig` parses the flags, opens the database, listens on the unix |
| + socket (or `127.0.0.1:<port>`), and hands each connection to its own |
| + task. SIGTERM shuts the listening socket down; the accept loop ends, |
| + open connections are cancelled, and the database is closed. |
| +- `src/app.zig` routes requests, reads the visitor's cookie, checks the |
| + `Origin` of every POST, and answers 303 (or, for the script, 200 with |
| + the fresh page). |
| +- `src/db.zig` is the store: one SQLite connection in WAL mode behind a |
| + mutex, with migrations that only add. Every query that touches a todo is |
| + scoped by list. |
| +- `src/view.zig` renders TodoMVC's own markup. Each action is a real |
| + form; the extra controls a page needs without JavaScript have a |
| + `nojs-` class, and `static/app.css` hides them once the page is marked |
| + `html.js`. |
| + |
| +### Visitors and lists |
| + |
| +The first request without a recognised cookie mints a 256-bit random |
| +token, stores its SHA-256 as a new list, and sets the cookie `todo_list` |
| +(`HttpOnly; SameSite=Lax; Path=/`, plus `Secure` on Carloku). A cookie |
| +the app doesn't recognise, garbled or not, gets a fresh empty list. Item |
| +ids are global, but every update is `WHERE id = ? AND list_id = ?`, so a |
| +POST naming someone else's item answers 404 and changes nothing. |
| + |
| +## Build, run, test, deploy |
| + |
| +``` |
| +source ../../scripts/env.sh # Zig 0.17.0 on PATH |
| +make build # dist/app for this machine (static, musl) |
| +make build-arm64 # dist/app-linux-arm64, the shipped binary |
| +make test # Zig unit tests (view, routing, store) |
| +``` |
| + |
| +Run it locally: |
| + |
| +``` |
| +dist/app --listen 127.0.0.1:8080 --db "$TMPDIR/zig.db" |
| +``` |
| + |
| +The shared behaviour suite, from the repo root, with the app running: |
| + |
| +``` |
| +scripts/test-app.sh http://127.0.0.1:8080 "$TMPDIR/zig-suite.json" |
| +``` |
| + |
| +Before deploying, check the arm64 binary boots on a unix socket under qemu: |
| + |
| +``` |
| +scripts/boot-check.sh apps/zig/dist/app-linux-arm64 |
| +``` |
| + |
| +Deploy (the version is the commit's short SHA): |
| + |
| +``` |
| +make build-arm64 deploy MESSAGE="what changed" |
| +``` |
| + |
| +The first deploy needed the app and its instance created once: |
| + |
| +``` |
| +carlos apps create --account bfj --app todo-zig --build-probe /api/version |
| +carlos instances enable --account bfj --app todo-zig |
| +carlos instances create --account bfj --app todo-zig --host todo-zig.bfj.oncarlos.com --channel edge |
| +``` |
| + |
| +### Caches |
| + |
| +Every Zig cache lives under `.cache/`: compiled objects in |
| +`.cache/zig` and `.cache/zig-local`, the downloaded SQLite archive in |
| +`.cache/zig/p`, and its unpacked sources in `.cache/pkg`. `make clean` |
| +removes build output; `make clean-deps` also removes every compiled |
| +object but keeps the downloaded sources, so the next build compiles |
| +SQLite and Zig's musl libc from scratch. |
diff --git a/apps/zig/build.zig b/apps/zig/build.zig| new file mode 100644 |
| index 0000000..61cf83f |
| --- /dev/null |
| +++ b/apps/zig/build.zig |
| @@ -0,0 +1,58 @@ |
| +const std = @import("std"); |
| + |
| +pub fn build(b: *std.Build) void { |
| + const target = b.standardTargetOptions(.{}); |
| + const optimize = b.standardOptimizeOption(.{}); |
| + const strip = b.option(bool, "strip", "Omit debug info (default: on unless Debug)") orelse (optimize != .debug); |
| + const version = b.option([]const u8, "version", "Build version reported by /api/version") orelse "dev"; |
| + |
| + const options = b.addOptions(); |
| + options.addOption([]const u8, "version", version); |
| + |
| + // SQLite is the official amalgamation, compiled here as C. |
| + const sqlite_dep = b.dependency("sqlite", .{}); |
| + const sqlite_h = b.addTranslateC(.{ |
| + .root_source_file = sqlite_dep.path("sqlite3.h"), |
| + .target = target, |
| + .optimize = optimize, |
| + }); |
| + |
| + const mod = b.createModule(.{ |
| + .root_source_file = b.path("src/main.zig"), |
| + .target = target, |
| + .optimize = optimize, |
| + .strip = strip, |
| + .link_libc = true, |
| + .imports = &.{ |
| + .{ .name = "sqlite", .module = sqlite_h.createModule() }, |
| + }, |
| + }); |
| + mod.addOptions("build_options", options); |
| + mod.addCSourceFile(.{ |
| + .file = sqlite_dep.path("sqlite3.c"), |
| + .flags = &.{ |
| + "-DSQLITE_DQS=0", |
| + "-DSQLITE_DEFAULT_MEMSTATUS=0", |
| + "-DSQLITE_DEFAULT_WAL_SYNCHRONOUS=1", |
| + "-DSQLITE_LIKE_DOESNT_MATCH_BLOBS", |
| + "-DSQLITE_OMIT_DEPRECATED", |
| + "-DSQLITE_OMIT_LOAD_EXTENSION", |
| + "-DSQLITE_OMIT_SHARED_CACHE", |
| + "-DSQLITE_USE_ALLOCA", |
| + "-DSQLITE_THREADSAFE=1", |
| + }, |
| + }); |
| + // The TodoMVC stylesheets are served byte for byte from the repo's |
| + // shared/ directory, so they are embedded from there, not copied. |
| + mod.addAnonymousImport("base.css", .{ .root_source_file = b.path("../../shared/base.css") }); |
| + mod.addAnonymousImport("index.css", .{ .root_source_file = b.path("../../shared/index.css") }); |
| + mod.addAnonymousImport("app.css", .{ .root_source_file = b.path("static/app.css") }); |
| + mod.addAnonymousImport("app.js", .{ .root_source_file = b.path("static/app.js") }); |
| + |
| + const exe = b.addExecutable(.{ .name = "app", .root_module = mod, .linkage = .static }); |
| + b.installArtifact(exe); |
| + |
| + const tests = b.addTest(.{ .root_module = mod }); |
| + const run_tests = b.addRunArtifact(tests); |
| + b.step("test", "Run unit tests").dependOn(&run_tests.step); |
| +} |
diff --git a/apps/zig/build.zig.zon b/apps/zig/build.zig.zon| new file mode 100644 |
| index 0000000..503c547 |
| --- /dev/null |
| +++ b/apps/zig/build.zig.zon |
| @@ -0,0 +1,18 @@ |
| +.{ |
| + .name = .todo_zig, |
| + .version = "0.1.0", |
| + .fingerprint = 0x152007b84cc41fbe, |
| + .minimum_zig_version = "0.17.0", |
| + .dependencies = .{ |
| + .sqlite = .{ |
| + .url = "https://sqlite.org/2026/sqlite-amalgamation-3540100.zip", |
| + .hash = "N-V-__8AAETTsABEodUZZFAyfdAm9hEyqBXuLw9pRSO0vFh9", |
| + }, |
| + }, |
| + .paths = .{ |
| + "build.zig", |
| + "build.zig.zon", |
| + "src", |
| + "static", |
| + }, |
| +} |
diff --git a/apps/zig/src/app.zig b/apps/zig/src/app.zig| new file mode 100644 |
| index 0000000..864b88c |
| --- /dev/null |
| +++ b/apps/zig/src/app.zig |
| @@ -0,0 +1,337 @@ |
| +//! Routes and handlers. One `handle` call answers one HTTP request. |
| + |
| +const std = @import("std"); |
| +const http = std.http; |
| +const Allocator = std.mem.Allocator; |
| +const Io = std.Io; |
| +const build_options = @import("build_options"); |
| +const db = @import("db.zig"); |
| +const view = @import("view.zig"); |
| +const Filter = view.Filter; |
| + |
| +/// The body of `GET /healthz`. `make bump` edits this string. |
| +const health_body = "ok"; |
| + |
| +const cookie_name = "todo_list"; |
| +/// Raw token bytes; the cookie carries them hex-encoded (256 bits). |
| +const token_len = 32; |
| +const max_body = 1 << 20; |
| + |
| +/// The request header app.js sends; with it, actions answer 200 with the |
| +/// re-rendered page instead of a 303. |
| +const fetch_header = "x-requested-with"; |
| + |
| +pub const App = struct { |
| + store: *db.Store, |
| + io: Io, |
| + /// Origins a POST may come from. Empty means "match the Host header". |
| + origins: []const []const u8, |
| + /// Whether the app is served over https, so its cookie is `Secure`. |
| + https: bool, |
| +}; |
| + |
| +/// The headers this app reads, copied out before the body is read |
| +/// (reading the body invalidates the request's header memory). |
| +const Headers = struct { |
| + cookie: ?[]const u8 = null, |
| + origin: ?[]const u8 = null, |
| + host: ?[]const u8 = null, |
| + content_type: ?[]const u8 = null, |
| + forwarded_proto: ?[]const u8 = null, |
| + from_script: bool = false, |
| + |
| + fn read(req: *const http.Server.Request) Headers { |
| + var h: Headers = .{ .content_type = req.head.content_type }; |
| + var it = req.iterateHeaders(); |
| + while (it.next()) |field| { |
| + if (std.ascii.eqlIgnoreCase(field.name, "cookie")) { |
| + h.cookie = field.value; |
| + } else if (std.ascii.eqlIgnoreCase(field.name, "origin")) { |
| + h.origin = field.value; |
| + } else if (std.ascii.eqlIgnoreCase(field.name, "host")) { |
| + h.host = field.value; |
| + } else if (std.ascii.eqlIgnoreCase(field.name, "x-forwarded-proto")) { |
| + h.forwarded_proto = field.value; |
| + } else if (std.ascii.eqlIgnoreCase(field.name, fetch_header)) { |
| + h.from_script = true; |
| + } |
| + } |
| + return h; |
| + } |
| +}; |
| + |
| +/// What a handler decided; `handle` turns it into bytes on the wire. |
| +const Response = struct { |
| + status: http.Status = .ok, |
| + content_type: []const u8 = "text/plain; charset=utf-8", |
| + body: []const u8 = "", |
| + location: ?[]const u8 = null, |
| + cache: []const u8 = "no-store", |
| +}; |
| + |
| +pub fn handle(app: *const App, req: *http.Server.Request, arena: Allocator) !void { |
| + // Copy what we need before reading the body invalidates it. |
| + const target = try arena.dupe(u8, req.head.target); |
| + const method = req.head.method; |
| + var headers = Headers.read(req); |
| + inline for (.{ "cookie", "origin", "host", "content_type", "forwarded_proto" }) |name| { |
| + if (@field(headers, name)) |v| @field(headers, name) = try arena.dupe(u8, v); |
| + } |
| + |
| + const query_start = std.mem.findScalar(u8, target, '?'); |
| + const path = target[0 .. query_start orelse target.len]; |
| + const query = if (query_start) |q| target[q + 1 ..] else ""; |
| + |
| + var visitor = Visitor{ .app = app, .headers = &headers }; |
| + const res: Response = switch (method) { |
| + .GET, .HEAD => try get(app, &visitor, arena, path, query), |
| + .POST => post: { |
| + // A body we can't size can't be read safely on a kept-alive |
| + // connection; refuse it rather than guess. |
| + if (req.head.content_length == null and req.head.transfer_encoding == .none) { |
| + break :post .{ .status = .length_required, .body = "length required\n" }; |
| + } |
| + if (!originAllowed(app, &headers)) break :post .{ .status = .forbidden, .body = "cross-origin request refused\n" }; |
| + const reader = req.readerExpectContinue(&.{}) catch return error.WriteFailed; |
| + const body = reader.allocRemaining(arena, .limited(max_body)) catch |err| switch (err) { |
| + error.StreamTooLong => break :post .{ .status = .payload_too_large, .body = "too large\n" }, |
| + else => return err, |
| + }; |
| + break :post try post(app, &visitor, arena, path, body, headers.from_script); |
| + }, |
| + else => .{ .status = .method_not_allowed, .body = "method not allowed\n" }, |
| + }; |
| + |
| + var extra: [4]http.Header = undefined; |
| + var n: usize = 0; |
| + extra[n] = .{ .name = "content-type", .value = res.content_type }; |
| + n += 1; |
| + extra[n] = .{ .name = "cache-control", .value = res.cache }; |
| + n += 1; |
| + if (res.location) |loc| { |
| + extra[n] = .{ .name = "location", .value = loc }; |
| + n += 1; |
| + } |
| + if (visitor.set_cookie) |cookie| { |
| + extra[n] = .{ .name = "set-cookie", .value = cookie }; |
| + n += 1; |
| + } |
| + // After a refused or oversized body, the rest of it may still be on |
| + // the wire, so the connection can't carry another request. |
| + const keep_alive = res.status != .length_required and res.status != .payload_too_large; |
| + try req.respond(res.body, .{ |
| + .status = res.status, |
| + .extra_headers = extra[0..n], |
| + .keep_alive = keep_alive, |
| + }); |
| +} |
| + |
| +fn get(app: *const App, visitor: *Visitor, arena: Allocator, path: []const u8, query: []const u8) !Response { |
| + if (std.mem.eql(u8, path, "/healthz")) return .{ .body = health_body }; |
| + if (std.mem.eql(u8, path, "/api/version")) return .{ .body = build_options.version }; |
| + if (std.mem.startsWith(u8, path, "/static/")) return static(path["/static/".len..]); |
| + |
| + const filter = Filter.fromPath(path) orelse return .{ .status = .not_found, .body = "not found\n" }; |
| + const list = try visitor.list(); |
| + const todos = try app.store.todos(arena, list); |
| + var editing: ?i64 = null; |
| + var params = std.mem.splitScalar(u8, query, '&'); |
| + while (params.next()) |param| { |
| + if (std.mem.startsWith(u8, param, "edit=")) editing = std.fmt.parseInt(i64, param["edit=".len..], 10) catch null; |
| + } |
| + return page(arena, .{ .todos = todos, .filter = filter, .editing = editing }); |
| +} |
| + |
| +fn page(arena: Allocator, p: view.Page) !Response { |
| + var out: Io.Writer.Allocating = try .initCapacity(arena, 4096 + 512 * p.todos.len); |
| + try view.render(&out.writer, p); |
| + return .{ .content_type = "text/html; charset=utf-8", .body = out.written() }; |
| +} |
| + |
| +fn static(name: []const u8) Response { |
| + const files = .{ |
| + .{ "base.css", "text/css; charset=utf-8", @embedFile("base.css") }, |
| + .{ "index.css", "text/css; charset=utf-8", @embedFile("index.css") }, |
| + .{ "app.css", "text/css; charset=utf-8", @embedFile("app.css") }, |
| + .{ "app.js", "text/javascript; charset=utf-8", @embedFile("app.js") }, |
| + }; |
| + inline for (files) |f| { |
| + if (std.mem.eql(u8, name, f[0])) return .{ .content_type = f[1], .body = f[2], .cache = "public, max-age=300" }; |
| + } |
| + return .{ .status = .not_found, .body = "not found\n" }; |
| +} |
| + |
| +fn post(app: *const App, visitor: *Visitor, arena: Allocator, path: []const u8, body: []u8, from_script: bool) !Response { |
| + var form: Form = .{}; |
| + form.parse(body); |
| + const filter = Filter.fromField(form.filter orelse ""); |
| + const store = app.store; |
| + |
| + const not_found: Response = .{ .status = .not_found, .body = "no such todo\n" }; |
| + if (std.mem.eql(u8, path, "/todos")) { |
| + const title = std.mem.trim(u8, form.title orelse "", &std.ascii.whitespace); |
| + const list = try visitor.list(); |
| + if (title.len > 0) try store.add(list, title); |
| + } else if (std.mem.eql(u8, path, "/todos/toggle-all")) { |
| + try store.toggleAll(try visitor.list()); |
| + } else if (std.mem.eql(u8, path, "/todos/clear-completed")) { |
| + try store.clearCompleted(try visitor.list()); |
| + } else if (itemAction(path)) |action| { |
| + const list = try visitor.list(); |
| + const found = switch (action.verb) { |
| + .toggle => try store.toggle(list, action.id), |
| + .destroy => try store.destroy(list, action.id), |
| + .edit => edit: { |
| + const title = std.mem.trim(u8, form.title orelse "", &std.ascii.whitespace); |
| + break :edit if (title.len == 0) |
| + try store.destroy(list, action.id) |
| + else |
| + try store.rename(list, action.id, title); |
| + }, |
| + }; |
| + if (!found) return not_found; |
| + } else { |
| + return .{ .status = .not_found, .body = "not found\n" }; |
| + } |
| + |
| + if (from_script) { |
| + return page(arena, .{ .todos = try store.todos(arena, try visitor.list()), .filter = filter }); |
| + } |
| + return .{ .status = .see_other, .location = filter.path() }; |
| +} |
| + |
| +const ItemAction = struct { |
| + id: i64, |
| + verb: enum { toggle, edit, destroy }, |
| +}; |
| + |
| +/// Parses `/todos/<id>/<verb>`. |
| +fn itemAction(path: []const u8) ?ItemAction { |
| + const rest = std.mem.cutPrefix(u8, path, "/todos/") orelse return null; |
| + const slash = std.mem.findScalar(u8, rest, '/') orelse return null; |
| + const id = std.fmt.parseInt(i64, rest[0..slash], 10) catch return null; |
| + const verb = std.meta.stringToEnum(@FieldType(ItemAction, "verb"), rest[slash + 1 ..]) orelse return null; |
| + return .{ .id = id, .verb = verb }; |
| +} |
| + |
| +/// The fields of an `application/x-www-form-urlencoded` body this app reads. |
| +const Form = struct { |
| + title: ?[]const u8 = null, |
| + filter: ?[]const u8 = null, |
| + |
| + fn parse(form: *Form, body: []u8) void { |
| + var pairs = std.mem.splitScalar(u8, body, '&'); |
| + while (pairs.next()) |pair_const| { |
| + // The split hands back slices of `body`, which is ours to decode in place. |
| + const pair = @constCast(pair_const); |
| + const eq = std.mem.findScalar(u8, pair, '=') orelse continue; |
| + const key = decode(pair[0..eq]); |
| + const value = decode(pair[eq + 1 ..]); |
| + if (std.mem.eql(u8, key, "title")) form.title = value; |
| + if (std.mem.eql(u8, key, "filter")) form.filter = value; |
| + } |
| + } |
| + |
| + fn decode(s: []u8) []u8 { |
| + for (s) |*ch| { |
| + if (ch.* == '+') ch.* = ' '; |
| + } |
| + return std.Uri.percentDecodeInPlace(s); |
| + } |
| +}; |
| + |
| +/// The visitor's list, found from (or minted into) their cookie on first use. |
| +const Visitor = struct { |
| + app: *const App, |
| + headers: *const Headers, |
| + list_id: ?i64 = null, |
| + set_cookie: ?[]const u8 = null, |
| + cookie_buf: [160]u8 = undefined, |
| + |
| + fn list(v: *Visitor) !i64 { |
| + if (v.list_id) |id| return id; |
| + if (v.headers.cookie) |header| { |
| + if (cookieValue(header, cookie_name)) |token_hex| { |
| + if (token_hex.len == token_len * 2) { |
| + if (try v.app.store.findList(&hashToken(token_hex))) |id| { |
| + v.list_id = id; |
| + return id; |
| + } |
| + } |
| + } |
| + } |
| + // No cookie, or one we don't recognise: a fresh, empty list. |
| + var token: [token_len]u8 = undefined; |
| + try v.app.io.randomSecure(&token); |
| + const token_hex = std.fmt.bytesToHex(token, .lower); |
| + const id = try v.app.store.createList(&hashToken(&token_hex)); |
| + const secure = v.app.https or |
| + (if (v.headers.forwarded_proto) |p| std.ascii.eqlIgnoreCase(p, "https") else false); |
| + v.set_cookie = try std.fmt.bufPrint(&v.cookie_buf, "{s}={s}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000{s}", .{ |
| + cookie_name, token_hex, if (secure) "; Secure" else "", |
| + }); |
| + v.list_id = id; |
| + return id; |
| + } |
| +}; |
| + |
| +/// The database keeps only a hash of each token, hex-encoded. |
| +fn hashToken(token_hex: []const u8) [64]u8 { |
| + var digest: [32]u8 = undefined; |
| + std.crypto.hash.sha2.Sha256.hash(token_hex, &digest, .{}); |
| + return std.fmt.bytesToHex(digest, .lower); |
| +} |
| + |
| +fn cookieValue(header: []const u8, name: []const u8) ?[]const u8 { |
| + var it = std.mem.splitScalar(u8, header, ';'); |
| + while (it.next()) |part| { |
| + const kv = std.mem.trim(u8, part, " "); |
| + const eq = std.mem.findScalar(u8, kv, '=') orelse continue; |
| + if (std.mem.eql(u8, kv[0..eq], name)) return kv[eq + 1 ..]; |
| + } |
| + return null; |
| +} |
| + |
| +fn originAllowed(app: *const App, h: *const Headers) bool { |
| + const origin = h.origin orelse return true; |
| + for (app.origins) |allowed| { |
| + if (std.mem.eql(u8, origin, allowed)) return true; |
| + } |
| + if (app.origins.len > 0) return false; |
| + // No configured origin: accept the request's own host, either scheme. |
| + const host = h.host orelse return false; |
| + const after_scheme = std.mem.cutPrefix(u8, origin, "https://") orelse |
| + std.mem.cutPrefix(u8, origin, "http://") orelse return false; |
| + return std.mem.eql(u8, after_scheme, host); |
| +} |
| + |
| +test itemAction { |
| + try std.testing.expectEqual(ItemAction{ .id = 12, .verb = .toggle }, itemAction("/todos/12/toggle").?); |
| + try std.testing.expectEqual(ItemAction{ .id = 3, .verb = .edit }, itemAction("/todos/3/edit").?); |
| + try std.testing.expectEqual(null, itemAction("/todos/x/toggle")); |
| + try std.testing.expectEqual(null, itemAction("/todos/3/explode")); |
| + try std.testing.expectEqual(null, itemAction("/todos/toggle-all")); |
| +} |
| + |
| +test "form decoding" { |
| + var body = "title=+caf%C3%A9+%26+%3Cb%3E+&filter=active".*; |
| + var form: Form = .{}; |
| + form.parse(&body); |
| + try std.testing.expectEqualStrings(" café & <b> ", form.title.?); |
| + try std.testing.expectEqualStrings("active", form.filter.?); |
| +} |
| + |
| +test cookieValue { |
| + try std.testing.expectEqualStrings("abc", cookieValue("a=1; todo_list=abc; b=2", "todo_list").?); |
| + try std.testing.expectEqual(null, cookieValue("a=1", "todo_list")); |
| +} |
| + |
| +test originAllowed { |
| + const configured: App = .{ .store = undefined, .io = undefined, .origins = &.{"https://todo.example"}, .https = true }; |
| + try std.testing.expect(originAllowed(&configured, &.{})); |
| + try std.testing.expect(originAllowed(&configured, &.{ .origin = "https://todo.example" })); |
| + try std.testing.expect(!originAllowed(&configured, &.{ .origin = "https://evil.example" })); |
| + const unconfigured: App = .{ .store = undefined, .io = undefined, .origins = &.{}, .https = false }; |
| + try std.testing.expect(originAllowed(&unconfigured, &.{ .origin = "http://h:1", .host = "h:1" })); |
| + try std.testing.expect(!originAllowed(&unconfigured, &.{ .origin = "http://evil", .host = "h:1" })); |
| +} |
diff --git a/apps/zig/src/db.zig b/apps/zig/src/db.zig| new file mode 100644 |
| index 0000000..119f6aa |
| --- /dev/null |
| +++ b/apps/zig/src/db.zig |
| @@ -0,0 +1,234 @@ |
| +//! The todo store: one SQLite file in WAL mode, one connection shared by |
| +//! every request behind a mutex. A todo app's queries take microseconds, |
| +//! so a single serialised connection is simpler than a pool and plenty fast. |
| + |
| +const std = @import("std"); |
| +const c = @import("sqlite"); |
| +const Io = std.Io; |
| +const Allocator = std.mem.Allocator; |
| + |
| +pub const Error = error{Sqlite}; |
| + |
| +pub const Todo = struct { |
| + id: i64, |
| + title: []const u8, |
| + completed: bool, |
| +}; |
| + |
| +/// Migrations only ever add. `PRAGMA user_version` records how many have run. |
| +const migrations = [_][:0]const u8{ |
| + \\CREATE TABLE lists ( |
| + \\ id INTEGER PRIMARY KEY, |
| + \\ token_hash TEXT NOT NULL UNIQUE, |
| + \\ created_at INTEGER NOT NULL DEFAULT (unixepoch()) |
| + \\); |
| + \\CREATE TABLE todos ( |
| + \\ id INTEGER PRIMARY KEY AUTOINCREMENT, |
| + \\ list_id INTEGER NOT NULL REFERENCES lists(id), |
| + \\ title TEXT NOT NULL, |
| + \\ completed INTEGER NOT NULL DEFAULT 0 |
| + \\); |
| + \\CREATE INDEX todos_by_list ON todos(list_id, id); |
| + , |
| +}; |
| + |
| +pub const Store = struct { |
| + db: *c.sqlite3, |
| + mutex: Io.Mutex = .init, |
| + io: Io, |
| + |
| + pub fn open(io: Io, path: [:0]const u8) Error!Store { |
| + var db: ?*c.sqlite3 = null; |
| + const flags = c.SQLITE_OPEN_READWRITE | c.SQLITE_OPEN_CREATE | c.SQLITE_OPEN_FULLMUTEX; |
| + if (c.sqlite3_open_v2(path.ptr, &db, flags, null) != c.SQLITE_OK) { |
| + if (db) |d| { |
| + std.log.err("sqlite open {s}: {s}", .{ path, c.sqlite3_errmsg(d) }); |
| + _ = c.sqlite3_close(d); |
| + } |
| + return error.Sqlite; |
| + } |
| + var store: Store = .{ .db = db.?, .io = io }; |
| + errdefer store.close(); |
| + _ = c.sqlite3_busy_timeout(store.db, 5000); |
| + try store.exec("PRAGMA journal_mode = WAL"); |
| + try store.exec("PRAGMA synchronous = NORMAL"); |
| + try store.exec("PRAGMA foreign_keys = ON"); |
| + try store.migrate(); |
| + return store; |
| + } |
| + |
| + pub fn close(s: *Store) void { |
| + _ = c.sqlite3_close(s.db); |
| + } |
| + |
| + fn migrate(s: *Store) Error!void { |
| + var st = try s.prepare("PRAGMA user_version"); |
| + const current: usize = if (try st.step()) @intCast(st.int(0)) else 0; |
| + st.finalize(); |
| + for (migrations[@min(current, migrations.len)..], current..) |sql, i| { |
| + try s.exec("BEGIN"); |
| + errdefer s.exec("ROLLBACK") catch {}; |
| + try s.exec(sql); |
| + var buf: [64]u8 = undefined; |
| + const set = std.fmt.bufPrintSentinel(&buf, "PRAGMA user_version = {d}", .{i + 1}, 0) catch unreachable; |
| + try s.exec(set); |
| + try s.exec("COMMIT"); |
| + } |
| + } |
| + |
| + fn exec(s: *Store, sql: [:0]const u8) Error!void { |
| + var err: [*c]u8 = null; |
| + if (c.sqlite3_exec(s.db, sql.ptr, null, null, &err) != c.SQLITE_OK) { |
| + std.log.err("sqlite: {s}", .{err}); |
| + c.sqlite3_free(err); |
| + return error.Sqlite; |
| + } |
| + } |
| + |
| + fn prepare(s: *Store, sql: [:0]const u8) Error!Stmt { |
| + var stmt: ?*c.sqlite3_stmt = null; |
| + if (c.sqlite3_prepare_v2(s.db, sql.ptr, @intCast(sql.len + 1), &stmt, null) != c.SQLITE_OK) { |
| + std.log.err("sqlite prepare: {s}", .{c.sqlite3_errmsg(s.db)}); |
| + return error.Sqlite; |
| + } |
| + return .{ .stmt = stmt.?, .db = s.db }; |
| + } |
| + |
| + /// Runs one statement that returns no rows; answers how many rows it changed. |
| + fn run(s: *Store, sql: [:0]const u8, args: anytype) Error!usize { |
| + var st = try s.prepare(sql); |
| + defer st.finalize(); |
| + try st.bindAll(args); |
| + while (try st.step()) {} |
| + return @intCast(c.sqlite3_changes(s.db)); |
| + } |
| + |
| + fn lock(s: *Store) void { |
| + s.mutex.lockUncancelable(s.io); |
| + } |
| + |
| + fn unlock(s: *Store) void { |
| + s.mutex.unlock(s.io); |
| + } |
| + |
| + // The queries the app runs. Every one that touches a todo is scoped |
| + // by list, so one visitor can never reach another's items. |
| + |
| + pub fn findList(s: *Store, token_hash: []const u8) Error!?i64 { |
| + s.lock(); |
| + defer s.unlock(); |
| + var st = try s.prepare("SELECT id FROM lists WHERE token_hash = ?"); |
| + defer st.finalize(); |
| + try st.bindAll(.{token_hash}); |
| + return if (try st.step()) st.int(0) else null; |
| + } |
| + |
| + pub fn createList(s: *Store, token_hash: []const u8) Error!i64 { |
| + s.lock(); |
| + defer s.unlock(); |
| + _ = try s.run("INSERT INTO lists (token_hash) VALUES (?)", .{token_hash}); |
| + return c.sqlite3_last_insert_rowid(s.db); |
| + } |
| + |
| + /// Every todo in the list, oldest first, copied into `arena`. |
| + pub fn todos(s: *Store, arena: Allocator, list: i64) (Error || Allocator.Error)![]Todo { |
| + s.lock(); |
| + defer s.unlock(); |
| + var st = try s.prepare("SELECT id, title, completed FROM todos WHERE list_id = ? ORDER BY id"); |
| + defer st.finalize(); |
| + try st.bindAll(.{list}); |
| + var out: std.ArrayList(Todo) = .empty; |
| + while (try st.step()) { |
| + try out.append(arena, .{ |
| + .id = st.int(0), |
| + .title = try arena.dupe(u8, st.text(1)), |
| + .completed = st.int(2) != 0, |
| + }); |
| + } |
| + return out.items; |
| + } |
| + |
| + pub fn add(s: *Store, list: i64, title: []const u8) Error!void { |
| + s.lock(); |
| + defer s.unlock(); |
| + _ = try s.run("INSERT INTO todos (list_id, title) VALUES (?, ?)", .{ list, title }); |
| + } |
| + |
| + /// Each of these answers false when `id` isn't in `list`. |
| + pub fn toggle(s: *Store, list: i64, id: i64) Error!bool { |
| + s.lock(); |
| + defer s.unlock(); |
| + return try s.run("UPDATE todos SET completed = 1 - completed WHERE id = ? AND list_id = ?", .{ id, list }) > 0; |
| + } |
| + |
| + pub fn rename(s: *Store, list: i64, id: i64, title: []const u8) Error!bool { |
| + s.lock(); |
| + defer s.unlock(); |
| + return try s.run("UPDATE todos SET title = ? WHERE id = ? AND list_id = ?", .{ title, id, list }) > 0; |
| + } |
| + |
| + pub fn destroy(s: *Store, list: i64, id: i64) Error!bool { |
| + s.lock(); |
| + defer s.unlock(); |
| + return try s.run("DELETE FROM todos WHERE id = ? AND list_id = ?", .{ id, list }) > 0; |
| + } |
| + |
| + /// Completes every item, or, if all already were, makes them all active. |
| + pub fn toggleAll(s: *Store, list: i64) Error!void { |
| + s.lock(); |
| + defer s.unlock(); |
| + _ = try s.run( |
| + \\UPDATE todos SET completed = |
| + \\ EXISTS (SELECT 1 FROM todos WHERE list_id = ?1 AND completed = 0) |
| + \\WHERE list_id = ?1 |
| + , .{list}); |
| + } |
| + |
| + pub fn clearCompleted(s: *Store, list: i64) Error!void { |
| + s.lock(); |
| + defer s.unlock(); |
| + _ = try s.run("DELETE FROM todos WHERE list_id = ? AND completed = 1", .{list}); |
| + } |
| +}; |
| + |
| +const Stmt = struct { |
| + stmt: *c.sqlite3_stmt, |
| + db: *c.sqlite3, |
| + |
| + fn finalize(st: *Stmt) void { |
| + _ = c.sqlite3_finalize(st.stmt); |
| + } |
| + |
| + /// Binds `args` in order. Text is bound without copying, so it must |
| + /// outlive the statement, which every caller here guarantees. |
| + fn bindAll(st: *Stmt, args: anytype) Error!void { |
| + inline for (args, 1..) |arg, i| { |
| + const rc = switch (@TypeOf(arg)) { |
| + i64 => c.sqlite3_bind_int64(st.stmt, i, arg), |
| + []const u8, []u8 => c.sqlite3_bind_text(st.stmt, i, arg.ptr, @intCast(arg.len), null), |
| + else => @compileError("can't bind " ++ @typeName(@TypeOf(arg))), |
| + }; |
| + if (rc != c.SQLITE_OK) return error.Sqlite; |
| + } |
| + } |
| + |
| + fn step(st: *Stmt) Error!bool { |
| + return switch (c.sqlite3_step(st.stmt)) { |
| + c.SQLITE_ROW => true, |
| + c.SQLITE_DONE => false, |
| + else => { |
| + std.log.err("sqlite step: {s}", .{c.sqlite3_errmsg(st.db)}); |
| + return error.Sqlite; |
| + }, |
| + }; |
| + } |
| + |
| + fn int(st: *Stmt, col: c_int) i64 { |
| + return c.sqlite3_column_int64(st.stmt, col); |
| + } |
| + |
| + fn text(st: *Stmt, col: c_int) []const u8 { |
| + const ptr = c.sqlite3_column_text(st.stmt, col) orelse return ""; |
| + return ptr[0..@intCast(c.sqlite3_column_bytes(st.stmt, col))]; |
| + } |
| +}; |
diff --git a/apps/zig/src/db_test.zig b/apps/zig/src/db_test.zig| new file mode 100644 |
| index 0000000..8457317 |
| --- /dev/null |
| +++ b/apps/zig/src/db_test.zig |
| @@ -0,0 +1,71 @@ |
| +//! Store tests against an in-memory SQLite database. |
| + |
| +const std = @import("std"); |
| +const testing = std.testing; |
| +const Store = @import("db.zig").Store; |
| + |
| +fn titles(todos: anytype) ![]const u8 { |
| + var out: std.Io.Writer.Allocating = .init(testing.allocator); |
| + errdefer out.deinit(); |
| + for (todos) |t| try out.writer.print("{s}{s};", .{ if (t.completed) "x " else "", t.title }); |
| + return out.toOwnedSlice(); |
| +} |
| + |
| +fn expectList(store: *Store, list: i64, want: []const u8) !void { |
| + var arena: std.heap.ArenaAllocator = .init(testing.allocator); |
| + defer arena.deinit(); |
| + const got = try titles(try store.todos(arena.allocator(), list)); |
| + defer testing.allocator.free(got); |
| + try testing.expectEqualStrings(want, got); |
| +} |
| + |
| +test "lists are separate and every action is scoped to its list" { |
| + var store = try Store.open(testing.io, ":memory:"); |
| + defer store.close(); |
| + |
| + const a = try store.createList("hash-a"); |
| + const b = try store.createList("hash-b"); |
| + try testing.expectEqual(a, (try store.findList("hash-a")).?); |
| + try testing.expectEqual(null, try store.findList("hash-c")); |
| + |
| + try store.add(a, "one"); |
| + try store.add(a, "two"); |
| + try store.add(b, "theirs"); |
| + try expectList(&store, a, "one;two;"); |
| + |
| + var arena: std.heap.ArenaAllocator = .init(testing.allocator); |
| + defer arena.deinit(); |
| + const theirs = (try store.todos(arena.allocator(), b))[0].id; |
| + try testing.expect(!try store.toggle(a, theirs)); |
| + try testing.expect(!try store.rename(a, theirs, "mine now")); |
| + try testing.expect(!try store.destroy(a, theirs)); |
| + try expectList(&store, b, "theirs;"); |
| + |
| + const two = (try store.todos(arena.allocator(), a))[1].id; |
| + try testing.expect(try store.toggle(a, two)); |
| + try expectList(&store, a, "one;x two;"); |
| + try testing.expect(try store.rename(a, two, "2 ✓")); |
| + try expectList(&store, a, "one;x 2 ✓;"); |
| +} |
| + |
| +test "toggle all, then back, then clear completed" { |
| + var store = try Store.open(testing.io, ":memory:"); |
| + defer store.close(); |
| + const a = try store.createList("hash-a"); |
| + try store.add(a, "one"); |
| + try store.add(a, "two"); |
| + |
| + try store.toggleAll(a); |
| + try expectList(&store, a, "x one;x two;"); |
| + try store.toggleAll(a); |
| + try expectList(&store, a, "one;two;"); |
| + |
| + var arena: std.heap.ArenaAllocator = .init(testing.allocator); |
| + defer arena.deinit(); |
| + _ = try store.toggle(a, (try store.todos(arena.allocator(), a))[0].id); |
| + try store.toggleAll(a); |
| + try expectList(&store, a, "x one;x two;"); |
| + |
| + try store.clearCompleted(a); |
| + try expectList(&store, a, ""); |
| +} |
diff --git a/apps/zig/src/main.zig b/apps/zig/src/main.zig| new file mode 100644 |
| index 0000000..56e6a0f |
| --- /dev/null |
| +++ b/apps/zig/src/main.zig |
| @@ -0,0 +1,154 @@ |
| +//! todo-zig: TodoMVC for the CARLOS bakeoff. |
| +//! |
| +//! app --socket <path> --db <path> (on Carloku) |
| +//! app --listen 127.0.0.1:<port> --db <path> (locally) |
| +//! |
| +//! Each connection runs as its own task on `std.Io`'s threaded backend. |
| +//! SIGTERM shuts the listening socket down, which ends the accept loop; |
| +//! open connections are cancelled and the database is closed before exit. |
| + |
| +const std = @import("std"); |
| +const Io = std.Io; |
| +const net = Io.net; |
| +const db = @import("db.zig"); |
| +const app_mod = @import("app.zig"); |
| + |
| +const usage = "usage: app (--socket <path> | --listen 127.0.0.1:<port>) --db <path>\n"; |
| + |
| +pub fn main(init: std.process.Init) !void { |
| + const io = init.io; |
| + const gpa = init.gpa; |
| + const arena = init.arena.allocator(); |
| + |
| + var socket_path: ?[]const u8 = null; |
| + var listen_addr: ?[]const u8 = null; |
| + var db_path: ?[:0]const u8 = null; |
| + var args = init.minimal.args.iterate(); |
| + _ = args.skip(); |
| + while (args.next()) |arg| { |
| + if (std.mem.eql(u8, arg, "--socket")) { |
| + socket_path = args.next() orelse return fail(usage); |
| + } else if (std.mem.eql(u8, arg, "--listen")) { |
| + listen_addr = args.next() orelse return fail(usage); |
| + } else if (std.mem.eql(u8, arg, "--db")) { |
| + db_path = args.next() orelse return fail(usage); |
| + } else return fail(usage); |
| + } |
| + const db_file = db_path orelse return fail(usage); |
| + if ((socket_path == null) == (listen_addr == null)) return fail(usage); |
| + |
| + var store = db.Store.open(io, db_file) catch return fail("cannot open the database\n"); |
| + defer store.close(); |
| + |
| + // A POST's Origin must be this app's own: https://$CARLOS_HOST on the |
| + // platform, http://<listen address> locally. |
| + var origins: std.ArrayList([]const u8) = .empty; |
| + if (init.environ_map.get("CARLOS_HOST")) |host| { |
| + if (host.len > 0) try origins.append(arena, try std.fmt.allocPrint(arena, "https://{s}", .{host})); |
| + } |
| + if (listen_addr) |addr| try origins.append(arena, try std.fmt.allocPrint(arena, "http://{s}", .{addr})); |
| + |
| + const app: app_mod.App = .{ |
| + .store = &store, |
| + .io = io, |
| + .origins = origins.items, |
| + .https = socket_path != null, |
| + }; |
| + |
| + var server = if (socket_path) |path| unix: { |
| + Io.Dir.cwd().deleteFile(io, path) catch |err| switch (err) { |
| + error.FileNotFound => {}, |
| + else => return err, |
| + }; |
| + const ua = try net.UnixAddress.init(path); |
| + break :unix try ua.listen(io, .{}); |
| + } else tcp: { |
| + const ip = net.IpAddress.parseLiteral(listen_addr.?) catch return fail("--listen wants 127.0.0.1:<port>\n"); |
| + if (ip != .ip4 or !std.mem.eql(u8, &ip.ip4.bytes, &.{ 127, 0, 0, 1 })) { |
| + return fail("--listen binds 127.0.0.1 only\n"); |
| + } |
| + break :tcp try ip.listen(io, .{ .reuse_address = true }); |
| + }; |
| + defer { |
| + server.deinit(io); |
| + if (socket_path) |path| Io.Dir.cwd().deleteFile(io, path) catch {}; |
| + } |
| + |
| + listen_fd.store(server.socket.handle, .release); |
| + const on_stop: std.posix.Sigaction = .{ |
| + .handler = .{ .handler = stop }, |
| + .mask = std.posix.sigemptyset(), |
| + .flags = 0, |
| + }; |
| + std.posix.sigaction(.TERM, &on_stop, null); |
| + std.posix.sigaction(.INT, &on_stop, null); |
| + |
| + std.log.info("todo-zig {s} listening on {s}", .{ |
| + @import("build_options").version, |
| + socket_path orelse listen_addr.?, |
| + }); |
| + |
| + var connections: Io.Group = .init; |
| + defer connections.cancel(io); |
| + while (true) { |
| + const stream = server.accept(io) catch |err| switch (err) { |
| + error.SocketNotListening => break, |
| + error.ConnectionAborted => continue, |
| + else => { |
| + std.log.err("accept: {t}", .{err}); |
| + continue; |
| + }, |
| + }; |
| + connections.concurrent(io, serve, .{ &app, gpa, stream }) catch { |
| + // No thread to spare: serve it here rather than drop it. |
| + serve(&app, gpa, stream); |
| + }; |
| + } |
| + std.log.info("shutting down", .{}); |
| +} |
| + |
| +/// Answers requests on one connection until the client or server closes it. |
| +fn serve(app: *const app_mod.App, gpa: std.mem.Allocator, stream: net.Stream) void { |
| + const io = app.io; |
| + defer stream.close(io); |
| + var read_buf: [16 * 1024]u8 = undefined; |
| + var write_buf: [16 * 1024]u8 = undefined; |
| + var reader = stream.reader(io, &read_buf); |
| + var writer = stream.writer(io, &write_buf); |
| + var http: std.http.Server = .init(&reader.interface, &writer.interface); |
| + var arena: std.heap.ArenaAllocator = .init(gpa); |
| + defer arena.deinit(); |
| + |
| + while (true) { |
| + var req = http.receiveHead() catch return; |
| + const keep_alive = req.head.keep_alive; |
| + app_mod.handle(app, &req, arena.allocator()) catch |err| { |
| + std.log.err("{s}: {t}", .{ @tagName(req.head.method), err }); |
| + req.respond("internal error\n", .{ .status = .internal_server_error, .keep_alive = false }) catch {}; |
| + return; |
| + }; |
| + _ = arena.reset(.retain_capacity); |
| + if (!keep_alive or http.reader.state != .ready) return; |
| + } |
| +} |
| + |
| +var listen_fd: std.atomic.Value(std.posix.fd_t) = .init(-1); |
| + |
| +/// Signal handler: shutting the listening socket down wakes `accept` with |
| +/// `error.SocketNotListening`. shutdown(2) is async-signal-safe. |
| +fn stop(_: std.posix.SIG) callconv(.c) void { |
| + const fd = listen_fd.load(.acquire); |
| + if (fd >= 0) _ = std.c.shutdown(fd, std.posix.SHUT.RDWR); |
| +} |
| + |
| +fn fail(msg: []const u8) error{InvalidArguments} { |
| + std.debug.print("{s}", .{msg}); |
| + return error.InvalidArguments; |
| +} |
| + |
| +test { |
| + _ = @import("app.zig"); |
| + _ = @import("view.zig"); |
| + _ = @import("db.zig"); |
| + _ = @import("db_test.zig"); |
| +} |
diff --git a/apps/zig/src/view.zig b/apps/zig/src/view.zig| new file mode 100644 |
| index 0000000..17e120e |
| --- /dev/null |
| +++ b/apps/zig/src/view.zig |
| @@ -0,0 +1,239 @@ |
| +//! The TodoMVC page, written straight to a `std.Io.Writer`. |
| +//! |
| +//! The markup follows TodoMVC's own template (shared/reference.html). Each |
| +//! action is a real form, so the page works with JavaScript off; the extra |
| +//! no-JS controls carry a `nojs-` class that static/app.css hides once the |
| +//! script has marked the page with `html.js`. |
| + |
| +const std = @import("std"); |
| +const Writer = std.Io.Writer; |
| +const Todo = @import("db.zig").Todo; |
| + |
| +pub const Filter = enum { |
| + all, |
| + active, |
| + completed, |
| + |
| + pub fn fromField(value: []const u8) Filter { |
| + return std.meta.stringToEnum(Filter, value) orelse .all; |
| + } |
| + |
| + pub fn fromPath(p: []const u8) ?Filter { |
| + if (std.mem.eql(u8, p, "/")) return .all; |
| + if (std.mem.eql(u8, p, "/active")) return .active; |
| + if (std.mem.eql(u8, p, "/completed")) return .completed; |
| + return null; |
| + } |
| + |
| + pub fn path(f: Filter) []const u8 { |
| + return switch (f) { |
| + .all => "/", |
| + .active => "/active", |
| + .completed => "/completed", |
| + }; |
| + } |
| + |
| + fn shows(f: Filter, t: Todo) bool { |
| + return switch (f) { |
| + .all => true, |
| + .active => !t.completed, |
| + .completed => t.completed, |
| + }; |
| + } |
| +}; |
| + |
| +pub const Page = struct { |
| + todos: []const Todo, |
| + filter: Filter, |
| + /// The item whose editor is open, from `?edit=<id>`. |
| + editing: ?i64 = null, |
| +}; |
| + |
| +pub fn render(w: *Writer, page: Page) Writer.Error!void { |
| + var completed: usize = 0; |
| + for (page.todos) |t| completed += @intFromBool(t.completed); |
| + const left = page.todos.len - completed; |
| + const empty = page.todos.len == 0; |
| + const filter = @tagName(page.filter); |
| + |
| + try w.writeAll( |
| + \\<!doctype html> |
| + \\<html lang="en"> |
| + \\<head> |
| + \\<meta charset="utf-8"> |
| + \\<meta name="viewport" content="width=device-width, initial-scale=1"> |
| + \\<title>TodoMVC</title> |
| + \\<link rel="stylesheet" href="/static/base.css"> |
| + \\<link rel="stylesheet" href="/static/index.css"> |
| + \\<link rel="stylesheet" href="/static/app.css"> |
| + \\<script>document.documentElement.classList.add("js")</script> |
| + \\<script type="module" src="/static/app.js"></script> |
| + \\</head> |
| + \\<body> |
| + \\<section class="todoapp"> |
| + \\<header class="header"> |
| + \\<h1>todos</h1> |
| + \\<form method="post" action="/todos"> |
| + \\ |
| + ); |
| + try hiddenFilter(w, filter); |
| + try w.print( |
| + \\<input class="new-todo" name="title" placeholder="What needs to be done?" autocomplete="off"{s}> |
| + \\</form> |
| + \\</header> |
| + \\<section class="main"{s}> |
| + \\<form method="post" action="/todos/toggle-all"> |
| + \\ |
| + , .{ |
| + if (page.editing == null) " autofocus" else "", |
| + if (empty) " hidden" else "", |
| + }); |
| + try hiddenFilter(w, filter); |
| + try w.print( |
| + \\<input id="toggle-all" class="toggle-all" type="checkbox"{s}> |
| + \\<label for="toggle-all">Mark all as complete</label> |
| + \\<button class="nojs-toggle-all" data-action="toggle-all">Mark all as complete</button> |
| + \\</form> |
| + \\<ul class="todo-list"> |
| + \\ |
| + , .{if (!empty and left == 0) " checked" else ""}); |
| + |
| + for (page.todos) |t| { |
| + if (page.filter.shows(t)) try item(w, t, page.filter, page.editing == t.id); |
| + } |
| + |
| + try w.print( |
| + \\</ul> |
| + \\</section> |
| + \\<footer class="footer"{s}> |
| + \\<span class="todo-count"><strong>{d}</strong> {s} left</span> |
| + \\<ul class="filters"> |
| + \\ |
| + , .{ if (empty) " hidden" else "", left, if (left == 1) "item" else "items" }); |
| + inline for (.{ .{ Filter.all, "All" }, .{ Filter.active, "Active" }, .{ Filter.completed, "Completed" } }) |link| { |
| + try w.print("<li><a{s} href=\"{s}\">{s}</a></li>\n", .{ |
| + if (page.filter == link[0]) " class=\"selected\"" else "", |
| + link[0].path(), |
| + link[1], |
| + }); |
| + } |
| + try w.writeAll("</ul>\n"); |
| + if (completed > 0) { |
| + try w.writeAll("<form method=\"post\" action=\"/todos/clear-completed\">\n"); |
| + try hiddenFilter(w, filter); |
| + try w.writeAll("<button class=\"clear-completed\">Clear completed</button>\n</form>\n"); |
| + } |
| + try w.writeAll( |
| + \\</footer> |
| + \\</section> |
| + \\<footer class="info"> |
| + \\<p>Double-click to edit a todo</p> |
| + \\<p>Created by <a href="https://bakeoff.bfj.oncarlos.com">the CARLOS bakeoff</a> · Zig</p> |
| + \\<p>Part of <a href="http://todomvc.com">TodoMVC</a></p> |
| + \\</footer> |
| + \\</body> |
| + \\</html> |
| + \\ |
| + ); |
| +} |
| + |
| +fn item(w: *Writer, t: Todo, filter: Filter, editing: bool) Writer.Error!void { |
| + const f = @tagName(filter); |
| + const classes: []const u8 = if (t.completed and editing) |
| + " class=\"completed editing\"" |
| + else if (t.completed) |
| + " class=\"completed\"" |
| + else if (editing) |
| + " class=\"editing\"" |
| + else |
| + ""; |
| + try w.print("<li data-id=\"{d}\"{s}>\n<div class=\"view\">\n", .{ t.id, classes }); |
| + try w.print("<input class=\"toggle\" type=\"checkbox\"{s}>\n<label>", .{if (t.completed) " checked" else ""}); |
| + try escape(w, t.title); |
| + try w.print("</label>\n<form method=\"post\" action=\"/todos/{d}/toggle\">\n", .{t.id}); |
| + try hiddenFilter(w, f); |
| + try w.print( |
| + \\<button class="nojs-toggle" data-action="toggle">{s}</button> |
| + \\</form> |
| + \\<a class="nojs-edit" data-action="edit" href="{s}?edit={d}">Edit</a> |
| + \\<form method="post" action="/todos/{d}/destroy"> |
| + \\ |
| + , .{ if (t.completed) "Mark as active" else "Mark as complete", filter.path(), t.id, t.id }); |
| + try hiddenFilter(w, f); |
| + try w.print( |
| + \\<button class="destroy" aria-label="Delete"></button> |
| + \\</form> |
| + \\</div> |
| + \\<form method="post" action="/todos/{d}/edit"> |
| + \\ |
| + , .{t.id}); |
| + try hiddenFilter(w, f); |
| + try w.writeAll("<input class=\"edit\" name=\"title\" value=\""); |
| + try escape(w, t.title); |
| + try w.print("\"{s}>\n</form>\n</li>\n", .{if (editing) " autofocus" else ""}); |
| +} |
| + |
| +fn hiddenFilter(w: *Writer, filter: []const u8) Writer.Error!void { |
| + try w.print("<input type=\"hidden\" name=\"filter\" value=\"{s}\">\n", .{filter}); |
| +} |
| + |
| +/// Escapes text for an HTML text node or a double-quoted attribute. |
| +pub fn escape(w: *Writer, s: []const u8) Writer.Error!void { |
| + var start: usize = 0; |
| + for (s, 0..) |ch, i| { |
| + const entity: []const u8 = switch (ch) { |
| + '&' => "&", |
| + '<' => "<", |
| + '>' => ">", |
| + '"' => """, |
| + '\'' => "'", |
| + else => continue, |
| + }; |
| + try w.writeAll(s[start..i]); |
| + try w.writeAll(entity); |
| + start = i + 1; |
| + } |
| + try w.writeAll(s[start..]); |
| +} |
| + |
| +test escape { |
| + var buf: [128]u8 = undefined; |
| + var w: Writer = .fixed(&buf); |
| + try escape(&w, "<img src=x onerror=\"alert('1')\"> & naïve 🚀"); |
| + try std.testing.expectEqualStrings( |
| + "<img src=x onerror="alert('1')"> & naïve 🚀", |
| + w.buffered(), |
| + ); |
| +} |
| + |
| +fn renderToString(gpa: std.mem.Allocator, page: Page) ![]u8 { |
| + var out: Writer.Allocating = .init(gpa); |
| + defer out.deinit(); |
| + try render(&out.writer, page); |
| + return out.toOwnedSlice(); |
| +} |
| + |
| +test "empty list hides main and footer" { |
| + const html = try renderToString(std.testing.allocator, .{ .todos = &.{}, .filter = .all }); |
| + defer std.testing.allocator.free(html); |
| + try std.testing.expect(std.mem.indexOf(u8, html, "<section class=\"main\" hidden>") != null); |
| + try std.testing.expect(std.mem.indexOf(u8, html, "<footer class=\"footer\" hidden>") != null); |
| + try std.testing.expect(std.mem.indexOf(u8, html, "clear-completed\"") == null); |
| +} |
| + |
| +test "filters, counter and editing" { |
| + const todos = [_]Todo{ |
| + .{ .id = 1, .title = "Buy milk", .completed = true }, |
| + .{ .id = 2, .title = "Walk <the> dog", .completed = false }, |
| + }; |
| + const html = try renderToString(std.testing.allocator, .{ .todos = &todos, .filter = .active, .editing = 2 }); |
| + defer std.testing.allocator.free(html); |
| + try std.testing.expect(std.mem.indexOf(u8, html, "<strong>1</strong> item left") != null); |
| + try std.testing.expect(std.mem.indexOf(u8, html, "data-id=\"1\"") == null); |
| + try std.testing.expect(std.mem.indexOf(u8, html, "<li data-id=\"2\" class=\"editing\">") != null); |
| + try std.testing.expect(std.mem.indexOf(u8, html, "<label>Walk <the> dog</label>") != null); |
| + try std.testing.expect(std.mem.indexOf(u8, html, "<a class=\"selected\" href=\"/active\">") != null); |
| + try std.testing.expect(std.mem.indexOf(u8, html, "href=\"/active?edit=2\"") != null); |
| + try std.testing.expect(std.mem.indexOf(u8, html, "<button class=\"clear-completed\">") != null); |
| + try std.testing.expect(std.mem.indexOf(u8, html, "class=\"new-todo\" name=\"title\" placeholder=\"What needs to be done?\" autocomplete=\"off\">") != null); |
| +} |
diff --git a/apps/zig/static/app.css b/apps/zig/static/app.css| new file mode 100644 |
| index 0000000..82eb957 |
| --- /dev/null |
| +++ b/apps/zig/static/app.css |
| @@ -0,0 +1,71 @@ |
| +/* Additions to TodoMVC's stylesheets for this server-rendered version. */ |
| + |
| +/* Forms only group fields; they take no space of their own, so the |
| + TodoMVC layout is unchanged. */ |
| +.todoapp form { |
| + display: contents; |
| +} |
| + |
| +/* Without JavaScript, each checkbox is covered by a transparent submit |
| + button, and each item gets an Edit link. With JavaScript (html.js), |
| + they're hidden and the page is exactly TodoMVC's. */ |
| +.nojs-toggle-all, |
| +.todo-list li .nojs-toggle { |
| + position: absolute; |
| + z-index: 1; |
| + margin: 0; |
| + padding: 0; |
| + border: 0; |
| + background: none; |
| + font-size: 0; |
| + color: transparent; |
| + cursor: pointer; |
| +} |
| + |
| +.nojs-toggle-all { |
| + top: -65px; |
| + left: 0; |
| + width: 45px; |
| + height: 65px; |
| +} |
| + |
| +.todo-list li .nojs-toggle { |
| + top: 0; |
| + bottom: 0; |
| + left: 0; |
| + width: 60px; |
| +} |
| + |
| +.nojs-toggle-all:focus-visible, |
| +.todo-list li .nojs-toggle:focus-visible { |
| + outline: 1px solid #CF7D7D; |
| +} |
| + |
| +.todo-list li .nojs-edit { |
| + position: absolute; |
| + top: 0; |
| + right: 60px; |
| + bottom: 0; |
| + height: 20px; |
| + margin: auto 0; |
| + font-size: 14px; |
| + line-height: 20px; |
| + color: #949494; |
| + text-decoration: none; |
| +} |
| + |
| +.todo-list li .nojs-edit:hover, |
| +.todo-list li .nojs-edit:focus { |
| + color: #484848; |
| + text-decoration: underline; |
| +} |
| + |
| +html:not(.js) .todo-list li label { |
| + padding-right: 110px; |
| +} |
| + |
| +.js .nojs-toggle-all, |
| +.js .todo-list li .nojs-toggle, |
| +.js .todo-list li .nojs-edit { |
| + display: none; |
| +} |
diff --git a/apps/zig/static/app.js b/apps/zig/static/app.js| new file mode 100644 |
| index 0000000..01559c9 |
| --- /dev/null |
| +++ b/apps/zig/static/app.js |
| @@ -0,0 +1,72 @@ |
| +// Progressive enhancement for the server-rendered TodoMVC. |
| +// |
| +// Every action is already a working HTML form. This script sends those same |
| +// forms with fetch, asks for the re-rendered page instead of a redirect, and |
| +// swaps in the new list and footer. It adds TodoMVC's own gestures on top: |
| +// click a checkbox to toggle, double-click a label to edit, Enter or blur to |
| +// save, Escape to cancel. |
| + |
| +const app = document.querySelector('.todoapp'); |
| + |
| +// Requests go out one at a time, so the page always shows the last answer. |
| +let queue = Promise.resolve(); |
| + |
| +function send(form) { |
| + const body = new URLSearchParams(new FormData(form)); |
| + queue = queue.then(async () => { |
| + const res = await fetch(form.action, { |
| + method: 'POST', |
| + headers: { 'X-Requested-With': 'fetch' }, |
| + body, |
| + }); |
| + if (!res.ok) throw new Error(`${res.status} from ${form.action}`); |
| + const fresh = new DOMParser().parseFromString(await res.text(), 'text/html'); |
| + for (const part of ['.main', '.footer']) { |
| + app.querySelector(part).replaceWith(fresh.querySelector(part)); |
| + } |
| + }).catch((err) => { |
| + console.error(err); |
| + location.reload(); |
| + }); |
| +} |
| + |
| +app.addEventListener('submit', (event) => { |
| + event.preventDefault(); |
| + const form = event.target; |
| + const editing = form.closest('li.editing'); |
| + if (editing) editing.classList.remove('editing'); |
| + send(form); |
| + if (form.closest('.header')) form.querySelector('.new-todo').value = ''; |
| +}); |
| + |
| +app.addEventListener('change', (event) => { |
| + const box = event.target; |
| + if (box.matches('.toggle')) { |
| + send(box.closest('li').querySelector('form[action$="/toggle"]')); |
| + } else if (box.matches('.toggle-all')) { |
| + send(box.form); |
| + } |
| +}); |
| + |
| +app.addEventListener('dblclick', (event) => { |
| + const label = event.target.closest('.todo-list label'); |
| + if (!label) return; |
| + const item = label.closest('li'); |
| + const input = item.querySelector('.edit'); |
| + item.classList.add('editing'); |
| + input.focus(); |
| + input.setSelectionRange(input.value.length, input.value.length); |
| +}); |
| + |
| +app.addEventListener('keydown', (event) => { |
| + if (event.key !== 'Escape' || !event.target.matches('.edit')) return; |
| + const input = event.target; |
| + input.value = input.defaultValue; |
| + input.closest('li').classList.remove('editing'); |
| +}); |
| + |
| +// Leaving the editor saves it, unless Enter or Escape already closed it. |
| +app.addEventListener('focusout', (event) => { |
| + const input = event.target; |
| + if (input.matches('.edit') && input.closest('li.editing')) input.form.requestSubmit(); |
| +}); |