atproto utils for zig
zat.dev
atproto
sdk
zig
1# zat - zig atproto primitives
2
3low-level building blocks for atproto applications in zig. not a full sdk - just the pieces that everyone reimplements.
4
5## philosophy
6
7from studying the wishlists: the pain is real, but the suggested solutions often over-engineer. we want:
8
91. **primitives, not frameworks** - types and parsers, not http clients or feed scaffolds
102. **layered design** - each piece usable independently
113. **zig idioms** - explicit buffers, comptime validation, no hidden allocations
124. **minimal scope** - solve the repeated pain, not every possible need
13
14## scope
15
16### in scope (v0.1)
17
18**tid** - timestamp identifiers
19- parse tid string to timestamp (microseconds)
20- generate tid from timestamp
21- extract clock id
22- comptime validation of format
23
24**at-uri** - `at://did:plc:xyz/collection/rkey`
25- parse to components (did, collection, rkey)
26- construct from components
27- validation
28
29**did** - decentralized identifiers
30- parse did:plc and did:web
31- validate format
32- type-safe wrapper (not just `[]const u8`)
33
34### maybe v0.2
35
36**facets** - extract links/mentions/tags from post records
37- given a json value with `text` and `facets`, extract urls
38- byte-offset handling for utf-8
39
40**cid** - content identifiers
41- parse cid strings
42- validate format
43
44### out of scope (for now)
45
46- lexicon codegen (too big, could be its own project)
47- xrpc client (std.http.Client is fine)
48- session management (app-specific)
49- jetstream client (websocket.zig exists, just wire it)
50- feed generator framework (each feed is unique)
51- did resolution (requires http, out of primitive scope)
52
53## design
54
55### tid.zig
56
57```zig
58pub const Tid = struct {
59 raw: [13]u8,
60
61 /// parse a tid string. returns null if invalid.
62 pub fn parse(s: []const u8) ?Tid
63
64 /// timestamp in microseconds since unix epoch
65 pub fn timestamp(self: Tid) u64
66
67 /// clock identifier (lower 10 bits)
68 pub fn clockId(self: Tid) u10
69
70 /// generate tid for current time
71 pub fn now() Tid
72
73 /// generate tid for specific timestamp
74 pub fn fromTimestamp(ts: u64, clock_id: u10) Tid
75
76 /// format to string
77 pub fn format(self: Tid, buf: *[13]u8) void
78};
79```
80
81encoding: base32-sortable (chars `234567abcdefghijklmnopqrstuvwxyz`), 13 chars, first 11 encode 53-bit timestamp, last 2 encode 10-bit clock id.
82
83### at_uri.zig
84
85```zig
86pub const AtUri = struct {
87 /// the full uri string (borrowed, not owned)
88 raw: []const u8,
89
90 /// offsets into raw for each component
91 did_end: usize,
92 collection_end: usize,
93
94 pub fn parse(s: []const u8) ?AtUri
95
96 pub fn did(self: AtUri) []const u8
97 pub fn collection(self: AtUri) []const u8
98 pub fn rkey(self: AtUri) []const u8
99
100 /// construct a new uri. caller owns the buffer.
101 pub fn format(
102 buf: []u8,
103 did: []const u8,
104 collection: []const u8,
105 rkey: []const u8,
106 ) ?[]const u8
107};
108```
109
110### did.zig
111
112```zig
113pub const Did = union(enum) {
114 plc: [24]u8, // the identifier after "did:plc:"
115 web: []const u8, // the domain after "did:web:"
116
117 pub fn parse(s: []const u8) ?Did
118
119 /// format to string
120 pub fn format(self: Did, buf: []u8) ?[]const u8
121
122 /// check if this is a plc did
123 pub fn isPlc(self: Did) bool
124};
125```
126
127## structure
128
129```
130zat/
131├── build.zig
132├── build.zig.zon
133├── src/
134│ ├── root.zig # public API (stable exports)
135│ ├── internal.zig # internal API (experimental)
136│ └── internal/
137│ ├── tid.zig
138│ ├── at_uri.zig
139│ └── did.zig
140└── docs/
141 └── plan.md
142```
143
144## internal → public promotion
145
146new features start in `internal` where we can iterate freely. when an API stabilizes:
147
148```zig
149// in root.zig, uncomment to promote:
150pub const Tid = internal.Tid;
151```
152
153users who need bleeding-edge access can always use:
154
155```zig
156const zat = @import("zat");
157const tid = zat.internal.Tid.parse("...");
158```
159
160this pattern exists indefinitely - even after 1.0, new experimental features start in internal.
161
162## decisions
163
164### why not typed lexicons?
165
166codegen from lexicon json is a big project on its own. the core pain (json navigation) can be partially addressed by documenting patterns, and the sdk should work regardless of how people parse json.
167
168### why not an http client wrapper?
169
170zig 0.15's `std.http.Client` with `Io.Writer.Allocating` works well. wrapping it doesn't add much value. the real pain is around auth token refresh and rate limiting - those are better solved at the application level where retry logic is domain-specific.
171
172### why not websocket/jetstream?
173
174websocket.zig already exists and works well. the jetstream protocol is simple json messages. a thin wrapper doesn't justify a dependency.
175
176### borrowing vs owning
177
178for parse operations, we borrow slices into the input rather than allocating. callers who need owned data can dupe. this matches zig's explicit memory style.
179
180## next steps
181
1821. ~~implement tid.zig with tests~~ done
1832. ~~implement at_uri.zig with tests~~ done
1843. ~~implement did.zig with tests~~ done
1854. ~~wire up build.zig as a module~~ done
1865. try using it in find-bufo or music-atmosphere-feed to validate the api
1876. iterate on internal APIs based on real usage
1887. promote stable APIs to root.zig