omq-cli 0.2.0 → 0.3.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.
@@ -0,0 +1,470 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module CLI
5
+ # Parses and validates command-line arguments for the omq CLI.
6
+ #
7
+ class CliParser
8
+ EXAMPLES = <<~'TEXT'
9
+ ── Request / Reply ──────────────────────────────────────────
10
+
11
+ ┌─────┐ "hello" ┌─────┐
12
+ │ REQ │────────────→│ REP │
13
+ │ │←────────────│ │
14
+ └─────┘ "HELLO" └─────┘
15
+
16
+ # terminal 1: echo server
17
+ omq rep --bind tcp://:5555 --recv-eval '$F.map(&:upcase)'
18
+
19
+ # terminal 2: send a request
20
+ echo "hello" | omq req --connect tcp://localhost:5555
21
+
22
+ # or over IPC (unix socket, single machine)
23
+ omq rep --bind ipc:///tmp/echo.sock --echo &
24
+ echo "hello" | omq req --connect ipc:///tmp/echo.sock
25
+
26
+ ── Publish / Subscribe ──────────────────────────────────────
27
+
28
+ ┌─────┐ "weather.nyc 72F" ┌─────┐
29
+ │ PUB │────────────────────→│ SUB │ --subscribe "weather."
30
+ └─────┘ └─────┘
31
+
32
+ # terminal 1: subscriber (all topics by default)
33
+ omq sub --bind tcp://:5556
34
+
35
+ # terminal 2: publisher (needs --delay for subscription to propagate)
36
+ echo "weather.nyc 72F" | omq pub --connect tcp://localhost:5556 --delay 1
37
+
38
+ ── Periodic Publish ───────────────────────────────────────────
39
+
40
+ ┌─────┐ "tick 1" ┌─────┐
41
+ │ PUB │──(every 1s)─→│ SUB │
42
+ └─────┘ └─────┘
43
+
44
+ # terminal 1: subscriber
45
+ omq sub --bind tcp://:5556
46
+
47
+ # terminal 2: publish a tick every second (wall-clock aligned)
48
+ omq pub --connect tcp://localhost:5556 --delay 1 \
49
+ --data "tick" --interval 1
50
+
51
+ # 5 ticks, then exit
52
+ omq pub --connect tcp://localhost:5556 --delay 1 \
53
+ --data "tick" --interval 1 --count 5
54
+
55
+ ── Pipeline ─────────────────────────────────────────────────
56
+
57
+ ┌──────┐ ┌──────┐
58
+ │ PUSH │──────────→│ PULL │
59
+ └──────┘ └──────┘
60
+
61
+ # terminal 1: worker
62
+ omq pull --bind tcp://:5557
63
+
64
+ # terminal 2: send tasks
65
+ echo "task 1" | omq push --connect tcp://localhost:5557
66
+
67
+ # or over IPC (unix socket)
68
+ omq pull --bind ipc:///tmp/pipeline.sock &
69
+ echo "task 1" | omq push --connect ipc:///tmp/pipeline.sock
70
+
71
+ ── Pipe (PULL → eval → PUSH) ────────────────────────────────
72
+
73
+ ┌──────┐ ┌──────┐ ┌──────┐
74
+ │ PUSH │────────→│ pipe │────────→│ PULL │
75
+ └──────┘ └──────┘ └──────┘
76
+
77
+ # terminal 1: producer
78
+ echo -e "hello\nworld" | omq push --bind ipc://@work
79
+
80
+ # terminal 2: worker — uppercase each message
81
+ omq pipe -c ipc://@work -c ipc://@sink -e '$F.map(&:upcase)'
82
+ # terminal 3: collector
83
+ omq pull --bind ipc://@sink
84
+
85
+ # 4 Ractor workers in a single process (-P)
86
+ omq pipe -c ipc://@work -c ipc://@sink -P4 -r./fib -e 'fib(Integer($_)).to_s'
87
+
88
+ # exit when producer disconnects (--transient)
89
+ omq pipe -c ipc://@work -c ipc://@sink --transient -e '$F.map(&:upcase)'
90
+
91
+ # fan-in: multiple sources → one sink
92
+ omq pipe --in -c ipc://@work1 -c ipc://@work2 \
93
+ --out -c ipc://@sink -e '$F.map(&:upcase)'
94
+
95
+ # fan-out: one source → multiple sinks (round-robin)
96
+ omq pipe --in -b tcp://:5555 --out -c ipc://@sink1 -c ipc://@sink2 -e '$F'
97
+
98
+ ── CLIENT / SERVER (draft) ──────────────────────────────────
99
+
100
+ ┌────────┐ "hello" ┌────────┐
101
+ │ CLIENT │───────────→│ SERVER │ --recv-eval '$F.map(&:upcase)'
102
+ │ │←───────────│ │
103
+ └────────┘ "HELLO" └────────┘
104
+
105
+ # terminal 1: upcasing server
106
+ omq server --bind tcp://:5555 --recv-eval '$F.map(&:upcase)'
107
+
108
+ # terminal 2: client
109
+ echo "hello" | omq client --connect tcp://localhost:5555
110
+
111
+ ── Formats ──────────────────────────────────────────────────
112
+
113
+ # ascii (default) — non-printable replaced with dots
114
+ omq pull --bind tcp://:5557 --ascii
115
+
116
+ # quoted — lossless, round-trippable (uses String#dump escaping)
117
+ omq pull --bind tcp://:5557 --quoted
118
+
119
+ # JSON Lines — structured, multipart as arrays
120
+ echo '["key","value"]' | omq push --connect tcp://localhost:5557 --jsonl
121
+ omq pull --bind tcp://:5557 --jsonl
122
+
123
+ # multipart via tabs
124
+ printf "routing-key\tpayload" | omq push --connect tcp://localhost:5557
125
+
126
+ ── Compression ──────────────────────────────────────────────
127
+
128
+ # both sides must use --compress
129
+ omq pull --bind tcp://:5557 --compress &
130
+ echo "compressible data" | omq push --connect tcp://localhost:5557 --compress
131
+
132
+ ── CURVE Encryption ─────────────────────────────────────────
133
+
134
+ # server (prints OMQ_SERVER_KEY=...)
135
+ omq rep --bind tcp://:5555 --echo --curve-server
136
+
137
+ # client (paste the server's key)
138
+ echo "secret" | omq req --connect tcp://localhost:5555 \
139
+ --curve-server-key '<key from server>'
140
+
141
+ ── ROUTER / DEALER ──────────────────────────────────────────
142
+
143
+ ┌────────┐ ┌────────┐
144
+ │ DEALER │─────────→│ ROUTER │
145
+ │ id=w1 │ │ │
146
+ └────────┘ └────────┘
147
+
148
+ # terminal 1: router shows identity + message
149
+ omq router --bind tcp://:5555
150
+
151
+ # terminal 2: dealer with identity
152
+ echo "hello" | omq dealer --connect tcp://localhost:5555 --identity worker-1
153
+
154
+ ── Ruby Eval ────────────────────────────────────────────────
155
+
156
+ # filter incoming: only pass messages containing "error"
157
+ omq pull -b tcp://:5557 --recv-eval '$F.first.include?("error") ? $F : nil'
158
+
159
+ # transform incoming with gems
160
+ omq sub -c tcp://localhost:5556 -rjson -e 'JSON.parse($F.first)["temperature"]'
161
+
162
+ # require a local file, use its methods
163
+ omq rep --bind tcp://:5555 --require ./transform.rb -e 'upcase_all($F)'
164
+
165
+ # next skips, break stops — regexps match against $_
166
+ omq pull -b tcp://:5557 -e 'next if /^#/; break if /quit/; $F'
167
+
168
+ # BEGIN/END blocks (like awk) — accumulate and summarize
169
+ omq pull -b tcp://:5557 -e 'BEGIN{@sum = 0} @sum += Integer($_); nil END{puts @sum}'
170
+
171
+ # transform outgoing messages
172
+ echo hello | omq push -c tcp://localhost:5557 --send-eval '$F.map(&:upcase)'
173
+
174
+ # REQ: transform request and reply independently
175
+ echo hello | omq req -c tcp://localhost:5555 -E '$F.map(&:upcase)' -e '$_'
176
+
177
+ ── Script Handlers (-r) ────────────────────────────────────
178
+
179
+ # handler.rb — register transforms from a file
180
+ # db = PG.connect("dbname=app")
181
+ # OMQ.incoming { |first_part, _| db.exec(first_part).values.flatten }
182
+ # at_exit { db.close }
183
+ omq pull --bind tcp://:5557 -r./handler.rb
184
+
185
+ # combine script handlers with inline eval
186
+ omq req -c tcp://localhost:5555 -r./handler.rb -E '$F.map(&:upcase)'
187
+
188
+ # OMQ.outgoing { |msg| ... } — registered outgoing transform
189
+ # OMQ.incoming { |msg| ... } — registered incoming transform
190
+ # CLI flags (-e/-E) override registered handlers
191
+ TEXT
192
+
193
+
194
+ DEFAULT_OPTS = {
195
+ type_name: nil,
196
+ endpoints: [],
197
+ connects: [],
198
+ binds: [],
199
+ in_endpoints: [],
200
+ out_endpoints: [],
201
+ data: nil,
202
+ file: nil,
203
+ format: :ascii,
204
+ subscribes: [],
205
+ joins: [],
206
+ group: nil,
207
+ identity: nil,
208
+ target: nil,
209
+ interval: nil,
210
+ count: nil,
211
+ delay: nil,
212
+ timeout: nil,
213
+ linger: 5,
214
+ reconnect_ivl: nil,
215
+ heartbeat_ivl: nil,
216
+ conflate: false,
217
+ compress: false,
218
+ send_expr: nil,
219
+ recv_expr: nil,
220
+ parallel: nil,
221
+ transient: false,
222
+ verbose: false,
223
+ quiet: false,
224
+ echo: false,
225
+ scripts: [],
226
+ recv_maxsz: nil,
227
+ curve_server: false,
228
+ curve_server_key: nil,
229
+ curve_crypto: nil,
230
+ }.freeze
231
+
232
+
233
+ # Parses +argv+ and returns a mutable options hash.
234
+ #
235
+ def self.parse(argv)
236
+ new.parse(argv)
237
+ end
238
+
239
+
240
+ # Validates option combinations, aborting on bad combos.
241
+ #
242
+ def self.validate!(opts)
243
+ new.validate!(opts)
244
+ end
245
+
246
+
247
+ # Validates that required gems are available.
248
+ #
249
+ def self.validate_gems!(config)
250
+ abort "--msgpack requires the msgpack gem" if config.format == :msgpack && !config.has_msgpack
251
+ abort "--compress requires the zstd-ruby gem" if config.compress && !config.has_zstd
252
+
253
+ if config.recv_only? && (config.data || config.file)
254
+ abort "--data/--file not valid for #{config.type_name} (receive-only)"
255
+ end
256
+ end
257
+
258
+
259
+ # Parses +argv+ and returns a mutable options hash.
260
+ #
261
+ # @param argv [Array<String>] command-line arguments (mutated in place)
262
+ # @return [Hash] parsed options
263
+ def parse(argv)
264
+ opts = DEFAULT_OPTS.transform_values { |v| v.is_a?(Array) ? v.dup : v }
265
+ pipe_side = nil # nil = legacy positional mode; :in/:out = modal
266
+
267
+ parser = OptionParser.new do |o|
268
+ o.banner = "Usage: omq TYPE [options]\n\n" \
269
+ "Types: req, rep, pub, sub, push, pull, pair, dealer, router\n" \
270
+ "Draft: client, server, radio, dish, scatter, gather, channel, peer\n" \
271
+ "Virtual: pipe (PULL → eval → PUSH)\n\n"
272
+
273
+ o.separator "Connection:"
274
+ o.on("-c", "--connect URL", "Connect to endpoint (repeatable)") { |v|
275
+ ep = Endpoint.new(v, false)
276
+ case pipe_side
277
+ when :in
278
+ opts[:in_endpoints] << ep
279
+ when :out
280
+ opts[:out_endpoints] << ep
281
+ else
282
+ opts[:endpoints] << ep
283
+ opts[:connects] << v
284
+ end
285
+ }
286
+ o.on("-b", "--bind URL", "Bind to endpoint (repeatable)") { |v|
287
+ ep = Endpoint.new(v, true)
288
+ case pipe_side
289
+ when :in
290
+ opts[:in_endpoints] << ep
291
+ when :out
292
+ opts[:out_endpoints] << ep
293
+ else
294
+ opts[:endpoints] << ep
295
+ opts[:binds] << v
296
+ end
297
+ }
298
+ o.on("--in", "Pipe: subsequent -b/-c attach to input (PULL) side") { pipe_side = :in }
299
+ o.on("--out", "Pipe: subsequent -b/-c attach to output (PUSH) side") { pipe_side = :out }
300
+
301
+ o.separator "\nData source (REP: reply source):"
302
+ o.on( "--echo", "Echo received messages back (REP)") { opts[:echo] = true }
303
+ o.on("-D", "--data DATA", "Message data (literal string)") { |v| opts[:data] = v }
304
+ o.on("-F", "--file FILE", "Read message from file (- = stdin)") { |v| opts[:file] = v }
305
+
306
+ o.separator "\nFormat (input + output):"
307
+ o.on("-A", "--ascii", "Tab-separated frames, safe ASCII (default)") { opts[:format] = :ascii }
308
+ o.on("-Q", "--quoted", "C-style quoted with escapes") { opts[:format] = :quoted }
309
+ o.on( "--raw", "Raw binary, no framing") { opts[:format] = :raw }
310
+ o.on("-J", "--jsonl", "JSON Lines (array of strings per line)") { opts[:format] = :jsonl }
311
+ o.on( "--msgpack", "MessagePack arrays (binary stream)") { opts[:format] = :msgpack }
312
+ o.on("-M", "--marshal", "Ruby Marshal stream (binary, Array<String>)") { opts[:format] = :marshal }
313
+
314
+ o.separator "\nSubscription/groups:"
315
+ o.on("-s", "--subscribe PREFIX", "Subscribe prefix (SUB, default all)") { |v| opts[:subscribes] << v }
316
+ o.on("-j", "--join GROUP", "Join group (repeatable, DISH only)") { |v| opts[:joins] << v }
317
+ o.on("-g", "--group GROUP", "Publish group (RADIO only)") { |v| opts[:group] = v }
318
+
319
+ o.separator "\nIdentity/routing:"
320
+ o.on("--identity ID", "Set socket identity (DEALER/ROUTER)") { |v| opts[:identity] = v }
321
+ o.on("--target ID", "Target peer (ROUTER/SERVER/PEER, 0x prefix for binary)") { |v| opts[:target] = v }
322
+
323
+ o.separator "\nTiming:"
324
+ o.on("-i", "--interval SECS", Float, "Repeat interval") { |v| opts[:interval] = v }
325
+ o.on("-n", "--count COUNT", Integer, "Max iterations (0=inf)") { |v| opts[:count] = v }
326
+ o.on("-d", "--delay SECS", Float, "Delay before first send") { |v| opts[:delay] = v }
327
+ o.on("-t", "--timeout SECS", Float, "Send/receive timeout") { |v| opts[:timeout] = v }
328
+ o.on("-l", "--linger SECS", Float, "Drain time on close (default 5)") { |v| opts[:linger] = v }
329
+ o.on("--reconnect-ivl IVL", "Reconnect interval: SECS or MIN..MAX (default 0.1)") { |v|
330
+ opts[:reconnect_ivl] = if v.include?("..")
331
+ lo, hi = v.split("..", 2)
332
+ Float(lo)..Float(hi)
333
+ else
334
+ Float(v)
335
+ end
336
+ }
337
+ o.on("--heartbeat-ivl SECS", Float, "ZMTP heartbeat interval (detects dead peers)") { |v| opts[:heartbeat_ivl] = v }
338
+ o.on("--recv-maxsz COUNT", Integer, "Max inbound message size in bytes (larger messages dropped)") { |v| opts[:recv_maxsz] = v }
339
+
340
+ o.separator "\nDelivery:"
341
+ o.on("--conflate", "Keep only last message per subscriber (PUB/RADIO)") { opts[:conflate] = true }
342
+
343
+ o.separator "\nCompression:"
344
+ o.on("-z", "--compress", "Zstandard compression per frame") { opts[:compress] = true }
345
+
346
+ o.separator "\nProcessing (-e = incoming, -E = outgoing):"
347
+ o.on("-e", "--recv-eval EXPR", "Eval Ruby for each incoming message ($F = parts)") { |v| opts[:recv_expr] = v }
348
+ o.on("-E", "--send-eval EXPR", "Eval Ruby for each outgoing message ($F = parts)") { |v| opts[:send_expr] = v }
349
+ o.on("-r", "--require LIB", "Require lib/file in Async context; use '-' for stdin. Scripts can register OMQ.outgoing/incoming") { |v|
350
+ require "omq" unless defined?(OMQ::VERSION)
351
+ opts[:scripts] << (v == "-" ? :stdin : (v.start_with?("./", "../") ? File.expand_path(v) : v))
352
+ }
353
+ o.on("-P", "--parallel [N]", Integer, "Parallel Ractor workers (default: nproc); for non-pipe requires --recv-eval") { |v|
354
+ require "etc"
355
+ opts[:parallel] = v || Etc.nprocessors
356
+ }
357
+
358
+ o.separator "\nCURVE encryption (requires rbnacl or nuckle gem):"
359
+ o.on("--curve-server", "Enable CURVE as server (generates keypair)") { opts[:curve_server] = true }
360
+ o.on("--curve-server-key KEY", "Enable CURVE as client (server's Z85 public key)") { |v| opts[:curve_server_key] = v }
361
+ o.on("--curve-crypto BACKEND", "Crypto backend: rbnacl (default if installed) or nuckle") { |v| opts[:curve_crypto] = v }
362
+ o.separator " Env vars: OMQ_SERVER_KEY (client), OMQ_SERVER_PUBLIC + OMQ_SERVER_SECRET (server)"
363
+ o.separator " OMQ_CURVE_CRYPTO (backend: rbnacl or nuckle)"
364
+
365
+ o.separator "\nOther:"
366
+ o.on("-v", "--verbose", "Print connection events to stderr") { opts[:verbose] = true }
367
+ o.on("-q", "--quiet", "Suppress message output") { opts[:quiet] = true }
368
+ o.on( "--transient", "Exit when all peers disconnect") { opts[:transient] = true }
369
+ o.on("-V", "--version") {
370
+ if ENV["OMQ_DEV"]
371
+ require_relative "../../../../omq/lib/omq/version"
372
+ else
373
+ require "omq/version"
374
+ end
375
+ puts "omq-cli #{OMQ::CLI::VERSION} (omq #{OMQ::VERSION})"
376
+ exit
377
+ }
378
+ o.on("-h") { puts o
379
+ exit }
380
+ o.on("--help") { CLI.page "#{o}\n#{EXAMPLES}"
381
+ exit }
382
+ o.on("--examples") { CLI.page EXAMPLES
383
+ exit }
384
+
385
+ o.separator "\nExit codes: 0 = success, 1 = error, 2 = timeout"
386
+ end
387
+
388
+ begin
389
+ parser.parse!(argv)
390
+ rescue OptionParser::ParseError => e
391
+ abort e.message
392
+ end
393
+
394
+ type_name = argv.shift
395
+ if type_name.nil?
396
+ abort parser.to_s if opts[:scripts].empty?
397
+ # bare script mode — type_name stays nil
398
+ elsif !SOCKET_TYPE_NAMES.include?(type_name.downcase)
399
+ abort "Unknown socket type: #{type_name}. Known: #{SOCKET_TYPE_NAMES.join(', ')}"
400
+ else
401
+ opts[:type_name] = type_name.downcase
402
+ end
403
+
404
+ normalize = ->(url) { url.sub(%r{\Atcp://\*:}, "tcp://0.0.0.0:").sub(%r{\Atcp://:}, "tcp://localhost:") }
405
+ normalize_ep = ->(ep) { Endpoint.new(normalize.call(ep.url), ep.bind?) }
406
+ opts[:binds].map!(&normalize)
407
+ opts[:connects].map!(&normalize)
408
+ opts[:endpoints].map!(&normalize_ep)
409
+ opts[:in_endpoints].map!(&normalize_ep)
410
+ opts[:out_endpoints].map!(&normalize_ep)
411
+
412
+ opts
413
+ end
414
+
415
+
416
+ # Validates option combinations, aborting on invalid combos.
417
+ #
418
+ # @param opts [Hash] parsed options from {#parse}
419
+ # @return [void]
420
+ def validate!(opts)
421
+ return if opts[:type_name].nil? # bare script mode
422
+
423
+ abort "-r- (stdin script) and -F- (stdin data) cannot both be used" if opts[:scripts]&.include?(:stdin) && opts[:file] == "-"
424
+
425
+ type_name = opts[:type_name]
426
+
427
+ if type_name == "pipe"
428
+ has_in_out = opts[:in_endpoints].any? || opts[:out_endpoints].any?
429
+ if has_in_out
430
+ abort "pipe --in requires at least one endpoint" if opts[:in_endpoints].empty?
431
+ abort "pipe --out requires at least one endpoint" if opts[:out_endpoints].empty?
432
+ abort "pipe: don't mix --in/--out with bare -b/-c endpoints" unless opts[:endpoints].empty?
433
+ else
434
+ abort "pipe requires exactly 2 endpoints (pull-side and push-side), or use --in/--out" if opts[:endpoints].size != 2
435
+ end
436
+ else
437
+ abort "--in/--out are only valid for pipe" if opts[:in_endpoints].any? || opts[:out_endpoints].any?
438
+ abort "At least one --connect or --bind is required" if opts[:connects].empty? && opts[:binds].empty?
439
+ end
440
+ abort "--data and --file are mutually exclusive" if opts[:data] && opts[:file]
441
+ abort "--subscribe is only valid for SUB" if !opts[:subscribes].empty? && type_name != "sub"
442
+ abort "--join is only valid for DISH" if !opts[:joins].empty? && type_name != "dish"
443
+ abort "--group is only valid for RADIO" if opts[:group] && type_name != "radio"
444
+ abort "--identity is only valid for DEALER/ROUTER" if opts[:identity] && !%w[dealer router].include?(type_name)
445
+ abort "--target is only valid for ROUTER/SERVER/PEER" if opts[:target] && !%w[router server peer].include?(type_name)
446
+ abort "--conflate is only valid for PUB/RADIO" if opts[:conflate] && !%w[pub radio].include?(type_name)
447
+ abort "--recv-eval is not valid for send-only sockets (use --send-eval / -E)" if opts[:recv_expr] && SEND_ONLY.include?(type_name)
448
+ abort "--send-eval is not valid for recv-only sockets (use --recv-eval / -e)" if opts[:send_expr] && RECV_ONLY.include?(type_name)
449
+ abort "--send-eval and --target are mutually exclusive" if opts[:send_expr] && opts[:target]
450
+
451
+ if opts[:parallel]
452
+ abort "-P/--parallel must be >= 2" if opts[:parallel] < 2
453
+ if type_name == "pipe"
454
+ all_pipe_eps = opts[:in_endpoints] + opts[:out_endpoints] + opts[:endpoints]
455
+ abort "-P/--parallel requires all endpoints to use --connect (not --bind)" if all_pipe_eps.any?(&:bind?)
456
+ elsif RECV_ONLY.include?(type_name)
457
+ abort "-P/--parallel on #{type_name} requires --recv-eval (-e)" unless opts[:recv_expr]
458
+ abort "-P/--parallel requires all endpoints to use --connect (not --bind)" if opts[:binds].any?
459
+ else
460
+ abort "-P/--parallel is only valid for pipe or recv-only socket types (#{RECV_ONLY.join(', ')})"
461
+ end
462
+ end
463
+
464
+ (opts[:connects] + opts[:binds]).each do |url|
465
+ abort "inproc not supported, use tcp:// or ipc://" if url.include?("inproc://")
466
+ end
467
+ end
468
+ end
469
+ end
470
+ end
@@ -2,11 +2,10 @@
2
2
 
3
3
  module OMQ
4
4
  module CLI
5
- class ClientRunner < ReqRunner
6
- end
7
-
8
-
5
+ # Runner for SERVER and PEER sockets (draft; routing-id-based messaging).
9
6
  class ServerRunner < BaseRunner
7
+ include RoutingHelper
8
+
10
9
  private
11
10
 
12
11
 
@@ -27,28 +26,40 @@ module OMQ
27
26
  break if parts.nil?
28
27
  routing_id = parts.shift
29
28
  body = @fmt.decompress(parts)
30
-
31
- if config.recv_expr || @recv_eval_proc
32
- reply = eval_recv_expr(body)
33
- output([display_routing_id(routing_id), *(reply || [""])])
34
- @sock.send_to(routing_id, @fmt.compress(reply || [""]).first)
35
- elsif config.echo
36
- output([display_routing_id(routing_id), *body])
37
- @sock.send_to(routing_id, @fmt.compress(body).first || "")
38
- elsif config.data || config.file || !config.stdin_is_tty
39
- reply = read_next
40
- break unless reply
41
- output([display_routing_id(routing_id), *body])
42
- @sock.send_to(routing_id, @fmt.compress(reply).first || "")
43
- end
29
+ break unless handle_server_request(routing_id, body)
44
30
  i += 1
45
31
  break if n && n > 0 && i >= n
46
32
  end
47
33
  end
48
34
 
49
35
 
36
+ def handle_server_request(routing_id, body)
37
+ if config.recv_expr || @recv_eval_proc
38
+ reply = eval_recv_expr(body)
39
+ output([display_routing_id(routing_id), *(reply || [""])])
40
+ @sock.send_to(routing_id, @fmt.compress(reply || [""]).first)
41
+ elsif config.echo
42
+ output([display_routing_id(routing_id), *body])
43
+ @sock.send_to(routing_id, @fmt.compress(body).first || "")
44
+ elsif config.data || config.file || !config.stdin_is_tty
45
+ reply = read_next
46
+ return false unless reply
47
+ output([display_routing_id(routing_id), *body])
48
+ @sock.send_to(routing_id, @fmt.compress(reply).first || "")
49
+ end
50
+ true
51
+ end
52
+
53
+
50
54
  def monitor_loop(task)
51
- receiver = task.async do
55
+ receiver = recv_async(task)
56
+ sender = async_send_loop(task)
57
+ wait_for_loops(receiver, sender)
58
+ end
59
+
60
+
61
+ def recv_async(task)
62
+ task.async do
52
63
  n = config.count
53
64
  i = 0
54
65
  loop do
@@ -62,49 +73,11 @@ module OMQ
62
73
  break if n && n > 0 && i >= n
63
74
  end
64
75
  end
65
-
66
- sender = task.async do
67
- n = config.count
68
- i = 0
69
- sleep(config.delay) if config.delay
70
- if config.interval
71
- Async::Loop.quantized(interval: config.interval) do
72
- parts = read_next
73
- break unless parts
74
- send_targeted_or_eval(parts)
75
- i += 1
76
- break if n && n > 0 && i >= n
77
- end
78
- elsif config.data || config.file
79
- parts = read_next
80
- send_targeted_or_eval(parts) if parts
81
- else
82
- loop do
83
- parts = read_next
84
- break unless parts
85
- send_targeted_or_eval(parts)
86
- i += 1
87
- break if n && n > 0 && i >= n
88
- end
89
- end
90
- end
91
-
92
- wait_for_loops(receiver, sender)
93
76
  end
94
77
 
95
78
 
96
- def send_targeted_or_eval(parts)
97
- if @send_eval_proc
98
- parts = eval_send_expr(parts)
99
- return unless parts
100
- routing_id = resolve_target(parts.shift)
101
- @sock.send_to(routing_id, @fmt.compress(parts).first || "")
102
- elsif config.target
103
- parts = @fmt.compress(parts)
104
- @sock.send_to(resolve_target(config.target), parts.first || "")
105
- else
106
- send_msg(parts)
107
- end
79
+ def send_to_peer(id, parts)
80
+ @sock.send_to(id, @fmt.compress(parts).first || "")
108
81
  end
109
82
  end
110
83
  end
@@ -2,14 +2,20 @@
2
2
 
3
3
  module OMQ
4
4
  module CLI
5
+ # Socket type names that only send messages.
5
6
  SEND_ONLY = %w[pub push scatter radio].freeze
7
+ # Socket type names that only receive messages.
6
8
  RECV_ONLY = %w[sub pull gather dish].freeze
7
9
 
10
+
11
+ # A bind or connect endpoint with its URL and direction.
8
12
  Endpoint = Data.define(:url, :bind?) do
13
+ # @return [Boolean] true if this endpoint connects rather than binds
9
14
  def connect? = !bind?
10
15
  end
11
16
 
12
17
 
18
+ # Frozen, Ractor-shareable configuration data class for a CLI invocation.
13
19
  Config = Data.define(
14
20
  :type_name,
15
21
  :endpoints,
@@ -41,6 +47,8 @@ module OMQ
41
47
  :verbose,
42
48
  :quiet,
43
49
  :echo,
50
+ :scripts,
51
+ :recv_maxsz,
44
52
  :curve_server,
45
53
  :curve_server_key,
46
54
  :curve_crypto,
@@ -48,7 +56,9 @@ module OMQ
48
56
  :has_zstd,
49
57
  :stdin_is_tty,
50
58
  ) do
59
+ # @return [Boolean] true if this socket type only sends
51
60
  def send_only? = SEND_ONLY.include?(type_name)
61
+ # @return [Boolean] true if this socket type only receives
52
62
  def recv_only? = RECV_ONLY.include?(type_name)
53
63
  end
54
64
  end