import {Container, ContainerBlueprint} from '../di' import { ErrorWithContext, globalRegistry, infer, isLoggingLevel, PathLike, StandardLogger, universalPath, UniversalPath, } from '../util' import {Logging} from '../service/Logging' import {RunLevelErrorHandler} from './RunLevelErrorHandler' import {Unit, UnitStatus} from './Unit' import * as dotenv from 'dotenv' import {CacheFactory} from '../support/cache/CacheFactory' import {FileLogger} from '../util/logging/FileLogger' /** * Helper function that resolves and infers environment variable values. * * If none is found, returns `defaultValue`. * * @param key * @param defaultValue */ export function env(key: string, defaultValue?: unknown): any { return Application.getApplication().env(key, defaultValue) } /** * Helper function for fetching a universal path relative to the root of the application. * @param parts */ export function basePath(...parts: PathLike[]): UniversalPath { return Application.getApplication().path(...parts) } /** * Helper function for fetching a universal path relative to the `app/` directory. * @param parts */ export function appPath(...parts: PathLike[]): UniversalPath { return Application.getApplication().appPath(...parts) } /** * The main application container. */ export class Application extends Container { public static readonly NODE_MODULES_INJECTION = 'extollo/npm' public static get NODE_MODULES_PROVIDER(): string { return process.env.EXTOLLO_NPM || 'pnpm' } public static getContainer(): Container { const existing = globalRegistry.getGlobal('extollo/injector') if ( !existing ) { const container = new Application() ContainerBlueprint.getContainerBlueprint() .resolve() .map(factory => container.registerFactory(factory)) globalRegistry.setGlobal('extollo/injector', container) return container } return existing as Container } /** * Get the global application instance. */ public static getApplication(): Application { const existing = globalRegistry.getGlobal('extollo/injector') if ( existing instanceof Application ) { return existing } else if ( existing ) { const app = new Application() existing.cloneTo(app) globalRegistry.setGlobal('extollo/injector', app) return app } else { const app = new Application() ContainerBlueprint.getContainerBlueprint() .resolve() .map(factory => app.registerFactory(factory)) globalRegistry.setGlobal('extollo/injector', app) return app } } /** * The fully-qualified path to the base directory of the app. * @protected */ protected baseDir!: string /** * Resolved universal path to the base directory of the app. * @protected */ protected basePath!: UniversalPath /** * The Unit classes registered with the app. * @protected */ protected applicationUnits: (typeof Unit)[] = [] /** * Instances of the units registered with this app. * @protected */ protected instantiatedUnits: Unit[] = [] /** * If true, the "Starting Extollo..." messages will always * be logged. */ public forceStartupMessage = true constructor() { super() if ( !this.hasKey(Application) ) { this.register(Application) this.instances.push({ key: Application, value: this, }) } if ( !this.hasKey('app') ) { this.registerSingleton('app', this) } } /** * Returns true if the given unit class is registered with the application. * @param unitClass */ public hasUnit(unitClass: typeof Unit): boolean { return this.applicationUnits.includes(unitClass) } /** * Return a UniversalPath to the root of the application. */ get root(): UniversalPath { return this.basePath.concat() } /** * Returns a UniversalPath to the `app/` directory in the application. */ get appRoot(): UniversalPath { return this.basePath.concat('app') } /** * Resolve a path relative to the root of the application. * @param parts */ path(...parts: PathLike[]): UniversalPath { return this.basePath.concat(...parts) } /** * Resolve a path relative to the `app/` directory in the application. * @param parts */ appPath(...parts: PathLike[]): UniversalPath { return this.basePath.concat('app', ...parts) } /** * Get an instance of the RunLevelErrorHandler. */ get errorHandler(): (e: Error) => void { const rleh: RunLevelErrorHandler = this.make(RunLevelErrorHandler) return rleh.handle } /** * Wrap a base Error instance into an ErrorWithContext. * @param e * @param context */ errorWrapContext(e: Error, context: {[key: string]: any}): ErrorWithContext { const rleh: RunLevelErrorHandler = this.make(RunLevelErrorHandler) return rleh.wrapContext(e, context) } /** * Set up the bare essentials to get the application up and running. * @param absolutePathToApplicationRoot * @param applicationUnits */ scaffold(absolutePathToApplicationRoot: string, applicationUnits: (typeof Unit)[]): void { this.baseDir = absolutePathToApplicationRoot this.basePath = universalPath(absolutePathToApplicationRoot) this.applicationUnits = applicationUnits this.bootstrapEnvironment() this.setupLogging() this.registerFactory(new CacheFactory()) // FIXME move this somewhere else? this.registerSingleton(Application.NODE_MODULES_INJECTION, Application.NODE_MODULES_PROVIDER) this.make(Logging).debug(`Application root: ${this.baseDir}`) } /** * Initialize the logger and load the logging level from the environment. * @protected */ protected setupLogging(): void { const standard: StandardLogger = this.make(StandardLogger) const logging: Logging = this.make(Logging) logging.registerLogger(standard) if ( this.env('EXTOLLO_LOGGING_ENABLE_FILE') ) { const file: FileLogger = this.make(FileLogger) logging.registerLogger(file) } logging.verbose('Attempting to load logging level from the environment...') const envLevel = this.env('EXTOLLO_LOGGING_LEVEL') logging.verbose(`Read logging level: ${envLevel}`) if ( isLoggingLevel(envLevel) ) { logging.verbose('Logging level is valid.') logging.level = envLevel logging.debug(`Set logging level from environment: ${envLevel}`) } } /** * Initialize the environment variable library and read from the `.env` file. * @protected */ protected bootstrapEnvironment(): void { dotenv.config({ path: this.basePath.concat('.env').toLocal, }) } /** * Get a value from the loaded environment variables. * If no value could be found, the default value will be returned. * @param key * @param defaultValue */ public env(key: string, defaultValue?: unknown): any { return infer(process.env[key] ?? '') ?? defaultValue } /** * Run the application by starting all units in order, then stopping them in reverse order. */ async run(): Promise { try { await this.up() await this.down() } catch (e) { this.errorHandler(e) } } /** * Start all units in the application, one at a time, in order. */ async up(): Promise { const logging: Logging = this.make(Logging) logging.info('Starting Extollo...', this.forceStartupMessage) for ( const unitClass of this.applicationUnits ) { const unit: Unit = this.make(unitClass) this.instantiatedUnits.push(unit) await this.startUnit(unit) } } /** * Stop all units in the application, one at a time, in reverse order. */ async down(): Promise { const logging: Logging = this.make(Logging) logging.info('Stopping Extollo...', this.forceStartupMessage) for ( const unit of [...this.instantiatedUnits].reverse() ) { if ( !unit ) { continue } await this.stopUnit(unit) } } /** * Start a single unit, setting its status. * @param unit */ public async startUnit(unit: Unit): Promise { const logging: Logging = this.make(Logging) try { logging.debug(`Starting ${unit.constructor.name}...`) unit.status = UnitStatus.Starting await unit.up() unit.status = UnitStatus.Started logging.info(`Started ${unit.constructor.name}.`) } catch (e) { unit.status = UnitStatus.Error throw this.errorWrapContext(e, {unitName: unit.constructor.name}) } } /** * Stop a single unit, setting its status. * @param unit */ public async stopUnit(unit: Unit): Promise { const logging: Logging = this.make(Logging) try { logging.debug(`Stopping ${unit.constructor.name}...`) unit.status = UnitStatus.Stopping await unit.down() unit.status = UnitStatus.Stopped logging.info(`Stopped ${unit.constructor.name}.`) } catch (e) { unit.status = UnitStatus.Error throw this.errorWrapContext(e, {unitName: unit.constructor.name}) } } }