@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.
- package/ElizaosCapacitorGateway.podspec +17 -0
- package/android/build.gradle +49 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/ai/eliza/plugins/gateway/GatewayPlugin.kt +614 -0
- package/dist/esm/definitions.d.ts +271 -0
- package/dist/esm/definitions.js +1 -0
- package/dist/esm/index.d.ts +3 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/web.d.ts +99 -0
- package/dist/esm/web.js +419 -0
- package/dist/plugin.cjs.js +435 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +438 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/GatewayPlugin/GatewayPlugin.swift +631 -0
- package/package.json +103 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Capacitor
|
|
3
|
+
import Network
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Gateway Plugin for Capacitor
|
|
7
|
+
*
|
|
8
|
+
* Provides WebSocket connectivity to an Eliza Gateway server.
|
|
9
|
+
* This implementation handles authentication, reconnection, and RPC-style
|
|
10
|
+
* request/response as well as event streaming. Also supports gateway
|
|
11
|
+
* discovery via Bonjour/mDNS.
|
|
12
|
+
*/
|
|
13
|
+
@objc(GatewayPlugin)
|
|
14
|
+
public class GatewayPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
15
|
+
public let identifier = "GatewayPlugin"
|
|
16
|
+
public let jsName = "Gateway"
|
|
17
|
+
public let pluginMethods: [CAPPluginMethod] = [
|
|
18
|
+
CAPPluginMethod(name: "startDiscovery", returnType: CAPPluginReturnPromise),
|
|
19
|
+
CAPPluginMethod(name: "stopDiscovery", returnType: CAPPluginReturnPromise),
|
|
20
|
+
CAPPluginMethod(name: "getDiscoveredGateways", returnType: CAPPluginReturnPromise),
|
|
21
|
+
CAPPluginMethod(name: "connect", returnType: CAPPluginReturnPromise),
|
|
22
|
+
CAPPluginMethod(name: "disconnect", returnType: CAPPluginReturnPromise),
|
|
23
|
+
CAPPluginMethod(name: "isConnected", returnType: CAPPluginReturnPromise),
|
|
24
|
+
CAPPluginMethod(name: "send", returnType: CAPPluginReturnPromise),
|
|
25
|
+
CAPPluginMethod(name: "getConnectionInfo", returnType: CAPPluginReturnPromise),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
// Discovery
|
|
29
|
+
private var browser: NWBrowser?
|
|
30
|
+
private var discoveredGateways: [String: JSObject] = [:]
|
|
31
|
+
private let serviceType = "_eliza-gw._tcp"
|
|
32
|
+
private var isDiscovering = false
|
|
33
|
+
|
|
34
|
+
private var webSocket: URLSessionWebSocketTask?
|
|
35
|
+
private var urlSession: URLSession?
|
|
36
|
+
private var pendingRequests: [String: (resolve: (JSObject) -> Void, reject: (Error) -> Void)] = [:]
|
|
37
|
+
private var options: JSObject?
|
|
38
|
+
private var sessionId: String?
|
|
39
|
+
private var protocolVersion: Int?
|
|
40
|
+
private var role: String?
|
|
41
|
+
private var scopes: [String] = []
|
|
42
|
+
private var methods: [String] = []
|
|
43
|
+
private var events: [String] = []
|
|
44
|
+
private var lastSeq: Int?
|
|
45
|
+
private var isClosed = false
|
|
46
|
+
private var backoffMs: TimeInterval = 0.8
|
|
47
|
+
private var reconnectTimer: Timer?
|
|
48
|
+
private var connectContinuation: CheckedContinuation<JSObject, Error>?
|
|
49
|
+
|
|
50
|
+
// MARK: - Discovery Methods
|
|
51
|
+
|
|
52
|
+
@objc func startDiscovery(_ call: CAPPluginCall) {
|
|
53
|
+
if isDiscovering {
|
|
54
|
+
call.resolve(buildDiscoveryResult())
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let parameters = NWBrowser.Descriptor.bonjour(type: serviceType, domain: "local.")
|
|
59
|
+
browser = NWBrowser(for: parameters, using: .tcp)
|
|
60
|
+
|
|
61
|
+
browser?.browseResultsChangedHandler = { [weak self] results, changes in
|
|
62
|
+
guard let self = self else { return }
|
|
63
|
+
|
|
64
|
+
for change in changes {
|
|
65
|
+
switch change {
|
|
66
|
+
case .added(let result):
|
|
67
|
+
self.handleServiceFound(result)
|
|
68
|
+
case .removed(let result):
|
|
69
|
+
self.handleServiceLost(result)
|
|
70
|
+
default:
|
|
71
|
+
break
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
browser?.stateUpdateHandler = { [weak self] state in
|
|
77
|
+
switch state {
|
|
78
|
+
case .ready:
|
|
79
|
+
self?.isDiscovering = true
|
|
80
|
+
case .failed(let error):
|
|
81
|
+
print("[Gateway] Browser failed: \(error)")
|
|
82
|
+
self?.isDiscovering = false
|
|
83
|
+
case .cancelled:
|
|
84
|
+
self?.isDiscovering = false
|
|
85
|
+
default:
|
|
86
|
+
break
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
browser?.start(queue: .main)
|
|
91
|
+
|
|
92
|
+
// Return initial result after brief delay
|
|
93
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
94
|
+
call.resolve(self?.buildDiscoveryResult() ?? [:])
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@objc func stopDiscovery(_ call: CAPPluginCall) {
|
|
99
|
+
browser?.cancel()
|
|
100
|
+
browser = nil
|
|
101
|
+
isDiscovering = false
|
|
102
|
+
call.resolve()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@objc func getDiscoveredGateways(_ call: CAPPluginCall) {
|
|
106
|
+
call.resolve(buildDiscoveryResult())
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private func handleServiceFound(_ result: NWBrowser.Result) {
|
|
110
|
+
guard case .service(let name, let type, let domain, _) = result.endpoint else { return }
|
|
111
|
+
|
|
112
|
+
let connection = NWConnection(to: result.endpoint, using: .tcp)
|
|
113
|
+
connection.stateUpdateHandler = { [weak self] state in
|
|
114
|
+
guard let self = self else { return }
|
|
115
|
+
|
|
116
|
+
if case .ready = state {
|
|
117
|
+
if let endpoint = connection.currentPath?.remoteEndpoint,
|
|
118
|
+
case .hostPort(let host, let port) = endpoint {
|
|
119
|
+
|
|
120
|
+
let hostString: String
|
|
121
|
+
switch host {
|
|
122
|
+
case .ipv4(let addr):
|
|
123
|
+
hostString = "\(addr)"
|
|
124
|
+
case .ipv6(let addr):
|
|
125
|
+
hostString = "\(addr)"
|
|
126
|
+
case .name(let hostname, _):
|
|
127
|
+
hostString = hostname
|
|
128
|
+
@unknown default:
|
|
129
|
+
hostString = "unknown"
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let id = self.stableId(name: name, domain: domain)
|
|
133
|
+
let displayName = self.decodeServiceName(name)
|
|
134
|
+
|
|
135
|
+
let gateway: JSObject = [
|
|
136
|
+
"stableId": id,
|
|
137
|
+
"name": displayName,
|
|
138
|
+
"host": hostString,
|
|
139
|
+
"port": Int(port.rawValue),
|
|
140
|
+
"gatewayPort": Int(port.rawValue),
|
|
141
|
+
"tlsEnabled": false,
|
|
142
|
+
"isLocal": true
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
let isNew = self.discoveredGateways[id] == nil
|
|
146
|
+
self.discoveredGateways[id] = gateway
|
|
147
|
+
|
|
148
|
+
self.notifyListeners("discovery", data: [
|
|
149
|
+
"type": isNew ? "found" : "updated",
|
|
150
|
+
"gateway": gateway
|
|
151
|
+
])
|
|
152
|
+
}
|
|
153
|
+
connection.cancel()
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
connection.start(queue: .main)
|
|
157
|
+
|
|
158
|
+
// Timeout for resolution
|
|
159
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
|
|
160
|
+
if connection.state != .ready {
|
|
161
|
+
connection.cancel()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private func handleServiceLost(_ result: NWBrowser.Result) {
|
|
167
|
+
guard case .service(let name, _, let domain, _) = result.endpoint else { return }
|
|
168
|
+
|
|
169
|
+
let id = stableId(name: name, domain: domain)
|
|
170
|
+
if let removed = discoveredGateways.removeValue(forKey: id) {
|
|
171
|
+
notifyListeners("discovery", data: [
|
|
172
|
+
"type": "lost",
|
|
173
|
+
"gateway": removed
|
|
174
|
+
])
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private func stableId(name: String, domain: String) -> String {
|
|
179
|
+
return "\(serviceType)|.\(domain)|.\(name.lowercased().trimmingCharacters(in: .whitespaces))"
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private func decodeServiceName(_ raw: String) -> String {
|
|
183
|
+
// Basic Bonjour escape decoding
|
|
184
|
+
var result = raw
|
|
185
|
+
let pattern = #"\\(\d{3})"#
|
|
186
|
+
if let regex = try? NSRegularExpression(pattern: pattern) {
|
|
187
|
+
let range = NSRange(result.startIndex..., in: result)
|
|
188
|
+
let matches = regex.matches(in: result, range: range).reversed()
|
|
189
|
+
for match in matches {
|
|
190
|
+
if let codeRange = Range(match.range(at: 1), in: result),
|
|
191
|
+
let code = Int(result[codeRange]),
|
|
192
|
+
let scalar = Unicode.Scalar(code) {
|
|
193
|
+
let replacement = String(Character(scalar))
|
|
194
|
+
if let fullRange = Range(match.range, in: result) {
|
|
195
|
+
result.replaceSubrange(fullRange, with: replacement)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return result
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private func buildDiscoveryResult() -> JSObject {
|
|
204
|
+
let sortedGateways = discoveredGateways.values.sorted {
|
|
205
|
+
($0["name"] as? String ?? "").lowercased() < ($1["name"] as? String ?? "").lowercased()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return [
|
|
209
|
+
"gateways": sortedGateways,
|
|
210
|
+
"status": isDiscovering ? "Discovering..." : "Discovery stopped"
|
|
211
|
+
]
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// MARK: - Connection Methods
|
|
215
|
+
|
|
216
|
+
@objc func connect(_ call: CAPPluginCall) {
|
|
217
|
+
guard let urlString = call.getString("url") else {
|
|
218
|
+
call.reject("Missing URL parameter")
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
guard let url = URL(string: urlString) else {
|
|
223
|
+
call.reject("Invalid URL")
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Store options for reconnection
|
|
228
|
+
options = call.jsObjectRepresentation
|
|
229
|
+
|
|
230
|
+
// Close existing connection
|
|
231
|
+
closeConnection()
|
|
232
|
+
isClosed = false
|
|
233
|
+
backoffMs = 0.8
|
|
234
|
+
|
|
235
|
+
Task {
|
|
236
|
+
do {
|
|
237
|
+
let result = try await establishConnection(url: url, options: call.jsObjectRepresentation)
|
|
238
|
+
call.resolve(result)
|
|
239
|
+
} catch {
|
|
240
|
+
call.reject("Connection failed: \(error.localizedDescription)")
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
@objc func disconnect(_ call: CAPPluginCall) {
|
|
246
|
+
isClosed = true
|
|
247
|
+
reconnectTimer?.invalidate()
|
|
248
|
+
reconnectTimer = nil
|
|
249
|
+
closeConnection()
|
|
250
|
+
sessionId = nil
|
|
251
|
+
protocolVersion = nil
|
|
252
|
+
notifyStateChange(state: "disconnected", reason: "Client disconnect")
|
|
253
|
+
call.resolve()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
@objc func isConnected(_ call: CAPPluginCall) {
|
|
257
|
+
let connected = webSocket != nil && webSocket?.state == .running
|
|
258
|
+
call.resolve(["connected": connected])
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
@objc func send(_ call: CAPPluginCall) {
|
|
262
|
+
guard let method = call.getString("method") else {
|
|
263
|
+
call.reject("Missing method parameter")
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
guard let ws = webSocket, ws.state == .running else {
|
|
268
|
+
call.resolve([
|
|
269
|
+
"ok": false,
|
|
270
|
+
"error": [
|
|
271
|
+
"code": "NOT_CONNECTED",
|
|
272
|
+
"message": "Not connected to gateway"
|
|
273
|
+
]
|
|
274
|
+
])
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let id = UUID().uuidString
|
|
279
|
+
let params = call.getObject("params") ?? [:]
|
|
280
|
+
|
|
281
|
+
let frame: [String: Any] = [
|
|
282
|
+
"type": "req",
|
|
283
|
+
"id": id,
|
|
284
|
+
"method": method,
|
|
285
|
+
"params": params
|
|
286
|
+
]
|
|
287
|
+
|
|
288
|
+
Task {
|
|
289
|
+
do {
|
|
290
|
+
let result = try await sendRequest(id: id, frame: frame)
|
|
291
|
+
call.resolve(result)
|
|
292
|
+
} catch {
|
|
293
|
+
call.resolve([
|
|
294
|
+
"ok": false,
|
|
295
|
+
"error": [
|
|
296
|
+
"code": "REQUEST_FAILED",
|
|
297
|
+
"message": error.localizedDescription
|
|
298
|
+
]
|
|
299
|
+
])
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
@objc func getConnectionInfo(_ call: CAPPluginCall) {
|
|
305
|
+
call.resolve([
|
|
306
|
+
"url": options?["url"] as? String ?? NSNull(),
|
|
307
|
+
"sessionId": sessionId ?? NSNull(),
|
|
308
|
+
"protocol": protocolVersion ?? NSNull(),
|
|
309
|
+
"role": role ?? NSNull()
|
|
310
|
+
])
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// MARK: - Private Methods
|
|
314
|
+
|
|
315
|
+
private func establishConnection(url: URL, options: JSObject) async throws -> JSObject {
|
|
316
|
+
// Create URL session with delegate
|
|
317
|
+
let config = URLSessionConfiguration.default
|
|
318
|
+
urlSession = URLSession(configuration: config, delegate: nil, delegateQueue: nil)
|
|
319
|
+
|
|
320
|
+
var request = URLRequest(url: url)
|
|
321
|
+
request.timeoutInterval = 30
|
|
322
|
+
|
|
323
|
+
webSocket = urlSession?.webSocketTask(with: request)
|
|
324
|
+
webSocket?.resume()
|
|
325
|
+
|
|
326
|
+
// Start receiving messages
|
|
327
|
+
startReceiving()
|
|
328
|
+
|
|
329
|
+
// Send connect frame
|
|
330
|
+
return try await sendConnectFrame(options: options)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private func sendConnectFrame(options: JSObject) async throws -> JSObject {
|
|
334
|
+
return try await withCheckedThrowingContinuation { continuation in
|
|
335
|
+
let clientName = options["clientName"] as? String ?? "eliza-capacitor-ios"
|
|
336
|
+
let clientVersion = options["clientVersion"] as? String ?? "1.0.0"
|
|
337
|
+
let roleParam = options["role"] as? String ?? "operator"
|
|
338
|
+
let scopesParam = options["scopes"] as? [String] ?? ["operator.admin"]
|
|
339
|
+
|
|
340
|
+
var auth: [String: Any] = [:]
|
|
341
|
+
if let token = options["token"] as? String {
|
|
342
|
+
auth["token"] = token
|
|
343
|
+
}
|
|
344
|
+
if let password = options["password"] as? String {
|
|
345
|
+
auth["password"] = password
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
let params: [String: Any] = [
|
|
349
|
+
"minProtocol": 3,
|
|
350
|
+
"maxProtocol": 3,
|
|
351
|
+
"client": [
|
|
352
|
+
"id": clientName,
|
|
353
|
+
"version": clientVersion,
|
|
354
|
+
"platform": "ios",
|
|
355
|
+
"mode": "ui"
|
|
356
|
+
],
|
|
357
|
+
"role": roleParam,
|
|
358
|
+
"scopes": scopesParam,
|
|
359
|
+
"caps": [],
|
|
360
|
+
"auth": auth
|
|
361
|
+
]
|
|
362
|
+
|
|
363
|
+
let id = UUID().uuidString
|
|
364
|
+
let frame: [String: Any] = [
|
|
365
|
+
"type": "req",
|
|
366
|
+
"id": id,
|
|
367
|
+
"method": "connect",
|
|
368
|
+
"params": params
|
|
369
|
+
]
|
|
370
|
+
|
|
371
|
+
// Store continuation for response
|
|
372
|
+
self.connectContinuation = continuation
|
|
373
|
+
|
|
374
|
+
do {
|
|
375
|
+
let jsonData = try JSONSerialization.data(withJSONObject: frame)
|
|
376
|
+
guard let jsonString = String(data: jsonData, encoding: .utf8) else {
|
|
377
|
+
continuation.resume(throwing: NSError(domain: "GatewayPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to serialize connect frame"]))
|
|
378
|
+
self.connectContinuation = nil
|
|
379
|
+
return
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
webSocket?.send(.string(jsonString)) { [weak self] error in
|
|
383
|
+
if let error = error {
|
|
384
|
+
self?.connectContinuation?.resume(throwing: error)
|
|
385
|
+
self?.connectContinuation = nil
|
|
386
|
+
}
|
|
387
|
+
// Response will come via receiveMessage
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Set timeout
|
|
391
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 30) { [weak self] in
|
|
392
|
+
if self?.connectContinuation != nil {
|
|
393
|
+
self?.connectContinuation?.resume(throwing: NSError(domain: "GatewayPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Connection timeout"]))
|
|
394
|
+
self?.connectContinuation = nil
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
} catch {
|
|
398
|
+
continuation.resume(throwing: error)
|
|
399
|
+
self.connectContinuation = nil
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private func sendRequest(id: String, frame: [String: Any]) async throws -> JSObject {
|
|
405
|
+
return try await withCheckedThrowingContinuation { continuation in
|
|
406
|
+
pendingRequests[id] = (
|
|
407
|
+
resolve: { result in continuation.resume(returning: result) },
|
|
408
|
+
reject: { error in continuation.resume(throwing: error) }
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
do {
|
|
412
|
+
let jsonData = try JSONSerialization.data(withJSONObject: frame)
|
|
413
|
+
guard let jsonString = String(data: jsonData, encoding: .utf8) else {
|
|
414
|
+
pendingRequests.removeValue(forKey: id)
|
|
415
|
+
continuation.resume(throwing: NSError(domain: "GatewayPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to serialize request"]))
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
webSocket?.send(.string(jsonString)) { [weak self] error in
|
|
420
|
+
if let error = error {
|
|
421
|
+
self?.pendingRequests.removeValue(forKey: id)
|
|
422
|
+
continuation.resume(throwing: error)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Set timeout
|
|
427
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 60) { [weak self] in
|
|
428
|
+
if self?.pendingRequests[id] != nil {
|
|
429
|
+
self?.pendingRequests.removeValue(forKey: id)
|
|
430
|
+
continuation.resume(returning: [
|
|
431
|
+
"ok": false,
|
|
432
|
+
"error": [
|
|
433
|
+
"code": "TIMEOUT",
|
|
434
|
+
"message": "Request timed out"
|
|
435
|
+
]
|
|
436
|
+
])
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
} catch {
|
|
440
|
+
pendingRequests.removeValue(forKey: id)
|
|
441
|
+
continuation.resume(throwing: error)
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private func startReceiving() {
|
|
447
|
+
webSocket?.receive { [weak self] result in
|
|
448
|
+
guard let self = self else { return }
|
|
449
|
+
|
|
450
|
+
switch result {
|
|
451
|
+
case .success(let message):
|
|
452
|
+
switch message {
|
|
453
|
+
case .string(let text):
|
|
454
|
+
self.handleMessage(text)
|
|
455
|
+
case .data(let data):
|
|
456
|
+
if let text = String(data: data, encoding: .utf8) {
|
|
457
|
+
self.handleMessage(text)
|
|
458
|
+
}
|
|
459
|
+
@unknown default:
|
|
460
|
+
break
|
|
461
|
+
}
|
|
462
|
+
// Continue receiving
|
|
463
|
+
if self.webSocket?.state == .running {
|
|
464
|
+
self.startReceiving()
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
case .failure(let error):
|
|
468
|
+
self.handleClose(error: error)
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private func handleMessage(_ text: String) {
|
|
474
|
+
guard let data = text.data(using: .utf8),
|
|
475
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
let frameType = json["type"] as? String
|
|
480
|
+
|
|
481
|
+
// Handle response frames
|
|
482
|
+
if frameType == "res" {
|
|
483
|
+
guard let id = json["id"] as? String else { return }
|
|
484
|
+
|
|
485
|
+
// Check if this is the connect response
|
|
486
|
+
if connectContinuation != nil {
|
|
487
|
+
let ok = json["ok"] as? Bool ?? false
|
|
488
|
+
if ok, let payload = json["payload"] as? [String: Any] {
|
|
489
|
+
handleHelloOk(payload)
|
|
490
|
+
let result: JSObject = [
|
|
491
|
+
"connected": true,
|
|
492
|
+
"sessionId": sessionId ?? "",
|
|
493
|
+
"protocol": protocolVersion ?? 3,
|
|
494
|
+
"methods": methods,
|
|
495
|
+
"events": events,
|
|
496
|
+
"role": role ?? "",
|
|
497
|
+
"scopes": scopes
|
|
498
|
+
]
|
|
499
|
+
connectContinuation?.resume(returning: result)
|
|
500
|
+
connectContinuation = nil
|
|
501
|
+
} else {
|
|
502
|
+
let errorMsg = (json["error"] as? [String: Any])?["message"] as? String ?? "Connection failed"
|
|
503
|
+
connectContinuation?.resume(throwing: NSError(domain: "GatewayPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: errorMsg]))
|
|
504
|
+
connectContinuation = nil
|
|
505
|
+
}
|
|
506
|
+
return
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Handle pending request
|
|
510
|
+
if let pending = pendingRequests[id] {
|
|
511
|
+
pendingRequests.removeValue(forKey: id)
|
|
512
|
+
let ok = json["ok"] as? Bool ?? false
|
|
513
|
+
var result: JSObject = ["ok": ok]
|
|
514
|
+
if let payload = json["payload"] {
|
|
515
|
+
result["payload"] = payload as? JSValue
|
|
516
|
+
}
|
|
517
|
+
if let error = json["error"] as? JSObject {
|
|
518
|
+
result["error"] = error
|
|
519
|
+
}
|
|
520
|
+
pending.resolve(result)
|
|
521
|
+
}
|
|
522
|
+
return
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Handle event frames
|
|
526
|
+
if frameType == "event" {
|
|
527
|
+
guard let event = json["event"] as? String else { return }
|
|
528
|
+
let payload = json["payload"]
|
|
529
|
+
let seq = json["seq"] as? Int
|
|
530
|
+
|
|
531
|
+
// Check for sequence gap
|
|
532
|
+
if let seq = seq, let lastSeq = lastSeq, seq > lastSeq + 1 {
|
|
533
|
+
print("[Gateway] Event sequence gap: expected \(lastSeq + 1), got \(seq)")
|
|
534
|
+
}
|
|
535
|
+
if let seq = seq {
|
|
536
|
+
lastSeq = seq
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Emit event
|
|
540
|
+
var eventData: JSObject = ["event": event]
|
|
541
|
+
if let payload = payload {
|
|
542
|
+
eventData["payload"] = payload as? JSValue
|
|
543
|
+
}
|
|
544
|
+
if let seq = seq {
|
|
545
|
+
eventData["seq"] = seq
|
|
546
|
+
}
|
|
547
|
+
notifyListeners("gatewayEvent", data: eventData)
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private func handleHelloOk(_ payload: [String: Any]) {
|
|
552
|
+
sessionId = UUID().uuidString
|
|
553
|
+
protocolVersion = payload["protocol"] as? Int ?? 3
|
|
554
|
+
|
|
555
|
+
if let auth = payload["auth"] as? [String: Any] {
|
|
556
|
+
role = auth["role"] as? String
|
|
557
|
+
scopes = auth["scopes"] as? [String] ?? []
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if let features = payload["features"] as? [String: Any] {
|
|
561
|
+
methods = features["methods"] as? [String] ?? []
|
|
562
|
+
events = features["events"] as? [String] ?? []
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
backoffMs = 0.8
|
|
566
|
+
notifyStateChange(state: "connected")
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private func handleClose(error: Error?) {
|
|
570
|
+
webSocket = nil
|
|
571
|
+
|
|
572
|
+
// Reject all pending requests
|
|
573
|
+
for (_, pending) in pendingRequests {
|
|
574
|
+
pending.reject(NSError(domain: "GatewayPlugin", code: -1, userInfo: [NSLocalizedDescriptionKey: "Connection closed"]))
|
|
575
|
+
}
|
|
576
|
+
pendingRequests.removeAll()
|
|
577
|
+
|
|
578
|
+
if isClosed {
|
|
579
|
+
notifyStateChange(state: "disconnected", reason: error?.localizedDescription)
|
|
580
|
+
return
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Attempt reconnection
|
|
584
|
+
notifyStateChange(state: "reconnecting", reason: error?.localizedDescription)
|
|
585
|
+
notifyListeners("error", data: [
|
|
586
|
+
"message": "Connection lost: \(error?.localizedDescription ?? "unknown")",
|
|
587
|
+
"willRetry": true
|
|
588
|
+
])
|
|
589
|
+
|
|
590
|
+
scheduleReconnect()
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
private func scheduleReconnect() {
|
|
594
|
+
guard !isClosed, reconnectTimer == nil else { return }
|
|
595
|
+
|
|
596
|
+
let delay = backoffMs
|
|
597
|
+
backoffMs = min(backoffMs * 1.7, 15.0)
|
|
598
|
+
|
|
599
|
+
reconnectTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in
|
|
600
|
+
self?.reconnectTimer = nil
|
|
601
|
+
guard let self = self,
|
|
602
|
+
let urlString = self.options?["url"] as? String,
|
|
603
|
+
let url = URL(string: urlString) else {
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
Task {
|
|
608
|
+
do {
|
|
609
|
+
_ = try await self.establishConnection(url: url, options: self.options ?? [:])
|
|
610
|
+
} catch {
|
|
611
|
+
self.handleClose(error: error)
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private func closeConnection() {
|
|
618
|
+
webSocket?.cancel(with: .goingAway, reason: nil)
|
|
619
|
+
webSocket = nil
|
|
620
|
+
urlSession?.invalidateAndCancel()
|
|
621
|
+
urlSession = nil
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private func notifyStateChange(state: String, reason: String? = nil) {
|
|
625
|
+
var data: JSObject = ["state": state]
|
|
626
|
+
if let reason = reason {
|
|
627
|
+
data["reason"] = reason
|
|
628
|
+
}
|
|
629
|
+
notifyListeners("stateChange", data: data)
|
|
630
|
+
}
|
|
631
|
+
}
|