togglecraft 1.0.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.
@@ -0,0 +1,568 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToggleCraft
4
+ # Main ToggleCraft client class
5
+ # Provides the primary interface for feature flag evaluation with real-time updates
6
+ class Client
7
+ attr_reader :config, :ready
8
+
9
+ # Initialize the ToggleCraft client
10
+ # @param sdk_key [String] SDK key for authentication
11
+ # @param options [Hash] Configuration options
12
+ def initialize(sdk_key:, **options)
13
+ raise ArgumentError, 'sdk_key is required' if sdk_key.nil? || sdk_key.empty?
14
+
15
+ @config = {
16
+ url: 'https://sse.togglecraft.io',
17
+ enable_cache: true,
18
+ cache_adapter: :memory,
19
+ cache_ttl: 300, # 5 minutes
20
+ reconnect_interval: 1,
21
+ max_reconnect_interval: 30,
22
+ max_reconnect_attempts: 10,
23
+ slow_reconnect_interval: 60,
24
+ share_connection: true,
25
+ debug: false,
26
+ enable_rollout_stage_polling: true,
27
+ rollout_stage_check_interval: 60,
28
+ fetch_jitter: 1500, # milliseconds
29
+ api_domain: 'https://togglecraft.io'
30
+ }.merge(options).merge(sdk_key: sdk_key)
31
+
32
+ # Initialize components
33
+ @evaluator = Evaluator.new
34
+ @cache = @config[:enable_cache] ? Cache.new(adapter: @config[:cache_adapter], ttl: @config[:cache_ttl]) : nil
35
+
36
+ # State
37
+ @ready = Concurrent::AtomicBoolean.new(false)
38
+ @flags = Concurrent::Map.new
39
+ @current_version = Concurrent::AtomicReference.new(nil)
40
+ @project_key = Concurrent::AtomicReference.new(nil)
41
+ @environment_key = Concurrent::AtomicReference.new(nil)
42
+ @fetch_in_progress = Concurrent::AtomicBoolean.new(false)
43
+
44
+ # Event listeners
45
+ @listeners = Concurrent::Map.new
46
+ @listeners[:ready] = []
47
+ @listeners[:flags_updated] = []
48
+ @listeners[:error] = []
49
+ @listeners[:disconnected] = []
50
+ @listeners[:reconnecting] = []
51
+ @listeners[:rollout_stage_changed] = []
52
+ @listeners_mutex = Mutex.new
53
+
54
+ # Rollout stage polling
55
+ @rollout_stage_percentages = {}
56
+ @rollout_stage_timer = nil
57
+
58
+ # Initialize connection
59
+ initialize_connection
60
+
61
+ # Enable debug on connection pool if debug is enabled
62
+ ConnectionPool.set_debug(true) if @config[:debug] && @config[:share_connection]
63
+ end
64
+
65
+ # Connect to SSE server
66
+ # @return [void]
67
+ def connect
68
+ # Try to load from cache first
69
+ if @cache
70
+ cached_flags = @cache.all_flags
71
+ if cached_flags.any?
72
+ @flags.clear
73
+ cached_flags.each { |k, v| @flags[k] = v }
74
+ @evaluator.update_flags(cached_flags)
75
+ log 'Loaded flags from cache'
76
+ end
77
+ end
78
+
79
+ # Connect to SSE server
80
+ if @using_shared_connection
81
+ @connection.connect(self)
82
+ else
83
+ @connection.connect
84
+ end
85
+
86
+ log 'Connected to ToggleCraft'
87
+ rescue StandardError => e
88
+ handle_error(e)
89
+ raise
90
+ end
91
+
92
+ # Disconnect from SSE server
93
+ # @return [void]
94
+ def disconnect
95
+ log 'Disconnecting from SSE'
96
+ stop_rollout_stage_polling
97
+ @connection.disconnect
98
+ @ready.make_false
99
+ end
100
+
101
+ # Reconnect to SSE server
102
+ # @return [void]
103
+ def reconnect
104
+ emit(:reconnecting)
105
+ @connection.reconnect
106
+ end
107
+
108
+ # Check if client is ready
109
+ # @return [Boolean]
110
+ def ready?
111
+ @ready.value
112
+ end
113
+
114
+ # Check if connected to SSE server
115
+ # @return [Boolean]
116
+ def connected?
117
+ @connection&.connected? || false
118
+ end
119
+
120
+ # Wait for client to be ready
121
+ # @param timeout [Integer] Timeout in seconds
122
+ # @return [void]
123
+ # @raise [Timeout::Error] if timeout is reached
124
+ def wait_for_ready(timeout: 5)
125
+ return if ready?
126
+
127
+ ready_latch = Concurrent::CountDownLatch.new(1)
128
+ ready_handler = -> { ready_latch.count_down }
129
+
130
+ once(:ready, &ready_handler)
131
+
132
+ return if ready_latch.wait(timeout)
133
+
134
+ off(:ready, ready_handler)
135
+ raise Timeout::Error, 'Timeout waiting for client to be ready'
136
+ end
137
+
138
+ # Evaluate a boolean flag
139
+ # @param flag_key [String] Flag key
140
+ # @param context [Hash] Evaluation context
141
+ # @param default [Boolean] Default value
142
+ # @return [Boolean]
143
+ def enabled?(flag_key, context = {}, default: false)
144
+ result = @evaluator.evaluate_boolean(flag_key, context, default)
145
+
146
+ # Cache the result
147
+ cache_evaluation(flag_key, context, result) if @cache && context
148
+
149
+ result
150
+ end
151
+
152
+ # Get variant for multivariate flag
153
+ # @param flag_key [String] Flag key
154
+ # @param context [Hash] Evaluation context
155
+ # @param default [String, nil] Default variant
156
+ # @return [String, nil]
157
+ def variant(flag_key, context = {}, default: nil)
158
+ result = @evaluator.evaluate_multivariate(flag_key, context, default)
159
+
160
+ # Cache the result
161
+ cache_evaluation(flag_key, context, result) if @cache && context
162
+
163
+ result
164
+ end
165
+
166
+ # Check if in percentage rollout
167
+ # @param flag_key [String] Flag key
168
+ # @param context [Hash] Evaluation context
169
+ # @param default [Boolean] Default value
170
+ # @return [Boolean]
171
+ def in_percentage?(flag_key, context = {}, default: false)
172
+ result = @evaluator.evaluate_percentage(flag_key, context, default)
173
+
174
+ # Cache the result
175
+ cache_evaluation(flag_key, context, result) if @cache && context
176
+
177
+ result
178
+ end
179
+
180
+ # Get current percentage for a flag
181
+ # @param flag_key [String] Flag key
182
+ # @return [Integer, nil]
183
+ def percentage(flag_key)
184
+ @evaluator.percentage(flag_key)
185
+ end
186
+
187
+ # Evaluate any flag type
188
+ # @param flag_key [String] Flag key
189
+ # @param context [Hash] Evaluation context
190
+ # @param default [Object] Default value
191
+ # @return [Object]
192
+ def evaluate(flag_key, context = {}, default: false)
193
+ @evaluator.evaluate(flag_key, context, default)
194
+ end
195
+
196
+ # Get all flag keys
197
+ # @return [Array<String>]
198
+ def all_flag_keys
199
+ @evaluator.flag_keys
200
+ end
201
+
202
+ # Get flag metadata
203
+ # @param flag_key [String] Flag key
204
+ # @return [Hash, nil]
205
+ def flag_metadata(flag_key)
206
+ @evaluator.flag_metadata(flag_key)
207
+ end
208
+
209
+ # Check if flag exists
210
+ # @param flag_key [String] Flag key
211
+ # @return [Boolean]
212
+ def has_flag?(flag_key)
213
+ @evaluator.has_flag?(flag_key)
214
+ end
215
+
216
+ # Add event listener
217
+ # @param event [Symbol] Event type
218
+ # @param block [Proc] Event handler
219
+ # @return [void]
220
+ def on(event, &block)
221
+ @listeners_mutex.synchronize do
222
+ @listeners[event] ||= []
223
+ @listeners[event] << block
224
+ end
225
+ end
226
+
227
+ # Add one-time event listener
228
+ # @param event [Symbol] Event type
229
+ # @param block [Proc] Event handler
230
+ # @return [void]
231
+ def once(event, &block)
232
+ wrapper = lambda do |*args|
233
+ block.call(*args)
234
+ off(event, wrapper)
235
+ end
236
+ on(event, &wrapper)
237
+ end
238
+
239
+ # Remove event listener
240
+ # @param event [Symbol] Event type
241
+ # @param callable [Proc, nil] Specific handler to remove
242
+ # @return [void]
243
+ def off(event, callable = nil)
244
+ @listeners_mutex.synchronize do
245
+ if callable
246
+ @listeners[event]&.delete(callable)
247
+ else
248
+ @listeners[event] = []
249
+ end
250
+ end
251
+ end
252
+
253
+ # Clean up resources
254
+ # @return [void]
255
+ def destroy
256
+ log 'Destroying client instance'
257
+
258
+ stop_rollout_stage_polling
259
+
260
+ # Handle cleanup based on connection type
261
+ if @using_shared_connection && @connection
262
+ @connection.remove_client(self)
263
+ elsif @connection
264
+ disconnect
265
+ end
266
+
267
+ @cache&.destroy
268
+
269
+ # Clear all listeners
270
+ @listeners_mutex.synchronize do
271
+ @listeners.each_key { |key| @listeners[key] = [] }
272
+ end
273
+
274
+ @connection = nil
275
+ @flags.clear
276
+ @ready.make_false
277
+ end
278
+
279
+ # Handle SSE connection event
280
+ # @return [void]
281
+ def handle_connect
282
+ log 'SSE connected'
283
+ end
284
+
285
+ # Handle SSE disconnection event
286
+ # @return [void]
287
+ def handle_disconnect
288
+ @ready.make_false
289
+ emit(:disconnected)
290
+ end
291
+
292
+ # Handle error
293
+ # @param error [Exception] Error that occurred
294
+ # @return [void]
295
+ def handle_error(error)
296
+ log "Error: #{error.message}"
297
+ emit(:error, error)
298
+ end
299
+
300
+ # Handle flags update from SSE
301
+ # @param payload [Hash] Message payload
302
+ # @return [void]
303
+ def handle_flags_update(payload)
304
+ log "Received message: #{payload}"
305
+
306
+ # Handle version update notification
307
+ if payload[:type] == 'version_update'
308
+ handle_version_update(payload)
309
+ return
310
+ end
311
+
312
+ # Handle full flags payload
313
+ process_flags_payload(payload) if payload[:flags]
314
+ end
315
+
316
+ private
317
+
318
+ # Initialize connection (shared or dedicated)
319
+ # @return [void]
320
+ def initialize_connection
321
+ # Try to get a shared connection from the pool
322
+ shared_connection = ConnectionPool.get_connection(@config)
323
+
324
+ if shared_connection
325
+ @connection = shared_connection
326
+ @using_shared_connection = true
327
+ log 'Using shared SSE connection'
328
+ else
329
+ # Create dedicated SSE connection
330
+ @connection = SSEConnection.new(
331
+ url: @config[:url],
332
+ sdk_key: @config[:sdk_key],
333
+ reconnect_interval: @config[:reconnect_interval],
334
+ max_reconnect_interval: @config[:max_reconnect_interval],
335
+ max_reconnect_attempts: @config[:max_reconnect_attempts],
336
+ slow_reconnect_interval: @config[:slow_reconnect_interval],
337
+ heartbeat_domain: @config[:heartbeat_domain],
338
+ debug: @config[:debug],
339
+ on_message: method(:handle_flags_update),
340
+ on_connect: method(:handle_connect),
341
+ on_disconnect: method(:handle_disconnect),
342
+ on_error: method(:handle_error)
343
+ )
344
+ @using_shared_connection = false
345
+ log 'Using dedicated SSE connection'
346
+ end
347
+ end
348
+
349
+ # Handle version update notification
350
+ # @param data [Hash] Version update data
351
+ # @return [void]
352
+ def handle_version_update(data)
353
+ version = data[:version]
354
+ project_key = data[:project_key]
355
+ environment_key = data[:environment_key]
356
+
357
+ # Store project and environment keys from first message
358
+ @project_key.set(project_key) if project_key && @project_key.get.nil?
359
+ @environment_key.set(environment_key) if environment_key && @environment_key.get.nil?
360
+
361
+ # Check if we already have this version
362
+ if @current_version.get == version
363
+ log "Already have version #{version}"
364
+ return
365
+ end
366
+
367
+ log "New version available: old=#{@current_version.get}, new=#{version}, changed_flag=#{data[:changed_flag]}"
368
+
369
+ # Apply jitter to prevent thundering herd (only on updates, not initial connection)
370
+ is_initial_connection = @current_version.get.nil?
371
+ jitter = is_initial_connection ? 0 : Utils.generate_jitter(@config[:fetch_jitter])
372
+
373
+ if jitter.positive?
374
+ log "Applying #{jitter}ms jitter before fetching"
375
+ sleep(jitter.to_f / 1000)
376
+ end
377
+
378
+ # Fetch new version via HTTP
379
+ fetch_flags_via_http(version)
380
+ end
381
+
382
+ # Fetch flags via HTTP using versioned URL
383
+ # @param version [Integer] Version to fetch
384
+ # @return [void]
385
+ def fetch_flags_via_http(version)
386
+ return if @fetch_in_progress.true?
387
+
388
+ unless @project_key.get && @environment_key.get
389
+ log 'Project/environment keys not available yet, cannot fetch'
390
+ return
391
+ end
392
+
393
+ @fetch_in_progress.make_true
394
+
395
+ begin
396
+ versioned_url = "#{@config[:api_domain]}/api/v1/flags/#{@project_key.get}/#{@environment_key.get}/v/#{version}"
397
+ log "Fetching flags from #{versioned_url}"
398
+
399
+ response = HTTP.timeout(10).get(versioned_url)
400
+
401
+ raise "HTTP #{response.status}: #{response.status.reason}" unless response.status.success?
402
+
403
+ payload = JSON.parse(response.body.to_s, symbolize_names: true)
404
+ process_flags_payload(payload)
405
+
406
+ log "Flags updated via HTTP: version=#{payload[:version]}, flagCount=#{payload[:flags]&.size || 0}"
407
+ rescue StandardError => e
408
+ log "Failed to fetch flags via HTTP: #{e.message}"
409
+ handle_error(e)
410
+ ensure
411
+ @fetch_in_progress.make_false
412
+ end
413
+ end
414
+
415
+ # Process a full flags payload
416
+ # @param payload [Hash] Flags payload
417
+ # @return [void]
418
+ def process_flags_payload(payload)
419
+ log 'Processing flags payload'
420
+
421
+ @flags.clear
422
+ payload[:flags]&.each { |k, v| @flags[k] = v }
423
+ @evaluator.update_flags(payload[:flags] || {})
424
+
425
+ # Update version tracking
426
+ @current_version.set(payload[:version]) if payload[:version]
427
+
428
+ # Update cache
429
+ @cache&.set_all_flags(payload[:flags] || {})
430
+
431
+ # Initialize rollout stage tracking
432
+ initialize_rollout_stage_tracking
433
+
434
+ # Mark as ready on first update
435
+ unless @ready.value
436
+ @ready.make_true
437
+ emit(:ready)
438
+
439
+ # Start rollout stage polling
440
+ start_rollout_stage_polling if @config[:enable_rollout_stage_polling]
441
+ end
442
+
443
+ # Emit flags updated event
444
+ emit(:flags_updated, payload[:flags] || {})
445
+ end
446
+
447
+ # Initialize rollout stage tracking
448
+ # @return [void]
449
+ def initialize_rollout_stage_tracking
450
+ @rollout_stage_percentages = {}
451
+
452
+ @flags.each do |flag_key, flag|
453
+ next unless flag[:type] == 'percentage'
454
+ next unless flag[:rollout_stages]&.any?
455
+
456
+ @rollout_stage_percentages[flag_key] = @evaluator.current_percentage_for_flag(flag)
457
+ end
458
+ end
459
+
460
+ # Start rollout stage polling
461
+ # @return [void]
462
+ def start_rollout_stage_polling
463
+ stop_rollout_stage_polling
464
+
465
+ log "Starting rollout stage polling (interval: #{@config[:rollout_stage_check_interval]}s)"
466
+
467
+ @rollout_stage_timer = Thread.new do
468
+ loop do
469
+ sleep @config[:rollout_stage_check_interval]
470
+ check_rollout_stage_changes
471
+ end
472
+ rescue StandardError => e
473
+ log "Rollout stage polling error: #{e.message}"
474
+ end
475
+
476
+ @rollout_stage_timer.abort_on_exception = false
477
+ end
478
+
479
+ # Stop rollout stage polling
480
+ # @return [void]
481
+ def stop_rollout_stage_polling
482
+ return unless @rollout_stage_timer
483
+
484
+ @rollout_stage_timer.kill if @rollout_stage_timer.alive?
485
+ @rollout_stage_timer = nil
486
+ log 'Stopped rollout stage polling'
487
+ end
488
+
489
+ # Check for rollout stage changes
490
+ # @return [void]
491
+ def check_rollout_stage_changes
492
+ changed_flags = []
493
+
494
+ @flags.each do |flag_key, flag|
495
+ next unless flag[:type] == 'percentage'
496
+ next unless flag[:rollout_stages]&.any?
497
+
498
+ old_percentage = @rollout_stage_percentages[flag_key]
499
+ new_percentage = @evaluator.current_percentage_for_flag(flag)
500
+
501
+ next unless old_percentage != new_percentage
502
+
503
+ @rollout_stage_percentages[flag_key] = new_percentage
504
+ changed_flags << {
505
+ key: flag_key,
506
+ old_percentage: old_percentage,
507
+ new_percentage: new_percentage
508
+ }
509
+
510
+ log "Rollout stage changed for #{flag_key}: #{old_percentage}% → #{new_percentage}%"
511
+ end
512
+
513
+ return unless changed_flags.any?
514
+
515
+ # Clear evaluation cache for changed flags
516
+ clear_evaluation_cache_for_flags(changed_flags.map { |f| f[:key] })
517
+
518
+ # Emit rollout stage changed event
519
+ emit(:rollout_stage_changed, {
520
+ flags: changed_flags,
521
+ timestamp: Time.now.iso8601
522
+ })
523
+ end
524
+
525
+ # Clear evaluation cache for specific flags
526
+ # @param flag_keys [Array<String>] Flag keys to clear cache for
527
+ # @return [void]
528
+ def clear_evaluation_cache_for_flags(flag_keys)
529
+ return unless @cache
530
+
531
+ log "Clearing evaluation cache for flags: #{flag_keys.join(', ')}"
532
+ flag_keys.each { |key| @cache.clear_by_prefix("eval:#{key}:") }
533
+ end
534
+
535
+ # Cache an evaluation result
536
+ # @param flag_key [String] Flag key
537
+ # @param context [Hash] Evaluation context
538
+ # @param result [Object] Evaluation result
539
+ # @return [void]
540
+ def cache_evaluation(flag_key, context, result)
541
+ cache_key = Utils.create_cache_key(flag_key, context)
542
+ @cache.set("eval:#{cache_key}", result, ttl: 60) # Cache for 1 minute
543
+ end
544
+
545
+ # Emit an event
546
+ # @param event [Symbol] Event type
547
+ # @param args [Array] Event arguments
548
+ # @return [void]
549
+ def emit(event, *args)
550
+ handlers = @listeners_mutex.synchronize { @listeners[event]&.dup || [] }
551
+
552
+ handlers.each do |handler|
553
+ handler.call(*args)
554
+ rescue StandardError => e
555
+ warn "Error in #{event} listener: #{e.message}"
556
+ end
557
+ end
558
+
559
+ # Log debug message
560
+ # @param message [String] Message to log
561
+ # @return [void]
562
+ def log(message)
563
+ return unless @config[:debug]
564
+
565
+ puts "[ToggleCraft Client] #{message}"
566
+ end
567
+ end
568
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToggleCraft
4
+ # Connection pool manager for SSE connections
5
+ # Ensures that multiple SDK instances can share the same SSE connection
6
+ class ConnectionPool
7
+ class << self
8
+ # Get the connections map
9
+ # @return [Concurrent::Map]
10
+ def connections
11
+ @connections ||= Concurrent::Map.new
12
+ end
13
+
14
+ # Get or create a shared connection
15
+ # @param config [Hash] Client configuration
16
+ # @return [SharedSSEConnection, nil] Shared connection instance or nil if sharing disabled
17
+ def get_connection(config)
18
+ # If connection sharing is disabled, return nil to create a dedicated connection
19
+ unless config[:share_connection]
20
+ log 'Connection sharing disabled for this client'
21
+ return nil
22
+ end
23
+
24
+ pool_key = generate_pool_key(config)
25
+ log "Getting connection for pool key: #{pool_key}"
26
+
27
+ # Check if connection already exists
28
+ connection = connections[pool_key]
29
+
30
+ if connection
31
+ log "Reusing existing connection for #{pool_key} (#{connection.client_count} clients)"
32
+ else
33
+ # Create new shared SSE connection
34
+ log "Creating new shared SSE connection for #{pool_key}"
35
+ connection = SharedSSEConnection.new(config, pool_key)
36
+ connections[pool_key] = connection
37
+
38
+ # Set up cleanup when connection has no more clients
39
+ connection.on_empty do
40
+ log "Removing empty connection for #{pool_key}"
41
+ connections.delete(pool_key)
42
+ end
43
+ end
44
+
45
+ connection
46
+ end
47
+
48
+ # Generate a pool key from configuration
49
+ # @param config [Hash] Client configuration
50
+ # @return [String] Pool key for the connection
51
+ def generate_pool_key(config)
52
+ # Allow custom pool key for advanced users
53
+ return config[:connection_pool_key] if config[:connection_pool_key]
54
+
55
+ # Generate key from connection parameters
56
+ url = config[:url] || 'https://sse.togglecraft.io'
57
+ sdk_key = config[:sdk_key]
58
+ "#{url}:#{sdk_key}"
59
+ end
60
+
61
+ # Get statistics about the connection pool
62
+ # @return [Hash] Pool statistics
63
+ def stats
64
+ conn_stats = []
65
+ connections.each do |key, conn|
66
+ conn_stats << {
67
+ key: key,
68
+ client_count: conn.client_count,
69
+ connected: conn.connected?
70
+ }
71
+ end
72
+
73
+ {
74
+ total_connections: connections.size,
75
+ connections: conn_stats
76
+ }
77
+ end
78
+
79
+ # Clear all connections
80
+ # @param force [Boolean] Force disconnect all connections
81
+ # @return [void]
82
+ def clear(force: false)
83
+ connections.each_value(&:force_disconnect) if force
84
+ connections.clear
85
+ end
86
+
87
+ # Enable or disable debug logging
88
+ # @param enabled [Boolean]
89
+ # @return [void]
90
+ def set_debug(enabled)
91
+ @debug = enabled
92
+ # Also enable debug on existing connections
93
+ connections.each_value { |conn| conn.set_debug(enabled) }
94
+ end
95
+
96
+ # Get the number of active connections
97
+ # @return [Integer]
98
+ def connection_count
99
+ connections.size
100
+ end
101
+
102
+ # Get the total number of clients across all connections
103
+ # @return [Integer]
104
+ def total_client_count
105
+ connections.values.sum(&:client_count)
106
+ end
107
+
108
+ # Check if a specific pool key has an active connection
109
+ # @param pool_key [String]
110
+ # @return [Boolean]
111
+ def has_connection?(pool_key)
112
+ connections.key?(pool_key)
113
+ end
114
+
115
+ # Get connection by pool key (for testing/debugging)
116
+ # @param pool_key [String]
117
+ # @return [SharedSSEConnection, nil]
118
+ def get_connection_by_key(pool_key)
119
+ connections[pool_key]
120
+ end
121
+
122
+ private
123
+
124
+ # Log debug messages
125
+ # @param message [String] Message to log
126
+ # @return [void]
127
+ def log(message)
128
+ return unless @debug
129
+
130
+ puts "[ToggleCraft ConnectionPool] #{message}"
131
+ end
132
+ end
133
+ end
134
+ end