diff --git a/js/nostr.js b/js/nostr.js index f8750e1d..419a989e 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -1509,6 +1509,21 @@ class NostrClient { this.watchHistoryRefreshPromises = new Map(); this.watchHistoryCacheTtlMs = WATCH_HISTORY_CACHE_TTL_MS; this.countRequestCounter = 0; + this.countUnsupportedRelays = new Set(); + } + + makeCountUnsupportedError(relayUrl) { + const normalizedUrl = + typeof relayUrl === "string" && relayUrl.trim() + ? relayUrl.trim() + : ""; + const error = new Error( + `[nostr] Relay ${normalizedUrl} does not support COUNT frames.` + ); + error.code = "count-unsupported"; + error.relay = normalizedUrl; + error.unsupported = true; + return error; } restoreLocalData() { @@ -5757,6 +5772,10 @@ class NostrClient { throw new Error("Invalid relay URL for COUNT request."); } + if (this.countUnsupportedRelays.has(normalizedUrl)) { + throw this.makeCountUnsupportedError(normalizedUrl); + } + const normalizedFilters = this.normalizeCountFilters(filters); if (!normalizedFilters.length) { throw new Error("At least one filter is required for a COUNT request."); @@ -5824,9 +5843,8 @@ class NostrClient { } else if (typeof relay.count === "function") { countPromise = relay.count(normalizedFilters, { id: requestId }); } else { - throw new Error( - `[nostr] Relay ${normalizedUrl} does not support COUNT frames.` - ); + this.countUnsupportedRelays.add(normalizedUrl); + throw this.makeCountUnsupportedError(normalizedUrl); } const timeoutMs = this.getRequestTimeoutMs(options.timeoutMs); @@ -5858,8 +5876,29 @@ class NostrClient { ? this.relays : RELAY_URLS; - const perRelayResults = await Promise.all( - relayList.map(async (url) => { + const normalizedRelayList = relayList + .map((url) => (typeof url === "string" ? url.trim() : "")) + .filter(Boolean); + + const activeRelays = []; + const precomputedEntries = []; + + for (const url of normalizedRelayList) { + if (this.countUnsupportedRelays.has(url)) { + const error = this.makeCountUnsupportedError(url); + precomputedEntries.push({ + url, + ok: false, + error, + unsupported: true, + }); + continue; + } + activeRelays.push(url); + } + + const activeResults = await Promise.all( + activeRelays.map(async (url) => { try { const frame = await this.sendRawCountFrame(url, normalizedFilters, { timeoutMs: options.timeoutMs, @@ -5867,14 +5906,31 @@ class NostrClient { const count = this.extractCountValue(frame?.[2]); return { url, ok: true, frame, count }; } catch (error) { - if (isDevMode) { + const isUnsupported = error?.code === "count-unsupported"; + if (isUnsupported) { + this.countUnsupportedRelays.add(url); + } else if (isDevMode) { console.warn(`[nostr] COUNT request failed on ${url}:`, error); } - return { url, ok: false, error }; + return { url, ok: false, error, unsupported: isUnsupported }; } }) ); + const resultsByUrl = new Map(); + for (const entry of [...precomputedEntries, ...activeResults]) { + if (entry && typeof entry.url === "string") { + resultsByUrl.set(entry.url, entry); + } + } + + const perRelayResults = normalizedRelayList.map((url) => { + if (resultsByUrl.has(url)) { + return resultsByUrl.get(url); + } + return { url, ok: false }; + }); + let bestEstimate = null; const perRelay = perRelayResults.map((entry) => { if (!entry || !entry.ok) { diff --git a/tests/nostr-count-fallback.test.mjs b/tests/nostr-count-fallback.test.mjs index fc7a3487..f3490498 100644 --- a/tests/nostr-count-fallback.test.mjs +++ b/tests/nostr-count-fallback.test.mjs @@ -144,8 +144,72 @@ async function testFallbackAfterCountFailureUsesDefaultWindow() { } } +async function testUnsupportedRelaySkipsWarningsAndFallsBack() { + const client = new NostrClient(); + const unsupportedRelay = "wss://unsupported.example"; + client.relays = [unsupportedRelay]; + + let ensureRelayCalls = 0; + client.pool = { + ensureRelay: async (url) => { + ensureRelayCalls += 1; + assert.equal( + url, + unsupportedRelay, + "COUNT helper should target the configured relay" + ); + return {}; + }, + }; + + let listCalls = 0; + client.listVideoViewEvents = async () => { + listCalls += 1; + return []; + }; + + const originalWarn = console.warn; + const warnCalls = []; + console.warn = (...args) => { + warnCalls.push(args); + }; + + try { + const pointer = { type: "e", value: "unsupported-relay" }; + const result = await client.countVideoViewEvents(pointer, {}); + + assert.equal(listCalls, 1, "fallback should query list once when COUNT fails"); + assert.equal(result?.fallback, true, "unsupported relay should trigger fallback"); + assert.ok( + client.countUnsupportedRelays.has(unsupportedRelay), + "unsupported relay should be remembered for future requests" + ); + assert.equal( + warnCalls.length, + 0, + "unsupported relay error should not emit console warnings" + ); + + // Subsequent calls should skip the unsupported relay entirely. + await client.countVideoViewEvents(pointer, {}); + assert.equal( + ensureRelayCalls, + 1, + "unsupported relays should be skipped after the first failure" + ); + assert.equal( + warnCalls.length, + 0, + "no warnings should be emitted for cached unsupported relays" + ); + } finally { + console.warn = originalWarn; + } +} + await testFallbackAddsDefaultSinceWhenPoolMissing(); await testFallbackHonorsCallerOverrides(); await testFallbackAfterCountFailureUsesDefaultWindow(); +await testUnsupportedRelaySkipsWarningsAndFallsBack(); console.log("nostr COUNT fallback tests completed successfully.");