@edenjs/cli
Version:
Web Application Framework built on Express.js and Redis
968 lines (843 loc) • 23.3 kB
text/typescript
// Require dependencies
import os from 'os';
import config from 'config';
import dotProp from 'dot-prop';
import winston from 'winston';
import EdenModel from '@edenjs/model';
import EventEmitter from 'events';
import { v4 as uuid } from 'uuid';
// Require local dependencies
import log from './log';
// import local
import pack from '../package.json';
/**
* Create Eden class
*/
class Eden {
/**
* Construct Eden class
*/
constructor() {
// Bind private variables
this.__data = {
id : `${os.hostname()}-${global.cluster}`,
config : global.config,
version : pack.version,
};
// Bind cache methods
this.get = this.get.bind(this);
this.set = this.set.bind(this);
this.del = this.del.bind(this);
this.register = this.register.bind(this);
// Bind public methods
this.start = this.start.bind(this);
// alt methods
this.error = this.error.bind(this);
this.ready = this.ready.bind(this);
this.background = this.background.bind(this);
// Bind event methods
this.on = this.on.bind(this);
this.off = this.off.bind(this);
this.emit = this.emit.bind(this);
this.once = this.once.bind(this);
this.call = this.call.bind(this);
this.endpoint = this.endpoint.bind(this);
// Bind lock methods
this.lock = this.lock.bind(this);
// Bind hook methods
this.pre = this.pre.bind(this);
this.post = this.post.bind(this);
this.hook = this.hook.bind(this);
// Bind private methods
this.buildLogger = this.buildLogger.bind(this);
this.buildDatabase = this.buildDatabase.bind(this);
}
// /////////////////////////////////////////////////////////////////////////////////////////////
//
// Get/Set Methods
//
// /////////////////////////////////////////////////////////////////////////////////////////////
/**
* gets key/value
*
* @param key
* @param remote
*/
get(key, remote = false) {
// get from dotprop
if (remote) {
// return get
return this.get('register.pubsub').get(key);
}
// return get
return dotProp.get(this.__data, key);
}
/**
* sets value
*
* @param key
* @param value
* @param ttl
*/
set(key, value, ttl = false) {
// set
dotProp.set(this.__data, key, value);
// set value
if (ttl) {
// return get
return this.get('register.pubsub').set(key, value, ttl === true ? (24 * 60 * 60 * 1000) : ttl);
}
// return value
return this.get(key);
}
/**
* deletes key
*
* @param key
* @param remote
*/
del(key, remote) {
// set
dotProp.delete(this.__data, key);
// set in pubsub
if (remote) {
// return delete
return this.get('register.pubsub').del(key);
}
}
/**
* register alias
*
* @param key
* @param value
*/
register(key, value) {
// return value
return value ? this.set(`register.${key}`, value) : this.get(`register.${key}`);
}
// /////////////////////////////////////////////////////////////////////////////////////////////
//
// Main Methods
//
// /////////////////////////////////////////////////////////////////////////////////////////////
/**
* Starts Eden framework
*/
async start () {
// Set process name
try {
// Set process name
process.title = `${config.get('domain')} - ${config.get('cluster')} #${this.get('id')}`;
} catch (e) { /* */ }
// add helpers
this.events = new EventEmitter();
this.logger = this.buildLogger();
// set data
this.__data.models = global.models;
this.__data.helpers = global.helpers;
this.__data.daemons = global.daemons;
this.__data.controllers = global.controllers;
// initialize daemons
this.logger.log('info', 'initializing static functions');
// initialize daemons then controllers
for (const daemon of Object.keys(this.get('daemons') || {})) {
// initialize
if (this.get(`daemons.${daemon}.ctrl`).initialize) {
// initialze daemon
await this.get(`daemons.${daemon}.ctrl`).initialize(this);
}
}
for (const controller of Object.keys(this.get('controllers') || {})) {
// initialize
if (this.get(`controllers.${controller}.ctrl`).initialize) {
// initialze daemon
await this.get(`controllers.${controller}.ctrl`).initialize(this);
}
}
// initialize daemons
this.logger.log('info', 'initialized static functions');
// Connect database
await this.buildDatabase();
// add router
if (!config.get('router.disable') && !['back'].includes(global.cluster)) {
// initialize daemons
this.logger.log('info', 'initializing controllers');
// Require router
const Router = require('./eden/router'); // eslint-disable-line global-require
// Bind Eden classes
this.router = new Router(this);
// await router building
await this.router.building;
// initialize daemons
this.logger.log('info', 'initialized controllers');
}
// initialize daemons
this.logger.log('info', 'initializing daemons');
// create daemons
for (const daemon of Object.keys(this.get('daemons') || {})) {
// initialize
await this.init(this.get(`daemons.${daemon}`));
}
// initialize daemons
this.logger.log('info', 'initialized daemons');
// let last ping
let lastPing = 0;
// Add ping/pong logic
this.on('eden.ping', (id) => {
// Pong
this.thread(id).emit('eden.pong', this.__data.id, global.cluster);
}, true);
this.on('eden.pong', (id, cluster) => {
// set threads
this.set(`threads.${id}`, {
ping : (new Date()).getTime() - lastPing,
updated : new Date(),
cluster,
});
}, false);
// Add thread specific listener
this.on(`${this.__data.id}`, ({ type, args, callee }) => {
// Emit data
this.events.emit(type, ...args, {
callee,
});
}, true);
// Emit ready
this.emit('eden.ready', true, false);
// do ping
const ping = () => {
// reset last ping
lastPing = (new Date()).getTime();
// emit ping
this.emit('eden.ping', this.__data.id, true);
};
// ping interval
this.threadInterval = setInterval(ping, 5000);
}
/**
* initializes a controller
*
* @param ctrl
*/
async init(ctrl) {
// check initialized
if (this.get(`controller.${ctrl.data.file}`)) return this.get(`controller.${ctrl.data.file}`);
// log
this.logger.log('debug', 'initializing', {
class : ctrl.data.file,
});
// created
const created = new ctrl.ctrl();
// create controller
this.set(`controller.${ctrl.data.file}`, created);
// loop events
ctrl.events.forEach(({ event, fn, all }) => {
// do endpoint
this.on(event, created[fn], all);
});
// loop endpoints
ctrl.endpoints.forEach(({ endpoint, fn, all }) => {
// do endpoint
this.endpoint(endpoint, created[fn], all);
});
// loop hooks
ctrl.hooks.forEach(({ hook, type, fn, priority }) => {
// do endpoint
this[type](hook, created[fn], priority);
});
// log
this.logger.log('debug', 'initialized', {
class : ctrl.data.file,
});
// return created
return created;
}
/**
* thread
*
* @param {Object} data
*/
background(logic, data, e) {
// check if logic is function
if (typeof logic !== 'string') {
// logic stringify
logic = logic.toString().split('\n');
// remove first/last
logic.pop();
logic.shift();
// return logic
logic = logic.join('\n');
}
// return promise
return new Promise((resolve, reject) => {
// create new worker
const worker = new Worker(`${global.edenRoot}/worker.js`, {
workerData : {
data,
logic,
},
});
// resolve
worker.on('error', reject);
worker.on('message', (message) => {
// check done
if (!message.event) {
// resolve done
return resolve(message.done);
}
// check event
if (message.event && e) {
e(...message.event);
}
});
});
}
/**
* Pretty prints error
*
* By default Eden core passes errors through pretty print
*
* @param {Error} e
*/
error(e) {
// Log error
throw e;
}
/**
* Returns ready
*
* @return {*}
*/
async ready() {
// Return promise
return await new Promise(async (resolve) => {
// @todo old way was stupid
resolve();
});
}
// /////////////////////////////////////////////////////////////////////////////////////////////
//
// Event Methods
//
// /////////////////////////////////////////////////////////////////////////////////////////////
/**
* on function
*
* `this.eden` also acts as a cross-thread event emitter.
* Using redis you can emit and receive Eden events using:
*
* `this.eden.on([event name], [callback], [all threads?])`
*
* For example if we wanted to listen to the event `example` on every thread we would do:
*
* `this.eden.on('example', (...args) => {
* // Log args
* console.info(...args);
* }, true);`
*
* While if we only want to listen to this event on the current thread, we would do:
*
* `this.eden.on('example', (...args) => {
* // Log args
* console.info(...args);
* });`
*
* @param {string} str
* @param {function} fn
* @param {boolean} all
*/
on(str, fn, all) {
// On str/fn
if (all) {
// Pubsub on
this.get('register.pubsub').on(str, fn);
} else {
// Add event listener
this.events.on(str, fn);
}
}
/**
* On function
*
* `this.eden.once([event name], [callback], [all threads?])`
*
* For example if we wanted to listen to one event `example` on every thread we would do:
*
* `this.eden.once('example', (...args) => {
* // Log args
* console.info(...args);
* }, true);`
*
* While if we only want to listen to this event once the current thread, we would do:
*
* `this.eden.once('example', (...args) => {
* // Log args
* console.info(...args);
* });`
*
* @param {string} str
* @param {function} fn
* @param {boolean} all
*/
once(str, fn, all) {
// On str/fn
if (all) {
// Pubsub on
this.get('register.pubsub').once(str, fn);
} else {
// Add event listener
this.events.once(str, fn);
}
}
/**
* Remove event listener function
*
* This method removes an event listener from either all
* or the current thread to do this simply run:
*
* `this.eden.off([event name], [callback], [all threads?])`
*
* @param {string} str
* @param {function} fn
* @param {boolean} all
*
* @return {*}
*/
off(str, fn, all) {
// Emit function
return !all ? this.events.removeListener(str, fn) : this.get('register.pubsub').removeListener(str, fn);
}
/**
* Call endpoint cross threads
*
* This method allows you to call an endpoint in another thread.
* Usually used for inter-daemon communication
* (Daemons running on different threads).
*
* To call a method we need to ensure that the endpoint we expect
* has been registered in the other thread using:
*
* `this.eden.endpoint([endpoint name], [endpoint function])`
*
* When we call an endpoint, we expect the above to return
* an async response,we do this by doing:
*
* `const response = await this.eden.call([endpoint name], [...args])`
*
* @param {string} str
* @param {array} args
*
* @return {Promise}
*/
call(str, ...args) {
// Set all
const all = typeof args[args.length - 1] === 'boolean' ? args[args.length - 1] : false;
// Set id
const id = uuid();
// Create emission
const emission = {
id,
str,
args,
};
// Emit to socket
this.emit(`eden.call.${str}`, emission, !!all);
// Await one response
return new Promise((resolve, reject) => {
// On message
this.once(id, (res) => {
// check success
if (!res.success) {
// deserialize error
const promiseError = new Error(res.error.message);
// set stack
promiseError.stack = res.error.stack;
// throw error
reject(promiseError);
}
// resolve result
resolve(res.result);
}, !!all);
});
}
/**
* Receive call cross threads
*
* This method allows you to register an endpoint in any thread.
* Usually used for inter-daemon communication
* (Daemons running on different threads).
*
* to register a method we need to do the following:
*
* `this.eden.endpoint([endpoint name], [endpoint function], true)`
*
* @param {string} str
* @param {function} fn
* @param {boolean} all
*/
endpoint(str, fn, all) {
// On connect call
this.on(`eden.call.${str}`, async ({ id, args }, opts) => {
// Check opts
if (opts && opts.callee) args.push(opts);
// check res
let res = null;
// try/catch
try {
// get real res
res = await fn(...args);
// Run function
if (opts && opts.callee) {
// emit
return this.thread(opts.callee).emit(id, {
result : res,
success : true,
});
}
// emit
this.emit(id, {
result : res,
success : true,
}, all);
} catch (e) {
// Run function
if (opts && opts.callee) {
// emit
return this.thread(opts.callee).emit(id, {
error : e,
success : false,
});
}
// Run function
return this.emit(id, {
error : e,
success : false,
}, all);
}
}, !!all);
}
/**
* Emit event
*
* this method emits an event to all or the current thread. To use this simply:
*
* `this.eden.emit([event name], [..arguments], [all threads?])`
*
* @param {string} str
* @param {array} args
*
* @return {*}
*/
emit(str, ...args) {
// Set all
const all = typeof args[args.length - 1] === 'boolean' ? args[args.length - 1] : false;
// Emit function
return !all ? this.events.emit(str, ...args) : this.get('register.pubsub').emit(str, ...args);
}
/**
* Emit to specific thread
*
* This method emits an event to a specific thread. To use this simply:
*
* `this.eden.thread('compute', 0).emit([event name], [..arguments])`
* `this.eden.thread('compute', 0).call([endpoint name], [..arguments])`
*
* @param {string} types
* @param {string} thread
*
* @return {*}
*/
thread(id) {
// Returns thread call logic
return {
call : (str, ...args) => {
// Create emission
const emission = {
id : uuid(),
str,
args,
};
// Await one response
const data = new Promise((resolve, reject) => {
// On message
this.once(emission.id, (res) => {
// check success
if (!res.success) {
// deserialize error
const promiseError = new Error(res.error.message);
// set stack
promiseError.stack = res.error.stack;
// throw error
reject(promiseError);
}
// resolve result
resolve(res.result);
}, false);
});
// do pubsub type emit
this.get('register.pubsub').emit(id, {
type : `eden.call.${str}`,
args : [emission],
callee : this.__data.id,
});
// return res
return data;
},
emit : (str, ...args) => {
// Emit to single thread
this.get('register.pubsub').emit(id, {
args,
type : str,
callee : this.__data.id,
});
},
};
}
// /////////////////////////////////////////////////////////////////////////////////////////////
//
// Lock Methods
//
// /////////////////////////////////////////////////////////////////////////////////////////////
/**
* Returns unlock function
*
* Core Eden clusters threads, this means we need to ensure we can stop other logic running
* where we already have logic running. For example checking a user's balance.
*
* To lock a function and ensure other threads need to wait to do their own logic
* on that function simply do the following:
*
* `const unlock = await this.eden.lock([lock name], [ttl ms])`
*
* Once you have finished with the logic you are running, and want to let
* other threads have a go, simply run
* `unlock()`
*
* @param {string} key
* @param {number} ttl
*
* @return {Promise}
*
* @async
*/
lock(key, ttl = 86400) {
// create lock
return this.get('register.pubsub').lock(key, ttl);
}
// /////////////////////////////////////////////////////////////////////////////////////////////
//
// Hook Methods
//
// /////////////////////////////////////////////////////////////////////////////////////////////
/**
* Adds hook pre event
*
* Eden has an internal hook system, though it is only able
* to hook within the thread that the hook is used
* (hooks are not cross-thread).
* This is because the expected hook data is not required to be sanitisable.
*
* To add a function to run before a hooked function, do the following:
*
* `this.eden.pre([hook name], [function to run])`
*
* @param {string} hook
* @param {function} fn
*/
pre(hook, fn, priority = 10) {
// Push pre-hook function
this.__hook(hook);
// add register
this.get('register.hook')[hook].pre.push({
fn,
priority,
});
}
/**
* Add post hook to act as pre
*
* To add a function to run after a hooked function, do the following:
*
* `this.eden.post([hook name], [function to run])`
*
* @param {string} hook
* @param {function} fn
*/
post(hook, fn, priority = 10) {
// Push post-hook function
this.__hook(hook);
// add register
this.get('register.hook')[hook].post.push({
fn,
priority,
});
}
/**
* Adds hook
*
* This method runs an Eden hook, this simply allows us to execute cross-bundle
* functionality on an event. This is used extensively within the core view logic.
*
* To await a hook simply:
*
* `const data = {
* 'hello' : 'world'
* };
*
* // Await hooks to run
* await this.eden.hook('example', data, (data) => {
* // All pre-hooks have run at this point
* data.hello = 'goodbye';
* });
*
* // All post hooks have run at this point
* console.info(data);`
*
* @param {string} hook
* @param {array} args
*
* @async
*/
async hook(hook, ...args) {
// Set fn
let fn = false;
// Get function
if (args.length > 1 && args[args.length - 1] instanceof Function && typeof args[args.length - 1] === 'function' && args[args.length - 1].call) {
[fn] = args.splice(-1);
}
// Check hook
const fns = this.__hook(hook);
// Loop pre-hook functions
for (let a = 0; a < (fns.pre || []).length; a += 1) {
// Exec pre-hook function
if (fns.pre[a]) await fns.pre[a](...args, { hook, type : 'pre' });
}
// Exec actual function
if (fn) await fn(...args);
// Loop post-hook functions
for (let b = 0; b < (fns.post || []).length; b += 1) {
// Exec post-hook function
if (fns.post[b]) await fns.post[b](...args, { hook, type : 'post' });
}
}
/**
* Checks hook
*
* @param {string} hook
*
* @returns {object} register
*
* @private
*/
__hook(hook) {
// Check register
if (!this.get('register.hook')) this.register('hook', {});
// Check hook exists
if (!this.get('register.hook')[hook]) {
// add register hook
this.get('register.hook')[hook] = {
pre : [],
post : [],
};
}
// keys
const keys = Object.keys(this.get('register.hook')).filter((key) => {
// exact match
if (key === hook) return true;
// check split
const splitA = hook.split('.');
const splitB = key.split('.');
// split A/B
if (key.includes('*') && splitA.length > 1 && splitB.length > 1 && splitA.length === splitB.length) {
// check parts
return !splitA.find((part, i) => // return match or star
part !== splitB[i] && splitB[i] !== '*');
}
// return false
return false;
});
// get functions
const fns = {
pre : [],
post : [],
};
// loop keys
keys.forEach((key) => {
// get register
const { pre, post } = this.get('register.hook')[key] || {};
// push post
fns.pre.push(...(pre || []));
fns.post.push(...(post || []));
});
// sort
fns.pre = fns.pre.sort((a, b) => (b.priority || 0) - (a.priority || 0)).map(pre => pre.fn);
fns.post = fns.post.sort((a, b) => (b.priority || 0) - (a.priority || 0)).map(post => post.fn);
// Return register
return fns;
}
// /////////////////////////////////////////////////////////////////////////////////////////////
//
// Private Methods
//
// /////////////////////////////////////////////////////////////////////////////////////////////
/**
* Registers logger
*
* @return {Logger} logger
*
* @private
*/
buildLogger() {
// Set logger
return winston.createLogger({
level : config.get('log.level') || 'info',
transports : [
new winston.transports.Console({
format : log,
colorize : true,
timestamp : true,
}),
],
});
}
/**
* builds database
*/
async buildDatabase() {
// retister db
const unlock = await this.lock('database.register');
// initialize database
try {
// Connects to database
const Plug = require(config.get('database.plug'));
const plug = new Plug(config.get('database.config'));
// Log registering
this.logger.log('info', 'Registering database', {
class : 'Eden',
});
// Construct database with plug
await EdenModel.init(plug);
// Loop models
for (const key of Object.keys(this.get('models'))) {
// Set Model
const Model = model(key);
// Register Model
await EdenModel.register(Model);
// Await initialize
await Model.initialize();
}
// Log registered
this.logger.log('info', 'Registered database', {
class : 'Eden',
});
} catch (e) { console.log(e); this.looger.log('error', e); }
// unlock db register
unlock();
}
}
/**
* Export new Eden instance
*
* @type {Eden}
*/
export default new Eden();