ruby_zmq_framework 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +55 -0
- data/LICENSE.txt +21 -0
- data/README.md +130 -0
- data/lib/ruby_zmq_framework/can_bridge.rb +92 -0
- data/lib/ruby_zmq_framework/flow.rb +85 -0
- data/lib/ruby_zmq_framework/framework.rb +109 -0
- data/lib/ruby_zmq_framework/state_registry.rb +31 -0
- data/lib/ruby_zmq_framework/version.rb +3 -0
- data/lib/ruby_zmq_framework/zeromq_bus.rb +167 -0
- data/lib/ruby_zmq_framework.rb +41 -0
- metadata +102 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f85c8cb84cb6cfb2fc84e4c03d5fd0e3f00fbf0627a814cab9edbb4a3ca82434
|
|
4
|
+
data.tar.gz: 02dcec88c184e898f094a292ba3309f3a7a8dc3d477335b7314654681e681d59
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f3e2d7ece26853813dccd26d68d9924ab8028cc65d69610827690b9741c67f4910097c137523d3b3bd47cc31eb0cc5f4ef0b83ddf73ac736e2bfcb11585cb6f6
|
|
7
|
+
data.tar.gz: 1f4c6eb92e5d93ba39e07e2863f25fd1e1f6843aac60a317836bb517e7b731b8dd219b38e54f7529113dc2712366c67aa9e09d0eaea0e0aa95375ba7eac6d1df
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
### Changed
|
|
9
|
+
- The repo is now a reference implementation of a flow-based,
|
|
10
|
+
language-agnostic node runtime ("Node-RED without the UI"): the graph
|
|
11
|
+
lives in `flow.yml`, `bin/flowctl` computes wiring and runs it, nodes
|
|
12
|
+
are blackbox processes configured entirely from the environment, and
|
|
13
|
+
`PROTOCOL.md` is the one-page contract for joining from any language.
|
|
14
|
+
`examples/` (hand-wired scripts) became `nodes/` (env-wired processes).
|
|
15
|
+
|
|
16
|
+
### Added (flow runtime)
|
|
17
|
+
- `RubyZmqFramework.boot`: builds a node from `BUS_PORT`, `BUS_PEERS`,
|
|
18
|
+
`BUS_SUBSCRIBES`, and `NODE_NAME`; standalone-friendly when unset.
|
|
19
|
+
- `RubyZmqFramework::Flow`: parses the manifest and computes each node's
|
|
20
|
+
peer wiring from topic publishers/subscribers.
|
|
21
|
+
- `bin/flowctl` (with `--plan`): port assignment, spawning, prefixed
|
|
22
|
+
output streaming, Ctrl-C teardown.
|
|
23
|
+
- `PROTOCOL.md`: transport, wire format, env contract, heartbeat schema,
|
|
24
|
+
and a minimal Python node.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- The `ZeroMQBus` listener thread no longer dies silently on malformed
|
|
28
|
+
JSON, non-two-frame messages, or exceptions raised inside a
|
|
29
|
+
subscriber's `handle_message` — one bad message previously left the
|
|
30
|
+
node permanently deaf while its heartbeat kept reporting "ok".
|
|
31
|
+
- Unknown topics arriving off the wire no longer grow the subscriber map
|
|
32
|
+
without bound, and topic strings from the network are no longer
|
|
33
|
+
interned into symbols unless someone subscribed to them.
|
|
34
|
+
- The `StrictContract` check now runs before construction, so a class
|
|
35
|
+
missing a required method can no longer leak a live heartbeat thread
|
|
36
|
+
from a failed `.new`. The contract also applies to subclasses.
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
- Local dispatch: messages published on a bus now reach subscribers on
|
|
40
|
+
that same bus, with the same symbolized-key payload representation as
|
|
41
|
+
wire delivery.
|
|
42
|
+
- Clean shutdown: `ZeroMQBus#close`, `FrameworkModule#stop_heartbeat`,
|
|
43
|
+
and `CanBridge#close`.
|
|
44
|
+
- Configurable endpoints: `bind_host:` keyword, peers as `"host:port"`
|
|
45
|
+
or full ZeroMQ endpoints, and `my_port = 0` for an OS-assigned
|
|
46
|
+
ephemeral port (readable via `ZeroMQBus#port`).
|
|
47
|
+
- `FrameworkModule#node_name`: set `@node_name` to give each node a
|
|
48
|
+
distinct heartbeat identity.
|
|
49
|
+
- ZeroMQ return codes are checked; failures raise
|
|
50
|
+
`RubyZmqFramework::Error` instead of passing silently.
|
|
51
|
+
|
|
52
|
+
## [0.1.0] - 2026-07-21
|
|
53
|
+
|
|
54
|
+
- Initial version: `ZeroMQBus`, `StrictContract`, `FrameworkModule`
|
|
55
|
+
auto-heartbeat, `StateRegistry`, `CanBridge`, examples, tests.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paul Daniel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# ruby_zmq_framework
|
|
2
|
+
|
|
3
|
+
**Think Node-RED without the UI: a flow-based, language-agnostic runtime for
|
|
4
|
+
blackbox nodes wired together by topics.**
|
|
5
|
+
|
|
6
|
+
- **Nodes** are independent OS processes. Each one does one job, lives in one
|
|
7
|
+
file, and knows nothing about any other node — not their ports, not their
|
|
8
|
+
names, not their language.
|
|
9
|
+
- **Wires** are pub/sub topics carrying JSON, over ZeroMQ.
|
|
10
|
+
- **The graph is data**: [`flow.yml`](flow.yml) is the only artifact that
|
|
11
|
+
knows the topology. `bin/flowctl` reads it, computes the wiring, and runs
|
|
12
|
+
everything.
|
|
13
|
+
- **The contract is one page**: [`PROTOCOL.md`](PROTOCOL.md) is everything a
|
|
14
|
+
node in any language needs to join.
|
|
15
|
+
|
|
16
|
+
The design goal is LLM-friendliness: each node is a task you can hand to an
|
|
17
|
+
agent with the protocol page and a one-line description ("write a Go node
|
|
18
|
+
that decodes DBC frames") — it never needs the rest of the repo in context.
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
You need the ZeroMQ library (`libzmq3-dev` on Debian/Ubuntu, `brew install
|
|
23
|
+
zeromq` on macOS), then:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
bundle install
|
|
27
|
+
bundle exec bin/flowctl
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
That runs the demo graph from `flow.yml`: a simulated ECU blasting RPM data,
|
|
31
|
+
a telemetry node that commands a throttle cut on over-rev, a web dashboard
|
|
32
|
+
on <http://localhost:4567>, a state registry caching heartbeats and
|
|
33
|
+
telemetry, and a dashboard consumer syncing the registry's snapshot. Output
|
|
34
|
+
is streamed with a `[node_name]` prefix; Ctrl-C stops everything.
|
|
35
|
+
|
|
36
|
+
`bundle exec bin/flowctl --plan` prints the computed wiring without running
|
|
37
|
+
anything.
|
|
38
|
+
|
|
39
|
+
## Writing a node
|
|
40
|
+
|
|
41
|
+
A Ruby node is a class with one method, booted from the environment:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
require_relative '../lib/ruby_zmq_framework'
|
|
45
|
+
$stdout.sync = true
|
|
46
|
+
|
|
47
|
+
class RpmSmoother
|
|
48
|
+
include RubyZmqFramework::FrameworkModule
|
|
49
|
+
|
|
50
|
+
def initialize(bus)
|
|
51
|
+
@bus = bus
|
|
52
|
+
@window = []
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def handle_message(topic, payload)
|
|
56
|
+
@window = (@window << payload[:rpm]).last(5)
|
|
57
|
+
broadcast(:engine_data_smooth, { rpm: @window.sum / @window.size })
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
RubyZmqFramework.boot(RpmSmoother)
|
|
62
|
+
sleep
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Note what's absent: no ports, no peers, no subscribe calls. Wiring comes
|
|
66
|
+
from environment variables (`BUS_PORT`, `BUS_PEERS`, `BUS_SUBSCRIBES`,
|
|
67
|
+
`NODE_NAME` — see `PROTOCOL.md`), which `flowctl` computes from the node's
|
|
68
|
+
entry in the manifest:
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
rpm_smoother:
|
|
72
|
+
cmd: ruby nodes/rpm_smoother.rb
|
|
73
|
+
subscribes: [engine_data]
|
|
74
|
+
publishes: [engine_data_smooth]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Run standalone (no environment needed — it binds an ephemeral port) to poke
|
|
78
|
+
at a node in isolation: `bundle exec ruby nodes/rpm_smoother.rb`.
|
|
79
|
+
|
|
80
|
+
Every node automatically heartbeats every 5 seconds. `StrictContract`
|
|
81
|
+
raises at `.new` if a node forgets `handle_message` — loudly and
|
|
82
|
+
immediately, which is exactly the feedback an iterating agent needs.
|
|
83
|
+
|
|
84
|
+
## Nodes in other languages
|
|
85
|
+
|
|
86
|
+
The bus is just two-frame ZeroMQ pub/sub — `[topic, json]` — and the whole
|
|
87
|
+
contract fits on one page: [`PROTOCOL.md`](PROTOCOL.md), including a
|
|
88
|
+
complete minimal Python node. Follow it, add a `cmd` entry to `flow.yml`,
|
|
89
|
+
and the language never matters again. A Python companion library exists at
|
|
90
|
+
[python_zmq_framework](https://github.com/pgdaniel/python_zmq_framework),
|
|
91
|
+
with the same `boot()`-from-environment contract, `node_name`, and clean
|
|
92
|
+
shutdown as this gem — a Python node under its `nodes/` and a Ruby node
|
|
93
|
+
under this repo's `nodes/` are interchangeable entries in one `flow.yml`.
|
|
94
|
+
|
|
95
|
+
## What's in the box
|
|
96
|
+
|
|
97
|
+
| piece | file | job |
|
|
98
|
+
|-------|------|-----|
|
|
99
|
+
| `ZeroMQBus` | `lib/ruby_zmq_framework/zeromq_bus.rb` | hardened transport: poison-message-proof listener, per-handler error isolation, local dispatch, clean `close` |
|
|
100
|
+
| `FrameworkModule` | `lib/ruby_zmq_framework/framework.rb` | node mixin: contract enforcement, auto-heartbeat, `broadcast`, `node_name`, `stop_heartbeat` |
|
|
101
|
+
| `Flow` | `lib/ruby_zmq_framework/flow.rb` | parses `flow.yml`, computes each node's env wiring |
|
|
102
|
+
| `flowctl` | `bin/flowctl` | assigns ports, spawns nodes, prefixes output, tears down |
|
|
103
|
+
| `StateRegistry` | `lib/ruby_zmq_framework/state_registry.rb` | passive cluster-state cache; replays snapshots on request |
|
|
104
|
+
| `CanBridge` | `lib/ruby_zmq_framework/can_bridge.rb` | real SocketCAN frames → `can_frame` topic (classic CAN, via raw ioctls, no extra gem) |
|
|
105
|
+
| demo nodes | `nodes/*.rb` | one blackbox process per file |
|
|
106
|
+
|
|
107
|
+
Delivery is fire-and-forget (latest-value-wins; slow consumers drop old
|
|
108
|
+
messages), handlers on one bus never run concurrently, and a bad message or
|
|
109
|
+
a raising handler can never kill a node's listener. See `CHANGELOG.md` for
|
|
110
|
+
the full hardening history.
|
|
111
|
+
|
|
112
|
+
> **Note:** ZeroMQ is reached through
|
|
113
|
+
> [`ffi-rzmq`](https://github.com/chuckremes/ffi-rzmq), which is stable but
|
|
114
|
+
> hasn't seen a release in years. The wire format is deliberately plain
|
|
115
|
+
> two-frame PUB/SUB, so swapping bindings — or the transport itself — stays
|
|
116
|
+
> a contained change behind the three-method bus interface
|
|
117
|
+
> (`publish`/`subscribe`/`close`).
|
|
118
|
+
|
|
119
|
+
## CAN hardware
|
|
120
|
+
|
|
121
|
+
Uncomment the `can_bridge` node in `flow.yml` (set `CAN_IFACE`, e.g.
|
|
122
|
+
`vcan0`) to relay real SocketCAN frames onto the bus as `can_frame`
|
|
123
|
+
messages. Needs an actual or virtual CAN interface; fails fast with the
|
|
124
|
+
underlying `Errno` if it doesn't exist.
|
|
125
|
+
|
|
126
|
+
## Tests
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
bundle exec rake test
|
|
130
|
+
```
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
require 'socket'
|
|
2
|
+
|
|
3
|
+
module RubyZmqFramework
|
|
4
|
+
# Reads raw frames off a real SocketCAN interface (can0, vcan0, ...) and
|
|
5
|
+
# rebroadcasts each one onto the ZeroMQ bus. Pure producer: it has no
|
|
6
|
+
# interest in bus traffic, so handle_message is a no-op that only exists
|
|
7
|
+
# to satisfy FrameworkModule's contract.
|
|
8
|
+
#
|
|
9
|
+
# Talks to the kernel directly via Ruby's built-in Socket + a couple of
|
|
10
|
+
# raw ioctl/struct calls (see linux/can.h, linux/sockios.h) rather than
|
|
11
|
+
# a third-party gem, since no maintained "socketcan" gem is published on
|
|
12
|
+
# RubyGems.
|
|
13
|
+
#
|
|
14
|
+
# Classic CAN only: reads assume the 16-byte struct can_frame. That is
|
|
15
|
+
# safe even on an FD-enabled interface — the kernel only delivers
|
|
16
|
+
# 72-byte canfd_frames to sockets that opt in via CAN_RAW_FD_FRAMES,
|
|
17
|
+
# which this one never does — but it means FD traffic is invisible here.
|
|
18
|
+
class CanBridge
|
|
19
|
+
include FrameworkModule
|
|
20
|
+
|
|
21
|
+
CAN_RAW = 1
|
|
22
|
+
SIOCGIFINDEX = 0x8933
|
|
23
|
+
FRAME_SIZE = 16 # sizeof(struct can_frame): 4(id) + 1(len) + 3(pad) + 8(data)
|
|
24
|
+
CAN_EFF_FLAG = 0x80000000
|
|
25
|
+
CAN_EFF_MASK = 0x1FFFFFFF
|
|
26
|
+
CAN_SFF_MASK = 0x7FF
|
|
27
|
+
|
|
28
|
+
def self.parse_frame(raw)
|
|
29
|
+
id_raw, len = raw.unpack('L< C')
|
|
30
|
+
extended = (id_raw & CAN_EFF_FLAG) != 0
|
|
31
|
+
can_id = id_raw & (extended ? CAN_EFF_MASK : CAN_SFF_MASK)
|
|
32
|
+
|
|
33
|
+
{ id: can_id, extended: extended, dlc: len, data: raw[8, len].bytes }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(bus, interface:, topic: :can_frame)
|
|
37
|
+
@bus = bus
|
|
38
|
+
@topic = topic
|
|
39
|
+
@socket = open_can_socket(interface)
|
|
40
|
+
@running = true
|
|
41
|
+
start_reader
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def handle_message(topic, payload); end
|
|
45
|
+
|
|
46
|
+
# Stops the whole node: heartbeat, reader thread, CAN socket. Closing
|
|
47
|
+
# the socket from here is what interrupts the reader's blocking read.
|
|
48
|
+
def close
|
|
49
|
+
return unless @running
|
|
50
|
+
|
|
51
|
+
@running = false
|
|
52
|
+
stop_heartbeat
|
|
53
|
+
@socket.close unless @socket.closed?
|
|
54
|
+
@reader.join(2)
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def open_can_socket(interface)
|
|
61
|
+
socket = Socket.new(Socket::PF_CAN, Socket::SOCK_RAW, CAN_RAW)
|
|
62
|
+
socket.bind(sockaddr_can(interface_index(socket, interface)))
|
|
63
|
+
socket
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def interface_index(socket, interface)
|
|
67
|
+
ifreq = [interface].pack('a16') + ("\0" * 24)
|
|
68
|
+
socket.ioctl(SIOCGIFINDEX, ifreq)
|
|
69
|
+
ifreq[16, 4].unpack1('l')
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def sockaddr_can(ifindex)
|
|
73
|
+
[Socket::AF_CAN, 0, ifindex].pack('S S l') + ("\0" * 8)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def start_reader
|
|
77
|
+
@reader = Thread.new do
|
|
78
|
+
while @running
|
|
79
|
+
begin
|
|
80
|
+
raw = @socket.read(FRAME_SIZE)
|
|
81
|
+
broadcast(@topic, self.class.parse_frame(raw)) if raw&.bytesize == FRAME_SIZE
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
break unless @running # close interrupted the blocking read
|
|
84
|
+
|
|
85
|
+
warn "[Framework Error] CanBridge read failed: #{e.message}"
|
|
86
|
+
sleep 1
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
require 'yaml'
|
|
2
|
+
|
|
3
|
+
module RubyZmqFramework
|
|
4
|
+
# Parses a flow manifest (flow.yml) — the graph as data, Node-RED style.
|
|
5
|
+
# The manifest is the ONLY place that knows the topology; node processes
|
|
6
|
+
# learn their wiring from the environment variables computed here, and a
|
|
7
|
+
# node's code never mentions another node.
|
|
8
|
+
#
|
|
9
|
+
# nodes:
|
|
10
|
+
# ecu:
|
|
11
|
+
# cmd: ruby nodes/ecu.rb
|
|
12
|
+
# publishes: [engine_data]
|
|
13
|
+
# subscribes: [throttle_request]
|
|
14
|
+
# env: { SOME_KNOB: "42" } # optional, merged into the process env
|
|
15
|
+
class Flow
|
|
16
|
+
Node = Struct.new(:name, :cmd, :publishes, :subscribes, :env, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
attr_reader :nodes
|
|
19
|
+
|
|
20
|
+
def self.load(path)
|
|
21
|
+
new(YAML.safe_load(File.read(path)))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(spec)
|
|
25
|
+
unless spec.is_a?(Hash) && spec['nodes'].is_a?(Hash)
|
|
26
|
+
raise Error, '[Framework Error] Flow manifest needs a top-level "nodes" map'
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@nodes = spec['nodes'].map do |name, cfg|
|
|
30
|
+
cfg ||= {}
|
|
31
|
+
raise Error, "[Framework Error] Flow node #{name} needs a cmd" unless cfg['cmd']
|
|
32
|
+
|
|
33
|
+
Node.new(
|
|
34
|
+
name: name.to_s,
|
|
35
|
+
cmd: cfg['cmd'],
|
|
36
|
+
publishes: Array(cfg['publishes']).map(&:to_s),
|
|
37
|
+
subscribes: Array(cfg['subscribes']).map(&:to_s),
|
|
38
|
+
env: (cfg['env'] || {}).transform_values(&:to_s)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
warn_about_deaf_subscriptions
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The environment for every node process, given a {name => port} map.
|
|
45
|
+
# This is the whole trick that keeps nodes blackboxes: each node's peer
|
|
46
|
+
# list is computed from who publishes the topics it subscribes to.
|
|
47
|
+
def wiring(ports)
|
|
48
|
+
@nodes.to_h do |node|
|
|
49
|
+
peers = peer_names(node).map { |name| "127.0.0.1:#{ports.fetch(name)}" }
|
|
50
|
+
[node.name, {
|
|
51
|
+
'BUS_PORT' => ports.fetch(node.name).to_s,
|
|
52
|
+
'BUS_PEERS' => peers.join(','),
|
|
53
|
+
'BUS_SUBSCRIBES' => node.subscribes.join(','),
|
|
54
|
+
'NODE_NAME' => node.name
|
|
55
|
+
}.merge(node.env)]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# Every FrameworkModule node broadcasts :heartbeat implicitly, so for
|
|
62
|
+
# that topic everyone counts as a publisher. A node never peers with
|
|
63
|
+
# itself — the bus already delivers its own publishes locally.
|
|
64
|
+
def peer_names(node)
|
|
65
|
+
node.subscribes
|
|
66
|
+
.flat_map { |topic| publisher_names(topic) }
|
|
67
|
+
.uniq - [node.name]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def publisher_names(topic)
|
|
71
|
+
return @nodes.map(&:name) if topic == 'heartbeat'
|
|
72
|
+
|
|
73
|
+
@nodes.select { |n| n.publishes.include?(topic) }.map(&:name)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def warn_about_deaf_subscriptions
|
|
77
|
+
published = @nodes.flat_map(&:publishes).uniq + ['heartbeat']
|
|
78
|
+
@nodes.each do |node|
|
|
79
|
+
(node.subscribes - published).each do |topic|
|
|
80
|
+
warn "[Framework Warning] #{node.name} subscribes to #{topic.inspect} but no node in the flow publishes it"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
module RubyZmqFramework
|
|
2
|
+
module StrictContract
|
|
3
|
+
def self.included(base)
|
|
4
|
+
base.extend(ClassMethods)
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
module ClassMethods
|
|
8
|
+
def requires_methods(*methods)
|
|
9
|
+
@required_methods = methods
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def required_methods
|
|
13
|
+
inherited = superclass.respond_to?(:required_methods) ? superclass.required_methods : []
|
|
14
|
+
inherited | (@required_methods || [])
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The check runs BEFORE super (i.e. before allocate/initialize), so a
|
|
18
|
+
# violating class never gets partially constructed. This matters for
|
|
19
|
+
# FrameworkModule: its prepended Heartbeat starts a broadcasting thread
|
|
20
|
+
# inside initialize, and checking afterwards would leak that thread
|
|
21
|
+
# from a zombie instance whose .new appeared to fail.
|
|
22
|
+
def new(...)
|
|
23
|
+
missing = required_methods.reject do |m|
|
|
24
|
+
method_defined?(m) || private_method_defined?(m)
|
|
25
|
+
end
|
|
26
|
+
if missing.any?
|
|
27
|
+
raise NotImplementedError,
|
|
28
|
+
"[Framework Error] Contract Violation: #{self} missing #{missing.join(', ')}"
|
|
29
|
+
end
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
module FrameworkModule
|
|
36
|
+
HEARTBEAT_INTERVAL = 5 # seconds
|
|
37
|
+
|
|
38
|
+
def self.included(base)
|
|
39
|
+
base.include(StrictContract)
|
|
40
|
+
base.requires_methods(:handle_message)
|
|
41
|
+
base.prepend(Heartbeat)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Prepended so it wraps whatever initialize the concrete module defines,
|
|
45
|
+
# starting the heartbeat only once @bus has actually been assigned.
|
|
46
|
+
module Heartbeat
|
|
47
|
+
def initialize(...)
|
|
48
|
+
super
|
|
49
|
+
start_heartbeat
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def broadcast(topic, payload = {})
|
|
54
|
+
@bus.publish(topic, payload)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Identity used in heartbeats. Precedence: @node_name set by the node
|
|
58
|
+
# itself, then NODE_NAME from the environment (how bin/flowctl names
|
|
59
|
+
# the processes it spawns), then the class name. Distinct names matter
|
|
60
|
+
# when several instances of one class run — otherwise they overwrite
|
|
61
|
+
# each other in any StateRegistry's active_nodes.
|
|
62
|
+
def node_name
|
|
63
|
+
@node_name || ENV['NODE_NAME'] || self.class.name || 'AnonymousNode'
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Gracefully stops the heartbeat thread. Call this before closing the
|
|
67
|
+
# bus the node broadcasts on. Wakes the thread out of its interval wait
|
|
68
|
+
# rather than killing it, so an in-flight broadcast always completes.
|
|
69
|
+
def stop_heartbeat
|
|
70
|
+
thread = @heartbeat_thread
|
|
71
|
+
return unless thread
|
|
72
|
+
|
|
73
|
+
@heartbeat_mutex.synchronize do
|
|
74
|
+
@heartbeat_running = false
|
|
75
|
+
@heartbeat_wakeup.signal
|
|
76
|
+
end
|
|
77
|
+
thread.join(HEARTBEAT_INTERVAL + 1)
|
|
78
|
+
@heartbeat_thread = nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def start_heartbeat
|
|
84
|
+
@heartbeat_mutex = Mutex.new
|
|
85
|
+
@heartbeat_wakeup = ConditionVariable.new
|
|
86
|
+
@heartbeat_running = true
|
|
87
|
+
|
|
88
|
+
@heartbeat_thread = Thread.new do
|
|
89
|
+
loop do
|
|
90
|
+
begin
|
|
91
|
+
broadcast(:heartbeat, {
|
|
92
|
+
node_name: node_name,
|
|
93
|
+
status: "ok",
|
|
94
|
+
timestamp: Time.now.to_i
|
|
95
|
+
})
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
warn "[Framework Error] Heartbeat failed for #{node_name}: #{e.message}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
keep_going = @heartbeat_mutex.synchronize do
|
|
101
|
+
@heartbeat_wakeup.wait(@heartbeat_mutex, HEARTBEAT_INTERVAL) if @heartbeat_running
|
|
102
|
+
@heartbeat_running
|
|
103
|
+
end
|
|
104
|
+
break unless keep_going
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module RubyZmqFramework
|
|
2
|
+
# A passive, in-memory cache of cluster-wide state. It listens to
|
|
3
|
+
# heartbeats and telemetry broadcast by other FrameworkModule nodes and,
|
|
4
|
+
# on request, replays its current snapshot back onto the bus. It never
|
|
5
|
+
# makes a blocking call and never crashes when a peer goes quiet — a
|
|
6
|
+
# silent node simply stops getting its active_nodes timestamp updated.
|
|
7
|
+
class StateRegistry
|
|
8
|
+
include FrameworkModule
|
|
9
|
+
|
|
10
|
+
attr_reader :store
|
|
11
|
+
|
|
12
|
+
def initialize(bus)
|
|
13
|
+
@bus = bus
|
|
14
|
+
@store = { active_nodes: {}, telemetry: {} }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def handle_message(topic, payload)
|
|
18
|
+
case topic
|
|
19
|
+
when :heartbeat
|
|
20
|
+
@store[:active_nodes][payload[:node_name]] = {
|
|
21
|
+
status: payload[:status],
|
|
22
|
+
timestamp: payload[:timestamp]
|
|
23
|
+
}
|
|
24
|
+
when :request_global_state
|
|
25
|
+
broadcast(:global_state_snapshot, @store)
|
|
26
|
+
else
|
|
27
|
+
@store[:telemetry][topic] = payload
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
require 'ffi-rzmq'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'monitor'
|
|
4
|
+
|
|
5
|
+
module RubyZmqFramework
|
|
6
|
+
class ZeroMQBus
|
|
7
|
+
# How long the listener blocks in poll before looping, in milliseconds.
|
|
8
|
+
POLL_TIMEOUT_MS = 100
|
|
9
|
+
|
|
10
|
+
# The port actually bound — differs from my_port when my_port is 0.
|
|
11
|
+
attr_reader :port
|
|
12
|
+
|
|
13
|
+
# my_port may be 0 to bind an OS-assigned ephemeral port (read it back
|
|
14
|
+
# via #port). peer_ports entries may be Integers (loopback ports),
|
|
15
|
+
# "host:port" strings, or full ZeroMQ endpoints ("tcp://10.0.0.5:5555").
|
|
16
|
+
# Pass bind_host: "0.0.0.0" to accept peers from other machines.
|
|
17
|
+
def initialize(my_port, peer_ports = [], bind_host: '127.0.0.1')
|
|
18
|
+
@context = ZMQ::Context.new
|
|
19
|
+
|
|
20
|
+
# Publisher Socket
|
|
21
|
+
@pub = @context.socket(ZMQ::PUB)
|
|
22
|
+
bind_endpoint = "tcp://#{bind_host}:#{my_port.to_i.zero? ? '*' : my_port}"
|
|
23
|
+
check! @pub.bind(bind_endpoint), "bind to #{bind_endpoint}"
|
|
24
|
+
@port = bound_port
|
|
25
|
+
|
|
26
|
+
# Subscriber Socket
|
|
27
|
+
@sub = @context.socket(ZMQ::SUB)
|
|
28
|
+
peer_ports.each do |peer|
|
|
29
|
+
endpoint = peer_endpoint(peer)
|
|
30
|
+
check! @sub.connect(endpoint), "connect to #{endpoint}"
|
|
31
|
+
end
|
|
32
|
+
check! @sub.setsockopt(ZMQ::SUBSCRIBE, ''), 'subscribe'
|
|
33
|
+
|
|
34
|
+
# Keyed by topic *string*, with no default proc: the listener looks up
|
|
35
|
+
# every topic that arrives off the wire, and a defaulting hash would
|
|
36
|
+
# insert a permanent empty entry per unknown topic — an unbounded,
|
|
37
|
+
# network-driven leak. A Monitor guards it because subscribe runs on
|
|
38
|
+
# the caller's thread while dispatch runs on the listener thread.
|
|
39
|
+
@local_subscribers = {}
|
|
40
|
+
@subscribers_lock = Monitor.new
|
|
41
|
+
@running = true
|
|
42
|
+
start_listener
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Stops the listener thread and releases both sockets and the context.
|
|
46
|
+
# Stop anything still publishing on this bus first (heartbeats, reader
|
|
47
|
+
# threads): publishing on a closed bus raises RubyZmqFramework::Error.
|
|
48
|
+
# Idempotent.
|
|
49
|
+
def close
|
|
50
|
+
return if @closed
|
|
51
|
+
|
|
52
|
+
@closed = true
|
|
53
|
+
@running = false
|
|
54
|
+
@listener.join(POLL_TIMEOUT_MS / 1000.0 + 1) unless Thread.current == @listener
|
|
55
|
+
|
|
56
|
+
[@sub, @pub].each do |sock|
|
|
57
|
+
sock.setsockopt(ZMQ::LINGER, 0)
|
|
58
|
+
sock.close
|
|
59
|
+
end
|
|
60
|
+
@context.terminate
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def subscribe(topic, module_instance)
|
|
65
|
+
@subscribers_lock.synchronize do
|
|
66
|
+
(@local_subscribers[topic.to_s] ||= []) << module_instance
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def publish(topic, payload = {})
|
|
71
|
+
json = payload.to_json
|
|
72
|
+
check! @pub.send_strings([topic.to_s, json]), "publish #{topic}"
|
|
73
|
+
|
|
74
|
+
# A SUB socket never connects back to its own PUB, so without this,
|
|
75
|
+
# two modules sharing one bus in the same process could not hear each
|
|
76
|
+
# other. Delivering the serialized form keeps the payload
|
|
77
|
+
# representation identical whether a message arrived locally or over
|
|
78
|
+
# the wire (string keys become symbols either way).
|
|
79
|
+
dispatch(topic.to_s, json)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def check!(rc, action)
|
|
85
|
+
return if ZMQ::Util.resultcode_ok?(rc)
|
|
86
|
+
|
|
87
|
+
raise Error, "[Framework Error] ZeroMQ #{action} failed: #{ZMQ::Util.error_string}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def bound_port
|
|
91
|
+
endpoint = []
|
|
92
|
+
check! @pub.getsockopt(ZMQ::LAST_ENDPOINT, endpoint), 'read bound endpoint'
|
|
93
|
+
# ffi-rzmq returns the endpoint with a trailing NUL, e.g. "tcp://127.0.0.1:5555\0"
|
|
94
|
+
endpoint.first.delete("\0").rpartition(':').last.to_i
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def peer_endpoint(peer)
|
|
98
|
+
case peer
|
|
99
|
+
when Integer then "tcp://127.0.0.1:#{peer}"
|
|
100
|
+
when %r{\A[a-z]+://} then peer # already a full ZeroMQ endpoint
|
|
101
|
+
else "tcp://#{peer}" # "host:port"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def start_listener
|
|
106
|
+
@listener = Thread.new do
|
|
107
|
+
poller = ZMQ::Poller.new
|
|
108
|
+
poller.register_readable(@sub)
|
|
109
|
+
|
|
110
|
+
while @running
|
|
111
|
+
ready = poller.poll(POLL_TIMEOUT_MS)
|
|
112
|
+
if ready == -1
|
|
113
|
+
break unless @running
|
|
114
|
+
|
|
115
|
+
warn "[Framework Error] Listener poll failed: #{ZMQ::Util.error_string}"
|
|
116
|
+
sleep 0.1
|
|
117
|
+
next
|
|
118
|
+
end
|
|
119
|
+
next if ready.zero?
|
|
120
|
+
|
|
121
|
+
messages = []
|
|
122
|
+
unless ZMQ::Util.resultcode_ok?(@sub.recv_strings(messages))
|
|
123
|
+
warn "[Framework Error] Listener receive failed: #{ZMQ::Util.error_string}"
|
|
124
|
+
next
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
topic, json = messages
|
|
128
|
+
# Drop anything that doesn't match the two-frame [topic, json] wire format.
|
|
129
|
+
next if topic.nil? || json.nil?
|
|
130
|
+
|
|
131
|
+
dispatch(topic, json)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# A bad payload or a raising subscriber must never kill the listener
|
|
137
|
+
# thread: one poisoned message would otherwise leave the node deaf for
|
|
138
|
+
# good while its heartbeat keeps reporting "ok".
|
|
139
|
+
# Runs entirely inside the Monitor, which is reentrant: dispatch happens
|
|
140
|
+
# on the listener thread AND on whichever thread called publish, and
|
|
141
|
+
# serializing them guarantees a subscriber's handle_message is never
|
|
142
|
+
# executed concurrently — while a handler that publishes from within
|
|
143
|
+
# handle_message simply re-enters the lock it already holds.
|
|
144
|
+
def dispatch(topic_str, json)
|
|
145
|
+
@subscribers_lock.synchronize do
|
|
146
|
+
subscribers = @local_subscribers[topic_str]
|
|
147
|
+
return if subscribers.nil? || subscribers.empty?
|
|
148
|
+
|
|
149
|
+
begin
|
|
150
|
+
payload = JSON.parse(json, symbolize_names: true)
|
|
151
|
+
rescue JSON::ParserError => e
|
|
152
|
+
warn "[Framework Error] Dropping malformed payload on #{topic_str}: #{e.message}"
|
|
153
|
+
return
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
topic = topic_str.to_sym
|
|
157
|
+
# Dup so a handler that subscribes mid-dispatch can't mutate the
|
|
158
|
+
# list we're iterating.
|
|
159
|
+
subscribers.dup.each do |mod|
|
|
160
|
+
mod.handle_message(topic, payload)
|
|
161
|
+
rescue StandardError => e
|
|
162
|
+
warn "[Framework Error] #{mod.class} failed handling #{topic}: #{e.message}"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require_relative "ruby_zmq_framework/version"
|
|
2
|
+
require_relative "ruby_zmq_framework/framework"
|
|
3
|
+
require_relative "ruby_zmq_framework/zeromq_bus"
|
|
4
|
+
require_relative "ruby_zmq_framework/state_registry"
|
|
5
|
+
require_relative "ruby_zmq_framework/can_bridge"
|
|
6
|
+
require_relative "ruby_zmq_framework/flow"
|
|
7
|
+
|
|
8
|
+
module RubyZmqFramework
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
|
|
11
|
+
# Boots a node the flow-runtime way: all bus wiring comes from
|
|
12
|
+
# environment variables (set by bin/flowctl, or by hand), so node code
|
|
13
|
+
# never contains ports, peer lists, or subscription calls — a node is
|
|
14
|
+
# just a class with handle_message plus whatever it broadcasts.
|
|
15
|
+
#
|
|
16
|
+
# BUS_PORT port to bind (default 0 = OS-assigned ephemeral)
|
|
17
|
+
# BUS_PEERS comma-separated peer endpoints ("127.0.0.1:5555,...")
|
|
18
|
+
# BUS_SUBSCRIBES comma-separated topics routed to node#handle_message
|
|
19
|
+
# NODE_NAME heartbeat identity (defaults to the class name)
|
|
20
|
+
#
|
|
21
|
+
# node_class must take the bus as its first constructor argument. With
|
|
22
|
+
# no environment set, the node still boots standalone on an ephemeral
|
|
23
|
+
# port — handy for poking at a single node in isolation.
|
|
24
|
+
def self.boot(node_class, *args, **kwargs)
|
|
25
|
+
bus = ZeroMQBus.new(ENV.fetch('BUS_PORT', '0').to_i, env_list('BUS_PEERS'))
|
|
26
|
+
node = node_class.new(bus, *args, **kwargs)
|
|
27
|
+
env_list('BUS_SUBSCRIBES').each { |topic| bus.subscribe(topic, node) }
|
|
28
|
+
|
|
29
|
+
# Booted nodes are processes managed by a supervisor (bin/flowctl) or a
|
|
30
|
+
# terminal: exit quietly on TERM/INT instead of dumping a backtrace
|
|
31
|
+
# from an interrupted sleep. Frameworks that install their own traps
|
|
32
|
+
# afterwards (e.g. Sinatra) simply override these.
|
|
33
|
+
%w[TERM INT].each { |sig| trap(sig) { exit } }
|
|
34
|
+
node
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.env_list(key)
|
|
38
|
+
ENV.fetch(key, '').split(',').map(&:strip).reject(&:empty?)
|
|
39
|
+
end
|
|
40
|
+
private_class_method :env_list
|
|
41
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ruby_zmq_framework
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Paul Daniel
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-24 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: ffi-rzmq
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '2.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: minitest
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '5.20'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '5.20'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rake
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '13.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '13.0'
|
|
55
|
+
description: 'Blackbox node processes wired together by pub/sub topics: the graph
|
|
56
|
+
lives in a flow.yml manifest, nodes are configured entirely from the environment,
|
|
57
|
+
and a one-page protocol lets any language join the bus. Includes a hardened ZeroMQ
|
|
58
|
+
transport with strict runtime module contracts.'
|
|
59
|
+
email:
|
|
60
|
+
- paulgdan@gmail.com
|
|
61
|
+
executables: []
|
|
62
|
+
extensions: []
|
|
63
|
+
extra_rdoc_files: []
|
|
64
|
+
files:
|
|
65
|
+
- CHANGELOG.md
|
|
66
|
+
- LICENSE.txt
|
|
67
|
+
- README.md
|
|
68
|
+
- lib/ruby_zmq_framework.rb
|
|
69
|
+
- lib/ruby_zmq_framework/can_bridge.rb
|
|
70
|
+
- lib/ruby_zmq_framework/flow.rb
|
|
71
|
+
- lib/ruby_zmq_framework/framework.rb
|
|
72
|
+
- lib/ruby_zmq_framework/state_registry.rb
|
|
73
|
+
- lib/ruby_zmq_framework/version.rb
|
|
74
|
+
- lib/ruby_zmq_framework/zeromq_bus.rb
|
|
75
|
+
homepage: https://github.com/pgdaniel/ruby_zmq_framework
|
|
76
|
+
licenses:
|
|
77
|
+
- MIT
|
|
78
|
+
metadata:
|
|
79
|
+
source_code_uri: https://github.com/pgdaniel/ruby_zmq_framework
|
|
80
|
+
changelog_uri: https://github.com/pgdaniel/ruby_zmq_framework/blob/master/CHANGELOG.md
|
|
81
|
+
rubygems_mfa_required: 'true'
|
|
82
|
+
post_install_message:
|
|
83
|
+
rdoc_options: []
|
|
84
|
+
require_paths:
|
|
85
|
+
- lib
|
|
86
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
87
|
+
requirements:
|
|
88
|
+
- - ">="
|
|
89
|
+
- !ruby/object:Gem::Version
|
|
90
|
+
version: '2.7'
|
|
91
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - ">="
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0'
|
|
96
|
+
requirements: []
|
|
97
|
+
rubygems_version: 3.5.22
|
|
98
|
+
signing_key:
|
|
99
|
+
specification_version: 4
|
|
100
|
+
summary: Flow-based, language-agnostic node runtime over ZeroMQ — Node-RED without
|
|
101
|
+
the UI
|
|
102
|
+
test_files: []
|