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?: number private healthCheckInterval?: number 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 return this.connectedRelays.size } get totalRelayCount(): number { return this.relayConfigs.size } get totalSubscriptionCount(): number { return this.subscriptions.size } get subscriptionDetails(): Array<{ id: string; filters: any[]; relays?: string[] }> { return Array.from(this.subscriptions.entries()).map(([id, subscription]) => { // Try to extract subscription details if available return { id, filters: subscription.filters || [], relays: subscription.relays || [] } }) } get relayStatuses(): RelayStatus[] { return Array.from(this.relayConfigs.values()).map(config => { const relay = this.connectedRelays.get(config.url) return { url: config.url, connected: !!relay, 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 } console.log('🔧 RelayHub: Initializing with URLs:', relayUrls) // Convert URLs to relay configs this.relayConfigs.clear() relayUrls.forEach((url, index) => { this.relayConfigs.set(url, { url, read: true, write: true, priority: index }) }) console.log('🔧 RelayHub: Relay configs created:', Array.from(this.relayConfigs.values())) // Start connection management console.log('🔧 RelayHub: Starting connection...') await this.connect() this.startHealthCheck() this.isInitialized = true console.log('🔧 RelayHub: Initialization complete') } /** * Connect to all configured relays */ async connect(): Promise { if (this.relayConfigs.size === 0) { throw new Error('No relay configurations found. Call initialize() first.') } console.log('🔧 RelayHub: Connecting to', this.relayConfigs.size, 'relays') try { this._connectionAttempts++ console.log('🔧 RelayHub: Connection 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)) console.log('🔧 RelayHub: Attempting connections to:', sortedRelays.map(r => r.url)) const connectionPromises = sortedRelays.map(async (config) => { try { console.log('🔧 RelayHub: Connecting to relay:', config.url) const relay = await this.pool.ensureRelay(config.url) this.connectedRelays.set(config.url, relay) console.log('🔧 RelayHub: Successfully connected to:', config.url) return { url: config.url, success: true } } catch (error) { console.error(`🔧 RelayHub: 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 ) console.log('🔧 RelayHub: Connection results:', { total: results.length, successful: successfulConnections.length, failed: results.length - successfulConnections.length }) if (successfulConnections.length > 0) { this._isConnected = true this._connectionAttempts = 0 console.log('🔧 RelayHub: Connection successful, connected to', successfulConnections.length, 'relays') this.emit('connected', successfulConnections.length) } else { console.error('🔧 RelayHub: Failed to connect to any relay') throw new Error('Failed to connect to any relay') } } catch (error) { this._isConnected = false console.error('🔧 RelayHub: Connection failed with error:', error) this.emit('connectionError', error) // Schedule reconnection if we haven't exceeded max attempts if (this._connectionAttempts < this.maxReconnectAttempts) { console.log('🔧 RelayHub: Scheduling reconnection attempt', this._connectionAttempts + 1) this.scheduleReconnect() } else { this.emit('maxReconnectionAttemptsReached') console.error('🔧 RelayHub: Max reconnection attempts reached') } } } /** * Disconnect from all relays */ disconnect(): void { // 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') } // 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) // Emit subscription created event this.emit('subscriptionCreated', { id: config.id, count: this.subscriptions.size }) // 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) // Emit subscription removed event this.emit('subscriptionRemoved', { id: subscriptionId, count: this.subscriptions.size }) } } /** * 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 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) } 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 { this.disconnect() await this.connect() } /** * Schedule automatic reconnection */ private scheduleReconnect(): void { if (this.reconnectInterval) { clearTimeout(this.reconnectInterval) } this.reconnectInterval = setTimeout(async () => { await this.connect() }, this.reconnectDelay) as unknown as number } /** * Start health check monitoring */ private startHealthCheck(): void { if (this.healthCheckInterval) { clearInterval(this.healthCheckInterval) } this.healthCheckInterval = setInterval(() => { this.performHealthCheck() }, this.healthCheckIntervalMs) as unknown as number } /** * Perform health check on all relays */ private async performHealthCheck(): Promise { if (!this._isConnected) return 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) }) // 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) { // 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', () => { this.performHealthCheck() }) window.addEventListener('offline', () => { console.log('Network offline, marking as disconnected...') this._isConnected = false this.emit('networkOffline') }) } } /** * Cleanup resources */ destroy(): void { // 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()