Control & Virtual Ports
Control & Virtual Ports
Virtual ports
Every node always has a set of virtual ports — control ports that carry lifecycle and status events rather than data. They are wired like any other port:
SelectProtocol out [clear] -> :start ClearCommands
Source :error -> err Sink
The names carry a leading : to distinguish them from data ports. They are materialized by the runtime on every node; they do not appear in the node's ports definition.
:start — the virtual input port
:start is a virtual input port that every node auto-creates on connect. When the flow runs, it sends an empty IIP to :start on every startable node — a node whose input ports are all filled (a node with no inputs is always startable). This is how no-input nodes get kicked off.
Virtual output ports
Every node carries these virtual output ports:
| Port | Fires when |
|---|---|
:complete |
The node completes a run |
:start |
The node starts a run |
:shutdown |
The node shuts down |
:error |
The node raises an error |
:statusUpdate |
The node's status changes |
:contextUpdate |
The node's context is set |
:contextClear |
The node's context is cleared |
:nodeTimeout |
An async node exceeds its timeout |
:stop |
(vestigial — see below) |
Two of them have special behavior:
:erroronly writes a packet when a consumer is wired. If the port has no connections, the same error is raised as a node error event instead of a packet. This keeps error wires explicit: wire:errorto a handler and errors flow as data; leave it silent and they surface as events.:shutdownonly fires if the node defines anon.shutdownhandler. A node without one never emits:shutdown, even when the runtime tears it down.
In a normal run, :complete, :start, and :statusUpdate fire; the rest fire only when their condition occurs. The FBP protocol advertises just two of them to tooling: the :start input and the :complete output on every component.
@fn — logic as data
A port declared type: 'function' carries a live reference, not data. A function value travels through the graph by reference — never JSON-serialized — which is how a node can accept code and use it internally.
The .fbp side of a function port is a context feed onto it:
( ... ) -> @fn PrepareCommand(array/map)
The ( ... ) is a function body string. It lands on the node's fn input port, and because that port is type: 'function', the body is compiled into a live function when the port fills. The port name is incidental — @ only means "context, not a link/IIP"; the interesting part is that the target input port is declared type: 'function'.
A node needs an input port under that name; feeding a function into a port the node does not have raises an error. This is the function-as-data mechanism: inline logic without authoring a node file.
The inert surface
Two link-syntax features parse but are not wired to runtime behavior. A flow that uses them runs exactly as if the syntax were absent:
MyProcess(test) *out -> in[MyProcess] Consume(consume)
*outparses tosource.setting.pointer: true— the pointer.in[MyProcess]parses totarget.setting.sync: 'MyProcess'— sync.
Together they were a language-level mechanism for lockstep delivery (a node consuming several ports runs on (x₁, y₁) then (x₂, y₂), never on a lone x). That concept was re-implemented as a node-level library, @fbpx/group (SendGroup / Sync / RecvGroup), and the old .fbp syntax was left behind — parsed for compatibility, consumed nowhere. The runtime still registers both settings when it sees them, but nothing reads them, so they are best avoided in new flows.
Similarly, :stop is a vestigial port: the runtime declares it on every node, but no node code ever emits it — there is no clean node-level "stop" lifecycle to hang it on. Node teardown is :shutdown; error recovery is a reset, not a stop. The flow-level stop (flow.stop()) works, but it is the whole flow stopping, not a per-node port.