smplkit 1.0.9 → 1.0.10

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.
Files changed (3) hide show
  1. checksums.yaml +4 -4
  2. data/lib/smplkit/ws.rb +193 -30
  3. metadata +43 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7c58128ff207d255ca61de78cd05db65a37dce681d7940441db0a10acddd5e7a
4
- data.tar.gz: 73500c410292e3a5a6daefa1a75ffaa0b9a61f951e50c9f8f52a1b5a893dd38a
3
+ metadata.gz: 42f9ef066eeb2612d4c6c4cc042933337e10562047eb41400506466a47ec4e99
4
+ data.tar.gz: 69d8819176f26b5199f2c508a71d77397106bfabe0ea5b99b071a3e83199dd0d
5
5
  SHA512:
6
- metadata.gz: 0f55ecc713c6c7171b52469098c6f9b3268d1bf43fe66ec8da85eb1d1a5293cb1f0e801c975cd1db4a9d15c3e509319b382b5f98220beb26d2761e7b78781cc1
7
- data.tar.gz: 7c5a33b527b9d042919b62c570ba14578607131386f1bcba9a6ede04f99f0d97ae9288287f99f9bad0bb826f3f12970196e82908fbf56d5e70c43ea0768d75dc
6
+ metadata.gz: 8f05531a8b81d3aa42895ed48f8e7b1680d9cf9a490c356b5ce4cf311b304d862964cb49e18b72e7e982ce1e351b2f7be2234c0be06be6c58cae3d3566c37c53
7
+ data.tar.gz: f45d376af397dad147c4ae24100bdfd990766e511ce4a2de10539e3d358375098c413bf46f09c7f8e149d0333aa3c5efdca88baaebdd275ebda03490fff5370c
data/lib/smplkit/ws.rb CHANGED
@@ -1,42 +1,55 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require "async"
5
+ require "async/http/endpoint"
6
+ require "async/websocket/client"
3
7
  require "concurrent"
4
8
 
5
9
  module Smplkit
6
10
  # Manages a single WebSocket connection to the app service event gateway.
7
11
  #
8
12
  # A single +SharedWebSocket+ instance is shared across all product modules
9
- # (config, flags) within one +Smplkit::Client+. Product modules register
10
- # listeners for specific event types; the shared connection dispatches
11
- # incoming events to the appropriate listeners.
13
+ # (config, flags, logging) within one +Smplkit::Client+. Product modules
14
+ # register listeners for specific event types; the shared connection
15
+ # dispatches incoming events to the appropriate listeners.
12
16
  #
13
- # The connection runs on a dedicated SDK-owned thread; public methods are
14
- # thread-safe and non-blocking.
17
+ # The connection runs on a dedicated SDK-owned thread that hosts the
18
+ # +Async+ reactor and the underlying +async-websocket+ I/O. Public
19
+ # methods are thread-safe and non-blocking.
20
+ #
21
+ # Gateway protocol (mirrors the Python reference in +smplkit._ws+):
15
22
  #
16
- # The app service gateway protocol:
17
23
  # - Connect to +wss://app.<base_domain>/api/ws/v1/events?api_key={key}+
18
24
  # - Receive +{"type": "connected"}+ on success
19
25
  # - Receive events: +{"event": "config_changed", ...}+, etc.
20
- # - No subscribe message - the API key determines the account
26
+ # - No subscribe message the API key determines the account
21
27
  # - Heartbeat: server sends +"ping"+ (text), client responds with +"pong"+
22
28
  #
23
- # NOTE: The actual WebSocket I/O is wired to async-websocket on a worker
24
- # thread. The initial Ruby SDK release defers full live-update wiring to a
25
- # follow-up because async-websocket interactions need integration testing
26
- # against the real platform.
29
+ # On disconnect the reactor reconnects with exponential backoff
30
+ # (1, 2, 4, 8, 16, 32, 60 seconds, then capped). +stop+ closes the
31
+ # connection from the outer thread; the reader exits and the daemon
32
+ # thread terminates.
27
33
  class SharedWebSocket
28
34
  BACKOFF_SCHEDULE = [1, 2, 4, 8, 16, 32, 60].freeze
29
35
 
36
+ USER_AGENT = "smplkit-ruby-sdk/#{Smplkit::VERSION}".freeze
37
+
30
38
  def initialize(app_base_url:, api_key:, metrics: nil)
31
39
  @app_base_url = app_base_url
32
40
  @api_key = api_key
33
41
  @metrics = metrics
