jetstream_bridge 4.5.0 → 4.5.1
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 +338 -87
- data/README.md +3 -13
- data/docs/GETTING_STARTED.md +8 -12
- data/docs/PRODUCTION.md +13 -35
- data/docs/RESTRICTED_PERMISSIONS.md +399 -0
- data/docs/TESTING.md +33 -22
- data/lib/generators/jetstream_bridge/health_check/health_check_generator.rb +3 -3
- data/lib/generators/jetstream_bridge/initializer/templates/jetstream_bridge.rb +3 -0
- data/lib/jetstream_bridge/consumer/consumer.rb +100 -39
- data/lib/jetstream_bridge/consumer/message_processor.rb +1 -1
- data/lib/jetstream_bridge/consumer/subscription_manager.rb +97 -121
- data/lib/jetstream_bridge/core/bridge_helpers.rb +127 -0
- data/lib/jetstream_bridge/core/config.rb +32 -161
- data/lib/jetstream_bridge/core/connection.rb +508 -0
- data/lib/jetstream_bridge/core/connection_factory.rb +95 -0
- data/lib/jetstream_bridge/core/debug_helper.rb +2 -9
- data/lib/jetstream_bridge/core.rb +2 -0
- data/lib/jetstream_bridge/models/subject.rb +15 -23
- data/lib/jetstream_bridge/provisioner.rb +67 -0
- data/lib/jetstream_bridge/publisher/publisher.rb +121 -92
- data/lib/jetstream_bridge/rails/integration.rb +5 -8
- data/lib/jetstream_bridge/rails/railtie.rb +3 -4
- data/lib/jetstream_bridge/tasks/install.rake +17 -1
- data/lib/jetstream_bridge/topology/topology.rb +1 -6
- data/lib/jetstream_bridge/version.rb +1 -1
- data/lib/jetstream_bridge.rb +345 -202
- metadata +8 -8
- data/lib/jetstream_bridge/consumer/health_monitor.rb +0 -107
- data/lib/jetstream_bridge/core/connection_manager.rb +0 -513
- data/lib/jetstream_bridge/core/health_checker.rb +0 -184
- data/lib/jetstream_bridge/facade.rb +0 -212
- data/lib/jetstream_bridge/publisher/event_envelope_builder.rb +0 -110
|
@@ -13,6 +13,7 @@ module JetstreamBridge
|
|
|
13
13
|
# config.nats_urls = "nats://localhost:4222"
|
|
14
14
|
# config.app_name = "api_service"
|
|
15
15
|
# config.destination_app = "worker_service"
|
|
16
|
+
# config.stream_name = "jetstream-bridge-stream"
|
|
16
17
|
# config.use_outbox = true
|
|
17
18
|
# config.use_inbox = true
|
|
18
19
|
# end
|
|
@@ -22,6 +23,7 @@ module JetstreamBridge
|
|
|
22
23
|
# config.nats_urls = ENV["NATS_URLS"]
|
|
23
24
|
# config.app_name = "api"
|
|
24
25
|
# config.destination_app = "worker"
|
|
26
|
+
# config.stream_name = "jetstream-bridge-stream"
|
|
25
27
|
# end
|
|
26
28
|
#
|
|
27
29
|
class Config
|
|
@@ -49,7 +51,11 @@ module JetstreamBridge
|
|
|
49
51
|
# NATS server URL(s)
|
|
50
52
|
# @return [String]
|
|
51
53
|
attr_accessor :nats_urls
|
|
54
|
+
# JetStream stream name (required)
|
|
55
|
+
# @return [String]
|
|
56
|
+
attr_accessor :stream_name
|
|
52
57
|
# Application name for subject routing
|
|
58
|
+
# @return [String]
|
|
53
59
|
attr_accessor :app_name
|
|
54
60
|
# Maximum delivery attempts before moving to DLQ
|
|
55
61
|
# @return [Integer]
|
|
@@ -90,23 +96,16 @@ module JetstreamBridge
|
|
|
90
96
|
# Enable lazy connection (connect on first use instead of during configure)
|
|
91
97
|
# @return [Boolean]
|
|
92
98
|
attr_accessor :lazy_connect
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
attr_accessor :inbox_prefix
|
|
96
|
-
# Skip JetStream management API calls (account_info, stream ensure, etc.)
|
|
97
|
-
# Requires streams/consumers to be pre-provisioned and permissions handled externally.
|
|
99
|
+
# Allow JetStream Bridge to create/update streams/consumers and call JetStream management APIs.
|
|
100
|
+
# Disable for locked-down environments and handle provisioning separately.
|
|
98
101
|
# @return [Boolean]
|
|
99
|
-
attr_accessor :
|
|
100
|
-
# Optional durable consumer name
|
|
101
|
-
attr_writer :durable_name
|
|
102
|
+
attr_accessor :auto_provision
|
|
102
103
|
|
|
103
104
|
def initialize
|
|
104
105
|
@nats_urls = ENV['NATS_URLS'] || ENV['NATS_URL'] || 'nats://localhost:4222'
|
|
105
|
-
@
|
|
106
|
+
@stream_name = ENV['JETSTREAM_STREAM_NAME'] || 'jetstream-bridge-stream'
|
|
107
|
+
@app_name = ENV['APP_NAME'] || 'app'
|
|
106
108
|
@destination_app = ENV.fetch('DESTINATION_APP', nil)
|
|
107
|
-
@stream_name = ENV.fetch('STREAM_NAME', nil)
|
|
108
|
-
@stream_name_set = !@stream_name.nil?
|
|
109
|
-
@durable_name = ENV.fetch('DURABLE_NAME', nil)
|
|
110
109
|
|
|
111
110
|
@max_deliver = 5
|
|
112
111
|
@ack_wait = '30s'
|
|
@@ -124,8 +123,7 @@ module JetstreamBridge
|
|
|
124
123
|
@connect_retry_attempts = 3
|
|
125
124
|
@connect_retry_delay = 2
|
|
126
125
|
@lazy_connect = false
|
|
127
|
-
@
|
|
128
|
-
@disable_js_api = (ENV['JETSTREAM_DISABLE_JS_API'] || 'true') == 'true'
|
|
126
|
+
@auto_provision = true
|
|
129
127
|
end
|
|
130
128
|
|
|
131
129
|
# Apply a configuration preset
|
|
@@ -139,83 +137,57 @@ module JetstreamBridge
|
|
|
139
137
|
self
|
|
140
138
|
end
|
|
141
139
|
|
|
142
|
-
# Get the JetStream stream name (required).
|
|
143
|
-
#
|
|
144
|
-
# Side-effect free query. Use validate! to ensure validity.
|
|
145
|
-
#
|
|
146
|
-
# @return [String, nil] Stream name
|
|
147
|
-
def stream_name
|
|
148
|
-
@cached_stream_name || (@stream_name_set ? @stream_name : default_stream_name)
|
|
149
|
-
end
|
|
150
|
-
|
|
151
140
|
# Get the NATS subject this application publishes to.
|
|
152
141
|
#
|
|
153
142
|
# Producer publishes to: {app}.sync.{dest}
|
|
154
143
|
# Consumer subscribes to: {dest}.sync.{app}
|
|
155
144
|
#
|
|
156
|
-
#
|
|
157
|
-
#
|
|
158
|
-
# @
|
|
145
|
+
# @return [String] Source subject for publishing
|
|
146
|
+
# @raise [InvalidSubjectError] If components contain NATS wildcards
|
|
147
|
+
# @raise [MissingConfigurationError] If required components empty
|
|
159
148
|
# @example
|
|
160
149
|
# config.app_name = "api"
|
|
161
150
|
# config.destination_app = "worker"
|
|
162
151
|
# config.source_subject # => "api.sync.worker"
|
|
163
152
|
def source_subject
|
|
164
|
-
|
|
153
|
+
validate_subject_component!(app_name, 'app_name')
|
|
154
|
+
validate_subject_component!(destination_app, 'destination_app')
|
|
155
|
+
"#{app_name}.sync.#{destination_app}"
|
|
165
156
|
end
|
|
166
157
|
|
|
167
158
|
# Get the NATS subject this application subscribes to.
|
|
168
159
|
#
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
# @
|
|
172
|
-
# @example
|
|
173
|
-
# config.app_name = "api"
|
|
174
|
-
# config.destination_app = "worker"
|
|
175
|
-
# config.destination_subject # => "worker.sync.api"
|
|
160
|
+
# @return [String] Destination subject for consuming
|
|
161
|
+
# @raise [InvalidSubjectError] If components contain NATS wildcards
|
|
162
|
+
# @raise [MissingConfigurationError] If required components empty
|
|
176
163
|
def destination_subject
|
|
177
|
-
|
|
164
|
+
validate_subject_component!(app_name, 'app_name')
|
|
165
|
+
validate_subject_component!(destination_app, 'destination_app')
|
|
166
|
+
"#{destination_app}.sync.#{app_name}"
|
|
178
167
|
end
|
|
179
168
|
|
|
180
169
|
# Get the dead letter queue subject for this application.
|
|
181
170
|
#
|
|
182
171
|
# Each app has its own DLQ for better isolation and monitoring.
|
|
183
172
|
#
|
|
184
|
-
#
|
|
185
|
-
#
|
|
186
|
-
# @
|
|
187
|
-
# @example
|
|
188
|
-
# config.app_name = "api"
|
|
189
|
-
# config.dlq_subject # => "api.sync.dlq"
|
|
173
|
+
# @return [String] DLQ subject in format "{app_name}.sync.dlq"
|
|
174
|
+
# @raise [InvalidSubjectError] If components contain NATS wildcards
|
|
175
|
+
# @raise [MissingConfigurationError] If required components are empty
|
|
190
176
|
def dlq_subject
|
|
191
|
-
|
|
177
|
+
validate_subject_component!(app_name, 'app_name')
|
|
178
|
+
"#{app_name}.sync.dlq"
|
|
192
179
|
end
|
|
193
180
|
|
|
194
181
|
# Get the durable consumer name for this application.
|
|
195
182
|
#
|
|
196
|
-
# @return [String] Durable name in format "{app_name}-workers"
|
|
197
|
-
# @example
|
|
198
|
-
# config.app_name = "api"
|
|
199
|
-
# config.durable_name # => "api-workers"
|
|
200
183
|
def durable_name
|
|
201
|
-
|
|
202
|
-
return "#{app_name}-workers" if value.to_s.strip.empty?
|
|
203
|
-
|
|
204
|
-
value
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
def stream_name=(value)
|
|
208
|
-
@stream_name_set = true
|
|
209
|
-
@stream_name = value
|
|
184
|
+
"#{app_name}-workers"
|
|
210
185
|
end
|
|
211
186
|
|
|
212
187
|
# Validate all configuration settings.
|
|
213
188
|
#
|
|
214
189
|
# Checks that required settings are present and valid. Raises errors
|
|
215
|
-
# for any invalid configuration.
|
|
216
|
-
#
|
|
217
|
-
# This is a command method - performs validation and updates internal state.
|
|
218
|
-
# Call this once after configuration is complete.
|
|
190
|
+
# for any invalid configuration.
|
|
219
191
|
#
|
|
220
192
|
# @return [true] If configuration is valid
|
|
221
193
|
# @raise [ConfigurationError] If any validation fails
|
|
@@ -223,122 +195,21 @@ module JetstreamBridge
|
|
|
223
195
|
# config.validate! # Raises if destination_app is missing
|
|
224
196
|
def validate!
|
|
225
197
|
errors = []
|
|
226
|
-
|
|
227
|
-
# Validate stream name
|
|
228
|
-
stream_val = @stream_name_set ? @stream_name : default_stream_name
|
|
229
|
-
if stream_val.to_s.strip.empty?
|
|
230
|
-
errors << 'stream_name is required'
|
|
231
|
-
else
|
|
232
|
-
begin
|
|
233
|
-
validate_stream_name!(stream_val)
|
|
234
|
-
rescue InvalidSubjectError => e
|
|
235
|
-
errors << e.message
|
|
236
|
-
end
|
|
237
|
-
end
|
|
238
|
-
|
|
239
|
-
# Validate required fields
|
|
240
198
|
errors << 'destination_app is required' if destination_app.to_s.strip.empty?
|
|
241
199
|
errors << 'nats_urls is required' if nats_urls.to_s.strip.empty?
|
|
200
|
+
errors << 'stream_name is required' if stream_name.to_s.strip.empty?
|
|
242
201
|
errors << 'app_name is required' if app_name.to_s.strip.empty?
|
|
243
202
|
errors << 'max_deliver must be >= 1' if max_deliver.to_i < 1
|
|
244
203
|
errors << 'backoff must be an array' unless backoff.is_a?(Array)
|
|
245
204
|
errors << 'backoff must not be empty' if backoff.is_a?(Array) && backoff.empty?
|
|
246
|
-
errors << 'disable_js_api must be boolean' unless [true, false].include?(disable_js_api)
|
|
247
|
-
|
|
248
|
-
# Validate inbox prefix
|
|
249
|
-
begin
|
|
250
|
-
validate_inbox_prefix!(inbox_prefix)
|
|
251
|
-
rescue InvalidSubjectError => e
|
|
252
|
-
errors << e.message
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
# Validate subject components
|
|
256
|
-
begin
|
|
257
|
-
validate_subject_component!(app_name, 'app_name') unless app_name.to_s.strip.empty?
|
|
258
|
-
validate_subject_component!(destination_app, 'destination_app') unless destination_app.to_s.strip.empty?
|
|
259
|
-
rescue InvalidSubjectError, MissingConfigurationError => e
|
|
260
|
-
errors << e.message
|
|
261
|
-
end
|
|
262
205
|
|
|
263
206
|
raise ConfigurationError, "Configuration errors: #{errors.join(', ')}" if errors.any?
|
|
264
207
|
|
|
265
|
-
# Cache computed values after successful validation
|
|
266
|
-
cache_computed_values!
|
|
267
|
-
|
|
268
208
|
true
|
|
269
209
|
end
|
|
270
210
|
|
|
271
211
|
private
|
|
272
212
|
|
|
273
|
-
# Cache computed subjects after validation
|
|
274
|
-
def cache_computed_values!
|
|
275
|
-
@cached_stream_name = @stream_name_set ? @stream_name : default_stream_name
|
|
276
|
-
@cached_source_subject = build_source_subject
|
|
277
|
-
@cached_destination_subject = build_destination_subject
|
|
278
|
-
@cached_dlq_subject = build_dlq_subject
|
|
279
|
-
end
|
|
280
|
-
|
|
281
|
-
# Build source subject without side effects
|
|
282
|
-
def build_source_subject
|
|
283
|
-
build_subject(app_name, 'sync', destination_app)
|
|
284
|
-
end
|
|
285
|
-
|
|
286
|
-
# Build destination subject without side effects
|
|
287
|
-
def build_destination_subject
|
|
288
|
-
build_subject(destination_app, 'sync', app_name)
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
# Build DLQ subject without side effects
|
|
292
|
-
def build_dlq_subject
|
|
293
|
-
return nil if app_name.to_s.strip.empty?
|
|
294
|
-
|
|
295
|
-
build_subject(app_name, 'sync', 'dlq')
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
# Generic subject builder
|
|
299
|
-
#
|
|
300
|
-
# @param parts [Array<String>] Subject parts to join
|
|
301
|
-
# @return [String, nil] Built subject or nil if any required part is empty
|
|
302
|
-
def build_subject(*parts)
|
|
303
|
-
# Check if any required parts are empty
|
|
304
|
-
return nil if parts.any? { |p| p.to_s.strip.empty? }
|
|
305
|
-
|
|
306
|
-
parts.join('.')
|
|
307
|
-
end
|
|
308
|
-
|
|
309
|
-
def validate_inbox_prefix!(value)
|
|
310
|
-
str = value.to_s.strip
|
|
311
|
-
raise InvalidSubjectError, 'inbox_prefix cannot be empty' if str.empty?
|
|
312
|
-
|
|
313
|
-
# Disallow wildcards, spaces, or control characters in inbox prefix
|
|
314
|
-
return unless str.match?(/[*> \t\r\n\x00-\x1F\x7F]/)
|
|
315
|
-
|
|
316
|
-
raise InvalidSubjectError,
|
|
317
|
-
"inbox_prefix contains invalid characters (wildcards, spaces, or control chars): #{value.inspect}"
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
def validate_override!(name, value)
|
|
321
|
-
str = value.to_s.strip
|
|
322
|
-
raise InvalidSubjectError, "#{name} cannot be empty" if str.empty?
|
|
323
|
-
return unless str.match?(/[ \t\r\n\x00-\x1F\x7F]/)
|
|
324
|
-
|
|
325
|
-
raise InvalidSubjectError, "#{name} contains invalid whitespace/control characters: #{value.inspect}"
|
|
326
|
-
end
|
|
327
|
-
|
|
328
|
-
def validate_stream_name!(value)
|
|
329
|
-
str = value.to_s.strip
|
|
330
|
-
raise MissingConfigurationError, 'stream_name is required' if str.empty?
|
|
331
|
-
|
|
332
|
-
return unless str.match?(/[*> \t\r\n\x00-\x1F\x7F]/)
|
|
333
|
-
|
|
334
|
-
raise InvalidSubjectError,
|
|
335
|
-
"stream_name contains invalid characters (wildcards, whitespace, or control chars): #{value.inspect}"
|
|
336
|
-
end
|
|
337
|
-
|
|
338
|
-
def default_stream_name
|
|
339
|
-
nil
|
|
340
|
-
end
|
|
341
|
-
|
|
342
213
|
def validate_subject_component!(value, name)
|
|
343
214
|
str = value.to_s.strip
|
|
344
215
|
raise MissingConfigurationError, "#{name} cannot be empty" if str.empty?
|