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