243 lines
7.6 KiB
JavaScript
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
|
|
};
|
|
}
|