zig lessons from building a bluesky feed generator#
notes from building a production feed generator in zig 0.15. things we learned, patterns that worked, mistakes made.
memory management#
arena allocators for request handling#
http requests are perfect for arena allocators - allocate freely during the request, free everything at once when done:
fn handleRequest(request: *http.Server.Request) !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const alloc = arena.allocator();
// allocate freely - no individual frees needed
const result = try processStuff(alloc);
try sendResponse(request, result);
}
// arena.deinit() frees everything
errdefer for cleanup on error paths#
when building up state that needs cleanup on error:
var posts: std.ArrayList(AuthorPost) = .empty;
errdefer {
for (posts.items) |p| {
allocator.free(p.uri);
allocator.free(p.cid);
}
posts.deinit(allocator);
}
// if anything below errors, cleanup runs automatically
allocator passed to methods (0.15 style)#
zig 0.15 changed ArrayList - allocator is passed to methods, not stored:
// old (pre-0.15)
var list = std.ArrayList(u8).init(allocator);
try list.append('x');
// new (0.15+)
var list: std.ArrayList(u8) = .empty;
try list.append(allocator, 'x');
defer list.deinit(allocator);
json handling#
runtime path navigation#
for one-off extractions, path-based navigation is clean:
const did = zat.json.getString(item, "post.author.did") orelse continue;
const text = zat.json.getString(item, "post.record.text") orelse "";
comptime struct extraction#
for repeated patterns, define a struct and extract at compile time:
const FeedPost = struct {
uri: []const u8,
cid: []const u8,
record: struct {
text: []const u8 = "",
embed: ?struct {
external: ?struct { uri: []const u8 } = null,
} = null,
},
};
// extracts nested structure, handles missing optionals
const post = zat.json.extractAt(FeedPost, allocator, item, .{"post"}) catch continue;
the struct mirrors the json shape. optional fields (?struct) handle missing keys gracefully. default values (= "") provide fallbacks.
std.json for raw parsing#
when you need the full json tree:
var parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch return error.ParseError;
defer parsed.deinit();
if (parsed.value != .object) return error.InvalidFormat;
const obj = parsed.value.object;
const kind = obj.get("kind") orelse return error.MissingField;
concurrency#
mutex for shared state#
simple mutex pattern for shared database access:
var db: ?zqlite.Conn = null;
var mutex: std.Thread.Mutex = .{};
pub fn addPost(uri: []const u8, cid: []const u8) !void {
mutex.lock();
defer mutex.unlock();
const conn = db orelse return error.NotInitialized;
conn.exec("INSERT ...", .{uri, cid}) catch |err| {
return err;
};
}
spawning threads#
const thread = std.Thread.spawn(.{}, jetstream.consumer, .{allocator}) catch |err| {
std.debug.print("failed to spawn: {}\n", .{err});
return;
};
thread.detach();
http patterns#
building responses with ArrayList#
var buf: std.ArrayList(u8) = .empty;
defer buf.deinit(alloc);
const w = buf.writer(alloc);
try w.print("{{\"status\":\"{s}\",\"count\":{d}}}", .{status, count});
try sendJson(request, .ok, buf.items);
multiline strings for sql#
zig's multiline strings work well for embedded sql:
db.exec(
\\CREATE TABLE IF NOT EXISTS posts (
\\ uri TEXT PRIMARY KEY,
\\ cid TEXT NOT NULL,
\\ indexed_at INTEGER NOT NULL
\\)
, .{}) catch |err| {
return err;
};
error handling#
error unions with catch#
// return error on failure
const result = try riskyOperation();
// handle specific errors
const value = operation() catch |err| {
std.debug.print("failed: {}\n", .{err});
return error.OperationFailed;
};
// fallback on error
const maybe = operation() catch null;
sentinel errors for filtering#
use specific errors to distinguish "not found" from "actual failure":
fn processRecord(payload: []const u8) !void {
// these are expected, not failures
if (!isPost(record)) return error.NotAPost;
if (!filter.matches(record)) return error.NoMatch;
// this is an actual failure
db.addPost(uri, cid) catch |err| {
std.debug.print("db error: {}\n", .{err});
return err;
};
}
// caller can ignore expected "errors"
self.processRecord(data) catch |err| {
if (err != error.NotAPost and err != error.NoMatch) {
std.debug.print("processing error: {}\n", .{err});
}
};
project structure#
adopted domain-based grouping after the codebase grew:
src/
main.zig # entry point, spawns threads
feed/
config.zig # environment variables, feed URIs
filter.zig # post matching logic
server/
http.zig # request handling
dashboard.zig # html rendering
stats.zig # metrics
stream/
jetstream.zig # websocket consumer
db.zig # sqlite operations
atproto.zig # AT Protocol utilities
relative imports: @import("../feed/config.zig")
tooling#
zig fmt#
built-in formatter. run before commits:
zig fmt src/ build.zig
check mode for CI/hooks:
zig fmt --check src/ build.zig
build.zig dependencies#
fetch external packages:
zig fetch --save https://example.com/package/archive/main
creates entry in build.zig.zon, imports via @import("package").
things that surprised us#
-
no hidden allocations - you always know when memory is allocated because you pass the allocator
-
comptime is powerful - struct extraction, string formatting, type introspection all happen at compile time
-
error handling is ergonomic -
try,catch,errdefercompose well -
multiline strings preserve indentation -
\\prefix strips leading whitespace consistently -
optionals chain nicely -
if (a) |b| if (b.c) |d| d.value else null else null
sdk adoption (zat)#
started with hand-rolled http/did resolution (~450 lines). adopted zat sdk:
zat.Did.parse()- DID parsingzat.DidResolver- DID document resolutionzat.XrpcClient- XRPC API calls with automatic JSON handlingzat.json.getString()- path-based JSON navigationzat.json.extractAt()- comptime struct extractionzat.Tid,zat.AtUri,zat.Nsid- AT Protocol primitives
result: atproto.zig went from 898 lines to 454 lines (50% reduction).
lesson: find or build good abstractions - the raw http/tls/json code worked but was noisy. moving it to a library cleaned up the application code significantly.