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