34
- @listeners = Concurrent::Hash.new { |h, k| h[k] = [] }
42
+ @listeners = Hash.new { |h, k| h[k] = [] }
35
43
  @listeners_lock = Mutex.new
36
44
  @connection_status = "disconnected"
37
45
  @closed = false
46
+ @ws_thread = nil
47
+ @connection = nil
48
+ @connection_lock = Mutex.new
38
49
  end
39
50
 
51
+ # ----- Listener registration ------------------------------------
52
+
40
53
  def on(event_name, &callback)
41
54
  @listeners_lock.synchronize { @listeners[event_name] << callback }
42
55
  end
@@ -45,6 +58,9 @@ module Smplkit
45
58
  @listeners_lock.synchronize { @listeners[event_name].delete(callback) }
46
59
  end
47
60
 
61
+ # Dispatch +data+ to every listener registered for +event_name+.
62
+ # Listener exceptions are caught and logged; one bad listener never
63
+ # blocks the rest.
48
64
  def dispatch(event_name, data)
49
65
  callbacks = @listeners_lock.synchronize { @listeners[event_name].dup }
50
66
  callbacks.each do |cb|
@@ -54,39 +70,186 @@ module Smplkit
54
70
  end
55
71
  end
56
72
 
73
+ # ----- Connection status ----------------------------------------
74
+
57
75
  attr_reader :connection_status
58
76
 
59
- # Marked as connected for in-process testing without a real WS connection.
60
- # Production wiring overrides this from the I/O thread once the gateway
61
- # confirms the handshake.
62
- def mark_connected!
63
- @connection_status = "connected"
64
- end
77
+ # ----- Lifecycle ------------------------------------------------
65
78
 
66
79
  def start
67
- Smplkit.debug("websocket", "starting shared WebSocket (Ruby SDK initial release: in-memory only)")
68
- # Live wiring is deferred. Behave as if the handshake succeeded so the
69
- # rest of the runtime can proceed - listeners still fire for any events
70
- # other code dispatches into this instance.
71
- mark_connected!
80
+ return if @ws_thread&.alive?
81
+
82
+ Smplkit.debug("websocket", "starting shared WebSocket background thread")
83
+ @closed = false
84
+ @connection_status = "connecting"
85
+ @ws_thread = Thread.new { run_reactor }
86
+ @ws_thread.name = "smplkit-shared-ws" if @ws_thread.respond_to?(:name=)
72
87
  end
73
88
 
74
89
  def stop
90
+ Smplkit.debug("websocket", "stopping shared WebSocket")
75
91
  @closed = true
76
92
  @connection_status = "disconnected"
93
+ close_active_connection
94
+ thread = @ws_thread
95
+ @ws_thread = nil
96
+ return unless thread
97
+
98
+ thread.join(2.0)
99
+ thread.kill if thread.alive?
77
100
  end
78
101
 
102
+ # ----- URL builder ----------------------------------------------
103
+
79
104
  def build_ws_url
80
105
  url = @app_base_url.dup
81
- ws_url = if url.start_with?("https://")
82
- "wss://#{url[("https://".length)..]}"
83
- elsif url.start_with?("http://")
84
- "ws://#{url[("http://".length)..]}"
85
- else
86
- "wss://#{url}"
87
- end
106
+ ws_url =
107
+ if url.start_with?("https://")
108
+ "wss://#{url[("https://".length)..]}"
109
+ elsif url.start_with?("http://")
110
+ "ws://#{url[("http://".length)..]}"
111
+ else
112
+ "wss://#{url}"
113
+ end
88
114
  ws_url = ws_url.chomp("/")
89
115
  "#{ws_url}/api/ws/v1/events?api_key=#{@api_key}"
90
116
  end
