+278
docs/zig-lessons.md
+278
docs/zig-lessons.md
···
···
1
+
# zig lessons from building a bluesky feed generator
2
+
3
+
notes from building a production feed generator in zig 0.15. things we learned, patterns that worked, mistakes made.
4
+
5
+
## memory management
6
+
7
+
### arena allocators for request handling
8
+
9
+
http requests are perfect for arena allocators - allocate freely during the request, free everything at once when done:
10
+
11
+
```zig
12
+
fn handleRequest(request: *http.Server.Request) !void {
13
+
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
14
+
defer arena.deinit();
15
+
const alloc = arena.allocator();
16
+
17
+
// allocate freely - no individual frees needed
18
+
const result = try processStuff(alloc);
19
+
try sendResponse(request, result);
20
+
}
21
+
// arena.deinit() frees everything
22
+
```
23
+
24
+
### errdefer for cleanup on error paths
25
+
26
+
when building up state that needs cleanup on error:
27
+
28
+
```zig
29
+
var posts: std.ArrayList(AuthorPost) = .empty;
30
+
errdefer {
31
+
for (posts.items) |p| {
32
+
allocator.free(p.uri);
33
+
allocator.free(p.cid);
34
+
}
35
+
posts.deinit(allocator);
36
+
}
37
+
// if anything below errors, cleanup runs automatically
38
+
```
39
+
40
+
### allocator passed to methods (0.15 style)
41
+
42
+
zig 0.15 changed ArrayList - allocator is passed to methods, not stored:
43
+
44
+
```zig
45
+
// old (pre-0.15)
46
+
var list = std.ArrayList(u8).init(allocator);
47
+
try list.append('x');
48
+
49
+
// new (0.15+)
50
+
var list: std.ArrayList(u8) = .empty;
51
+
try list.append(allocator, 'x');
52
+
defer list.deinit(allocator);
53
+
```
54
+
55
+
## json handling
56
+
57
+
### runtime path navigation
58
+
59
+
for one-off extractions, path-based navigation is clean:
60
+
61
+
```zig
62
+
const did = zat.json.getString(item, "post.author.did") orelse continue;
63
+
const text = zat.json.getString(item, "post.record.text") orelse "";
64
+
```
65
+
66
+
### comptime struct extraction
67
+
68
+
for repeated patterns, define a struct and extract at compile time:
69
+
70
+
```zig
71
+
const FeedPost = struct {
72
+
uri: []const u8,
73
+
cid: []const u8,
74
+
record: struct {
75
+
text: []const u8 = "",
76
+
embed: ?struct {
77
+
external: ?struct { uri: []const u8 } = null,
78
+
} = null,
79
+
},
80
+
};
81
+
82
+
// extracts nested structure, handles missing optionals
83
+
const post = zat.json.extractAt(FeedPost, allocator, item, .{"post"}) catch continue;
84
+
```
85
+
86
+
the struct mirrors the json shape. optional fields (`?struct`) handle missing keys gracefully. default values (`= ""`) provide fallbacks.
87
+
88
+
### std.json for raw parsing
89
+
90
+
when you need the full json tree:
91
+
92
+
```zig
93
+
var parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch return error.ParseError;
94
+
defer parsed.deinit();
95
+
96
+
if (parsed.value != .object) return error.InvalidFormat;
97
+
const obj = parsed.value.object;
98
+
const kind = obj.get("kind") orelse return error.MissingField;
99
+
```
100
+
101
+
## concurrency
102
+
103
+
### mutex for shared state
104
+
105
+
simple mutex pattern for shared database access:
106
+
107
+
```zig
108
+
var db: ?zqlite.Conn = null;
109
+
var mutex: std.Thread.Mutex = .{};
110
+
111
+
pub fn addPost(uri: []const u8, cid: []const u8) !void {
112
+
mutex.lock();
113
+
defer mutex.unlock();
114
+
115
+
const conn = db orelse return error.NotInitialized;
116
+
conn.exec("INSERT ...", .{uri, cid}) catch |err| {
117
+
return err;
118
+
};
119
+
}
120
+
```
121
+
122
+
### spawning threads
123
+
124
+
```zig
125
+
const thread = std.Thread.spawn(.{}, jetstream.consumer, .{allocator}) catch |err| {
126
+
std.debug.print("failed to spawn: {}\n", .{err});
127
+
return;
128
+
};
129
+
thread.detach();
130
+
```
131
+
132
+
## http patterns
133
+
134
+
### building responses with ArrayList
135
+
136
+
```zig
137
+
var buf: std.ArrayList(u8) = .empty;
138
+
defer buf.deinit(alloc);
139
+
const w = buf.writer(alloc);
140
+
141
+
try w.print("{{\"status\":\"{s}\",\"count\":{d}}}", .{status, count});
142
+
143
+
try sendJson(request, .ok, buf.items);
144
+
```
145
+
146
+
### multiline strings for sql
147
+
148
+
zig's multiline strings work well for embedded sql:
149
+
150
+
```zig
151
+
db.exec(
152
+
\\CREATE TABLE IF NOT EXISTS posts (
153
+
\\ uri TEXT PRIMARY KEY,
154
+
\\ cid TEXT NOT NULL,
155
+
\\ indexed_at INTEGER NOT NULL
156
+
\\)
157
+
, .{}) catch |err| {
158
+
return err;
159
+
};
160
+
```
161
+
162
+
## error handling
163
+
164
+
### error unions with catch
165
+
166
+
```zig
167
+
// return error on failure
168
+
const result = try riskyOperation();
169
+
170
+
// handle specific errors
171
+
const value = operation() catch |err| {
172
+
std.debug.print("failed: {}\n", .{err});
173
+
return error.OperationFailed;
174
+
};
175
+
176
+
// fallback on error
177
+
const maybe = operation() catch null;
178
+
```
179
+
180
+
### sentinel errors for filtering
181
+
182
+
use specific errors to distinguish "not found" from "actual failure":
183
+
184
+
```zig
185
+
fn processRecord(payload: []const u8) !void {
186
+
// these are expected, not failures
187
+
if (!isPost(record)) return error.NotAPost;
188
+
if (!filter.matches(record)) return error.NoMatch;
189
+
190
+
// this is an actual failure
191
+
db.addPost(uri, cid) catch |err| {
192
+
std.debug.print("db error: {}\n", .{err});
193
+
return err;
194
+
};
195
+
}
196
+
197
+
// caller can ignore expected "errors"
198
+
self.processRecord(data) catch |err| {
199
+
if (err != error.NotAPost and err != error.NoMatch) {
200
+
std.debug.print("processing error: {}\n", .{err});
201
+
}
202
+
};
203
+
```
204
+
205
+
## project structure
206
+
207
+
adopted domain-based grouping after the codebase grew:
208
+
209
+
```
210
+
src/
211
+
main.zig # entry point, spawns threads
212
+
feed/
213
+
config.zig # environment variables, feed URIs
214
+
filter.zig # post matching logic
215
+
server/
216
+
http.zig # request handling
217
+
dashboard.zig # html rendering
218
+
stats.zig # metrics
219
+
stream/
220
+
jetstream.zig # websocket consumer
221
+
db.zig # sqlite operations
222
+
atproto.zig # AT Protocol utilities
223
+
```
224
+
225
+
relative imports: `@import("../feed/config.zig")`
226
+
227
+
## tooling
228
+
229
+
### zig fmt
230
+
231
+
built-in formatter. run before commits:
232
+
233
+
```sh
234
+
zig fmt src/ build.zig
235
+
```
236
+
237
+
check mode for CI/hooks:
238
+
239
+
```sh
240
+
zig fmt --check src/ build.zig
241
+
```
242
+
243
+
### build.zig dependencies
244
+
245
+
fetch external packages:
246
+
247
+
```bash
248
+
zig fetch --save https://example.com/package/archive/main
249
+
```
250
+
251
+
creates entry in `build.zig.zon`, imports via `@import("package")`.
252
+
253
+
## things that surprised us
254
+
255
+
1. **no hidden allocations** - you always know when memory is allocated because you pass the allocator
256
+
257
+
2. **comptime is powerful** - struct extraction, string formatting, type introspection all happen at compile time
258
+
259
+
3. **error handling is ergonomic** - `try`, `catch`, `errdefer` compose well
260
+
261
+
4. **multiline strings preserve indentation** - `\\` prefix strips leading whitespace consistently
262
+
263
+
5. **optionals chain nicely** - `if (a) |b| if (b.c) |d| d.value else null else null`
264
+
265
+
## sdk adoption (zat)
266
+
267
+
started with hand-rolled http/did resolution (~450 lines). adopted zat sdk:
268
+
269
+
- `zat.Did.parse()` - DID parsing
270
+
- `zat.DidResolver` - DID document resolution
271
+
- `zat.XrpcClient` - XRPC API calls with automatic JSON handling
272
+
- `zat.json.getString()` - path-based JSON navigation
273
+
- `zat.json.extractAt()` - comptime struct extraction
274
+
- `zat.Tid`, `zat.AtUri`, `zat.Nsid` - AT Protocol primitives
275
+
276
+
result: atproto.zig went from 898 lines to 454 lines (50% reduction).
277
+
278
+
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.