zzq 0.1.1 → 0.2.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/LICENSE-APACHE +202 -0
  4. data/LICENSE-ISC +15 -0
  5. data/README.md +67 -122
  6. data/ext/zzq_native/Cargo.toml +35 -0
  7. data/ext/zzq_native/extconf.rb +8 -0
  8. data/ext/zzq_native/src/broker.rs +163 -0
  9. data/ext/zzq_native/src/client.rs +294 -0
  10. data/ext/zzq_native/src/context.rs +51 -0
  11. data/ext/zzq_native/src/error.rs +117 -0
  12. data/ext/zzq_native/src/lib.rs +52 -0
  13. data/ext/zzq_native/src/op.rs +140 -0
  14. data/ext/zzq_native/src/options.rs +168 -0
  15. data/ext/zzq_native/src/rb.rs +424 -0
  16. data/ext/zzq_native/src/wake.rs +155 -0
  17. data/lib/zzq/broker.rb +90 -21
  18. data/lib/zzq/client.rb +202 -35
  19. data/lib/zzq/errors.rb +37 -0
  20. data/lib/zzq/message.rb +16 -107
  21. data/lib/zzq/options.rb +27 -86
  22. data/lib/zzq/version.rb +2 -1
  23. data/lib/zzq/waiting.rb +65 -0
  24. data/lib/zzq.rb +22 -19
  25. metadata +33 -77
  26. data/LICENSE +0 -13
  27. data/lib/zzq/engine/connection_lifecycle.rb +0 -139
  28. data/lib/zzq/engine/keepalive.rb +0 -75
  29. data/lib/zzq/engine/socket_lifecycle.rb +0 -79
  30. data/lib/zzq/engine.rb +0 -169
  31. data/lib/zzq/error.rb +0 -42
  32. data/lib/zzq/monitor_event.rb +0 -14
  33. data/lib/zzq/persistence/interface.rb +0 -43
  34. data/lib/zzq/persistence/memory.rb +0 -37
  35. data/lib/zzq/persistence/pstore.rb +0 -66
  36. data/lib/zzq/routing/broker.rb +0 -380
  37. data/lib/zzq/routing/client.rb +0 -327
  38. data/lib/zzq/routing/packet_id_allocator.rb +0 -54
  39. data/lib/zzq/routing/qos_tracker.rb +0 -108
  40. data/lib/zzq/routing/retained_store.rb +0 -67
  41. data/lib/zzq/routing/topic_match.rb +0 -49
  42. data/lib/zzq/routing/topic_trie.rb +0 -232
  43. data/lib/zzq/socket.rb +0 -146
  44. data/lib/zzq/subscription.rb +0 -70
  45. data/lib/zzq/transport/ipc.rb +0 -92
  46. data/lib/zzq/transport/mqtt.rb +0 -83
  47. data/lib/zzq/transport/mqtts.rb +0 -173
