nnq-cli 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +78 -0
- data/LICENSE +15 -0
- data/README.md +391 -0
- data/exe/nnq +10 -0
- data/lib/nnq/cli/base_runner.rb +448 -0
- data/lib/nnq/cli/bus.rb +33 -0
- data/lib/nnq/cli/cli_parser.rb +485 -0
- data/lib/nnq/cli/config.rb +59 -0
- data/lib/nnq/cli/expression_evaluator.rb +142 -0
- data/lib/nnq/cli/formatter.rb +140 -0
- data/lib/nnq/cli/pair.rb +33 -0
- data/lib/nnq/cli/pipe.rb +206 -0
- data/lib/nnq/cli/pipe_worker.rb +138 -0
- data/lib/nnq/cli/pub_sub.rb +16 -0
- data/lib/nnq/cli/push_pull.rb +19 -0
- data/lib/nnq/cli/ractor_helpers.rb +81 -0
- data/lib/nnq/cli/req_rep.rb +105 -0
- data/lib/nnq/cli/socket_setup.rb +93 -0
- data/lib/nnq/cli/surveyor_respondent.rb +112 -0
- data/lib/nnq/cli/term.rb +86 -0
- data/lib/nnq/cli/transient_monitor.rb +41 -0
- data/lib/nnq/cli/version.rb +7 -0
- data/lib/nnq/cli.rb +190 -0
- metadata +110 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module NNQ
|
|
6
|
+
module CLI
|
|
7
|
+
# Parses and validates command-line arguments for the nnq CLI.
|
|
8
|
+
#
|
|
9
|
+
class CliParser
|
|
10
|
+
EXAMPLES = <<~'TEXT'
|
|
11
|
+
-- Request / Reply ------------------------------------------
|
|
12
|
+
|
|
13
|
+
+-----+ "hello" +-----+
|
|
14
|
+
| REQ |------------->| REP |
|
|
15
|
+
| |<-------------| |
|
|
16
|
+
+-----+ "HELLO" +-----+
|
|
17
|
+
|
|
18
|
+
# terminal 1: echo server
|
|
19
|
+
nnq rep --bind tcp://:5555 --recv-eval '$_.upcase'
|
|
20
|
+
|
|
21
|
+
# terminal 2: send a request
|
|
22
|
+
echo "hello" | nnq req --connect tcp://localhost:5555
|
|
23
|
+
|
|
24
|
+
# or over IPC (unix socket, single machine)
|
|
25
|
+
nnq rep --bind ipc:///tmp/echo.sock --echo &
|
|
26
|
+
echo "hello" | nnq req --connect ipc:///tmp/echo.sock
|
|
27
|
+
|
|
28
|
+
-- Publish / Subscribe --------------------------------------
|
|
29
|
+
|
|
30
|
+
+-----+ "weather.nyc 72F" +-----+
|
|
31
|
+
| PUB |--------------------->| SUB | --subscribe "weather."
|
|
32
|
+
+-----+ +-----+
|
|
33
|
+
|
|
34
|
+
# terminal 1: subscriber (all topics by default)
|
|
35
|
+
nnq sub --bind tcp://:5556
|
|
36
|
+
|
|
37
|
+
# terminal 2: publisher (needs --delay for subscription to propagate)
|
|
38
|
+
echo "weather.nyc 72F" | nnq pub --connect tcp://localhost:5556 --delay 1
|
|
39
|
+
|
|
40
|
+
-- Periodic Publish -------------------------------------------
|
|
41
|
+
|
|
42
|
+
+-----+ "tick 1" +-----+
|
|
43
|
+
| PUB |--(every 1s)-->| SUB |
|
|
44
|
+
+-----+ +-----+
|
|
45
|
+
|
|
46
|
+
# terminal 1: subscriber
|
|
47
|
+
nnq sub --bind tcp://:5556
|
|
48
|
+
|
|
49
|
+
# terminal 2: publish a tick every second (wall-clock aligned)
|
|
50
|
+
nnq pub --connect tcp://localhost:5556 --delay 1 \
|
|
51
|
+
--data "tick" --interval 1
|
|
52
|
+
|
|
53
|
+
# 5 ticks, then exit
|
|
54
|
+
nnq pub --connect tcp://localhost:5556 --delay 1 \
|
|
55
|
+
--data "tick" --interval 1 --count 5
|
|
56
|
+
|
|
57
|
+
-- Pipeline -------------------------------------------------
|
|
58
|
+
|
|
59
|
+
+------+ +------+
|
|
60
|
+
| PUSH |----------->| PULL |
|
|
61
|
+
+------+ +------+
|
|
62
|
+
|
|
63
|
+
# terminal 1: worker
|
|
64
|
+
nnq pull --bind tcp://:5557
|
|
65
|
+
|
|
66
|
+
# terminal 2: send tasks
|
|
67
|
+
echo "task 1" | nnq push --connect tcp://localhost:5557
|
|
68
|
+
|
|
69
|
+
# or over IPC (unix socket)
|
|
70
|
+
nnq pull --bind ipc:///tmp/pipeline.sock &
|
|
71
|
+
echo "task 1" | nnq push --connect ipc:///tmp/pipeline.sock
|
|
72
|
+
|
|
73
|
+
-- Pipe (PULL -> eval -> PUSH) --------------------------------
|
|
74
|
+
|
|
75
|
+
+------+ +------+ +------+
|
|
76
|
+
| PUSH |--------->| pipe |--------->| PULL |
|
|
77
|
+
+------+ +------+ +------+
|
|
78
|
+
|
|
79
|
+
# terminal 1: producer
|
|
80
|
+
echo -e "hello\nworld" | nnq push --bind ipc://@work
|
|
81
|
+
|
|
82
|
+
# terminal 2: worker -- uppercase each message
|
|
83
|
+
nnq pipe -c ipc://@work -c ipc://@sink -e '$_.upcase'
|
|
84
|
+
# terminal 3: collector
|
|
85
|
+
nnq pull --bind ipc://@sink
|
|
86
|
+
|
|
87
|
+
# 4 Ractor workers in a single process (-P)
|
|
88
|
+
nnq pipe -c ipc://@work -c ipc://@sink -P4 -r./fib -e 'fib(Integer($_)).to_s'
|
|
89
|
+
|
|
90
|
+
# exit when producer disconnects (--transient)
|
|
91
|
+
nnq pipe -c ipc://@work -c ipc://@sink --transient -e '$_.upcase'
|
|
92
|
+
|
|
93
|
+
# fan-in: multiple sources -> one sink
|
|
94
|
+
nnq pipe --in -c ipc://@work1 -c ipc://@work2 \
|
|
95
|
+
--out -c ipc://@sink -e '$_.upcase'
|
|
96
|
+
|
|
97
|
+
# fan-out: one source -> multiple sinks (round-robin)
|
|
98
|
+
nnq pipe --in -b tcp://:5555 --out -c ipc://@sink1 -c ipc://@sink2 -e '$_'
|
|
99
|
+
|
|
100
|
+
-- Formats --------------------------------------------------
|
|
101
|
+
|
|
102
|
+
# ascii (default) -- non-printable replaced with dots
|
|
103
|
+
nnq pull --bind tcp://:5557 --ascii
|
|
104
|
+
|
|
105
|
+
# quoted -- lossless, round-trippable (uses String#dump escaping)
|
|
106
|
+
nnq pull --bind tcp://:5557 --quoted
|
|
107
|
+
|
|
108
|
+
# JSON Lines -- single-element arrays (nnq is single-body)
|
|
109
|
+
echo '["payload"]' | nnq push --connect tcp://localhost:5557 --jsonl
|
|
110
|
+
nnq pull --bind tcp://:5557 --jsonl
|
|
111
|
+
|
|
112
|
+
# raw -- emit the body verbatim (no framing, no newline)
|
|
113
|
+
nnq pull --bind tcp://:5557 --raw
|
|
114
|
+
|
|
115
|
+
-- Compression ----------------------------------------------
|
|
116
|
+
|
|
117
|
+
# both sides must use --compress
|
|
118
|
+
nnq pull --bind tcp://:5557 --compress &
|
|
119
|
+
echo "compressible data" | nnq push --connect tcp://localhost:5557 --compress
|
|
120
|
+
|
|
121
|
+
-- Ruby Eval ------------------------------------------------
|
|
122
|
+
|
|
123
|
+
# filter incoming: only pass messages containing "error"
|
|
124
|
+
nnq pull -b tcp://:5557 --recv-eval '$_.include?("error") ? $_ : nil'
|
|
125
|
+
|
|
126
|
+
# transform incoming with gems
|
|
127
|
+
nnq sub -c tcp://localhost:5556 -rjson -e 'JSON.parse($_)["temperature"]'
|
|
128
|
+
|
|
129
|
+
# require a local file, use its methods
|
|
130
|
+
nnq rep --bind tcp://:5555 --require ./transform.rb -e 'upcase($_)'
|
|
131
|
+
|
|
132
|
+
# next skips, break stops -- regexps match against $_
|
|
133
|
+
nnq pull -b tcp://:5557 -e 'next if /^#/; break if /quit/; $_'
|
|
134
|
+
|
|
135
|
+
# BEGIN/END blocks (like awk) -- accumulate and summarize
|
|
136
|
+
nnq pull -b tcp://:5557 -e 'BEGIN{@sum = 0} @sum += Integer($_); nil END{puts @sum}'
|
|
137
|
+
|
|
138
|
+
# transform outgoing messages
|
|
139
|
+
echo hello | nnq push -c tcp://localhost:5557 --send-eval '$_.upcase'
|
|
140
|
+
|
|
141
|
+
# REQ: transform request and reply independently
|
|
142
|
+
echo hello | nnq req -c tcp://localhost:5555 -E '$_.upcase' -e '$_'
|
|
143
|
+
|
|
144
|
+
-- Script Handlers (-r) ------------------------------------
|
|
145
|
+
|
|
146
|
+
# handler.rb -- register transforms from a file
|
|
147
|
+
# db = PG.connect("dbname=app")
|
|
148
|
+
# NNQ.incoming { |msg| db.exec(msg).values.flatten.first }
|
|
149
|
+
# at_exit { db.close }
|
|
150
|
+
nnq pull --bind tcp://:5557 -r./handler.rb
|
|
151
|
+
|
|
152
|
+
# combine script handlers with inline eval
|
|
153
|
+
nnq req -c tcp://localhost:5555 -r./handler.rb -E '$_.upcase'
|
|
154
|
+
|
|
155
|
+
# NNQ.outgoing { |msg| ... } -- registered outgoing transform
|
|
156
|
+
# NNQ.incoming { |msg| ... } -- registered incoming transform
|
|
157
|
+
# CLI flags (-e/-E) override registered handlers
|
|
158
|
+
TEXT
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
DEFAULT_OPTS = {
|
|
162
|
+
type_name: nil,
|
|
163
|
+
endpoints: [],
|
|
164
|
+
connects: [],
|
|
165
|
+
binds: [],
|
|
166
|
+
in_endpoints: [],
|
|
167
|
+
out_endpoints: [],
|
|
168
|
+
data: nil,
|
|
169
|
+
file: nil,
|
|
170
|
+
format: :ascii,
|
|
171
|
+
subscribes: [],
|
|
172
|
+
interval: nil,
|
|
173
|
+
count: nil,
|
|
174
|
+
delay: nil,
|
|
175
|
+
timeout: nil,
|
|
176
|
+
linger: 5,
|
|
177
|
+
reconnect_ivl: nil,
|
|
178
|
+
send_hwm: nil,
|
|
179
|
+
sndbuf: nil,
|
|
180
|
+
rcvbuf: nil,
|
|
181
|
+
compress: false,
|
|
182
|
+
compress_in: false,
|
|
183
|
+
compress_out: false,
|
|
184
|
+
send_expr: nil,
|
|
185
|
+
recv_expr: nil,
|
|
186
|
+
parallel: nil,
|
|
187
|
+
transient: false,
|
|
188
|
+
verbose: 0,
|
|
189
|
+
quiet: false,
|
|
190
|
+
echo: false,
|
|
191
|
+
scripts: [],
|
|
192
|
+
recv_maxsz: nil,
|
|
193
|
+
}.freeze
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# Parses +argv+ and returns a mutable options hash.
|
|
197
|
+
#
|
|
198
|
+
def self.parse(argv)
|
|
199
|
+
new.parse(argv)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# Validates option combinations, aborting on bad combos.
|
|
204
|
+
#
|
|
205
|
+
def self.validate!(opts)
|
|
206
|
+
new.validate!(opts)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# Validates option combinations that depend on socket type.
|
|
211
|
+
#
|
|
212
|
+
def self.validate_gems!(config)
|
|
213
|
+
if config.recv_only? && (config.data || config.file)
|
|
214
|
+
abort "--data/--file not valid for #{config.type_name} (receive-only)"
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# Parses +argv+ and returns a mutable options hash.
|
|
220
|
+
#
|
|
221
|
+
# @param argv [Array<String>] command-line arguments (mutated in place)
|
|
222
|
+
# @return [Hash] parsed options
|
|
223
|
+
def parse(argv)
|
|
224
|
+
opts = DEFAULT_OPTS.transform_values { |v| v.is_a?(Array) ? v.dup : v }
|
|
225
|
+
pipe_side = nil # nil = legacy positional mode; :in/:out = modal
|
|
226
|
+
|
|
227
|
+
parser = OptionParser.new do |o|
|
|
228
|
+
o.banner = "Usage: nnq TYPE [options]\n\n" \
|
|
229
|
+
"Types: req, rep, pub, sub, push, pull, pair\n" \
|
|
230
|
+
"Virtual: pipe (PULL -> eval -> PUSH)\n\n"
|
|
231
|
+
|
|
232
|
+
o.separator "Connection:"
|
|
233
|
+
o.on("-c", "--connect URL", "Connect to endpoint (repeatable)") { |v|
|
|
234
|
+
v = expand_endpoint(v)
|
|
235
|
+
ep = Endpoint.new(v, false)
|
|
236
|
+
case pipe_side
|
|
237
|
+
when :in
|
|
238
|
+
opts[:in_endpoints] << ep
|
|
239
|
+
when :out
|
|
240
|
+
opts[:out_endpoints] << ep
|
|
241
|
+
else
|
|
242
|
+
opts[:endpoints] << ep
|
|
243
|
+
opts[:connects] << v
|
|
244
|
+
end
|
|
245
|
+
}
|
|
246
|
+
o.on("-b", "--bind URL", "Bind to endpoint (repeatable)") { |v|
|
|
247
|
+
v = expand_endpoint(v)
|
|
248
|
+
ep = Endpoint.new(v, true)
|
|
249
|
+
case pipe_side
|
|
250
|
+
when :in
|
|
251
|
+
opts[:in_endpoints] << ep
|
|
252
|
+
when :out
|
|
253
|
+
opts[:out_endpoints] << ep
|
|
254
|
+
else
|
|
255
|
+
opts[:endpoints] << ep
|
|
256
|
+
opts[:binds] << v
|
|
257
|
+
end
|
|
258
|
+
}
|
|
259
|
+
o.on("--in", "Pipe: subsequent -b/-c attach to input (PULL) side") { pipe_side = :in }
|
|
260
|
+
o.on("--out", "Pipe: subsequent -b/-c attach to output (PUSH) side") { pipe_side = :out }
|
|
261
|
+
|
|
262
|
+
o.separator "\nData source (REP: reply source):"
|
|
263
|
+
o.on( "--echo", "Echo received messages back (REP)") { opts[:echo] = true }
|
|
264
|
+
o.on("-D", "--data DATA", "Message data (literal string)") { |v| opts[:data] = v }
|
|
265
|
+
o.on("-F", "--file FILE", "Read message from file (- = stdin)") { |v| opts[:file] = v }
|
|
266
|
+
|
|
267
|
+
o.separator "\nFormat (input + output):"
|
|
268
|
+
o.on("-A", "--ascii", "Safe ASCII, non-printable as dots (default)") { opts[:format] = :ascii }
|
|
269
|
+
o.on("-Q", "--quoted", "C-style quoted with escapes") { opts[:format] = :quoted }
|
|
270
|
+
o.on( "--raw", "Raw binary body, no framing, no newline") { opts[:format] = :raw }
|
|
271
|
+
o.on("-J", "--jsonl", "JSON Lines (single-element array per line)") { opts[:format] = :jsonl }
|
|
272
|
+
o.on( "--msgpack", "MessagePack (binary stream)") { require "msgpack"; opts[:format] = :msgpack }
|
|
273
|
+
o.on("-M", "--marshal", "Ruby Marshal stream (binary)") { opts[:format] = :marshal }
|
|
274
|
+
|
|
275
|
+
o.separator "\nSubscription:"
|
|
276
|
+
o.on("-s", "--subscribe PREFIX", "Subscribe prefix (SUB, default all)") { |v| opts[:subscribes] << v }
|
|
277
|
+
|
|
278
|
+
o.separator "\nTiming:"
|
|
279
|
+
o.on("-i", "--interval SECS", Float, "Repeat interval") { |v| opts[:interval] = v }
|
|
280
|
+
o.on("-n", "--count COUNT", Integer, "Max iterations (0=inf)") { |v| opts[:count] = v }
|
|
281
|
+
o.on("-d", "--delay SECS", Float, "Delay before first send") { |v| opts[:delay] = v }
|
|
282
|
+
o.on("-t", "--timeout SECS", Float, "Send/receive timeout") { |v| opts[:timeout] = v }
|
|
283
|
+
o.on("-l", "--linger SECS", Float, "Drain time on close (default 5)") { |v| opts[:linger] = v }
|
|
284
|
+
o.on("--reconnect-ivl IVL", "Reconnect interval: SECS or MIN..MAX (default 0.1)") { |v|
|
|
285
|
+
opts[:reconnect_ivl] = if v.include?("..")
|
|
286
|
+
lo, hi = v.split("..", 2)
|
|
287
|
+
Float(lo)..Float(hi)
|
|
288
|
+
else
|
|
289
|
+
Float(v)
|
|
290
|
+
end
|
|
291
|
+
}
|
|
292
|
+
o.on("--recv-maxsz SIZE", "Max inbound message size, e.g. 4096, 64K, 1M, 2G (default 1M, 0=unlimited; larger messages drop the connection)") { |v| opts[:recv_maxsz] = parse_byte_size(v) }
|
|
293
|
+
o.on("--hwm N", Integer, "Send high water mark (default 100, 0=unbounded)") { |v| opts[:send_hwm] = v }
|
|
294
|
+
o.on("--sndbuf N", "SO_SNDBUF kernel buffer size (e.g. 4K, 1M)") { |v| opts[:sndbuf] = parse_byte_size(v) }
|
|
295
|
+
o.on("--rcvbuf N", "SO_RCVBUF kernel buffer size (e.g. 4K, 1M)") { |v| opts[:rcvbuf] = parse_byte_size(v) }
|
|
296
|
+
|
|
297
|
+
o.separator "\nCompression:"
|
|
298
|
+
o.on("-z", "--compress", "LZ4 compression per message (modal with --in/--out)") do
|
|
299
|
+
require "rlz4"
|
|
300
|
+
case pipe_side
|
|
301
|
+
when :in
|
|
302
|
+
opts[:compress_in] = true
|
|
303
|
+
when :out
|
|
304
|
+
opts[:compress_out] = true
|
|
305
|
+
else
|
|
306
|
+
opts[:compress] = true
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
o.separator "\nProcessing (-e = incoming, -E = outgoing):"
|
|
311
|
+
o.on("-e", "--recv-eval EXPR", "Eval Ruby for each incoming message ($_ = body)") { |v| opts[:recv_expr] = v }
|
|
312
|
+
o.on("-E", "--send-eval EXPR", "Eval Ruby for each outgoing message ($_ = body)") { |v| opts[:send_expr] = v }
|
|
313
|
+
o.on("-r", "--require LIB", "Require lib/file in Async context; use '-' for stdin. Scripts can register NNQ.outgoing/incoming") { |v|
|
|
314
|
+
require "nnq" unless defined?(NNQ::VERSION)
|
|
315
|
+
opts[:scripts] << (v == "-" ? :stdin : (v.start_with?("./", "../") ? File.expand_path(v) : v))
|
|
316
|
+
}
|
|
317
|
+
o.on("-P", "--parallel N", Integer, "Parallel Ractor workers (max 16)") { |v|
|
|
318
|
+
opts[:parallel] = [v, 16].min
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
o.separator "\nOther:"
|
|
322
|
+
o.on("-v", "--verbose", "Verbosity: -v endpoints, -vv events, -vvv messages, -vvvv timestamps") { opts[:verbose] += 1 }
|
|
323
|
+
o.on("-q", "--quiet", "Suppress message output") { opts[:quiet] = true }
|
|
324
|
+
o.on( "--transient", "Exit when all peers disconnect") { opts[:transient] = true }
|
|
325
|
+
o.on("-V", "--version") {
|
|
326
|
+
if ENV["NNQ_DEV"]
|
|
327
|
+
require_relative "../../../../nnq/lib/nnq/version"
|
|
328
|
+
else
|
|
329
|
+
require "nnq/version"
|
|
330
|
+
end
|
|
331
|
+
puts "nnq-cli #{NNQ::CLI::VERSION} (nnq #{NNQ::VERSION})"
|
|
332
|
+
exit
|
|
333
|
+
}
|
|
334
|
+
o.on("-h") { puts o
|
|
335
|
+
exit }
|
|
336
|
+
o.on("--help") { CLI.page "#{o}\n#{EXAMPLES}"
|
|
337
|
+
exit }
|
|
338
|
+
o.on("--examples") { CLI.page EXAMPLES
|
|
339
|
+
exit }
|
|
340
|
+
|
|
341
|
+
o.separator "\nExit codes: 0 = success, 1 = error, 2 = timeout, 3 = eval error"
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
begin
|
|
345
|
+
parser.parse!(argv)
|
|
346
|
+
rescue OptionParser::ParseError => e
|
|
347
|
+
abort e.message
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
type_name = argv.shift
|
|
351
|
+
if type_name.nil?
|
|
352
|
+
abort parser.to_s if opts[:scripts].empty?
|
|
353
|
+
# bare script mode -- type_name stays nil
|
|
354
|
+
elsif !SOCKET_TYPE_NAMES.include?(type_name.downcase)
|
|
355
|
+
abort "Unknown socket type: #{type_name}. Known: #{SOCKET_TYPE_NAMES.join(', ')}"
|
|
356
|
+
else
|
|
357
|
+
opts[:type_name] = type_name.downcase
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Normalize shorthand hostnames to concrete addresses.
|
|
361
|
+
#
|
|
362
|
+
# Binds: tcp://:PORT → loopback (::1 if IPv6 available, else 127.0.0.1)
|
|
363
|
+
# tcp://*:PORT → 0.0.0.0 (all interfaces, IPv4)
|
|
364
|
+
#
|
|
365
|
+
# Connects: tcp://:PORT → localhost (Happy Eyeballs)
|
|
366
|
+
# tcp://*:PORT → localhost
|
|
367
|
+
loopback = self.class.loopback_bind_host
|
|
368
|
+
normalize_bind = ->(url) { url.sub(%r{\Atcp://\*:}, "tcp://0.0.0.0:").sub(%r{\Atcp://:}, "tcp://#{loopback}:") }
|
|
369
|
+
normalize_connect = ->(url) { url.sub(%r{\Atcp://(\*|):}, "tcp://localhost:") }
|
|
370
|
+
normalize_ep = ->(ep) { Endpoint.new(ep.bind? ? normalize_bind.call(ep.url) : normalize_connect.call(ep.url), ep.bind?) }
|
|
371
|
+
opts[:binds].map!(&normalize_bind)
|
|
372
|
+
opts[:connects].map!(&normalize_connect)
|
|
373
|
+
opts[:endpoints].map!(&normalize_ep)
|
|
374
|
+
opts[:in_endpoints].map!(&normalize_ep)
|
|
375
|
+
opts[:out_endpoints].map!(&normalize_ep)
|
|
376
|
+
|
|
377
|
+
opts
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# Parses a byte size string with an optional K/M/G suffix (binary,
|
|
382
|
+
# i.e. 1K = 1024 bytes).
|
|
383
|
+
#
|
|
384
|
+
# @param str [String] e.g. "4096", "4K", "1M", "2G"
|
|
385
|
+
# @return [Integer] size in bytes
|
|
386
|
+
#
|
|
387
|
+
def parse_byte_size(str)
|
|
388
|
+
case str
|
|
389
|
+
when /\A(\d+)[kK]\z/ then $1.to_i * 1024
|
|
390
|
+
when /\A(\d+)[mM]\z/ then $1.to_i * 1024 * 1024
|
|
391
|
+
when /\A(\d+)[gG]\z/ then $1.to_i * 1024 * 1024 * 1024
|
|
392
|
+
when /\A\d+\z/ then str.to_i
|
|
393
|
+
else
|
|
394
|
+
abort "invalid byte size: #{str} (use e.g. 4096, 4K, 1M, 2G)"
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# Returns the loopback address for bind normalization.
|
|
400
|
+
# Prefers IPv6 loopback ([::1]) when the host has at least one
|
|
401
|
+
# non-loopback, non-link-local IPv6 address, otherwise 127.0.0.1.
|
|
402
|
+
def self.loopback_bind_host
|
|
403
|
+
@loopback_bind_host ||= begin
|
|
404
|
+
has_ipv6 = ::Socket.getifaddrs.any? { |ifa|
|
|
405
|
+
addr = ifa.addr
|
|
406
|
+
addr&.ipv6? && !addr.ipv6_loopback? && !addr.ipv6_linklocal?
|
|
407
|
+
}
|
|
408
|
+
has_ipv6 ? "[::1]" : "127.0.0.1"
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# Validates option combinations, aborting on invalid combos.
|
|
414
|
+
#
|
|
415
|
+
# @param opts [Hash] parsed options from {#parse}
|
|
416
|
+
# @return [void]
|
|
417
|
+
def validate!(opts)
|
|
418
|
+
return if opts[:type_name].nil? # bare script mode
|
|
419
|
+
|
|
420
|
+
abort "-r- (stdin script) and -F- (stdin data) cannot both be used" if opts[:scripts]&.include?(:stdin) && opts[:file] == "-"
|
|
421
|
+
|
|
422
|
+
type_name = opts[:type_name]
|
|
423
|
+
|
|
424
|
+
if type_name == "pipe"
|
|
425
|
+
has_in_out = opts[:in_endpoints].any? || opts[:out_endpoints].any?
|
|
426
|
+
if has_in_out
|
|
427
|
+
# Promote bare endpoints into the missing side:
|
|
428
|
+
# `pipe -c SRC --out -c DST` → bare SRC becomes --in
|
|
429
|
+
if opts[:in_endpoints].empty? && opts[:endpoints].any?
|
|
430
|
+
opts[:in_endpoints] = opts[:endpoints]
|
|
431
|
+
opts[:endpoints] = []
|
|
432
|
+
elsif opts[:out_endpoints].empty? && opts[:endpoints].any?
|
|
433
|
+
opts[:out_endpoints] = opts[:endpoints]
|
|
434
|
+
opts[:endpoints] = []
|
|
435
|
+
end
|
|
436
|
+
abort "pipe --in requires at least one endpoint" if opts[:in_endpoints].empty?
|
|
437
|
+
abort "pipe --out requires at least one endpoint" if opts[:out_endpoints].empty?
|
|
438
|
+
abort "pipe: don't mix --in/--out with bare -b/-c endpoints" unless opts[:endpoints].empty?
|
|
439
|
+
else
|
|
440
|
+
abort "pipe requires exactly 2 endpoints (pull-side and push-side), or use --in/--out" if opts[:endpoints].size != 2
|
|
441
|
+
end
|
|
442
|
+
else
|
|
443
|
+
abort "--in/--out are only valid for pipe" if opts[:in_endpoints].any? || opts[:out_endpoints].any?
|
|
444
|
+
abort "At least one --connect or --bind is required" if opts[:connects].empty? && opts[:binds].empty?
|
|
445
|
+
end
|
|
446
|
+
abort "--data and --file are mutually exclusive" if opts[:data] && opts[:file]
|
|
447
|
+
abort "--subscribe is only valid for SUB" if !opts[:subscribes].empty? && type_name != "sub"
|
|
448
|
+
abort "--recv-eval is not valid for send-only sockets (use --send-eval / -E)" if opts[:recv_expr] && SEND_ONLY.include?(type_name)
|
|
449
|
+
abort "--send-eval is not valid for recv-only sockets (use --recv-eval / -e)" if opts[:send_expr] && RECV_ONLY.include?(type_name)
|
|
450
|
+
abort "--send-eval is not valid for REP (the reply is the result of --recv-eval / -e)" if opts[:send_expr] && type_name == "rep"
|
|
451
|
+
|
|
452
|
+
if opts[:parallel]
|
|
453
|
+
parallel_types = %w[pipe]
|
|
454
|
+
abort "-P/--parallel is only valid for #{parallel_types.join(", ")}" unless parallel_types.include?(type_name)
|
|
455
|
+
abort "-P/--parallel must be 1..16" unless (1..16).include?(opts[:parallel])
|
|
456
|
+
all_eps = if type_name == "pipe"
|
|
457
|
+
opts[:in_endpoints] + opts[:out_endpoints] + opts[:endpoints]
|
|
458
|
+
else
|
|
459
|
+
opts[:endpoints]
|
|
460
|
+
end
|
|
461
|
+
abort "-P/--parallel requires all endpoints to use --connect (not --bind)" if all_eps.any?(&:bind?)
|
|
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
|
+
|
|
468
|
+
all_urls = if type_name == "pipe"
|
|
469
|
+
(opts[:in_endpoints] + opts[:out_endpoints] + opts[:endpoints]).map(&:url)
|
|
470
|
+
else
|
|
471
|
+
opts[:connects] + opts[:binds]
|
|
472
|
+
end
|
|
473
|
+
dups = all_urls.tally.select { |_, n| n > 1 }.keys
|
|
474
|
+
abort "duplicate endpoint: #{dups.first}" if dups.any?
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
# Expands shorthand `@name` to `ipc://@name` (Linux abstract namespace).
|
|
479
|
+
# Only triggers when the value starts with `@` and has no `://` scheme.
|
|
480
|
+
def expand_endpoint(url)
|
|
481
|
+
url.start_with?("@") && !url.include?("://") ? "ipc://#{url}" : url
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NNQ
|
|
4
|
+
module CLI
|
|
5
|
+
# Socket type names that only send messages.
|
|
6
|
+
SEND_ONLY = %w[pub push].freeze
|
|
7
|
+
# Socket type names that only receive messages.
|
|
8
|
+
RECV_ONLY = %w[sub pull].freeze
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# A bind or connect endpoint with its URL and direction.
|
|
12
|
+
Endpoint = Data.define(:url, :bind?) do
|
|
13
|
+
# @return [Boolean] true if this endpoint connects rather than binds
|
|
14
|
+
def connect? = !bind?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Frozen, Ractor-shareable configuration data class for a CLI invocation.
|
|
19
|
+
Config = Data.define(
|
|
20
|
+
:type_name,
|
|
21
|
+
:endpoints,
|
|
22
|
+
:connects,
|
|
23
|
+
:binds,
|
|
24
|
+
:in_endpoints,
|
|
25
|
+
:out_endpoints,
|
|
26
|
+
:data,
|
|
27
|
+
:file,
|
|
28
|
+
:format,
|
|
29
|
+
:subscribes,
|
|
30
|
+
:interval,
|
|
31
|
+
:count,
|
|
32
|
+
:delay,
|
|
33
|
+
:timeout,
|
|
34
|
+
:linger,
|
|
35
|
+
:reconnect_ivl,
|
|
36
|
+
:send_hwm,
|
|
37
|
+
:sndbuf,
|
|
38
|
+
:rcvbuf,
|
|
39
|
+
:compress,
|
|
40
|
+
:compress_in,
|
|
41
|
+
:compress_out,
|
|
42
|
+
:send_expr,
|
|
43
|
+
:recv_expr,
|
|
44
|
+
:parallel,
|
|
45
|
+
:transient,
|
|
46
|
+
:verbose,
|
|
47
|
+
:quiet,
|
|
48
|
+
:echo,
|
|
49
|
+
:scripts,
|
|
50
|
+
:recv_maxsz,
|
|
51
|
+
:stdin_is_tty,
|
|
52
|
+
) do
|
|
53
|
+
# @return [Boolean] true if this socket type only sends
|
|
54
|
+
def send_only? = SEND_ONLY.include?(type_name)
|
|
55
|
+
# @return [Boolean] true if this socket type only receives
|
|
56
|
+
def recv_only? = RECV_ONLY.include?(type_name)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NNQ
|
|
4
|
+
module CLI
|
|
5
|
+
# Compiles and evaluates a single Ruby expression string for use in
|
|
6
|
+
# --recv-eval / --send-eval. Handles BEGIN{}/END{} block extraction,
|
|
7
|
+
# proc compilation, and result normalisation.
|
|
8
|
+
#
|
|
9
|
+
# One instance per direction (send or recv).
|
|
10
|
+
#
|
|
11
|
+
# nnq has no multipart, so `$F` is always a 1-element array and `$_`
|
|
12
|
+
# is the body string.
|
|
13
|
+
#
|
|
14
|
+
class ExpressionEvaluator
|
|
15
|
+
attr_reader :begin_proc, :end_proc, :eval_proc
|
|
16
|
+
|
|
17
|
+
# Sentinel: eval proc returned the context object, meaning it already
|
|
18
|
+
# sent the reply itself.
|
|
19
|
+
SENT = Object.new.freeze
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# @param src [String, nil] raw expression string (may include BEGIN{}/END{})
|
|
23
|
+
# @param format [Symbol] active format, used to normalise results
|
|
24
|
+
# @param fallback_proc [Proc, nil] registered NNQ.outgoing/incoming handler;
|
|
25
|
+
# used only when +src+ is nil
|
|
26
|
+
def initialize(src, format:, fallback_proc: nil)
|
|
27
|
+
@format = format
|
|
28
|
+
|
|
29
|
+
if src
|
|
30
|
+
expr, begin_body, end_body = extract_blocks(src)
|
|
31
|
+
@begin_proc = eval("proc { #{begin_body} }") if begin_body # rubocop:disable Security/Eval
|
|
32
|
+
@end_proc = eval("proc { #{end_body} }") if end_body # rubocop:disable Security/Eval
|
|
33
|
+
if expr && !expr.strip.empty?
|
|
34
|
+
@eval_proc = eval("proc { $_ = $F&.first; #{expr} }") # rubocop:disable Security/Eval
|
|
35
|
+
end
|
|
36
|
+
elsif fallback_proc
|
|
37
|
+
@eval_proc = proc { |msg|
|
|
38
|
+
body = msg&.first
|
|
39
|
+
$_ = body
|
|
40
|
+
fallback_proc.call(body)
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Runs the eval proc against +msg+ using +context+ as self.
|
|
47
|
+
# Returns the normalised result Array, nil (filter/skip), or SENT.
|
|
48
|
+
def call(msg, context)
|
|
49
|
+
return msg unless @eval_proc
|
|
50
|
+
|
|
51
|
+
$F = msg
|
|
52
|
+
result = context.instance_exec(msg, &@eval_proc)
|
|
53
|
+
return nil if result.nil?
|
|
54
|
+
return SENT if result.equal?(context)
|
|
55
|
+
return [result] if @format == :marshal
|
|
56
|
+
|
|
57
|
+
result = result.is_a?(Array) ? result.first(1) : [result]
|
|
58
|
+
result.map!(&:to_s)
|
|
59
|
+
rescue => e
|
|
60
|
+
$stderr.puts "nnq: eval error: #{e.message} (#{e.class})"
|
|
61
|
+
exit 3
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Normalises an eval result to nil (skip) or a 1-element Array of strings.
|
|
66
|
+
# Used inside Ractor worker blocks.
|
|
67
|
+
def self.normalize_result(result)
|
|
68
|
+
return nil if result.nil?
|
|
69
|
+
result = result.is_a?(Array) ? result.first(1) : [result]
|
|
70
|
+
result.map!(&:to_s)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Compiles begin/end/eval procs inside a Ractor from a raw expression
|
|
75
|
+
# string. Returns [begin_proc, end_proc, eval_proc], any may be nil.
|
|
76
|
+
# Must be called inside the Ractor block.
|
|
77
|
+
def self.compile_inside_ractor(src)
|
|
78
|
+
return [nil, nil, nil] unless src
|
|
79
|
+
|
|
80
|
+
extract = ->(expr, kw) {
|
|
81
|
+
s = expr.index(/#{kw}\s*\{/)
|
|
82
|
+
return [expr, nil] unless s
|
|
83
|
+
ci = expr.index("{", s)
|
|
84
|
+
depth = 1
|
|
85
|
+
j = ci + 1
|
|
86
|
+
while j < expr.length && depth > 0
|
|
87
|
+
depth += 1 if expr[j] == "{"
|
|
88
|
+
depth -= 1 if expr[j] == "}"
|
|
89
|
+
j += 1
|
|
90
|
+
end
|
|
91
|
+
[expr[0...s] + expr[j..], expr[(ci + 1)..(j - 2)]]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
expr, begin_body = extract.(src, "BEGIN")
|
|
95
|
+
expr, end_body = extract.(expr, "END")
|
|
96
|
+
|
|
97
|
+
begin_proc = eval("proc { #{begin_body} }") if begin_body # rubocop:disable Security/Eval
|
|
98
|
+
end_proc = eval("proc { #{end_body} }") if end_body # rubocop:disable Security/Eval
|
|
99
|
+
eval_proc = nil
|
|
100
|
+
if expr && !expr.strip.empty?
|
|
101
|
+
ractor_expr = expr.gsub(/\$F\b/, "__F")
|
|
102
|
+
eval_proc = eval("proc { |__F| $_ = __F&.first; #{ractor_expr} }") # rubocop:disable Security/Eval
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
[begin_proc, end_proc, eval_proc]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def extract_blocks(expr)
|
|
113
|
+
expr, begin_body = extract_block(expr, "BEGIN")
|
|
114
|
+
expr, end_body = extract_block(expr, "END")
|
|
115
|
+
[expr, begin_body, end_body]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def extract_block(expr, keyword)
|
|
120
|
+
start = expr.index(/#{keyword}\s*\{/)
|
|
121
|
+
return [expr, nil] unless start
|
|
122
|
+
|
|
123
|
+
i = expr.index("{", start)
|
|
124
|
+
depth = 1
|
|
125
|
+
j = i + 1
|
|
126
|
+
while j < expr.length && depth > 0
|
|
127
|
+
case expr[j]
|
|
128
|
+
when "{"
|
|
129
|
+
depth += 1
|
|
130
|
+
when "}"
|
|
131
|
+
depth -= 1
|
|
132
|
+
end
|
|
133
|
+
j += 1
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
body = expr[(i + 1)..(j - 2)]
|
|
137
|
+
trimmed = expr[0...start] + expr[j..]
|
|
138
|
+
[trimmed, body]
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|