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} 11 12export interface DidCacheSnapshot { 13 did?: string; 14 handle?: string; 15 doc?: DidDocument; 16 pdsEndpoint?: string; 17} 18 19const toSnapshot = (entry: DidCacheEntry | undefined): DidCacheSnapshot | undefined => { 20 if (!entry) return undefined; 21 const { did, handle, doc, pdsEndpoint } = entry; 22 return { did, handle, doc, pdsEndpoint }; 23}; 24 25const derivePdsEndpoint = (doc: DidDocument | undefined): string | undefined => { 26 if (!doc?.service) return undefined; 27 const svc = doc.service.find(service => service.type === 'AtprotoPersonalDataServer'); 28 if (!svc) return undefined; 29 const endpoint = typeof svc.serviceEndpoint === 'string' ? svc.serviceEndpoint : undefined; 30 if (!endpoint) return undefined; 31 return endpoint.replace(/\/$/, ''); 32}; 33 34export class DidCache { 35 private byHandle = new Map<string, DidCacheEntry>(); 36 private byDid = new Map<string, DidCacheEntry>(); 37 private handlePromises = new Map<string, Promise<DidCacheSnapshot>>(); 38 private docPromises = new Map<string, Promise<DidCacheSnapshot>>(); 39 private pdsPromises = new Map<string, Promise<DidCacheSnapshot>>(); 40 41 getByHandle(handle: string | undefined): DidCacheSnapshot | undefined { 42 if (!handle) return undefined; 43 return toSnapshot(this.byHandle.get(handle.toLowerCase())); 44 } 45 46 getByDid(did: string | undefined): DidCacheSnapshot | undefined { 47 if (!did) return undefined; 48 return toSnapshot(this.byDid.get(did)); 49 } 50 51 memoize(entry: { did: string; handle?: string; doc?: DidDocument; pdsEndpoint?: string }): DidCacheSnapshot { 52 const did = entry.did; 53 const normalizedHandle = entry.handle?.toLowerCase(); 54 const existing = this.byDid.get(did) ?? (normalizedHandle ? this.byHandle.get(normalizedHandle) : undefined); 55 56 const doc = entry.doc ?? existing?.doc; 57 const handle = normalizedHandle ?? existing?.handle; 58 const pdsEndpoint = entry.pdsEndpoint ?? derivePdsEndpoint(doc) ?? existing?.pdsEndpoint; 59 60 const merged: DidCacheEntry = { 61 did, 62 handle, 63 doc, 64 pdsEndpoint, 65 timestamp: Date.now(), 66 }; 67 68 this.byDid.set(did, merged); 69 if (handle) { 70 this.byHandle.set(handle, merged); 71 } 72 73 return toSnapshot(merged) as DidCacheSnapshot; 74 } 75 76 ensureHandle(resolver: ServiceResolver, handle: string): Promise<DidCacheSnapshot> { 77 const normalized = handle.toLowerCase(); 78 const cached = this.getByHandle(normalized); 79 if (cached?.did) return Promise.resolve(cached); 80 const pending = this.handlePromises.get(normalized); 81 if (pending) return pending; 82 const promise = resolver 83 .resolveHandle(normalized) 84 .then(did => this.memoize({ did, handle: normalized })) 85 .finally(() => { 86 this.handlePromises.delete(normalized); 87 }); 88 this.handlePromises.set(normalized, promise); 89 return promise; 90 } 91 92 ensureDidDoc(resolver: ServiceResolver, did: string): Promise<DidCacheSnapshot> { 93 const cached = this.getByDid(did); 94 if (cached?.doc && cached.handle !== undefined) return Promise.resolve(cached); 95 const pending = this.docPromises.get(did); 96 if (pending) return pending; 97 const promise = resolver 98 .resolveDidDoc(did) 99 .then(doc => { 100 const aka = doc.alsoKnownAs?.find(a => a.startsWith('at://')); 101 const handle = aka ? aka.replace('at://', '').toLowerCase() : cached?.handle; 102 return this.memoize({ did, handle, doc }); 103 }) 104 .finally(() => { 105 this.docPromises.delete(did); 106 }); 107 this.docPromises.set(did, promise); 108 return promise; 109 } 110 111 ensurePdsEndpoint(resolver: ServiceResolver, did: string): Promise<DidCacheSnapshot> { 112 const cached = this.getByDid(did); 113 if (cached?.pdsEndpoint) return Promise.resolve(cached); 114 const pending = this.pdsPromises.get(did); 115 if (pending) return pending; 116 const promise = (async () => { 117 const docSnapshot = await this.ensureDidDoc(resolver, did).catch(() => undefined); 118 if (docSnapshot?.pdsEndpoint) return docSnapshot; 119 const endpoint = await resolver.pdsEndpointForDid(did); 120 return this.memoize({ did, pdsEndpoint: endpoint }); 121 })().finally(() => { 122 this.pdsPromises.delete(did); 123 }); 124 this.pdsPromises.set(did, promise); 125 return promise; 126 } 127} 128 129interface BlobCacheEntry { 130 blob: Blob; 131 timestamp: number; 132} 133 134interface InFlightBlobEntry { 135 promise: Promise<Blob>; 136 abort: () => void; 137 refCount: number; 138} 139 140interface EnsureResult { 141 promise: Promise<Blob>; 142 release: () => void; 143} 144 145export class BlobCache { 146 private store = new Map<string, BlobCacheEntry>(); 147 private inFlight = new Map<string, InFlightBlobEntry>(); 148 149 private key(did: string, cid: string): string { 150 return `${did}::${cid}`; 151 } 152 153 get(did?: string, cid?: string): Blob | undefined { 154 if (!did || !cid) return undefined; 155 return this.store.get(this.key(did, cid))?.blob; 156 } 157 158 set(did: string, cid: string, blob: Blob): void { 159 this.store.set(this.key(did, cid), { blob, timestamp: Date.now() }); 160 } 161 162 ensure(did: string, cid: string, loader: () => { promise: Promise<Blob>; abort: () => void }): EnsureResult { 163 const cached = this.get(did, cid); 164 if (cached) { 165 return { promise: Promise.resolve(cached), release: () => {} }; 166 } 167 168 const key = this.key(did, cid); 169 const existing = this.inFlight.get(key); 170 if (existing) { 171 existing.refCount += 1; 172 return { 173 promise: existing.promise, 174 release: () => this.release(key), 175 }; 176 } 177 178 const { promise, abort } = loader(); 179 const wrapped = promise.then(blob => { 180 this.set(did, cid, blob); 181 return blob; 182 }); 183 184 const entry: InFlightBlobEntry = { 185 promise: wrapped, 186 abort, 187 refCount: 1, 188 }; 189 190 this.inFlight.set(key, entry); 191 192 wrapped 193 .catch(() => {}) 194 .finally(() => { 195 this.inFlight.delete(key); 196 }); 197 198 return { 199 promise: wrapped, 200 release: () => this.release(key), 201 }; 202 } 203 204 private release(key: string) { 205 const entry = this.inFlight.get(key); 206 if (!entry) return; 207 entry.refCount -= 1; 208 if (entry.refCount <= 0) { 209 this.inFlight.delete(key); 210 entry.abort(); 211 } 212 } 213}