your personal website on atproto - mirror
blento.app
1import type { ActorIdentifier, Did } from '@atcute/lexicons';
2import { isDid } from '@atcute/lexicons/syntax';
3import type { KVNamespace } from '@cloudflare/workers-types';
4import { getBlentoOrBskyProfile } from '$lib/atproto/methods';
5
6/** TTL in seconds for each cache namespace */
7const NAMESPACE_TTL = {
8 blento: 60 * 60 * 24, // 24 hours
9 identity: 60 * 60 * 24 * 7, // 7 days
10 github: 60 * 60 * 12, // 12 hours
11 'gh-contrib': 60 * 60 * 12, // 12 hours
12 lastfm: 60 * 60, // 1 hour (default, overridable per-put)
13 npmx: 60 * 60 * 12, // 12 hours
14 profile: 60 * 60 * 24, // 24 hours
15 meta: 0 // no auto-expiry
16} as const;
17
18export type CacheNamespace = keyof typeof NAMESPACE_TTL;
19
20export class CacheService {
21 constructor(private kv: KVNamespace) {}
22
23 // === Generic namespaced operations ===
24
25 async get(namespace: CacheNamespace, key: string): Promise<string | null> {
26 return this.kv.get(`${namespace}:${key}`);
27 }
28
29 async put(
30 namespace: CacheNamespace,
31 key: string,
32 value: string,
33 ttlSeconds?: number
34 ): Promise<void> {
35 const ttl = ttlSeconds ?? NAMESPACE_TTL[namespace] ?? 0;
36 await this.kv.put(`${namespace}:${key}`, value, ttl > 0 ? { expirationTtl: ttl } : undefined);
37 }
38
39 async delete(namespace: CacheNamespace, key: string): Promise<void> {
40 await this.kv.delete(`${namespace}:${key}`);
41 }
42
43 async list(namespace: CacheNamespace): Promise<string[]> {
44 const prefix = `${namespace}:`;
45 const result = await this.kv.list({ prefix });
46 return result.keys.map((k) => k.name.slice(prefix.length));
47 }
48
49 // === JSON convenience ===
50
51 async getJSON<T = unknown>(namespace: CacheNamespace, key: string): Promise<T | null> {
52 const raw = await this.get(namespace, key);
53 if (!raw) return null;
54 return JSON.parse(raw) as T;
55 }
56
57 async putJSON(
58 namespace: CacheNamespace,
59 key: string,
60 value: unknown,
61 ttlSeconds?: number
62 ): Promise<void> {
63 await this.put(namespace, key, JSON.stringify(value), ttlSeconds);
64 }
65
66 // === blento data (keyed by DID, with handle↔did resolution) ===
67 async getBlento(identifier: ActorIdentifier): Promise<string | null> {
68 const did = await this.resolveDid(identifier);
69 if (!did) return null;
70 return this.get('blento', did);
71 }
72
73 async putBlento(did: string, handle: string, data: string): Promise<void> {
74 await Promise.all([
75 this.put('blento', did, data),
76 this.put('identity', `h:${handle}`, did),
77 this.put('identity', `d:${did}`, handle)
78 ]);
79 }
80
81 async listBlentos(): Promise<string[]> {
82 return this.list('blento');
83 }
84
85 // === Identity resolution ===
86 async resolveDid(identifier: ActorIdentifier): Promise<string | null> {
87 if (isDid(identifier)) return identifier;
88 return this.get('identity', `h:${identifier}`);
89 }
90
91 async resolveHandle(did: Did): Promise<string | null> {
92 return this.get('identity', `d:${did}`);
93 }
94
95 // === Profile cache (did → profile data) ===
96 async getProfile(did: Did): Promise<CachedProfile> {
97 const cached = await this.getJSON<CachedProfile>('profile', did);
98 if (cached) return cached;
99
100 const profile = await getBlentoOrBskyProfile({ did });
101 const data: CachedProfile = {
102 did: profile.did as string,
103 handle: profile.handle as string,
104 displayName: profile.displayName as string | undefined,
105 avatar: profile.avatar as string | undefined,
106 hasBlento: profile.hasBlento,
107 url: profile.url
108 };
109
110 await this.putJSON('profile', did, data);
111 return data;
112 }
113}
114
115export type CachedProfile = {
116 did: string;
117 handle: string;
118 displayName?: string;
119 avatar?: string;
120 hasBlento: boolean;
121 url?: string;
122};
123
124export function createCache(platform?: App.Platform): CacheService | undefined {
125 const kv = platform?.env?.USER_DATA_CACHE;
126 if (!kv) return undefined;
127 return new CacheService(kv);
128}