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