// Minimal read-only AT Protocol service layer. Hits the public Bluesky AppView // (no auth). Components must go through this module, never fetch XRPC directly. import type { FeedPost, PostEmbed, Profile } from './types'; const APPVIEW = 'https://public.api.bsky.app'; async function getJson(path: string, params: URLSearchParams): Promise { const url = `${APPVIEW}${path}?${params.toString()}`; // Bound every request: a hung upstream must never stall a build (the caller degrades on throw). const res = await fetch(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(8000) }); if (!res.ok) throw new Error(`${path} -> ${res.status}`); return res.json(); } /** Resolve profiles for up to N actors (handles or DIDs). Skips any that fail. */ export async function getProfiles(actors: string[]): Promise { const out: Profile[] = []; // app.bsky.actor.getProfiles takes at most 25 actors per call. for (let i = 0; i < actors.length; i += 25) { const batch = actors.slice(i, i + 25); const params = new URLSearchParams(); for (const a of batch) params.append('actors', a); try { const data = (await getJson('/xrpc/app.bsky.actor.getProfiles', params)) as { profiles?: Array>; }; for (const p of data.profiles ?? []) { out.push({ did: p.did, handle: p.handle, displayName: p.displayName, description: p.description, avatar: p.avatar, }); } } catch { // A failed batch shouldn't take down the build; skip it. } } return out; } /** * Members of a public Bluesky list (or starter pack list), as profiles. * `listUri` is the list's AT-URI (at://did/app.bsky.graph.list/rkey). Public, no auth. * Paginates up to `max`. Empty on failure. */ export async function getListMembers(listUri: string, max = 200): Promise { const out: Profile[] = []; let cursor: string | undefined; try { do { const params = new URLSearchParams({ list: listUri, limit: '100' }); if (cursor) params.set('cursor', cursor); const data = (await getJson('/xrpc/app.bsky.graph.getList', params)) as { items?: Array<{ subject?: Record }>; cursor?: string; }; for (const item of data.items ?? []) { const s = item.subject; if (!s) continue; out.push({ did: s.did, handle: s.handle, displayName: s.displayName, description: s.description, avatar: s.avatar, }); } cursor = data.cursor; } while (cursor && out.length < max); } catch { // fall through with whatever we collected } return out.slice(0, max); } /** Normalize a post's embed view (images / external card / quoted post). */ function parseEmbed(embed?: Record): PostEmbed | undefined { if (!embed || typeof embed !== 'object') return undefined; const type = String(embed.$type ?? ''); const readImages = (v: any) => (v?.images ?? []).map((im: any) => ({ thumb: im.thumb as string, alt: (im.alt ?? '') as string })); const isGif = (uri: string) => /\.gif(\?|$)/i.test(uri) || /media\.tenor\.com/i.test(uri); const readExternal = (v: any) => { if (!v?.external) return undefined; const uri = v.external.uri as string; return { uri, title: (v.external.title ?? '') as string, description: (v.external.description ?? '') as string, thumb: v.external.thumb as string | undefined, gif: isGif(uri), }; }; const readQuote = (rec: any) => { // app.bsky.embed.record#viewRecord — skip notFound/blocked/detached if (!rec || !rec.author || !rec.value || String(rec.$type ?? '').includes('viewRecord') === false) { return undefined; } // The quoted post can carry its own embed (a card, gif, or images) in rec.embeds. const nested = Array.isArray(rec.embeds) ? rec.embeds[0] : undefined; const ntype = String(nested?.$type ?? ''); const nestedExternal = ntype.startsWith('app.bsky.embed.external') ? readExternal(nested) : undefined; const nestedImages = ntype.startsWith('app.bsky.embed.images') ? readImages(nested) : undefined; return { handle: rec.author.handle as string, displayName: rec.author.displayName as string | undefined, avatar: rec.author.avatar as string | undefined, text: (rec.value.text ?? '') as string, uri: rec.uri as string, external: nestedExternal, images: nestedImages?.length ? nestedImages : undefined, }; }; const out: PostEmbed = {}; if (type.startsWith('app.bsky.embed.images')) out.images = readImages(embed); else if (type.startsWith('app.bsky.embed.external')) out.external = readExternal(embed); else if (type.startsWith('app.bsky.embed.record#')) out.quote = readQuote(embed.record); else if (type.startsWith('app.bsky.embed.recordWithMedia')) { out.quote = readQuote(embed.record?.record); const media = embed.media; const mtype = String(media?.$type ?? ''); if (mtype.startsWith('app.bsky.embed.images')) out.images = readImages(media); else if (mtype.startsWith('app.bsky.embed.external')) out.external = readExternal(media); } return out.images?.length || out.external || out.quote ? out : undefined; } const pdsCache = new Map(); async function getJson2(url: string): Promise { // Bound every request: a hung upstream must never stall a build (the caller degrades on throw). const res = await fetch(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(8000) }); if (!res.ok) throw new Error(`${url} -> ${res.status}`); return res.json(); } /** Resolve a DID's PDS endpoint (did:plc via plc.directory). Cached. */ export async function resolvePds(did: string): Promise { if (pdsCache.has(did)) return pdsCache.get(did) ?? null; let pds: string | null = null; try { if (did.startsWith('did:plc:')) { const doc = (await getJson2(`https://plc.directory/${did}`)) as { service?: Array<{ id?: string; type?: string; serviceEndpoint?: string }>; }; const svc = (doc.service ?? []).find( (s) => s.id?.endsWith('atproto_pds') || /pds/i.test(s.type ?? ''), ); pds = svc?.serviceEndpoint ?? null; } } catch { pds = null; } pdsCache.set(did, pds); return pds; } /** List records of a collection straight from the repo's PDS (public, no auth). */ export async function listRecords( did: string, collection: string, limit = 30, ): Promise }>> { const pds = await resolvePds(did); if (!pds) return []; try { const params = new URLSearchParams({ repo: did, collection, limit: String(limit) }); const data = (await getJson2(`${pds}/xrpc/com.atproto.repo.listRecords?${params}`)) as { records?: Array<{ uri: string; cid: string; value: Record }>; }; return data.records ?? []; } catch { return []; } } /** One record by DID + collection + rkey. Null on failure, so a single unreachable record * never fails a build. */ export async function getRecord( did: string, collection: string, rkey: string, ): Promise<{ uri: string; cid: string; value: Record } | null> { const pds = await resolvePds(did); if (!pds) return null; try { const params = new URLSearchParams({ repo: did, collection, rkey }); const data = (await getJson2(`${pds}/xrpc/com.atproto.repo.getRecord?${params}`)) as { uri?: string; cid?: string; value?: Record; }; return data.uri && data.value ? { uri: data.uri, cid: data.cid ?? '', value: data.value } : null; } catch { return null; } } /** Recent top-level posts (no replies, no reposts) for one actor. Empty on failure. */ export async function getAuthorFeed(actor: string, limit = 10): Promise { const params = new URLSearchParams({ actor, limit: String(limit), filter: 'posts_no_replies', }); try { const data = (await getJson('/xrpc/app.bsky.feed.getAuthorFeed', params)) as { feed?: Array<{ post?: Record; reason?: unknown }>; }; return (data.feed ?? []) .filter((item) => !item.reason) // drop reposts .map((item) => item.post) .filter((post): post is Record => Boolean(post?.record?.text)) .map(mapPost); } catch { return []; } } function mapPost(post: Record): FeedPost { return { uri: post.uri as string, cid: post.cid as string, author: { did: post.author.did, handle: post.author.handle, displayName: post.author.displayName, avatar: post.author.avatar, }, text: (post.record.text ?? '') as string, createdAt: (post.record.createdAt ?? post.indexedAt) as string, likeCount: (post.likeCount ?? 0) as number, repostCount: (post.repostCount ?? 0) as number, replyCount: (post.replyCount ?? 0) as number, quoteCount: (post.quoteCount ?? 0) as number, embed: parseEmbed(post.embed), }; } /** Fetch a custom feed generator's posts (public AppView, no auth). Empty on failure. */ export async function getFeed(feedUri: string, limit = 20): Promise { const params = new URLSearchParams({ feed: feedUri, limit: String(limit) }); try { const data = (await getJson('/xrpc/app.bsky.feed.getFeed', params)) as { feed?: Array<{ post?: Record }>; }; return (data.feed ?? []) .map((it) => it.post) .filter((p): p is Record => Boolean(p?.record)) .map(mapPost); } catch { return []; } } /** Fetch specific posts by AT-URI (up to 25). Empty on failure. */ export async function getPosts(uris: string[]): Promise { if (!uris.length) return []; const params = new URLSearchParams(); for (const u of uris.slice(0, 25)) params.append('uris', u); try { const data = (await getJson('/xrpc/app.bsky.feed.getPosts', params)) as { posts?: Array>; }; return (data.posts ?? []).filter((p) => p?.record).map(mapPost); } catch { return []; } }