jetstream_bridge 4.4.0 → 4.5.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 +4 -4
- data/CHANGELOG.md +92 -337
- data/README.md +1 -5
- data/docs/GETTING_STARTED.md +11 -7
- data/docs/PRODUCTION.md +51 -11
- data/docs/TESTING.md +24 -35
- data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +1 -1
- data/lib/generators/jetstream_bridge/initializer/initializer_generator.rb +1 -1
- data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +0 -4
- data/lib/generators/jetstream_bridge/install/install_generator.rb +5 -5
- data/lib/generators/jetstream_bridge/migrations/migrations_generator.rb +2 -2
- data/lib/jetstream_bridge/consumer/consumer.rb +34 -96
- data/lib/jetstream_bridge/consumer/health_monitor.rb +107 -0
- data/lib/jetstream_bridge/consumer/message_processor.rb +1 -1
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +51 -34
- data/lib/jetstream_bridge/core/config.rb +153 -46
- data/lib/jetstream_bridge/core/connection_manager.rb +513 -0
- data/lib/jetstream_bridge/core/debug_helper.rb +9 -3
- data/lib/jetstream_bridge/core/health_checker.rb +184 -0
- data/lib/jetstream_bridge/core.rb +0 -2
- data/lib/jetstream_bridge/facade.rb +212 -0
- data/lib/jetstream_bridge/publisher/event_envelope_builder.rb +110 -0
- data/lib/jetstream_bridge/publisher/publisher.rb +87 -117
- data/lib/jetstream_bridge/rails/integration.rb +8 -5
- data/lib/jetstream_bridge/rails/railtie.rb +4 -3
- data/lib/jetstream_bridge/tasks/install.rake +0 -1
- data/lib/jetstream_bridge/topology/topology.rb +6 -1
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +206 -297
- metadata +7 -5
- data/lib/jetstream_bridge/core/bridge_helpers.rb +0 -109
- data/lib/jetstream_bridge/core/connection.rb +0 -464
- data/lib/jetstream_bridge/core/connection_factory.rb +0 -100
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'nats/io/client'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require_relative 'logging'
|
|
6
|
+
require_relative 'config'
|
|
7
|
+
require_relative '../topology/topology'
|
|
8
|
+
|
|
9
|
+
module JetstreamBridge
|
|
10
|
+
# Manages NATS connection lifecycle
|
|
11
|
+
#
|
|
12
|
+
# Responsible for:
|
|
13
|
+
# - Establishing and closing connections
|
|
14
|
+
# - Validating connection URLs
|
|
15
|
+
# - Health checking
|
|
16
|
+
# - Reconnection handling
|
|
17
|
+
# - JetStream context management
|
|
18
|
+
#
|
|
19
|
+
# NOT a singleton - instances are created and injected
|
|
20
|
+
class ConnectionManager
|
|
21
|
+
# Connection states for observability
|
|
22
|
+
module State
|
|
23
|
+
DISCONNECTED = :disconnected
|
|
24
|
+
CONNECTING = :connecting
|
|
25
|
+
CONNECTED = :connected
|
|
26
|
+
RECONNECTING = :reconnecting
|
|
27
|
+
FAILED = :failed
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Valid NATS URL schemes
|
|
31
|
+
VALID_NATS_SCHEMES = %w[nats nats+tls].freeze
|
|
32
|
+
|
|
33
|
+
DEFAULT_CONN_OPTS = {
|
|
34
|
+
reconnect: true,
|
|
35
|
+
reconnect_time_wait: 2,
|
|
36
|
+
max_reconnect_attempts: 10,
|
|
37
|
+
connect_timeout: 5
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
# Health check cache TTL in seconds
|
|
41
|
+
HEALTH_CHECK_CACHE_TTL = 30
|
|
42
|
+
|
|
43
|
+
attr_reader :config, :connected_at, :last_reconnect_error, :last_reconnect_error_at, :state
|
|
44
|
+
|
|
45
|
+
# @param config [Config] Configuration instance
|
|
46
|
+
def initialize(config)
|
|
47
|
+
@config = config
|
|
48
|
+
@mutex = Mutex.new
|
|
49
|
+
@state = State::DISCONNECTED
|
|
50
|
+
@nc = nil
|
|
51
|
+
@jts = nil
|
|
52
|
+
@connected_at = nil
|
|
53
|
+
@cached_health_status = nil
|
|
54
|
+
@last_health_check = nil
|
|
55
|
+
@reconnecting = false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Establish connection to NATS and ensure topology
|
|
59
|
+
#
|
|
60
|
+
# Idempotent - safe to call multiple times
|
|
61
|
+
#
|
|
62
|
+
# @return [NATS::JetStream::JS] JetStream context
|
|
63
|
+
# @raise [ConnectionError] If connection fails
|
|
64
|
+
def connect!
|
|
65
|
+
@mutex.synchronize do
|
|
66
|
+
return @jts if connected_without_lock?
|
|
67
|
+
|
|
68
|
+
servers = validate_and_parse_servers!(@config.nats_urls)
|
|
69
|
+
@state = State::CONNECTING
|
|
70
|
+
|
|
71
|
+
establish_connection_with_retry(servers)
|
|
72
|
+
|
|
73
|
+
Logging.info(
|
|
74
|
+
"Connected to NATS (#{servers.size} server#{'s' unless servers.size == 1}): " \
|
|
75
|
+
"#{sanitize_urls(servers).join(', ')}",
|
|
76
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
ensure_topology_if_enabled
|
|
80
|
+
|
|
81
|
+
@connected_at = Time.now.utc
|
|
82
|
+
@state = State::CONNECTED
|
|
83
|
+
@jts
|
|
84
|
+
end
|
|
85
|
+
rescue StandardError
|
|
86
|
+
@state = State::FAILED
|
|
87
|
+
cleanup_connection!
|
|
88
|
+
raise
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Close the NATS connection
|
|
92
|
+
#
|
|
93
|
+
# @return [void]
|
|
94
|
+
def disconnect!
|
|
95
|
+
@mutex.synchronize do
|
|
96
|
+
cleanup_connection!(close_nc: true)
|
|
97
|
+
@state = State::DISCONNECTED
|
|
98
|
+
Logging.info('Disconnected from NATS', tag: 'JetstreamBridge::ConnectionManager')
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Reconnect to NATS (disconnect + connect)
|
|
103
|
+
#
|
|
104
|
+
# @return [void]
|
|
105
|
+
def reconnect!
|
|
106
|
+
Logging.info('Reconnecting to NATS...', tag: 'JetstreamBridge::ConnectionManager')
|
|
107
|
+
disconnect!
|
|
108
|
+
connect!
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Get JetStream context
|
|
112
|
+
#
|
|
113
|
+
# @return [NATS::JetStream::JS, nil] JetStream context if connected
|
|
114
|
+
def jetstream
|
|
115
|
+
@jts
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Get raw NATS client
|
|
119
|
+
#
|
|
120
|
+
# @return [NATS::IO::Client, nil] NATS client if connected
|
|
121
|
+
def nats_client
|
|
122
|
+
@nc
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Check if connected and healthy
|
|
126
|
+
#
|
|
127
|
+
# @param skip_cache [Boolean] Force fresh health check
|
|
128
|
+
# @return [Boolean] true if connected and healthy
|
|
129
|
+
def connected?(skip_cache: false)
|
|
130
|
+
return false unless @nc&.connected?
|
|
131
|
+
return false unless @jts
|
|
132
|
+
|
|
133
|
+
# Use cached result if available and fresh
|
|
134
|
+
now = Time.now.to_i
|
|
135
|
+
if !skip_cache && @last_health_check && (now - @last_health_check) < HEALTH_CHECK_CACHE_TTL
|
|
136
|
+
return @cached_health_status
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Thread-safe cache update
|
|
140
|
+
@mutex.synchronize do
|
|
141
|
+
# Double-check after acquiring lock
|
|
142
|
+
now = Time.now.to_i
|
|
143
|
+
if !skip_cache && @last_health_check && (now - @last_health_check) < HEALTH_CHECK_CACHE_TTL
|
|
144
|
+
return @cached_health_status
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Perform actual health check
|
|
148
|
+
@cached_health_status = jetstream_healthy?
|
|
149
|
+
@last_health_check = now
|
|
150
|
+
@cached_health_status
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Get detailed health status
|
|
155
|
+
#
|
|
156
|
+
# @param skip_cache [Boolean] Force fresh check
|
|
157
|
+
# @return [Hash] Health status details
|
|
158
|
+
def health_check(skip_cache: false)
|
|
159
|
+
{
|
|
160
|
+
connected: connected?(skip_cache: skip_cache),
|
|
161
|
+
state: @state,
|
|
162
|
+
connected_at: @connected_at&.iso8601,
|
|
163
|
+
last_error: @last_reconnect_error&.message,
|
|
164
|
+
last_error_at: @last_reconnect_error_at&.iso8601
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def connected_without_lock?
|
|
171
|
+
@jts && @nc&.connected?
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def jetstream_healthy?
|
|
175
|
+
return true if @config.disable_js_api
|
|
176
|
+
|
|
177
|
+
# Verify JetStream responds to simple API call
|
|
178
|
+
@jts.account_info
|
|
179
|
+
true
|
|
180
|
+
rescue StandardError => e
|
|
181
|
+
Logging.warn(
|
|
182
|
+
"JetStream health check failed: #{e.class} #{e.message}",
|
|
183
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
184
|
+
)
|
|
185
|
+
false
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def establish_connection_with_retry(servers)
|
|
189
|
+
attempts = 0
|
|
190
|
+
max_attempts = @config.connect_retry_attempts
|
|
191
|
+
retry_delay = @config.connect_retry_delay
|
|
192
|
+
|
|
193
|
+
begin
|
|
194
|
+
attempts += 1
|
|
195
|
+
establish_connection(servers)
|
|
196
|
+
rescue ConnectionError => e
|
|
197
|
+
if attempts < max_attempts
|
|
198
|
+
delay = retry_delay * attempts
|
|
199
|
+
Logging.warn(
|
|
200
|
+
"Connection attempt #{attempts}/#{max_attempts} failed: #{e.message}. " \
|
|
201
|
+
"Retrying in #{delay}s...",
|
|
202
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
203
|
+
)
|
|
204
|
+
sleep(delay)
|
|
205
|
+
retry
|
|
206
|
+
else
|
|
207
|
+
Logging.error(
|
|
208
|
+
"Failed to establish connection after #{attempts} attempts",
|
|
209
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
210
|
+
)
|
|
211
|
+
raise
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def establish_connection(servers)
|
|
217
|
+
@nc = create_nats_client
|
|
218
|
+
|
|
219
|
+
setup_callbacks
|
|
220
|
+
|
|
221
|
+
connect_opts = { servers: servers }.merge(DEFAULT_CONN_OPTS)
|
|
222
|
+
inbox_prefix = @config.inbox_prefix.to_s.strip
|
|
223
|
+
connect_opts[:inbox_prefix] = inbox_prefix unless inbox_prefix.empty?
|
|
224
|
+
|
|
225
|
+
@nc.connect(connect_opts) unless skip_connect?
|
|
226
|
+
|
|
227
|
+
verify_connection!
|
|
228
|
+
|
|
229
|
+
# Create JetStream context
|
|
230
|
+
@jts = @nc.jetstream
|
|
231
|
+
|
|
232
|
+
verify_jetstream!
|
|
233
|
+
|
|
234
|
+
# Ensure JetStream responds to #nc
|
|
235
|
+
add_nc_accessor unless @jts.respond_to?(:nc)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def create_nats_client
|
|
239
|
+
# Use mock NATS client if explicitly enabled for testing
|
|
240
|
+
if test_mode?
|
|
241
|
+
JetstreamBridge.instance_variable_get(:@mock_nats_client)
|
|
242
|
+
else
|
|
243
|
+
NATS::IO::Client.new
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def test_mode?
|
|
248
|
+
defined?(JetstreamBridge::TestHelpers) &&
|
|
249
|
+
JetstreamBridge::TestHelpers.respond_to?(:test_mode?) &&
|
|
250
|
+
JetstreamBridge::TestHelpers.test_mode? &&
|
|
251
|
+
JetstreamBridge.instance_variable_defined?(:@mock_nats_client)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def skip_connect?
|
|
255
|
+
@nc.connected? && test_mode?
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def setup_callbacks
|
|
259
|
+
@nc.on_reconnect do
|
|
260
|
+
@reconnecting = true
|
|
261
|
+
Logging.info(
|
|
262
|
+
'NATS reconnected, refreshing JetStream context',
|
|
263
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
264
|
+
)
|
|
265
|
+
refresh_jetstream_context
|
|
266
|
+
@reconnecting = false
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
@nc.on_disconnect do |reason|
|
|
270
|
+
@state = State::DISCONNECTED
|
|
271
|
+
Logging.warn(
|
|
272
|
+
"NATS disconnected: #{reason}",
|
|
273
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
@nc.on_error do |err|
|
|
278
|
+
Logging.error(
|
|
279
|
+
"NATS error: #{err}",
|
|
280
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
281
|
+
)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def add_nc_accessor
|
|
286
|
+
nc_ref = @nc
|
|
287
|
+
@jts.define_singleton_method(:nc) { nc_ref }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def verify_connection!
|
|
291
|
+
Logging.debug(
|
|
292
|
+
'Verifying NATS connection...',
|
|
293
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
unless @nc.connected?
|
|
297
|
+
Logging.error(
|
|
298
|
+
'NATS connection verification failed - client not connected',
|
|
299
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
300
|
+
)
|
|
301
|
+
raise ConnectionError, 'Failed to establish connection to NATS server(s)'
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
Logging.info(
|
|
305
|
+
'NATS connection verified successfully',
|
|
306
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
307
|
+
)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def verify_jetstream!
|
|
311
|
+
return true if @config.disable_js_api
|
|
312
|
+
|
|
313
|
+
Logging.debug(
|
|
314
|
+
'Verifying JetStream availability...',
|
|
315
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
account_info = @jts.account_info
|
|
319
|
+
|
|
320
|
+
# Handle both object-style and hash-style access for compatibility
|
|
321
|
+
streams = account_info.respond_to?(:streams) ? account_info.streams : account_info[:streams]
|
|
322
|
+
consumers = account_info.respond_to?(:consumers) ? account_info.consumers : account_info[:consumers]
|
|
323
|
+
memory = account_info.respond_to?(:memory) ? account_info.memory : account_info[:memory]
|
|
324
|
+
storage = account_info.respond_to?(:storage) ? account_info.storage : account_info[:storage]
|
|
325
|
+
|
|
326
|
+
Logging.info(
|
|
327
|
+
"JetStream verified - Streams: #{streams}, " \
|
|
328
|
+
"Consumers: #{consumers}, " \
|
|
329
|
+
"Memory: #{format_bytes(memory)}, " \
|
|
330
|
+
"Storage: #{format_bytes(storage)}",
|
|
331
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
332
|
+
)
|
|
333
|
+
rescue NATS::IO::NoRespondersError
|
|
334
|
+
Logging.error(
|
|
335
|
+
'JetStream not available - no responders (JetStream not enabled)',
|
|
336
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
337
|
+
)
|
|
338
|
+
raise ConnectionError, 'JetStream not enabled on NATS server. Please enable JetStream with -js flag'
|
|
339
|
+
rescue StandardError => e
|
|
340
|
+
Logging.error(
|
|
341
|
+
"JetStream verification failed: #{e.class} - #{e.message}",
|
|
342
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
343
|
+
)
|
|
344
|
+
raise ConnectionError, "JetStream verification failed: #{e.message}"
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def ensure_topology_if_enabled
|
|
348
|
+
return if @config.disable_js_api
|
|
349
|
+
|
|
350
|
+
Topology.ensure!(@jts, force: true)
|
|
351
|
+
rescue StandardError => e
|
|
352
|
+
Logging.warn(
|
|
353
|
+
"Topology ensure skipped: #{e.class} #{e.message}",
|
|
354
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
355
|
+
)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def refresh_jetstream_context
|
|
359
|
+
@jts = @nc.jetstream
|
|
360
|
+
add_nc_accessor unless @jts.respond_to?(:nc)
|
|
361
|
+
ensure_topology_if_enabled
|
|
362
|
+
|
|
363
|
+
# Invalidate health check cache on successful reconnect
|
|
364
|
+
@cached_health_status = nil
|
|
365
|
+
@last_health_check = nil
|
|
366
|
+
|
|
367
|
+
# Clear error state on successful reconnect
|
|
368
|
+
@last_reconnect_error = nil
|
|
369
|
+
@last_reconnect_error_at = nil
|
|
370
|
+
@state = State::CONNECTED
|
|
371
|
+
|
|
372
|
+
Logging.info(
|
|
373
|
+
'JetStream context refreshed successfully after reconnect',
|
|
374
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
375
|
+
)
|
|
376
|
+
rescue StandardError => e
|
|
377
|
+
# Store error state for diagnostics
|
|
378
|
+
@last_reconnect_error = e
|
|
379
|
+
@last_reconnect_error_at = Time.now
|
|
380
|
+
@state = State::FAILED
|
|
381
|
+
cleanup_connection!(close_nc: false)
|
|
382
|
+
Logging.error(
|
|
383
|
+
"Failed to refresh JetStream context: #{e.class} #{e.message}",
|
|
384
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
# Invalidate health check cache to force re-check
|
|
388
|
+
@cached_health_status = false
|
|
389
|
+
@last_health_check = Time.now.to_i
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def cleanup_connection!(close_nc: true)
|
|
393
|
+
begin
|
|
394
|
+
# Avoid touching RSpec doubles used in unit tests
|
|
395
|
+
is_rspec_double = defined?(RSpec::Mocks::Double) && @nc.is_a?(RSpec::Mocks::Double)
|
|
396
|
+
@nc.close if !is_rspec_double && close_nc && @nc.respond_to?(:close) && @nc.connected?
|
|
397
|
+
rescue StandardError
|
|
398
|
+
# ignore cleanup errors
|
|
399
|
+
end
|
|
400
|
+
@nc = nil
|
|
401
|
+
@jts = nil
|
|
402
|
+
@cached_health_status = nil
|
|
403
|
+
@last_health_check = nil
|
|
404
|
+
@connected_at = nil
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def format_bytes(bytes)
|
|
408
|
+
return 'N/A' if bytes.nil? || bytes.zero?
|
|
409
|
+
|
|
410
|
+
units = %w[B KB MB GB TB]
|
|
411
|
+
exp = (Math.log(bytes) / Math.log(1024)).to_i
|
|
412
|
+
exp = [exp, units.length - 1].min
|
|
413
|
+
"#{(bytes / (1024.0**exp)).round(2)} #{units[exp]}"
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def sanitize_urls(urls)
|
|
417
|
+
urls.map { |u| Logging.sanitize_url(u) }
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# Validate and parse NATS server URLs
|
|
421
|
+
#
|
|
422
|
+
# @param urls [String] Comma-separated NATS URLs
|
|
423
|
+
# @return [Array<String>] Validated server URLs
|
|
424
|
+
# @raise [ConnectionError] If validation fails
|
|
425
|
+
def validate_and_parse_servers!(urls)
|
|
426
|
+
servers = parse_urls(urls)
|
|
427
|
+
validate_not_empty!(servers)
|
|
428
|
+
|
|
429
|
+
servers.each { |url| validate_url!(url) }
|
|
430
|
+
|
|
431
|
+
Logging.info(
|
|
432
|
+
'All NATS URLs validated successfully',
|
|
433
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
servers
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def parse_urls(urls)
|
|
440
|
+
urls.to_s
|
|
441
|
+
.split(',')
|
|
442
|
+
.map(&:strip)
|
|
443
|
+
.reject(&:empty?)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def validate_not_empty!(servers)
|
|
447
|
+
return unless servers.empty?
|
|
448
|
+
|
|
449
|
+
raise ConnectionError, 'No NATS URLs configured'
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def validate_url!(url)
|
|
453
|
+
validate_url_format!(url)
|
|
454
|
+
|
|
455
|
+
uri = URI.parse(url)
|
|
456
|
+
validate_url_scheme!(uri, url)
|
|
457
|
+
validate_url_host!(uri, url)
|
|
458
|
+
validate_url_port!(uri, url)
|
|
459
|
+
|
|
460
|
+
Logging.debug(
|
|
461
|
+
"URL validated: #{Logging.sanitize_url(url)}",
|
|
462
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
463
|
+
)
|
|
464
|
+
rescue URI::InvalidURIError => e
|
|
465
|
+
Logging.error(
|
|
466
|
+
"Malformed URL: #{url} (#{e.message})",
|
|
467
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
468
|
+
)
|
|
469
|
+
raise ConnectionError, "Invalid NATS URL format: #{url} (#{e.message})"
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def validate_url_format!(url)
|
|
473
|
+
return if url.include?('://')
|
|
474
|
+
|
|
475
|
+
Logging.error(
|
|
476
|
+
"Invalid URL format (missing scheme): #{url}",
|
|
477
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
478
|
+
)
|
|
479
|
+
raise ConnectionError, "Invalid NATS URL format: #{url}. Expected format: nats://host:port"
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def validate_url_scheme!(uri, url)
|
|
483
|
+
scheme = uri.scheme&.downcase
|
|
484
|
+
return if VALID_NATS_SCHEMES.include?(scheme)
|
|
485
|
+
|
|
486
|
+
Logging.error(
|
|
487
|
+
"Invalid URL scheme '#{uri.scheme}': #{Logging.sanitize_url(url)}",
|
|
488
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
489
|
+
)
|
|
490
|
+
raise ConnectionError, "Invalid NATS URL scheme '#{uri.scheme}' in: #{url}. Expected 'nats' or 'nats+tls'"
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def validate_url_host!(uri, url)
|
|
494
|
+
return unless uri.host.nil? || uri.host.empty?
|
|
495
|
+
|
|
496
|
+
Logging.error(
|
|
497
|
+
"Missing host in URL: #{Logging.sanitize_url(url)}",
|
|
498
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
499
|
+
)
|
|
500
|
+
raise ConnectionError, "Invalid NATS URL - missing host: #{url}"
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def validate_url_port!(uri, url)
|
|
504
|
+
return unless uri.port && (uri.port < 1 || uri.port > 65_535)
|
|
505
|
+
|
|
506
|
+
Logging.error(
|
|
507
|
+
"Invalid port #{uri.port} in URL: #{Logging.sanitize_url(url)}",
|
|
508
|
+
tag: 'JetstreamBridge::ConnectionManager'
|
|
509
|
+
)
|
|
510
|
+
raise ConnectionError, "Invalid NATS URL - port must be 1-65535: #{url}"
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
end
|
|
@@ -30,10 +30,13 @@ module JetstreamBridge
|
|
|
30
30
|
def config_debug
|
|
31
31
|
cfg = JetstreamBridge.config
|
|
32
32
|
{
|
|
33
|
-
env: cfg.env,
|
|
34
33
|
app_name: cfg.app_name,
|
|
35
34
|
destination_app: cfg.destination_app,
|
|
36
|
-
stream_name:
|
|
35
|
+
stream_name: begin
|
|
36
|
+
cfg.stream_name
|
|
37
|
+
rescue StandardError
|
|
38
|
+
'ERROR'
|
|
39
|
+
end,
|
|
37
40
|
source_subject: begin
|
|
38
41
|
cfg.source_subject
|
|
39
42
|
rescue StandardError
|
|
@@ -58,7 +61,9 @@ module JetstreamBridge
|
|
|
58
61
|
use_inbox: cfg.use_inbox,
|
|
59
62
|
use_dlq: cfg.use_dlq,
|
|
60
63
|
outbox_model: cfg.outbox_model,
|
|
61
|
-
inbox_model: cfg.inbox_model
|
|
64
|
+
inbox_model: cfg.inbox_model,
|
|
65
|
+
inbox_prefix: cfg.inbox_prefix,
|
|
66
|
+
disable_js_api: cfg.disable_js_api
|
|
62
67
|
}
|
|
63
68
|
end
|
|
64
69
|
|
|
@@ -76,6 +81,7 @@ module JetstreamBridge
|
|
|
76
81
|
|
|
77
82
|
def stream_debug
|
|
78
83
|
return { error: 'Not connected' } unless Connection.instance.connected?
|
|
84
|
+
return { error: 'JS API disabled' } if JetstreamBridge.config.disable_js_api
|
|
79
85
|
|
|
80
86
|
jts = Connection.jetstream
|
|
81
87
|
cfg = JetstreamBridge.config
|