smplkit 3.0.134 → 3.0.135

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,453 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "async"
5
+ require "async/http/client"
6
+ require "async/http/endpoint"
7
+
8
+ module Smplkit
9
+ # Manages the single live-updates event stream to the app service.
10
+ #
11
+ # A single +EventStream+ instance is shared across all product modules
12
+ # (config, flags, logging) within one +Smplkit::Client+. Product modules
13
+ # register listeners for specific event names; the shared stream dispatches
14
+ # incoming events to the appropriate listeners. Modules also register
15
+ # refetch callbacks, invoked after every successful *re*connect so their
16
+ # caches recover anything the server published while the stream was down.
17
+ #
18
+ # The stream runs on a dedicated SDK-owned thread that hosts the +Async+
19
+ # reactor and the underlying +async-http+ I/O. Public methods are
20
+ # thread-safe and non-blocking.
21
+ #
22
+ # Wire protocol — Server-Sent Events (SSE) over plain HTTPS:
23
+ #
24
+ # - +GET <app_base_url>/api/v1/events+ with +Accept: text/event-stream+
25
+ # and +Authorization: Bearer <api_key>+.
26
+ # - A 200 response with a +text/event-stream+ content type is a
27
+ # successful connect; auth failure is a plain HTTP 401.
28
+ # - Each SSE frame carries the event name in the +event:+ field and a
29
+ # JSON object in +data:+ (+{"id": "<key>"}+ for single-resource
30
+ # events, +{}+ for bulk refreshes and the initial +connected+ event).
31
+ # - The server emits a +: keepalive+ comment frame every 30 seconds when
32
+ # idle; any received bytes count as liveness. Reads that stall past
33
+ # +READ_TIMEOUT+ seconds tear the connection down for a reconnect.
34
+ # - The server's +retry:+ field seeds the reconnect backoff base.
35
+ #
36
+ # On disconnect the reactor reconnects with exponential backoff (base
37
+ # delay doubling up to +MAX_BACKOFF+ seconds), resetting to the base on
38
+ # every successful connect. +stop+ closes the stream from the outer
39
+ # thread; the reader exits and the daemon thread terminates.
40
+ class EventStream
41
+ # Seconds without any bytes from the server (events or keepalive
42
+ # comments) before the connection is considered dead — two missed
43
+ # 30-second server keepalives.
44
+ READ_TIMEOUT = 45
45
+
46
+ # Ceiling for the exponential reconnect backoff, in seconds.
47
+ MAX_BACKOFF = 60
48
+
49
+ # Initial reconnect backoff base, in seconds, used until the server
50
+ # supplies its own via the SSE +retry:+ field.
51
+ DEFAULT_RETRY = 1.0
52
+
53
+ # Sent on the stream request — the platform WAF rejects requests that
54
+ # carry no User-Agent. There is no caller-supplied header surface on the
55
+ # event stream, so the SDK default always applies.
56
+ USER_AGENT = Smplkit.user_agent.freeze
57
+
58
+ # Incremental parser for a +text/event-stream+ byte stream.
59
+ #
60
+ # Feed it raw chunks as they arrive; it returns the events completed by
61
+ # each chunk. Implements the SSE wire format: +\n+, +\r\n+, and +\r+
62
+ # line terminators (including a CRLF split across chunks), a leading
63
+ # UTF-8 BOM, comment lines (leading +:+), field values split across
64
+ # chunk boundaries, multiple +data:+ lines joined with +\n+, and the
65
+ # numeric +retry:+ field. Unknown fields are ignored.
66
+ class Parser
67
+ # One complete SSE event: +name+ is the event name (from the +event:+
68
+ # field, defaulting to +"message"+), +data+ the joined data payload.
69
+ Event = Struct.new(:name, :data, keyword_init: true)
70
+
71
+ UTF8_BOM = String.new("\xEF\xBB\xBF", encoding: Encoding::BINARY).freeze
72
+
73
+ # Milliseconds from the most recent valid +retry:+ field, or +nil+.
74
+ attr_reader :retry_ms
75
+
76
+ def initialize
77
+ @buffer = String.new(encoding: Encoding::BINARY)
78
+ @bom_pending = true
79
+ @event_type = ""
80
+ @data_lines = []
81
+ @retry_ms = nil
82
+ end
83
+
84
+ # Consume one chunk of the stream. Returns the (possibly empty) array
85
+ # of +Event+s completed by this chunk.
86
+ def feed(chunk)
87
+ @buffer << chunk.dup.force_encoding(Encoding::BINARY)
88
+ strip_bom if @bom_pending
89
+ events = []
90
+ while (line = next_line)
91
+ handle_line(line, events)
92
+ end
93
+ events
94
+ end
95
+
96
+ private
97
+
98
+ # Drop a UTF-8 BOM at the very start of the stream. The BOM is three
99
+ # bytes and may itself arrive split across chunks, so stay pending
100
+ # while the buffer is still a strict prefix of it.
101
+ def strip_bom
102
+ if @buffer.bytesize >= UTF8_BOM.bytesize
103
+ @buffer = @buffer.byteslice(UTF8_BOM.bytesize..) if @buffer.start_with?(UTF8_BOM)
104
+ @bom_pending = false
105
+ elsif !UTF8_BOM.start_with?(@buffer)
106
+ @bom_pending = false
107
+ end
108
+ end
109
+
110
+ # Extract the next complete line (terminator: +\n+, +\r\n+, or bare
111
+ # +\r+), or +nil+ if the buffer holds none. A CR as the final buffered
112
+ # byte is held back — it may be the first half of a CRLF whose LF is
113
+ # still in flight. Line splitting happens on the raw bytes (0x0A/0x0D
114
+ # never appear inside a UTF-8 multi-byte sequence); the completed line
115
+ # is re-tagged UTF-8.
116
+ def next_line
117
+ cr = @buffer.index("\r")
118
+ lf = @buffer.index("\n")
119
+ return nil if cr.nil? && lf.nil?
120
+
121
+ if cr && (lf.nil? || cr < lf)
122
+ return nil if cr == @buffer.bytesize - 1
123
+
124
+ line = @buffer.byteslice(0, cr)
125
+ skip = @buffer.getbyte(cr + 1) == 0x0A ? 2 : 1
126
+ @buffer = @buffer.byteslice(cr + skip, @buffer.bytesize - cr - skip)
127
+ else
128
+ line = @buffer.byteslice(0, lf)
129
+ @buffer = @buffer.byteslice(lf + 1, @buffer.bytesize - lf - 1)
130
+ end
131
+ line.force_encoding(Encoding::UTF_8)
132
+ end
133
+
134
+ # A blank line dispatches the accumulated event; a leading +:+ marks a
135
+ # comment (the server's keepalive frame) — ignored here, since mere
136
+ # receipt of its bytes already counted as liveness at the read layer.
137
+ def handle_line(line, events)
138
+ if line.empty?
139
+ flush_pending(events)
140
+ elsif !line.start_with?(":")
141
+ apply_field(*split_field(line))
142
+ end
143
+ end
144
+
145
+ # Split +"field: value"+ on the first colon, stripping at most one
146
+ # leading space from the value. A line with no colon is a field with
147
+ # an empty value.
148
+ def split_field(line)
149
+ sep = line.index(":")
150
+ return [line, ""] if sep.nil?
151
+
152
+ value = line[(sep + 1)..]
153
+ value = value[1..] if value.start_with?(" ")
154
+ [line[0...sep], value]
155
+ end
156
+
157
+ # +id:+ is deliberately ignored along with unknown fields — the SDK
158
+ # never resumes with +Last-Event-ID+.
159
+ def apply_field(field, value)
160
+ case field
161
+ when "event"
162
+ @event_type = value
163
+ when "data"
164
+ @data_lines << value
165
+ when "retry"
166
+ @retry_ms = Integer(value, 10) if value.match?(/\A\d+\z/)
167
+ end
168
+ end
169
+
170
+ # Blank line: emit the accumulated event. Per the SSE spec an event
171
+ # with an empty data buffer is discarded (the event type still
172
+ # resets); multiple +data:+ lines join with +\n+.
173
+ def flush_pending(events)
174
+ unless @data_lines.empty?
175
+ name = @event_type.empty? ? "message" : @event_type
176
+ events << Event.new(name: name, data: @data_lines.join("\n"))
177
+ end
178
+ @event_type = ""
179
+ @data_lines = []
180
+ end
181
+ end
182
+
183
+ def initialize(app_base_url:, api_key:, metrics: nil)
184
+ @app_base_url = app_base_url
185
+ @api_key = api_key
186
+ @metrics = metrics
187
+ @listeners = Hash.new { |h, k| h[k] = [] }
188
+ @refetch_callbacks = []
189
+ @listeners_lock = Mutex.new
190
+ @connection_status = "disconnected"
191
+ @closed = false
192
+ @stream_thread = nil
193
+ @client = nil
194
+ @response = nil
195
+ @connection_lock = Mutex.new
196
+ @retry_base = DEFAULT_RETRY
197
+ @attempt = 0
198
+ @ever_connected = false
199
+ end
200
+
201
+ # ----- Listener registration ------------------------------------
202
+
203
+ def on(event_name, &callback)
204
+ @listeners_lock.synchronize { @listeners[event_name] << callback }
205
+ end
206
+
207
+ def off(event_name, callback)
208
+ @listeners_lock.synchronize { @listeners[event_name].delete(callback) }
209
+ end
210
+
211
+ # Register a refetch callback, invoked (with no arguments) after every
212
+ # successful *re*connect — never on the initial connect. Product modules
213
+ # use this to run their bulk-refresh path so caches recover events
214
+ # missed while the stream was down.
215
+ def on_reconnect(callback = nil, &block)
216
+ cb = callback || block
217
+ @listeners_lock.synchronize { @refetch_callbacks << cb }
218
+ cb
219
+ end
220
+
221
+ def off_reconnect(callback)
222
+ @listeners_lock.synchronize { @refetch_callbacks.delete(callback) }
223
+ end
224
+
225
+ # Dispatch +payload+ to every listener registered for +event_name+.
226
+ # Event names nothing subscribed to dispatch to zero listeners — unknown
227
+ # events are ignored by construction. Listener exceptions are caught and
228
+ # logged; one bad listener never blocks the rest.
229
+ def dispatch(event_name, payload)
230
+ callbacks = @listeners_lock.synchronize { @listeners[event_name].dup }
231
+ callbacks.each do |cb|
232
+ cb.call(payload)
233
+ rescue StandardError => e
234
+ Smplkit.debug("events", "listener for #{event_name} raised: #{e.class}: #{e.message}")
235
+ end
236
+ end
237
+
238
+ # ----- Connection status ----------------------------------------
239
+
240
+ attr_reader :connection_status
241
+
242
+ # ----- Lifecycle ------------------------------------------------
243
+
244
+ def start
245
+ return if @stream_thread&.alive?
246
+
247
+ Smplkit.debug("events", "starting shared event stream background thread")
248
+ @closed = false
249
+ @connection_status = "connecting"
250
+ @stream_thread = Thread.new { run_reactor }
251
+ @stream_thread.name = "smplkit-events" if @stream_thread.respond_to?(:name=)
252
+ end
253
+
254
+ def stop
255
+ Smplkit.debug("events", "stopping shared event stream")
256
+ @closed = true
257
+ close_active_connection
258
+ thread = @stream_thread
259
+ @stream_thread = nil
260
+ if thread
261
+ thread.join(2.0)
262
+ thread.kill if thread.alive?
263
+ end
264
+ # Set authoritatively after the thread is dead so a racing connect
265
+ # call (which also sets "connecting") cannot clobber this value.
266
+ @connection_status = "disconnected"
267
+ end
268
+
269
+ # ----- URL builder ----------------------------------------------
270
+
271
+ def build_events_url
272
+ url = @app_base_url.dup
273
+ url = "https://#{url}" unless url.start_with?("https://", "http://")
274
+ "#{url.chomp("/")}/api/v1/events"
275
+ end
276
+
277
+ # ----- Inbound event handling (extracted for tests) -------------
278
+
279
+ # Process one parsed SSE event the way the live read loop does: parse
280
+ # the JSON payload and dispatch it to the listeners registered for the
281
+ # event name.
282
+ #
283
+ # Returns +:dispatched+ or +:unparseable+ for the caller to log/observe;
284
+ # the live read loop ignores the return value.
285
+ def handle_event(event_name, data)
286
+ payload =
287
+ begin
288
+ JSON.parse(data)
289
+ rescue JSON::ParserError
290
+ nil
291
+ end
292
+ unless payload.is_a?(Hash)
293
+ Smplkit.debug("events", "ignoring #{event_name.inspect} event with non-object payload")
294
+ return :unparseable
295
+ end
296
+
297
+ dispatch(event_name, payload)
298
+ :dispatched
299
+ end
300
+
301
+ private
302
+
303
+ def run_reactor
304
+ Sync do |task|
305
+ stream_main(task)
306
+ end
307
+ rescue StandardError => e
308
+ Smplkit.debug("events", "event stream thread exited unexpectedly: #{e.class}: #{e.message}")
309
+ end
310
+
311
+ # Connect/read/reconnect forever until +stop+. Every pass either ends
312
+ # with a clean server EOF or an exception (connect failure, read error,
313
+ # liveness timeout) — both funnel into the same backoff + retry.
314
+ def stream_main(task)
315
+ until @closed
316
+ begin
317
+ connect_and_stream(task)
318
+ rescue StandardError => e
319
+ return if @closed
320
+
321
+ Smplkit.debug("events", "stream error (url: #{build_events_url}): #{e.class}: #{e.message}")
322
+ end
323
+ return if @closed
324
+
325
+ @connection_status = "reconnecting"
326
+ delay = next_backoff_delay
327
+ Smplkit.debug("events", "reconnecting in #{delay}s")
328
+ task.sleep(delay)
329
+ end
330
+ end
331
+
332
+ # One connection lifetime: open the request, verify the SSE handshake,
333
+ # then read frames until EOF/error. The response and client are always
334
+ # torn down on the way out.
335
+ def connect_and_stream(task)
336
+ @connection_status = "connecting"
337
+ Smplkit.debug("events", "connecting to #{build_events_url}")
338
+ endpoint = Async::HTTP::Endpoint.parse(build_events_url)
339
+ client = Async::HTTP::Client.new(endpoint)
340
+ @connection_lock.synchronize { @client = client }
341
+ response = nil
342
+ begin
343
+ response = client.get(endpoint.path, request_headers)
344
+ @connection_lock.synchronize { @response = response }
345
+ verify_response!(response)
346
+ mark_connected
347
+ read_loop(task, response.body)
348
+ ensure
349
+ mark_disconnected
350
+ close_quietly(response)
351
+ close_quietly(client)
352
+ @connection_lock.synchronize do
353
+ @response = nil
354
+ @client = nil
355
+ end
356
+ end
357
+ end
358
+
359
+ def request_headers
360
+ [
361
+ ["accept", "text/event-stream"],
362
+ ["authorization", "Bearer #{@api_key}"],
363
+ ["user-agent", USER_AGENT]
364
+ ]
365
+ end
366
+
367
+ # A successful connect is exactly: HTTP 200 with a text/event-stream
368
+ # content type. Anything else (401 on bad auth, proxies serving HTML,
369
+ # ...) tears down and backs off.
370
+ def verify_response!(response)
371
+ status = response.status
372
+ content_type = response.headers["content-type"].to_s
373
+ return if status == 200 && content_type.start_with?("text/event-stream")
374
+
375
+ raise ConnectionError, "event stream connect failed: HTTP #{status} " \
376
+ "(content-type: #{content_type.inspect})"
377
+ end
378
+
379
+ def mark_connected
380
+ reconnected = @ever_connected
381
+ @ever_connected = true
382
+ @attempt = 0
383
+ @connection_status = "connected"
384
+ @metrics&.record_gauge("platform.event_connections", 1, unit: "connections")
385
+ Smplkit.debug("events", reconnected ? "event stream reconnected" : "event stream connected")
386
+ run_refetch_callbacks if reconnected
387
+ end
388
+
389
+ # Leaving the connected state (only): flip the gauge and status. A
390
+ # connect attempt that never completed the handshake records nothing.
391
+ def mark_disconnected
392
+ return unless @connection_status == "connected"
393
+
394
+ @connection_status = "reconnecting"
395
+ @metrics&.record_gauge("platform.event_connections", 0, unit: "connections")
396
+ end
397
+
398
+ def run_refetch_callbacks
399
+ callbacks = @listeners_lock.synchronize { @refetch_callbacks.dup }
400
+ callbacks.each do |cb|
401
+ cb.call
402
+ rescue StandardError => e
403
+ Smplkit.debug("events", "refetch callback raised: #{e.class}: #{e.message}")
404
+ end
405
+ end
406
+
407
+ # Read chunks until EOF, feeding the SSE parser and dispatching the
408
+ # events it completes. Each read is individually wrapped in the liveness
409
+ # timeout, so any received bytes — including keepalive comment frames —
410
+ # reset the clock.
411
+ def read_loop(task, body)
412
+ parser = Parser.new
413
+ until @closed
414
+ chunk = task.with_timeout(READ_TIMEOUT) { body.read }
415
+ break if chunk.nil?
416
+
417
+ process_chunk(parser, chunk)
418
+ end
419
+ end
420
+
421
+ def process_chunk(parser, chunk)
422
+ parser.feed(chunk).each { |event| handle_event(event.name, event.data) }
423
+ @retry_base = parser.retry_ms / 1000.0 unless parser.retry_ms.nil?
424
+ end
425
+
426
+ # Exponential backoff: base, 2x, 4x, ... capped at MAX_BACKOFF. The
427
+ # base comes from the server's +retry:+ field (DEFAULT_RETRY until one
428
+ # arrives); +mark_connected+ resets the exponent on every successful
429
+ # connect. No jitter.
430
+ def next_backoff_delay
431
+ delay = [@retry_base * (2**@attempt), MAX_BACKOFF].min
432
+ @attempt += 1 if delay < MAX_BACKOFF
433
+ delay
434
+ end
435
+
436
+ def close_active_connection
437
+ response, client = @connection_lock.synchronize do
438
+ pair = [@response, @client]
439
+ @response = nil
440
+ @client = nil
441
+ pair
442
+ end
443
+ close_quietly(response)
444
+ close_quietly(client)
445
+ end
446
+
447
+ def close_quietly(resource)
448
+ resource&.close
449
+ rescue StandardError => e
450
+ Smplkit.debug("events", "close raised: #{e.class}: #{e.message}")
451
+ end
452
+ end
453
+ end
@@ -18,18 +18,18 @@ require "digest"
18
18
  # +json_flag+) whose +.get+ evaluates against the cached definitions, plus
