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 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}