import util from 'util'
import lodash from 'lodash'
import Bajo from './bajo.js'
import fastGlob from 'fast-glob'
import { sprintf } from 'sprintf-js'
import outmatch from 'outmatch'
import fs from 'fs-extra'
import aneka from 'aneka/index.js'
import Base from './base.js'
import resolvePath from '../lib/resolve-path.js'
import parseArgsArgv from '../lib/parse-args-argv.js'
import parseEnv from '../lib/parse-env.js'
import {
buildBaseConfig,
buildExtConfig,
buildPlugins,
collectConfigHandlers,
bootOrder,
bootPlugins,
exitHandler,
runAsApplet
} from './helper/bajo.js'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc.js'
import customParseFormat from 'dayjs/plugin/customParseFormat.js'
import localizedFormat from 'dayjs/plugin/localizedFormat.js'
dayjs.extend(utc)
dayjs.extend(customParseFormat)
dayjs.extend(localizedFormat)
const { isPlainObject, get, reverse, map, isString, last, without, keys } = lodash
let unknownLangWarning = false
function outmatchNs (source, pattern) {
const { breakNsPath } = this.bajo
const [src, subSrc] = source.split(':')
if (!subSrc) return pattern === src
try {
const { fullNs, path } = breakNsPath(pattern)
const isMatch = outmatch(path)
return src === fullNs && isMatch(subSrc)
} catch (err) {
return false
}
}
/**
* @typedef {Object} TAppEnv
* @property {string} dev=development
* @property {string} prod=production
* @see App
*/
/**
* @typedef {Object} TAppLib
* @property {Object} _ - Access to {@link https://lodash.com|lodash}
* @property {Object} fs - Access to {@link https://github.com/jprichardson/node-fs-extra|fs-extra}
* @property {Object} fastGlob - Access to {@link https://github.com/mrmlnc/fast-glob|fast-glob}
* @property {Object} sprintf - Access to {@link https://github.com/alexei/sprintf.js|sprintf}
* @property {Object} aneka - Access to {@link https://github.com/ardhi/aneka|aneka}
* @property {Object} outmatch - Access to {@link https://github.com/axtgr/outmatch|outmatch}
* @property {Object} dayjs - Access to {@link https://day.js.org|dayjs} with utc & customParseFormat plugin already applied
* @see App
*/
const lib = {
_: lodash,
fs,
fastGlob,
sprintf,
outmatch,
dayjs,
aneka
}
/**
* App class. This is the root. This is where all plugins call it home.
*
* Boot process:
*
* 1. Parsing {@link module:Lib.parseArgsArgv|program arguments, options} and {@link module:Lib.parseEnv|environment values}
* 2. Create {@link Bajo|Bajo} instance
* 3. Building {@link module:Helper/Bajo.buildBaseConfig|base config}
* 4. {@link module:Helper/Bajo.buildPlugins|Building plugins}
* 5. Collect all {@link module:Helper/Bajo.collectConfigHandlers|config handler}
* 6. Building {@link module:Helper/Bajo.buildExtConfig|extra config}
* 7. Setup {@link module:Helper/Bajo.bootOrder|boot order}
* 8. {@link module:Helper/Bajo.bootPlugins|Boot loaded plugins}
* 9. Attach {@link module:Helper/Bajo.exitHandler|exit handlers}
* 10. {@link module:Helper/Bajo.runAsApplet|Run in applet mode} if ```-a``` or ```--applet``` is given
*
* After boot process is completed, event ```bajo:afterBootComplete``` is emitted.
*
* If app mode is ```applet```, it runs your choosen applet instead.
*
* @class
*/
class App {
/**
* Your main namespace. And yes, you suppose to NOT CHANGE this
*
* @memberof App
* @constant {string}
* @default 'main'
*/
static mainNs = 'main'
/**
* App environments
* @memberof App
* @constant {TAppEnv}
*/
static envs = { dev: 'development', prod: 'production' }
/**
* @param {string} cwd - Current working dirctory
*/
constructor (cwd) {
/**
* 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 = []
/**
* @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)
/**
* Instance of system log
*
* @type {Log}
*/
this.log = {}
/**
* All plugin's class definitions are saved here as key-value pairs with plugin name as its key.
* The special key ```base``` is for {@link Base}'s class so anytime you want to
* create your own plugin, just use something like this:
*
* ```javascript
* class MyPlugin extends this.app.pluginClass.base {
* ... your class
* }
*/
this.pluginClass = { base: Base }
/**
* 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 (!cwd) cwd = process.cwd()
const l = last(process.argv)
if (l.startsWith('--cwd')) {
const parts = l.split('=')
cwd = parts[1]
}
this.dir = resolvePath(cwd)
process.env.APPDIR = this.dir
}
get mainNs () {
return this.constructor.mainNs
}
/**
* Add and save plugin and it's class definition (if provided)
*
* @method
* @param {TPlugin} plugin - A valid bajo plugin
* @param {Object} [pluginClass] - Plugin's class definition
*/
addPlugin = (plugin, pluginClass) => {
if (this[plugin.ns]) throw new Error(`Plugin '${plugin.ns}' added already`)
this[plugin.ns] = plugin
if (pluginClass) this.pluginClass[plugin.ns] = pluginClass
}
/**
* Get all loaded plugin namesspaces
*
* @method
* @returns {string[]}
*/
getAllNs = () => {
return without(keys(this.pluginClass), 'base')
}
/**
* 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')
}
/**
* Booting the app.
*
* @method
* @async
* @returns {App}
* @fires bajo:afterBootComplete
*/
boot = async () => {
this.bajo = new Bajo(this)
// argv/args/env
const { argv, args } = await parseArgsArgv.call(this) ?? {}
this.args = args
this.argv = argv
this.envVars = parseEnv.call(this)
this.applet = this.envVars._.applet ?? this.argv._.applet
await buildBaseConfig.call(this.bajo)
await collectConfigHandlers.call(this.bajo)
await buildExtConfig.call(this.bajo)
await buildPlugins.call(this.bajo)
await bootOrder.call(this.bajo)
await bootPlugins.call(this.bajo)
await exitHandler.call(this.bajo)
// boot complete
const elapsed = new Date() - this.runAt
this.bajo.log.debug('bootCompleted%s', this.lib.aneka.secToHms(elapsed, true))
/**
* Run after boot process is completed
*
* @global
* @event bajo:afterBootComplete
* @see {@tutorial hook}
* @see App#boot
*/
await this.bajo.runHook('bajo:afterBootComplete')
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') => {
process.kill(process.pid, 'SIGINT')
}
/**
* Load internationalization & languages files for particular plugin
*
* @method
* @param {string} ns - Plugin name
*/
loadIntl = (ns) => {
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) {}
}
}
/**
* 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) => {
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
}
const { fallback, supported } = this.bajo.config.intl
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 (trans) break
}
if (!trans) {
for (const p of plugins) {
const store = get(this, `${p}.intl.${fallback}`, {})
trans = get(store, text)
if (trans) break
}
}
if (!trans) trans = text
const values = map(params, a => {
if (!isString(a)) return a
return a
})
return sprintf(trans, ...values)
}
/**
* Helper method to list all supported config formats
*
* @returns {string[]}
*/
getConfigFormats = () => {
return map(this.configHandlers, 'ext')
}
}
export default App