A TypeScript toolkit for consuming the Bluesky network in real-time.
1import type {
2 UserRegistry,
3 SearchPostsOptions,
4 GetAuthorFeedOptions,
5 PaginationOptions,
6} from "./types.js";
7import { mergeUsers } from "./parsing.js";
8
9const API_BASE = "https://public.api.bsky.app/xrpc";
10
11// --- Auth ---
12
13export const getBskyAuthToken = (): string | undefined =>
14 process.env.BSKY_AUTH_TOKEN;
15
16const authHeaders = (): HeadersInit | undefined => {
17 const token = getBskyAuthToken();
18 return token ? { Authorization: `Bearer ${token}` } : undefined;
19};
20
21// --- Search Actors ---
22
23export const buildSearchActorsUrl = (
24 query: string,
25 options: { limit?: number; cursor?: string } = {}
26): string => {
27 const url = new URL(`${API_BASE}/app.bsky.actor.searchActors`);
28 url.searchParams.set("q", query);
29 url.searchParams.set("limit", String(options.limit ?? 100));
30 if (options.cursor) url.searchParams.set("cursor", options.cursor);
31 return url.toString();
32};
33
34export const fetchSearchActors = async (
35 query: string,
36 options: { limit?: number; cursor?: string } = {}
37): Promise<any> => {
38 const res = await fetch(buildSearchActorsUrl(query, options));
39 if (!res.ok) throw new Error(`Search failed: ${res.status}`);
40 return res.json();
41};
42
43export const fetchAndMergeUsers = async (
44 query: string,
45 existing: UserRegistry,
46 cursor?: string
47): Promise<{
48 merged: UserRegistry;
49 newCount: number;
50 cursor: string | undefined;
51 actorCount: number;
52}> => {
53 const data = await fetchSearchActors(query, { cursor });
54 const { merged, newCount } = mergeUsers(existing, data.actors);
55 return {
56 merged,
57 newCount,
58 cursor: data.cursor,
59 actorCount: data.actors.length,
60 };
61};
62
63// --- Resolve Handle ---
64
65export const buildResolveHandleUrl = (handle: string): string => {
66 const url = new URL(`${API_BASE}/com.atproto.identity.resolveHandle`);
67 url.searchParams.set("handle", handle);
68 return url.toString();
69};
70
71export const fetchResolveHandle = async (handle: string): Promise<string> => {
72 const res = await fetch(buildResolveHandleUrl(handle));
73 if (!res.ok) throw new Error(`Resolve handle failed: ${res.status}`);
74 const data = await res.json();
75 return data.did;
76};
77
78// --- Get Profile ---
79
80export const buildGetProfileUrl = (actor: string): string => {
81 const url = new URL(`${API_BASE}/app.bsky.actor.getProfile`);
82 url.searchParams.set("actor", actor);
83 return url.toString();
84};
85
86export const fetchGetProfile = async (actor: string): Promise<any> => {
87 const res = await fetch(buildGetProfileUrl(actor));
88 if (!res.ok) throw new Error(`Get profile failed: ${res.status}`);
89 return res.json();
90};
91
92// --- Get Profiles ---
93
94export const buildGetProfilesUrl = (actors: readonly string[]): string => {
95 const url = new URL(`${API_BASE}/app.bsky.actor.getProfiles`);
96 for (const actor of actors) {
97 url.searchParams.append("actors", actor);
98 }
99 return url.toString();
100};
101
102export const fetchGetProfiles = async (
103 actors: readonly string[]
104): Promise<any> => {
105 const res = await fetch(buildGetProfilesUrl(actors));
106 if (!res.ok) throw new Error(`Get profiles failed: ${res.status}`);
107 return res.json();
108};
109
110// --- Search Posts ---
111
112export const buildSearchPostsUrl = (
113 query: string,
114 options: SearchPostsOptions = {}
115): string => {
116 const url = new URL(`${API_BASE}/app.bsky.feed.searchPosts`);
117 url.searchParams.set("q", query);
118 if (options.sort) url.searchParams.set("sort", options.sort);
119 if (options.since) url.searchParams.set("since", options.since);
120 if (options.until) url.searchParams.set("until", options.until);
121 if (options.mentions) url.searchParams.set("mentions", options.mentions);
122 if (options.author) url.searchParams.set("author", options.author);
123 if (options.lang) url.searchParams.set("lang", options.lang);
124 if (options.domain) url.searchParams.set("domain", options.domain);
125 if (options.url) url.searchParams.set("url", options.url);
126 if (options.tag?.length) {
127 for (const t of options.tag) {
128 url.searchParams.append("tag", t);
129 }
130 }
131 if (options.limit != null) {
132 url.searchParams.set("limit", String(options.limit));
133 }
134 if (options.cursor) url.searchParams.set("cursor", options.cursor);
135 return url.toString();
136};
137
138export const fetchSearchPosts = async (
139 query: string,
140 options: SearchPostsOptions = {}
141): Promise<any> => {
142 const headers = authHeaders();
143 const res = await fetch(buildSearchPostsUrl(query, options), { headers });
144 if (!res.ok) throw new Error(`Search posts failed: ${res.status}`);
145 return res.json();
146};
147
148// --- Get Author Feed ---
149
150export const buildGetAuthorFeedUrl = (
151 actor: string,
152 options: GetAuthorFeedOptions = {}
153): string => {
154 const url = new URL(`${API_BASE}/app.bsky.feed.getAuthorFeed`);
155 url.searchParams.set("actor", actor);
156 if (options.filter) url.searchParams.set("filter", options.filter);
157 if (options.limit != null) {
158 url.searchParams.set("limit", String(options.limit));
159 }
160 if (options.cursor) url.searchParams.set("cursor", options.cursor);
161 if (options.includePins != null) {
162 url.searchParams.set("includePins", String(options.includePins));
163 }
164 return url.toString();
165};
166
167export const fetchGetAuthorFeed = async (
168 actor: string,
169 options: GetAuthorFeedOptions = {}
170): Promise<any> => {
171 const res = await fetch(buildGetAuthorFeedUrl(actor, options));
172 if (!res.ok) throw new Error(`Get author feed failed: ${res.status}`);
173 return res.json();
174};
175
176// --- Get Post Thread ---
177
178export const buildGetPostThreadUrl = (
179 uri: string,
180 options: { depth?: number; parentHeight?: number } = {}
181): string => {
182 const url = new URL(`${API_BASE}/app.bsky.feed.getPostThread`);
183 url.searchParams.set("uri", uri);
184 if (options.depth != null) {
185 url.searchParams.set("depth", String(options.depth));
186 }
187 if (options.parentHeight != null) {
188 url.searchParams.set("parentHeight", String(options.parentHeight));
189 }
190 return url.toString();
191};
192
193export const fetchGetPostThread = async (
194 uri: string,
195 options: { depth?: number; parentHeight?: number } = {}
196): Promise<any> => {
197 const res = await fetch(buildGetPostThreadUrl(uri, options));
198 if (!res.ok) throw new Error(`Get post thread failed: ${res.status}`);
199 return res.json();
200};
201
202// --- Get Followers ---
203
204export const buildGetFollowersUrl = (
205 actor: string,
206 options: PaginationOptions = {}
207): string => {
208 const url = new URL(`${API_BASE}/app.bsky.graph.getFollowers`);
209 url.searchParams.set("actor", actor);
210 if (options.limit != null) {
211 url.searchParams.set("limit", String(options.limit));
212 }
213 if (options.cursor) url.searchParams.set("cursor", options.cursor);
214 return url.toString();
215};
216
217export const fetchGetFollowers = async (
218 actor: string,
219 options: PaginationOptions = {}
220): Promise<any> => {
221 const res = await fetch(buildGetFollowersUrl(actor, options));
222 if (!res.ok) throw new Error(`Get followers failed: ${res.status}`);
223 return res.json();
224};
225
226// --- Get Follows ---
227
228export const buildGetFollowsUrl = (
229 actor: string,
230 options: PaginationOptions = {}
231): string => {
232 const url = new URL(`${API_BASE}/app.bsky.graph.getFollows`);
233 url.searchParams.set("actor", actor);
234 if (options.limit != null) {
235 url.searchParams.set("limit", String(options.limit));
236 }
237 if (options.cursor) url.searchParams.set("cursor", options.cursor);
238 return url.toString();
239};
240
241export const fetchGetFollows = async (
242 actor: string,
243 options: PaginationOptions = {}
244): Promise<any> => {
245 const res = await fetch(buildGetFollowsUrl(actor, options));
246 if (!res.ok) throw new Error(`Get follows failed: ${res.status}`);
247 return res.json();
248};
249
250// --- Get Likes ---
251
252export const buildGetLikesUrl = (
253 uri: string,
254 options: PaginationOptions = {}
255): string => {
256 const url = new URL(`${API_BASE}/app.bsky.feed.getLikes`);
257 url.searchParams.set("uri", uri);
258 if (options.limit != null) {
259 url.searchParams.set("limit", String(options.limit));
260 }
261 if (options.cursor) url.searchParams.set("cursor", options.cursor);
262 return url.toString();
263};
264
265export const fetchGetLikes = async (
266 uri: string,
267 options: PaginationOptions = {}
268): Promise<any> => {
269 const res = await fetch(buildGetLikesUrl(uri, options));
270 if (!res.ok) throw new Error(`Get likes failed: ${res.status}`);
271 return res.json();
272};
273
274// --- Get Reposted By ---
275
276export const buildGetRepostedByUrl = (
277 uri: string,
278 options: PaginationOptions = {}
279): string => {
280 const url = new URL(`${API_BASE}/app.bsky.feed.getRepostedBy`);
281 url.searchParams.set("uri", uri);
282 if (options.limit != null) {
283 url.searchParams.set("limit", String(options.limit));
284 }
285 if (options.cursor) url.searchParams.set("cursor", options.cursor);
286 return url.toString();
287};
288
289export const fetchGetRepostedBy = async (
290 uri: string,
291 options: PaginationOptions = {}
292): Promise<any> => {
293 const res = await fetch(buildGetRepostedByUrl(uri, options));
294 if (!res.ok) throw new Error(`Get reposted by failed: ${res.status}`);
295 return res.json();
296};
297
298// --- Get Quotes ---
299
300export const buildGetQuotesUrl = (
301 uri: string,
302 options: PaginationOptions = {}
303): string => {
304 const url = new URL(`${API_BASE}/app.bsky.feed.getQuotes`);
305 url.searchParams.set("uri", uri);
306 if (options.limit != null) {
307 url.searchParams.set("limit", String(options.limit));
308 }
309 if (options.cursor) url.searchParams.set("cursor", options.cursor);
310 return url.toString();
311};
312
313export const fetchGetQuotes = async (
314 uri: string,
315 options: PaginationOptions = {}
316): Promise<any> => {
317 const res = await fetch(buildGetQuotesUrl(uri, options));
318 if (!res.ok) throw new Error(`Get quotes failed: ${res.status}`);
319 return res.json();
320};
321
322// --- Utilities ---
323
324export const isRateLimitError = (error: unknown): boolean => {
325 if (error instanceof Error) {
326 return error.message.includes("429");
327 }
328 return false;
329};
330
331export const delay = (ms: number): Promise<void> =>
332 new Promise((r) => setTimeout(r, ms));