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'];