add zig atproto sdk wishlist doc

Changed files
+272
docs
+272
docs/zig-atproto-sdk-wishlist.md
··· 1 + # zig atproto sdk wishlist 2 + 3 + notes from building a bluesky feed generator in zig. what would make life easier. 4 + 5 + ## the pain points 6 + 7 + ### 1. json navigation is brutal 8 + 9 + every single field access looks like this: 10 + 11 + ```zig 12 + if (record.get("embed")) |embed_val| { 13 + if (embed_val == .object) { 14 + if (embed_val.object.get("external")) |external_val| { 15 + if (external_val == .object) { 16 + if (external_val.object.get("uri")) |uri_val| { 17 + if (uri_val == .string) { 18 + // finally, the actual value 19 + } 20 + } 21 + } 22 + } 23 + } 24 + } 25 + ``` 26 + 27 + this is 6 levels of nesting to get `embed.external.uri`. it's error-prone, hard to read, and easy to mess up. 28 + 29 + **wish**: typed structs generated from lexicons. just give me: 30 + 31 + ```zig 32 + const post = try Post.fromJson(data); 33 + if (post.embed) |embed| { 34 + if (embed.external) |ext| { 35 + doSomething(ext.uri); 36 + } 37 + } 38 + ``` 39 + 40 + ### 2. no lexicon types at all 41 + 42 + we're working blind. the at-protocol has a full lexicon system with typed schemas, but in zig we get raw `json.Value` and have to know the structure from memory or docs. 43 + 44 + **wish**: codegen from lexicons. run a build step that reads `app.bsky.feed.post.json` and outputs `Post`, `Facet`, `Embed`, `ExternalEmbed`, etc. with proper zig types. 45 + 46 + even better: ship pre-generated types for the core `app.bsky.*` and `com.atproto.*` namespaces. 47 + 48 + ### 3. no xrpc client 49 + 50 + making api calls means manually: 51 + - constructing urls 52 + - handling auth headers 53 + - parsing responses 54 + - dealing with rate limits 55 + - cursor pagination 56 + 57 + **wish**: typed xrpc client: 58 + 59 + ```zig 60 + const client = try AtProto.init(allocator); 61 + try client.login("handle", "password"); 62 + 63 + // typed request, typed response 64 + const feed = try client.call(.app_bsky_feed_getFeed, .{ 65 + .feed = "at://did:plc:.../app.bsky.feed.generator/my-feed", 66 + .limit = 50, 67 + }); 68 + 69 + for (feed.posts) |post| { 70 + // post is already typed 71 + } 72 + ``` 73 + 74 + ### 4. no firehose/jetstream client 75 + 76 + we had to build our own websocket handler from scratch: 77 + - tls connection setup 78 + - websocket frame parsing 79 + - reconnection logic 80 + - backpressure handling 81 + - cursor management for resumption 82 + 83 + **wish**: built-in stream client: 84 + 85 + ```zig 86 + const stream = try Jetstream.connect(allocator, .{ 87 + .collections = &.{"app.bsky.feed.post"}, 88 + .cursor = saved_cursor, 89 + }); 90 + 91 + while (try stream.next()) |event| { 92 + switch (event) { 93 + .commit => |commit| { 94 + // commit.record is already typed based on collection 95 + }, 96 + .identity => |id| { ... }, 97 + .account => |acc| { ... }, 98 + } 99 + } 100 + ``` 101 + 102 + ### 5. tid parsing is non-obvious 103 + 104 + tids encode timestamps in base32-sortable format. we had to figure out the algorithm and implement it: 105 + 106 + ```zig 107 + pub fn parseTidTimestamp(tid: []const u8) ?i64 { 108 + if (tid.len < 13) return null; 109 + var timestamp: u64 = 0; 110 + for (tid[0..13]) |c| { 111 + const val: u64 = switch (c) { 112 + '2'...'7' => c - '2', 113 + 'a'...'z' => c - 'a' + 6, 114 + else => return null, 115 + }; 116 + timestamp = (timestamp << 5) | val; 117 + } 118 + return @intCast(timestamp / 1000); 119 + } 120 + ``` 121 + 122 + **wish**: `Tid` type with utilities: 123 + 124 + ```zig 125 + const tid = Tid.parse("3jui7...") orelse return error.InvalidTid; 126 + const created_at = tid.timestamp(); // returns i64 milliseconds 127 + const clock_id = tid.clockId(); 128 + 129 + // also: generate tids 130 + const new_tid = Tid.now(); 131 + ``` 132 + 133 + ### 6. at-uri parsing 134 + 135 + at-uris are everywhere: `at://did:plc:xyz/app.bsky.feed.post/abc123` 136 + 137 + we need to extract did, collection, rkey constantly. currently doing string splits manually. 138 + 139 + **wish**: `AtUri` type: 140 + 141 + ```zig 142 + const uri = try AtUri.parse("at://did:plc:xyz/app.bsky.feed.post/abc123"); 143 + uri.did // "did:plc:xyz" 144 + uri.collection // "app.bsky.feed.post" 145 + uri.rkey // "abc123" 146 + 147 + // construct 148 + const new_uri = AtUri.init("did:plc:xyz", "app.bsky.feed.post", "abc123"); 149 + new_uri.toString() // "at://did:plc:xyz/app.bsky.feed.post/abc123" 150 + ``` 151 + 152 + ### 7. feed generator scaffolding 153 + 154 + building a feed generator requires: 155 + - http server for xrpc endpoints 156 + - `describeFeedGenerator` endpoint 157 + - `getFeedSkeleton` endpoint with cursor handling 158 + - well-known did document serving 159 + 160 + we built all this from scratch. 161 + 162 + **wish**: feed generator framework: 163 + 164 + ```zig 165 + const FeedGenerator = @import("atproto").FeedGenerator; 166 + 167 + const MyFeed = struct { 168 + pub fn filter(post: Post) bool { 169 + // return true to include in feed 170 + return post.hasLink("soundcloud.com"); 171 + } 172 + 173 + pub fn sort(posts: []Post) void { 174 + // custom sort, or use default chronological 175 + } 176 + }; 177 + 178 + pub fn main() !void { 179 + var generator = try FeedGenerator.init(allocator, .{ 180 + .did = "did:web:my-feed.example.com", 181 + .feeds = &.{ 182 + .{ .name = "my-feed", .handler = MyFeed }, 183 + }, 184 + }); 185 + try generator.serve(3000); 186 + } 187 + ``` 188 + 189 + ### 8. did resolution 190 + 191 + resolving `did:plc:*` and `did:web:*` to did documents requires http calls and json parsing. needed for verifying identities, getting service endpoints. 192 + 193 + **wish**: did resolver: 194 + 195 + ```zig 196 + const resolver = DidResolver.init(allocator); 197 + const doc = try resolver.resolve("did:plc:xyz"); 198 + doc.alsoKnownAs // ["at://handle.bsky.social"] 199 + doc.service // pds endpoint, etc. 200 + ``` 201 + 202 + ### 9. cbor/dag-cbor support 203 + 204 + the actual at-proto repo format uses dag-cbor. if you want to verify signatures or work with raw repo data, you need cbor. 205 + 206 + **wish**: cbor codec, at least for reading commit data from firehose. 207 + 208 + ### 10. labeler integration 209 + 210 + reading and applying labels (nsfw, etc.) from labeler services. 211 + 212 + **wish**: label types and utilities: 213 + 214 + ```zig 215 + const labels = post.labels orelse &.{}; 216 + if (Label.hasAny(labels, &.{"porn", "sexual", "nudity"})) { 217 + // exclude 218 + } 219 + ``` 220 + 221 + ### 11. facet helpers 222 + 223 + facets (links, mentions, tags in post text) have a specific structure. extracting links means navigating: 224 + 225 + ```zig 226 + for (facets) |facet| { 227 + for (facet.features) |feature| { 228 + if (feature.type == .link) { 229 + // feature.uri 230 + } 231 + } 232 + } 233 + ``` 234 + 235 + **wish**: facet utilities: 236 + 237 + ```zig 238 + const links = post.extractLinks(); // [][]const u8 239 + const mentions = post.extractMentions(); // []Did 240 + const tags = post.extractTags(); // [][]const u8 241 + ``` 242 + 243 + ### 12. rich text building 244 + 245 + creating posts with links/mentions requires building facet byte ranges correctly. 246 + 247 + **wish**: rich text builder: 248 + 249 + ```zig 250 + var rt = RichText.init(allocator); 251 + rt.text("check out "); 252 + rt.link("this track", "https://soundcloud.com/..."); 253 + rt.text(" by "); 254 + rt.mention("@artist.bsky.social"); 255 + 256 + const post = rt.toPost(); // has .text and .facets set correctly 257 + ``` 258 + 259 + ## summary 260 + 261 + the ideal sdk would have: 262 + 263 + 1. **typed lexicons** - generated zig structs for all at-proto record types 264 + 2. **xrpc client** - typed api calls with auth, pagination, rate limiting 265 + 3. **stream client** - jetstream/firehose with reconnection, typed events 266 + 4. **primitives** - Tid, AtUri, Did, Cid types with parsing/generation 267 + 5. **feed generator framework** - scaffolding for common feed patterns 268 + 6. **facet utilities** - extract/build links, mentions, tags 269 + 7. **label handling** - read and apply moderation labels 270 + 8. **did resolution** - resolve did:plc and did:web 271 + 272 + basically: let me focus on the feed logic, not the protocol plumbing.