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