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