/** * Nostr client - optimized for low bandwidth and efficiency * Features: request throttling, batching, rate limiting, efficient caching */ import { Relay, type Filter, matchFilter, type EventTemplate, type VerifiedEvent } from 'nostr-tools'; import { config } from './config.js'; import type { NostrEvent } from '../../types/nostr.js'; import { cacheEvent, cacheEvents, getEvent, getEventsByKind, getEventsByPubkey } from '../cache/event-cache.js'; import { getDB } from '../cache/indexeddb-store.js'; import { filterEvents, shouldHideEvent } from '../event-filter.js'; import { sessionManager } from '../auth/session-manager.js'; import { KIND } from '../../types/kind-lookup.js'; export interface PublishOptions { relays?: string[]; skipRelayValidation?: boolean; } interface FetchOptions { useCache?: boolean; cacheResults?: boolean; onUpdate?: (events: NostrEvent[]) => void; timeout?: number; } class NostrClient { private initialized = false; private relays: Map = new Map(); private subscriptions: Map = new Map(); private nextSubId = 1; private activeFetches: Map> = new Map(); // Rate limiting and throttling private requestQueue: Array<() => void> = []; private processingQueue = false; private lastRequestTime: Map = new Map(); // relay -> timestamp private activeRequestsPerRelay: Map = new Map(); private readonly MIN_REQUEST_INTERVAL = 200; // 200ms between requests to same relay private readonly MAX_CONCURRENT_PER_RELAY = 1; // Only 1 concurrent request per relay private readonly MAX_CONCURRENT_TOTAL = 3; // Max 3 total concurrent requests private totalActiveRequests = 0; // Track background refresh operations to prevent duplicates private backgroundRefreshes: Set = new Set(); // Failed relay tracking with exponential backoff private failedRelays: Map = new Map(); private readonly INITIAL_RETRY_DELAY = 5000; // 5 seconds private readonly MAX_RETRY_DELAY = 300000; // 5 minutes private readonly MAX_FAILURE_COUNT = 10; // After 10 failures, wait max delay private readonly PERMANENT_FAILURE_THRESHOLD = 20; // After 20 failures, skip relay for this session // Track authenticated relays to avoid re-authenticating private authenticatedRelays: Set = new Set(); async initialize(): Promise { if (this.initialized) return; // Connect to default relays with timeout const connectionPromises = config.defaultRelays.map(async (url) => { try { await Promise.race([ this.addRelay(url), new Promise((_, reject) => setTimeout(() => reject(new Error('Connection timeout')), 10000) ) ]); } catch (error) { // Silently fail - we'll retry later if needed } }); await Promise.allSettled(connectionPromises); this.initialized = true; } /** * Authenticate with a relay using NIP-42 AUTH * Only call this when the relay has sent an AUTH challenge or an operation failed with auth-required */ private async authenticateRelay(relay: Relay, url: string): Promise { // Check if we're already authenticated if (this.authenticatedRelays.has(url)) { return true; } // Check if user is logged in const session = sessionManager.getSession(); if (!session) { console.debug(`[nostr-client] Cannot authenticate with ${url}: user not logged in`); return false; } try { // Check if relay supports AUTH by checking for auth method // nostr-tools Relay.auth() will handle the AUTH flow // Only call this when relay has sent a challenge (handled by nostr-tools) if (typeof (relay as any).auth === 'function') { await relay.auth(async (authEvent: EventTemplate): Promise => { // Ensure authEvent has required fields const eventToSign: Omit = { pubkey: session.pubkey, created_at: authEvent.created_at, kind: authEvent.kind, tags: authEvent.tags || [], content: authEvent.content || '' }; // Sign the auth event using the session signer const signedEvent = await session.signer(eventToSign); // Return as VerifiedEvent (nostr-tools will verify the signature) return signedEvent as VerifiedEvent; }); this.authenticatedRelays.add(url); console.debug(`[nostr-client] Successfully authenticated with relay: ${url}`); return true; } } catch (error) { // Only log if it's not the "no challenge" error (which is expected for relays that don't require auth) const errorMessage = error instanceof Error ? error.message : String(error); if (!errorMessage.includes('no challenge was received')) { console.debug(`[nostr-client] Failed to authenticate with relay ${url}:`, errorMessage); } return false; } return false; } async addRelay(url: string): Promise { if (this.relays.has(url)) return; // Check if this relay has failed too many times - skip permanently for this session const failureInfo = this.failedRelays.get(url); if (failureInfo && failureInfo.failureCount >= this.PERMANENT_FAILURE_THRESHOLD) { // Silently skip - don't throw or log, just return return; } // Check if this relay has failed recently and we should wait if (failureInfo) { const timeSinceFailure = Date.now() - failureInfo.lastFailure; if (timeSinceFailure < failureInfo.retryAfter) { // Still in backoff period, silently skip return; } } try { // Use connection timeout similar to jumble's approach (5 seconds) const relay = await Promise.race([ Relay.connect(url), new Promise((_, reject) => setTimeout(() => reject(new Error('Connection timeout')), 5000) ) ]); this.relays.set(url, relay); // Add connection close handler to automatically clean up closed relays try { const ws = (relay as any).ws; if (ws) { ws.addEventListener('close', () => { // Only log if relay wasn't already marked as permanently failed const failureInfo = this.failedRelays.get(url); if (!failureInfo || failureInfo.failureCount < this.PERMANENT_FAILURE_THRESHOLD) { // Don't log - connection closes are normal } this.relays.delete(url); this.authenticatedRelays.delete(url); }); } } catch (error) { // Ignore errors accessing WebSocket } // Don't proactively authenticate - only authenticate when: // 1. Relay sends an AUTH challenge (handled by nostr-tools automatically) // 2. An operation fails with 'auth-required' error (handled in publish/subscribe methods) // Clear failure tracking on successful connection this.failedRelays.delete(url); // Don't log successful connections - too verbose } catch (error) { // Track the failure but don't throw - allow graceful degradation like jumble const existingFailure = this.failedRelays.get(url) || { lastFailure: 0, retryAfter: this.INITIAL_RETRY_DELAY, failureCount: 0 }; const failureCount = existingFailure.failureCount + 1; // For connection refused errors, use longer backoff to avoid spam const errorMessage = error instanceof Error ? error.message : String(error); const isConnectionRefused = errorMessage.includes('CONNECTION_REFUSED') || errorMessage.includes('connection refused') || errorMessage.includes('Connection refused'); // Use longer initial delay for connection refused errors const initialDelay = isConnectionRefused ? this.INITIAL_RETRY_DELAY * 2 : this.INITIAL_RETRY_DELAY; const retryAfter = Math.min( initialDelay * Math.pow(2, Math.min(failureCount - 1, 6)), // Exponential backoff, max 2^6 = 64x this.MAX_RETRY_DELAY ); this.failedRelays.set(url, { lastFailure: Date.now(), retryAfter, failureCount }); // Only log warnings for persistent failures to reduce console noise // Connection failures are expected and normal, so we don't log every attempt if (failureCount >= this.PERMANENT_FAILURE_THRESHOLD) { // Only log once when threshold is reached if (failureCount === this.PERMANENT_FAILURE_THRESHOLD) { console.warn(`[nostr-client] Relay ${url} has failed ${failureCount} times, will be skipped for this session`); } } else if (failureCount === 10) { // Log once at 10 failures as a warning console.warn(`[nostr-client] Relay ${url} has failed ${failureCount} times, will stop retrying after ${this.PERMANENT_FAILURE_THRESHOLD} failures`); } // Don't throw - allow graceful degradation like jumble does // The caller can check if relay was added by checking this.relays.has(url) } } async removeRelay(url: string): Promise { const relay = this.relays.get(url); if (relay) { try { relay.close(); } catch (error) { // Ignore } this.relays.delete(url); // Remove from authenticated relays when disconnecting this.authenticatedRelays.delete(url); } } private checkAndCleanupRelay(relayUrl: string): boolean { const relay = this.relays.get(relayUrl); if (!relay) return false; // Check relay connection status // Status values: 0 = CONNECTING, 1 = OPEN, 2 = CLOSING, 3 = CLOSED const status = (relay as any).status; if (status === 3 || status === 2) { // Relay is closed or closing, remove it silently this.relays.delete(relayUrl); this.authenticatedRelays.delete(relayUrl); return false; } // Check if relay has a connection property and if it's closed try { const ws = (relay as any).ws; if (ws && (ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING)) { console.debug(`[nostr-client] Relay ${relayUrl} WebSocket is closed, removing from active relays`); this.relays.delete(relayUrl); this.authenticatedRelays.delete(relayUrl); return false; } } catch (error) { // If we can't check the WebSocket, assume it's still valid } return true; } /** * Get a relay instance by URL * Will connect if not already connected * Returns null if connection fails (graceful degradation like jumble) */ async getRelay(url: string): Promise { // Ensure relay is connected if (!this.relays.has(url)) { // addRelay doesn't throw on failure, it just doesn't add the relay await this.addRelay(url); // Check if relay was actually added if (!this.relays.has(url)) { console.debug(`[nostr-client] Failed to connect to relay ${url}, skipping gracefully`); return null; } } return this.relays.get(url) || null; } private shouldFilterZapReceipt(event: NostrEvent): boolean { if (event.kind !== KIND.ZAP_RECEIPT) return false; const amountTag = event.tags.find((t) => t[0] === 'amount'); if (!amountTag || !amountTag[1]) return true; const amount = parseInt(amountTag[1], 10); return isNaN(amount) || amount < config.zapThreshold; } private addToCache(event: NostrEvent): void { if (this.shouldFilterZapReceipt(event)) return; cacheEvent(event).catch(() => { // Silently fail }); } private async getCachedEvents(filters: Filter[]): Promise { try { const results: NostrEvent[] = []; const seen = new Set(); for (const filter of filters) { try { let candidateEvents: NostrEvent[] = []; // Prioritize ID queries when IDs are specified (more efficient than querying by kind) if (filter.ids && filter.ids.length > 0 && filter.ids.length <= 10) { // For small number of IDs, query directly by ID const idEvents: NostrEvent[] = []; for (const id of filter.ids) { const event = await getEvent(id); if (event) { idEvents.push(event); } } candidateEvents = idEvents; } else if (filter.kinds && filter.kinds.length > 0) { // Query by kind(s) if specified // If single kind, use index for efficiency if (filter.kinds.length === 1) { candidateEvents = await getEventsByKind(filter.kinds[0], (filter.limit || 100) * 3); } else { // Multiple kinds - query each and combine const allEvents: NostrEvent[] = []; for (const kind of filter.kinds) { const kindEvents = await getEventsByKind(kind, (filter.limit || 100) * 3); allEvents.push(...kindEvents); } candidateEvents = allEvents; } } else if (filter.authors && filter.authors.length > 0) { // Query by author(s) if no kinds specified if (filter.authors.length === 1) { candidateEvents = await getEventsByPubkey(filter.authors[0], (filter.limit || 100) * 3); } else { // Multiple authors - query each and combine const allEvents: NostrEvent[] = []; for (const author of filter.authors) { const authorEvents = await getEventsByPubkey(author, (filter.limit || 100) * 3); allEvents.push(...authorEvents); } candidateEvents = allEvents; } } else { // No specific kind or author - get recent events by created_at // This is a fallback for broad queries try { const db = await getDB(); const tx = db.transaction('events', 'readonly'); const index = tx.store.index('created_at'); const events: NostrEvent[] = []; let count = 0; const limit = (filter.limit || 100) * 3; // Iterate in reverse (newest first) let cursor = await index.openCursor(null, 'prev'); while (cursor && count < limit) { events.push(cursor.value as NostrEvent); count++; cursor = await cursor.continue(); } await tx.done; candidateEvents = events; } catch (dbError) { console.error('[nostr-client] Error querying IndexedDB for recent events:', dbError); candidateEvents = []; } } // Filter candidates by all filter criteria const queriedByIds = filter.ids && filter.ids.length > 0 && filter.ids.length <= 10; let beforeFilter = candidateEvents.length; for (const event of candidateEvents) { if (seen.has(event.id)) continue; // Apply since/until filters if (filter.since && event.created_at < filter.since) continue; if (filter.until && event.created_at > filter.until) continue; // Apply ids filter (skip if we already queried by IDs) if (!queriedByIds && filter.ids && filter.ids.length > 0 && !filter.ids.includes(event.id)) continue; // Apply authors filter (if not already used for query) if (filter.authors && filter.authors.length > 0 && !filter.authors.includes(event.pubkey)) continue; // Apply kinds filter (if not already used for query) if (filter.kinds && filter.kinds.length > 0 && !filter.kinds.includes(event.kind)) continue; // Apply tag filters (#e, #E, #p, #P, #a, #A, #d, etc.) // Handle both uppercase and lowercase tag filters (Nostr spec allows both) // Ignore relays that don't support the tag filter, they need to be corrected by the relay operator. if (filter['#e'] && filter['#e'].length > 0) { const eventTags = event.tags.filter(t => (t[0] === 'e' || t[0] === 'E') && t[1]).map(t => t[1]); if (eventTags.length === 0 || !filter['#e'].some(id => eventTags.includes(id))) continue; } if (filter['#E'] && filter['#E'].length > 0) { const eventTags = event.tags.filter(t => (t[0] === 'e' || t[0] === 'E') && t[1]).map(t => t[1]); if (eventTags.length === 0 || !filter['#E'].some(id => eventTags.includes(id))) continue; } if (filter['#p'] && filter['#p'].length > 0) { const pubkeyTags = event.tags.filter(t => (t[0] === 'p' || t[0] === 'P') && t[1]).map(t => t[1]); if (pubkeyTags.length === 0 || !filter['#p'].some(pk => pubkeyTags.includes(pk))) continue; } if (filter['#P'] && filter['#P'].length > 0) { const pubkeyTags = event.tags.filter(t => (t[0] === 'p' || t[0] === 'P') && t[1]).map(t => t[1]); if (pubkeyTags.length === 0 || !filter['#P'].some(pk => pubkeyTags.includes(pk))) continue; } if (filter['#a'] && filter['#a'].length > 0) { const aTags = event.tags.filter(t => (t[0] === 'a' || t[0] === 'A') && t[1]).map(t => t[1]); if (aTags.length === 0 || !filter['#a'].some(a => aTags.includes(a))) continue; } if (filter['#A'] && filter['#A'].length > 0) { const aTags = event.tags.filter(t => (t[0] === 'a' || t[0] === 'A') && t[1]).map(t => t[1]); if (aTags.length === 0 || !filter['#A'].some(a => aTags.includes(a))) continue; } if (filter['#d'] && filter['#d'].length > 0) { const dTags = event.tags.filter(t => (t[0] === 'd' || t[0] === 'D') && t[1]).map(t => t[1]); if (dTags.length === 0 || !filter['#d'].some(d => dTags.includes(d))) continue; } if (filter['#q'] && filter['#q'].length > 0) { const qTags = event.tags.filter(t => (t[0] === 'q' || t[0] === 'Q') && t[1]).map(t => t[1]); if (qTags.length === 0 || !filter['#q'].some(q => qTags.includes(q))) continue; } if (filter['#Q'] && filter['#Q'].length > 0) { const qTags = event.tags.filter(t => (t[0] === 'q' || t[0] === 'Q') && t[1]).map(t => t[1]); if (qTags.length === 0 || !filter['#Q'].some(q => qTags.includes(q))) continue; } // Use matchFilter for final validation if (matchFilter(filter, event)) { results.push(event); seen.add(event.id); } } } catch (error) { // Continue with other filters console.error('[nostr-client] Error querying cache for filter:', error, filter); } } // Sort by created_at descending and apply limit const sorted = results.sort((a, b) => b.created_at - a.created_at); const limit = filters[0]?.limit || 100; const limited = sorted.slice(0, limit); const filtered = filterEvents(limited); // Only log cache queries at debug level to reduce console noise // Only log if we got multiple events or if it's an interesting query if (filtered.length > 5 || (filtered.length > 0 && limited.length > filtered.length * 2)) { console.debug(`[nostr-client] Cache query: ${limited.length} events before filter, ${filtered.length} after filter`); } return filtered; } catch (error) { console.error('[nostr-client] Error getting cached events:', error); return []; } } async publish(event: NostrEvent, options: PublishOptions = {}): Promise<{ success: string[]; failed: Array<{ relay: string; error: string }>; }> { const relays = options.relays || Array.from(this.relays.keys()); const results = { success: [] as string[], failed: [] as Array<{ relay: string; error: string }> }; this.addToCache(event); for (const url of relays) { const relay = this.relays.get(url); if (!relay) { // addRelay doesn't throw on failure, it just doesn't add the relay (graceful degradation like jumble) await this.addRelay(url); const newRelay = this.relays.get(url); if (newRelay && this.checkAndCleanupRelay(url)) { try { await newRelay.publish(event); results.success.push(url); } catch (error) { // Check if error is auth-required if (error instanceof Error && error.message.startsWith('auth-required')) { // Try to authenticate and retry const authSuccess = await this.authenticateRelay(newRelay, url); if (authSuccess) { try { await newRelay.publish(event); results.success.push(url); } catch (retryError) { results.failed.push({ relay: url, error: retryError instanceof Error ? retryError.message : 'Unknown error' }); } } else { results.failed.push({ relay: url, error: 'Authentication required but failed' }); } } else { // Check if it's a closed connection error const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.includes('SendingOnClosedConnection') || errorMessage.includes('closed connection')) { console.debug(`[nostr-client] Relay ${url} connection closed during publish, removing from active relays`); this.relays.delete(url); this.authenticatedRelays.delete(url); } results.failed.push({ relay: url, error: error instanceof Error ? error.message : 'Unknown error' }); } } } else { // Relay connection failed (addRelay didn't add it) results.failed.push({ relay: url, error: 'Failed to connect' }); } } else { // Check relay status before publishing if (!this.checkAndCleanupRelay(url)) { results.failed.push({ relay: url, error: 'Relay connection closed' }); continue; } try { await relay.publish(event); results.success.push(url); } catch (error) { // Check if error is auth-required if (error instanceof Error && error.message.startsWith('auth-required')) { // Try to authenticate and retry const authSuccess = await this.authenticateRelay(relay, url); if (authSuccess) { try { await relay.publish(event); results.success.push(url); } catch (retryError) { results.failed.push({ relay: url, error: retryError instanceof Error ? retryError.message : 'Unknown error' }); } } else { results.failed.push({ relay: url, error: 'Authentication required but failed' }); } } else { // Check if it's a closed connection error const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.includes('SendingOnClosedConnection') || errorMessage.includes('closed connection')) { console.debug(`[nostr-client] Relay ${url} connection closed during publish, removing from active relays`); this.relays.delete(url); this.authenticatedRelays.delete(url); } results.failed.push({ relay: url, error: error instanceof Error ? error.message : 'Unknown error' }); } } } } return results; } subscribe( filters: Filter[], relays: string[], onEvent: (event: NostrEvent, relay: string) => void, onEose?: (relay: string) => void ): string { const subId = `sub_${this.nextSubId++}_${Date.now()}`; for (const url of relays) { if (!this.relays.has(url)) { // Check if relay should be skipped before attempting connection const failureInfo = this.failedRelays.get(url); if (failureInfo && failureInfo.failureCount >= this.PERMANENT_FAILURE_THRESHOLD) { // Skip permanently failed relays silently continue; } // Check if relay failed recently and is still in backoff period if (failureInfo) { const timeSinceFailure = Date.now() - failureInfo.lastFailure; if (timeSinceFailure < failureInfo.retryAfter) { // Still in backoff period, skip this attempt continue; } } // addRelay doesn't throw on failure, it just doesn't add the relay (graceful degradation like jumble) this.addRelay(url).then(() => { const newRelay = this.relays.get(url); if (newRelay) { this.setupSubscription(newRelay, url, subId, filters, onEvent, onEose); } }).catch(() => { // Ignore errors - addRelay handles failures gracefully }); continue; } const relay = this.relays.get(url); if (!relay) continue; // Check relay status before setting up subscription if (!this.checkAndCleanupRelay(url)) { // Relay is closed, skip silently continue; } try { this.setupSubscription(relay, url, subId, filters, onEvent, onEose); } catch (error) { // Handle errors - setupSubscription already handles closed connection errors const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.includes('SendingOnClosedConnection') || errorMessage.includes('closed connection')) { console.debug(`[nostr-client] Relay ${url} connection closed, removing from active relays`); this.relays.delete(url); this.authenticatedRelays.delete(url); } } } return subId; } private setupSubscription( relay: Relay, url: string, subId: string, filters: Filter[], onEvent: (event: NostrEvent, relay: string) => void, onEose?: (relay: string) => void ): void { if (!this.relays.has(url)) return; // Check relay status before setting up subscription if (!this.checkAndCleanupRelay(url)) { console.debug(`[nostr-client] Relay ${url} is closed, skipping subscription setup`); return; } try { const client = this; let hasAuthed = this.authenticatedRelays.has(url); const startSub = () => { // Check relay status again before subscribing if (!client.checkAndCleanupRelay(url)) { console.debug(`[nostr-client] Relay ${url} closed before subscription, aborting`); return; } const sub = relay.subscribe(filters, { onevent: (event: NostrEvent) => { try { if (!client.relays.has(url)) return; if (client.shouldFilterZapReceipt(event)) return; client.addToCache(event); onEvent(event, url); } catch (err) { // Silently handle errors - connection may be closed } }, oneose: () => { try { if (!client.relays.has(url)) return; onEose?.(url); } catch (err) { // Silently handle errors - connection may be closed } }, onclose: (reason: string) => { // Handle auth-required on subscription close if (reason.startsWith('auth-required') && !hasAuthed) { const session = sessionManager.getSession(); if (session) { client.authenticateRelay(relay, url) .then((authSuccess) => { if (authSuccess) { hasAuthed = true; // Retry subscription after authentication startSub(); } }) .catch(() => { // Authentication failed, give up }); } } } }); client.subscriptions.set(`${url}_${subId}`, { relay, sub }); }; startSub(); } catch (error) { // Handle SendingOnClosedConnection and other errors const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.includes('SendingOnClosedConnection') || errorMessage.includes('closed connection')) { console.debug(`[nostr-client] Relay ${url} connection closed during subscription setup, removing from active relays`); this.relays.delete(url); this.authenticatedRelays.delete(url); } } } unsubscribe(subId: string): void { for (const [key, { sub }] of this.subscriptions.entries()) { if (key.endsWith(`_${subId}`)) { try { // Try to close subscription - may fail if connection already closed if (sub && typeof sub.close === 'function') { sub.close(); } } catch (error) { // Silently handle errors when closing subscriptions // Connection may already be closed, which is fine } this.subscriptions.delete(key); } } } // Throttled request to a relay private async throttledRelayRequest( relayUrl: string, filters: Filter[], events: Map, timeout: number ): Promise { return new Promise((resolve) => { const makeRequest = () => { const now = Date.now(); const lastRequest = this.lastRequestTime.get(relayUrl) || 0; const timeSinceLastRequest = now - lastRequest; const activeForRelay = this.activeRequestsPerRelay.get(relayUrl) || 0; // Check if we can make the request now if (timeSinceLastRequest >= this.MIN_REQUEST_INTERVAL && activeForRelay < this.MAX_CONCURRENT_PER_RELAY && this.totalActiveRequests < this.MAX_CONCURRENT_TOTAL) { // Update tracking this.lastRequestTime.set(relayUrl, now); this.activeRequestsPerRelay.set(relayUrl, activeForRelay + 1); this.totalActiveRequests++; // Make the request this.makeRelayRequest(relayUrl, filters, events, timeout) .finally(() => { const current = this.activeRequestsPerRelay.get(relayUrl) || 0; if (current > 0) { this.activeRequestsPerRelay.set(relayUrl, current - 1); } if (this.totalActiveRequests > 0) { this.totalActiveRequests--; } resolve(); this.processQueue(); // Process next in queue }); } else { // Wait and retry const waitTime = Math.max( this.MIN_REQUEST_INTERVAL - timeSinceLastRequest, 100 ); setTimeout(makeRequest, waitTime); } }; makeRequest(); }); } private async makeRelayRequest( relayUrl: string, filters: Filter[], events: Map, timeout: number ): Promise { const relay = this.relays.get(relayUrl); if (!relay || !this.checkAndCleanupRelay(relayUrl)) { return; } const subId = `sub_${this.nextSubId++}_${Date.now()}`; let resolved = false; let timeoutId: ReturnType | null = null; let hasAuthed = this.authenticatedRelays.has(relayUrl); const finish = () => { if (resolved) return; resolved = true; if (timeoutId) clearTimeout(timeoutId); this.unsubscribe(subId); }; const startSub = () => { try { // Check relay status before subscribing if (!this.checkAndCleanupRelay(relayUrl)) { console.debug(`[nostr-client] Relay ${relayUrl} is closed, skipping subscription`); finish(); return; } const client = this; const sub = relay.subscribe(filters, { onevent: (event: NostrEvent) => { try { if (!client.relays.has(relayUrl)) return; if (shouldHideEvent(event)) return; if (client.shouldFilterZapReceipt(event)) return; events.set(event.id, event); client.addToCache(event); } catch (error) { // Silently handle errors - connection may be closed } }, oneose: () => { try { if (!resolved) finish(); } catch (error) { // Silently handle errors - connection may be closed } }, onclose: (reason: string) => { // Handle auth-required on subscription close if (reason.startsWith('auth-required') && !hasAuthed && !resolved) { const session = sessionManager.getSession(); if (session) { client.authenticateRelay(relay, relayUrl) .then((authSuccess) => { if (authSuccess) { hasAuthed = true; // Retry subscription after authentication if (!resolved) { startSub(); } } else { finish(); } }) .catch(() => { finish(); }); } else { finish(); } } } }); client.subscriptions.set(`${relayUrl}_${subId}`, { relay, sub }); timeoutId = setTimeout(() => { if (!resolved) finish(); }, timeout); } catch (error) { // Handle SendingOnClosedConnection and other errors const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.includes('SendingOnClosedConnection') || errorMessage.includes('closed connection')) { console.debug(`[nostr-client] Relay ${relayUrl} connection closed, removing from active relays`); this.relays.delete(relayUrl); this.authenticatedRelays.delete(relayUrl); } finish(); } }; startSub(); } private processQueue(): void { if (this.processingQueue || this.requestQueue.length === 0) return; this.processingQueue = true; const next = this.requestQueue.shift(); if (next) { next(); } this.processingQueue = false; } async fetchEvents( filters: Filter[], relays: string[], options: FetchOptions = {} ): Promise { const { useCache = true, cacheResults = true, onUpdate, timeout = 10000 } = options; // Create a key for this fetch to prevent duplicates const fetchKey = JSON.stringify({ filters, relays: relays.sort() }); const activeFetch = this.activeFetches.get(fetchKey); if (activeFetch) { return activeFetch; } // Query cache first if (useCache) { try { const cachedEvents = await this.getCachedEvents(filters); if (cachedEvents.length > 0) { console.debug(`[nostr-client] Returning ${cachedEvents.length} cached events for filter:`, filters); // Return cached immediately, fetch fresh in background with delay // Don't pass onUpdate to background fetch to avoid interfering with cached results if (cacheResults) { // Prevent duplicate background refreshes for the same filter if (!this.backgroundRefreshes.has(fetchKey)) { this.backgroundRefreshes.add(fetchKey); // Use a longer delay for background refresh to avoid interfering with initial load setTimeout(() => { // Only update cache, don't call onUpdate for background refresh // This ensures cached events persist and are not cleared by background refresh const bgPromise = this.fetchFromRelays(filters, relays, { cacheResults: true, onUpdate: undefined, timeout }); bgPromise.finally(() => { // Remove from background refreshes set after a delay to allow re-refresh if needed setTimeout(() => { this.backgroundRefreshes.delete(fetchKey); }, 60000); // Allow re-refresh after 60 seconds }).catch((error) => { // Log but don't throw - background refresh failures shouldn't affect cached results console.debug('[nostr-client] Background refresh failed:', error); this.backgroundRefreshes.delete(fetchKey); }); }, 5000); // 5 second delay for background refresh to avoid interfering } } return cachedEvents; } // No cached events - this is expected and normal, so don't log it } catch (error) { console.error('[nostr-client] Error querying cache:', error); // Continue to fetch from relays } } // Fetch from relays const fetchPromise = this.fetchFromRelays(filters, relays, { cacheResults, onUpdate, timeout }); this.activeFetches.set(fetchKey, fetchPromise); fetchPromise.finally(() => { this.activeFetches.delete(fetchKey); }); return fetchPromise; } private async fetchFromRelays( filters: Filter[], relays: string[], options: { cacheResults: boolean; onUpdate?: (events: NostrEvent[]) => void; timeout: number } ): Promise { const timeout = options.timeout || config.relayTimeout; // Filter out relays that have failed recently or permanently const now = Date.now(); const availableRelays = relays.filter(url => { if (this.relays.has(url)) return true; // Already connected const failureInfo = this.failedRelays.get(url); if (failureInfo) { // Skip permanently failed relays if (failureInfo.failureCount >= this.PERMANENT_FAILURE_THRESHOLD) { return false; // Skip this relay, it has failed too many times } // Skip relays that failed recently (still in backoff period) const timeSinceFailure = now - failureInfo.lastFailure; if (timeSinceFailure < failureInfo.retryAfter) { return false; // Skip this relay, it failed recently } } return true; // Can try to connect }); // Try to connect to relays that aren't already connected // Like jumble, we gracefully handle failures - addRelay doesn't throw, it just doesn't add failed relays const relaysToConnect = availableRelays.filter(url => !this.relays.has(url)); if (relaysToConnect.length > 0) { if (relays.length === 1) { console.log(`[nostr-client] Attempting to connect to relay ${relaysToConnect[0]}...`); } await Promise.allSettled( relaysToConnect.map(url => this.addRelay(url)) ); // For single relay, wait a bit longer for connection to establish if (relays.length === 1 && relaysToConnect.length > 0) { await new Promise(resolve => setTimeout(resolve, 1000)); } } // Get list of actually connected relays const connectedRelays = availableRelays.filter(url => this.relays.has(url)); if (connectedRelays.length === 0) { // Log at warn level for single relay queries (more important to know about failures) const logLevel = relays.length === 1 ? 'warn' : 'debug'; const message = `[nostr-client] No connected relays available for fetch (${relays.length} requested: ${relays.join(', ')}, all failed or unavailable)`; if (logLevel === 'warn') { console.warn(message); // For single relay, also log which relays were attempted and failure info if (relays.length === 1) { const failureInfo = this.failedRelays.get(relays[0]); if (failureInfo) { console.warn(`[nostr-client] Relay ${relays[0]} failure info:`, failureInfo); } if (relaysToConnect.length > 0) { console.warn(`[nostr-client] Attempted to connect to: ${relaysToConnect.join(', ')}`); } console.warn(`[nostr-client] Available relays (after filtering): ${availableRelays.join(', ')}`); console.warn(`[nostr-client] Currently connected relays: ${Array.from(this.relays.keys()).join(', ')}`); } } else { console.debug(message); } return []; } // Log connection status for single relay queries if (relays.length === 1 && connectedRelays.length === 1) { console.log(`[nostr-client] Successfully connected to relay ${relays[0]}, fetching events...`); } else if (connectedRelays.length < relays.length * 0.5) { console.debug(`[nostr-client] Fetching from ${connectedRelays.length} connected relay(s) out of ${relays.length} requested`); } // Process relays sequentially with throttling to avoid overload const events: Map = new Map(); for (const relayUrl of connectedRelays) { await this.throttledRelayRequest(relayUrl, filters, events, timeout); // Small delay between relays await new Promise(resolve => setTimeout(resolve, 100)); } const eventArray = Array.from(events.values()); const filtered = filterEvents(eventArray); const zapFiltered = filtered.filter(event => !this.shouldFilterZapReceipt(event)); if (options.cacheResults && zapFiltered.length > 0) { cacheEvents(zapFiltered).catch(() => { // Silently fail }); } // Only call onUpdate if we got new events AND onUpdate is provided // This prevents clearing the UI when background fetch returns fewer results if (options.onUpdate && filtered.length > 0) { console.log(`[nostr-client] Fetch returned ${filtered.length} events, calling onUpdate`); options.onUpdate(filtered); } else if (options.onUpdate && filtered.length === 0) { console.debug(`[nostr-client] Fetch returned 0 events, skipping onUpdate to preserve cached results`); } else if (!options.onUpdate) { // Only log background refreshes that return events, not empty results if (filtered.length > 0) { console.debug(`[nostr-client] Fetch returned ${filtered.length} events (background refresh, no onUpdate)`); } } return filtered; } async getEventById(id: string, relays: string[]): Promise { try { const dbEvent = await getEvent(id); if (dbEvent) return dbEvent; } catch (error) { // Continue to fetch from relays } const filters: Filter[] = [{ ids: [id] }]; const events = await this.fetchEvents(filters, relays, { useCache: false }); return events[0] || null; } async getByFilters(filters: Filter[]): Promise { return this.getCachedEvents(filters); } getConfig() { return config; } getConnectedRelays(): string[] { return Array.from(this.relays.keys()); } close(): void { for (const { sub } of this.subscriptions.values()) { sub.close(); } this.subscriptions.clear(); for (const relay of this.relays.values()) { relay.close(); } this.relays.clear(); this.initialized = false; } } export const nostrClient = new NostrClient();