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