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,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'http'
4
+ require 'json'
5
+
6
+ module ToggleCraft
7
+ # SSE (Server-Sent Events) connection manager
8
+ # Manages persistent connection to ToggleCraft SSE server with heartbeat
9
+ class SSEConnection
10
+ attr_reader :url, :sdk_key, :connected, :connection_id
11
+
12
+ # Initialize SSE connection
13
+ # @param url [String] SSE endpoint URL
14
+ # @param sdk_key [String] SDK key for authentication
15
+ # @param options [Hash] Connection options
16
+ def initialize(url:, sdk_key:, **options)
17
+ @url = url.sub(%r{/cable$}, '') # Remove /cable if present
18
+ @sdk_key = sdk_key
19
+ @options = {
20
+ reconnect_interval: 1,
21
+ max_reconnect_interval: 30,
22
+ max_reconnect_attempts: 10,
23
+ slow_reconnect_interval: 60,
24
+ heartbeat_domain: 'https://togglecraft.io',
25
+ heartbeat_interval: 300, # 5 minutes
26
+ debug: false
27
+ }.merge(options)
28
+
29
+ @connected = false
30
+ @connection_id = nil
31
+ @reconnect_attempts = 0
32
+ @should_reconnect = true
33
+
34
+ # Callbacks
35
+ @on_message = options[:on_message] || proc {}
36
+ @on_connect = options[:on_connect] || proc {}
37
+ @on_disconnect = options[:on_disconnect] || proc {}
38
+ @on_error = options[:on_error] || proc {}
39
+
40
+ # Threads
41
+ @connection_thread = nil
42
+ @heartbeat_thread = nil
43
+ @heartbeat_timer = nil
44
+ end
45
+
46
+ # Connect to SSE server
47
+ # @return [void]
48
+ def connect
49
+ return if @connected
50
+
51
+ @should_reconnect = true
52
+ start_connection
53
+ end
54
+
55
+ # Disconnect from SSE server
56
+ # @return [void]
57
+ def disconnect
58
+ log 'Disconnecting'
59
+ @should_reconnect = false
60
+ @connection_id = nil
61
+ stop_heartbeat
62
+ stop_connection
63
+ @connected = false
64
+ end
65
+
66
+ # Reconnect to SSE server
67
+ # @return [void]
68
+ def reconnect
69
+ disconnect
70
+ @should_reconnect = true
71
+ @reconnect_attempts = 0
72
+ connect
73
+ end
74
+
75
+ # Check if connected to SSE server
76
+ # @return [Boolean]
77
+ def connected?
78
+ @connected
79
+ end
80
+
81
+ private
82
+
83
+ # Start SSE connection in background thread
84
+ def start_connection
85
+ @connection_thread = Thread.new do
86
+ connect_sse
87
+ rescue StandardError => e
88
+ log "Connection error: #{e.message}"
89
+ handle_error(e)
90
+ handle_disconnection
91
+ end
92
+ @connection_thread.abort_on_exception = false
93
+ end
94
+
95
+ # Stop connection thread
96
+ def stop_connection
97
+ return unless @connection_thread
98
+
99
+ @connection_thread.kill if @connection_thread.alive?
100
+ @connection_thread = nil
101
+ end
102
+
103
+ # Connect to SSE server using HTTP streaming
104
+ def connect_sse
105
+ # Build SSE URL with channel identifier
106
+ channel_identifier = JSON.generate({
107
+ channel: 'FlagsChannel',
108
+ sdk_key: @sdk_key
109
+ })
110
+
111
+ sse_url = "#{@url}/events?identifier=#{CGI.escape(channel_identifier)}"
112
+ log "Connecting to #{sse_url}"
113
+
114
+ HTTP.get(sse_url) do |response|
115
+ raise "HTTP #{response.status}: #{response.status.reason}" unless response.status.success?
116
+
117
+ @connected = true
118
+ @reconnect_attempts = 0
119
+ @on_connect.call
120
+ start_heartbeat
121
+
122
+ # Read SSE stream
123
+ buffer = ''
124
+ response.body.each do |chunk|
125
+ buffer += chunk
126
+
127
+ # Process complete SSE messages (ending with \n\n)
128
+ while buffer.include?("\n\n")
129
+ message, buffer = buffer.split("\n\n", 2)
130
+ process_sse_message(message)
131
+ end
132
+ end
133
+ end
134
+ rescue StandardError => e
135
+ log "SSE error: #{e.message}"
136
+ raise
137
+ end
138
+
139
+ # Process SSE message
140
+ # @param message [String] Raw SSE message
141
+ def process_sse_message(message)
142
+ return if message.strip.empty?
143
+
144
+ # Parse SSE format: "event: type\ndata: {...}"
145
+ lines = message.split("\n")
146
+ event_type = nil
147
+ data = nil
148
+
149
+ lines.each do |line|
150
+ if line.start_with?('event:')
151
+ event_type = line.sub('event:', '').strip
152
+ elsif line.start_with?('data:')
153
+ data = line.sub('data:', '').strip
154
+ end
155
+ end
156
+
157
+ return unless data
158
+
159
+ begin
160
+ parsed_data = JSON.parse(data, symbolize_names: true)
161
+
162
+ # Extract connection ID from server messages
163
+ if parsed_data[:connection_id] && @connection_id != parsed_data[:connection_id]
164
+ log "Connection ID received: #{parsed_data[:connection_id]}"
165
+ @connection_id = parsed_data[:connection_id]
166
+ end
167
+
168
+ @on_message.call(parsed_data)
169
+ rescue JSON::ParserError => e
170
+ log "Failed to parse SSE data: #{e.message}"
171
+ end
172
+ end
173
+
174
+ # Handle disconnection and schedule reconnection
175
+ def handle_disconnection
176
+ return unless @connected
177
+
178
+ @connected = false
179
+ @connection_id = nil
180
+ stop_heartbeat
181
+ @on_disconnect.call
182
+
183
+ schedule_reconnection if @should_reconnect
184
+ end
185
+
186
+ # Handle errors
187
+ # @param error [Exception] The error that occurred
188
+ def handle_error(error)
189
+ @on_error.call(error)
190
+ end
191
+
192
+ # Schedule reconnection with exponential backoff
193
+ def schedule_reconnection
194
+ # Hybrid approach: switch to slow mode after max attempts
195
+ if @reconnect_attempts >= @options[:max_reconnect_attempts]
196
+ interval = @options[:slow_reconnect_interval]
197
+ log "Max fast reconnection attempts reached, switching to slow mode (every #{interval}s)"
198
+ else
199
+ @reconnect_attempts += 1
200
+ interval = [@options[:reconnect_interval] * (2**(@reconnect_attempts - 1)),
201
+ @options[:max_reconnect_interval]].min
202
+ log "Scheduling reconnection attempt #{@reconnect_attempts}/" \
203
+ "#{@options[:max_reconnect_attempts]} in #{interval}s"
204
+ end
205
+
206
+ Thread.new do
207
+ sleep interval
208
+ connect if @should_reconnect
209
+ rescue StandardError => e
210
+ log "Reconnection failed: #{e.message}"
211
+ end
212
+ end
213
+
214
+ # Start heartbeat timer
215
+ def start_heartbeat
216
+ stop_heartbeat # Clear any existing timer
217
+
218
+ @heartbeat_timer = Thread.new do
219
+ loop do
220
+ sleep @options[:heartbeat_interval]
221
+
222
+ # Add random jitter (0-30 seconds)
223
+ jitter = rand(0..30)
224
+ sleep jitter
225
+
226
+ send_heartbeat if @connected
227
+ end
228
+ rescue StandardError => e
229
+ log "Heartbeat thread error: #{e.message}"
230
+ end
231
+
232
+ @heartbeat_timer.abort_on_exception = false
233
+ log "Heartbeat started (interval: #{@options[:heartbeat_interval]}s with 0-30s jitter)"
234
+ end
235
+
236
+ # Stop heartbeat timer
237
+ def stop_heartbeat
238
+ return unless @heartbeat_timer
239
+
240
+ @heartbeat_timer.kill if @heartbeat_timer.alive?
241
+ @heartbeat_timer = nil
242
+ log 'Heartbeat stopped'
243
+ end
244
+
245
+ # Send heartbeat via HTTP POST
246
+ # CRITICAL: Only send when connection is actually established
247
+ def send_heartbeat
248
+ unless @connected
249
+ log 'Skipping heartbeat - not connected'
250
+ return
251
+ end
252
+
253
+ unless @connection_id
254
+ log 'Skipping heartbeat - connection ID not yet received'
255
+ return
256
+ end
257
+
258
+ begin
259
+ heartbeat_url = "#{@options[:heartbeat_domain]}/api/v1/heartbeat"
260
+ log "Sending heartbeat to #{heartbeat_url}"
261
+
262
+ response = HTTP.timeout(10)
263
+ .headers(
264
+ 'Content-Type' => 'application/json',
265
+ 'X-SDK-Key' => @sdk_key,
266
+ 'X-Connection-ID' => @connection_id
267
+ )
268
+ .post(heartbeat_url)
269
+
270
+ if response.status.success?
271
+ data = JSON.parse(response.body.to_s)
272
+ log "Heartbeat successful: #{data}"
273
+ elsif response.status.code == 404
274
+ # Connection not found on server - reconnect
275
+ log 'Connection not found on server, disconnecting and reconnecting'
276
+ disconnect
277
+ @should_reconnect = true
278
+ schedule_reconnection
279
+ else
280
+ log "Heartbeat failed: #{response.status}"
281
+ end
282
+ rescue StandardError => e
283
+ log "Heartbeat request failed: #{e.message}"
284
+ # Don't disconnect on heartbeat failure - might be temporary network issue
285
+ end
286
+ end
287
+
288
+ # Log debug messages
289
+ # @param message [String] Message to log
290
+ def log(message)
291
+ return unless @options[:debug]
292
+
293
+ puts "[ToggleCraft SSE] #{message}"
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToggleCraft
4
+ module Utils
5
+ # Operators supported for rule evaluation
6
+ OPERATORS = %w[
7
+ equals not_equals contains not_contains starts_with ends_with
8
+ in not_in gt gte lt lte between regex
9
+ semver_eq semver_gt semver_gte semver_lt semver_lte
10
+ ].freeze
11
+
12
+ # Consistent hashing for percentage rollouts
13
+ # Returns a value between 0 and 100
14
+ # Mirrors the JavaScript implementation exactly for consistency
15
+ #
16
+ # @param key [String] The key to hash
17
+ # @return [Integer] A value between 0 and 100
18
+ def self.hash_key(key)
19
+ hash = 0
20
+ key.to_s.each_char do |char|
21
+ hash = ((hash << 5) - hash) + char.ord
22
+ hash &= hash # Convert to 32-bit integer
23
+ end
24
+ hash.abs % 100
25
+ end
26
+
27
+ # Deep get a nested property from a hash using dot notation
28
+ # Supports both string and symbol keys
29
+ #
30
+ # @param obj [Hash] The hash to query
31
+ # @param path [String] The path to the property (e.g., "user.email")
32
+ # @return [Object, nil] The value at the path or nil
33
+ def self.get_nested_property(obj, path)
34
+ return nil unless obj && path
35
+ return nil if path.to_s.strip.empty?
36
+
37
+ keys = path.to_s.split('.')
38
+ current = obj
39
+
40
+ keys.each do |key|
41
+ return nil if current.nil?
42
+
43
+ # Try both symbol and string keys
44
+ current = current[key.to_sym] || current[key.to_s]
45
+ end
46
+
47
+ current
48
+ end
49
+
50
+ # Compare semantic versions
51
+ # Returns -1 if version1 < version2, 0 if equal, 1 if version1 > version2
52
+ #
53
+ # @param version1 [String] First version
54
+ # @param version2 [String] Second version
55
+ # @return [Integer] -1, 0, or 1
56
+ def self.compare_semver(version1, version2)
57
+ v1_parts = version1.to_s.split('.').map(&:to_i)
58
+ v2_parts = version2.to_s.split('.').map(&:to_i)
59
+
60
+ 3.times do |i|
61
+ part1 = v1_parts[i] || 0
62
+ part2 = v2_parts[i] || 0
63
+
64
+ return 1 if part1 > part2
65
+ return -1 if part1 < part2
66
+ end
67
+
68
+ 0
69
+ end
70
+
71
+ # Generate random jitter delay to prevent thundering herd
72
+ # When many clients receive a version update simultaneously, jitter ensures
73
+ # they don't all fetch the new version at exactly the same time
74
+ #
75
+ # @param max_jitter_ms [Integer] Maximum jitter in milliseconds
76
+ # @return [Integer] Random delay between 0 and max_jitter_ms
77
+ def self.generate_jitter(max_jitter_ms = 1500)
78
+ rand(0..max_jitter_ms)
79
+ end
80
+
81
+ # Create a unique key for caching based on flag and context
82
+ #
83
+ # @param flag_key [String] The flag key
84
+ # @param context [Hash] The evaluation context
85
+ # @return [String] A unique cache key
86
+ def self.create_cache_key(flag_key, context)
87
+ user_id = context.dig(:user, :id) || context.dig(:user, :email) || 'anonymous'
88
+ "#{flag_key}:#{user_id}"
89
+ end
90
+
91
+ # Evaluate a condition operator
92
+ # Supports 14 different operators for flexible targeting rules
93
+ #
94
+ # @param attribute_value [Object] The actual value from context
95
+ # @param operator [String] The operator to use
96
+ # @param condition_values [Array] The values to compare against
97
+ # @return [Boolean] Whether the condition passes
98
+ def self.evaluate_operator(attribute_value, operator, condition_values)
99
+ # Handle nil/null values
100
+ return %w[not_equals not_in].include?(operator) if attribute_value.nil?
101
+
102
+ first_value = condition_values[0]
103
+ attribute_str = attribute_value.to_s.downcase
104
+ value_str = first_value.to_s.downcase
105
+
106
+ case operator
107
+ when 'equals'
108
+ attribute_str == value_str
109
+
110
+ when 'not_equals'
111
+ attribute_str != value_str
112
+
113
+ when 'contains'
114
+ attribute_str.include?(value_str)
115
+
116
+ when 'not_contains'
117
+ !attribute_str.include?(value_str)
118
+
119
+ when 'starts_with'
120
+ attribute_str.start_with?(value_str)
121
+
122
+ when 'ends_with'
123
+ attribute_str.end_with?(value_str)
124
+
125
+ when 'in'
126
+ condition_values.any? { |v| v.to_s.downcase == attribute_str }
127
+
128
+ when 'not_in'
129
+ condition_values.none? { |v| v.to_s.downcase == attribute_str }
130
+
131
+ when 'gt'
132
+ attribute_value.to_f > first_value.to_f
133
+
134
+ when 'gte'
135
+ attribute_value.to_f >= first_value.to_f
136
+
137
+ when 'lt'
138
+ attribute_value.to_f < first_value.to_f
139
+
140
+ when 'lte'
141
+ attribute_value.to_f <= first_value.to_f
142
+
143
+ when 'between'
144
+ return false if condition_values.length < 2
145
+
146
+ num = attribute_value.to_f
147
+ num.between?(condition_values[0].to_f, condition_values[1].to_f)
148
+
149
+ when 'regex'
150
+ begin
151
+ regex = Regexp.new(first_value.to_s)
152
+ regex.match?(attribute_value.to_s)
153
+ rescue RegexpError
154
+ false
155
+ end
156
+
157
+ when 'semver_eq'
158
+ compare_semver(attribute_value, first_value).zero?
159
+
160
+ when 'semver_gt'
161
+ compare_semver(attribute_value, first_value).positive?
162
+
163
+ when 'semver_gte'
164
+ compare_semver(attribute_value, first_value) >= 0
165
+
166
+ when 'semver_lt'
167
+ compare_semver(attribute_value, first_value).negative?
168
+
169
+ when 'semver_lte'
170
+ compare_semver(attribute_value, first_value) <= 0
171
+
172
+ else
173
+ # Unknown operator - log warning and return false
174
+ warn "[ToggleCraft] Unknown operator: #{operator}"
175
+ false
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToggleCraft
4
+ VERSION = '1.0.0'
5
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent'
4
+ require 'http'
5
+ require 'cgi'
6
+ require 'timeout'
7
+
8
+ require_relative 'togglecraft/version'
9
+ require_relative 'togglecraft/utils'
10
+ require_relative 'togglecraft/cache'
11
+ require_relative 'togglecraft/evaluator'
12
+ require_relative 'togglecraft/sse_connection'
13
+ require_relative 'togglecraft/shared_sse_connection'
14
+ require_relative 'togglecraft/connection_pool'
15
+ require_relative 'togglecraft/client'
16
+
17
+ module ToggleCraft
18
+ class Error < StandardError; end
19
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'simplecov'
4
+ SimpleCov.start do
5
+ add_filter '/spec/'
6
+ end
7
+
8
+ require 'togglecraft'
9
+ require 'webmock/rspec'
10
+
11
+ RSpec.configure do |config|
12
+ # Enable flags like --only-failures and --next-failure
13
+ config.example_status_persistence_file_path = '.rspec_status'
14
+
15
+ # Disable RSpec exposing methods globally on `Module` and `main`
16
+ config.disable_monkey_patching!
17
+
18
+ config.expect_with :rspec do |c|
19
+ c.syntax = :expect
20
+ end
21
+
22
+ # Disable external HTTP requests during tests
23
+ config.before(:each) do
24
+ WebMock.disable_net_connect!(allow_localhost: true)
25
+ end
26
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe ToggleCraft::CacheAdapters::MemoryAdapter do
4
+ let(:adapter) { described_class.new }
5
+
6
+ describe '#get' do
7
+ it 'returns nil for non-existent keys' do
8
+ expect(adapter.get('missing')).to be_nil
9
+ end
10
+
11
+ it 'returns the stored value' do
12
+ adapter.set('key', 'value')
13
+ expect(adapter.get('key')).to eq('value')
14
+ end
15
+ end
16
+
17
+ describe '#set' do
18
+ it 'stores a value' do
19
+ adapter.set('key', 'value')
20
+ expect(adapter.get('key')).to eq('value')
21
+ end
22
+
23
+ it 'overwrites existing values' do
24
+ adapter.set('key', 'old')
25
+ adapter.set('key', 'new')
26
+ expect(adapter.get('key')).to eq('new')
27
+ end
28
+
29
+ it 'stores different types of values' do
30
+ adapter.set('string', 'text')
31
+ adapter.set('number', 123)
32
+ adapter.set('hash', { foo: 'bar' })
33
+ adapter.set('array', [1, 2, 3])
34
+
35
+ expect(adapter.get('string')).to eq('text')
36
+ expect(adapter.get('number')).to eq(123)
37
+ expect(adapter.get('hash')).to eq({ foo: 'bar' })
38
+ expect(adapter.get('array')).to eq([1, 2, 3])
39
+ end
40
+ end
41
+
42
+ describe '#delete' do
43
+ it 'removes a value and returns true' do
44
+ adapter.set('key', 'value')
45
+ result = adapter.delete('key')
46
+ expect(result).to be true
47
+ expect(adapter.get('key')).to be_nil
48
+ end
49
+
50
+ it 'returns false for non-existent keys' do
51
+ expect(adapter.delete('missing')).to be false
52
+ end
53
+ end
54
+
55
+ describe '#clear' do
56
+ it 'removes all values' do
57
+ adapter.set('key1', 'value1')
58
+ adapter.set('key2', 'value2')
59
+ adapter.set('key3', 'value3')
60
+
61
+ adapter.clear
62
+
63
+ expect(adapter.get('key1')).to be_nil
64
+ expect(adapter.get('key2')).to be_nil
65
+ expect(adapter.get('key3')).to be_nil
66
+ end
67
+ end
68
+
69
+ describe '#has?' do
70
+ it 'returns true for existing keys' do
71
+ adapter.set('key', 'value')
72
+ expect(adapter.has?('key')).to be true
73
+ end
74
+
75
+ it 'returns false for non-existent keys' do
76
+ expect(adapter.has?('missing')).to be false
77
+ end
78
+
79
+ it 'returns false after deletion' do
80
+ adapter.set('key', 'value')
81
+ adapter.delete('key')
82
+ expect(adapter.has?('key')).to be false
83
+ end
84
+ end
85
+
86
+ describe '#keys' do
87
+ it 'returns an empty array when no keys exist' do
88
+ expect(adapter.keys).to eq([])
89
+ end
90
+
91
+ it 'returns all stored keys' do
92
+ adapter.set('key1', 'value1')
93
+ adapter.set('key2', 'value2')
94
+ adapter.set('key3', 'value3')
95
+
96
+ keys = adapter.keys
97
+ expect(keys).to contain_exactly('key1', 'key2', 'key3')
98
+ end
99
+
100
+ it 'does not include deleted keys' do
101
+ adapter.set('key1', 'value1')
102
+ adapter.set('key2', 'value2')
103
+ adapter.delete('key1')
104
+
105
+ keys = adapter.keys
106
+ expect(keys).to contain_exactly('key2')
107
+ end
108
+ end
109
+
110
+ describe 'thread safety' do
111
+ it 'handles concurrent reads and writes' do
112
+ threads = 10.times.map do |i|
113
+ Thread.new do
114
+ 100.times do |j|
115
+ key = "thread_#{i}_key_#{j}"
116
+ adapter.set(key, "value_#{j}")
117
+ expect(adapter.get(key)).to eq("value_#{j}")
118
+ end
119
+ end
120
+ end
121
+
122
+ threads.each(&:join)
123
+
124
+ # Verify all values were stored correctly
125
+ expect(adapter.keys.length).to eq(1000)
126
+ end
127
+ end
128
+ end