forked from
pds.ls/pdsls
atmosphere explorer
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, string> = {};
82const [labelerCache, setLabelerCache] = createStore<Record<string, string>>({});
83const didDocCache: Record<string, DidDocument> = {};
84const getPDS = async (did: string) => {
85 if (did in didPDSCache) return didPDSCache[did];
86
87 if (!isAtprotoDid(did)) {
88 throw new Error("Not a valid DID identifier");
89 }
90
91 let doc: DidDocument;
92 try {
93 doc = await didDocumentResolver().resolve(did);
94 didDocCache[did] = doc;
95 } catch (e) {
96 console.error(e);
97 throw new Error("Error during did document resolution");
98 }
99
100 const pds = getPdsEndpoint(doc);
101 const labeler = getLabelerEndpoint(doc);
102
103 if (labeler) {
104 setLabelerCache(did, labeler);
105 }
106
107 if (!pds) {
108 throw new Error("No PDS found");
109 }
110
111 return (didPDSCache[did] = pds);
112};
113
114const resolveHandle = async (handle: Handle) => {
115 if (!isHandle(handle)) {
116 throw new Error("Not a valid handle");
117 }
118
119 return await handleResolver.resolve(handle);
120};
121
122const resolveDidDoc = async (did: Did) => {
123 if (!isAtprotoDid(did)) {
124 throw new Error("Not a valid DID identifier");
125 }
126 return await didDocumentResolver().resolve(did);
127};
128
129const validateHandle = async (handle: Handle, did: Did) => {
130 if (!isHandle(handle)) return false;
131
132 let resolvedDid: string;
133 try {
134 resolvedDid = await handleResolver.resolve(handle);
135 } catch (err) {
136 console.error(err);
137 return false;
138 }
139 if (resolvedDid !== did) return false;
140 return true;
141};
142
143const resolveLexiconAuthority = async (nsid: Nsid) => {
144 return await authorityResolver.resolve(nsid);
145};
146
147const resolveLexiconAuthorityDirect = async (authority: string) => {
148 const dohUrl = "https://dns.google/resolve?";
149 const reversedAuthority = authority.split(".").reverse().join(".");
150 const domain = `_lexicon.${reversedAuthority}`;
151 const url = new URL(dohUrl);
152 url.searchParams.set("name", domain);
153 url.searchParams.set("type", "TXT");
154
155 const response = await fetch(url.toString());
156 if (!response.ok) {
157 throw new Error(`Failed to resolve lexicon authority for ${authority}`);
158 }
159
160 const data = await response.json();
161 if (!data.Answer || data.Answer.length === 0) {
162 throw new Error(`No lexicon authority found for ${authority}`);
163 }
164
165 const txtRecord = data.Answer[0].data.replace(/"/g, "");
166
167 if (!txtRecord.startsWith("did=")) {
168 throw new Error(`Invalid lexicon authority record for ${authority}`);
169 }
170
171 return txtRecord.replace("did=", "");
172};
173
174const resolveLexiconSchema = async (authority: AtprotoDid, nsid: Nsid) => {
175 return await schemaResolver().resolve(authority, nsid);
176};
177
178interface LinkData {
179 links: {
180 [key: string]: {
181 [key: string]: {
182 records: number;
183 distinct_dids: number;
184 };
185 };
186 };
187}
188
189type LinksWithRecords = {
190 cursor: string;
191 total: number;
192 linking_records: Array<{ did: string; collection: string; rkey: string }>;
193};
194
195const getConstellation = async (
196 endpoint: string,
197 target: string,
198 collection?: string,
199 path?: string,
200 cursor?: string,
201 limit?: number,
202) => {
203 const url = new URL("https://constellation.microcosm.blue");
204 url.pathname = endpoint;
205 url.searchParams.set("target", target);
206 if (collection) {
207 if (!path) throw new Error("collection and path must either both be set or neither");
208 url.searchParams.set("collection", collection);
209 url.searchParams.set("path", path);
210 } else {
211 if (path) throw new Error("collection and path must either both be set or neither");
212 }
213 if (limit) url.searchParams.set("limit", `${limit}`);
214 if (cursor) url.searchParams.set("cursor", `${cursor}`);
215 const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
216 if (!res.ok) throw new Error("failed to fetch from constellation");
217 return await res.json();
218};
219
220const getAllBacklinks = (target: string) => getConstellation("/links/all", target);
221
222const getRecordBacklinks = (
223 target: string,
224 collection: string,
225 path: string,
226 cursor?: string,
227 limit?: number,
228): Promise<LinksWithRecords> =>
229 getConstellation("/links", target, collection, path, cursor, limit || 100);
230
231export interface HandleResolveResult {
232 success: boolean;
233 did?: string;
234 error?: string;
235}
236
237export const resolveHandleDetailed = async (handle: Handle) => {
238 const dnsResolver = new DohJsonHandleResolver({ dohUrl: "https://dns.google/resolve?" });
239 const httpResolver = new WellKnownHandleResolver({ fetch: handleHttpProxyFetch });
240
241 const tryResolve = async (
242 resolver: DohJsonHandleResolver | WellKnownHandleResolver,
243 timeoutMs: number = 5000,
244 ): Promise<HandleResolveResult> => {
245 try {
246 const timeoutPromise = new Promise<never>((_, reject) =>
247 setTimeout(() => reject(new Error("Request timed out")), timeoutMs),
248 );
249 const did = await Promise.race([resolver.resolve(handle), timeoutPromise]);
250 return { success: true, did };
251 } catch (err: any) {
252 return { success: false, error: err.message ?? String(err) };
253 }
254 };
255
256 const [dns, http] = await Promise.all([tryResolve(dnsResolver), tryResolve(httpResolver)]);
257
258 return { dns, http };
259};
260
261export {
262 didDocCache,
263 getAllBacklinks,
264 getPDS,
265 getRecordBacklinks,
266 labelerCache,
267 resolveDidDoc,
268 resolveHandle,
269 resolveLexiconAuthority,
270 resolveLexiconAuthorityDirect,
271 resolveLexiconSchema,
272 validateHandle,
273 type LinkData,
274 type LinksWithRecords,
275};