UNPKG

@deepkit/app

Version:

Deepkit App, CLI framework and service container

421 lines (363 loc) 14.7 kB
/* * Deepkit Framework * Copyright (C) 2021 Deepkit UG, Marc J. Schmidt * * This program is free software: you can redistribute it and/or modify * it under the terms of the MIT License. * * You should have received a copy of the MIT License along with this program. */ import { ClassType, ExtractClassType, isFunction, isObject, pathBasename, setPathValue } from '@deepkit/core'; import { ConfigLoader, ServiceContainer } from './service-container.js'; import { ConfigureProviderOptions, injectedFunction, InjectorContext, ResolveToken, Scope, Token } from '@deepkit/injector'; import { AppModule, RootModuleDefinition } from './module.js'; import { EnvConfiguration } from './configuration.js'; import { DataEventToken, DispatchArguments, EventDispatcher, EventDispatcherDispatchType, EventListener, EventListenerCallback, EventToken, } from '@deepkit/event'; import { ReceiveType, ReflectionClass, ReflectionKind } from '@deepkit/type'; import { Logger } from '@deepkit/logger'; import { executeCommand, getArgsFromEnvironment, getBinFromEnvironment } from './command.js'; export function setPartialConfig(target: { [name: string]: any }, partial: { [name: string]: any }, incomingPath: string = '') { for (const i in partial) { const path = (incomingPath ? incomingPath + '.' : '') + i; if (isObject(partial[i])) { setPartialConfig(target, partial[i], path); } else { setPathValue(target, path, partial[i]); } } } type EnvNamingStrategy = 'same' | 'upper' | 'lower' | ((name: string) => string | 'same' | 'upper' | 'lower' | undefined); function camelToUpperCase(str: string) { return str.replace(/[A-Z]+/g, (letter: string) => `_${letter.toUpperCase()}`).toUpperCase(); } function camelToLowerCase(str: string) { return str.replace(/[A-Z]+/g, (letter: string) => `_${letter.toLowerCase()}`).toLowerCase(); } function convertNameStrategy(namingStrategy: EnvNamingStrategy, name: string): string { const strategy = isFunction(namingStrategy) ? namingStrategy(name) || 'same' : namingStrategy; if (strategy === 'upper') { return camelToUpperCase(name); } else if (strategy === 'lower') { return camelToLowerCase(name); } else if (strategy === 'same') { return name; } else { return strategy; } } function parseEnv( config: { [name: string]: any }, prefix: string, schema: ReflectionClass<any>, incomingDotPath: string, incomingEnvPath: string, namingStrategy: EnvNamingStrategy, envContainer: { [name: string]: any }, ) { for (const property of schema.getProperties()) { const name = convertNameStrategy(namingStrategy, property.name); if (property.type.kind === ReflectionKind.class || property.type.kind === ReflectionKind.objectLiteral) { parseEnv( config, prefix, ReflectionClass.from(property.type), (incomingDotPath ? incomingDotPath + '.' : '') + property.name, (incomingEnvPath ? incomingEnvPath + '_' : '') + name, namingStrategy, envContainer, ); } else { const dotPath = (incomingDotPath ? incomingDotPath + '.' : '') + property.name; const envName = prefix + (incomingEnvPath ? incomingEnvPath + '_' : '') + name; if (envContainer[envName] === undefined) continue; setPathValue(config, dotPath, envContainer[envName]); } } } /** * Options for configuring an instance of the EnvConfigLoader */ interface EnvConfigOptions { /** * A path or paths to optional .env files that will be processed and mapped to app/module config */ envFilePath?: string | string[], /** * A naming strategy for converting env variables to app/module config. Defaults to 'upper'. * For example, allows converting DB_HOST to dbHost */ namingStrategy?: EnvNamingStrategy, /** * A prefix for environment variables that helps to avoid potential collisions * By default this will be set to APP_ * * Eg. * APP_DATABASE_URL="mongodb://localhost/mydb" will be mapped to databaseUrl when using the upper * naming strategy * */ prefix?: string } const defaultEnvConfigOptions: Required<EnvConfigOptions> = { prefix: 'APP_', envFilePath: ['.env'], namingStrategy: 'upper', }; class EnvConfigLoader { private readonly prefix: string; private readonly envFilePaths: string[]; private readonly namingStrategy: EnvNamingStrategy; constructor(options?: EnvConfigOptions) { const normalizedOptions = { ...defaultEnvConfigOptions, ...options, }; const { prefix, envFilePath, namingStrategy } = normalizedOptions; this.prefix = prefix; this.envFilePaths = Array.isArray(envFilePath) ? envFilePath : [envFilePath]; this.namingStrategy = namingStrategy; } load(module: AppModule<any>, config: { [p: string]: any }, schema: ReflectionClass<any>) { const envConfiguration = new EnvConfiguration(); for (const path of this.envFilePaths) { if (envConfiguration.loadEnvFile(path)) break; } const env = Object.assign({}, envConfiguration.getAll()); Object.assign(env, process.env); parseEnv(config, this.prefix, schema, '', convertNameStrategy(this.namingStrategy, module.name), this.namingStrategy, env); } } export class RootAppModule<T extends RootModuleDefinition> extends AppModule<T> { } export interface AppEvent { /** * The command that is about to be executed. */ command: string; parameters: { [name: string]: any }; /** * Scoped 'cli' injector context. */ injector: InjectorContext; } export interface AppExecutedEvent extends AppEvent { exitCode: number; } export interface AppErrorEvent extends AppEvent { error: Error; } /** * When a CLI command is about to be executed, this event is emitted. * * This is different to @deepkit/framework's onBootstrap event, which is only executed * when the server:start is execute. This event is executed for every CLI command (including server:start). */ export const onAppExecute = new DataEventToken<AppEvent>('app.execute'); /** * When a CLI command is successfully executed, this event is emitted. */ export const onAppExecuted = new DataEventToken<AppExecutedEvent>('app.executed'); /** * When a CLI command failed to execute, this event is emitted. */ export const onAppError = new DataEventToken<AppErrorEvent>('app.error'); /** * When the application is about to shut down, this event is emitted. * This is always executed, even when an error occurred. So it's a good place to clean up. */ export const onAppShutdown = new DataEventToken<AppEvent>('app.shutdown'); /** * This is the smallest available application abstraction in Deepkit. * * It is based on a module and executes registered CLI controllers in `execute`. * * @deepkit/framework extends that with a more powerful Application class, that contains also HTTP and RPC controllers. * * You can use this class for more integrated unit-tests. */ export class App<T extends RootModuleDefinition> { protected envConfigLoader?: EnvConfigLoader; public readonly serviceContainer: ServiceContainer; public appModule: AppModule<ExtractClassType<T['config']>>; constructor( appModuleOptions: T, serviceContainer?: ServiceContainer, appModule?: AppModule<any>, ) { this.appModule = appModule || new RootAppModule({}, appModuleOptions) as any; this.serviceContainer = serviceContainer || new ServiceContainer(this.appModule); } static fromModule<T extends RootModuleDefinition>(module: AppModule<T>): App<T> { return new App({} as T, undefined, module); } /** * Allows to change the module after the configuration has been loaded, right before the service container is built. * * This enables you to change the module or its imports depending on the configuration the last time before their services are built. * * At this point no services can be requested as the service container was not built. */ setup(...args: Parameters<this['appModule']['setup']>): this { this.appModule = (this.appModule.setup as any)(...args as any[]); return this; } /** * Allows to call services before the application bootstraps. * * This enables you to configure modules and request their services. */ use(setup: (...args: any[]) => void): this { this.appModule.use(setup); return this; } /** * Calls a function immediately and resolves all parameters using the * current service container. */ call<T>(fn: (...args: any[]) => T, module?: AppModule<any>): T { const injector = this.serviceContainer.getInjector(module || this.appModule); const resolvedFunction = injectedFunction(fn, injector); return resolvedFunction(); } command(name: string | ((...args: any[]) => any), callback?: (...args: any[]) => any): this { callback = isFunction(name) ? name : callback!; name = isFunction(name) ? '' : name; this.appModule.addCommand(name, callback); return this; } addConfigLoader(loader: ConfigLoader): this { this.serviceContainer.addConfigLoader(loader); return this; } configure(config: Partial<ExtractClassType<T['config']>>): this { this.serviceContainer.appModule.configure(config); return this; } /** * Register a new event listener for given token. * * order: The lower the order, the sooner the listener is called. Default is 0. */ listen<T extends EventToken<any>>(eventToken: T, callback: EventListenerCallback<T>, order: number = 0): this { const listener: EventListener = { callback, order, eventToken }; this.appModule.listeners.push(listener); return this; } dispatch<T extends EventToken<any>>(eventToken: T, ...args: DispatchArguments<T>): EventDispatcherDispatchType<T> { return this.get(EventDispatcher).dispatch(eventToken, ...args); } /** * Loads environment variables and optionally reads from .env files in order to find matching configuration options * in your application and modules in order to set their values. * * Prefixing ENV variables is encouraged to avoid collisions and by default a prefix of APP_ is used * Example: * * APP_databaseUrl="mongodb://localhost/mydb" * * new App({}).loadConfigFromEnvVariables('APP_').run(); * * * `envFilePath` can be either an absolute or relative path. For relative paths the first * folder with a package.json starting from process.cwd() upwards is picked. * * So if you use 'local.env' make sure a 'local.env' file is located beside your 'package.json'. * * @param options Configuration options for retrieving configuration from env * @returns */ loadConfigFromEnv(options?: EnvConfigOptions): this { this.addConfigLoader(new EnvConfigLoader(options)); return this; } /** * Loads a JSON encoded environment variable and applies its content to the configuration. * * Example: * * APP_CONFIG={'databaseUrl": "mongodb://localhost/mydb", "moduleA": {"foo": "bar'}} * * new App().run().loadConfigFromEnvVariable('APP_CONFIG').run(); */ loadConfigFromEnvVariable(variableName: string = 'APP_CONFIG'): this { if (!process.env[variableName]) return this; this.addConfigLoader({ load(module: AppModule<any>, config: { [p: string]: any }, schema: ReflectionClass<any>) { try { const jsonConfig = JSON.parse(process.env[variableName] || ''); setPartialConfig(config, module.name ? jsonConfig[module.name] : jsonConfig); } catch (error) { throw new Error(`Invalid JSON in env variable ${variableName}. Parse error: ${error}`); } }, }); return this; } async run(argv?: any[], bin?: string[]) { const exitCode = await this.execute(argv, bin); if (exitCode > 0) process.exit(exitCode); } get<T>( token?: ReceiveType<T> | Token<T>, moduleOrClass?: AppModule<any> | ClassType<AppModule<any>>, scope?: Scope, ): ResolveToken<T> { return this.serviceContainer.getInjector(moduleOrClass || this.appModule).get(token, scope) as ResolveToken<T>; } getInjector<T>(moduleOrClass?: AppModule<any> | ClassType<AppModule<any>>) { return this.serviceContainer.getInjector(moduleOrClass || this.appModule); } public getInjectorContext(): InjectorContext { return this.serviceContainer.getInjectorContext(); } /** * @see InjectorModule.configureProvider */ configureProvider<T>(configure: (instance: T, ...args: any[]) => any, options: Partial<ConfigureProviderOptions> = {}, type?: ReceiveType<T>): this { this.appModule.configureProvider<T>(configure, options, type); return this; } public async execute(argv?: string[], bin?: string[] | string): Promise<number> { const eventDispatcher = this.get(EventDispatcher); const logger = this.get(Logger); function unhandledRejectionHandler(error: any) { logger.error('unhandledRejection', error); } if ('undefined' !== typeof process) { process.on('unhandledRejection', unhandledRejectionHandler); } const scopedInjectorContext = this.getInjectorContext().createChildScope('cli'); if ('string' !== typeof bin) { bin = bin || getBinFromEnvironment(); let binary = pathBasename(bin[0]); let file = pathBasename(bin[1]); bin = `${binary} ${file}`; } try { return await executeCommand( bin, argv || getArgsFromEnvironment(), eventDispatcher, logger, scopedInjectorContext, this.serviceContainer.cliControllerRegistry.controllers, ); } finally { if ('undefined' !== typeof process) { process.removeListener('unhandledRejection', unhandledRejectionHandler); } } } }