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}