diff --git a/ARCHITECTURE_ANALYSIS.md b/ARCHITECTURE_ANALYSIS.md index 41b7bfc..7dab78c 100644 --- a/ARCHITECTURE_ANALYSIS.md +++ b/ARCHITECTURE_ANALYSIS.md @@ -1,5 +1,9 @@ # Web App Architecture Analysis & Modularity Assessment +> **⚠️ OUTDATED DOCUMENT** +> **Updated Version**: `/docs/modular-architecture-analysis.md` +> **Date**: This document from September 4, 2025 is outdated - significant architectural changes implemented September 6, 2025 + **Date:** September 4, 2025 **Project:** Ario Web App (Vue 3 + Nostr + LNbits) **Objective:** Evaluate current architecture for modular plugin-based development diff --git a/ARCHITECTURE_ANALYSIS_PRINT.md b/ARCHITECTURE_ANALYSIS_PRINT.md index fc4eead..acb8fc2 100644 --- a/ARCHITECTURE_ANALYSIS_PRINT.md +++ b/ARCHITECTURE_ANALYSIS_PRINT.md @@ -1,5 +1,9 @@ # Web App Architecture Analysis & Modularity Assessment +> **⚠️ OUTDATED DOCUMENT** +> **Updated Version**: `/docs/modular-architecture-analysis.md` +> **Status**: Print version of outdated September 4, 2025 analysis + **Date:** September 4, 2025 **Project:** Ario Web App (Vue 3 + Nostr + LNbits) **Objective:** Evaluate current architecture for modular plugin-based development diff --git a/NOSTR_ARCHITECTURE.md b/NOSTR_ARCHITECTURE.md index 550eb22..fad5cf8 100644 --- a/NOSTR_ARCHITECTURE.md +++ b/NOSTR_ARCHITECTURE.md @@ -1,8 +1,16 @@ # Ario Web App Architecture +> **⚠️ DEPRECATED DOCUMENT** +> **Date**: September 6, 2025 +> **Status**: This document describes the legacy singleton-based architecture which has been replaced by a **modular service-based architecture** with dependency injection. +> +> **Current Architecture**: See `CLAUDE.md` and `docs/modular-architecture-analysis.md` for up-to-date information. + ## Overview -The Ario web app uses a **singleton-based architecture** to manage shared state and resources across components. This document explains how the core singletons work and how different components (chat, market, feed) plug into the system. +~~The Ario web app uses a **singleton-based architecture** to manage shared state and resources across components. This document explains how the core singletons work and how different components (chat, market, feed) plug into the system.~~ + +**Updated**: The Ario web app now uses a modular plugin architecture with dependency injection through the BaseService pattern and SERVICE_TOKENS. ## Core Singleton Architecture diff --git a/RELAY_HUB_ARCHITECTURE.md b/RELAY_HUB_ARCHITECTURE.md index b7c6922..53c4860 100644 --- a/RELAY_HUB_ARCHITECTURE.md +++ b/RELAY_HUB_ARCHITECTURE.md @@ -18,7 +18,7 @@ The `RelayHub` class is the foundation of the relay management system, built on - **Mobile Optimization**: Handles mobile app visibility changes - **Event Emission**: Provides event-driven architecture for status updates -**Location:** `src/lib/nostr/relayHub.ts` +**Location:** `src/modules/base/nostr/relay-hub.ts` *(moved from legacy lib/nostr location)* ### 2. Relay Hub Composable (`useRelayHub.ts`) diff --git a/src/lib/nostr/nostrclientHub.ts b/src/lib/nostr/nostrclientHub.ts deleted file mode 100644 index e37d189..0000000 --- a/src/lib/nostr/nostrclientHub.ts +++ /dev/null @@ -1,359 +0,0 @@ -import type { Filter, Event } from 'nostr-tools' - -// Simple EventEmitter for browser compatibility -class EventEmitter { - private events: { [key: string]: Function[] } = {} - - 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 = {} - } - } -} - -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 EventEmitter { - 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 - - // Connection state - private _isConnected = false - private _isConnecting = false - - constructor(config: NostrclientConfig) { - super() - this.config = config - } - - 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 - })) - } - - /** - * Initialize and connect to nostrclient WebSocket - */ - async initialize(): Promise { - console.log('🔧 NostrclientHub: Initializing connection to', this.config.url) - await this.connect() - } - - /** - * 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 - } -} - -// Export singleton instance -export const nostrclientHub = new NostrclientHub({ - url: import.meta.env.VITE_NOSTRCLIENT_URL || 'wss://localhost:5000/nostrclient/api/v1' -}) diff --git a/src/lib/nostr/relayHub.ts b/src/lib/nostr/relayHub.ts deleted file mode 100644 index d4f3dee..0000000 --- a/src/lib/nostr/relayHub.ts +++ /dev/null @@ -1,521 +0,0 @@ -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()