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.
- checksums.yaml +4 -4
- data/lib/smplkit/client.rb +27 -27
- data/lib/smplkit/config/client.rb +44 -41
- data/lib/smplkit/event_stream.rb +483 -0
- data/lib/smplkit/flags/client.rb +45 -42
- data/lib/smplkit/jobs/client.rb +2 -2
- data/lib/smplkit/jobs/models.rb +2 -2
- data/lib/smplkit/logging/client.rb +69 -59
- data/lib/smplkit/transport.rb +1 -1
- data/lib/smplkit/version.rb +2 -2
- data/lib/smplkit.rb +1 -1
- metadata +2 -22
- data/lib/smplkit/ws.rb +0 -268
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
|