nats-async 0.1.4 → 0.1.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6553bb8adf02cd233df4cc933e2105b5ef5c2be79b55872b66143f01d8162890
4
- data.tar.gz: a0fee08a18816390e046ca7dca4d1763e884584146b5939abc1af2b37aaacdf4
3
+ metadata.gz: 5b1258fa795e1c8f2c02e93508469053ceac348404b146b56d9a158f86cdb864
4
+ data.tar.gz: 89f6419e4e219c23a4b2c3a266cd9d13adedd17604cebd1ff5426a1b68f19270
5
5
  SHA512:
6
- metadata.gz: e8034ee55ee0f575a42890e0f882286bed6c8db5598511c566d754afb6e87ffd624008c99c073074f28f079f100f0e3c35daface6f07d4c83b115119b3a9ad5e
7
- data.tar.gz: e3f757abed15e89ccc47ce6d59ad147dc99779522ce56673958270beba773a17394feff856839d958f0cb246f8bc4776e9c8a0ed5c6c435c8a331f6cacd3d5ea
6
+ metadata.gz: 56005624768a4523e441dc2a2e36438f4f911fbc21f73cb08b686bc9b5fbb7e6edbacaf022ddaa7eb8655c2211877e9f942b5321b8fb048ff31216380e463301
7
+ data.tar.gz: 915d66b577c8c9f332c5134e065460547b36ea07a7b7755e31813d3f2a84f5397f2f459bd918045fe893ae64c860ce55d965029fd168b411bc5349d71e92bdf1
@@ -0,0 +1,36 @@
1
+ name: docs
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+ pages: write
11
+ id-token: write
12
+
13
+ env:
14
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
15
+
16
+ jobs:
17
+ build:
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v5
21
+ - uses: withastro/action@v5
22
+ with:
23
+ path: docs-site
24
+ node-version: 24
25
+ package-manager: npm@11.6.2
26
+
27
+ deploy:
28
+ needs: build
29
+ runs-on: ubuntu-latest
30
+ environment:
31
+ name: github-pages
32
+ url: ${{ steps.deployment.outputs.page_url }}
33
+ steps:
34
+ - name: Deploy to GitHub Pages
35
+ id: deployment
36
+ uses: actions/deploy-pages@v4
data/README.md CHANGED
@@ -21,14 +21,18 @@ gem install nats-async
21
21
  ```ruby
22
22
  require "nats-async"
23
23
 
24
- connector = NatsAsync::SimpleConnector.new(url: "nats://127.0.0.1:4222", verbose: false)
25
- connector.run(duration: 1) do |client, task|
24
+ Async do |task|
25
+ client = NatsAsync::Client.new(url: "nats://127.0.0.1:4222", verbose: false)
26
+ client.start(task: task)
27
+
26
28
  client.subscribe("demo.subject") do |message|
27
29
  puts "received: #{message.data}"
28
- task.stop
29
30
  end
30
31
 
31
32
  client.publish("demo.subject", "hello")
33
+ client.flush
34
+ ensure
35
+ client&.close
32
36
  end
33
37
  ```
34
38
 
@@ -37,7 +41,7 @@ end
37
41
  Core pub/sub:
38
42
 
39
43
  ```bash
40
- bundle exec ruby examples/basic_pub_sub.rb
44
+ bundle exec ruby examples/core_lifecycle.rb
41
45
  ```
42
46
 
43
47
  JetStream publish and pull:
@@ -48,6 +52,16 @@ bundle exec ruby examples/jetstream_roundtrip.rb
48
52
 
49
53
  The integration spec boots the bundled [`bin/nats-server`](bin/nats-server) and runs these examples locally.
50
54
 
55
+ ## Documentation
56
+
57
+ The documentation site is an Astro/Starlight project in [`docs-site`](docs-site).
58
+
59
+ ```bash
60
+ npm --prefix docs-site install
61
+ bundle exec rake docs:dev
62
+ npm --prefix docs-site run build
63
+ ```
64
+
51
65
  ## Development
52
66
 
