import { SimplePool, type Filter, type Event, type Relay } from 'nostr-tools' // Simple EventEmitter implementation for browser compatibility class EventEmitter { private events: { [key: string]: Function[] } = {} on(event: string, listener: Function): void { if (!this.events[event]) { this.events[event] = [] } this.events[event].push(listener) } off(event: string, listener: Function): void { if (!this.events[event]) return const index = this.events[event].indexOf(listener) if (index > -1) { this.events[event].splice(index, 1) } } emit(event: string, ...args: any[]): void { if (!this.events[event]) return this.events[event].forEach(listener => listener(...args)) } removeAllListeners(event?: string): void { if (event) { delete this.events[event] } else { this.events = {} } } } export interface RelayConfig { url: string read: boolean write: boolean priority?: number // Lower number = higher priority } export interface SubscriptionConfig { id: string filters: Filter[] relays?: string[] // If not specified, uses all connected relays onEvent?: (event: Event) => void onEose?: () => void onClose?: () => void } export interface RelayStatus { url: string connected: boolean lastSeen: number error?: string latency?: number } export class RelayHub extends EventEmitter { private pool: SimplePool private relayConfigs: Map = new Map() private connectedRelays: Map = new Map() private subscriptions: Map = new Map() public isInitialized = false private reconnectInterval?: NodeJS.Timeout private healthCheckInterval?: NodeJS.Timeout private mobileVisibilityHandler?: () => void // Connection state private _isConnected = false private _connectionAttempts = 0 private readonly maxReconnectAttempts = 5 private readonly reconnectDelay = 5000 // 5 seconds private readonly healthCheckIntervalMs = 30000 // 30 seconds constructor() { super() this.pool = new SimplePool() this.setupMobileVisibilityHandling() } get isConnected(): boolean { return this._isConnected } get connectedRelayCount(): number { // Return the actual size of connectedRelays map const count = this.connectedRelays.size console.log('🔍 connectedRelayCount getter called, returning:', count) return count } get totalRelayCount(): number { return this.relayConfigs.size } get totalSubscriptionCount(): number { return this.subscriptions.size } get relayStatuses(): RelayStatus[] { console.log('🔍 relayStatuses getter called') console.log('🔍 relayConfigs size:', this.relayConfigs.size) console.log('🔍 connectedRelays size:', this.connectedRelays.size) console.log('🔍 connectedRelays keys:', Array.from(this.connectedRelays.keys())) return Array.from(this.relayConfigs.values()).map(config => { const relay = this.connectedRelays.get(config.url) const isConnected = !!relay console.log(`🔍 Relay ${config.url}: connected=${isConnected}, relay=${relay ? 'exists' : 'null'}`) return { url: config.url, connected: isConnected, lastSeen: relay ? Date.now() : 0, error: relay ? undefined : 'Not connected', latency: relay ? 0 : undefined // TODO: Implement actual latency measurement } }) } /** * Initialize the relay hub with relay configurations */ async initialize(relayUrls: string[]): Promise { if (this.isInitialized) { console.warn('RelayHub already initialized') return } // Convert URLs to relay configs this.relayConfigs.clear() relayUrls.forEach((url, index) => { this.relayConfigs.set(url, { url, read: true, write: true, priority: index }) }) // Start connection management await this.connect() this.startHealthCheck() this.isInitialized = true console.log(`RelayHub initialized with ${relayUrls.length} relays`) } /** * Connect to all configured relays */ async connect(): Promise { if (this.relayConfigs.size === 0) { throw new Error('No relay configurations found. Call initialize() first.') } try { this._connectionAttempts++ console.log(`Connecting to ${this.relayConfigs.size} relays (attempt ${this._connectionAttempts})`) // Connect to relays in priority order const sortedRelays = Array.from(this.relayConfigs.values()) .sort((a, b) => (a.priority || 0) - (b.priority || 0)) const connectionPromises = sortedRelays.map(async (config) => { try { const relay = await this.pool.ensureRelay(config.url) this.connectedRelays.set(config.url, relay) console.log(`Connected to relay: ${config.url}`) return { url: config.url, success: true } } catch (error) { console.error(`Failed to connect to relay ${config.url}:`, error) return { url: config.url, success: false, error } } }) const results = await Promise.allSettled(connectionPromises) const successfulConnections = results.filter( result => result.status === 'fulfilled' && result.value.success ) if (successfulConnections.length > 0) { this._isConnected = true this._connectionAttempts = 0 this.emit('connected', successfulConnections.length) console.log(`Successfully connected to ${successfulConnections.length}/${this.relayConfigs.size} relays`) } else { throw new Error('Failed to connect to any relay') } } catch (error) { this._isConnected = false this.emit('connectionError', error) console.error('Connection failed:', error) // Schedule reconnection if we haven't exceeded max attempts if (this._connectionAttempts < this.maxReconnectAttempts) { this.scheduleReconnect() } else { this.emit('maxReconnectAttemptsReached') console.error('Max reconnection attempts reached') } } } /** * Disconnect from all relays */ disconnect(): void { console.log('Disconnecting from all relays') // Clear intervals if (this.reconnectInterval) { clearTimeout(this.reconnectInterval) this.reconnectInterval = undefined } if (this.healthCheckInterval) { clearInterval(this.healthCheckInterval) this.healthCheckInterval = undefined } // Close all subscriptions this.subscriptions.forEach(sub => sub.close()) this.subscriptions.clear() // Close all relay connections this.pool.close(Array.from(this.relayConfigs.keys())) this.connectedRelays.clear() this._isConnected = false this.emit('disconnected') } /** * Subscribe to events from relays */ subscribe(config: SubscriptionConfig): () => void { if (!this.isInitialized) { throw new Error('RelayHub not initialized. Call initialize() first.') } if (!this._isConnected) { throw new Error('Not connected to any relays') } // Determine which relays to use const targetRelays = config.relays || Array.from(this.connectedRelays.keys()) const availableRelays = targetRelays.filter(url => this.connectedRelays.has(url)) if (availableRelays.length === 0) { throw new Error('No available relays for subscription') } console.log(`Creating subscription ${config.id} on ${availableRelays.length} relays`) // Create subscription using the pool const subscription = this.pool.subscribeMany(availableRelays, config.filters, { onevent: (event: Event) => { config.onEvent?.(event) this.emit('event', { subscriptionId: config.id, event, relay: 'unknown' }) }, oneose: () => { config.onEose?.() this.emit('eose', { subscriptionId: config.id }) } }) // Store subscription for cleanup this.subscriptions.set(config.id, subscription) // Return unsubscribe function return () => { this.unsubscribe(config.id) } } /** * Unsubscribe from a specific subscription */ unsubscribe(subscriptionId: string): void { const subscription = this.subscriptions.get(subscriptionId) if (subscription) { subscription.close() this.subscriptions.delete(subscriptionId) console.log(`Unsubscribed from ${subscriptionId}`) } } /** * Publish an event to all connected relays */ async publishEvent(event: Event): Promise<{ success: number; total: number }> { if (!this._isConnected) { throw new Error('Not connected to any relays') } const relayUrls = Array.from(this.connectedRelays.keys()) const results = await Promise.allSettled( relayUrls.map(relay => this.pool.publish([relay], event)) ) const successful = results.filter(result => result.status === 'fulfilled').length const total = results.length console.log(`Published event ${event.id} to ${successful}/${total} relays`) this.emit('eventPublished', { eventId: event.id, success: successful, total }) return { success: successful, total } } /** * Query events from relays (one-time fetch) */ async queryEvents(filters: Filter[], relays?: string[]): Promise { if (!this._isConnected) { throw new Error('Not connected to any relays') } const targetRelays = relays || Array.from(this.connectedRelays.keys()) const availableRelays = targetRelays.filter(url => this.connectedRelays.has(url)) if (availableRelays.length === 0) { throw new Error('No available relays for query') } try { // Query each filter separately and combine results const allEvents: Event[] = [] for (const filter of filters) { const events = await this.pool.querySync(availableRelays, filter) allEvents.push(...events) } console.log(`Queried ${allEvents.length} events from ${availableRelays.length} relays`) return allEvents } catch (error) { console.error('Query failed:', error) throw error } } /** * Get a specific relay instance */ getRelay(url: string): Relay | undefined { return this.connectedRelays.get(url) } /** * Check if a specific relay is connected */ isRelayConnected(url: string): boolean { return this.connectedRelays.has(url) } /** * Force reconnection to all relays */ async reconnect(): Promise { console.log('Forcing reconnection to all relays') this.disconnect() await this.connect() } /** * Schedule automatic reconnection */ private scheduleReconnect(): void { if (this.reconnectInterval) { clearTimeout(this.reconnectInterval) } this.reconnectInterval = setTimeout(async () => { console.log('Attempting automatic reconnection...') await this.connect() }, this.reconnectDelay) } /** * Start health check monitoring */ private startHealthCheck(): void { if (this.healthCheckInterval) { clearInterval(this.healthCheckInterval) } this.healthCheckInterval = setInterval(() => { this.performHealthCheck() }, this.healthCheckIntervalMs) } /** * Perform health check on all relays */ private async performHealthCheck(): Promise { if (!this._isConnected) return console.log('Performing relay health check...') const disconnectedRelays: string[] = [] // Check each relay connection for (const [url] of this.connectedRelays) { try { // Try to send a ping or check connection status // For now, we'll just check if the relay is still in our connected relays map if (!this.connectedRelays.has(url)) { disconnectedRelays.push(url) } } catch (error) { console.warn(`Health check failed for relay ${url}:`, error) disconnectedRelays.push(url) } } // Remove disconnected relays disconnectedRelays.forEach(url => { this.connectedRelays.delete(url) console.log(`Removed disconnected relay: ${url}`) }) // Update connection status if (this.connectedRelays.size === 0) { this._isConnected = false this.emit('allRelaysDisconnected') console.warn('All relays disconnected, attempting reconnection...') await this.connect() } else if (this.connectedRelays.size < this.relayConfigs.size) { this.emit('partialDisconnection', { connected: this.connectedRelays.size, total: this.relayConfigs.size }) } } /** * Setup mobile visibility handling for better WebSocket management */ private setupMobileVisibilityHandling(): void { // Handle page visibility changes (mobile app backgrounding) if (typeof document !== 'undefined') { this.mobileVisibilityHandler = () => { if (document.hidden) { console.log('Page hidden, maintaining WebSocket connections') // Keep connections alive but reduce activity } else { console.log('Page visible, resuming normal WebSocket activity') // Resume normal activity and check connections this.performHealthCheck() } } document.addEventListener('visibilitychange', this.mobileVisibilityHandler) } // Handle online/offline events if (typeof window !== 'undefined') { window.addEventListener('online', () => { console.log('Network online, checking relay connections...') this.performHealthCheck() }) window.addEventListener('offline', () => { console.log('Network offline, marking as disconnected...') this._isConnected = false this.emit('networkOffline') }) } } /** * Cleanup resources */ destroy(): void { console.log('Destroying RelayHub...') // Remove event listeners if (this.mobileVisibilityHandler && typeof document !== 'undefined') { document.removeEventListener('visibilitychange', this.mobileVisibilityHandler) } if (typeof window !== 'undefined') { window.removeEventListener('online', () => {}) window.removeEventListener('offline', () => {}) } // Disconnect and cleanup this.disconnect() this.removeAllListeners() this.isInitialized = false } } // Export singleton instance export const relayHub = new RelayHub()