@athenna/core
Version:
One foundation for multiple applications.
521 lines (520 loc) • 17.9 kB
JavaScript
/**
* @athenna/core
*
* (c) João Lenon <lenon@athenna.io>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import sourceMapSupport from 'source-map-support';
import { Ioc } from '@athenna/ioc';
import { debug } from '#src/debug';
import { Cron } from '#src/applications/Cron';
import { Http } from '#src/applications/Http';
import { EnvHelper, Rc } from '@athenna/config';
import { isAbsolute, resolve } from 'node:path';
import { Worker } from '#src/applications/Worker';
import { Console } from '#src/applications/Console';
import { CommanderHandler } from '@athenna/artisan';
import { LoadHelper } from '#src/helpers/LoadHelper';
import { Log, LoggerProvider } from '@athenna/logger';
import { Repl as ReplApp } from '#src/applications/Repl';
import { parse as semverParse, satisfies as semverSatisfies } from 'semver';
import { Is, Path, File, Module, Options, Macroable } from '@athenna/common';
import { NotSatisfiedNodeVersion } from '#src/exceptions/NotSatisfiedNodeVersion';
export class Ignite extends Macroable {
constructor() {
super(...arguments);
/**
* The Athenna service provider instance (Ioc container).
*/
this.container = new Ioc();
/**
* Holds if Ignite has already been fired.
*/
this.hasFired = false;
}
/**
* Install source maps support if the --enable-source-maps
* flag is not set.
*/
installSourceMaps() {
if (!process.execArgv.includes('--enable-source-maps')) {
sourceMapSupport.install({ handleUncaughtExceptions: false });
}
return this;
}
/**
* Load the Ignite class using the options and meta url path.
*/
async load(parentURL, options) {
try {
new LoggerProvider().register();
this.parentURL = parentURL;
this.options = Options.create(options, {
bootLogs: true,
shutdownLogs: true,
environments: [],
exitOnError: true,
exitOnUncaughtError: false,
loadConfigSafe: true,
athennaRcPath: './.athennarc.json',
uncaughtExceptionHandler: this.handleUncaughtError.bind(this)
});
this.setUncaughtExceptionHandler();
this.setApplicationRootPath();
this.options.envPath = this.resolvePath(this.options.envPath);
this.options.athennaRcPath = this.resolvePath(this.options.athennaRcPath);
await this.setRcContentAndAppVars();
Path.mergeDirs(Config.get('rc.directories', {}));
this.setApplicationBeforePath();
this.verifyNodeEngineVersion();
this.registerItselfToTheContainer();
this.setApplicationSignals();
CommanderHandler.reconstruct();
return this;
}
catch (err) {
await this.handleError(err);
}
}
/**
* Ignite the REPL application.
*/
async repl() {
try {
const { ReplProvider } = await import('#src/providers/ReplProvider');
new ReplProvider().register();
this.options.environments.push('repl');
await this.fire();
this.options.uncaughtExceptionHandler = ReplApp.handleError;
this.setUncaughtExceptionHandler();
return await ReplApp.boot();
}
catch (err) {
await this.handleError(err);
}
}
/**
* Ignite the Console application.
*/
async console(argv, options) {
try {
const { ViewProvider } = await import('@athenna/view');
const { ArtisanProvider } = await import('@athenna/artisan');
new ViewProvider().register();
new ArtisanProvider().register();
this.options.environments.push('console');
return await Console.boot(argv, options);
}
catch (err) {
await this.handleError(err);
}
}
/**
* Ignite the Http server application.
*/
async httpServer(options) {
try {
this.options.environments.push('http');
await this.fire(options?.forceIgniteFire);
return await Http.boot(options);
}
catch (err) {
await this.handleError(err);
}
}
/**
* Ignite the CRON application.
*/
async cron(options) {
try {
this.options.environments.push('cron');
await this.fire(options?.forceIgniteFire);
return await Cron.boot(options);
}
catch (err) {
await this.handleError(err);
}
}
/**
* Ignite the Worker application.
*/
async worker(options) {
try {
this.options.environments.push('worker');
await this.fire(options?.forceIgniteFire);
return await Worker.boot(options);
}
catch (err) {
await this.handleError(err);
}
}
/**
* Fire the application configuring the env variables file, configuration files
* providers and preload files.
*/
async fire(forceIgniteFire) {
Config.set('rc.environments', this.options.environments);
if (this.hasFired && !forceIgniteFire) {
debug('application already fired. if you need to refire use forceIgniteFire option in your application bootstrap.');
return;
}
this.hasFired = true;
try {
this.setEnvVariablesFile();
await this.setConfigurationFiles();
Config.set('rc.environments', this.options.environments);
await LoadHelper.regootProviders();
await LoadHelper.preloadFiles();
}
catch (err) {
await this.handleError(err);
}
}
/**
* Verify the Node.js engine version if meets the required
* version to run Athenna Framework.
*/
verifyNodeEngineVersion() {
const engines = Config.get('rc.engines');
const nodeEngine = engines?.node;
if (!nodeEngine) {
return;
}
if (!semverSatisfies(process.version, nodeEngine)) {
throw new NotSatisfiedNodeVersion(process.version, nodeEngine);
}
}
/**
* Set the application handler for uncaught exceptions. Any exception throwed that is
* not catched will be resolved by this handler. Also, if this behavior happens, the error
* will be logged and the application will exit with code "1".
*
* @example
* ```ts
* this.setUncaughtExceptionHandler(error => {
* console.error('UncaughtException:', error)
* })
* ```
*/
setUncaughtExceptionHandler() {
/**
* Remove listeners registered more then once by
* Ignite class.
*/
if (process.listeners('uncaughtException').length) {
process.listeners('uncaughtException').forEach(l => {
process.removeListener('uncaughtException', l);
});
}
process.on('uncaughtException', this.options.uncaughtExceptionHandler);
}
/**
* Set the application chdir, change the process.cwd method to return the
* root path where the application root is stored. Also resolve the environment
* where the application is running (JavaScript or TypeScript).
*
* This method will determine if the application is using TypeScript by the meta url.
*
* Let's check this example when application is running in TypeScript environment:
*
* @example
* ```ts
* this.setApplicationRootPath()
*
* console.log(Path.ext()) // ts
* console.log(Path.pwd()) // /Users/jlenon7/Development/Athenna/AthennaIO
* console.log(Path.config(`app.${Path.ext()}`)) // /Users/jlenon7/Development/Athenna/AthennaIO/config/app.ts
* ```
*/
setApplicationRootPath() {
if (!Config.exists('rc.callPath')) {
Config.set('rc.callPath', process.cwd());
}
const __dirname = Module.createDirname(import.meta.url);
process.chdir(resolve(__dirname, '..', '..', '..', '..', '..'));
/**
* If env IS_TS is already set, then we cant change it.
*/
if (Env('IS_TS') === undefined) {
if (this.parentURL.endsWith('.ts')) {
process.env.IS_TS = 'true';
}
else {
process.env.IS_TS = 'false';
}
}
}
/**
* Set the application before path, in all directories of Path class unless
* the nodeModules and nodeModulesBin directories.
*
* @example
* ```ts
* this.setApplicationBeforePath()
*
* console.log(Path.config(`app.${Path.ext()}`)) // /Users/jlenon7/Development/Athenna/AthennaIO/config/build/app.ts
* ```
*/
setApplicationBeforePath() {
if (Env('IS_TS') || !this.options.beforePath) {
return;
}
Object.keys(Path.dirs).forEach(dir => {
const ignoreDirs = Config.get('rc.ignoreDirsBeforePath');
if (ignoreDirs.includes(dir)) {
return;
}
Path.dirs[dir] = this.options.beforePath + '/' + Path.dirs[dir];
});
}
/**
* Set the env file that the application will use. The env file path will be
* automatically resolved by Athenna (using the NODE_ENV variable) if any
* path is set.
*
* In case path is empty:
* If NODE_ENV variable it's already set the .env.${NODE_ENV} file will be used.
* If not, Athenna will read the .env file and try to find the NODE_ENV value and
* then load the environment variables inside the .env.${NODE_ENV} file. If any
* NODE_ENV value is found in .env or .env.${NODE_ENV} file does not exist, Athenna
* will use the .env file.
*/
setEnvVariablesFile() {
if (this.options.envPath) {
return EnvHelper.resolveFilePath(this.options.envPath);
}
EnvHelper.resolveFile(true);
}
/**
* Configure the application signals.
*/
setApplicationSignals() {
if (Env('SIGNALS_CONFIGURED', false)) {
return;
}
const signals = Config.get('app.signals', {});
if (!signals.SIGINT) {
signals.SIGINT = () => {
process.exit(0);
};
}
if (!signals.SIGTERM) {
signals.SIGTERM = () => {
LoadHelper.shutdownProviders().then(() => process.exit(0));
};
}
Object.keys(signals).forEach((key) => {
if (!signals[key]) {
return;
}
process.on(key, signals[key]);
});
process.env.SIGNALS_CONFIGURED = 'true';
}
/**
* Load all the content of the .athennarc.json or athenna property of
* package json inside the "rc" config. .athennarc.json file will always
* be the priority, but if it does not exist, Athenna will try to use
* the "athenna" property of package.json. Also, set app name, app version
* and athenna version variables in env.
*
* @example
* ```ts
* Config.get('rc.providers')
* ```
*/
async setRcContentAndAppVars() {
const file = new File(this.options.athennaRcPath, '');
const pkgJson = await new File(Path.pwd('package.json')).getContentAsJson();
const __dirname = Module.createDirname(import.meta.url);
const corePkgJson = await new File(resolve(__dirname, '..', '..', 'package.json')).getContentAsJson();
const coreSemverVersion = this.parseVersion(corePkgJson.version);
process.env.APP_NAME = process.env.APP_NAME || pkgJson.name;
process.env.APP_VERSION =
process.env.APP_VERSION || this.parseVersion(pkgJson.version).toString();
process.env.ATHENNA_VERSION = `Athenna Framework v${coreSemverVersion.toString()}`;
const athennaRc = {
parentURL: this.parentURL,
typescript: Env('IS_TS', false),
version: process.env.APP_VERSION,
athennaVersion: process.env.ATHENNA_VERSION,
engines: pkgJson.engines || {},
ignoreDirsBeforePath: ['nodeModules', 'nodeModulesBin'],
commands: {},
directories: {},
services: [],
preloads: [],
providers: [],
controllers: [],
middlewares: [],
namedMiddlewares: {},
globalMiddlewares: [],
environments: []
};
const replaceableConfigs = {
bootLogs: this.options.bootLogs,
shutdownLogs: this.options.shutdownLogs
};
if (file.fileExists) {
Config.set('rc', {
...athennaRc,
...file.getContentAsJsonSync(),
...Config.get('rc', {}),
...replaceableConfigs
});
this.options.athennaRcPath = file.path;
await Rc.setFile(this.options.athennaRcPath);
return;
}
if (!pkgJson.athenna) {
Config.set('rc', {
...athennaRc,
...Config.get('rc', {}),
...replaceableConfigs
});
this.options.athennaRcPath = null;
return;
}
this.options.athennaRcPath = Path.pwd('package.json');
Config.set('rc', {
...athennaRc,
...pkgJson.athenna,
...Config.get('rc', {}),
...replaceableConfigs
});
await Rc.setFile(this.options.athennaRcPath);
}
/**
* Load all the configuration files of some path. Remember that the path
* needs to contains only configuration files (It can be nested inside folders).
*
* Imagine this path:
*
* config/
* user/
* database.ts
* customer/
* database.ts
*
* @example
* ```ts
* await this.setConfigurationFiles()
*
* console.log(Config('user.database.url')) // some-url
* console.log(Config('customer.database.url')) // some-different-url
* ```
*/
async setConfigurationFiles() {
await Config.loadAll(Path.config(), this.options.loadConfigSafe);
}
/**
* Register this Ignite instance inside the IoC container.
*/
registerItselfToTheContainer() {
this.container.instance('Athenna/Core/Ignite', this);
}
/**
* Handle an error turning it pretty and logging as fatal.
*/
async handleError(error) {
if (process.versions.bun || (!Is.Error(error) && !Is.Exception(error))) {
console.error(error);
return this.safeExitOnError(1);
}
if (!Is.Exception(error)) {
error = error.toAthennaException();
}
if (!error.details) {
error.details = [];
}
error.details.push({ isUncaughtError: false });
if (Config.is('app.logger.prettifyException', true)) {
await Log.channelOrVanilla('exception').fatal(await error.prettify());
return this.safeExitOnError(1);
}
await Log.channelOrVanilla('exception').fatal(error);
return this.safeExitOnError(1);
}
/**
* Handle an uncaught error turning it pretty and logging as fatal.
*/
async handleUncaughtError(error) {
if (process.versions.bun || (!Is.Error(error) && !Is.Exception(error))) {
console.error(error);
return this.safeExitOnUncaughtError(1);
}
if (!Is.Exception(error)) {
error = error.toAthennaException();
}
if (!error.details) {
error.details = [];
}
error.details.push({ isUncaughtError: true });
if (Config.is('app.logger.prettifyException', true)) {
await Log.channelOrVanilla('exception').fatal(await error.prettify());
return this.safeExitOnUncaughtError(1);
}
await Log.channelOrVanilla('exception').fatal(error);
return this.safeExitOnUncaughtError(1);
}
/**
* Exit the application only if the exitOnError option is true.
*/
safeExitOnError(code) {
if (!this.options.exitOnError) {
return;
}
process.exit(code);
}
/**
* Exit the application only if the exitOnUncaughtError option is true.
*/
safeExitOnUncaughtError(code) {
if (!this.options.exitOnUncaughtError) {
return;
}
process.exit(code);
}
/**
* Parse some version string to the SemverNode type.
*/
parseVersion(version) {
const parsed = semverParse(version);
if (!parsed) {
return {
major: null,
minor: null,
patch: null,
prerelease: [],
version: null,
toString() {
return this.version;
}
};
}
return {
major: parsed.major,
minor: parsed.minor,
patch: parsed.patch,
prerelease: parsed.prerelease.map(release => release),
version: parsed.version,
toString() {
return this.version;
}
};
}
/**
* Resolve some relative path from the root of the project.
*/
resolvePath(path) {
if (!path) {
return path;
}
if (!isAbsolute(path)) {
return resolve(Path.pwd(), path);
}
return path;
}
}