bajo
Version:
The ultimate framework for whipping up massive apps in no time
430 lines (394 loc) • 12.8 kB
JavaScript
import util from 'util'
import Bajo from './bajo.js'
import Base from './base.js'
import { runAsApplet } from './helper/bajo.js'
import Tools from './plugin/tools.js'
import { outmatchNs, parseObject, lib } from './helper/app.js'
const { camelCase, isPlainObject, get, reverse, map, last, without, set } = lib._
const { pascalCase } = lib.aneka
let unknownLangWarning = false
/**
* @typedef {Object} TAppEnv
* @property {string} dev=development
* @property {string} prod=production
* @see App
*/
/**
* App class. This is the root. This is where all plugins call it home.
*
* @class
*/
class App {
/**
* @param {Object} [options] - App options
* @param {string} [options.cwd] - Set current working directory. Defaults to the script directory
* @param {string[]} [options.plugins] - Array of plugins to load. If provided, it override the list in ```package.json``` and ```.plugins``` file
* @param {Object} [options.config] - Plugin's config object. If provided, plugin configs will no longer be read from its config files
*/
constructor (options = {}) {
/**
* Copy of provided options
*
* @type {Object}
*/
this.options = options
/**
* Your main namespace. And yes, you suppose to NOT CHANGE this
*
* @memberof App
* @constant {string}
* @default 'main'
*/
this.mainNs = 'main'
/**
* App environments
* @memberof App
* @constant {TAppEnv}
*/
this.envs = { dev: 'development', prod: 'production' }
/**
* Date/time when your app start
* @type {Date}
*/
this.runAt = new Date()
/**
* Applets
*
* @type {Array}
*/
this.applets = []
/**
* List of all loaded plugin's package names
*
* @type {Array}
*/
this.pluginPkgs = options.plugins ?? []
/**
* @typedef {Object} TAppConfigHandler
* @property {string} ext - File extension
* @property {function} [readHandler] - Function to call for reading
* @property {function} [writeHandler] - Function to call for writing
* @see App#configHandlers
*/
/**
* Config handlers.
*
* By default, there are two built-in handlers available: ```.js```
* and ```.json```. Use plugins to add more, e.g {@link https://github.com/ardhi/bajo-config|bajo-config}
* lets you to use ```.yaml/.yml``` and ```.toml```
*
* @type {TAppConfigHandler[]}
*/
this.configHandlers = []
/**
* Gives you direct access to the most commonly used 3rd party library in a Bajo based app.
* No manual import necessary, always available, anywhere, anytime!
*
* Example:
* ```javascript
* const { camelCase, kebabCase } = this.app.lib._
* console.log(camelCase('Elit commodo sit et aliqua'))
* ```
*
* @type {TAppLib}
*/
this.lib = lib
this.lib.outmatchNs = outmatchNs.bind(this)
this.lib.parseObject = parseObject.bind(this)
/**
* Instance of system log
*
* @type {Log}
*/
this.log = {}
/**
* All plugin's base class are saved here as key-value pairs with plugin name as its key.
* The special key ```Base``` && ```Tools``` is for {@link Base} & {@link Tools} class so anytime you want to
* create your own plugin, just use something like this:
*
* ```javascript
* class MyPlugin extends this.app.baseClass.Base {
* ... your class
* }
*/
this.baseClass = { Base, Tools }
/**
* If app runs in applet mode, this will be the applet's name
*
* @type {string}
*/
this.applet = undefined
/**
* Program arguments
*
* ```
* $ node index.js arg1 arg2
* ...
* console.log(this.args) // it should print: ['arg1', 'arg2']
* ```
*
* @type {string[]}
* @see module:Lib.parseArgsArgv
*/
this.args = []
/**
* Program options.
*
* - Dash (```-```) breaks the string into object keys
* - While colon (```:```) is used as namespace separator. If no namespace found, it is saved under ```_``` key.
*
* Values are parsed automatically. See {@link https://github.com/ladjs/dotenv-parse-variables|dotenv-parse-variables}
* for details.
*
* ```
* $ node index.js --my-name-first=John --my-name-last=Doe --my-birthDay=secret --nameSpace:path-subPath=true
* ...
* // {
* // _: {
* // my: {
* // name: { first: 'John', last: 'Doe' },
* // birthDay: 'secret'
* // }
* // },
* // nameSpace: { path: { subPath: true } }
* // }
* ```
*
* @type {Object}
* @see module:Lib.parseArgsArgv
*/
this.argv = {}
/**
* Environment variables. Support dotenv (```.env```) file too!
*
* - Underscore (```_```) translates key to camel-cased one
* - Double underscores (```__```) breaks the key into object keys
* - While dot (```.```) is used as namespace separator. If no namespace found, it is saved under ```_``` key.
*
* Values are also parsed automatically using {@link https://github.com/ladjs/dotenv-parse-variables|dotenv-parse-variables}.
*
* Example:
*
* - ```MY_KEY=secret``` → ```{ _: { myKey: 'secret' } }```
* - ```MY_KEY__SUB_KEY=supersecret``` → ```{ _: { myKey: { subKey: 'supersecret' } } }```
* - ```MY_NS.MY_NAME=John``` → ```{ myNs: { myName: 'John' } }```
*
* @type {Object}
* @see module:Lib.parseEnv
*/
this.envVars = {}
if (!options.cwd) options.cwd = process.cwd()
const l = last(process.argv)
if (l.startsWith('--cwd')) {
const parts = l.split('=')
options.cwd = parts[1]
}
this.dir = this.lib.aneka.resolvePath(options.cwd)
process.env.APPDIR = this.dir
}
/**
* Add and save plugin and it's base class definition (if provided)
*
* @method
* @param {TPlugin} plugin - A valid bajo plugin
* @param {Object} [baseClass] - Base class definition
*/
addPlugin = (plugin, baseClass) => {
if (this[plugin.ns]) throw new Error(`Plugin '${plugin.ns}' added already`)
this[plugin.ns] = plugin
if (baseClass) this.baseClass[pascalCase(plugin.ns)] = baseClass
}
/**
* Get all loaded plugin namespaces
*
* @method
* @returns {string[]}
*/
getAllNs = () => {
return this.pluginPkgs.map(pkg => camelCase(pkg))
}
/**
* Dumping variable on screen. Like ```console.log``` but with max 10 depth.
*
* @method
* @param {...any} args - any arguments passed will be displayed on screen. If the last argument is a boolean 'true', app will quit rightaway
*/
dump = (...args) => {
const terminate = last(args) === true
if (terminate) args.pop()
for (const arg of args) {
const result = util.inspect(arg, { depth: 10, colors: true })
console.log(result)
}
// if (terminate) process.kill(process.pid, 'SIGINT')
if (terminate) process.exit('1')
}
/**
* Boot process:
*
* - Parsing {@link module:Lib.parseArgsArgv|program arguments, options} and {@link module:Lib.parseEnv|environment values}
* - Create {@link Bajo|Bajo} instance & initialize it
* - {@link module:Helper/Bajo.runAsApplet|Run in applet mode} if ```-a``` or ```--applet``` is given
*
* After boot process is completed, event ```bajo:afterBootCompleted``` is emitted.
*
* If app mode is ```applet```, it runs your choosen applet instead.
*
* @method
* @async
* @returns {App}
* @fires bajo:afterBootCompleted
*/
boot = async () => {
this.bajo = new Bajo(this)
this.bajo.hooks.push(...(this.options.hooks ?? []))
delete this.options.hooks
// argv/args/env
const { parseArgsArgv, parseEnv, secToHms } = this.lib.aneka
const { parseObject } = this.lib
const { argv, args } = await parseArgsArgv({ cwd: this.options.cwd })
this.args = args
this.argv = parseObject(argv, { parseValue: true })
this.envVars = parseObject(parseEnv(), { parseValue: true })
if (get(this, 'envVars._.env') === '[object Object]') set(this, 'envVars._.env', 'dev')
this.applet = this.envVars._.applet ?? this.argv._.applet
await this.bajo.runHook('bajo:beforeBoot')
await this.bajo.init()
// boot complete
const elapsed = new Date() - this.runAt
this.bajo.log.debug('bootCompleted%s', secToHms(elapsed, true))
/**
* Run after boot process is completed
*
* @global
* @event bajo:afterBootComplete
* @see {@tutorial hook}
* @see App#boot
*/
await this.bajo.runHook('bajo:afterBoot')
if (this.applet) await runAsApplet.call(this.bajo)
return this
}
/**
* Terminate the app and back to console
*
* @method
* @param {string} [signal=SIGINT] - Signal to send
*/
exit = (signal = 'SIGINT') => {
if (signal === true) process.exit('1')
process.kill(process.pid, signal)
}
/**
* Load internationalization & languages files for particular plugin
*
* @method
* @param {string} ns - Plugin name
*/
loadIntl = (ns) => {
const { fs } = this.lib
this[ns].intl = {}
for (const l of this.bajo.config.intl.supported) {
this[ns].intl[l] = {}
const path = `${this[ns].dir.pkg}/extend/bajo/intl/${l}.json`
if (!fs.existsSync(path)) continue
const trans = fs.readFileSync(path, 'utf8')
try {
this[ns].intl[l] = JSON.parse(trans)
} catch (err) {}
}
}
_prepTrans = (ns, text, params) => {
const { fallback, supported } = this.bajo.config.intl
const { isSet } = this.lib.aneka
if (!text) {
text = ns
ns = 'bajo'
}
const opts = last(params)
let lang = this.bajo.config.lang
if (isPlainObject(opts)) {
params.pop()
if (opts.lang) lang = opts.lang
}
if (!unknownLangWarning && !supported.includes(lang)) {
unknownLangWarning = true
this.bajo.log.warn(`Unsupported language, fallback to '${fallback}'`)
}
const plugins = reverse(without([...this.getAllNs()], ns))
plugins.unshift(ns)
plugins.push('bajo')
let trans
for (const p of plugins) {
const store = get(this, `${p}.intl.${lang}`, {})
trans = get(store, text)
if (isSet(trans)) break
}
if (!isSet(trans)) {
for (const p of plugins) {
const store = get(this, `${p}.intl.${fallback}`, {})
trans = get(store, text)
if (isSet(trans)) break
}
}
return { ns, text, lang, params, plugins, trans }
}
/**
* Translate text and interpolate with given ```args```.
*
* There is a shortcut to this method attached on all plugins. You'll normally call that shorcut
* instead of this method, because it is bound to plugin's name already
*
* ```javascript
* ... within your main plugin
* const translated = this.app.t('main', 'My cute cat is %s', 'purring')
* // or
* const translated = this.t('My cute cat is %s', 'purring')
* ```
* @method
* @param {string} ns - Namespace
* @param {string} text - Text to translate
* @param {...any} params - Arguments
* @returns {string}
*/
t = (ns, text, ...params) => {
const { formatText, isSet } = this.lib.aneka
const { isArray, last } = this.lib._
const { join } = this.bajo
let { text: newText, trans, params: args } = this._prepTrans(ns, text, params)
if (!isSet(trans)) trans = newText
const lang = isPlainObject(last(args)) ? last(args).lang : undefined
for (const idx in args) {
const arg = args[idx]
if (isArray(arg)) args[idx] = join(arg, { lang })
}
return formatText(trans, ...args)
}
/**
* Check whether translation text/key exists
*
* @method
* @param {string} ns - Namespace
* @param {string} text - Text to translate
* @returns {boolean}
*/
te = (ns, text, ...params) => {
const { trans } = this._prepTrans(ns, text, params)
return !!trans
}
/**
* Helper method to list all supported config formats
*
* @returns {string[]}
*/
getConfigFormats = () => {
return map(this.configHandlers, 'ext')
}
startPlugin = (ns, ...args) => {
this[ns].start(...args)
}
stopPlugin = (ns, ...args) => {
this[ns].stop(...args)
}
}
export default App