agent-relay 3.2.13 → 3.2.15
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/README.md +1 -0
- package/dist/index.cjs +27 -9
- package/package.json +12 -11
- package/packages/acp-bridge/package.json +2 -2
- package/packages/config/package.json +1 -1
- package/packages/hooks/package.json +4 -4
- package/packages/memory/package.json +2 -2
- package/packages/openclaw/package.json +2 -2
- package/packages/policy/package.json +2 -2
- package/packages/sdk/dist/cli-registry.d.ts.map +1 -1
- package/packages/sdk/dist/cli-registry.js +6 -1
- package/packages/sdk/dist/cli-registry.js.map +1 -1
- package/packages/sdk/dist/workflows/runner.js +1 -1
- package/packages/sdk/dist/workflows/runner.js.map +1 -1
- package/packages/sdk/package.json +2 -2
- package/packages/sdk/src/__tests__/workflow-runner.test.ts +2 -2
- package/packages/sdk/src/cli-registry.ts +6 -1
- package/packages/sdk/src/workflows/runner.ts +1 -1
- package/packages/sdk-py/pyproject.toml +1 -1
- package/packages/sdk-swift/Package.swift +26 -0
- package/packages/sdk-swift/README.md +39 -0
- package/packages/sdk-swift/Sources/AgentRelaySDK/RelayCast.swift +405 -0
- package/packages/sdk-swift/Sources/AgentRelaySDK/RelayObserver.swift +323 -0
- package/packages/sdk-swift/Sources/AgentRelaySDK/RelayObserverTypes.swift +143 -0
- package/packages/sdk-swift/Sources/AgentRelaySDK/RelayTransport.swift +220 -0
- package/packages/sdk-swift/Sources/AgentRelaySDK/RelayTypes.swift +435 -0
- package/packages/sdk-swift/Tests/AgentRelaySDKTests/AgentRelaySDKTests.swift +15 -0
- package/packages/sdk-swift/Tests/AgentRelaySDKTests/RelayObserverTests.swift +526 -0
- package/packages/telemetry/package.json +1 -1
- package/packages/trajectory/package.json +2 -2
- package/packages/user-directory/package.json +2 -2
- package/packages/utils/package.json +2 -2
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// MARK: - Delegate
|
|
4
|
+
|
|
5
|
+
@MainActor
|
|
6
|
+
public protocol RelayObserverDelegate: AnyObject {
|
|
7
|
+
func relayObserver(_ observer: RelayObserver, didReceiveEvent event: RelayObserverEvent)
|
|
8
|
+
func relayObserverDidConnect(_ observer: RelayObserver)
|
|
9
|
+
func relayObserverDidDisconnect(_ observer: RelayObserver, error: Error?)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// MARK: - RelayObserver
|
|
13
|
+
|
|
14
|
+
public final class RelayObserver: NSObject, URLSessionWebSocketDelegate, @unchecked Sendable {
|
|
15
|
+
|
|
16
|
+
// MARK: - ConnectionState
|
|
17
|
+
|
|
18
|
+
public enum ConnectionState: Equatable, Sendable {
|
|
19
|
+
case disconnected
|
|
20
|
+
case connecting
|
|
21
|
+
case connected
|
|
22
|
+
case reconnecting(attempt: Int)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// MARK: - Public Properties (thread-safe via queue)
|
|
26
|
+
|
|
27
|
+
public var connectionState: ConnectionState {
|
|
28
|
+
queue.sync { _connectionState }
|
|
29
|
+
}
|
|
30
|
+
public var lastEvent: RelayObserverEvent? {
|
|
31
|
+
queue.sync { _lastEvent }
|
|
32
|
+
}
|
|
33
|
+
public var eventCounter: Int {
|
|
34
|
+
queue.sync { _eventCounter }
|
|
35
|
+
}
|
|
36
|
+
public weak var delegate: RelayObserverDelegate?
|
|
37
|
+
|
|
38
|
+
// MARK: - AsyncStream
|
|
39
|
+
|
|
40
|
+
public var events: AsyncStream<RelayObserverEvent> {
|
|
41
|
+
queue.sync {
|
|
42
|
+
_eventsContinuation?.finish()
|
|
43
|
+
return AsyncStream { continuation in
|
|
44
|
+
self._eventsContinuation = continuation
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// MARK: - Private Properties
|
|
50
|
+
|
|
51
|
+
private let queue = DispatchQueue(label: "com.agentrelay.observer", qos: .userInitiated)
|
|
52
|
+
private var _connectionState: ConnectionState = .disconnected
|
|
53
|
+
private var _lastEvent: RelayObserverEvent?
|
|
54
|
+
private var _eventCounter: Int = 0
|
|
55
|
+
private var webSocketTask: URLSessionWebSocketTask?
|
|
56
|
+
private var urlSession: URLSession?
|
|
57
|
+
private let maxReconnectAttempts: Int
|
|
58
|
+
private let baseReconnectDelay: TimeInterval
|
|
59
|
+
private var reconnectAttempts: Int = 0
|
|
60
|
+
private var reconnectTask: Task<Void, Never>?
|
|
61
|
+
private var subscribedChannel: String?
|
|
62
|
+
private var pendingOutbound: [String] = []
|
|
63
|
+
private var isConnectionReady: Bool = false
|
|
64
|
+
private var activeURL: URL?
|
|
65
|
+
private var _eventsContinuation: AsyncStream<RelayObserverEvent>.Continuation?
|
|
66
|
+
|
|
67
|
+
private let jsonEncoder: JSONEncoder = {
|
|
68
|
+
let encoder = JSONEncoder()
|
|
69
|
+
encoder.keyEncodingStrategy = .convertToSnakeCase
|
|
70
|
+
return encoder
|
|
71
|
+
}()
|
|
72
|
+
|
|
73
|
+
private let jsonDecoder: JSONDecoder = {
|
|
74
|
+
// NOT using convertFromSnakeCase — we use explicit CodingKeys
|
|
75
|
+
// because of dual-name fields (name/agent_name, step/step_name)
|
|
76
|
+
let decoder = JSONDecoder()
|
|
77
|
+
return decoder
|
|
78
|
+
}()
|
|
79
|
+
|
|
80
|
+
// MARK: - Init
|
|
81
|
+
|
|
82
|
+
public init(maxReconnectAttempts: Int = 8, baseReconnectDelay: TimeInterval = 1.0) {
|
|
83
|
+
self.maxReconnectAttempts = maxReconnectAttempts
|
|
84
|
+
self.baseReconnectDelay = baseReconnectDelay
|
|
85
|
+
super.init()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// MARK: - Public Methods
|
|
89
|
+
|
|
90
|
+
/// Connect to a WebSocket proxy URL and subscribe to a channel on open.
|
|
91
|
+
public func connect(url: URL, channel: String) {
|
|
92
|
+
queue.sync {
|
|
93
|
+
self.subscribedChannel = channel
|
|
94
|
+
self._openSocket(url: url)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// Connect to a WebSocket proxy URL without channel subscription.
|
|
99
|
+
public func connect(url: URL) {
|
|
100
|
+
queue.sync {
|
|
101
|
+
self.subscribedChannel = nil
|
|
102
|
+
self._openSocket(url: url)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/// Disconnect — closes socket, cancels reconnect, clears state.
|
|
107
|
+
public func disconnect() {
|
|
108
|
+
queue.sync {
|
|
109
|
+
reconnectTask?.cancel()
|
|
110
|
+
reconnectTask = nil
|
|
111
|
+
reconnectAttempts = 0
|
|
112
|
+
isConnectionReady = false
|
|
113
|
+
subscribedChannel = nil
|
|
114
|
+
pendingOutbound.removeAll()
|
|
115
|
+
activeURL = nil
|
|
116
|
+
_closeSocket(code: .goingAway, reason: nil)
|
|
117
|
+
_connectionState = .disconnected
|
|
118
|
+
_eventsContinuation?.finish()
|
|
119
|
+
_eventsContinuation = nil
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/// Send a message to a channel through the proxy.
|
|
124
|
+
public func sendChannel(
|
|
125
|
+
channel: String,
|
|
126
|
+
text: String,
|
|
127
|
+
personas: [String]? = nil,
|
|
128
|
+
cliPreferences: [String: String]? = nil
|
|
129
|
+
) throws {
|
|
130
|
+
let msg = ObserverChannelSendMessage(
|
|
131
|
+
channel: channel,
|
|
132
|
+
text: text,
|
|
133
|
+
personas: personas,
|
|
134
|
+
cliPreferences: cliPreferences
|
|
135
|
+
)
|
|
136
|
+
try queue.sync {
|
|
137
|
+
try _sendEncodable(msg)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// Send a direct message to a specific agent through the proxy.
|
|
142
|
+
public func sendDirect(to: String, text: String) throws {
|
|
143
|
+
let msg = ObserverDirectSendMessage(to: to, text: text)
|
|
144
|
+
try queue.sync {
|
|
145
|
+
try _sendEncodable(msg)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// MARK: - Private Methods (must be called on queue)
|
|
150
|
+
|
|
151
|
+
private func _openSocket(url: URL) {
|
|
152
|
+
_closeSocket(code: .goingAway, reason: nil)
|
|
153
|
+
activeURL = url
|
|
154
|
+
_connectionState = .connecting
|
|
155
|
+
isConnectionReady = false
|
|
156
|
+
|
|
157
|
+
let delegateQueue = OperationQueue()
|
|
158
|
+
delegateQueue.underlyingQueue = queue
|
|
159
|
+
delegateQueue.maxConcurrentOperationCount = 1
|
|
160
|
+
|
|
161
|
+
let session = URLSession(
|
|
162
|
+
configuration: .default,
|
|
163
|
+
delegate: self,
|
|
164
|
+
delegateQueue: delegateQueue
|
|
165
|
+
)
|
|
166
|
+
self.urlSession = session
|
|
167
|
+
let task = session.webSocketTask(with: url)
|
|
168
|
+
self.webSocketTask = task
|
|
169
|
+
task.resume()
|
|
170
|
+
_scheduleReceive()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private func _closeSocket(code: URLSessionWebSocketTask.CloseCode, reason: Data?) {
|
|
174
|
+
webSocketTask?.cancel(with: code, reason: reason)
|
|
175
|
+
webSocketTask = nil
|
|
176
|
+
urlSession?.invalidateAndCancel()
|
|
177
|
+
urlSession = nil
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private func _scheduleReceive() {
|
|
181
|
+
webSocketTask?.receive { [weak self] result in
|
|
182
|
+
guard let self else { return }
|
|
183
|
+
// Callback arrives on our queue via delegateQueue
|
|
184
|
+
switch result {
|
|
185
|
+
case .success(let message):
|
|
186
|
+
self._handleMessage(message)
|
|
187
|
+
self._scheduleReceive()
|
|
188
|
+
case .failure(let error):
|
|
189
|
+
self._handleSocketError(error)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private func _handleMessage(_ message: URLSessionWebSocketTask.Message) {
|
|
195
|
+
let data: Data
|
|
196
|
+
switch message {
|
|
197
|
+
case .string(let text):
|
|
198
|
+
guard let d = text.data(using: .utf8) else { return }
|
|
199
|
+
data = d
|
|
200
|
+
case .data(let d):
|
|
201
|
+
data = d
|
|
202
|
+
@unknown default:
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
guard let event = try? jsonDecoder.decode(RelayObserverEvent.self, from: data) else { return }
|
|
207
|
+
|
|
208
|
+
_lastEvent = event
|
|
209
|
+
_eventCounter += 1
|
|
210
|
+
|
|
211
|
+
if let delegate {
|
|
212
|
+
Task { @MainActor in
|
|
213
|
+
delegate.relayObserver(self, didReceiveEvent: event)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
_eventsContinuation?.yield(event)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private func _handleSocketError(_ error: Error) {
|
|
221
|
+
guard reconnectAttempts < maxReconnectAttempts else {
|
|
222
|
+
_connectionState = .disconnected
|
|
223
|
+
let delegate = self.delegate
|
|
224
|
+
Task { @MainActor in
|
|
225
|
+
delegate?.relayObserverDidDisconnect(self, error: error)
|
|
226
|
+
}
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
reconnectAttempts += 1
|
|
231
|
+
_connectionState = .reconnecting(attempt: reconnectAttempts)
|
|
232
|
+
|
|
233
|
+
let delay = baseReconnectDelay * pow(2.0, Double(reconnectAttempts - 1))
|
|
234
|
+
|
|
235
|
+
reconnectTask?.cancel()
|
|
236
|
+
reconnectTask = Task { [weak self] in
|
|
237
|
+
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
|
238
|
+
guard !Task.isCancelled, let self else { return }
|
|
239
|
+
self.queue.sync {
|
|
240
|
+
guard let url = self.activeURL else { return }
|
|
241
|
+
self._openSocket(url: url)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private func _sendSubscription() {
|
|
247
|
+
guard let channel = subscribedChannel else { return }
|
|
248
|
+
let msg = ObserverSubscribeMessage(channel: channel)
|
|
249
|
+
if let data = try? jsonEncoder.encode(msg),
|
|
250
|
+
let str = String(data: data, encoding: .utf8) {
|
|
251
|
+
webSocketTask?.send(.string(str)) { _ in }
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private func _sendEncodable<T: Encodable>(_ value: T) throws {
|
|
256
|
+
guard let task = webSocketTask else {
|
|
257
|
+
throw RelayObserverError.notConnected
|
|
258
|
+
}
|
|
259
|
+
guard let data = try? jsonEncoder.encode(value),
|
|
260
|
+
let str = String(data: data, encoding: .utf8) else {
|
|
261
|
+
throw RelayObserverError.encodingFailed
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if isConnectionReady {
|
|
265
|
+
task.send(.string(str)) { _ in }
|
|
266
|
+
} else {
|
|
267
|
+
pendingOutbound.append(str)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private func _flushPendingOutbound() {
|
|
272
|
+
guard let task = webSocketTask else { return }
|
|
273
|
+
for str in pendingOutbound {
|
|
274
|
+
task.send(.string(str)) { _ in }
|
|
275
|
+
}
|
|
276
|
+
pendingOutbound.removeAll()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// MARK: - URLSessionWebSocketDelegate (callbacks arrive on queue via delegateQueue)
|
|
280
|
+
|
|
281
|
+
public func urlSession(
|
|
282
|
+
_ session: URLSession,
|
|
283
|
+
webSocketTask: URLSessionWebSocketTask,
|
|
284
|
+
didOpenWithProtocol protocol: String?
|
|
285
|
+
) {
|
|
286
|
+
_connectionState = .connected
|
|
287
|
+
reconnectAttempts = 0
|
|
288
|
+
isConnectionReady = true
|
|
289
|
+
|
|
290
|
+
_flushPendingOutbound()
|
|
291
|
+
_sendSubscription()
|
|
292
|
+
|
|
293
|
+
let delegate = self.delegate
|
|
294
|
+
Task { @MainActor in
|
|
295
|
+
delegate?.relayObserverDidConnect(self)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
public func urlSession(
|
|
300
|
+
_ session: URLSession,
|
|
301
|
+
webSocketTask: URLSessionWebSocketTask,
|
|
302
|
+
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
|
|
303
|
+
reason: Data?
|
|
304
|
+
) {
|
|
305
|
+
isConnectionReady = false
|
|
306
|
+
|
|
307
|
+
if closeCode != .goingAway && closeCode != .normalClosure {
|
|
308
|
+
_handleSocketError(
|
|
309
|
+
NSError(
|
|
310
|
+
domain: "RelayObserver",
|
|
311
|
+
code: Int(closeCode.rawValue),
|
|
312
|
+
userInfo: [NSLocalizedDescriptionKey: "WebSocket closed with code \(closeCode.rawValue)"]
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
} else {
|
|
316
|
+
_connectionState = .disconnected
|
|
317
|
+
let delegate = self.delegate
|
|
318
|
+
Task { @MainActor in
|
|
319
|
+
delegate?.relayObserverDidDisconnect(self, error: nil)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// MARK: - RelayObserverEventType
|
|
4
|
+
|
|
5
|
+
public enum RelayObserverEventType: String, Codable, Sendable {
|
|
6
|
+
case agentSpawned = "agent_spawned"
|
|
7
|
+
case agentReleased = "agent_released"
|
|
8
|
+
case agentIdle = "agent_idle"
|
|
9
|
+
case agentStatus = "agent_status"
|
|
10
|
+
case workerStream = "worker_stream"
|
|
11
|
+
case delivery = "delivery"
|
|
12
|
+
case channelMessage = "channel_message"
|
|
13
|
+
case stepStarted = "step_started"
|
|
14
|
+
case stepCompleted = "step_completed"
|
|
15
|
+
case runCompleted = "run_completed"
|
|
16
|
+
case relayConfig = "relay_config"
|
|
17
|
+
case relayWorkspace = "relay_workspace"
|
|
18
|
+
case commentPollTick = "comment_poll_tick"
|
|
19
|
+
case commentDetected = "comment_detected"
|
|
20
|
+
case error = "error"
|
|
21
|
+
case ack = "ack"
|
|
22
|
+
case connected = "connected"
|
|
23
|
+
case subscribed = "subscribed"
|
|
24
|
+
case pong = "pong"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// MARK: - RelayObserverEvent
|
|
28
|
+
|
|
29
|
+
public struct RelayObserverEvent: Decodable, Sendable {
|
|
30
|
+
public let type: RelayObserverEventType
|
|
31
|
+
|
|
32
|
+
// agent_spawned fields
|
|
33
|
+
public let name: String?
|
|
34
|
+
public let agentName: String?
|
|
35
|
+
public let cli: String?
|
|
36
|
+
public let channels: [String]?
|
|
37
|
+
|
|
38
|
+
// agent_released fields
|
|
39
|
+
public let reason: String?
|
|
40
|
+
|
|
41
|
+
// agent_idle fields
|
|
42
|
+
public let idleSecs: Int?
|
|
43
|
+
|
|
44
|
+
// agent_status fields
|
|
45
|
+
public let status: String?
|
|
46
|
+
|
|
47
|
+
// worker_stream fields
|
|
48
|
+
public let agent: String?
|
|
49
|
+
public let data: String?
|
|
50
|
+
public let stream: String?
|
|
51
|
+
|
|
52
|
+
// delivery fields
|
|
53
|
+
public let id: String?
|
|
54
|
+
public let from: String?
|
|
55
|
+
public let to: String?
|
|
56
|
+
public let text: String?
|
|
57
|
+
public let state: String?
|
|
58
|
+
|
|
59
|
+
// channel_message fields
|
|
60
|
+
public let channel: String?
|
|
61
|
+
public let timestamp: String?
|
|
62
|
+
|
|
63
|
+
// step_started / step_completed fields
|
|
64
|
+
public let step: String?
|
|
65
|
+
public let stepName: String?
|
|
66
|
+
public let output: String?
|
|
67
|
+
|
|
68
|
+
// run_completed fields
|
|
69
|
+
public let runId: String?
|
|
70
|
+
|
|
71
|
+
// relay_config fields
|
|
72
|
+
public let observerUrl: String?
|
|
73
|
+
|
|
74
|
+
// relay_workspace fields
|
|
75
|
+
public let workspaceId: String?
|
|
76
|
+
|
|
77
|
+
// comment_poll_tick fields
|
|
78
|
+
public let checkedAt: String?
|
|
79
|
+
public let intervalSeconds: Int?
|
|
80
|
+
|
|
81
|
+
// comment_detected / error fields
|
|
82
|
+
public let message: String?
|
|
83
|
+
|
|
84
|
+
enum CodingKeys: String, CodingKey {
|
|
85
|
+
case type
|
|
86
|
+
case name
|
|
87
|
+
case agentName = "agent_name"
|
|
88
|
+
case cli, channels, reason
|
|
89
|
+
case idleSecs = "idle_secs"
|
|
90
|
+
case status, agent, data, stream
|
|
91
|
+
case id, from, to, text, state
|
|
92
|
+
case channel, timestamp
|
|
93
|
+
case step
|
|
94
|
+
case stepName = "step_name"
|
|
95
|
+
case output
|
|
96
|
+
case runId = "run_id"
|
|
97
|
+
case observerUrl = "observer_url"
|
|
98
|
+
case workspaceId = "workspace_id"
|
|
99
|
+
case checkedAt = "checked_at"
|
|
100
|
+
case intervalSeconds = "interval_seconds"
|
|
101
|
+
case message
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// MARK: - RelayObserverError
|
|
106
|
+
|
|
107
|
+
public enum RelayObserverError: LocalizedError, Sendable {
|
|
108
|
+
case notConnected
|
|
109
|
+
case encodingFailed
|
|
110
|
+
|
|
111
|
+
public var errorDescription: String? {
|
|
112
|
+
switch self {
|
|
113
|
+
case .notConnected: return "Relay not connected"
|
|
114
|
+
case .encodingFailed: return "Message encoding failed"
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// MARK: - Outbound Message Structs (internal)
|
|
120
|
+
|
|
121
|
+
struct ObserverSubscribeMessage: Encodable {
|
|
122
|
+
let type: String = "subscribe"
|
|
123
|
+
let channel: String
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
struct ObserverChannelSendMessage: Encodable {
|
|
127
|
+
let type: String = "channel_send"
|
|
128
|
+
let channel: String
|
|
129
|
+
let text: String
|
|
130
|
+
let personas: [String]?
|
|
131
|
+
let cliPreferences: [String: String]?
|
|
132
|
+
|
|
133
|
+
enum CodingKeys: String, CodingKey {
|
|
134
|
+
case type, channel, text, personas
|
|
135
|
+
case cliPreferences = "cli_preferences"
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
struct ObserverDirectSendMessage: Encodable {
|
|
140
|
+
let type: String = "send"
|
|
141
|
+
let to: String
|
|
142
|
+
let text: String
|
|
143
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public actor RelayTransport {
|
|
4
|
+
public enum ConnectionState: Sendable {
|
|
5
|
+
case disconnected
|
|
6
|
+
case connecting
|
|
7
|
+
case connected
|
|
8
|
+
case reconnecting
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
public enum TransportError: Error, Sendable {
|
|
12
|
+
case invalidResponse
|
|
13
|
+
case notConnected
|
|
14
|
+
case sendFailed(String)
|
|
15
|
+
case connectionFailed(String)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public nonisolated let inbound: AsyncStream<Data>
|
|
19
|
+
|
|
20
|
+
private let baseURL: URL
|
|
21
|
+
private let session: URLSession
|
|
22
|
+
private let authToken: String
|
|
23
|
+
private var webSocketTask: URLSessionWebSocketTask?
|
|
24
|
+
private var receiveTask: Task<Void, Never>?
|
|
25
|
+
private var pingTask: Task<Void, Never>?
|
|
26
|
+
private var reconnectTask: Task<Void, Never>?
|
|
27
|
+
private var inboundContinuation: AsyncStream<Data>.Continuation?
|
|
28
|
+
private var state: ConnectionState = .disconnected
|
|
29
|
+
private var manuallyDisconnected = false
|
|
30
|
+
private var reconnectAttempt = 0
|
|
31
|
+
private var lastPongAt = Date()
|
|
32
|
+
private var onConnect: (@Sendable () async -> Void)?
|
|
33
|
+
|
|
34
|
+
public init(baseURL: URL, authToken: String, session: URLSession = .shared) {
|
|
35
|
+
self.baseURL = baseURL
|
|
36
|
+
self.authToken = authToken
|
|
37
|
+
self.session = session
|
|
38
|
+
var continuationRef: AsyncStream<Data>.Continuation?
|
|
39
|
+
self.inbound = AsyncStream<Data> { continuation in
|
|
40
|
+
continuationRef = continuation
|
|
41
|
+
}
|
|
42
|
+
self.inboundContinuation = continuationRef
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public func setOnConnect(_ handler: @escaping @Sendable () async -> Void) {
|
|
46
|
+
self.onConnect = handler
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public func connect() async throws {
|
|
50
|
+
switch state {
|
|
51
|
+
case .connected, .connecting:
|
|
52
|
+
return
|
|
53
|
+
case .disconnected, .reconnecting:
|
|
54
|
+
break
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
manuallyDisconnected = false
|
|
58
|
+
state = reconnectAttempt == 0 ? .connecting : .reconnecting
|
|
59
|
+
|
|
60
|
+
let isReconnect = reconnectAttempt > 0
|
|
61
|
+
let request = websocketRequest()
|
|
62
|
+
let task = session.webSocketTask(with: request)
|
|
63
|
+
webSocketTask = task
|
|
64
|
+
task.resume()
|
|
65
|
+
state = .connected
|
|
66
|
+
reconnectAttempt = 0
|
|
67
|
+
lastPongAt = Date()
|
|
68
|
+
|
|
69
|
+
startReceiveLoop()
|
|
70
|
+
startPingLoop()
|
|
71
|
+
if isReconnect, let onConnect {
|
|
72
|
+
await onConnect()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public func disconnect() {
|
|
77
|
+
manuallyDisconnected = true
|
|
78
|
+
receiveTask?.cancel()
|
|
79
|
+
pingTask?.cancel()
|
|
80
|
+
reconnectTask?.cancel()
|
|
81
|
+
receiveTask = nil
|
|
82
|
+
pingTask = nil
|
|
83
|
+
reconnectTask = nil
|
|
84
|
+
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
|
85
|
+
webSocketTask = nil
|
|
86
|
+
state = .disconnected
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
public func send(_ message: Data) async throws {
|
|
90
|
+
guard let task = webSocketTask, state == .connected else {
|
|
91
|
+
throw TransportError.notConnected
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
do {
|
|
95
|
+
try await task.send(.data(message))
|
|
96
|
+
} catch {
|
|
97
|
+
throw TransportError.sendFailed(String(describing: error))
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private func websocketRequest() -> URLRequest {
|
|
102
|
+
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
|
|
103
|
+
if components?.scheme == "http" { components?.scheme = "ws" }
|
|
104
|
+
if components?.scheme == "https" { components?.scheme = "wss" }
|
|
105
|
+
if components?.path.isEmpty ?? true { components?.path = "/v1/ws" }
|
|
106
|
+
if !(components?.path.hasSuffix("/v1/ws") ?? false) && !(components?.path.hasSuffix("/ws") ?? false) {
|
|
107
|
+
components?.path = "/v1/ws"
|
|
108
|
+
}
|
|
109
|
+
let existingItems = components?.queryItems ?? []
|
|
110
|
+
components?.queryItems = existingItems + [URLQueryItem(name: "token", value: authToken)]
|
|
111
|
+
|
|
112
|
+
var request = URLRequest(url: components?.url ?? baseURL)
|
|
113
|
+
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
|
|
114
|
+
request.setValue(authToken, forHTTPHeaderField: "X-API-Key")
|
|
115
|
+
return request
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private func startReceiveLoop() {
|
|
119
|
+
receiveTask?.cancel()
|
|
120
|
+
receiveTask = Task { [weak self] in
|
|
121
|
+
guard let self else { return }
|
|
122
|
+
while !Task.isCancelled {
|
|
123
|
+
do {
|
|
124
|
+
guard let task = await self.webSocketTask else { return }
|
|
125
|
+
let message = try await task.receive()
|
|
126
|
+
await self.handle(message)
|
|
127
|
+
} catch {
|
|
128
|
+
await self.handleDisconnect(error: error)
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private func startPingLoop() {
|
|
136
|
+
pingTask?.cancel()
|
|
137
|
+
pingTask = Task { [weak self] in
|
|
138
|
+
guard let self else { return }
|
|
139
|
+
while !Task.isCancelled {
|
|
140
|
+
try? await Task.sleep(for: .seconds(20))
|
|
141
|
+
if Task.isCancelled { return }
|
|
142
|
+
do {
|
|
143
|
+
try await self.sendPing()
|
|
144
|
+
} catch {
|
|
145
|
+
await self.handleDisconnect(error: error)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private func sendPing() async throws {
|
|
153
|
+
guard let task = webSocketTask else { throw TransportError.notConnected }
|
|
154
|
+
let before = Date()
|
|
155
|
+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
|
156
|
+
task.sendPing { error in
|
|
157
|
+
if let error {
|
|
158
|
+
continuation.resume(throwing: error)
|
|
159
|
+
} else {
|
|
160
|
+
continuation.resume(returning: ())
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
lastPongAt = Date()
|
|
165
|
+
if lastPongAt.timeIntervalSince(before) > 10 {
|
|
166
|
+
throw TransportError.connectionFailed("Pong exceeded watchdog")
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private func handle(_ message: URLSessionWebSocketTask.Message) {
|
|
171
|
+
switch message {
|
|
172
|
+
case .data(let data):
|
|
173
|
+
inboundContinuation?.yield(data)
|
|
174
|
+
case .string(let string):
|
|
175
|
+
if let data = string.data(using: .utf8) {
|
|
176
|
+
inboundContinuation?.yield(data)
|
|
177
|
+
}
|
|
178
|
+
@unknown default:
|
|
179
|
+
break
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private func handleDisconnect(error: Error) async {
|
|
184
|
+
receiveTask?.cancel()
|
|
185
|
+
pingTask?.cancel()
|
|
186
|
+
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
|
187
|
+
webSocketTask = nil
|
|
188
|
+
|
|
189
|
+
guard !manuallyDisconnected else {
|
|
190
|
+
state = .disconnected
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
state = .reconnecting
|
|
195
|
+
let delay = reconnectDelay(for: reconnectAttempt)
|
|
196
|
+
reconnectAttempt += 1
|
|
197
|
+
reconnectTask?.cancel()
|
|
198
|
+
reconnectTask = Task { [weak self] in
|
|
199
|
+
guard let self else { return }
|
|
200
|
+
try? await Task.sleep(for: .milliseconds(delay))
|
|
201
|
+
do {
|
|
202
|
+
try await self.connect()
|
|
203
|
+
} catch {
|
|
204
|
+
await self.handleDisconnect(error: error)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private func reconnectDelay(for attempt: Int) -> Int {
|
|
210
|
+
switch attempt {
|
|
211
|
+
case 0: return 500
|
|
212
|
+
case 1: return 1_000
|
|
213
|
+
case 2: return 2_000
|
|
214
|
+
case 3: return 4_000
|
|
215
|
+
case 4: return 8_000
|
|
216
|
+
case 5: return 16_000
|
|
217
|
+
default: return 30_000
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|