@@ -1,327 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "async"
4
- require "async/promise"
5
- require "protocol/mqtt"
6
- require_relative "../engine/keepalive"
7
- require_relative "../message"
8
- require_relative "../subscription"
9
- require_relative "packet_id_allocator"
10
- require_relative "qos_tracker"
11
- require_relative "topic_match"
12
-
13
- module ZZQ
14
- module Routing
15
- # Client-side routing.
16
- #
17
- # Responsibilities:
18
- # * CONNECT / CONNACK handshake on +handle_connected+.
19
- # * Per-connection read loop dispatching PINGRESP, DISCONNECT,
20
- # PUBLISH (fan-out to matching subscriptions), SUBACK/UNSUBACK
21
- # (resolve pending acks), and PUBACK/PUBREC/PUBREL/PUBCOMP
22
- # (drive outbound QoS 1/2 state machines).
23
- # * Keepalive fiber.
24
- # * QoS 0/1/2 publish and subscribe.
25
- class Client
26
- HANDSHAKE_TIMEOUT = 10 # seconds to wait for CONNACK
27
- SUBSCRIBE_TIMEOUT = 30 # seconds to wait for SUBACK / UNSUBACK
28
- PUBLISH_TIMEOUT = 30 # seconds to wait for terminal ack on qos>0
29
-
30
-
31
- # @return [Protocol::MQTT::Packet::Connack, nil] the most recent
32
- # CONNACK received, or nil before the first connect.
33
- attr_reader :last_connack
34
-
35
-
36
- def initialize(engine)
37
- @engine = engine
38
- @conn = nil
39
- @keepalive = nil
40
- @last_connack = nil
41
- @subscriptions = [] # ZZQ::Subscription
42
- @packet_ids = PacketIdAllocator.new(
43
- receive_maximum: @engine.options.receive_maximum,
44
- )
45
- @qos_tracker = QosTracker.new
46
- @pending_ctl = {} # packet_id => Async::Promise (SUBACK / UNSUBACK)
47
- @inbound_qos2 = {} # packet_id => Message awaiting PUBREL (manual-ack path; M9)
48
- end
49
-
50
-
51
- def session_present?
52
- @last_connack&.session_present || false
53
- end
54
-
55
-
56
- # Called by Engine#handle_connected. Runs inside the caller's
57
- # Async task (Socket#connect → Engine.connect → Transport.connect
58
- # → Engine.handle_connected), so we can synchronously drive the
59
- # CONNECT/CONNACK exchange and raise to the caller on refusal.
60
- def handle_connected(mqtt, lifecycle)
61
- opts = @engine.options
62
- mqtt.version = opts.version
63
-
64
- connect_packet = build_connect(opts)
65
-
66
- Async::Task.current.with_timeout(HANDSHAKE_TIMEOUT) do
67
- mqtt.write_packet(connect_packet)
68
- reply = mqtt.read_packet
69
- raise ConnectionRefused.new(0, "no CONNACK from broker") if reply.nil?
70
- raise ConnectionRefused.new(0, "expected CONNACK, got #{reply.class.name}") unless reply.is_a?(Protocol::MQTT::Packet::Connack)
71
- unless success_connack?(reply)
72
- raise ConnectionRefused.new(reply.reason_code)
73
- end
74
- @last_connack = reply
75
- end
76
-
77
- @conn = mqtt
78
- lifecycle.handshake_succeeded!
79
-
80
- start_read_loop(mqtt, lifecycle)
81
- start_keepalive(mqtt, lifecycle)
82
- end
83
-
84
-
85
- def connection_added(_conn); end
86
-
87
-
88
- def connection_removed(conn)
89
- return unless @conn.equal?(conn)
90
- @conn = nil
91
- @subscriptions.each(&:close)
92
- @subscriptions.clear
93
- @pending_ctl.each_value { |p| p.resolve(nil) unless p.resolved? }
94
- @pending_ctl.clear
95
- # Wake any fiber blocked on a publish ack. Don't clear the
96
- # tracker yet — M6 retransmit-on-reconnect will want to inspect
97
- # +inflight_outbound+. For now, just unblock with nil.
98
- @qos_tracker.inflight_outbound.each do |pending|
99
- pending.promise.resolve(nil) unless pending.promise.resolved?
100
- end
101
- end
102
-
103
-
104
- def close
105
- return unless @conn
106
- begin
107
- @conn.write_packet(Protocol::MQTT::Packet::Disconnect.new)
108
- rescue IOError, Errno::EPIPE, Errno::ECONNRESET, ClosedError
109
- # peer already gone; fine
110
- end
111
- @conn = nil
112
- end
113
-
114
-
115
- # Send a PUBLISH. For QoS 0 returns immediately after write. For
116
- # QoS 1 blocks the fiber until PUBACK. For QoS 2 blocks until
117
- # PUBCOMP.
118
- def publish(topic:, payload:, qos: 0, retain: false, properties: {})
119
- raise ClosedError, "client not connected" unless @conn
120
- if qos.zero?
121
- pkt = Protocol::MQTT::Packet::Publish.new(
122
- topic: topic, payload: payload, qos: 0, retain: retain,
123
- properties: properties,
124
- )
125
- @conn.write_packet(pkt)
126
- @keepalive&.note_sent
127
- return nil
128
- end
129
-
130
- packet_id = @packet_ids.acquire
131
- pkt = Protocol::MQTT::Packet::Publish.new(
132
- topic: topic, payload: payload, qos: qos, retain: retain,
133
- packet_id: packet_id, properties: properties,
134
- )
135
- pending = qos == 1 \
136
- ? @qos_tracker.track_qos1(packet_id, pkt) \
137
- : @qos_tracker.track_qos2(packet_id, pkt)
138
- begin
139
- @conn.write_packet(pkt)
140
- @keepalive&.note_sent
141
- result = Async::Task.current.with_timeout(PUBLISH_TIMEOUT) { pending.promise.wait }
142
- raise ClosedError, "connection lost before publish ack" if result.nil?
143
- ensure
144
- @packet_ids.release(packet_id)
145
- end
146
- nil
147
- end
148
-
149
-
150
- # Send a SUBSCRIBE, wait for SUBACK, return a Subscription.
151
- def subscribe(filter, qos: 0, no_local: false, retain_as_published: false,
152
- retain_handling: 0)
153
- raise ClosedError, "client not connected" unless @conn
154
- packet_id = @packet_ids.acquire
155
- filter_entry = {
156
- filter: filter, qos: qos, no_local: no_local,
157
- retain_as_published: retain_as_published,
158
- retain_handling: retain_handling,
159
- }
160
- subscription = Subscription.new(
161
- filters: [filter],
162
- hwm: @engine.options.recv_hwm,
163
- on_close: ->(sub) { send_unsubscribe(sub) },
164
- )
165
- @subscriptions << subscription
166
-
167
- promise = Async::Promise.new
168
- @pending_ctl[packet_id] = promise
169
- begin
170
- @conn.write_packet(Protocol::MQTT::Packet::Subscribe.new(
171
- packet_id: packet_id, filters: [filter_entry],
172
- ))
173
- @keepalive&.note_sent
174
-
175
- suback = Async::Task.current.with_timeout(SUBSCRIBE_TIMEOUT) { promise.wait }
176
- raise ClosedError, "connection lost before SUBACK" if suback.nil?
177
- reason = suback.reason_codes.first
178
- if reason >= 0x80
179
- @subscriptions.delete(subscription)
180
- raise Error, "SUBSCRIBE refused: reason=0x#{reason.to_s(16)}"
181
- end
182
- ensure
183
- @packet_ids.release(packet_id)
184
- end
185
- subscription
186
- end
187
-
188
-
189
- private
190
-
191
-
192
- def build_connect(opts)
193
- Protocol::MQTT::Packet::Connect.new(
194
- client_id: opts.client_id.to_s,
195
- clean_start: opts.clean_start,
196
- keep_alive: opts.keep_alive,
197
- username: opts.username,
198
- password: opts.password,
199
- will: opts.will,
200
- properties: build_connect_properties(opts),
201
- )
202
- end
203
-
204
-
205
- def build_connect_properties(opts)
206
- return {} unless opts.version == 5
207
- props = {}
208
- props[:session_expiry_interval] = opts.session_expiry_interval if opts.session_expiry_interval > 0
209
- props[:receive_maximum] = opts.receive_maximum if opts.receive_maximum != 65_535
210
- props[:maximum_packet_size] = opts.max_packet_size if opts.max_packet_size
211
- props[:topic_alias_maximum] = opts.topic_alias_maximum if opts.topic_alias_maximum > 0
212
- props
213
- end
214
-
215
-
216
- def success_connack?(ack)
217
- # v3 accepted = 0x00; v5 success = 0x00. Both map to 0.
218
- ack.reason_code.zero?
219
- end
220
-
221
-
222
- def send_unsubscribe(subscription)
223
- return unless @conn
224
- packet_id = @packet_ids.acquire
225
- promise = Async::Promise.new
226
- @pending_ctl[packet_id] = promise
227
- begin
228
- @conn.write_packet(Protocol::MQTT::Packet::Unsubscribe.new(
229
- packet_id: packet_id, filters: subscription.filters.dup,
230
- ))
231
- @keepalive&.note_sent
232
- rescue *CONNECTION_LOST, ClosedError
233
- @pending_ctl.delete(packet_id)
234
- ensure
235
- @packet_ids.release(packet_id)
236
- end
237
- @subscriptions.delete(subscription)
238
- end
239
-
240
-
241
- def start_read_loop(mqtt, lifecycle)
242
- lifecycle.barrier.async(annotation: "zzq client read #{lifecycle.endpoint}") do
243
- loop do
244
- pkt = mqtt.read_packet
245
- break if pkt.nil? # clean EOF
246
- @keepalive&.note_received
247
- dispatch(pkt, mqtt)
248
- rescue *CONNECTION_LOST, Protocol::MQTT::MalformedPacket
249
- break
250
- end
251
- ensure
252
- lifecycle.lost! unless lifecycle.state == :closed
253
- end
254
- end
255
-
256
-
257
- def start_keepalive(mqtt, lifecycle)
258
- ka = @engine.options.keep_alive
259
- return if ka.zero?
260
- @keepalive = Engine::Keepalive.new(
261
- mqtt, keep_alive: ka,
262
- on_timeout: -> { lifecycle.lost! },
263
- )
264
- lifecycle.barrier.async(annotation: "zzq client keepalive") do
265
- @keepalive.run
266
- end
267
- end
268
-
269
-
270
- def dispatch(pkt, mqtt)
271
- case pkt
272
- when Protocol::MQTT::Packet::Pingresp
273
- # liveness refreshed via @keepalive.note_received above
274
- when Protocol::MQTT::Packet::Disconnect
275
- raise EOFError # cleanly unwind the read loop
276
- when Protocol::MQTT::Packet::Publish
277
- handle_inbound_publish(pkt, mqtt)
278
- when Protocol::MQTT::Packet::Suback, Protocol::MQTT::Packet::Unsuback
279
- promise = @pending_ctl.delete(pkt.packet_id)
280
- promise&.resolve(pkt)
281
- when Protocol::MQTT::Packet::Puback
282
- @qos_tracker.on_puback(pkt.packet_id)
283
- when Protocol::MQTT::Packet::Pubrec
284
- entry = @qos_tracker.on_pubrec(pkt.packet_id)
285
- if entry
286
- mqtt.write_packet(Protocol::MQTT::Packet::Pubrel.new(packet_id: pkt.packet_id))
287
- @keepalive&.note_sent
288
- end
289
- when Protocol::MQTT::Packet::Pubrel
290
- # Inbound half of QoS 2 (broker → client). Release the held
291
- # dedup entry and acknowledge with PUBCOMP.
292
- @qos_tracker.on_pubrel(pkt.packet_id)
293
- mqtt.write_packet(Protocol::MQTT::Packet::Pubcomp.new(packet_id: pkt.packet_id))
294
- @keepalive&.note_sent
295
- when Protocol::MQTT::Packet::Pubcomp
296
- @qos_tracker.on_pubcomp(pkt.packet_id)
297
- end
298
- end
299
-
300
-
301
- def handle_inbound_publish(pkt, mqtt)
302
- case pkt.qos
303
- when 0
304
- fan_out_publish(pkt)
305
- when 1
306
- fan_out_publish(pkt)
307
- mqtt.write_packet(Protocol::MQTT::Packet::Puback.new(packet_id: pkt.packet_id))
308
- @keepalive&.note_sent
309
- when 2
310
- # Only fan out the first receipt; duplicates (same packet id
311
- # before PUBREL) are dropped but still PUBRECed.
312
- fan_out_publish(pkt) if @qos_tracker.on_qos2_publish(pkt.packet_id)
313
- mqtt.write_packet(Protocol::MQTT::Packet::Pubrec.new(packet_id: pkt.packet_id))
314
- @keepalive&.note_sent
315
- end
316
- end
317
-
318
-
319
- def fan_out_publish(pkt)
320
- msg = Message.from_publish(pkt)
321
- @subscriptions.each do |sub|
322
- sub.deliver(msg) if sub.filters.any? { |f| TopicMatch.match?(f, pkt.topic) }
323
- end
324
- end
325
- end
326
- end
327
- end
@@ -1,54 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "async/condition"
4
-
5
- module ZZQ
6
- module Routing
7
- # u16 packet-id allocator with an optional receive-maximum cap.
8
- # Thread-unsafe (designed to be owned by a single connection's
9
- # writer fiber). Packet id 0 is reserved (MQTT-2.2.1-3); we wrap
10
- # 1..65535.
11
- #
12
- # When the outstanding count reaches +receive_maximum+, +#acquire+
13
- # blocks the caller fiber until a later +#release+ frees a slot.
14
- class PacketIdAllocator
15
- def initialize(receive_maximum: 65_535)
16
- @receive_maximum = receive_maximum
17
- @outstanding = {} # packet_id => true
18
- @next = 0
19
- @free_cond = Async::Condition.new
20
- end
21
-
22
-
23
- # Acquire a fresh packet id. Blocks the calling fiber if the
24
- # receive-maximum quota is exhausted.
25
- def acquire
26
- while @outstanding.size >= @receive_maximum
27
- @free_cond.wait
28
- end
29
- id = loop do
30
- @next = (@next % 0xFFFF) + 1
31
- break @next unless @outstanding.key?(@next)
32
- end
33
- @outstanding[id] = true
34
- id
35
- end
36
-
37
-
38
- # Mark +id+ free. Wakes one waiter if any.
39
- def release(id)
40
- return false unless @outstanding.delete(id)
41
- @free_cond.signal
42
- true
43
- end
44
-
45
-
46
- def outstanding?(id)
47
- @outstanding.key?(id)
48
- end
49
-
50
-
51
- def size = @outstanding.size
52
- end
53
- end
54
- end
@@ -1,108 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "async/promise"
4
-
5
- module ZZQ
6
- module Routing
7
- # Outbound inflight tracking + inbound QoS 2 dedup for one
8
- # session.
9
- #
10
- # Each side of a QoS 1/2 exchange owns one tracker. The publisher
11
- # side records a {PendingPublish} per outbound packet id, awaiting
12
- # a PUBACK (QoS 1) or completing via PUBREC/PUBREL/PUBCOMP
13
- # (QoS 2). The subscriber side remembers which QoS 2 packet ids
14
- # have been received-but-not-yet-released so duplicate PUBLISH
15
- # packets with the same id are dropped.
16
- class QosTracker
17
- # One outbound publication awaiting ack.
18
- #
19
- # +stage+ is one of:
20
- # * :waiting_puback — QoS 1, awaiting PUBACK
21
- # * :waiting_pubrec — QoS 2, awaiting PUBREC
22
- # * :waiting_pubcomp — QoS 2, PUBREC received, PUBREL sent
23
- PendingPublish = Struct.new(:packet_id, :packet, :stage, :promise, keyword_init: true)
24
-
25
-
26
- def initialize
27
- @outbound = {} # packet_id => PendingPublish
28
- @inbound_qos2 = {} # packet_id => true (received, awaiting PUBREL)
29
- end
30
-
31
-
32
- # Register an outbound QoS 1 publish. Returns the PendingPublish.
33
- def track_qos1(packet_id, packet)
34
- promise = Async::Promise.new
35
- @outbound[packet_id] = PendingPublish.new(
36
- packet_id: packet_id, packet: packet, stage: :waiting_puback,
37
- promise: promise,
38
- )
39
- end
40
-
41
-
42
- # Register an outbound QoS 2 publish (awaiting PUBREC).
43
- def track_qos2(packet_id, packet)
44
- promise = Async::Promise.new
45
- @outbound[packet_id] = PendingPublish.new(
46
- packet_id: packet_id, packet: packet, stage: :waiting_pubrec,
47
- promise: promise,
48
- )
49
- end
50
-
51
-
52
- # Called on inbound PUBACK. Returns the PendingPublish that
53
- # completed, or nil if the id was unknown / stage mismatched.
54
- def on_puback(packet_id)
55
- pending = @outbound[packet_id]
56
- return nil unless pending && pending.stage == :waiting_puback
57
- @outbound.delete(packet_id)
58
- pending.promise.resolve(:acked) unless pending.promise.resolved?
59
- pending
60
- end
61
-
62
-
63
- # Called on inbound PUBREC. Transitions the entry to
64
- # +:waiting_pubcomp+ and returns it so the caller can send
65
- # PUBREL. Returns nil if unknown / stage mismatched.
66
- def on_pubrec(packet_id)
67
- pending = @outbound[packet_id]
68
- return nil unless pending && pending.stage == :waiting_pubrec
69
- pending.stage = :waiting_pubcomp
70
- pending
71
- end
72
-
73
-
74
- # Called on inbound PUBCOMP. Completes a QoS 2 exchange.
75
- def on_pubcomp(packet_id)
76
- pending = @outbound[packet_id]
77
- return nil unless pending && pending.stage == :waiting_pubcomp
78
- @outbound.delete(packet_id)
79
- pending.promise.resolve(:acked) unless pending.promise.resolved?
80
- pending
81
- end
82
-
83
-
84
- # Inbound QoS 2 PUBLISH dedup. Returns true if this id is a new
85
- # (non-duplicate) reception; false if it's a duplicate.
86
- def on_qos2_publish(packet_id)
87
- return false if @inbound_qos2.key?(packet_id)
88
- @inbound_qos2[packet_id] = true
89
- true
90
- end
91
-
92
-
93
- # Inbound PUBREL: peer tells us to release the held qos2 id.
94
- def on_pubrel(packet_id)
95
- @inbound_qos2.delete(packet_id)
96
- end
97
-
98
-
99
- # Snapshot of still-pending outbound publications, used on
100
- # reconnect for retransmission.
101
- def inflight_outbound = @outbound.values
102
-
103
-
104
- def outbound_size = @outbound.size
105
- def inbound_size = @inbound_qos2.size
106
- end
107
- end
108
- end
@@ -1,67 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "topic_match"
4
-
5
- module ZZQ
6
- module Routing
7
- # Exact-topic map of retained application messages.
8
- #
9
- # MQTT spec: the last PUBLISH with RETAIN=1 for a given topic is
10
- # stored. A zero-length payload + RETAIN=1 deletes the retained
11
- # message for that topic (MQTT-3.3.1-6).
12
- #
13
- # On SUBSCRIBE, the broker walks this store and delivers every
14
- # retained message whose topic matches the new filter.
15
- class RetainedStore
16
- # +persistence+ is any {ZZQ::Persistence::Interface} subclass.
17
- # Default is in-memory (no durability). On construction the
18
- # in-process cache is warmed from the backend's load_retained.
19
- def initialize(persistence: nil)
20
- @store = {} # topic => ZZQ::Message
21
- @persistence = persistence
22
- @persistence&.load_retained { |msg| @store[msg.topic] = msg }
23
- end
24
-
25
-
26
- # Upsert or delete per RETAIN semantics. Returns the stored
27
- # message (nil if the empty-payload rule deleted it).
28
- def store(message)
29
- if message.payload.empty?
30
- @store.delete(message.topic)
31
- @persistence&.delete_retained(message.topic)
32
- nil
33
- else
34
- @store[message.topic] = message
35
- @persistence&.save_retained(message)
36
- message
37
- end
38
- end
39
-
40
-
41
- def delete(topic)
42
- @persistence&.delete_retained(topic)
43
- @store.delete(topic)
44
- end
45
-
46
-
47
- # Enumerate retained messages matching +filter+.
48
- def each_matching(filter)
49
- return enum_for(:each_matching, filter) unless block_given?
50
- @store.each_value do |msg|
51
- yield msg if TopicMatch.match?(filter, msg.topic)
52
- end
53
- end
54
-
55
-
56
- def size = @store.size
57
- def empty? = @store.empty?
58
-
59
-
60
- # Test helper: wipe everything.
61
- def clear
62
- @persistence&.clear_retained
63
- @store.clear
64
- end
65
- end
66
- end
67
- end
@@ -1,49 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ZZQ
4
- module Routing
5
- # Tiny topic-filter matcher (MQTT §4.7). `+` matches exactly one
6
- # topic level; `#` must be the last segment and matches zero or
7
- # more remaining levels. Shared-subscription prefixes
8
- # (`$share/<group>/<filter>`) are stripped before matching.
9
- #
10
- # Superseded by a proper trie in M7; this linear matcher is fine
11
- # for MVP broker traffic.
12
- module TopicMatch
13
- module_function
14
-
15
-
16
- # @param filter [String] topic filter with `+` / `#` wildcards.
17
- # @param topic [String] concrete topic name.
18
- # @return [Boolean]
19
- def match?(filter, topic)
20
- filter = strip_share(filter)
21
- return false if topic.start_with?("$") && filter.start_with?("+", "#")
22
-
23
- f_parts = filter.split("/", -1)
24
- t_parts = topic.split("/", -1)
25
-
26
- i = 0
27
- while i < f_parts.length
28
- f = f_parts[i]
29
- if f == "#"
30
- # Must be last; matches zero or more remaining levels.
31
- return i == f_parts.length - 1
32
- end
33
- return false if i >= t_parts.length
34
- return false unless f == "+" || f == t_parts[i]
35
- i += 1
36
- end
37
- i == t_parts.length
38
- end
39
-
40
-
41
- def strip_share(filter)
42
- return filter unless filter.start_with?("$share/")
43
- # $share/<group>/<filter> — drop first two segments.
44
- parts = filter.split("/", 3)
45
- parts.length == 3 ? parts[2] : filter
46
- end
47
- end
48
- end
49
- end