UNPKG

noflo

Version:

Flow-Based Programming environment for JavaScript

947 lines (801 loc) 27.5 kB
# NoFlo - Flow-Based Programming for JavaScript # (c) 2013-2016 TheGrid (Rituwall Inc.) # (c) 2011-2012 Henri Bergius, Nemein # NoFlo may be freely distributed under the MIT license # # NoFlo graphs are Event Emitters, providing signals when the graph # definition changes. # {EventEmitter} = require 'events' clone = require('./Utils').clone platform = require './Platform' # This class represents an abstract NoFlo graph containing nodes # connected to each other with edges. # # These graphs can be used for visualization and sketching, but # also are the way to start a NoFlo network. class Graph extends EventEmitter name: '' properties: {} nodes: [] edges: [] initializers: [] exports: [] inports: {} outports: {} groups: [] # ## Creating new graphs # # Graphs are created by simply instantiating the Graph class # and giving it a name: # # myGraph = new Graph 'My very cool graph' constructor: (@name = '') -> @properties = {} @nodes = [] @edges = [] @initializers = [] @exports = [] @inports = {} @outports = {} @groups = [] @transaction = id: null depth: 0 # ## Group graph changes into transactions # # If no transaction is explicitly opened, each call to # the graph API will implicitly create a transaction for that change startTransaction: (id, metadata) -> if @transaction.id throw Error("Nested transactions not supported") @transaction.id = id @transaction.depth = 1 @emit 'startTransaction', id, metadata endTransaction: (id, metadata) -> if not @transaction.id throw Error("Attempted to end non-existing transaction") @transaction.id = null @transaction.depth = 0 @emit 'endTransaction', id, metadata checkTransactionStart: () -> if not @transaction.id @startTransaction 'implicit' else if @transaction.id == 'implicit' @transaction.depth += 1 checkTransactionEnd: () -> if @transaction.id == 'implicit' @transaction.depth -= 1 if @transaction.depth == 0 @endTransaction 'implicit' # ## Modifying Graph properties # # This method allows changing properties of the graph. setProperties: (properties) -> @checkTransactionStart() before = clone @properties for item, val of properties @properties[item] = val @emit 'changeProperties', @properties, before @checkTransactionEnd() # ## Exporting a port from subgraph # # This allows subgraphs to expose a cleaner API by having reasonably # named ports shown instead of all the free ports of the graph # # The ports exported using this way are ambiguous in their direciton. Use # `addInport` or `addOutport` instead to disambiguate. addExport: (publicPort, nodeKey, portKey, metadata = {x:0,y:0}) -> # Check that node exists return unless @getNode nodeKey @checkTransactionStart() exported = public: publicPort.toLowerCase() process: nodeKey port: portKey.toLowerCase() metadata: metadata @exports.push exported @emit 'addExport', exported @checkTransactionEnd() removeExport: (publicPort) -> publicPort = publicPort.toLowerCase() found = null for exported, idx in @exports found = exported if exported.public is publicPort return unless found @checkTransactionStart() @exports.splice @exports.indexOf(found), 1 @emit 'removeExport', found @checkTransactionEnd() addInport: (publicPort, nodeKey, portKey, metadata) -> # Check that node exists return unless @getNode nodeKey publicPort = publicPort.toLowerCase() @checkTransactionStart() @inports[publicPort] = process: nodeKey port: portKey.toLowerCase() metadata: metadata @emit 'addInport', publicPort, @inports[publicPort] @checkTransactionEnd() removeInport: (publicPort) -> publicPort = publicPort.toLowerCase() return unless @inports[publicPort] @checkTransactionStart() port = @inports[publicPort] @setInportMetadata publicPort, {} delete @inports[publicPort] @emit 'removeInport', publicPort, port @checkTransactionEnd() renameInport: (oldPort, newPort) -> oldPort = oldPort.toLowerCase() newPort = newPort.toLowerCase() return unless @inports[oldPort] @checkTransactionStart() @inports[newPort] = @inports[oldPort] delete @inports[oldPort] @emit 'renameInport', oldPort, newPort @checkTransactionEnd() setInportMetadata: (publicPort, metadata) -> publicPort = publicPort.toLowerCase() return unless @inports[publicPort] @checkTransactionStart() before = clone @inports[publicPort].metadata @inports[publicPort].metadata = {} unless @inports[publicPort].metadata for item, val of metadata if val? @inports[publicPort].metadata[item] = val else delete @inports[publicPort].metadata[item] @emit 'changeInport', publicPort, @inports[publicPort], before @checkTransactionEnd() addOutport: (publicPort, nodeKey, portKey, metadata) -> # Check that node exists return unless @getNode nodeKey publicPort = publicPort.toLowerCase() @checkTransactionStart() @outports[publicPort] = process: nodeKey port: portKey.toLowerCase() metadata: metadata @emit 'addOutport', publicPort, @outports[publicPort] @checkTransactionEnd() removeOutport: (publicPort) -> publicPort = publicPort.toLowerCase() return unless @outports[publicPort] @checkTransactionStart() port = @outports[publicPort] @setOutportMetadata publicPort, {} delete @outports[publicPort] @emit 'removeOutport', publicPort, port @checkTransactionEnd() renameOutport: (oldPort, newPort) -> oldPort = oldPort.toLowerCase() newPort = newPort.toLowerCase() return unless @outports[oldPort] @checkTransactionStart() @outports[newPort] = @outports[oldPort] delete @outports[oldPort] @emit 'renameOutport', oldPort, newPort @checkTransactionEnd() setOutportMetadata: (publicPort, metadata) -> publicPort = publicPort.toLowerCase() return unless @outports[publicPort] @checkTransactionStart() before = clone @outports[publicPort].metadata @outports[publicPort].metadata = {} unless @outports[publicPort].metadata for item, val of metadata if val? @outports[publicPort].metadata[item] = val else delete @outports[publicPort].metadata[item] @emit 'changeOutport', publicPort, @outports[publicPort], before @checkTransactionEnd() # ## Grouping nodes in a graph # addGroup: (group, nodes, metadata) -> @checkTransactionStart() g = name: group nodes: nodes metadata: metadata @groups.push g @emit 'addGroup', g @checkTransactionEnd() renameGroup: (oldName, newName) -> @checkTransactionStart() for group in @groups continue unless group continue unless group.name is oldName group.name = newName @emit 'renameGroup', oldName, newName @checkTransactionEnd() removeGroup: (groupName) -> @checkTransactionStart() for group in @groups continue unless group continue unless group.name is groupName @setGroupMetadata group.name, {} @groups.splice @groups.indexOf(group), 1 @emit 'removeGroup', group @checkTransactionEnd() setGroupMetadata: (groupName, metadata) -> @checkTransactionStart() for group in @groups continue unless group continue unless group.name is groupName before = clone group.metadata for item, val of metadata if val? group.metadata[item] = val else delete group.metadata[item] @emit 'changeGroup', group, before @checkTransactionEnd() # ## Adding a node to the graph # # Nodes are identified by an ID unique to the graph. Additionally, # a node may contain information on what NoFlo component it is and # possible display coordinates. # # For example: # # myGraph.addNode 'Read, 'ReadFile', # x: 91 # y: 154 # # Addition of a node will emit the `addNode` event. addNode: (id, component, metadata) -> @checkTransactionStart() metadata = {} unless metadata node = id: id component: component metadata: metadata @nodes.push node @emit 'addNode', node @checkTransactionEnd() node # ## Removing a node from the graph # # Existing nodes can be removed from a graph by their ID. This # will remove the node and also remove all edges connected to it. # # myGraph.removeNode 'Read' # # Once the node has been removed, the `removeNode` event will be # emitted. removeNode: (id) -> node = @getNode id return unless node @checkTransactionStart() toRemove = [] for edge in @edges if (edge.from.node is node.id) or (edge.to.node is node.id) toRemove.push edge for edge in toRemove @removeEdge edge.from.node, edge.from.port, edge.to.node, edge.to.port toRemove = [] for initializer in @initializers if initializer.to.node is node.id toRemove.push initializer for initializer in toRemove @removeInitial initializer.to.node, initializer.to.port toRemove = [] for exported in @exports if id.toLowerCase() is exported.process toRemove.push exported for exported in toRemove @removeExport exported.public toRemove = [] for pub, priv of @inports if priv.process is id toRemove.push pub for pub in toRemove @removeInport pub toRemove = [] for pub, priv of @outports if priv.process is id toRemove.push pub for pub in toRemove @removeOutport pub for group in @groups continue unless group index = group.nodes.indexOf(id) continue if index is -1 group.nodes.splice index, 1 @setNodeMetadata id, {} if -1 isnt @nodes.indexOf node @nodes.splice @nodes.indexOf(node), 1 @emit 'removeNode', node @checkTransactionEnd() # ## Getting a node # # Nodes objects can be retrieved from the graph by their ID: # # myNode = myGraph.getNode 'Read' getNode: (id) -> for node in @nodes continue unless node return node if node.id is id return null # ## Renaming a node # # Nodes IDs can be changed by calling this method. renameNode: (oldId, newId) -> @checkTransactionStart() node = @getNode oldId return unless node node.id = newId for edge in @edges continue unless edge if edge.from.node is oldId edge.from.node = newId if edge.to.node is oldId edge.to.node = newId for iip in @initializers continue unless iip if iip.to.node is oldId iip.to.node = newId for pub, priv of @inports if priv.process is oldId priv.process = newId for pub, priv of @outports if priv.process is oldId priv.process = newId for exported in @exports if exported.process is oldId exported.process = newId for group in @groups continue unless group index = group.nodes.indexOf(oldId) continue if index is -1 group.nodes[index] = newId @emit 'renameNode', oldId, newId @checkTransactionEnd() # ## Changing a node's metadata # # Node metadata can be set or changed by calling this method. setNodeMetadata: (id, metadata) -> node = @getNode id return unless node @checkTransactionStart() before = clone node.metadata node.metadata = {} unless node.metadata for item, val of metadata if val? node.metadata[item] = val else delete node.metadata[item] @emit 'changeNode', node, before @checkTransactionEnd() # ## Connecting nodes # # Nodes can be connected by adding edges between a node's outport # and another node's inport: # # myGraph.addEdge 'Read', 'out', 'Display', 'in' # myGraph.addEdgeIndex 'Read', 'out', null, 'Display', 'in', 2 # # Adding an edge will emit the `addEdge` event. addEdge: (outNode, outPort, inNode, inPort, metadata = {}) -> outPort = outPort.toLowerCase() inPort = inPort.toLowerCase() for edge in @edges # don't add a duplicate edge return if (edge.from.node is outNode and edge.from.port is outPort and edge.to.node is inNode and edge.to.port is inPort) return unless @getNode outNode return unless @getNode inNode @checkTransactionStart() edge = from: node: outNode port: outPort to: node: inNode port: inPort metadata: metadata @edges.push edge @emit 'addEdge', edge @checkTransactionEnd() edge # Adding an edge will emit the `addEdge` event. addEdgeIndex: (outNode, outPort, outIndex, inNode, inPort, inIndex, metadata = {}) -> return unless @getNode outNode return unless @getNode inNode outPort = outPort.toLowerCase() inPort = inPort.toLowerCase() inIndex = undefined if inIndex is null outIndex = undefined if outIndex is null metadata = {} unless metadata @checkTransactionStart() edge = from: node: outNode port: outPort index: outIndex to: node: inNode port: inPort index: inIndex metadata: metadata @edges.push edge @emit 'addEdge', edge @checkTransactionEnd() edge # ## Disconnected nodes # # Connections between nodes can be removed by providing the # nodes and ports to disconnect. # # myGraph.removeEdge 'Display', 'out', 'Foo', 'in' # # Removing a connection will emit the `removeEdge` event. removeEdge: (node, port, node2, port2) -> @checkTransactionStart() port = port.toLowerCase() port2 = port2.toLowerCase() toRemove = [] toKeep = [] if node2 and port2 for edge,index in @edges if edge.from.node is node and edge.from.port is port and edge.to.node is node2 and edge.to.port is port2 @setEdgeMetadata edge.from.node, edge.from.port, edge.to.node, edge.to.port, {} toRemove.push edge else toKeep.push edge else for edge,index in @edges if (edge.from.node is node and edge.from.port is port) or (edge.to.node is node and edge.to.port is port) @setEdgeMetadata edge.from.node, edge.from.port, edge.to.node, edge.to.port, {} toRemove.push edge else toKeep.push edge @edges = toKeep for edge in toRemove @emit 'removeEdge', edge @checkTransactionEnd() # ## Getting an edge # # Edge objects can be retrieved from the graph by the node and port IDs: # # myEdge = myGraph.getEdge 'Read', 'out', 'Write', 'in' getEdge: (node, port, node2, port2) -> port = port.toLowerCase() port2 = port2.toLowerCase() for edge,index in @edges continue unless edge if edge.from.node is node and edge.from.port is port if edge.to.node is node2 and edge.to.port is port2 return edge return null # ## Changing an edge's metadata # # Edge metadata can be set or changed by calling this method. setEdgeMetadata: (node, port, node2, port2, metadata) -> edge = @getEdge node, port, node2, port2 return unless edge @checkTransactionStart() before = clone edge.metadata edge.metadata = {} unless edge.metadata for item, val of metadata if val? edge.metadata[item] = val else delete edge.metadata[item] @emit 'changeEdge', edge, before @checkTransactionEnd() # ## Adding Initial Information Packets # # Initial Information Packets (IIPs) can be used for sending data # to specified node inports without a sending node instance. # # IIPs are especially useful for sending configuration information # to components at NoFlo network start-up time. This could include # filenames to read, or network ports to listen to. # # myGraph.addInitial 'somefile.txt', 'Read', 'source' # myGraph.addInitialIndex 'somefile.txt', 'Read', 'source', 2 # # If inports are defined on the graph, IIPs can be applied calling # the `addGraphInitial` or `addGraphInitialIndex` methods. # # myGraph.addGraphInitial 'somefile.txt', 'file' # myGraph.addGraphInitialIndex 'somefile.txt', 'file', 2 # # Adding an IIP will emit a `addInitial` event. addInitial: (data, node, port, metadata) -> return unless @getNode node port = port.toLowerCase() @checkTransactionStart() initializer = from: data: data to: node: node port: port metadata: metadata @initializers.push initializer @emit 'addInitial', initializer @checkTransactionEnd() initializer addInitialIndex: (data, node, port, index, metadata) -> return unless @getNode node index = undefined if index is null port = port.toLowerCase() @checkTransactionStart() initializer = from: data: data to: node: node port: port index: index metadata: metadata @initializers.push initializer @emit 'addInitial', initializer @checkTransactionEnd() initializer addGraphInitial: (data, node, metadata) -> inport = @inports[node] return unless inport @addInitial data, inport.process, inport.port, metadata addGraphInitialIndex: (data, node, index, metadata) -> inport = @inports[node] return unless inport @addInitialIndex data, inport.process, inport.port, index, metadata # ## Removing Initial Information Packets # # IIPs can be removed by calling the `removeInitial` method. # # myGraph.removeInitial 'Read', 'source' # # If the IIP was applied via the `addGraphInitial` or # `addGraphInitialIndex` functions, it can be removed using # the `removeGraphInitial` method. # # myGraph.removeGraphInitial 'file' # # Remove an IIP will emit a `removeInitial` event. removeInitial: (node, port) -> port = port.toLowerCase() @checkTransactionStart() toRemove = [] toKeep = [] for edge, index in @initializers if edge.to.node is node and edge.to.port is port toRemove.push edge else toKeep.push edge @initializers = toKeep for edge in toRemove @emit 'removeInitial', edge @checkTransactionEnd() removeGraphInitial: (node) -> inport = @inports[node] return unless inport @removeInitial inport.process, inport.port toDOT: -> cleanID = (id) -> id.replace /\s*/g, "" cleanPort = (port) -> port.replace /\./g, "" dot = "digraph {\n" for node in @nodes dot += " #{cleanID(node.id)} [label=#{node.id} shape=box]\n" for initializer, id in @initializers if typeof initializer.from.data is 'function' data = 'Function' else data = initializer.from.data dot += " data#{id} [label=\"'#{data}'\" shape=plaintext]\n" dot += " data#{id} -> #{cleanID(initializer.to.node)}[headlabel=#{cleanPort(initializer.to.port)} labelfontcolor=blue labelfontsize=8.0]\n" for edge in @edges dot += " #{cleanID(edge.from.node)} -> #{cleanID(edge.to.node)}[taillabel=#{cleanPort(edge.from.port)} headlabel=#{cleanPort(edge.to.port)} labelfontcolor=blue labelfontsize=8.0]\n" dot += "}" return dot toYUML: -> yuml = [] for initializer in @initializers yuml.push "(start)[#{initializer.to.port}]->(#{initializer.to.node})" for edge in @edges yuml.push "(#{edge.from.node})[#{edge.from.port}]->(#{edge.to.node})" yuml.join "," toJSON: -> json = properties: {} inports: {} outports: {} groups: [] processes: {} connections: [] json.properties.name = @name if @name for property, value of @properties json.properties[property] = value for pub, priv of @inports json.inports[pub] = priv for pub, priv of @outports json.outports[pub] = priv # Legacy exported ports for exported in @exports json.exports = [] unless json.exports json.exports.push exported for group in @groups groupData = name: group.name nodes: group.nodes if Object.keys(group.metadata).length groupData.metadata = group.metadata json.groups.push groupData for node in @nodes json.processes[node.id] = component: node.component if node.metadata json.processes[node.id].metadata = node.metadata for edge in @edges connection = src: process: edge.from.node port: edge.from.port index: edge.from.index tgt: process: edge.to.node port: edge.to.port index: edge.to.index connection.metadata = edge.metadata if Object.keys(edge.metadata).length json.connections.push connection for initializer in @initializers json.connections.push data: initializer.from.data tgt: process: initializer.to.node port: initializer.to.port index: initializer.to.index json save: (file, callback) -> json = JSON.stringify @toJSON(), null, 4 require('fs').writeFile "#{file}.json", json, "utf-8", (err, data) -> throw err if err callback file exports.Graph = Graph exports.createGraph = (name) -> new Graph name exports.loadJSON = (definition, callback, metadata = {}) -> definition = JSON.parse definition if typeof definition is 'string' definition.properties = {} unless definition.properties definition.processes = {} unless definition.processes definition.connections = [] unless definition.connections graph = new Graph definition.properties.name graph.startTransaction 'loadJSON', metadata properties = {} for property, value of definition.properties continue if property is 'name' properties[property] = value graph.setProperties properties for id, def of definition.processes def.metadata = {} unless def.metadata graph.addNode id, def.component, def.metadata for conn in definition.connections metadata = if conn.metadata then conn.metadata else {} if conn.data isnt undefined if typeof conn.tgt.index is 'number' graph.addInitialIndex conn.data, conn.tgt.process, conn.tgt.port.toLowerCase(), conn.tgt.index, metadata else graph.addInitial conn.data, conn.tgt.process, conn.tgt.port.toLowerCase(), metadata continue if typeof conn.src.index is 'number' or typeof conn.tgt.index is 'number' graph.addEdgeIndex conn.src.process, conn.src.port.toLowerCase(), conn.src.index, conn.tgt.process, conn.tgt.port.toLowerCase(), conn.tgt.index, metadata continue graph.addEdge conn.src.process, conn.src.port.toLowerCase(), conn.tgt.process, conn.tgt.port.toLowerCase(), metadata if definition.exports and definition.exports.length for exported in definition.exports if exported.private # Translate legacy ports to new split = exported.private.split('.') continue unless split.length is 2 processId = split[0] portId = split[1] # Get properly cased process id for id of definition.processes if id.toLowerCase() is processId.toLowerCase() processId = id else processId = exported.process portId = exported.port.toLowerCase() graph.addExport exported.public, processId, portId, exported.metadata if definition.inports for pub, priv of definition.inports graph.addInport pub, priv.process, priv.port.toLowerCase(), priv.metadata if definition.outports for pub, priv of definition.outports graph.addOutport pub, priv.process, priv.port.toLowerCase(), priv.metadata if definition.groups for group in definition.groups graph.addGroup group.name, group.nodes, group.metadata || {} graph.endTransaction 'loadJSON' callback null, graph exports.loadFBP = (fbpData, callback) -> try definition = require('fbp').parse fbpData catch e return callback e exports.loadJSON definition, callback exports.loadHTTP = (url, callback) -> req = new XMLHttpRequest req.onreadystatechange = -> return unless req.readyState is 4 unless req.status is 200 return callback new Error "Failed to load #{url}: HTTP #{req.status}" callback null, req.responseText req.open 'GET', url, true req.send() exports.loadFile = (file, callback, metadata = {}) -> if platform.isBrowser() try # Graph exposed via Component packaging definition = require file catch e # Graph available via HTTP exports.loadHTTP file, (err, data) -> return callback err if err if file.split('.').pop() is 'fbp' return exports.loadFBP data, callback, metadata definition = JSON.parse data exports.loadJSON definition, callback, metadata return exports.loadJSON definition, callback, metadata return # Node.js graph file require('fs').readFile file, "utf-8", (err, data) -> return callback err if err if file.split('.').pop() is 'fbp' return exports.loadFBP data, callback definition = JSON.parse data exports.loadJSON definition, callback # remove everything in the graph resetGraph = (graph) -> # Edges and similar first, to have control over the order # If we'd do nodes first, it will implicitly delete edges # Important to make journal transactions invertible for group in (clone graph.groups).reverse() graph.removeGroup group.name if group? for port, v of clone graph.outports graph.removeOutport port for port, v of clone graph.inports graph.removeInport port for exp in clone (graph.exports).reverse() graph.removeExport exp.public # XXX: does this actually null the props?? graph.setProperties {} for iip in (clone graph.initializers).reverse() graph.removeInitial iip.to.node, iip.to.port for edge in (clone graph.edges).reverse() graph.removeEdge edge.from.node, edge.from.port, edge.to.node, edge.to.port for node in (clone graph.nodes).reverse() graph.removeNode node.id # Note: Caller should create transaction # First removes everything in @base, before building it up to mirror @to mergeResolveTheirsNaive = (base, to) -> resetGraph base for node in to.nodes base.addNode node.id, node.component, node.metadata for edge in to.edges base.addEdge edge.from.node, edge.from.port, edge.to.node, edge.to.port, edge.metadata for iip in to.initializers base.addInitial iip.from.data, iip.to.node, iip.to.port, iip.metadata for exp in to.exports base.addExport exp.public, exp.node, exp.port, exp.metadata base.setProperties to.properties for pub, priv of to.inports base.addInport pub, priv.process, priv.port, priv.metadata for pub, priv of to.outports base.addOutport pub, priv.process, priv.port, priv.metadata for group in to.groups base.addGroup group.name, group.nodes, group.metadata exports.equivalent = (a, b, options = {}) -> # TODO: add option to only compare known fields # TODO: add option to ignore metadata A = JSON.stringify a B = JSON.stringify b return A == B exports.mergeResolveTheirs = mergeResolveTheirsNaive