19
19
  # +refresh+ / +stats+ / +on_change+. The first live call transparently flushes
20
20
  # discovery, fetches all flag definitions into the local cache, and opens the
21
- # live-updates WebSocket — no explicit install step.
21
+ # live-updates event stream — no explicit install step.
22
22
  #
23
23
  # The client supports two construction shapes:
24
24
  #
25
25
  # * *Wired* into +Smplkit::Client+ — borrows the parent's flags transport for
26
- # both runtime fetch and CRUD, the parent's shared WebSocket for the live
26
+ # both runtime fetch and CRUD, the parent's shared event stream for the live
27
27
  # channel, and +client.platform.contexts+ for evaluation-context
28
28
  # registration. This is the common path.
29
29
  # * *Standalone* — +FlagsClient.new(api_key: ..., base_url: ..., ...)+ builds
30
30
  # and owns its own flags transport and a contexts client (against its own app
31
- # transport), and on first live use opens and owns its own WebSocket. +close+
32
- # tears down only the owned transports and owned WebSocket.
31
+ # transport), and on first live use opens and owns its own event stream.
32
+ # +close+ tears down only the owned transports and owned event stream.
33
33
  module Smplkit
34
34
  module Flags
35
35
  # Describes a flag definition change. Frozen — fields are set at construction.