117
+
118
+ # ----- Inbound message handling (extracted for tests) -----------
119
+
120
+ # Process a single inbound text frame the way the live reactor does:
121
+ # +"ping"+ → call +send_pong+ with +"pong"+; otherwise parse JSON and,
122
+ # if a +"event"+ key is present, dispatch to listeners.
123
+ #
124
+ # Returns one of +:ping+, +:event+, +:no_event+, +:unparseable+ for the
125
+ # caller to log/observe; the live reactor ignores the return value.
126
+ def handle_inbound(text, send_pong:)
127
+ if text == "ping"
128
+ send_pong.call("pong")
129
+ return :ping
130
+ end
131
+
132
+ data =
133
+ begin
134
+ JSON.parse(text)
135
+ rescue JSON::ParserError
136
+ return :unparseable
137
+ end
138
+
139
+ event = data["event"]
140
+ if event
141
+ dispatch(event, data)
142
+ :event
143
+ else
144
+ :no_event
145
+ end
146
+ end
147
+
148
+ private
149
+
150
+ def run_reactor
151
+ Sync do |task|
152
+ ws_main(task)
153
+ end
154
+ rescue StandardError => e
155
+ Smplkit.debug("websocket", "shared WebSocket thread exited unexpectedly: #{e.class}: #{e.message}")
156
+ end
157
+
158
+ def ws_main(task)
159
+ connect(task)
160
+ rescue StandardError => e
161
+ return if @closed
162
+
163
+ Smplkit.debug(
164
+ "websocket",
165
+ "connection failed on startup (url: #{safe_url}): #{e.class}: #{e.message}"
166
+ )
167
+ reconnect(task)
168
+ end
169
+
170
+ def connect(task)
171
+ url = build_ws_url
172
+ @connection_status = "connecting"
173
+ Smplkit.debug("websocket", "connecting to #{safe_url}")
174
+
175
+ endpoint = Async::HTTP::Endpoint.parse(url)
176
+ headers = { "user-agent" => USER_AGENT }
177
+ connection = Async::WebSocket::Client.connect(endpoint, headers: headers)
178
+ @connection_lock.synchronize { @connection = connection }
179
+ Smplkit.debug("websocket", "WebSocket connected, waiting for confirmation")
180
+
181
+ raw = connection.read
182
+ data = JSON.parse(message_to_string(raw))
183
+ if data["type"] == "error"
184
+ err = data["message"]
185
+ Smplkit.debug("websocket", "connection error from server: #{err.inspect}")
186
+ raise "Connection error: #{err}"
187
+ end
188
+
189
+ @connection_status = "connected"
190
+ @metrics&.record_gauge("platform.websocket_connections", 1, unit: "connections")
191
+ receive_loop(task, connection)
192
+ end
193
+
194
+ def receive_loop(task, connection)
195
+ until @closed
196
+ message = connection.read
197
+ break if message.nil?
198
+
199
+ text = message_to_string(message)
200
+ handle_inbound(text, send_pong: ->(reply) { connection.write(reply) })
201
+ end
202
+ rescue StandardError => e
203
+ return if @closed
204
+
205
+ Smplkit.debug("websocket", "receive loop error: #{e.class}: #{e.message}")
206
+ @connection_status = "reconnecting"
207
+ @metrics&.record_gauge("platform.websocket_connections", 0, unit: "connections")
208
+ reconnect(task)
209
+ end
210
+
211
+ def reconnect(task)
212
+ attempt = 0
213
+ until @closed
214
+ delay = BACKOFF_SCHEDULE[[attempt, BACKOFF_SCHEDULE.length - 1].min]
215
+ Smplkit.debug("websocket", "reconnecting in #{delay}s (attempt #{attempt + 1})")
216
+ task.sleep(delay)
217
+ return if @closed
218
+
219
+ begin
220
+ connect(task)
221
+ return
222
+ rescue StandardError => e
223
+ Smplkit.debug("websocket", "reconnect attempt #{attempt + 1} failed: #{e.class}: #{e.message}")
224
+ attempt += 1
225
+ end
226
+ end
227
+ end
228
+
229
+ def message_to_string(message)
230
+ return message if message.is_a?(String)
231
+ return message.to_str if message.respond_to?(:to_str)
232
+
233
+ message.to_s
234
+ end
235
+
236
+ def close_active_connection
237
+ conn = @connection_lock.synchronize do
238
+ c = @connection
239
+ @connection = nil
240
+ c
241
+ end
242
+ return unless conn
243
+
244
+ begin
245
+ conn.close
246
+ rescue StandardError => e
247
+ Smplkit.debug("websocket", "close raised: #{e.class}: #{e.message}")
248
+ end
249
+ end
250
+
251
+ def safe_url
252
+ build_ws_url.split("?", 2).first
253
+ end
91
254
  end
92
255
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smplkit
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.9
4
+ version: 1.0.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Smpl Solutions LLC
@@ -9,6 +9,48 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: async
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.39'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.39'
26
+ - !ruby/object:Gem::Dependency
27
+ name: async-http
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.95'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.95'
40
+ - !ruby/object:Gem::Dependency
41
+ name: async-websocket
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.30'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.30'
12
54
  - !ruby/object:Gem::Dependency
13
55
  name: concurrent-ruby
14
56
  requirement: !ruby/object:Gem::Requirement