Add BaseService and refactor ChatService for improved dependency management

- Introduce BaseService as a foundational class for services, providing standardized dependency injection and initialization logic.
- Refactor ChatService to extend BaseService, enhancing its initialization process and dependency handling.
- Implement service metadata and structured initialization in ChatService, allowing for better tracking and error handling during service setup.
- Update chat module to initialize ChatService with dependency management, ensuring readiness before use.
This commit is contained in:
padreug 2025-09-05 06:19:08 +02:00
parent c7fcd51990
commit 8d4c389f71
4 changed files with 398 additions and 66 deletions

View file

@ -0,0 +1,311 @@
import { tryInjectService, SERVICE_TOKENS, type ServiceToken } from '@/core/di-container'
import { eventBus } from '@/core/event-bus'
import type { Ref } from 'vue'
import { ref } from 'vue'
/**
* Service metadata for tracking and debugging
*/
export interface ServiceMetadata {
name: string
version?: string
dependencies?: string[]
}
/**
* Service initialization options
*/
export interface ServiceInitOptions {
waitForDependencies?: boolean
maxRetries?: number
retryDelay?: number
}
/**
* Base class for all services in the modular architecture
* Provides standardized dependency injection, initialization, and error handling
*
* @example
* ```typescript
* export class MyService extends BaseService {
* protected metadata = {
* name: 'MyService',
* dependencies: ['RelayHub', 'AuthService']
* }
*
* protected async onInitialize(): Promise<void> {
* // Service-specific initialization
* await this.setupSubscriptions()
* }
* }
* ```
*/
export abstract class BaseService {
// Core dependencies with proper typing
protected relayHub: any = null
protected authService: any = null
protected nostrClientHub: any = null
// Service state
public readonly isInitialized: Ref<boolean> = ref(false)
public readonly isInitializing: Ref<boolean> = ref(false)
public readonly initError: Ref<Error | null> = ref(null)
// Service metadata
protected abstract readonly metadata: ServiceMetadata
constructor() {
// Dependencies will be injected after construction
}
/**
* Initialize the service with dependency injection and error handling
*/
public async initialize(options: ServiceInitOptions = {}): Promise<void> {
const {
waitForDependencies = true,
maxRetries = 3,
retryDelay = 1000
} = options
if (this.isInitialized.value) {
console.log(`${this.metadata.name} already initialized`)
return
}
if (this.isInitializing.value) {
console.log(`${this.metadata.name} is already initializing...`)
return
}
this.isInitializing.value = true
this.initError.value = null
try {
console.log(`🚀 Initializing ${this.metadata.name}...`)
// Inject dependencies
await this.injectDependencies(waitForDependencies, maxRetries, retryDelay)
// Call service-specific initialization
await this.onInitialize()
this.isInitialized.value = true
console.log(`${this.metadata.name} initialized successfully`)
// Emit initialization event
eventBus.emit(`service:initialized`, {
service: this.metadata.name,
timestamp: Date.now()
}, this.metadata.name)
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error))
this.initError.value = err
console.error(`❌ Failed to initialize ${this.metadata.name}:`, err)
// Emit error event
eventBus.emit(`service:error`, {
service: this.metadata.name,
error: err.message,
timestamp: Date.now()
}, this.metadata.name)
throw err
} finally {
this.isInitializing.value = false
}
}
/**
* Inject required dependencies with retry logic
*/
protected async injectDependencies(
waitForDependencies: boolean,
maxRetries: number,
retryDelay: number
): Promise<void> {
let retries = 0
while (retries < maxRetries) {
try {
// Try to inject core dependencies
this.relayHub = tryInjectService(SERVICE_TOKENS.RELAY_HUB)
this.authService = tryInjectService(SERVICE_TOKENS.AUTH_SERVICE)
this.nostrClientHub = tryInjectService(SERVICE_TOKENS.NOSTR_CLIENT_HUB)
// Check if all required dependencies are available
const missingDeps = this.getMissingDependencies()
if (missingDeps.length === 0) {
console.log(`✅ All dependencies injected for ${this.metadata.name}`)
return
}
if (!waitForDependencies) {
console.warn(`⚠️ ${this.metadata.name} starting with missing dependencies:`, missingDeps)
return
}
if (retries < maxRetries - 1) {
console.log(`⏳ Waiting for dependencies (${missingDeps.join(', ')})... Retry ${retries + 1}/${maxRetries}`)
await new Promise(resolve => setTimeout(resolve, retryDelay))
retries++
} else {
throw new Error(`Missing required dependencies: ${missingDeps.join(', ')}`)
}
} catch (error) {
if (retries >= maxRetries - 1) {
throw error
}
retries++
}
}
}
/**
* Get list of missing dependencies
*/
protected getMissingDependencies(): string[] {
const missing: string[] = []
const deps = this.metadata.dependencies || []
if (deps.includes('RelayHub') && !this.relayHub) {
missing.push('RelayHub')
}
if (deps.includes('AuthService') && !this.authService) {
missing.push('AuthService')
}
if (deps.includes('NostrClientHub') && !this.nostrClientHub) {
missing.push('NostrClientHub')
}
return missing
}
/**
* Wait for specific dependencies to be available
*/
protected async waitForDependencies(timeout = 10000): Promise<void> {
const startTime = Date.now()
while (Date.now() - startTime < timeout) {
const missingDeps = this.getMissingDependencies()
if (missingDeps.length === 0) {
return
}
await new Promise(resolve => setTimeout(resolve, 100))
}
throw new Error(`Timeout waiting for dependencies: ${this.getMissingDependencies().join(', ')}`)
}
/**
* Check if user is authenticated (helper method)
*/
protected requireAuth(): void {
if (!this.authService?.isAuthenticated?.value) {
throw new Error('Authentication required')
}
}
/**
* Get current user pubkey (helper method)
*/
protected getUserPubkey(): string | undefined {
return this.authService?.user?.value?.pubkey
}
/**
* Handle errors consistently across all services
*/
protected handleError(error: unknown, context: string): Error {
const err = error instanceof Error ? error : new Error(String(error))
console.error(`[${this.metadata.name}] Error in ${context}:`, err)
// Emit error event for monitoring
eventBus.emit('service:error', {
service: this.metadata.name,
context,
error: err.message,
timestamp: Date.now()
}, this.metadata.name)
return err
}
/**
* Log debug information (only in development)
*/
protected debug(...args: any[]): void {
if (import.meta.env.DEV) {
console.log(`[${this.metadata.name}]`, ...args)
}
}
/**
* Abstract method for service-specific initialization
* Must be implemented by derived classes
*/
protected abstract onInitialize(): Promise<void>
/**
* Cleanup method for service disposal
*/
public async dispose(): Promise<void> {
try {
await this.onDispose()
this.isInitialized.value = false
this.relayHub = null
this.authService = null
this.nostrClientHub = null
console.log(`♻️ ${this.metadata.name} disposed`)
} catch (error) {
console.error(`Failed to dispose ${this.metadata.name}:`, error)
}
}
/**
* Optional cleanup logic for derived classes
*/
protected async onDispose(): Promise<void> {
// Override in derived classes if cleanup is needed
}
}
/**
* Type-safe service class with proper dependency typing
* Use this when you need full type safety for dependencies
*/
export abstract class TypedBaseService<TDeps extends Record<string, any> = {}> extends BaseService {
protected dependencies!: TDeps
/**
* Get typed dependency
*/
protected getDependency<K extends keyof TDeps>(key: K): TDeps[K] {
return this.dependencies[key]
}
/**
* Inject typed dependencies
*/
protected async injectTypedDependencies(tokens: Record<keyof TDeps, ServiceToken>): Promise<void> {
this.dependencies = {} as TDeps
for (const [key, token] of Object.entries(tokens)) {
const service = tryInjectService(token as ServiceToken)
if (service) {
(this.dependencies as any)[key] = service
}
}
}
}

