Something went wrong. Try again.
An atproto Tilemap editor
Something went wrong. Try again.
8.6 kB · 228 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229// ---------------------------------------------------------------------------// Minimal, unauthenticated atproto reads for the Sandbox.//// The flow: type a handle → autocomplete via the public AppView → resolve the// handle to a DID → resolve the DID to its PDS → read the `actor.rpg.sprite`// record (rkey `self`) from that repo → build a URL for its sprite-sheet blob.// No login required: every endpoint here serves public data with open CORS.// ---------------------------------------------------------------------------
/** Public Bluesky AppView — typeahead + handle resolution. */const APPVIEW = 'https://public.api.bsky.app'/** PLC directory — resolves `did:plc:*` to a DID document. */const PLC = 'https://plc.directory'
/** The collection + record key holding a repo's player sprite. */export const SPRITE_COLLECTION = 'actor.rpg.sprite'export const SPRITE_RKEY = 'self'
export interface ActorSuggestion { did: string handle: string displayName?: string avatar?: string}
/** An `actor.rpg.sprite` record, resolved to a loadable sheet. */export interface ActorSprite { did: string handle?: string /** absolute URL of the sprite-sheet image blob (PNG) */ sheetUrl: string /** sheet layout, straight from the record */ columns: number rows: number frameWidth: number frameHeight: number}
/** One record from a repo listing: its `at://` uri, cid, the rkey parsed off * the uri, and the decoded record value. */export interface RepoRecord<T = unknown> { uri: string cid: string rkey: string value: T}
async function xrpc<T>(url: string, signal?: AbortSignal, cache?: RequestCache): Promise<T> { const res = await fetch(url, { signal, cache }) if (!res.ok) { let detail = `${res.status}` try { const body = (await res.json()) as { error?: string; message?: string } detail = body.message || body.error || detail } catch { // non-JSON error body — keep the status code } throw new Error(detail) } return res.json() as Promise<T>}
/** Handle autocomplete (debounce + abort at the call site). */export async function searchActors( query: string, signal?: AbortSignal,): Promise<ActorSuggestion[]> { const url = `${APPVIEW}/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=8` const data = await xrpc<{ actors?: ActorSuggestion[] }>(url, signal) return data.actors ?? []}
/** handle → DID, via the AppView's identity resolver. */export async function resolveHandleToDid(handle: string, signal?: AbortSignal): Promise<string> { const url = `${APPVIEW}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}` const data = await xrpc<{ did: string }>(url, signal) return data.did}
interface DidDoc { service?: { id: string; type: string; serviceEndpoint: string }[]}
/** DID → its PDS service endpoint (the `#atproto_pds` service). */export async function resolveDidToPds(did: string, signal?: AbortSignal): Promise<string> { let doc: DidDoc if (did.startsWith('did:plc:')) { doc = await xrpc<DidDoc>(`${PLC}/${encodeURIComponent(did)}`, signal) } else if (did.startsWith('did:web:')) { // did:web:example.com → https://example.com/.well-known/did.json const host = did.slice('did:web:'.length).replace(/:/g, '/') doc = await xrpc<DidDoc>(`https://${host}/.well-known/did.json`, signal) } else { throw new Error(`unsupported DID method: ${did}`) } const pds = doc.service?.find((s) => s.id.endsWith('atproto_pds'))?.serviceEndpoint if (!pds) throw new Error('no PDS in DID document') return pds.replace(/\/$/, '')}
/** List every record in `collection` for a repo (handle or DID), following * pagination. Resolves the identity → PDS, then pages `listRecords`. Returns * the resolved did/pds alongside the records so callers can build blob URLs. */export async function listRecords<T = unknown>( handleOrDid: string, collection: string, signal?: AbortSignal,): Promise<{ did: string; pds: string; records: RepoRecord<T>[] }> { const did = handleOrDid.startsWith('did:') ? handleOrDid : await resolveHandleToDid(handleOrDid.replace(/^@/, ''), signal) const pds = await resolveDidToPds(did, signal)
const records: RepoRecord<T>[] = [] let cursor: string | undefined do { const url = `${pds}/xrpc/com.atproto.repo.listRecords` + `?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&limit=100` + (cursor ? `&cursor=${encodeURIComponent(cursor)}` : '') const page = await xrpc<{ records?: { uri: string; cid: string; value: T }[] cursor?: string }>(url, signal) for (const r of page.records ?? []) { records.push({ uri: r.uri, cid: r.cid, rkey: r.uri.split('/').pop() ?? '', value: r.value }) } cursor = page.cursor } while (cursor)
return { did, pds, records }}
/** Fetch a blob by CID from a repo's PDS and return it as a data URL, so the * bytes are self-contained (survive local save / offline) rather than a remote * reference. */export async function fetchBlobAsDataUrl( pds: string, did: string, cid: string, signal?: AbortSignal,): Promise<string> { const url = `${pds}/xrpc/com.atproto.sync.getBlob` + `?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}` const res = await fetch(url, { signal }) if (!res.ok) throw new Error(`blob fetch failed (${res.status})`) const blob = await res.blob() return await new Promise<string>((resolve, reject) => { const reader = new FileReader() reader.onload = () => resolve(reader.result as string) reader.onerror = () => reject(reader.error) reader.readAsDataURL(blob) })}
/** Parse an `at://<authority>/<collection>/<rkey>` URI into its parts. The * authority is a DID (or handle). Returns null for anything that isn't a * three-segment at:// URI. */export function parseAtUri( uri: string,): { authority: string; collection: string; rkey: string } | null { const m = /^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/.exec(uri) return m ? { authority: m[1], collection: m[2], rkey: m[3] } : null}
/** Fetch one record (by repo handle/DID, collection, rkey) from its owning PDS. * Public/unauthenticated. Returns the resolved did + pds so callers can build * blob URLs for any embedded blobs. */export async function getRecord<T = unknown>( handleOrDid: string, collection: string, rkey: string, signal?: AbortSignal,): Promise<{ did: string; pds: string; uri: string; cid?: string; value: T }> { const did = handleOrDid.startsWith('did:') ? handleOrDid : await resolveHandleToDid(handleOrDid.replace(/^@/, ''), signal) const pds = await resolveDidToPds(did, signal) const url = `${pds}/xrpc/com.atproto.repo.getRecord` + `?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}` + `&rkey=${encodeURIComponent(rkey)}` const data = await xrpc<{ uri: string; cid?: string; value: T }>(url, signal) return { did, pds, uri: data.uri, cid: data.cid, value: data.value }}
interface SpriteRecord { spriteSheet?: { ref?: { $link?: string }; mimeType?: string } columns?: number rows?: number frameWidth?: number frameHeight?: number}
/** Resolve a handle (or DID) to its loadable `actor.rpg.sprite` record. */export async function loadActorSprite( handleOrDid: string, signal?: AbortSignal, // skip the HTTP cache so a freshly re-uploaded sheet shows up immediately force = false,): Promise<ActorSprite> { const did = handleOrDid.startsWith('did:') ? handleOrDid : await resolveHandleToDid(handleOrDid, signal) const pds = await resolveDidToPds(did, signal)
const recUrl = `${pds}/xrpc/com.atproto.repo.getRecord` + `?repo=${encodeURIComponent(did)}&collection=${SPRITE_COLLECTION}&rkey=${SPRITE_RKEY}` // bypass the cache on the record fetch: the sheet blob is content-addressed, // so a new upload only surfaces once we see the record's updated cid const rec = await xrpc<{ value: SpriteRecord }>(recUrl, signal, force ? 'no-store' : undefined)
const cid = rec.value.spriteSheet?.ref?.$link if (!cid) throw new Error('record has no sprite sheet blob') const sheetUrl = `${pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`
return { did, sheetUrl, columns: rec.value.columns ?? 3, rows: rec.value.rows ?? 4, frameWidth: rec.value.frameWidth ?? 0, frameHeight: rec.value.frameHeight ?? 0, }}