From 5b69722f72ee0d0317f8fc739f353c0962ff1c04 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 27 Jan 2026 22:54:31 +0000 Subject: [PATCH] Improve relay selection and fault tolerance for list fetching - Updated `NostrClient.fetchListIncrementally` to prioritize user's NIP-65 read relays, cap concurrent requests, and temporarily mark failing relays as unreachable. - Added `markRelayUnreachable` to `NostrClient` for temporary relay exclusion. - Updated `SubscriptionsManager`, `HashtagPreferencesService`, and `UserBlockListManager` to use `relayManager.getReadRelayUrls()` for selecting relays, ensuring consistency with user preferences. - Added structured logging for relay selection in `fetchListIncrementally`. - Updated unit tests to mock `relayManager` behavior correctly. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/nostr/client.js | 44 +++++++++++++++++++++- js/services/hashtagPreferencesService.js | 23 ++---------- js/subscriptions.js | 33 ++-------------- js/userBlocks.js | 23 ++---------- tests/hashtag-preferences.test.mjs | 14 +++++++ tests/subscriptions-manager.test.mjs | 5 +++ tests/user-blocks.test.mjs | 48 +++++++++++++++++++++--- 7 files changed, 114 insertions(+), 76 deletions(-) diff --git a/js/nostr/client.js b/js/nostr/client.js index a7a7ac56..19c31fb7 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -3580,7 +3580,29 @@ export class NostrClient { ? relayUrls : this.relays; - const normalizedRelays = sanitizeRelayList(this.getHealthyRelays(relaysToUse)); + const healthyCandidates = this.getHealthyRelays(relaysToUse); + const readPreferences = new Set(Array.isArray(this.readRelays) ? this.readRelays : []); + + // Sort relays: prefer user's read relays first + const sortedRelays = healthyCandidates.sort((a, b) => { + const aPreferred = readPreferences.has(a); + const bPreferred = readPreferences.has(b); + if (aPreferred && !bPreferred) return -1; + if (!aPreferred && bPreferred) return 1; + return 0; + }); + + // Cap the number of relays to avoid excessive requests + const cappedRelays = sortedRelays.slice(0, 8); + const normalizedRelays = sanitizeRelayList(cappedRelays); + + if (isDevMode) { + devLogger.log("[fetchListIncrementally] Selected relays:", { + count: normalizedRelays.length, + relays: normalizedRelays, + }); + } + const results = []; const concurrencyLimit = 8; @@ -3706,6 +3728,7 @@ export class NostrClient { } } else { devLogger.warn(`[fetchListIncrementally] Fetch failed for ${relayUrl}`, error); + this.markRelayUnreachable(relayUrl); return { error, ok: false }; } } @@ -4084,6 +4107,25 @@ export class NostrClient { return results; } + markRelayUnreachable(url, ttlMs = 60000) { + if (!url || typeof url !== "string") { + return; + } + const normalized = url.trim(); + if (!normalized) { + return; + } + this.unreachableRelays.add(normalized); + if (ttlMs > 0) { + setTimeout(() => { + this.unreachableRelays.delete(normalized); + if (isDevMode) { + devLogger.debug(`[nostr] Cleared temporary failure mark for ${normalized}`); + } + }, ttlMs); + } + } + getHealthyRelays(candidates) { const source = Array.isArray(candidates) ? candidates : this.relays; if (!this.unreachableRelays.size) { diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 784989f4..0779682d 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -18,6 +18,7 @@ import { userLogger, devLogger } from "../utils/logger.js"; import { normalizeHashtag } from "../utils/hashtagNormalization.js"; import { profileCache } from "../state/profileCache.js"; import { DEFAULT_RELAY_URLS } from "../nostr/toolkit.js"; +import { relayManager } from "../relayManager.js"; const LOG_PREFIX = "[HashtagPreferences]"; const HASHTAG_IDENTIFIER = "bitvid:tag-preferences"; @@ -486,26 +487,8 @@ class HashtagPreferencesService { return; } - const relaySet = new Set(); - const addRelays = (candidate) => { - const sanitized = sanitizeRelayList(candidate); - for (const relay of sanitized) { - relaySet.add(relay); - } - }; - - addRelays(nostrClient.relays); - if (!relaySet.size) { - addRelays(nostrClient.readRelays); - } - if (!relaySet.size) { - addRelays(nostrClient.writeRelays); - } - if (!relaySet.size) { - addRelays(DEFAULT_RELAY_URLS); - } - - const relays = Array.from(relaySet); + const readRelays = relayManager.getReadRelayUrls(); + const relays = readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS); if (!relays.length) { if (wasLoadedForUser) { userLogger.warn( diff --git a/js/subscriptions.js b/js/subscriptions.js index d17f6f08..81b915b7 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -11,6 +11,7 @@ import { subscribeVideoViewEvents, } from "./nostrViewEventsFacade.js"; import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js"; +import { relayManager } from "./relayManager.js"; import { buildSubscriptionListEvent, SUBSCRIPTION_LIST_IDENTIFIER, @@ -429,36 +430,8 @@ class SubscriptionsManager { try { const normalizedUserPubkey = normalizeHexPubkey(userPubkey) || userPubkey; - const relaySet = new Set(); - const addRelayCandidates = (candidates) => { - if (!candidates) { - return; - } - const iterable = Array.isArray(candidates) - ? candidates - : candidates instanceof Set - ? Array.from(candidates) - : []; - for (const candidate of iterable) { - if (typeof candidate !== "string") { - continue; - } - const trimmed = candidate.trim(); - if (trimmed) { - relaySet.add(trimmed); - } - } - }; - - addRelayCandidates(nostrClient.relays); - if (!relaySet.size) { - addRelayCandidates(nostrClient.readRelays); - } - if (!relaySet.size) { - addRelayCandidates(DEFAULT_RELAY_URLS); - } - - const relayUrls = Array.from(relaySet); + const readRelays = relayManager.getReadRelayUrls(); + const relayUrls = readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS); if (!relayUrls.length) { devLogger.warn( "[SubscriptionsManager] No relay URLs available while loading subscriptions.", diff --git a/js/userBlocks.js b/js/userBlocks.js index c3abc42c..ed7062e5 100644 --- a/js/userBlocks.js +++ b/js/userBlocks.js @@ -19,6 +19,7 @@ import { } from "./nostrPublish.js"; import { profileCache } from "./state/profileCache.js"; import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js"; +import { relayManager } from "./relayManager.js"; class TinyEventEmitter { constructor() { @@ -768,26 +769,8 @@ class UserBlockListManager { } }; - const relaySet = new Set(); - const addRelays = (candidate) => { - const sanitized = sanitizeRelayList(candidate); - for (const relay of sanitized) { - relaySet.add(relay); - } - }; - - addRelays(nostrClient.relays); - if (!relaySet.size) { - addRelays(nostrClient.readRelays); - } - if (!relaySet.size) { - addRelays(nostrClient.writeRelays); - } - if (!relaySet.size) { - addRelays(DEFAULT_RELAY_URLS); - } - - const relays = Array.from(relaySet); + const readRelays = relayManager.getReadRelayUrls(); + const relays = readRelays.length > 0 ? readRelays : Array.from(DEFAULT_RELAY_URLS); emitStatus({ status: "loading", relays }); const localBlocks = this._loadLocal(normalized); diff --git a/tests/hashtag-preferences.test.mjs b/tests/hashtag-preferences.test.mjs index a185c99d..bde0acbd 100644 --- a/tests/hashtag-preferences.test.mjs +++ b/tests/hashtag-preferences.test.mjs @@ -10,6 +10,7 @@ const [{ nostrClient }, { setActiveSigner, clearActiveSigner }] = import("../js/nostrClientFacade.js"), import("../js/nostr/client.js"), ]); +const { relayManager } = await import("../js/relayManager.js"); if (typeof globalThis.window === "undefined") { globalThis.window = {}; @@ -22,6 +23,7 @@ const originalRelays = Array.isArray(nostrClient.relays) const originalWriteRelays = Array.isArray(nostrClient.writeRelays) ? [...nostrClient.writeRelays] : nostrClient.writeRelays; +const originalRelayEntries = relayManager.getEntries(); const originalWindowNostr = window.nostr; function restoreNostrClient() { @@ -34,9 +36,14 @@ function restoreNostrClient() { : originalWriteRelays; } +function restoreRelayManager() { + relayManager.setEntries(originalRelayEntries, { allowEmpty: true, updateClient: false }); +} + test.beforeEach(async () => { hashtagPreferences.reset(); restoreNostrClient(); + restoreRelayManager(); window.nostr = originalWindowNostr; // Setup default mock signer to prevent auto-save errors @@ -68,6 +75,7 @@ test.beforeEach(async () => { test.after(() => { restoreNostrClient(); + restoreRelayManager(); window.nostr = originalWindowNostr; clearActiveSigner(); }); @@ -116,6 +124,7 @@ test( }; nostrClient.relays = ["wss://relay.one"]; nostrClient.writeRelays = ["wss://relay.one"]; + relayManager.setEntries([{ url: "wss://relay.one", mode: "both" }], { allowEmpty: false, updateClient: false }); await hashtagPreferences.load(pubkey); @@ -156,6 +165,7 @@ test( }; nostrClient.relays = ["wss://relay.two"]; nostrClient.writeRelays = ["wss://relay.two"]; + relayManager.setEntries([{ url: "wss://relay.two", mode: "both" }], { allowEmpty: false, updateClient: false }); await hashtagPreferences.load(pubkey); @@ -201,6 +211,7 @@ test( }; nostrClient.relays = ["wss://relay.retry"]; nostrClient.writeRelays = ["wss://relay.retry"]; + relayManager.setEntries([{ url: "wss://relay.retry", mode: "both" }], { allowEmpty: false, updateClient: false }); await hashtagPreferences.load(pubkey); @@ -259,6 +270,7 @@ test( }; nostrClient.relays = ["wss://relay.publish"]; nostrClient.writeRelays = ["wss://relay.publish"]; + relayManager.setEntries([{ url: "wss://relay.publish", mode: "both" }], { allowEmpty: false, updateClient: false }); await hashtagPreferences.load(pubkey); hashtagPreferences.addInterest("TagA"); @@ -331,6 +343,7 @@ test( }; nostrClient.relays = ["wss://relay.window"]; nostrClient.writeRelays = ["wss://relay.window"]; + relayManager.setEntries([{ url: "wss://relay.window", mode: "both" }], { allowEmpty: false, updateClient: false }); await hashtagPreferences.load(pubkey); hashtagPreferences.addInterest("WindowTag"); @@ -420,6 +433,7 @@ test( }; nostrClient.relays = ["wss://relay.tie"]; nostrClient.writeRelays = ["wss://relay.tie"]; + relayManager.setEntries([{ url: "wss://relay.tie", mode: "both" }], { allowEmpty: false, updateClient: false }); await hashtagPreferences.load(pubkey); diff --git a/tests/subscriptions-manager.test.mjs b/tests/subscriptions-manager.test.mjs index 5a13b993..183eeb42 100644 --- a/tests/subscriptions-manager.test.mjs +++ b/tests/subscriptions-manager.test.mjs @@ -11,6 +11,7 @@ import nostrService from "../js/services/nostrService.js"; import moderationService from "../js/services/moderationService.js"; import { getNostrEventSchema, NOTE_TYPES } from "../js/nostrEventSchemas.js"; import { profileCache } from "../js/state/profileCache.js"; +import { relayManager } from "../js/relayManager.js"; test("loadSubscriptions aggregates relay results when one rejects", async () => { const SubscriptionsManager = subscriptions.constructor; @@ -39,6 +40,9 @@ test("loadSubscriptions aggregates relay results when one rejects", async () => nostrClient.relays = relayUrls; nostrClient.writeRelays = relayUrls; + const originalRelayEntries = relayManager.getEntries(); + relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false }); + const eventsByRelay = { "wss://relay-a.example": [ { @@ -120,6 +124,7 @@ test("loadSubscriptions aggregates relay results when one rejects", async () => ); } } finally { + relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false }); nostrClient.relays = originalRelays; nostrClient.writeRelays = originalWriteRelays; nostrClient.pool = originalPool; diff --git a/tests/user-blocks.test.mjs b/tests/user-blocks.test.mjs index a48b30de..67d4fbcf 100644 --- a/tests/user-blocks.test.mjs +++ b/tests/user-blocks.test.mjs @@ -8,6 +8,7 @@ const { const { setActiveSigner, clearActiveSigner, getActiveSigner } = await import( "../js/nostr/client.js" ); +const { relayManager } = await import("../js/relayManager.js"); if (typeof globalThis.window === "undefined") { globalThis.window = {}; @@ -33,6 +34,9 @@ await (async () => { "wss://relay-three.example", ]; + const originalRelayEntries = relayManager.getEntries(); + relayManager.setEntries(relays.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false }); + const originalNostr = window.nostr; const originalPool = nostrClient.pool; const originalRelays = Array.isArray(nostrClient.relays) @@ -147,6 +151,7 @@ await (async () => { "decrypted block list should hydrate from newest event" ); } finally { + relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false }); userBlocks.blockedPubkeys = originalBlocked; userBlocks.blockEventId = originalBlockEventId; userBlocks.muteEventId = originalMuteEventId; @@ -170,6 +175,9 @@ await (async () => { "wss://background.example", ]; + const originalRelayEntries = relayManager.getEntries(); + relayManager.setEntries(relays.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false }); + const originalNostr = window.nostr; const originalPool = nostrClient.pool; const originalRelays = Array.isArray(nostrClient.relays) @@ -302,6 +310,7 @@ await (async () => { } finally { unsubscribeStatus?.(); unsubscribeChange?.(); + relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false }); userBlocks.blockedPubkeys = originalBlocked; userBlocks.blockEventId = originalBlockEventId; userBlocks.blockEventCreatedAt = originalBlockEventCreatedAt; @@ -520,7 +529,11 @@ await (async () => { const signedEvents = []; - nostrClient.relays = ["wss://direct-signer.example"]; + const relayUrls = ["wss://direct-signer.example"]; + const originalRelayEntries = relayManager.getEntries(); + relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false }); + + nostrClient.relays = relayUrls; nostrClient.writeRelays = nostrClient.relays; nostrClient.pool = { publish: (_targets, event) => { @@ -662,6 +675,7 @@ await (async () => { "mute list event should include p-tags for blocked pubkeys", ); } finally { + relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false }); userBlocks.blockedPubkeys = originalBlocked; userBlocks.blockEventId = originalBlockEventId; userBlocks.blockEventCreatedAt = originalBlockEventCreatedAt; @@ -866,6 +880,9 @@ await (async () => { clearActiveSigner(); const relayUrls = ["wss://relay-nip44-read.example"]; + const originalRelayEntries = relayManager.getEntries(); + relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false }); + nostrClient.relays = relayUrls; nostrClient.ensureExtensionPermissions = async () => ({ ok: true }); @@ -908,6 +925,7 @@ await (async () => { assert.equal(decryptCalls.nip44, 1, "nip44 decryptor should be preferred"); assert.equal(decryptCalls.nip04, 0, "nip04 decryptor should not be used"); } finally { + relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false }); window.nostr = originalNostr; nostrClient.relays = originalRelays; nostrClient.pool = originalPool; @@ -1088,7 +1106,11 @@ await (async () => { const originalRelays = Array.isArray(nostrClient.relays) ? [...nostrClient.relays] : nostrClient.relays; const originalPool = nostrClient.pool; - nostrClient.relays = ["wss://tag-only.example"]; + const relayUrls = ["wss://tag-only.example"]; + const originalRelayEntries = relayManager.getEntries(); + relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false }); + + nostrClient.relays = relayUrls; nostrClient.pool = { list: async () => [ { @@ -1109,6 +1131,7 @@ await (async () => { assert.equal(manager.blockedPubkeys.size, 1, "should have exactly one blocked pubkey"); assert.equal(manager.blockEventId, "event-tag-only", "should track the correct event ID"); } finally { + relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false }); nostrClient.relays = originalRelays; nostrClient.pool = originalPool; } @@ -1125,7 +1148,11 @@ await (async () => { const originalRelays = Array.isArray(nostrClient.relays) ? [...nostrClient.relays] : nostrClient.relays; const originalPool = nostrClient.pool; - nostrClient.relays = ["wss://tag-only-whitespace.example"]; + const relayUrls = ["wss://tag-only-whitespace.example"]; + const originalRelayEntries = relayManager.getEntries(); + relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false }); + + nostrClient.relays = relayUrls; nostrClient.pool = { list: async () => [ { @@ -1144,6 +1171,7 @@ await (async () => { assert(manager.blockedPubkeys.has(blockedHex), "should load blocked pubkeys from p-tags when content is whitespace"); } finally { + relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false }); nostrClient.relays = originalRelays; nostrClient.pool = originalPool; } @@ -1161,7 +1189,11 @@ await (async () => { const originalRelays = Array.isArray(nostrClient.relays) ? [...nostrClient.relays] : nostrClient.relays; const originalPool = nostrClient.pool; - nostrClient.relays = ["wss://empty-clear.example"]; + const relayUrls = ["wss://empty-clear.example"]; + const originalRelayEntries = relayManager.getEntries(); + relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false }); + + nostrClient.relays = relayUrls; nostrClient.pool = { list: async () => [ { @@ -1180,6 +1212,7 @@ await (async () => { assert.equal(manager.blockedPubkeys.size, 0, "should clear block list when content and tags are empty"); } finally { + relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false }); nostrClient.relays = originalRelays; nostrClient.pool = originalPool; } @@ -1195,7 +1228,11 @@ await (async () => { const originalPool = nostrClient.pool; const originalNostr = window.nostr; - nostrClient.relays = ["wss://repro.example"]; + const relayUrls = ["wss://repro.example"]; + const originalRelayEntries = relayManager.getEntries(); + relayManager.setEntries(relayUrls.map(url => ({ url, mode: "both" })), { allowEmpty: false, updateClient: false }); + + nostrClient.relays = relayUrls; window.nostr = { nip04: { @@ -1238,6 +1275,7 @@ await (async () => { assert.ok(blocked.includes(taggedBlocked), "Should include pubkey from tagged list"); assert.ok(!blocked.includes(plainBlocked), "Should NOT include pubkey from plain list (fallback ignored)"); } finally { + relayManager.setEntries(originalRelayEntries, { allowEmpty: false, updateClient: false }); nostrClient.relays = originalRelays; nostrClient.pool = originalPool; window.nostr = originalNostr;