diff --git a/js/app.js b/js/app.js index 804dc5a0..eeeaa2f3 100644 --- a/js/app.js +++ b/js/app.js @@ -275,6 +275,7 @@ class Application { this.dmRelayHints = new Map(); this.authLoadingState = { profile: "idle", lists: "idle", dms: "idle" }; this.relayUiRefreshTimeout = null; + this.lastRelayHealthWarningAt = 0; this.activeIntervals = []; this.torrentStatusIntervalId = null; this.torrentStatusNodes = null; @@ -6567,6 +6568,35 @@ class Application { }); } + checkRelayHealthWarning() { + if (!nostrClient || typeof nostrClient.getHealthyRelays !== "function") { + return false; + } + + const relayCandidates = Array.isArray(nostrClient.relays) ? nostrClient.relays : []; + if (!relayCandidates.length) { + return false; + } + + const healthyRelays = nostrClient.getHealthyRelays(relayCandidates); + if (healthyRelays.length) { + return false; + } + + const now = Date.now(); + const cooldownMs = 30000; + if (now - (this.lastRelayHealthWarningAt || 0) < cooldownMs) { + return true; + } + + this.lastRelayHealthWarningAt = now; + this.showStatus( + "All configured relays are unhealthy. Data may be missing until a relay reconnects.", + { autoHideMs: 12000, showSpinner: false }, + ); + return true; + } + /** * Subscribe to videos (older + new) and render them as they come in. */ @@ -6586,6 +6616,7 @@ class Application { this.loadVideosPromise = (async () => { devLogger.log("Starting loadVideos... (forceFetch =", forceFetch, ")"); this.setFeedTelemetryContext("recent"); + this.checkRelayHealthWarning(); const container = this.mountVideoListView(); const hasCachedVideos = @@ -6641,6 +6672,7 @@ class Application { async loadForYouVideos(forceFetch = false) { devLogger.log("Starting loadForYouVideos... (forceFetch =", forceFetch, ")"); this.setFeedTelemetryContext("for-you"); + this.checkRelayHealthWarning(); const container = this.mountVideoListView({ includeTags: false }); const hasCachedVideos = diff --git a/js/embed.js b/js/embed.js index 9cdcbec3..ed785957 100644 --- a/js/embed.js +++ b/js/embed.js @@ -1,6 +1,7 @@ import Application from "./app.js"; import { nostrClient } from "./nostrClientFacade.js"; import { convertEventToVideo, getDTagValueFromTags } from "./nostr/index.js"; +import { sanitizeRelayList } from "./nostr/nip46Client.js"; import { resolveVideoPointer } from "./utils/videoPointer.js"; import { devLogger, userLogger } from "./utils/logger.js"; import { nostrToolsReady } from "./nostrToolsBootstrap.js"; @@ -180,13 +181,7 @@ const buildRelayList = (relays) => { ...(Array.isArray(nostrClient.relays) ? nostrClient.relays : []), ]; - const result = Array.from( - new Set( - combined - .map((relay) => (typeof relay === "string" ? relay.trim() : "")) - .filter(Boolean) - ) - ); + const result = sanitizeRelayList(combined); diag?.emit("relay-list", { relays: result }); return result; }; diff --git a/js/nostr/client.js b/js/nostr/client.js index 2c964634..245b275c 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -415,6 +415,8 @@ const RELAY_RECONNECT_MAX_DELAY_MS = 60000; const RELAY_RECONNECT_MAX_ATTEMPTS = 5; const RELAY_CIRCUIT_BREAKER_THRESHOLD = 3; const RELAY_CIRCUIT_BREAKER_COOLDOWN_MS = 10 * 60 * 1000; +const RELAY_FAILURE_WINDOW_MS = 5 * 60 * 1000; +const RELAY_FAILURE_WINDOW_THRESHOLD = 3; const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1"; // We use the policy TTL, but currently the storage backend is hardcoded to IDB (with localStorage fallback). // Future refactors should make EventsCacheStore dynamic based on CACHE_POLICIES[NOTE_TYPES.VIDEO_POST].storage. @@ -1294,6 +1296,7 @@ export class NostrClient { this.unreachableRelays = new Set(); this.relayBackoff = new Map(); this.relayFailureCounts = new Map(); + this.relayFailureWindows = new Map(); this.relayCircuitBreakers = new Map(); this.relayReconnectTimer = null; this.relayReconnectAttempt = 0; @@ -4578,10 +4581,30 @@ export class NostrClient { } this.relayBackoff.delete(normalized); this.relayFailureCounts.delete(normalized); + this.relayFailureWindows.delete(normalized); this.unreachableRelays.delete(normalized); this.relayCircuitBreakers.delete(normalized); } + recordRelayFailureWindow(url) { + if (!url || typeof url !== "string") { + return 0; + } + const normalized = url.trim(); + if (!normalized) { + return 0; + } + const now = Date.now(); + const cutoff = now - RELAY_FAILURE_WINDOW_MS; + const history = this.relayFailureWindows.get(normalized) || []; + const filtered = history.filter( + (timestamp) => Number.isFinite(timestamp) && timestamp >= cutoff, + ); + filtered.push(now); + this.relayFailureWindows.set(normalized, filtered); + return filtered.length; + } + markRelayUnreachable(url, ttlMs = 60000, { reason = null } = {}) { if (!url || typeof url !== "string") { return; @@ -4590,6 +4613,7 @@ export class NostrClient { if (!normalized) { return; } + const windowFailureCount = this.recordRelayFailureWindow(normalized); const nextFailureCount = (this.relayFailureCounts.get(normalized) || 0) + 1; this.relayFailureCounts.set(normalized, nextFailureCount); const backoffMs = this.resolveRelayBackoffMs(nextFailureCount, ttlMs); @@ -4601,7 +4625,7 @@ export class NostrClient { reason, }); this.unreachableRelays.add(normalized); - if (nextFailureCount >= RELAY_CIRCUIT_BREAKER_THRESHOLD) { + if (windowFailureCount >= RELAY_FAILURE_WINDOW_THRESHOLD) { const now = Date.now(); const openUntil = now + RELAY_CIRCUIT_BREAKER_COOLDOWN_MS; const existing = this.relayCircuitBreakers.get(normalized); @@ -4609,17 +4633,18 @@ export class NostrClient { existing && Number.isFinite(existing.openUntil) ? Math.max(existing.openUntil, openUntil) : openUntil; + const circuitReason = reason || "windowed-failures"; this.relayCircuitBreakers.set(normalized, { openUntil: nextOpenUntil, failureCount: nextFailureCount, - reason, + reason: circuitReason, }); if (isDevMode) { devLogger.debug("[nostr] Circuit breaker opened for relay.", { relay: normalized, openUntil: nextOpenUntil, failureCount: nextFailureCount, - reason, + reason: circuitReason, }); } } @@ -4644,6 +4669,7 @@ export class NostrClient { } this.relayCircuitBreakers.delete(url); this.relayFailureCounts.delete(url); + this.relayFailureWindows.delete(url); this.unreachableRelays.delete(url); this.relayBackoff.delete(url); if (isDevMode) { @@ -4662,6 +4688,7 @@ export class NostrClient { } this.relayBackoff.delete(url); this.relayFailureCounts.delete(url); + this.relayFailureWindows.delete(url); this.unreachableRelays.delete(url); if (isDevMode) { devLogger.debug(`[nostr] Relay backoff expired for ${url}.`); diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 538ceaf1..ec6c1de1 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -15,6 +15,7 @@ import { assertAnyRelayAccepted, } from "../nostrPublish.js"; import { userLogger, devLogger } from "../utils/logger.js"; +import { sanitizeRelayList } from "../nostr/nip46Client.js"; import { normalizeHashtag } from "../utils/hashtagNormalization.js"; import { profileCache } from "../state/profileCache.js"; import { DEFAULT_RELAY_URLS } from "../nostr/toolkit.js"; @@ -98,14 +99,6 @@ function normalizeHexPubkey(pubkey) { return null; } -function sanitizeRelayList(candidate) { - return Array.isArray(candidate) - ? candidate - .map((url) => (typeof url === "string" ? url.trim() : "")) - .filter(Boolean) - : []; -} - function normalizeEncryptionToken(value) { if (typeof value !== "string") { return ""; diff --git a/js/services/relayHealthService.js b/js/services/relayHealthService.js index 87ed52fc..2bc64750 100644 --- a/js/services/relayHealthService.js +++ b/js/services/relayHealthService.js @@ -132,6 +132,10 @@ class RelayHealthService { if (Number.isFinite(latencyMs)) { state.lastLatencyMs = Math.max(0, Math.round(latencyMs)); } + + if (this.nostrClient && typeof this.nostrClient.clearRelayBackoff === "function") { + this.nostrClient.clearRelayBackoff(relayUrl); + } } recordRelayFailure(relayUrl, error) { @@ -141,6 +145,12 @@ class RelayHealthService { state.lastCheckedAt = Date.now(); state.lastErrorAt = Date.now(); + if (this.nostrClient && typeof this.nostrClient.markRelayUnreachable === "function") { + this.nostrClient.markRelayUnreachable(relayUrl, 60000, { + reason: "relay-health-failed", + }); + } + this.logger.dev.warn("[relayHealth] Relay check failed.", { relayUrl, error, diff --git a/js/subscriptions.js b/js/subscriptions.js index 760cd616..6a61cc8d 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -5,7 +5,7 @@ import { requestDefaultExtensionPermissions, } from "./nostrClientFacade.js"; import { convertEventToVideo as sharedConvertEventToVideo } from "./nostr/index.js"; -import { normalizeNostrPubkey } from "./nostr/nip46Client.js"; +import { normalizeNostrPubkey, sanitizeRelayList } from "./nostr/nip46Client.js"; import { listVideoViewEvents, subscribeVideoViewEvents, @@ -1229,13 +1229,6 @@ class SubscriptionsManager { throw signErr; } - const sanitizeRelayList = (candidate) => - Array.isArray(candidate) - ? candidate - .map((url) => (typeof url === "string" ? url.trim() : "")) - .filter(Boolean) - : []; - const writeRelays = sanitizeRelayList(nostrClient.writeRelays); const fallbackRelays = writeRelays.length ? writeRelays