noflo
Version:
Flow-Based Programming environment for JavaScript
717 lines (610 loc) • 23.1 kB
text/coffeescript
# NoFlo - Flow-Based Programming for JavaScript
# (c) 2013-2017 Flowhub UG
# (c) 2011-2012 Henri Bergius, Nemein
# NoFlo may be freely distributed under the MIT license
internalSocket = require "./InternalSocket"
graph = require "fbp-graph"
{EventEmitter} = require 'events'
platform = require './Platform'
componentLoader = require './ComponentLoader'
utils = require './Utils'
IP = require './IP'
# ## The NoFlo network coordinator
#
# NoFlo networks consist of processes connected to each other
# via sockets attached from outports to inports.
#
# The role of the network coordinator is to take a graph and
# instantiate all the necessary processes from the designated
# components, attach sockets between them, and handle the sending
# of Initial Information Packets.
class Network extends EventEmitter
# Processes contains all the instantiated components for this network
processes: {}
# Connections contains all the socket connections in the network
connections: []
# Initials contains all Initial Information Packets (IIPs)
initials: []
# Container to hold sockets that will be sending default data.
defaults: []
# The Graph this network is instantiated with
graph: null
# Start-up timestamp for the network, used for calculating uptime
startupDate: null
# All NoFlo networks are instantiated with a graph. Upon instantiation
# they will load all the needed components, instantiate them, and
# set up the defined connections and IIPs.
#
# The network will also listen to graph changes and modify itself
# accordingly, including removing connections, adding new nodes,
# and sending new IIPs.
constructor: (graph, @options = {}) ->
@processes = {}
@connections = []
@initials = []
@nextInitials = []
@defaults = []
@graph = graph
@started = false
@debug = true
@eventBuffer = []
# On Node.js we default the baseDir for component loading to
# the current working directory
unless platform.isBrowser()
@baseDir = graph.baseDir or process.cwd()
# On browser we default the baseDir to the Component loading
# root
else
@baseDir = graph.baseDir or '/'
# As most NoFlo networks are long-running processes, the
# network coordinator marks down the start-up time. This
# way we can calculate the uptime of the network.
@startupDate = null
# Initialize a Component Loader for the network
if graph.componentLoader
@loader = graph.componentLoader
else
@loader = new componentLoader.ComponentLoader @baseDir, @options
# The uptime of the network is the current time minus the start-up
# time, in seconds.
uptime: ->
return 0 unless @startupDate
new Date() - @startupDate
getActiveProcesses: ->
active = []
return active unless @started
for name, process of @processes
if process.component.load > 0
# Modern component with load
active.push name
if process.component.__openConnections > 0
# Legacy component
active.push name
return active
bufferedEmit: (event, payload) ->
# Errors get emitted immediately, like does network end
if event in ['error', 'process-error', 'end']
@emit event, payload
return
if not @isStarted() and event isnt 'end'
@eventBuffer.push
type: event
payload: payload
return
@emit event, payload
if event is 'start'
# Once network has started we can send the IP-related events
for ev in @eventBuffer
@emit ev.type, ev.payload
@eventBuffer = []
# ## Loading components
#
# Components can be passed to the NoFlo network in two ways:
#
# * As direct, instantiated JavaScript objects
# * As filenames
load: (component, metadata, callback) ->
@loader.load component, callback, metadata
# ## Add a process to the network
#
# Processes can be added to a network at either start-up time
# or later. The processes are added with a node definition object
# that includes the following properties:
#
# * `id`: Identifier of the process in the network. Typically a string
# * `component`: Filename or path of a NoFlo component, or a component instance object
addNode: (node, callback) ->
# Processes are treated as singletons by their identifier. If
# we already have a process with the given ID, return that.
if @processes[node.id]
callback null, @processes[node.id]
return
process =
id: node.id
# No component defined, just register the process but don't start.
unless node.component
@processes[process.id] = process
callback null, process
return
# Load the component for the process.
@load node.component, node.metadata, (err, instance) =>
return callback err if err
instance.nodeId = node.id
process.component = instance
process.componentName = node.component
# Inform the ports of the node name
# FIXME: direct process.component.inPorts/outPorts access is only for legacy compat
inPorts = process.component.inPorts.ports or process.component.inPorts
outPorts = process.component.outPorts.ports or process.component.outPorts
for name, port of inPorts
port.node = node.id
port.nodeInstance = instance
port.name = name
for name, port of outPorts
port.node = node.id
port.nodeInstance = instance
port.name = name
@subscribeSubgraph process if instance.isSubgraph()
@subscribeNode process
# Store and return the process instance
@processes[process.id] = process
callback null, process
removeNode: (node, callback) ->
unless @processes[node.id]
return callback new Error "Node #{node.id} not found"
@processes[node.id].component.shutdown (err) =>
return callback err if err
delete @processes[node.id]
callback null
renameNode: (oldId, newId, callback) ->
process = @getNode oldId
return callback new Error "Process #{oldId} not found" unless process
# Inform the process of its ID
process.id = newId
# Inform the ports of the node name
# FIXME: direct process.component.inPorts/outPorts access is only for legacy compat
inPorts = process.component.inPorts.ports or process.component.inPorts
outPorts = process.component.outPorts.ports or process.component.outPorts
for name, port of inPorts
continue unless port
port.node = newId
for name, port of outPorts
continue unless port
port.node = newId
@processes[newId] = process
delete @processes[oldId]
callback null
# Get process by its ID.
getNode: (id) ->
@processes[id]
connect: (done = ->) ->
# Wrap the future which will be called when done in a function and return
# it
callStack = 0
serialize = (next, add) =>
(type) =>
# Add either a Node, an Initial, or an Edge and move on to the next one
# when done
this["add#{type}"] add, (err) ->
return done err if err
callStack++
if callStack % 100 is 0
setTimeout ->
next type
, 0
return
next type
# Subscribe to graph changes when everything else is done
subscribeGraph = =>
@subscribeGraph()
done()
# Serialize default socket creation then call callback when done
setDefaults = utils.reduceRight @graph.nodes, serialize, subscribeGraph
# Serialize initializers then call defaults.
initializers = utils.reduceRight @graph.initializers, serialize, -> setDefaults "Defaults"
# Serialize edge creators then call the initializers.
edges = utils.reduceRight @graph.edges, serialize, -> initializers "Initial"
# Serialize node creators then call the edge creators
nodes = utils.reduceRight @graph.nodes, serialize, -> edges "Edge"
# Start with node creators
nodes "Node"
connectPort: (socket, process, port, index, inbound) ->
if inbound
socket.to =
process: process
port: port
index: index
unless process.component.inPorts and process.component.inPorts[port]
throw new Error "No inport '#{port}' defined in process #{process.id} (#{socket.getId()})"
return
if process.component.inPorts[port].isAddressable()
return process.component.inPorts[port].attach socket, index
return process.component.inPorts[port].attach socket
socket.from =
process: process
port: port
index: index
unless process.component.outPorts and process.component.outPorts[port]
throw new Error "No outport '#{port}' defined in process #{process.id} (#{socket.getId()})"
return
if process.component.outPorts[port].isAddressable()
return process.component.outPorts[port].attach socket, index
process.component.outPorts[port].attach socket
subscribeGraph: ->
# A NoFlo graph may change after network initialization.
# For this, the network subscribes to the change events from
# the graph.
#
# In graph we talk about nodes and edges. Nodes correspond
# to NoFlo processes, and edges to connections between them.
graphOps = []
processing = false
registerOp = (op, details) ->
graphOps.push
op: op
details: details
processOps = (err) =>
if err
throw err if @listeners('process-error').length is 0
@bufferedEmit 'process-error', err
unless graphOps.length
processing = false
return
processing = true
op = graphOps.shift()
cb = processOps
switch op.op
when 'renameNode'
@renameNode op.details.from, op.details.to, cb
else
@[op.op] op.details, cb
@graph.on 'addNode', (node) ->
registerOp 'addNode', node
do processOps unless processing
@graph.on 'removeNode', (node) ->
registerOp 'removeNode', node
do processOps unless processing
@graph.on 'renameNode', (oldId, newId) ->
registerOp 'renameNode',
from: oldId
to: newId
do processOps unless processing
@graph.on 'addEdge', (edge) ->
registerOp 'addEdge', edge
do processOps unless processing
@graph.on 'removeEdge', (edge) ->
registerOp 'removeEdge', edge
do processOps unless processing
@graph.on 'addInitial', (iip) ->
registerOp 'addInitial', iip
do processOps unless processing
@graph.on 'removeInitial', (iip) ->
registerOp 'removeInitial', iip
do processOps unless processing
subscribeSubgraph: (node) ->
unless node.component.isReady()
node.component.once 'ready', =>
@subscribeSubgraph node
return
return unless node.component.network
node.component.network.setDebug @debug
emitSub = (type, data) =>
if type is 'process-error' and @listeners('process-error').length is 0
throw data.error if data.id and data.metadata and data.error
throw data
data = {} unless data
if data.subgraph
unless data.subgraph.unshift
data.subgraph = [data.subgraph]
data.subgraph = data.subgraph.unshift node.id
else
data.subgraph = [node.id]
@bufferedEmit type, data
node.component.network.on 'connect', (data) -> emitSub 'connect', data
node.component.network.on 'begingroup', (data) -> emitSub 'begingroup', data
node.component.network.on 'data', (data) -> emitSub 'data', data
node.component.network.on 'endgroup', (data) -> emitSub 'endgroup', data
node.component.network.on 'disconnect', (data) -> emitSub 'disconnect', data
node.component.network.on 'ip', (data) -> emitSub 'ip', data
node.component.network.on 'process-error', (data) ->
emitSub 'process-error', data
# Subscribe to events from all connected sockets and re-emit them
subscribeSocket: (socket, source) ->
socket.on 'ip', (ip) =>
@bufferedEmit 'ip',
id: socket.getId()
type: ip.type
socket: socket
data: ip.data
metadata: socket.metadata
socket.on 'connect', =>
if source and source.component.isLegacy()
# Handle activation for legacy components
source.component.__openConnections = 0 unless source.component.__openConnections
source.component.__openConnections++
@bufferedEmit 'connect',
id: socket.getId()
socket: socket
metadata: socket.metadata
socket.on 'begingroup', (group) =>
@bufferedEmit 'begingroup',
id: socket.getId()
socket: socket
group: group
metadata: socket.metadata
socket.on 'data', (data) =>
@bufferedEmit 'data',
id: socket.getId()
socket: socket
data: data
metadata: socket.metadata
socket.on 'endgroup', (group) =>
@bufferedEmit 'endgroup',
id: socket.getId()
socket: socket
group: group
metadata: socket.metadata
socket.on 'disconnect', =>
@bufferedEmit 'disconnect',
id: socket.getId()
socket: socket
metadata: socket.metadata
if source and source.component.isLegacy()
# Handle deactivation for legacy components
source.component.__openConnections--
if source.component.__openConnections < 0
source.component.__openConnections = 0
if source.component.__openConnections is 0
@checkIfFinished()
socket.on 'error', (event) =>
if @listeners('process-error').length is 0
throw event.error if event.id and event.metadata and event.error
throw event
@bufferedEmit 'process-error', event
subscribeNode: (node) ->
node.component.on 'deactivate', (load) =>
return if load > 0
@checkIfFinished()
return unless node.component.getIcon
node.component.on 'icon', =>
@bufferedEmit 'icon',
id: node.id
icon: node.component.getIcon()
addEdge: (edge, callback) ->
socket = internalSocket.createSocket edge.metadata
socket.setDebug @debug
from = @getNode edge.from.node
unless from
return callback new Error "No process defined for outbound node #{edge.from.node}"
unless from.component
return callback new Error "No component defined for outbound node #{edge.from.node}"
unless from.component.isReady()
from.component.once "ready", =>
@addEdge edge, callback
return
to = @getNode edge.to.node
unless to
return callback new Error "No process defined for inbound node #{edge.to.node}"
unless to.component
return callback new Error "No component defined for inbound node #{edge.to.node}"
unless to.component.isReady()
to.component.once "ready", =>
@addEdge edge, callback
return
# Subscribe to events from the socket
@subscribeSocket socket, from
@connectPort socket, to, edge.to.port, edge.to.index, true
@connectPort socket, from, edge.from.port, edge.from.index, false
@connections.push socket
callback()
removeEdge: (edge, callback) ->
for connection in @connections
continue unless connection
continue unless edge.to.node is connection.to.process.id and edge.to.port is connection.to.port
connection.to.process.component.inPorts[connection.to.port].detach connection
if edge.from.node
if connection.from and edge.from.node is connection.from.process.id and edge.from.port is connection.from.port
connection.from.process.component.outPorts[connection.from.port].detach connection
@connections.splice @connections.indexOf(connection), 1
do callback
addDefaults: (node, callback) ->
process = @processes[node.id]
unless process.component.isReady()
process.component.setMaxListeners 0 if process.component.setMaxListeners
process.component.once "ready", =>
@addDefaults process, callback
return
for key, port of process.component.inPorts.ports
# Attach a socket to any defaulted inPorts as long as they aren't already attached.
# TODO: hasDefault existence check is for backwards compatibility, clean
# up when legacy ports are removed.
if typeof port.hasDefault is 'function' and port.hasDefault() and not port.isAttached()
socket = internalSocket.createSocket()
socket.setDebug @debug
# Subscribe to events from the socket
@subscribeSocket socket
@connectPort socket, process, key, undefined, true
@connections.push socket
@defaults.push socket
callback()
addInitial: (initializer, callback) ->
socket = internalSocket.createSocket initializer.metadata
socket.setDebug @debug
# Subscribe to events from the socket
@subscribeSocket socket
to = @getNode initializer.to.node
unless to
return callback new Error "No process defined for inbound node #{initializer.to.node}"
unless to.component.isReady() or to.component.inPorts[initializer.to.port]
to.component.setMaxListeners 0 if to.component.setMaxListeners
to.component.once "ready", =>
@addInitial initializer, callback
return
@connectPort socket, to, initializer.to.port, initializer.to.index, true
@connections.push socket
init =
socket: socket
data: initializer.from.data
@initials.push init
@nextInitials.push init
do @sendInitials if @isStarted()
callback()
removeInitial: (initializer, callback) ->
for connection in @connections
continue unless connection
continue unless initializer.to.node is connection.to.process.id and initializer.to.port is connection.to.port
connection.to.process.component.inPorts[connection.to.port].detach connection
@connections.splice @connections.indexOf(connection), 1
for init in @initials
continue unless init
continue unless init.socket is connection
@initials.splice @initials.indexOf(init), 1
for init in @nextInitials
continue unless init
continue unless init.socket is connection
@nextInitials.splice @nextInitials.indexOf(init), 1
do callback
sendInitial: (initial) ->
initial.socket.post new IP 'data', initial.data,
initial: true
sendInitials: (callback) ->
unless callback
callback = ->
send = =>
@sendInitial initial for initial in @initials
@initials = []
do callback
if typeof process isnt 'undefined' and process.execPath and process.execPath.indexOf('node') isnt -1
# nextTick is faster on Node.js
process.nextTick send
else
setTimeout send, 0
isStarted: ->
@started
isRunning: ->
return false unless @started
return @getActiveProcesses().length > 0
startComponents: (callback) ->
unless callback
callback = ->
# Emit start event when all processes are started
count = 0
length = if @processes then Object.keys(@processes).length else 0
onProcessStart = (err) ->
return callback err if err
count++
callback() if count is length
# Perform any startup routines necessary for every component.
return callback() unless @processes and Object.keys(@processes).length
for id, process of @processes
if process.component.isStarted()
onProcessStart()
continue
if process.component.start.length is 0
platform.deprecated 'component.start method without callback is deprecated'
process.component.start()
onProcessStart()
continue
process.component.start onProcessStart
sendDefaults: (callback) ->
unless callback
callback = ->
return callback() unless @defaults.length
for socket in @defaults
# Don't send defaults if more than one socket is present on the port.
# This case should only happen when a subgraph is created as a component
# as its network is instantiated and its inputs are serialized before
# a socket is attached from the "parent" graph.
continue unless socket.to.process.component.inPorts[socket.to.port].sockets.length is 1
socket.connect()
socket.send()
socket.disconnect()
do callback
start: (callback) ->
unless callback
platform.deprecated 'Calling network.start() without callback is deprecated'
callback = ->
@abortDebounce = true if @debouncedEnd
if @started
@stop (err) =>
return callback err if err
@start callback
return
@initials = @nextInitials.slice 0
@eventBuffer = []
@startComponents (err) =>
return callback err if err
@sendInitials (err) =>
return callback err if err
@sendDefaults (err) =>
return callback err if err
@setStarted true
callback null
stop: (callback) ->
unless callback
platform.deprecated 'Calling network.stop() without callback is deprecated'
callback = ->
@abortDebounce = true if @debouncedEnd
return callback null unless @started
# Disconnect all connections
for connection in @connections
continue unless connection.isConnected()
connection.disconnect()
# Emit stop event when all processes are stopped
count = 0
length = if @processes then Object.keys(@processes).length else 0
onProcessEnd = (err) =>
return callback err if err
count++
if count is length
@setStarted false
callback()
unless @processes and Object.keys(@processes).length
@setStarted false
return callback()
# Tell processes to shut down
for id, process of @processes
unless process.component.isStarted()
onProcessEnd()
continue
if process.component.shutdown.length is 0
platform.deprecated 'component.shutdown method without callback is deprecated'
process.component.shutdown()
onProcessEnd()
continue
process.component.shutdown onProcessEnd
setStarted: (started) ->
return if @started is started
unless started
# Ending the execution
@started = false
@bufferedEmit 'end',
start: @startupDate
end: new Date
uptime: @uptime()
return
# Starting the execution
@startupDate = new Date unless @startupDate
@started = true
@bufferedEmit 'start',
start: @startupDate
checkIfFinished: ->
return if @isRunning()
delete @abortDebounce
unless @debouncedEnd
@debouncedEnd = utils.debounce =>
return if @abortDebounce
@setStarted false
, 50
do @debouncedEnd
getDebug: () ->
@debug
setDebug: (active) ->
return if active == @debug
@debug = active
for socket in @connections
socket.setDebug active
for processId, process of @processes
instance = process.component
instance.network.setDebug active if instance.isSubgraph()
exports.Network = Network