zzq 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4ecbbe3094e3570604ee7b7b1b69d45d4a088c4da2dffb9c04a79d8b07475ac8
4
+ data.tar.gz: 9daf51c7c1b2c7e30c33b306b5e4aad5440ef0af129f261951a13b74e30163af
5
+ SHA512:
6
+ metadata.gz: be9e2fa7e9159f333122b40b8776dfc659527d3c37153f7b94bc88eb3732e4fd11d4093e5cfb4366b105032a97bf9cda76334d019126d537dbb767b490586bd5
7
+ data.tar.gz: c1d8ae054e97e5e9a5ec90c3a403933e59b5ae55e67b29a5bc65bd3df126719c990770e8f8563e29a76fe728ae14c27b0c2b7a6c6311a885ee2dc4d82a713b53
data/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## v0.1.0 — 2026-04-20
4
+
5
+ - First release. Async-native MQTT 3.1.1 + 5.0 client and broker in
6
+ one gem, built on `async`, `io-stream`, and `protocol-mqtt`.
7
+ - Transports: `mqtt://` (plain TCP, port 1883), `mqtts://` (TLS,
8
+ port 8883), `ipc://` (Unix-domain sockets, filesystem and Linux
9
+ abstract namespace).
10
+ - `mqtts://` accepts any `OpenSSL::SSL::SSLContext` and drops the
11
+ connection at 128 GiB of application data so reconnects force
12
+ fresh keys before AEAD nonce counters approach their unsafe
13
+ range.
14
+ - Retained-message persistence: in-memory (default) or stdlib
15
+ `PStore` with `ultra_safe = true` for fsync-on-commit durability.
16
+ - Broker-to-broker bridging via `Client` + `Broker` composition;
17
+ MQTT v5 `no_local` loop guard supported.
data/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ Copyright (c) 2026, Patrik Wenger
2
+
3
+ Permission to use, copy, modify, and/or distribute this software for any
4
+ purpose with or without fee is hereby granted, provided that the above
5
+ copyright notice and this permission notice appear in all copies.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
8
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
9
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
10
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
11
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
12
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
13
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,153 @@
1
+ # ZZQ — async-native MQTT for Ruby
2
+
3
+ > Codename **Zanzara** — Italian for "mosquito", in the same spirit as
4
+ > the Mosquitto broker that popularised MQTT. `zzq` keeps the
5
+ > three-letter gem-name symmetry with its siblings `omq` and `nnq`.
6
+
7
+ [![CI](https://github.com/paddor/zzq/actions/workflows/ci.yml/badge.svg)](https://github.com/paddor/zzq/actions/workflows/ci.yml)
8
+ [![Gem Version](https://img.shields.io/gem/v/zzq?color=e9573f)](https://rubygems.org/gems/zzq)
9
+ [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](LICENSE)
10
+ [![Ruby](https://img.shields.io/badge/Ruby-%3E%3D%204.0-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org)
11
+
12
+ ZZQ is a pure-Ruby MQTT 3.1.1 + 5.0 implementation — **client and
13
+ broker in one gem** — built on [async](https://github.com/socketry/async),
14
+ [io-stream](https://github.com/socketry/io-stream), and
15
+ [protocol-mqtt](https://github.com/paddor/protocol-mqtt). It applies
16
+ the [omq](https://github.com/paddor/omq) / [nnq](https://github.com/paddor/nnq)
17
+ architecture to MQTT.
18
+
19
+ Status: pre-alpha. See [the design plan](../../.claude/plans/omq-and-nnq-s-design-robust-dolphin.md) for scope and roadmap.
20
+
21
+ ## Quickstart
22
+
23
+ ```ruby
24
+ require "zzq"
25
+ require "async"
26
+
27
+ # Client
28
+ Async do
29
+ client = ZZQ::Client.new(client_id: "sensor-42", version: 5, keepalive: 30)
30
+ client.connect("mqtt://broker.example:1883") do |task|
31
+ sub = client.subscribe("cmd/#", qos: 1)
32
+ task.async do
33
+ sub.each { |msg| puts "#{msg.topic}: #{msg.payload}" }
34
+ end
35
+ client.publish("status/sensor-42", "online", qos: 1, retain: true)
36
+ end
37
+ end
38
+
39
+ # Broker
40
+ Async do
41
+ ctx = OpenSSL::SSL::SSLContext.new
42
+ ctx.cert = OpenSSL::X509::Certificate.new(File.read("server.crt"))
43
+ ctx.key = OpenSSL::PKey.read(File.read("server.key"))
44
+ # Recommended: TLS 1.3 with DJB-family AEAD.
45
+ ctx.min_version = OpenSSL::SSL::TLS1_3_VERSION
46
+ ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION
47
+ ctx.ciphersuites = "TLS_CHACHA20_POLY1305_SHA256"
48
+
49
+ broker = ZZQ::Broker.new
50
+ broker.bind("mqtt://0.0.0.0:1883")
51
+ broker.bind("mqtts://0.0.0.0:8883", tls_context: ctx)
52
+ # ... bind more transports on the same broker
53
+ end
54
+ ```
55
+
56
+ ## Transports
57
+
58
+ - `mqtt://` — plain MQTT over TCP (default port 1883).
59
+ - `mqtts://` — MQTT over TLS (default port 8883). Takes any
60
+ `OpenSSL::SSL::SSLContext` via `tls_context:`; the recommended
61
+ configuration is TLS 1.3 pinned to `TLS_CHACHA20_POLY1305_SHA256`
62
+ as shown above. The transport drops the connection at 128 GiB
63
+ of application data — well below ChaCha20-Poly1305's ~256 GiB
64
+ per-key integrity bound — so reconnects force fresh keys before
65
+ AEAD nonce counters approach their unsafe range.
66
+ - `ws://` / `wss://` — MQTT over WebSockets.
67
+ - `ipc:///path` — Unix-domain socket, filesystem path.
68
+ - `ipc://@abstract` — Linux abstract-namespace Unix socket.
69
+
70
+ ## Persistence
71
+
72
+ By default the broker keeps retained messages in process memory — fast,
73
+ zero-config, and lost when the process exits. That's the right choice
74
+ for tests and ephemeral workloads.
75
+
76
+ For durability across restarts, pass a `PStore`-backed persistence
77
+ object. It uses stdlib [`PStore`](https://docs.ruby-lang.org/en/master/PStore.html)
78
+ with `ultra_safe = true` so every commit fsyncs the data file and its
79
+ parent directory — retained state survives power loss, not just clean
80
+ shutdowns.
81
+
82
+ ```ruby
83
+ # Default: in-memory, ephemeral
84
+ broker = ZZQ::Broker.new
85
+
86
+ # Explicit in-memory (same as default)
87
+ broker = ZZQ::Broker.new persistence: ZZQ::Persistence::Memory.new
88
+
89
+ # Durable: retained messages persist to ./zzq-data/
90
+ persistence = ZZQ::Persistence::PStore.new data_dir: "./zzq-data"
91
+ broker = ZZQ::Broker.new persistence: persistence
92
+ ```
93
+
94
+ PStore is single-process only — file locking doesn't coordinate across
95
+ processes. For HA deployments, a future external adapter (Redis,
96
+ SQLite) will ship as a separate gem.
97
+
98
+ ## Bridging two brokers
99
+
100
+ ZZQ does not ship a `Bridge` class — broker-to-broker forwarding is a
101
+ short composition of a `Client` and a local `Broker`. Subscribe on one
102
+ side, publish to the other:
103
+
104
+ ```ruby
105
+ require "zzq"
106
+ require "async"
107
+
108
+ Async do |task|
109
+ upstream = ZZQ::Client.new(client_id: "site-42-bridge", version: 5)
110
+ local = ZZQ::Broker.new
111
+
112
+ upstream.connect("mqtts://cloud.example:8883")
113
+ local.bind("mqtt://0.0.0.0:1883")
114
+
115
+ # local → upstream: forward telemetry out.
116
+ task.async do
117
+ local.subscribe("telemetry/#", qos: 1).each do |m|
118
+ upstream.publish(m.topic, m.payload, qos: m.qos, retain: m.retain)
119
+ end
120
+ end
121
+
122
+ # upstream → local: pull commands in. `Broker#<<` fans the Message
123
+ # straight into the local broker's delivery path — no need to pick
124
+ # apart and re-pack fields (alias of `#ingest`).
125
+ task.async do
126
+ upstream.subscribe("cmd/#", qos: 1, no_local: true).each do |m|
127
+ local << m
128
+ end
129
+ end
130
+ end
131
+ ```
132
+
133
+ Loop guard for two-way routes:
134
+
135
+ * MQTT v5: pass `no_local: true` on the subscribe call — the spec
136
+ forbids the broker from echoing back to the publisher of the
137
+ subscription, so messages originating on one side don't bounce.
138
+ * MQTT v3.1.1: no `no_local`. Use non-overlapping filters on each
139
+ direction, or tag topics with a side-specific prefix.
140
+
141
+ ## When MQTT, when OMQ/NNQ
142
+
143
+ MQTT bridging makes sense only when the remote speaks MQTT and nothing
144
+ else (AWS IoT Core, HiveMQ Cloud, partner Mosquitto). For inter-service
145
+ comm where both ends are under your control, reach for
146
+ [omq](https://github.com/paddor/omq) or [nnq](https://github.com/paddor/nnq)
147
+ — cleaner wire format, no protocol overhead. ZZQ exposes
148
+ `ZZQ::Message#to_wire` / `.from_wire` so you can ship MQTT messages
149
+ across an OMQ/NNQ socket without re-encoding.
150
+
151
+ ## License
152
+
153
+ ISC. See [LICENSE](LICENSE).
data/lib/zzq/broker.rb ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "socket"
4
+
5
+ module ZZQ
6
+ # MQTT broker. Owns the session map (by client id), retained-message
7
+ # store, topic trie, and shared-subscription groups.
8
+ #
9
+ # M3 stub: constructor + {#build_routing}. CONNECT handling,
10
+ # subscription matching, and retained store come in M7.
11
+ class Broker < Socket
12
+ def build_routing(engine)
13
+ Routing::Broker.new(engine)
14
+ end
15
+
16
+
17
+ # Number of currently-connected MQTT sessions.
18
+ def session_count = @engine.routing.session_count
19
+
20
+
21
+ # Inject a {ZZQ::Message} into the broker's delivery path as if it
22
+ # had arrived via a PUBLISH packet. Fans out to every matching
23
+ # subscriber. Use this to bridge non-MQTT inputs (NNQ, OMQ, a
24
+ # file) into broker topics.
25
+ def ingest(message)
26
+ raise NotInAsyncContext unless Async::Task.current?
27
+ @engine.routing.dispatch_message(message)
28
+ nil
29
+ end
30
+ alias_method :<<, :ingest
31
+ end
32
+ end
33
+
34
+ require_relative "routing/broker"
data/lib/zzq/client.rb ADDED
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "socket"
4
+
5
+ module ZZQ
6
+ # MQTT client. Owns exactly one MQTT session at a time (possibly
7
+ # persisted across reconnects), subscriptions, and a QoS tracker for
8
+ # inflight PUB flows.
9
+ #
10
+ # M3 stub: only the constructor and {#build_routing} stand. The
11
+ # CONNECT/CONNACK handshake and publish/subscribe plumbing come in
12
+ # M4 / M5.
13
+ class Client < Socket
14
+ def build_routing(engine)
15
+ Routing::Client.new(engine)
16
+ end
17
+
18
+
19
+ # Most recent CONNACK received from the broker, or nil before the
20
+ # first successful connect.
21
+ def last_connack = @engine.routing.last_connack
22
+ def session_present? = @engine.routing.session_present?
23
+
24
+
25
+ # Publish a message. For QoS 0 returns once the packet is
26
+ # written. For QoS 1 blocks until PUBACK; for QoS 2 blocks until
27
+ # PUBCOMP.
28
+ def publish(topic, payload, qos: 0, retain: false, properties: {})
29
+ raise NotInAsyncContext unless Async::Task.current?
30
+ @engine.routing.publish(
31
+ topic: topic, payload: coerce_binary(payload),
32
+ qos: qos, retain: retain, properties: properties,
33
+ )
34
+ end
35
+
36
+
37
+ # Subscribe to a topic filter. Returns a {ZZQ::Subscription} whose
38
+ # queue receives matching messages until it is closed.
39
+ def subscribe(filter, qos: 0, no_local: false, retain_as_published: false,
40
+ retain_handling: 0)
41
+ raise NotInAsyncContext unless Async::Task.current?
42
+ @engine.routing.subscribe(
43
+ filter, qos: qos, no_local: no_local,
44
+ retain_as_published: retain_as_published,
45
+ retain_handling: retain_handling,
46
+ )
47
+ end
48
+ end
49
+ end
50
+
51
+ require_relative "routing/client"
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async/barrier"
4
+ require "protocol/mqtt"
5
+
6
+ module ZZQ
7
+ class Engine
8
+ # Owns the full arc of one connection: new → handshaking → ready →
9
+ # closed. Centralises side-effect ordering so routing doesn't have
10
+ # to.
11
+ #
12
+ # The MQTT CONNECT/CONNACK exchange itself is NOT performed here —
13
+ # it's role-asymmetric (client writes CONNECT first, broker reads
14
+ # it first) and so lives in the role's routing layer, which calls
15
+ # +handshake_succeeded!+ once the exchange is done.
16
+ #
17
+ # new → handshaking → ready → closed
18
+ #
19
+ # #lost! / #close! are idempotent.
20
+ class ConnectionLifecycle
21
+ class InvalidTransition < RuntimeError; end
22
+
23
+ STATES = %i[new handshaking ready closed].freeze
24
+
25
+ TRANSITIONS = {
26
+ new: %i[handshaking ready closed].freeze,
27
+ handshaking: %i[ready closed].freeze,
28
+ ready: %i[closed].freeze,
29
+ closed: [].freeze,
30
+ }.freeze
31
+
32
+
33
+ attr_reader :conn # Protocol::MQTT::Connection
34
+ attr_reader :endpoint
35
+ attr_reader :state
36
+ attr_reader :barrier
37
+
38
+
39
+ def initialize(engine, endpoint:)
40
+ @engine = engine
41
+ @endpoint = endpoint
42
+ @state = :new
43
+ @conn = nil
44
+ @barrier = Async::Barrier.new(parent: engine.barrier)
45
+ end
46
+
47
+
48
+ # Wraps the accepted/connected IO in a Protocol::MQTT::Connection
49
+ # and returns it. The handshake itself (CONNECT/CONNACK exchange)
50
+ # is driven by the routing layer — this is "transport ready,
51
+ # MQTT handshake pending".
52
+ def begin_handshake!(io)
53
+ transition!(:handshaking)
54
+ mqtt = Protocol::MQTT::Connection.new(
55
+ io,
56
+ version: @engine.options.version,
57
+ max_packet_size: @engine.options.max_packet_size,
58
+ )
59
+ @conn = mqtt
60
+ mqtt
61
+ end
62
+
63
+
64
+ # Called by the routing layer after CONNECT/CONNACK succeeds.
65
+ def handshake_succeeded!
66
+ @engine.connections[@conn] = self
67
+ transition!(:ready)
68
+ begin
69
+ @engine.routing.connection_added(@conn) if @engine.routing.respond_to?(:connection_added)
70
+ rescue ConnectionRejected
71
+ @engine.emit_monitor_event(:connection_rejected, endpoint: @endpoint)
72
+ tear_down!(reconnect: false)
73
+ raise
74
+ end
75
+ @engine.lifecycle.peer_connected.resolve(@conn) unless @engine.lifecycle.peer_connected.resolved?
76
+ @engine.emit_monitor_event(:connected, endpoint: @endpoint)
77
+ end
78
+
79
+
80
+ # Called on handshake failure — e.g. refused CONNACK, RST mid-
81
+ # CONNECT. Tears down and requests a reconnect if eligible.
82
+ def handshake_failed!(error:)
83
+ @engine.emit_monitor_event(:handshake_failed, endpoint: @endpoint, detail: { error: error })
84
+ @conn&.close rescue nil
85
+ tear_down!(reconnect: true)
86
+ end
87
+
88
+
89
+ # Unexpected loss of an established connection.
90
+ def lost!
91
+ tear_down!(reconnect: true)
92
+ end
93
+
94
+
95
+ # Deliberate close (engine shutdown, kicked by broker, …).
96
+ def close!
97
+ tear_down!(reconnect: false)
98
+ end
99
+
100
+
101
+ # Starts a supervisor task that tears down when any pump exits.
102
+ def start_supervisor!
103
+ return if @barrier.empty?
104
+ @engine.barrier.async(transient: true, annotation: "zzq conn supervisor") do
105
+ @barrier.wait { |task| task.wait; break }
106
+ rescue Async::Stop, Async::Cancel
107
+ rescue *CONNECTION_LOST
108
+ ensure
109
+ lost!
110
+ end
111
+ end
112
+
113
+
114
+ private def tear_down!(reconnect: false)
115
+ return if @state == :closed
116
+ transition!(:closed)
117
+ if @conn
118
+ @engine.connections.delete(@conn)
119
+ @engine.routing.connection_removed(@conn) if @engine.routing.respond_to?(:connection_removed)
120
+ @conn.close rescue nil
121
+ @engine.emit_monitor_event(:disconnected, endpoint: @endpoint)
122
+ @engine.resolve_all_peers_gone_if_empty
123
+ end
124
+ @engine.maybe_reconnect(@endpoint) if reconnect
125
+ # Stop after cleanup: when tear_down! is called from a task on
126
+ # this barrier (e.g. the read-loop's ensure), stopping earlier
127
+ # would cancel the current fiber mid-cleanup.
128
+ @barrier.stop
129
+ end
130
+
131
+
132
+ private def transition!(new_state)
133
+ allowed = TRANSITIONS[@state]
134
+ raise InvalidTransition, "#{@state} → #{new_state}" unless allowed&.include?(new_state)
135
+ @state = new_state
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/clock"
5
+ require "protocol/mqtt"
6
+
7
+ module ZZQ
8
+ class Engine
9
+ # Keepalive driver for a single connection.
10
+ #
11
+ # +role+:
12
+ # * +:sender+ (client default) — sends PINGREQ if locally idle
13
+ # for keep_alive/2; closes if nothing received for 1.5 × keep_alive.
14
+ # * +:watcher+ (broker default) — does not send; just closes if
15
+ # nothing received for 1.5 × keep_alive. MQTT-3.1.2-22 is
16
+ # enforced by the broker.
17
+ #
18
+ # One fiber per connection, attached to the connection's barrier
19
+ # so it tears down with the connection.
20
+ class Keepalive
21
+ attr_accessor :last_sent_at, :last_received_at
22
+
23
+
24
+ def initialize(conn, keep_alive:, on_timeout:, role: :sender)
25
+ @conn = conn
26
+ @keep_alive = keep_alive
27
+ @on_timeout = on_timeout
28
+ @role = role
29
+ now = Async::Clock.now
30
+ @last_sent_at = now
31
+ @last_received_at = now
32
+ end
33
+
34
+
35
+ # Call this every time a packet is written, to reset the send-idle
36
+ # timer.
37
+ def note_sent
38
+ @last_sent_at = Async::Clock.now
39
+ end
40
+
41
+
42
+ # Call this every time a packet is read, to reset the recv-idle
43
+ # timer.
44
+ def note_received
45
+ @last_received_at = Async::Clock.now
46
+ end
47
+
48
+
49
+ def run
50
+ return if @keep_alive.zero? # 0 disables keepalive
51
+ loop do
52
+ sleep(@keep_alive / 2.0)
53
+ now = Async::Clock.now
54
+
55
+ if (now - @last_received_at) > (@keep_alive * 1.5)
56
+ @on_timeout.call
57
+ break
58
+ end
59
+
60
+ next unless @role == :sender
61
+
62
+ if (now - @last_sent_at) >= (@keep_alive / 2.0)
63
+ begin
64
+ @conn.write_packet(Protocol::MQTT::Packet::Pingreq.new)
65
+ note_sent
66
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
67
+ break
68
+ end
69
+ end
70
+ end
71
+ rescue Async::Stop
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async/barrier"
4
+ require "async/promise"
5
+
6
+ module ZZQ
7
+ class Engine
8
+ # Owns the socket-level state: `:new → :open → :closing → :closed`.
9
+ #
10
+ # Adapted from NNQ::Engine::SocketLifecycle, minus the shared-IO-
11
+ # thread fallback (ZZQ is pure-Async).
12
+ class SocketLifecycle
13
+ class InvalidTransition < RuntimeError; end
14
+
15
+ STATES = %i[new open closing closed].freeze
16
+
17
+ TRANSITIONS = {
18
+ new: %i[open closed].freeze,
19
+ open: %i[closing closed].freeze,
20
+ closing: %i[closed].freeze,
21
+ closed: [].freeze,
22
+ }.freeze
23
+
24
+
25
+ attr_reader :state
26
+ attr_reader :parent_task
27
+ attr_reader :peer_connected
28
+ attr_reader :all_peers_gone
29
+ attr_reader :barrier
30
+ attr_accessor :reconnect_enabled
31
+
32
+
33
+ def initialize
34
+ @state = :new
35
+ @parent_task = nil
36
+ @peer_connected = Async::Promise.new
37
+ @all_peers_gone = Async::Promise.new
38
+ @reconnect_enabled = true
39
+ @barrier = nil
40
+ end
41
+
42
+
43
+ def open? = @state == :open
44
+ def closing? = @state == :closing
45
+ def closed? = @state == :closed
46
+ def alive? = @state == :new || @state == :open
47
+
48
+
49
+ # Must be called from within an Async::Task. Raises
50
+ # NotInAsyncContext otherwise.
51
+ def capture_parent_task
52
+ return false if @parent_task
53
+ task = Async::Task.current? or raise NotInAsyncContext
54
+ @parent_task = task
55
+ @barrier = Async::Barrier.new(parent: @parent_task)
56
+ transition!(:open)
57
+ true
58
+ end
59
+
60
+
61
+ def start_closing! = transition!(:closing)
62
+ def finish_closing! = transition!(:closed)
63
+
64
+
65
+ def resolve_all_peers_gone_if_empty(connections)
66
+ return unless @peer_connected.resolved? && connections.empty?
67
+ return if @all_peers_gone.resolved?
68
+ @all_peers_gone.resolve(true)
69
+ end
70
+
71
+
72
+ private def transition!(new_state)
73
+ allowed = TRANSITIONS[@state]
74
+ raise InvalidTransition, "#{@state} → #{new_state}" unless allowed&.include?(new_state)
75
+ @state = new_state
76
+ end
77
+ end
78
+ end
79
+ end