noflo
Version:
Flow-Based Programming environment for JavaScript
770 lines (686 loc) • 27.2 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
#
# Baseclass for regular NoFlo components.
{EventEmitter} = require 'events'
ports = require './Ports'
IP = require './IP'
debug = require('debug') 'noflo:component'
debugBrackets = require('debug') 'noflo:component:brackets'
debugSend = require('debug') 'noflo:component:send'
class Component extends EventEmitter
description: ''
icon: null
constructor: (options) ->
options = {} unless options
options.inPorts = {} unless options.inPorts
if options.inPorts instanceof ports.InPorts
@inPorts = options.inPorts
else
@inPorts = new ports.InPorts options.inPorts
options.outPorts = {} unless options.outPorts
if options.outPorts instanceof ports.OutPorts
@outPorts = options.outPorts
else
@outPorts = new ports.OutPorts options.outPorts
@icon = options.icon if options.icon
@description = options.description if options.description
@started = false
@load = 0
@ordered = options.ordered ? false
@autoOrdering = options.autoOrdering ? null
@outputQ = []
@bracketContext =
in: {}
out: {}
@activateOnInput = options.activateOnInput ? true
@forwardBrackets = in: ['out', 'error']
if 'forwardBrackets' of options
@forwardBrackets = options.forwardBrackets
if typeof options.process is 'function'
@process options.process
getDescription: -> @description
isReady: -> true
isSubgraph: -> false
setIcon: (@icon) ->
@emit 'icon', @icon
getIcon: -> @icon
error: (e, groups = [], errorPort = 'error', scope = null) =>
if @outPorts[errorPort] and (@outPorts[errorPort].isAttached() or not @outPorts[errorPort].isRequired())
@outPorts[errorPort].openBracket group, scope: scope for group in groups
@outPorts[errorPort].data e, scope: scope
@outPorts[errorPort].closeBracket group, scope: scope for group in groups
# @outPorts[errorPort].disconnect()
return
throw e
# The setUp method is for component-specific initialization. Called
# at start-up
setUp: (callback) ->
do callback
# The tearDown method is for component-specific cleanup. Called
# at shutdown
tearDown: (callback) ->
do callback
start: (callback) ->
return callback() if @isStarted()
@setUp (err) =>
return callback err if err
@started = true
@emit 'start'
callback null
shutdown: (callback) ->
finalize = =>
# Clear contents of inport buffers
inPorts = @inPorts.ports or @inPorts
for portName, inPort of inPorts
continue unless typeof inPort.clear is 'function'
inPort.clear()
# Clear bracket context
@bracketContext =
in: {}
out: {}
return callback() unless @isStarted()
@started = false
@emit 'end'
callback()
# Tell the component that it is time to shut down
@tearDown (err) =>
return callback err if err
if @load > 0
# Some in-flight processes, wait for them to finish
checkLoad = (load) ->
return if load > 0
@removeListener 'deactivate', checkLoad
finalize()
@on 'deactivate', checkLoad
return
finalize()
isStarted: -> @started
# Ensures braket forwarding map is correct for the existing ports
prepareForwarding: ->
for inPort, outPorts of @forwardBrackets
unless inPort of @inPorts.ports
delete @forwardBrackets[inPort]
continue
tmp = []
for outPort in outPorts
tmp.push outPort if outPort of @outPorts.ports
if tmp.length is 0
delete @forwardBrackets[inPort]
else
@forwardBrackets[inPort] = tmp
isLegacy: ->
# Process API
return false if @handle
# WirePattern
return false if @_wpData
# Legacy
true
# Sets process handler function
process: (handle) ->
unless typeof handle is 'function'
throw new Error "Process handler must be a function"
unless @inPorts
throw new Error "Component ports must be defined before process function"
@prepareForwarding()
@handle = handle
for name, port of @inPorts.ports
do (name, port) =>
port.name = name unless port.name
port.on 'ip', (ip) =>
@handleIP ip, port
@
isForwardingInport: (port) ->
if typeof port is 'string'
portName = port
else
portName = port.name
if portName of @forwardBrackets
return true
false
isForwardingOutport: (inport, outport) ->
if typeof inport is 'string'
inportName = inport
else
inportName = inport.name
if typeof outport is 'string'
outportName = outport
else
outportName = outport.name
return false unless @forwardBrackets[inportName]
return true if @forwardBrackets[inportName].indexOf(outportName) isnt -1
false
isOrdered: ->
return true if @ordered
return true if @autoOrdering
false
# The component has received an Information Packet. Call the processing function
# so that firing pattern preconditions can be checked and component can do
# processing as needed.
handleIP: (ip, port) ->
unless port.options.triggering
# If port is non-triggering, we can skip the process function call
return
if ip.type is 'openBracket' and @autoOrdering is null and not @ordered
# Switch component to ordered mode when receiving a stream unless
# auto-ordering is disabled
debug "#{@nodeId} port '#{port.name}' entered auto-ordering mode"
@autoOrdering = true
# Initialize the result object for situations where output needs
# to be queued to be kept in order
result = {}
if @isForwardingInport port
# For bracket-forwarding inports we need to initialize a bracket context
# so that brackets can be sent as part of the output, and closed after.
if ip.type is 'openBracket'
# For forwarding ports openBrackets don't fire
return
if ip.type is 'closeBracket'
# For forwarding ports closeBrackets don't fire
# However, we need to handle several different scenarios:
# A. There are closeBrackets in queue before current packet
# B. There are closeBrackets in queue after current packet
# C. We've queued the results from all in-flight processes and
# new closeBracket arrives
buf = port.getBuffer ip.scope, ip.index
dataPackets = buf.filter (ip) -> ip.type is 'data'
if @outputQ.length >= @load and dataPackets.length is 0
return unless buf[0] is ip
# Remove from buffer
port.get ip.scope, ip.index
context = @getBracketContext('in', port.name, ip.scope, ip.index).pop()
context.closeIp = ip
debugBrackets "#{@nodeId} closeBracket-C from '#{context.source}' to #{context.ports}: '#{ip.data}'"
result =
__resolved: true
__bracketClosingAfter: [context]
@outputQ.push result
do @processOutputQueue
# Check if buffer contains data IPs. If it does, we want to allow
# firing
return unless dataPackets.length
# Prepare the input/output pair
context = new ProcessContext ip, @, port, result
input = new ProcessInput @inPorts, context
output = new ProcessOutput @outPorts, context
try
# Call the processing function
@handle input, output, context
catch e
@deactivate context
output.sendDone e
return if context.activated
if port.isAddressable()
debug "#{@nodeId} packet on '#{port.name}[#{ip.index}]' didn't match preconditions: #{ip.type}"
return
debug "#{@nodeId} packet on '#{port.name}' didn't match preconditions: #{ip.type}"
return
getBracketContext: (type, port, scope, idx) ->
{name, index} = ports.normalizePortName port
index = idx if idx?
portsList = if type is 'in' then @inPorts else @outPorts
if portsList[name].isAddressable()
port = "#{name}[#{index}]"
# Ensure we have a bracket context for the current scope
@bracketContext[type][port] = {} unless @bracketContext[type][port]
@bracketContext[type][port][scope] = [] unless @bracketContext[type][port][scope]
return @bracketContext[type][port][scope]
addToResult: (result, port, ip, before = false) ->
{name, index} = ports.normalizePortName port
method = if before then 'unshift' else 'push'
if @outPorts[name].isAddressable()
idx = if index then parseInt(index) else ip.index
result[name] = {} unless result[name]
result[name][idx] = [] unless result[name][idx]
ip.index = idx
result[name][idx][method] ip
return
result[name] = [] unless result[name]
result[name][method] ip
getForwardableContexts: (inport, outport, contexts) ->
{name, index} = ports.normalizePortName outport
forwardable = []
contexts.forEach (ctx, idx) =>
# No forwarding to this outport
return unless @isForwardingOutport inport, name
# We have already forwarded this context to this outport
return unless ctx.ports.indexOf(outport) is -1
# See if we have already forwarded the same bracket from another
# inport
outContext = @getBracketContext('out', name, ctx.ip.scope, index)[idx]
if outContext
return if outContext.ip.data is ctx.ip.data and outContext.ports.indexOf(outport) isnt -1
forwardable.push ctx
return forwardable
addBracketForwards: (result) ->
if result.__bracketClosingBefore?.length
for context in result.__bracketClosingBefore
debugBrackets "#{@nodeId} closeBracket-A from '#{context.source}' to #{context.ports}: '#{context.closeIp.data}'"
continue unless context.ports.length
for port in context.ports
ipClone = context.closeIp.clone()
@addToResult result, port, ipClone, true
@getBracketContext('out', port, ipClone.scope).pop()
if result.__bracketContext
# First see if there are any brackets to forward. We need to reverse
# the keys so that they get added in correct order
Object.keys(result.__bracketContext).reverse().forEach (inport) =>
context = result.__bracketContext[inport]
return unless context.length
for outport, ips of result
continue if outport.indexOf('__') is 0
if @outPorts[outport].isAddressable()
for idx, idxIps of ips
# Don't register indexes we're only sending brackets to
datas = idxIps.filter (ip) -> ip.type is 'data'
continue unless datas.length
portIdentifier = "#{outport}[#{idx}]"
unforwarded = @getForwardableContexts inport, portIdentifier, context
continue unless unforwarded.length
forwardedOpens = []
for ctx in unforwarded
debugBrackets "#{@nodeId} openBracket from '#{inport}' to '#{portIdentifier}': '#{ctx.ip.data}'"
ipClone = ctx.ip.clone()
ipClone.index = parseInt idx
forwardedOpens.push ipClone
ctx.ports.push portIdentifier
@getBracketContext('out', outport, ctx.ip.scope, idx).push ctx
forwardedOpens.reverse()
@addToResult result, outport, ip, true for ip in forwardedOpens
continue
# Don't register ports we're only sending brackets to
datas = ips.filter (ip) -> ip.type is 'data'
continue unless datas.length
unforwarded = @getForwardableContexts inport, outport, context
continue unless unforwarded.length
forwardedOpens = []
for ctx in unforwarded
debugBrackets "#{@nodeId} openBracket from '#{inport}' to '#{outport}': '#{ctx.ip.data}'"
forwardedOpens.push ctx.ip.clone()
ctx.ports.push outport
@getBracketContext('out', outport, ctx.ip.scope).push ctx
forwardedOpens.reverse()
@addToResult result, outport, ip, true for ip in forwardedOpens
if result.__bracketClosingAfter?.length
for context in result.__bracketClosingAfter
debugBrackets "#{@nodeId} closeBracket-B from '#{context.source}' to #{context.ports}: '#{context.closeIp.data}'"
continue unless context.ports.length
for port in context.ports
ipClone = context.closeIp.clone()
@addToResult result, port, ipClone, false
@getBracketContext('out', port, ipClone.scope).pop()
delete result.__bracketClosingBefore
delete result.__bracketContext
delete result.__bracketClosingAfter
processOutputQueue: ->
while @outputQ.length > 0
result = @outputQ[0]
break unless result.__resolved
@addBracketForwards result
for port, ips of result
continue if port.indexOf('__') is 0
if @outPorts.ports[port].isAddressable()
for idx, idxIps of ips
idx = parseInt idx
continue unless @outPorts.ports[port].isAttached idx
for ip in idxIps
portIdentifier = "#{port}[#{ip.index}]"
if ip.type is 'openBracket'
debugSend "#{@nodeId} sending #{portIdentifier} < '#{ip.data}'"
else if ip.type is 'closeBracket'
debugSend "#{@nodeId} sending #{portIdentifier} > '#{ip.data}'"
else
debugSend "#{@nodeId} sending #{portIdentifier} DATA"
@outPorts[port].sendIP ip
continue
continue unless @outPorts.ports[port].isAttached()
for ip in ips
portIdentifier = port
if ip.type is 'openBracket'
debugSend "#{@nodeId} sending #{portIdentifier} < '#{ip.data}'"
else if ip.type is 'closeBracket'
debugSend "#{@nodeId} sending #{portIdentifier} > '#{ip.data}'"
else
debugSend "#{@nodeId} sending #{portIdentifier} DATA"
@outPorts[port].sendIP ip
@outputQ.shift()
activate: (context) ->
return if context.activated # prevent double activation
context.activated = true
context.deactivated = false
@load++
@emit 'activate', @load
if @ordered or @autoOrdering
@outputQ.push context.result
deactivate: (context) ->
return if context.deactivated # prevent double deactivation
context.deactivated = true
context.activated = false
if @isOrdered()
@processOutputQueue()
@load--
@emit 'deactivate', @load
exports.Component = Component
class ProcessContext
constructor: (@ip, @nodeInstance, @port, @result) ->
@scope = @ip.scope
@activated = false
@deactivated = false
activate: ->
# Push a new result value if previous has been sent already
if @result.__resolved or @nodeInstance.outputQ.indexOf(@result) is -1
@result = {}
@nodeInstance.activate @
deactivate: ->
@result.__resolved = true unless @result.__resolved
@nodeInstance.deactivate @
class ProcessInput
constructor: (@ports, @context) ->
@nodeInstance = @context.nodeInstance
@ip = @context.ip
@port = @context.port
@result = @context.result
@scope = @context.scope
# When preconditions are met, set component state to `activated`
activate: ->
return if @context.activated
if @nodeInstance.isOrdered()
# We're handling packets in order. Set the result as non-resolved
# so that it can be send when the order comes up
@result.__resolved = false
@nodeInstance.activate @context
if @port.isAddressable()
debug "#{@nodeInstance.nodeId} packet on '#{@port.name}[#{@ip.index}]' caused activation #{@nodeInstance.load}: #{@ip.type}"
else
debug "#{@nodeInstance.nodeId} packet on '#{@port.name}' caused activation #{@nodeInstance.load}: #{@ip.type}"
# ## Connection listing
# This allows components to check which input ports are attached. This is
# useful mainly for addressable ports
attached: (args...) ->
args = ['in'] unless args.length
res = []
for port in args
res.push @ports[port].listAttached()
return res.pop() if args.length is 1
res
# ## Input preconditions
# When the processing function is called, it can check if input buffers
# contain the packets needed for the process to fire.
# This precondition handling is done via the `has` and `hasStream` methods.
# Returns true if a port (or ports joined by logical AND) has a new IP
# Passing a validation callback as a last argument allows more selective
# checking of packets.
has: (args...) ->
args = ['in'] unless args.length
if typeof args[args.length - 1] is 'function'
validate = args.pop()
else
validate = -> true
for port in args
if Array.isArray port
unless @ports[port[0]].isAddressable()
throw new Error "Non-addressable ports, access must be with string #{port[0]}"
return false unless @ports[port[0]].has @scope, port[1], validate
continue
if @ports[port].isAddressable()
throw new Error "For addressable ports, access must be with array [#{port}, idx]"
return false unless @ports[port].has @scope, validate
return true
# Returns true if the ports contain data packets
hasData: (args...) ->
args = ['in'] unless args.length
args.push (ip) -> ip.type is 'data'
return @has.apply @, args
# Returns true if a port has a complete stream in its input buffer.
hasStream: (args...) ->
args = ['in'] unless args.length
if typeof args[args.length - 1] is 'function'
validateStream = args.pop()
else
validateStream = -> true
for port in args
portBrackets = []
dataBrackets = []
hasData = false
validate = (ip) ->
if ip.type is 'openBracket'
portBrackets.push ip.data
return false
if ip.type is 'data'
# Run the stream validation callback
hasData = validateStream ip, portBrackets
# Data IP on its own is a valid stream
return hasData unless portBrackets.length
# Otherwise we need to check for complete stream
return false
if ip.type is 'closeBracket'
portBrackets.pop()
return false if portBrackets.length
return false unless hasData
return true
return false unless @has port, validate
true
# ## Input processing
#
# Once preconditions have been met, the processing function can read from
# the input buffers. Reading packets sets the component as "activated".
#
# Fetches IP object(s) for port(s)
get: (args...) ->
@activate()
args = ['in'] unless args.length
res = []
for port in args
if Array.isArray port
[portname, idx] = port
unless @ports[portname].isAddressable()
throw new Error 'Non-addressable ports, access must be with string portname'
else
portname = port
if @ports[portname].isAddressable()
throw new Error 'For addressable ports, access must be with array [portname, idx]'
if @nodeInstance.isForwardingInport portname
ip = @__getForForwarding portname, idx
res.push ip
continue
ip = @ports[portname].get @scope, idx
res.push ip
if args.length is 1 then res[0] else res
__getForForwarding: (port, idx) ->
prefix = []
dataIp = null
# Read IPs until we hit data
loop
# Read next packet
ip = @ports[port].get @scope, idx
# Stop at the end of the buffer
break unless ip
if ip.type is 'data'
# Hit the data IP, stop here
dataIp = ip
break
# Keep track of bracket closings and openings before
prefix.push ip
# Forwarding brackets that came before data packet need to manipulate context
# and be added to result so they can be forwarded correctly to ports that
# need them
for ip in prefix
if ip.type is 'closeBracket'
# Bracket closings before data should remove bracket context
@result.__bracketClosingBefore = [] unless @result.__bracketClosingBefore
context = @nodeInstance.getBracketContext('in', port, @scope, idx).pop()
context.closeIp = ip
@result.__bracketClosingBefore.push context
continue
if ip.type is 'openBracket'
# Bracket openings need to go to bracket context
@nodeInstance.getBracketContext('in', port, @scope, idx).push
ip: ip
ports: []
source: port
continue
# Add current bracket context to the result so that when we send
# to ports we can also add the surrounding brackets
@result.__bracketContext = {} unless @result.__bracketContext
@result.__bracketContext[port] = @nodeInstance.getBracketContext('in', port, @scope, idx).slice 0
# Bracket closings that were in buffer after the data packet need to
# be added to result for done() to read them from
return dataIp
# Fetches `data` property of IP object(s) for given port(s)
getData: (args...) ->
args = ['in'] unless args.length
datas = []
for port in args
packet = @get port
unless packet?
# we add the null packet to the array so when getting
# multiple ports, if one is null we still return it
# so the indexes are correct.
datas.push packet
continue
until packet.type is 'data'
packet = @get port
break unless packet
datas.push packet.data
return datas.pop() if args.length is 1
datas
# Fetches a complete data stream from the buffer.
getStream: (args...) ->
args = ['in'] unless args.length
datas = []
for port in args
portBrackets = []
portPackets = []
hasData = false
ip = @get port
datas.push undefined unless ip
while ip
if ip.type is 'openBracket'
unless portBrackets.length
# First openBracket in stream, drop previous
portPackets = []
hasData = false
portBrackets.push ip.data
portPackets.push ip
if ip.type is 'data'
portPackets.push ip
hasData = true
# Unbracketed data packet is a valid stream
break unless portBrackets.length
if ip.type is 'closeBracket'
portPackets.push ip
portBrackets.pop()
if hasData and not portBrackets.length
# Last close bracket finishes stream if there was data inside
break
ip = @get port
datas.push portPackets
return datas.pop() if args.length is 1
datas
class ProcessOutput
constructor: (@ports, @context) ->
@nodeInstance = @context.nodeInstance
@ip = @context.ip
@result = @context.result
@scope = @context.scope
# Checks if a value is an Error
isError: (err) ->
err instanceof Error or
Array.isArray(err) and err.length > 0 and err[0] instanceof Error
# Sends an error object
error: (err) ->
multiple = Array.isArray err
err = [err] unless multiple
if 'error' of @ports and
(@ports.error.isAttached() or not @ports.error.isRequired())
@sendIP 'error', new IP 'openBracket' if multiple
@sendIP 'error', e for e in err
@sendIP 'error', new IP 'closeBracket' if multiple
else
throw e for e in err
# Sends a single IP object to a port
sendIP: (port, packet) ->
unless IP.isIP packet
ip = new IP 'data', packet
else
ip = packet
ip.scope = @scope if @scope isnt null and ip.scope is null
if @nodeInstance.outPorts[port].isAddressable() and ip.index is null
throw new Error 'Sending packets to addressable ports requires specifying index'
if @nodeInstance.isOrdered()
@nodeInstance.addToResult @result, port, ip
return
@nodeInstance.outPorts[port].sendIP ip
# Sends packets for each port as a key in the map
# or sends Error or a list of Errors if passed such
send: (outputMap) ->
return @error outputMap if @isError outputMap
componentPorts = []
mapIsInPorts = false
for port in Object.keys @ports.ports
componentPorts.push port if port isnt 'error' and port isnt 'ports' and port isnt '_callbacks'
if not mapIsInPorts and outputMap? and typeof outputMap is 'object' and Object.keys(outputMap).indexOf(port) isnt -1
mapIsInPorts = true
if componentPorts.length is 1 and not mapIsInPorts
@sendIP componentPorts[0], outputMap
return
if componentPorts.length > 1 and not mapIsInPorts
throw new Error 'Port must be specified for sending output'
for port, packet of outputMap
@sendIP port, packet
# Sends the argument via `send()` and marks activation as `done()`
sendDone: (outputMap) ->
@send outputMap
@done()
# Makes a map-style component pass a result value to `out`
# keeping all IP metadata received from `in`,
# or modifying it if `options` is provided
pass: (data, options = {}) ->
unless 'out' of @ports
throw new Error 'output.pass() requires port "out" to be present'
for key, val of options
@ip[key] = val
@ip.data = data
@sendIP 'out', @ip
@done()
# Finishes process activation gracefully
done: (error) ->
@result.__resolved = true
@nodeInstance.activate @context
@error error if error
isLast = =>
# We only care about real output sets with processing data
resultsOnly = @nodeInstance.outputQ.filter (q) ->
return true unless q.__resolved
if Object.keys(q).length is 2 and q.__bracketClosingAfter
return false
true
pos = resultsOnly.indexOf @result
len = resultsOnly.length
load = @nodeInstance.load
return true if pos is len - 1
return true if pos is -1 and load is len + 1
return true if len <= 1 and load is 1
false
if @nodeInstance.isOrdered() and isLast()
# We're doing bracket forwarding. See if there are
# dangling closeBrackets in buffer since we're the
# last running process function.
for port, contexts of @nodeInstance.bracketContext.in
continue unless contexts[@scope]
nodeContext = contexts[@scope]
continue unless nodeContext.length
context = nodeContext[nodeContext.length - 1]
buf = @nodeInstance.inPorts[context.source].getBuffer context.ip.scope, context.ip.index
loop
break unless buf.length
break unless buf[0].type is 'closeBracket'
ip = @nodeInstance.inPorts[context.source].get context.ip.scope, context.ip.index
ctx = nodeContext.pop()
ctx.closeIp = ip
@result.__bracketClosingAfter = [] unless @result.__bracketClosingAfter
@result.__bracketClosingAfter.push ctx
debug "#{@nodeInstance.nodeId} finished processing #{@nodeInstance.load}"
@nodeInstance.deactivate @context