import { AsyncLocalStorage } from 'node:async_hooks' import { createRequire } from 'node:module' import { hostname } from 'node:os' import path from 'node:path' import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node' import { AwilixContainer, Constructor, InjectionMode, ModuleDescriptor, NameAndRegistrationPair, asClass, asValue, createContainer, listModules, } from 'awilix' import { LoadedModuleDescriptor, NameFormatter } from 'awilix/lib/load-modules' import lodash from 'lodash' import pluralize from 'pluralize' import type { Class } from 'type-fest' import { Counter, MetricOptions, Observer } from '@diia-inhouse/diia-metrics' import { Env, EnvService } from '@diia-inhouse/env' import { AlsData, Logger, LoggerConstructor, LoggerOptions } from '@diia-inhouse/types' import { ApplicationHooks } from './applicationHooks.js' import { getBaseDeps } from './baseDeps.js' import { AppConfigType, AppDepsType, ConfigFactoryFn, ContainerDependency, DepsFactoryFn, DepsType, LoadDepsFromFolderOptions, OnStartHooksResult, ServiceContext, ServiceOperator, } from './interfaces/application.js' import { BaseDeps } from './interfaces/deps.js' import { NodeEnvLabelsMap, nodeEnvAllowedFields } from './metrics.js' // oxlint-disable-next-line typescript/unbound-method const { camelCase, upperFirst } = lodash const { singular } = pluralize const requireFromHere = createRequire(import.meta.url) export class Application extends ApplicationHooks { container?: AwilixContainer> protected asyncCommunicationClasses: Class[] = [] private config?: AppConfigType private baseContainer: AwilixContainer>> private groupedDepsNames: Record = {} private defaultFoldersWithDeps: Record = { actions: { folderName: 'actions', groupName: 'actionList' }, tasks: { folderName: 'tasks', groupName: 'taskList' }, scheduledTasks: { folderName: 'scheduledTasks', groupName: 'scheduledTaskList' }, eventListeners: { folderName: 'eventListeners', groupName: 'eventListenerList' }, externalEventListeners: { folderName: 'externalEventListeners', groupName: 'externalEventListenerList' }, services: { folderName: 'services' }, dataMappers: { folderName: 'dataMappers', nameFormatter: (name) => name }, repositories: { folderName: 'repositories', nameFormatter: (name) => `${name}Repository` }, views: { folderName: 'views' }, } private readonly nodeEnvObserver: Observer constructor( private readonly serviceName: string, private readonly nodeTracerProvider: NodeTracerProvider, loggerOptions: LoggerOptions, loggerPkg = '@diia-inhouse/diia-logger', ) { super() this.baseContainer = createContainer>>({ injectionMode: InjectionMode.CLASSIC }).register({ serviceName: asValue(this.serviceName), systemServiceName: asValue(EnvService.getVar('APP_NAME', 'string', null) || this.serviceName), hostName: asValue(EnvService.getVar('POD_NAME', 'string', null) || hostname()), envService: asClass(EnvService).singleton(), // prettier-ignore logger: asClass(requireFromHere(loggerPkg).default as LoggerConstructor, { // nosemgrep: eslint.detect-non-literal-require injector: () => ({ options: { redactDisabled: EnvService.getVar('NODE_ENV', 'string') === Env.Sandbox, ...loggerOptions } }), }).singleton(), asyncLocalStorage: asValue(new AsyncLocalStorage()), }) this.nodeEnvObserver = new Observer( 'diia_node_env', nodeEnvAllowedFields, 'Indicates the NODE_ENV environment value', { onCollect: (): ReturnType>['onCollect']> => ({ labels: { env: this.baseContainer.resolve('envService').getEnv() }, value: 1, }), }, ) } async setConfig(factory: ConfigFactoryFn>): Promise { const envService = this.baseContainer.resolve('envService') await envService.init() this.config = await factory(envService, this.serviceName) return this } patchConfig(config: Partial>): void { if (!this.config) { throw new Error('Config should be set before patch') } Object.assign(this.config, config) } getConfig(): AppConfigType { if (!this.config) { throw new Error('Config should be set before getting') } return this.config } async setDeps(factory: DepsFactoryFn, AppDepsType>): Promise { if (!this.config) { throw new Error('Config should be set before deps') } const baseDeps = await this.getBaseDeps() this.baseContainer.register(baseDeps) const appDeps = await factory(this.config, this.baseContainer) this.container = this.baseContainer .createScope>() .register(appDeps as NameAndRegistrationPair>) return this } async initialize(dependencies?: ContainerDependency[]): Promise, DepsType>> { this.setOnShutDownHook() if (dependencies?.length) { this.registerDependencies(dependencies) } else { await this.loadDefaultDepsFolders() } const ctx = this.createContext() return { ...ctx, start: this.start.bind(this), stop: this.stop.bind(this), } } defaultNameFormatter(folderName: string, depsDir?: string): NameFormatter { return (_, descriptor) => { const parsedPath = path.parse(descriptor.path) const fileName = parsedPath.name const dependencyPath = parsedPath.dir .split(`${depsDir ?? this.getDepsDir()}${path.sep}${folderName}`)[1] .split(path.sep) .map((p) => upperFirst(p)) if (fileName !== 'index') { dependencyPath.push(upperFirst(fileName)) } const dependencyType = upperFirst(singular(folderName)) return camelCase(`${dependencyPath.join('')}${dependencyType}`) } } extractDepsFromFolder(options: LoadDepsFromFolderOptions): ModuleDescriptor[] { const { folderName, depsDir } = options let fileMask = options.fileMask ?? '**/*.js' if (folderName === 'repositories') { const adapter = this.container?.resolve('databaseAdapter') fileMask = `{*.js,${adapter}/**/*.js}` } const directory = `${depsDir ?? this.getDepsDir()}/${folderName}/${fileMask}` const modules = listModules(directory) return modules } async extractDependenciesFromModules( modules: Record Promise>>, depsDir: string, ): Promise { modules = this.filterRepositoryModulesByAdapter(modules) const unwrappedModules: { folderName: string; filePath: string; file: () => Promise }[] = [] for (const [folderName, files] of Object.entries(modules)) { for (const [filePath, file] of Object.entries(files)) { unwrappedModules.push({ folderName, filePath, file }) } } const dependencies: ContainerDependency[] = [] for (const dep of unwrappedModules) { const { folderName, filePath, file } = dep const options = this.defaultFoldersWithDeps[folderName] ?? { folderName } const { groupName, nameFormatter = this.defaultNameFormatter(folderName, depsDir) } = options const { name } = path.parse(filePath) const registrationName = nameFormatter(name, { path: filePath } as LoadedModuleDescriptor) // eslint-disable-next-line @typescript-eslint/no-explicit-any const module: any = await file() const defaultExport = module.default?.default ?? module.default dependencies.push({ groupName: groupName, registrationName, dependency: defaultExport }) } return dependencies } async loadDepsFromFolder(options: LoadDepsFromFolderOptions): Promise { const { folderName, depsDir, nameFormatter = this.defaultNameFormatter(folderName, depsDir), groupName } = options const modules = this.extractDepsFromFolder(options) if (groupName) { this.registerGroup(groupName) } const dependencies: ContainerDependency[] = [] for (const module of modules) { const { name, path: modulePath } = module const registrationName = nameFormatter(name, { ...module, value: '' }) const dependency = await import(path.resolve(modulePath)) const defaultExport = dependency.default?.default ?? dependency.default if (typeof defaultExport === 'function') { dependencies.push({ groupName, registrationName, dependency: defaultExport }) } } this.registerDependencies(dependencies) return this } private registerDependency(dependency: Constructor, registrationName: string, groupName: string | undefined): void { this.baseContainer.register( registrationName, asClass(dependency, { injector: (c) => { const logger = c.resolve('logger') const childLogger = logger.child?.({ regName: registrationName }) return { logger: childLogger ?? logger } }, }).singleton(), ) if (groupName) { this.groupedDepsNames[groupName].push(registrationName) } } private registerGroup(groupName: string): void { if (!Object.keys(this.baseContainer.registrations).includes(groupName)) { this.baseContainer.register(groupName, asValue([])) } this.groupedDepsNames[groupName] = this.groupedDepsNames[groupName] || [] } private getDepsDir(): string { return path.resolve(this.config?.depsDir ?? 'dist') } private registerDependencies(dependencies: ContainerDependency[]): void { for (const dependency of dependencies) { const { dependency: defaultExport, groupName, registrationName } = dependency if (groupName) { this.registerGroup(groupName) } if (typeof defaultExport === 'function') { this.registerDependency(defaultExport, registrationName, groupName) } } const groupsToRegister = Object.values(this.defaultFoldersWithDeps).filter(({ groupName }) => groupName) for (const { groupName } of groupsToRegister) { this.registerGroup(groupName!) } } private async getBaseDeps(): Promise>>> { const { config, asyncCommunicationClasses } = this if (!config) { throw new Error('Config should be set before using [getBaseDeps]') } if (config.rabbit) { const { ExternalCommunicator, ExternalEventBus, ScheduledTask, EventBus, Task } = await import('@diia-inhouse/diia-queue') asyncCommunicationClasses.push(ExternalCommunicator, ExternalEventBus, ScheduledTask, EventBus, Task) } return await getBaseDeps(config) } private async start(): Promise { if (!this.container) { throw new Error('Container should be initialized before start') } for (const [aggregateName, moduleNames] of Object.entries(this.groupedDepsNames)) { const group = this.container.resolve(aggregateName) for (const moduleName of moduleNames) { group.push(this.container.resolve(moduleName)) } } try { return await this.runOnStartHooks() } catch (err) { return await this.onShutDown('Failed to start service', err) } } private async stop(): Promise { try { await this.runOnStopHooks() } finally { try { await this.nodeTracerProvider.forceFlush() } catch (err) { this.baseContainer?.resolve('logger').error('Failed to flush tracer', { err }) } } } private async loadDefaultDepsFolders(): Promise { for (const config of Object.values(this.defaultFoldersWithDeps)) { await this.loadDepsFromFolder(config) } } private createContext(): ServiceContext, DepsType> { if (!this.container || !this.config) { throw new Error('Container and config should be initialized before creating context') } return { config: this.config, container: this.container, } } // eslint-disable-next-line @typescript-eslint/explicit-function-return-type private filterRepositoryModulesByAdapter(modules: Record Promise>>) { if (!modules.repositories) { return modules } const databaseAdapter = this.container?.resolve('databaseAdapter') const filteredRepositories = Object.entries(modules.repositories).filter(([filePath]) => { const isInRepositoriesFolder = filePath.includes('/repositories/') const isInSubfolder = filePath.match(/\/repositories\/[^/]+\//) const isInDatabaseAdapterFolder = filePath.includes(`/repositories/${databaseAdapter}/`) return isInRepositoriesFolder && (!isInSubfolder || isInDatabaseAdapterFolder) }) modules.repositories = Object.fromEntries(filteredRepositories) return modules } private setOnShutDownHook(): void { const listenTerminationSignals = this.config?.listenTerminationSignals ?? true if (listenTerminationSignals) { process.on('SIGINT', (signal) => this.onShutDown(`On ${signal} shutdown`)) process.on('SIGQUIT', (signal) => this.onShutDown(`On ${signal} shutdown`)) process.on('SIGTERM', (signal) => this.onShutDown(`On ${signal} shutdown`)) } const UncaughtExceptionMetric = new Counter('uncaught_exceptions_total', [], 'Indicates the number of uncaught exceptions', { registry: this.baseContainer.resolve('metrics').pushGatewayRegistry, }) process.on('uncaughtException', async (err) => { UncaughtExceptionMetric.increment({}) this.baseContainer?.resolve('logger').error('On uncaughtException', { err }) }) const UnhandledRejectionMetric = new Counter('unhandled_rejections_total', [], 'Indicates the number of unhandled rejections', { registry: this.baseContainer.resolve('metrics').pushGatewayRegistry, }) process.on('unhandledRejection', async (err: Error) => { UnhandledRejectionMetric.increment({}) this.baseContainer?.resolve('logger').error('On unhandledRejection', { err }) }) } private async onShutDown(msg: string, error?: unknown): Promise { if (error) { this.baseContainer?.resolve('logger').error(msg, { err: error }) } else { this.baseContainer?.resolve('logger').warn(msg) } try { await this.stop() } catch (err) { this.baseContainer?.resolve('logger').error('Failed to stop service. Shutdown completed', { err }) } setImmediate(() => process.exit(error ? 1 : 0)) return await new Promise(() => undefined) } } export default Application