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