@zellio/chat-react-native 0.0.0-stage → 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/LICENSE +202 -0
- package/README.md +239 -2
- package/ZellioChatReactNative.podspec +30 -0
- package/android/build.gradle +62 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/ai/zellio/chat/reactnative/ZellioChatModule.kt +214 -0
- package/android/src/main/java/ai/zellio/chat/reactnative/ZellioChatPackage.kt +25 -0
- package/app.plugin.js +4 -0
- package/ios/RNZellioChat.h +5 -0
- package/ios/RNZellioChat.mm +116 -0
- package/ios/ZCRNBridge.swift +217 -0
- package/ios/vendor/resources/PrivacyInfo.xcprivacy +99 -0
- package/ios/vendor/swift/ZellioChat/Model/ChatModel.swift +261 -0
- package/ios/vendor/swift/ZellioChat/Platform/Platform.swift +124 -0
- package/ios/vendor/swift/ZellioChat/Platform/URLSessionTransport.swift +175 -0
- package/ios/vendor/swift/ZellioChat/Preview/Previews.swift +62 -0
- package/ios/vendor/swift/ZellioChat/Public/InAppBanner.swift +155 -0
- package/ios/vendor/swift/ZellioChat/Public/PublicTypes.swift +141 -0
- package/ios/vendor/swift/ZellioChat/Public/Runtime.swift +406 -0
- package/ios/vendor/swift/ZellioChat/Public/ZellioChat.swift +209 -0
- package/ios/vendor/swift/ZellioChat/Theme/ChatTheme.swift +185 -0
- package/ios/vendor/swift/ZellioChat/Thread/AttachmentPicker.swift +354 -0
- package/ios/vendor/swift/ZellioChat/Thread/ComposerBar.swift +355 -0
- package/ios/vendor/swift/ZellioChat/Thread/ThreadCells.swift +776 -0
- package/ios/vendor/swift/ZellioChat/Thread/ThreadViewController.swift +703 -0
- package/ios/vendor/swift/ZellioChat/Views/ChatRootView.swift +154 -0
- package/ios/vendor/swift/ZellioChat/Views/Components.swift +335 -0
- package/ios/vendor/swift/ZellioChat/Views/HomeView.swift +413 -0
- package/ios/vendor/swift/ZellioChatCore/ChatModels.swift +373 -0
- package/ios/vendor/swift/ZellioChatCore/ConfigModels.swift +389 -0
- package/ios/vendor/swift/ZellioChatCore/Engine.swift +325 -0
- package/ios/vendor/swift/ZellioChatCore/Errors.swift +158 -0
- package/ios/vendor/swift/ZellioChatCore/Generated/ChatStringTable.swift +125 -0
- package/ios/vendor/swift/ZellioChatCore/Outbox.swift +223 -0
- package/ios/vendor/swift/ZellioChatCore/Push.swift +74 -0
- package/ios/vendor/swift/ZellioChatCore/Realtime.swift +328 -0
- package/ios/vendor/swift/ZellioChatCore/Session.swift +210 -0
- package/ios/vendor/swift/ZellioChatCore/Storage.swift +201 -0
- package/ios/vendor/swift/ZellioChatCore/Strings.swift +104 -0
- package/ios/vendor/swift/ZellioChatCore/Theme.swift +238 -0
- package/ios/vendor/swift/ZellioChatCore/ThreadController.swift +319 -0
- package/ios/vendor/swift/ZellioChatCore/Timeline.swift +157 -0
- package/ios/vendor/swift/ZellioChatCore/Transport.swift +236 -0
- package/ios/vendor/swift/ZellioChatCore/Util.swift +137 -0
- package/lib/NativeZellioChat.d.ts +34 -0
- package/lib/NativeZellioChat.js +4 -0
- package/lib/index.d.ts +37 -0
- package/lib/index.js +241 -0
- package/lib/types.d.ts +54 -0
- package/lib/types.js +2 -0
- package/lib/version.d.ts +4 -0
- package/lib/version.js +7 -0
- package/package.json +75 -4
- package/plugin/build/index.js +73 -0
- package/src/NativeZellioChat.ts +30 -0
- package/src/index.ts +237 -0
- package/src/types.ts +56 -0
- package/src/version.ts +5 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public enum QueueStatus: String, Codable, Equatable, Sendable { case pending, failed }
|
|
4
|
+
|
|
5
|
+
public struct QueuedMessage: Codable, Equatable, Sendable {
|
|
6
|
+
public var clientMessageId: String
|
|
7
|
+
public var text: String
|
|
8
|
+
public var attachments: [Attachment]
|
|
9
|
+
public var context: ChatContext?
|
|
10
|
+
public var createdAt: Date
|
|
11
|
+
public var sequence: Int
|
|
12
|
+
public var attempts: Int
|
|
13
|
+
public var status: QueueStatus
|
|
14
|
+
public var failureReason: String?
|
|
15
|
+
public var conversationId: String?
|
|
16
|
+
|
|
17
|
+
public var attachmentIds: [String] { attachments.compactMap { $0.id } }
|
|
18
|
+
|
|
19
|
+
public func asMessage(delivery: DeliveryState) -> ChatMessage {
|
|
20
|
+
ChatMessage(serverId: nil, clientMessageId: clientMessageId, from: .visitor, text: text, attachments: attachments,
|
|
21
|
+
createdAt: ISODate.string(createdAt), delivery: delivery, failureReason: failureReason)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// The saved send queue. Order is the order the customer tapped send. Survives restarts.
|
|
26
|
+
public final class OfflineQueue: @unchecked Sendable {
|
|
27
|
+
public static let expiry: TimeInterval = 24 * 60 * 60
|
|
28
|
+
|
|
29
|
+
private let storage: StorageBackend
|
|
30
|
+
private let clock: ChatClock
|
|
31
|
+
private let lock = NSLock()
|
|
32
|
+
private var items: [QueuedMessage]
|
|
33
|
+
|
|
34
|
+
public init(storage: StorageBackend, clock: ChatClock = SystemClock()) {
|
|
35
|
+
self.storage = storage
|
|
36
|
+
self.clock = clock
|
|
37
|
+
self.items = storage.load([QueuedMessage].self, StorageName.queue) ?? []
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private func persist() { storage.save(items, StorageName.queue) }
|
|
41
|
+
|
|
42
|
+
public var all: [QueuedMessage] { lock.lock(); defer { lock.unlock() }; return items.sorted { $0.sequence < $1.sequence } }
|
|
43
|
+
public var pending: [QueuedMessage] { all.filter { $0.status == .pending } }
|
|
44
|
+
|
|
45
|
+
@discardableResult
|
|
46
|
+
public func enqueue(clientMessageId: String = UUID().uuidString.lowercased(), text: String, attachments: [Attachment] = [], context: ChatContext?, conversationId: String?) -> QueuedMessage {
|
|
47
|
+
lock.lock(); defer { lock.unlock() }
|
|
48
|
+
let next = (items.map { $0.sequence }.max() ?? 0) + 1
|
|
49
|
+
let item = QueuedMessage(clientMessageId: clientMessageId, text: text, attachments: attachments, context: context,
|
|
50
|
+
createdAt: clock.now(), sequence: next, attempts: 0, status: .pending, failureReason: nil, conversationId: conversationId)
|
|
51
|
+
items.append(item)
|
|
52
|
+
persist()
|
|
53
|
+
return item
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public func item(_ clientMessageId: String) -> QueuedMessage? {
|
|
57
|
+
lock.lock(); defer { lock.unlock() }
|
|
58
|
+
return items.first { $0.clientMessageId == clientMessageId }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public func remove(_ clientMessageId: String) {
|
|
62
|
+
lock.lock(); defer { lock.unlock() }
|
|
63
|
+
items.removeAll { $0.clientMessageId == clientMessageId }
|
|
64
|
+
persist()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
public func recordAttempt(_ clientMessageId: String) {
|
|
68
|
+
lock.lock(); defer { lock.unlock() }
|
|
69
|
+
if let index = items.firstIndex(where: { $0.clientMessageId == clientMessageId }) {
|
|
70
|
+
items[index].attempts += 1
|
|
71
|
+
persist()
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public func markFailed(_ clientMessageId: String, reason: String) {
|
|
76
|
+
lock.lock(); defer { lock.unlock() }
|
|
77
|
+
if let index = items.firstIndex(where: { $0.clientMessageId == clientMessageId }) {
|
|
78
|
+
items[index].status = .failed
|
|
79
|
+
items[index].failureReason = reason
|
|
80
|
+
persist()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// "Try again": back to pending in the same place. The 24 hours start again.
|
|
85
|
+
public func retry(_ clientMessageId: String) {
|
|
86
|
+
lock.lock(); defer { lock.unlock() }
|
|
87
|
+
if let index = items.firstIndex(where: { $0.clientMessageId == clientMessageId }) {
|
|
88
|
+
items[index].status = .pending
|
|
89
|
+
items[index].failureReason = nil
|
|
90
|
+
items[index].attempts = 0
|
|
91
|
+
items[index].createdAt = clock.now()
|
|
92
|
+
persist()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// Marks pending messages older than 24 hours as failed. Returns their ids.
|
|
97
|
+
@discardableResult
|
|
98
|
+
public func expireOld() -> [String] {
|
|
99
|
+
lock.lock(); defer { lock.unlock() }
|
|
100
|
+
let now = clock.now()
|
|
101
|
+
var expired: [String] = []
|
|
102
|
+
for index in items.indices where items[index].status == .pending && now.timeIntervalSince(items[index].createdAt) >= OfflineQueue.expiry {
|
|
103
|
+
items[index].status = .failed
|
|
104
|
+
items[index].failureReason = "expired"
|
|
105
|
+
expired.append(items[index].clientMessageId)
|
|
106
|
+
}
|
|
107
|
+
if !expired.isEmpty { persist() }
|
|
108
|
+
return expired
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
public func clear() {
|
|
112
|
+
lock.lock(); defer { lock.unlock() }
|
|
113
|
+
items = []
|
|
114
|
+
storage.remove(StorageName.queue)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public enum SendOutcome: Equatable, Sendable {
|
|
119
|
+
case sent(SendReceipt)
|
|
120
|
+
case retryLater(after: TimeInterval?)
|
|
121
|
+
case failedPermanently(reason: String)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
public enum OutboxPolicy {
|
|
125
|
+
/// 401 and 429 and 5xx and network errors wait and retry. Any other 4xx fails for good.
|
|
126
|
+
public static func classify(_ error: Error) -> SendOutcome {
|
|
127
|
+
if error is CancellationError { return .retryLater(after: nil) }
|
|
128
|
+
let mapped = ErrorPolicy.map(error)
|
|
129
|
+
guard let api = error as? ChatAPIError else {
|
|
130
|
+
switch mapped {
|
|
131
|
+
case .rateLimited(let retry): return .retryLater(after: retry)
|
|
132
|
+
case .loginNeeded, .consentRequired, .network, .cancelled: return .retryLater(after: nil)
|
|
133
|
+
case .unavailable: return .failedPermanently(reason: "unavailable")
|
|
134
|
+
default: return .retryLater(after: nil)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
switch api {
|
|
138
|
+
case .network, .timeout, .cancelled, .decoding: return .retryLater(after: nil)
|
|
139
|
+
case .http(let status, _, let retry):
|
|
140
|
+
if status == 401 || status == 429 { return .retryLater(after: retry) }
|
|
141
|
+
if (400..<500).contains(status) { return .failedPermanently(reason: "http_\(status)") }
|
|
142
|
+
return .retryLater(after: nil)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// Sends the queue in order. One drain at a time.
|
|
148
|
+
public actor Outbox {
|
|
149
|
+
public enum Update: Equatable, Sendable {
|
|
150
|
+
case changed
|
|
151
|
+
case sent(clientMessageId: String, receipt: SendReceipt)
|
|
152
|
+
case failed(clientMessageId: String, reason: String)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
public typealias Sender = @Sendable (QueuedMessage) async throws -> SendReceipt
|
|
156
|
+
public typealias Listener = @Sendable (Update) -> Void
|
|
157
|
+
|
|
158
|
+
public let queue: OfflineQueue
|
|
159
|
+
private let clock: ChatClock
|
|
160
|
+
private let random: RandomSource
|
|
161
|
+
private let sender: Sender
|
|
162
|
+
private var listener: Listener?
|
|
163
|
+
private var draining = false
|
|
164
|
+
private var task: Task<Void, Never>?
|
|
165
|
+
private var online = true
|
|
166
|
+
|
|
167
|
+
public init(queue: OfflineQueue, clock: ChatClock = SystemClock(), random: RandomSource = SystemRandom(), sender: @escaping Sender) {
|
|
168
|
+
self.queue = queue
|
|
169
|
+
self.clock = clock
|
|
170
|
+
self.random = random
|
|
171
|
+
self.sender = sender
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
public func setListener(_ listener: Listener?) { self.listener = listener }
|
|
175
|
+
|
|
176
|
+
/// Starts a drain when none runs.
|
|
177
|
+
public func kick() {
|
|
178
|
+
guard !draining else { return }
|
|
179
|
+
draining = true
|
|
180
|
+
task = Task { [weak self] in
|
|
181
|
+
await self?.drain()
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
public func networkReturned() { online = true; kick() }
|
|
186
|
+
public func networkLost() { online = false }
|
|
187
|
+
|
|
188
|
+
public func cancelAll() {
|
|
189
|
+
task?.cancel()
|
|
190
|
+
task = nil
|
|
191
|
+
draining = false
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/// One full pass. Public so tests can await it.
|
|
195
|
+
public func drain() async {
|
|
196
|
+
defer { draining = false }
|
|
197
|
+
var waitAttempt = 0
|
|
198
|
+
while !Task.isCancelled {
|
|
199
|
+
for id in queue.expireOld() { listener?(.failed(clientMessageId: id, reason: "expired")) }
|
|
200
|
+
guard let next = queue.pending.first else { return }
|
|
201
|
+
queue.recordAttempt(next.clientMessageId)
|
|
202
|
+
do {
|
|
203
|
+
let receipt = try await sender(next)
|
|
204
|
+
queue.remove(next.clientMessageId)
|
|
205
|
+
listener?(.sent(clientMessageId: next.clientMessageId, receipt: receipt))
|
|
206
|
+
waitAttempt = 0
|
|
207
|
+
} catch {
|
|
208
|
+
switch OutboxPolicy.classify(error) {
|
|
209
|
+
case .failedPermanently(let reason):
|
|
210
|
+
queue.markFailed(next.clientMessageId, reason: reason)
|
|
211
|
+
listener?(.failed(clientMessageId: next.clientMessageId, reason: reason))
|
|
212
|
+
case .retryLater(let after):
|
|
213
|
+
listener?(.changed)
|
|
214
|
+
let delay = after ?? Backoff.delay(attempt: waitAttempt, random: random)
|
|
215
|
+
waitAttempt += 1
|
|
216
|
+
do { try await clock.sleep(seconds: min(delay, 300)) } catch { return }
|
|
217
|
+
case .sent:
|
|
218
|
+
break
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public struct ZellioPush: Equatable, Sendable {
|
|
4
|
+
public var version: Int
|
|
5
|
+
public var type: String
|
|
6
|
+
public var conversationId: String?
|
|
7
|
+
public var messageId: String?
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
public enum PushPayload {
|
|
11
|
+
public static let key = "zellio"
|
|
12
|
+
|
|
13
|
+
/// True only when the payload has the `zellio` key.
|
|
14
|
+
public static func isZellioPush(_ payload: [AnyHashable: Any]) -> Bool {
|
|
15
|
+
payload[key] != nil
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// Reads the `zellio` value. It is an object on APNs and a JSON string on FCM. Both work.
|
|
19
|
+
public static func parse(_ payload: [AnyHashable: Any]) -> ZellioPush? {
|
|
20
|
+
guard let raw = payload[key] else { return nil }
|
|
21
|
+
var object: [String: Any]?
|
|
22
|
+
if let dictionary = raw as? [String: Any] {
|
|
23
|
+
object = dictionary
|
|
24
|
+
} else if let dictionary = raw as? [AnyHashable: Any] {
|
|
25
|
+
var converted: [String: Any] = [:]
|
|
26
|
+
for (k, v) in dictionary { if let name = k as? String { converted[name] = v } }
|
|
27
|
+
object = converted
|
|
28
|
+
} else if let text = raw as? String, let data = text.data(using: .utf8) {
|
|
29
|
+
object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
|
|
30
|
+
}
|
|
31
|
+
guard let fields = object else { return nil }
|
|
32
|
+
let version = (fields["v"] as? Int) ?? Int((fields["v"] as? String) ?? "") ?? 1
|
|
33
|
+
return ZellioPush(version: version, type: (fields["type"] as? String) ?? "message",
|
|
34
|
+
conversationId: fields["conversationId"] as? String, messageId: fields["messageId"] as? String)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// The unread number. Safe from any thread.
|
|
39
|
+
public final class UnreadTracker: @unchecked Sendable {
|
|
40
|
+
private struct State {
|
|
41
|
+
var count = 0
|
|
42
|
+
var listeners: [Int: @Sendable (Int) -> Void] = [:]
|
|
43
|
+
var nextToken = 0
|
|
44
|
+
}
|
|
45
|
+
private let state = Locked(State())
|
|
46
|
+
|
|
47
|
+
public init() {}
|
|
48
|
+
|
|
49
|
+
public var count: Int { state.with { $0.count } }
|
|
50
|
+
|
|
51
|
+
public func set(_ value: Int) {
|
|
52
|
+
let clean = max(0, value)
|
|
53
|
+
let (changed, listeners): (Bool, [@Sendable (Int) -> Void]) = state.with { s in
|
|
54
|
+
let changed = s.count != clean
|
|
55
|
+
s.count = clean
|
|
56
|
+
return (changed, Array(s.listeners.values))
|
|
57
|
+
}
|
|
58
|
+
if changed { for listener in listeners { listener(clean) } }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Returns a token for `remove`. The listener is also called once with the current value.
|
|
62
|
+
@discardableResult
|
|
63
|
+
public func add(_ listener: @escaping @Sendable (Int) -> Void) -> Int {
|
|
64
|
+
let (token, current): (Int, Int) = state.with { s in
|
|
65
|
+
s.nextToken += 1
|
|
66
|
+
s.listeners[s.nextToken] = listener
|
|
67
|
+
return (s.nextToken, s.count)
|
|
68
|
+
}
|
|
69
|
+
listener(current)
|
|
70
|
+
return token
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
public func remove(_ token: Int) { state.with { $0.listeners[token] = nil } }
|
|
74
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
public struct SSEEvent: Equatable, Sendable {
|
|
4
|
+
public var name: String?
|
|
5
|
+
public var data: String
|
|
6
|
+
public var id: String?
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/// Incremental server-sent events parser. Feed bytes as they arrive.
|
|
10
|
+
/// Handles LF, CRLF and CR line ends, comments (`: ping`), multi-line data, and chunks that split a line or a UTF-8 character.
|
|
11
|
+
public struct SSEParser: Sendable {
|
|
12
|
+
private var buffer = [UInt8]()
|
|
13
|
+
private var name: String?
|
|
14
|
+
private var dataLines = [String]()
|
|
15
|
+
private var lastId: String?
|
|
16
|
+
private var sawCR = false
|
|
17
|
+
|
|
18
|
+
public init() {}
|
|
19
|
+
|
|
20
|
+
public mutating func feed(_ data: Data) -> [SSEEvent] {
|
|
21
|
+
var events: [SSEEvent] = []
|
|
22
|
+
for byte in data {
|
|
23
|
+
if byte == 10 { // LF
|
|
24
|
+
if sawCR { sawCR = false; continue } // second half of CRLF
|
|
25
|
+
finishLine(&events)
|
|
26
|
+
} else if byte == 13 { // CR
|
|
27
|
+
sawCR = true
|
|
28
|
+
finishLine(&events)
|
|
29
|
+
} else {
|
|
30
|
+
sawCR = false
|
|
31
|
+
buffer.append(byte)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return events
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private mutating func finishLine(_ events: inout [SSEEvent]) {
|
|
38
|
+
let line = String(decoding: buffer, as: UTF8.self)
|
|
39
|
+
buffer.removeAll(keepingCapacity: true)
|
|
40
|
+
if line.isEmpty {
|
|
41
|
+
// A blank line ends the event.
|
|
42
|
+
if !dataLines.isEmpty || name != nil {
|
|
43
|
+
events.append(SSEEvent(name: name, data: dataLines.joined(separator: "\n"), id: lastId))
|
|
44
|
+
}
|
|
45
|
+
name = nil
|
|
46
|
+
dataLines = []
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
if line.hasPrefix(":") { return } // comment, for example ": ping"
|
|
50
|
+
var field = line
|
|
51
|
+
var value = ""
|
|
52
|
+
if let colon = line.firstIndex(of: ":") {
|
|
53
|
+
field = String(line[line.startIndex..<colon])
|
|
54
|
+
var rest = line[line.index(after: colon)...]
|
|
55
|
+
if rest.hasPrefix(" ") { rest = rest.dropFirst() }
|
|
56
|
+
value = String(rest)
|
|
57
|
+
}
|
|
58
|
+
switch field {
|
|
59
|
+
case "event": name = value
|
|
60
|
+
case "data": dataLines.append(value)
|
|
61
|
+
case "id": lastId = value
|
|
62
|
+
default: break // retry and unknown fields are ignored
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// Reconnect delays: 1, 2, 4, 8, 16, then 30 seconds, each with jitter.
|
|
68
|
+
public struct Backoff: Sendable {
|
|
69
|
+
public static let steps: [Double] = [1, 2, 4, 8, 16, 30]
|
|
70
|
+
public static let jitter = 0.25
|
|
71
|
+
|
|
72
|
+
/// `attempt` starts at 0. Jitter spreads the delay by plus or minus 25 percent.
|
|
73
|
+
public static func delay(attempt: Int, random: RandomSource) -> Double {
|
|
74
|
+
let base = steps[min(max(attempt, 0), steps.count - 1)]
|
|
75
|
+
let spread = (random.unit() * 2 - 1) * jitter
|
|
76
|
+
return base * (1 + spread)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
public static func baseDelay(attempt: Int) -> Double { steps[min(max(attempt, 0), steps.count - 1)] }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
public enum StreamState: Equatable, Sendable {
|
|
83
|
+
case idle
|
|
84
|
+
case connecting
|
|
85
|
+
case open
|
|
86
|
+
case waiting(attempt: Int)
|
|
87
|
+
case paused // app is in the background
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
public enum StreamInput: Equatable, Sendable {
|
|
91
|
+
case start
|
|
92
|
+
case opened
|
|
93
|
+
case bytes
|
|
94
|
+
case ended // closed by the server (10 minutes) or lost
|
|
95
|
+
case failed
|
|
96
|
+
case deadTimeout // 45 s with no bytes
|
|
97
|
+
case enteredBackground
|
|
98
|
+
case enteredForeground
|
|
99
|
+
case stop
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
public enum StreamAction: Equatable, Sendable {
|
|
103
|
+
case connect
|
|
104
|
+
case closeConnection
|
|
105
|
+
case scheduleReconnect(attempt: Int)
|
|
106
|
+
/// Read /unread, then messages with `after`, then reopen the stream.
|
|
107
|
+
case catchUp
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// The reconnect rules as a pure function of state and input.
|
|
111
|
+
public struct StreamMachine: Sendable {
|
|
112
|
+
public private(set) var state: StreamState = .idle
|
|
113
|
+
public private(set) var wanted = false
|
|
114
|
+
private var attempt = 0
|
|
115
|
+
|
|
116
|
+
public static let deadAfter: TimeInterval = 45
|
|
117
|
+
|
|
118
|
+
public init() {}
|
|
119
|
+
|
|
120
|
+
public mutating func handle(_ input: StreamInput) -> [StreamAction] {
|
|
121
|
+
switch input {
|
|
122
|
+
case .start:
|
|
123
|
+
wanted = true
|
|
124
|
+
guard state == .idle || state == .paused else { return [] }
|
|
125
|
+
attempt = 0
|
|
126
|
+
state = .connecting
|
|
127
|
+
return [.connect]
|
|
128
|
+
case .opened:
|
|
129
|
+
guard state == .connecting else { return [] }
|
|
130
|
+
attempt = 0
|
|
131
|
+
state = .open
|
|
132
|
+
return []
|
|
133
|
+
case .bytes:
|
|
134
|
+
return []
|
|
135
|
+
case .ended, .failed, .deadTimeout:
|
|
136
|
+
guard wanted, state == .connecting || state == .open else { return [] }
|
|
137
|
+
let next = attempt
|
|
138
|
+
attempt += 1
|
|
139
|
+
state = .waiting(attempt: next)
|
|
140
|
+
var actions: [StreamAction] = []
|
|
141
|
+
if input == .deadTimeout { actions.append(.closeConnection) }
|
|
142
|
+
actions.append(.scheduleReconnect(attempt: next))
|
|
143
|
+
return actions
|
|
144
|
+
case .enteredBackground:
|
|
145
|
+
guard wanted else { return [] }
|
|
146
|
+
let wasLive = state != .idle && state != .paused
|
|
147
|
+
state = .paused
|
|
148
|
+
return wasLive ? [.closeConnection] : []
|
|
149
|
+
case .enteredForeground:
|
|
150
|
+
guard wanted, state == .paused else { return [] }
|
|
151
|
+
attempt = 0
|
|
152
|
+
state = .connecting
|
|
153
|
+
return [.catchUp]
|
|
154
|
+
case .stop:
|
|
155
|
+
wanted = false
|
|
156
|
+
let wasLive = state != .idle
|
|
157
|
+
state = .idle
|
|
158
|
+
attempt = 0
|
|
159
|
+
return wasLive ? [.closeConnection] : []
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// The reconnect timer fired.
|
|
164
|
+
public mutating func reconnectTimerFired() -> [StreamAction] {
|
|
165
|
+
guard wanted, case .waiting = state else { return [] }
|
|
166
|
+
state = .connecting
|
|
167
|
+
return [.catchUp]
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// Runs the stream: connect, parse, reconnect with backoff, catch up after every gap.
|
|
172
|
+
public actor RealtimeClient {
|
|
173
|
+
public typealias TokenSource = @Sendable () async throws -> String
|
|
174
|
+
public typealias EventHandler = @Sendable (RealtimeEvent) -> Void
|
|
175
|
+
public typealias CatchUp = @Sendable () async -> Void
|
|
176
|
+
public typealias AuthFailure = @Sendable (ChatAPIError) async -> Void
|
|
177
|
+
|
|
178
|
+
private let api: ChatAPI
|
|
179
|
+
private let clock: ChatClock
|
|
180
|
+
private let random: RandomSource
|
|
181
|
+
private let tokenSource: TokenSource
|
|
182
|
+
private let onEvent: EventHandler
|
|
183
|
+
private let catchUp: CatchUp
|
|
184
|
+
private let onFailure: AuthFailure
|
|
185
|
+
private var machine = StreamMachine()
|
|
186
|
+
private var loop: Task<Void, Never>?
|
|
187
|
+
private var generation = 0
|
|
188
|
+
|
|
189
|
+
public init(api: ChatAPI, clock: ChatClock = SystemClock(), random: RandomSource = SystemRandom(),
|
|
190
|
+
tokenSource: @escaping TokenSource, onEvent: @escaping EventHandler,
|
|
191
|
+
catchUp: @escaping CatchUp, onFailure: @escaping AuthFailure) {
|
|
192
|
+
self.api = api
|
|
193
|
+
self.clock = clock
|
|
194
|
+
self.random = random
|
|
195
|
+
self.tokenSource = tokenSource
|
|
196
|
+
self.onEvent = onEvent
|
|
197
|
+
self.catchUp = catchUp
|
|
198
|
+
self.onFailure = onFailure
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
public var state: StreamState { machine.state }
|
|
202
|
+
|
|
203
|
+
public func start() { apply(machine.handle(.start)) }
|
|
204
|
+
public func stop() { apply(machine.handle(.stop)) }
|
|
205
|
+
public func enteredBackground() { apply(machine.handle(.enteredBackground)) }
|
|
206
|
+
public func enteredForeground() { apply(machine.handle(.enteredForeground)) }
|
|
207
|
+
|
|
208
|
+
private func apply(_ actions: [StreamAction]) {
|
|
209
|
+
for action in actions {
|
|
210
|
+
switch action {
|
|
211
|
+
case .connect, .catchUp:
|
|
212
|
+
launch(initialDelay: 0, runCatchUp: action == .catchUp)
|
|
213
|
+
case .closeConnection:
|
|
214
|
+
generation += 1
|
|
215
|
+
loop?.cancel()
|
|
216
|
+
loop = nil
|
|
217
|
+
case .scheduleReconnect(let attempt):
|
|
218
|
+
launch(initialDelay: Backoff.delay(attempt: attempt, random: random), runCatchUp: true, fromTimer: true)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private func launch(initialDelay: Double, runCatchUp: Bool, fromTimer: Bool = false) {
|
|
224
|
+
generation += 1
|
|
225
|
+
let mine = generation
|
|
226
|
+
loop?.cancel()
|
|
227
|
+
loop = Task { [weak self] in
|
|
228
|
+
await self?.run(generation: mine, delay: initialDelay, runCatchUp: runCatchUp, fromTimer: fromTimer)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private func isCurrent(_ mine: Int) -> Bool { mine == generation && !Task.isCancelled }
|
|
233
|
+
|
|
234
|
+
private func run(generation mine: Int, delay: Double, runCatchUp: Bool, fromTimer: Bool) async {
|
|
235
|
+
if delay > 0 {
|
|
236
|
+
do { try await clock.sleep(seconds: delay) } catch { return }
|
|
237
|
+
}
|
|
238
|
+
guard isCurrent(mine) else { return }
|
|
239
|
+
if fromTimer {
|
|
240
|
+
let actions = machine.reconnectTimerFired()
|
|
241
|
+
if actions.isEmpty { return }
|
|
242
|
+
}
|
|
243
|
+
if runCatchUp { await catchUp() }
|
|
244
|
+
guard isCurrent(mine) else { return }
|
|
245
|
+
|
|
246
|
+
let token: String
|
|
247
|
+
do { token = try await tokenSource() } catch {
|
|
248
|
+
await ended(mine, input: .failed)
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
guard isCurrent(mine) else { return }
|
|
252
|
+
|
|
253
|
+
let lastByte = Locked(clock.now())
|
|
254
|
+
let dead = Locked(false)
|
|
255
|
+
let stream = api.openStream(token: token)
|
|
256
|
+
let reader: Task<ChatAPIError?, Never> = Task { [weak self, clock] in
|
|
257
|
+
var parser = SSEParser()
|
|
258
|
+
do {
|
|
259
|
+
for try await chunk in stream {
|
|
260
|
+
lastByte.with { $0 = clock.now() }
|
|
261
|
+
switch chunk {
|
|
262
|
+
case .opened:
|
|
263
|
+
await self?.opened(mine)
|
|
264
|
+
case .data(let data):
|
|
265
|
+
for event in parser.feed(data) {
|
|
266
|
+
if let decoded = RealtimeEventDecoder.decode(event) { await self?.deliver(decoded, generation: mine) }
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return nil as ChatAPIError?
|
|
271
|
+
} catch let error as ChatAPIError {
|
|
272
|
+
return error
|
|
273
|
+
} catch {
|
|
274
|
+
return ChatAPIError.network
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
let watchdog = Task { [clock] in
|
|
278
|
+
while !Task.isCancelled {
|
|
279
|
+
do { try await clock.sleep(seconds: 5) } catch { return }
|
|
280
|
+
let quiet = clock.now().timeIntervalSince(lastByte.snapshot)
|
|
281
|
+
if quiet >= StreamMachine.deadAfter {
|
|
282
|
+
dead.with { $0 = true }
|
|
283
|
+
reader.cancel()
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
let failure = await withTaskCancellationHandler {
|
|
289
|
+
await reader.value
|
|
290
|
+
} onCancel: {
|
|
291
|
+
reader.cancel()
|
|
292
|
+
}
|
|
293
|
+
watchdog.cancel()
|
|
294
|
+
guard isCurrent(mine) else { return }
|
|
295
|
+
if let failure = failure {
|
|
296
|
+
if failure.status == 401 || failure.status == 403 || failure.status == 426 {
|
|
297
|
+
await onFailure(failure)
|
|
298
|
+
}
|
|
299
|
+
// These do not change by trying again. `start()` opens the stream later.
|
|
300
|
+
let reaction = ErrorPolicy.reaction(for: failure)
|
|
301
|
+
if reaction == .stopUnavailable || reaction == .showLoginNeeded {
|
|
302
|
+
guard isCurrent(mine) else { return }
|
|
303
|
+
apply(machine.handle(.stop))
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
if case .http(429, _, let retryAfter?) = failure, retryAfter > 0 {
|
|
307
|
+
do { try await clock.sleep(seconds: min(retryAfter, 300)) } catch { return }
|
|
308
|
+
guard isCurrent(mine) else { return }
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
await ended(mine, input: dead.snapshot ? .deadTimeout : (failure == nil ? .ended : .failed))
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private func opened(_ mine: Int) {
|
|
315
|
+
guard mine == generation else { return }
|
|
316
|
+
_ = machine.handle(.opened)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private func deliver(_ event: RealtimeEvent, generation mine: Int) {
|
|
320
|
+
guard mine == generation else { return }
|
|
321
|
+
onEvent(event)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private func ended(_ mine: Int, input: StreamInput) async {
|
|
325
|
+
guard mine == generation else { return }
|
|
326
|
+
apply(machine.handle(input))
|
|
327
|
+
}
|
|
328
|
+
}
|