bajo
Version:
The ultimate framework for whipping up massive apps in no time
399 lines (373 loc) • 12.6 kB
JavaScript
import Print from '../plugin/print.js'
import Log from '../app/log.js'
import os from 'os'
import fs from 'fs-extra'
import lodash from 'lodash'
import {
buildConfigs,
checkDependencies,
checkNameAliases,
collectHooks,
run
} from './base.js'
const {
orderBy,
isFunction,
isPlainObject,
map,
pick,
values,
keys,
set,
get,
without,
uniq,
camelCase,
isEmpty
} = lodash
const omitted = ['spawn', 'cwd', 'name', 'alias', 'applet', 'a', 'plugins']
const defConfig = {
env: 'dev',
runtime: {
noWarning: false
},
log: {
timeTaken: false,
dateFormat: 'YYYY-MM-DDTHH:mm:ss.SSS',
useUtc: false,
pretty: false,
applet: false,
traceHook: false,
save: false,
rotation: {
cycle: 'none', // none, daily, weekly, monthly
compressOld: true,
byPlugin: false,
retain: 5
}
},
lang: Intl.DateTimeFormat().resolvedOptions().lang ?? 'en-US',
intl: {
supported: ['en-US', 'id'],
fallback: 'en-US',
lookupOrder: [],
format: {
emptyValue: '',
datetime: { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' },
date: { dateStyle: 'medium', timeZone: 'UTC' },
time: { timeStyle: 'short', timeZone: 'UTC' },
float: { maximumFractionDigits: 2 },
double: { maximumFractionDigits: 5 },
smallint: {},
integer: {}
},
unitSys: {
'en-US': 'imperial',
id: 'metric'
}
},
exitHandler: true
}
const defMain = `async function factory (pkgName) {
const me = this
return class Main extends this.app.baseClass.Base {
constructor () {
super(pkgName, me.app)
this.config = {}
}
}
}
export default factory
`
/**
* Internal helpers called by Bajo that only used once for bootstrapping. It should remains
* hidden and not to be imported by any program.
*
* @module Helper/Bajo
*/
/**
* Building bajo base config. Mostly dealing with directory setups:
* - determine base directory
* - check whether data directory is valid. If not exist, create one inside app dir
* - ensure data config directory is there
* - ensure tmp dir is there
* - read the list of plugins from ```.plugins``` file
*
* @async
*/
export async function buildBaseConfig () {
// dirs
const { defaultsDeep, textToArray, currentLoc, resolvePath } = this.app.lib.aneka
this.config = defaultsDeep({}, this.app.argv._, this.app.envVars._)
set(this, 'dir.base', this.app.dir)
const path = currentLoc(import.meta).dir + '/../..'
set(this, 'dir.pkg', resolvePath(path))
if (get(this, 'config.dir.data')) set(this, 'dir.data', this.config.dir.data)
if (!get(this, 'dir.data')) set(this, 'dir.data', `${this.dir.base}/data`)
this.dir.data = resolvePath(this.dir.data)
fs.ensureDirSync(`${this.dir.data}/config`)
if (!this.dir.tmp) {
this.dir.tmp = `${resolvePath(os.tmpdir())}/${this.ns}`
fs.ensureDirSync(this.dir.tmp)
}
this.pkg = await this.getPkgInfo()
let pluginPkgs = this.app.pluginPkgs
if (isEmpty(pluginPkgs)) {
// collect list of plugins
const mainPkg = await this.getPkgInfo(this.app.dir)
pluginPkgs = get(mainPkg, 'bajo.plugins', [])
if (isEmpty(pluginPkgs)) {
const pluginsFile = `${this.dir.data}/config/.plugins`
if (fs.existsSync(pluginsFile)) {
pluginPkgs = textToArray(fs.readFileSync(pluginsFile, 'utf8'))
}
}
}
this.app.pluginPkgs = without(uniq(pluginPkgs), this.app.mainNs)
this.app.pluginPkgs.push(this.app.mainNs)
}
/**
* Building all plugins:
* - load from app's pluginPkgs
* - iterate through the list and build related plugins
* - making sure main plugin is there. If not, create from template
* - attach these plugins to the app instance
*
* @async
*/
export async function buildPlugins () {
const { resolvePath } = this.app.lib.aneka
this.log.trace('buildPluginsStart')
for (const pkg of this.app.pluginPkgs) {
const ns = camelCase(pkg)
let dir
if (ns === 'main') {
dir = `${this.dir.base}/${this.app.mainNs}`
fs.ensureDirSync(dir)
if (!fs.existsSync(`${dir}/index.js`)) {
fs.writeFileSync(`${dir}/index.js`, defMain, 'utf8')
}
} else dir = this.getModuleDir(pkg)
const factory = `${dir}/index.js`
if (!fs.existsSync(factory)) throw this.error('pluginPackageNotFound%s', pkg)
const { default: builder } = await import(resolvePath(factory, true))
const ClassDef = await builder.call(this, pkg)
const plugin = new ClassDef()
if (!(plugin instanceof this.app.baseClass.Base)) throw this.error('pluginPackageInvalid%s', pkg)
plugin.pkg = plugin.getPkgInfo(ns === 'main' ? this.dir.base : dir)
plugin.alias = ns === 'main' ? this.app.mainNs : get(plugin.pkg, 'bajo.alias', (pkg.slice(0, 5) === 'bajo-' ? pkg.slice(5) : ns).toLowerCase())
plugin.dependencies = get(plugin.pkg, 'bajo.dependencies', [])
this.app.addPlugin(plugin, ClassDef)
this.log.trace('- ' + pkg)
}
this.log.debug('buildPluginsComplete')
}
/**
* Collect all config handlers, including the one provided by plugins
*
* @async
*/
export async function collectConfigHandlers () {
for (const pkg of this.app.pluginPkgs) {
let dir
try {
dir = this.getModuleDir(pkg)
} catch (err) {}
if (!dir) continue
const file = `${dir}/extend/bajo/config-handlers.js`
let mod = await this.importModule(file)
if (!mod) continue
if (isFunction(mod)) mod = await mod.call(this.app[camelCase(pkg)])
if (isPlainObject(mod)) mod = [mod]
this.app.configHandlers = this.app.configHandlers.concat(mod)
}
}
/**
* Bajo extra config:
* - reading config file
* - merge config with arguments & environments values
* - Set environment (```dev``` or ```prod```)
*
* @async
*/
export async function buildExtConfig () {
// config merging
const { defaultsDeep } = this.app.lib.aneka
const { parseObject, omitDeep } = this.app.lib
const { isEmpty, get } = this.app.lib._
let resp = get(this, `app.options.config.${this.ns}`, {})
if (isEmpty(resp)) resp = await this.readAllConfigs(`${this.dir.data}/config/${this.ns}`)
resp = omitDeep(pick(resp, ['log', 'exitHandler', 'env', 'runtime']), omitted)
const envs = this.app.envs
this.config = defaultsDeep({}, this.config, resp, defConfig)
// language
this.config.lang = (this.config.lang ?? '').split('.')[0]
this.app.loadIntl(this.ns)
this.print = new Print(this)
// environment
if (values(envs).includes(this.config.env)) this.config.env = this.app.lib.aneka.getKeyByValue(envs, this.config.env)
if (!keys(envs).includes(this.config.env)) throw this.error('unknownEnv%s%s', this.config.env, this.join(keys(envs), { lastSeparator: this.t('or') }))
process.env.NODE_ENV = envs[this.config.env]
if (!this.config.log.level) this.config.log.level = this.config.env === 'dev' ? 'debug' : 'info'
// misc
const obj = this.app.applet ? this.config : pick(this.config, keys(defConfig))
this.config = parseObject(obj, { parseValue: true })
const exts = this.app.getConfigFormats()
if (this.app.applet) {
if (!this.app.pluginPkgs.includes('bajo-cli')) throw this.error('appletNeedsBajoCli')
if (!this.config.log.applet) this.config.log.level = 'silent'
this.config.exitHandler = false
}
if (this.config.runtime.noWarning) process.removeAllListeners('warning')
this.app.log = new Log(this.app)
this.log.trace('dataDir%s', this.dir.data)
this.log.debug('configHandlers%s', this.join(exts))
}
/**
* Setup plugins boot orders by reading plugin's ```.bootorder``` file if provided.
*
* @async
*/
export async function bootOrder () {
const { freeze } = this.app.lib
const { isNumber } = this.app.lib._
this.log.debug('setupBootOrder')
let counter = 1000
const orders = []
for (const pkg of this.app.pluginPkgs) {
const item = { pkg }
const ns = camelCase(pkg)
const order = get(this.app[ns], 'pkg.bajo.bootorder')
if (isNumber(order)) item.val = order
else {
item.val = counter
counter++
}
orders.push(item)
}
this.app.pluginPkgs = map(orderBy(orders, ['val']), 'pkg')
this.log.debug('runInEnv%s', this.t(this.app.envs[this.config.env]))
// misc
freeze(this.config)
}
/**
* Iterate through all plugins loaded and do:
*
* 1. {@link module:Helper/Base.buildConfigs|build configs}
* 2. {@link module:Helper/Base.checkNameAliases|ensure names & aliases uniqueness}
* 3. {@link module:Helper/Base.checkDependencies|ensure dependencies are met}
* 4. {@link module:Helper/Base.collectHooks|collect hooks}
* 5. {@link module:Helper/Base.run|run plugins}
*
* @async
*/
export async function bootPlugins () {
await buildConfigs.call(this.app)
await checkNameAliases.call(this.app)
await checkDependencies.call(this.app)
await collectHooks.call(this.app)
await run.call(this.app)
}
/**
* Attach plugins exit handlers and make sure the app shutdowns gracefully
*
* @async
*/
export async function exitHandler () {
if (!this.config.exitHandler) return
async function exit (signal) {
const { eachPlugins } = this
if (signal) this.log.warn('signalReceived%s', signal)
const me = this
await eachPlugins(async function ({ ns }) {
try {
await this.exit()
} catch (err) {}
me.log.trace('exited%s', this.ns)
})
this.log.debug('appShutdown')
process.exit(0)
}
process.on('SIGINT', async () => {
await exit.call(this, 'SIGINT')
})
process.on('SIGTERM', async () => {
await exit.call(this, 'SIGTERM')
})
process.on('beforeExit', async () => {
await exit.call(this)
})
process.on('uncaughtException', (error, origin) => {
setTimeout(() => {
console.error(error)
// process.exit(1)
}, 50)
})
process.on('unhandledRejection', (reason, promise) => {
const stackFile = reason.stack.split('\n')[1]
let file
const info = stackFile.match(/\((.*)\)/) // file is in (<file>)
if (info) file = info[1]
else if (stackFile.startsWith(' at ')) file = stackFile.slice(7) // file is stackFile itself
if (!file) return
const parts = file.split(':')
const column = parseInt(parts[parts.length - 1])
const line = parseInt(parts[parts.length - 2])
parts.pop()
parts.pop()
file = parts.join(':')
this.log.error({ file, line, column }, '%s', reason.message)
})
process.on('warning', warning => {
this.log.error('%s', warning.message)
})
}
/**
* If app is in ```applet``` mode, this little helper should take care plugin's applet boot process
*
* @async
* @fires {ns}:beforeAppletRun
* @fires {ns}:afterAppletRun
*/
export async function runAsApplet () {
const { isString, map, find } = this.app.lib._
await this.eachPlugins(async function ({ file }) {
const { ns, alias } = this
this.app.applets.push({ ns, file, alias })
}, { glob: 'applet.js', prefix: 'bajoCli' })
this.log.debug('appletModeActivated')
this.print.info('appRunningAsApplet')
if (this.app.applets.length === 0) this.print.fatal('noAppletLoaded')
let name = this.app.applet
if (!isString(name)) {
const select = await this.importPkg('bajoCli:@inquirer/select')
name = await select({
message: this.t('Please select:'),
choices: map(this.app.applets, t => ({ value: t.ns }))
})
}
const [ns, path] = name.split(':')
const applet = find(this.app.applets, a => (a.ns === ns || a.alias === ns))
if (!applet) this.print.fatal('notFound%s%s', this.app.t('applet'), name)
/**
* Run before applet is run. ```[ns]``` is applet's namespace
*
* @global
* @event {ns}:beforeAppletRun
* @param {...any} params
* @see {@tutorial hook}
* @see module:Helper/Bajo.runAsApplet
*/
await this.runHook(`${applet.ns}:beforeAppletRun`, ...this.app.args)
await this.app.bajoCli.runApplet(applet, path, ...this.app.args)
/**
* Run after applet is run. ```[ns]``` is applet's namespace
*
* @global
* @event {ns}:afterAppletRun
* @param {...any} params
* @see {@tutorial hook}
* @see module:Helper/Bajo.runAsApplet
*/
await this.runHook(`${applet.ns}:afterAppletRun`, ...this.app.args)
}