nats-async 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: 5711fb8f1a7bcbb63a2e0f25f2d8c397840517af36b050486c51f97a5766d959
4
+ data.tar.gz: cbe97a371adfb30e0f856941b791c61bbd79bb216e90d41b943e1d2fd962546f
5
+ SHA512:
6
+ metadata.gz: b28f9fe2b99bcc2acf765df56e0681c0ea616708b1bdd873a612495604eb7a445111a07d7e31058aac6dbaa10c7b8d3d6e16e3071bf2d11a032c102b83a84bdd
7
+ data.tar.gz: dc559c667dfc89842ae802fd7d63263d6af0b63896f702a3f657636f1735093f9d4e77aa1ca9a56d9311695a3e8733063558f14f44ce3019b5245492619143ae
@@ -0,0 +1,15 @@
1
+ on: { push: { branches: [main] } }
2
+
3
+ jobs:
4
+ build_and_publish:
5
+ runs-on: ubuntu-latest
6
+ steps:
7
+ - uses: actions/checkout@v3
8
+ - uses: ruby/setup-ruby@v1
9
+ with: { ruby-version: "3.4.4" }
10
+ - run: |
11
+ mkdir -p ~/.gem && touch ~/.gem/credentials && chmod 0600 ~/.gem/credentials
12
+ printf -- "---\n:rubygems_api_key: ${API_KEY}\n" > ~/.gem/credentials
13
+ bundle install && rake push
14
+ env:
15
+ API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --color
2
+ --require spec_helper
3
+ --warnings
data/.rubocop.yml ADDED
@@ -0,0 +1,226 @@
1
+ require:
2
+ - rubocop-rake
3
+ - rubocop-rspec
4
+
5
+ AllCops:
6
+ NewCops: enable
7
+ UseCache: false
8
+ Exclude:
9
+ - benchmarks/*.rb
10
+ - "*.gemspec"
11
+
12
+ Layout/SpaceAroundMethodCallOperator:
13
+ Enabled: false
14
+
15
+ Layout/SpaceInLambdaLiteral:
16
+ Enabled: false
17
+
18
+ Layout/MultilineMethodCallIndentation:
19
+ Enabled: true
20
+ EnforcedStyle: indented
21
+
22
+ Layout/FirstArrayElementIndentation:
23
+ EnforcedStyle: consistent
24
+
25
+ Layout/SpaceInsideHashLiteralBraces:
26
+ Enabled: true
27
+ EnforcedStyle: no_space
28
+ EnforcedStyleForEmptyBraces: no_space
29
+
30
+ Layout/LineLength:
31
+ Enabled: false
32
+
33
+ Layout/EmptyLineAfterGuardClause:
34
+ Enabled: false
35
+
36
+ Lint/AmbiguousBlockAssociation:
37
+ Enabled: true
38
+ Exclude:
39
+ - "spec/**/*.rb"
40
+
41
+ Lint/BooleanSymbol:
42
+ Enabled: false
43
+
44
+ Lint/ConstantDefinitionInBlock:
45
+ Exclude:
46
+ - "spec/**/*.rb"
47
+
48
+ Lint/RaiseException:
49
+ Enabled: false
50
+
51
+ Lint/StructNewOverride:
52
+ Enabled: false
53
+
54
+ Lint/SuppressedException:
55
+ Exclude:
56
+ - "spec/spec_helper.rb"
57
+
58
+ Lint/LiteralAsCondition:
59
+ Exclude:
60
+ - "spec/**/*.rb"
61
+
62
+ Naming/PredicatePrefix:
63
+ Enabled: false
64
+
65
+ Naming/PredicateMethod:
66
+ Enabled: false
67
+
68
+ Naming/FileName:
69
+ Exclude:
70
+ - "lib/*-*.rb"
71
+
72
+ Naming/MethodName:
73
+ Enabled: false
74
+
75
+ Naming/MethodParameterName:
76
+ Enabled: false
77
+
78
+ Naming/MemoizedInstanceVariableName:
79
+ Enabled: false
80
+
81
+ Metrics/MethodLength:
82
+ Enabled: false
83
+
84
+ Metrics/ClassLength:
85
+ Enabled: false
86
+
87
+ Metrics/BlockLength:
88
+ Enabled: false
89
+
90
+ Metrics/AbcSize:
91
+ Enabled: false
92
+
93
+ Metrics/ParameterLists:
94
+ Enabled: false
95
+
96
+ Metrics/CyclomaticComplexity:
97
+ Enabled: true
98
+ Max: 12
99
+
100
+ Style/ExponentialNotation:
101
+ Enabled: false
102
+
103
+ Style/HashEachMethods:
104
+ Enabled: false
105
+
106
+ Style/HashTransformKeys:
107
+ Enabled: false
108
+
109
+ Style/HashTransformValues:
110
+ Enabled: false
111
+
112
+ Style/AccessModifierDeclarations:
113
+ Enabled: false
114
+
115
+ Style/Alias:
116
+ Enabled: true
117
+ EnforcedStyle: prefer_alias_method
118
+
119
+ Style/AsciiComments:
120
+ Enabled: false
121
+
122
+ Style/BlockDelimiters:
123
+ Enabled: false
124
+
125
+ Style/ClassAndModuleChildren:
126
+ Exclude:
127
+ - "spec/**/*.rb"
128
+
129
+ Style/ConditionalAssignment:
130
+ Enabled: false
131
+
132
+ Style/DateTime:
133
+ Enabled: false
134
+
135
+ Style/Documentation:
136
+ Enabled: false
137
+
138
+ Style/FrozenStringLiteralComment:
139
+ Enabled: false
140
+
141
+ Style/EachWithObject:
142
+ Enabled: false
143
+
144
+ Style/FormatString:
145
+ Enabled: false
146
+
147
+ Style/FormatStringToken:
148
+ Enabled: false
149
+
150
+ Style/GuardClause:
151
+ Enabled: false
152
+
153
+ Style/IfUnlessModifier:
154
+ Enabled: false
155
+
156
+ Style/Lambda:
157
+ Enabled: false
158
+
159
+ Style/LambdaCall:
160
+ Enabled: false
161
+
162
+ Style/ParallelAssignment:
163
+ Enabled: false
164
+
165
+ Style/StabbyLambdaParentheses:
166
+ Enabled: false
167
+
168
+ Style/SymbolArray:
169
+ Exclude:
170
+ - "spec/**/*.rb"
171
+
172
+ Style/TrailingUnderscoreVariable:
173
+ Enabled: false
174
+
175
+ Style/MultipleComparison:
176
+ Enabled: false
177
+
178
+ Style/Next:
179
+ Enabled: false
180
+
181
+ Style/AccessorGrouping:
182
+ Enabled: false
183
+
184
+ Style/EmptyLiteral:
185
+ Enabled: false
186
+
187
+ Style/Semicolon:
188
+ Enabled: false
189
+
190
+ Style/HashAsLastArrayItem:
191
+ Exclude:
192
+ - "spec/**/*.rb"
193
+
194
+ Style/CaseEquality:
195
+ Exclude:
196
+ - "spec/**/*.rb"
197
+
198
+ Style/CombinableLoops:
199
+ Enabled: false
200
+
201
+ Style/EmptyElse:
202
+ Enabled: false
203
+
204
+ Style/DoubleNegation:
205
+ Enabled: false
206
+
207
+ Style/MultilineBlockChain:
208
+ Enabled: false
209
+
210
+ Style/NumberedParametersLimit:
211
+ Max: 2
212
+
213
+ Style/StringLiterals:
214
+ Enabled: false
215
+
216
+ Style/RedundantArgument:
217
+ Enabled: false
218
+
219
+ Style/SoleNestedConditional:
220
+ Enabled: false
221
+
222
+ Style/RescueModifier:
223
+ Enabled: false
224
+
225
+ Lint/UnusedMethodArgument:
226
+ Enabled: false
data/README.erb ADDED
@@ -0,0 +1,46 @@
1
+ # Nats Async
2
+
3
+ `nats-async` packages the `nats-test` prototype as a Ruby gem with the same project layout and build flow as `dry-stack`.
4
+
5
+ ```
6
+ $ ./bin/nats-async --help
7
+ <%= %x{./bin/nats-async --help} %>
8
+ ```
9
+
10
+ ## Installation
11
+
12
+ Add the gem to your bundle:
13
+
14
+ ```ruby
15
+ gem "nats-async"
16
+ ```
17
+
18
+ Or install it directly:
19
+
20
+ ```bash
21
+ gem install nats-async
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ```ruby
27
+ require "nats-async"
28
+
29
+ connector = NatsAsync::SimpleConnector.new(url: "nats://127.0.0.1:4222", verbose: false)
30
+ connector.run(duration: 1) do |client, task|
31
+ client.subscribe("demo.subject") do |message|
32
+ puts "received: #{message.data}"
33
+ task.stop
34
+ end
35
+
36
+ client.publish("demo.subject", "hello")
37
+ end
38
+ ```
39
+
40
+ ## Development
41
+
42
+ ```bash
43
+ bundle install
44
+ bundle exec rspec
45
+ rake build
46
+ ```
data/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # Nats Async
2
+
3
+ `nats-async` packages the `nats-test` prototype as a Ruby gem with the same project layout and build flow as `dry-stack`.
4
+
5
+ ```
6
+ $ ./bin/nats-async --help
7
+ Usage: nats-async [--version] [--help]
8
+
9
+ Library gem for the NatsAsync connector prototype.
10
+
11
+ ```
12
+
13
+ ## Installation
14
+
15
+ Add the gem to your bundle:
16
+
17
+ ```ruby
18
+ gem "nats-async"
19
+ ```
20
+
21
+ Or install it directly:
22
+
23
+ ```bash
24
+ gem install nats-async
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```ruby
30
+ require "nats-async"
31
+
32
+ connector = NatsAsync::SimpleConnector.new(url: "nats://127.0.0.1:4222", verbose: false)
33
+ connector.run(duration: 1) do |client, task|
34
+ client.subscribe("demo.subject") do |message|
35
+ puts "received: #{message.data}"
36
+ task.stop
37
+ end
38
+
39
+ client.publish("demo.subject", "hello")
40
+ end
41
+ ```
42
+
43
+ ## Development
44
+
45
+ ```bash
46
+ bundle install
47
+ bundle exec rspec
48
+ rake build
49
+ ```
data/Rakefile ADDED
@@ -0,0 +1,41 @@
1
+ require "rspec/core/rake_task"
2
+ require_relative "lib/version"
3
+
4
+ rspec = RSpec::Core::RakeTask.new(:spec)
5
+
6
+ require "rubocop/rake_task"
7
+
8
+ RuboCop::RakeTask.new
9
+
10
+ task default: %i[rspec]
11
+
12
+ desc "CI Rspec run with reports"
13
+ task :rspec do
14
+ rspec.rspec_opts = "--profile --color -f documentation -f RspecJunitFormatter --out ./results/rspec.xml"
15
+ Rake::Task["spec"].invoke
16
+ end
17
+
18
+ require "erb"
19
+
20
+ desc "Update readme"
21
+ task :readme do
22
+ puts "Update README.erb -> README.md"
23
+ template = File.read("./README.erb")
24
+ renderer = ERB.new(template, trim_mode: "-")
25
+ File.write("./README.md", renderer.result)
26
+ end
27
+
28
+ desc "Build&push new version"
29
+ task push: %i[spec readme] do
30
+ puts "Build&push new version"
31
+ system "gem build nats-async.gemspec" or exit 1
32
+ system "gem install ./nats-async-#{NatsAsync::VERSION}.gem" or exit 1
33
+ system "gem push nats-async-#{NatsAsync::VERSION}.gem" or exit 1
34
+ system "gem list -r nats-async" or exit 1
35
+ end
36
+
37
+ desc "Build new version"
38
+ task build: %i[spec readme] do
39
+ puts "Build new version"
40
+ system "gem build nats-async.gemspec" or exit 1
41
+ end
data/bin/nats-async ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ lib = File.expand_path("../lib", __dir__)
4
+ $LOAD_PATH << lib
5
+
6
+ require "nats-async"
7
+ require "nats_async/command_line"
8
+
9
+ NatsAsync::CommandLine.run(ARGV)
data/lib/nats-async.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "version"
4
+ require_relative "nats_async/simple_connector"
5
+ require_relative "nats_async/command_line"
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsAsync
4
+ module CommandLine
5
+ module_function
6
+
7
+ def run(argv, stdout: $stdout)
8
+ case argv.first
9
+ when "-v", "--version"
10
+ stdout.puts(NatsAsync::VERSION)
11
+ else
12
+ stdout.puts("Usage: nats-async [--version] [--help]")
13
+ stdout.puts
14
+ stdout.puts("Library gem for the NatsAsync connector prototype.")
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,553 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/condition"
5
+ require "async/semaphore"
6
+ ENV["CONSOLE_OUTPUT"] ||= "XTerm"
7
+ require "console"
8
+ require "io/endpoint"
9
+ require "io/endpoint/host_endpoint"
10
+ require "io/stream"
11
+ require "json"
12
+ require "timeout"
13
+ require "uri"
14
+ require "base64"
15
+ require "openssl"
16
+
17
+ module NatsAsync
18
+ class SimpleConnector
19
+ CR_LF = "\r\n"
20
+
21
+ class AckError < StandardError; end
22
+ class MsgAlreadyAcked < AckError; end
23
+ class NotAckable < AckError; end
24
+ class RequestError < StandardError; end
25
+ class ResponseParseError < RequestError; end
26
+ class ProtocolError < StandardError; end
27
+
28
+ class Message
29
+ ACK = "+ACK"
30
+ NAK = "-NAK"
31
+ TERM = "+TERM"
32
+ WPI = "+WPI"
33
+
34
+ attr_reader :subject, :sid, :reply, :data
35
+
36
+ def initialize(subject:, sid:, reply:, data:, connector:)
37
+ @subject = subject
38
+ @sid = sid
39
+ @reply = reply
40
+ @data = data
41
+ @connector = connector
42
+ @acked = false
43
+ end
44
+
45
+ def ack(**_params) = finalize_ack!(ACK)
46
+
47
+ def ack_sync(timeout: 0.5, **_params) = finalize_ack!(ACK, timeout: timeout)
48
+
49
+ def nak(delay: nil, timeout: nil, **_params)
50
+ payload = delay ? "#{NAK} #{{delay: delay}.to_json}" : NAK
51
+ finalize_ack!(payload, timeout: timeout)
52
+ end
53
+
54
+ def term(timeout: nil, **_params) = finalize_ack!(TERM, timeout: timeout)
55
+
56
+ def in_progress(timeout: nil, **_params)
57
+ ensure_reply!
58
+ publish_ack(WPI, timeout: timeout)
59
+ end
60
+
61
+ def metadata
62
+ return unless reply&.start_with?("$JS.ACK.")
63
+
64
+ tokens = reply.split(".")
65
+ return if tokens.size < 9
66
+
67
+ if tokens.size >= 12
68
+ domain = tokens[2] == "_" ? "" : tokens[2]
69
+ stream = tokens[4]
70
+ consumer = tokens[5]
71
+ delivered = tokens[6].to_i
72
+ stream_seq = tokens[7].to_i
73
+ consumer_seq = tokens[8].to_i
74
+ timestamp_ns = tokens[9].to_i
75
+ pending = tokens[10].to_i
76
+ else
77
+ domain = ""
78
+ stream = tokens[2]
79
+ consumer = tokens[3]
80
+ delivered = tokens[4].to_i
81
+ stream_seq = tokens[5].to_i
82
+ consumer_seq = tokens[6].to_i
83
+ timestamp_ns = tokens[7].to_i
84
+ pending = tokens[8].to_i
85
+ end
86
+
87
+ {
88
+ stream: stream,
89
+ consumer: consumer,
90
+ delivered: delivered,
91
+ sequence: {stream: stream_seq, consumer: consumer_seq},
92
+ timestamp_ns: timestamp_ns,
93
+ pending: pending,
94
+ domain: domain
95
+ }
96
+ end
97
+
98
+ private
99
+
100
+ def finalize_ack!(payload, timeout: nil)
101
+ raise MsgAlreadyAcked, "message already acknowledged" if @acked
102
+
103
+ publish_ack(payload, timeout: timeout)
104
+ @acked = true
105
+ true
106
+ end
107
+
108
+ def publish_ack(payload, timeout: nil)
109
+ ensure_reply!
110
+ @connector.publish(@reply, payload)
111
+ end
112
+
113
+ def ensure_reply!
114
+ raise NotAckable, "message has no reply subject" if reply.nil? || reply.empty?
115
+ end
116
+ end
117
+
118
+ def initialize(
119
+ url: "nats://127.0.0.1:4222",
120
+ verbose: true,
121
+ js_api_prefix: "$JS.API",
122
+ tls: nil,
123
+ tls_verify: true,
124
+ tls_ca_file: nil,
125
+ tls_ca_path: nil,
126
+ tls_hostname: nil,
127
+ tls_handshake_first: false,
128
+ user: nil,
129
+ password: nil,
130
+ nkey_seed: nil,
131
+ nkey_seed_file: nil,
132
+ nkey_public_key: nil
133
+ )
134
+ @url = URI(url)
135
+ @verbose = verbose
136
+ @js_api_prefix = normalize_subject_prefix(js_api_prefix)
137
+ @tls_enabled = tls.nil? ? %w[tls nats+tls].include?(@url.scheme) : tls
138
+ @tls_verify = tls_verify
139
+ @tls_ca_file = presence(tls_ca_file)
140
+ @tls_ca_path = presence(tls_ca_path)
141
+ @tls_hostname = presence(tls_hostname)
142
+ @tls_handshake_first = tls_handshake_first
143
+ @auth_user = presence(user || @url.user)
144
+ @auth_password = presence(password || @url.password)
145
+ @nkey_seed = presence(nkey_seed)
146
+ @nkey_seed_file = presence(nkey_seed_file)
147
+ @nkey_public_key = presence(nkey_public_key)
148
+ @stream = nil
149
+ @logger = Console.logger.with(level: (verbose ? :debug : :error), verbose: false)
150
+
151
+ @received_pings = 0
152
+ @received_pongs = 0
153
+ @sent_pings = 0
154
+ @server_info = nil
155
+
156
+ @read_task = nil
157
+ @read_error = nil
158
+ @write_lock = Async::Semaphore.new(1)
159
+ @pong_condition = Async::Condition.new
160
+ @sid_seq = 0
161
+ @subscriptions = {}
162
+ end
163
+
164
+ attr_reader :received_pings, :received_pongs, :sent_pings, :server_info, :js_api_prefix
165
+
166
+ def run(duration: 10, ping_every: 2, ping_timeout: 2)
167
+ Async do |task|
168
+ connect!
169
+ read_initial_info!
170
+ send_connect!
171
+
172
+ @read_task = task.async { read_loop }
173
+ ping!(timeout: ping_timeout)
174
+ yield self, task if block_given?
175
+
176
+ deadline = monotonic_now + duration
177
+ while monotonic_now < deadline
178
+ task.sleep(ping_every)
179
+ ping!(timeout: ping_timeout)
180
+ end
181
+ ensure
182
+ stop
183
+ end.wait
184
+ end
185
+
186
+ def stop
187
+ @read_task&.stop
188
+ safe_close_stream
189
+ @read_task = nil
190
+ end
191
+
192
+ def ping!(timeout: 2)
193
+ expected_pongs = @received_pongs + 1
194
+
195
+ @sent_pings += 1
196
+ write_line("PING")
197
+ await(timeout: timeout, condition: @pong_condition, timeout_message: "timeout waiting for PONG after #{timeout}s", predicate: lambda {
198
+ raise @read_error if @read_error
199
+ @received_pongs >= expected_pongs
200
+ })
201
+ end
202
+
203
+ def publish(subject, payload = "", reply: nil)
204
+ payload = payload.to_s
205
+ command = build_pub_command(subject, payload.bytesize, reply: reply)
206
+ @logger.debug("C->S #{command}")
207
+
208
+ @write_lock.acquire do
209
+ @stream.write("#{command}#{CR_LF}", flush: false)
210
+ @stream.write(payload, flush: false)
211
+ @stream.write(CR_LF, flush: true)
212
+ end
213
+ protocol_payload_out(payload)
214
+ end
215
+
216
+ def subscribe(subject, queue: nil, handler: nil, &block)
217
+ callback = handler || block
218
+ raise ArgumentError, "handler or block required for subscribe" unless callback
219
+
220
+ sid = next_sid
221
+ @subscriptions[sid] = callback
222
+ command = queue ? "SUB #{subject} #{queue} #{sid}" : "SUB #{subject} #{sid}"
223
+ write_line(command)
224
+ sid
225
+ end
226
+
227
+ def unsubscribe(sid)
228
+ write_line("UNSUB #{sid}")
229
+ @subscriptions.delete(sid)
230
+ true
231
+ end
232
+
233
+ def request(subject, payload = "", timeout: 0.5, parse_json: true)
234
+ response = request_message(subject, payload, timeout: timeout)
235
+ result = parse_json ? parse_json_response(subject, response.data) : response
236
+ ensure_request_ok!(subject, result) if parse_json
237
+ block_given? ? yield(result) : result
238
+ end
239
+
240
+ def request_message(subject, payload = "", timeout: 0.5)
241
+ inbox = "_INBOX.#{rand(1 << 30)}.#{next_sid}"
242
+ response = nil
243
+ condition = Async::Condition.new
244
+ on_response = ->(msg) { response = msg; condition.signal }
245
+ with_temp_subscription(inbox, handler: on_response) do
246
+ publish(subject, request_payload(payload), reply: inbox)
247
+ await(timeout: timeout, condition: condition, timeout_message: "request timeout after #{timeout}s", predicate: lambda {
248
+ raise @read_error if @read_error
249
+ !response.nil?
250
+ })
251
+ end
252
+
253
+ block_given? ? yield(response) : response
254
+ end
255
+
256
+ def js_api_subject(*tokens)
257
+ [js_api_prefix, *tokens.flatten].compact.map(&:to_s).reject(&:empty?).join(".")
258
+ end
259
+
260
+ private
261
+
262
+ def connect!
263
+ host = @url.host || "127.0.0.1"
264
+ port = @url.port || 4222
265
+ socket = IO::Endpoint.tcp(host, port).connect
266
+ @stream = IO.Stream(socket)
267
+
268
+ return unless @tls_enabled
269
+
270
+ @server_info = parse_info_line(read_line) unless @tls_handshake_first
271
+ socket = wrap_tls_socket(socket, host)
272
+ @stream = IO.Stream(socket)
273
+ end
274
+
275
+ def read_initial_info!
276
+ return if @server_info
277
+
278
+ @server_info = parse_info_line(read_line)
279
+ end
280
+
281
+ def send_connect!
282
+ payload = {
283
+ verbose: false,
284
+ pedantic: false,
285
+ tls_required: @tls_enabled,
286
+ lang: "ruby",
287
+ version: "nats-async-playground",
288
+ protocol: 1,
289
+ echo: true
290
+ }
291
+ payload.merge!(auth_connect_fields)
292
+
293
+ write_line("CONNECT #{JSON.generate(payload)}")
294
+ end
295
+
296
+ def read_loop
297
+ loop do
298
+ line = read_line
299
+ case line
300
+ when "PING"
301
+ @received_pings += 1
302
+ write_line("PONG")
303
+ when "PONG"
304
+ @received_pongs += 1
305
+ @pong_condition.signal
306
+ when /\A-ERR\s+/
307
+ raise ProtocolError, "server error: #{line}"
308
+ when /\AINFO\s+/
309
+ @server_info = parse_info_line(line)
310
+ when /\AMSG\s+/
311
+ dispatch_msg(line)
312
+ end
313
+ end
314
+ rescue StandardError => e
315
+ @read_error = e
316
+ @logger.error("read loop error: #{e.class}: #{e.message}")
317
+ end
318
+
319
+ def write_line(data)
320
+ @write_lock.acquire do
321
+ @stream.write("#{data}#{CR_LF}", flush: true)
322
+ end
323
+ @logger.debug("C->S #{data}")
324
+ end
325
+
326
+ def read_line
327
+ data = @stream.read_until(CR_LF, chomp: true)
328
+ raise EOFError, "socket closed" unless data
329
+
330
+ @logger.debug("S->C #{data}")
331
+ data
332
+ end
333
+
334
+ def await(timeout:, condition:, timeout_message:, predicate:)
335
+ return true if predicate.call
336
+
337
+ deadline = monotonic_now + timeout
338
+ until predicate.call
339
+ remaining = deadline - monotonic_now
340
+ raise Timeout::Error, timeout_message if remaining <= 0
341
+
342
+ Async::Task.current.with_timeout(remaining) { condition.wait }
343
+ end
344
+
345
+ true
346
+ rescue Timeout::Error
347
+ raise Timeout::Error, timeout_message
348
+ end
349
+
350
+ def monotonic_now
351
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
352
+ end
353
+
354
+ def request_payload(payload)
355
+ return "" if payload.nil?
356
+ return payload if payload.is_a?(String)
357
+
358
+ JSON.generate(payload)
359
+ end
360
+
361
+ def ensure_request_ok!(subject, result)
362
+ return result unless result.is_a?(Hash) && result[:error]
363
+
364
+ error = result[:error]
365
+ description = error.is_a?(Hash) ? error[:description] || error[:code] || error.inspect : error
366
+ raise RequestError, "request failed for #{subject}: #{description}"
367
+ end
368
+
369
+ def parse_json_response(subject, data)
370
+ JSON.parse(data, symbolize_names: true)
371
+ rescue JSON::ParserError => e
372
+ raise ResponseParseError, "request failed for #{subject}: invalid JSON response (#{e.message})"
373
+ end
374
+
375
+ def parse_info_line(line)
376
+ raise ProtocolError, "expected INFO, got: #{line.inspect}" unless line.start_with?("INFO ")
377
+
378
+ JSON.parse(line.delete_prefix("INFO "), symbolize_names: true)
379
+ rescue JSON::ParserError => e
380
+ raise ProtocolError, "invalid INFO payload: #{e.message}"
381
+ end
382
+
383
+ def next_sid
384
+ @sid_seq += 1
385
+ end
386
+
387
+ def dispatch_msg(control_line)
388
+ subject, sid, reply, size = parse_msg_control_line(control_line)
389
+ payload = @stream.read_exactly(size)
390
+ suffix = @stream.read_exactly(CR_LF.bytesize)
391
+ raise ProtocolError, "malformed MSG payload ending: #{suffix.inspect}" unless suffix == CR_LF
392
+
393
+ msg = Message.new(subject: subject, sid: sid, reply: reply, data: payload, connector: self)
394
+ handler = @subscriptions[sid]
395
+ protocol_payload_in(payload)
396
+
397
+ if handler
398
+ handler.call(msg)
399
+ else
400
+ @logger.warn("no subscription handler for sid=#{sid}")
401
+ end
402
+ rescue StandardError => e
403
+ @logger.error("message dispatch error: #{e.class}: #{e.message}")
404
+ end
405
+
406
+ def parse_msg_control_line(control_line)
407
+ tokens = control_line.split(" ")
408
+ raise ProtocolError, "malformed MSG line: #{control_line.inspect}" unless tokens.first == "MSG"
409
+
410
+ case tokens.length
411
+ when 4
412
+ [tokens[1], Integer(tokens[2]), nil, Integer(tokens[3])]
413
+ when 5
414
+ [tokens[1], Integer(tokens[2]), tokens[3], Integer(tokens[4])]
415
+ else
416
+ raise ProtocolError, "unexpected MSG control tokens: #{tokens.length}"
417
+ end
418
+ rescue ArgumentError => e
419
+ raise ProtocolError, "invalid MSG control values: #{e.message}"
420
+ end
421
+
422
+ def protocol_payload_out(payload)
423
+ return if payload.empty?
424
+
425
+ @logger.debug("C->S PAYLOAD #{payload.inspect}")
426
+ end
427
+
428
+ def protocol_payload_in(payload)
429
+ return if payload.empty?
430
+
431
+ @logger.debug("S->C PAYLOAD #{payload.inspect}")
432
+ end
433
+
434
+ def with_temp_subscription(subject, queue: nil, handler: nil)
435
+ sid = subscribe(subject, queue: queue, handler: handler)
436
+ yield
437
+ ensure
438
+ unsubscribe(sid) if sid
439
+ end
440
+
441
+ def build_pub_command(subject, size, reply: nil)
442
+ reply ? "PUB #{subject} #{reply} #{size}" : "PUB #{subject} #{size}"
443
+ end
444
+
445
+ def nkey_connect_fields
446
+ return {} unless nkey_auth?
447
+
448
+ nonce = server_info&.dig(:nonce).to_s
449
+ raise ProtocolError, "server nonce is required for nkey auth" if nonce.empty?
450
+
451
+ {nkey: nkey_public_key_value, sig: nkey_signature(nonce)}
452
+ end
453
+
454
+ def auth_connect_fields
455
+ return {user: @auth_user, pass: @auth_password} if @auth_user && @auth_password
456
+ return {auth_token: @auth_user} if @auth_user
457
+
458
+ nkey_connect_fields
459
+ end
460
+
461
+ def nkey_auth? = !nkey_seed_value.to_s.empty?
462
+
463
+ def nkey_seed_value
464
+ return @nkey_seed if @nkey_seed
465
+ return unless @nkey_seed_file
466
+
467
+ @nkey_seed = File.read(@nkey_seed_file).strip
468
+ end
469
+
470
+ def nkey_public_key_value
471
+ return @nkey_public_key if @nkey_public_key
472
+
473
+ with_nkey_pair { |kp| @nkey_public_key = kp.public_key.dup }
474
+ end
475
+
476
+ def nkey_signature(nonce)
477
+ with_nkey_pair do |kp|
478
+ Base64.urlsafe_encode64(kp.sign(nonce)).delete("=")
479
+ end
480
+ end
481
+
482
+ def with_nkey_pair
483
+ begin
484
+ require "nkeys"
485
+ rescue LoadError
486
+ raise LoadError, "nkeys gem is required for nkey auth"
487
+ end
488
+
489
+ seed = nkey_seed_value.to_s
490
+ raise ArgumentError, "nkey_seed or nkey_seed_file is required for nkey signing" if seed.empty?
491
+
492
+ kp = NKEYS.from_seed(seed)
493
+ yield kp
494
+ ensure
495
+ kp&.wipe!
496
+ end
497
+
498
+ def normalize_subject_prefix(value)
499
+ prefix = value.to_s.strip.sub(/\.+\z/, "")
500
+ raise ArgumentError, "js_api_prefix cannot be empty" if prefix.empty?
501
+
502
+ prefix
503
+ end
504
+
505
+ def tls_params
506
+ params = {}
507
+ if @tls_verify
508
+ params[:verify_mode] = OpenSSL::SSL::VERIFY_PEER
509
+ params[:ca_file] = @tls_ca_file if @tls_ca_file
510
+ params[:ca_path] = @tls_ca_path if @tls_ca_path
511
+ else
512
+ params[:verify_mode] = OpenSSL::SSL::VERIFY_NONE
513
+ end
514
+
515
+ params
516
+ end
517
+
518
+ def tls_hostname_for_ssl(default_host)
519
+ return @tls_hostname if @tls_hostname
520
+ return nil unless @tls_verify
521
+
522
+ default_host
523
+ end
524
+
525
+ def wrap_tls_socket(socket, host)
526
+ context = OpenSSL::SSL::SSLContext.new
527
+ context.set_params(tls_params)
528
+ ssl_socket = OpenSSL::SSL::SSLSocket.new(socket, context)
529
+ ssl_socket.sync_close = true
530
+ if (hostname = tls_hostname_for_ssl(host))
531
+ ssl_socket.hostname = hostname if ssl_socket.respond_to?(:hostname=)
532
+ end
533
+ ssl_socket.connect
534
+ ssl_socket
535
+ rescue StandardError
536
+ ssl_socket&.close rescue nil
537
+ socket.close rescue nil
538
+ raise
539
+ end
540
+
541
+ def presence(value)
542
+ stripped = value.to_s.strip
543
+ stripped.empty? ? nil : stripped
544
+ end
545
+
546
+ def safe_close_stream
547
+ @stream&.close
548
+ @stream = nil
549
+ rescue StandardError
550
+ @stream = nil
551
+ end
552
+ end
553
+ end
data/lib/version.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsAsync
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe NatsAsync do
4
+ it "exposes a version" do
5
+ expect(NatsAsync::VERSION).to eq("0.1.0")
6
+ end
7
+
8
+ it "loads the simple connector" do
9
+ expect(NatsAsync::SimpleConnector).to be_a(Class)
10
+ end
11
+
12
+ it "formats cli help" do
13
+ output = StringIO.new
14
+
15
+ NatsAsync::CommandLine.run([], stdout: output)
16
+
17
+ expect(output.string).to include("Usage: nats-async")
18
+ end
19
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lib/nats-async"
metadata ADDED
@@ -0,0 +1,230 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: nats-async
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - OpenAI Codex
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: async
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.36'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.36'
26
+ - !ruby/object:Gem::Dependency
27
+ name: base64
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.3'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.3'
40
+ - !ruby/object:Gem::Dependency
41
+ name: console
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.34'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.34'
54
+ - !ruby/object:Gem::Dependency
55
+ name: io-endpoint
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.17'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.17'
68
+ - !ruby/object:Gem::Dependency
69
+ name: io-stream
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.11'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.11'
82
+ - !ruby/object:Gem::Dependency
83
+ name: nkeys
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0.1'
89
+ - - "<"
90
+ - !ruby/object:Gem::Version
91
+ version: '1.0'
92
+ type: :runtime
93
+ prerelease: false
94
+ version_requirements: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '0.1'
99
+ - - "<"
100
+ - !ruby/object:Gem::Version
101
+ version: '1.0'
102
+ - !ruby/object:Gem::Dependency
103
+ name: rake
104
+ requirement: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - "~>"
107
+ - !ruby/object:Gem::Version
108
+ version: '13.0'
109
+ type: :development
110
+ prerelease: false
111
+ version_requirements: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - "~>"
114
+ - !ruby/object:Gem::Version
115
+ version: '13.0'
116
+ - !ruby/object:Gem::Dependency
117
+ name: rspec
118
+ requirement: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - "~>"
121
+ - !ruby/object:Gem::Version
122
+ version: '3.10'
123
+ type: :development
124
+ prerelease: false
125
+ version_requirements: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - "~>"
128
+ - !ruby/object:Gem::Version
129
+ version: '3.10'
130
+ - !ruby/object:Gem::Dependency
131
+ name: rubocop
132
+ requirement: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - "~>"
135
+ - !ruby/object:Gem::Version
136
+ version: '1.12'
137
+ type: :development
138
+ prerelease: false
139
+ version_requirements: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - "~>"
142
+ - !ruby/object:Gem::Version
143
+ version: '1.12'
144
+ - !ruby/object:Gem::Dependency
145
+ name: rubocop-rake
146
+ requirement: !ruby/object:Gem::Requirement
147
+ requirements:
148
+ - - "~>"
149
+ - !ruby/object:Gem::Version
150
+ version: 0.6.0
151
+ type: :development
152
+ prerelease: false
153
+ version_requirements: !ruby/object:Gem::Requirement
154
+ requirements:
155
+ - - "~>"
156
+ - !ruby/object:Gem::Version
157
+ version: 0.6.0
158
+ - !ruby/object:Gem::Dependency
159
+ name: rubocop-rspec
160
+ requirement: !ruby/object:Gem::Requirement
161
+ requirements:
162
+ - - "~>"
163
+ - !ruby/object:Gem::Version
164
+ version: 2.14.2
165
+ type: :development
166
+ prerelease: false
167
+ version_requirements: !ruby/object:Gem::Requirement
168
+ requirements:
169
+ - - "~>"
170
+ - !ruby/object:Gem::Version
171
+ version: 2.14.2
172
+ - !ruby/object:Gem::Dependency
173
+ name: rspec_junit_formatter
174
+ requirement: !ruby/object:Gem::Requirement
175
+ requirements:
176
+ - - "~>"
177
+ - !ruby/object:Gem::Version
178
+ version: 0.5.1
179
+ type: :development
180
+ prerelease: false
181
+ version_requirements: !ruby/object:Gem::Requirement
182
+ requirements:
183
+ - - "~>"
184
+ - !ruby/object:Gem::Version
185
+ version: 0.5.1
186
+ description: Lightweight async Ruby connector for NATS with request/reply and basic
187
+ JetStream ack helpers.
188
+ email:
189
+ - author@email.address
190
+ executables:
191
+ - nats-async
192
+ extensions: []
193
+ extra_rdoc_files: []
194
+ files:
195
+ - ".github/workflows/release.yml"
196
+ - ".rspec"
197
+ - ".rubocop.yml"
198
+ - README.erb
199
+ - README.md
200
+ - Rakefile
201
+ - bin/nats-async
202
+ - lib/nats-async.rb
203
+ - lib/nats_async/command_line.rb
204
+ - lib/nats_async/simple_connector.rb
205
+ - lib/version.rb
206
+ - spec/nats_async_spec.rb
207
+ - spec/spec_helper.rb
208
+ homepage: https://rubygems.org/gems/nats-async
209
+ licenses:
210
+ - Nonstandard
211
+ metadata:
212
+ source_code_uri: https://github.com/example/nats-async
213
+ rdoc_options: []
214
+ require_paths:
215
+ - lib
216
+ required_ruby_version: !ruby/object:Gem::Requirement
217
+ requirements:
218
+ - - ">="
219
+ - !ruby/object:Gem::Version
220
+ version: 3.4.4
221
+ required_rubygems_version: !ruby/object:Gem::Requirement
222
+ requirements:
223
+ - - ">="
224
+ - !ruby/object:Gem::Version
225
+ version: '0'
226
+ requirements: []
227
+ rubygems_version: 3.6.7
228
+ specification_version: 4
229
+ summary: Async NATS connector prototype packaged as a gem
230
+ test_files: []