53
67
  ```bash
data/Rakefile CHANGED
@@ -25,6 +25,32 @@ task :readme do
25
25
  File.write("./README.md", renderer.result)
26
26
  end
27
27
 
28
+ def npm_bin
29
+ return ENV["NPM_BIN"] if ENV["NPM_BIN"] && !ENV["NPM_BIN"].empty?
30
+
31
+ ENV["PATH"].to_s.split(File::PATH_SEPARATOR).each do |path|
32
+ candidate = File.join(path, "npm")
33
+ return candidate if File.executable?(candidate)
34
+ end
35
+
36
+ Dir[File.join(Dir.home, ".nvm/versions/node/*/bin/npm")].sort.last
37
+ end
38
+
39
+ namespace :docs do
40
+ desc "Run documentation development server"
41
+ task :dev do
42
+ npm = npm_bin
43
+ abort "npm was not found. Install Node.js or run with NPM_BIN=/path/to/npm." unless npm
44
+
45
+ npm_path = File.dirname(npm)
46
+ env = {"PATH" => [npm_path, ENV["PATH"]].compact.join(File::PATH_SEPARATOR)}
47
+
48
+ Dir.chdir("docs-site") do
49
+ exec env, npm, "run", "dev"
50
+ end
51
+ end
52
+ end
53
+
28
54
  desc "Build&push new version"
29
55
  task push: %i[spec readme] do
30
56
  puts "Build&push new version"
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "nats-async"
6
+
7
+ Async do |task|
8
+ client = NatsAsync::Client.new(url: ENV.fetch("NATS_URL", "nats://127.0.0.1:4222"))
9
+ client.start(task: task)
10
+
11
+ client.jetstream.add_stream?("inference", subjects: ["jobs.>"])
12
+ backend = client.resolve_backend(mode: :auto, stream: "inference")
13
+ puts "using #{backend}"
14
+ ensure
15
+ client&.close
16
+ end
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "nats-async"
6
+
7
+ Async do |task|
8
+ client = NatsAsync::Client.new(url: ENV.fetch("NATS_URL", "nats://127.0.0.1:4222"))
9
+ client.start(task: task)
10
+ done = Async::Condition.new
11
+
12
+ client.subscribe("demo.>") do |message|
13
+ puts "#{message.subject}: #{message.data}"
14
+ done.signal
15
+ end
16
+ client.publish("demo.created", "hello")
17
+ done.wait
18
+ ensure
19
+ client&.close
20
+ end
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "nats-async"
6
+
7
+ headers = {"traceparent" => "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00"}
8
+ payload = "\x00frame\xFF".b
9
+
10
+ Async do |task|
11
+ client = NatsAsync::Client.new(url: ENV.fetch("NATS_URL", "nats://127.0.0.1:4222"))
12
+ client.start(task: task)
13
+ done = Async::Condition.new
14
+
15
+ client.subscribe("tcp.frames") do |message|
16
+ puts "#{message.headers["traceparent"]}: #{message.data.bytesize} bytes"
17
+ done.signal
18
+ end
19
+ client.publish("tcp.frames", payload, headers: headers)
20
+ done.wait
21
+ ensure
22
+ client&.close
23
+ end
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "nats-async"
6
+
7
+ Async do |task|
8
+ client = NatsAsync::Client.new(url: ENV.fetch("NATS_URL", "nats://127.0.0.1:4222"))
9
+ client.start(task: task)
10
+
11
+ js = client.jetstream
12
+ js.add_stream?("inference", subjects: ["jobs.>"])
13
+ puts js.stream_exists?("inference")
14
+ js.add_consumer?("inference", durable_name: "worker", ack_policy: "explicit", filter_subject: "jobs.>")
15
+ puts js.consumer_info("inference", "worker").dig(:config, :durable_name)
16
+ ensure
17
+ client&.close
18
+ end
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "nats-async"
6
+
7
+ Async do |task|
8
+ client = NatsAsync::Client.new(url: ENV.fetch("NATS_URL", "nats://127.0.0.1:4222"))
9
+ client.start(task: task)
10
+
11
+ js = client.jetstream
12
+ js.add_stream?("jobs", subjects: ["jobs.>"])
13
+
14
+ ack = js.publish("jobs.render", "payload", headers: {"x-id" => "42"})
15
+ puts "stored in #{ack.stream}##{ack.seq}"
16
+ ensure
17
+ client&.close
18
+ end
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "nats-async"
6
+
7
+ Async do |task|
8
+ client = NatsAsync::Client.new(url: ENV.fetch("NATS_URL", "nats://127.0.0.1:4222"))
9
+ client.start(task: task)
10
+
11
+ js = client.jetstream
12
+ js.add_stream?("inference", subjects: ["jobs.>"])
13
+ sub = js.pull_subscribe("jobs.>", stream: "inference", durable: "worker")
14
+ js.publish("jobs.render", "payload")
15
+
16
+ sub.fetch(batch: 5, timeout: 1).each do |message|
17
+ puts message.data
18
+ message.ack
19
+ rescue StandardError
20
+ message.nak
21
+ end
22
+ ensure
23
+ sub&.unsubscribe
24
+ client&.close
25
+ end
@@ -11,51 +11,33 @@ consumer = ENV.fetch("JS_CONSUMER", "example_consumer")
11
11
  payload = ENV.fetch("JS_PAYLOAD", "hello jetstream")
12
12
  js_api_prefix = ENV.fetch("JS_API_PREFIX", "$JS.API")
13
13
 
14
- connector = NatsAsync::SimpleConnector.new(
15
- url: url,
16
- verbose: true,
17
- js_api_prefix: js_api_prefix
18
- )
19
-
20
- connector.run(duration: 3, ping_every: 1, ping_timeout: 1) do |client, _task|
21
- client.request(
22
- client.js_api_subject("STREAM.CREATE", stream_name),
23
- {
24
- name: stream_name,
25
- subjects: [subject]
26
- },
27
- timeout: 2
14
+ Async do |task|
15
+ client = NatsAsync::Client.new(
16
+ url: url,
17
+ verbose: true,
18
+ js_api_prefix: js_api_prefix,
19
+ ping_interval: 1,
20
+ ping_timeout: 1
28
21
  )
22
+ client.start(task: task)
29
23
 
30
- client.request(
31
- client.js_api_subject("CONSUMER.CREATE", stream_name, consumer),
32
- {
33
- stream_name: stream_name,
34
- config: {
35
- name: consumer,
36
- durable_name: consumer,
37
- ack_policy: "explicit",
38
- deliver_policy: "new",
39
- filter_subject: subject
40
- }
41
- },
42
- timeout: 2
43
- )
24
+ js = client.jetstream
25
+ js.add_stream?(stream_name, subjects: [subject])
26
+ sub = js.pull_subscribe(subject, stream: stream_name, durable: consumer)
44
27
 
45
- pub_ack = client.request(subject, payload, timeout: 2)
46
- puts "published seq=#{pub_ack[:seq]} stream=#{pub_ack[:stream]}"
28
+ pub_ack = js.publish(subject, payload)
29
+ puts "published seq=#{pub_ack.seq} stream=#{pub_ack.stream}"
47
30
 
48
- message = client.request(
49
- client.js_api_subject("CONSUMER.MSG.NEXT", stream_name, consumer),
50
- {batch: 1, expires: 1_000_000_000},
51
- timeout: 2,
52
- parse_json: false
53
- )
31
+ message = sub.fetch(batch: 1, timeout: 2).first
54
32
 
55
33
  puts "received subject=#{message.subject} data=#{message.data.inspect}"
56
34
  puts "metadata=#{message.metadata.inspect}"
57
- message.ack if message.reply
58
-
59
- client.request(client.js_api_subject("CONSUMER.DELETE", stream_name, consumer), {}, timeout: 2)
60
- client.request(client.js_api_subject("STREAM.DELETE", stream_name), {}, timeout: 2)
35
+ message.ack
36
+
37
+ sub.unsubscribe
38
+ js.delete_consumer(stream_name, consumer)
39
+ js.delete_stream(stream_name)
40
+ ensure
41
+ sub&.unsubscribe
42
+ client&.close
61
43
  end
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "nats-async"
6
+
7
+ Async do |task|
8
+ client = NatsAsync::Client.new(url: ENV.fetch("NATS_URL", "nats://127.0.0.1:4222"))
9
+ client.start(task: task)
10
+ done = Async::Condition.new
11
+
12
+ client.subscribe("jobs.render", queue: "renderers") do |message|
13
+ puts "worker got #{message.data}"
14
+ done.signal
15
+ end
16
+
17
+ client.publish("jobs.render", "job-1")
18
+ done.wait
19
+ ensure
20
+ client&.drain(timeout: 5)
21
+ end
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "nats-async"
6
+
7
+ Async do |task|
8
+ client = NatsAsync::Client.new(url: ENV.fetch("NATS_URL", "nats://127.0.0.1:4222"))
9
+ client.start(task: task)
10
+
11
+ client.subscribe("math.double") { |message| client.publish(message.reply, message.data.to_i * 2) }
12
+ puts client.request("math.double", "21", timeout: 1)
13
+ ensure
14
+ client&.close
15
+ end
data/lib/nats-async.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "version"
4
- require_relative "nats_async/simple_connector"
4
+ require_relative "nats_async/client"
5
+ require_relative "nats_async/jetstream"
@@ -15,8 +15,9 @@ require "base64"
15
15
  require "openssl"
16
16
 
17
17
  module NatsAsync
18
- class SimpleConnector
18
+ class Client
19
19
  CR_LF = "\r\n"
20
+ HEADER_LINE = "NATS/1.0"
20
21
 
21
22
  class AckError < StandardError; end
22
23
  class MsgAlreadyAcked < AckError; end
@@ -25,19 +26,34 @@ module NatsAsync
25
26
  class ResponseParseError < RequestError; end
26
27
  class ProtocolError < StandardError; end
27
28
 
29
+ class Headers < Hash
30
+ def self.wrap(values)
31
+ new.tap { |headers| values.each { |key, value| headers[key] = value } }
32
+ end
33
+
34
+ def [](key)
35
+ return super if key?(key)
36
+
37
+ match = keys.find { |existing| existing.to_s.casecmp?(key.to_s) }
38
+ match ? super(match) : nil
39
+ end
40
+ end
41
+
28
42
  class Message
29
43
  ACK = "+ACK"
30
44
  NAK = "-NAK"
31
45
  TERM = "+TERM"
32
46
  WPI = "+WPI"
33
47
 
34
- attr_reader :subject, :sid, :reply, :data
48
+ attr_reader :subject, :sid, :reply, :data, :headers
49
+ alias header headers
35
50
 
36
- def initialize(subject:, sid:, reply:, data:, connector:)
51
+ def initialize(subject:, sid:, reply:, data:, connector:, headers: {})
37
52
  @subject = subject
38
53
  @sid = sid
39
54
  @reply = reply
40
55
  @data = data
56
+ @headers = Headers.wrap(headers)
41
57
  @connector = connector
42
58
  @acked = false
43
59
  end
@@ -58,6 +74,14 @@ module NatsAsync
58
74
  publish_ack(WPI, timeout: timeout)
59
75
  end
60
76
 
77
+ def ackable?
78
+ !reply.to_s.empty?
79
+ end
80
+
81
+ def acked?
82
+ @acked
83
+ end
84
+
61
85
  def metadata
62
86
  return unless reply&.start_with?("$JS.ACK.")
63
87
 
@@ -119,6 +143,8 @@ module NatsAsync
119
143
  url: "nats://127.0.0.1:4222",
120
144
  verbose: true,
121
145
  js_api_prefix: "$JS.API",
146
+ ping_interval: 30,
147
+ ping_timeout: 5,
122
148
  tls: nil,
123
149
  tls_verify: true,
124
150
  tls_ca_file: nil,
@@ -134,6 +160,8 @@ module NatsAsync
134
160
  @url = URI(url)
135
161
  @verbose = verbose
136
162
  @js_api_prefix = normalize_subject_prefix(js_api_prefix)
163
+ @ping_interval = ping_interval
164
+ @ping_timeout = ping_timeout
137
165
  @tls_enabled = tls.nil? ? %w[tls nats+tls].include?(@url.scheme) : tls
138
166
  @tls_verify = tls_verify
139
167
  @tls_ca_file = presence(tls_ca_file)
@@ -154,7 +182,10 @@ module NatsAsync
154
182
  @server_info = nil
155
183
 
156
184
  @read_task = nil
185
+ @ping_task = nil
157
186
  @read_error = nil
187
+ @started = false
188
+ @closed = true
158
189
  @write_lock = Async::Semaphore.new(1)
159
190
  @pong_condition = Async::Condition.new
160
191
  @sid_seq = 0
@@ -163,32 +194,89 @@ module NatsAsync
163
194
 
164
195
  attr_reader :received_pings, :received_pongs, :sent_pings, :server_info, :js_api_prefix
165
196
 
166
- def run(duration: 10, ping_every: 2, ping_timeout: 2)
167
- Async do |task|
168
- connect!
169
- read_initial_info!
170
- send_connect!
197
+ def start(task:)
198
+ return self if connected?
199
+
200
+ connect!
201
+ read_initial_info!
202
+ send_connect!
203
+ @read_task = task.async { read_loop }
204
+ @started = true
205
+ @closed = false
206
+ ping!(timeout: @ping_timeout)
207
+ start_ping_loop(task)
208
+ self
209
+ rescue StandardError
210
+ stop
211
+ @started = false
212
+ @closed = true
213
+ raise
214
+ end
171
215
 
172
- @read_task = task.async { read_loop }
173
- ping!(timeout: ping_timeout)
174
- yield self, task if block_given?
216
+ def close
217
+ return true if closed?
175
218
 
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
219
+ stop
220
+ @started = false
221
+ @closed = true
222
+ true
223
+ end
224
+
225
+ def resolve_backend(mode: :auto, stream: nil)
226
+ mode = mode.to_sym
227
+ return :core if mode == :core
228
+
229
+ raise ArgumentError, "stream is required for #{mode} backend" if stream.to_s.empty?
230
+
231
+ case mode
232
+ when :jetstream
233
+ jetstream.stream_info(stream)
234
+ :jetstream
235
+ when :auto
236
+ jetstream.stream_exists?(stream) ? :jetstream : :core
237
+ else
238
+ raise ArgumentError, "unsupported backend mode: #{mode.inspect}"
239
+ end
240
+ rescue JetStream::Error
241
+ raise if mode == :jetstream
242
+
243
+ :core
244
+ end
245
+
246
+ def drain(timeout: 5)
247
+ @ping_task&.stop
248
+ @ping_task = nil
249
+ flush(timeout: timeout) if connected?
250
+ true
251
+ ensure
252
+ close
184
253
  end
185
254
 
186
255
  def stop
256
+ @ping_task&.stop
187
257
  @read_task&.stop
188
258
  safe_close_stream
259
+ @ping_task = nil
189
260
  @read_task = nil
190
261
  end
191
262
 
263
+ def flush(timeout: 2)
264
+ ping!(timeout: timeout)
265
+ true
266
+ end
267
+
268
+ def connected?
269
+ @started && !@closed && !@stream.nil?
270
+ end
271
+
272
+ def closed?
273
+ @closed
274
+ end
275
+
276
+ def last_error
277
+ @read_error
278
+ end
279
+
192
280
  def ping!(timeout: 2)
193
281
  expected_pongs = @received_pongs + 1
194
282
 
@@ -200,8 +288,10 @@ module NatsAsync
200
288
  })
201
289
  end
202
290
 
203
- def publish(subject, payload = "", reply: nil)
291
+ def publish(subject, payload = "", reply: nil, headers: nil)
204
292
  payload = payload.to_s
293
+ return publish_with_headers(subject, payload, headers, reply: reply) if headers && !headers.empty?
294
+
205
295
  command = build_pub_command(subject, payload.bytesize, reply: reply)
206
296
  @logger.debug("C->S #{command}")
207
297
 
@@ -230,20 +320,20 @@ module NatsAsync
230
320
  true
231
321
  end
232
322
 
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
323
+ def request(subject, payload = "", timeout: 0.5, parse_json: false, headers: nil)
324
+ response = request_message(subject, payload, timeout: timeout, headers: headers)
325
+ result = parse_json ? parse_json_response(subject, response.data) : response.data
236
326
  ensure_request_ok!(subject, result) if parse_json
237
327
  block_given? ? yield(result) : result
238
328
  end
239
329
 
240
- def request_message(subject, payload = "", timeout: 0.5)
330
+ def request_message(subject, payload = "", timeout: 0.5, headers: nil)
241
331
  inbox = "_INBOX.#{rand(1 << 30)}.#{next_sid}"
242
332
  response = nil
243
333
  condition = Async::Condition.new
244
334
  on_response = ->(msg) { response = msg; condition.signal }
245
335
  with_temp_subscription(inbox, handler: on_response) do
246
- publish(subject, request_payload(payload), reply: inbox)
336
+ publish(subject, request_payload(payload), reply: inbox, headers: headers)
247
337
  await(timeout: timeout, condition: condition, timeout_message: "request timeout after #{timeout}s", predicate: lambda {
248
338
  raise @read_error if @read_error
249
339
  !response.nil?
@@ -257,8 +347,29 @@ module NatsAsync
257
347
  [js_api_prefix, *tokens.flatten].compact.map(&:to_s).reject(&:empty?).join(".")
258
348
  end
259
349
 
350
+ def jetstream
351
+ @jetstream ||= JetStream.new(self)
352
+ end
353
+
260
354
  private
261
355
 
356
+ def start_ping_loop(task)
357
+ return unless @ping_interval && @ping_interval.positive?
358
+
359
+ @ping_task = task.async { |ping_task| ping_loop(ping_task) }
360
+ end
361
+
362
+ def ping_loop(task)
363
+ loop do
364
+ task.sleep(@ping_interval)
365
+ ping!(timeout: @ping_timeout)
366
+ end
367
+ rescue StandardError => e
368
+ @read_error ||= e
369
+ @logger.error("ping loop error: #{e.class}: #{e.message}")
370
+ safe_close_stream
371
+ end
372
+
262
373
  def connect!
263
374
  host = @url.host || "127.0.0.1"
264
375
  port = @url.port || 4222
@@ -286,6 +397,7 @@ module NatsAsync
286
397
  lang: "ruby",
287
398
  version: "nats-async-playground",
288
399
  protocol: 1,
400
+ headers: true,
289
401
  echo: true
290
402
  }
291
403
  payload.merge!(auth_connect_fields)
@@ -307,6 +419,8 @@ module NatsAsync
307
419
  raise ProtocolError, "server error: #{line}"
308
420
  when /\AINFO\s+/
309
421
  @server_info = parse_info_line(line)
422
+ when /\AHMSG\s+/
423
+ dispatch_hmsg(line)
310
424
  when /\AMSG\s+/
311
425
  dispatch_msg(line)
312
426
  end
@@ -390,17 +504,36 @@ module NatsAsync
390
504
  suffix = @stream.read_exactly(CR_LF.bytesize)
391
505
  raise ProtocolError, "malformed MSG payload ending: #{suffix.inspect}" unless suffix == CR_LF
392
506
 
393
- msg = Message.new(subject: subject, sid: sid, reply: reply, data: payload, connector: self)
394
- handler = @subscriptions[sid]
395
- protocol_payload_in(payload)
507
+ dispatch_message(Message.new(subject: subject, sid: sid, reply: reply, data: payload, connector: self))
508
+ rescue StandardError => e
509
+ @logger.error("message dispatch error: #{e.class}: #{e.message}")
510
+ end
511
+
512
+ def dispatch_hmsg(control_line)
513
+ subject, sid, reply, header_size, total_size = parse_hmsg_control_line(control_line)
514
+ raise ProtocolError, "HMSG header size exceeds total size" if header_size > total_size
515
+
516
+ data = @stream.read_exactly(total_size)
517
+ suffix = @stream.read_exactly(CR_LF.bytesize)
518
+ raise ProtocolError, "malformed HMSG payload ending: #{suffix.inspect}" unless suffix == CR_LF
519
+
520
+ header_block = data.byteslice(0, header_size) || +""
521
+ payload = data.byteslice(header_size, total_size - header_size) || +""
522
+ headers = parse_header_block(header_block)
523
+ dispatch_message(Message.new(subject: subject, sid: sid, reply: reply, data: payload, connector: self, headers: headers))
524
+ rescue StandardError => e
525
+ @logger.error("header message dispatch error: #{e.class}: #{e.message}")
526
+ end
527
+
528
+ def dispatch_message(message)
529
+ handler = @subscriptions[message.sid]
530
+ protocol_payload_in(message.data)
396
531
 
397
532
  if handler
398
- handler.call(msg)
533
+ handler.call(message)
399
534
  else
400
- @logger.warn("no subscription handler for sid=#{sid}")
535
+ @logger.warn("no subscription handler for sid=#{message.sid}")
401
536
  end
402
- rescue StandardError => e
403
- @logger.error("message dispatch error: #{e.class}: #{e.message}")
404
537
  end
405
538
 
406
539
  def parse_msg_control_line(control_line)
@@ -419,6 +552,80 @@ module NatsAsync
419
552
  raise ProtocolError, "invalid MSG control values: #{e.message}"
420
553
  end
421
554
 
555
+ def parse_hmsg_control_line(control_line)
556
+ tokens = control_line.split(" ")
557
+ raise ProtocolError, "malformed HMSG line: #{control_line.inspect}" unless tokens.first == "HMSG"
558
+
559
+ case tokens.length
560
+ when 5
561
+ [tokens[1], Integer(tokens[2]), nil, Integer(tokens[3]), Integer(tokens[4])]
562
+ when 6
563
+ [tokens[1], Integer(tokens[2]), tokens[3], Integer(tokens[4]), Integer(tokens[5])]
564
+ else
565
+ raise ProtocolError, "unexpected HMSG control tokens: #{tokens.length}"
566
+ end
567
+ rescue ArgumentError => e
568
+ raise ProtocolError, "invalid HMSG control values: #{e.message}"
569
+ end
570
+
571
+ def publish_with_headers(subject, payload, headers, reply: nil)
572
+ header_block = build_header_block(headers)
573
+ command = build_hpub_command(subject, header_block.bytesize, header_block.bytesize + payload.bytesize, reply: reply)
574
+ @logger.debug("C->S #{command}")
575
+
576
+ @write_lock.acquire do
577
+ @stream.write("#{command}#{CR_LF}", flush: false)
578
+ @stream.write(header_block, flush: false)
579
+ @stream.write(payload, flush: false)
580
+ @stream.write(CR_LF, flush: true)
581
+ end
582
+ protocol_payload_out(payload)
583
+ end
584
+
585
+ def build_header_block(headers)
586
+ lines = [HEADER_LINE]
587
+ headers.each do |key, value|
588
+ header_name = validate_header_name(key)
589
+ Array(value).each do |item|
590
+ lines << "#{header_name}: #{validate_header_value(item)}"
591
+ end
592
+ end
593
+
594
+ "#{lines.join(CR_LF)}#{CR_LF}#{CR_LF}".b
595
+ end
596
+
597
+ def parse_header_block(block)
598
+ lines = block.split(CR_LF)
599
+ status = lines.shift
600
+ raise ProtocolError, "invalid header block status: #{status.inspect}" unless status&.start_with?(HEADER_LINE)
601
+
602
+ lines.each_with_object({}) do |line, headers|
603
+ next if line.empty?
604
+
605
+ key, value = line.split(":", 2)
606
+ raise ProtocolError, "malformed header line: #{line.inspect}" unless key && value
607
+
608
+ value = value.sub(/\A[ \t]/, "")
609
+ existing = headers[key]
610
+ headers[key] = existing ? Array(existing).push(value) : value
611
+ end
612
+ end
613
+
614
+ def validate_header_name(key)
615
+ name = key.to_s
616
+ raise ArgumentError, "header name cannot be empty" if name.empty?
617
+ raise ArgumentError, "invalid header name: #{name.inspect}" if name.match?(/[:\r\n]/)
618
+
619
+ name
620
+ end
621
+
622
+ def validate_header_value(value)
623
+ string = value.to_s
624
+ raise ArgumentError, "invalid header value: #{string.inspect}" if string.match?(/[\r\n]/)
625
+
626
+ string
627
+ end
628
+
422
629
  def protocol_payload_out(payload)
423
630
  return if payload.empty?
424
631
 
@@ -442,6 +649,10 @@ module NatsAsync
442
649
  reply ? "PUB #{subject} #{reply} #{size}" : "PUB #{subject} #{size}"
443
650
  end
444
651
 
652
+ def build_hpub_command(subject, header_size, total_size, reply: nil)
653
+ reply ? "HPUB #{subject} #{reply} #{header_size} #{total_size}" : "HPUB #{subject} #{header_size} #{total_size}"
654
+ end
655
+
445
656
  def nkey_connect_fields
446
657
  return {} unless nkey_auth?
447
658
 
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsAsync
4
+ class JetStream
5
+ class Error < Client::RequestError
6
+ attr_reader :code, :err_code, :description
7
+
8
+ def initialize(message, code: nil, err_code: nil, description: nil)
9
+ super(message)
10
+ @code = code
11
+ @err_code = err_code
12
+ @description = description
13
+ end
14
+ end
15
+
16
+ class NotFound < Error; end
17
+ class ConsumerError < Error; end
18
+
19
+ PublishAck = Struct.new(:stream, :seq, :duplicate, keyword_init: true) do
20
+ def duplicate?
21
+ !!duplicate
22
+ end
23
+ end
24
+
25
+ class PullSubscription
26
+ def initialize(client:, stream:, consumer:)
27
+ @client = client
28
+ @stream = stream
29
+ @consumer = consumer
30
+ @inbox = "_INBOX.#{rand(1 << 30)}.#{object_id.abs}"
31
+ @sid = @client.subscribe(@inbox) { |message| receive(message) }
32
+ end
33
+
34
+ def fetch(batch: 1, timeout: 1)
35
+ @messages = []
36
+ @done = false
37
+ @batch = batch
38
+ @condition = Async::Condition.new
39
+
40
+ @client.publish(next_subject, JSON.generate({batch: batch, expires: seconds_to_nanoseconds(timeout)}), reply: @inbox)
41
+ wait_for_messages(timeout)
42
+ @messages
43
+ ensure
44
+ @messages = nil
45
+ @condition = nil
46
+ @batch = nil
47
+ @done = false
48
+ end
49
+
50
+ def unsubscribe
51
+ return true unless @sid
52
+
53
+ @client.unsubscribe(@sid)
54
+ @sid = nil
55
+ true
56
+ end
57
+
58
+ private
59
+
60
+ def receive(message)
61
+ return unless @condition
62
+
63
+ if status_message?(message)
64
+ @done = true
65
+ else
66
+ @messages << message
67
+ end
68
+
69
+ @condition.signal if @done || @messages.size >= @batch
70
+ end
71
+
72
+ def wait_for_messages(timeout)
73
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
74
+
75
+ until @done || @messages.size >= @batch
76
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
77
+ break if remaining <= 0
78
+
79
+ begin
80
+ Async::Task.current.with_timeout(remaining) { @condition.wait }
81
+ rescue Timeout::Error, Async::TimeoutError
82
+ break
83
+ end
84
+ end
85
+ end
86
+
87
+ def status_message?(message)
88
+ !message.headers["Status"].to_s.empty?
89
+ end
90
+
91
+ def next_subject
92
+ @client.js_api_subject("CONSUMER.MSG.NEXT", @stream, @consumer)
93
+ end
94
+
95
+ def seconds_to_nanoseconds(value)
96
+ (value.to_f * 1_000_000_000).to_i
97
+ end
98
+ end
99
+
100
+ def initialize(client)
101
+ @client = client
102
+ end
103
+
104
+ def stream_info(name)
105
+ api_request("STREAM.INFO", name)
106
+ rescue Error => e
107
+ raise not_found_error(e) if not_found?(e)
108
+
109
+ raise
110
+ end
111
+
112
+ def available?
113
+ account_info
114
+ true
115
+ rescue Error
116
+ false
117
+ end
118
+
119
+ def account_info
120
+ api_request("INFO")
121
+ end
122
+
123
+ def stream_exists?(name)
124
+ stream_info(name)
125
+ true
126
+ rescue NotFound
127
+ false
128
+ end
129
+
130
+ def add_stream(name, config = nil, **options)
131
+ config = merge_config(config, options).merge(name: name)
132
+ api_request("STREAM.CREATE", name, config)
133
+ end
134
+
135
+ def add_stream?(name, config = nil, **options)
136
+ return false if stream_exists?(name)
137
+
138
+ add_stream(name, config, **options)
139
+ true
140
+ end
141
+
142
+ def delete_stream(name)
143
+ api_request("STREAM.DELETE", name, {})
144
+ end
145
+
146
+ def consumer_info(stream, consumer)
147
+ api_request("CONSUMER.INFO", stream, consumer)
148
+ rescue Error => e
149
+ raise not_found_error(e) if not_found?(e)
150
+
151
+ raise
152
+ end
153
+
154
+ def consumer_exists?(stream, consumer)
155
+ consumer_info(stream, consumer)
156
+ true
157
+ rescue NotFound
158
+ false
159
+ end
160
+
161
+ def add_consumer(stream, config = nil, **options)
162
+ config = merge_config(config, options)
163
+ consumer = consumer_name(config)
164
+ subject = consumer ? @client.js_api_subject("CONSUMER.CREATE", stream, consumer) : @client.js_api_subject("CONSUMER.CREATE", stream)
165
+ api_request_subject(subject, {stream_name: stream, config: config})
166
+ rescue Error => e
167
+ raise ConsumerError.new(e.message, code: e.code, err_code: e.err_code, description: e.description)
168
+ end
169
+
170
+ def add_consumer?(stream, config = nil, **options)
171
+ config = merge_config(config, options)
172
+ consumer = consumer_name(config)
173
+ return false if consumer && consumer_exists?(stream, consumer)
174
+
175
+ add_consumer(stream, config)
176
+ true
177
+ end
178
+
179
+ def delete_consumer(stream, consumer)
180
+ api_request("CONSUMER.DELETE", stream, consumer, {})
181
+ end
182
+
183
+ def publish(subject, payload = "", headers: nil, timeout: 2)
184
+ response = @client.request_message(subject, payload, timeout: timeout, headers: headers)
185
+ result = JSON.parse(response.data, symbolize_names: true)
186
+ raise_api_error(subject, result[:error]) if result[:error]
187
+
188
+ PublishAck.new(stream: result[:stream], seq: result[:seq], duplicate: result[:duplicate])
189
+ rescue JSON::ParserError => e
190
+ raise Error, "JetStream publish returned invalid JSON for #{subject}: #{e.message}"
191
+ end
192
+
193
+ def pull_subscribe(subject, stream:, durable: nil, consumer: nil, config: {}, create: true)
194
+ config = merge_config(config, {})
195
+ config[:filter_subject] ||= subject
196
+ config[:ack_policy] ||= "explicit"
197
+ config[:durable_name] ||= durable if durable
198
+ config[:name] ||= consumer if consumer
199
+
200
+ consumer_name = consumer_name(config)
201
+ raise ArgumentError, "durable, consumer, config[:name], or config[:durable_name] is required" if consumer_name.to_s.empty?
202
+
203
+ add_consumer?(stream, config) if create
204
+ PullSubscription.new(client: @client, stream: stream, consumer: consumer_name)
205
+ end
206
+
207
+ private
208
+
209
+ def api_request(*tokens)
210
+ payload = tokens.last.is_a?(Hash) ? tokens.pop : {}
211
+ api_request_subject(@client.js_api_subject(*tokens), payload)
212
+ end
213
+
214
+ def api_request_subject(subject, payload)
215
+ response = @client.request_message(subject, payload, timeout: 2)
216
+ result = JSON.parse(response.data, symbolize_names: true)
217
+ raise_api_error(subject, result[:error]) if result[:error]
218
+
219
+ result
220
+ rescue JSON::ParserError => e
221
+ raise Error, "JetStream API returned invalid JSON for #{subject}: #{e.message}"
222
+ end
223
+
224
+ def raise_api_error(subject, error)
225
+ details = error.is_a?(Hash) ? error : {description: error.to_s}
226
+ code = details[:code]
227
+ err_code = details[:err_code]
228
+ description = details[:description] || details[:message] || details.inspect
229
+ klass = not_found_code?(code, description) ? NotFound : Error
230
+ raise klass.new("JetStream API request failed for #{subject}: #{description}", code: code, err_code: err_code, description: description)
231
+ end
232
+
233
+ def not_found?(error)
234
+ error.is_a?(NotFound) || not_found_code?(error.code, error.description)
235
+ end
236
+
237
+ def not_found_code?(code, description)
238
+ code.to_i == 404 || description.to_s.match?(/not found/i)
239
+ end
240
+
241
+ def not_found_error(error)
242
+ NotFound.new(error.message, code: error.code, err_code: error.err_code, description: error.description)
243
+ end
244
+
245
+ def merge_config(config, options)
246
+ (config || {}).transform_keys(&:to_sym).merge(options)
247
+ end
248
+
249
+ def consumer_name(config)
250
+ config[:name] || config[:durable_name]
251
+ end
252
+ end
253
+ end
data/lib/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NatsAsync
4
- VERSION = "0.1.4"
4
+ VERSION = "0.1.5"
5
5
  end
@@ -88,7 +88,14 @@ RSpec.describe "example scripts" do
88
88
 
89
89
  begin
90
90
  wait_for_server(port, server, log_path)
91
- run_example("examples/basic_pub_sub.rb", {"NATS_URL" => url})
91
+ run_example("examples/core_lifecycle.rb", {"NATS_URL" => url})
92
+ run_example("examples/queue_group.rb", {"NATS_URL" => url})
93
+ run_example("examples/request_reply.rb", {"NATS_URL" => url})
94
+ run_example("examples/headers_and_binary.rb", {"NATS_URL" => url})
95
+ run_example("examples/jetstream_management.rb", {"NATS_URL" => url})
96
+ run_example("examples/jetstream_publish.rb", {"NATS_URL" => url})
97
+ run_example("examples/backend_detection.rb", {"NATS_URL" => url})
98
+ run_example("examples/jetstream_pull_consumer.rb", {"NATS_URL" => url})
92
99
  run_example(
93
100
  "examples/jetstream_roundtrip.rb",
94
101
  {
@@ -7,7 +7,7 @@ describe NatsAsync do
7
7
  # expect(NatsAsync::VERSION).to eq("0.1.0")
8
8
  # end
9
9
 
10
- it "loads the simple connector" do
11
- expect(NatsAsync::SimpleConnector).to be_a(Class)
10
+ it "loads the client" do
11
+ expect(NatsAsync::Client).to be_a(Class)
12
12
  end
13
13
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nats-async
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Artem Borodkin
@@ -190,15 +190,24 @@ executables: []
190
190
  extensions: []
191
191
  extra_rdoc_files: []
192
192
  files:
193
+ - ".github/workflows/docs.yml"
193
194
  - ".github/workflows/release.yml"
194
195
  - ".rspec"
195
196
  - ".rubocop.yml"
196
197
  - README.md
197
198
  - Rakefile
198
- - examples/basic_pub_sub.rb
199
+ - examples/backend_detection.rb
200
+ - examples/core_lifecycle.rb
201
+ - examples/headers_and_binary.rb
202
+ - examples/jetstream_management.rb
203
+ - examples/jetstream_publish.rb
204
+ - examples/jetstream_pull_consumer.rb
199
205
  - examples/jetstream_roundtrip.rb
206
+ - examples/queue_group.rb
207
+ - examples/request_reply.rb
200
208
  - lib/nats-async.rb
201
- - lib/nats_async/simple_connector.rb
209
+ - lib/nats_async/client.rb
210
+ - lib/nats_async/jetstream.rb
202
211
  - lib/version.rb
203
212
  - spec/examples_spec.rb
204
213
  - spec/nats_async_spec.rb
@@ -1,21 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- require "bundler/setup"
5
- require "nats-async"
6
-
7
- url = ENV.fetch("NATS_URL", "nats://127.0.0.1:4222")
8
- subject = ENV.fetch("NATS_SUBJECT", "demo.subject")
9
- payload = ENV.fetch("NATS_PAYLOAD", "hello from nats-async")
10
-
11
- connector = NatsAsync::SimpleConnector.new(url: url, verbose: true)
12
-
13
- connector.run(duration: 1.5, ping_every: 1, ping_timeout: 1) do |client, task|
14
- sid = client.subscribe(subject) do |message|
15
- puts "received subject=#{message.subject} data=#{message.data.inspect}"
16
- client.unsubscribe(sid)
17
- task.stop
18
- end
19
-
20
- client.publish(subject, payload)
21
- end