import type { Filter, Event } from 'nostr-tools' import { BaseService } from '@/core/base/BaseService' export interface NostrclientConfig { url: string privateKey?: string // For private WebSocket endpoint } export interface SubscriptionConfig { id: string filters: Filter[] onEvent?: (event: Event) => void onEose?: () => void onClose?: () => void } export interface RelayStatus { url: string connected: boolean lastSeen: number error?: string } export class NostrclientHub extends BaseService { // Service metadata protected readonly metadata = { name: 'NostrclientHub', version: '1.0.0', dependencies: ['VisibilityService'] } // EventEmitter functionality private events: { [key: string]: Function[] } = {} // Service state private ws: WebSocket | null = null private config: NostrclientConfig private subscriptions: Map = new Map() private reconnectInterval?: number private reconnectAttempts = 0 private readonly maxReconnectAttempts = 5 private readonly reconnectDelay = 5000 private visibilityUnsubscribe?: () => void // Connection state private _isConnected = false private _isConnecting = false constructor(config: NostrclientConfig) { super() this.config = config } // EventEmitter methods on(event: string, listener: Function) { if (!this.events[event]) { this.events[event] = [] } this.events[event].push(listener) } emit(event: string, ...args: any[]) { if (this.events[event]) { this.events[event].forEach(listener => listener(...args)) } } removeAllListeners(event?: string) { if (event) { delete this.events[event] } else { this.events = {} } } /** * Service-specific initialization (called by BaseService) */ protected async onInitialize(): Promise { // Connect to WebSocket console.log('🔧 NostrclientHub: Initializing connection to', this.config.url) await this.connect() // Register with visibility service this.registerWithVisibilityService() this.debug('NostrclientHub initialized') } get isConnected(): boolean { return this._isConnected } get isConnecting(): boolean { return this._isConnecting } get totalSubscriptionCount(): number { return this.subscriptions.size } get subscriptionDetails(): Array<{ id: string; filters: Filter[] }> { return Array.from(this.subscriptions.values()).map(sub => ({ id: sub.id, filters: sub.filters })) } /** * Connect to the nostrclient WebSocket */ async connect(): Promise { if (this._isConnecting || this._isConnected) { return } this._isConnecting = true this.reconnectAttempts++ try { console.log('🔧 NostrclientHub: Connecting to nostrclient WebSocket') // Determine WebSocket endpoint const wsUrl = this.config.privateKey ? `${this.config.url}/${this.config.privateKey}` // Private endpoint : `${this.config.url}/relay` // Public endpoint this.ws = new WebSocket(wsUrl) this.ws.onopen = () => { console.log('🔧 NostrclientHub: WebSocket connected') this._isConnected = true this._isConnecting = false this.reconnectAttempts = 0 this.emit('connected') // Resubscribe to existing subscriptions this.resubscribeAll() } this.ws.onmessage = (event) => { this.handleMessage(event.data) } this.ws.onclose = (event) => { console.log('🔧 NostrclientHub: WebSocket closed:', event.code, event.reason) this._isConnected = false this._isConnecting = false this.emit('disconnected', event) // Schedule reconnection if (this.reconnectAttempts < this.maxReconnectAttempts) { this.scheduleReconnect() } else { this.emit('maxReconnectionAttemptsReached') } } this.ws.onerror = (error) => { console.error('🔧 NostrclientHub: WebSocket error:', error) this.emit('error', error) } } catch (error) { this._isConnecting = false console.error('🔧 NostrclientHub: Connection failed:', error) this.emit('connectionError', error) if (this.reconnectAttempts < this.maxReconnectAttempts) { this.scheduleReconnect() } } } /** * Disconnect from the WebSocket */ disconnect(): void { if (this.reconnectInterval) { clearTimeout(this.reconnectInterval) this.reconnectInterval = undefined } if (this.ws) { this.ws.close() this.ws = null } this._isConnected = false this._isConnecting = false this.subscriptions.clear() this.emit('disconnected') } /** * Subscribe to events */ subscribe(config: SubscriptionConfig): () => void { if (!this._isConnected) { throw new Error('Not connected to nostrclient') } // Store subscription this.subscriptions.set(config.id, config) // Send REQ message const reqMessage = JSON.stringify([ 'REQ', config.id, ...config.filters ]) this.ws?.send(reqMessage) console.log('🔧 NostrclientHub: Subscribed to', config.id) // Return unsubscribe function return () => { this.unsubscribe(config.id) } } /** * Unsubscribe from events */ unsubscribe(subscriptionId: string): void { if (!this._isConnected) { return } // Send CLOSE message const closeMessage = JSON.stringify(['CLOSE', subscriptionId]) this.ws?.send(closeMessage) // Remove from subscriptions this.subscriptions.delete(subscriptionId) console.log('🔧 NostrclientHub: Unsubscribed from', subscriptionId) } /** * Publish an event */ async publishEvent(event: Event): Promise { if (!this._isConnected) { throw new Error('Not connected to nostrclient') } const eventMessage = JSON.stringify(['EVENT', event]) this.ws?.send(eventMessage) console.log('🔧 NostrclientHub: Published event', event.id) this.emit('eventPublished', { eventId: event.id }) } /** * Query events (one-time fetch) */ async queryEvents(filters: Filter[]): Promise { return new Promise((resolve, reject) => { if (!this._isConnected) { reject(new Error('Not connected to nostrclient')) return } const queryId = `query-${Date.now()}` const events: Event[] = [] let eoseReceived = false // Create temporary subscription for query const tempSubscription = this.subscribe({ id: queryId, filters, onEvent: (event) => { events.push(event) }, onEose: () => { eoseReceived = true this.unsubscribe(queryId) resolve(events) }, onClose: () => { if (!eoseReceived) { reject(new Error('Query subscription closed unexpectedly')) } } }) // Timeout after 30 seconds setTimeout(() => { if (!eoseReceived) { tempSubscription() reject(new Error('Query timeout')) } }, 30000) }) } /** * Handle incoming WebSocket messages */ private handleMessage(data: string): void { try { const message = JSON.parse(data) if (Array.isArray(message) && message.length >= 2) { const [type, subscriptionId, ...rest] = message switch (type) { case 'EVENT': const event = rest[0] as Event const subscription = this.subscriptions.get(subscriptionId) if (subscription?.onEvent) { subscription.onEvent(event) } this.emit('event', { subscriptionId, event }) break case 'EOSE': const eoseSubscription = this.subscriptions.get(subscriptionId) if (eoseSubscription?.onEose) { eoseSubscription.onEose() } this.emit('eose', { subscriptionId }) break case 'NOTICE': console.log('🔧 NostrclientHub: Notice from relay:', rest[0]) this.emit('notice', { message: rest[0] }) break default: console.log('🔧 NostrclientHub: Unknown message type:', type) } } } catch (error) { console.error('🔧 NostrclientHub: Failed to parse message:', error) } } /** * Resubscribe to all existing subscriptions after reconnection */ private resubscribeAll(): void { for (const [id, config] of this.subscriptions) { const reqMessage = JSON.stringify([ 'REQ', id, ...config.filters ]) this.ws?.send(reqMessage) } console.log('🔧 NostrclientHub: Resubscribed to', this.subscriptions.size, 'subscriptions') } /** * Schedule automatic reconnection */ private scheduleReconnect(): void { if (this.reconnectInterval) { clearTimeout(this.reconnectInterval) } const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1) console.log(`🔧 NostrclientHub: Scheduling reconnection in ${delay}ms`) this.reconnectInterval = setTimeout(async () => { await this.connect() }, delay) as unknown as number } /** * Register with VisibilityService for connection management */ private registerWithVisibilityService(): void { if (!this.visibilityService) { this.debug('VisibilityService not available') return } this.visibilityUnsubscribe = this.visibilityService.registerService( this.metadata.name, async () => this.handleAppResume(), async () => this.handleAppPause() ) this.debug('Registered with VisibilityService') } /** * Handle app resuming from visibility change */ private async handleAppResume(): Promise { this.debug('App resumed - checking nostrclient WebSocket connection') // Check if we need to reconnect if (!this.isConnected && !this._isConnecting) { this.debug('WebSocket disconnected, attempting to reconnect...') await this.connect() } else if (this.isConnected) { // Connection is alive, resubscribe to ensure all subscriptions are active this.resubscribeAll() } } /** * Handle app pausing from visibility change */ private async handleAppPause(): Promise { this.debug('App paused - WebSocket connection will be maintained for quick resume') // Don't immediately disconnect - WebSocket will be checked on resume // This allows for quick resume without full reconnection overhead } /** * Cleanup when service is disposed (overrides BaseService) */ protected async onDispose(): Promise { // Unregister from visibility service if (this.visibilityUnsubscribe) { this.visibilityUnsubscribe() this.visibilityUnsubscribe = undefined } // Disconnect WebSocket this.disconnect() // Clear all event listeners this.removeAllListeners() this.debug('NostrclientHub disposed') } } // Export singleton instance export const nostrclientHub = new NostrclientHub({ url: import.meta.env.VITE_NOSTRCLIENT_URL || 'wss://localhost:5000/nostrclient/api/v1' }) // Ensure global export ;(globalThis as any).nostrclientHub = nostrclientHub