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