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