huge refactor to remove depenencie hell and add typesafe container

This commit is contained in:
Boki 2025-06-24 09:37:51 -04:00
parent 28b9822d55
commit 843a7b9b9b
148 changed files with 3603 additions and 2378 deletions

View file

@ -20,6 +20,8 @@
"@stock-bot/queue": "workspace:*",
"@stock-bot/shutdown": "workspace:*",
"@stock-bot/handlers": "workspace:*",
"@stock-bot/handler-registry": "workspace:*",
"glob": "^10.0.0",
"zod": "^3.23.8",
"hono": "^4.0.0",
"awilix": "^12.0.5"

View file

@ -3,16 +3,16 @@
* Creates a decoupled, reusable dependency injection container
*/
import { type AwilixContainer } from 'awilix';
import type { Browser } from '@stock-bot/browser';
import type { CacheProvider } from '@stock-bot/cache';
import type { IServiceContainer } from '@stock-bot/types';
import type { Logger } from '@stock-bot/logger';
import type { MongoDBClient } from '@stock-bot/mongodb';
import type { PostgreSQLClient } from '@stock-bot/postgres';
import type { ProxyManager } from '@stock-bot/proxy';
import type { QuestDBClient } from '@stock-bot/questdb';
import type { QueueManager } from '@stock-bot/queue';
import { type AwilixContainer } from 'awilix';
import type { IServiceContainer } from '@stock-bot/types';
import type { AppConfig } from './config/schemas';
// Re-export for backward compatibility
@ -41,8 +41,6 @@ export interface ServiceDefinitions {
serviceContainer: IServiceContainer;
}
// Export typed container
export type ServiceContainer = AwilixContainer<ServiceDefinitions>;
export type ServiceCradle = ServiceDefinitions;
@ -59,5 +57,3 @@ export interface ServiceContainerOptions {
enableBrowser?: boolean;
enableProxy?: boolean;
}

View file

