UNPKG

bajo

Version:

A framework to build a giant monstrous app rapidly

686 lines (641 loc) 25.3 kB
import Plugin from './plugin.js' import BajoPlugin from './bajo-plugin.js' import increment from 'add-filename-increment' import fs from 'fs-extra' import path from 'path' import os from 'os' import ms from 'ms' import dotenvParseVariables from 'dotenv-parse-variables' import emptyDir from 'empty-dir' import lodash from 'lodash' import currentLoc from '../lib/current-loc.js' import { createRequire } from 'module' import getGlobalPath from 'get-global-path' import { customAlphabet } from 'nanoid' import fastGlob from 'fast-glob' import querystring from 'querystring' import deepFreeze from 'deep-freeze-strict' import resolvePath from '../lib/resolve-path.js' import importModule from '../lib/import-module.js' import logLevels from '../lib/log-levels.js' import { types as formatTypes, formats } from '../lib/formats.js' const require = createRequire(import.meta.url) const { isFunction, map, isObject, trim, filter, isEmpty, orderBy, pullAt, find, camelCase, isNumber, cloneDeep, isPlainObject, isArray, isString, set, omit, keys, indexOf, last, get, has, values, dropRight, pick } = lodash class BajoCore extends Plugin { constructor (app) { super('bajo', app) this.runAt = new Date() this.mainNs = 'main' this.lib.BajoPlugin = BajoPlugin this.applets = [] this.pluginPkgs = [] this.pluginNames = [] this.configHandlers = [ { ext: '.js', readHandler: this._defConfigHandler }, { ext: '.json', readHandler: this.readJson } ] this.whiteSpace = [' ', '\t', '\n', '\r'] this.logLevels = logLevels this.envs = { dev: 'development', staging: 'staging', prod: 'production' } } async _defConfigHandler (file, opts = {}) { let mod = await importModule(file) if (isFunction(mod)) mod = await mod.call(this, opts) return mod } resolvePath = (item, asFileUrl) => { return resolvePath(item, asFileUrl) } freeze = (o, shallow) => { if (shallow) Object.freeze(o) else deepFreeze(o) } setImmediate = async () => { return new Promise((resolve) => { setImmediate(() => resolve()) }) } breakNsPathFromFile = ({ file, dir, baseNs, suffix = '', getType } = {}) => { let item = file.replace(dir + suffix, '') let type if (getType) { const items = item.split('/') type = items.shift() item = items.join('/') } item = item.slice(0, item.length - path.extname(item).length) let [name, _path] = item.split('@') if (!_path) { _path = name name = baseNs } _path = camelCase(_path) const names = map(name.split('.'), n => camelCase(n)) const [ns, subNs] = names return { ns, subNs, path: _path, fullNs: names.join('.'), type } } buildNsPath = ({ ns = '', subNs, subSubNs, path } = {}) => { if (subNs) ns += '.' + subNs if (subSubNs) ns += '.' + subSubNs return `${ns}:${path}` } breakNsPath = (item = '', defaultNs = 'bajo', checkNs = true) => { let [ns, ...path] = item.split(':') const fullNs = ns let subNs let subSubNs path = path.join(':') if (path.startsWith('//')) { return { path: item } // for: http:// etc } [ns, subNs, subSubNs] = ns.split('.') if (checkNs) { if (!this.app[ns]) { const plugin = this.getPlugin(ns) if (plugin) ns = plugin.name } if (!this.app[ns]) throw this.error('unknownPluginOrNotLoaded%s') } let qs [path, qs] = path.split('?') qs = querystring.parse(qs) ?? {} // normalize path const parts = path.split('/') const realParts = [] const params = {} for (const idx in parts) { const part = parts[idx] if (part[0] !== ':' || part.indexOf('|') === -1) { realParts.push(part) continue } const [key, val] = part.split('|') parts[idx] = key params[key.slice(1)] = val realParts.push(val) } path = parts.join('/') const realPath = realParts.join('/') let fullPath = path if (!isEmpty(qs)) fullPath += ('?' + querystring.stringify(qs, null, null, { encodeURIComponent: (text) => (text) })) let realFullPath = realPath if (!isEmpty(qs)) realFullPath += ('?' + querystring.stringify(qs, null, null, { encodeURIComponent: (text) => (text) })) return { ns, path, subNs, subSubNs, qs, fullPath, fullNs, realPath, realFullPath } } buildCollections = async (options = {}) => { let { ns, handler, dupChecks = [], container, useDefaultName } = options useDefaultName = useDefaultName ?? true if (!ns) ns = this.name const cfg = this.app[ns].getConfig() let items = get(cfg, container, []) if (!isArray(items)) items = [items] this.app[ns].log.trace('collecting%s', this.app[ns].print.write(container)) await this.runHook(`${ns}:${camelCase('beforeBuildCollection')}`, container) const deleted = [] for (const index in items) { const item = items[index] if (useDefaultName) { if (!has(item, 'name')) { if (find(items, { name: 'default' })) throw this.app[ns].error('collExists%s', 'default') else item.name = 'default' } } this.app[ns].log.trace('- %s', item.name) const result = await handler.call(this.app[ns], { item, index, cfg }) if (result) items[index] = result else if (result === false) deleted.push(index) if (this.app.bajo.applet && item.skipOnTool && !deleted.includes(index)) deleted.push(index) } if (deleted.length > 0) pullAt(items, deleted) // check for duplicity if (dupChecks.length > 0) { const checkers = [] for (const c of items) { const checker = JSON.stringify(pick(c, dupChecks)) if (checkers.includes(checker)) this.app[ns].fatal('oneOrMoreSharedTheSame%s%s', container, this.join(dupChecks.filter(i => !isFunction(i)))) } } await this.runHook(`${ns}:${camelCase('afterBuildCollection')}`, container) this.app[ns].log.debug('collected%s%d', this.app[ns].print.write(container), items.length) return items } callHandler = async (item, ...args) => { let result let scope = this if (item instanceof BajoPlugin) { scope = item item = args.shift() } const bajo = scope.app.bajo if (isString(item)) { if (item.startsWith('applet:') && bajo.applets.length > 0) { const [, ns, path] = item.split(':') const applet = find(bajo.applets, a => (a.ns === ns || a.alias === ns)) if (applet) result = await bajo.runApplet(applet, path, ...args) } else { const [ns, method, ...params] = item.split(':') const fn = bajo.getMethod(`${ns}:${method}`) if (fn) { if (params.length > 0) args.unshift(...params) result = await fn(...args) } } } else if (isFunction(item)) { result = await item.call(scope, ...args) } else if (isPlainObject(item) && item.handler) { result = await item.handler.call(scope, ...args) } return result } eachPlugins = async (handler, options = {}) => { if (typeof options === 'string') options = { glob: options } const result = {} const pluginPkgs = cloneDeep(this.app.bajo.pluginPkgs) ?? [] const { glob, useBajo, prefix = '', noUnderscore = true, returnItems } = options if (useBajo) pluginPkgs.unshift('bajo') for (const pkgName of pluginPkgs) { const ns = camelCase(pkgName) const config = this.app[ns].config const alias = this.app[ns].alias let r if (glob) { const base = prefix === '' ? this.app[ns].dir.pkg : `${this.app[ns].dir.pkg}/${prefix}` let opts = isString(glob) ? { pattern: [glob] } : glob let pattern = opts.pattern ?? [] if (isString(pattern)) pattern = [pattern] opts = omit(opts, ['pattern']) for (const i in pattern) { if (!path.isAbsolute(pattern[i])) pattern[i] = `${base}/${pattern[i]}` } const files = await fastGlob(pattern, opts) for (const f of files) { if (path.basename(f)[0] === '_' && noUnderscore) continue const resp = await handler.call(this.app[ns], { ns, pkgName, config, alias, file: f, dir: base }) if (resp === false) break else if (resp === undefined) continue else { result[ns] = result[ns] ?? {} result[ns][f] = resp } } } else { r = await handler.call(this.app[ns], { ns, pkgName, config, dir: this.app[ns].dir.pkg, alias }) if (r === false) break else if (r === undefined) continue else result[ns] = r } } if (returnItems) { const data = [] for (const r in result) { for (const f in result[r]) { data.push(result[r][f]) } } return data } return result } getUnitFormat = (options = {}) => { const lang = options.lang ?? this.config.lang let unitSys = options.unitSys ?? this.config.intl.unitSys[lang] ?? 'metric' if (!['imperial', 'nautical', 'metric'].includes(unitSys)) unitSys = 'metric' return { unitSys, format: formats[unitSys] } } formatByType = (type, value, dataType, options = {}) => { const { defaultsDeep } = this.lib.aneka const { format } = this.getUnitFormat(options) const { withUnit = true } = options const lang = options.lang ?? this.config.lang value = format[`${type}Fn`](value) const unit = format[`${type}Unit`] const sep = format[`${type}UnitSep`] ?? ' ' if (!withUnit) return [value, unit, sep] const setting = defaultsDeep(options[dataType], this.config.intl.format[dataType]) value = new Intl.NumberFormat(lang, setting).format(value) return `${value}${sep}${unit}` } format = (value, type, options = {}) => { const { defaultsDeep } = this.lib.aneka const { format } = this.config.intl const { emptyValue = format.emptyValue } = options const lang = options.lang ?? this.config.lang options.withUnit = options.withUnit ?? true let valueFormatted if ([undefined, null, ''].includes(value)) return emptyValue if (type === 'auto') { if (value instanceof Date) type = 'datetime' } if (['float', 'double'].includes(type) && this.app.bajoSpatial) { const { latToDms, lngToDms } = this.app.bajoSpatial.lib.anekaSpatial if (options.latitude) return latToDms(value) if (options.longitude) return lngToDms(value) } if (['integer', 'smallint', 'float', 'double'].includes(type)) { value = ['integer', 'smallint'].includes(type) ? parseInt(value) : parseFloat(value) if (isNaN(value)) return emptyValue for (const u of formatTypes) { if (options[u]) valueFormatted = this.formatByType(u, value, type, options) } } if (['integer', 'smallint'].includes(type)) { const setting = defaultsDeep(options.integer, format.integer) value = new Intl.NumberFormat(lang, setting).format(Math.round(value)) return valueFormatted && options.withUnit ? valueFormatted : value } if (['float', 'double'].includes(type)) { const setting = defaultsDeep(options[type], format[type]) value = new Intl.NumberFormat(lang, setting).format(value) return valueFormatted && options.withUnit ? valueFormatted : value } if (['datetime', 'date'].includes(type)) { const setting = defaultsDeep(options[type], format[type]) return new Intl.DateTimeFormat(lang, setting).format(new Date(value)) } if (['time'].includes(type)) { const setting = defaultsDeep(options.time, format.time) return new Intl.DateTimeFormat(lang, setting).format(new Date(`1970-01-01T${value}Z`)) } if (['array'].includes(type)) return value.join(', ') if (['object'].includes(type)) return JSON.stringify(value) return value } generateId = (options = {}) => { let type if (options === true) options = 'alpha' if (options === 'int') { type = options options = { pattern: '0123456789', length: 15 } } else if (options === 'alpha') { type = options options = { pattern: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', length: 15 } } let { pattern, length = 13, returnInstance } = options pattern = pattern ?? 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' if (options.case === 'lower') pattern = pattern.toLowerCase() else if (options.case === 'upper') pattern = pattern.toUpperCase() const nid = customAlphabet(pattern, length) if (returnInstance) return nid const value = nid() return type === 'int' ? parseInt(value) : value } getGlobalModuleDir = (pkgName, silent = true) => { let nodeModulesDir = process.env.BAJO_GLOBAL_MODULE_DIR if (!nodeModulesDir) { const npmPath = getGlobalPath('npm') if (!npmPath) { if (silent) return throw this.error('cantLocateNpmGlobalDir', { code: 'BAJO_CANT_LOCATE_NPM_GLOBAL_DIR' }) } nodeModulesDir = dropRight(resolvePath(npmPath).split('/'), 1).join('/') process.env.BAJO_GLOBAL_MODULE_DIR = nodeModulesDir } if (!pkgName) return nodeModulesDir const dir = `${nodeModulesDir}/${pkgName}` if (!fs.existsSync(dir)) { if (silent) return throw this.error('cantLocateGlobalDir%s', pkgName, { code: 'BAJO_CANT_LOCATE_MODULE_GLOBAL_DIR' }) } return dir } getMethod = (name = '', thrown = true) => { const { ns, path } = this.breakNsPath(name) const method = get(this.app, `${ns}.${path}`) if (method && isFunction(method)) return method if (thrown) throw this.error('cantFindMethod%s', name) } findDeep = (item, paths) => { let dir for (const p of paths) { const d = `${p}/${item}` if (fs.existsSync(d)) { dir = d break } } return dir } getModuleDir = (pkgName, base) => { if (pkgName === 'main') return resolvePath(this.app.dir) if (base === 'main') base = this.app.dir else if (this && this.app && this.app[base]) base = this.app[base].pkgName const pkgPath = pkgName + '/package.json' const paths = require.resolve.paths(pkgPath) const gdir = this.getGlobalModuleDir() paths.unshift(gdir) paths.unshift(resolvePath(path.join(this.app.dir, 'node_modules'))) let dir = this.findDeep(pkgPath, paths) if (base && !dir) dir = this.findDeep(`${base}/node_modules/${pkgPath}`, paths) if (!dir) return null return resolvePath(path.dirname(dir)) } getPluginDataDir = (name, ensureDir = true) => { const plugin = this.getPlugin(name) const dir = `${this.app.bajo.dir.data}/plugins/${plugin.name}` if (ensureDir) fs.ensureDirSync(dir) return dir } getPluginFile = (file) => { if (!this) return file if (file[0] === '.') file = `${currentLoc(import.meta).dir}/${trim(file.slice(1), '/')}` if (file.includes(':')) { if (file.slice(1, 2) === ':') return file // windows fs const { ns, path } = this.breakNsPath(file) if (ns !== 'file' && this && this.app && this.app[ns] && ns.length > 1) { file = `${this.app[ns].dir.pkg}${path}` } } return file } getPlugin = (name, silent) => { if (!this.app[name]) { // alias? let plugin for (const key in this.app) { const item = this.app[key] if (item instanceof BajoPlugin && (item.alias === name || item.pkgName === name)) { plugin = item break } } if (!plugin) { if (silent) return false throw this.error('pluginWithNameAliasNotLoaded%s', name) } name = plugin.name } return this.app[name] } importModule = async (file, { asDefaultImport, asHandler, noCache } = {}) => { return await importModule.call(this, file, { asDefaultImport, asHandler, noCache }) } importPkg = async (...pkgs) => { const { defaultsDeep } = this.lib.aneka const result = {} const notFound = [] let opts = { returnDefault: true, thrownNotFound: false } if (isPlainObject(last(pkgs))) { opts = defaultsDeep(pkgs.pop(), opts) } for (const pkg of pkgs) { const { ns, path: name } = this.breakNsPath(pkg) const dir = this.getModuleDir(name, ns) if (!dir) { notFound.push(pkg) continue } const p = this.readJson(`${dir}/package.json`, opts.thrownNotFound) const mainFileOrg = dir + '/' + (p.main ?? get(p, 'exports.default', 'index.js')) let mainFile = resolvePath(mainFileOrg, os.platform() === 'win32') if (isEmpty(path.extname(mainFile))) { if (fs.existsSync(`${mainFileOrg}/index.js`)) mainFile += '/index.js' else mainFile += '.js' } if (opts.noCache) mainFile += `?_=${Date.now()}` let mod = await import(mainFile) if (opts.returnDefault && has(mod, 'default')) { mod = mod.default if (opts.returnDefault && has(mod, 'default')) mod = mod.default } result[name] = mod } if (notFound.length > 0) throw this.error('cantFind%s', this.join(notFound)) if (pkgs.length === 1) return result[keys(result)[0]] if (opts.asObject) return result return values(result) } isEmptyDir = async (dir) => { await fs.exists(dir) return await emptyDir(dir) } isLogInRange = (level) => { const levels = keys(this.logLevels) const logLevel = indexOf(levels, this.app.bajo.config.log.level) return indexOf(levels, level) >= logLevel } isValidAppPlugin = (file, type, returnPkg) => { if (isObject(file)) return get(file, 'bajo.type') === type file = resolvePath(file) if (path.basename(file) !== 'package.json') file += '/package.json' try { const pkg = fs.readJsonSync(file) const valid = get(pkg, 'bajo.type') === type if (valid) return returnPkg ? pkg : valid return false } catch (err) { return false } } isValidApp = (dir, returnPkg) => { if (!dir) dir = this.app.dir return this.isValidAppPlugin(dir, 'app', returnPkg) } isValidPlugin = (dir, returnPkg) => { if (!dir) dir = this.app.dir return this.isValidAppPlugin(dir, 'plugin', returnPkg) } join = (array, sep) => { const { isSet } = this.lib.aneka const translate = val => { if (this && this.print) return this.print.write(val).toLowerCase() return val } if (array.length === 0) return translate('none') if (array.length === 1) return array[0] if (isSet(sep) && !isPlainObject(sep)) return array.join(sep) let { separator = ', ', joiner = 'and' } = sep ?? {} joiner = translate(joiner) const last = (array.pop() ?? '').trim() return array.map(a => (a + '').trim()).join(separator) + ` ${joiner} ${last}` } numUnit = (value = '', defUnit = '') => { const num = value.match(/\d+/g) const unit = value.match(/[a-zA-Z]+/g) return `${num[0]}${isEmpty(unit) ? defUnit : unit[0]}` } parseDur = (val) => { return isNumber(val) ? val : ms(val) } parseDt = (val) => { const dt = this.lib.dayjs(val) if (!dt.isValid()) throw this.error('dtUnparsable%s', val) return dt.toDate() } parseObject = (input, options = {}) => { const { silent = true, parseValue = false, lang, ns } = options const { isSet } = this.lib.aneka const translate = (item) => { const scope = ns ? this.app[ns] : this const [text, ...args] = item.split('|') return scope.print.write(text, ...args, { lang }) } const statics = ['*'] let obj = cloneDeep(input) const keys = Object.keys(obj) const mutated = [] keys.forEach(k => { let v = obj[k] if (isPlainObject(v)) obj[k] = this.parseObject(v, options) else if (isArray(v)) { v.forEach((i, idx) => { if (isPlainObject(i)) obj[k][idx] = this.parseObject(i, options) else if (statics.includes(i)) obj[k][idx] = i else if (parseValue) obj[k][idx] = dotenvParseVariables(set({}, 'item', obj[k][idx]), { assignToProcessEnv: false }).item if (isArray(obj[k][idx])) obj[k][idx] = obj[k][idx].map(item => typeof item === 'string' ? item.trim() : item) }) } else if (isSet(v)) { if (isString(v) && v.startsWith('t:') && lang) v = translate(v.slice(2)) try { if (statics.includes(v)) obj[k] = v else if (k.startsWith('t:') && isString(v)) { const newK = k.slice(2) if (lang) obj[newK] = translate(v) else obj[newK] = v mutated.push(k) } else if (parseValue) { obj[k] = dotenvParseVariables(set({}, 'item', v), { assignToProcessEnv: false }).item if (isArray(obj[k])) obj[k] = obj[k].map(item => typeof item === 'string' ? item.trim() : item) } if (k.slice(-3) === 'Dur') obj[k] = this.parseDur(v) if (k.slice(-2) === 'Dt') obj[k] = this.parseDt(v) } catch (err) { obj[k] = undefined if (!silent) throw err } } }) if (mutated.length > 0) obj = omit(obj, mutated) return obj } pick = (obj, items, excludeUnset) => { const { isSet } = this.lib.aneka const result = {} for (const item of items) { const [k, nk] = item.split(':') if (excludeUnset && !isSet(obj[k])) continue result[nk ?? k] = obj[k] } return result } readConfig = async (file, { ns, pattern, globOptions = {}, ignoreError, defValue = {}, opts = {} } = {}) => { if (!ns) ns = this.name file = resolvePath(this.getPluginFile(file)) let ext = path.extname(file) const fname = path.dirname(file) + '/' + path.basename(file, ext) ext = ext.toLowerCase() if (['.mjs', '.js'].includes(ext)) { const { readHandler } = find(this.app.bajo.configHandlers, { ext }) return this.parseObject(await readHandler.call(this.app[ns], file, opts)) } if (ext === '.json') return await this.readJson(file) if (!['', '.*'].includes(ext)) { const item = find(this.app.bajo.configHandlers, { ext }) if (!item) { if (!ignoreError) throw this.error('cantParse%s', file, { code: 'BAJO_CONFIG_NO_PARSER' }) return this.parseObject(defValue) } return this.parseObject(await item.readHandler.call(this.app[ns], file, opts)) } const item = pattern ?? `${fname}.{${map(map(this.app.bajo.configHandlers, 'ext'), k => k.slice(1)).join(',')}}` const files = await fastGlob(item, globOptions) if (files.length === 0) { if (!ignoreError) throw this.error('noConfigFileFound', { code: 'BAJO_CONFIG_FILE_NOT_FOUND' }) return this.parseObject(defValue) } let config = defValue for (const f of files) { const ext = path.extname(f).toLowerCase() const item = find(this.app.bajo.configHandlers, { ext }) if (!item) { if (!ignoreError) throw this.error('cantParse%s', f, { code: 'BAJO_CONFIG_NO_PARSER' }) continue } config = await item.readHandler.call(this.app[ns], f, opts) if (!isEmpty(config)) break } return this.parseObject(config) } readJson = (file, thrownNotFound = false) => { if (isPlainObject(thrownNotFound)) thrownNotFound = false if (!fs.existsSync(file) && thrownNotFound) throw this.error('notFound%s%s', this.print.write('file'), file) let resp try { resp = fs.readFileSync(file, 'utf8') } catch (err) {} if (isEmpty(resp)) return resp return this.parseObject(JSON.parse(resp)) } runHook = async (hookName, ...args) => { const [ns, path] = (hookName ?? '').split(':') let fns = filter(this.app.bajo.hooks, { ns, path }) if (isEmpty(fns)) return fns = orderBy(fns, ['level']) const results = [] for (const i in fns) { const fn = fns[i] const scope = this.app[fn.src] const res = await fn.handler.call(scope, ...args) results.push({ hook: hookName, resp: res }) if (this.config.log.traceHook) scope.log.trace('hookExecuted%s', hookName) } return results } saveAsDownload = async (file, obj, printSaved = true) => { const { print, getPluginDataDir } = this.app.bajo const fname = increment(`${getPluginDataDir(this.name)}/${trim(file, '/')}`, { fs: true }) const dir = path.dirname(fname) if (!fs.existsSync(dir)) fs.ensureDirSync(dir) await fs.writeFile(fname, obj, 'utf8') if (printSaved) print.succeed('savedAs%s', path.resolve(fname), { skipSilence: true }) return fname } } export default BajoCore