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.
- checksums.yaml +4 -4
- data/lib/smplkit/ws.rb +193 -30
- metadata +43 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 42f9ef066eeb2612d4c6c4cc042933337e10562047eb41400506466a47ec4e99
|
|
4
|
+
data.tar.gz: 69d8819176f26b5199f2c508a71d77397106bfabe0ea5b99b071a3e83199dd0d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
10
|
-
# listeners for specific event types; the shared connection
|
|
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
|
|
14
|
-
#
|
|
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
|
|
26
|
+
# - No subscribe message — the API key determines the account
|
|
21
27
|
# - Heartbeat: server sends +"ping"+ (text), client responds with +"pong"+
|
|
22
28
|
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
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 =
|
|
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
|
-
#
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 =
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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.
|
|
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
|