stream-flow-control
Version:
Stream Flow Control
437 lines (400 loc) • 19 kB
JavaScript
const yaml = require('js-yaml');
const fs = require('fs');
const path = require('path');
const classes = require('../index');
const vm = require('vm');
const {Writable, Readable} = require('stream');
const wrreg = /^writev?|destroy|final$/;
const rereg = /^read|destroy$/;
const trreg = /^transform|flush|final$/;
const horeg = /^hold|release|check$/;
const joreg = /^join$/;
const rureg = /^rule$/;
const fireg = /^identify|match|criteria$/
/**
* Mannager is your "toolbelt" class. It's responsible to regiter classes, fetch instances, parse yaml files and building stream chains.
*
* @typicalname sfc
* @extends Writable
*/
class Manager {
constructor() {
this.clean();
}
/**
* Clean namager state
*
* Useful when you need to start again
*
* @returns {Manager} 'this' element for chainable purpose.
*
*/
clean() {
this._registry = {};
this._paused = false;
this._resumeStack = [];
return this;
}
/**
* Set manager in paused mode.
*
* Use it before parsing files like `sfc.pause().parseFiles(...)`
*
* Useful when you need all streams to be created before piping
*
* @returns {Manager} 'this' element for chainable purpose.
*
*/
pause() {
this._paused = true;
return this;
}
/**
* Set inner streams in fowing mode.
*
* Use it after parsing files like `sfc.pause().parseFiles(...).resume()`
*
* Useful when you need all streams to be created before piping
*
* @returns {Manager} 'this' element for chainable purpose.
*
*/
resume() {
if(!this._paused) return this;
// ensure all streams are built
for(let type in this._registry) {
for(let name in this._registry[type]) {
this.get(type, name);
}
}
// execute all piping
this._resumeStack.forEach(fn => fn());
this._paused = false;
return this;
}
/**
* Register a stream
* @param {string} type type of stream
* @param {string} [name] name for this stream
* @param {Writable|Readable} elem the stream to register
*/
set(type, name, elem) {
if(!elem) {
elem = name;
name = elem.options.name;
}
if(!this._registry[type]) this._registry[type] = {};
if(!this._registry[type][name]) this._registry[type][name] = {elem};
if(!this._registry[type][name].elem) this._registry[type][name].elem = elem;
}
/**
* Retrieve a stream by type and name. If stream has ended, creates a new instance
* @param {string} type type of stream
* @param {string} name the name of the stream to retrieve
* @returns {Readable|Writable|null} returns the indicated stream or null if not found
*/
get(type, name) {
const reg = this._registry[type] && this._registry[type][name];
if(!reg) return null;
if(!reg.elem || (reg.elem instanceof Writable && reg.elem._writableState.ended) || (reg.elem instanceof Readable && reg.elem._readableState.ended)) {
if(!reg.build) return null;
reg.elem = reg.build()
}
return reg.elem;
}
/**
* Parse a JSON or YAML string and create streams
* @param {string} confStr JSON or YAML string.
* @param {NodeCallback} cb Callback returning an error if occurred.
*/
parse(confStr, cb) {
let conf;
try {
conf = JSON.parse(confStr)
} catch(e1) {
try {
conf = yaml.safeLoadAll(confStr);
} catch(e2) {
return cb("Probably not valid JSON or YAML."+[e1.message, e2.message]);
}
}
this._build(conf, cb);
}
_build(conf, options, cb) {
if(typeof options == 'function') {
cb=options;
options={};
}
try {
if(!Array.isArray(conf)) conf = [conf];
conf.forEach(conf => {
let _require={};
if(conf._require) {
Object.keys(conf._require).forEach((key)=>{
const m = key.match(/^{(.*)}$/)
if(m) {
const tmp = require(conf._require[key])
m[1].split(/\s*,\s*/).forEach((key)=>{
_require[key] = tmp[key];
})
} else {
_require[key]=require(conf._require[key])
}
});
delete conf._require;
}
_require = {...options.require||{}, ..._require};
const context = {..._require, ...classes};
vm.createContext(context);
if(options.goal && !conf.__goal__) throw 'Goal chain must have "__goal__" key to get a starting point';
const _buildMethod = (code, params)=>{
let fn = null;
if(Array.isArray(code)) code = code.join('\n');
if(typeof code == 'string') fn = vm.compileFunction(code, params||[], {parsingContext: context, contextExtensions: [process, global, {__dirname: require.main.path, __filename: require.main.filename}]});
return fn;
}
let _customTypes={};
if(conf._customTypes) {
Object.keys(conf._customTypes).forEach((key)=>{
if(!conf._customTypes[key].constructor) conf._customTypes[key] = {constructor: conf._customTypes[key]};
_customTypes[key] = _buildMethod(conf._customTypes[key].constructor, ['options']);
});
delete conf._customTypes;
}
_customTypes = {...options.customTypes||{}, ..._customTypes};
const _getDst = (dst) => {
if(Array.isArray(dst)) return dst.map(_getDst);
if(options.goal && dst == '__resolve__') {
let self = options.goal;
return self._resolve_writer;
} else if (options.goal && dst == '__reject__') {
let self = options.goal;
return self._reject_writer;
} else {
return this.get(dst.type, dst.name);
}
};
Object.keys(conf).forEach(key => {
if(key == "__goal__" || key == "_customTypes" || key == "_editorAttrs" || key == "_require") return;
const cnf = conf[key];
if(!cnf.type) throw 'Configuration must have a "type" defined in '+key;
if(cnf.type == 'sfc' || cnf.type == 'DataWrapper') throw 'Invalid type '+cnf.type+' in '+key;
if(cnf.options) {
Object.keys(cnf.options).forEach(op => {
let option = cnf.options[op];
if(option._type == 'method') {
cnf.options[op] = _buildMethod(option.code, option.params);
}
});
}
if(!this._registry[cnf.type]) this._registry[cnf.type] = {};
this._registry[cnf.type][key] = {build: () => {
let elem;
if(cnf.hasOwnProperty('constructor')) {
if(typeof cnf.constructor != 'function') cnf.constructor = _buildMethod(cnf.constructor, ['options']);
elem = cnf.constructor({...cnf.options||{}, name: key});
this.set(cnf.type, key, elem);
} else {
if(!classes[cnf.type]&&!_customTypes[cnf.type]) throw 'Unrecognized type '+cnf.type+' in '+key;
if(classes[cnf.type]) {
elem = new context[cnf.type]({...cnf.options||{}, name: key});
} else {
elem = _customTypes[cnf.type]({...cnf.options||{}, name: key});
}
}
if(options.goal) {
elem.goal = options.goal;
options.goal._registerChild(elem);
}
if(cnf.methods) {
const methods = Object.keys(cnf.methods);
methods.forEach(method=>{
const def = cnf.methods[method];
switch(cnf.type) {
case 'FlowHold':
method = method.replace(horeg, str=>'_'+str);
break;
case 'FlowJoin':
method = method.replace(joreg, str=>'_'+str);
break;
case 'FlowFirst':
method = method.replace(fireg, str=>'_'+str);
break;
case 'Rule':
method = method.replace(rureg, str=>'_'+str);
break;
case 'Duplex':
method = method.replace(wrreg, str=>'_'+str).replace(rereg, str=>'_'+str);
break;
case 'Readable':
method = method.replace(rereg, str=>'_'+str);
break;
case 'Transform':
method = method.replace(trreg, str=>'_'+str);
break;
case 'Writable':
method = method.replace(wrreg, str=>'_'+str);
break;
}
if(typeof def.code != 'function') def.code = _buildMethod(def.code, def.params);
if(typeof def.code == 'function') {
elem[method] = def.code.bind(elem);
}
})
}
if(cnf.on) {
const events = Object.keys(cnf.on);
events.forEach(event=>{
const def = cnf.on[event];
if(typeof def.code != 'function') def.code = _buildMethod(def.code, def.params);
if(typeof def.code == 'function') {
elem.on(event, def.code.bind(elem));
}
});
}
if(cnf.pon) {
const events = Object.keys(cnf.pon);
events.forEach(event=>{
const def = cnf.pon[event];
if(typeof def.code != 'function') def.code = _buildMethod(def.code, def.params);
if(typeof def.code == 'function') {
elem.prependListener(event, def.code.bind(elem));
}
});
}
if(cnf.once) {
const events = Object.keys(cnf.once);
events.forEach(event=>{
const def = cnf.once[event];
if(typeof def.code != 'function') def.code = _buildMethod(def.code, def.params);
if(typeof def.code == 'function') {
elem.once(event, def.code.bind(elem));
}
});
}
if(cnf.ponce) {
const events = Object.keys(cnf.ponce);
events.forEach(event=>{
const def = cnf.ponce[event];
if(typeof def.code != 'function') def.code = _buildMethod(def.code, def.params);
if(typeof def.code == 'function') {
elem.prependOnceListener(event, def.code.bind(elem));
}
});
}
if(cnf.when && elem.when) {
let whens = Array.isArray(cnf.when)?cnf.when:[cnf.when]
whens.forEach(when => {
if(typeof when.cond != 'function') when.cond = _buildMethod(when.cond, ['payload']);
if(typeof when.cond == 'function') {
const dst = _getDst(when.dst);
if(this._paused) {
return this._resumeStack.push(() => elem.when(when.cond.bind(elem), dst));
}
elem.when(when.cond.bind(elem), dst);
}
});
}
if(cnf.pipe && elem.pipe) {
const pipes = Array.isArray(cnf.pipe)?cnf.pipe:[cnf.pipe];
pipes.forEach(pipe => {
const dst = _getDst(pipe);
if(this._paused) {
return this._resumeStack.push(() => elem.pipe(dst));
}
elem.pipe(dst);
});
}
if(cnf.none && elem.none) {
let dst = _getDst(cnf.none);
if(this._paused) {
this._resumeStack.push(() => elem.none(dst));
} else {
elem.none(dst);
}
}
if(cnf.resolve && elem.resolve) {
let dst = _getDst(cnf.resolve);
if(this._paused) {
this._resumeStack.push(() => elem.resolve(dst));
} else {
elem.resolve(dst);
}
}
if(cnf.reject && elem.reject) {
let dst = _getDst(cnf.reject);
if(this._paused) {
this._resumeStack.push(() => elem.reject(dst));
} else {
elem.reject(dst);
}
}
if(cnf.build && elem.build) {
elem.build(cnf.build, {require: _require, customTypes: _customTypes});
}
if(cnf.chain && elem.chain) {
Object.keys(cnf.chain).forEach((event)=>{
let dst = _getDst(cnf.chain[event]);
if(this._paused) {
this._resumeStack.push(() => elem.chain(event, dst));
} else {
elem.chain(event, dst);
}
});
}
return elem;
}}
});
if(options.goal) {
let startElems = _getDst(conf.__goal__);
if(!Array.isArray(startElems)) startElems = [startElems];
startElems = startElems.filter(elem => elem);
if(!startElems.length) throw "Manager could not resolve any of the following elements: "+conf.__group__.join(', ');
options.goal._start.unpipe();
startElems.forEach(dst => options.goal._start.pipe(dst));
}
});
} catch(e) {
if (cb) return cb(e);
throw e;
}
cb && cb();
}
/**
* Parse a file or directory and build streams. If no parameter is passed, parseFiles will automatically search for _sfc_ directory, and files that ends with _.sfc_, _.sfc.yaml_, _.sfc.yml_ or _.sfc.json_.
* @param {string|array} [filePaths] path of yaml/json file or a directory containing those files.
* @returns {Manager} returns 'this' for chainable purposes
*/
parseFiles(filePaths) {
let autoSearch = false;
if(!filePaths) {
autoSearch = true;
filePaths = './';
}
if(!Array.isArray(filePaths)) filePaths = [filePaths];
filePaths.forEach(filePath => {
try {
let stat = fs.statSync(filePath);
if(stat.isDirectory()) {
let files = fs.readdirSync(filePath);
if(files.length) this.parseFiles(files.filter(file => {
if(autoSearch && !/^(.+\.)?sfc(\.(yaml|yml|json))?$/.test(file)) {
return false;
}
return true;
}).map(file=>path.join(filePath, file)));
} else {
if(!/\.(yaml|yml|json|sfc\.?)/.test(path.basename(filePath))) return;
let data = fs.readFileSync(filePath);
this.parse(data, (err) => {
if(err) console.log('Cannot parse file '+filePath+'. '+err);
});
}
} catch(e) {
return console.log(e);
}
});
return this;
}
}
module.exports.Manager = new Manager();