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 { 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 SLINGSHOT_BASE_URL = 'https://slingshot.microcosm.blue';
22
23export const normalizeBaseUrl = (input: string): string => {
24 const trimmed = input.trim();
25 if (!trimmed) throw new Error('Service URL cannot be empty');
26 const withScheme = ABSOLUTE_URL_RE.test(trimmed) ? trimmed : `https://${trimmed.replace(/^\/+/, '')}`;
27 const url = new URL(withScheme);
28 const pathname = url.pathname.replace(/\/+$/, '');
29 return pathname ? `${url.origin}${pathname}` : url.origin;
30};
31
32export class ServiceResolver {
33 private plc: string;
34 private didResolver: CompositeDidDocumentResolver<SupportedDidMethod>;
35 private handleResolver: XrpcHandleResolver;
36 private fetchImpl: typeof fetch;
37 constructor(opts: ServiceResolverOptions = {}) {
38 const plcSource = opts.plcDirectory && opts.plcDirectory.trim() ? opts.plcDirectory : DEFAULT_PLC;
39 const identitySource = opts.identityService && opts.identityService.trim() ? opts.identityService : DEFAULT_IDENTITY_SERVICE;
40 this.plc = normalizeBaseUrl(plcSource);
41 const identityBase = normalizeBaseUrl(identitySource);
42 this.fetchImpl = bindFetch(opts.fetch);
43 const plcResolver = new PlcDidDocumentResolver({ apiUrl: this.plc, fetch: this.fetchImpl });
44 const webResolver = new WebDidDocumentResolver({ fetch: this.fetchImpl });
45 this.didResolver = new CompositeDidDocumentResolver({ methods: { plc: plcResolver, web: webResolver } });
46 this.handleResolver = new XrpcHandleResolver({ serviceUrl: identityBase, fetch: this.fetchImpl });
47 }
48
49 async resolveDidDoc(did: string): Promise<DidDocument> {
50 const trimmed = did.trim();
51 if (!trimmed.startsWith('did:')) throw new Error(`Invalid DID ${did}`);
52 const methodEnd = trimmed.indexOf(':', 4);
53 const method = (methodEnd === -1 ? trimmed.slice(4) : trimmed.slice(4, methodEnd)) as string;
54 if (!SUPPORTED_DID_METHODS.includes(method as SupportedDidMethod)) {
55 throw new Error(`Unsupported DID method ${method ?? '<unknown>'}`);
56 }
57 return this.didResolver.resolve(trimmed as SupportedDid);
58 }
59
60 async pdsEndpointForDid(did: string): Promise<string> {
61 const doc = await this.resolveDidDoc(did);
62 const svc = doc.service?.find(s => s.type === 'AtprotoPersonalDataServer');
63 if (!svc || !svc.serviceEndpoint || typeof svc.serviceEndpoint !== 'string') {
64 throw new Error(`No PDS endpoint in DID doc for ${did}`);
65 }
66 return svc.serviceEndpoint.replace(/\/$/, '');
67 }
68
69 async resolveHandle(handle: string): Promise<string> {
70 const normalized = handle.trim().toLowerCase();
71 if (!normalized) throw new Error('Handle cannot be empty');
72 let slingshotError: Error | undefined;
73 try {
74 const url = new URL('/xrpc/com.atproto.identity.resolveHandle', SLINGSHOT_BASE_URL);
75 url.searchParams.set('handle', normalized);
76 const response = await this.fetchImpl(url);
77 if (response.ok) {
78 const payload = await response.json() as { did?: string } | null;
79 if (payload?.did) {
80 return payload.did;
81 }
82 slingshotError = new Error('Slingshot resolveHandle response missing DID');
83 } else {
84 slingshotError = new Error(`Slingshot resolveHandle failed with status ${response.status}`);
85 const body = response.body;
86 if (body) {
87 body.cancel().catch(() => {});
88 }
89 }
90 } catch (err) {
91 if (err instanceof DOMException && err.name === 'AbortError') throw err;
92 slingshotError = err instanceof Error ? err : new Error(String(err));
93 }
94
95 try {
96 const did = await this.handleResolver.resolve(normalized as Handle);
97 return did;
98 } catch (err) {
99 if (slingshotError && err instanceof Error) {
100 const prior = err.message;
101 err.message = `${prior}; Slingshot resolveHandle failed: ${slingshotError.message}`;
102 }
103 throw err;
104 }
105 }
106}
107
108export interface CreateClientOptions extends ServiceResolverOptions {
109 did?: string; // optional to create a DID-scoped client
110 service?: string; // override service base url
111}
112
113export async function createAtprotoClient(opts: CreateClientOptions = {}) {
114 const fetchImpl = bindFetch(opts.fetch);
115 let service = opts.service;
116 const resolver = new ServiceResolver({ ...opts, fetch: fetchImpl });
117 if (!service && opts.did) {
118 service = await resolver.pdsEndpointForDid(opts.did);
119 }
120 if (!service) throw new Error('service or did required');
121 const normalizedService = normalizeBaseUrl(service);
122 const handler = createSlingshotAwareHandler(normalizedService, fetchImpl);
123 const rpc = new Client({ handler });
124 return { rpc, service: normalizedService, resolver };
125}
126
127export type AtprotoClient = Awaited<ReturnType<typeof createAtprotoClient>>['rpc'];
128
129const SLINGSHOT_RETRY_PATHS = [
130 '/xrpc/com.atproto.repo.getRecord',
131 '/xrpc/com.atproto.identity.resolveHandle',
132];
133
134function createSlingshotAwareHandler(service: string, fetchImpl: typeof fetch): FetchHandler {
135 const primary = simpleFetchHandler({ service, fetch: fetchImpl });
136 const slingshot = simpleFetchHandler({ service: SLINGSHOT_BASE_URL, fetch: fetchImpl });
137 return async (pathname, init) => {
138 const matched = SLINGSHOT_RETRY_PATHS.find(candidate => pathname === candidate || pathname.startsWith(`${candidate}?`));
139 if (matched) {
140 try {
141 const slingshotResponse = await slingshot(pathname, init);
142 if (slingshotResponse.ok) {
143 return slingshotResponse;
144 }
145 const body = slingshotResponse.body;
146 if (body) {
147 body.cancel().catch(() => {});
148 }
149 } catch (err) {
150 if (err instanceof DOMException && err.name === 'AbortError') {
151 throw err;
152 }
153 }
154 }
155 return primary(pathname, init);
156 };
157}
158
159function bindFetch(fetchImpl?: typeof fetch): typeof fetch {
160 const impl = fetchImpl ?? globalThis.fetch;
161 if (typeof impl !== 'function') {
162 throw new Error('fetch implementation not available');
163 }
164 return impl.bind(globalThis);
165}