atmosphere explorer pds.ls
tool typescript atproto
at main 233 lines 6.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 { 219 didDocCache, 220 getAllBacklinks, 221 getPDS, 222 getRecordBacklinks, 223 labelerCache, 224 resolveDidDoc, 225 resolveHandle, 226 resolveLexiconAuthority, 227 resolveLexiconAuthorityDirect, 228 resolveLexiconSchema, 229 resolvePDS, 230 validateHandle, 231 type LinkData, 232 type LinksWithRecords, 233};