@elizaos/capacitor-gateway 1.0.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.
@@ -0,0 +1,17 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'ElizaosCapacitorGateway'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.license = package['license'] || { :type => 'MIT' }
10
+ s.homepage = 'https://elizaos.ai'
11
+ s.authors = { 'elizaOS' => 'dev@elizaos.ai' }
12
+ s.source = { :git => 'https://github.com/elizaOS/eliza.git', :tag => s.version.to_s }
13
+ s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
14
+ s.ios.deployment_target = '15.0'
15
+ s.dependency 'Capacitor'
16
+ s.swift_version = '5.9'
17
+ end
@@ -0,0 +1,49 @@
1
+ ext {
2
+ junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
3
+ androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.6.1'
4
+ androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.1.5'
5
+ androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.5.1'
6
+ kotlinVersion = project.hasProperty('kotlinVersion') ? rootProject.ext.kotlinVersion : '1.9.25'
7
+ }
8
+
9
+ apply plugin: 'com.android.library'
10
+ android {
11
+ namespace = "ai.eliza.plugins.gateway"
12
+ compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
13
+ defaultConfig {
14
+ minSdk project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
15
+ targetSdk project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
16
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
17
+ }
18
+ buildTypes {
19
+ release {
20
+ minifyEnabled false
21
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
22
+ }
23
+ }
24
+ compileOptions {
25
+ sourceCompatibility JavaVersion.VERSION_17
26
+ targetCompatibility JavaVersion.VERSION_17
27
+ }
28
+ }
29
+
30
+ repositories {
31
+ google()
32
+ maven {
33
+ url = uri(rootProject.ext.mavenCentralMirrorUrl)
34
+ }
35
+ mavenCentral()
36
+ }
37
+
38
+ dependencies {
39
+ implementation fileTree(dir: 'libs', include: ['*.jar'])
40
+ implementation project(':capacitor-android')
41
+ implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
42
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
43
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2"
44
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
45
+ implementation "com.squareup.okhttp3:okhttp:5.3.2"
46
+ testImplementation "junit:junit:$junitVersion"
47
+ androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
48
+ androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
49
+ }
@@ -0,0 +1,4 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
3
+ <uses-permission android:name="android.permission.INTERNET" />
4
+ </manifest>
@@ -0,0 +1,614 @@
1
+ package ai.eliza.plugins.gateway
2
+
3
+ import android.content.Context
4
+ import android.net.nsd.NsdManager
5
+ import android.net.nsd.NsdServiceInfo
6
+ import android.util.Log
7
+ import com.getcapacitor.JSArray
8
+ import com.getcapacitor.JSObject
9
+ import com.getcapacitor.Plugin
10
+ import com.getcapacitor.PluginCall
11
+ import com.getcapacitor.PluginMethod
12
+ import com.getcapacitor.annotation.CapacitorPlugin
13
+ import kotlinx.coroutines.*
14
+ import okhttp3.*
15
+ import org.json.JSONArray
16
+ import org.json.JSONObject
17
+ import java.util.UUID
18
+ import java.util.concurrent.ConcurrentHashMap
19
+ import java.util.concurrent.TimeUnit
20
+ import kotlin.coroutines.Continuation
21
+ import kotlin.coroutines.resume
22
+ import kotlin.coroutines.resumeWithException
23
+ import kotlin.coroutines.suspendCoroutine
24
+
25
+ /**
26
+ * Gateway Plugin for Capacitor
27
+ *
28
+ * Provides WebSocket connectivity to an Eliza Gateway server.
29
+ * This implementation handles authentication, reconnection, and RPC-style
30
+ * request/response as well as event streaming.
31
+ */
32
+ @CapacitorPlugin(name = "Gateway")
33
+ class GatewayPlugin : Plugin() {
34
+ private val TAG = "GatewayPlugin"
35
+
36
+ private var webSocket: WebSocket? = null
37
+ private var okHttpClient: OkHttpClient? = null
38
+ private val pendingRequests = ConcurrentHashMap<String, Continuation<JSObject>>()
39
+ private var options: JSObject? = null
40
+ private var sessionId: String? = null
41
+ private var protocolVersion: Int? = null
42
+ private var role: String? = null
43
+ private var scopes: List<String> = emptyList()
44
+ private var methods: List<String> = emptyList()
45
+ private var events: List<String> = emptyList()
46
+ private var lastSeq: Int? = null
47
+ private var isClosed = false
48
+ private var backoffMs: Long = 800
49
+ private var reconnectJob: Job? = null
50
+ private var connectContinuation: Continuation<JSObject>? = null
51
+
52
+ private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
53
+
54
+ // Discovery
55
+ private var nsdManager: NsdManager? = null
56
+ private var isDiscovering = false
57
+ private val discoveredGateways = ConcurrentHashMap<String, JSObject>()
58
+ private val serviceType = "_eliza-gw._tcp."
59
+
60
+ private val discoveryListener = object : NsdManager.DiscoveryListener {
61
+ override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
62
+ Log.e(TAG, "Discovery start failed: $errorCode")
63
+ isDiscovering = false
64
+ }
65
+
66
+ override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
67
+ Log.e(TAG, "Discovery stop failed: $errorCode")
68
+ }
69
+
70
+ override fun onDiscoveryStarted(serviceType: String) {
71
+ Log.d(TAG, "Discovery started for $serviceType")
72
+ isDiscovering = true
73
+ }
74
+
75
+ override fun onDiscoveryStopped(serviceType: String) {
76
+ Log.d(TAG, "Discovery stopped for $serviceType")
77
+ isDiscovering = false
78
+ }
79
+
80
+ override fun onServiceFound(serviceInfo: NsdServiceInfo) {
81
+ if (serviceInfo.serviceType != this@GatewayPlugin.serviceType) return
82
+ resolveService(serviceInfo)
83
+ }
84
+
85
+ override fun onServiceLost(serviceInfo: NsdServiceInfo) {
86
+ val serviceName = decodeServiceName(serviceInfo.serviceName)
87
+ val id = stableId(serviceName, "local.")
88
+ val removed = discoveredGateways.remove(id)
89
+ if (removed != null) {
90
+ notifyListeners("discovery", JSObject().apply {
91
+ put("type", "lost")
92
+ put("gateway", removed)
93
+ })
94
+ }
95
+ }
96
+ }
97
+
98
+ private fun decodeServiceName(raw: String): String {
99
+ // Basic Bonjour escape decoding
100
+ return raw.replace(Regex("\\\\(\\d{3})")) {
101
+ it.groupValues[1].toIntOrNull()?.let { code ->
102
+ code.toChar().toString()
103
+ } ?: it.value
104
+ }
105
+ }
106
+
107
+ private fun stableId(serviceName: String, domain: String): String {
108
+ return "${serviceType}|${domain}|${serviceName.trim().lowercase()}"
109
+ }
110
+
111
+ @Suppress("DEPRECATION")
112
+ private fun resolveService(serviceInfo: NsdServiceInfo) {
113
+ nsdManager?.resolveService(serviceInfo, object : NsdManager.ResolveListener {
114
+ override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
115
+ Log.e(TAG, "Resolve failed for ${serviceInfo.serviceName}: $errorCode")
116
+ }
117
+
118
+ override fun onServiceResolved(resolved: NsdServiceInfo) {
119
+ val host = resolved.host?.hostAddress ?: return
120
+ val port = resolved.port
121
+ if (port <= 0) return
122
+
123
+ val serviceName = decodeServiceName(resolved.serviceName)
124
+ val displayName = txt(resolved, "displayName") ?: serviceName
125
+ val lanHost = txt(resolved, "lanHost")
126
+ val tailnetDns = txt(resolved, "tailnetDns")
127
+ val gatewayPort = txtInt(resolved, "gatewayPort")
128
+ val canvasPort = txtInt(resolved, "canvasPort")
129
+ val tlsEnabled = txtBool(resolved, "gatewayTls")
130
+ val tlsFingerprint = txt(resolved, "gatewayTlsSha256")
131
+ val id = stableId(serviceName, "local.")
132
+
133
+ val gateway = JSObject().apply {
134
+ put("stableId", id)
135
+ put("name", displayName)
136
+ put("host", host)
137
+ put("port", gatewayPort ?: port)
138
+ put("lanHost", lanHost)
139
+ put("tailnetDns", tailnetDns)
140
+ put("gatewayPort", gatewayPort ?: port)
141
+ put("canvasPort", canvasPort)
142
+ put("tlsEnabled", tlsEnabled)
143
+ put("tlsFingerprintSha256", tlsFingerprint)
144
+ put("isLocal", true)
145
+ }
146
+
147
+ val isNew = discoveredGateways.put(id, gateway) == null
148
+ notifyListeners("discovery", JSObject().apply {
149
+ put("type", if (isNew) "found" else "updated")
150
+ put("gateway", gateway)
151
+ })
152
+ }
153
+ })
154
+ }
155
+
156
+ private fun txt(info: NsdServiceInfo, key: String): String? {
157
+ val bytes = info.attributes[key] ?: return null
158
+ return try {
159
+ String(bytes, Charsets.UTF_8).trim().ifEmpty { null }
160
+ } catch (_: Throwable) {
161
+ null
162
+ }
163
+ }
164
+
165
+ private fun txtInt(info: NsdServiceInfo, key: String): Int? {
166
+ return txt(info, key)?.toIntOrNull()
167
+ }
168
+
169
+ private fun txtBool(info: NsdServiceInfo, key: String): Boolean {
170
+ val raw = txt(info, key)?.trim()?.lowercase() ?: return false
171
+ return raw == "1" || raw == "true" || raw == "yes"
172
+ }
173
+
174
+ @PluginMethod
175
+ fun startDiscovery(call: PluginCall) {
176
+ if (isDiscovering) {
177
+ call.resolve(buildDiscoveryResult())
178
+ return
179
+ }
180
+
181
+ try {
182
+ nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager
183
+ nsdManager?.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, discoveryListener)
184
+
185
+ // Return initial result after a brief delay for discovery
186
+ scope.launch {
187
+ delay(500)
188
+ call.resolve(buildDiscoveryResult())
189
+ }
190
+ } catch (e: Exception) {
191
+ call.reject("Failed to start discovery: ${e.message}")
192
+ }
193
+ }
194
+
195
+ @PluginMethod
196
+ fun stopDiscovery(call: PluginCall) {
197
+ if (isDiscovering) {
198
+ try {
199
+ nsdManager?.stopServiceDiscovery(discoveryListener)
200
+ } catch (_: Throwable) {
201
+ // Ignore - best effort
202
+ }
203
+ }
204
+ isDiscovering = false
205
+ call.resolve()
206
+ }
207
+
208
+ @PluginMethod
209
+ fun getDiscoveredGateways(call: PluginCall) {
210
+ call.resolve(buildDiscoveryResult())
211
+ }
212
+
213
+ private fun buildDiscoveryResult(): JSObject {
214
+ val gateways = JSArray()
215
+ for (gateway in discoveredGateways.values.sortedBy { it.getString("name")?.lowercase() }) {
216
+ gateways.put(gateway)
217
+ }
218
+
219
+ return JSObject().apply {
220
+ put("gateways", gateways)
221
+ put("status", if (isDiscovering) "Discovering..." else "Discovery stopped")
222
+ }
223
+ }
224
+
225
+ @PluginMethod
226
+ fun connect(call: PluginCall) {
227
+ val urlString = call.getString("url")
228
+ if (urlString == null) {
229
+ call.reject("Missing URL parameter")
230
+ return
231
+ }
232
+
233
+ // Store options for reconnection
234
+ options = call.data
235
+
236
+ // Close existing connection
237
+ closeConnection()
238
+ isClosed = false
239
+ backoffMs = 800
240
+
241
+ scope.launch {
242
+ try {
243
+ val result = establishConnection(urlString, call.data)
244
+ call.resolve(result)
245
+ } catch (e: Exception) {
246
+ call.reject("Connection failed: ${e.message}")
247
+ }
248
+ }
249
+ }
250
+
251
+ @PluginMethod
252
+ fun disconnect(call: PluginCall) {
253
+ isClosed = true
254
+ reconnectJob?.cancel()
255
+ reconnectJob = null
256
+ closeConnection()
257
+ sessionId = null
258
+ protocolVersion = null
259
+ notifyStateChange("disconnected", "Client disconnect")
260
+ call.resolve()
261
+ }
262
+
263
+ @PluginMethod
264
+ fun isConnected(call: PluginCall) {
265
+ val connected = webSocket != null
266
+ call.resolve(JSObject().apply {
267
+ put("connected", connected)
268
+ })
269
+ }
270
+
271
+ @PluginMethod
272
+ fun send(call: PluginCall) {
273
+ val method = call.getString("method")
274
+ if (method == null) {
275
+ call.reject("Missing method parameter")
276
+ return
277
+ }
278
+
279
+ val ws = webSocket
280
+ if (ws == null) {
281
+ call.resolve(JSObject().apply {
282
+ put("ok", false)
283
+ put("error", JSObject().apply {
284
+ put("code", "NOT_CONNECTED")
285
+ put("message", "Not connected to gateway")
286
+ })
287
+ })
288
+ return
289
+ }
290
+
291
+ val id = UUID.randomUUID().toString()
292
+ val params = call.getObject("params") ?: JSObject()
293
+
294
+ val frame = JSONObject().apply {
295
+ put("type", "req")
296
+ put("id", id)
297
+ put("method", method)
298
+ put("params", params.toJson())
299
+ }
300
+
301
+ scope.launch {
302
+ try {
303
+ val result = sendRequest(id, frame.toString())
304
+ call.resolve(result)
305
+ } catch (e: Exception) {
306
+ call.resolve(JSObject().apply {
307
+ put("ok", false)
308
+ put("error", JSObject().apply {
309
+ put("code", "REQUEST_FAILED")
310
+ put("message", e.message ?: "Unknown error")
311
+ })
312
+ })
313
+ }
314
+ }
315
+ }
316
+
317
+ @PluginMethod
318
+ fun getConnectionInfo(call: PluginCall) {
319
+ call.resolve(JSObject().apply {
320
+ put("url", options?.getString("url"))
321
+ put("sessionId", sessionId)
322
+ put("protocol", protocolVersion)
323
+ put("role", role)
324
+ })
325
+ }
326
+
327
+ // Private methods
328
+
329
+ private suspend fun establishConnection(url: String, options: JSObject): JSObject {
330
+ return suspendCoroutine { continuation ->
331
+ connectContinuation = continuation
332
+
333
+ okHttpClient = OkHttpClient.Builder()
334
+ .connectTimeout(30, TimeUnit.SECONDS)
335
+ .readTimeout(0, TimeUnit.SECONDS) // No read timeout for WebSocket
336
+ .writeTimeout(30, TimeUnit.SECONDS)
337
+ .build()
338
+
339
+ val request = Request.Builder()
340
+ .url(url)
341
+ .build()
342
+
343
+ webSocket = okHttpClient?.newWebSocket(request, object : WebSocketListener() {
344
+ override fun onOpen(webSocket: WebSocket, response: Response) {
345
+ Log.d(TAG, "WebSocket connected")
346
+ sendConnectFrame(options)
347
+ }
348
+
349
+ override fun onMessage(webSocket: WebSocket, text: String) {
350
+ handleMessage(text)
351
+ }
352
+
353
+ override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
354
+ Log.e(TAG, "WebSocket failure: ${t.message}")
355
+ handleClose(t)
356
+ }
357
+
358
+ override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
359
+ Log.d(TAG, "WebSocket closed: $code $reason")
360
+ handleClose(null)
361
+ }
362
+ })
363
+
364
+ // Set timeout
365
+ scope.launch {
366
+ delay(30000)
367
+ if (connectContinuation != null) {
368
+ connectContinuation?.resumeWithException(Exception("Connection timeout"))
369
+ connectContinuation = null
370
+ }
371
+ }
372
+ }
373
+ }
374
+
375
+ private fun sendConnectFrame(options: JSObject) {
376
+ val clientName = options.getString("clientName") ?: "eliza-capacitor-android"
377
+ val clientVersion = options.getString("clientVersion") ?: "1.0.0"
378
+ val roleParam = options.getString("role") ?: "operator"
379
+ val scopesParam = options.optJSONArray("scopes")?.let { arr ->
380
+ (0 until arr.length()).map { arr.getString(it) }
381
+ } ?: listOf("operator.admin")
382
+
383
+ val auth = JSONObject().apply {
384
+ options.getString("token")?.let { put("token", it) }
385
+ options.getString("password")?.let { put("password", it) }
386
+ }
387
+
388
+ val params = JSONObject().apply {
389
+ put("minProtocol", 3)
390
+ put("maxProtocol", 3)
391
+ put("client", JSONObject().apply {
392
+ put("id", clientName)
393
+ put("version", clientVersion)
394
+ put("platform", "android")
395
+ put("mode", "ui")
396
+ })
397
+ put("role", roleParam)
398
+ put("scopes", JSONArray(scopesParam))
399
+ put("caps", JSONArray())
400
+ put("auth", auth)
401
+ }
402
+
403
+ val id = UUID.randomUUID().toString()
404
+ val frame = JSONObject().apply {
405
+ put("type", "req")
406
+ put("id", id)
407
+ put("method", "connect")
408
+ put("params", params)
409
+ }
410
+
411
+ webSocket?.send(frame.toString())
412
+ }
413
+
414
+ private suspend fun sendRequest(id: String, frameJson: String): JSObject {
415
+ return suspendCoroutine { continuation ->
416
+ pendingRequests[id] = continuation
417
+
418
+ val sent = webSocket?.send(frameJson) ?: false
419
+ if (!sent) {
420
+ pendingRequests.remove(id)
421
+ continuation.resumeWithException(Exception("Failed to send request"))
422
+ return@suspendCoroutine
423
+ }
424
+
425
+ // Set timeout
426
+ scope.launch {
427
+ delay(60000)
428
+ pendingRequests.remove(id)?.let {
429
+ it.resume(JSObject().apply {
430
+ put("ok", false)
431
+ put("error", JSObject().apply {
432
+ put("code", "TIMEOUT")
433
+ put("message", "Request timed out")
434
+ })
435
+ })
436
+ }
437
+ }
438
+ }
439
+ }
440
+
441
+ private fun handleMessage(text: String) {
442
+ try {
443
+ val json = JSONObject(text)
444
+ val frameType = json.optString("type")
445
+
446
+ // Handle response frames
447
+ if (frameType == "res") {
448
+ val id = json.optString("id")
449
+
450
+ // Check if this is the connect response
451
+ if (connectContinuation != null) {
452
+ val ok = json.optBoolean("ok", false)
453
+ if (ok) {
454
+ val payload = json.optJSONObject("payload")
455
+ if (payload != null) {
456
+ handleHelloOk(payload)
457
+ }
458
+ val result = JSObject().apply {
459
+ put("connected", true)
460
+ put("sessionId", sessionId ?: "")
461
+ put("protocol", protocolVersion ?: 3)
462
+ put("methods", JSONArray(methods))
463
+ put("events", JSONArray(events))
464
+ put("role", role ?: "")
465
+ put("scopes", JSONArray(scopes))
466
+ }
467
+ connectContinuation?.resume(result)
468
+ connectContinuation = null
469
+ } else {
470
+ val errorMsg = json.optJSONObject("error")?.optString("message") ?: "Connection failed"
471
+ connectContinuation?.resumeWithException(Exception(errorMsg))
472
+ connectContinuation = null
473
+ }
474
+ return
475
+ }
476
+
477
+ // Handle pending request
478
+ pendingRequests.remove(id)?.let { continuation ->
479
+ val ok = json.optBoolean("ok", false)
480
+ val result = JSObject().apply {
481
+ put("ok", ok)
482
+ json.opt("payload")?.let { put("payload", it) }
483
+ json.optJSONObject("error")?.let { error ->
484
+ put("error", JSObject().apply {
485
+ put("code", error.optString("code"))
486
+ put("message", error.optString("message"))
487
+ })
488
+ }
489
+ }
490
+ continuation.resume(result)
491
+ }
492
+ return
493
+ }
494
+
495
+ // Handle event frames
496
+ if (frameType == "event") {
497
+ val event = json.optString("event")
498
+ val payload = json.opt("payload")
499
+ val seq = if (json.has("seq")) json.optInt("seq") else null
500
+
501
+ // Check for sequence gap
502
+ if (seq != null && lastSeq != null && seq > lastSeq!! + 1) {
503
+ Log.w(TAG, "Event sequence gap: expected ${lastSeq!! + 1}, got $seq")
504
+ }
505
+ if (seq != null) {
506
+ lastSeq = seq
507
+ }
508
+
509
+ // Emit event
510
+ val eventData = JSObject().apply {
511
+ put("event", event)
512
+ payload?.let { put("payload", it) }
513
+ seq?.let { put("seq", it) }
514
+ }
515
+ notifyListeners("gatewayEvent", eventData)
516
+ }
517
+ } catch (e: Exception) {
518
+ Log.e(TAG, "Error handling message: ${e.message}")
519
+ }
520
+ }
521
+
522
+ private fun handleHelloOk(payload: JSONObject) {
523
+ sessionId = UUID.randomUUID().toString()
524
+ protocolVersion = payload.optInt("protocol", 3)
525
+
526
+ payload.optJSONObject("auth")?.let { auth ->
527
+ role = auth.optString("role")
528
+ scopes = auth.optJSONArray("scopes")?.let { arr ->
529
+ (0 until arr.length()).map { arr.getString(it) }
530
+ } ?: emptyList()
531
+ }
532
+
533
+ payload.optJSONObject("features")?.let { features ->
534
+ methods = features.optJSONArray("methods")?.let { arr ->
535
+ (0 until arr.length()).map { arr.getString(it) }
536
+ } ?: emptyList()
537
+ events = features.optJSONArray("events")?.let { arr ->
538
+ (0 until arr.length()).map { arr.getString(it) }
539
+ } ?: emptyList()
540
+ }
541
+
542
+ backoffMs = 800
543
+ notifyStateChange("connected")
544
+ }
545
+
546
+ private fun handleClose(error: Throwable?) {
547
+ webSocket = null
548
+
549
+ // Reject all pending requests
550
+ pendingRequests.forEach { (_, continuation) ->
551
+ continuation.resumeWithException(Exception("Connection closed"))
552
+ }
553
+ pendingRequests.clear()
554
+
555
+ if (isClosed) {
556
+ notifyStateChange("disconnected", error?.message)
557
+ return
558
+ }
559
+
560
+ // Attempt reconnection
561
+ notifyStateChange("reconnecting", error?.message)
562
+ notifyListeners("error", JSObject().apply {
563
+ put("message", "Connection lost: ${error?.message ?: "unknown"}")
564
+ put("willRetry", true)
565
+ })
566
+
567
+ scheduleReconnect()
568
+ }
569
+
570
+ private fun scheduleReconnect() {
571
+ if (isClosed || reconnectJob?.isActive == true) return
572
+
573
+ val delay = backoffMs
574
+ backoffMs = minOf((backoffMs * 1.7).toLong(), 15000)
575
+
576
+ reconnectJob = scope.launch {
577
+ delay(delay)
578
+ val url = options?.getString("url")
579
+ if (url != null && !isClosed) {
580
+ try {
581
+ establishConnection(url, options ?: JSObject())
582
+ } catch (e: Exception) {
583
+ handleClose(e)
584
+ }
585
+ }
586
+ }
587
+ }
588
+
589
+ private fun closeConnection() {
590
+ webSocket?.close(1000, "Client disconnect")
591
+ webSocket = null
592
+ okHttpClient?.dispatcher?.executorService?.shutdown()
593
+ okHttpClient = null
594
+ }
595
+
596
+ private fun notifyStateChange(state: String, reason: String? = null) {
597
+ val data = JSObject().apply {
598
+ put("state", state)
599
+ reason?.let { put("reason", it) }
600
+ }
601
+ notifyListeners("stateChange", data)
602
+ }
603
+
604
+ override fun handleOnDestroy() {
605
+ super.handleOnDestroy()
606
+ scope.cancel()
607
+ closeConnection()
608
+ }
609
+
610
+ // Helper extension
611
+ private fun JSObject.toJson(): JSONObject {
612
+ return JSONObject(this.toString())
613
+ }
614
+ }