smplkit 3.0.134 → 3.0.136

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,483 @@
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+ flips +@closed+ and waits; the reader
39
+ # re-checks the flag at least every +POLL_INTERVAL+, tears its own
40
+ # connection down in-reactor, and the daemon thread terminates.
41
+ class EventStream
42
+ # Seconds without any bytes from the server (events or keepalive
43
+ # comments) before the connection is considered dead — two missed
44
+ # 30-second server keepalives.
45
+ READ_TIMEOUT = 45
46
+
47
+ # How long a single blocking read may park the reactor before it wakes
48
+ # to re-check +@closed+. Liveness is tracked as a deadline across
49
+ # polls (see +read_loop+), so this changes nothing on the wire — it
50
+ # exists so +stop+ never has to interrupt the reactor from a foreign
51
+ # thread: a cross-thread close cannot wake a fiber blocked in
52
+ # +body.read+, which left teardown hanging until the next keepalive.
53
+ POLL_INTERVAL = 1.0
54
+
55
+ # Ceiling for the exponential reconnect backoff, in seconds.
56
+ MAX_BACKOFF = 60
57
+
58
+ # Initial reconnect backoff base, in seconds, used until the server
59
+ # supplies its own via the SSE +retry:+ field.
60
+ DEFAULT_RETRY = 1.0
61
+
62
+ # Sent on the stream request — the platform WAF rejects requests that
63
+ # carry no User-Agent. There is no caller-supplied header surface on the
64
+ # event stream, so the SDK default always applies.
65
+ USER_AGENT = Smplkit.user_agent.freeze
66
+
67
+ # Incremental parser for a +text/event-stream+ byte stream.
68
+ #
69
+ # Feed it raw chunks as they arrive; it returns the events completed by
70
+ # each chunk. Implements the SSE wire format: +\n+, +\r\n+, and +\r+
71
+ # line terminators (including a CRLF split across chunks), a leading
72
+ # UTF-8 BOM, comment lines (leading +:+), field values split across
73
+ # chunk boundaries, multiple +data:+ lines joined with +\n+, and the
74
+ # numeric +retry:+ field. Unknown fields are ignored.
75
+ class Parser
76
+ # One complete SSE event: +name+ is the event name (from the +event:+
77
+ # field, defaulting to +"message"+), +data+ the joined data payload.
78
+ Event = Struct.new(:name, :data, keyword_init: true)
79
+
80
+ UTF8_BOM = String.new("\xEF\xBB\xBF", encoding: Encoding::BINARY).freeze
81
+
82
+ # Milliseconds from the most recent valid +retry:+ field, or +nil+.
83
+ attr_reader :retry_ms
84
+
85
+ def initialize
86
+ @buffer = String.new(encoding: Encoding::BINARY)
87
+ @bom_pending = true
88
+ @event_type = ""
89
+ @data_lines = []
90
+ @retry_ms = nil
91
+ end
92
+
93
+ # Consume one chunk of the stream. Returns the (possibly empty) array
94
+ # of +Event+s completed by this chunk.
95
+ def feed(chunk)
96
+ @buffer << chunk.dup.force_encoding(Encoding::BINARY)
97
+ strip_bom if @bom_pending
98
+ events = []
99
+ while (line = next_line)
100
+ handle_line(line, events)
101
+ end
102
+ events
103
+ end
104
+
105
+ private
106
+
107
+ # Drop a UTF-8 BOM at the very start of the stream. The BOM is three
108
+ # bytes and may itself arrive split across chunks, so stay pending
109
+ # while the buffer is still a strict prefix of it.
110
+ def strip_bom
111
+ if @buffer.bytesize >= UTF8_BOM.bytesize
112
+ @buffer = @buffer.byteslice(UTF8_BOM.bytesize..) if @buffer.start_with?(UTF8_BOM)
113
+ @bom_pending = false
114
+ elsif !UTF8_BOM.start_with?(@buffer)
115
+ @bom_pending = false
116
+ end
117
+ end
118
+
119
+ # Extract the next complete line (terminator: +\n+, +\r\n+, or bare
120
+ # +\r+), or +nil+ if the buffer holds none. A CR as the final buffered
121
+ # byte is held back — it may be the first half of a CRLF whose LF is
122
+ # still in flight. Line splitting happens on the raw bytes (0x0A/0x0D
123
+ # never appear inside a UTF-8 multi-byte sequence); the completed line
124
+ # is re-tagged UTF-8.
125
+ def next_line
126
+ cr = @buffer.index("\r")
127
+ lf = @buffer.index("\n")
128
+ return nil if cr.nil? && lf.nil?
129
+
130
+ if cr && (lf.nil? || cr < lf)
131
+ return nil if cr == @buffer.bytesize - 1
132
+
133
+ line = @buffer.byteslice(0, cr)
134
+ skip = @buffer.getbyte(cr + 1) == 0x0A ? 2 : 1
135
+ @buffer = @buffer.byteslice(cr + skip, @buffer.bytesize - cr - skip)
136
+ else
137
+ line = @buffer.byteslice(0, lf)
138
+ @buffer = @buffer.byteslice(lf + 1, @buffer.bytesize - lf - 1)
139
+ end
140
+ line.force_encoding(Encoding::UTF_8)
141
+ end
142
+
143
+ # A blank line dispatches the accumulated event; a leading +:+ marks a
144
+ # comment (the server's keepalive frame) — ignored here, since mere
145
+ # receipt of its bytes already counted as liveness at the read layer.
146
+ def handle_line(line, events)
147
+ if line.empty?
148
+ flush_pending(events)
149
+ elsif !line.start_with?(":")
150
+ apply_field(*split_field(line))
151
+ end
152
+ end
153
+
154
+ # Split +"field: value"+ on the first colon, stripping at most one
155
+ # leading space from the value. A line with no colon is a field with
156
+ # an empty value.
157
+ def split_field(line)
158
+ sep = line.index(":")
159
+ return [line, ""] if sep.nil?
160
+
161
+ value = line[(sep + 1)..]
162
+ value = value[1..] if value.start_with?(" ")
163
+ [line[0...sep], value]
164
+ end
165
+
166
+ # +id:+ is deliberately ignored along with unknown fields — the SDK
167
+ # never resumes with +Last-Event-ID+.
168
+ def apply_field(field, value)
169
+ case field
170
+ when "event"
171
+ @event_type = value
172
+ when "data"
173
+ @data_lines << value
174
+ when "retry"
175
+ @retry_ms = Integer(value, 10) if value.match?(/\A\d+\z/)
176
+ end
177
+ end
178
+
179
+ # Blank line: emit the accumulated event. Per the SSE spec an event
180
+ # with an empty data buffer is discarded (the event type still
181
+ # resets); multiple +data:+ lines join with +\n+.
182
+ def flush_pending(events)
183
+ unless @data_lines.empty?
184
+ name = @event_type.empty? ? "message" : @event_type
185
+ events << Event.new(name: name, data: @data_lines.join("\n"))
186
+ end
187
+ @event_type = ""
188
+ @data_lines = []
189
+ end
190
+ end
191
+
192
+ def initialize(app_base_url:, api_key:, metrics: nil)
193
+ @app_base_url = app_base_url
194
+ @api_key = api_key
195
+ @metrics = metrics
196
+ @listeners = Hash.new { |h, k| h[k] = [] }
197
+ @refetch_callbacks = []
198
+ @listeners_lock = Mutex.new
199
+ @connection_status = "disconnected"
200
+ @closed = false
201
+ @stream_thread = nil
202
+ @client = nil
203
+ @response = nil
204
+ @connection_lock = Mutex.new
205
+ @retry_base = DEFAULT_RETRY
206
+ @attempt = 0
207
+ @ever_connected = false
208
+ end
209
+
210
+ # ----- Listener registration ------------------------------------
211
+
212
+ def on(event_name, &callback)
213
+ @listeners_lock.synchronize { @listeners[event_name] << callback }
214
+ end
215
+
216
+ def off(event_name, callback)
217
+ @listeners_lock.synchronize { @listeners[event_name].delete(callback) }
218
+ end
219
+
220
+ # Register a refetch callback, invoked (with no arguments) after every
221
+ # successful *re*connect — never on the initial connect. Product modules
222
+ # use this to run their bulk-refresh path so caches recover events
223
+ # missed while the stream was down.
224
+ def on_reconnect(callback = nil, &block)
225
+ cb = callback || block
226
+ @listeners_lock.synchronize { @refetch_callbacks << cb }
227
+ cb
228
+ end
229
+
230
+ def off_reconnect(callback)
231
+ @listeners_lock.synchronize { @refetch_callbacks.delete(callback) }
232
+ end
233
+
234
+ # Dispatch +payload+ to every listener registered for +event_name+.
235
+ # Event names nothing subscribed to dispatch to zero listeners — unknown
236
+ # events are ignored by construction. Listener exceptions are caught and
237
+ # logged; one bad listener never blocks the rest.
238
+ def dispatch(event_name, payload)
239
+ callbacks = @listeners_lock.synchronize { @listeners[event_name].dup }
240
+ callbacks.each do |cb|
241
+ cb.call(payload)
242
+ rescue StandardError => e
243
+ Smplkit.debug("events", "listener for #{event_name} raised: #{e.class}: #{e.message}")
244
+ end
245
+ end
246
+
247
+ # ----- Connection status ----------------------------------------
248
+
249
+ attr_reader :connection_status
250
+
251
+ # ----- Lifecycle ------------------------------------------------
252
+
253
+ def start
254
+ return if @stream_thread&.alive?
255
+
256
+ Smplkit.debug("events", "starting shared event stream background thread")
257
+ @closed = false
258
+ @connection_status = "connecting"
259
+ @stream_thread = Thread.new { run_reactor }
260
+ @stream_thread.name = "smplkit-events" if @stream_thread.respond_to?(:name=)
261
+ end
262
+
263
+ def stop
264
+ Smplkit.debug("events", "stopping shared event stream")
265
+ @closed = true
266
+ thread = @stream_thread
267
+ @stream_thread = nil
268
+ if thread
269
+ # The reactor re-checks +@closed+ at least every POLL_INTERVAL and
270
+ # closes its own connection in-reactor on the way out — closing it
271
+ # from this thread instead would race the reactor and cannot wake
272
+ # a blocked read.
273
+ thread.join(POLL_INTERVAL + 1.0)
274
+ if thread.alive?
275
+ # Last resort: a connect attempt wedged before the read loop.
276
+ thread.kill
277
+ close_active_connection
278
+ end
279
+ end
280
+ # Set authoritatively after the thread is dead so a racing connect
281
+ # call (which also sets "connecting") cannot clobber this value.
282
+ @connection_status = "disconnected"
283
+ end
284
+
285
+ # ----- URL builder ----------------------------------------------
286
+
287
+ def build_events_url
288
+ url = @app_base_url.dup
289
+ url = "https://#{url}" unless url.start_with?("https://", "http://")
290
+ "#{url.chomp("/")}/api/v1/events"
291
+ end
292
+
293
+ # ----- Inbound event handling (extracted for tests) -------------
294
+
295
+ # Process one parsed SSE event the way the live read loop does: parse
296
+ # the JSON payload and dispatch it to the listeners registered for the
297
+ # event name.
298
+ #
299
+ # Returns +:dispatched+ or +:unparseable+ for the caller to log/observe;
300
+ # the live read loop ignores the return value.
301
+ def handle_event(event_name, data)
302
+ payload =
303
+ begin
304
+ JSON.parse(data)
305
+ rescue JSON::ParserError
306
+ nil
307
+ end
308
+ unless payload.is_a?(Hash)
309
+ Smplkit.debug("events", "ignoring #{event_name.inspect} event with non-object payload")
310
+ return :unparseable
311
+ end
312
+
313
+ dispatch(event_name, payload)
314
+ :dispatched
315
+ end
316
+
317
+ private
318
+
319
+ def run_reactor
320
+ Sync do |task|
321
+ stream_main(task)
322
+ end
323
+ rescue StandardError => e
324
+ Smplkit.debug("events", "event stream thread exited unexpectedly: #{e.class}: #{e.message}")
325
+ end
326
+
327
+ # Connect/read/reconnect forever until +stop+. Every pass either ends
328
+ # with a clean server EOF or an exception (connect failure, read error,
329
+ # liveness timeout) — both funnel into the same backoff + retry.
330
+ def stream_main(task)
331
+ until @closed
332
+ begin
333
+ connect_and_stream(task)
334
+ rescue StandardError => e
335
+ return if @closed
336
+
337
+ Smplkit.debug("events", "stream error (url: #{build_events_url}): #{e.class}: #{e.message}")
338
+ end
339
+ return if @closed
340
+
341
+ @connection_status = "reconnecting"
342
+ delay = next_backoff_delay
343
+ Smplkit.debug("events", "reconnecting in #{delay}s")
344
+ task.sleep(delay)
345
+ end
346
+ end
347
+
348
+ # One connection lifetime: open the request, verify the SSE handshake,
349
+ # then read frames until EOF/error. The response and client are always
350
+ # torn down on the way out.
351
+ def connect_and_stream(task)
352
+ @connection_status = "connecting"
353
+ Smplkit.debug("events", "connecting to #{build_events_url}")
354
+ endpoint = Async::HTTP::Endpoint.parse(build_events_url)
355
+ client = Async::HTTP::Client.new(endpoint)
356
+ @connection_lock.synchronize { @client = client }
357
+ response = nil
358
+ begin
359
+ response = client.get(endpoint.path, request_headers)
360
+ @connection_lock.synchronize { @response = response }
361
+ verify_response!(response)
362
+ mark_connected
363
+ read_loop(task, response.body)
364
+ ensure
365
+ mark_disconnected
366
+ close_quietly(response)
367
+ close_quietly(client)
368
+ @connection_lock.synchronize do
369
+ @response = nil
370
+ @client = nil
371
+ end
372
+ end
373
+ end
374
+
375
+ def request_headers
376
+ [
377
+ ["accept", "text/event-stream"],
378
+ ["authorization", "Bearer #{@api_key}"],
379
+ ["user-agent", USER_AGENT]
380
+ ]
381
+ end
382
+
383
+ # A successful connect is exactly: HTTP 200 with a text/event-stream
384
+ # content type. Anything else (401 on bad auth, proxies serving HTML,
385
+ # ...) tears down and backs off.
386
+ def verify_response!(response)
387
+ status = response.status
388
+ content_type = response.headers["content-type"].to_s
389
+ return if status == 200 && content_type.start_with?("text/event-stream")
390
+
391
+ raise ConnectionError, "event stream connect failed: HTTP #{status} " \
392
+ "(content-type: #{content_type.inspect})"
393
+ end
394
+
395
+ def mark_connected
396
+ reconnected = @ever_connected
397
+ @ever_connected = true
398
+ @attempt = 0
399
+ @connection_status = "connected"
400
+ @metrics&.record_gauge("platform.event_connections", 1, unit: "connections")
401
+ Smplkit.debug("events", reconnected ? "event stream reconnected" : "event stream connected")
402
+ run_refetch_callbacks if reconnected
403
+ end
404
+
405
+ # Leaving the connected state (only): flip the gauge and status. A
406
+ # connect attempt that never completed the handshake records nothing.
407
+ def mark_disconnected
408
+ return unless @connection_status == "connected"
409
+
410
+ @connection_status = "reconnecting"
411
+ @metrics&.record_gauge("platform.event_connections", 0, unit: "connections")
412
+ end
413
+
414
+ def run_refetch_callbacks
415
+ callbacks = @listeners_lock.synchronize { @refetch_callbacks.dup }
416
+ callbacks.each do |cb|
417
+ cb.call
418
+ rescue StandardError => e
419
+ Smplkit.debug("events", "refetch callback raised: #{e.class}: #{e.message}")
420
+ end
421
+ end
422
+
423
+ # Read chunks until EOF, feeding the SSE parser and dispatching the
424
+ # events it completes. Reads park for at most POLL_INTERVAL at a time
425
+ # so +stop+ is honored promptly; liveness is a rolling deadline — any
426
+ # received bytes, including keepalive comment frames, push it out by
427
+ # READ_TIMEOUT. A deadline breach raises into the reconnect path.
428
+ def read_loop(task, body)
429
+ parser = Parser.new
430
+ deadline = monotonic_now + READ_TIMEOUT
431
+ until @closed
432
+ begin
433
+ chunk = task.with_timeout(POLL_INTERVAL) { body.read }
434
+ rescue Async::TimeoutError
435
+ raise Async::TimeoutError, "no data for #{READ_TIMEOUT}s" if monotonic_now >= deadline
436
+
437
+ next
438
+ end
439
+ break if chunk.nil?
440
+
441
+ deadline = monotonic_now + READ_TIMEOUT
442
+ process_chunk(parser, chunk)
443
+ end
444
+ end
445
+
446
+ # Seam for specs; the liveness deadline math needs a controllable clock.
447
+ def monotonic_now
448
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
449
+ end
450
+
451
+ def process_chunk(parser, chunk)
452
+ parser.feed(chunk).each { |event| handle_event(event.name, event.data) }
453
+ @retry_base = parser.retry_ms / 1000.0 unless parser.retry_ms.nil?
454
+ end
455
+
456
+ # Exponential backoff: base, 2x, 4x, ... capped at MAX_BACKOFF. The
457
+ # base comes from the server's +retry:+ field (DEFAULT_RETRY until one
458
+ # arrives); +mark_connected+ resets the exponent on every successful
459
+ # connect. No jitter.
460
+ def next_backoff_delay
461
+ delay = [@retry_base * (2**@attempt), MAX_BACKOFF].min
462
+ @attempt += 1 if delay < MAX_BACKOFF
463
+ delay
464
+ end
465
+
466
+ def close_active_connection
467
+ response, client = @connection_lock.synchronize do
468
+ pair = [@response, @client]
469
+ @response = nil
470
+ @client = nil
471
+ pair
472
+ end
473
+ close_quietly(response)
474
+ close_quietly(client)
475
+ end
476
+
477
+ def close_quietly(resource)
478
+ resource&.close
479
+ rescue StandardError => e
480
+ Smplkit.debug("events", "close raised: #{e.class}: #{e.message}")
481
+ end
482
+ end
483
+ end