From 35a583160bf3343486893be6a06548b051306d16 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 19 Oct 2025 14:01:40 -0400 Subject: [PATCH] Handle partial relay profile fetch failures --- js/app.js | 69 ++-------- js/utils/profileBatchFetcher.js | 173 ++++++++++++++++++++++++ tests/app-batch-fetch-profiles.test.mjs | 119 ++++++++++++++++ 3 files changed, 301 insertions(+), 60 deletions(-) create mode 100644 js/utils/profileBatchFetcher.js create mode 100644 tests/app-batch-fetch-profiles.test.mjs diff --git a/js/app.js b/js/app.js index 7628279d..6f7d4826 100644 --- a/js/app.js +++ b/js/app.js @@ -87,6 +87,7 @@ import { resolveVideoPointer } from "./utils/videoPointer.js"; import { isValidMagnetUri } from "./utils/magnetValidators.js"; import { dedupeToNewestByRoot } from "./utils/videoDeduper.js"; import { buildServiceWorkerFallbackStatus } from "./utils/serviceWorkerFallbackMessages.js"; +import { batchFetchProfilesFromRelays } from "./utils/profileBatchFetcher.js"; import { getVideoRootIdentifier, applyRootTimestampToVideosMap, @@ -2279,67 +2280,15 @@ class Application { } async batchFetchProfiles(authorSet) { - const pubkeys = Array.from(authorSet); - if (!pubkeys.length) return; - - const toFetch = []; - - pubkeys.forEach((pubkey) => { - const cacheEntry = this.getProfileCacheEntry(pubkey); - if (cacheEntry) { - this.updateProfileInDOM(pubkey, cacheEntry.profile); - } else { - toFetch.push(pubkey); - } + return batchFetchProfilesFromRelays({ + authorSet, + getProfileCacheEntry: (pubkey) => this.getProfileCacheEntry(pubkey), + setProfileCacheEntry: (pubkey, profile) => + this.setProfileCacheEntry(pubkey, profile), + updateProfileInDOM: (pubkey, profile) => + this.updateProfileInDOM(pubkey, profile), + hex64Regex: HEX64_REGEX, }); - - if (!toFetch.length) { - return; - } - - const filter = { - kinds: [0], - authors: toFetch, - limit: toFetch.length, - }; - - try { - // Query each relay - const results = await Promise.all( - nostrClient.relays.map((relayUrl) => - nostrClient.pool.list([relayUrl], [filter]) - ) - ); - const allProfileEvents = results.flat(); - - // Keep only the newest per author - const newestEvents = new Map(); - for (const evt of allProfileEvents) { - if ( - !newestEvents.has(evt.pubkey) || - evt.created_at > newestEvents.get(evt.pubkey).created_at - ) { - newestEvents.set(evt.pubkey, evt); - } - } - - // Update the cache & DOM - for (const [pubkey, evt] of newestEvents.entries()) { - try { - const data = JSON.parse(evt.content); - const profile = { - name: data.name || data.display_name || "Unknown", - picture: data.picture || "assets/svg/default-profile.svg", - }; - this.setProfileCacheEntry(pubkey, profile); - this.updateProfileInDOM(pubkey, profile); - } catch (err) { - devLogger.error("Profile parse error:", err); - } - } - } catch (err) { - devLogger.error("Batch profile fetch error:", err); - } } updateProfileInDOM(pubkey, profile) { diff --git a/js/utils/profileBatchFetcher.js b/js/utils/profileBatchFetcher.js new file mode 100644 index 00000000..8b480c45 --- /dev/null +++ b/js/utils/profileBatchFetcher.js @@ -0,0 +1,173 @@ +// js/utils/profileBatchFetcher.js + +import { nostrClient } from "../nostr.js"; +import { devLogger } from "./logger.js"; + +const DEFAULT_PROFILE_IMAGE = "assets/svg/default-profile.svg"; +const HEX64_REGEX = /^[0-9a-f]{64}$/i; + +export async function batchFetchProfilesFromRelays({ + authorSet, + getProfileCacheEntry, + setProfileCacheEntry, + updateProfileInDOM, + nostr = nostrClient, + logger = devLogger, + hex64Regex = HEX64_REGEX, + defaultProfileImage = DEFAULT_PROFILE_IMAGE, +} = {}) { + if (typeof getProfileCacheEntry !== "function") { + throw new TypeError("getProfileCacheEntry callback is required"); + } + if (typeof setProfileCacheEntry !== "function") { + throw new TypeError("setProfileCacheEntry callback is required"); + } + if (typeof updateProfileInDOM !== "function") { + throw new TypeError("updateProfileInDOM callback is required"); + } + + const pubkeys = Array.isArray(authorSet) + ? authorSet + : Array.from(authorSet || []); + if (!pubkeys.length) { + return; + } + + const toFetch = []; + + pubkeys.forEach((pubkey) => { + const normalizedPubkey = + typeof pubkey === "string" ? pubkey.trim() : ""; + const hasValidPubkey = + normalizedPubkey && hex64Regex.test(normalizedPubkey); + + if (!hasValidPubkey) { + return; + } + + const cacheEntry = getProfileCacheEntry(normalizedPubkey); + if (cacheEntry) { + updateProfileInDOM(normalizedPubkey, cacheEntry.profile); + } else { + toFetch.push(normalizedPubkey); + } + }); + + if (!toFetch.length) { + return; + } + + if (!nostr?.pool || typeof nostr.pool.list !== "function") { + logger.warn( + "[batchFetchProfiles] Relay pool is not ready; skipping profile fetch.", + ); + return; + } + + const filter = { + kinds: [0], + authors: toFetch, + limit: toFetch.length, + }; + + const newestProfiles = new Map(); + + const applyProfileEvent = (evt, relayUrl) => { + if (!evt || typeof evt !== "object") { + return; + } + + const pubkey = + typeof evt.pubkey === "string" && hex64Regex.test(evt.pubkey) + ? evt.pubkey + : null; + if (!pubkey) { + return; + } + + const createdAt = Number.isFinite(evt.created_at) ? evt.created_at : 0; + const previous = newestProfiles.get(pubkey); + if (previous && previous.createdAt >= createdAt) { + return; + } + + try { + const data = JSON.parse(evt.content); + const profile = { + name: data?.name || data?.display_name || "Unknown", + picture: data?.picture || defaultProfileImage, + }; + + newestProfiles.set(pubkey, { createdAt, profile }); + setProfileCacheEntry(pubkey, profile); + updateProfileInDOM(pubkey, profile); + } catch (error) { + logger.warn( + `[batchFetchProfiles] Profile parse error for ${pubkey} from ${relayUrl}:`, + error, + ); + } + }; + + const relayPromises = (Array.isArray(nostr.relays) ? nostr.relays : []).map( + (relayUrl) => + nostr.pool + .list([relayUrl], [filter]) + .then((events) => ({ relayUrl, events })), + ); + + if (!relayPromises.length) { + logger.warn( + "[batchFetchProfiles] No relays configured; skipping profile fetch.", + ); + return; + } + + const results = await Promise.allSettled(relayPromises); + + results.forEach((result, index) => { + const relayUrl = + result.status === "fulfilled" + ? result.value.relayUrl + : nostr.relays[index]; + + if (result.status === "fulfilled") { + const events = Array.isArray(result.value.events) + ? result.value.events + : []; + if (!events.length) { + return; + } + + const newestByPubkey = new Map(); + for (const evt of events) { + if (!evt || typeof evt !== "object") { + continue; + } + const pubkey = + typeof evt.pubkey === "string" && hex64Regex.test(evt.pubkey) + ? evt.pubkey + : null; + if (!pubkey) { + continue; + } + const createdAt = Number.isFinite(evt.created_at) + ? evt.created_at + : 0; + const prior = newestByPubkey.get(pubkey); + if (!prior || createdAt > prior.createdAt) { + newestByPubkey.set(pubkey, { createdAt, event: evt }); + } + } + + newestByPubkey.forEach(({ event }) => { + applyProfileEvent(event, relayUrl); + }); + } else if (relayUrl) { + logger.warn( + `[batchFetchProfiles] Failed to fetch profiles from relay ${relayUrl}:`, + result.reason, + ); + } + }); +} diff --git a/tests/app-batch-fetch-profiles.test.mjs b/tests/app-batch-fetch-profiles.test.mjs new file mode 100644 index 00000000..2dceb811 --- /dev/null +++ b/tests/app-batch-fetch-profiles.test.mjs @@ -0,0 +1,119 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { nostrClient } from "../js/nostr.js"; +import { batchFetchProfilesFromRelays } from "../js/utils/profileBatchFetcher.js"; + +const FAST_RELAY = "wss://fast.example"; +const FAIL_RELAY = "wss://fail.example"; +const VALID_PUBKEY = "a".repeat(64); +const CACHED_PUBKEY = "b".repeat(64); + +function createProfileEvent({ pubkey, createdAt, content }) { + return { + id: `${pubkey}:${createdAt}`, + pubkey, + created_at: createdAt, + content: JSON.stringify(content), + }; +} + +test("batchFetchProfiles handles fast and failing relays", async () => { + const cache = new Map(); + const updates = []; + const setCalls = []; + const authorSet = new Set([ + VALID_PUBKEY, + CACHED_PUBKEY, + "", + null, + undefined, + "npubInvalid", + ]); + + const getProfileCacheEntry = (pubkey) => { + if (pubkey === CACHED_PUBKEY) { + return { profile: { name: "Cached", picture: "cached.png" } }; + } + return cache.get(pubkey) ?? null; + }; + + const setProfileCacheEntry = (pubkey, profile) => { + cache.set(pubkey, { profile }); + setCalls.push({ pubkey, profile }); + }; + + const updateProfileInDOM = (pubkey, profile) => { + updates.push({ pubkey, profile }); + }; + + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : []; + const originalPool = nostrClient.pool; + + const poolListCalls = []; + + nostrClient.relays = [FAST_RELAY, FAIL_RELAY]; + nostrClient.pool = { + list: (relays, filters) => { + poolListCalls.push({ relays, filters }); + const relayUrl = Array.isArray(relays) ? relays[0] : undefined; + if (relayUrl === FAST_RELAY) { + return Promise.resolve([ + createProfileEvent({ + pubkey: VALID_PUBKEY, + createdAt: 200, + content: { name: "Fast", picture: "fast.png" }, + }), + createProfileEvent({ + pubkey: VALID_PUBKEY, + createdAt: 150, + content: { name: "Old", picture: "old.png" }, + }), + ]); + } + if (relayUrl === FAIL_RELAY) { + return Promise.reject(new Error("relay timed out")); + } + return Promise.resolve([]); + }, + }; + + try { + await batchFetchProfilesFromRelays({ + authorSet, + getProfileCacheEntry, + setProfileCacheEntry, + updateProfileInDOM, + }); + } finally { + nostrClient.relays = originalRelays; + nostrClient.pool = originalPool; + } + + assert.equal(poolListCalls.length, 2, "expected a query per relay"); + + const [fastCall] = poolListCalls; + assert.deepEqual( + fastCall.filters[0].authors, + [VALID_PUBKEY], + "should query only uncached, valid pubkeys", + ); + + assert.deepEqual( + updates[0], + { pubkey: CACHED_PUBKEY, profile: { name: "Cached", picture: "cached.png" } }, + "cached profiles should hydrate immediately", + ); + + assert.equal(updates.length, 2, "fast relay should update immediately"); + assert.deepEqual( + updates[1], + { pubkey: VALID_PUBKEY, profile: { name: "Fast", picture: "fast.png" } }, + "newest relay profile should render", + ); + + assert.deepEqual(setCalls, [ + { pubkey: VALID_PUBKEY, profile: { name: "Fast", picture: "fast.png" } }, + ]); +});