470 lines
15 KiB
TypeScript
470 lines
15 KiB
TypeScript
/**
|
|
* 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<typeof cors>[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<string, string>;
|
|
};
|
|
|
|
/** 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> | void;
|
|
|
|
/** Called after app is created but before routes are mounted */
|
|
onAppReady?: (app: Hono, container: IServiceContainer) => Promise<void> | void;
|
|
|
|
/** Called after routes are mounted but before server starts */
|
|
onBeforeStart?: (app: Hono, container: IServiceContainer) => Promise<void> | void;
|
|
|
|
/** Called after successful server startup */
|
|
onStarted?: (port: number) => Promise<void> | void;
|
|
|
|
/** Called during shutdown before cleanup */
|
|
onBeforeShutdown?: () => Promise<void> | 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<ServiceDefinitions> | null = null;
|
|
private serviceContainer: IServiceContainer | null = null;
|
|
private app: Hono | null = null;
|
|
private server: ReturnType<typeof Bun.serve> | 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<AwilixContainer<ServiceDefinitions>>,
|
|
routeFactory: (container: IServiceContainer) => Hono,
|
|
handlerInitializer?: (container: IServiceContainer) => Promise<void>
|
|
): Promise<void> {
|
|
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<void> {
|
|
if (!this.container) {
|
|
throw new Error('Container not initialized');
|
|
}
|
|
|
|
this.logger.debug('Creating scheduled jobs from registered handlers...');
|
|
const handlerRegistry = this.container.resolve<HandlerRegistry>('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<void> {
|
|
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;
|
|
}
|
|
}
|