UNPKG

slavery-js

Version:

A simple clustering app that allows you to scale an application on multiple thread, containers or machines

537 lines (487 loc) 22.8 kB
import Network, { Listener, Connection } from '../network/index.js'; import { ServiceClient } from '../service/index.js'; import type { ServiceAddress } from './types/index.js'; import { await_interval, execAsyncCode, log } from '../utils/index.js'; import { serializeError, deserializeError } from 'serialize-error'; /* * this class will basicaly connect to all of the services given to it by the primary service. * 1.- attempt to make a conenction the primary service passed by the server * 2.- it will take a list of services /* this calss will make a slave type which will be an a child of the Node class * this is the class that will be used to run the the client of a node * like other classes it will work as both * the server connection to the client and the client conenction to the server. * this class will have a list of methods that will be converted to listeners * and a list of listeners that will be converted to methods */ type NodeStatus = 'idle' | 'working' | 'error'; type NodeOptions = { timeout?: number, } type NodeClientParamters = { mode: 'client', master_host: string, master_port: number, methods: { [key: string]: (parameter: any) => any }, services?: ServiceAddress[], options?: NodeOptions, } type NodeServerParameters = { mode: 'server', connection: Connection, network: Network, stashSetFunction: (key: string, value: any) => any, stashGetFunction: (key: string) => any, services: ServiceAddress[], statusChangeCallback: (status: NodeStatus, node: Node) => void, options?: NodeOptions, } class Node { public mode: 'client' | 'server'; public id: string | undefined = undefined; public status: NodeStatus = 'idle'; public listeners: Listener[] = []; public lastUpdateAt: number = Date.now(); public master_host: string | undefined = undefined; public master_port: number | undefined = undefined; public network: Network | undefined = undefined; public servicesConnected: boolean = false; public hasStartupFinished: boolean = false; // fields when the class is client handler on a service public statusChangeCallback: ((status: NodeStatus, node: Node) => void) | null = null; // stash changes functions public stashSetFunction: (({ key, value }: { key: string, value: any }) => any) | null = null; public stashGetFunction: ((key: string) => any) | null = null; // fields when the class is a service handler on a node public services: ServiceAddress[] = []; public doneMethods: { [key: string]: boolean } = {}; public methods: { [key: string]: (parameter?: any, self?: Node) => any } = {}; // options public options: NodeOptions = { timeout: 10000, } // takes and empty parameter or a object with the propertie methods constructor(params : NodeClientParamters | NodeServerParameters){ // set the mode this.mode = params.mode; if(this.mode === 'client'){ params = params as NodeClientParamters; // set the master host and port this.master_host = params.master_host; this.master_port = params.master_port; // set the services this.services = params.services || []; // set the options this.options = params.options || {}; // add the methods this.addMethods(params.methods); }else if(this.mode === 'server'){ params = params as NodeServerParameters; // set the stash functions this.setStashFunctions({ set: params.stashSetFunction, get: params.stashGetFunction }); // set the connection this.setNodeConnection(params.connection, params.network); // set the services this.services = params.services; // set the status change callback this.statusChangeCallback = params.statusChangeCallback; } } /* this function will work on any mode the class is on */ public getId = () => this.id; public getStatus = () => this.status; public lastHeardOfIn = () => Date.now() - this.lastUpdateAt; public isIdle = () => this.status === 'idle'; public isWorking = () => this.status === 'working'; public isError = () => this.status === 'error'; private updateLastHeardOf = () => this.lastUpdateAt = Date.now(); private updateStatus = (status: NodeStatus) => this.status = status; public untilFinish = async () => { await await_interval({ condition: () => this.isIdle(), interval: 100, }).catch(() => { throw new Error('The node is not idle') }) return true; } public start = async () => { // this function will start the node if(this.mode === 'client') return await this.start_client(); else if(this.mode === 'server') return await this.start_server(); } public run = async (method: string, parameter: any) => { if(this.mode === 'client') return await this.run_client({ method, parameter }); else if(this.mode === 'server') return await this.run_server({ method, parameter }); } public exec = async (method: string, code: string) => { if(this.mode === 'client') return await this.exec_client(code); else if(this.mode === 'server') return await this.exec_server(code); } public setServices = async (services: ServiceAddress[]) => { if(this.mode === 'client') return await this.setServices_client(services); else if(this.mode === 'server') return await this.setServices_server(services); } public exit = async () => { if(this.mode === 'client') return await this.exit_client(); else if(this.mode === 'server') return await this.exit_server(); } public ping = async () => { if(this.mode === 'client') return await this.ping_client(); else if(this.mode === 'server') return await this.ping_server(); } /* this functions will set the Node.ts as a client handler for the server */ public setNodeConnection(connection: Connection, network: Network){ // get the node id from the conenction this.id = connection.getTargetId(); // set the network this.network = network; // define the listners which we will be using to talk witht the client node if(this.stashSetFunction === null || this.stashGetFunction === null ) throw new Error('The stash functions have not been set'); // set the listeners this.listeners = [// this callbacks will run when we recive this event from the client node { event: '_set_status', parameters: ['status'], callback: this.handleStatusChange.bind(this) }, { event: '_ping', parameters: [], callback: () => '_pong' }, { event: '_set_stash', parameters: ['key', 'value'], callback: this.stashSetFunction }, { event: '_get_stash', parameters: ['key'], callback: this.stashGetFunction }, { event: '_get_services_address', parameters: [], callback: () => this.services }, ] // register the listeners on the connection connection.setListeners(this.listeners); } public setStatusChangeCallback(callback: (status: NodeStatus, node: Node) => void){ this.statusChangeCallback = callback; } public setStashFunctions({ set, get }: { set: (key: string, value: any) => any, get: (key: string) => any }){ this.stashSetFunction = ({ key, value }: { key: string, value: any }) => set(key, value); this.stashGetFunction = get; } public handleStatusChange(status: NodeStatus){ // set status as status and call the callback this.updateStatus(status); this.statusChangeCallback && this.statusChangeCallback(status, this); } public lastHeardOf(){ // this function will be called when the client node tells us that it is working this.updateLastHeardOf(); return this.lastHeardOfIn(); } private async start_server(){ // send the list of services to the client node let response = await this.setServices_server(this.services); if(response === true){ this.servicesConnected = true; return true; }else throw new Error(`slavery-js: [Node][${this.id}] Could not set services on the client node`); } private async run_server({method, parameter}: {method: string, parameter: any}){ // this function will send the node a method to be run in the client // set the status to working this.handleStatusChange('working'); let res = await this.send('_run', { method, parameter }); // set the status to idle this.handleStatusChange('idle'); // if there is an error if(res.isError === true) res.error = deserializeError(res.error); // return the result return res } private async exec_server(code: string){ // this function will send the node a code to be run in the client // set the status to working try { this.handleStatusChange('working'); let res = await this.send('_exec', code); // set the status to idle this.handleStatusChange('idle'); // if there is an error if(res.isError === true) res.error = deserializeError(res.error); // return the result return res; } catch (error) { this.handleStatusChange('idle'); // Make sure to return to idle state log(`[Node][${this.id}] Error in exec_server: ${error}`); return { isError: true, error: serializeError(error) }; } } private async setServices_server(services: ServiceAddress[]){ // this function will send send a list of services to the client node try { return await this.send('_set_services', services); } catch (error) { log(`[Node][${this.id}] Error in setServices_server: ${error}`); throw error; } } public async ping_server(){ // this function will ping the client node try { let res = await this.send('_ping'); if(res === 'pong') this.updateLastHeardOf(); return true; } catch (error) { log(`[Node][${this.id}] Error in ping_server: ${error}`); return false; } } public async exit_server(){ // this function tell the node client to exit try { let res = await this.send('_exit', null) // we catch the timeout erro scince the client node will exit .catch((error) => { if(error === 'timeout') return true; log(`[Node][${this.id}] Error in exit_server: ${error}`); return false; }); return res; } catch (error) { log(`[Node][${this.id}] Error in exit_server: ${error}`); return false; } } public async send(method: string, parameter: any = null){ // fucntion for sending a method to the client node if(this.network === undefined) throw new Error('The network has not been set'); if(this.id === undefined) throw new Error('The id has not been set'); if(this.mode === undefined) throw new Error('The mode has not been set'); // get the connection of which we will send the method let connection: Connection | undefined = undefined; if(this.mode === 'server') connection = this.network.getNode(this.id); else if(this.mode === 'client') connection = this.network.getService('master'); if(connection === undefined) throw new Error('Could not get the conenction from the network'); // send the method to the node return await connection.send(method, parameter); } /* this function will be called when the client node tells us that it is working */ public async start_client(){ // conenct the master process which will tell us what to do // create an id for the node this.id = this.id || Math.random().toString(36).substring(4); this.network = new Network({ name: 'node', id: this.id, options: { timeout: this.options.timeout || 5 * 60 * 1000, } }); // check if the network is defined if(this.master_host === undefined || this.master_port === undefined) throw new Error('The master host and port have not been set'); // form the conenction with the master this.network.connect({ host: this.master_host, port: this.master_port, as: 'master' }); // verify that the connection is established // set the listeners which we will us on the and the master can call on this.listeners = [ { event: '_run', parameters: ['method', 'parameter'], callback: this.run_client.bind(this) }, { event: '_exec', parameters: ['code_string'], callback: this.exec_client.bind(this) }, { event: '_set_services', parameters: ['services'], callback: this.setServices_client.bind(this) }, { event: '_is_idle', parameters: [], callback: this.isIdle.bind(this) }, { event: '_is_busy', parameters: [], callback: this.isBusy.bind(this) }, { event: '_has_done', parameters: ['method'], callback: this.hasDone.bind(this) }, { event: '_ping', parameters: [], callback: () => 'pong' }, { event: '_exit', parameters: [], callback: this.exit_client.bind(this) } ]; // register the listeners on the network this.network.registerListeners(this.listeners); // if we have not recvied the services from the master yet ask for them await await_interval({ condition: () => this.servicesConnected, timeout: 1000 }).catch(async () => { let services = await this.get_sevices_address(); this.setServices_client(services); }) // run startup method await this.run_startup(); } private async run_client({method, parameter}: {method: string, parameter: any}){ // this function will be called by the a service or another node to run a function // wait until services are connected, with timeout of 10 seconds await await_interval({ condition: () => this.servicesConnected, timeout: 10000, interval: 10 }).catch(() => { throw new Error(`slavery-js: [Node][${this.id}] run method, because it could not connect to services`) }) await await_interval({ condition: () => this.hasStartupFinished, timeout: 60 * 1000, interval: 1 }).catch(() => { throw new Error(`slavery-js: [Node][${this.id}] run method, because the startup method did not finish`) }) await await_interval({ condition: () => this.isIdle(), timeout: 60 * 1000, interval: 1 }).catch(() => { throw new Error(`slavery-js: [Node][${this.id}] run method, because the node timed for becoming idle`) }) try { // set the status to working this.updateStatus('working'); // get the services that we have connected to let services = await this.get_services(); let services_params = { ...services, service_slave: this, self: this }; // run method const result = await this.methods[method](parameter, services_params); // set has done method this.doneMethods[method] = true; // return the result return { result, isError: false }; } catch(error){ // serilize the error this.updateStatus('error'); // return the error return { error: serializeError(error), isError: true }; } finally { // set the status to idle this.updateStatus('idle'); } } private async exec_client(code_string: string){ /* this function will execute some passed albitrary code */ // check if the code_string is a string if(typeof code_string !== 'string') return { isError: true, error: serializeError(new Error('Code string is not a string')) } // await until service is connected await await_interval({ condition: () => this.servicesConnected, timeout: 20 * 1000 }).catch(() => { throw new Error(`slavery-js: [Node][${this.id}] executing code, because it could not connect to services`); }) let services = await this.get_services(); let parameter = { ...services, service_slave: this, self: this }; try { // run the albitrary code let result = await execAsyncCode(code_string, parameter); return { result: result, isError: false }; } catch(e) { return { isError: true, error: serializeError(e) } } } private async run_startup(){ // make sure that we have the services connected await await_interval({ condition: () => this.servicesConnected, timeout: 20 * 1000 }).catch(() => { throw new Error(`slavery-js: [Node][${this.id}] Could not startup Node becasue it could not connect to services`); }) if(this.methods['_startup'] === undefined){ // if there is no startup method we just return this.hasStartupFinished = true; return true; } try { // set the status to working let services = await this.get_services(); let parameter = { ...services, service_slave: this, self: this }; // run method const result = await this.methods['_startup'](null, parameter); // set has done method this.doneMethods['_startup'] = true; this.hasStartupFinished = true; // return the result return { result, isError: false }; } catch(error){ // serilize the error this.updateStatus('error'); // return the error throw new Error(`[Node][${this.id}] Could not run startup method: ${error}`); } } private async get_services(){ // get the services that we have connected with their respective clients let services = this.services.map( (s: ServiceAddress) => new ServiceClient(s.name, this.network as Network) ).reduce((acc: any, s: ServiceClient) => { acc[s.name] = s; return acc; }, {}) return services; } // this function will communicate with the master node and set the stash in that moment public setStash = async (key: any, value: any = null) => await this.send('_set_stash', { key, value }); public getStash = async (key: string = '') => await this.send('_get_stash', key); public addMethods(methods: { [key: string]: (parameter: any) => any }){ // we add the methods to this class this.methods = methods; // populate methods done for(let method in methods) this.doneMethods[method] = false; } private async setServices_client(services: ServiceAddress[]){ // we get the list of services that we need to connect to this.services = services; // connect to the services for(let service of services){ let res = await this.connectService(service); if(!res){ console.error('slavery-js: [Node] Client could not connect to the service, ', service.name); return false; }else log(`[Node][${this.id}] Connected to the service, ${service.name}`); } this.servicesConnected = true; return true } public async connectService({ name, host, port }: ServiceAddress){ /* this is the client inplementation. * it will connect to the service and create methods * for every listener that the service has */ if(!host || !port) throw new Error('The service information is not complete'); // check if there is a service already running on the port and host if(this.network === undefined) throw new Error('The network has not been set'); return await this.network.connect({name, host, port}); } private async get_sevices_address(){ // get the services that we have connected with their respective clients let res = await this.send('_get_services_address'); this.services = res; return res; } private async ping_client(){ // this function will ping the master node let res = await this.send('_ping'); if(res === '_pong') this.updateLastHeardOf(); return true; } private async exit_client(){ // before we bail we must be nice enough to close our connections setTimeout(async () => { // if there is a _cleanup method defined if(this.methods['_cleanup'] !== undefined) await this.run_client({ method: '_cleanup', parameter: null }); // we close the connections we have, if(this.network !== undefined) this.network.close(); // then we exit the process process.exit(0); }, 1000); return true } public getListeners(){ if(this.network === undefined) throw new Error('The network has not been set'); if(this.id === undefined) throw new Error('The id has not been set'); let listeners = []; let connection: Connection | undefined = undefined; if(this.mode === 'server'){ connection = this.network.getNode(this.id); listeners = connection.getListeners(); }else if(this.mode === 'client'){ connection = this.network.getNode('master'); listeners = connection.getListeners(); if(connection === undefined) throw new Error('Could not get the conenction from the network'); } return listeners; } public hasDone(method: string){ return this.doneMethods[method] || false; } /* method synonims */ public isBusy = this.isWorking; public hasFinished = this.hasDone; public hasError = this.isError; public toFinish = this.untilFinish; public set = this.setStash; public get = this.getStash; public stash = this.setStash; public unstash = this.getStash; } export default Node;