@kitmi/jacaranda
Version:
JavaScript application framework
301 lines (249 loc) • 9.11 kB
JavaScript
import { _, sleep_, batchAsync_ } from '@kitmi/utils';
import { InvalidConfiguration } from '@kitmi/types';
import { defaultRunnableOpts } from './defaultOpts';
import { fs, isDir_ } from '@kitmi/sys';
import path from 'node:path';
import minimist from 'minimist';
import { loadModuleFrom_ } from './helpers';
/**
* Runnable app mixin.
* @param {object} T - Base class.
* @returns {Runnable} A runable app class.
* @constructs Runnable(T)
*/
const Runnable = (T) =>
class extends T {
_getOnUncaughtException = (exitOnError) => (err) => {
if (exitOnError) {
//wait 1 second for flushing the last log
this.log('error', err);
} else {
this.logError(err);
}
};
_onWarning = (warning) => {
this.log('warn', warning.message);
};
_onExit = (code) => {
if (this.started) {
if (this._logCache.length) {
this.flushLogCache();
}
}
this.stop_().catch(this.logError);
};
/**
* @param {string} name - The name of the application.
* @param {object} [options] - Application options
* @property {string} [options.logLevel='info'] - Logging level
* @property {object} [options.ignoreUncaught=false] - Whether to skip the handling of uncaught exception
* @property {object} [options.exitOnUncaught=true] - Whether to exit process on uncaught exception thrown
* @property {object} [options.logFeatures=false] - Log enabled features
* @property {object} [options.logConfig=false] - Log finalized config
* @constructs Runnable
*/
constructor(name, options) {
if (process.argv.length > 2) {
const { _, ...cliOptions } = minimist(process.argv.slice(2));
options = { ...cliOptions, ...options };
}
super(name, {
...defaultRunnableOpts,
...options,
});
this.runnable = true;
this.libModulesPath = this.toAbsolutePath(this.options.libModulesPath);
}
/**
* Start the app
* @returns {Promise}
* @memberof Runnable
*/
async start_() {
if (this.started) {
throw new Error('App already started.');
}
this._initialize();
process.on('exit', this._onExit);
await super.start_();
if (
this.options.logFeatures &&
(this.options.logLevel === 'verbose' || this.options.logLevel === 'debug')
) {
const childModules = {};
this.visitChildModules((childModule, name) => {
childModules[name] = {
features: Object.keys(childModule.features),
services: Object.keys(childModule.services),
};
});
this.log('verbose', 'Enabled features & services:', {
features: Object.keys(this.features),
services: Object.keys(this.services),
modules: childModules,
});
}
return this;
}
/**
* Stop the app
* @returns {Promise}
* @memberof Runnable
*/
async stop_() {
let stopByThis = false;
if (this.started) {
stopByThis = true;
if (this.libModules) {
await batchAsync_(this.libModules, (lib) => lib.stop_());
delete this.libModules;
}
}
process.removeListener('exit', this._onExit);
await super.stop_();
await sleep_(0);
if (stopByThis) {
this._uninitialize();
}
}
/**
* Flush pending async logs
*/
async flushLogs() {
this.visitChildModules((childModule) => {
if (childModule.logger.flush) {
childModule.logger.flush();
}
});
if (this.logger.flush) {
await new Promise((resolve) => {
this.logger.flush(resolve);
});
return sleep_(100);
}
return Promise.resolve();
}
/**
* Visit child modules
* @param {function} vistor
*/
visitChildModules(vistor) {
if (this.libModules) {
_.each(this.libModules, vistor);
}
}
/**
* Try to load a module.
* @param {object} config
* @param {string} name
* @param {string} fromFeature
* @returns {object} { appPath, moduleMeta }
*/
async tryLoadModule_(config, name, defaultBasePath, configItemName) {
let appPath;
let moduleMeta;
// { npmModule: true } is a backward compatibility for old config
const configSource = config.source || (config.npmModule ? 'runtime' : null);
if (configSource != null) {
moduleMeta = await loadModuleFrom_(
this,
configSource,
configSource === 'direct' ? config.packageName ?? name : name,
configSource === 'registry' ? 'modules' : null
);
appPath = moduleMeta.appPath;
} else {
appPath = path.join(defaultBasePath, name);
}
let exists = (await fs.pathExists(appPath)) && (await isDir_(appPath));
if (!exists) {
throw new InvalidConfiguration(`Module [${name}] not found at "${appPath}".`, this, configItemName);
}
return { appPath, moduleMeta };
}
/**
* Get the lib module
* @param {string} libName
* @memberof Runnable
*/
getLib(libName) {
if (!this.libModules) {
throw new Error('"libs" feature is required to access lib among modules.');
}
let libModule = this.libModules[libName];
if (!libModule) {
throw new Error(`Lib module [${libName}] not found.`);
}
return libModule;
}
/**
* Register a loaded lib module
* @param {LibModule} lib
* @memberof Runnable
*/
registerLib(lib) {
if (!this.libModules) {
this.libModules = {};
}
if (lib.name in this.libModules) {
throw new InvalidConfiguration(`Lib module [${lib.name}] already exists.`, this, {
name: lib.name,
});
}
this.libModules[lib.name] = lib;
}
/**
* Get a registered service
* @param {string} name
*
* @example
* // Get service from a lib module
* const service = app.getService('<lib name>/<service name>');
* // e.g const service = app.getService('data/mysql.mydb');
* @memberof Runnable
*/
getService(name) {
let pos = name.indexOf('/');
if (pos === -1) {
return super.getService(name);
}
let lib = name.substring(0, pos);
name = name.substring(pos + 1);
let app = this.getLib(lib);
return app?.getService(name, true);
}
_initialize() {
this._pwd = process.cwd();
if (this.workingPath !== this._pwd) {
process.chdir(this.workingPath);
}
this._injectErrorHandlers();
}
_uninitialize() {
const detach = true;
this._injectErrorHandlers(detach);
process.chdir(this._pwd);
delete this._pwd;
}
_injectErrorHandlers(detach) {
if (detach) {
process.removeListener('warning', this._onWarning);
if (this._onUncaughtException) {
process.removeListener('uncaughtException', this._onUncaughtException);
delete this._onUncaughtException;
}
return;
}
if (!this.options.ignoreUncaught) {
this._onUncaughtException = this._getOnUncaughtException(this.options.exitOnUncaught);
process.on('uncaughtException', this._onUncaughtException);
}
process.on('warning', this._onWarning);
}
_getFeatureFallbackPath() {
let pathArray = super._getFeatureFallbackPath();
pathArray.splice(1, 0, path.resolve(__dirname, 'appFeatures'));
return pathArray;
}
};
export default Runnable;