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