@ -1,9 +1,9 @@
import { z } from 'zod';
import { redisConfigSchema } from './redis.schema';
import { mongodbConfigSchema } from './mongodb.schema';
import { postgresConfigSchema } from './postgres.schema';
import { questdbConfigSchema } from './questdb.schema';
import { proxyConfigSchema, browserConfigSchema, queueConfigSchema } from './service.schema';
import { redisConfigSchema } from './redis.schema';
import { browserConfigSchema, proxyConfigSchema, queueConfigSchema } from './service.schema';
export const appConfigSchema = z.object({
redis: redisConfigSchema,
@ -13,11 +13,13 @@ export const appConfigSchema = z.object({
proxy: proxyConfigSchema.optional(),
browser: browserConfigSchema.optional(),
queue: queueConfigSchema.optional(),
service: z.object({
name: z.string(),
serviceName: z.string().optional(), // Standard kebab-case service name
port: z.number().optional(),
}).optional(),
service: z
.object({
name: z.string(),
serviceName: z.string().optional(), // Standard kebab-case service name
port: z.number().optional(),
})
.optional(),
});
export type AppConfig = z.infer<typeof appConfigSchema>;
@ -27,4 +29,4 @@ export * from './redis.schema';
export * from './mongodb.schema';
export * from './postgres.schema';
export * from './questdb.schema';
export * from './service.schema';
export * from './service.schema';

View file

@ -1,9 +1,9 @@
import { z } from 'zod';
export const mongodbConfigSchema = z.object({
enabled: z.boolean().optional().default(true),
uri: z.string(),
database: z.string(),
});
export type MongoDBConfig = z.infer<typeof mongodbConfigSchema>;
import { z } from 'zod';
export const mongodbConfigSchema = z.object({
enabled: z.boolean().optional().default(true),
uri: z.string(),
database: z.string(),
});
export type MongoDBConfig = z.infer<typeof mongodbConfigSchema>;

View file

@ -1,12 +1,12 @@
import { z } from 'zod';
export const postgresConfigSchema = z.object({
enabled: z.boolean().optional().default(true),
host: z.string().default('localhost'),
port: z.number().default(5432),
database: z.string(),
user: z.string(),
password: z.string(),
});
export type PostgresConfig = z.infer<typeof postgresConfigSchema>;
import { z } from 'zod';
export const postgresConfigSchema = z.object({
enabled: z.boolean().optional().default(true),
host: z.string().default('localhost'),
port: z.number().default(5432),
database: z.string(),
user: z.string(),
password: z.string(),
});
export type PostgresConfig = z.infer<typeof postgresConfigSchema>;

View file

@ -1,12 +1,12 @@
import { z } from 'zod';
export const questdbConfigSchema = z.object({
enabled: z.boolean().optional().default(true),
host: z.string().default('localhost'),
httpPort: z.number().optional().default(9000),
pgPort: z.number().optional().default(8812),
influxPort: z.number().optional().default(9009),
database: z.string().optional().default('questdb'),
});
export type QuestDBConfig = z.infer<typeof questdbConfigSchema>;
import { z } from 'zod';
export const questdbConfigSchema = z.object({
enabled: z.boolean().optional().default(true),
host: z.string().default('localhost'),
httpPort: z.number().optional().default(9000),
pgPort: z.number().optional().default(8812),
influxPort: z.number().optional().default(9009),
database: z.string().optional().default('questdb'),
});
export type QuestDBConfig = z.infer<typeof questdbConfigSchema>;

View file

@ -1,12 +1,12 @@
import { z } from 'zod';
export const redisConfigSchema = z.object({
enabled: z.boolean().optional().default(true),
host: z.string().default('localhost'),
port: z.number().default(6379),
password: z.string().optional(),
username: z.string().optional(),
db: z.number().optional().default(0),
});
export type RedisConfig = z.infer<typeof redisConfigSchema>;
import { z } from 'zod';
export const redisConfigSchema = z.object({
enabled: z.boolean().optional().default(true),
host: z.string().default('localhost'),
port: z.number().default(6379),
password: z.string().optional(),
username: z.string().optional(),
db: z.number().optional().default(0),
});
export type RedisConfig = z.infer<typeof redisConfigSchema>;

View file

@ -4,10 +4,12 @@ export const proxyConfigSchema = z.object({
enabled: z.boolean().default(false),
cachePrefix: z.string().optional().default('proxy:'),
ttl: z.number().optional().default(3600),
webshare: z.object({
apiKey: z.string(),
apiUrl: z.string().default('https://proxy.webshare.io/api/v2/'),
}).optional(),
webshare: z
.object({
apiKey: z.string(),
apiUrl: z.string().default('https://proxy.webshare.io/api/v2/'),
})
.optional(),
});
export const browserConfigSchema = z.object({
@ -21,18 +23,23 @@ export const queueConfigSchema = z.object({
concurrency: z.number().optional().default(1),
enableScheduledJobs: z.boolean().optional().default(true),
delayWorkerStart: z.boolean().optional().default(false),
defaultJobOptions: z.object({
attempts: z.number().default(3),
backoff: z.object({
type: z.enum(['exponential', 'fixed']).default('exponential'),
delay: z.number().default(1000),
}).default({}),
removeOnComplete: z.number().default(100),
removeOnFail: z.number().default(50),
timeout: z.number().optional(),
}).optional().default({}),
defaultJobOptions: z
.object({
attempts: z.number().default(3),
backoff: z
.object({
type: z.enum(['exponential', 'fixed']).default('exponential'),
delay: z.number().default(1000),
})
.default({}),
removeOnComplete: z.number().default(100),
removeOnFail: z.number().default(50),
timeout: z.number().optional(),
})
.optional()
.default({}),
});
export type ProxyConfig = z.infer<typeof proxyConfigSchema>;
export type BrowserConfig = z.infer<typeof browserConfigSchema>;
export type QueueConfig = z.infer<typeof queueConfigSchema>;
export type QueueConfig = z.infer<typeof queueConfigSchema>;

View file

@ -1,15 +1,17 @@
import { createContainer, InjectionMode, asFunction, type AwilixContainer } from 'awilix';
import { asClass, asFunction, createContainer, InjectionMode, type AwilixContainer } from 'awilix';
import type { BaseAppConfig as StockBotAppConfig, UnifiedAppConfig } from '@stock-bot/config';
import { appConfigSchema, type AppConfig } from '../config/schemas';
import { toUnifiedConfig } from '@stock-bot/config';
import {
registerCoreServices,
import { HandlerRegistry } from '@stock-bot/handler-registry';
import { appConfigSchema, type AppConfig } from '../config/schemas';
import {
registerApplicationServices,
registerCacheServices,
registerCoreServices,
registerDatabaseServices,
registerApplicationServices
} from '../registrations';
import { HandlerScanner } from '../scanner';
import { ServiceLifecycleManager } from '../utils/lifecycle';
import type { ServiceDefinitions, ContainerBuildOptions } from './types';
import type { ContainerBuildOptions, ServiceDefinitions } from './types';
export class ServiceContainerBuilder {
private config: Partial<AppConfig> = {};
@ -38,7 +40,10 @@ export class ServiceContainerBuilder {
return this;
}
enableService(service: keyof Omit<ContainerBuildOptions, 'skipInitialization' | 'initializationTimeout'>, enabled = true): this {
enableService(
service: keyof Omit<ContainerBuildOptions, 'skipInitialization' | 'initializationTimeout'>,
enabled = true
): this {
this.options[service] = enabled;
return this;
}
@ -51,7 +56,7 @@ export class ServiceContainerBuilder {
async build(): Promise<AwilixContainer<ServiceDefinitions>> {
// Validate and prepare config
const validatedConfig = this.prepareConfig();
// Create container
const container = createContainer<ServiceDefinitions>({
injectionMode: InjectionMode.PROXY,
@ -77,17 +82,19 @@ export class ServiceContainerBuilder {
private applyServiceOptions(config: Partial<AppConfig>): AppConfig {
// Ensure questdb config has the right field names for DI
const questdbConfig = config.questdb ? {
...config.questdb,
influxPort: (config.questdb as any).influxPort || (config.questdb as any).ilpPort || 9009,
} : {
enabled: true,
host: 'localhost',
httpPort: 9000,
pgPort: 8812,
influxPort: 9009,
database: 'questdb',
};
const questdbConfig = config.questdb
? {
...config.questdb,
influxPort: (config.questdb as any).influxPort || (config.questdb as any).ilpPort || 9009,
}
: {
enabled: true,
host: 'localhost',
httpPort: 9000,
pgPort: 8812,
influxPort: 9009,
database: 'questdb',
};
return {
redis: config.redis || {
@ -110,61 +117,88 @@ export class ServiceContainerBuilder {
password: 'postgres',
},
questdb: this.options.enableQuestDB ? questdbConfig : undefined,
proxy: this.options.enableProxy ? (config.proxy || { enabled: false, cachePrefix: 'proxy:', ttl: 3600 }) : undefined,
browser: this.options.enableBrowser ? (config.browser || { headless: true, timeout: 30000 }) : undefined,
queue: this.options.enableQueue ? (config.queue || {
enabled: true,
workers: 1,
concurrency: 1,
enableScheduledJobs: true,
delayWorkerStart: false,
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential' as const, delay: 1000 },
removeOnComplete: 100,
removeOnFail: 50,
}
}) : undefined,
proxy: this.options.enableProxy
? config.proxy || { enabled: false, cachePrefix: 'proxy:', ttl: 3600 }
: undefined,
browser: this.options.enableBrowser
? config.browser || { headless: true, timeout: 30000 }
: undefined,
queue: this.options.enableQueue
? config.queue || {
enabled: true,
workers: 1,
concurrency: 1,
enableScheduledJobs: true,
delayWorkerStart: false,
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential' as const, delay: 1000 },
removeOnComplete: 100,
removeOnFail: 50,
},
}
: undefined,
service: config.service,
};
}
private registerServices(container: AwilixContainer<ServiceDefinitions>, config: AppConfig): void {
private registerServices(
container: AwilixContainer<ServiceDefinitions>,
config: AppConfig
): void {
// Register handler infrastructure first
container.register({
handlerRegistry: asClass(HandlerRegistry).singleton(),
handlerScanner: asClass(HandlerScanner).singleton(),
});
registerCoreServices(container, config);
registerCacheServices(container, config);
registerDatabaseServices(container, config);
registerApplicationServices(container, config);
// Register service container aggregate
container.register({
serviceContainer: asFunction(({
config: _config, logger, cache, globalCache, proxyManager, browser,
queueManager, mongoClient, postgresClient, questdbClient
}) => ({
logger,
cache,
globalCache,
proxy: proxyManager, // Map proxyManager to proxy
browser,
queue: queueManager, // Map queueManager to queue
mongodb: mongoClient, // Map mongoClient to mongodb
postgres: postgresClient, // Map postgresClient to postgres
questdb: questdbClient, // Map questdbClient to questdb
})).singleton(),
serviceContainer: asFunction(
({
config: _config,
logger,
cache,
globalCache,
proxyManager,
browser,
queueManager,
mongoClient,
postgresClient,
questdbClient,
}) => ({
logger,
cache,
globalCache,
proxy: proxyManager, // Map proxyManager to proxy
browser,
queue: queueManager, // Map queueManager to queue
mongodb: mongoClient, // Map mongoClient to mongodb
postgres: postgresClient, // Map postgresClient to postgres
questdb: questdbClient, // Map questdbClient to questdb
})
).singleton(),
});
}
private transformStockBotConfig(config: UnifiedAppConfig): Partial<AppConfig> {
// Unified config already has flat structure, just extract what we need
// Handle questdb field name mapping
const questdb = config.questdb ? {
enabled: config.questdb.enabled || true,
host: config.questdb.host || 'localhost',
httpPort: config.questdb.httpPort || 9000,
pgPort: config.questdb.pgPort || 8812,
influxPort: (config.questdb as any).influxPort || (config.questdb as any).ilpPort || 9009,
database: config.questdb.database || 'questdb',
} : undefined;
const questdb = config.questdb
? {
enabled: config.questdb.enabled || true,
host: config.questdb.host || 'localhost',
httpPort: config.questdb.httpPort || 9000,
pgPort: config.questdb.pgPort || 8812,
influxPort: (config.questdb as any).influxPort || (config.questdb as any).ilpPort || 9009,
database: config.questdb.database || 'questdb',
}
: undefined;
return {
redis: config.redis,
@ -177,4 +211,4 @@ export class ServiceContainerBuilder {
service: config.service,
};
}
}
}

View file

@ -1,48 +1,54 @@
import type { Browser } from '@stock-bot/browser';
import type { CacheProvider } from '@stock-bot/cache';
import type { IServiceContainer } from '@stock-bot/types';
import type { Logger } from '@stock-bot/logger';
import type { MongoDBClient } from '@stock-bot/mongodb';
import type { PostgreSQLClient } from '@stock-bot/postgres';
import type { ProxyManager } from '@stock-bot/proxy';
import type { QuestDBClient } from '@stock-bot/questdb';
import type { SmartQueueManager } from '@stock-bot/queue';
import type { AppConfig } from '../config/schemas';
export interface ServiceDefinitions {
// Configuration
config: AppConfig;
logger: Logger;
// Core services
cache: CacheProvider | null;
globalCache: CacheProvider | null;
proxyManager: ProxyManager | null;
browser: Browser;
queueManager: SmartQueueManager | null;
// Database clients
mongoClient: MongoDBClient | null;
postgresClient: PostgreSQLClient | null;
questdbClient: QuestDBClient | null;
// Aggregate service container
serviceContainer: IServiceContainer;
}
export type ServiceCradle = ServiceDefinitions;
export interface ServiceContainerOptions {
enableQuestDB?: boolean;
enableMongoDB?: boolean;
enablePostgres?: boolean;
enableCache?: boolean;
enableQueue?: boolean;
enableBrowser?: boolean;
enableProxy?: boolean;
}
export interface ContainerBuildOptions extends ServiceContainerOptions {
skipInitialization?: boolean;
initializationTimeout?: number;
}
import type { Browser } from '@stock-bot/browser';
import type { CacheProvider } from '@stock-bot/cache';
import type { HandlerRegistry } from '@stock-bot/handler-registry';
import type { Logger } from '@stock-bot/logger';
import type { MongoDBClient } from '@stock-bot/mongodb';
import type { PostgreSQLClient } from '@stock-bot/postgres';
import type { ProxyManager } from '@stock-bot/proxy';
import type { QuestDBClient } from '@stock-bot/questdb';
import type { SmartQueueManager } from '@stock-bot/queue';
import type { IServiceContainer } from '@stock-bot/types';
import type { AppConfig } from '../config/schemas';
import type { HandlerScanner } from '../scanner';
export interface ServiceDefinitions {
// Configuration
config: AppConfig;
logger: Logger;
// Handler infrastructure
handlerRegistry: HandlerRegistry;
handlerScanner: HandlerScanner;
// Core services
cache: CacheProvider | null;
globalCache: CacheProvider | null;
proxyManager: ProxyManager | null;
browser: Browser;
queueManager: SmartQueueManager | null;
// Database clients
mongoClient: MongoDBClient | null;
postgresClient: PostgreSQLClient | null;
questdbClient: QuestDBClient | null;
// Aggregate service container
serviceContainer: IServiceContainer;
}
export type ServiceCradle = ServiceDefinitions;
export interface ServiceContainerOptions {
enableQuestDB?: boolean;
enableMongoDB?: boolean;
enablePostgres?: boolean;
enableCache?: boolean;
enableQueue?: boolean;
enableBrowser?: boolean;
enableProxy?: boolean;
}
export interface ContainerBuildOptions extends ServiceContainerOptions {
skipInitialization?: boolean;
initializationTimeout?: number;
}

View file

@ -3,10 +3,7 @@ import { NamespacedCache, type CacheProvider } from '@stock-bot/cache';
import type { ServiceDefinitions } from '../container/types';
export class CacheFactory {
static createNamespacedCache(
baseCache: CacheProvider,
namespace: string
): NamespacedCache {
static createNamespacedCache(baseCache: CacheProvider, namespace: string): NamespacedCache {
return new NamespacedCache(baseCache, namespace);
}
@ -15,8 +12,10 @@ export class CacheFactory {
serviceName: string
): CacheProvider | null {
const baseCache = container.cradle.cache;
if (!baseCache) {return null;}
if (!baseCache) {
return null;
}
return this.createNamespacedCache(baseCache, serviceName);
}
@ -25,8 +24,10 @@ export class CacheFactory {
handlerName: string
): CacheProvider | null {
const baseCache = container.cradle.cache;
if (!baseCache) {return null;}
if (!baseCache) {
return null;
}
return this.createNamespacedCache(baseCache, `handler:${handlerName}`);
}
@ -35,10 +36,12 @@ export class CacheFactory {
prefix: string
): CacheProvider | null {
const baseCache = container.cradle.cache;
if (!baseCache) {return null;}
if (!baseCache) {
return null;
}
// Remove 'cache:' prefix if already included
const cleanPrefix = prefix.replace(/^cache:/, '');
return this.createNamespacedCache(baseCache, cleanPrefix);
}
}
}

View file

@ -1 +1 @@
export { CacheFactory } from './cache.factory';
export { CacheFactory } from './cache.factory';

View file

@ -33,3 +33,7 @@ export {
type ServiceApplicationConfig,
type ServiceLifecycleHooks,
} from './service-application';
// Handler scanner
export { HandlerScanner } from './scanner';
export type { HandlerScannerOptions } from './scanner';

View file

@ -12,26 +12,34 @@ export function registerCacheServices(
const { createServiceCache } = require('@stock-bot/queue');
// Get standardized service name from config
const serviceName = config.service?.serviceName || config.service?.name || 'unknown';
// Create service-specific cache that uses the service's Redis DB
return createServiceCache(serviceName, {
host: config.redis.host,
port: config.redis.port,
password: config.redis.password,
db: config.redis.db, // This will be overridden by ServiceCache
}, { logger });
return createServiceCache(
serviceName,
{
host: config.redis.host,
port: config.redis.port,
password: config.redis.password,
db: config.redis.db, // This will be overridden by ServiceCache
},
{ logger }
);
}).singleton(),
// Also provide global cache for shared data
globalCache: asFunction(({ logger }) => {
const { createServiceCache } = require('@stock-bot/queue');
const serviceName = config.service?.serviceName || config.service?.name || 'unknown';
return createServiceCache(serviceName, {
host: config.redis.host,
port: config.redis.port,
password: config.redis.password,
}, { global: true, logger });
return createServiceCache(
serviceName,
{
host: config.redis.host,
port: config.redis.port,
password: config.redis.password,
},
{ global: true, logger }
);
}).singleton(),
});
} else {
@ -40,4 +48,4 @@ export function registerCacheServices(
globalCache: asValue(null),
});
}
}
}

View file

@ -11,4 +11,4 @@ export function registerCoreServices(
config: asValue(config),
logger: asValue(getLogger('di-container')),
});
}
}

View file

@ -1,7 +1,7 @@
import { asFunction, asValue, type AwilixContainer } from 'awilix';
import { MongoDBClient } from '@stock-bot/mongodb';
import { PostgreSQLClient } from '@stock-bot/postgres';
import { QuestDBClient } from '@stock-bot/questdb';
import { asFunction, asValue, type AwilixContainer } from 'awilix';
import type { AppConfig } from '../config/schemas';
import type { ServiceDefinitions } from '../container/types';
@ -14,7 +14,9 @@ export function registerDatabaseServices(
container.register({
mongoClient: asFunction(({ logger }) => {
// Parse MongoDB URI to extract components
const uriMatch = config.mongodb.uri.match(/mongodb:\/\/(?:([^:]+):([^@]+)@)?([^:/]+):(\d+)\/([^?]+)(?:\?authSource=(.+))?/);
const uriMatch = config.mongodb.uri.match(
/mongodb:\/\/(?:([^:]+):([^@]+)@)?([^:/]+):(\d+)\/([^?]+)(?:\?authSource=(.+))?/
);
const mongoConfig = {
host: uriMatch?.[3] || 'localhost',
port: parseInt(uriMatch?.[4] || '27017'),
@ -44,9 +46,9 @@ export function registerDatabaseServices(
username: config.postgres.user,
password: String(config.postgres.password), // Ensure password is a string
};
logger.debug('PostgreSQL config:', {
...pgConfig,
logger.debug('PostgreSQL config:', {
...pgConfig,
password: pgConfig.password ? '***' : 'NO_PASSWORD',
});
return new PostgreSQLClient(pgConfig, logger);
@ -79,4 +81,4 @@ export function registerDatabaseServices(
questdbClient: asValue(null),
});
}
}
}

View file

@ -1,4 +1,4 @@
export { registerCoreServices } from './core.registration';
export { registerCacheServices } from './cache.registration';
export { registerDatabaseServices } from './database.registration';
export { registerApplicationServices } from './service.registration';
export { registerCoreServices } from './core.registration';
export { registerCacheServices } from './cache.registration';
export { registerDatabaseServices } from './database.registration';
export { registerApplicationServices } from './service.registration';

View file

@ -44,9 +44,9 @@ export function registerApplicationServices(
enableMetrics: true,
logger,
});
const proxyManager = new ProxyManager(proxyCache, config.proxy, logger);
// Note: Initialization will be handled by the lifecycle manager
return proxyManager;
}).singleton(),
@ -60,7 +60,7 @@ export function registerApplicationServices(
// Queue Manager
if (config.queue?.enabled && config.redis.enabled) {
container.register({
queueManager: asFunction(({ logger }) => {
queueManager: asFunction(({ logger, handlerRegistry }) => {
const { SmartQueueManager } = require('@stock-bot/queue');
const queueConfig = {
serviceName: config.service?.serviceName || config.service?.name || 'unknown',
@ -79,7 +79,7 @@ export function registerApplicationServices(
delayWorkerStart: config.queue!.delayWorkerStart ?? false,
autoDiscoverHandlers: true,
};
return new SmartQueueManager(queueConfig, logger);
return new SmartQueueManager(queueConfig, handlerRegistry, logger);
}).singleton(),
});
} else {
@ -87,4 +87,4 @@ export function registerApplicationServices(
queueManager: asValue(null),
});
}
}
}

View file

@ -0,0 +1,201 @@
/**
* Handler Scanner
* Discovers and registers handlers with the DI container
*/
import { asClass, type AwilixContainer } from 'awilix';
import { glob } from 'glob';
import type {
HandlerConfiguration,
HandlerMetadata,
HandlerRegistry,
} from '@stock-bot/handler-registry';
import { createJobHandler } from '@stock-bot/handlers';
import { getLogger } from '@stock-bot/logger';
import type { ExecutionContext, IHandler } from '@stock-bot/types';
export interface HandlerScannerOptions {
serviceName?: string;
autoRegister?: boolean;
patterns?: string[];
}
export class HandlerScanner {
private logger = getLogger('handler-scanner');
private discoveredHandlers = new Map<string, any>();
constructor(
private registry: HandlerRegistry,
private container: AwilixContainer,
private options: HandlerScannerOptions = {}
) {}
/**
* Scan for handlers matching the given patterns
*/
async scanHandlers(patterns: string[] = this.options.patterns || []): Promise<void> {
this.logger.info('Starting handler scan', { patterns });
for (const pattern of patterns) {
const files = await glob(pattern, { absolute: true });
this.logger.debug(`Found ${files.length} files for pattern: ${pattern}`);
for (const file of files) {
try {
await this.scanFile(file);
} catch (error) {
this.logger.error('Failed to scan file', { file, error });
}
}
}
this.logger.info('Handler scan complete', {
discovered: this.discoveredHandlers.size,
patterns,
});
}
/**
* Scan a single file for handlers
*/
private async scanFile(filePath: string): Promise<void> {
try {
const module = await import(filePath);
this.registerHandlersFromModule(module, filePath);
} catch (error) {
this.logger.error('Failed to import module', { filePath, error });
}
}
/**
* Register handlers found in a module
*/
private registerHandlersFromModule(module: any, filePath: string): void {
for (const [exportName, exported] of Object.entries(module)) {
if (this.isHandler(exported)) {
this.registerHandler(exported, exportName, filePath);
}
}
}
/**
* Check if an exported value is a handler
*/
private isHandler(exported: any): boolean {
if (typeof exported !== 'function') return false;
// Check for handler metadata added by decorators
const hasHandlerName = !!(exported as any).__handlerName;
const hasOperations = Array.isArray((exported as any).__operations);
return hasHandlerName && hasOperations;
}
/**
* Register a handler with the registry and DI container
*/
private registerHandler(HandlerClass: any, exportName: string, filePath: string): void {
const handlerName = HandlerClass.__handlerName;
const operations = HandlerClass.__operations || [];
const schedules = HandlerClass.__schedules || [];
const isDisabled = HandlerClass.__disabled || false;
if (isDisabled) {
this.logger.debug('Skipping disabled handler', { handlerName });
return;
}
// Build metadata
const metadata: HandlerMetadata = {
name: handlerName,
service: this.options.serviceName,
operations: operations.map((op: any) => ({
name: op.name,
method: op.method,
})),
schedules: schedules.map((schedule: any) => ({
operation: schedule.operation,
cronPattern: schedule.cronPattern,
priority: schedule.priority,
immediately: schedule.immediately,
description: schedule.description,
})),
};
// Build configuration with operation handlers
const operationHandlers: Record<string, any> = {};
for (const op of operations) {
operationHandlers[op.name] = createJobHandler(async payload => {
const handler = this.container.resolve<IHandler>(handlerName);
const context: ExecutionContext = {
type: 'queue',
metadata: { source: 'queue', timestamp: Date.now() },
};
return await handler.execute(op.name, payload, context);
});
}
const configuration: HandlerConfiguration = {
name: handlerName,
operations: operationHandlers,
scheduledJobs: schedules.map((schedule: any) => {
const operation = operations.find((op: any) => op.method === schedule.operation);
return {
type: `${handlerName}-${schedule.operation}`,
operation: operation?.name || schedule.operation,
cronPattern: schedule.cronPattern,
priority: schedule.priority || 5,
immediately: schedule.immediately || false,
description: schedule.description || `${handlerName} ${schedule.operation}`,
};
}),
};
// Register with registry
this.registry.register(metadata, configuration);
// Register with DI container if auto-register is enabled
if (this.options.autoRegister !== false) {
this.container.register({
[handlerName]: asClass(HandlerClass).singleton(),
});
}
// Track discovered handler
this.discoveredHandlers.set(handlerName, HandlerClass);
this.logger.info('Registered handler', {
handlerName,
exportName,
filePath,
operations: operations.length,
schedules: schedules.length,
service: this.options.serviceName,
});
}
/**
* Get all discovered handlers
*/
getDiscoveredHandlers(): Map<string, any> {
return new Map(this.discoveredHandlers);
}
/**
* Manually register a handler class
*/
registerHandlerClass(HandlerClass: any, options: { serviceName?: string } = {}): void {
const serviceName = options.serviceName || this.options.serviceName;
const originalServiceName = this.options.serviceName;
// Temporarily override service name if provided
if (serviceName) {
this.options.serviceName = serviceName;
}
this.registerHandler(HandlerClass, HandlerClass.name, 'manual');
// Restore original service name
this.options.serviceName = originalServiceName;
}
}

View file

@ -0,0 +1,2 @@
export { HandlerScanner } from './handler-scanner';
export type { HandlerScannerOptions } from './handler-scanner';

View file

@ -5,12 +5,14 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { getLogger, setLoggerConfig, shutdownLoggers, type Logger } from '@stock-bot/logger';
import { Shutdown } from '@stock-bot/shutdown';
import type { BaseAppConfig, UnifiedAppConfig } from '@stock-bot/config';
import { toUnifiedConfig } from '@stock-bot/config';
import { getLogger, setLoggerConfig, shutdownLoggers, type Logger } from '@stock-bot/logger';
import { Shutdown } from '@stock-bot/shutdown';
import type { IServiceContainer } from '@stock-bot/types';
import type { ServiceContainer } from './awilix-container';
import type { HandlerRegistry } from '@stock-bot/handler-registry';
import type { ServiceDefinitions } from './container/types';
import type { AwilixContainer } from 'awilix';
/**
* Configuration for ServiceApplication
@ -18,26 +20,26 @@ import type { ServiceContainer } from './awilix-container';
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;
}
@ -48,16 +50,16 @@ export interface ServiceApplicationConfig {
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;
}
@ -70,13 +72,13 @@ export class ServiceApplication {
private serviceConfig: ServiceApplicationConfig;
private hooks: ServiceLifecycleHooks;
private logger: Logger;
private container: ServiceContainer | null = null;
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,
@ -84,12 +86,12 @@ export class ServiceApplication {
) {
// 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,
@ -98,17 +100,17 @@ export class ServiceApplication {
...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
this.shutdown = Shutdown.getInstance({
timeout: this.serviceConfig.shutdownTimeout,
});
}
/**
* Configure logger based on application config
*/
@ -123,13 +125,13 @@ export class ServiceApplication {
});
}
}
/**
* 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: '*',
@ -137,9 +139,9 @@ export class ServiceApplication {
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 || {};
@ -154,10 +156,10 @@ export class ServiceApplication {
});
});
}
return app;
}
/**
* Register graceful shutdown handlers
*/
@ -177,7 +179,7 @@ export class ServiceApplication {
}
}, 'Queue System');
}
// Priority 1: HTTP Server (high priority)
this.shutdown.onShutdownHigh(async () => {
if (this.server) {
@ -190,7 +192,7 @@ export class ServiceApplication {
}
}
}, 'HTTP Server');
// Custom shutdown hook
if (this.hooks.onBeforeShutdown) {
this.shutdown.onShutdownHigh(async () => {
@ -201,7 +203,7 @@ export class ServiceApplication {
}
}, 'Custom Shutdown');
}
// Priority 2: Services and connections (medium priority)
this.shutdown.onShutdownMedium(async () => {
this.logger.info('Disposing services and connections...');
@ -212,24 +214,24 @@ export class ServiceApplication {
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 });
}
}, 'Services');
// Priority 3: Logger shutdown (lowest priority - runs last)
this.shutdown.onShutdownLow(async () => {
try {
@ -241,62 +243,62 @@ export class ServiceApplication {
}
}, 'Loggers');
}
/**
* Start the service with full initialization
*/
async start(
containerFactory: (config: UnifiedAppConfig) => Promise<ServiceContainer>,
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.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...');
await handlerInitializer(this.serviceContainer);
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({
@ -304,14 +306,13 @@ export class ServiceApplication {
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', {
@ -322,7 +323,7 @@ export class ServiceApplication {
throw error;
}
}
/**
* Initialize scheduled jobs from handler registry
*/
@ -330,17 +331,17 @@ export class ServiceApplication {
if (!this.container) {
throw new Error('Container not initialized');
}
this.logger.debug('Creating scheduled jobs from registered handlers...');
const { handlerRegistry } = await import('@stock-bot/handlers');
const handlerRegistry = this.container.resolve<HandlerRegistry>('handlerRegistry');
const allHandlers = handlerRegistry.getAllHandlersWithSchedule();
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,
@ -349,14 +350,14 @@ export class ServiceApplication {
});
continue;
}
const queueManager = this.container.resolve('queueManager');
if (!queueManager) {
this.logger.error('Queue manager is not initialized, cannot create scheduled jobs');
continue;
}
const queue = queueManager.getQueue(handlerName);
for (const scheduledJob of config.scheduledJobs) {
// Include handler and operation info in job data
const jobData = {
@ -364,7 +365,7 @@ export class ServiceApplication {
operation: scheduledJob.operation,
payload: scheduledJob.payload,
};
// Build job options from scheduled job config
const jobOptions = {
priority: scheduledJob.priority,
@ -373,7 +374,7 @@ export class ServiceApplication {
immediately: scheduledJob.immediately,
},
};
await queue.addScheduledJob(
scheduledJob.operation,
jobData,
@ -392,7 +393,7 @@ export class ServiceApplication {
}
}
this.logger.info('Scheduled jobs created', { totalJobs: totalScheduledJobs });
// Start queue workers
this.logger.debug('Starting queue workers...');
const queueManager = this.container.resolve('queueManager');
@ -401,7 +402,7 @@ export class ServiceApplication {
this.logger.info('Queue workers started');
}
}
/**
* Stop the service gracefully
*/
@ -409,18 +410,18 @@ export class ServiceApplication {
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;
}
}
}

View file

@ -1,6 +1,6 @@
import type { AwilixContainer } from 'awilix';
import type { ServiceDefinitions } from '../container/types';
import { getLogger } from '@stock-bot/logger';
import type { ServiceDefinitions } from '../container/types';
interface ServiceWithLifecycle {
connect?: () => Promise<void>;
@ -29,13 +29,16 @@ export class ServiceLifecycleManager {
for (const { name, key } of this.services) {
const service = container.cradle[key] as ServiceWithLifecycle | null;
if (service) {
const initPromise = this.initializeService(name, service);
initPromises.push(
Promise.race([
initPromise,
this.createTimeoutPromise(timeout, `${name} initialization timed out after ${timeout}ms`),
this.createTimeoutPromise(
timeout,
`${name} initialization timed out after ${timeout}ms`
),
])
);
}
@ -51,7 +54,7 @@ export class ServiceLifecycleManager {
// Shutdown in reverse order
for (const { name, key } of [...this.services].reverse()) {
const service = container.cradle[key] as ServiceWithLifecycle | null;
if (service) {
shutdownPromises.push(this.shutdownService(name, service));
}