Something went wrong. Try again.
Central platform for European atproto.<cc> country community websites
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274// 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<unknown> { 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<Profile[]> { 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<Record<string, string>>; }; 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<Profile[]> { 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<string, string> }>; 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<string, any>): 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<string, string | null>();
async function getJson2(url: string): Promise<unknown> { // 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<string | null> { 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<Array<{ uri: string; cid: string; value: Record<string, any> }>> { 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<string, any> }>; }; 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<string, any> } | 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<string, any>; }; 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<FeedPost[]> { 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<string, any>; reason?: unknown }>; }; return (data.feed ?? []) .filter((item) => !item.reason) // drop reposts .map((item) => item.post) .filter((post): post is Record<string, any> => Boolean(post?.record?.text)) .map(mapPost); } catch { return []; }}
function mapPost(post: Record<string, any>): 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<FeedPost[]> { 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<string, any> }>; }; return (data.feed ?? []) .map((it) => it.post) .filter((p): p is Record<string, any> => 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<FeedPost[]> { 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<Record<string, any>>; }; return (data.posts ?? []).filter((p) => p?.record).map(mapPost); } catch { return []; }}