A TypeScript toolkit for consuming the Bluesky network in real-time.
at main 332 lines 9.9 kB view raw
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));