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 console.info('[slingshot] resolveHandle cache hit', { handle: normalized });
81 return payload.did;
82 }
83 slingshotError = new Error('Slingshot resolveHandle response missing DID');
84 console.warn('[slingshot] resolveHandle payload missing DID; falling back', { handle: normalized });
85 } else {
86 slingshotError = new Error(`Slingshot resolveHandle failed with status ${response.status}`);
87 const body = response.body;
88 if (body) {
89 body.cancel().catch(() => {});
90 }
91 console.info('[slingshot] resolveHandle cache miss', { handle: normalized, status: response.status });
92 }
93 } catch (err) {
94 if (err instanceof DOMException && err.name === 'AbortError') throw err;
95 slingshotError = err instanceof Error ? err : new Error(String(err));
96 console.warn('[slingshot] resolveHandle error; falling back to identity service', { handle: normalized, error: slingshotError });
97 }
98
99 try {
100 const did = await this.handleResolver.resolve(normalized as Handle);
101 if (slingshotError) {
102 console.info('[slingshot] resolveHandle fallback succeeded', { handle: normalized });
103 }
104 return did;
105 } catch (err) {
106 if (slingshotError && err instanceof Error) {
107 const prior = err.message;
108 err.message = `${prior}; Slingshot resolveHandle failed: ${slingshotError.message}`;
109 if (slingshotError) {
110 console.warn('[slingshot] resolveHandle fallback failed', { handle: normalized, error: slingshotError });
111 }
112 }
113 throw err;
114 }
115 }
116}
117
118export interface CreateClientOptions extends ServiceResolverOptions {
119 did?: string; // optional to create a DID-scoped client
120 service?: string; // override service base url
121}
122
123export async function createAtprotoClient(opts: CreateClientOptions = {}) {
124 const fetchImpl = bindFetch(opts.fetch);
125 let service = opts.service;
126 const resolver = new ServiceResolver({ ...opts, fetch: fetchImpl });
127 if (!service && opts.did) {
128 service = await resolver.pdsEndpointForDid(opts.did);
129 }
130 if (!service) throw new Error('service or did required');
131 const normalizedService = normalizeBaseUrl(service);
132 const handler = createSlingshotAwareHandler(normalizedService, fetchImpl);
133 const rpc = new Client({ handler });
134 return { rpc, service: normalizedService, resolver };
135}
136
137export type AtprotoClient = Awaited<ReturnType<typeof createAtprotoClient>>['rpc'];
138
139const SLINGSHOT_RETRY_PATHS = [
140 '/xrpc/com.atproto.repo.getRecord',
141 '/xrpc/com.atproto.identity.resolveHandle',
142];
143
144function createSlingshotAwareHandler(service: string, fetchImpl: typeof fetch): FetchHandler {
145 const primary = simpleFetchHandler({ service, fetch: fetchImpl });
146 const slingshot = simpleFetchHandler({ service: SLINGSHOT_BASE_URL, fetch: fetchImpl });
147 return async (pathname, init) => {
148 const matched = SLINGSHOT_RETRY_PATHS.find(candidate => pathname === candidate || pathname.startsWith(`${candidate}?`));
149 if (matched) {
150 try {
151 const slingshotResponse = await slingshot(pathname, init);
152 if (slingshotResponse.ok) {
153 console.info(`[slingshot] cache hit for ${matched}`);
154 return slingshotResponse;
155 }
156 const body = slingshotResponse.body;
157 if (body) {
158 body.cancel().catch(() => {});
159 }
160 console.info(`[slingshot] cache miss ${slingshotResponse.status} for ${matched}, falling back to ${service}`);
161 } catch (err) {
162 if (err instanceof DOMException && err.name === 'AbortError') {
163 throw err;
164 }
165 console.warn(`[slingshot] fetch error for ${matched}, falling back to ${service}`, err);
166 }
167 }
168 return primary(pathname, init);
169 };
170}
171
172function bindFetch(fetchImpl?: typeof fetch): typeof fetch {
173 const impl = fetchImpl ?? globalThis.fetch;
174 if (typeof impl !== 'function') {
175 throw new Error('fetch implementation not available');
176 }
177 return impl.bind(globalThis);
178}