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 return payload.did; 81 } 82 slingshotError = new Error('Slingshot resolveHandle response missing DID'); 83 } else { 84 slingshotError = new Error(`Slingshot resolveHandle failed with status ${response.status}`); 85 const body = response.body; 86 if (body) { 87 body.cancel().catch(() => {}); 88 } 89 } 90 } catch (err) { 91 if (err instanceof DOMException && err.name === 'AbortError') throw err; 92 slingshotError = err instanceof Error ? err : new Error(String(err)); 93 } 94 95 try { 96 const did = await this.handleResolver.resolve(normalized as Handle); 97 return did; 98 } catch (err) { 99 if (slingshotError && err instanceof Error) { 100 const prior = err.message; 101 err.message = `${prior}; Slingshot resolveHandle failed: ${slingshotError.message}`; 102 } 103 throw err; 104 } 105 } 106} 107 108export interface CreateClientOptions extends ServiceResolverOptions { 109 did?: string; // optional to create a DID-scoped client 110 service?: string; // override service base url 111} 112 113export async function createAtprotoClient(opts: CreateClientOptions = {}) { 114 const fetchImpl = bindFetch(opts.fetch); 115 let service = opts.service; 116 const resolver = new ServiceResolver({ ...opts, fetch: fetchImpl }); 117 if (!service && opts.did) { 118 service = await resolver.pdsEndpointForDid(opts.did); 119 } 120 if (!service) throw new Error('service or did required'); 121 const normalizedService = normalizeBaseUrl(service); 122 const handler = createSlingshotAwareHandler(normalizedService, fetchImpl); 123 const rpc = new Client({ handler }); 124 return { rpc, service: normalizedService, resolver }; 125} 126 127export type AtprotoClient = Awaited<ReturnType<typeof createAtprotoClient>>['rpc']; 128 129const SLINGSHOT_RETRY_PATHS = [ 130 '/xrpc/com.atproto.repo.getRecord', 131 '/xrpc/com.atproto.identity.resolveHandle', 132]; 133 134function createSlingshotAwareHandler(service: string, fetchImpl: typeof fetch): FetchHandler { 135 const primary = simpleFetchHandler({ service, fetch: fetchImpl }); 136 const slingshot = simpleFetchHandler({ service: SLINGSHOT_BASE_URL, fetch: fetchImpl }); 137 return async (pathname, init) => { 138 const matched = SLINGSHOT_RETRY_PATHS.find(candidate => pathname === candidate || pathname.startsWith(`${candidate}?`)); 139 if (matched) { 140 try { 141 const slingshotResponse = await slingshot(pathname, init); 142 if (slingshotResponse.ok) { 143 return slingshotResponse; 144 } 145 const body = slingshotResponse.body; 146 if (body) { 147 body.cancel().catch(() => {}); 148 } 149 } catch (err) { 150 if (err instanceof DOMException && err.name === 'AbortError') { 151 throw err; 152 } 153 } 154 } 155 return primary(pathname, init); 156 }; 157} 158 159function bindFetch(fetchImpl?: typeof fetch): typeof fetch { 160 const impl = fetchImpl ?? globalThis.fetch; 161 if (typeof impl !== 'function') { 162 throw new Error('fetch implementation not available'); 163 } 164 return impl.bind(globalThis); 165}