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