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 294 private key(did: string, collection: string, rkey: string): string { 295 return `${did}::${collection}::${rkey}`; 296 } 297 298 get<T = unknown>( 299 did?: string, 300 collection?: string, 301 rkey?: string, 302 ): T | undefined { 303 if (!did || !collection || !rkey) return undefined; 304 return this.store.get(this.key(did, collection, rkey))?.record as 305 | T 306 | undefined; 307 } 308 309 set<T = unknown>( 310 did: string, 311 collection: string, 312 rkey: string, 313 record: T, 314 ): void { 315 this.store.set(this.key(did, collection, rkey), { 316 record, 317 timestamp: Date.now(), 318 }); 319 } 320 321 ensure<T = unknown>( 322 did: string, 323 collection: string, 324 rkey: string, 325 loader: () => { promise: Promise<T>; abort: () => void }, 326 ): RecordEnsureResult<T> { 327 const cached = this.get<T>(did, collection, rkey); 328 if (cached !== undefined) { 329 return { promise: Promise.resolve(cached), release: () => {} }; 330 } 331 332 const key = this.key(did, collection, rkey); 333 const existing = this.inFlight.get(key) as 334 | InFlightRecordEntry<T> 335 | undefined; 336 if (existing) { 337 existing.refCount += 1; 338 return { 339 promise: existing.promise, 340 release: () => this.release(key), 341 }; 342 } 343 344 const { promise, abort } = loader(); 345 const wrapped = promise.then((record) => { 346 this.set(did, collection, rkey, record); 347 return record; 348 }); 349 350 const entry: InFlightRecordEntry<T> = { 351 promise: wrapped, 352 abort, 353 refCount: 1, 354 }; 355 356 this.inFlight.set(key, entry as InFlightRecordEntry); 357 358 wrapped 359 .catch(() => {}) 360 .finally(() => { 361 this.inFlight.delete(key); 362 }); 363 364 return { 365 promise: wrapped, 366 release: () => this.release(key), 367 }; 368 } 369 370 private release(key: string) { 371 const entry = this.inFlight.get(key); 372 if (!entry) return; 373 entry.refCount -= 1; 374 if (entry.refCount <= 0) { 375 this.inFlight.delete(key); 376 entry.abort(); 377 } 378 } 379}