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}