ruby_zmq_framework
Think Node-RED without the UI: a flow-based, language-agnostic runtime for blackbox nodes wired together by topics.
- Nodes are independent OS processes. Each one does one job, lives in one file, and knows nothing about any other node — not their ports, not their names, not their language.
- Wires are pub/sub topics carrying JSON, over ZeroMQ.
- The graph is data:
flow.ymlis the only artifact that knows the topology.bin/flowctlreads it, computes the wiring, and runs everything. - The contract is one page:
PROTOCOL.mdis everything a node in any language needs to join.
The design goal is LLM-friendliness: each node is a task you can hand to an agent with the protocol page and a one-line description ("write a Go node that decodes DBC frames") — it never needs the rest of the repo in context.
Quick start
You need the ZeroMQ library (libzmq3-dev on Debian/Ubuntu, brew install zeromq on macOS), then:
bundle install
bundle exec bin/flowctl
That runs the demo graph from flow.yml: a simulated ECU blasting RPM data,
a telemetry node that commands a throttle cut on over-rev, a web dashboard
on http://localhost:4567, a state registry caching heartbeats and
telemetry, and a dashboard consumer syncing the registry's snapshot. Output
is streamed with a [node_name] prefix; Ctrl-C stops everything.
bundle exec bin/flowctl --plan prints the computed wiring without running
anything.
Writing a node
A Ruby node is a class with one method, booted from the environment:
require_relative '../lib/ruby_zmq_framework'
$stdout.sync = true
class RpmSmoother
include RubyZmqFramework::FrameworkModule
def initialize(bus)
@bus = bus
@window = []
end
def (topic, payload)
@window = (@window << payload[:rpm]).last(5)
broadcast(:engine_data_smooth, { rpm: @window.sum / @window.size })
end
end
RubyZmqFramework.boot(RpmSmoother)
sleep
Note what's absent: no ports, no peers, no subscribe calls. Wiring comes
from environment variables (BUS_PORT, BUS_PEERS, BUS_SUBSCRIBES,
NODE_NAME — see PROTOCOL.md), which flowctl computes from the node's
entry in the manifest:
rpm_smoother:
cmd: ruby nodes/rpm_smoother.rb
subscribes: [engine_data]
publishes: [engine_data_smooth]
Run standalone (no environment needed — it binds an ephemeral port) to poke
at a node in isolation: bundle exec ruby nodes/rpm_smoother.rb.
Every node automatically heartbeats every 5 seconds. StrictContract
raises at .new if a node forgets handle_message — loudly and
immediately, which is exactly the feedback an iterating agent needs.
Nodes in other languages
The bus is just two-frame ZeroMQ pub/sub — [topic, json] — and the whole
contract fits on one page: PROTOCOL.md, including a
complete minimal Python node. Follow it, add a cmd entry to flow.yml,
and the language never matters again. A Python companion library exists at
python_zmq_framework,
with the same boot()-from-environment contract, node_name, and clean
shutdown as this gem — a Python node under its nodes/ and a Ruby node
under this repo's nodes/ are interchangeable entries in one flow.yml.
What's in the box
| piece | file | job |
|---|---|---|
ZeroMQBus |
lib/ruby_zmq_framework/zeromq_bus.rb |
hardened transport: poison-message-proof listener, per-handler error isolation, local dispatch, clean close |
FrameworkModule |
lib/ruby_zmq_framework/framework.rb |
node mixin: contract enforcement, auto-heartbeat, broadcast, node_name, stop_heartbeat |
Flow |
lib/ruby_zmq_framework/flow.rb |
parses flow.yml, computes each node's env wiring |
flowctl |
bin/flowctl |
assigns ports, spawns nodes, prefixes output, tears down |
StateRegistry |
lib/ruby_zmq_framework/state_registry.rb |
passive cluster-state cache; replays snapshots on request |
CanBridge |
lib/ruby_zmq_framework/can_bridge.rb |
real SocketCAN frames → can_frame topic (classic CAN, via raw ioctls, no extra gem) |
| demo nodes | nodes/*.rb |
one blackbox process per file |
Delivery is fire-and-forget (latest-value-wins; slow consumers drop old
messages), handlers on one bus never run concurrently, and a bad message or
a raising handler can never kill a node's listener. See CHANGELOG.md for
the full hardening history.
Note: ZeroMQ is reached through
ffi-rzmq, which is stable but hasn't seen a release in years. The wire format is deliberately plain two-frame PUB/SUB, so swapping bindings — or the transport itself — stays a contained change behind the three-method bus interface (publish/subscribe/close).
CAN hardware
Uncomment the can_bridge node in flow.yml (set CAN_IFACE, e.g.
vcan0) to relay real SocketCAN frames onto the bus as can_frame
messages. Needs an actual or virtual CAN interface; fails fast with the
underlying Errno if it doesn't exist.
Tests
bundle exec rake test