atmosphere explorer pds.ls
tool typescript atproto
at main 281 lines 7.9 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 { 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};