/** * ServiceApplication - Common service initialization and lifecycle management * Encapsulates common patterns for Hono-based microservices */ import type { AwilixContainer } from 'awilix'; import { Hono } from 'hono'; import { cors } from 'hono/cors'; import type { BaseAppConfig, UnifiedAppConfig } from '@stock-bot/config'; import { toUnifiedConfig } from '@stock-bot/config'; import type { HandlerRegistry } from '@stock-bot/handler-registry'; import { getLogger, setLoggerConfig, shutdownLoggers, type Logger } from '@stock-bot/logger'; import { Shutdown, SHUTDOWN_DEFAULTS } from '@stock-bot/shutdown'; import type { IServiceContainer } from '@stock-bot/types'; import type { ServiceDefinitions } from './container/types'; /** * Configuration for ServiceApplication */ export interface ServiceApplicationConfig { /** Service name for logging and identification */ serviceName: string; /** CORS configuration - if not provided, uses permissive defaults */ corsConfig?: Parameters[0]; /** Whether to enable handler initialization */ enableHandlers?: boolean; /** Whether to enable scheduled job creation */ enableScheduledJobs?: boolean; /** Custom shutdown timeout in milliseconds */ shutdownTimeout?: number; /** Service metadata for info endpoint */ serviceMetadata?: { version?: string; description?: string; endpoints?: Record; }; /** Whether to add a basic info endpoint at root */ addInfoEndpoint?: boolean; } /** * Lifecycle hooks for service customization */ export interface ServiceLifecycleHooks { /** Called after container is created but before routes */ onContainerReady?: (container: IServiceContainer) => Promise | void; /** Called after app is created but before routes are mounted */ onAppReady?: (app: Hono, container: IServiceContainer) => Promise | void; /** Called after routes are mounted but before server starts */ onBeforeStart?: (app: Hono, container: IServiceContainer) => Promise | void; /** Called after successful server startup */ onStarted?: (port: number) => Promise | void; /** Called during shutdown before cleanup */ onBeforeShutdown?: () => Promise | void; } /** * ServiceApplication - Manages the complete lifecycle of a microservice */ export class ServiceApplication { private config: UnifiedAppConfig; private serviceConfig: ServiceApplicationConfig; private hooks: ServiceLifecycleHooks; private logger: Logger; private container: AwilixContainer | null = null; private serviceContainer: IServiceContainer | null = null; private app: Hono | null = null; private server: ReturnType | null = null; private shutdown: Shutdown; constructor( config: BaseAppConfig | UnifiedAppConfig, serviceConfig: ServiceApplicationConfig, hooks: ServiceLifecycleHooks = {} ) { // Convert to unified config this.config = toUnifiedConfig(config); // Ensure service name is set in config if (!this.config.service.serviceName) { this.config.service.serviceName = serviceConfig.serviceName; } this.serviceConfig = { shutdownTimeout: 15000, enableHandlers: false, enableScheduledJobs: false, addInfoEndpoint: true, ...serviceConfig, }; this.hooks = hooks; // Initialize logger configuration this.configureLogger(); this.logger = getLogger(this.serviceConfig.serviceName); // Initialize shutdown manager this.shutdown = Shutdown.getInstance({ timeout: this.serviceConfig.shutdownTimeout, }); } /** * Configure logger based on application config */ private configureLogger(): void { if (this.config.log) { setLoggerConfig({ logLevel: this.config.log.level, logConsole: true, logFile: false, environment: this.config.environment, hideObject: this.config.log.hideObject, }); } } /** * Create and configure Hono application with CORS */ private createApp(): Hono { const app = new Hono(); // Add CORS middleware with service-specific or default configuration const corsConfig = this.serviceConfig.corsConfig || { origin: '*', allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'], allowHeaders: ['Content-Type', 'Authorization'], credentials: false, }; app.use('*', cors(corsConfig)); // Add basic info endpoint if enabled if (this.serviceConfig.addInfoEndpoint) { const metadata = this.serviceConfig.serviceMetadata || {}; app.get('/', c => { return c.json({ name: this.serviceConfig.serviceName, version: metadata.version || '1.0.0', description: metadata.description, status: 'running', timestamp: new Date().toISOString(), endpoints: metadata.endpoints || {}, }); }); } return app; } /** * Register graceful shutdown handlers */ private registerShutdownHandlers(): void { // Priority 1: Queue system (highest priority) if (this.serviceConfig.enableScheduledJobs) { this.shutdown.onShutdown( async () => { this.logger.info('Shutting down queue system...'); try { const queueManager = this.container?.resolve('queueManager'); if (queueManager) { await queueManager.shutdown(); } this.logger.info('Queue system shut down'); } catch (error) { this.logger.error('Error shutting down queue system', { error }); } }, SHUTDOWN_DEFAULTS.HIGH_PRIORITY, 'Queue System' ); } // Priority 1: HTTP Server (high priority) this.shutdown.onShutdown( async () => { if (this.server) { this.logger.info('Stopping HTTP server...'); try { this.server.stop(); this.logger.info('HTTP server stopped'); } catch (error) { this.logger.error('Error stopping HTTP server', { error }); } } }, SHUTDOWN_DEFAULTS.HIGH_PRIORITY, 'HTTP Server' ); // Custom shutdown hook if (this.hooks.onBeforeShutdown) { this.shutdown.onShutdown( async () => { try { await this.hooks.onBeforeShutdown!(); } catch (error) { this.logger.error('Error in custom shutdown hook', { error }); } }, SHUTDOWN_DEFAULTS.HIGH_PRIORITY, 'Custom Shutdown' ); } // Priority 2: Services and connections (medium priority) this.shutdown.onShutdown( async () => { this.logger.info('Disposing services and connections...'); try { if (this.container) { // Disconnect database clients const mongoClient = this.container.resolve('mongoClient'); if (mongoClient?.disconnect) { await mongoClient.disconnect(); } const postgresClient = this.container.resolve('postgresClient'); if (postgresClient?.disconnect) { await postgresClient.disconnect(); } const questdbClient = this.container.resolve('questdbClient'); if (questdbClient?.disconnect) { await questdbClient.disconnect(); } this.logger.info('All services disposed successfully'); } } catch (error) { this.logger.error('Error disposing services', { error }); } }, SHUTDOWN_DEFAULTS.MEDIUM_PRIORITY, 'Services' ); // Priority 3: Logger shutdown (lowest priority - runs last) this.shutdown.onShutdown( async () => { try { this.logger.info('Shutting down loggers...'); await shutdownLoggers(); // Don't log after shutdown } catch { // Silently ignore logger shutdown errors } }, SHUTDOWN_DEFAULTS.LOW_PRIORITY, 'Loggers' ); } /** * Start the service with full initialization */ async start( containerFactory: (config: UnifiedAppConfig) => Promise>, routeFactory: (container: IServiceContainer) => Hono, handlerInitializer?: (container: IServiceContainer) => Promise ): Promise { this.logger.info(`Initializing ${this.serviceConfig.serviceName} service...`); try { // Create and initialize container this.logger.debug('Creating DI container...'); // Config already has service name from constructor this.container = await containerFactory(this.config); this.serviceContainer = this.container!.resolve('serviceContainer'); this.logger.info('DI container created and initialized'); // Call container ready hook if (this.hooks.onContainerReady) { await this.hooks.onContainerReady(this.serviceContainer); } // Create Hono application this.app = this.createApp(); // Call app ready hook if (this.hooks.onAppReady) { await this.hooks.onAppReady(this.app, this.serviceContainer); } // Initialize handlers if enabled if (this.serviceConfig.enableHandlers && handlerInitializer) { this.logger.debug('Initializing handlers...'); // Pass the service container with the DI container attached const containerWithDI = Object.assign({}, this.serviceContainer, { _diContainer: this.container, }); await handlerInitializer(containerWithDI); this.logger.info('Handlers initialized'); } // Create and mount routes const routes = routeFactory(this.serviceContainer); this.app.route('/', routes); // Initialize scheduled jobs if enabled if (this.serviceConfig.enableScheduledJobs) { await this.initializeScheduledJobs(); } // Call before start hook if (this.hooks.onBeforeStart) { await this.hooks.onBeforeStart(this.app, this.serviceContainer); } // Register shutdown handlers this.registerShutdownHandlers(); // Start HTTP server const port = this.config.service.port; this.server = Bun.serve({ port, fetch: this.app.fetch, development: this.config.environment === 'development', }); this.logger.info(`${this.serviceConfig.serviceName} service started on port ${port}`); // Call started hook if (this.hooks.onStarted) { await this.hooks.onStarted(port); } } catch (error) { this.logger.error('DETAILED ERROR:', error); this.logger.error('Failed to start service', { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, details: JSON.stringify(error, null, 2), }); throw error; } } /** * Initialize scheduled jobs from handler registry */ private async initializeScheduledJobs(): Promise { if (!this.container) { throw new Error('Container not initialized'); } this.logger.debug('Creating scheduled jobs from registered handlers...'); const handlerRegistry = this.container.resolve('handlerRegistry'); const allHandlers = handlerRegistry.getAllHandlersWithSchedule(); this.logger.info( `Found ${allHandlers.size} handlers with scheduled jobs: ${Array.from(allHandlers.keys()).join(', ')}` ); let totalScheduledJobs = 0; for (const [handlerName, config] of allHandlers) { if (config.scheduledJobs && config.scheduledJobs.length > 0) { // Check if this handler belongs to the current service const ownerService = handlerRegistry.getHandlerService(handlerName); if (ownerService !== this.config.service.serviceName) { this.logger.trace('Skipping scheduled jobs for handler from different service', { handler: handlerName, ownerService, currentService: this.config.service.serviceName, }); continue; } const queueManager = this.container.resolve('queueManager'); if (!queueManager) { this.logger.error('Queue manager is not initialized, cannot create scheduled jobs'); continue; } // Pass the handler registry explicitly when creating queues for scheduled jobs this.logger.debug('Creating queue for scheduled jobs', { handlerName, hasHandlerRegistry: !!handlerRegistry, registeredHandlers: handlerRegistry.getHandlerNames(), }); const queue = queueManager.getQueue(handlerName, { handlerRegistry: handlerRegistry, }); for (const scheduledJob of config.scheduledJobs) { // Include handler and operation info in job data const jobData = { handler: handlerName, operation: scheduledJob.operation, payload: scheduledJob.payload, }; // Build job options from scheduled job config const jobOptions = { priority: scheduledJob.priority, delay: scheduledJob.delay, repeat: { immediately: scheduledJob.immediately, }, }; this.logger.debug('Adding scheduled job', { handler: handlerName, operation: scheduledJob.operation, hasOperation: !!handlerRegistry.getOperation(handlerName, scheduledJob.operation), }); await queue.addScheduledJob( scheduledJob.operation, jobData, scheduledJob.cronPattern, jobOptions ); totalScheduledJobs++; this.logger.debug('Scheduled job created', { handler: handlerName, operation: scheduledJob.operation, cronPattern: scheduledJob.cronPattern, immediately: scheduledJob.immediately, priority: scheduledJob.priority, }); } } } this.logger.info('Scheduled jobs created', { totalJobs: totalScheduledJobs }); // Start queue workers this.logger.debug('Starting queue workers...'); const queueManager = this.container.resolve('queueManager'); if (queueManager) { queueManager.startAllWorkers(); this.logger.info('Queue workers started'); } } /** * Stop the service gracefully */ async stop(): Promise { this.logger.info(`Stopping ${this.serviceConfig.serviceName} service...`); await this.shutdown.shutdown(); } /** * Get the service container (for testing or advanced use cases) */ getServiceContainer(): IServiceContainer | null { return this.serviceContainer; } /** * Get the Hono app (for testing or advanced use cases) */ getApp(): Hono | null { return this.app; } }