@@ -37,7 +37,7 @@ module Smplkit
37
37
  # @!attribute [r] id
38
38
  # @return [String] id of the flag whose definition changed.
39
39
  # @!attribute [r] source
40
- # @return [String] origin of the change (e.g. +"websocket"+ for a live
40
+ # @return [String] origin of the change (e.g. +"push"+ for a live
41
41
  # update or +"manual"+ for a refresh).
42
42
  # @!attribute [r] deleted
43
43
  # @return [Boolean] whether the change was a deletion of the flag.
@@ -148,7 +148,7 @@ module Smplkit
148
148
  # defaults). +environment+/+service+ resolve the same way (constructor
149
149
  # argument wins). The app transport backs the standalone contexts client
150
150
  # (evaluation-context registration); the app base URL is returned so a
151
- # standalone client can open its own WebSocket against the event gateway.
151
+ # standalone client can open its own event stream against the app service.
152
152
  #
153
153
  # @api private
154
154
  def self.flags_transport(api_key:, base_url:, profile:, base_domain:, scheme:,
@@ -186,8 +186,8 @@ module Smplkit
186
186
  # is pure CRUD. The live surface (+boolean_flag+ / +string_flag+ /
187
187
  # +number_flag+ / +json_flag+ / +refresh+ / +stats+ / +on_change+) connects
188
188
  # lazily on first use — the first call flushes discovery, fetches all flag
189
- # definitions into the local cache, and opens the live-updates WebSocket. No
190
- # explicit install step is required.
189
+ # definitions into the local cache, and opens the live-updates event
190
+ # stream. No explicit install step is required.
191
191
  class FlagsClient
192
192
  # @param api_key [String, nil] API key. When omitted, resolved from
193
193
  # +SMPLKIT_API_KEY+ or +~/.smplkit+.
@@ -207,8 +207,8 @@ module Smplkit
207
207
  # @param debug [Boolean, nil] Enable SDK debug logging.
208
208
  # @param extra_headers [Hash{String => String}, nil] Extra headers
209
209
  # attached to every request.
210
- # @param streaming [Boolean] Live updates over WebSocket (default
211
- # +true+): the first live call opens a shared socket and flag changes
210
+ # @param streaming [Boolean] Live updates over the event stream (default
211
+ # +true+): the first live call opens a shared stream and flag changes
212
212
  # stream in. Set +false+ for the stateless read-through surface: the
213
213
  # first live call still fetches all flag definitions once (blocking),
214
214
  # evaluation stays local, +refresh+ re-fetches on demand, and NO socket
@@ -263,13 +263,13 @@ module Smplkit
263
263
  # Live-surface state.
264
264
  @flag_store = {}
265
265
  @connected = false
266
- @ws_subscribed = false
266
+ @stream_subscribed = false
267
267
  @cache = ResolutionCache.new
268
268
  @handles = {}
269
269
  @global_listeners = []
270
270
  @key_listeners = Hash.new { |h, k| h[k] = [] }
271
- @ws_manager = nil
272
- @owns_ws = false
271
+ @event_stream = nil
272
+ @owns_stream = false
273
273
  @lock = Mutex.new
274
274
  end
275
275
 
@@ -566,16 +566,16 @@ module Smplkit
566
566
 
567
567
  # Release resources — only those this client owns.
568
568
  #
569
- # Tears down the owned WebSocket (standalone install). A wired client
570
- # borrows the parent's transport, WebSocket, and contexts client and
569
+ # Tears down the owned event stream (standalone install). A wired client
570
+ # borrows the parent's transport, event stream, and contexts client and
571
571
  # closes none of them.
572
572
  #
573
573
  # @return [void]
574
574
  def close
575
- if @owns_ws && @ws_manager
576
- @ws_manager.stop
577
- @ws_manager = nil
578
- @owns_ws = false
575
+ if @owns_stream && @event_stream
576
+ @event_stream.stop
577
+ @event_stream = nil
578
+ @owns_stream = false
579
579
  end
580
580
  # Owned flags/app transports (standalone construction) release their
581
581
  # Faraday connections on GC; there is no explicit shutdown to call.
@@ -680,29 +680,31 @@ module Smplkit
680
680
  end
681
681
 
682
682
  # ----------------------------------------------------------------
683
- # Live surface: lazy connect + transport / WebSocket helpers
683
+ # Live surface: lazy connect + transport / event stream helpers
684
684
  # ----------------------------------------------------------------
685
685
 
686
- def ensure_ws
687
- return @parent._ensure_ws unless @parent.nil?
686
+ def ensure_event_stream
687
+ return @parent._ensure_event_stream unless @parent.nil?
688
688
 
689
- if @ws_manager.nil?
690
- @ws_manager = SharedWebSocket.new(
689
+ if @event_stream.nil?
690
+ @event_stream = EventStream.new(
691
691
  app_base_url: @app_base_url, api_key: @standalone_api_key, metrics: @metrics
692
692
  )
693
- @ws_manager.start
694
- @owns_ws = true
693
+ @event_stream.start
694
+ @owns_stream = true
695
695
  end
696
- @ws_manager
696
+ @event_stream
697
697
  end
698
698
 
699
699
  # Open the live connection to the running Smpl Flags service.
700
700
  #
701
701
  # Flushes any buffered discovery declarations, fetches all flag
702
- # definitions into the local cache, opens the shared WebSocket, and
702
+ # definitions into the local cache, opens the shared event stream, and
703
703
  # subscribes to +flag_changed+ / +flag_deleted+ / +flags_changed+ events.
704
- # In stateless mode (+streaming: false+) no socket is ever created;
705
- # +refresh+ re-fetches on demand.
704
+ # Also registers the bulk-refresh path as the stream's reconnect refetch,
705
+ # so a stream outage ends with a full re-sync. In stateless mode
706
+ # (+streaming: false+) no stream is ever created; +refresh+ re-fetches on
707
+ # demand.
706
708
  #
707
709
  # Idempotent and internal — every live method calls it on first use, so
708
710
  # the live surface auto-connects with no explicit step.
@@ -723,13 +725,14 @@ module Smplkit
723
725
  @connected = true
724
726
  return unless @streaming
725
727
 
726
- @ws_manager = ensure_ws
727
- return if @ws_subscribed
728
+ @event_stream = ensure_event_stream
729
+ return if @stream_subscribed
728
730
 
729
- @ws_manager.on("flag_changed") { |data| handle_flag_changed(data) }
730
- @ws_manager.on("flag_deleted") { |data| handle_flag_deleted(data) }
731
- @ws_manager.on("flags_changed") { |data| handle_flags_changed(data) }
732
- @ws_subscribed = true
731
+ @event_stream.on("flag_changed") { |data| handle_flag_changed(data) }
732
+ @event_stream.on("flag_deleted") { |data| handle_flag_deleted(data) }
733
+ @event_stream.on("flags_changed") { |data| handle_flags_changed(data) }
734
+ @event_stream.on_reconnect { handle_flags_changed({}) }
735
+ @stream_subscribed = true
733
736
  end
734
737
 
735
738
  def do_refresh(_source)
@@ -759,7 +762,7 @@ module Smplkit
759
762
  end
760
763
 
761
764
  # ----------------------------------------------------------------
762
- # Internal: event handlers (called by SharedWebSocket)
765
+ # Internal: event handlers (called by EventStream)
763
766
  # ----------------------------------------------------------------
764
767
 
765
768
  def handle_flag_changed(data)
@@ -770,7 +773,7 @@ module Smplkit
770
773
  new_data = fetch_flag_single_data(key)
771
774
  @flag_store[key] = new_data
772
775
  @cache.clear
773
- fire_change_listeners(key, "websocket") if pre != new_data
776
+ fire_change_listeners(key, "push") if pre != new_data
774
777
  end
775
778
 
776
779
  def handle_flag_deleted(data)
@@ -780,7 +783,7 @@ module Smplkit
780
783
  existed = @flag_store.key?(key)
781
784
  @flag_store.delete(key)
782
785
  @cache.clear
783
- fire_change_listeners(key, "websocket", deleted: true) if existed
786
+ fire_change_listeners(key, "push", deleted: true) if existed
784
787
  end
785
788
 
786
789
  def handle_flags_changed(_data)
@@ -788,7 +791,7 @@ module Smplkit
788
791
  begin
789
792
  fetch_all_flags
790
793
  rescue StandardError => e
791
- Smplkit.debug("ws", "flags refresh after flags_changed failed: #{e.message}")
794
+ Smplkit.debug("flags", "flags refresh after flags_changed failed: #{e.message}")
792
795
  return
793
796
  end
794
797
  @cache.clear
@@ -798,7 +801,7 @@ module Smplkit
798
801
  return if changed.empty?
799
802
 
800
803
  # Global listener fires once.
801
- first_event = FlagChangeEvent.new(id: changed.first, source: "websocket")
804
+ first_event = FlagChangeEvent.new(id: changed.first, source: "push")
802
805
  @global_listeners.each do |cb|
803
806
  cb.call(first_event)
804
807
  rescue StandardError => e
@@ -808,7 +811,7 @@ module Smplkit
808
811
  # Per-key listeners fire for each changed key.
809
812
  changed.each do |k|
810
813
  deleted = pre_store.key?(k) && !post_store.key?(k)
811
- event = FlagChangeEvent.new(id: k, source: "websocket", deleted: deleted)
814
+ event = FlagChangeEvent.new(id: k, source: "push", deleted: deleted)
812
815
  @key_listeners[k].each do |cb|
813
816
  cb.call(event)
814
817
  rescue StandardError => e
@@ -268,8 +268,8 @@ module Smplkit
268
268
  # The Smpl Jobs client — accessed via +client.jobs+.
269
269
  #
270
270
  # Unlike Config/Flags/Logging, Jobs has no live "phone-home" agent — no
271
- # environment registration, no WebSocket — so its entire surface lives on
272
- # one client. Defining a job, triggering a run, and reading run history are
271
+ # environment registration, no event stream — so its entire surface lives
272
+ # on one client. Defining a job, triggering a run, and reading run history are
273
273
  # all plain request/response calls here:
274
274
  #
275
275
  # client.jobs.{new_recurring_job,new_manual_job,schedule,get,list,delete,run,usage}