A React component library for rendering common AT Protocol records for applications such as Bluesky and Leaflet.
1import type { DidDocument } from "@atcute/identity"; 2import { ServiceResolver } from "./atproto-client"; 3 4interface DidCacheEntry { 5 did: string; 6 handle?: string; 7 doc?: DidDocument; 8 pdsEndpoint?: string; 9 timestamp: number; 10 snapshot?: DidCacheSnapshot; // Memoized snapshot to prevent rerenders 11} 12 13export interface DidCacheSnapshot { 14 did?: string; 15 handle?: string; 16 doc?: DidDocument; 17 pdsEndpoint?: string; 18} 19 20const toSnapshot = ( 21 entry: DidCacheEntry | undefined, 22): DidCacheSnapshot | undefined => { 23 if (!entry) return undefined; 24 // Return memoized snapshot if it exists 25 if (entry.snapshot) return entry.snapshot; 26 // Create and cache new snapshot 27 const { did, handle, doc, pdsEndpoint } = entry; 28 const snapshot = { did, handle, doc, pdsEndpoint }; 29 entry.snapshot = snapshot; 30 return snapshot; 31}; 32 33const derivePdsEndpoint = ( 34 doc: DidDocument | undefined, 35): string | undefined => { 36 if (!doc?.service) return undefined; 37 const svc = doc.service.find( 38 (service) => service.type === "AtprotoPersonalDataServer", 39 ); 40 if (!svc) return undefined; 41 const endpoint = 42 typeof svc.serviceEndpoint === "string" 43 ? svc.serviceEndpoint 44 : undefined; 45 if (!endpoint) return undefined; 46 return endpoint.replace(/\/$/, ""); 47}; 48 49export class DidCache { 50 private byHandle = new Map<string, DidCacheEntry>(); 51 private byDid = new Map<string, DidCacheEntry>(); 52 private handlePromises = new Map<string, Promise<DidCacheSnapshot>>(); 53 private docPromises = new Map<string, Promise<DidCacheSnapshot>>(); 54 private pdsPromises = new Map<string, Promise<DidCacheSnapshot>>(); 55 56 getByHandle(handle: string | undefined): DidCacheSnapshot | undefined { 57 if (!handle) return undefined; 58 return toSnapshot(this.byHandle.get(handle.toLowerCase())); 59 } 60 61 getByDid(did: string | undefined): DidCacheSnapshot | undefined { 62 if (!did) return undefined; 63 return toSnapshot(this.byDid.get(did)); 64 } 65 66 memoize(entry: { 67 did: string; 68 handle?: string; 69 doc?: DidDocument; 70 pdsEndpoint?: string; 71 }): DidCacheSnapshot { 72 const did = entry.did; 73 const normalizedHandle = entry.handle?.toLowerCase(); 74 const existing = 75 this.byDid.get(did) ?? 76 (normalizedHandle 77 ? this.byHandle.get(normalizedHandle) 78 : undefined); 79 80 const doc = entry.doc ?? existing?.doc; 81 const handle = normalizedHandle ?? existing?.handle; 82 const pdsEndpoint = 83 entry.pdsEndpoint ?? 84 derivePdsEndpoint(doc) ?? 85 existing?.pdsEndpoint; 86 87 // Check if data has changed - if not, reuse existing snapshot 88 if ( 89 existing && 90 existing.did === did && 91 existing.handle === handle && 92 existing.doc === doc && 93 existing.pdsEndpoint === pdsEndpoint 94 ) { 95 // Data unchanged, return existing memoized snapshot 96 return toSnapshot(existing) as DidCacheSnapshot; 97 } 98 99 // Data changed, create new entry (snapshot will be created on first access) 100 const merged: DidCacheEntry = { 101 did, 102 handle, 103 doc, 104 pdsEndpoint, 105 timestamp: Date.now(), 106 snapshot: undefined, // Will be created lazily by toSnapshot 107 }; 108 109 this.byDid.set(did, merged); 110 if (handle) { 111 this.byHandle.set(handle, merged); 112 } 113 114 return toSnapshot(merged) as DidCacheSnapshot; 115 } 116 117 ensureHandle( 118 resolver: ServiceResolver, 119 handle: string, 120 ): Promise<DidCacheSnapshot> { 121 const normalized = handle.toLowerCase(); 122 const cached = this.getByHandle(normalized); 123 if (cached?.did) return Promise.resolve(cached); 124 const pending = this.handlePromises.get(normalized); 125 if (pending) return pending; 126 const promise = resolver 127 .resolveHandle(normalized) 128 .then((did) => this.memoize({ did, handle: normalized })) 129 .finally(() => { 130 this.handlePromises.delete(normalized); 131 }); 132 this.handlePromises.set(normalized, promise); 133 return promise; 134 } 135 136 ensureDidDoc( 137 resolver: ServiceResolver, 138 did: string, 139 ): Promise<DidCacheSnapshot> { 140 const cached = this.getByDid(did); 141 if (cached?.doc && cached.handle !== undefined) 142 return Promise.resolve(cached); 143 const pending = this.docPromises.get(did); 144 if (pending) return pending; 145 const promise = resolver 146 .resolveDidDoc(did) 147 .then((doc) => { 148 const aka = doc.alsoKnownAs?.find((a) => a.startsWith("at://")); 149 const handle = aka 150 ? aka.replace("at://", "").toLowerCase() 151 : cached?.handle; 152 return this.memoize({ did, handle, doc }); 153 }) 154 .finally(() => { 155 this.docPromises.delete(did); 156 }); 157 this.docPromises.set(did, promise); 158 return promise; 159 } 160 161 ensurePdsEndpoint( 162 resolver: ServiceResolver, 163 did: string, 164 ): Promise<DidCacheSnapshot> { 165 const cached = this.getByDid(did); 166 if (cached?.pdsEndpoint) return Promise.resolve(cached); 167 const pending = this.pdsPromises.get(did); 168 if (pending) return pending; 169 const promise = (async () => { 170 const docSnapshot = await this.ensureDidDoc(resolver, did).catch( 171 () => undefined, 172 ); 173 if (docSnapshot?.pdsEndpoint) return docSnapshot; 174 const endpoint = await resolver.pdsEndpointForDid(did); 175 return this.memoize({ did, pdsEndpoint: endpoint }); 176 })().finally(() => { 177 this.pdsPromises.delete(did); 178 }); 179 this.pdsPromises.set(did, promise); 180 return promise; 181 } 182} 183 184interface BlobCacheEntry { 185 blob: Blob; 186 timestamp: number; 187} 188 189interface InFlightBlobEntry { 190 promise: Promise<Blob>; 191 abort: () => void; 192 refCount: number; 193} 194 195interface EnsureResult { 196 promise: Promise<Blob>; 197 release: () => void; 198} 199 200export class BlobCache { 201 private store = new Map<string, BlobCacheEntry>(); 202 private inFlight = new Map<string, InFlightBlobEntry>(); 203 204 private key(did: string, cid: string): string { 205 return `${did}::${cid}`; 206 } 207 208 get(did?: string, cid?: string): Blob | undefined { 209 if (!did || !cid) return undefined; 210 return this.store.get(this.key(did, cid))?.blob; 211 } 212 213 set(did: string, cid: string, blob: Blob): void { 214 this.store.set(this.key(did, cid), { blob, timestamp: Date.now() }); 215 } 216 217 ensure( 218 did: string, 219 cid: string, 220 loader: () => { promise: Promise<Blob>; abort: () => void }, 221 ): EnsureResult { 222 const cached = this.get(did, cid); 223 if (cached) { 224 return { promise: Promise.resolve(cached), release: () => {} }; 225 } 226 227 const key = this.key(did, cid); 228 const existing = this.inFlight.get(key); 229 if (existing) { 230 existing.refCount += 1; 231 return { 232 promise: existing.promise, 233 release: () => this.release(key), 234 }; 235 } 236 237 const { promise, abort } = loader(); 238 const wrapped = promise.then((blob) => { 239 this.set(did, cid, blob); 240 return blob; 241 }); 242 243 const entry: InFlightBlobEntry = { 244 promise: wrapped, 245 abort, 246 refCount: 1, 247 }; 248 249 this.inFlight.set(key, entry); 250 251 wrapped 252 .catch(() => {}) 253 .finally(() => { 254 this.inFlight.delete(key); 255 }); 256 257 return { 258 promise: wrapped, 259 release: () => this.release(key), 260 }; 261 } 262 263 private release(key: string) { 264 const entry = this.inFlight.get(key); 265 if (!entry) return; 266 entry.refCount -= 1; 267 if (entry.refCount <= 0) { 268 this.inFlight.delete(key); 269 entry.abort(); 270 } 271 } 272}