11package net.xzos.upgradeall.getter.rpc
22
33import com.google.gson.Gson
4- import com.google.gson.JsonElement
54import com.google.gson.JsonObject
65import com.google.gson.reflect.TypeToken
76import io.ktor.client.*
@@ -18,74 +17,78 @@ import kotlin.time.Duration.Companion.seconds
1817
1918/* *
2019 * WebSocket-based JSON-RPC 2.0 client using Ktor.
21- *
20+ *
2221 * This client maintains a persistent WebSocket connection and handles concurrent
2322 * JSON-RPC requests by matching request IDs with responses.
2423 */
25- class RpcClient (private val url : String ) {
24+ class RpcClient (
25+ private val url : String ,
26+ ) {
2627 private val gson = Gson ()
2728 private val requestId = AtomicLong (1 )
28-
29- private val client = HttpClient (CIO ) {
30- install(WebSockets ) {
31- pingInterval = 30 .seconds
29+
30+ private val client =
31+ HttpClient (CIO ) {
32+ install(WebSockets ) {
33+ pingInterval = 30 .seconds
34+ }
3235 }
33- }
34-
36+
3537 private var sessionJob: Job ? = null
3638 private var session: DefaultClientWebSocketSession ? = null
3739 private val pendingRequests = ConcurrentHashMap <Long , CompletableDeferred <JsonObject >>()
3840 private val sessionMutex = Mutex ()
3941 private val scope = CoroutineScope (SupervisorJob () + Dispatchers .IO )
40-
42+
4143 @Volatile
4244 private var isConnected = false
43-
45+
4446 /* *
4547 * Ensure WebSocket connection is established
4648 */
4749 private suspend fun ensureConnected () {
4850 sessionMutex.withLock {
4951 if (isConnected && session != null ) return @withLock
50-
52+
5153 // Close old session if exists
5254 session?.close()
5355 sessionJob?.cancel()
54-
56+
5557 // Parse URL (format: "ws://host:port" or "http://host:port")
5658 val wsUrl = url.replace(" http://" , " ws://" ).replace(" https://" , " wss://" )
57-
59+
5860 try {
59- sessionJob = scope.launch {
60- client.webSocket(wsUrl) {
61- session = this
62- isConnected = true
63-
64- // Start message receiver loop
65- for (frame in incoming) {
66- if (frame is Frame .Text ) {
67- val text = frame.readText()
68- handleResponse(text)
61+ sessionJob =
62+ scope.launch {
63+ client.webSocket(wsUrl) {
64+ session = this
65+ isConnected = true
66+
67+ // Start message receiver loop
68+ for (frame in incoming) {
69+ if (frame is Frame .Text ) {
70+ val text = frame.readText()
71+ handleResponse(text)
72+ }
6973 }
7074 }
75+ // Connection closed
76+ isConnected = false
77+ session = null
78+
79+ // Fail all pending requests
80+ val exception = RpcException (" WebSocket connection closed" )
81+ pendingRequests.values.forEach { it.completeExceptionally(exception) }
82+ pendingRequests.clear()
7183 }
72- // Connection closed
73- isConnected = false
74- session = null
75-
76- // Fail all pending requests
77- val exception = RpcException (" WebSocket connection closed" )
78- pendingRequests.values.forEach { it.completeExceptionally(exception) }
79- pendingRequests.clear()
80- }
81-
84+
8285 // Wait for connection to be established
8386 var attempts = 0
8487 while (! isConnected && attempts < 50 ) {
8588 delay(100 )
8689 attempts++
8790 }
88-
91+
8992 if (! isConnected) {
9093 throw RpcException (" Failed to connect to WebSocket server" )
9194 }
@@ -96,24 +99,25 @@ class RpcClient(private val url: String) {
9699 }
97100 }
98101 }
99-
102+
100103 /* *
101104 * Handle incoming JSON-RPC response
102105 */
103106 private fun handleResponse (text : String ) {
104107 try {
105108 val response = gson.fromJson(text, JsonObject ::class .java)
106-
109+
107110 if (response.has(" id" ) && ! response.get(" id" ).isJsonNull) {
108111 val id = response.get(" id" ).asLong
109112 val deferred = pendingRequests.remove(id)
110-
113+
111114 if (deferred != null ) {
112115 if (response.has(" error" ) && ! response.get(" error" ).isJsonNull) {
113116 val error = response.getAsJsonObject(" error" )
114117 val message = error.get(" message" )?.asString ? : " Unknown error"
115118 val data = error.get(" data" )?.asString
116- deferred.completeExceptionally(RpcException (message, data))
119+ val code = error.get(" code" )?.asInt ? : 0
120+ deferred.completeExceptionally(RpcException (message, data, code))
117121 } else {
118122 deferred.complete(response)
119123 }
@@ -123,34 +127,41 @@ class RpcClient(private val url: String) {
123127 // Ignore malformed responses
124128 }
125129 }
126-
130+
127131 /* *
128132 * Invoke a JSON-RPC method with named parameters
129133 */
130- suspend fun <T > invoke (method : String , params : Map <String , Any ?>, resultType : Type , timeoutMillis : Long = 60_000): T {
134+ suspend fun <T > invoke (
135+ method : String ,
136+ params : Map <String , Any ?>,
137+ resultType : Type ,
138+ timeoutMillis : Long = 60_000,
139+ ): T {
131140 ensureConnected()
132-
141+
133142 val id = requestId.getAndIncrement()
134- val request = JsonObject ().apply {
135- addProperty(" jsonrpc" , " 2.0" )
136- addProperty(" method" , method)
137- addProperty(" id" , id)
138- add(" params" , gson.toJsonTree(params))
139- }
140-
143+ val request =
144+ JsonObject ().apply {
145+ addProperty(" jsonrpc" , " 2.0" )
146+ addProperty(" method" , method)
147+ addProperty(" id" , id)
148+ add(" params" , gson.toJsonTree(params))
149+ }
150+
141151 val deferred = CompletableDeferred <JsonObject >()
142152 pendingRequests[id] = deferred
143-
153+
144154 return try {
145155 // Send request
146156 session?.send(Frame .Text (request.toString()))
147157 ? : throw RpcException (" WebSocket session is null" )
148-
158+
149159 // Wait for response with timeout
150- val response = withTimeout(timeoutMillis) {
151- deferred.await()
152- }
153-
160+ val response =
161+ withTimeout(timeoutMillis) {
162+ deferred.await()
163+ }
164+
154165 // Parse result
155166 val result = response.get(" result" )
156167 @Suppress(" UNCHECKED_CAST" )
@@ -165,33 +176,39 @@ class RpcClient(private val url: String) {
165176 throw RpcException (" Request failed: ${e.message} " , e.toString())
166177 }
167178 }
168-
179+
169180 /* *
170181 * Invoke a JSON-RPC method without parameters
171182 */
172- suspend fun <T > invoke (method : String , resultType : Type , timeoutMillis : Long = 60_000): T {
183+ suspend fun <T > invoke (
184+ method : String ,
185+ resultType : Type ,
186+ timeoutMillis : Long = 60_000,
187+ ): T {
173188 ensureConnected()
174-
189+
175190 val id = requestId.getAndIncrement()
176- val request = JsonObject ().apply {
177- addProperty(" jsonrpc" , " 2.0" )
178- addProperty(" method" , method)
179- addProperty(" id" , id)
180- }
181-
191+ val request =
192+ JsonObject ().apply {
193+ addProperty(" jsonrpc" , " 2.0" )
194+ addProperty(" method" , method)
195+ addProperty(" id" , id)
196+ }
197+
182198 val deferred = CompletableDeferred <JsonObject >()
183199 pendingRequests[id] = deferred
184-
200+
185201 return try {
186202 // Send request
187203 session?.send(Frame .Text (request.toString()))
188204 ? : throw RpcException (" WebSocket session is null" )
189-
205+
190206 // Wait for response with timeout
191- val response = withTimeout(timeoutMillis) {
192- deferred.await()
193- }
194-
207+ val response =
208+ withTimeout(timeoutMillis) {
209+ deferred.await()
210+ }
211+
195212 // Parse result
196213 val result = response.get(" result" )
197214 @Suppress(" UNCHECKED_CAST" )
@@ -206,36 +223,42 @@ class RpcClient(private val url: String) {
206223 throw RpcException (" Request failed: ${e.message} " , e.toString())
207224 }
208225 }
209-
226+
210227 /* *
211228 * Invoke a JSON-RPC method that returns nothing
212229 */
213- suspend fun invokeVoid (method : String , params : Map <String , Any ?> = emptyMap(), timeoutMillis : Long = 60_000) {
230+ suspend fun invokeVoid (
231+ method : String ,
232+ params : Map <String , Any ?> = emptyMap(),
233+ timeoutMillis : Long = 60_000,
234+ ) {
214235 ensureConnected()
215-
236+
216237 val id = requestId.getAndIncrement()
217- val request = JsonObject ().apply {
218- addProperty(" jsonrpc" , " 2.0" )
219- addProperty(" method" , method)
220- addProperty(" id" , id)
221- if (params.isNotEmpty()) {
222- add(" params" , gson.toJsonTree(params))
238+ val request =
239+ JsonObject ().apply {
240+ addProperty(" jsonrpc" , " 2.0" )
241+ addProperty(" method" , method)
242+ addProperty(" id" , id)
243+ if (params.isNotEmpty()) {
244+ add(" params" , gson.toJsonTree(params))
245+ }
223246 }
224- }
225-
247+
226248 val deferred = CompletableDeferred <JsonObject >()
227249 pendingRequests[id] = deferred
228-
250+
229251 try {
230252 // Send request
231253 session?.send(Frame .Text (request.toString()))
232254 ? : throw RpcException (" WebSocket session is null" )
233-
255+
234256 // Wait for response with timeout
235- val response = withTimeout(timeoutMillis) {
236- deferred.await()
237- }
238-
257+ val response =
258+ withTimeout(timeoutMillis) {
259+ deferred.await()
260+ }
261+
239262 // Check for errors
240263 if (response.has(" error" ) && ! response.get(" error" ).isJsonNull) {
241264 val error = response.getAsJsonObject(" error" )
@@ -253,7 +276,7 @@ class RpcClient(private val url: String) {
253276 throw RpcException (" Request failed: ${e.message} " , e.toString())
254277 }
255278 }
256-
279+
257280 /* *
258281 * Close the WebSocket connection
259282 */
@@ -266,7 +289,7 @@ class RpcClient(private val url: String) {
266289 sessionJob = null
267290 scope.cancel()
268291 client.close()
269-
292+
270293 // Fail all pending requests
271294 val exception = RpcException (" Client closed" )
272295 pendingRequests.values.forEach { it.completeExceptionally(exception) }
@@ -276,11 +299,21 @@ class RpcClient(private val url: String) {
276299}
277300
278301/* *
279- * JSON-RPC exception thrown when RPC call fails
302+ * JSON-RPC exception thrown when RPC call fails.
303+ *
304+ * [code] mirrors the JSON-RPC error code:
305+ * -32001 = no result / app not found (expected, not a bug)
306+ * -32600 = invalid request
307+ * -32601 = method not found
308+ * -32602 = invalid params
309+ * -32603 = internal error
310+ * -32700 = parse error
280311 */
281- class RpcException (message : String , val data : String? = null ) : RuntimeException(
282- if (data != null) " $message : $data " else message
283- )
312+ class RpcException (
313+ message : String ,
314+ val data : String? = null ,
315+ val code : Int = 0 ,
316+ ) : RuntimeException(if (data != null) " $message : $data " else message)
284317
285318/* *
286319 * Type token helper for generic types with Gson
0 commit comments