A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import { Client, simpleFetchHandler, type FetchHandler } from '@atcute/client'; 2import { CompositeDidDocumentResolver, PlcDidDocumentResolver, WebDidDocumentResolver, XrpcHandleResolver } from '@atcute/identity-resolver'; 3import type { DidDocument } from '@atcute/identity'; 4import type { Did, Handle } from '@atcute/lexicons/syntax'; 5import type {} from '@atcute/tangled'; 6import type {} from '@atcute/atproto'; 7 8export interface ServiceResolverOptions { 9 plcDirectory?: string; 10 identityService?: string; 11 fetch?: typeof fetch; 12} 13 14const DEFAULT_PLC = 'https://plc.directory'; 15const DEFAULT_IDENTITY_SERVICE = 'https://public.api.bsky.app'; 16const ABSOLUTE_URL_RE = /^[a-zA-Z][a-zA-Z\d+\-.]*:/; 17const SUPPORTED_DID_METHODS = ['plc', 'web'] as const; 18type SupportedDidMethod = (typeof SUPPORTED_DID_METHODS)[number]; 19type SupportedDid = Did<SupportedDidMethod>; 20 21export const SLINGSHOT_BASE_URL = 'https://slingshot.microcosm.blue'; 22 23export const normalizeBaseUrl = (input: string): string => { 24 const trimmed = input.trim(); 25 if (!trimmed) throw new Error('Service URL cannot be empty'); 26 const withScheme = ABSOLUTE_URL_RE.test(trimmed) ? trimmed : `https://${trimmed.replace(/^\/+/, '')}`; 27 const url = new URL(withScheme); 28 const pathname = url.pathname.replace(/\/+$/, ''); 29 return pathname ? `${url.origin}${pathname}` : url.origin; 30}; 31 32export class ServiceResolver { 33 private plc: string; 34 private didResolver: CompositeDidDocumentResolver<SupportedDidMethod>; 35 private handleResolver: XrpcHandleResolver; 36 private fetchImpl: typeof fetch; 37 constructor(opts: ServiceResolverOptions = {}) { 38 const plcSource = opts.plcDirectory && opts.plcDirectory.trim() ? opts.plcDirectory : DEFAULT_PLC; 39 const identitySource = opts.identityService && opts.identityService.trim() ? opts.identityService : DEFAULT_IDENTITY_SERVICE; 40 this.plc = normalizeBaseUrl(plcSource); 41 const identityBase = normalizeBaseUrl(identitySource); 42 this.fetchImpl = bindFetch(opts.fetch); 43 const plcResolver = new PlcDidDocumentResolver({ apiUrl: this.plc, fetch: this.fetchImpl }); 44 const webResolver = new WebDidDocumentResolver({ fetch: this.fetchImpl }); 45 this.didResolver = new CompositeDidDocumentResolver({ methods: { plc: plcResolver, web: webResolver } }); 46 this.handleResolver = new XrpcHandleResolver({ serviceUrl: identityBase, fetch: this.fetchImpl }); 47 } 48 49 async resolveDidDoc(did: string): Promise<DidDocument> { 50 const trimmed = did.trim(); 51 if (!trimmed.startsWith('did:')) throw new Error(`Invalid DID ${did}`); 52 const methodEnd = trimmed.indexOf(':', 4); 53 const method = (methodEnd === -1 ? trimmed.slice(4) : trimmed.slice(4, methodEnd)) as string; 54 if (!SUPPORTED_DID_METHODS.includes(method as SupportedDidMethod)) { 55 throw new Error(`Unsupported DID method ${method ?? '<unknown>'}`); 56 } 57 return this.didResolver.resolve(trimmed as SupportedDid); 58 } 59 60 async pdsEndpointForDid(did: string): Promise<string> { 61 const doc = await this.resolveDidDoc(did); 62 const svc = doc.service?.find(s => s.type === 'AtprotoPersonalDataServer'); 63 if (!svc || !svc.serviceEndpoint || typeof svc.serviceEndpoint !== 'string') { 64 throw new Error(`No PDS endpoint in DID doc for ${did}`); 65 } 66 return svc.serviceEndpoint.replace(/\/$/, ''); 67 } 68 69 async resolveHandle(handle: string): Promise<string> { 70 const normalized = handle.trim().toLowerCase(); 71 if (!normalized) throw new Error('Handle cannot be empty'); 72 let slingshotError: Error | undefined; 73 try { 74 const url = new URL('/xrpc/com.atproto.identity.resolveHandle', SLINGSHOT_BASE_URL); 75 url.searchParams.set('handle', normalized); 76 const response = await this.fetchImpl(url); 77 if (response.ok) { 78 const payload = await response.json() as { did?: string } | null; 79 if (payload?.did) { 80 console.info('[slingshot] resolveHandle cache hit', { handle: normalized }); 81 return payload.did; 82 } 83 slingshotError = new Error('Slingshot resolveHandle response missing DID'); 84 console.warn('[slingshot] resolveHandle payload missing DID; falling back', { handle: normalized }); 85 } else { 86 slingshotError = new Error(`Slingshot resolveHandle failed with status ${response.status}`); 87 const body = response.body; 88 if (body) { 89 body.cancel().catch(() => {}); 90 } 91 console.info('[slingshot] resolveHandle cache miss', { handle: normalized, status: response.status }); 92 } 93 } catch (err) { 94 if (err instanceof DOMException && err.name === 'AbortError') throw err; 95 slingshotError = err instanceof Error ? err : new Error(String(err)); 96 console.warn('[slingshot] resolveHandle error; falling back to identity service', { handle: normalized, error: slingshotError }); 97 } 98 99 try { 100 const did = await this.handleResolver.resolve(normalized as Handle); 101 if (slingshotError) { 102 console.info('[slingshot] resolveHandle fallback succeeded', { handle: normalized }); 103 } 104 return did; 105 } catch (err) { 106 if (slingshotError && err instanceof Error) { 107 const prior = err.message; 108 err.message = `${prior}; Slingshot resolveHandle failed: ${slingshotError.message}`; 109 if (slingshotError) { 110 console.warn('[slingshot] resolveHandle fallback failed', { handle: normalized, error: slingshotError }); 111 } 112 } 113 throw err; 114 } 115 } 116} 117 118export interface CreateClientOptions extends ServiceResolverOptions { 119 did?: string; // optional to create a DID-scoped client 120 service?: string; // override service base url 121} 122 123export async function createAtprotoClient(opts: CreateClientOptions = {}) { 124 const fetchImpl = bindFetch(opts.fetch); 125 let service = opts.service; 126 const resolver = new ServiceResolver({ ...opts, fetch: fetchImpl }); 127 if (!service && opts.did) { 128 service = await resolver.pdsEndpointForDid(opts.did); 129 } 130 if (!service) throw new Error('service or did required'); 131 const normalizedService = normalizeBaseUrl(service); 132 const handler = createSlingshotAwareHandler(normalizedService, fetchImpl); 133 const rpc = new Client({ handler }); 134 return { rpc, service: normalizedService, resolver }; 135} 136 137export type AtprotoClient = Awaited<ReturnType<typeof createAtprotoClient>>['rpc']; 138 139const SLINGSHOT_RETRY_PATHS = [ 140 '/xrpc/com.atproto.repo.getRecord', 141 '/xrpc/com.atproto.identity.resolveHandle', 142]; 143 144function createSlingshotAwareHandler(service: string, fetchImpl: typeof fetch): FetchHandler { 145 const primary = simpleFetchHandler({ service, fetch: fetchImpl }); 146 const slingshot = simpleFetchHandler({ service: SLINGSHOT_BASE_URL, fetch: fetchImpl }); 147 return async (pathname, init) => { 148 const matched = SLINGSHOT_RETRY_PATHS.find(candidate => pathname === candidate || pathname.startsWith(`${candidate}?`)); 149 if (matched) { 150 try { 151 const slingshotResponse = await slingshot(pathname, init); 152 if (slingshotResponse.ok) { 153 console.info(`[slingshot] cache hit for ${matched}`); 154 return slingshotResponse; 155 } 156 const body = slingshotResponse.body; 157 if (body) { 158 body.cancel().catch(() => {}); 159 } 160 console.info(`[slingshot] cache miss ${slingshotResponse.status} for ${matched}, falling back to ${service}`); 161 } catch (err) { 162 if (err instanceof DOMException && err.name === 'AbortError') { 163 throw err; 164 } 165 console.warn(`[slingshot] fetch error for ${matched}, falling back to ${service}`, err); 166 } 167 } 168 return primary(pathname, init); 169 }; 170} 171 172function bindFetch(fetchImpl?: typeof fetch): typeof fetch { 173 const impl = fetchImpl ?? globalThis.fetch; 174 if (typeof impl !== 'function') { 175 throw new Error('fetch implementation not available'); 176 } 177 return impl.bind(globalThis); 178}