Files
client/www/js/profile-cache.mjs
T
2026-04-17 16:52:51 -04:00

243 lines
7.6 KiB
JavaScript

/**
* Shared profile cache utilities for pubkey -> kind0 profile resolution.
*/
const DEFAULT_REFRESH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
function normalizePubkey(pubkey) {
const value = String(pubkey || '').trim().toLowerCase();
return /^[a-f0-9]{64}$/i.test(value) ? value : '';
}
export function createProfileCache(options = {}) {
let fetchCachedProfileFn = typeof options.fetchCachedProfile === 'function' ? options.fetchCachedProfile : null;
let ndkFetchEventsFn = typeof options.ndkFetchEvents === 'function' ? options.ndkFetchEvents : null;
let storeProfileFn = typeof options.storeProfile === 'function' ? options.storeProfile : null;
let queryCacheFn = typeof options.queryCache === 'function' ? options.queryCache : null;
let refreshIntervalMs = Number.isFinite(options.refreshIntervalMs)
? Math.max(0, Number(options.refreshIntervalMs))
: DEFAULT_REFRESH_INTERVAL_MS;
const profileCache = new Map();
const pendingProfileFetches = new Map();
const profileRefreshTimes = new Map();
function configure(nextOptions = {}) {
if (typeof nextOptions.fetchCachedProfile === 'function') {
fetchCachedProfileFn = nextOptions.fetchCachedProfile;
}
if (typeof nextOptions.ndkFetchEvents === 'function') {
ndkFetchEventsFn = nextOptions.ndkFetchEvents;
}
if (typeof nextOptions.storeProfile === 'function') {
storeProfileFn = nextOptions.storeProfile;
}
if (typeof nextOptions.queryCache === 'function') {
queryCacheFn = nextOptions.queryCache;
}
if (Number.isFinite(nextOptions.refreshIntervalMs)) {
refreshIntervalMs = Math.max(0, Number(nextOptions.refreshIntervalMs));
}
}
function getCachedProfile(pubkey) {
const normalized = normalizePubkey(pubkey);
if (!normalized) return null;
return profileCache.get(normalized) || null;
}
function seedProfileCache(pubkey, profile) {
const normalized = normalizePubkey(pubkey);
if (!normalized || !profile || typeof profile !== 'object') return;
profileCache.set(normalized, profile);
}
function prewarmProfileCache(profileEvents, options = {}) {
if (!profileEvents || profileEvents.length === 0) return;
const byPubkey = new Map();
for (const evt of profileEvents) {
const pubkey = normalizePubkey(evt?.pubkey);
if (!pubkey) continue;
const existing = byPubkey.get(pubkey);
if (!existing || (evt.created_at || 0) > (existing.created_at || 0)) {
byPubkey.set(pubkey, evt);
}
}
const logPrefix = options.logPrefix || '[profile-cache]';
let warmed = 0;
for (const [pubkey, evt] of byPubkey) {
if (profileCache.has(pubkey)) continue;
try {
let profile = evt.content;
if (typeof profile === 'string' && profile.length > 0) {
profile = JSON.parse(profile);
}
if (profile && typeof profile === 'object') {
profileCache.set(pubkey, profile);
warmed++;
}
} catch (error) {
console.warn(`${logPrefix} Failed to parse profile content for`, pubkey.slice(0, 8), error?.message || error);
}
}
console.log(`${logPrefix} Pre-warmed profile cache with`, warmed, '/', byPubkey.size, 'profiles');
}
async function fetchProfileFromRelays(pubkey, options = {}) {
const normalized = normalizePubkey(pubkey);
if (!normalized) return null;
const logPrefix = options.logPrefix || '[profile-cache]';
if (!ndkFetchEventsFn) {
console.warn(`${logPrefix} ndkFetchEvents not set, cannot fetch profile`);
return null;
}
const allEvents = await ndkFetchEventsFn({
kinds: [0],
authors: [normalized],
limit: 1
});
if (!allEvents || allEvents.length === 0) return null;
const latest = allEvents.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
try {
const profile = JSON.parse(latest?.content || '{}');
if (!profile || typeof profile !== 'object') return null;
profileCache.set(normalized, profile);
if (storeProfileFn) storeProfileFn(normalized, profile);
return profile;
} catch (_) {
return null;
}
}
function refreshProfileFromRelays(pubkey, options = {}) {
const normalized = normalizePubkey(pubkey);
if (!normalized || !ndkFetchEventsFn) return;
const lastRefresh = profileRefreshTimes.get(normalized) || 0;
if (refreshIntervalMs > 0 && (Date.now() - lastRefresh) < refreshIntervalMs) {
return;
}
profileRefreshTimes.set(normalized, Date.now());
const logPrefix = options.logPrefix || '[profile-cache]';
fetchProfileFromRelays(normalized, options)
.then((profile) => {
if (profile) {
console.log(`${logPrefix} Background refresh updated profile for`, `${normalized.slice(0, 8)}…`);
}
})
.catch((error) => {
console.error(`${logPrefix} Background refresh failed for`, `${normalized.slice(0, 8)}…`, error);
});
}
async function fetchProfile(pubkey, options = {}) {
const normalized = normalizePubkey(pubkey);
if (!normalized) return null;
if (profileCache.has(normalized)) {
return profileCache.get(normalized);
}
if (pendingProfileFetches.has(normalized)) {
return pendingProfileFetches.get(normalized);
}
const {
allowNetwork = true,
backgroundRefresh = true,
logPrefix = '[profile-cache]'
} = options;
const fetchPromise = (async () => {
try {
if (fetchCachedProfileFn) {
const cached = await fetchCachedProfileFn(normalized);
if (cached) {
profileCache.set(normalized, cached);
if (backgroundRefresh) {
refreshProfileFromRelays(normalized, { logPrefix });
}
return cached;
}
}
if (!allowNetwork) return null;
return await fetchProfileFromRelays(normalized, { logPrefix });
} catch (error) {
console.error(`${logPrefix} Failed to fetch profile for`, `${normalized.slice(0, 8)}…`, error);
return null;
} finally {
pendingProfileFetches.delete(normalized);
}
})();
pendingProfileFetches.set(normalized, fetchPromise);
return fetchPromise;
}
async function resolveNip05ToPubkey(nip05, options = {}) {
const target = String(nip05 || '').trim().toLowerCase().replace(/^_@/, '');
if (!target || !target.includes('@')) return null;
const {
queryLimit = 800,
relayLimit = 300,
queryCache = queryCacheFn
} = options;
const allProfiles = [];
if (typeof queryCache === 'function') {
try {
const cached = await queryCache({ kinds: [0], limit: queryLimit });
if (Array.isArray(cached)) allProfiles.push(...cached);
} catch (_) {}
}
if (ndkFetchEventsFn) {
try {
const relayEvents = await ndkFetchEventsFn({ kinds: [0], limit: relayLimit });
if (Array.isArray(relayEvents)) allProfiles.push(...relayEvents);
} catch (_) {}
}
for (const evt of allProfiles) {
const pubkey = normalizePubkey(evt?.pubkey);
if (!pubkey || !evt?.content) continue;
try {
const profile = JSON.parse(evt.content);
if (!profile || typeof profile !== 'object') continue;
const candidate = String(profile.nip05 || '').trim().toLowerCase().replace(/^_@/, '');
if (candidate && candidate === target) {
profileCache.set(pubkey, profile);
return pubkey;
}
} catch (_) {}
}
return null;
}
return {
configure,
fetchProfile,
fetchProfileFromRelays,
refreshProfileFromRelays,
prewarmProfileCache,
seedProfileCache,
getCachedProfile,
resolveNip05ToPubkey
};
}