atmosphere explorer
pds.ls
tool
typescript
atproto
1import "@atcute/atproto";
2import {
3 type DidDocument,
4 getLabelerEndpoint,
5 getPdsEndpoint,
6 isAtprotoDid,
7} from "@atcute/identity";
8import {
9 AtprotoWebDidDocumentResolver,
10 CompositeDidDocumentResolver,
11 CompositeHandleResolver,
12 DohJsonHandleResolver,
13 PlcDidDocumentResolver,
14 WellKnownHandleResolver,
15} from "@atcute/identity-resolver";
16import { DohJsonLexiconAuthorityResolver, LexiconSchemaResolver } from "@atcute/lexicon-resolver";
17import { Did, Handle } from "@atcute/lexicons";
18import { AtprotoDid, isHandle, Nsid } from "@atcute/lexicons/syntax";
19import { createMemo } from "solid-js";
20import { createStore } from "solid-js/store";
21import { plcDirectory } from "../views/settings";
22
23const proxyFetch = (rewrite: (url: URL) => string): typeof fetch => {
24 return async (input, init) => {
25 try {
26 return await fetch(input, init);
27 } catch (err) {
28 if (init?.signal?.aborted) throw err;
29 const url = new URL(
30 typeof input === "string" ? input
31 : input instanceof URL ? input.href
32 : input.url,
33 );
34 return fetch(rewrite(url));
35 }
36 };
37};
38
39const didWebProxyFetch = proxyFetch(
40 (url) => `/resolve-did-web?host=${encodeURIComponent(url.host)}`,
41);
42const dnsProxyFetch = proxyFetch(
43 (url) =>
44 `/resolve-handle-dns?handle=${encodeURIComponent(url.searchParams.get("name")?.replace("_atproto.", "") ?? "")}`,
45);
46const handleHttpProxyFetch = proxyFetch(
47 (url) => `/resolve-handle-http?handle=${encodeURIComponent(url.host)}`,
48);
49
50export const didDocumentResolver = createMemo(
51 () =>
52 new CompositeDidDocumentResolver({
53 methods: {
54 plc: new PlcDidDocumentResolver({
55 apiUrl: plcDirectory(),
56 }),
57 web: new AtprotoWebDidDocumentResolver({ fetch: didWebProxyFetch }),
58 },
59 }),
60);
61
62export const handleResolver = new CompositeHandleResolver({
63 strategy: "dns-first",
64 methods: {
65 dns: new DohJsonHandleResolver({ dohUrl: "https://dns.google/resolve?", fetch: dnsProxyFetch }),
66 http: new WellKnownHandleResolver({ fetch: handleHttpProxyFetch }),
67 },
68});
69
70const authorityResolver = new DohJsonLexiconAuthorityResolver({
71 dohUrl: "https://dns.google/resolve?",
72});
73
74const schemaResolver = createMemo(
75 () =>
76 new LexiconSchemaResolver({
77 didDocumentResolver: didDocumentResolver(),
78 }),
79);
80
81const didPDSCache: Record<string, Promise<string>> = {};
82const [labelerCache, setLabelerCache] = createStore<Record<string, string>>({});
83const didDocCache: Record<string, DidDocument> = {};
84const getPDS = (did: string): Promise<string> => {
85 if (did in didPDSCache) return didPDSCache[did];
86
87 if (!isAtprotoDid(did)) {
88 return Promise.reject(new Error("Not a valid DID identifier"));
89 }
90
91 didPDSCache[did] = (async () => {
92 let doc: DidDocument;
93 try {
94 doc = await didDocumentResolver().resolve(did);
95 didDocCache[did] = doc;
96 } catch (e) {
97 console.error(e);
98 delete didPDSCache[did];
99 throw new Error("Error during did document resolution");
100 }
101
102 const pds = getPdsEndpoint(doc);
103 const labeler = getLabelerEndpoint(doc);
104
105 if (labeler) {
106 setLabelerCache(did, labeler);
107 }
108
109 if (!pds) {
110 delete didPDSCache[did];
111 throw new Error("No PDS found");
112 }
113
114 return pds;
115 })();
116
117 return didPDSCache[did];
118};
119
120const resolveHandle = async (handle: Handle) => {
121 if (!isHandle(handle)) {
122 throw new Error("Not a valid handle");
123 }
124
125 return await handleResolver.resolve(handle);
126};
127
128const resolveDidDoc = async (did: Did) => {
129 if (!isAtprotoDid(did)) {
130 throw new Error("Not a valid DID identifier");
131 }
132 return await didDocumentResolver().resolve(did);
133};
134
135const validateHandle = async (handle: Handle, did: Did) => {
136 if (!isHandle(handle)) return false;
137
138 let resolvedDid: string;
139 try {
140 resolvedDid = await handleResolver.resolve(handle);
141 } catch (err) {
142 console.error(err);
143 return false;
144 }
145 if (resolvedDid !== did) return false;
146 return true;
147};
148
149const resolveLexiconAuthority = async (nsid: Nsid) => {
150 return await authorityResolver.resolve(nsid);
151};
152
153const resolveLexiconAuthorityDirect = async (authority: string) => {
154 const dohUrl = "https://dns.google/resolve?";
155 const reversedAuthority = authority.split(".").reverse().join(".");
156 const domain = `_lexicon.${reversedAuthority}`;
157 const url = new URL(dohUrl);
158 url.searchParams.set("name", domain);
159 url.searchParams.set("type", "TXT");
160
161 const response = await fetch(url.toString());
162 if (!response.ok) {
163 throw new Error(`Failed to resolve lexicon authority for ${authority}`);
164 }
165
166 const data = await response.json();
167 if (!data.Answer || data.Answer.length === 0) {
168 throw new Error(`No lexicon authority found for ${authority}`);
169 }
170
171 const txtRecord = data.Answer[0].data.replace(/"/g, "");
172
173 if (!txtRecord.startsWith("did=")) {
174 throw new Error(`Invalid lexicon authority record for ${authority}`);
175 }
176
177 return txtRecord.replace("did=", "");
178};
179
180const resolveLexiconSchema = async (authority: AtprotoDid, nsid: Nsid) => {
181 return await schemaResolver().resolve(authority, nsid);
182};
183
184interface LinkData {
185 links: {
186 [key: string]: {
187 [key: string]: {
188 records: number;
189 distinct_dids: number;
190 };
191 };
192 };
193}
194
195type LinksWithRecords = {
196 cursor: string;
197 total: number;
198 linking_records: Array<{ did: string; collection: string; rkey: string }>;
199};
200
201const getConstellation = async (
202 endpoint: string,
203 target: string,
204 collection?: string,
205 path?: string,
206 cursor?: string,
207 limit?: number,
208) => {
209 const url = new URL("https://constellation.microcosm.blue");
210 url.pathname = endpoint;
211 url.searchParams.set("target", target);
212 if (collection) {
213 if (!path) throw new Error("collection and path must either both be set or neither");
214 url.searchParams.set("collection", collection);
215 url.searchParams.set("path", path);
216 } else {
217 if (path) throw new Error("collection and path must either both be set or neither");
218 }
219 if (limit) url.searchParams.set("limit", `${limit}`);
220 if (cursor) url.searchParams.set("cursor", `${cursor}`);
221 const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
222 if (!res.ok) throw new Error("failed to fetch from constellation");
223 return await res.json();
224};
225
226const getAllBacklinks = (target: string) => getConstellation("/links/all", target);
227
228const getRecordBacklinks = (
229 target: string,
230 collection: string,
231 path: string,
232 cursor?: string,
233 limit?: number,
234): Promise<LinksWithRecords> =>
235 getConstellation("/links", target, collection, path, cursor, limit || 100);
236
237export interface HandleResolveResult {
238 success: boolean;
239 did?: string;
240 error?: string;
241}
242
243export const resolveHandleDetailed = async (handle: Handle) => {
244 const dnsResolver = new DohJsonHandleResolver({ dohUrl: "https://dns.google/resolve?" });
245 const httpResolver = new WellKnownHandleResolver({ fetch: handleHttpProxyFetch });
246
247 const tryResolve = async (
248 resolver: DohJsonHandleResolver | WellKnownHandleResolver,
249 timeoutMs: number = 5000,
250 ): Promise<HandleResolveResult> => {
251 try {
252 const timeoutPromise = new Promise<never>((_, reject) =>
253 setTimeout(() => reject(new Error("Request timed out")), timeoutMs),
254 );
255 const did = await Promise.race([resolver.resolve(handle), timeoutPromise]);
256 return { success: true, did };
257 } catch (err: any) {
258 return { success: false, error: err.message ?? String(err) };
259 }
260 };
261
262 const [dns, http] = await Promise.all([tryResolve(dnsResolver), tryResolve(httpResolver)]);
263
264 return { dns, http };
265};
266
267export {
268 didDocCache,
269 getAllBacklinks,
270 getPDS,
271 getRecordBacklinks,
272 labelerCache,
273 resolveDidDoc,
274 resolveHandle,
275 resolveLexiconAuthority,
276 resolveLexiconAuthorityDirect,
277 resolveLexiconSchema,
278 validateHandle,
279 type LinkData,
280 type LinksWithRecords,
281};