You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
lib/src/lifecycle/Application.ts

373 lines
11 KiB

import {Container} from '../di'
import {
ErrorWithContext,
FileLogger,
globalRegistry,
ifDebugging,
infer,
isLoggingLevel,
LoggingLevel,
logIfDebugging,
PathLike,
StandardLogger,
universalPath,
UniversalPath,
} from '../util'
import {Logging} from '../service/Logging'
import {RunLevelErrorHandler} from './RunLevelErrorHandler'
import {Unit, UnitStatus} from './Unit'
import * as fs from 'fs'
import * as dotenv from 'dotenv'
import {CacheFactory} from '../support/cache/CacheFactory'
/**
* 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 = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
if ( !existing ) {
const container = Application.realizeContainer(new Application())
globalRegistry.setGlobal('extollo/injector', container)
return container
}
return existing as Container
}
/**
* Get the global application instance.
*/
public static getApplication(): Application {
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
if ( existing instanceof Application ) {
return existing
} else if ( existing ) {
const app = Application.realizeContainer(new Application())
existing.cloneTo(app)
globalRegistry.setGlobal('extollo/injector', app)
return app
} else {
const app = Application.realizeContainer(new Application())
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>(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>(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>(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>(StandardLogger)
const logging: Logging = this.make<Logging>(Logging)
logging.registerLogger(standard)
if ( this.env('EXTOLLO_LOGGING_ENABLE_FILE') ) {
const file: FileLogger = this.make<FileLogger>(FileLogger)
logging.registerLogger(file)
}
logging.level = LoggingLevel.Verbose
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 {
let path = this.basePath.concat('.env').toLocal
logIfDebugging('extollo.env', `Trying .env path: ${path}`)
if ( fs.existsSync(path) ) {
dotenv.config({
path,
})
return
}
path = this.basePath.concat('../.env').toLocal
logIfDebugging('extollo.env', `Trying .env path: ${path}`)
if ( fs.existsSync(path) ) {
dotenv.config({
path,
})
}
}
/**
* 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<void> {
try {
await this.up()
ifDebugging('extollo.wontstop', () => {
setTimeout(() => {
import('wtfnode').then(wtf => wtf.dump())
}, 1000)
})
await this.down()
} catch (e: unknown) {
if ( e instanceof Error ) {
this.errorHandler(e)
}
throw e
}
}
/**
* Start all units in the application, one at a time, in order.
*/
async up(): Promise<void> {
const logging: Logging = this.make<Logging>(Logging)
logging.info('Starting Extollo...', this.forceStartupMessage)
for ( const unitClass of this.applicationUnits ) {
const unit: Unit = this.make<Unit>(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<void> {
const logging: Logging = this.make<Logging>(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<void> {
const logging: Logging = this.make<Logging>(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: unknown) {
unit.status = UnitStatus.Error
if ( e instanceof Error ) {
throw this.errorWrapContext(e, {unitName: unit.constructor.name})
}
throw e
}
}
/**
* Stop a single unit, setting its status.
* @param unit
*/
public async stopUnit(unit: Unit): Promise<void> {
const logging: Logging = this.make<Logging>(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
if ( e instanceof Error ) {
throw this.errorWrapContext(e, {unitName: unit.constructor.name})
}
throw e
}
}
}