A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import { Client, simpleFetchHandler } 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 normalizeBaseUrl = (input: string): string => { 22 const trimmed = input.trim(); 23 if (!trimmed) throw new Error('Service URL cannot be empty'); 24 const withScheme = ABSOLUTE_URL_RE.test(trimmed) ? trimmed : `https://${trimmed.replace(/^\/+/, '')}`; 25 const url = new URL(withScheme); 26 const pathname = url.pathname.replace(/\/+$/, ''); 27 return pathname ? `${url.origin}${pathname}` : url.origin; 28}; 29 30export class ServiceResolver { 31 private plc: string; 32 private didResolver: CompositeDidDocumentResolver<SupportedDidMethod>; 33 private handleResolver: XrpcHandleResolver; 34 constructor(opts: ServiceResolverOptions = {}) { 35 const plcSource = opts.plcDirectory && opts.plcDirectory.trim() ? opts.plcDirectory : DEFAULT_PLC; 36 const identitySource = opts.identityService && opts.identityService.trim() ? opts.identityService : DEFAULT_IDENTITY_SERVICE; 37 this.plc = normalizeBaseUrl(plcSource); 38 const identityBase = normalizeBaseUrl(identitySource); 39 const fetchImpl = opts.fetch ?? fetch; 40 const plcResolver = new PlcDidDocumentResolver({ apiUrl: this.plc, fetch: fetchImpl }); 41 const webResolver = new WebDidDocumentResolver({ fetch: fetchImpl }); 42 this.didResolver = new CompositeDidDocumentResolver({ methods: { plc: plcResolver, web: webResolver } }); 43 this.handleResolver = new XrpcHandleResolver({ serviceUrl: identityBase, fetch: fetchImpl }); 44 } 45 46 async resolveDidDoc(did: string): Promise<DidDocument> { 47 const trimmed = did.trim(); 48 if (!trimmed.startsWith('did:')) throw new Error(`Invalid DID ${did}`); 49 const methodEnd = trimmed.indexOf(':', 4); 50 const method = (methodEnd === -1 ? trimmed.slice(4) : trimmed.slice(4, methodEnd)) as string; 51 if (!SUPPORTED_DID_METHODS.includes(method as SupportedDidMethod)) { 52 throw new Error(`Unsupported DID method ${method ?? '<unknown>'}`); 53 } 54 return this.didResolver.resolve(trimmed as SupportedDid); 55 } 56 57 async pdsEndpointForDid(did: string): Promise<string> { 58 const doc = await this.resolveDidDoc(did); 59 const svc = doc.service?.find(s => s.type === 'AtprotoPersonalDataServer'); 60 if (!svc || !svc.serviceEndpoint || typeof svc.serviceEndpoint !== 'string') { 61 throw new Error(`No PDS endpoint in DID doc for ${did}`); 62 } 63 return svc.serviceEndpoint.replace(/\/$/, ''); 64 } 65 66 async resolveHandle(handle: string): Promise<string> { 67 const normalized = handle.trim().toLowerCase(); 68 if (!normalized) throw new Error('Handle cannot be empty'); 69 return this.handleResolver.resolve(normalized as Handle); 70 } 71} 72 73export interface CreateClientOptions extends ServiceResolverOptions { 74 did?: string; // optional to create a DID-scoped client 75 service?: string; // override service base url 76} 77 78export async function createAtprotoClient(opts: CreateClientOptions = {}) { 79 let service = opts.service; 80 const resolver = new ServiceResolver(opts); 81 if (!service && opts.did) { 82 service = await resolver.pdsEndpointForDid(opts.did); 83 } 84 if (!service) throw new Error('service or did required'); 85 const handler = simpleFetchHandler({ service: normalizeBaseUrl(service) }); 86 const rpc = new Client({ handler }); 87 return { rpc, service, resolver }; 88} 89 90export type AtprotoClient = Awaited<ReturnType<typeof createAtprotoClient>>['rpc'];