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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +108 -0
- data/LICENSE +18 -0
- data/README.md +347 -0
- data/lib/togglecraft/cache.rb +151 -0
- data/lib/togglecraft/cache_adapters/memory_adapter.rb +54 -0
- data/lib/togglecraft/client.rb +568 -0
- data/lib/togglecraft/connection_pool.rb +134 -0
- data/lib/togglecraft/evaluator.rb +309 -0
- data/lib/togglecraft/shared_sse_connection.rb +266 -0
- data/lib/togglecraft/sse_connection.rb +296 -0
- data/lib/togglecraft/utils.rb +179 -0
- data/lib/togglecraft/version.rb +5 -0
- data/lib/togglecraft.rb +19 -0
- data/spec/spec_helper.rb +26 -0
- data/spec/togglecraft/cache/memory_adapter_spec.rb +128 -0
- data/spec/togglecraft/cache_spec.rb +274 -0
- data/spec/togglecraft/client_spec.rb +728 -0
- data/spec/togglecraft/connection_pool_spec.rb +178 -0
- data/spec/togglecraft/evaluator_spec.rb +443 -0
- data/spec/togglecraft/shared_sse_connection_spec.rb +585 -0
- data/spec/togglecraft/sse_connection_spec.rb +691 -0
- data/spec/togglecraft/utils_spec.rb +506 -0
- metadata +151 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module ToggleCraft
|
|
6
|
+
# Flag evaluation engine for ToggleCraft Ruby SDK
|
|
7
|
+
# Evaluates boolean, multivariate, and percentage flags based on rules and context
|
|
8
|
+
class Evaluator
|
|
9
|
+
def initialize
|
|
10
|
+
@flags = Concurrent::Map.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Update the flags data
|
|
14
|
+
# @param flags [Hash] The flags payload
|
|
15
|
+
def update_flags(flags)
|
|
16
|
+
@flags.clear
|
|
17
|
+
flags&.each { |key, value| @flags[key] = value }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Evaluate a boolean flag
|
|
21
|
+
# @param flag_key [String] The flag key
|
|
22
|
+
# @param context [Hash] The evaluation context
|
|
23
|
+
# @param default_value [Boolean] Default value if flag not found
|
|
24
|
+
# @return [Boolean]
|
|
25
|
+
def evaluate_boolean(flag_key, context = {}, default_value = false)
|
|
26
|
+
flag = @flags[flag_key]
|
|
27
|
+
|
|
28
|
+
# Flag doesn't exist
|
|
29
|
+
return default_value unless flag
|
|
30
|
+
|
|
31
|
+
# Flag is not a boolean type
|
|
32
|
+
unless flag[:type] == 'boolean'
|
|
33
|
+
warn "[ToggleCraft] Flag #{flag_key} is not a boolean flag"
|
|
34
|
+
return default_value
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Flag is disabled
|
|
38
|
+
return false unless flag[:enabled]
|
|
39
|
+
|
|
40
|
+
# Evaluate targeting rules
|
|
41
|
+
if flag[:rules]&.any?
|
|
42
|
+
flag[:rules].each do |rule|
|
|
43
|
+
if evaluate_rule(rule, context)
|
|
44
|
+
return rule[:value].nil? ? flag[:value] : rule[:value]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Return default flag value
|
|
50
|
+
flag[:value].nil? ? default_value : flag[:value]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Evaluate a multivariate flag
|
|
54
|
+
# @param flag_key [String] The flag key
|
|
55
|
+
# @param context [Hash] The evaluation context
|
|
56
|
+
# @param default_variant [String, nil] Default variant if flag not found
|
|
57
|
+
# @return [String, nil]
|
|
58
|
+
def evaluate_multivariate(flag_key, context = {}, default_variant = nil)
|
|
59
|
+
flag = @flags[flag_key]
|
|
60
|
+
|
|
61
|
+
# Flag doesn't exist
|
|
62
|
+
return default_variant unless flag
|
|
63
|
+
|
|
64
|
+
# Flag is not a multivariate type
|
|
65
|
+
unless flag[:type] == 'multivariate'
|
|
66
|
+
warn "[ToggleCraft] Flag #{flag_key} is not a multivariate flag"
|
|
67
|
+
return default_variant
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Flag is disabled
|
|
71
|
+
return default_variant unless flag[:enabled]
|
|
72
|
+
|
|
73
|
+
# Evaluate targeting rules
|
|
74
|
+
if flag[:rules]&.any?
|
|
75
|
+
flag[:rules].each do |rule|
|
|
76
|
+
return rule[:variant] || flag[:default_variant] || default_variant if evaluate_rule(rule, context)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Use weighted distribution if weights are defined
|
|
81
|
+
if flag[:weights]&.any?
|
|
82
|
+
variant = select_variant_by_weight(flag_key, flag[:weights], context)
|
|
83
|
+
return variant if variant
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Return default variant
|
|
87
|
+
flag[:default_variant] || default_variant
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Evaluate a percentage rollout flag
|
|
91
|
+
# @param flag_key [String] The flag key
|
|
92
|
+
# @param context [Hash] The evaluation context
|
|
93
|
+
# @param default_value [Boolean] Default value if flag not found
|
|
94
|
+
# @return [Boolean]
|
|
95
|
+
def evaluate_percentage(flag_key, context = {}, default_value = false)
|
|
96
|
+
flag = @flags[flag_key]
|
|
97
|
+
|
|
98
|
+
# Flag doesn't exist
|
|
99
|
+
return default_value unless flag
|
|
100
|
+
|
|
101
|
+
# Flag is not a percentage type
|
|
102
|
+
unless flag[:type] == 'percentage'
|
|
103
|
+
warn "[ToggleCraft] Flag #{flag_key} is not a percentage flag"
|
|
104
|
+
return default_value
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Flag is disabled
|
|
108
|
+
return false unless flag[:enabled]
|
|
109
|
+
|
|
110
|
+
# Evaluate targeting rules first
|
|
111
|
+
if flag[:rules]&.any?
|
|
112
|
+
flag[:rules].each do |rule|
|
|
113
|
+
return rule[:enabled].nil? || rule[:enabled] if evaluate_rule(rule, context)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Get current percentage (considering rollout stages)
|
|
118
|
+
percentage = current_percentage_for_flag(flag)
|
|
119
|
+
|
|
120
|
+
# Check percentage boundaries
|
|
121
|
+
return false if percentage <= 0
|
|
122
|
+
return true if percentage >= 100
|
|
123
|
+
|
|
124
|
+
# Use consistent hashing based on user ID or random value
|
|
125
|
+
user_id = context.dig(:user, :id) || context.dig(:user, :email) || SecureRandom.uuid
|
|
126
|
+
hash_value = Utils.hash_key("#{flag_key}:#{user_id}")
|
|
127
|
+
|
|
128
|
+
hash_value < percentage
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Calculate the current percentage for a flag based on rollout stages
|
|
132
|
+
# @param flag [Hash] The percentage flag object
|
|
133
|
+
# @return [Integer] The current percentage to use
|
|
134
|
+
def current_percentage_for_flag(flag)
|
|
135
|
+
# If no rollout stages, use base percentage
|
|
136
|
+
return flag[:percentage] if flag[:rollout_stages].nil? || flag[:rollout_stages].empty?
|
|
137
|
+
|
|
138
|
+
now = Time.now
|
|
139
|
+
active_stage = nil
|
|
140
|
+
latest_start_time = nil
|
|
141
|
+
|
|
142
|
+
# Find the most recent active stage
|
|
143
|
+
flag[:rollout_stages].each do |stage|
|
|
144
|
+
start_at = Time.parse(stage[:start_at])
|
|
145
|
+
end_at = stage[:end_at] ? Time.parse(stage[:end_at]) : nil
|
|
146
|
+
|
|
147
|
+
# Check if stage is currently active
|
|
148
|
+
is_active = start_at <= now && (end_at.nil? || end_at > now)
|
|
149
|
+
|
|
150
|
+
if is_active && (latest_start_time.nil? || start_at > latest_start_time)
|
|
151
|
+
active_stage = stage
|
|
152
|
+
latest_start_time = start_at
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Return active stage percentage, or fall back to base percentage
|
|
157
|
+
active_stage ? active_stage[:percentage] : flag[:percentage]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Get the current percentage value of a flag
|
|
161
|
+
# @param flag_key [String] The flag key
|
|
162
|
+
# @return [Integer, nil]
|
|
163
|
+
def percentage(flag_key)
|
|
164
|
+
flag = @flags[flag_key]
|
|
165
|
+
|
|
166
|
+
return nil unless flag && flag[:type] == 'percentage'
|
|
167
|
+
|
|
168
|
+
current_percentage_for_flag(flag)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Evaluate a single rule
|
|
172
|
+
# @param rule [Hash] The rule to evaluate
|
|
173
|
+
# @param context [Hash] The evaluation context
|
|
174
|
+
# @return [Boolean]
|
|
175
|
+
def evaluate_rule(rule, context)
|
|
176
|
+
return false if rule[:conditions].nil? || rule[:conditions].empty?
|
|
177
|
+
|
|
178
|
+
result = nil
|
|
179
|
+
previous_combinator = nil
|
|
180
|
+
|
|
181
|
+
rule[:conditions].each do |condition|
|
|
182
|
+
condition_result = evaluate_condition(condition, context)
|
|
183
|
+
|
|
184
|
+
result = if result.nil?
|
|
185
|
+
condition_result
|
|
186
|
+
else
|
|
187
|
+
# Apply combinator logic
|
|
188
|
+
case previous_combinator
|
|
189
|
+
when 'OR'
|
|
190
|
+
result || condition_result
|
|
191
|
+
else
|
|
192
|
+
# Default to AND
|
|
193
|
+
result && condition_result
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
previous_combinator = condition[:combinator] || 'AND'
|
|
198
|
+
|
|
199
|
+
# Early exit for AND conditions
|
|
200
|
+
return false if !result && previous_combinator == 'AND'
|
|
201
|
+
|
|
202
|
+
# Early exit for OR conditions
|
|
203
|
+
return true if result && previous_combinator == 'OR'
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
result || false
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Evaluate a single condition
|
|
210
|
+
# @param condition [Hash] The condition to evaluate
|
|
211
|
+
# @param context [Hash] The evaluation context
|
|
212
|
+
# @return [Boolean]
|
|
213
|
+
def evaluate_condition(condition, context)
|
|
214
|
+
attribute_value = Utils.get_nested_property(context, condition[:attribute])
|
|
215
|
+
Utils.evaluate_operator(attribute_value, condition[:operator], condition[:values] || [])
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Select a variant based on weights using consistent hashing
|
|
219
|
+
# @param flag_key [String] The flag key
|
|
220
|
+
# @param weights [Hash] Variant weights
|
|
221
|
+
# @param context [Hash] The evaluation context
|
|
222
|
+
# @return [String, nil]
|
|
223
|
+
def select_variant_by_weight(flag_key, weights, context)
|
|
224
|
+
# Calculate total weight
|
|
225
|
+
total_weight = 0
|
|
226
|
+
variants = []
|
|
227
|
+
|
|
228
|
+
weights.each do |variant, weight|
|
|
229
|
+
total_weight += weight
|
|
230
|
+
variants << { variant: variant.to_s, weight: weight }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
return nil if total_weight.zero?
|
|
234
|
+
|
|
235
|
+
# Use consistent hashing
|
|
236
|
+
user_id = context.dig(:user, :id) || context.dig(:user, :email) || SecureRandom.uuid
|
|
237
|
+
hash = Utils.hash_key("#{flag_key}:#{user_id}")
|
|
238
|
+
scaled_hash = (hash.to_f / 100) * total_weight
|
|
239
|
+
|
|
240
|
+
# Select variant based on hash
|
|
241
|
+
accumulator = 0
|
|
242
|
+
variants.each do |v|
|
|
243
|
+
accumulator += v[:weight]
|
|
244
|
+
return v[:variant] if scaled_hash < accumulator
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Fallback to last variant
|
|
248
|
+
variants.last&.dig(:variant)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Check if a flag exists
|
|
252
|
+
# @param flag_key [String] The flag key
|
|
253
|
+
# @return [Boolean]
|
|
254
|
+
def has_flag?(flag_key)
|
|
255
|
+
@flags.key?(flag_key)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Get all flag keys
|
|
259
|
+
# @return [Array<String>]
|
|
260
|
+
def flag_keys
|
|
261
|
+
@flags.keys
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Get flag metadata
|
|
265
|
+
# @param flag_key [String] The flag key
|
|
266
|
+
# @return [Hash, nil]
|
|
267
|
+
def flag_metadata(flag_key)
|
|
268
|
+
flag = @flags[flag_key]
|
|
269
|
+
return nil unless flag
|
|
270
|
+
|
|
271
|
+
metadata = {
|
|
272
|
+
type: flag[:type],
|
|
273
|
+
enabled: flag[:enabled]
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
case flag[:type]
|
|
277
|
+
when 'multivariate'
|
|
278
|
+
metadata[:variants] = flag[:variants]
|
|
279
|
+
metadata[:default_variant] = flag[:default_variant]
|
|
280
|
+
when 'percentage'
|
|
281
|
+
metadata[:percentage] = flag[:percentage]
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
metadata
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Evaluate any flag type
|
|
288
|
+
# @param flag_key [String] The flag key
|
|
289
|
+
# @param context [Hash] The evaluation context
|
|
290
|
+
# @param default_value [Object] Default value
|
|
291
|
+
# @return [Object]
|
|
292
|
+
def evaluate(flag_key, context = {}, default_value = false)
|
|
293
|
+
flag = @flags[flag_key]
|
|
294
|
+
return default_value unless flag
|
|
295
|
+
|
|
296
|
+
case flag[:type]
|
|
297
|
+
when 'boolean'
|
|
298
|
+
evaluate_boolean(flag_key, context, default_value)
|
|
299
|
+
when 'multivariate'
|
|
300
|
+
evaluate_multivariate(flag_key, context, default_value)
|
|
301
|
+
when 'percentage'
|
|
302
|
+
evaluate_percentage(flag_key, context, default_value)
|
|
303
|
+
else
|
|
304
|
+
warn "[ToggleCraft] Unknown flag type: #{flag[:type]}"
|
|
305
|
+
default_value
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ToggleCraft
|
|
4
|
+
# Shared SSE connection wrapper
|
|
5
|
+
# Manages a single SSE connection that can be shared by multiple SDK clients
|
|
6
|
+
class SharedSSEConnection
|
|
7
|
+
attr_reader :pool_key
|
|
8
|
+
|
|
9
|
+
def initialize(config, pool_key)
|
|
10
|
+
@config = config
|
|
11
|
+
@pool_key = pool_key
|
|
12
|
+
@clients = Concurrent::Set.new
|
|
13
|
+
@connection = nil
|
|
14
|
+
@last_payload = nil
|
|
15
|
+
@is_connecting = Concurrent::AtomicBoolean.new(false)
|
|
16
|
+
@connection_promise = nil
|
|
17
|
+
@debug = config[:debug] || false
|
|
18
|
+
@on_empty_callback = nil
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Add a client to this shared connection
|
|
23
|
+
# @param client [Object] The ToggleCraftClient instance
|
|
24
|
+
# @return [void]
|
|
25
|
+
def add_client(client)
|
|
26
|
+
log "Adding client to shared connection (#{@clients.size + 1} total clients)"
|
|
27
|
+
@clients.add(client)
|
|
28
|
+
|
|
29
|
+
# If connection is already established, simulate initialization for new client
|
|
30
|
+
return unless connected? && @last_payload
|
|
31
|
+
|
|
32
|
+
log 'Connection already established - simulating initialization for new client'
|
|
33
|
+
|
|
34
|
+
# Simulate connection event
|
|
35
|
+
client.handle_connect if client.respond_to?(:handle_connect)
|
|
36
|
+
|
|
37
|
+
# Send cached flags
|
|
38
|
+
client.handle_flags_update(@last_payload) if client.respond_to?(:handle_flags_update)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Remove a client from this shared connection
|
|
42
|
+
# @param client [Object] The ToggleCraftClient instance
|
|
43
|
+
# @return [void]
|
|
44
|
+
def remove_client(client)
|
|
45
|
+
log "Removing client from shared connection (#{@clients.size - 1} remaining)"
|
|
46
|
+
@clients.delete(client)
|
|
47
|
+
|
|
48
|
+
# If no more clients, clean up the connection
|
|
49
|
+
return unless @clients.empty?
|
|
50
|
+
|
|
51
|
+
log 'No more clients, closing shared connection'
|
|
52
|
+
cleanup
|
|
53
|
+
@on_empty_callback&.call
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Connect to the SSE server
|
|
57
|
+
# @param client_to_add [Object, nil] Optional client to add when connecting
|
|
58
|
+
# @return [void]
|
|
59
|
+
def connect(client_to_add = nil)
|
|
60
|
+
# If already connecting, wait for completion
|
|
61
|
+
if @is_connecting.value
|
|
62
|
+
log 'Connection already in progress, waiting...'
|
|
63
|
+
# Just add the client, connection will complete
|
|
64
|
+
add_client(client_to_add) if client_to_add
|
|
65
|
+
return
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# If already connected, just add the client
|
|
69
|
+
if connected?
|
|
70
|
+
log 'Already connected'
|
|
71
|
+
add_client(client_to_add) if client_to_add
|
|
72
|
+
return
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
@is_connecting.make_true
|
|
76
|
+
|
|
77
|
+
begin
|
|
78
|
+
create_connection
|
|
79
|
+
add_client(client_to_add) if client_to_add
|
|
80
|
+
ensure
|
|
81
|
+
@is_connecting.make_false
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Disconnect from the SSE server
|
|
86
|
+
# @return [void]
|
|
87
|
+
def disconnect
|
|
88
|
+
log 'Disconnect called on shared connection'
|
|
89
|
+
@connection&.disconnect
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Force disconnect (used by pool cleanup)
|
|
93
|
+
# @return [void]
|
|
94
|
+
def force_disconnect
|
|
95
|
+
log 'Force disconnecting shared connection'
|
|
96
|
+
@connection&.disconnect
|
|
97
|
+
@clients.clear
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Reconnect to the SSE server
|
|
101
|
+
# @return [void]
|
|
102
|
+
def reconnect
|
|
103
|
+
log 'Reconnecting shared connection'
|
|
104
|
+
|
|
105
|
+
# Notify all clients about reconnection
|
|
106
|
+
@clients.each do |client|
|
|
107
|
+
client.emit(:reconnecting) if client.respond_to?(:emit)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
if @connection
|
|
111
|
+
@connection.reconnect
|
|
112
|
+
else
|
|
113
|
+
connect
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Check if connected
|
|
118
|
+
# @return [Boolean]
|
|
119
|
+
def connected?
|
|
120
|
+
@connection&.connected?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Get the number of connected clients
|
|
124
|
+
# @return [Integer]
|
|
125
|
+
def client_count
|
|
126
|
+
@clients.size
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Set callback for when connection becomes empty
|
|
130
|
+
# @param callback [Proc] Callback to invoke
|
|
131
|
+
# @return [void]
|
|
132
|
+
def on_empty(&callback)
|
|
133
|
+
@on_empty_callback = callback
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Enable or disable debug logging
|
|
137
|
+
# @param enabled [Boolean]
|
|
138
|
+
# @return [void]
|
|
139
|
+
def set_debug(enabled)
|
|
140
|
+
@debug = enabled
|
|
141
|
+
return unless @connection
|
|
142
|
+
|
|
143
|
+
@connection.instance_variable_set(:@options,
|
|
144
|
+
@connection.instance_variable_get(:@options).merge(debug: enabled))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
# Create the actual SSE connection
|
|
150
|
+
# @return [void]
|
|
151
|
+
def create_connection
|
|
152
|
+
log 'Creating new SSE connection'
|
|
153
|
+
|
|
154
|
+
@connection = SSEConnection.new(
|
|
155
|
+
url: @config[:url],
|
|
156
|
+
sdk_key: @config[:sdk_key],
|
|
157
|
+
reconnect_interval: @config[:reconnect_interval],
|
|
158
|
+
max_reconnect_interval: @config[:max_reconnect_interval],
|
|
159
|
+
max_reconnect_attempts: @config[:max_reconnect_attempts],
|
|
160
|
+
slow_reconnect_interval: @config[:slow_reconnect_interval],
|
|
161
|
+
heartbeat_domain: @config[:heartbeat_domain],
|
|
162
|
+
debug: @debug,
|
|
163
|
+
on_message: method(:handle_message),
|
|
164
|
+
on_connect: method(:handle_connect),
|
|
165
|
+
on_disconnect: method(:handle_disconnect),
|
|
166
|
+
on_error: method(:handle_error)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
@connection.connect
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Handle incoming messages from SSE
|
|
173
|
+
# @param payload [Hash] Message payload
|
|
174
|
+
# @return [void]
|
|
175
|
+
def handle_message(payload)
|
|
176
|
+
log "Broadcasting message to #{@clients.size} clients"
|
|
177
|
+
|
|
178
|
+
# Cache the last payload for new clients
|
|
179
|
+
@last_payload = payload if payload[:flags]
|
|
180
|
+
|
|
181
|
+
# Broadcast to all connected clients
|
|
182
|
+
@clients.each do |client|
|
|
183
|
+
next unless client.respond_to?(:handle_flags_update)
|
|
184
|
+
|
|
185
|
+
begin
|
|
186
|
+
client.handle_flags_update(payload)
|
|
187
|
+
rescue StandardError => e
|
|
188
|
+
warn "Error handling flags update in client: #{e.message}"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Handle SSE connection
|
|
194
|
+
# @return [void]
|
|
195
|
+
def handle_connect
|
|
196
|
+
log 'SSE connected'
|
|
197
|
+
|
|
198
|
+
# Notify all clients
|
|
199
|
+
@clients.each do |client|
|
|
200
|
+
next unless client.respond_to?(:handle_connect)
|
|
201
|
+
|
|
202
|
+
begin
|
|
203
|
+
client.handle_connect
|
|
204
|
+
rescue StandardError => e
|
|
205
|
+
warn "Error handling connect in client: #{e.message}"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Handle SSE disconnection
|
|
211
|
+
# @return [void]
|
|
212
|
+
def handle_disconnect
|
|
213
|
+
log 'SSE disconnected'
|
|
214
|
+
|
|
215
|
+
# Notify all clients
|
|
216
|
+
@clients.each do |client|
|
|
217
|
+
next unless client.respond_to?(:handle_disconnect)
|
|
218
|
+
|
|
219
|
+
begin
|
|
220
|
+
client.handle_disconnect
|
|
221
|
+
rescue StandardError => e
|
|
222
|
+
warn "Error handling disconnect in client: #{e.message}"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Handle SSE errors
|
|
228
|
+
# @param error [Exception] Error that occurred
|
|
229
|
+
# @return [void]
|
|
230
|
+
def handle_error(error)
|
|
231
|
+
log "SSE error: #{error.message}"
|
|
232
|
+
|
|
233
|
+
# Broadcast error to all clients
|
|
234
|
+
@clients.each do |client|
|
|
235
|
+
next unless client.respond_to?(:handle_error)
|
|
236
|
+
|
|
237
|
+
begin
|
|
238
|
+
client.handle_error(error)
|
|
239
|
+
rescue StandardError => e
|
|
240
|
+
warn "Error handling error in client: #{e.message}"
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Clean up resources
|
|
246
|
+
# @return [void]
|
|
247
|
+
def cleanup
|
|
248
|
+
log 'Cleaning up shared connection'
|
|
249
|
+
|
|
250
|
+
@connection&.disconnect
|
|
251
|
+
@connection = nil
|
|
252
|
+
@last_payload = nil
|
|
253
|
+
@is_connecting.make_false
|
|
254
|
+
@connection_promise = nil
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Log debug messages
|
|
258
|
+
# @param message [String] Message to log
|
|
259
|
+
# @return [void]
|
|
260
|
+
def log(message)
|
|
261
|
+
return unless @debug
|
|
262
|
+
|
|
263
|
+
puts "[ToggleCraft SharedSSEConnection #{@pool_key}] #{message}"
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|