Skip to content

Repository files navigation

cpp_zmq_framework

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.yml is the only artifact that knows the topology. flowctl reads it, computes the wiring, and runs everything.
  • The contract is one page: PROTOCOL.md is 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 one flow.yml together 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.

Quick start

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/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.

build/flowctl --plan prints the computed wiring without running anything. build/flowctl --graph prints the node topology as JSON.

Writing a node

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.

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 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.

What's in the box

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 under third_party/), since unlike Go/Zig/Node, C++ has no JSON in std — the same reasoning that led the Rust port to take on serde_json. Everything else (the flow.yml parser, the test harness) is hand-rolled rather than pulling in another dependency for something this small.

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 if it doesn't exist.

Tests

cmake --build build -j
build/zmq_framework_test

test/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.

About

A C++ port of ruby_zmq_framework: flow-based, language-agnostic node runtime over ZeroMQ pub/sub

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages