datagrout-conduit 0.3.0 → 0.4.0
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/README.md +45 -3
- data/lib/datagrout_conduit/client.rb +23 -1
- data/lib/datagrout_conduit/transport/ws.rb +478 -0
- data/lib/datagrout_conduit/version.rb +1 -1
- data/lib/datagrout_conduit.rb +1 -0
- metadata +17 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 27d8af58b9252394051bb3c764f30bc19b5062664a3bac0295e5bd7aa4e8df94
|
|
4
|
+
data.tar.gz: 4f6c526fe03897d6666d2b0c76c61651dc9bc71e1eaa269f55f35e321a34671f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bba7f383d85b62c05fff950f527047c830b82842536a31151988d87414c5da597a1b67160f507753a8c43c5cd41f0e9cf463a1bdd93ab0bb898165da7a1afc18
|
|
7
|
+
data.tar.gz: a8c3fec760966850b04ec6c5e9b0c4af46f967b87a9627130bb6bb18cf84157bd1fa0da90486cc64ff01e97c03d815e153e7e1bccd77579f6b93a5775b75df6f
|
data/README.md
CHANGED
|
@@ -9,13 +9,13 @@ Connect to remote MCP and JSONRPC servers, invoke tools, discover capabilities w
|
|
|
9
9
|
Add to your Gemfile:
|
|
10
10
|
|
|
11
11
|
```ruby
|
|
12
|
-
gem "datagrout-conduit", "~> 0.
|
|
12
|
+
gem "datagrout-conduit", "~> 0.4.0"
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Or install directly:
|
|
16
16
|
|
|
17
17
|
```sh
|
|
18
|
-
gem install datagrout-conduit
|
|
18
|
+
gem install datagrout-conduit -v 0.4.0
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
## Quick Start
|
|
@@ -168,7 +168,49 @@ client = DatagroutConduit::Client.new(
|
|
|
168
168
|
)
|
|
169
169
|
```
|
|
170
170
|
|
|
171
|
-
|
|
171
|
+
### WebSocket (`datagrout-jsonrpc.v1`)
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
client = DatagroutConduit::Client.new(
|
|
175
|
+
url: "wss://gateway.datagrout.ai/servers/{uuid}/ws",
|
|
176
|
+
auth: { bearer: "your-token" },
|
|
177
|
+
transport: :websocket
|
|
178
|
+
)
|
|
179
|
+
client.connect
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Bidirectional push over a single `wss://` connection using `websocket-driver ~> 0.7` (the library underlying Rails ActionCable — no EventMachine dependency). A background `Thread` runs the read loop; shared state is protected by a `Mutex`.
|
|
183
|
+
|
|
184
|
+
#### Push subscriptions
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
# Subscribe — returns a Subscription with recv + each (Enumerable)
|
|
188
|
+
sub = client.subscribe("agents.my-agent-id.events")
|
|
189
|
+
|
|
190
|
+
# Block on the next event
|
|
191
|
+
event = sub.recv(timeout: 30)
|
|
192
|
+
puts "#{event.event}: #{event.data.inspect}"
|
|
193
|
+
|
|
194
|
+
# Or iterate until the subscription is closed
|
|
195
|
+
sub.each do |event|
|
|
196
|
+
puts "#{event.event}: #{event.data.inspect}"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Unsubscribe when done
|
|
200
|
+
client.unsubscribe(sub)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Supported topics:
|
|
204
|
+
|
|
205
|
+
| Topic | Fires when |
|
|
206
|
+
|-------|-----------|
|
|
207
|
+
| `agents.<agent_id>.events` | Agent lifecycle events (plan started, IC completed, grounding failed, …) |
|
|
208
|
+
| `tools.<tool_name>.results` | A specific tool call completes |
|
|
209
|
+
| `tasks.<task_id>.*` | Long-running background task transitions |
|
|
210
|
+
| `flows.<flow_id>.*` | `flow.into` progress and completion |
|
|
211
|
+
| `governor.<server_uuid>` | Governor percept events (file change, schedule, webhook) |
|
|
212
|
+
|
|
213
|
+
**Reconnection**: after a disconnect, `send_request` and `subscribe` raise `DatagroutConduit::NotInitializedError`. Call `client.connect` again and re-subscribe — subscriptions do not survive reconnects in v0.4.
|
|
172
214
|
|
|
173
215
|
## Standard MCP Methods
|
|
174
216
|
|
|
@@ -13,6 +13,23 @@ module DatagroutConduit
|
|
|
13
13
|
|
|
14
14
|
attr_reader :transport, :server_info, :use_intelligent_interface
|
|
15
15
|
|
|
16
|
+
# Subscribe to a server-push topic over a WebSocket transport.
|
|
17
|
+
# Returns a {DatagroutConduit::Transport::Ws::Subscription}.
|
|
18
|
+
# Raises RuntimeError when transport is not :websocket.
|
|
19
|
+
def subscribe(topic)
|
|
20
|
+
raise "subscribe() requires transport: :websocket" unless @transport.is_a?(Transport::Ws)
|
|
21
|
+
|
|
22
|
+
@transport.subscribe(topic)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Cancel a push subscription.
|
|
26
|
+
# Accepts a Subscription object or a subscription ID string.
|
|
27
|
+
def unsubscribe(subscription)
|
|
28
|
+
raise "unsubscribe() requires transport: :websocket" unless @transport.is_a?(Transport::Ws)
|
|
29
|
+
|
|
30
|
+
@transport.unsubscribe(subscription)
|
|
31
|
+
end
|
|
32
|
+
|
|
16
33
|
def initialize(url:, auth: {}, transport: :mcp, identity: nil, identity_dir: nil,
|
|
17
34
|
use_intelligent_interface: nil, max_retries: 3, logger: nil, disable_mtls: false)
|
|
18
35
|
@url = url
|
|
@@ -389,8 +406,13 @@ module DatagroutConduit
|
|
|
389
406
|
# transport, transparently rewrite the path to the DG JSONRPC endpoint.
|
|
390
407
|
rpc_url = @url.end_with?("/mcp") ? @url.sub(%r{/mcp$}, "/rpc") : @url
|
|
391
408
|
Transport::JsonRpc.new(url: rpc_url, auth: @auth, identity: @identity)
|
|
409
|
+
when :websocket, "websocket"
|
|
410
|
+
ws_url = @url
|
|
411
|
+
.sub(/\Ahttps:\/\//, "wss://")
|
|
412
|
+
.sub(/\Ahttp:\/\//, "ws://")
|
|
413
|
+
Transport::Ws.new(url: ws_url, auth: @auth, identity: @identity)
|
|
392
414
|
else
|
|
393
|
-
raise ConfigError, "Unknown transport: #{@transport_mode}. Use :mcp or :
|
|
415
|
+
raise ConfigError, "Unknown transport: #{@transport_mode}. Use :mcp, :jsonrpc, or :websocket."
|
|
394
416
|
end
|
|
395
417
|
end
|
|
396
418
|
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "websocket/driver"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "thread"
|
|
8
|
+
require "json"
|
|
9
|
+
require "securerandom"
|
|
10
|
+
require "base64"
|
|
11
|
+
require "timeout"
|
|
12
|
+
|
|
13
|
+
module DatagroutConduit
|
|
14
|
+
module Transport
|
|
15
|
+
# WebSocket transport for datagrout-jsonrpc.v1.
|
|
16
|
+
#
|
|
17
|
+
# Manages a single wss:// connection with concurrent JSON-RPC request
|
|
18
|
+
# multiplexing and server-push subscriptions. Uses a background thread
|
|
19
|
+
# for frame reading; callers block on Thread::Queue for responses.
|
|
20
|
+
#
|
|
21
|
+
# Usage:
|
|
22
|
+
# ws = DatagroutConduit::Transport::Ws.new(
|
|
23
|
+
# url: "wss://gateway.datagrout.ai/servers/<uuid>/ws",
|
|
24
|
+
# auth: { bearer: "token" }
|
|
25
|
+
# )
|
|
26
|
+
# ws.connect
|
|
27
|
+
# result = ws.send_request("tools/list")
|
|
28
|
+
# sub = ws.subscribe("agents.my-agent.events")
|
|
29
|
+
# event = sub.recv
|
|
30
|
+
# ws.unsubscribe(sub)
|
|
31
|
+
# ws.disconnect
|
|
32
|
+
class Ws
|
|
33
|
+
SUBPROTOCOL = "datagrout-jsonrpc.v1"
|
|
34
|
+
|
|
35
|
+
# ── Subscription ─────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
# Per-subscription event stream delivered via a thread-safe Queue.
|
|
38
|
+
# Call recv to block until the next event, or iterate with each.
|
|
39
|
+
class Subscription
|
|
40
|
+
attr_reader :sub_id, :topic
|
|
41
|
+
|
|
42
|
+
def initialize(sub_id, topic)
|
|
43
|
+
@sub_id = sub_id
|
|
44
|
+
@topic = topic
|
|
45
|
+
@queue = ::Thread::Queue.new
|
|
46
|
+
@closed = false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Block until the next event arrives.
|
|
50
|
+
# Returns nil and raises StopIteration on close when iterating via each.
|
|
51
|
+
# @param timeout [Numeric, nil] optional timeout in seconds; returns nil on expiry
|
|
52
|
+
def recv(timeout: nil)
|
|
53
|
+
event =
|
|
54
|
+
if timeout
|
|
55
|
+
Timeout.timeout(timeout) { @queue.pop }
|
|
56
|
+
else
|
|
57
|
+
@queue.pop
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
raise StopIteration if event.nil?
|
|
61
|
+
|
|
62
|
+
event
|
|
63
|
+
rescue Timeout::Error
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Iterate over events until the subscription is closed.
|
|
68
|
+
def each(&block)
|
|
69
|
+
loop do
|
|
70
|
+
event = @queue.pop
|
|
71
|
+
break if event.nil?
|
|
72
|
+
block.call(event)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
include Enumerable
|
|
77
|
+
|
|
78
|
+
def closed?
|
|
79
|
+
@closed
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @api private
|
|
83
|
+
def _enqueue(event)
|
|
84
|
+
@queue.push(event) unless @closed
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @api private
|
|
88
|
+
def _close
|
|
89
|
+
return if @closed
|
|
90
|
+
|
|
91
|
+
@closed = true
|
|
92
|
+
@queue.push(nil)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Value object for a push notification delivered to a subscription.
|
|
97
|
+
SubscriptionEvent = Struct.new(:subscription, :event, :data, keyword_init: true)
|
|
98
|
+
|
|
99
|
+
# ── Construction ─────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
def initialize(url:, auth: {}, identity: nil)
|
|
102
|
+
@url = url
|
|
103
|
+
@auth = normalize_auth(auth)
|
|
104
|
+
@identity = identity
|
|
105
|
+
@mutex = Mutex.new
|
|
106
|
+
@write_mutex = Mutex.new
|
|
107
|
+
|
|
108
|
+
@pending = {} # id => RequestFuture
|
|
109
|
+
@pending_subscribe = {} # id => { topic:, future: }
|
|
110
|
+
@subscriptions = {} # sub_id => [Subscription, ...]
|
|
111
|
+
@next_id = 0
|
|
112
|
+
|
|
113
|
+
@io = nil
|
|
114
|
+
@driver = nil
|
|
115
|
+
@read_thread = nil
|
|
116
|
+
@connected = false
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# ── Public API ────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
# Establish the WebSocket connection.
|
|
122
|
+
# Blocks until the server-side handshake completes (up to 10 s).
|
|
123
|
+
def connect
|
|
124
|
+
uri = URI.parse(@url)
|
|
125
|
+
@io = open_socket(uri)
|
|
126
|
+
|
|
127
|
+
adapter = SocketAdapter.new(@url, @io)
|
|
128
|
+
@driver = WebSocket::Driver.client(adapter, protocols: [SUBPROTOCOL])
|
|
129
|
+
|
|
130
|
+
build_upgrade_headers.each { |k, v| @driver.set_header(k, v) }
|
|
131
|
+
|
|
132
|
+
handshake_q = ::Thread::Queue.new
|
|
133
|
+
|
|
134
|
+
@driver.on(:open) { handshake_q.push(nil) unless @connected }
|
|
135
|
+
@driver.on(:message) { |e| handle_message(e.data) }
|
|
136
|
+
@driver.on(:close) { handle_disconnect }
|
|
137
|
+
@driver.on(:error) { |e| handshake_q.push(e.message) unless @connected }
|
|
138
|
+
|
|
139
|
+
@driver.start
|
|
140
|
+
@read_thread = Thread.new { read_loop }
|
|
141
|
+
@read_thread.abort_on_exception = false
|
|
142
|
+
@read_thread.name = "conduit-ws-reader"
|
|
143
|
+
|
|
144
|
+
err = Timeout.timeout(10) { handshake_q.pop }
|
|
145
|
+
raise ConnectionError, "WebSocket handshake failed: #{err}" if err
|
|
146
|
+
|
|
147
|
+
@connected = true
|
|
148
|
+
self
|
|
149
|
+
rescue Timeout::Error
|
|
150
|
+
cleanup_socket
|
|
151
|
+
raise ConnectionError, "WebSocket connection timed out"
|
|
152
|
+
rescue ConnectionError
|
|
153
|
+
raise
|
|
154
|
+
rescue => e
|
|
155
|
+
cleanup_socket
|
|
156
|
+
raise ConnectionError, "WebSocket connect error: #{e.message}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Close the connection and fail all pending requests.
|
|
160
|
+
def disconnect
|
|
161
|
+
@mutex.synchronize { @connected = false }
|
|
162
|
+
fail_all_pending(:disconnected)
|
|
163
|
+
cleanup_socket
|
|
164
|
+
self
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def connected?
|
|
168
|
+
@connected
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Send a JSON-RPC request and block until the response arrives.
|
|
172
|
+
# Pass id: nil to fire a notification (no response expected).
|
|
173
|
+
# Returns the result value, or raises McpError on RPC-level error.
|
|
174
|
+
def send_request(method, params = nil, id: :auto)
|
|
175
|
+
ensure_connected!
|
|
176
|
+
|
|
177
|
+
# id: nil means fire-and-forget notification (no id field, no response wait)
|
|
178
|
+
if id.nil?
|
|
179
|
+
frame = { "jsonrpc" => "2.0", "method" => method }
|
|
180
|
+
frame["params"] = params if params
|
|
181
|
+
write_frame(frame)
|
|
182
|
+
return { "result" => {} }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
req_id = mint_id
|
|
186
|
+
future = RequestFuture.new
|
|
187
|
+
@mutex.synchronize { @pending[req_id] = future }
|
|
188
|
+
|
|
189
|
+
write_frame(build_request(req_id, method, params))
|
|
190
|
+
|
|
191
|
+
result, value = future.wait
|
|
192
|
+
if result == :ok
|
|
193
|
+
{ "result" => value }
|
|
194
|
+
else
|
|
195
|
+
raise McpError.new(code: -1, message: value.to_s, data: nil)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Subscribe to a dotted-namespace topic.
|
|
200
|
+
# Returns a Subscription that delivers events via recv / each.
|
|
201
|
+
def subscribe(topic)
|
|
202
|
+
ensure_connected!
|
|
203
|
+
|
|
204
|
+
req_id = mint_id
|
|
205
|
+
future = RequestFuture.new
|
|
206
|
+
@mutex.synchronize { @pending_subscribe[req_id] = { topic: topic, future: future } }
|
|
207
|
+
|
|
208
|
+
write_frame(build_request(req_id, "subscribe", { "topic" => topic }))
|
|
209
|
+
|
|
210
|
+
result, value = future.wait
|
|
211
|
+
if result == :ok
|
|
212
|
+
sub_id = value.is_a?(Hash) ? (value["subscription"] || req_id) : req_id
|
|
213
|
+
sub = Subscription.new(sub_id, topic)
|
|
214
|
+
@mutex.synchronize do
|
|
215
|
+
@subscriptions[sub_id] ||= []
|
|
216
|
+
@subscriptions[sub_id] << sub
|
|
217
|
+
end
|
|
218
|
+
sub
|
|
219
|
+
else
|
|
220
|
+
raise McpError.new(code: -1, message: value.to_s, data: nil)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Cancel a push subscription locally and notify the server.
|
|
225
|
+
# Accepts a Subscription object or a subscription ID string.
|
|
226
|
+
def unsubscribe(subscription)
|
|
227
|
+
sub_id = subscription.is_a?(Subscription) ? subscription.sub_id : subscription.to_s
|
|
228
|
+
|
|
229
|
+
subs = @mutex.synchronize { @subscriptions.delete(sub_id) || [] }
|
|
230
|
+
subs.each(&:_close)
|
|
231
|
+
|
|
232
|
+
if @connected
|
|
233
|
+
req_id = mint_id
|
|
234
|
+
write_frame(build_request(req_id, "unsubscribe", { "subscription" => sub_id }))
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
:ok
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
private
|
|
241
|
+
|
|
242
|
+
# ── Socket adapter for websocket-driver ──────────────────────────────────
|
|
243
|
+
|
|
244
|
+
class SocketAdapter
|
|
245
|
+
attr_reader :url
|
|
246
|
+
|
|
247
|
+
def initialize(url, io)
|
|
248
|
+
@url = url
|
|
249
|
+
@io = io
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def write(data)
|
|
253
|
+
@io.write(data)
|
|
254
|
+
data.bytesize
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# ── Request future ────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
class RequestFuture
|
|
261
|
+
def initialize
|
|
262
|
+
@queue = ::Thread::Queue.new
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def wait(timeout: 30)
|
|
266
|
+
Timeout.timeout(timeout) { @queue.pop }
|
|
267
|
+
rescue Timeout::Error
|
|
268
|
+
[:error, "Request timed out after #{timeout}s"]
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def resolve(value)
|
|
272
|
+
@queue.push([:ok, value])
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def reject(reason)
|
|
276
|
+
@queue.push([:error, reason])
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# ── Socket creation ───────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
def open_socket(uri)
|
|
283
|
+
host = uri.host
|
|
284
|
+
port = uri.port || (uri.scheme == "wss" ? 443 : 80)
|
|
285
|
+
|
|
286
|
+
tcp = TCPSocket.new(host, port)
|
|
287
|
+
tcp.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
|
|
288
|
+
|
|
289
|
+
if uri.scheme == "wss"
|
|
290
|
+
ctx = build_ssl_context
|
|
291
|
+
ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
|
|
292
|
+
ssl.hostname = host
|
|
293
|
+
ssl.sync_close = true
|
|
294
|
+
ssl.connect
|
|
295
|
+
ssl
|
|
296
|
+
else
|
|
297
|
+
tcp
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def build_ssl_context
|
|
302
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
303
|
+
ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
|
|
304
|
+
|
|
305
|
+
if @identity
|
|
306
|
+
ctx.cert = @identity.openssl_cert
|
|
307
|
+
ctx.key = @identity.openssl_key
|
|
308
|
+
if @identity.ca_pem
|
|
309
|
+
store = OpenSSL::X509::Store.new
|
|
310
|
+
store.add_cert(@identity.openssl_ca)
|
|
311
|
+
ctx.cert_store = store
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
ctx
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# ── Upgrade headers ───────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
def build_upgrade_headers
|
|
321
|
+
headers = {}
|
|
322
|
+
|
|
323
|
+
case @auth[:type]
|
|
324
|
+
when :bearer
|
|
325
|
+
headers["Authorization"] = "Bearer #{@auth[:token]}"
|
|
326
|
+
when :api_key
|
|
327
|
+
headers["X-API-Key"] = @auth[:key]
|
|
328
|
+
when :basic
|
|
329
|
+
encoded = Base64.strict_encode64("#{@auth[:username]}:#{@auth[:password]}")
|
|
330
|
+
headers["Authorization"] = "Basic #{encoded}"
|
|
331
|
+
when :oauth
|
|
332
|
+
token = @auth[:provider].get_token
|
|
333
|
+
headers["Authorization"] = "Bearer #{token}"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
headers
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# ── Read loop ─────────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
def read_loop
|
|
342
|
+
loop do
|
|
343
|
+
data = @io.readpartial(4096)
|
|
344
|
+
@driver.parse(data)
|
|
345
|
+
rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE
|
|
346
|
+
handle_disconnect
|
|
347
|
+
break
|
|
348
|
+
rescue StandardError
|
|
349
|
+
handle_disconnect
|
|
350
|
+
break
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# ── Message routing ───────────────────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
def handle_message(raw)
|
|
357
|
+
msg = JSON.parse(raw)
|
|
358
|
+
|
|
359
|
+
str_id = msg["id"]&.to_s
|
|
360
|
+
|
|
361
|
+
if str_id && (entry = @mutex.synchronize { @pending_subscribe.delete(str_id) })
|
|
362
|
+
future = entry[:future]
|
|
363
|
+
if msg["error"]
|
|
364
|
+
future.reject(msg.dig("error", "message") || "Subscribe failed")
|
|
365
|
+
else
|
|
366
|
+
future.resolve(msg["result"] || {})
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
elsif str_id && (future = @mutex.synchronize { @pending.delete(str_id) })
|
|
370
|
+
if msg["error"]
|
|
371
|
+
future.reject(msg.dig("error", "message") || "RPC error")
|
|
372
|
+
else
|
|
373
|
+
future.resolve(msg["result"])
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
elsif msg["method"] == "notification"
|
|
377
|
+
route_notification(msg["params"] || {})
|
|
378
|
+
end
|
|
379
|
+
rescue JSON::ParserError
|
|
380
|
+
# Silently discard malformed frames
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def route_notification(params)
|
|
384
|
+
sub_id = params["subscription"]
|
|
385
|
+
return unless sub_id.is_a?(String)
|
|
386
|
+
|
|
387
|
+
subs = @mutex.synchronize { @subscriptions[sub_id] }
|
|
388
|
+
return unless subs
|
|
389
|
+
|
|
390
|
+
event = SubscriptionEvent.new(
|
|
391
|
+
subscription: sub_id,
|
|
392
|
+
event: params["event"] || "",
|
|
393
|
+
data: params["data"]
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
subs.each { |sub| sub._enqueue(event) }
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def handle_disconnect
|
|
400
|
+
was_connected = @mutex.synchronize do
|
|
401
|
+
old = @connected
|
|
402
|
+
@connected = false
|
|
403
|
+
old
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
fail_all_pending(:disconnected) if was_connected
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def fail_all_pending(reason)
|
|
410
|
+
pending, pending_sub, subs = @mutex.synchronize do
|
|
411
|
+
p = @pending.dup
|
|
412
|
+
ps = @pending_subscribe.dup
|
|
413
|
+
s = @subscriptions.dup
|
|
414
|
+
@pending.clear
|
|
415
|
+
@pending_subscribe.clear
|
|
416
|
+
@subscriptions.clear
|
|
417
|
+
[p, ps, s]
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
pending.each_value { |f| f.reject(reason) }
|
|
421
|
+
pending_sub.each_value { |entry| entry[:future].reject(reason) }
|
|
422
|
+
subs.each_value { |list| list.each(&:_close) }
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def cleanup_socket
|
|
426
|
+
@write_mutex.synchronize do
|
|
427
|
+
@driver = nil
|
|
428
|
+
end
|
|
429
|
+
@read_thread&.kill
|
|
430
|
+
@read_thread = nil
|
|
431
|
+
@io&.close rescue nil
|
|
432
|
+
@io = nil
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# ── Helpers ───────────────────────────────────────────────────────────────
|
|
436
|
+
|
|
437
|
+
def ensure_connected!
|
|
438
|
+
raise NotInitializedError, "WebSocket not connected. Call connect() first." unless @connected
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def mint_id
|
|
442
|
+
@mutex.synchronize do
|
|
443
|
+
@next_id += 1
|
|
444
|
+
"ws-#{@next_id}"
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def build_request(id, method, params)
|
|
449
|
+
body = { "jsonrpc" => "2.0", "id" => id, "method" => method }
|
|
450
|
+
body["params"] = params if params
|
|
451
|
+
body
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def write_frame(data)
|
|
455
|
+
json = JSON.generate(data)
|
|
456
|
+
@write_mutex.synchronize { @driver&.text(json) }
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def normalize_auth(auth)
|
|
460
|
+
return { type: :none } if auth.nil? || auth.empty?
|
|
461
|
+
|
|
462
|
+
auth = auth.transform_keys(&:to_sym) if auth.is_a?(Hash)
|
|
463
|
+
|
|
464
|
+
if auth[:bearer]
|
|
465
|
+
{ type: :bearer, token: auth[:bearer] }
|
|
466
|
+
elsif auth[:api_key]
|
|
467
|
+
{ type: :api_key, key: auth[:api_key] }
|
|
468
|
+
elsif auth[:basic]
|
|
469
|
+
{ type: :basic, username: auth[:basic][:username], password: auth[:basic][:password] }
|
|
470
|
+
elsif auth[:oauth] || auth[:provider]
|
|
471
|
+
{ type: :oauth, provider: auth[:oauth] || auth[:provider] }
|
|
472
|
+
else
|
|
473
|
+
{ type: :none }
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
end
|
data/lib/datagrout_conduit.rb
CHANGED
|
@@ -11,6 +11,7 @@ require_relative "datagrout_conduit/registration"
|
|
|
11
11
|
require_relative "datagrout_conduit/transport/base"
|
|
12
12
|
require_relative "datagrout_conduit/transport/mcp"
|
|
13
13
|
require_relative "datagrout_conduit/transport/jsonrpc"
|
|
14
|
+
require_relative "datagrout_conduit/transport/ws"
|
|
14
15
|
require_relative "datagrout_conduit/namespaces/prism"
|
|
15
16
|
require_relative "datagrout_conduit/namespaces/logic"
|
|
16
17
|
require_relative "datagrout_conduit/namespaces/warden"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: datagrout-conduit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- DataGrout
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-05-02 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|
|
@@ -52,6 +52,20 @@ dependencies:
|
|
|
52
52
|
- - ">="
|
|
53
53
|
- !ruby/object:Gem::Version
|
|
54
54
|
version: '0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: websocket-driver
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '0.7'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '0.7'
|
|
55
69
|
- !ruby/object:Gem::Dependency
|
|
56
70
|
name: minitest
|
|
57
71
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -119,6 +133,7 @@ files:
|
|
|
119
133
|
- lib/datagrout_conduit/transport/base.rb
|
|
120
134
|
- lib/datagrout_conduit/transport/jsonrpc.rb
|
|
121
135
|
- lib/datagrout_conduit/transport/mcp.rb
|
|
136
|
+
- lib/datagrout_conduit/transport/ws.rb
|
|
122
137
|
- lib/datagrout_conduit/types.rb
|
|
123
138
|
- lib/datagrout_conduit/version.rb
|
|
124
139
|
homepage: https://github.com/DataGrout/conduit-sdk
|