View file

@ -1,5 +1,7 @@
import type { DIContainer, ServiceToken } from './types' import type { DIContainer, ServiceToken } from './types'
export type { ServiceToken } from './types'
interface ServiceRegistration { interface ServiceRegistration {
service: any service: any
scope: 'singleton' | 'transient' scope: 'singleton' | 'transient'

View file

@ -35,6 +35,15 @@ export const chatModule: ModulePlugin = {
const chatService = new ChatService(config) const chatService = new ChatService(config)
container.provide(CHAT_SERVICE_TOKEN, chatService) container.provide(CHAT_SERVICE_TOKEN, chatService)
// Initialize the service (will handle dependency injection)
await chatService.initialize({
waitForDependencies: true,
maxRetries: 3
}).catch(error => {
console.warn('💬 Chat service initialization deferred:', error)
// Service will auto-initialize when dependencies are available
})
// Also make it globally available for other modules (like market) // Also make it globally available for other modules (like market)
;(globalThis as any).chatService = chatService ;(globalThis as any).chatService = chatService

View file

@ -1,6 +1,6 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { eventBus } from '@/core/event-bus' import { eventBus } from '@/core/event-bus'
import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { BaseService } from '@/core/base/BaseService'
import { nip04, finalizeEvent, type Event, type EventTemplate } from 'nostr-tools' import { nip04, finalizeEvent, type Event, type EventTemplate } from 'nostr-tools'
import type { ChatMessage, ChatPeer, UnreadMessageData, ChatConfig } from '../types' import type { ChatMessage, ChatPeer, UnreadMessageData, ChatConfig } from '../types'
import { getAuthToken } from '@/lib/config/lnbits' import { getAuthToken } from '@/lib/config/lnbits'
@ -9,20 +9,25 @@ import { config } from '@/lib/config'
const UNREAD_MESSAGES_KEY = 'nostr-chat-unread-messages' const UNREAD_MESSAGES_KEY = 'nostr-chat-unread-messages'
const PEERS_KEY = 'nostr-chat-peers' const PEERS_KEY = 'nostr-chat-peers'
export class ChatService { export class ChatService extends BaseService {
// Service metadata
protected readonly metadata = {
name: 'ChatService',
version: '1.0.0',
dependencies: ['RelayHub', 'AuthService']
}
// Service-specific state
private messages = ref<Map<string, ChatMessage[]>>(new Map()) private messages = ref<Map<string, ChatMessage[]>>(new Map())
private peers = ref<Map<string, ChatPeer>>(new Map()) private peers = ref<Map<string, ChatPeer>>(new Map())
private config: ChatConfig private config: ChatConfig
private subscriptionUnsubscriber?: () => void private subscriptionUnsubscriber?: () => void
private isInitialized = ref(false)
private marketMessageHandler?: (event: any) => Promise<void> private marketMessageHandler?: (event: any) => Promise<void>
constructor(config: ChatConfig) { constructor(config: ChatConfig) {
super()
this.config = config this.config = config
this.loadPeersFromStorage() this.loadPeersFromStorage()
// Defer initialization until services are available
this.deferredInitialization()
} }
// Register market message handler for forwarding market-related DMs // Register market message handler for forwarding market-related DMs
@ -30,47 +35,43 @@ export class ChatService {
this.marketMessageHandler = handler this.marketMessageHandler = handler
} }
// Defer initialization until services are ready /**
private deferredInitialization(): void { * Service-specific initialization (called by BaseService)
// Try initialization immediately */
this.tryInitialization() protected async onInitialize(): Promise<void> {
// Check if we have user pubkey
if (!this.authService?.user?.value?.pubkey) {
this.debug('User not authenticated yet, deferring full initialization')
// Also listen for auth events to re-initialize when user logs in // Listen for auth events to complete initialization when user logs in
eventBus.on('auth:login', () => { const unsubscribe = eventBus.on('auth:login', async () => {
console.log('💬 Auth login detected, initializing chat...') this.debug('Auth login detected, completing chat initialization...')
this.tryInitialization() unsubscribe()
})
}
// Try to initialize services if they're available // Re-inject dependencies and complete initialization
private async tryInitialization(): Promise<void> { await this.waitForDependencies()
try { await this.completeInitialization()
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
if (!relayHub || !authService?.user?.value?.pubkey) {
console.log('💬 Services not ready yet, will retry when auth completes...')
return
}
console.log('💬 Services ready, initializing chat functionality...')
// Load peers from API
await this.loadPeersFromAPI().catch(error => {
console.warn('Failed to load peers from API:', error)
}) })
// Initialize message handling (subscription + history loading) return
await this.initializeMessageHandling()
// Mark as initialized
this.isInitialized.value = true
console.log('💬 Chat service fully initialized and ready!')
} catch (error) {
console.error('💬 Failed to initialize chat:', error)
this.isInitialized.value = false
} }
await this.completeInitialization()
}
/**
* Complete the initialization once all dependencies are available
*/
private async completeInitialization(): Promise<void> {
// Load peers from API
await this.loadPeersFromAPI().catch(error => {
console.warn('Failed to load peers from API:', error)
})
// Initialize message handling (subscription + history loading)
await this.initializeMessageHandling()
this.debug('Chat service fully initialized and ready!')
} }
// Initialize message handling (subscription + history loading) // Initialize message handling (subscription + history loading)
@ -95,7 +96,7 @@ export class ChatService {
} }
get isReady() { get isReady() {
return computed(() => this.isInitialized.value) return this.isInitialized
} }
// Get messages for a specific peer // Get messages for a specific peer
@ -197,18 +198,17 @@ export class ChatService {
// Check if services are available for messaging // Check if services are available for messaging
private checkServicesAvailable(): { relayHub: any; authService: any } | null { private checkServicesAvailable(): { relayHub: any; authService: any } | null {
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any // Dependencies are already injected by BaseService
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
if (!relayHub || !authService?.user?.value?.prvkey) { if (!this.relayHub || !this.authService?.user?.value?.prvkey) {
return null return null
} }
if (!relayHub.isConnected) { if (!this.relayHub.isConnected) {
return null return null
} }
return { relayHub, authService } return { relayHub: this.relayHub, authService: this.authService }
} }
// Send a message // Send a message
@ -390,16 +390,15 @@ export class ChatService {
// Load message history for known peers // Load message history for known peers
private async loadMessageHistory(): Promise<void> { private async loadMessageHistory(): Promise<void> {
try { try {
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any // Dependencies are already injected by BaseService
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
if (!relayHub || !authService?.user?.value?.pubkey) { if (!this.relayHub || !this.authService?.user?.value?.pubkey) {
console.warn('Cannot load message history: missing services') console.warn('Cannot load message history: missing services')
return return
} }
const userPubkey = authService.user.value.pubkey const userPubkey = this.authService.user.value.pubkey
const userPrivkey = authService.user.value.prvkey const userPrivkey = this.authService.user.value.prvkey
const peerPubkeys = Array.from(this.peers.value.keys()) const peerPubkeys = Array.from(this.peers.value.keys())
if (peerPubkeys.length === 0) { if (peerPubkeys.length === 0) {
@ -411,7 +410,7 @@ export class ChatService {
// Query historical messages (kind 4) to/from known peers // Query historical messages (kind 4) to/from known peers
// We need separate queries for sent vs received messages due to different tagging // We need separate queries for sent vs received messages due to different tagging
const receivedEvents = await relayHub.queryEvents([ const receivedEvents = await this.relayHub.queryEvents([
{ {
kinds: [4], kinds: [4],
authors: peerPubkeys, // Messages from peers authors: peerPubkeys, // Messages from peers
@ -420,7 +419,7 @@ export class ChatService {
} }
]) ])
const sentEvents = await relayHub.queryEvents([ const sentEvents = await this.relayHub.queryEvents([
{ {
kinds: [4], kinds: [4],
authors: [userPubkey], // Messages from us authors: [userPubkey], // Messages from us
@ -474,29 +473,28 @@ export class ChatService {
// Setup subscription for incoming messages // Setup subscription for incoming messages
private setupMessageSubscription(): void { private setupMessageSubscription(): void {
try { try {
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any // Dependencies are already injected by BaseService
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
if (!relayHub || !authService?.user?.value?.pubkey) { if (!this.relayHub || !this.authService?.user?.value?.pubkey) {
console.warn('💬 Cannot setup message subscription: missing services') console.warn('💬 Cannot setup message subscription: missing services')
return return
} }
if (!relayHub.isConnected) { if (!this.relayHub.isConnected) {
console.warn('💬 RelayHub not connected, waiting for connection...') console.warn('💬 RelayHub not connected, waiting for connection...')
// Listen for connection event // Listen for connection event
relayHub.on('connected', () => { this.relayHub.on('connected', () => {
console.log('💬 RelayHub connected, setting up message subscription...') console.log('💬 RelayHub connected, setting up message subscription...')
this.setupMessageSubscription() this.setupMessageSubscription()
}) })
return return
} }
const userPubkey = authService.user.value.pubkey const userPubkey = this.authService.user.value.pubkey
const userPrivkey = authService.user.value.prvkey const userPrivkey = this.authService.user.value.prvkey
// Subscribe to encrypted direct messages (kind 4) addressed to this user // Subscribe to encrypted direct messages (kind 4) addressed to this user
this.subscriptionUnsubscriber = relayHub.subscribe({ this.subscriptionUnsubscriber = this.relayHub.subscribe({
id: 'chat-messages', id: 'chat-messages',
filters: [ filters: [
{ {
@ -573,14 +571,26 @@ export class ChatService {
} }
} }
// Cleanup /**
destroy(): void { * Cleanup when service is disposed (overrides BaseService)
*/
protected async onDispose(): Promise<void> {
// Unsubscribe from message subscription // Unsubscribe from message subscription
if (this.subscriptionUnsubscriber) { if (this.subscriptionUnsubscriber) {
this.subscriptionUnsubscriber() this.subscriptionUnsubscriber()
this.subscriptionUnsubscriber = undefined
} }
this.messages.value.clear() this.messages.value.clear()
this.peers.value.clear() this.peers.value.clear()
this.debug('Chat service disposed')
}
/**
* Legacy destroy method for backward compatibility
*/
destroy(): void {
this.dispose()
} }
} }