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
text/typescript
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;