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} 273 274interface RecordCacheEntry<T = unknown> { 275 record: T; 276 timestamp: number; 277} 278 279interface InFlightRecordEntry<T = unknown> { 280 promise: Promise<T>; 281 abort: () => void; 282 refCount: number; 283} 284 285interface RecordEnsureResult<T = unknown> { 286 promise: Promise<T>; 287 release: () => void; 288} 289 290export class RecordCache { 291 private store = new Map<string, RecordCacheEntry>(); 292 private inFlight = new Map<string, InFlightRecordEntry>(); 293 // Collections that should not be cached (e.g., status records that change frequently) 294 private noCacheCollections = new Set<string>([ 295 "fm.teal.alpha.actor.status", 296 "fm.teal.alpha.feed.play", 297 ]); 298 299 private key(did: string, collection: string, rkey: string): string { 300 return `${did}::${collection}::${rkey}`; 301 } 302 303 private shouldCache(collection: string): boolean { 304 return !this.noCacheCollections.has(collection); 305 } 306 307 get<T = unknown>( 308 did?: string, 309 collection?: string, 310 rkey?: string, 311 ): T | undefined { 312 if (!did || !collection || !rkey) return undefined; 313 // Don't return cached data for non-cacheable collections 314 if (!this.shouldCache(collection)) return undefined; 315 return this.store.get(this.key(did, collection, rkey))?.record as 316 | T 317 | undefined; 318 } 319 320 set<T = unknown>( 321 did: string, 322 collection: string, 323 rkey: string, 324 record: T, 325 ): void { 326 // Don't cache records for non-cacheable collections 327 if (!this.shouldCache(collection)) return; 328 this.store.set(this.key(did, collection, rkey), { 329 record, 330 timestamp: Date.now(), 331 }); 332 } 333 334 ensure<T = unknown>( 335 did: string, 336 collection: string, 337 rkey: string, 338 loader: () => { promise: Promise<T>; abort: () => void }, 339 ): RecordEnsureResult<T> { 340 const cached = this.get<T>(did, collection, rkey); 341 if (cached !== undefined) { 342 return { promise: Promise.resolve(cached), release: () => {} }; 343 } 344 345 const key = this.key(did, collection, rkey); 346 const existing = this.inFlight.get(key) as 347 | InFlightRecordEntry<T> 348 | undefined; 349 if (existing) { 350 existing.refCount += 1; 351 return { 352 promise: existing.promise, 353 release: () => this.release(key), 354 }; 355 } 356 357 const { promise, abort } = loader(); 358 const wrapped = promise.then((record) => { 359 this.set(did, collection, rkey, record); 360 return record; 361 }); 362 363 const entry: InFlightRecordEntry<T> = { 364 promise: wrapped, 365 abort, 366 refCount: 1, 367 }; 368 369 this.inFlight.set(key, entry as InFlightRecordEntry); 370 371 wrapped 372 .catch(() => {}) 373 .finally(() => { 374 this.inFlight.delete(key); 375 }); 376 377 return { 378 promise: wrapped, 379 release: () => this.release(key), 380 }; 381 } 382 383 private release(key: string) { 384 const entry = this.inFlight.get(key); 385 if (!entry) return; 386 entry.refCount -= 1; 387 if (entry.refCount <= 0) { 388 this.inFlight.delete(key); 389 entry.abort(); 390 } 391 } 392}