UNPKG

@sam-logic/compile-code

Version:
520 lines (453 loc) 15.1 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var logicTpl = `import nodeFns from './nodeFns/index.js'; import Context from './context.js'; import EventEmitter from 'eventemitter3'; const LIFECYCLE = new Set(['ctxCreated', 'enterNode', 'leaveNode']); const SHAPES = { START: 'sami-start', BRANCH: 'sami-branch', BEHAVIOR: 'sami-behavior', }; export default class Logic extends EventEmitter { constructor(opts = {}) { super(); this.dsl = opts.dsl; this.lifeCycleEvents = {}; } get cells() { return this.dsl.cells; } get nodes() { return this.cells.filter((cell) => cell.shape !== 'edge'); } get startNodes() { return this.cells.filter((cell) => cell.shape === SHAPES.START); } get edges() { return this.cells.filter((cell) => cell.shape === 'edge'); } _getUnsafeCtx() { // NOTE: don't use in prod return this._unsafeCtx; } _runLifecycleEvent(eventName, ctx) { if (!LIFECYCLE.has(eventName)) { return console.warn(\`Lifecycle \${eventName} is not supported!\`); } if (this.lifeCycleEvents[eventName]) { this.lifeCycleEvents[eventName].forEach((fn) => fn(ctx)); } } _createCtx(opts) { const ctx = new Context(opts); ctx.emit = this.emit.bind(this); this._runLifecycleEvent('ctxCreated', ctx); return ctx; } _getStartNode(trigger) { for (const cell of this.startNodes) { if (cell.data.trigger === trigger) { return cell; } } } _getNextNodes(ctx, curNode, curRet) { const nodes = []; // NOTE: if it is a sami-branch node, find out which port match the curRet condition const isCurNodeShapeBranch = curNode.shape === SHAPES.BRANCH; let curNodeMatchedPort = ''; if (isCurNodeShapeBranch) { const { ports } = curNode.data; for (const key in ports) { const { condition } = ports[key]; // eslint-disable-next-line no-new-func const ret = new Function('ctx', 'return ' + condition)(ctx); if (ret === Boolean(curRet)) { curNodeMatchedPort = key; break; // for (const key in ports) } } } // NOTE: find out next node via edges which source is curNode for (const edge of this.edges) { // edge's source is curNode const isMatchedSource = edge.source.cell === curNode.id; // if it is a sami-branch node, edge.source.port match curRet condition const isMatchedPort = !isCurNodeShapeBranch || edge.source.port === curNodeMatchedPort; if (isMatchedSource && isMatchedPort) { // NOTE: not each edge both has source and target const nextNode = this.nodes.find((item) => item.id === edge.target.cell); nextNode && nodes.push(nextNode); } } return nodes; } use(pluginCreator) { if (typeof pluginCreator !== 'function') { console.error('sami plugin must be a function.'); return; } const plugin = pluginCreator(this); if (typeof plugin !== 'object' || plugin === null) { console.error('sami plugin must return an object.'); return; } for (const eventName in plugin) { if (!Object.prototype.hasOwnProperty.call(plugin, eventName)) { continue; } if (!LIFECYCLE.has(eventName)) { console.warn(\`Lifecycle \${eventName} is not supported in sami.\`); continue; } if (!this.lifeCycleEvents[eventName]) { this.lifeCycleEvents[eventName] = []; } this.lifeCycleEvents[eventName].push(plugin[eventName]); } } async _execNode(ctx, curNode, lastRet, callback) { ctx._transitTo(curNode, lastRet); const fn = nodeFns[curNode.id]; this._runLifecycleEvent('enterNode', ctx); const curRet = await fn(ctx); this._runLifecycleEvent('leaveNode', ctx); if (curNode.shape !== SHAPES.BRANCH) { lastRet = curRet; } const nextNodes = this._getNextNodes(ctx, curNode, curRet); if (nextNodes.length > 0) { nextNodes.forEach(async (node) => { await this._execNode(ctx, node, lastRet, callback); }); } else { callback && callback(lastRet); } } async invoke(trigger, data, callback) { const curNode = this._getStartNode(trigger); if (!curNode) { return Promise.reject(new Error(\`Invoke failed! No logic-start named \${trigger} found!\`)); } this._unsafeCtx = this._createCtx({ payload: data }); await this._execNode(this._unsafeCtx, curNode, undefined, callback); } } `; var contextTpl = `export default class Context { constructor(opts) { this._init(opts); } _init(opts = {}) { const { payload = {} } = opts; this.curNode = null; this.context = {}; this.payload = Object.freeze({ ...payload }); } _transitTo(node, lastRet) { this.curNode = node; this.lastRet = lastRet; } getConfig() { return this.curNode.data.configData; } getPayload() { return this.payload; } getPipe() { return this.lastRet; } getContext() { return this.context; } setContext(data = {}) { Object.keys(data).forEach((key) => { this.context[key] = data[key]; }); } } `; const makeCode = (mockNode, mockInput) => ` (async function run() { // Context ${contextTpl.replace(/export\s+default/, '')} // Logic ${logicTpl .split('\n') .filter( (line) => !line.match(/import nodeFns/) && !line.match(/import Context/), ) .join('\n') .replace(/export\s+default/, '') .replace( `import EventEmitter from 'eventemitter3';`, `const EventEmitter = window.EventEmitter || (await import('https://jspm.dev/eventemitter3')).default;`, )} // DSL // define dsl here // nodeFns map // define nodeFns here // sami plugin const mockPlugin = () => { const mockNode = ${JSON.stringify(mockNode)}; const mockInput = ${JSON.stringify(mockInput)}; const toMockTargets = [ ['pipe', 'getPipe'], ['config', 'getConfig'], ['payload', 'getPayload'], ['context', 'getContext'], ]; return { enterNode(ctx) { // hijack if(ctx.curNode.id === mockNode.id) { toMockTargets.forEach(item => { const [type, method] = item; item[2] = ctx[method]; ctx[method] = () => mockInput[type]; }); } }, leaveNode(ctx) { // restore if(ctx.curNode.id === mockNode.id) { toMockTargets.forEach(item => { const [type, method, originMethod] = item; ctx[method] = originMethod; }); } } }; }; // instantiation and invoke const logic = new Logic({ dsl }); logic.use(mockPlugin); // use custom code start logic.invoke('$TRIGGER$', {}, (pipe) => { const ctx = logic._getUnsafeCtx(); const context = ctx.getContext(); window.dispatchEvent(new CustomEvent('samiOnlineExecEnds', {detail: {pipe, context}})); }); // use custom code end })().catch(err => { console.error(err.message); window.dispatchEvent(new CustomEvent('samiOnlineExecEnds', {detail: {error: {message: err.message}}})); }); `; const extractObj = ( obj = {}, keys = [], ) => { const ret = {}; keys.forEach((key) => { if (obj[key]) { ret[key] = obj[key]; } }); return ret; }; const simplifyDSL = (dsl) => { const { cells = [] } = dsl; return { cells: cells.map((cell) => { if (cell.shape === 'edge') { return extractObj(cell, ['id', 'shape', 'source', 'target']); } else { const newCell = extractObj(cell, ['id', 'shape', 'data']); newCell.data = extractObj(cell.data, [ 'trigger', 'configData', 'ports', ]); return newCell; } }), }; }; /* eslint-disable no-useless-escape */ /** * Solution * * 1. find the source node form dsl, and if it is not sami-start, * then insert a vitural sami-start at first. * * 2. transform node funciton, follows should be noted: * - import statement should be replaced with import('packge/from/network') * - export statement should be replace with return function * - each node function should be wrapped within a new function to avoid duplicate declaration global variable * * 3. assemble Logic, Context, simplyfied dsl and nodeFns map into one file * */ const INSERT_DSL_COMMENT = '// define dsl here'; const INSERT_NODE_FNS_COMMENT = '// define nodeFns here'; const INSERT_USE_CUSTOM_CODE_START = '// use custom code start'; const INSERT_USE_CUSTOM_CODE_END = '// use custom code end'; const importRegex = /import\s([\s\S]*?)\sfrom\s('|")((@\w[\w\.\-]+\/)?(\w[\w\.\-\/]+))\2/gm; const virtualSourceNode = { id: 'virtual-sami-start', shape: 'sami-start', data: { trigger: 'virtual-sami-start', configData: {}, code: 'export default async function(ctx) {\n \n}', }, }; const findStartNode = (dsl) => { const nodes = dsl.cells.filter((cell) => cell.shape !== 'edge'); const edges = dsl.cells.filter((cell) => cell.shape === 'edge'); if (nodes.length === 0) { throw new Error('Compile failed, no node is selected'); } let foundEdge = null; let startNode = nodes[0]; while ( (foundEdge = edges.find((edge) => edge.target.cell === startNode.id)) ) { const newSourceId = foundEdge.source.cell; startNode = nodes.find( (node) => node.id === newSourceId, ); } if (startNode.shape !== 'sami-start') { dsl.cells.push(virtualSourceNode, { shape: 'edge', source: { cell: 'virtual-sami-start', }, target: { cell: startNode.id, }, }); startNode = virtualSourceNode; } return startNode; }; const getNextNode = (curNode, dsl) => { const nodes = dsl.cells.filter((cell) => cell.shape !== 'edge'); const edges = dsl.cells.filter((cell) => cell.shape === 'edge'); const foundEdge = edges.find((edge) => edge.source.cell === curNode.id); if (foundEdge) { return nodes.find((node) => node.id === foundEdge.target.cell); } }; const compileSimplifiedDSL = (dsl) => { const simplyfiedDSL = JSON.stringify(simplifyDSL(dsl), null, 2); return `const dsl = ${simplyfiedDSL};`; }; const compileNodeFn = (node) => { const { data: { /* label, */ code }, } = node; const newCode = code .replace( importRegex, (match, p1, p2, p3) => { return `const ${p1} = (await import('https://jspm.dev/${p3}')).default;`; }, ) .replace(/export\s+default/, 'return'); return `await (async function() { ${newCode} }())`; }; const compileNodeFnsMap = (dsl) => { const nodes = dsl.cells.filter((cell) => cell.shape !== 'edge'); const kvs = nodes.map((node) => { const { id } = node; return `'${id}': ${compileNodeFn(node)}`; }); return `const nodeFns = {\n ${kvs.join(',\n ')}\n}`; }; const compile$1 = (dsl, mockInput, options = { customCode: { 'index.js': '' } }) => { const startNode = findStartNode(dsl); const mockNode = getNextNode(startNode, dsl); let output = makeCode(mockNode, mockInput) .replace(INSERT_DSL_COMMENT, compileSimplifiedDSL(dsl)) .replace(INSERT_NODE_FNS_COMMENT, compileNodeFnsMap(dsl)) .replace('$TRIGGER$', startNode.data.trigger); const start = output.substring(0, output.indexOf(INSERT_USE_CUSTOM_CODE_START)); const end = output.substring(output.indexOf(INSERT_USE_CUSTOM_CODE_END)); if (options.customCode['index.js']) { output = start + options.customCode['index.js'] + end; } return output; }; const INSERT_IMPORT_PLUGINS_COMMENT = '// import plugins here'; const INSERT_USE_PLUGINS_COMMENT = '// use plugins here'; const INSERT_USE_CUSTOM_CODE = '// use custom code'; const addPlugins = (originalCode = '', plugins = [], options = {}) => { const modifiedContent = originalCode .replace(new RegExp(INSERT_IMPORT_PLUGINS_COMMENT), () => { return plugins .map((plugin, index) => `import plugin${index} from '${plugin}';`) .join('\n'); }) .replace(new RegExp(INSERT_USE_PLUGINS_COMMENT), () => { return plugins.map((_, index) => `logic.use(plugin${index});`).join('\n'); }) .replace(new RegExp(INSERT_USE_CUSTOM_CODE), () => { return options.customCode['index.js'] || ''; }); return modifiedContent; }; const genEntryFile = (nodeIds) => { const imports = []; const funcMaps = []; nodeIds.forEach((id, idx) => { const funcName = `fn_${idx}`; imports.push(`import ${funcName} from './${id}';`); funcMaps.push(`'${id}': ${funcName}`); }); const fileContent = [ imports.join('\n'), `const nodeFns = {\n ${funcMaps.join(',\n ')}\n};`, 'export default nodeFns;', ].join('\n'); return fileContent; }; const genNodeFns = (dsl) => { const nodeFns = {}; const { cells = [] } = dsl; const nodes = cells.filter((cell) => cell.shape !== 'edge'); for (const { id, shape, data: { label, code }, } of nodes) { const fileName = id + '.js'; const descData = `// ${shape}: ${label}\n`; const saveData = `${descData}\n${code}`; nodeFns[fileName] = saveData; } return nodeFns; }; const extract = (dsl) => { const nodeFns = genNodeFns(dsl); const nodeIds = Object.keys(nodeFns).map((fileName) => fileName.slice(0, -3)); const entryFileContent = genEntryFile(nodeIds); nodeFns['index.js'] = entryFileContent; return nodeFns; }; var indexTpl = `import Logic from './logic.js'; import dsl from './dsl.json'; // import plugins here const logic = new Logic({ dsl }); // use plugins here // use custom code export default logic; `; const compile = (dsl, plugins = [], options = {customCode: {'index.js': ''}}) => { const output = { nodeFns: extract(dsl), 'context.js': contextTpl, 'dsl.json': JSON.stringify(simplifyDSL(dsl), null, 2), 'index.js': addPlugins(indexTpl, plugins, options), 'logic.js': logicTpl, }; return output; }; exports.compileForOnline = compile$1; exports.compileForProject = compile;