A C++ port of ruby_zmq_framework
and its Zig, Go, Rust, and Node siblings: Node-RED without the UI, built
directly on libzmq, std::thread, and a small vendored JSON header.
- 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.flowctlreads it, computes the wiring, and runs everything. - The contract is one page:
PROTOCOL.mdis everything a node in any language needs to join — and it's the same contract as the Ruby, Zig, Go, Rust, and Node originals, so nodes from any of the six repos can sit in oneflow.ymltogether without any of them knowing the others exist.
The node contract is a pure-virtual Handler::handle_message — a node
class that forgets to override it can't be instantiated, so boot<NodeT>()
fails to compile at the call site rather than raising at runtime.
You need a C++17 compiler, CMake, and the ZeroMQ library (libzmq3-dev on
Debian/Ubuntu, brew install zeromq on macOS), then:
cmake -B build
cmake --build build -j
build/flowctlThat 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.
build/flowctl --plan prints the computed wiring without running
anything. build/flowctl --graph prints the node topology as JSON.
A C++ node is a class implementing Handler, booted from the environment:
#include "zmq_framework/framework.hpp"
#include <iostream>
#include <vector>
using namespace zmqf;
// RpmSmoother publishes: engine_data_smooth. Subscribes: engine_data.
class RpmSmoother : public Handler {
public:
explicit RpmSmoother(std::shared_ptr<Bus> bus) : bus_(std::move(bus)) {}
void handle_message(const std::string& topic, const Payload& payload) override {
if (topic != "engine_data") return;
window_.push_back(payload.value("rpm", 0));
if (window_.size() > 5) window_.erase(window_.begin());
int sum = 0;
for (int v : window_) sum += v;
bus_->publish("engine_data_smooth", Payload{{"rpm", sum / static_cast<int>(window_.size())}});
}
private:
std::shared_ptr<Bus> bus_;
std::vector<int> window_;
};
int main() {
boot<RpmSmoother>();
std::cout << "online\n";
sleep_forever();
}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: build/rpm_smoother
subscribes: [engine_data]
publishes: [engine_data_smooth](add it to CMakeLists.txt's list of zmq_framework_executable(...) calls
to get it built.)
Run standalone (no environment needed — it binds an ephemeral port) to poke
at a node in isolation: build/rpm_smoother.
Every node automatically heartbeats every 5 seconds. boot<NodeT>(args...)
is a template that perfect-forwards any extra constructor arguments after
the bus — for a node whose constructor needs more than that (CanBridge
takes an interface name), just pass the extra argument to boot<T>()
directly; no closures or manual wiring needed, unlike the Zig port.
The bus is just two-frame ZeroMQ pub/sub — [topic, json] — and the whole
contract fits on one page: PROTOCOL.md, including the raw
libzmq calls this framework's Bus makes under the hood. Follow it, add a
cmd entry to flow.yml, and the language never matters again —
including the original
ruby_zmq_framework and
its Zig,
Go,
Rust, and
Node ports, which all
speak the exact same wire format.
flow_viewer can view and edit
any of their flow.yml files.
| piece | file | job |
|---|---|---|
Bus |
src/bus.cpp |
ZeroMQ transport via a direct binding to libzmq's C API — no ZeroMQ-specific wrapper. A dedicated dispatcher thread owns all subscriber state (fed by one queue carrying both subscribe and dispatch operations, so they stay strictly FIFO), giving "handlers never run concurrently" without a recursive mutex — the same design the Rust port uses, including its send_mutex_ fix for the fact that zmq_send on one socket from multiple threads is undefined behavior |
boot<NodeT>() |
include/zmq_framework/framework.hpp |
wires a node from BUS_*/NODE_NAME, NodeHandle::broadcast, TERM/INT handling, auto-heartbeat |
Flow |
src/flow.cpp |
parses and serializes flow.yml — no YAML dependency, just a small hand-rolled parser/writer for the subset flow.yml uses |
flowctl |
src/flowctl.cpp |
assigns ports, spawns nodes via fork/execve, prefixes output, tears down on Ctrl-C |
StateRegistry |
src/state_registry.cpp |
passive cluster-state cache; replays snapshots on request |
CanBridge |
src/can_bridge.cpp |
real SocketCAN frames → can_frame topic (classic CAN, via native Linux headers, no extra library) |
| demo nodes | nodes/*.cpp |
one blackbox process per file |
Delivery is fire-and-forget (latest-value-wins), and a throwing handler
can never kill the dispatcher thread (caught and logged, mirroring the
other ports' per-handler error isolation). Signal handlers call _exit()
rather than a normal exit — safer inside a signal handler, since it skips
running arbitrary C++ static destructors that might deadlock on a lock
the interrupted thread was holding — which is also why every node sets
std::cout << std::unitbuf: a piped, fully-buffered stdout would
otherwise lose everything not yet flushed when _exit() cuts in.
Note on dependencies: ZeroMQ is reached through a direct binding to libzmq's C API (
src/bus.cpp) — no ZeroMQ-specific wrapper. The one real dependency is nlohmann/json (vendored as a single header underthird_party/), since unlike Go/Zig/Node, C++ has no JSON in std — the same reasoning that led the Rust port to take onserde_json. Everything else (the flow.yml parser, the test harness) is hand-rolled rather than pulling in another dependency for something this small.
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 if it
doesn't exist.
cmake --build build -j
build/zmq_framework_testtest/test_framework.hpp is a ~40-line hand-rolled test harness (no
gtest/Catch2 dependency) — a TEST(name) { ... } macro registers a
self-contained test function, ASSERT_TRUE/ASSERT_EQ throw on failure.
Tests mirror the other ports' suites: bus dispatch (including a throwing
handler not taking down the bus), flow wiring/graph computation (plus a
round-trip parse → serialize → parse test), and StateRegistry's
heartbeat/telemetry/snapshot behavior.