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.
data/lib/smplkit/ws.rb DELETED
@@ -1,268 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "async"
5
- require "async/http/endpoint"
6
- require "async/http/protocol/http1"
7
- require "async/websocket/client"
8
- require "concurrent"
9
-
10
- module Smplkit
11
- # Manages a single WebSocket connection to the app service event gateway.
12
- #
13
- # A single +SharedWebSocket+ instance is shared across all product modules
14
- # (config, flags, logging) within one +Smplkit::Client+. Product modules
15
- # register listeners for specific event types; the shared connection
16
- # dispatches incoming events to the appropriate listeners.
17
- #
18
- # The connection runs on a dedicated SDK-owned thread that hosts the
19
- # +Async+ reactor and the underlying +async-websocket+ I/O. Public
20
- # methods are thread-safe and non-blocking.
21
- #
22
- # Gateway protocol:
23
- #
24
- # - Connect to +wss://app.<base_domain>/api/ws/v1/events?api_key={key}+
25
- # - Receive +{"type": "connected"}+ on success
26
- # - Receive events: +{"event": "config_changed", ...}+, etc.
27
- # - No subscribe message — the API key determines the account
28
- # - Heartbeat: server sends +"ping"+ (text), client responds with +"pong"+
29
- #
30
- # On disconnect the reactor reconnects with exponential backoff
31
- # (1, 2, 4, 8, 16, 32, 60 seconds, then capped). +stop+ closes the
32
- # connection from the outer thread; the reader exits and the daemon
33
- # thread terminates.
34
- class SharedWebSocket
35
- BACKOFF_SCHEDULE = [1, 2, 4, 8, 16, 32, 60].freeze
36
-
37
- # Sent on the WebSocket upgrade request — the platform WAF rejects
38
- # handshakes that carry no User-Agent. There is no caller-supplied
39
- # header surface on the WebSocket, so the SDK default always applies.
40
- USER_AGENT = Smplkit.user_agent.freeze
41
-
42
- def initialize(app_base_url:, api_key:, metrics: nil)
43
- @app_base_url = app_base_url
44
- @api_key = api_key
45
- @metrics = metrics
46
- @listeners = Hash.new { |h, k| h[k] = [] }
47
- @listeners_lock = Mutex.new
48
- @connection_status = "disconnected"
49
- @closed = false
50
- @ws_thread = nil
51
- @connection = nil
52
- @connection_lock = Mutex.new
53
- end
54
-
55
- # ----- Listener registration ------------------------------------
56
-
57
- def on(event_name, &callback)
58
- @listeners_lock.synchronize { @listeners[event_name] << callback }
59
- end
60
-
61
- def off(event_name, callback)
62
- @listeners_lock.synchronize { @listeners[event_name].delete(callback) }
63
- end
64
-
65
- # Dispatch +data+ to every listener registered for +event_name+.
66
- # Listener exceptions are caught and logged; one bad listener never
67
- # blocks the rest.
68
- def dispatch(event_name, data)
69
- callbacks = @listeners_lock.synchronize { @listeners[event_name].dup }
70
- callbacks.each do |cb|
71
- cb.call(data)
72
- rescue StandardError => e
73
- Smplkit.debug("websocket", "listener for #{event_name} raised: #{e.class}: #{e.message}")
74
- end
75
- end
76
-
77
- # ----- Connection status ----------------------------------------
78
-
79
- attr_reader :connection_status
80
-
81
- # ----- Lifecycle ------------------------------------------------
82
-
83
- def start
84
- return if @ws_thread&.alive?
85
-
86
- Smplkit.debug("websocket", "starting shared WebSocket background thread")
87
- @closed = false
88
- @connection_status = "connecting"
89
- @ws_thread = Thread.new { run_reactor }
90
- @ws_thread.name = "smplkit-shared-ws" if @ws_thread.respond_to?(:name=)
91
- end
92
-
93
- def stop
94
- Smplkit.debug("websocket", "stopping shared WebSocket")
95
- @closed = true
96
- close_active_connection
97
- thread = @ws_thread
98
- @ws_thread = nil
99
- if thread
100
- thread.join(2.0)
101
- thread.kill if thread.alive?
102
- end
103
- # Set authoritatively after the thread is dead so a racing connect
104
- # call (which also sets "connecting") cannot clobber this value.
105
- @connection_status = "disconnected"
106
- end
107
-
108
- # ----- URL builder ----------------------------------------------
109
-
110
- def build_ws_url
111
- url = @app_base_url.dup
112
- ws_url =
113
- if url.start_with?("https://")
114
- "wss://#{url[("https://".length)..]}"
115
- elsif url.start_with?("http://")
116
- "ws://#{url[("http://".length)..]}"
117
- else
118
- "wss://#{url}"
119
- end
120
- ws_url = ws_url.chomp("/")
121
- "#{ws_url}/api/ws/v1/events?api_key=#{@api_key}"
122
- end
123
-
124
- # ----- Inbound message handling (extracted for tests) -----------
125
-
126
- # Process a single inbound text frame the way the live reactor does:
127
- # +"ping"+ → call +send_pong+ with +"pong"+; otherwise parse JSON and,
128
- # if a +"event"+ key is present, dispatch to listeners.
129
- #
130
- # Returns one of +:ping+, +:event+, +:no_event+, +:unparseable+ for the
131
- # caller to log/observe; the live reactor ignores the return value.
132
- def handle_inbound(text, send_pong:)
133
- if text == "ping"
134
- send_pong.call("pong")
135
- return :ping
136
- end
137
-
138
- data =
139
- begin
140
- JSON.parse(text)
141
- rescue JSON::ParserError
142
- return :unparseable
143
- end
144
-
145
- event = data["event"]
146
- if event
147
- dispatch(event, data)
148
- :event
149
- else
150
- :no_event
151
- end
152
- end
153
-
154
- private
155
-
156
- def run_reactor
157
- Sync do |task|
158
- ws_main(task)
159
- end
160
- rescue StandardError => e
161
- Smplkit.debug("websocket", "shared WebSocket thread exited unexpectedly: #{e.class}: #{e.message}")
162
- end
163
-
164
- def ws_main(task)
165
- connect(task)
166
- rescue StandardError => e
167
- return if @closed
168
-
169
- Smplkit.debug(
170
- "websocket",
171
- "connection failed on startup (url: #{safe_url}): #{e.class}: #{e.message}"
172
- )
173
- reconnect(task)
174
- end
175
-
176
- def connect(task)
177
- return if @closed
178
-
179
- url = build_ws_url
180
- @connection_status = "connecting"
181
- Smplkit.debug("websocket", "connecting to #{safe_url}")
182
-
183
- # Force HTTP/1.1 for the WebSocket upgrade. async-http defaults to
184
- # HTTP/2 on TLS endpoints, but the smplkit event gateway speaks the
185
- # classic RFC 6455 upgrade over HTTP/1.1, not the HTTP/2-tunneled
186
- # variant from RFC 8441 — without this override the upgrade returns
187
- # a Protocol::HTTP2::StreamError before any frame is exchanged.
188
- endpoint = Async::HTTP::Endpoint.parse(url, protocol: Async::HTTP::Protocol::HTTP1)
189
- headers = { "user-agent" => USER_AGENT }
190
- connection = Async::WebSocket::Client.connect(endpoint, headers: headers)
191
- @connection_lock.synchronize { @connection = connection }
192
- Smplkit.debug("websocket", "WebSocket connected, waiting for confirmation")
193
-
194
- raw = connection.read
195
- data = JSON.parse(message_to_string(raw))
196
- if data["type"] == "error"
197
- err = data["message"]
198
- Smplkit.debug("websocket", "connection error from server: #{err.inspect}")
199
- raise "Connection error: #{err}"
200
- end
201
-
202
- @connection_status = "connected"
203
- @metrics&.record_gauge("platform.websocket_connections", 1, unit: "connections")
204
- receive_loop(task, connection)
205
- end
206
-
207
- def receive_loop(task, connection)
208
- until @closed
209
- message = connection.read
210
- break if message.nil?
211
-
212
- text = message_to_string(message)
213
- handle_inbound(text, send_pong: ->(reply) { connection.write(reply) })
214
- end
215
- rescue StandardError => e
216
- return if @closed
217
-
218
- Smplkit.debug("websocket", "receive loop error: #{e.class}: #{e.message}")
219
- @connection_status = "reconnecting"
220
- @metrics&.record_gauge("platform.websocket_connections", 0, unit: "connections")
221
- reconnect(task)
222
- end
223
-
224
- def reconnect(task)
225
- attempt = 0
226
- until @closed
227
- delay = BACKOFF_SCHEDULE[[attempt, BACKOFF_SCHEDULE.length - 1].min]
228
- Smplkit.debug("websocket", "reconnecting in #{delay}s (attempt #{attempt + 1})")
229
- task.sleep(delay)
230
- return if @closed
231
-
232
- begin
233
- connect(task)
234
- return
235
- rescue StandardError => e
236
- Smplkit.debug("websocket", "reconnect attempt #{attempt + 1} failed: #{e.class}: #{e.message}")
237
- attempt += 1
238
- end
239
- end
240
- end
241
-
242
- def message_to_string(message)
243
- return message if message.is_a?(String)
244
- return message.to_str if message.respond_to?(:to_str)
245
-
246
- message.to_s
247
- end
248
-
249
- def close_active_connection
250
- conn = @connection_lock.synchronize do
251
- c = @connection
252
- @connection = nil
253
- c
254
- end
255
- return unless conn
256
-
257
- begin
258
- conn.close
259
- rescue StandardError => e
260
- Smplkit.debug("websocket", "close raised: #{e.class}: #{e.message}")
261
- end
262
- end
263
-
264
- def safe_url
265
- build_ws_url.split("?", 2).first
266
- end
267
- end
268
- end